Refactor FloatingActionSearchBar to accept actions and props; remove map-navigations component; implement top-controls with search functionality; enhance crime map with fly-to incident feature; add district layer highlight effect.

This commit is contained in:
vergiLgood1 2025-05-04 08:54:03 +07:00
parent 5d13ff532b
commit d807942688
7 changed files with 1121 additions and 348 deletions

View File

@ -25,7 +25,7 @@ import { MoreHorizontal } from "lucide-react";
import { ThemeSwitcher } from "@/app/_components/theme-switcher"; import { ThemeSwitcher } from "@/app/_components/theme-switcher";
import { Separator } from "@/app/_components/ui/separator"; import { Separator } from "@/app/_components/ui/separator";
import { InboxDrawer } from "@/app/_components/inbox-drawer"; import { InboxDrawer } from "@/app/_components/inbox-drawer";
import FloatingActionSearchBar from "@/app/_components/floating-action-search-bar"; import { FloatingActionSearchBar } from "@/app/_components/floating-action-search-bar";
import { AppSidebar } from "@/app/(pages)/(admin)/_components/app-sidebar"; import { AppSidebar } from "@/app/(pages)/(admin)/_components/app-sidebar";
import { createClient } from "@/app/_utils/supabase/server"; import { createClient } from "@/app/_utils/supabase/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";

View File

