Refactor tooltip and control types for improved clarity and consistency

- Renamed ITooltips to ITooltipsControl for better context in map controls.
- Updated type references in search-control, tooltips, crime-map, and layers to use the new ITooltipsControl type.
- Enhanced fill opacity logic in map layers based on active control state.
- Introduced getFillOpacity utility function to centralize opacity determination based on active control.
- Adjusted event handling for district clicks to ensure proper focus and visibility toggling.
- Cleaned up CSS styles by commenting out unused styles for circles.
This commit is contained in:
vergiLgood1 2025-05-14 11:08:06 +07:00
parent 9c6e005839
commit 6c96c1140c
11 changed files with 531 additions and 536 deletions

View File

@ -7,7 +7,7 @@ import { ChevronDown, Siren } from "lucide-react"
import { IconMessage } from "@tabler/icons-react" import { IconMessage } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import type { ITooltips } from "./tooltips" import type { ITooltipsControl } from "./tooltips"
import MonthSelector from "../month-selector" import MonthSelector from "../month-selector"
import YearSelector from "../year-selector" import YearSelector from "../year-selector"
import CategorySelector from "../category-selector" import CategorySelector from "../category-selector"
@ -15,13 +15,13 @@ import SourceTypeSelector from "../source-type-selector"
// Define the additional tools and features // Define the additional tools and features
const additionalTooltips = [ const additionalTooltips = [
{ id: "reports" as ITooltips, icon: <IconMessage size={20} />, label: "Police Report" }, { id: "reports" as ITooltipsControl, icon: <IconMessage size={20} />, label: "Police Report" },
{ id: "recents" as ITooltips, icon: <Siren size={20} />, label: "Recent incidents" }, { id: "recents" as ITooltipsControl, icon: <Siren size={20} />, label: "Recent incidents" },
] ]
interface AdditionalTooltipsProps { interface AdditionalTooltipsProps {
activeControl?: string activeControl?: string
onControlChange?: (controlId: ITooltips) => void onControlChange?: (controlId: ITooltipsControl) => void
selectedYear: number selectedYear: number
setSelectedYear: (year: number) => void setSelectedYear: (year: number) => void
selectedMonth: number | "all" selectedMonth: number | "all"
@ -68,7 +68,7 @@ export default function AdditionalTooltips({
setIsClient(true) setIsClient(true)
}, []) }, [])
const isControlDisabled = (controlId: ITooltips) => { const isControlDisabled = (controlId: ITooltipsControl) => {
// When source type is CBU, disable all controls except for layers // When source type is CBU, disable all controls except for layers
return selectedSourceType === "cbu" && controlId !== "layers" return selectedSourceType === "cbu" && controlId !== "layers"
} }

View File

