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:
vergiLgood1 2025-05-07 10:23:54 +07:00
parent 3448945f4d
commit 8a1a4320c2
13 changed files with 3046 additions and 167 deletions

View File

@ -1,7 +1,6 @@
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import "@/app/_styles/globals.css";
import "@/app/_styles/ui.css";
import ReactQueryProvider from "@/app/_lib/react-query-provider";
import { Toaster } from "@/app/_components/ui/sonner";

File diff suppressed because it is too large Load Diff

View File

@ -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>
);
}

View File

@ -42,6 +42,7 @@ export default function CrimeMap() {
const [showUnclustered, setShowUnclustered] = useState(true)
const [useAllYears, setUseAllYears] = useState<boolean>(false)
const [useAllMonths, setUseAllMonths] = useState<boolean>(false)
const [showEWS, setShowEWS] = useState<boolean>(true)
const mapContainerRef = useRef<HTMLDivElement>(null)
@ -185,6 +186,9 @@ export default function CrimeMap() {
setUseAllYears(false);
setUseAllMonths(false);
}
// Enable EWS in all modes for demo purposes
setShowEWS(true);
}
const showTimelineLayer = activeControl === "timeline";
@ -218,11 +222,12 @@ export default function CrimeMap() {
<Button onClick={() => window.location.reload()}>Retry</Button>
</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(
"transition-all duration-300 ease-in-out",
!sidebarCollapsed && isFullscreen && "ml-[400px]"
)}>
<div className="">
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
<Layers
crimes={filteredCrimes || []}
@ -232,6 +237,7 @@ export default function CrimeMap() {
filterCategory={selectedCategory}
activeControl={activeControl}
useAllData={useAllYears}
showEWS={showEWS}
/>
{isFullscreen && (
@ -301,6 +307,7 @@ export default function CrimeMap() {
</MapView>
</div>
</div>
</div>
)}
</CardContent>
</Card>

View File

@ -14,19 +14,28 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
// Function to add coastline layer
function addCoastline() {
const sourceId = 'coastline';
const sourceId = 'coastline_id';
const layerId = 'outline-coastline';
// Make sure map is defined
if (!map) return;
try {
// Check if the source already exists
if (!map.getSource(sourceId)) {
// More robust check if the source already exists
let sourceExists = false;
try {
sourceExists = !!map.getSource(sourceId);
} catch (e) {
sourceExists = false;
}
if (!sourceExists) {
// Add coastline data source
fetch('/geojson/garis_pantai.geojson')
.then(response => response.json())
.then(data => {
// Double-check the source doesn't exist right before adding
if (!map.getSource(sourceId)) {
// Add coastline data source
map.addSource(sourceId, {
type: 'geojson',
@ -43,11 +52,12 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
'visibility': visible ? 'visible' : 'none',
},
'paint': {
'line-color': ['get', 'color'],
'line-color': '#1a1a1a', // dull white color instead of ['get', 'color']
'line-width': 5,
'line-opacity': 1
}
});
}
})
.catch((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;
const layerId = 'outline-coastline';
const sourceId = 'coastline';
const sourceId = 'coastline_id';
if (map.getLayer(layerId)) {
map.removeLayer(layerId);
@ -92,14 +102,24 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
if (map.loaded() && map.isStyleLoaded()) {
addCoastline();
} else {
// Use multiple events to catch map ready state
map.on('load', addCoastline);
map.on('style.load', addCoastline);
map.on('styledata', addCoastline);
// Reduce event listeners to minimize duplicates
const addLayerOnce = () => {
// Remove all listeners after first successful execution
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
timeoutId = setTimeout(() => {
addCoastline();
addLayerOnce();
}, 2000);
}
} catch (error) {

View File

@ -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
}

View File

@ -23,6 +23,12 @@ import CrimePopup from "../pop-up/crime-popup"
import TimeZonesDisplay from "./timezone"
import TimezoneLayer from "./timezone"
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 ICrimeIncident {
@ -67,6 +73,7 @@ interface LayersProps {
activeControl: ITooltips
tilesetId?: string
useAllData?: boolean
showEWS?: boolean
}
export default function Layers({
@ -79,6 +86,7 @@ export default function Layers({
activeControl,
tilesetId = MAPBOX_TILESET_ID,
useAllData = false,
showEWS = true,
}: LayersProps) {
const { current: map } = useMap()
@ -96,25 +104,47 @@ export default function Layers({
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(() => {
// Reset selected state
selectedDistrictRef.current = null
setSelectedDistrict(null)
setSelectedIncident(null)
setFocusedDistrictId(null)
// Reset map view/camera
if (map) {
map.easeTo({
zoom: BASE_ZOOM,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
duration: 1500,
easing: (t) => t * (2 - t), // easeOutQuad
easing: (t) => t * (2 - t),
})
// Show all clusters again when closing popup
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
}
@ -122,7 +152,6 @@ export default function Layers({
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
}
// Update fill color for all districts
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
@ -130,32 +159,26 @@ export default function Layers({
}
}, [map, crimeDataByDistrict])
// Handle district popup close specifically
const handleCloseDistrictPopup = useCallback(() => {
console.log("Closing district popup")
handlePopupClose()
}, [handlePopupClose])
// Handle incident popup close specifically
const handleCloseIncidentPopup = useCallback(() => {
console.log("Closing incident popup")
handlePopupClose()
}, [handlePopupClose])
// Handle district clicks
const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => {
console.log("District clicked:", feature)
// Clear any incident selection when showing a district
setSelectedIncident(null)
// Set the selected district
setSelectedDistrict(feature)
selectedDistrictRef.current = feature
setFocusedDistrictId(feature.id)
// Fly to the district
if (map && feature.longitude && feature.latitude) {
map.flyTo({
center: [feature.longitude, feature.latitude],
@ -166,7 +189,6 @@ export default function Layers({
easing: (t) => t * (2 - t),
})
// Hide clusters when focusing on district
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "none")
}
@ -178,7 +200,6 @@ export default function Layers({
[map],
)
// Set up custom event handler for fly-to events
useEffect(() => {
if (!mapboxMap) return
@ -206,7 +227,6 @@ export default function Layers({
}
}, [mapboxMap, map])
// Handle incident click events
useEffect(() => {
if (!mapboxMap) return
@ -214,13 +234,11 @@ export default function Layers({
const customEvent = e as CustomEvent
console.log("Received incident_click event in layers:", customEvent.detail)
// Enhanced error checking
if (!customEvent.detail) {
console.error("Empty incident click event data")
return
}
// Allow for different property names in the event data
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
if (!incidentId) {
@ -230,10 +248,8 @@ export default function Layers({
console.log("Looking for incident with ID:", incidentId)
// Improved incident finding
let foundIncident: ICrimeIncident | undefined
// First try to use the data directly from the event if it has all needed properties
if (
customEvent.detail.latitude !== undefined &&
customEvent.detail.longitude !== undefined &&
@ -252,7 +268,6 @@ export default function Layers({
address: customEvent.detail.address,
}
} else {
// Otherwise search through the crimes data
for (const crime of crimes) {
for (const incident of crime.crime_incidents) {
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
@ -288,20 +303,16 @@ export default function Layers({
console.log("Setting selected incident:", foundIncident)
// Clear any existing district selection first
setSelectedDistrict(null)
selectedDistrictRef.current = null
setFocusedDistrictId(null)
// Set the selected incident
setSelectedIncident(foundIncident)
}
// Add event listeners to both the map canvas and document
mapboxMap.getCanvas().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")
return () => {
@ -313,7 +324,6 @@ export default function Layers({
}
}, [mapboxMap, crimes, setFocusedDistrictId])
// Update selected district when year/month/filter changes
useEffect(() => {
if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id
@ -395,7 +405,6 @@ export default function Layers({
}
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
// Make sure we have a defined handler for setFocusedDistrictId
const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => {
console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick)
setFocusedDistrictId(id)
@ -403,23 +412,18 @@ export default function Layers({
if (!visible) return null
// Determine which layers should be visible based on the active control
const showDistrictLayer = activeControl === "incidents"
const showHeatmapLayer = activeControl === "heatmap"
const showClustersLayer = activeControl === "clusters"
const showUnitsLayer = activeControl === "units"
const showTimelineLayer = activeControl === "timeline"
// District fill should only be visible for incidents and 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"
return (
<>
{/* Standard District Layer with incident points */}
<DistrictFillLineLayer
visible={true}
map={mapboxMap}
@ -433,10 +437,9 @@ export default function Layers({
crimeDataByDistrict={crimeDataByDistrict}
showFill={showDistrictFill}
activeControl={activeControl}
onDistrictClick={handleDistrictClick} // Add this prop to pass the click handler
onDistrictClick={handleDistrictClick}
/>
{/* Heatmap Layer */}
<HeatmapLayer
crimes={crimes}
year={year}
@ -448,7 +451,6 @@ export default function Layers({
setFocusedDistrictId={handleSetFocusedDistrictId}
/>
{/* Timeline Layer - make sure this is the only visible layer in timeline mode */}
<TimelineLayer
crimes={crimes}
year={year}
@ -459,7 +461,6 @@ export default function Layers({
useAllData={useAllData}
/>
{/* Units Layer - always show incidents when Units is active */}
<UnitsLayer
crimes={crimes}
units={units}
@ -468,7 +469,6 @@ export default function Layers({
map={mapboxMap}
/>
{/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */}
<ClusterLayer
visible={visible && activeControl === "clusters"}
map={mapboxMap}
@ -479,7 +479,6 @@ export default function Layers({
showClusters={activeControl === "clusters"}
/>
{/* Unclustered Points Layer - now show for both incidents and units modes */}
<UnclusteredPointLayer
visible={visible && showIncidentMarkers && !focusedDistrictId}
map={mapboxMap}
@ -488,7 +487,6 @@ export default function Layers({
focusedDistrictId={focusedDistrictId}
/>
{/* District Popup */}
{selectedDistrict && !selectedIncident && (
<>
<DistrictPopup
@ -511,15 +509,30 @@ export default function Layers({
</>
)}
{/* Timeline Layer - only show if active control is timeline */}
<TimezoneLayer map={mapboxMap} />
{/* Fault line layer */}
<FaultLinesLayer map={mapboxMap} />
<CoastlineLayer
<CoastlineLayer map={mapboxMap} />
{showEWS && (
<EWSAlertLayer
map={mapboxMap}
incidents={ewsIncidents}
onIncidentResolved={handleResolveIncident}
/>
)}
{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>
)}
{/* Incident Popup */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<CrimePopup
longitude={selectedIncident.longitude}
@ -528,24 +541,6 @@ export default function Layers({
incident={selectedIncident}
/>
)}
{/* Debug info for development */}
{/* <div
className="text-red-500 bg-accent"
style={{
position: "absolute",
bottom: 10,
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> */}
</>
)
}

View File

@ -19,6 +19,7 @@ function Jam({ timeZone }: { timeZone: string }) {
const options: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit', // Added seconds display
hour12: false,
timeZone
};

View File

@ -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;
}

View File

@ -2,6 +2,9 @@
@tailwind components;
@tailwind utilities;
@import "./ews.css";
@import "./ui.css";
@import url(//fonts.googleapis.com/css?family=Roboto+Condensed:400,600,700);
@layer base {

View File

@ -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;
};

View File

@ -7,6 +7,7 @@ import {
districts,
geographics,
locations,
incident_logs,
} from '@prisma/client';
export interface ICrimes extends crimes {
@ -91,3 +92,16 @@ export interface IDistanceResult {
district_name: string;
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;
}

View File

@ -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';