@ -1,121 +1,243 @@
"use client"; "use client"
import React, { import React, { useState, useEffect, forwardRef, useImperativeHandle } from "react"
useState, import { Input } from "@/app/_components/ui/input"
useEffect, import { motion, AnimatePresence } from "framer-motion"
forwardRef, import { Search, Send, BarChart2, Globe, Video, PlaneTakeoff, AudioLines, X } from "lucide-react"
useImperativeHandle, import { cn } from "../_lib/utils"
} from "react"; import useDebounce from "../_hooks/use-debounce"
import { Input } from "@/app/_components/ui/input";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
Send,
BarChart2,
Globe,
Video,
PlaneTakeoff,
AudioLines,
} from "lucide-react";
import useDebounce from "@/app/_hooks/use-debounce";
interface Action { export interface Action {
id: string; id: string
label: string; label: string
icon: React.ReactNode; icon: React.ReactNode
description?: string; description?: string
short?: string; shortcut?: string
end?: string; category?: string
onClick?: () => void
prefix?: string
regex?: RegExp
placeholder?: string
} }
interface SearchResult { export interface ActionSearchBarProps {
actions: Action[]; actions?: Action[]
defaultActions?: boolean
autoFocus?: boolean
isFloating?: boolean
placeholder?: string
onSearch?: (query: string) => void
onActionSelect?: (actionId: string) => void
onSuggestionSelect?: (suggestion: any) => void
className?: string
inputClassName?: string
dropdownClassName?: string
showShortcutHint?: boolean
commandKey?: string
value?: string
onChange?: (value: string) => void
activeActionId?: string | null
onClearAction?: () => void
showActiveAction?: boolean
} }
const allActions = [ const defaultActionsList: Action[] = [
{ {
id: "1", id: "1",
label: "Book tickets", label: "Book tickets",
icon: <PlaneTakeoff className="h-4 w-4 text-blue-500" />, icon: <PlaneTakeoff className="h-4 w-4 text-blue-500" />,
description: "Operator", description: "Operator",
short: "⌘K", shortcut: "⌘K",
end: "Agent", category: "Agent",
}, },
{ {
id: "2", id: "2",
label: "Summarize", label: "Summarize",
icon: <BarChart2 className="h-4 w-4 text-orange-500" />, icon: <BarChart2 className="h-4 w-4 text-orange-500" />,
description: "gpt-4o", description: "gpt-4o",
short: "⌘cmd+p", shortcut: "⌘cmd+p",
end: "Command", category: "Command",
}, },
{ {
id: "3", id: "3",
label: "Screen Studio", label: "Screen Studio",
icon: <Video className="h-4 w-4 text-purple-500" />, icon: <Video className="h-4 w-4 text-purple-500" />,
description: "gpt-4o", description: "gpt-4o",
short: "", category: "Application",
end: "Application",
}, },
{ {
id: "4", id: "4",
label: "Talk to Jarvis", label: "Talk to Jarvis",
icon: <AudioLines className="h-4 w-4 text-green-500" />, icon: <AudioLines className="h-4 w-4 text-green-500" />,
description: "gpt-4o voice", description: "gpt-4o voice",
short: "", category: "Active",
end: "Active",
}, },
{ {
id: "5", id: "5",
label: "Translate", label: "Translate",
icon: <Globe className="h-4 w-4 text-blue-500" />, icon: <Globe className="h-4 w-4 text-blue-500" />,
description: "gpt-4o", description: "gpt-4o",
short: "", category: "Command",
end: "Command",
}, },
]; ]
interface ActionSearchBarProps {
actions?: Action[];
autoFocus?: boolean;
isFloating?: boolean;
}
const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>( const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
({ actions = allActions, autoFocus = false, isFloating = false }, ref) => { (
const [query, setQuery] = useState(""); {
const [result, setResult] = useState<SearchResult | null>(null); actions,
const [isFocused, setIsFocused] = useState(autoFocus); defaultActions = true,
const [selectedAction, setSelectedAction] = useState<Action | null>(null); autoFocus = false,
const debouncedQuery = useDebounce(query, 200); isFloating = false,
const inputRef = React.useRef<HTMLInputElement>(null); placeholder = "What's up?",
onSearch,
onActionSelect,
onSuggestionSelect,
className,
inputClassName,
dropdownClassName,
showShortcutHint = true,
commandKey = "⌘K",
value,
onChange,
activeActionId,
onClearAction,
showActiveAction = true,
},
ref,
) => {
const allActionsList = React.useMemo(() => {
return defaultActions ? [...(actions || []), ...defaultActionsList] : actions || defaultActionsList
}, [actions, defaultActions]);
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); const [query, setQuery] = useState(value || "")
const [filteredActions, setFilteredActions] = useState<Action[]>(allActionsList)
const [isFocused, setIsFocused] = useState(autoFocus)
const [selectedAction, setSelectedAction] = useState<Action | null>(null)
const debouncedQuery = useDebounce(query, 200)
const inputRef = React.useRef<HTMLInputElement>(null)
const activeAction = React.useMemo(() => {
if (!activeActionId) return null;
return allActionsList.find(action => action.id === activeActionId) || null;
}, [activeActionId, allActionsList]);
useEffect(() => {
if (value !== undefined) {
setQuery(value);
}
}, [value]);
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
useEffect(() => { useEffect(() => {
if (autoFocus && inputRef.current) { if (autoFocus && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus()
} }
}, [autoFocus]); }, [autoFocus])
useEffect(() => { useEffect(() => {
if (!debouncedQuery) { if (!debouncedQuery) {
setResult({ actions: allActions }); setFilteredActions(allActionsList)
return; return
} }
const normalizedQuery = debouncedQuery.toLowerCase().trim(); const normalizedQuery = debouncedQuery.toLowerCase().trim()
const filteredActions = allActions.filter((action) => { const filtered = allActionsList.filter((action) => {
const searchableText = action.label.toLowerCase(); const searchableText = `${action.label} ${action.description || ""}`.toLowerCase()
return searchableText.includes(normalizedQuery); return searchableText.includes(normalizedQuery)
}); })
setResult({ actions: filteredActions }); setFilteredActions(filtered)
}, [debouncedQuery]);
if (onSearch) {
onSearch(debouncedQuery)
}
}, [debouncedQuery, allActionsList, onSearch])
useEffect(() => {
if (activeAction?.prefix && onChange) {
if (!value?.startsWith(activeAction.prefix)) {
onChange(activeAction.prefix + (value || "").replace(activeAction.prefix || "", ""));
}
}
}, [activeAction, onChange, value]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value); const newValue = e.target.value;
};
// More careful prefix enforcement
if (activeAction?.prefix) {
if (!newValue.startsWith(activeAction.prefix)) {
// If the user is trying to delete the entire value including prefix,
// just keep the prefix alone
const valueWithPrefix = activeAction.prefix;
if (onChange) {
onChange(valueWithPrefix);
} else {
setQuery(valueWithPrefix);
}
return;
}
}
if (onChange) {
onChange(newValue);
} else {
setQuery(newValue);
}
}
const handleActionClick = (action: Action) => {
setSelectedAction(action)
if (action.onClick) {
action.onClick()
}
if (onActionSelect) {
onActionSelect(action.id)
}
if (onChange && action.prefix !== undefined) {
onChange(action.prefix);
} else {
setQuery(action.prefix || "");
}
if (isFloating && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
}, 50);
} else {
inputRef.current?.blur()
}
setSelectedAction(null);
}
const handleClearAction = (e: React.MouseEvent) => {
e.stopPropagation();
if (onClearAction) {
onClearAction();
}
if (!onChange) {
setQuery("");
}
if (inputRef.current) {
inputRef.current.focus();
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
inputRef.current?.blur()
setIsFocused(false)
}
}
const container = { const container = {
hidden: { opacity: 0, height: 0 }, hidden: { opacity: 0, height: 0 },
@ -141,7 +263,7 @@ const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
}, },
}, },
}, },
}; }
const item = { const item = {
hidden: { opacity: 0, y: 20 }, hidden: { opacity: 0, y: 20 },
@ -159,28 +281,48 @@ const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
duration: 0.2, duration: 0.2,
}, },
}, },
}; }
return ( return (
<div <div className={cn("relative w-full", isFloating ? "bg-background rounded-lg shadow-lg" : "", className)}>
className={`relative w-full ${isFloating ? "bg-background rounded-lg shadow-lg" : ""}`}
>
<div className="relative"> <div className="relative">
{activeAction && showActiveAction && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 flex items-center z-10 bg-muted/50 rounded-md px-1.5 py-0.5 max-w-[45%] mr-2">
<span className="mr-1.5 flex-shrink-0">{activeAction.icon}</span>
<span className="text-xs font-medium truncate mr-1">
{activeAction.label.replace("Search by ", "")}
</span>
<button
type="button"
onClick={handleClearAction}
className="flex-shrink-0 rounded-full hover:bg-muted p-0.5"
title="Clear search type"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<Input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder="What's up?" placeholder={activeAction?.placeholder || placeholder}
value={query} value={value !== undefined ? value : query}
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => onBlur={() => !isFloating && setTimeout(() => setIsFocused(false), 200)}
!isFloating && setTimeout(() => setIsFocused(false), 200) onKeyDown={handleKeyDown}
} className={cn(
className="pl-3 pr-9 py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0" "py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0",
activeAction ? "pl-[120px]" : "pl-3",
"pr-9",
inputClassName
)}
/> />
<div className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4"> <div className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{query.length > 0 ? ( {(value || query).length > 0 ? (
<motion.div <motion.div
key="send" key="send"
initial={{ y: -20, opacity: 0 }} initial={{ y: -20, opacity: 0 }}
@ -206,59 +348,61 @@ const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
</div> </div>
<AnimatePresence> <AnimatePresence>
{(isFocused || isFloating) && result && !selectedAction && ( {(isFocused || isFloating) && filteredActions.length > 0 && !selectedAction && !activeAction && (
<motion.div <motion.div
className={`${isFloating ? "relative mt-2" : "absolute left-0 right-0 z-50"} border rounded-md shadow-lg overflow-hidden dark:border-gray-800 bg-background`} className={cn(
isFloating ? "relative mt-2" : "absolute left-0 right-0 z-50",
"border rounded-md shadow-lg overflow-hidden dark:border-gray-800 bg-background",
dropdownClassName,
)}
variants={container} variants={container}
initial="hidden" initial="hidden"
animate="show" animate="show"
exit="exit" exit="exit"
> >
<motion.ul> <motion.ul>
{result.actions.map((action) => ( {filteredActions.map((action) => (
<motion.li <motion.li
key={action.id} key={action.id}
className="px-3 py-2 flex items-center justify-between hover:bg-accent hover:text-accent-foreground cursor-pointer" className="px-3 py-2 flex items-center justify-between hover:bg-accent hover:text-accent-foreground cursor-pointer"
variants={item} variants={item}
layout layout
onClick={() => setSelectedAction(action)} onClick={() => handleActionClick(action)}
> >
<div className="flex items-center gap-2 justify-between"> <div className="flex items-center gap-2 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-500">{action.icon}</span> <span className="text-gray-500">{action.icon}</span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">{action.label}</span>
{action.label} {action.description && (
</span> <span className="text-xs text-muted-foreground">{action.description}</span>
<span className="text-xs text-muted-foreground"> )}
{action.description}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> {action.shortcut && <span className="text-xs text-muted-foreground">{action.shortcut}</span>}
{action.short} {action.category && (
</span> <span className="text-xs text-muted-foreground text-right">{action.category}</span>
<span className="text-xs text-muted-foreground text-right"> )}
{action.end}
</span>
</div> </div>
</motion.li> </motion.li>
))} ))}
</motion.ul> </motion.ul>
<div className="mt-2 px-3 py-2 border-t border-border"> {showShortcutHint && (
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="mt-2 px-3 py-2 border-t border-border">
<span>Press K to open commands</span> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>ESC to cancel</span> <span>Press {commandKey} to open commands</span>
<span>ESC to cancel</span>
</div>
</div> </div>
</div> )}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
); )
} },
); )
ActionSearchBar.displayName = "ActionSearchBar"; ActionSearchBar.displayName = "ActionSearchBar"
export default ActionSearchBar; export default ActionSearchBar

View File

@ -1,10 +1,52 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, forwardRef } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import ActionSearchBar from "@/app/_components/action-search-bar"; 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<HTMLInputElement, ActionSearchBarProps>(
(
{
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 [isOpen, setIsOpen] = useState(false);
const searchBarRef = useRef<HTMLInputElement>(null); const searchBarRef = useRef<HTMLInputElement>(null);
@ -51,10 +93,20 @@ export default function FloatingActionSearchBar() {
ref={searchBarRef} ref={searchBarRef}
autoFocus={true} autoFocus={true}
isFloating={true} isFloating={true}
actions={actions}
defaultActions={defaultActions}
placeholder={placeholder}
onSearch={onSearch}
className={className}
inputClassName={inputClassName}
dropdownClassName={dropdownClassName}
showShortcutHint={showShortcutHint}
commandKey={commandKey}
/> />
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); );
} });

View File

@ -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<HTMLDivElement>(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: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITopTooltipsMapId, icon: <Map size={20} />, label: "Crime Heatmap" },
{ id: "trends" as ITopTooltipsMapId, icon: <BarChart2 size={20} />, label: "Crime Trends" },
{ id: "patrol" as ITopTooltipsMapId, icon: <Shield size={20} />, label: "Patrol Areas" },
{ id: "clusters" as ITopTooltipsMapId, icon: <Users size={20} />, label: "Clusters" },
{ id: "timeline" as ITopTooltipsMapId, icon: <Clock size={20} />, label: "Time Analysis" },
]
// Define the additional tools and features
const additionalControls = [
{ id: "refresh" as ITopTooltipsMapId, icon: <RefreshCw size={20} />, label: "Refresh Data" },
{ id: "search" as ITopTooltipsMapId, icon: <Search size={20} />, label: "Search Cases" },
{ id: "alerts" as ITopTooltipsMapId, icon: <Siren size={20} className="text-red-500" />, label: "Active Alerts" },
{ id: "layers" as ITopTooltipsMapId, icon: <Layers size={20} />, label: "Map Layers" },
]
const toggleSelectors = () => {
setShowSelectors(!showSelectors)
}
return (
<div ref={containerRef} className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
{/* Main crime controls */}
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{crimeControls.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
{/* Additional controls */}
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{additionalControls.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
{/* Filters button */}
<Tooltip>
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-white/10"
onClick={toggleSelectors}
>
<ChevronDown size={20} />
<span className="sr-only">Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent
container={containerRef.current || container || undefined}
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
align="end"
style={{ zIndex: 2000 }}>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs w-16">Year:</span>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Month:</span>
<MonthSelector
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Category:</span>
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[180px]"
/>
</div>
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Selectors row - visible when expanded */}
{showSelectors && (
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[100px]"
/>
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[100px]"
/>
</div>
)}
</div>
)
}