@ -3,27 +3,27 @@
import { Button } from "@/app/_components/ui/button" import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { AlertTriangle, Building, Car, Thermometer, History } from "lucide-react" import { AlertTriangle, Building, Car, Thermometer, History } from "lucide-react"
import type { ITooltips } from "./tooltips" import type { ITooltipsControl } from "./tooltips"
import { IconChartBubble, IconClock } from "@tabler/icons-react" import { IconChartBubble, IconClock } from "@tabler/icons-react"
// Define the primary crime data controls // Define the primary crime data controls
const crimeTooltips = [ const crimeTooltips = [
// { id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" }, // { id: "incidents" as ITooltipsControl, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" }, { id: "heatmap" as ITooltipsControl, icon: <Thermometer size={20} />, label: "Density Heatmap" },
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" }, { id: "units" as ITooltipsControl, icon: <Building size={20} />, label: "Police Units" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" }, { id: "clusters" as ITooltipsControl, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" }, { id: "patrol" as ITooltipsControl, icon: <Car size={20} />, label: "Patrol Areas" },
{ id: "timeline" as ITooltips, icon: <IconClock size={20} />, label: "Time Analysis" }, { id: "timeline" as ITooltipsControl, icon: <IconClock size={20} />, label: "Time Analysis" },
] ]
interface CrimeTooltipsProps { interface CrimeTooltipsProps {
activeControl?: string activeControl?: string
onControlChange?: (controlId: ITooltips) => void onControlChange?: (controlId: ITooltipsControl) => void
sourceType?: string sourceType?: string
} }
export default function CrimeTooltips({ activeControl, onControlChange, sourceType = "cbt" }: CrimeTooltipsProps) { export default function CrimeTooltips({ activeControl, onControlChange, sourceType = "cbt" }: CrimeTooltipsProps) {
const handleControlClick = (controlId: ITooltips) => { const handleControlClick = (controlId: ITooltipsControl) => {
// If control is disabled, don't do anything // If control is disabled, don't do anything
if (isDisabled(controlId)) { if (isDisabled(controlId)) {
return return
@ -37,7 +37,7 @@ export default function CrimeTooltips({ activeControl, onControlChange, sourceTy
} }
// Determine which controls should be disabled based on source type // Determine which controls should be disabled based on source type
const isDisabled = (controlId: ITooltips) => { const isDisabled = (controlId: ITooltipsControl) => {
return sourceType === "cbu" && controlId !== "clusters" return sourceType === "cbu" && controlId !== "clusters"
} }

View File

@ -20,7 +20,7 @@ import { AnimatePresence, motion } from "framer-motion"
import ActionSearchBar from "@/app/_components/action-search-bar" import ActionSearchBar from "@/app/_components/action-search-bar"
import { Card } from "@/app/_components/ui/card" import { Card } from "@/app/_components/ui/card"
import { format } from "date-fns" import { format } from "date-fns"
import type { ITooltips } from "./tooltips" import type { ITooltipsControl } from "./tooltips"
// Define types based on the crime data structure // Define types based on the crime data structure
interface ICrimeIncident { interface ICrimeIncident {
@ -95,7 +95,7 @@ const ACTIONS = [
] ]
interface SearchTooltipProps { interface SearchTooltipProps {
onControlChange?: (controlId: ITooltips) => void onControlChange?: (controlId: ITooltipsControl) => void
activeControl?: string activeControl?: string
crimes?: ICrime[] crimes?: ICrime[]
sourceType?: string sourceType?: string
@ -336,7 +336,7 @@ export default function SearchTooltip({
setShowSearch(!showSearch) setShowSearch(!showSearch)
if (!showSearch && onControlChange) { if (!showSearch && onControlChange) {
onControlChange("search" as ITooltips) onControlChange("search" as ITooltipsControl)
setSelectedSearchType(null) setSelectedSearchType(null)
setSearchValue("") setSearchValue("")
setSuggestions([]) setSuggestions([])

View File

@ -8,7 +8,7 @@ import SearchTooltip from "./search-control"
import type { ReactNode } from "react" import type { ReactNode } from "react"
// Define the possible control IDs for the crime map // Define the possible control IDs for the crime map
export type ITooltips = export type ITooltipsControl =
// Crime data views // Crime data views
| "incidents" | "incidents"
| "historical" | "historical"
@ -30,14 +30,14 @@ export type ITooltips =
// Map tools type definition // Map tools type definition
export interface IMapTools { export interface IMapTools {
id: ITooltips id: ITooltipsControl
label: string label: string
icon: ReactNode icon: ReactNode
description?: string description?: string
} }
interface TooltipProps { interface TooltipProps {
onControlChange?: (controlId: ITooltips) => void onControlChange?: (controlId: ITooltipsControl) => void
activeControl?: string activeControl?: string
selectedSourceType: string selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void setSelectedSourceType: (sourceType: string) => void

View File

@ -18,7 +18,7 @@ import MapSelectors from "./controls/map-selector"
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils"
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/bottom/crime-timelapse" import { CrimeTimelapse } from "./controls/bottom/crime-timelapse"
import { ITooltips } from "./controls/top/tooltips" import { ITooltipsControl } from "./controls/top/tooltips"
import CrimeSidebar from "./controls/left/sidebar/map-sidebar" import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
import Tooltips from "./controls/top/tooltips" import Tooltips from "./controls/top/tooltips"
import Layers from "./layers/layers" import Layers from "./layers/layers"
@ -29,7 +29,7 @@ export default function CrimeMap() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null) const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true) const [showLegend, setShowLegend] = useState<boolean>(true)
const [activeControl, setActiveControl] = useState<ITooltips>("clusters") const [activeControl, setActiveControl] = useState<ITooltipsControl>("clusters")
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu") const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu")
const [selectedYear, setSelectedYear] = useState<number>(2024) const [selectedYear, setSelectedYear] = useState<number>(2024)
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all") const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
@ -190,7 +190,7 @@ export default function CrimeMap() {
return title; return title;
} }
const handleControlChange = (controlId: ITooltips) => { const handleControlChange = (controlId: ITooltipsControl) => {
if (selectedSourceType === "cbu" && if (selectedSourceType === "cbu" &&
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) { !["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) {
return; return;

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { getCrimeRateColor } from "@/app/_utils/map" import { getCrimeRateColor, getFillOpacity } from "@/app/_utils/map"
import type { IExtrusionLayerProps } from "@/app/_utils/types/map" import type { IExtrusionLayerProps } from "@/app/_utils/types/map"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
@ -17,231 +17,232 @@ export default function DistrictExtrusionLayer({
const extrusionCreatedRef = useRef(false) const extrusionCreatedRef = useRef(false)
const lastFocusedDistrictRef = useRef<string | null>(null) const lastFocusedDistrictRef = useRef<string | null>(null)
// Handle extrusion layer creation and updates // Helper to (re)create the extrusion layer
useEffect(() => { const createExtrusionLayer = () => {
if (!map || !visible) return if (!map) return
console.log("DistrictExtrusionLayer effect running, focusedDistrictId:", focusedDistrictId) const fillOpacity = getFillOpacity('units', true)
const onStyleLoad = () => { // Remove existing layer if exists
if (!map) return if (map.getLayer("district-extrusion")) {
map.removeLayer("district-extrusion")
extrusionCreatedRef.current = false
}
try { // Make sure the districts source exists
const layers = map.getStyle().layers if (!map.getSource("districts")) {
let firstSymbolId: string | undefined if (!tilesetId) {
for (const layer of layers) { console.error("No tileset ID provided for districts source")
if (layer.type === "symbol") { return
firstSymbolId = layer.id
break
}
} }
// Remove existing layer if it exists to avoid conflicts
if (map.getLayer("district-extrusion")) {
map.removeLayer("district-extrusion")
extrusionCreatedRef.current = false
}
// Make sure the districts source exists
if (!map.getSource("districts")) {
if (!tilesetId) {
console.error("No tileset ID provided for districts source")
return
}
map.addSource("districts", { map.addSource("districts", {
type: "vector", type: "vector",
url: `mapbox://${tilesetId}`, url: `mapbox://${tilesetId}`,
}) })
} }
// Create the extrusion layer // Find first symbol layer for correct layer order
map.addLayer( const layers = map.getStyle().layers
{ let firstSymbolId: string | undefined
id: "district-extrusion", for (const layer of layers) {
type: "fill-extrusion", if (layer.type === "symbol") {
source: "districts", firstSymbolId = layer.id
"source-layer": "Districts", break
paint: { }
"fill-extrusion-color": [ }
"case",
["has", "kode_kec"],
[ // Create the extrusion layer
"match", map.addLayer(
["get", "kode_kec"], {
focusedDistrictId || "", id: "district-extrusion",
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level), type: "fill-extrusion",
"transparent", source: "districts",
], "source-layer": "Districts",
"transparent", paint: {
], "fill-extrusion-color": [
"fill-extrusion-height": [ "case",
"case", ["has", "kode_kec"],
["has", "kode_kec"], [
["match", ["get", "kode_kec"], focusedDistrictId || "", 0, 0], // Start at 0 for animation "match",
0, ["get", "kode_kec"],
], focusedDistrictId || "",
"fill-extrusion-base": 0, getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
"fill-extrusion-opacity": 0.8, "transparent",
}, ],
filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""], "transparent",
}, ],
"fill-extrusion-height": [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], focusedDistrictId || "", 0, 0], // Start at 0 for animation
0,
],
"fill-extrusion-base": 0,
"fill-extrusion-opacity": fillOpacity,
},
filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""],
},
firstSymbolId, firstSymbolId,
) )
extrusionCreatedRef.current = true
extrusionCreatedRef.current = true
console.log("District extrusion layer created")
// If a district is focused, start the animation
if (focusedDistrictId) {
console.log("Starting animation for district:", focusedDistrictId)
lastFocusedDistrictRef.current = focusedDistrictId
animateExtrusion()
}
} catch (error) {
console.error("Error adding district extrusion layer:", error)
}
} }
if (map.isStyleLoaded()) { // Handle extrusion layer creation and updates
onStyleLoad()
} else {
map.once("style.load", onStyleLoad)
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
}, [map, visible, tilesetId, focusedDistrictId, crimeDataByDistrict])
// Update filter and color when focused district changes
useEffect(() => { useEffect(() => {
if (!map || !map.getLayer("district-extrusion")) return if (!map || !visible) return
console.log("Updating district extrusion for district:", focusedDistrictId)
// Skip unnecessary updates if nothing has changed
if (lastFocusedDistrictRef.current === focusedDistrictId) return
// If we're unfocusing a district
if (!focusedDistrictId) {
// Stop rotation when unfocusing
if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null
}
bearingRef.current = 0
// Animate height down
const animateHeightDown = () => {
if (!map || !map.getLayer("district-extrusion")) return
const currentHeight = 800
const duration = 500
const startTime = performance.now()
const animate = (time: number) => {
const elapsed = time - startTime
const progress = Math.min(elapsed / duration, 1)
const easedProgress = progress * (2 - progress) // easeOutQuad
const height = 800 - 800 * easedProgress
const onStyleLoad = () => {
if (!map) return
try { try {
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [ createExtrusionLayer()
"case", // Start animation if focusedDistrictId ada
["has", "kode_kec"], if (focusedDistrictId) {
["match", ["get", "kode_kec"], lastFocusedDistrictRef.current || "", height, 0], lastFocusedDistrictRef.current = focusedDistrictId
0, // setTimeout(() => {
if (map.getLayer("district-extrusion")) {
animateExtrusion()
}
// }, 50)
}
} catch (error) {
console.error("Error adding district extrusion layer:", error)
}
}
if (map.isStyleLoaded()) {
onStyleLoad()
} else {
map.once("style.load", onStyleLoad)
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
// Tambahkan crimeDataByDistrict ke deps agar update color jika data berubah
}, [map, visible, tilesetId, focusedDistrictId, crimeDataByDistrict])
// Update filter dan color ketika focusedDistrictId berubah
useEffect(() => {
if (!map) return
// Jika layer belum ada, buat dulu
if (!map.getLayer("district-extrusion")) {
createExtrusionLayer()
}
// Tunggu layer benar-benar ada
if (!map.getLayer("district-extrusion")) return
// Skip unnecessary updates if nothing has changed
if (lastFocusedDistrictRef.current === focusedDistrictId) return
// Jika unfocus
if (!focusedDistrictId) {
if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null
}
bearingRef.current = 0
// Animate height down
const animateHeightDown = () => {
if (!map || !map.getLayer("district-extrusion")) return
const currentHeight = 800
const duration = 500
const startTime = performance.now()
const animate = (time: number) => {
const elapsed = time - startTime
const progress = Math.min(elapsed / duration, 1)
const easedProgress = progress * (2 - progress)
const height = 800 - 800 * easedProgress
try {
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], lastFocusedDistrictRef.current || "", height, 0],
0,
])
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
} else {
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], "", 0, 0],
0,
])
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], ""])
lastFocusedDistrictRef.current = null
map.setBearing(0)
}
} catch (error) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
}
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
animationRef.current = requestAnimationFrame(animate)
}
animateHeightDown()
return
}
try {
// Update filter dan color
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId])
map.setPaintProperty("district-extrusion", "fill-extrusion-color", [
"case",
["has", "kode_kec"],
[
"match",
["get", "kode_kec"],
focusedDistrictId,
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
"transparent",
],
"transparent",
])
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], focusedDistrictId, 0, 0],
0,
]) ])
if (progress < 1) { lastFocusedDistrictRef.current = focusedDistrictId
animationRef.current = requestAnimationFrame(animate)
} else {
// Reset when animation completes
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], "", 0, 0],
0,
])
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], ""])
lastFocusedDistrictRef.current = null if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null
}
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
// Ensure bearing is reset setTimeout(() => {
map.setBearing(0) if (map.getLayer("district-extrusion")) {
} animateExtrusion()
} catch (error) { }
console.error("Error animating extrusion down:", error) }, 50)
if (animationRef.current) { } catch (error) {
cancelAnimationFrame(animationRef.current) console.error("Error updating district extrusion:", error)
animationRef.current = null
}
}
} }
}, [map, focusedDistrictId, crimeDataByDistrict])
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
animationRef.current = requestAnimationFrame(animate)
}
animateHeightDown()
return
}
try {
// Update filter for the new district
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId])
// Update the extrusion color
map.setPaintProperty("district-extrusion", "fill-extrusion-color", [
"case",
["has", "kode_kec"],
[
"match",
["get", "kode_kec"],
focusedDistrictId,
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
"transparent",
],
"transparent",
])
// Reset height for animation
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], focusedDistrictId, 0, 0],
0,
])
// Store current focused district
lastFocusedDistrictRef.current = focusedDistrictId
// Stop any existing animations and restart
if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null
}
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
// Start animation with small delay to ensure smooth transition
setTimeout(() => {
console.log("Starting animation after district update")
animateExtrusion()
}, 100)
} catch (error) {
console.error("Error updating district extrusion:", error)
}
}, [map, focusedDistrictId, crimeDataByDistrict])
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@ -259,95 +260,84 @@ export default function DistrictExtrusionLayer({
// Animate extrusion height // Animate extrusion height
const animateExtrusion = () => { const animateExtrusion = () => {
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) { if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) {
console.log("Cannot animate extrusion: missing map, layer, or focusedDistrictId") return
return }
}
console.log("Animating extrusion for district:", focusedDistrictId) if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
if (animationRef.current) { const startHeight = 0
cancelAnimationFrame(animationRef.current) const targetHeight = 800
animationRef.current = null const duration = 700
} const startTime = performance.now()
const startHeight = 0 const animate = (currentTime: number) => {
const targetHeight = 800 const elapsed = currentTime - startTime
const duration = 700 const progress = Math.min(elapsed / duration, 1)
const startTime = performance.now() const easedProgress = progress * (2 - progress)
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
const animate = (currentTime: number) => { try {
const elapsed = currentTime - startTime map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
const progress = Math.min(elapsed / duration, 1) "case",
const easedProgress = progress * (2 - progress) // easeOutQuad ["has", "kode_kec"],
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress ["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0],
0,
])
try { if (progress < 1) {
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [ animationRef.current = requestAnimationFrame(animate)
"case", } else {
["has", "kode_kec"], startRotation()
["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0], }
0, } catch (error) {
]) if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
if (progress < 1) { animationRef.current = null
animationRef.current = requestAnimationFrame(animate) }
} else {
console.log("Extrusion animation complete, starting rotation")
// Start rotation after extrusion completes
startRotation()
}
} catch (error) {
console.error("Error animating extrusion:", error)
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
} }
} }
}
animationRef.current = requestAnimationFrame(animate) animationRef.current = requestAnimationFrame(animate)
} }
// Start rotation animation // Start rotation animation
const startRotation = () => { const startRotation = () => {
if (!map || !focusedDistrictId) return if (!map || !focusedDistrictId) return
const rotationSpeed = 0.05 // degrees per frame const rotationSpeed = 0.05 // degrees per frame
bearingRef.current = 0 // Reset bearing at start bearingRef.current = 0
const animate = () => { const animate = () => {
if (!map || !focusedDistrictId || focusedDistrictId !== lastFocusedDistrictRef.current) { if (!map || !focusedDistrictId || focusedDistrictId !== lastFocusedDistrictRef.current) {
if (rotationAnimationRef.current) { if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current) cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null rotationAnimationRef.current = null
} }
return return
} }
try { try {
// Update bearing with smooth increment bearingRef.current = (bearingRef.current + rotationSpeed) % 360
bearingRef.current = (bearingRef.current + rotationSpeed) % 360 map.setBearing(bearingRef.current)
map.setBearing(bearingRef.current) rotationAnimationRef.current = requestAnimationFrame(animate)
} catch (error) {
// Continue the animation if (rotationAnimationRef.current) {
rotationAnimationRef.current = requestAnimationFrame(animate) cancelAnimationFrame(rotationAnimationRef.current)
} catch (error) { rotationAnimationRef.current = null
console.error("Error during rotation animation:", error) }
if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null
} }
} }
}
// Start the animation loop if (rotationAnimationRef.current) {
if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current)
cancelAnimationFrame(rotationAnimationRef.current) rotationAnimationRef.current = null
rotationAnimationRef.current = null }
} rotationAnimationRef.current = requestAnimationFrame(animate)
rotationAnimationRef.current = requestAnimationFrame(animate) }
}
return null return null
} }

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map" import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
import { createFillColorExpression, processDistrictFeature } from "@/app/_utils/map" import { createFillColorExpression, getFillOpacity, processDistrictFeature } from "@/app/_utils/map"
import type { IDistrictLayerProps } from "@/app/_utils/types/map" import type { IDistrictLayerProps } from "@/app/_utils/types/map"
import { useEffect } from "react" import { useEffect } from "react"
@ -25,74 +25,118 @@ export default function DistrictFillLineLayer({
useEffect(() => { useEffect(() => {
if (!map || !visible) return if (!map || !visible) return
const handleDistrictClick = (e: any) => { const handleDistrictClick = (e: any) => {
// First check if the click was on a marker or cluster // Only include layers that exist in the map style
const incidentFeatures = map.queryRenderedFeatures(e.point, { const possibleLayers = [
layers: [ "unclustered-point",
"unclustered-point", "clusters",
"clusters", "crime-points",
"crime-points", "units-points",
"units-points", "incidents-points",
"incidents-points", "timeline-markers",
"timeline-markers", "recent-incidents",
"recent-incidents", ]
], const availableLayers = possibleLayers.filter(layer => map.getLayer(layer))
}) const incidentFeatures = map.queryRenderedFeatures(e.point, {
layers: availableLayers,
})
if (incidentFeatures && incidentFeatures.length > 0) { if (incidentFeatures && incidentFeatures.length > 0) {
// Click was on a marker or cluster, so don't process it as a district click // Click was on a marker or cluster, so don't process it as a district click
return return
}
if (!map || !e.features || e.features.length === 0) return
const feature = e.features[0]
const districtId = feature.properties.kode_kec
// If clicking the same district, deselect it
if (focusedDistrictId === districtId) {
// Add null check for setFocusedDistrictId
if (setFocusedDistrictId) {
setFocusedDistrictId(null)
} }
// Reset pitch and bearing with animation if (!map || !e.features || e.features.length === 0) return
map.easeTo({
zoom: BASE_ZOOM,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
duration: 1500,
easing: (t) => t * (2 - t), // easeOutQuad
})
// Restore fill color for all districts when unfocusing const feature = e.features[0]
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict) const districtId = feature.properties.kode_kec
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
// Show all clusters again when unfocusing // If clicking the same district, deselect it
if (map.getLayer("clusters")) { if (focusedDistrictId === districtId) {
map.setLayoutProperty("clusters", "visibility", "visible") // Add null check for setFocusedDistrictId
} if (setFocusedDistrictId) {
if (map.getLayer("unclustered-point")) { setFocusedDistrictId(null)
map.setLayoutProperty("unclustered-point", "visibility", "visible") }
}
return // Reset pitch and bearing with animation
} else if (focusedDistrictId) { map.easeTo({
// If we're already focusing on a district and clicking a different one, zoom: BASE_ZOOM,
// we need to reset the current one and move to the new one pitch: BASE_PITCH,
if (setFocusedDistrictId) { bearing: BASE_BEARING,
setFocusedDistrictId(null) duration: 1500,
} easing: (t) => t * (2 - t), // easeOutQuad
})
// Wait a moment before selecting the new district to ensure clean transitions // Restore fill color for all districts when unfocusing
setTimeout(() => { const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
const district = processDistrictFeature(feature, e, districtId, crimeDataByDistrict, crimes, year, month) map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
if (!district || !setFocusedDistrictId) return
setFocusedDistrictId(district.id) // Show all clusters again when unfocusing
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", "visible")
}
if (map.getLayer("unclustered-point")) {
map.setLayoutProperty("unclustered-point", "visibility", "visible")
}
// Fly to the new district return
} else if (focusedDistrictId) {
// If we're already focusing on a district and clicking a different one,
// we need to reset the current one and move to the new one
if (setFocusedDistrictId) {
setFocusedDistrictId(null)
}
// Wait a moment before selecting the new district to ensure clean transitions
setTimeout(() => {
const district = processDistrictFeature(feature, e, districtId, crimeDataByDistrict, crimes, year, month)
if (!district || !setFocusedDistrictId) return
setFocusedDistrictId(district.id)
// Fly to the new district
map.flyTo({
center: [district.longitude, district.latitude],
zoom: 12.5,
pitch: 75,
bearing: 0,
duration: 1500,
easing: (t) => t * (2 - t), // easeOutQuad
})
// Use onDistrictClick if available, otherwise fall back to onClick
if (onDistrictClick) {
onDistrictClick(district)
} else if (onClick) {
onClick(district)
}
}, 100)
return
}
const district = processDistrictFeature(feature, e, districtId, crimeDataByDistrict, crimes, year, month)
if (!district) return
// Set the fill color expression immediately to show the focus
const focusedFillColorExpression = createFillColorExpression(district.id, crimeDataByDistrict)
map.setPaintProperty("district-fill", "fill-color", focusedFillColorExpression as any)
// Add null check for setFocusedDistrictId
if (setFocusedDistrictId) {
setFocusedDistrictId(district.id)
}
// Hide clusters when focusing on a district
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", "none")
}
if (map.getLayer("unclustered-point")) {
map.setLayoutProperty("unclustered-point", "visibility", "none")
}
// Animate to a pitched view focused on the district
map.flyTo({ map.flyTo({
center: [district.longitude, district.latitude], center: [district.longitude, district.latitude],
zoom: 12.5, zoom: 12.5,
@ -108,193 +152,134 @@ export default function DistrictFillLineLayer({
} else if (onClick) { } else if (onClick) {
onClick(district) onClick(district)
} }
}, 100)
return
}
const district = processDistrictFeature(feature, e, districtId, crimeDataByDistrict, crimes, year, month)
if (!district) return
// Set the fill color expression immediately to show the focus
const focusedFillColorExpression = createFillColorExpression(district.id, crimeDataByDistrict)
map.setPaintProperty("district-fill", "fill-color", focusedFillColorExpression as any)
// Add null check for setFocusedDistrictId
if (setFocusedDistrictId) {
setFocusedDistrictId(district.id)
} }
// Hide clusters when focusing on a district const onStyleLoad = () => {
if (map.getLayer("clusters")) { if (!map) return
map.setLayoutProperty("clusters", "visibility", "none")
}
if (map.getLayer("unclustered-point")) {
map.setLayoutProperty("unclustered-point", "visibility", "none")
}
// Animate to a pitched view focused on the district try {
map.flyTo({ if (!map.getSource("districts")) {
center: [district.longitude, district.latitude], const layers = map.getStyle().layers
zoom: 12.5, let firstSymbolId: string | undefined
pitch: 75, for (const layer of layers) {
bearing: 0, if (layer.type === "symbol") {
duration: 1500, firstSymbolId = layer.id
easing: (t) => t * (2 - t), // easeOutQuad break
}) }
}
// Use onDistrictClick if available, otherwise fall back to onClick map.addSource("districts", {
if (onDistrictClick) { type: "vector",
onDistrictClick(district) url: `mapbox://${tilesetId}`,
} else if (onClick) { })
onClick(district)
}
}
const onStyleLoad = () => { const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
if (!map) return
try { // Determine fill opacity based on active control
if (!map.getSource("districts")) { const fillOpacity = getFillOpacity(activeControl, showFill)
const layers = map.getStyle().layers
let firstSymbolId: string | undefined if (!map.getLayer("district-fill")) {
for (const layer of layers) { map.addLayer(
if (layer.type === "symbol") { {
firstSymbolId = layer.id id: "district-fill",
break type: "fill",
source: "districts",
"source-layer": "Districts",
paint: {
"fill-color": fillColorExpression as any,
"fill-opacity": fillOpacity,
},
},
firstSymbolId,
)
}
if (!map.getLayer("district-line")) {
map.addLayer(
{
id: "district-line",
type: "line",
source: "districts",
"source-layer": "Districts",
paint: {
"line-color": "#ffffff",
"line-width": 1,
"line-opacity": 0.5,
},
},
firstSymbolId,
)
}
map.on("mouseenter", "district-fill", () => {
map.getCanvas().style.cursor = "pointer"
})
map.on("mouseleave", "district-fill", () => {
map.getCanvas().style.cursor = ""
})
map.off("click", "district-fill", handleDistrictClick)
map.on("click", "district-fill", handleDistrictClick)
} else {
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
// Update fill opacity when active control changes
const fillOpacity = getFillOpacity(activeControl, showFill)
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
} }
} }
} catch (error) {
map.addSource("districts", { console.error("Error adding district layers:", error)
type: "vector",
url: `mapbox://${tilesetId}`,
})
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
// Determine fill opacity based on active control
const fillOpacity = getFillOpacity(activeControl, showFill)
if (!map.getLayer("district-fill")) {
map.addLayer(
{
id: "district-fill",
type: "fill",
source: "districts",
"source-layer": "Districts",
paint: {
"fill-color": fillColorExpression as any,
"fill-opacity": fillOpacity,
},
},
firstSymbolId,
)
} }
if (!map.getLayer("district-line")) {
map.addLayer(
{
id: "district-line",
type: "line",
source: "districts",
"source-layer": "Districts",
paint: {
"line-color": "#ffffff",
"line-width": 1,
"line-opacity": 0.5,
},
},
firstSymbolId,
)
}
map.on("mouseenter", "district-fill", () => {
map.getCanvas().style.cursor = "pointer"
})
map.on("mouseleave", "district-fill", () => {
map.getCanvas().style.cursor = ""
})
map.off("click", "district-fill", handleDistrictClick)
map.on("click", "district-fill", handleDistrictClick)
} else {
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
// Update fill opacity when active control changes
const fillOpacity = getFillOpacity(activeControl, showFill)
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
}
}
} catch (error) {
console.error("Error adding district layers:", error)
} }
}
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
onStyleLoad() onStyleLoad()
} else { } else {
map.once("style.load", onStyleLoad) map.once("style.load", onStyleLoad)
} }
return () => { return () => {
if (map) { if (map) {
map.off("click", "district-fill", handleDistrictClick) map.off("click", "district-fill", handleDistrictClick)
} }
} }
}, [ }, [
map, map,
visible, visible,
tilesetId, tilesetId,
crimes, crimes,
filterCategory, filterCategory,
year, year,
month, month,
focusedDistrictId, focusedDistrictId,
crimeDataByDistrict, crimeDataByDistrict,
onClick, onClick,
onDistrictClick, // Add to dependency array onDistrictClick, // Add to dependency array
setFocusedDistrictId, setFocusedDistrictId,
showFill, showFill,
activeControl, activeControl,
]) ])
// Add an effect to update the fill color and opacity whenever relevant props change // Add an effect to update the fill color and opacity whenever relevant props change
useEffect(() => { useEffect(() => {
if (!map || !map.getLayer("district-fill")) return if (!map || !map.getLayer("district-fill")) return
try { try {
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict) const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any) map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
// Update fill opacity when active control changes // Update fill opacity when active control changes
const fillOpacity = getFillOpacity(activeControl, showFill) const fillOpacity = getFillOpacity(activeControl, showFill)
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity) map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
} catch (error) { } catch (error) {
console.error("Error updating district fill colors or opacity:", error) console.error("Error updating district fill colors or opacity:", error)
} }
}, [map, focusedDistrictId, crimeDataByDistrict, activeControl, showFill]) }, [map, focusedDistrictId, crimeDataByDistrict, activeControl, showFill])
return null return null
} }
// Helper function to determine fill opacity based on active control
function getFillOpacity(activeControl?: string, showFill?: boolean): number {
if (!showFill) return 0
// Full opacity for incidents and clusters
if (activeControl === "incidents" || activeControl === "clusters") {
return 0.6
}
// Low opacity for timeline to show markers but still see district boundaries
if (activeControl === "timeline") {
return 0.1
}
// No fill for other controls, but keep boundaries visible
return 0
}

