From 6c96c1140cf4812615d18c333644d2804d4769fe Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Wed, 14 May 2025 11:08:06 +0700 Subject: [PATCH] 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. --- .../map/controls/top/additional-tooltips.tsx | 10 +- .../map/controls/top/crime-tooltips.tsx | 20 +- .../map/controls/top/search-control.tsx | 6 +- .../_components/map/controls/top/tooltips.tsx | 6 +- .../app/_components/map/crime-map.tsx | 6 +- .../map/layers/district-extrusion-layer.tsx | 540 +++++++++--------- .../_components/map/layers/district-layer.tsx | 443 +++++++------- .../app/_components/map/layers/layers.tsx | 8 +- sigap-website/app/_styles/ui.css | 4 +- sigap-website/app/_utils/map.ts | 21 +- sigap-website/app/_utils/types/map.ts | 3 +- 11 files changed, 531 insertions(+), 536 deletions(-) diff --git a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx index fdabce4..db8841a 100644 --- a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx @@ -7,7 +7,7 @@ import { ChevronDown, Siren } from "lucide-react" import { IconMessage } from "@tabler/icons-react" import { useEffect, useRef, useState } from "react" -import type { ITooltips } from "./tooltips" +import type { ITooltipsControl } from "./tooltips" import MonthSelector from "../month-selector" import YearSelector from "../year-selector" import CategorySelector from "../category-selector" @@ -15,13 +15,13 @@ import SourceTypeSelector from "../source-type-selector" // Define the additional tools and features const additionalTooltips = [ - { id: "reports" as ITooltips, icon: , label: "Police Report" }, - { id: "recents" as ITooltips, icon: , label: "Recent incidents" }, + { id: "reports" as ITooltipsControl, icon: , label: "Police Report" }, + { id: "recents" as ITooltipsControl, icon: , label: "Recent incidents" }, ] interface AdditionalTooltipsProps { activeControl?: string - onControlChange?: (controlId: ITooltips) => void + onControlChange?: (controlId: ITooltipsControl) => void selectedYear: number setSelectedYear: (year: number) => void selectedMonth: number | "all" @@ -68,7 +68,7 @@ export default function AdditionalTooltips({ setIsClient(true) }, []) - const isControlDisabled = (controlId: ITooltips) => { + const isControlDisabled = (controlId: ITooltipsControl) => { // When source type is CBU, disable all controls except for layers return selectedSourceType === "cbu" && controlId !== "layers" } diff --git a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx index e5fcfc2..c239f8f 100644 --- a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx @@ -3,27 +3,27 @@ import { Button } from "@/app/_components/ui/button" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" 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" // Define the primary crime data controls const crimeTooltips = [ - // { id: "incidents" as ITooltips, icon: , label: "All Incidents" }, - { id: "heatmap" as ITooltips, icon: , label: "Density Heatmap" }, - { id: "units" as ITooltips, icon: , label: "Police Units" }, - { id: "clusters" as ITooltips, icon: , label: "Clustered Incidents" }, - { id: "patrol" as ITooltips, icon: , label: "Patrol Areas" }, - { id: "timeline" as ITooltips, icon: , label: "Time Analysis" }, + // { id: "incidents" as ITooltipsControl, icon: , label: "All Incidents" }, + { id: "heatmap" as ITooltipsControl, icon: , label: "Density Heatmap" }, + { id: "units" as ITooltipsControl, icon: , label: "Police Units" }, + { id: "clusters" as ITooltipsControl, icon: , label: "Clustered Incidents" }, + { id: "patrol" as ITooltipsControl, icon: , label: "Patrol Areas" }, + { id: "timeline" as ITooltipsControl, icon: , label: "Time Analysis" }, ] interface CrimeTooltipsProps { activeControl?: string - onControlChange?: (controlId: ITooltips) => void + onControlChange?: (controlId: ITooltipsControl) => void sourceType?: string } 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 (isDisabled(controlId)) { return @@ -37,7 +37,7 @@ export default function CrimeTooltips({ activeControl, onControlChange, sourceTy } // Determine which controls should be disabled based on source type - const isDisabled = (controlId: ITooltips) => { + const isDisabled = (controlId: ITooltipsControl) => { return sourceType === "cbu" && controlId !== "clusters" } diff --git a/sigap-website/app/_components/map/controls/top/search-control.tsx b/sigap-website/app/_components/map/controls/top/search-control.tsx index d4354b2..e4b250b 100644 --- a/sigap-website/app/_components/map/controls/top/search-control.tsx +++ b/sigap-website/app/_components/map/controls/top/search-control.tsx @@ -20,7 +20,7 @@ import { AnimatePresence, motion } from "framer-motion" import ActionSearchBar from "@/app/_components/action-search-bar" import { Card } from "@/app/_components/ui/card" import { format } from "date-fns" -import type { ITooltips } from "./tooltips" +import type { ITooltipsControl } from "./tooltips" // Define types based on the crime data structure interface ICrimeIncident { @@ -95,7 +95,7 @@ const ACTIONS = [ ] interface SearchTooltipProps { - onControlChange?: (controlId: ITooltips) => void + onControlChange?: (controlId: ITooltipsControl) => void activeControl?: string crimes?: ICrime[] sourceType?: string @@ -336,7 +336,7 @@ export default function SearchTooltip({ setShowSearch(!showSearch) if (!showSearch && onControlChange) { - onControlChange("search" as ITooltips) + onControlChange("search" as ITooltipsControl) setSelectedSearchType(null) setSearchValue("") setSuggestions([]) diff --git a/sigap-website/app/_components/map/controls/top/tooltips.tsx b/sigap-website/app/_components/map/controls/top/tooltips.tsx index 338a710..ce0af33 100644 --- a/sigap-website/app/_components/map/controls/top/tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/tooltips.tsx @@ -8,7 +8,7 @@ import SearchTooltip from "./search-control" import type { ReactNode } from "react" // Define the possible control IDs for the crime map -export type ITooltips = +export type ITooltipsControl = // Crime data views | "incidents" | "historical" @@ -30,14 +30,14 @@ export type ITooltips = // Map tools type definition export interface IMapTools { - id: ITooltips + id: ITooltipsControl label: string icon: ReactNode description?: string } interface TooltipProps { - onControlChange?: (controlId: ITooltips) => void + onControlChange?: (controlId: ITooltipsControl) => void activeControl?: string selectedSourceType: string setSelectedSourceType: (sourceType: string) => void diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index fcad740..c4e737d 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -18,7 +18,7 @@ import MapSelectors from "./controls/map-selector" import { cn } from "@/app/_lib/utils" import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" 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 Tooltips from "./controls/top/tooltips" import Layers from "./layers/layers" @@ -29,7 +29,7 @@ export default function CrimeMap() { const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const [selectedDistrict, setSelectedDistrict] = useState(null) const [showLegend, setShowLegend] = useState(true) - const [activeControl, setActiveControl] = useState("clusters") + const [activeControl, setActiveControl] = useState("clusters") const [selectedSourceType, setSelectedSourceType] = useState("cbu") const [selectedYear, setSelectedYear] = useState(2024) const [selectedMonth, setSelectedMonth] = useState("all") @@ -190,7 +190,7 @@ export default function CrimeMap() { return title; } - const handleControlChange = (controlId: ITooltips) => { + const handleControlChange = (controlId: ITooltipsControl) => { if (selectedSourceType === "cbu" && !["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) { return; diff --git a/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx b/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx index d4c5ec5..8acd156 100644 --- a/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx @@ -1,6 +1,6 @@ "use client" -import { getCrimeRateColor } from "@/app/_utils/map" +import { getCrimeRateColor, getFillOpacity } from "@/app/_utils/map" import type { IExtrusionLayerProps } from "@/app/_utils/types/map" import { useEffect, useRef } from "react" @@ -17,231 +17,232 @@ export default function DistrictExtrusionLayer({ const extrusionCreatedRef = useRef(false) const lastFocusedDistrictRef = useRef(null) - // Handle extrusion layer creation and updates - useEffect(() => { - if (!map || !visible) return + // Helper to (re)create the extrusion layer + const createExtrusionLayer = () => { + if (!map) return - console.log("DistrictExtrusionLayer effect running, focusedDistrictId:", focusedDistrictId) + const fillOpacity = getFillOpacity('units', true) - const onStyleLoad = () => { - if (!map) return + // Remove existing layer if exists + if (map.getLayer("district-extrusion")) { + map.removeLayer("district-extrusion") + extrusionCreatedRef.current = false + } - try { - const layers = map.getStyle().layers - let firstSymbolId: string | undefined - for (const layer of layers) { - if (layer.type === "symbol") { - firstSymbolId = layer.id - break - } + // Make sure the districts source exists + if (!map.getSource("districts")) { + if (!tilesetId) { + console.error("No tileset ID provided for districts source") + return } - - // 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", { type: "vector", url: `mapbox://${tilesetId}`, }) } - // Create the extrusion layer - map.addLayer( - { - id: "district-extrusion", - type: "fill-extrusion", - source: "districts", - "source-layer": "Districts", - paint: { - "fill-extrusion-color": [ - "case", - ["has", "kode_kec"], - [ - "match", - ["get", "kode_kec"], - focusedDistrictId || "", - getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level), - "transparent", - ], - "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": 0.8, - }, - filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""], - }, + // Find first symbol layer for correct layer order + const layers = map.getStyle().layers + let firstSymbolId: string | undefined + for (const layer of layers) { + if (layer.type === "symbol") { + firstSymbolId = layer.id + break + } + } + + + // Create the extrusion layer + map.addLayer( + { + id: "district-extrusion", + type: "fill-extrusion", + source: "districts", + "source-layer": "Districts", + paint: { + "fill-extrusion-color": [ + "case", + ["has", "kode_kec"], + [ + "match", + ["get", "kode_kec"], + focusedDistrictId || "", + getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level), + "transparent", + ], + "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, ) - - 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) - } + extrusionCreatedRef.current = true } - if (map.isStyleLoaded()) { - 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 + // Handle extrusion layer creation and updates useEffect(() => { - if (!map || !map.getLayer("district-extrusion")) 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 + if (!map || !visible) return + const onStyleLoad = () => { + if (!map) return try { - map.setPaintProperty("district-extrusion", "fill-extrusion-height", [ - "case", - ["has", "kode_kec"], - ["match", ["get", "kode_kec"], lastFocusedDistrictRef.current || "", height, 0], - 0, + createExtrusionLayer() + // Start animation if focusedDistrictId ada + if (focusedDistrictId) { + lastFocusedDistrictRef.current = focusedDistrictId + // 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) { - 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 = focusedDistrictId - 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 - map.setBearing(0) - } - } catch (error) { - console.error("Error animating extrusion down:", error) - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - animationRef.current = null - } - } + setTimeout(() => { + if (map.getLayer("district-extrusion")) { + animateExtrusion() + } + }, 50) + } catch (error) { + console.error("Error updating district extrusion:", error) } - - 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]) + }, [map, focusedDistrictId, crimeDataByDistrict]) // Cleanup on unmount useEffect(() => { @@ -259,95 +260,84 @@ export default function DistrictExtrusionLayer({ // Animate extrusion height const animateExtrusion = () => { - if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) { - console.log("Cannot animate extrusion: missing map, layer, or focusedDistrictId") - return - } + if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) { + return + } - console.log("Animating extrusion for district:", focusedDistrictId) + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - animationRef.current = null - } + const startHeight = 0 + const targetHeight = 800 + const duration = 700 + const startTime = performance.now() - const startHeight = 0 - const targetHeight = 800 - const duration = 700 - const startTime = performance.now() + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + const easedProgress = progress * (2 - progress) + const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime - const progress = Math.min(elapsed / duration, 1) - const easedProgress = progress * (2 - progress) // easeOutQuad - const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress + try { + map.setPaintProperty("district-extrusion", "fill-extrusion-height", [ + "case", + ["has", "kode_kec"], + ["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0], + 0, + ]) - try { - map.setPaintProperty("district-extrusion", "fill-extrusion-height", [ - "case", - ["has", "kode_kec"], - ["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0], - 0, - ]) - - if (progress < 1) { - 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 + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + startRotation() + } + } catch (error) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } } } - } - animationRef.current = requestAnimationFrame(animate) - } + animationRef.current = requestAnimationFrame(animate) + } // Start rotation animation const startRotation = () => { if (!map || !focusedDistrictId) return - const rotationSpeed = 0.05 // degrees per frame - bearingRef.current = 0 // Reset bearing at start + const rotationSpeed = 0.05 // degrees per frame + bearingRef.current = 0 - const animate = () => { - if (!map || !focusedDistrictId || focusedDistrictId !== lastFocusedDistrictRef.current) { - if (rotationAnimationRef.current) { - cancelAnimationFrame(rotationAnimationRef.current) - rotationAnimationRef.current = null - } - return - } + const animate = () => { + if (!map || !focusedDistrictId || focusedDistrictId !== lastFocusedDistrictRef.current) { + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + return + } - try { - // Update bearing with smooth increment - bearingRef.current = (bearingRef.current + rotationSpeed) % 360 - map.setBearing(bearingRef.current) - - // Continue the animation - rotationAnimationRef.current = requestAnimationFrame(animate) - } catch (error) { - console.error("Error during rotation animation:", error) - if (rotationAnimationRef.current) { - cancelAnimationFrame(rotationAnimationRef.current) - rotationAnimationRef.current = null + try { + bearingRef.current = (bearingRef.current + rotationSpeed) % 360 + map.setBearing(bearingRef.current) + rotationAnimationRef.current = requestAnimationFrame(animate) + } catch (error) { + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } } } - } - // Start the animation loop - if (rotationAnimationRef.current) { - cancelAnimationFrame(rotationAnimationRef.current) - rotationAnimationRef.current = null - } - rotationAnimationRef.current = requestAnimationFrame(animate) - } + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + rotationAnimationRef.current = requestAnimationFrame(animate) + } return null } diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index c108799..e404de5 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -1,7 +1,7 @@ "use client" 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 { useEffect } from "react" @@ -25,74 +25,118 @@ export default function DistrictFillLineLayer({ useEffect(() => { if (!map || !visible) return - const handleDistrictClick = (e: any) => { - // First check if the click was on a marker or cluster - const incidentFeatures = map.queryRenderedFeatures(e.point, { - layers: [ - "unclustered-point", - "clusters", - "crime-points", - "units-points", - "incidents-points", - "timeline-markers", - "recent-incidents", - ], - }) + const handleDistrictClick = (e: any) => { + // Only include layers that exist in the map style + const possibleLayers = [ + "unclustered-point", + "clusters", + "crime-points", + "units-points", + "incidents-points", + "timeline-markers", + "recent-incidents", + ] + const availableLayers = possibleLayers.filter(layer => map.getLayer(layer)) + const incidentFeatures = map.queryRenderedFeatures(e.point, { + layers: availableLayers, + }) - if (incidentFeatures && incidentFeatures.length > 0) { - // Click was on a marker or cluster, so don't process it as a district click - 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) + if (incidentFeatures && incidentFeatures.length > 0) { + // Click was on a marker or cluster, so don't process it as a district click + return } - // Reset pitch and bearing with animation - map.easeTo({ - zoom: BASE_ZOOM, - pitch: BASE_PITCH, - bearing: BASE_BEARING, - duration: 1500, - easing: (t) => t * (2 - t), // easeOutQuad - }) + if (!map || !e.features || e.features.length === 0) return - // Restore fill color for all districts when unfocusing - const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict) - map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any) + const feature = e.features[0] + const districtId = feature.properties.kode_kec - // 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") - } + // If clicking the same district, deselect it + if (focusedDistrictId === districtId) { + // Add null check for setFocusedDistrictId + if (setFocusedDistrictId) { + setFocusedDistrictId(null) + } - 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) - } + // Reset pitch and bearing with animation + map.easeTo({ + zoom: BASE_ZOOM, + pitch: BASE_PITCH, + bearing: BASE_BEARING, + duration: 1500, + easing: (t) => t * (2 - t), // easeOutQuad + }) - // 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 + // Restore fill color for all districts when unfocusing + const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict) + map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any) - 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({ center: [district.longitude, district.latitude], zoom: 12.5, @@ -108,193 +152,134 @@ export default function DistrictFillLineLayer({ } 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") - } + const onStyleLoad = () => { + if (!map) return - // Animate to a pitched view focused on the district - map.flyTo({ - center: [district.longitude, district.latitude], - zoom: 12.5, - pitch: 75, - bearing: 0, - duration: 1500, - easing: (t) => t * (2 - t), // easeOutQuad - }) + try { + if (!map.getSource("districts")) { + const layers = map.getStyle().layers + let firstSymbolId: string | undefined + for (const layer of layers) { + if (layer.type === "symbol") { + firstSymbolId = layer.id + break + } + } - // Use onDistrictClick if available, otherwise fall back to onClick - if (onDistrictClick) { - onDistrictClick(district) - } else if (onClick) { - onClick(district) - } - } + map.addSource("districts", { + type: "vector", + url: `mapbox://${tilesetId}`, + }) - const onStyleLoad = () => { - if (!map) return + const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict) - try { - if (!map.getSource("districts")) { - const layers = map.getStyle().layers - let firstSymbolId: string | undefined - for (const layer of layers) { - if (layer.type === "symbol") { - firstSymbolId = layer.id - break + // 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) } } - - map.addSource("districts", { - 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, - ) + } catch (error) { + console.error("Error adding district layers:", error) } - - 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()) { - onStyleLoad() - } else { - map.once("style.load", onStyleLoad) - } + if (map.isStyleLoaded()) { + onStyleLoad() + } else { + map.once("style.load", onStyleLoad) + } - return () => { - if (map) { - map.off("click", "district-fill", handleDistrictClick) - } - } - }, [ - map, - visible, - tilesetId, - crimes, - filterCategory, - year, - month, - focusedDistrictId, - crimeDataByDistrict, - onClick, - onDistrictClick, // Add to dependency array - setFocusedDistrictId, - showFill, - activeControl, - ]) + return () => { + if (map) { + map.off("click", "district-fill", handleDistrictClick) + } + } + }, [ + map, + visible, + tilesetId, + crimes, + filterCategory, + year, + month, + focusedDistrictId, + crimeDataByDistrict, + onClick, + onDistrictClick, // Add to dependency array + setFocusedDistrictId, + showFill, + activeControl, + ]) // Add an effect to update the fill color and opacity whenever relevant props change useEffect(() => { - if (!map || !map.getLayer("district-fill")) return + if (!map || !map.getLayer("district-fill")) return - try { - const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict) - map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any) + try { + 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 updating district fill colors or opacity:", error) - } - }, [map, focusedDistrictId, crimeDataByDistrict, activeControl, showFill]) + // Update fill opacity when active control changes + const fillOpacity = getFillOpacity(activeControl, showFill) + map.setPaintProperty("district-fill", "fill-opacity", fillOpacity) + } catch (error) { + console.error("Error updating district fill colors or opacity:", error) + } + }, [map, focusedDistrictId, crimeDataByDistrict, activeControl, showFill]) 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 -} diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index 0d5a05c..82f13a2 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -15,7 +15,7 @@ import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_ut import UnclusteredPointLayer from "./uncluster-layer" 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 UnitsLayer from "./units-layer" import DistrictFillLineLayer from "./district-layer" @@ -59,7 +59,7 @@ export interface IDistrictLayerProps { setFocusedDistrictId?: (id: string | null) => void crimeDataByDistrict?: Record showFill?: boolean - activeControl?: ITooltips + activeControl?: ITooltipsControl } interface LayersProps { @@ -70,7 +70,7 @@ interface LayersProps { year: string month: string filterCategory: string | "all" - activeControl: ITooltips + activeControl: ITooltipsControl tilesetId?: string useAllData?: boolean showEWS?: boolean @@ -132,7 +132,7 @@ export default function Layers({ if (incident.status === "active") { resolveIncident(incident.id) } - }) + }) setEwsIncidents(getAllIncidents()) }, [ewsIncidents]) diff --git a/sigap-website/app/_styles/ui.css b/sigap-website/app/_styles/ui.css index 491d06c..000db04 100644 --- a/sigap-website/app/_styles/ui.css +++ b/sigap-website/app/_styles/ui.css @@ -984,7 +984,7 @@ label#internal { margin: auto; } -.circles div { +/* .circles div { animation: growAndFade 3s infinite ease-out; background-color: rgb(156, 94, 0); border-radius: 50%; @@ -993,7 +993,7 @@ label#internal { opacity: 0; position: absolute; box-shadow: 0 0 10px 5px rgba(156, 75, 0, 0.5); -} +} */ @keyframes growAndFade { 0% { diff --git a/sigap-website/app/_utils/map.ts b/sigap-website/app/_utils/map.ts index 0601df5..e19197d 100644 --- a/sigap-website/app/_utils/map.ts +++ b/sigap-website/app/_utils/map.ts @@ -2,6 +2,7 @@ import { $Enums } from '@prisma/client'; import { CRIME_RATE_COLORS } from '@/app/_utils/const/map'; import type { ICrimes } from '@/app/_utils/types/crimes'; import { IDistrictFeature } from './types/map'; +import { ITooltipsControl } from '../_components/map/controls/top/tooltips'; // Process crime data by district export const processCrimeDataByDistrict = (crimes: ICrimes[]) => { @@ -262,4 +263,22 @@ export function formatDistance(meters: number): string { } else { return `${(meters / 1000).toFixed(1)} km`; } -} \ No newline at end of file +} + +// 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 +} diff --git a/sigap-website/app/_utils/types/map.ts b/sigap-website/app/_utils/types/map.ts index 0f42b82..44e080c 100644 --- a/sigap-website/app/_utils/types/map.ts +++ b/sigap-website/app/_utils/types/map.ts @@ -19,6 +19,7 @@ export interface IGeoJSONFeatureCollection { import { $Enums } from '@prisma/client'; import type { ICrimes } from '@/app/_utils/types/crimes'; import mapboxgl from 'mapbox-gl'; +import { ITooltipsControl } from "@/app/_components/map/controls/top/tooltips"; // Types for district properties export interface IDistrictFeature { @@ -75,7 +76,7 @@ export interface IDistrictLayerProps { filterCategory: string | 'all'; crimes: ICrimes[]; tilesetId?: string; - activeControl?: string; + activeControl: ITooltipsControl; focusedDistrictId: string | null; setFocusedDistrictId?: (id: string | null) => void; crimeDataByDistrict?: any;