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:
parent
5d13ff532b
commit
d807942688
|
@ -25,7 +25,7 @@ import { MoreHorizontal } from "lucide-react";
|
|||
import { ThemeSwitcher } from "@/app/_components/theme-switcher";
|
||||
import { Separator } from "@/app/_components/ui/separator";
|
||||
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 { createClient } from "@/app/_utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
|
|
@ -1,121 +1,243 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
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";
|
||||
import React, { useState, useEffect, forwardRef, useImperativeHandle } from "react"
|
||||
import { Input } from "@/app/_components/ui/input"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Search, Send, BarChart2, Globe, Video, PlaneTakeoff, AudioLines, X } from "lucide-react"
|
||||
import { cn } from "../_lib/utils"
|
||||
import useDebounce from "../_hooks/use-debounce"
|
||||
|
||||
interface Action {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description?: string;
|
||||
short?: string;
|
||||
end?: string;
|
||||
export interface Action {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
description?: string
|
||||
shortcut?: string
|
||||
category?: string
|
||||
onClick?: () => void
|
||||
prefix?: string
|
||||
regex?: RegExp
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
actions: Action[];
|
||||
export interface ActionSearchBarProps {
|
||||
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",
|
||||
label: "Book tickets",
|
||||
icon: <PlaneTakeoff className="h-4 w-4 text-blue-500" />,
|
||||
description: "Operator",
|
||||
short: "⌘K",
|
||||
end: "Agent",
|
||||
shortcut: "⌘K",
|
||||
category: "Agent",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
label: "Summarize",
|
||||
icon: <BarChart2 className="h-4 w-4 text-orange-500" />,
|
||||
description: "gpt-4o",
|
||||
short: "⌘cmd+p",
|
||||
end: "Command",
|
||||
shortcut: "⌘cmd+p",
|
||||
category: "Command",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
label: "Screen Studio",
|
||||
icon: <Video className="h-4 w-4 text-purple-500" />,
|
||||
description: "gpt-4o",
|
||||
short: "",
|
||||
end: "Application",
|
||||
category: "Application",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
label: "Talk to Jarvis",
|
||||
icon: <AudioLines className="h-4 w-4 text-green-500" />,
|
||||
description: "gpt-4o voice",
|
||||
short: "",
|
||||
end: "Active",
|
||||
category: "Active",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
label: "Translate",
|
||||
icon: <Globe className="h-4 w-4 text-blue-500" />,
|
||||
description: "gpt-4o",
|
||||
short: "",
|
||||
end: "Command",
|
||||
category: "Command",
|
||||
},
|
||||
];
|
||||
|
||||
interface ActionSearchBarProps {
|
||||
actions?: Action[];
|
||||
autoFocus?: boolean;
|
||||
isFloating?: boolean;
|
||||
}
|
||||
]
|
||||
|
||||
const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
|
||||
({ actions = allActions, autoFocus = false, isFloating = false }, ref) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [result, setResult] = useState<SearchResult | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(autoFocus);
|
||||
const [selectedAction, setSelectedAction] = useState<Action | null>(null);
|
||||
const debouncedQuery = useDebounce(query, 200);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
(
|
||||
{
|
||||
actions,
|
||||
defaultActions = true,
|
||||
autoFocus = false,
|
||||
isFloating = false,
|
||||
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(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [autoFocus]);
|
||||
}, [autoFocus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery) {
|
||||
setResult({ actions: allActions });
|
||||
return;
|
||||
setFilteredActions(allActionsList)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedQuery = debouncedQuery.toLowerCase().trim();
|
||||
const filteredActions = allActions.filter((action) => {
|
||||
const searchableText = action.label.toLowerCase();
|
||||
return searchableText.includes(normalizedQuery);
|
||||
});
|
||||
const normalizedQuery = debouncedQuery.toLowerCase().trim()
|
||||
const filtered = allActionsList.filter((action) => {
|
||||
const searchableText = `${action.label} ${action.description || ""}`.toLowerCase()
|
||||
return searchableText.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
setResult({ actions: filteredActions });
|
||||
}, [debouncedQuery]);
|
||||
setFilteredActions(filtered)
|
||||
|
||||
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>) => {
|
||||
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 = {
|
||||
hidden: { opacity: 0, height: 0 },
|
||||
|
@ -141,7 +263,7 @@ const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
|
|||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
|
@ -159,28 +281,48 @@ const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
|
|||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full ${isFloating ? "bg-background rounded-lg shadow-lg" : ""}`}
|
||||
>
|
||||
<div className={cn("relative w-full", isFloating ? "bg-background rounded-lg shadow-lg" : "", className)}>
|
||||
<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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="What's up?"
|
||||
value={query}
|
||||
placeholder={activeAction?.placeholder || placeholder}
|
||||
value={value !== undefined ? value : query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => 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
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{query.length > 0 ? (
|
||||
{(value || query).length > 0 ? (
|
||||
<motion.div
|
||||
key="send"
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
|
@ -206,59 +348,61 @@ const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
|
|||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{(isFocused || isFloating) && result && !selectedAction && (
|
||||
{(isFocused || isFloating) && filteredActions.length > 0 && !selectedAction && !activeAction && (
|
||||
<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}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="exit"
|
||||
>
|
||||
<motion.ul>
|
||||
{result.actions.map((action) => (
|
||||
{filteredActions.map((action) => (
|
||||
<motion.li
|
||||
key={action.id}
|
||||
className="px-3 py-2 flex items-center justify-between hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||
variants={item}
|
||||
layout
|
||||
onClick={() => setSelectedAction(action)}
|
||||
onClick={() => handleActionClick(action)}
|
||||
>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500">{action.icon}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{action.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{action.description}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{action.label}</span>
|
||||
{action.description && (
|
||||
<span className="text-xs text-muted-foreground">{action.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{action.short}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground text-right">
|
||||
{action.end}
|
||||
</span>
|
||||
{action.shortcut && <span className="text-xs text-muted-foreground">{action.shortcut}</span>}
|
||||
{action.category && (
|
||||
<span className="text-xs text-muted-foreground text-right">{action.category}</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
<div className="mt-2 px-3 py-2 border-t border-border">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Press ⌘K to open commands</span>
|
||||
<span>ESC to cancel</span>
|
||||
{showShortcutHint && (
|
||||
<div className="mt-2 px-3 py-2 border-t border-border">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Press {commandKey} to open commands</span>
|
||||
<span>ESC to cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ActionSearchBar.displayName = "ActionSearchBar";
|
||||
ActionSearchBar.displayName = "ActionSearchBar"
|
||||
|
||||
export default ActionSearchBar;
|
||||
export default ActionSearchBar
|
||||
|
|
|
@ -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<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 searchBarRef = useRef<HTMLInputElement>(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}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 >
|
||||
)
|
||||
}
|
|
@ -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<ITopTooltipsMapId>("incidents")
|
||||
const [yearProgress, setYearProgress] = useState(0)
|
||||
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement>(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 (
|
||||
<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">
|
||||
|
@ -284,7 +350,7 @@ export default function CrimeMap() {
|
|||
<div className="flex justify-center">
|
||||
<TopControl
|
||||
activeControl={activeControl}
|
||||
onControlChange={setActiveControl}
|
||||
onControlChange={handleControlChange}
|
||||
selectedYear={selectedYear}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
|
|
|
@ -411,6 +411,93 @@ export default function DistrictLayer({
|
|||
}
|
||||
}, [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(() => {
|
||||
if (!map || !visible) return
|
||||
|
||||
|
|
Loading…
Reference in New Issue