View File

@ -15,7 +15,7 @@ import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_ut
import UnclusteredPointLayer from "./uncluster-layer" import UnclusteredPointLayer from "./uncluster-layer"
import { toast } from "sonner" import { toast } from "sonner"
import type { ITooltips } from "../controls/top/tooltips" import type { ITooltipsControl } from "../controls/top/tooltips"
import type { IUnits } from "@/app/_utils/types/units" import type { IUnits } from "@/app/_utils/types/units"
import UnitsLayer from "./units-layer" import UnitsLayer from "./units-layer"
import DistrictFillLineLayer from "./district-layer" import DistrictFillLineLayer from "./district-layer"
@ -59,7 +59,7 @@ export interface IDistrictLayerProps {
setFocusedDistrictId?: (id: string | null) => void setFocusedDistrictId?: (id: string | null) => void
crimeDataByDistrict?: Record<string, any> crimeDataByDistrict?: Record<string, any>
showFill?: boolean showFill?: boolean
activeControl?: ITooltips activeControl?: ITooltipsControl
} }
interface LayersProps { interface LayersProps {
@ -70,7 +70,7 @@ interface LayersProps {
year: string year: string
month: string month: string
filterCategory: string | "all" filterCategory: string | "all"
activeControl: ITooltips activeControl: ITooltipsControl
tilesetId?: string tilesetId?: string
useAllData?: boolean useAllData?: boolean
showEWS?: boolean showEWS?: boolean
@ -132,7 +132,7 @@ export default function Layers({
if (incident.status === "active") { if (incident.status === "active") {
resolveIncident(incident.id) resolveIncident(incident.id)
} }
}) })
setEwsIncidents(getAllIncidents()) setEwsIncidents(getAllIncidents())
}, [ewsIncidents]) }, [ewsIncidents])

