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:
parent
9c6e005839
commit
6c96c1140c
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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([])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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% {
|
||||||
|
|
|
@ -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[]) => {
|
||||||
|
@ -262,4 +263,22 @@ export function formatDistance(meters: number): string {
|
||||||
} else {
|
} else {
|
||||||
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
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue