Skip to content

Commit bed5a16

Browse files
author
GeoCodeCrafter
committed
fix: tile rendering, right-click zoom-to-extent, status bar layout, CSP broadened
1 parent c2a9234 commit bed5a16

5 files changed

Lines changed: 235 additions & 50 deletions

File tree

‎index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.basemaps.cartocdn.com; connect-src 'self' https://*.tile.openstreetmap.org https://*.basemaps.cartocdn.com https://epsg.io" />
6+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https:; font-src https: data:; img-src 'self' data: blob: https:; connect-src 'self' https:;" />
77
<link rel="preconnect" href="https://fonts.googleapis.com" />
88
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
99
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />

‎src/components/map/LayerPanel.tsx‎

Lines changed: 134 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
import { useState } from 'react';
1+
import { useState, useEffect, useRef } from 'react';
22
import {
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';
69
import { useProjectStore } from '@/stores/projectStore';
10+
import { mapRegistry } from '@/services/map/mapRegistry';
711
import type { Layer } from '@/types/layers';
812

13+
interface ContextMenuState {
14+
x: number;
15+
y: number;
16+
layerId: string;
17+
}
18+
919
export 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>

‎src/components/map/MapViewer.tsx‎

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@ import { useRef, useEffect, useCallback } from 'react';
22
import OlMap from 'ol/Map';
33
import View from 'ol/View';
44
import TileLayer from 'ol/layer/Tile';
5+
import OlVectorLayer from 'ol/layer/Vector';
56
import XYZ from 'ol/source/XYZ';
6-
import { defaults as defaultControls, ScaleLine, ZoomSlider } from 'ol/control';
7+
import VectorSource from 'ol/source/Vector';
8+
import GeoJSON from 'ol/format/GeoJSON';
9+
import Style from 'ol/style/Style';
10+
import Fill from 'ol/style/Fill';
11+
import Stroke from 'ol/style/Stroke';
12+
import CircleStyle from 'ol/style/Circle';
13+
import type BaseLayer from 'ol/layer/Base';
14+
import { defaults as defaultControls, ScaleLine } from 'ol/control';
715
import { defaults as defaultInteractions } from 'ol/interaction';
816
import { fromLonLat, toLonLat } from 'ol/proj';
917
import { useProjectStore } from '@/stores/projectStore';
1018
import { useAppStore } from '@/stores/appStore';
19+
import { mapRegistry } from '@/services/map/mapRegistry';
20+
import type { Layer } from '@/types/layers';
1121
import 'ol/ol.css';
1222

1323
interface MapViewerProps {
@@ -21,25 +31,22 @@ export function MapViewer({ className = '', onMapReady, onCoordinateChange }: Ma
2131
const mapInstance = useRef<OlMap | null>(null);
2232
const setViewState = useProjectStore((s) => s.setViewState);
2333
const viewState = useProjectStore((s) => s.project?.viewState);
34+
const layers = useProjectStore((s) => s.project?.layers ?? []);
2435
const showScaleBar = useAppStore((s) => s.showScaleBar);
2536

2637
const initMap = useCallback(() => {
2738
if (!mapRef.current || mapInstance.current) return;
2839

2940
const controls = defaultControls({ zoom: true, attribution: true, rotate: false }).extend([
30-
new ScaleLine({
31-
units: 'metric',
32-
bar: false,
33-
minWidth: 100,
34-
}),
35-
new ZoomSlider(),
41+
new ScaleLine({ units: 'metric', bar: false, minWidth: 100 }),
3642
]);
3743

3844
const basemap = new TileLayer({
3945
source: new XYZ({
4046
url: 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
4147
attributions: '© CARTO, © OpenStreetMap contributors',
4248
maxZoom: 20,
49+
crossOrigin: 'anonymous',
4350
}),
4451
properties: { name: 'basemap', isBasemap: true },
4552
});
@@ -61,27 +68,22 @@ export function MapViewer({ className = '', onMapReady, onCoordinateChange }: Ma
6168
}),
6269
});
6370

64-
// Track view changes
6571
map.getView().on('change', () => {
6672
const view = map.getView();
6773
const center = toLonLat(view.getCenter() ?? [0, 0]);
6874
const zoom = view.getZoom() ?? 2;
69-
setViewState({
70-
center: center as [number, number],
71-
zoom,
72-
rotation: view.getRotation(),
73-
});
75+
setViewState({ center: center as [number, number], zoom, rotation: view.getRotation() });
7476
onCoordinateChange?.(center as [number, number], zoom);
7577
});
7678

77-
// Track pointer position
7879
map.on('pointermove', (evt) => {
7980
if (evt.dragging) return;
8081
const coord = toLonLat(evt.coordinate);
8182
onCoordinateChange?.(coord as [number, number], map.getView().getZoom() ?? 2);
8283
});
8384

8485
mapInstance.current = map;
86+
mapRegistry.set(map);
8587
onMapReady?.(map);
8688
}, []); // eslint-disable-line react-hooks/exhaustive-deps
8789

