diff --git a/sigap-website/app/_components/map/layers/cluster-layer.tsx b/sigap-website/app/_components/map/layers/cluster-layer.tsx index 29cd39f..d053b0a 100644 --- a/sigap-website/app/_components/map/layers/cluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/cluster-layer.tsx @@ -215,6 +215,7 @@ export default function ClusterLayer({ if (map.getLayer("clusters")) { map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") } + if (map.getLayer("cluster-count")) { map.setLayoutProperty( "cluster-count", diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index 3f65986..3614b33 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -20,6 +20,7 @@ import type { IUnits } from "@/app/_utils/types/units" import UnitsLayer from "./units-layer" import DistrictFillLineLayer from "./district-layer" import CrimePopup from "../pop-up/crime-popup" +import TimeZonesDisplay from "../timezone" // Interface for crime incident interface ICrimeIncident { @@ -98,8 +99,8 @@ export default function Layers({ // Reset selected state selectedDistrictRef.current = null setSelectedDistrict(null) - setSelectedIncident(null) - setFocusedDistrictId(null) + setSelectedIncident(null) + setFocusedDistrictId(null) // Reset map view/camera if (map) { @@ -111,20 +112,20 @@ export default function Layers({ easing: (t) => t * (2 - t), // easeOutQuad }) - // Show all clusters again when closing popup - if (map.getLayer("clusters")) { - map.getMap().setLayoutProperty("clusters", "visibility", "visible") - } + // Show all clusters again when closing popup + if (map.getLayer("clusters")) { + map.getMap().setLayoutProperty("clusters", "visibility", "visible") + } if (map.getLayer("unclustered-point")) { 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) - } - } + // 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) + } + } }, [map, crimeDataByDistrict]) // Handle district popup close specifically @@ -169,9 +170,9 @@ export default function Layers({ } if (map.getLayer("unclustered-point")) { map.getMap().setLayoutProperty("unclustered-point", "visibility", "none") - } - } - }, + } + } + }, [map], ) @@ -211,88 +212,88 @@ 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 - } + // 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 + // Allow for different property names in the event data + 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 - } + if (!incidentId) { + console.error("No incident ID found in event data:", customEvent.detail) + return + } - console.log("Looking for incident with ID:", incidentId) + console.log("Looking for incident with ID:", incidentId) - // Improved incident finding - let foundIncident: ICrimeIncident | undefined + // 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 && - 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 { - // 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()) { - 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 - } - } + // 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 && + 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 { + // 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()) { + 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) - return - } + if (!foundIncident) { + console.error("Could not find incident with ID:", incidentId) + return + } - if (!foundIncident.latitude || !foundIncident.longitude) { - console.error("Found incident has invalid coordinates:", foundIncident) - return - } + if (!foundIncident.latitude || !foundIncident.longitude) { + console.error("Found incident has invalid coordinates:", foundIncident) + return + } - console.log("Setting selected incident:", foundIncident) + console.log("Setting selected incident:", foundIncident) - // Clear any existing district selection first - setSelectedDistrict(null) - selectedDistrictRef.current = null - setFocusedDistrictId(null) + // Clear any existing district selection first + setSelectedDistrict(null) + selectedDistrictRef.current = null + setFocusedDistrictId(null) - // Set the selected incident - setSelectedIncident(foundIncident) - } + // Set the selected incident + setSelectedIncident(foundIncident) + } // Add event listeners to both the map canvas and document mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener) @@ -378,18 +379,18 @@ export default function Layers({ selectedDistrictRef.current = updatedDistrict - setSelectedDistrict((prevDistrict) => { - if ( - prevDistrict?.id === updatedDistrict.id && - prevDistrict?.selectedYear === updatedDistrict.selectedYear && - prevDistrict?.selectedMonth === updatedDistrict.selectedMonth - ) { - return prevDistrict - } - return updatedDistrict - }) - } - } + setSelectedDistrict((prevDistrict) => { + if ( + prevDistrict?.id === updatedDistrict.id && + prevDistrict?.selectedYear === updatedDistrict.selectedYear && + prevDistrict?.selectedMonth === updatedDistrict.selectedMonth + ) { + return prevDistrict + } + return updatedDistrict + }) + } + } }, [crimes, filterCategory, year, month, crimeDataByDistrict]) // Make sure we have a defined handler for setFocusedDistrictId @@ -508,6 +509,9 @@ export default function Layers({ > )} + {/* Timeline Layer - only show if active control is timeline */} + + {/* Incident Popup */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( (null) - const [popup, setPopup] = useState(null) + // State for selected district and popup + const [selectedDistrict, setSelectedDistrict] = useState(null) + const [showTimeZones, setShowTimeZones] = useState(true) // Process district data to extract average incident times const districtTimeData = useMemo(() => { // Group incidents by district - const districtGroups = new Map, - center: [number, number] - }>() + const districtGroups = new Map< + string, + { + districtId: string + districtName: string + incidents: Array<{ timestamp: Date; category: string }> + center: [number, number] + } + >() - crimes.forEach(crime => { + crimes.forEach((crime) => { if (!crime.districts || !crime.district_id) return // Initialize district group if not exists if (!districtGroups.has(crime.district_id)) { // Find a central location for the district from any incident - const centerIncident = crime.crime_incidents.find(inc => - inc.locations?.latitude && inc.locations?.longitude - ) + const centerIncident = crime.crime_incidents.find((inc) => inc.locations?.latitude && inc.locations?.longitude) const center: [number, number] = centerIncident ? [centerIncident.locations.longitude, centerIncident.locations.latitude] @@ -58,12 +61,12 @@ export default function TimelineLayer({ districtId: crime.district_id, districtName: crime.districts.name, incidents: [], - center - }) + center, + }) } // Filter incidents appropriately before adding - crime.crime_incidents.forEach(incident => { + crime.crime_incidents.forEach((incident) => { // Skip invalid incidents if (!incident.timestamp) return if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return @@ -73,17 +76,17 @@ export default function TimelineLayer({ if (group) { group.incidents.push({ timestamp: new Date(incident.timestamp), - category: incident.crime_categories.name - }) + category: incident.crime_categories.name, + }) } }) }) // Calculate average time for each district const result = Array.from(districtGroups.values()) - .filter(group => group.incidents.length > 0 && group.center[0] !== 0) - .map(group => { - const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map(inc => inc.timestamp)) + .filter((group) => group.incidents.length > 0 && group.center[0] !== 0) + .map((group) => { + const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map((inc) => inc.timestamp)) return { id: group.districtId, @@ -94,31 +97,28 @@ export default function TimelineLayer({ formattedTime: avgTimeInfo.formattedTime, timeDescription: avgTimeInfo.description, totalIncidents: group.incidents.length, - // Categorize by morning, afternoon, evening, night - timeOfDay: avgTimeInfo.timeOfDay, - // Additional statistics - earliestTime: format(avgTimeInfo.earliest, 'p'), - latestTime: format(avgTimeInfo.latest, 'p'), - mostFrequentHour: avgTimeInfo.mostFrequentHour, - // Group incidents by category for the popup - categoryCounts: group.incidents.reduce((acc, inc) => { + timeOfDay: avgTimeInfo.timeOfDay, + earliestTime: format(avgTimeInfo.earliest, "p"), + latestTime: format(avgTimeInfo.latest, "p"), + mostFrequentHour: avgTimeInfo.mostFrequentHour, + categoryCounts: group.incidents.reduce( + (acc, inc) => { acc[inc.category] = (acc[inc.category] || 0) + 1 return acc - }, {} as Record) + }, + {} as Record, + ), } }) - // Add title to indicate all years data - const title = useAllData ? "All Years Data" : `Year: ${year}${month !== "all" ? `, Month: ${month}` : ""}`; - return result - }, [crimes, filterCategory, useAllData, year, month]) + }, [crimes, filterCategory, year, month]) // Convert processed data to GeoJSON for display const timelineGeoJSON = useMemo(() => { return { type: "FeatureCollection" as const, - features: districtTimeData.map(district => ({ + features: districtTimeData.map((district) => ({ type: "Feature" as const, properties: { id: district.id, @@ -126,56 +126,21 @@ export default function TimelineLayer({ avgTime: district.formattedTime, timeDescription: district.timeDescription, totalIncidents: district.totalIncidents, - timeOfDay: district.timeOfDay + timeOfDay: district.timeOfDay, + hour: district.avgHour, + minute: district.avgMinute, }, geometry: { type: "Point" as const, - coordinates: district.center - } - })) + coordinates: district.center, + }, + })), } }, [districtTimeData]) - // Style time markers based on time of day - const getTimeMarkerColor = (timeOfDay: string) => { - switch (timeOfDay) { - case 'morning': return '#FFEB3B' // yellow - case 'afternoon': return '#FF9800' // orange - case 'evening': return '#3F51B5' // indigo - case 'night': return '#263238' // dark blue-grey - default: return '#4CAF50' // green fallback - } - } - - // Add an effect to hide all other incident markers and clusters when timeline is active - useEffect(() => { - if (!map || !visible) return; - - // Hide incident markers when timeline mode is activated - if (map.getLayer("unclustered-point")) { - map.setLayoutProperty("unclustered-point", "visibility", "none"); - } - - // Hide clusters when timeline mode is activated - if (map.getLayer("clusters")) { - map.setLayoutProperty("clusters", "visibility", "none"); - } - - if (map.getLayer("cluster-count")) { - map.setLayoutProperty("cluster-count", "visibility", "none"); - } - - return () => { - // This cleanup won't restore visibility since that's handled by the parent component - // based on the activeControl value - }; - }, [map, visible]); - - // Event handlers - useEffect(() => { - if (!map || !visible) return - - const handleTimeMarkerClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { + // Handle marker click + const handleMarkerClick = useCallback( + (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { if (!e.features || e.features.length === 0) return const feature = e.features[0] @@ -183,155 +148,150 @@ export default function TimelineLayer({ if (!props) return // Get the corresponding district data for detailed info - const districtData = districtTimeData.find(d => d.id === props.id) + const districtData = districtTimeData.find((d) => d.id === props.id) if (!districtData) return - // Remove existing popup if any - if (popup) popup.remove() + // Fly to the location + if (map) { + map.flyTo({ + center: districtData.center, + zoom: 12, + duration: 1000, + pitch: 45, + bearing: 0, + }) + } - // Create HTML content for popup - const categoriesHtml = Object.entries(districtData.categoryCounts) - .sort(([, countA], [, countB]) => countB - countA) - .slice(0, 5) // Top 5 categories - .map(([category, count]) => - ` - ${category} - ${count} - ` - ).join('') + // Set the selected district for popup + setSelectedDistrict(districtData) + }, + [map, districtTimeData], + ) - // Create popup - const newPopup = new mapboxgl.Popup({ closeButton: true, closeOnClick: false }) - .setLngLat(feature.geometry.type === 'Point' ? (feature.geometry as GeoJSON.Point).coordinates as [number, number] : [0, 0]) - .setHTML(` - - ${districtData.name} - - Average incident time - - ${districtData.formattedTime} - ${districtData.timeDescription} - - Based on ${districtData.totalIncidents} incidents - - - - - Earliest incident: - ${districtData.earliestTime} - - - Latest incident: - ${districtData.latestTime} - - - - - Top incident types: - ${categoriesHtml} - - - `) - .addTo(map) + // Handle popup close + const handleClosePopup = useCallback(() => { + setSelectedDistrict(null) + }, []) - // Store popup reference - setPopup(newPopup) - setSelectedDistrict(props.id) + // Add an effect to hide other layers when timeline is active + useEffect(() => { + if (!map || !visible) return - // Remove popup when closed - newPopup.on('close', () => { - setPopup(null) - setSelectedDistrict(null) - }) + // Hide incident markers when timeline mode is activated + if (map.getLayer("unclustered-point")) { + map.setLayoutProperty("unclustered-point", "visibility", "none") + } + + // Hide clusters when timeline mode is activated + if (map.getLayer("clusters")) { + map.setLayoutProperty("clusters", "visibility", "none") + } + + if (map.getLayer("cluster-count")) { + map.setLayoutProperty("cluster-count", "visibility", "none") } // Set up event handlers const handleMouseEnter = () => { - if (map) map.getCanvas().style.cursor = 'pointer' + if (map) map.getCanvas().style.cursor = "pointer" } const handleMouseLeave = () => { - if (map) map.getCanvas().style.cursor = '' + if (map) map.getCanvas().style.cursor = "" } // Add event listeners - if (map.getLayer('timeline-markers')) { - map.on('click', 'timeline-markers', handleTimeMarkerClick) - map.on('mouseenter', 'timeline-markers', handleMouseEnter) - map.on('mouseleave', 'timeline-markers', handleMouseLeave) + if (map.getLayer("timeline-markers")) { + map.on("click", "timeline-markers", handleMarkerClick) + map.on("mouseenter", "timeline-markers", handleMouseEnter) + map.on("mouseleave", "timeline-markers", handleMouseLeave) } return () => { // Clean up event listeners if (map) { - map.off('click', 'timeline-markers', handleTimeMarkerClick) - map.off('mouseenter', 'timeline-markers', handleMouseEnter) - map.off('mouseleave', 'timeline-markers', handleMouseLeave) - - // Remove popup if it exists - if (popup) { - popup.remove() - setPopup(null) - } + map.off("click", "timeline-markers", handleMarkerClick) + map.off("mouseenter", "timeline-markers", handleMouseEnter) + map.off("mouseleave", "timeline-markers", handleMouseLeave) } } - }, [map, visible, districtTimeData, popup]) + }, [map, visible, handleMarkerClick]) - // Clean up popup on unmount or when visibility changes + // Clean up on unmount or when visibility changes useEffect(() => { - if (!visible && popup) { - popup.remove() - setPopup(null) + if (!visible) { setSelectedDistrict(null) } - }, [visible, popup]) + }, [visible]) if (!visible) return null return ( - - {/* Time marker circles */} - + <> + + {/* Digital clock background */} + - {/* Time labels */} - - + {/* Digital clock display */} + + + + {/* Custom Popup Component */} + {selectedDistrict && ( + + )} + + + > ) } diff --git a/sigap-website/app/_components/map/layers/uncluster-layer.tsx b/sigap-website/app/_components/map/layers/uncluster-layer.tsx index 0d93436..53eeb7c 100644 --- a/sigap-website/app/_components/map/layers/uncluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/uncluster-layer.tsx @@ -1,7 +1,7 @@ "use client" import type { IUnclusteredPointLayerProps } from "@/app/_utils/types/map" -import { useEffect, useCallback } from "react" +import { useEffect, useCallback, useRef } from "react" export default function UnclusteredPointLayer({ visible = true, @@ -10,6 +10,9 @@ export default function UnclusteredPointLayer({ filterCategory = "all", focusedDistrictId, }: IUnclusteredPointLayerProps) { + // Add a ref to track if we're currently interacting with a marker + const isInteractingWithMarker = useRef(false); + const handleIncidentClick = useCallback( (e: any) => { if (!map) return @@ -17,6 +20,9 @@ export default function UnclusteredPointLayer({ const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) if (!features || features.length === 0) return + // Set flag to indicate we're interacting with a marker + isInteractingWithMarker.current = true; + const incident = features[0] if (!incident.properties) return @@ -37,13 +43,18 @@ export default function UnclusteredPointLayer({ console.log("Incident clicked:", incidentDetails) + // Ensure markers stay visible when clicking on them + if (map.getLayer("unclustered-point")) { + map.setLayoutProperty("unclustered-point", "visibility", "visible"); + } + // First fly to the incident location map.flyTo({ center: [incidentDetails.longitude, incidentDetails.latitude], zoom: 15, bearing: 0, pitch: 45, - duration: 1000, + duration: 2000, }) // Then dispatch the incident_click event to show the popup @@ -55,6 +66,11 @@ export default function UnclusteredPointLayer({ // Dispatch on both the map canvas and document to ensure it's caught map.getCanvas().dispatchEvent(customEvent) document.dispatchEvent(customEvent) + + // Reset the flag after a delay to allow the event to process + setTimeout(() => { + isInteractingWithMarker.current = false; + }, 500); }, [map], ) @@ -130,9 +146,10 @@ export default function UnclusteredPointLayer({ "circle-stroke-width": 1, "circle-stroke-color": "#fff", }, - // layout: { - // visibility: focusedDistrictId ? "visible" : "visible", - // }, + layout: { + // Only hide markers if a district is focused AND we're not interacting with a marker + visibility: focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible", + }, }, firstSymbolId, ) @@ -145,8 +162,9 @@ export default function UnclusteredPointLayer({ map.getCanvas().style.cursor = "" }) } else { - // Update visibility based on focused district - map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible") + // Update visibility based on focused district, but keep visible when interacting with markers + const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible"; + map.setLayoutProperty("unclustered-point", "visibility", newVisibility); } // Always ensure click handler is properly registered @@ -157,6 +175,16 @@ export default function UnclusteredPointLayer({ } } + if (map.getLayer("crime-incidents")) { + const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible"; + map.setLayoutProperty("crime-incidents", "visibility", newVisibility); + } + + if (map.getLayer("unclustered-point")) { + const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible"; + map.setLayoutProperty("unclustered-point", "visibility", newVisibility); + } + // Check if style is loaded and set up layer accordingly if (map.isStyleLoaded()) { setupLayerAndSource() diff --git a/sigap-website/app/_components/map/layers/units-layer.tsx b/sigap-website/app/_components/map/layers/units-layer.tsx index 23735f0..c05d743 100644 --- a/sigap-website/app/_components/map/layers/units-layer.tsx +++ b/sigap-website/app/_components/map/layers/units-layer.tsx @@ -1,16 +1,16 @@ "use client" -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, useCallback } from "react" import { Layer, Source } from "react-map-gl/mapbox" -import { ICrimes, IDistanceResult } from "@/app/_utils/types/crimes" -import { IUnits } from "@/app/_utils/types/units" -import mapboxgl from 'mapbox-gl' -import { useQuery } from '@tanstack/react-query' +import type { ICrimes } from "@/app/_utils/types/crimes" +import type { IUnits } from "@/app/_utils/types/units" +import type mapboxgl from "mapbox-gl" +import { useQuery } from "@tanstack/react-query" -import { generateCategoryColorMap, getCategoryColor } from '@/app/_utils/colors' -import UnitPopup from '../pop-up/unit-popup' -import IncidentPopup from '../pop-up/incident-popup' -import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action' +import { generateCategoryColorMap } from "@/app/_utils/colors" +import UnitPopup from "../pop-up/unit-popup" +import IncidentPopup from "../pop-up/incident-popup" +import { calculateDistances } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action" interface UnitsLayerProps { crimes: ICrimes[] @@ -21,29 +21,23 @@ interface UnitsLayerProps { } // Custom hook for fetching distance data -const useDistanceData = (entityId?: string, isUnit: boolean = false, districtId?: string) => { +const useDistanceData = (entityId?: string, isUnit = false, districtId?: string) => { // Skip the query when no entity is selected return useQuery({ - queryKey: ['distance-incidents', entityId, isUnit, districtId], + queryKey: ["distance-incidents", entityId, isUnit, districtId], queryFn: async () => { - if (!entityId) return []; - const unitId = isUnit ? entityId : undefined; - const result = await calculateDistances(unitId, districtId); - return result; + if (!entityId) return [] + const unitId = isUnit ? entityId : undefined + const result = await calculateDistances(unitId, districtId) + return result }, enabled: !!entityId, // Only run query when there's an entityId staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes gcTime: 10 * 60 * 1000, // Keep cache for 10 minutes - }); -}; + }) +} -export default function UnitsLayer({ - crimes, - units = [], - filterCategory, - visible = false, - map -}: UnitsLayerProps) { +export default function UnitsLayer({ crimes, units = [], filterCategory, visible = false, map }: UnitsLayerProps) { const [loadedUnits, setLoadedUnits] = useState([]) const loadedUnitsRef = useRef([]) @@ -58,37 +52,38 @@ export default function UnitsLayer({ const { data: distances = [], isLoading: isLoadingDistances } = useDistanceData( selectedEntityId, isUnitSelected, - selectedDistrictId - ); + selectedDistrictId, + ) // Use either provided units or loaded units const unitsData = useMemo(() => { - return units.length > 0 ? units : (loadedUnits || []) + return units.length > 0 ? units : loadedUnits || [] }, [units, loadedUnits]) // Extract all unique crime categories for color generation const uniqueCategories = useMemo(() => { - const categories = new Set(); - crimes.forEach(crime => { - crime.crime_incidents.forEach(incident => { + const categories = new Set() + crimes.forEach((crime) => { + crime.crime_incidents.forEach((incident) => { if (incident.crime_categories?.name) { - categories.add(incident.crime_categories.name); - } - }); - }); - return Array.from(categories); - }, [crimes]); + categories.add(incident.crime_categories.name) + } + }) + }) + return Array.from(categories) + }, [crimes]) // Generate color map for all categories const categoryColorMap = useMemo(() => { - return generateCategoryColorMap(uniqueCategories); - }, [uniqueCategories]); + return generateCategoryColorMap(uniqueCategories) + }, [uniqueCategories]) // Process units data to GeoJSON format const unitsGeoJSON = useMemo(() => { return { type: "FeatureCollection" as const, - features: unitsData.map(unit => ({ + features: unitsData + .map((unit) => ({ type: "Feature" as const, properties: { id: unit.code_unit, @@ -101,248 +96,368 @@ export default function UnitsLayer({ }, geometry: { type: "Point" as const, - coordinates: [unit.longitude || 0, unit.latitude || 0] - } - })).filter(feature => - feature.geometry.coordinates[0] !== 0 && - feature.geometry.coordinates[1] !== 0 - ) + coordinates: [unit.longitude || 0, unit.latitude || 0], + }, + })) + .filter((feature) => feature.geometry.coordinates[0] !== 0 && feature.geometry.coordinates[1] !== 0), } }, [unitsData]) // Process incident data to GeoJSON format const incidentsGeoJSON = useMemo(() => { - const features: any[] = []; + const features: any[] = [] - crimes.forEach(crime => { - crime.crime_incidents.forEach(incident => { + crimes.forEach((crime) => { + crime.crime_incidents.forEach((incident) => { // Skip incidents without location data or filtered by category if ( !incident.locations?.latitude || !incident.locations?.longitude || (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) - ) return; + ) + return - features.push({ - type: "Feature" as const, - properties: { - id: incident.id, - description: incident.description || "No description", - category: incident.crime_categories.name, - date: incident.timestamp, - district: crime.districts?.name || "", - district_id: crime.district_id, - categoryColor: categoryColorMap[incident.crime_categories.name] || '#22c55e', - }, - geometry: { - type: "Point" as const, - coordinates: [incident.locations.longitude, incident.locations.latitude] - } - }); - }); - }); + features.push({ + type: "Feature" as const, + properties: { + id: incident.id, + description: incident.description || "No description", + category: incident.crime_categories.name, + date: incident.timestamp, + district: crime.districts?.name || "", + district_id: crime.district_id, + categoryColor: categoryColorMap[incident.crime_categories.name] || "#22c55e", + }, + geometry: { + type: "Point" as const, + coordinates: [incident.locations.longitude, incident.locations.latitude], + }, + }) + }) + }) return { type: "FeatureCollection" as const, - features - }; - }, [crimes, filterCategory, categoryColorMap]); + features, + } + }, [crimes, filterCategory, categoryColorMap]) // Create lines between units and incidents within their districts const connectionLinesGeoJSON = useMemo(() => { - if (!unitsData.length || !crimes.length) return { - type: "FeatureCollection" as const, - features: [] - } + if (!unitsData.length || !crimes.length) + return { + type: "FeatureCollection" as const, + features: [], + } // Map district IDs to their units const districtUnitsMap = new Map() - unitsData.forEach(unit => { + unitsData.forEach((unit) => { if (!unit.district_id || !unit.longitude || !unit.latitude) return - if (!districtUnitsMap.has(unit.district_id)) { - districtUnitsMap.set(unit.district_id, []) - } - districtUnitsMap.get(unit.district_id)!.push(unit) - }) + if (!districtUnitsMap.has(unit.district_id)) { + districtUnitsMap.set(unit.district_id, []) + } + districtUnitsMap.get(unit.district_id)!.push(unit) + }) // Create lines from units to incidents in their district const lineFeatures: any[] = [] - crimes.forEach(crime => { + crimes.forEach((crime) => { // Get all units in this district const districtUnits = districtUnitsMap.get(crime.district_id) || [] if (!districtUnits.length) return - // For each incident in this district - crime.crime_incidents.forEach(incident => { - // Skip incidents without location data or filtered by category - if ( - !incident.locations?.latitude || - !incident.locations?.longitude || - (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) - ) return + // For each incident in this district + crime.crime_incidents.forEach((incident) => { + // Skip incidents without location data or filtered by category + if ( + !incident.locations?.latitude || + !incident.locations?.longitude || + (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) + ) + return - // Create a line from each unit in this district to this incident - districtUnits.forEach(unit => { - if (!unit.longitude || !unit.latitude) return + // Create a line from each unit in this district to this incident + districtUnits.forEach((unit) => { + if (!unit.longitude || !unit.latitude) return - lineFeatures.push({ - type: "Feature" as const, - properties: { - unit_id: unit.code_unit, - unit_name: unit.name, - incident_id: incident.id, - district_id: crime.district_id, - district_name: crime.districts.name, - category: incident.crime_categories.name, - lineColor: categoryColorMap[incident.crime_categories.name] || '#22c55e', - }, - geometry: { - type: "LineString" as const, - coordinates: [ - [unit.longitude, unit.latitude], - [incident.locations.longitude, incident.locations.latitude] - ] - } - }) - }) + lineFeatures.push({ + type: "Feature" as const, + properties: { + unit_id: unit.code_unit, + unit_name: unit.name, + incident_id: incident.id, + district_id: crime.district_id, + district_name: crime.districts.name, + category: incident.crime_categories.name, + lineColor: categoryColorMap[incident.crime_categories.name] || "#22c55e", + }, + geometry: { + type: "LineString" as const, + coordinates: [ + [unit.longitude, unit.latitude], + [incident.locations.longitude, incident.locations.latitude], + ], + }, }) + }) }) + }) return { type: "FeatureCollection" as const, - features: lineFeatures + features: lineFeatures, } }, [unitsData, crimes, filterCategory, categoryColorMap]) + // Handle unit click + const handleUnitClick = useCallback( + ( + map: mapboxgl.Map, + unitsData: IUnits[], + setSelectedUnit: (unit: IUnits | null) => void, + setSelectedIncident: (incident: any | null) => void, + setSelectedEntityId: (id: string | undefined) => void, + setIsUnitSelected: (isSelected: boolean) => void, + setSelectedDistrictId: (id: string | undefined) => void, + ) => + (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { + if (!e.features || e.features.length === 0) return + + e.originalEvent.stopPropagation() + e.preventDefault() + + const feature = e.features[0] + const properties = feature.properties + + if (!properties) return + + // Find the unit in our data + const unit = unitsData.find((u) => u.code_unit === properties.id) + if (!unit) return + + // Fly to the unit location + map.flyTo({ + center: [unit.longitude || 0, unit.latitude || 0], + zoom: 14, + pitch: 45, + bearing: 0, + duration: 1000, + }) + + // Set the selected unit and query parameters + setSelectedUnit(unit) + setSelectedIncident(null) // Clear any selected incident + setSelectedEntityId(properties.id) + setIsUnitSelected(true) + setSelectedDistrictId(properties.district_id) + + // Highlight the connected lines for this unit + if (map.getLayer("units-connection-lines")) { + map.setFilter("units-connection-lines", ["==", ["get", "unit_id"], properties.id]) + } + + // Dispatch a custom event for other components to react to + const customEvent = new CustomEvent("unit_click", { + detail: { + unitId: properties.id, + districtId: properties.district_id, + name: properties.name, + longitude: feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[0] : 0, + latitude: feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[1] : 0, + }, + bubbles: true, + }) + + map.getCanvas().dispatchEvent(customEvent) + document.dispatchEvent(customEvent) + }, + [], + ) + + // Handle incident click + const handleIncidentClick = useCallback( + ( + map: mapboxgl.Map, + setSelectedIncident: (incident: any | null) => void, + setSelectedUnit: (unit: IUnits | null) => void, + setSelectedEntityId: (id: string | undefined) => void, + setIsUnitSelected: (isSelected: boolean) => void, + setSelectedDistrictId: (id: string | undefined) => void, + ) => + (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { + if (!e.features || e.features.length === 0) return + + e.originalEvent.stopPropagation() + e.preventDefault() + + const feature = e.features[0] + const properties = feature.properties + + if (!properties) return + + // Get coordinates + const longitude = feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[0] : 0 + const latitude = feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[1] : 0 + + // Fly to the incident location + map.flyTo({ + center: [longitude, latitude], + zoom: 15, + pitch: 45, + bearing: 0, + duration: 1000, + }) + + // Create incident object from properties + const incident = { + id: properties.id, + category: properties.category, + description: properties.description, + date: properties.date, + district: properties.district, + district_id: properties.district_id, + longitude, + latitude, + } + + // Set the selected incident and query parameters + setSelectedIncident(incident) + setSelectedUnit(null) // Clear any selected unit + setSelectedEntityId(properties.id) + setIsUnitSelected(false) + setSelectedDistrictId(properties.district_id) + + // Highlight the connected lines for this incident + if (map.getLayer("units-connection-lines")) { + map.setFilter("units-connection-lines", ["==", ["get", "incident_id"], properties.id]) + } + + // Dispatch a custom event for other components to react to + const customEvent = new CustomEvent("incident_click", { + detail: { + id: properties.id, + district: properties.district, + category: properties.category, + description: properties.description, + longitude, + latitude, + }, + bubbles: true, + }) + + map.getCanvas().dispatchEvent(customEvent) + document.dispatchEvent(customEvent) + }, + [], + ) + + const unitClickHandler = useMemo( + () => + handleUnitClick( + map as mapboxgl.Map, + unitsData, + setSelectedUnit, + setSelectedIncident, + setSelectedEntityId, + setIsUnitSelected, + setSelectedDistrictId, + ), + [ + map, + unitsData, + setSelectedUnit, + setSelectedIncident, + setSelectedEntityId, + setIsUnitSelected, + setSelectedDistrictId, + ], + ) + + const incidentClickHandler = useMemo( + () => + handleIncidentClick( + map as mapboxgl.Map, + setSelectedIncident, + setSelectedUnit, + setSelectedEntityId, + setIsUnitSelected, + setSelectedDistrictId, + ), + [map, setSelectedIncident, setSelectedUnit, setSelectedEntityId, setIsUnitSelected, setSelectedDistrictId], + ) + + // Set up event handlers useEffect(() => { if (!map || !visible) return - const handleUnitClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { - if (!e.features || e.features.length === 0) return - - const feature = e.features[0] - const properties = feature.properties - - if (!properties) return - - // Find the unit in our data - const unit = unitsData.find(u => u.code_unit === properties.id); - if (!unit) return; - - // Set the selected unit and query parameters - setSelectedUnit(unit); - setSelectedIncident(null); // Clear any selected incident - setSelectedEntityId(properties.id); - setIsUnitSelected(true); - setSelectedDistrictId(properties.district_id); - - // Highlight the connected lines for this unit - if (map.getLayer('units-connection-lines')) { - map.setFilter('units-connection-lines', [ - '==', - ['get', 'unit_id'], - properties.id - ]) - } - } - - const handleIncidentClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { - if (!e.features || e.features.length === 0) return; - - const feature = e.features[0]; - const properties = feature.properties; - - if (!properties) return; - - // Create incident object from properties - const incident = { - id: properties.id, - category: properties.category, - description: properties.description, - date: properties.date, - district: properties.district, - district_id: properties.district_id, - }; - - // Set the selected incident and query parameters - setSelectedIncident({ - ...incident, - latitude: feature.geometry.type === 'Point' ? - (feature.geometry as any).coordinates[1] : 0, - longitude: feature.geometry.type === 'Point' ? - (feature.geometry as any).coordinates[0] : 0 - }); - setSelectedUnit(null); - setSelectedEntityId(properties.id); - setIsUnitSelected(false); - setSelectedDistrictId(properties.district_id); - - // Highlight the connected lines for this incident - if (map.getLayer('units-connection-lines')) { - map.setFilter('units-connection-lines', [ - '==', - ['get', 'incident_id'], - properties.id - ]); - } - }; - // Define event handlers that can be referenced for both adding and removing const handleMouseEnter = () => { - map.getCanvas().style.cursor = 'pointer' - } + map.getCanvas().style.cursor = "pointer" + } const handleMouseLeave = () => { - map.getCanvas().style.cursor = '' - } + map.getCanvas().style.cursor = "" + } // Add click event for units-points layer - if (map.getLayer('units-points')) { - map.on('click', 'units-points', handleUnitClick) + if (map.getLayer("units-points")) { + map.off("click", "units-points", unitClickHandler) + map.on("click", "units-points", unitClickHandler) - // Change cursor on hover - map.on('mouseenter', 'units-points', handleMouseEnter) - map.on('mouseleave', 'units-points', handleMouseLeave) - } + // Change cursor on hover + map.on("mouseenter", "units-points", handleMouseEnter) + map.on("mouseleave", "units-points", handleMouseLeave) + } // Add click event for incidents-points layer - if (map.getLayer('incidents-points')) { - map.on('click', 'incidents-points', handleIncidentClick) + if (map.getLayer("incidents-points")) { + map.off("click", "incidents-points", incidentClickHandler) + map.on("click", "incidents-points", incidentClickHandler) - // Change cursor on hover - map.on('mouseenter', 'incidents-points', handleMouseEnter) - map.on('mouseleave', 'incidents-points', handleMouseLeave) - } + // Change cursor on hover + map.on("mouseenter", "incidents-points", handleMouseEnter) + map.on("mouseleave", "incidents-points", handleMouseLeave) + } return () => { - if (map.getLayer('units-points')) { - map.off('click', 'units-points', handleUnitClick) - map.off('mouseenter', 'units-points', handleMouseEnter) - map.off('mouseleave', 'units-points', handleMouseLeave) + if (map) { + if (map.getLayer("units-points")) { + map.off("click", "units-points", unitClickHandler) + map.off("mouseenter", "units-points", handleMouseEnter) + map.off("mouseleave", "units-points", handleMouseLeave) } - if (map.getLayer('incidents-points')) { - map.off('click', 'incidents-points', handleIncidentClick) - map.off('mouseenter', 'incidents-points', handleMouseEnter) - map.off('mouseleave', 'incidents-points', handleMouseLeave) + if (map.getLayer("incidents-points")) { + map.off("click", "incidents-points", incidentClickHandler) + map.off("mouseenter", "incidents-points", handleMouseEnter) + map.off("mouseleave", "incidents-points", handleMouseLeave) } } - }, [map, visible, unitsData]) + } + }, [map, visible, unitClickHandler, incidentClickHandler]) // Reset map filters when popup is closed - const handleClosePopup = () => { - setSelectedUnit(null); - setSelectedIncident(null); - setSelectedEntityId(undefined); - setSelectedDistrictId(undefined); + const handleClosePopup = useCallback(() => { + setSelectedUnit(null) + setSelectedIncident(null) + setSelectedEntityId(undefined) + setSelectedDistrictId(undefined) - if (map && map.getLayer('units-connection-lines')) { - map.setFilter('units-connection-lines', ['has', 'unit_id']); + if (map && map.getLayer("units-connection-lines")) { + map.setFilter("units-connection-lines", ["has", "unit_id"]) } - }; + }, [map]) + + // Clean up on unmount or when visibility changes + useEffect(() => { + if (!visible) { + handleClosePopup() + } + }, [visible, handleClosePopup]) if (!visible) return null @@ -354,11 +469,11 @@ export default function UnitsLayer({ id="units-points" type="circle" paint={{ - 'circle-radius': 8, - 'circle-color': '#1e40af', // Deep blue for police units - 'circle-stroke-width': 2, - 'circle-stroke-color': '#ffffff', - 'circle-opacity': 0.8 + "circle-radius": 8, + "circle-color": "#1e40af", // Deep blue for police units + "circle-stroke-width": 2, + "circle-stroke-color": "#ffffff", + "circle-opacity": 0.8, }} /> @@ -367,18 +482,18 @@ export default function UnitsLayer({ id="units-symbols" type="symbol" layout={{ - 'text-field': ['get', 'name'], - 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], - 'text-size': 12, - 'text-offset': [0, -2], - 'text-anchor': 'bottom', - 'text-allow-overlap': false, - 'text-ignore-placement': false + "text-field": ["get", "name"], + "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + "text-offset": [0, -2], + "text-anchor": "bottom", + "text-allow-overlap": false, + "text-ignore-placement": false, }} paint={{ - 'text-color': '#ffffff', - 'text-halo-color': '#000000', - 'text-halo-width': 1 + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1, }} /> @@ -389,12 +504,12 @@ export default function UnitsLayer({ id="incidents-points" type="circle" paint={{ - 'circle-radius': 6, - // Use the pre-computed color stored in the properties - 'circle-color': ['get', 'categoryColor'], - 'circle-stroke-width': 1, - 'circle-stroke-color': '#ffffff', - 'circle-opacity': 0.8 + "circle-radius": 6, + // Use the pre-computed color stored in the properties + "circle-color": ["get", "categoryColor"], + "circle-stroke-width": 1, + "circle-stroke-color": "#ffffff", + "circle-opacity": 0.8, }} /> @@ -405,12 +520,12 @@ export default function UnitsLayer({ id="units-connection-lines" type="line" paint={{ - // Use the pre-computed color stored in the properties - 'line-color': ['get', 'lineColor'], - 'line-width': 3, - 'line-opacity': 0.9, - 'line-blur': 0.5, - 'line-dasharray': [3, 1], + // Use the pre-computed color stored in the properties + "line-color": ["get", "lineColor"], + "line-width": 3, + "line-opacity": 0.9, + "line-blur": 0.5, + "line-dasharray": [3, 1], }} /> @@ -428,7 +543,7 @@ export default function UnitsLayer({ address: selectedUnit.address || "No address", phone: selectedUnit.phone || "No phone", district: selectedUnit.districts?.name, - district_id: selectedUnit.district_id + district_id: selectedUnit.district_id, }} distances={distances} isLoadingDistances={isLoadingDistances} diff --git a/sigap-website/app/_components/map/markers/digital-clock.tsx b/sigap-website/app/_components/map/markers/digital-clock.tsx new file mode 100644 index 0000000..9118f4e --- /dev/null +++ b/sigap-website/app/_components/map/markers/digital-clock.tsx @@ -0,0 +1,83 @@ +"use client" + +import { useState, useEffect } from "react" +import { Marker } from "react-map-gl/mapbox" + +interface DigitalClockMarkerProps { + longitude: number + latitude: number + time: string + timeOfDay: string + onClick?: () => void +} + +export default function DigitalClockMarker({ longitude, latitude, time, timeOfDay, onClick }: DigitalClockMarkerProps) { + const [isBlinking, setIsBlinking] = useState(false) + + // Blink effect for the colon in the time display + useEffect(() => { + const interval = setInterval(() => { + setIsBlinking((prev) => !prev) + }, 1000) + + return () => clearInterval(interval) + }, []) + + // Get background color based on time of day + const getBackgroundColor = () => { + switch (timeOfDay) { + case "morning": + return "#FFEB3B" // yellow + case "afternoon": + return "#FF9800" // orange + case "evening": + return "#3F51B5" // indigo + case "night": + return "#263238" // dark blue-grey + default: + return "#4CAF50" // green fallback + } + } + + // Get text color based on time of day + const getTextColor = () => { + switch (timeOfDay) { + case "morning": + case "afternoon": + return "#000000" + case "evening": + case "night": + return "#FFFFFF" + default: + return "#000000" + } + } + + // Format time with blinking colon + const formatTime = (timeString: string) => { + if (!isBlinking) { + return timeString + } + return timeString.replace(":", " ") + } + + return ( + + + + {formatTime(time)} + + + + ) +} diff --git a/sigap-website/app/_components/map/pop-up/timeline-popup.tsx b/sigap-website/app/_components/map/pop-up/timeline-popup.tsx new file mode 100644 index 0000000..d2f4003 --- /dev/null +++ b/sigap-website/app/_components/map/pop-up/timeline-popup.tsx @@ -0,0 +1,122 @@ +"use client" + +import { Popup } from "react-map-gl/mapbox" +import { X } from 'lucide-react' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../ui/card" +import { Button } from "../../ui/button" +import { Badge } from "../../ui/badge" + + +interface TimelinePopupProps { + longitude: number + latitude: number + onClose: () => void + district: { + id: string + name: string + formattedTime: string + timeDescription: string + totalIncidents: number + earliestTime: string + latestTime: string + mostFrequentHour: number + categoryCounts: Record + timeOfDay: string + } +} + +export default function TimelinePopup({ + longitude, + latitude, + onClose, + district, +}: TimelinePopupProps) { + // Get top 5 categories + const topCategories = Object.entries(district.categoryCounts) + .sort(([, countA], [, countB]) => countB - countA) + .slice(0, 5) + + // Get time of day color + const getTimeOfDayColor = (timeOfDay: string) => { + switch (timeOfDay) { + case "morning": + return "bg-yellow-400 text-black" + case "afternoon": + return "bg-orange-500 text-white" + case "evening": + return "bg-indigo-600 text-white" + case "night": + return "bg-slate-800 text-white" + default: + return "bg-green-500 text-white" + } + } + + return ( + + + + + {district.name} + + + + + + Average incident time analysis + + + + + + {district.formattedTime} + + {district.timeDescription} + + + + Based on {district.totalIncidents} incidents + + + + + + Earliest incident: + {district.earliestTime} + + + Latest incident: + {district.latestTime} + + + + + Top incident types: + + {topCategories.map(([category, count]) => ( + + {category} + {count} + + ))} + + + + + + ) +} diff --git a/sigap-website/app/_components/map/timezone.tsx b/sigap-website/app/_components/map/timezone.tsx new file mode 100644 index 0000000..fa77913 --- /dev/null +++ b/sigap-website/app/_components/map/timezone.tsx @@ -0,0 +1,65 @@ +"use client" + +import { useEffect, useState } from "react" +import { Marker } from "react-map-gl/mapbox" + +interface TimeZoneMarker { + name: string + offset: number + longitude: number + latitude: number +} + +const TIME_ZONES: TimeZoneMarker[] = [ + { name: "WIB", offset: 7, longitude: 106.8456, latitude: -6.2088 }, // Jakarta + { name: "WITA", offset: 8, longitude: 115.1889, latitude: -8.4095 }, // Denpasar + { name: "WIT", offset: 9, longitude: 140.7887, latitude: -2.5916 }, // Jayapura +] + +export default function TimeZonesDisplay() { + const [currentTimes, setCurrentTimes] = useState>({}) + + useEffect(() => { + const updateTimes = () => { + const now = new Date() + const times: Record = {} + + TIME_ZONES.forEach((zone) => { + const localTime = new Date(now.getTime()) + localTime.setHours(now.getUTCHours() + zone.offset) + + const hours = localTime.getHours().toString().padStart(2, "0") + const minutes = localTime.getMinutes().toString().padStart(2, "0") + const seconds = localTime.getSeconds().toString().padStart(2, "0") + + times[zone.name] = `${hours}:${minutes}:${seconds}` + }) + + setCurrentTimes(times) + } + + // Update immediately and then every second + updateTimes() + const interval = setInterval(updateTimes, 1000) + + return () => clearInterval(interval) + }, []) + + return ( + <> + {TIME_ZONES.map((zone) => ( + + + + + {zone.name} + {currentTimes[zone.name] || "00:00:00"} + GMT+{zone.offset} + + + + + ))} + > + ) +} diff --git a/sigap-website/app/_styles/globals.css b/sigap-website/app/_styles/globals.css index 83f62e1..c682710 100644 --- a/sigap-website/app/_styles/globals.css +++ b/sigap-website/app/_styles/globals.css @@ -181,3 +181,39 @@ } +/* Digital clock styling */ +.digital-clock { + font-family: monospace; + font-size: 1rem; + font-weight: bold; + color: #ffb700; + background-color: #000; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + border: 1px solid #333; + text-align: center; + letter-spacing: 0.05rem; + box-shadow: 0 0 5px rgba(255, 183, 0, 0.5); +} + +/* Time zone markers */ +.time-zone-marker { + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 0.5rem; + border-radius: 0.25rem; + border: 1px solid #333; + font-family: monospace; +} + +.time-zone-marker .zone-name { + font-weight: bold; + text-align: center; + margin-bottom: 0.25rem; +} + +.time-zone-marker .zone-offset { + font-size: 0.75rem; + text-align: center; + color: #ccc; +}