+ {activeAction && showActiveAction && (
+
+ {activeAction.icon}
+
+ {activeAction.label.replace("Search by ", "")}
+
+
+
+ )}
+
setIsFocused(true)}
- onBlur={() =>
- !isFloating && setTimeout(() => setIsFocused(false), 200)
- }
- className="pl-3 pr-9 py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0"
+ onBlur={() => !isFloating && setTimeout(() => setIsFocused(false), 200)}
+ onKeyDown={handleKeyDown}
+ className={cn(
+ "py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0",
+ activeAction ? "pl-[120px]" : "pl-3",
+ "pr-9",
+ inputClassName
+ )}
/>
+
- {query.length > 0 ? (
+ {(value || query).length > 0 ? (
(
- {(isFocused || isFloating) && result && !selectedAction && (
+ {(isFocused || isFloating) && filteredActions.length > 0 && !selectedAction && !activeAction && (
- {result.actions.map((action) => (
+ {filteredActions.map((action) => (
setSelectedAction(action)}
+ onClick={() => handleActionClick(action)}
>
{action.icon}
-
- {action.label}
-
-
- {action.description}
-
+ {action.label}
+ {action.description && (
+ {action.description}
+ )}
-
- {action.short}
-
-
- {action.end}
-
+ {action.shortcut && {action.shortcut}}
+ {action.category && (
+ {action.category}
+ )}
))}
-
-
-
Press ⌘K to open commands
-
ESC to cancel
+ {showShortcutHint && (
+
+
+ Press {commandKey} to open commands
+ ESC to cancel
+
-
+ )}
)}
- );
- }
-);
+ )
+ },
+)
-ActionSearchBar.displayName = "ActionSearchBar";
+ActionSearchBar.displayName = "ActionSearchBar"
-export default ActionSearchBar;
+export default ActionSearchBar
diff --git a/sigap-website/app/_components/floating-action-search-bar.tsx b/sigap-website/app/_components/floating-action-search-bar.tsx
index ec87e10..52b04f4 100644
--- a/sigap-website/app/_components/floating-action-search-bar.tsx
+++ b/sigap-website/app/_components/floating-action-search-bar.tsx
@@ -1,10 +1,52 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, forwardRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import ActionSearchBar from "@/app/_components/action-search-bar";
-export default function FloatingActionSearchBar() {
+export interface Action {
+ id: string
+ label: string
+ icon: React.ReactNode
+ description?: string
+ shortcut?: string
+ category?: string
+ onClick?: () => void
+}
+
+export interface ActionSearchBarProps {
+ actions?: Action[]
+ defaultActions?: boolean
+ autoFocus?: boolean
+ isFloating?: boolean
+ placeholder?: string
+ onSearch?: (query: string) => void
+ onActionSelect?: (action: Action) => void
+ className?: string
+ inputClassName?: string
+ dropdownClassName?: string
+ showShortcutHint?: boolean
+ commandKey?: string
+}
+
+export const FloatingActionSearchBar = forwardRef(
+ (
+ {
+ actions,
+ defaultActions = true,
+ autoFocus = false,
+ isFloating = false,
+ placeholder = "What's up?",
+ onSearch,
+ onActionSelect,
+ className,
+ inputClassName,
+ dropdownClassName,
+ showShortcutHint = true,
+ commandKey = "⌘K",
+ },
+ ref,
+ ) => {
const [isOpen, setIsOpen] = useState(false);
const searchBarRef = useRef(null);
@@ -51,10 +93,20 @@ export default function FloatingActionSearchBar() {
ref={searchBarRef}
autoFocus={true}
isFloating={true}
+ actions={actions}
+ defaultActions={defaultActions}
+ placeholder={placeholder}
+ onSearch={onSearch}
+
+ className={className}
+ inputClassName={inputClassName}
+ dropdownClassName={dropdownClassName}
+ showShortcutHint={showShortcutHint}
+ commandKey={commandKey}
/>
)}
);
-}
+ });
diff --git a/sigap-website/app/_components/map/controls/map-navigations.tsx b/sigap-website/app/_components/map/controls/map-navigations.tsx
deleted file mode 100644
index cce4a10..0000000
--- a/sigap-website/app/_components/map/controls/map-navigations.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-"use client"
-
-import { useEffect, useRef, useState } from "react"
-import { Button } from "@/app/_components/ui/button"
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
-import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
-import { ChevronDown } from "lucide-react"
-import YearSelector from "./year-selector"
-import MonthSelector from "./month-selector"
-import CategorySelector from "./category-selector"
-
-// Import all the icons we need
-import {
- AlertTriangle,
- Shield,
- FileText,
- Users,
- Map,
- BarChart2,
- Clock,
- Filter,
- Search,
- RefreshCw,
- Layers,
- Siren,
- BadgeAlert,
- FolderOpen,
-} from "lucide-react"
-import { ITopTooltipsMapId } from "./map-tooltips"
-
-interface TopControlProps {
- onControlChange?: (controlId: ITopTooltipsMapId) => void
- activeControl?: string
- selectedYear: number
- setSelectedYear: (year: number) => void
- selectedMonth: number | "all"
- setSelectedMonth: (month: number | "all") => void
- selectedCategory: string | "all"
- setSelectedCategory: (category: string | "all") => void
- availableYears?: (number | null)[]
- categories?: string[]
-}
-
-export default function TopControl({
- onControlChange,
- activeControl,
- selectedYear,
- setSelectedYear,
- selectedMonth,
- setSelectedMonth,
- selectedCategory,
- setSelectedCategory,
- availableYears = [2022, 2023, 2024],
- categories = [],
-}: TopControlProps) {
- const [showSelectors, setShowSelectors] = useState(false)
- const containerRef = useRef
(null)
-
- const [isClient, setIsClient] = useState(false)
-
- const container = isClient ? document.getElementById("root") : null
-
- useEffect(() => {
- // This will ensure that the document is only used in the client-side context
- setIsClient(true)
- }, [])
-
- // Define the primary crime data controls
- const crimeControls = [
- { id: "incidents" as ITopTooltipsMapId, icon: , label: "All Incidents" },
- { id: "heatmap" as ITopTooltipsMapId, icon: , label: "Crime Heatmap" },
- { id: "trends" as ITopTooltipsMapId, icon: , label: "Crime Trends" },
- { id: "patrol" as ITopTooltipsMapId, icon: , label: "Patrol Areas" },
- { id: "clusters" as ITopTooltipsMapId, icon: , label: "Clusters" },
- { id: "timeline" as ITopTooltipsMapId, icon: , label: "Time Analysis" },
- ]
-
- // Define the additional tools and features
- const additionalControls = [
- { id: "refresh" as ITopTooltipsMapId, icon: , label: "Refresh Data" },
- { id: "search" as ITopTooltipsMapId, icon: , label: "Search Cases" },
- { id: "alerts" as ITopTooltipsMapId, icon: , label: "Active Alerts" },
- { id: "layers" as ITopTooltipsMapId, icon: , label: "Map Layers" },
- ]
-
- const toggleSelectors = () => {
- setShowSelectors(!showSelectors)
- }
-
- return (
-
-
- {/* Main crime controls */}
-
-
- {crimeControls.map((control) => (
-
-
-
-
-
- {control.label}
-
-
- ))}
-
-
-
- {/* Additional controls */}
-
-
- {additionalControls.map((control) => (
-
-
-
-
-
- {control.label}
-
-
- ))}
-
- {/* Filters button */}
-
-
-
-
-
-
-
-
- Year:
-
-
-
- Month:
-
-
-
- Category:
-
-
-
-
-
-
-
-
-
-
- {/* Selectors row - visible when expanded */}
- {showSelectors && (
-
-
-
-
-
- )}
-
- )
-}
diff --git a/sigap-website/app/_components/map/controls/top-controls.tsx b/sigap-website/app/_components/map/controls/top-controls.tsx
new file mode 100644
index 0000000..f1e655b
--- /dev/null
+++ b/sigap-website/app/_components/map/controls/top-controls.tsx
@@ -0,0 +1,643 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { Button } from "@/app/_components/ui/button"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
+import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
+import { ChevronDown, MessageSquare, MapPin, Calendar, Info, ExternalLink } from "lucide-react"
+import YearSelector from "./year-selector"
+import MonthSelector from "./month-selector"
+import CategorySelector from "./category-selector"
+import ActionSearchBar from "@/app/_components/action-search-bar"
+import { AnimatePresence, motion } from "framer-motion"
+import {
+ AlertTriangle,
+ Shield,
+ FileText,
+ Users,
+ Map,
+ BarChart2,
+ Clock,
+ Filter,
+ Search,
+ RefreshCw,
+ Layers,
+ Siren,
+ BadgeAlert,
+ FolderOpen,
+ XCircle,
+} from "lucide-react"
+import { ITopTooltipsMapId } from "./map-tooltips"
+import { IconAnalyze, IconMessage } from "@tabler/icons-react"
+import { FloatingActionSearchBar } from "../../floating-action-search-bar"
+import { format } from 'date-fns'
+import { Card } from "@/app/_components/ui/card"
+
+// Sample crime data for suggestions
+const SAMPLE_CRIME_DATA = [
+ { id: "CR-12345-2023", description: "Robbery at Main Street" },
+ { id: "CR-23456-2023", description: "Assault in Central Park" },
+ { id: "CI-7890-2023", description: "Burglary report at Downtown" },
+ { id: "CI-4567-2024", description: "Vandalism at City Hall" },
+ { id: "CR-34567-2024", description: "Car theft on 5th Avenue" },
+];
+
+const ACTIONS = [
+ {
+ id: "crime_id",
+ label: "Search by Crime ID",
+ icon: ,
+ description: "e.g., CR-12345",
+ category: "Search",
+ prefix: "CR-",
+ regex: /^CR-\d+(-\d{4})?$/,
+ placeholder: "CR-12345-2023",
+ },
+ {
+ id: "incident_id",
+ label: "Search by Incident ID",
+ icon: ,
+ description: "e.g., CI-789",
+ category: "Search",
+ prefix: "CI-",
+ regex: /^CI-\d+(-\d{4})?$/,
+ placeholder: "CI-7890-2023",
+ },
+ {
+ id: "coordinates",
+ label: "Search by Coordinates",
+ icon: ,
+ description: "e.g., -6.2, 106.8",
+ category: "Search",
+ prefix: "",
+ regex: /^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/,
+ placeholder: "-6.2, 106.8",
+ },
+ {
+ id: "description",
+ label: "Search by Description",
+ icon: ,
+ description: "e.g., robbery",
+ category: "Search",
+ prefix: "",
+ regex: /.+/,
+ placeholder: "Enter crime description",
+ },
+ {
+ id: "address",
+ label: "Search by Address",
+ icon: ,
+ description: "e.g., Jalan Sudirman",
+ category: "Search",
+ prefix: "",
+ regex: /.+/,
+ placeholder: "Enter location or address",
+ },
+]
+
+// Define the primary crime data controls
+const crimeControls = [
+ { id: "incidents" as ITopTooltipsMapId, icon: , label: "All Incidents" },
+ { id: "heatmap" as ITopTooltipsMapId, icon: , label: "Crime Heatmap" },
+ { id: "trends" as ITopTooltipsMapId, icon: , label: "Crime Trends" },
+ { id: "patrol" as ITopTooltipsMapId, icon: , label: "Patrol Areas" },
+ { id: "clusters" as ITopTooltipsMapId, icon: , label: "Clusters" },
+ { id: "timeline" as ITopTooltipsMapId, icon: , label: "Time Analysis" },
+]
+
+// Define the additional tools and features
+const additionalControls = [
+ { id: "reports" as ITopTooltipsMapId, icon: , label: "Police Report" },
+ { id: "layers" as ITopTooltipsMapId, icon: , label: "Map Layers" },
+ { id: "alerts" as ITopTooltipsMapId, icon: , label: "Active Alerts" },
+]
+
+interface TopControlProps {
+ onControlChange?: (controlId: ITopTooltipsMapId) => void
+ activeControl?: string
+ selectedYear: number
+ setSelectedYear: (year: number) => void
+ selectedMonth: number | "all"
+ setSelectedMonth: (month: number | "all") => void
+ selectedCategory: string | "all"
+ setSelectedCategory: (category: string | "all") => void
+ availableYears?: (number | null)[]
+ categories?: string[]
+}
+
+export default function TopControl({
+ onControlChange,
+ activeControl,
+ selectedYear,
+ setSelectedYear,
+ selectedMonth,
+ setSelectedMonth,
+ selectedCategory,
+ setSelectedCategory,
+ availableYears = [2022, 2023, 2024],
+ categories = [],
+}: TopControlProps) {
+ const [showSelectors, setShowSelectors] = useState(false)
+ const [showSearch, setShowSearch] = useState(false)
+ const containerRef = useRef(null)
+ const searchInputRef = useRef(null)
+ const [selectedSearchType, setSelectedSearchType] = useState(null)
+ const [searchValue, setSearchValue] = useState("")
+ const [suggestions, setSuggestions] = useState>([])
+ const [isInputValid, setIsInputValid] = useState(true)
+ const [selectedSuggestion, setSelectedSuggestion] = useState<{
+ id: string;
+ description: string;
+ latitude?: number;
+ longitude?: number;
+ timestamp?: Date;
+ category?: string;
+ type?: string;
+ address?: string;
+ } | null>(null)
+ const [showInfoBox, setShowInfoBox] = useState(false)
+
+ const [isClient, setIsClient] = useState(false)
+
+ const container = isClient ? document.getElementById("root") : null
+
+ useEffect(() => {
+ setIsClient(true)
+ }, [])
+
+ useEffect(() => {
+ if (showSearch && searchInputRef.current) {
+ setTimeout(() => {
+ searchInputRef.current?.focus();
+ }, 100);
+ }
+ }, [showSearch]);
+
+ const handleSearchTypeSelect = (actionId: string) => {
+ const selectedAction = ACTIONS.find(action => action.id === actionId);
+ if (selectedAction) {
+ setSelectedSearchType(actionId);
+ setSearchValue(selectedAction.prefix || "");
+ setIsInputValid(true);
+ if (selectedAction.prefix) {
+ const initialSuggestions = SAMPLE_CRIME_DATA.filter(item => {
+ if (actionId === 'crime_id' && item.id.startsWith('CR-')) {
+ return true;
+ } else if (actionId === 'incident_id' && item.id.startsWith('CI-')) {
+ return true;
+ } else if (actionId === 'description') {
+ return true;
+ } else if (actionId === 'address') {
+ return true;
+ }
+ return false;
+ });
+ setSuggestions(initialSuggestions.slice(0, 5));
+ }
+ }
+ };
+
+ const handleClearSearchType = () => {
+ setSelectedSearchType(null);
+ setSearchValue("");
+ setSuggestions([]);
+ if (searchInputRef.current) {
+ setTimeout(() => {
+ searchInputRef.current?.focus();
+ }, 50);
+ }
+ };
+
+ const handleSuggestionSelect = (item: { id: string, description: string }) => {
+ setSearchValue(item.id);
+ setSuggestions([]);
+ const fullIncidentData = {
+ ...item,
+ timestamp: new Date(),
+ latitude: -6.2088,
+ longitude: 106.8456,
+ category: selectedSearchType === 'crime_id' ? "Theft" : "Vandalism",
+ type: selectedSearchType === 'crime_id' ? "Property Crime" : "Public Disturbance",
+ address: "Jl. Sudirman No. 123, Jakarta"
+ };
+ setSelectedSuggestion(fullIncidentData);
+ setShowInfoBox(true);
+ };
+
+ const handleFlyToIncident = () => {
+ if (!selectedSuggestion || !selectedSuggestion.latitude || !selectedSuggestion.longitude) return;
+ const flyToEvent = new CustomEvent('fly_to_incident', {
+ detail: {
+ longitude: selectedSuggestion.longitude,
+ latitude: selectedSuggestion.latitude,
+ id: selectedSuggestion.id,
+ zoom: 15
+ },
+ bubbles: true
+ });
+ document.dispatchEvent(flyToEvent);
+ toggleSearch();
+ };
+
+ const handleCloseInfoBox = () => {
+ setShowInfoBox(false);
+ setSelectedSuggestion(null);
+ };
+
+ const handleSearchChange = (value: string) => {
+ const currentSearchType = selectedSearchType ?
+ ACTIONS.find(action => action.id === selectedSearchType) : null;
+
+ if (currentSearchType?.prefix && value) {
+ if (!value.startsWith(currentSearchType.prefix)) {
+ value = currentSearchType.prefix;
+ }
+ }
+
+ setSearchValue(value);
+
+ if (currentSearchType?.regex) {
+ if (!value || value === currentSearchType.prefix) {
+ setIsInputValid(true);
+ } else {
+ setIsInputValid(currentSearchType.regex.test(value));
+ }
+ } else {
+ setIsInputValid(true);
+ }
+
+ if (currentSearchType) {
+ if (!value || value === currentSearchType.prefix) {
+ const initialSuggestions = SAMPLE_CRIME_DATA.filter(item => {
+ if (currentSearchType.id === 'crime_id' && item.id.startsWith('CR-')) {
+ return true;
+ } else if (currentSearchType.id === 'incident_id' && item.id.startsWith('CI-')) {
+ return true;
+ } else if (currentSearchType.id === 'description') {
+ return true;
+ } else if (currentSearchType.id === 'address') {
+ return true;
+ }
+ return false;
+ });
+ setSuggestions(initialSuggestions.slice(0, 5));
+ } else {
+ const filteredSuggestions = SAMPLE_CRIME_DATA.filter(item => {
+ if (currentSearchType.id === 'crime_id' && item.id.startsWith('CR-')) {
+ return item.id.toLowerCase().includes(value.toLowerCase());
+ } else if (currentSearchType.id === 'incident_id' && item.id.startsWith('CI-')) {
+ return item.id.toLowerCase().includes(value.toLowerCase());
+ } else if (currentSearchType.id === 'description') {
+ return item.description.toLowerCase().includes(value.toLowerCase());
+ } else if (currentSearchType.id === 'address') {
+ return item.description.toLowerCase().includes(value.toLowerCase());
+ }
+ return false;
+ });
+ setSuggestions(filteredSuggestions);
+ }
+ } else {
+ setSuggestions([]);
+ }
+ };
+
+ const toggleSelectors = () => {
+ setShowSelectors(!showSelectors)
+ }
+
+ const toggleSearch = () => {
+ setShowSearch(!showSearch)
+ if (!showSearch && onControlChange) {
+ onControlChange("search" as ITopTooltipsMapId)
+ }
+ }
+
+ return (
+
+
+
+
+ {crimeControls.map((control) => (
+
+
+
+
+
+ {control.label}
+
+
+ ))}
+
+
+
+
+
+ {additionalControls.map((control) => (
+
+
+
+
+
+ {control.label}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Year:
+
+
+
+ Month:
+
+
+
+ Category:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Incidents
+
+
+
+
+
+
+ {showSelectors && (
+
+
+
+
+
+ )}
+
+
+ {showSearch && (
+ <>
+
+
+
+
+
+
Search Incidents
+
+
+
+ {!showInfoBox ? (
+ <>
+
a.id === selectedSearchType)?.placeholder :
+ "Select a search type..."}
+ inputClassName={!isInputValid ?
+ "border-destructive focus-visible:ring-destructive bg-destructive/50" : ""}
+ />
+
+ {!isInputValid && selectedSearchType && (
+
+ Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description}
+
+ )}
+
+ {suggestions.length > 0 && (
+
+
+ {suggestions.map((item, index) => (
+ - handleSuggestionSelect(item)}
+ >
+ {item.id}
+
+ {item.description}
+
+
+
+ ))}
+
+
+ )}
+
+ {searchValue.length > 0 &&
+ searchValue !== (ACTIONS.find(a => a.id === selectedSearchType)?.prefix || '') &&
+ selectedSearchType &&
+ suggestions.length === 0 && (
+
+
No matching incidents found
+
+ )}
+
+
+
+ {selectedSearchType ? (
+ <>
+ {ACTIONS.find(a => a.id === selectedSearchType)?.icon}
+
+ {ACTIONS.find(a => a.id === selectedSearchType)?.description}
+
+ >
+ ) : (
+
+ Select a search type and enter your search criteria
+
+ )}
+
+
+ >
+ ) : (
+
+
+
{selectedSuggestion?.id}
+
+
+
+ {selectedSuggestion && (
+
+
+
+
{selectedSuggestion.description}
+
+
+ {selectedSuggestion.timestamp && (
+
+
+
+ {format(selectedSuggestion.timestamp, 'PPP p')}
+
+
+ )}
+
+ {selectedSuggestion.address && (
+
+
+
{selectedSuggestion.address}
+
+ )}
+
+
+
+
Category
+
{selectedSuggestion.category || 'N/A'}
+
+
+
Type
+
{selectedSuggestion.type || 'N/A'}
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx
index af53241..be25c50 100644
--- a/sigap-website/app/_components/map/crime-map.tsx
+++ b/sigap-website/app/_components/map/crime-map.tsx
@@ -21,7 +21,7 @@ import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
import { CrimeTimelapse } from "./controls/crime-timelapse"
-import TopControl from "./controls/map-navigations"
+import TopControl from "./controls/top-controls"
// Updated CrimeIncident type to match the structure in crime_incidents
interface CrimeIncident {
@@ -48,6 +48,7 @@ export default function CrimeMap() {
const [activeControl, setActiveControl] = useState("incidents")
const [yearProgress, setYearProgress] = useState(0)
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
+ const [isSearchActive, setIsSearchActive] = useState(false)
const mapContainerRef = useRef(null)
@@ -110,19 +111,19 @@ export default function CrimeMap() {
}, [filteredByYearAndMonth, selectedCategory])
// Handle incident marker click
- const handleIncidentClick = (incident: CrimeIncident) => {
- console.log("Incident clicked directly:", incident);
- if (!incident.longitude || !incident.latitude) {
- console.error("Invalid incident coordinates:", incident);
- return;
- }
+ // const handleIncidentClick = (incident: CrimeIncident) => {
+ // console.log("Incident clicked directly:", incident);
+ // if (!incident.longitude || !incident.latitude) {
+ // console.error("Invalid incident coordinates:", incident);
+ // return;
+ // }
- // When an incident is clicked, clear any selected district
- setSelectedDistrict(null);
+ // // When an incident is clicked, clear any selected district
+ // setSelectedDistrict(null);
- // Set the selected incident
- setSelectedIncident(incident);
- }
+ // // Set the selected incident
+ // setSelectedIncident(incident);
+ // }
// Set up event listener for incident clicks from the district layer
useEffect(() => {
@@ -167,16 +168,71 @@ export default function CrimeMap() {
}
}, []);
+ // Set up event listener for fly-to-incident events from search
+ useEffect(() => {
+ const handleFlyToIncident = (e: CustomEvent) => {
+ if (!e.detail || !e.detail.longitude || !e.detail.latitude) {
+ console.error("Invalid fly-to coordinates:", e.detail);
+ return;
+ }
+
+ // Handle the fly-to event by dispatching to the map
+ const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
+ if (mapInstance) {
+ // Clear any existing selections first
+ setSelectedIncident(null);
+ setSelectedDistrict(null);
+
+ // Create an incident object to highlight
+ const incidentToHighlight: CrimeIncident = {
+ id: e.detail.id as string,
+ latitude: e.detail.latitude as number,
+ longitude: e.detail.longitude as number,
+ timestamp: new Date(),
+ description: e.detail.description || "",
+ status: e.detail.status
+ };
+
+ // First fly to the location
+ const flyEvent = new CustomEvent('mapbox_fly_to', {
+ detail: {
+ longitude: e.detail.longitude,
+ latitude: e.detail.latitude,
+ zoom: e.detail.zoom || 15,
+ bearing: 0,
+ pitch: 45,
+ duration: 2000,
+ },
+ bubbles: true
+ });
+
+ mapInstance.dispatchEvent(flyEvent);
+
+ // After flying, select the incident with a slight delay
+ setTimeout(() => {
+ setSelectedIncident(incidentToHighlight);
+ }, 2000);
+ }
+ }
+
+ // Add event listener
+ document.addEventListener('fly_to_incident', handleFlyToIncident as EventListener);
+
+ return () => {
+ document.removeEventListener('fly_to_incident', handleFlyToIncident as EventListener);
+ }
+ }, []);
+
// Handle district click
- const handleDistrictClick = (feature: DistrictFeature) => {
- console.log("District clicked in CrimeMap:", feature.name);
+ // const handleDistrictClick = (feature: DistrictFeature) => {
+ // console.log("District clicked in CrimeMap:", feature.name);
- // When a district is clicked, clear any selected incident
- setSelectedIncident(null);
+ // // When a district is clicked, clear any selected incident
+ // setSelectedIncident(null);
- // Set the selected district (for the sidebar or other components)
- setSelectedDistrict(feature);
- }
+ // // Set the selected district (for the sidebar or other components)
+ // setSelectedDistrict(feature);
+ // }
// Handle year-month timeline change
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
@@ -220,6 +276,16 @@ export default function CrimeMap() {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed])
+ // Handle control changes from the top controls component
+ const handleControlChange = (controlId: ITopTooltipsMapId) => {
+ setActiveControl(controlId)
+
+ // Toggle search state when search control is clicked
+ if (controlId === "search") {
+ setIsSearchActive(prev => !prev)
+ }
+ }
+
return (
@@ -284,7 +350,7 @@ export default function CrimeMap() {
{
+ if (!map) return;
+
+ const handleFlyToEvent = (e: CustomEvent) => {
+ if (!map || !e.detail) return;
+
+ const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail;
+
+ map.flyTo({
+ center: [longitude, latitude],
+ zoom: zoom || 15,
+ bearing: bearing || 0,
+ pitch: pitch || 45,
+ duration: duration || 2000
+ });
+
+ // Add a highlight or pulse effect to the target incident
+ // This could be implemented by adding a temporary marker or animation
+ // at the target coordinates
+ if (map.getMap().getLayer('target-incident-highlight')) {
+ map.getMap().removeLayer('target-incident-highlight');
+ }
+
+ if (map.getMap().getSource('target-incident-highlight')) {
+ map.getMap().removeSource('target-incident-highlight');
+ }
+
+ map.getMap().addSource('target-incident-highlight', {
+ type: 'geojson',
+ data: {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [longitude, latitude]
+ },
+ properties: {}
+ }
+ });
+
+ map.getMap().addLayer({
+ id: 'target-incident-highlight',
+ source: 'target-incident-highlight',
+ type: 'circle',
+ paint: {
+ 'circle-radius': [
+ 'interpolate', ['linear'], ['zoom'],
+ 10, 10,
+ 15, 15,
+ 20, 20
+ ],
+ 'circle-color': '#ff0000',
+ 'circle-opacity': 0.7,
+ 'circle-stroke-width': 2,
+ 'circle-stroke-color': '#ffffff'
+ }
+ });
+
+ // Add a pulsing effect using animations
+ let size = 10;
+ const animatePulse = () => {
+ if (!map || !map.getMap().getLayer('target-incident-highlight')) return;
+
+ size = (size % 20) + 1;
+
+ map.getMap().setPaintProperty('target-incident-highlight', 'circle-radius', [
+ 'interpolate', ['linear'], ['zoom'],
+ 10, size,
+ 15, size * 1.5,
+ 20, size * 2
+ ]);
+
+ requestAnimationFrame(animatePulse);
+ };
+
+ requestAnimationFrame(animatePulse);
+ };
+
+ map.getMap().getCanvas().addEventListener('mapbox_fly_to', handleFlyToEvent as EventListener);
+
+ return () => {
+ if (map && map.getMap() && map.getMap().getCanvas()) {
+ map.getMap().getCanvas().removeEventListener('mapbox_fly_to', handleFlyToEvent as EventListener);
+ }
+ };
+ }, [map]);
+
useEffect(() => {
if (!map || !visible) return