@@ -90,21 +92,82 @@ export function MapViewer({ className = '', onMapReady, onCoordinateChange }: Ma
9092
return () => {
9193
mapInstance.current?.setTarget(undefined);
9294
mapInstance.current = null;
95+
mapRegistry.set(null);
9396
};
9497
}, [initMap]);
9598

96-
// Toggle scale bar visibility
99+
// Sync project layers → OL layers
97100
useEffect(() => {
98-
if (!mapInstance.current) return;
99-
const controls = mapInstance.current.getControls();
100-
controls.forEach((control) => {
101-
if (control instanceof ScaleLine) {
102-
control.setMap(showScaleBar ? mapInstance.current : null);
101+
const map = mapInstance.current;
102+
if (!map) return;
103+
104+
const allOlLayers = map.getLayers().getArray();
105+
const projectOlLayers = allOlLayers.filter((l) => l.get('projectLayerId'));
106+
const desiredIds = new Set(layers.map((l) => l.id));
107+
108+
// Remove deleted layers
109+
projectOlLayers.forEach((l) => {
110+
if (!desiredIds.has(l.get('projectLayerId') as string)) map.removeLayer(l);
111+
});
112+
113+
// Add new / update existing
114+
layers.forEach((layer) => {
115+
const existing = allOlLayers.find((l) => l.get('projectLayerId') === layer.id);
116+
if (existing) {
117+
existing.setVisible(layer.visible);
118+
existing.setOpacity(layer.opacity);
119+
existing.setZIndex(layer.zIndex + 1);
120+
} else {
121+
const olLayer = buildOlLayer(layer);
122+
if (olLayer) map.addLayer(olLayer);
103123
}
104124
});
125+
}, [layers]);
126+
127+
// Toggle scale bar
128+
useEffect(() => {
129+
if (!mapInstance.current) return;
130+
mapInstance.current.getControls().forEach((ctrl) => {
131+
if (ctrl instanceof ScaleLine) ctrl.setMap(showScaleBar ? mapInstance.current : null);
132+
});
105133
}, [showScaleBar]);
106134

107-
return (
108-
<div ref={mapRef} className={`w-full h-full ${className}`} />
109-
);
135+
return <div ref={mapRef} className={`w-full h-full ${className}`} />;
136+
}
137+
138+
const defaultVectorStyle = new Style({
139+
fill: new Fill({ color: 'rgba(32, 201, 151, 0.15)' }),
140+
stroke: new Stroke({ color: '#20c997', width: 1.5 }),
141+
image: new CircleStyle({
142+
radius: 5,
143+
fill: new Fill({ color: '#20c997' }),
144+
stroke: new Stroke({ color: '#fff', width: 1 }),
145+
}),
146+
});
147+
148+
function buildOlLayer(layer: Layer): BaseLayer | null {
149+
if (layer.type === 'tile') {
150+
const tl = new TileLayer({
151+
source: new XYZ({ url: layer.source.uri, crossOrigin: 'anonymous', maxZoom: 22 }),
152+
visible: layer.visible,
153+
opacity: layer.opacity,
154+
zIndex: layer.zIndex + 1,
155+
});
156+
tl.set('projectLayerId', layer.id);
157+
return tl;
158+
}
159+
160+
if (layer.type === 'vector') {
161+
const vl = new OlVectorLayer({
162+
source: new VectorSource({ url: layer.source.uri, format: new GeoJSON() }),
163+
style: defaultVectorStyle,
164+
visible: layer.visible,
165+
opacity: layer.opacity,
166+
zIndex: layer.zIndex + 1,
167+
});
168+
vl.set('projectLayerId', layer.id);
169+
return vl;
170+
}
171+
172+
return null;
110173
}

0 commit comments

Comments
 (0)