View File

@ -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: <Search className="h-4 w-4 text-blue-500" />,
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: <FileText className="h-4 w-4 text-orange-500" />,
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: <Map className="h-4 w-4 text-green-500" />,
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: <MessageSquare className="h-4 w-4 text-purple-500" />,
description: "e.g., robbery",
category: "Search",
prefix: "",
regex: /.+/,
placeholder: "Enter crime description",
},
{
id: "address",
label: "Search by Address",
icon: <FolderOpen className="h-4 w-4 text-amber-500" />,
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: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITopTooltipsMapId, icon: <Map size={20} />, label: "Crime Heatmap" },
{ id: "trends" as ITopTooltipsMapId, icon: <BarChart2 size={20} />, label: "Crime Trends" },
{ id: "patrol" as ITopTooltipsMapId, icon: <Shield size={20} />, label: "Patrol Areas" },
{ id: "clusters" as ITopTooltipsMapId, icon: <Users size={20} />, label: "Clusters" },
{ id: "timeline" as ITopTooltipsMapId, icon: <Clock size={20} />, label: "Time Analysis" },
]
// Define the additional tools and features
const additionalControls = [
{ id: "reports" as ITopTooltipsMapId, icon: <IconMessage size={20} />, label: "Police Report" },
{ id: "layers" as ITopTooltipsMapId, icon: <Layers size={20} />, label: "Map Layers" },
{ id: "alerts" as ITopTooltipsMapId, icon: <Siren size={20} className="text-red-500" />, 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<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
const [searchValue, setSearchValue] = useState("")
const [suggestions, setSuggestions] = useState<Array<{ id: string, description: string }>>([])
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 (
<div ref={containerRef} className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{crimeControls.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{additionalControls.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
<Tooltip>
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-white/10"
onClick={toggleSelectors}
>
<ChevronDown size={20} />
<span className="sr-only">Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent
container={containerRef.current || container || undefined}
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
align="end"
style={{ zIndex: 2000 }}>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs w-16">Year:</span>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Month:</span>
<MonthSelector
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Category:</span>
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[180px]"
/>
</div>
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
</div>
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showSearch ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${showSearch
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={toggleSearch}
>
<Search size={20} />
<span className="sr-only">Search Incidents</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Search Incidents</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{showSelectors && (
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[100px]"
/>
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[100px]"
/>
</div>
)}
<AnimatePresence>
{showSearch && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40"
onClick={toggleSearch}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 w-[600px] max-w-[90vw]"
>
<div className="bg-background border border-border rounded-lg shadow-xl p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-medium">Search Incidents</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-slate-200"
onClick={toggleSearch}
>
<XCircle size={18} />
</Button>
</div>
{!showInfoBox ? (
<>
<ActionSearchBar
ref={searchInputRef}
autoFocus
isFloating
defaultActions={false}
actions={ACTIONS}
onActionSelect={handleSearchTypeSelect}
onClearAction={handleClearSearchType}
activeActionId={selectedSearchType}
value={searchValue}
onChange={handleSearchChange}
placeholder={selectedSearchType ?
ACTIONS.find(a => a.id === selectedSearchType)?.placeholder :
"Select a search type..."}
inputClassName={!isInputValid ?
"border-destructive focus-visible:ring-destructive bg-destructive/50" : ""}
/>
{!isInputValid && selectedSearchType && (
<div className="mt-1 text-xs text-destructive">
Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description}
</div>
)}
{suggestions.length > 0 && (
<div className="mt-2 max-h-60 overflow-y-auto border border-border rounded-md bg-background/80">
<ul className="py-1">
{suggestions.map((item, index) => (
<li
key={index}
className="px-3 py-2 hover:bg-muted cursor-pointer flex justify-between items-center"
onClick={() => handleSuggestionSelect(item)}
>
<span className="font-medium">{item.id}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">{item.description}</span>
<Info className="h-4 w-4 text-muted-foreground" />
</div>
</li>
))}
</ul>
</div>
)}
{searchValue.length > 0 &&
searchValue !== (ACTIONS.find(a => a.id === selectedSearchType)?.prefix || '') &&
selectedSearchType &&
suggestions.length === 0 && (
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
<p className="text-sm text-muted-foreground">No matching incidents found</p>
</div>
)}
<div className="mt-3 px-3 py-2 border-t border-border bg-muted/30 rounded-b-md">
<p className="flex items-center text-sm text-muted-foreground">
{selectedSearchType ? (
<>
<span className="mr-1">{ACTIONS.find(a => a.id === selectedSearchType)?.icon}</span>
<span>
{ACTIONS.find(a => a.id === selectedSearchType)?.description}
</span>
</>
) : (
<span>
Select a search type and enter your search criteria
</span>
)}
</p>
</div>
</>
) : (
<Card className="p-4 border border-border">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
<Button
variant="ghost"
size="sm"
onClick={handleCloseInfoBox}
className="h-6 w-6 p-0 rounded-full"
>
<XCircle size={16} />
</Button>
</div>
{selectedSuggestion && (
<div className="space-y-3">
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Info className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.description}</p>
</div>
{selectedSuggestion.timestamp && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">
{format(selectedSuggestion.timestamp, 'PPP p')}
</p>
</div>
)}
{selectedSuggestion.address && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<MapPin className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.address}</p>
</div>
)}
<div className="grid grid-cols-2 gap-2 mt-2">
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Category</p>
<p className="text-sm">{selectedSuggestion.category || 'N/A'}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Type</p>
<p className="text-sm">{selectedSuggestion.type || 'N/A'}</p>
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
<Button
variant="outline"
size="sm"
onClick={handleCloseInfoBox}
>
Close
</Button>
<Button
variant="default"
size="sm"
onClick={handleFlyToIncident}
disabled={!selectedSuggestion.latitude || !selectedSuggestion.longitude}
className="flex items-center gap-2"
>
<span>Fly to Incident</span>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Card>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div >
)
}

View File

@ -21,7 +21,7 @@ import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup" import CrimePopup from "./pop-up/crime-popup"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
import { CrimeTimelapse } from "./controls/crime-timelapse" 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 // Updated CrimeIncident type to match the structure in crime_incidents
interface CrimeIncident { interface CrimeIncident {
@ -48,6 +48,7 @@ export default function CrimeMap() {
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents") const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
const [yearProgress, setYearProgress] = useState(0) const [yearProgress, setYearProgress] = useState(0)
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false) const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
const [isSearchActive, setIsSearchActive] = useState(false)
const mapContainerRef = useRef<HTMLDivElement>(null) const mapContainerRef = useRef<HTMLDivElement>(null)
@ -110,19 +111,19 @@ export default function CrimeMap() {
}, [filteredByYearAndMonth, selectedCategory]) }, [filteredByYearAndMonth, selectedCategory])
// Handle incident marker click // Handle incident marker click
const handleIncidentClick = (incident: CrimeIncident) => { // const handleIncidentClick = (incident: CrimeIncident) => {
console.log("Incident clicked directly:", incident); // console.log("Incident clicked directly:", incident);
if (!incident.longitude || !incident.latitude) { // if (!incident.longitude || !incident.latitude) {
console.error("Invalid incident coordinates:", incident); // console.error("Invalid incident coordinates:", incident);
return; // return;
} // }
// When an incident is clicked, clear any selected district // // When an incident is clicked, clear any selected district
setSelectedDistrict(null); // setSelectedDistrict(null);
// Set the selected incident // // Set the selected incident
setSelectedIncident(incident); // setSelectedIncident(incident);
} // }
// Set up event listener for incident clicks from the district layer // Set up event listener for incident clicks from the district layer
useEffect(() => { 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 // Handle district click
const handleDistrictClick = (feature: DistrictFeature) => { // const handleDistrictClick = (feature: DistrictFeature) => {
console.log("District clicked in CrimeMap:", feature.name); // console.log("District clicked in CrimeMap:", feature.name);
// When a district is clicked, clear any selected incident // // When a district is clicked, clear any selected incident
setSelectedIncident(null); // setSelectedIncident(null);
// Set the selected district (for the sidebar or other components) // // Set the selected district (for the sidebar or other components)
setSelectedDistrict(feature); // setSelectedDistrict(feature);
} // }
// Handle year-month timeline change // Handle year-month timeline change
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => { const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
@ -220,6 +276,16 @@ export default function CrimeMap() {
setSidebarCollapsed(!sidebarCollapsed) setSidebarCollapsed(!sidebarCollapsed)
}, [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 ( return (
<Card className="w-full p-0 border-none shadow-none h-96"> <Card className="w-full p-0 border-none shadow-none h-96">
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between"> <CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
@ -284,7 +350,7 @@ export default function CrimeMap() {
<div className="flex justify-center"> <div className="flex justify-center">
<TopControl <TopControl
activeControl={activeControl} activeControl={activeControl}
onControlChange={setActiveControl} onControlChange={handleControlChange}
selectedYear={selectedYear} selectedYear={selectedYear}
setSelectedYear={setSelectedYear} setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth} selectedMonth={selectedMonth}

View File

@ -411,6 +411,93 @@ export default function DistrictLayer({
} }
}, [map]) }, [map])
// Add handler for fly-to events
useEffect(() => {
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(() => { useEffect(() => {
if (!map || !visible) return if (!map || !visible) return