View File

@ -984,7 +984,7 @@ label#internal {
margin: auto; margin: auto;
} }
.circles div { /* .circles div {
animation: growAndFade 3s infinite ease-out; animation: growAndFade 3s infinite ease-out;
background-color: rgb(156, 94, 0); background-color: rgb(156, 94, 0);
border-radius: 50%; border-radius: 50%;
@ -993,7 +993,7 @@ label#internal {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
box-shadow: 0 0 10px 5px rgba(156, 75, 0, 0.5); box-shadow: 0 0 10px 5px rgba(156, 75, 0, 0.5);
} } */
@keyframes growAndFade { @keyframes growAndFade {
0% { 0% {

View File

@ -2,6 +2,7 @@ import { $Enums } from '@prisma/client';
import { CRIME_RATE_COLORS } from '@/app/_utils/const/map'; import { CRIME_RATE_COLORS } from '@/app/_utils/const/map';
import type { ICrimes } from '@/app/_utils/types/crimes'; import type { ICrimes } from '@/app/_utils/types/crimes';
import { IDistrictFeature } from './types/map'; import { IDistrictFeature } from './types/map';
import { ITooltipsControl } from '../_components/map/controls/top/tooltips';
// Process crime data by district // Process crime data by district
export const processCrimeDataByDistrict = (crimes: ICrimes[]) => { export const processCrimeDataByDistrict = (crimes: ICrimes[]) => {
@ -263,3 +264,21 @@ export function formatDistance(meters: number): string {
return `${(meters / 1000).toFixed(1)} km`; return `${(meters / 1000).toFixed(1)} km`;
} }
} }
// Helper function to determine fill opacity based on active control
export function getFillOpacity(activeControl?: ITooltipsControl, showFill?: boolean): number {
if (!showFill) return 0
// Full opacity for incidents and clusters
if (activeControl === "incidents" || activeControl === "clusters" || activeControl === "units") {
return 0.6
}
// Low opacity for timeline to show markers but still see district boundaries
if (activeControl === "timeline") {
return 0.1
}
// No fill for other controls, but keep boundaries visible
return 0
}

View File

@ -19,6 +19,7 @@ export interface IGeoJSONFeatureCollection {
import { $Enums } from '@prisma/client'; import { $Enums } from '@prisma/client';
import type { ICrimes } from '@/app/_utils/types/crimes'; import type { ICrimes } from '@/app/_utils/types/crimes';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import { ITooltipsControl } from "@/app/_components/map/controls/top/tooltips";
// Types for district properties // Types for district properties
export interface IDistrictFeature { export interface IDistrictFeature {
@ -75,7 +76,7 @@ export interface IDistrictLayerProps {
filterCategory: string | 'all'; filterCategory: string | 'all';
crimes: ICrimes[]; crimes: ICrimes[];
tilesetId?: string; tilesetId?: string;
activeControl?: string; activeControl: ITooltipsControl;
focusedDistrictId: string | null; focusedDistrictId: string | null;
setFocusedDistrictId?: (id: string | null) => void; setFocusedDistrictId?: (id: string | null) => void;
crimeDataByDistrict?: any; crimeDataByDistrict?: any;