1- import { useState } from 'react' ;
1+ import { useState , useEffect , useRef } from 'react' ;
22import {
33 Eye , EyeOff , GripVertical , Trash2 ,
4- ChevronDown , ChevronRight ,
4+ ChevronDown , ChevronRight , ZoomIn , Copy ,
55} from 'lucide-react' ;
6+ import { transformExtent } from 'ol/proj' ;
7+ import VectorSource from 'ol/source/Vector' ;
8+ import OlVectorLayer from 'ol/layer/Vector' ;
69import { useProjectStore } from '@/stores/projectStore' ;
10+ import { mapRegistry } from '@/services/map/mapRegistry' ;
711import type { Layer } from '@/types/layers' ;
812
13+ interface ContextMenuState {
14+ x : number ;
15+ y : number ;
16+ layerId : string ;
17+ }
18+
919export function LayerPanel ( ) {
1020 const layers = useProjectStore ( ( s ) => s . project ?. layers ?? [ ] ) ;
1121 const toggleVisibility = useProjectStore ( ( s ) => s . toggleLayerVisibility ) ;
@@ -14,21 +24,85 @@ export function LayerPanel() {
1424 const reorderLayers = useProjectStore ( ( s ) => s . reorderLayers ) ;
1525 const [ expandedId , setExpandedId ] = useState < string | null > ( null ) ;
1626 const [ dragIndex , setDragIndex ] = useState < number | null > ( null ) ;
27+ const [ contextMenu , setContextMenu ] = useState < ContextMenuState | null > ( null ) ;
28+ const menuRef = useRef < HTMLDivElement > ( null ) ;
1729
1830 const sortedLayers = [ ...layers ] . sort ( ( a , b ) => b . zIndex - a . zIndex ) ;
1931
2032 const handleDragStart = ( idx : number ) => setDragIndex ( idx ) ;
21-
2233 const handleDragOver = ( e : React . DragEvent , idx : number ) => {
2334 e . preventDefault ( ) ;
2435 if ( dragIndex !== null && dragIndex !== idx ) {
2536 reorderLayers ( dragIndex , idx ) ;
2637 setDragIndex ( idx ) ;
2738 }
2839 } ;
29-
3040 const handleDragEnd = ( ) => setDragIndex ( null ) ;
3141
42+ const handleContextMenu = ( e : React . MouseEvent , _layer : Layer ) => {
43+ e . preventDefault ( ) ;
44+ setContextMenu ( { x : e . clientX , y : e . clientY , layerId : _layer . id } ) ;
45+ } ;
46+
47+ const closeMenu = ( ) => setContextMenu ( null ) ;
48+
49+ // Close context menu on outside click
50+ useEffect ( ( ) => {
51+ if ( ! contextMenu ) return ;
52+ const handler = ( e : MouseEvent ) => {
53+ if ( menuRef . current && ! menuRef . current . contains ( e . target as Node ) ) closeMenu ( ) ;
54+ } ;
55+ document . addEventListener ( 'mousedown' , handler ) ;
56+ return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
57+ } , [ contextMenu ] ) ;
58+
59+ const handleZoomToExtent = ( layerId : string ) => {
60+ const map = mapRegistry . get ( ) ;
61+ if ( ! map ) { closeMenu ( ) ; return ; }
62+
63+ const worldExtent : [ number , number , number , number ] = [ - 20037508.34 , - 20037508.34 , 20037508.34 , 20037508.34 ] ;
64+
65+ // Try to find the OL layer and fit to its source extent
66+ const olLayer = map . getLayers ( ) . getArray ( ) . find ( ( l ) => l . get ( 'projectLayerId' ) === layerId ) ;
67+ if ( olLayer instanceof OlVectorLayer ) {
68+ const source = olLayer . getSource ( ) as VectorSource ;
69+ if ( source ) {
70+ // Wait for features to be loaded if needed
71+ const tryFit = ( ) => {
72+ const ext = source . getExtent ( ) ;
73+ if ( ext && ext [ 0 ] !== Infinity ) {
74+ map . getView ( ) . fit ( ext , { duration : 600 , padding : [ 40 , 40 , 40 , 40 ] } ) ;
75+ } else {
76+ map . getView ( ) . fit ( worldExtent , { duration : 600 } ) ;
77+ }
78+ } ;
79+ if ( source . getState ( ) === 'ready' ) {
80+ tryFit ( ) ;
81+ } else {
82+ source . once ( 'change' , tryFit ) ;
83+ }
84+ closeMenu ( ) ;
85+ return ;
86+ }
87+ }
88+
89+ // For tile layers or fallback: check if project layer has an extent
90+ const projectLayer = layers . find ( ( l ) => l . id === layerId ) ;
91+ if ( projectLayer ?. extent ) {
92+ const ext = transformExtent ( projectLayer . extent , 'EPSG:4326' , 'EPSG:3857' ) ;
93+ map . getView ( ) . fit ( ext , { duration : 600 , padding : [ 40 , 40 , 40 , 40 ] } ) ;
94+ } else {
95+ map . getView ( ) . fit ( worldExtent , { duration : 600 } ) ;
96+ }
97+ closeMenu ( ) ;
98+ } ;
99+
100+ const handleCopyUrl = ( layerId : string ) => {
101+ const layer = layers . find ( ( l ) => l . id === layerId ) ;
102+ if ( layer ?. source . uri ) navigator . clipboard . writeText ( layer . source . uri ) . catch ( ( ) => { } ) ;
103+ closeMenu ( ) ;
104+ } ;
105+
32106 if ( layers . length === 0 ) {
33107 return (
34108 < div className = "p-4 text-center" >
@@ -39,23 +113,58 @@ export function LayerPanel() {
39113 }
40114
41115 return (
42- < div className = "flex flex-col" >
43- { sortedLayers . map ( ( layer , idx ) => (
44- < LayerItem
45- key = { layer . id }
46- layer = { layer }
47- isExpanded = { expandedId === layer . id }
48- onToggleExpand = { ( ) => setExpandedId ( expandedId === layer . id ? null : layer . id ) }
49- onToggleVisibility = { ( ) => toggleVisibility ( layer . id ) }
50- onOpacityChange = { ( opacity ) => setOpacity ( layer . id , opacity ) }
51- onRemove = { ( ) => removeLayer ( layer . id ) }
52- onDragStart = { ( ) => handleDragStart ( idx ) }
53- onDragOver = { ( e ) => handleDragOver ( e , idx ) }
54- onDragEnd = { handleDragEnd }
55- isDragging = { dragIndex === idx }
56- />
57- ) ) }
58- </ div >
116+ < >
117+ < div className = "flex flex-col" >
118+ { sortedLayers . map ( ( layer , idx ) => (
119+ < LayerItem
120+ key = { layer . id }
121+ layer = { layer }
122+ isExpanded = { expandedId === layer . id }
123+ onToggleExpand = { ( ) => setExpandedId ( expandedId === layer . id ? null : layer . id ) }
124+ onToggleVisibility = { ( ) => toggleVisibility ( layer . id ) }
125+ onOpacityChange = { ( opacity ) => setOpacity ( layer . id , opacity ) }
126+ onRemove = { ( ) => removeLayer ( layer . id ) }
127+ onDragStart = { ( ) => handleDragStart ( idx ) }
128+ onDragOver = { ( e ) => handleDragOver ( e , idx ) }
129+ onDragEnd = { handleDragEnd }
130+ isDragging = { dragIndex === idx }
131+ onContextMenu = { ( e ) => handleContextMenu ( e , layer ) }
132+ />
133+ ) ) }
134+ </ div >
135+
136+ { /* Context menu */ }
137+ { contextMenu && (
138+ < div
139+ ref = { menuRef }
140+ className = "fixed z-50 min-w-[160px] bg-gis-surface border border-gis-border rounded-lg shadow-xl py-1 text-sm"
141+ style = { { left : contextMenu . x , top : contextMenu . y } }
142+ >
143+ < button
144+ onClick = { ( ) => handleZoomToExtent ( contextMenu . layerId ) }
145+ className = "w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-gis-deep-blue/50 text-white/80 hover:text-white transition-colors text-left"
146+ >
147+ < ZoomIn size = { 13 } className = "text-gis-teal shrink-0" />
148+ Zoom to Extent
149+ </ button >
150+ < button
151+ onClick = { ( ) => handleCopyUrl ( contextMenu . layerId ) }
152+ className = "w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-gis-deep-blue/50 text-white/80 hover:text-white transition-colors text-left"
153+ >
154+ < Copy size = { 13 } className = "text-white/40 shrink-0" />
155+ Copy Source URL
156+ </ button >
157+ < div className = "border-t border-gis-border my-1" />
158+ < button
159+ onClick = { ( ) => { removeLayer ( contextMenu . layerId ) ; closeMenu ( ) ; } }
160+ className = "w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-red-500/10 text-red-400/80 hover:text-red-400 transition-colors text-left"
161+ >
162+ < Trash2 size = { 13 } className = "shrink-0" />
163+ Remove Layer
164+ </ button >
165+ </ div >
166+ ) }
167+ </ >
59168 ) ;
60169}
61170
@@ -70,6 +179,7 @@ function LayerItem({
70179 onDragOver,
71180 onDragEnd,
72181 isDragging,
182+ onContextMenu,
73183} : {
74184 layer : Layer ;
75185 isExpanded : boolean ;
@@ -81,6 +191,7 @@ function LayerItem({
81191 onDragOver : ( e : React . DragEvent ) => void ;
82192 onDragEnd : ( ) => void ;
83193 isDragging : boolean ;
194+ onContextMenu : ( e : React . MouseEvent ) => void ;
84195} ) {
85196 const typeColors : Record < string , string > = {
86197 raster : 'bg-amber-500/20 text-amber-400' ,
@@ -96,8 +207,9 @@ function LayerItem({
96207 onDragStart = { onDragStart }
97208 onDragOver = { onDragOver }
98209 onDragEnd = { onDragEnd }
210+ onContextMenu = { onContextMenu }
99211 >
100- < div className = "flex items-center gap-1 px-2 py-1.5 hover:bg-gis-deep-blue/20 transition-colors" >
212+ < div className = "group flex items-center gap-1 px-2 py-1.5 hover:bg-gis-deep-blue/20 transition-colors" >
101213 < div className = "cursor-grab text-white/20 hover:text-white/40" >
102214 < GripVertical size = { 12 } />
103215 </ div >
0 commit comments