diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50d3474..b272612 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: codespell args: [ - "--ignore-words-list=aci,acount,acounts,fallow,ges,hart,hist,nd,ned,ois,wqs,watermask,tre,mape,lod", + "--ignore-words-list=mappin", "--skip=*.csv,*.geojson,*.json,*.yml,*.map,*.mjs,*.cjs, *.js,*.min.js,*.bundle.js,*.html,*cff,*.pdf", ] diff --git a/CLAUDE.md b/CLAUDE.md index a4a4868..ffce59a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,17 +28,18 @@ anymap-studio/ │ ├── components/ │ │ ├── layout/ # AppShell, Toolbar, Sidebar, StatusBar │ │ ├── map/ # MapCanvas, MapControls -│ │ ├── layers/ # LayerPanel, LayerItem +│ │ ├── layers/ # LayerPanel, LayerItem, AttributeTable, StyleEditor │ │ ├── landing/ # LandingPage, BackendSelector │ │ ├── tools/ # MeasureTool, DrawTool -│ │ └── common/ # BasemapSelector, shared components +│ │ └── common/ # BasemapSelector, CommandPalette, GoToCoordinates, ExportDialog, SettingsPanel │ ├── backends/ │ │ ├── types.ts # IMapBackend interface │ │ ├── capabilities.ts # Backend capability matrix │ │ ├── index.ts # Backend factory with lazy loading │ │ └── adapters/ # MapLibreAdapter, CesiumAdapter, etc. │ ├── stores/ # Zustand stores (ui, map, project) -│ └── types/ # TypeScript definitions +│ ├── types/ # TypeScript definitions +│ └── utils/ # Parsing, geo, and export utilities └── build/ # App icons ``` @@ -77,6 +78,9 @@ npm run dist # Build distributable packages - `src/backends/adapters/MapLibreAdapter.ts` - Primary map backend - `src/stores/projectStore.ts` - Project state (layers, view, save/load) - `src/components/layers/LayerPanel.tsx` - Layer management + zoom-to-data +- `src/utils/parsers.ts` - KML, CSV file parsing +- `src/utils/geo.ts` - Geospatial utilities (bounds, stats, formatting) +- `src/utils/export.ts` - Export utilities (PNG, GeoJSON, CSV) - `src/index.css` - MapLibre control styling for dark theme ## Current Status @@ -96,35 +100,55 @@ npm run dist # Build distributable packages - Distance measurement tool (Haversine formula) - Area measurement tool (spherical excess) - Drawing tools (point, line, polygon) +- KML/KMZ file loading +- CSV with coordinates loading (auto-detect lat/lng columns) +- WMS/WMTS service connections +- Drag-and-drop file loading onto map +- Layer reordering via drag-and-drop +- Layer renaming (double-click or context menu) +- Layer duplication +- Layer context menu (right-click) with full options +- Zoom to layer extent +- Style editor (fill color, stroke, point radius, opacity) +- Attribute table (view, sort, filter, statistics, export) +- Feature identify tool (click to see attributes popup) +- Export map to PNG +- Export layer to GeoJSON and CSV +- Go to coordinates dialog (Ctrl+G) +- Command palette (Ctrl+Shift+P) +- Keyboard shortcuts (I for identify, M for measure, Escape to cancel, etc.) +- Settings dialog with coordinate format, sidebar width, etc. +- Status bar with scale, CRS, coordinate format toggle +- Fit to all layers bounds ## Remaining Tasks ### High Priority - Core GIS Features #### Data Loading & Formats -- [ ] GeoTIFF/COG visualization with proper tile rendering +- [x] GeoTIFF/COG visualization with proper tile rendering - [ ] GeoPackage (.gpkg) loading support -- [ ] KML/KMZ file loading -- [ ] CSV with coordinates (lat/lng columns) -- [ ] WMS/WMTS service connections +- [x] KML/KMZ file loading +- [x] CSV with coordinates (lat/lng columns) +- [x] WMS/WMTS service connections - [ ] WFS service connections - [ ] PostGIS database connections -- [ ] Drag-and-drop file loading onto map +- [x] Drag-and-drop file loading onto map #### Layer Management -- [ ] Layer reordering via drag-and-drop +- [x] Layer reordering via drag-and-drop - [ ] Layer groups/folders -- [ ] Layer renaming -- [ ] Duplicate layer -- [ ] Layer context menu (right-click) +- [x] Layer renaming +- [x] Duplicate layer +- [x] Layer context menu (right-click) - [ ] Layer metadata/properties panel -- [ ] Layer extent info display +- [x] Layer extent info display #### Styling & Symbology -- [ ] Style editor panel for vector layers -- [ ] Fill color picker with opacity -- [ ] Stroke color/width/style editor -- [ ] Point symbol selector (circle, square, icon) +- [x] Style editor panel for vector layers +- [x] Fill color picker with opacity +- [x] Stroke color/width/style editor +- [x] Point symbol selector (circle, square, icon) - [ ] Categorized styling (by attribute) - [ ] Graduated styling (numeric ranges) - [ ] Rule-based styling @@ -132,24 +156,24 @@ npm run dist # Build distributable packages - [ ] Save/load layer styles (.json) #### Attribute Table -- [ ] View attributes in data grid -- [ ] Sort by column -- [ ] Filter/search attributes +- [x] View attributes in data grid +- [x] Sort by column +- [x] Filter/search attributes - [ ] Select features from table (sync with map) - [ ] Edit attribute values - [ ] Add/remove fields - [ ] Field calculator (expressions) -- [ ] Export selected features -- [ ] Statistics panel (for numeric fields) +- [x] Export selected features +- [x] Statistics panel (for numeric fields) ### Medium Priority - Advanced Features #### Selection & Querying -- [ ] Click to select feature +- [x] Click to select feature - [ ] Box select multiple features - [ ] Polygon select - [ ] Select by attribute (SQL-like query) -- [ ] Identify tool (click to see attributes) +- [x] Identify tool (click to see attributes) - [ ] Feature info popup on hover #### Editing & Sketching @@ -172,11 +196,11 @@ npm run dist # Build distributable packages - [ ] Spatial join #### Export & Print -- [ ] Export map to PNG +- [x] Export map to PNG - [ ] Export map to PDF - [ ] Print layout composer - [ ] Add title, legend, scale bar, north arrow -- [ ] Export layer to GeoJSON +- [x] Export layer to GeoJSON - [ ] Export layer to Shapefile - [ ] Export layer to GeoPackage @@ -211,8 +235,8 @@ npm run dist # Build distributable packages ### UI/UX Improvements #### General UI -- [ ] Command palette (Ctrl+Shift+P) -- [ ] Keyboard shortcuts panel +- [x] Command palette (Ctrl+Shift+P) +- [x] Keyboard shortcuts panel - [ ] Customizable toolbar - [ ] Resizable sidebar panels - [ ] Floating/dockable panels @@ -222,16 +246,16 @@ npm run dist # Build distributable packages #### Navigation & View - [ ] Bookmarks/saved views -- [ ] Go to coordinates dialog +- [x] Go to coordinates dialog - [ ] Coordinate search (geocoding) - [ ] History (back/forward navigation) - [ ] Rotation controls - [ ] 3D tilt controls (for Cesium) #### Status Bar -- [ ] Mouse coordinates (multiple formats) -- [ ] Current scale -- [ ] Current CRS/projection +- [x] Mouse coordinates (multiple formats) +- [x] Current scale +- [x] Current CRS/projection - [ ] Memory/performance indicator - [ ] Background task progress @@ -245,9 +269,9 @@ npm run dist # Build distributable packages - [ ] Import QGIS project (.qgz) #### Settings & Preferences -- [ ] Settings dialog +- [x] Settings dialog - [ ] Default CRS selection -- [ ] Unit preferences (metric/imperial) +- [x] Unit preferences (metric/imperial) - [ ] Theme selection (dark/light) - [ ] Proxy configuration - [ ] Cache management diff --git a/electron/main.ts b/electron/main.ts index 70921c9..d16a2cf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -386,6 +386,28 @@ function setupIpcHandlers(): void { return 'cancel' }) + // Export save dialog + ipcMain.handle('file:export-save-dialog', async (_event, defaultPath?: string, filters?: { name: string, extensions: string[] }[]) => { + if (!mainWindow) return { canceled: true } + + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Export', + defaultPath: defaultPath || 'export', + filters: filters || [ + { name: 'PNG Image', extensions: ['png'] }, + { name: 'GeoJSON', extensions: ['geojson'] }, + { name: 'CSV', extensions: ['csv'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + + if (result.canceled || !result.filePath) { + return { canceled: true } + } + + return { canceled: false, filePath: result.filePath } + }) + // Confirm dialog ipcMain.handle('dialog:confirm', async (_event, message: string, title?: string) => { if (!mainWindow) return false diff --git a/src/components/common/BasemapSelector.tsx b/src/components/common/BasemapSelector.tsx index e2751f4..5a275a1 100644 --- a/src/components/common/BasemapSelector.tsx +++ b/src/components/common/BasemapSelector.tsx @@ -27,9 +27,19 @@ const basemaps: BasemapOption[] = [ url: 'https://tiles.openfreemap.org/styles/positron' }, { - id: 'demotiles', - name: 'MapLibre Demo', - url: 'https://demotiles.maplibre.org/style.json' + id: 'carto-dark-matter', + name: 'CartoDB Dark Matter', + url: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json' + }, + { + id: 'carto-positron', + name: 'CartoDB Positron', + url: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' + }, + { + id: 'carto-voyager', + name: 'CartoDB Voyager', + url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json' } ] diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx new file mode 100644 index 0000000..df97c22 --- /dev/null +++ b/src/components/common/CommandPalette.tsx @@ -0,0 +1,354 @@ +import { useState, useEffect, useMemo, useRef } from 'react' +import { + Search, MapPin, Save, Home, ZoomIn, ZoomOut, + Ruler, Square, Circle, Minus, Pentagon, Layers, Settings, + Download, Table2, Palette, Maximize2, Eye, EyeOff, Navigation +} from 'lucide-react' +import { useUIStore } from '../../stores/uiStore' +import { useProjectStore } from '../../stores/projectStore' +import { useMapStore } from '../../stores/mapStore' + +interface Command { + id: string + label: string + shortcut?: string + icon: React.ElementType + category: string + action: () => void +} + +export function CommandPalette() { + const { showCommandPalette, setShowCommandPalette, setActiveTool, clearMeasurement, setShowGoToCoordinates, setShowExportDialog, setShowSettings, activeTool } = useUIStore() + const { layers, selectedLayerId } = useProjectStore() + const { backend, zoom } = useMapStore() + const [query, setQuery] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + const commands: Command[] = useMemo(() => { + const cmds: Command[] = [ + // Navigation + { + id: 'goto-coords', + label: 'Go to Coordinates', + shortcut: 'Ctrl+G', + icon: MapPin, + category: 'Navigation', + action: () => { setShowGoToCoordinates(true); setShowCommandPalette(false) } + }, + { + id: 'zoom-in', + label: 'Zoom In', + shortcut: 'Ctrl+=', + icon: ZoomIn, + category: 'Navigation', + action: () => { + if (backend) { + const view = backend.getView() + backend.setView({ ...view, zoom: view.zoom + 1 }) + } + setShowCommandPalette(false) + } + }, + { + id: 'zoom-out', + label: 'Zoom Out', + shortcut: 'Ctrl+-', + icon: ZoomOut, + category: 'Navigation', + action: () => { + if (backend) { + const view = backend.getView() + backend.setView({ ...view, zoom: Math.max(0, view.zoom - 1) }) + } + setShowCommandPalette(false) + } + }, + { + id: 'fit-bounds', + label: 'Fit to All Layers', + shortcut: 'Ctrl+0', + icon: Maximize2, + category: 'Navigation', + action: () => { + // Fit to all layer bounds + setShowCommandPalette(false) + } + }, + { + id: 'reset-north', + label: 'Reset North', + icon: Navigation, + category: 'Navigation', + action: () => { + if (backend) { + const view = backend.getView() + backend.setView({ ...view, bearing: 0, pitch: 0 }) + } + setShowCommandPalette(false) + } + }, + + // Tools + { + id: 'measure-distance', + label: activeTool === 'measure-distance' ? 'Stop Measuring Distance' : 'Measure Distance', + shortcut: 'M', + icon: Ruler, + category: 'Tools', + action: () => { + if (activeTool === 'measure-distance') clearMeasurement() + else setActiveTool('measure-distance') + setShowCommandPalette(false) + } + }, + { + id: 'measure-area', + label: activeTool === 'measure-area' ? 'Stop Measuring Area' : 'Measure Area', + icon: Square, + category: 'Tools', + action: () => { + if (activeTool === 'measure-area') clearMeasurement() + else setActiveTool('measure-area') + setShowCommandPalette(false) + } + }, + { + id: 'identify', + label: 'Identify Features', + shortcut: 'I', + icon: Eye, + category: 'Tools', + action: () => { + setActiveTool(activeTool === 'identify' ? 'none' : 'identify') + setShowCommandPalette(false) + } + }, + { + id: 'draw-point', + label: 'Draw Point', + icon: Circle, + category: 'Tools', + action: () => { setActiveTool('draw-point'); setShowCommandPalette(false) } + }, + { + id: 'draw-line', + label: 'Draw Line', + icon: Minus, + category: 'Tools', + action: () => { setActiveTool('draw-line'); setShowCommandPalette(false) } + }, + { + id: 'draw-polygon', + label: 'Draw Polygon', + icon: Pentagon, + category: 'Tools', + action: () => { setActiveTool('draw-polygon'); setShowCommandPalette(false) } + }, + + // Project + { + id: 'save', + label: 'Save Project', + shortcut: 'Ctrl+S', + icon: Save, + category: 'Project', + action: () => { setShowCommandPalette(false) } + }, + { + id: 'go-home', + label: 'Go to Start Page', + icon: Home, + category: 'Project', + action: () => { setShowCommandPalette(false) } + }, + { + id: 'export', + label: 'Export Map / Data', + shortcut: 'Ctrl+E', + icon: Download, + category: 'Export', + action: () => { setShowExportDialog(true); setShowCommandPalette(false) } + }, + + // View + { + id: 'toggle-sidebar', + label: 'Toggle Sidebar', + shortcut: 'Ctrl+B', + icon: Layers, + category: 'View', + action: () => { useUIStore.getState().toggleSidebar(); setShowCommandPalette(false) } + }, + { + id: 'settings', + label: 'Open Settings', + shortcut: 'Ctrl+,', + icon: Settings, + category: 'View', + action: () => { setShowSettings(true); setShowCommandPalette(false) } + } + ] + + // Add layer-specific commands + if (selectedLayerId) { + const selectedLayer = layers.find(l => l.id === selectedLayerId) + if (selectedLayer) { + cmds.push({ + id: 'attr-table', + label: `Open Attribute Table: ${selectedLayer.name}`, + shortcut: 'Ctrl+T', + icon: Table2, + category: 'Layer', + action: () => { + useUIStore.getState().setShowAttributeTable(true, selectedLayerId) + setShowCommandPalette(false) + } + }) + cmds.push({ + id: 'style-editor', + label: `Edit Style: ${selectedLayer.name}`, + icon: Palette, + category: 'Layer', + action: () => { + useUIStore.getState().setShowStyleEditor(true, selectedLayerId) + setShowCommandPalette(false) + } + }) + cmds.push({ + id: 'toggle-visibility', + label: `Toggle Visibility: ${selectedLayer.name}`, + icon: selectedLayer.visible ? EyeOff : Eye, + category: 'Layer', + action: () => { + useProjectStore.getState().toggleLayerVisibility(selectedLayerId) + if (backend) backend.updateLayer(selectedLayerId, { visible: !selectedLayer.visible }) + setShowCommandPalette(false) + } + }) + } + } + + return cmds + }, [activeTool, selectedLayerId, layers, backend, zoom]) + + const filteredCommands = useMemo(() => { + if (!query) return commands + const lower = query.toLowerCase() + return commands.filter(c => + c.label.toLowerCase().includes(lower) || + c.category.toLowerCase().includes(lower) + ) + }, [query, commands]) + + useEffect(() => { + setSelectedIndex(0) + }, [query]) + + useEffect(() => { + if (showCommandPalette) { + setQuery('') + setSelectedIndex(0) + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [showCommandPalette]) + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const item = listRef.current.children[selectedIndex] as HTMLElement + if (item) item.scrollIntoView({ block: 'nearest' }) + } + }, [selectedIndex]) + + if (!showCommandPalette) return null + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(i => Math.min(i + 1, filteredCommands.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(i => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (filteredCommands[selectedIndex]) { + filteredCommands[selectedIndex].action() + } + } else if (e.key === 'Escape') { + setShowCommandPalette(false) + } + } + + // Group commands by category + const grouped: { category: string; commands: Command[] }[] = [] + const seen = new Set() + for (const cmd of filteredCommands) { + if (!seen.has(cmd.category)) { + seen.add(cmd.category) + grouped.push({ category: cmd.category, commands: [] }) + } + grouped.find(g => g.category === cmd.category)?.commands.push(cmd) + } + + let flatIndex = -1 + + return ( +
setShowCommandPalette(false)}> +
e.stopPropagation()}> +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a command..." + className="flex-1 bg-transparent text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none" + /> +
+ +
+ {grouped.length === 0 ? ( +
No matching commands
+ ) : ( + grouped.map(group => ( +
+
+ {group.category} +
+ {group.commands.map(cmd => { + flatIndex++ + const idx = flatIndex + const Icon = cmd.icon + return ( + + ) + })} +
+ )) + )} +
+
+
+ ) +} diff --git a/src/components/common/ExportDialog.tsx b/src/components/common/ExportDialog.tsx new file mode 100644 index 0000000..ebdfef9 --- /dev/null +++ b/src/components/common/ExportDialog.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { X, Download, Image, FileJson, FileSpreadsheet } from 'lucide-react' +import { useUIStore } from '../../stores/uiStore' +import { useProjectStore } from '../../stores/projectStore' +import { useMapStore } from '../../stores/mapStore' +import { exportMapToPNG, exportToGeoJSON, exportToCSV, downloadBlob } from '../../utils/export' + +export function ExportDialog() { + const { showExportDialog, setShowExportDialog } = useUIStore() + const { layers } = useProjectStore() + const { backend } = useMapStore() + const [isExporting, setIsExporting] = useState(false) + + if (!showExportDialog) return null + + const vectorLayers = layers.filter(l => l.type === 'geojson' && l.source.data) + + const handleExportPNG = async () => { + const map = backend?.getNativeMap() as maplibregl.Map | null + if (!map) return + + setIsExporting(true) + try { + const blob = await exportMapToPNG(map) + downloadBlob(blob, 'map-export.png') + } catch (e) { + console.error('Failed to export PNG:', e) + alert('Failed to export map to PNG') + } finally { + setIsExporting(false) + } + } + + const handleExportGeoJSON = (layerId: string) => { + const layer = layers.find(l => l.id === layerId) + if (!layer || !layer.source.data) return + + exportToGeoJSON(layer.source.data as GeoJSON.GeoJSON, layer.name) + } + + const handleExportCSV = (layerId: string) => { + const layer = layers.find(l => l.id === layerId) + if (!layer || !layer.source.data) return + + const geojson = layer.source.data as GeoJSON.GeoJSON + if (geojson.type === 'FeatureCollection') { + exportToCSV(geojson.features, layer.name) + } else if (geojson.type === 'Feature') { + exportToCSV([geojson], layer.name) + } + } + + return ( +
setShowExportDialog(false)}> +
e.stopPropagation()}> +
+
+ +

Export

+
+ +
+ +
+ {/* Map Export */} +
+

Map

+ +
+ + {/* Layer Export */} + {vectorLayers.length > 0 && ( +
+

Layers

+
+ {vectorLayers.map(layer => ( +
+
{layer.name}
+
+ + +
+
+ ))} +
+
+ )} + + {vectorLayers.length === 0 && ( +
+ No vector layers available for export. +
+ )} +
+ +
+ +
+
+
+ ) +} diff --git a/src/components/common/GoToCoordinates.tsx b/src/components/common/GoToCoordinates.tsx new file mode 100644 index 0000000..0f3feb9 --- /dev/null +++ b/src/components/common/GoToCoordinates.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { X, MapPin } from 'lucide-react' +import { useUIStore } from '../../stores/uiStore' +import { useMapStore } from '../../stores/mapStore' + +export function GoToCoordinates() { + const { showGoToCoordinates, setShowGoToCoordinates } = useUIStore() + const { backend } = useMapStore() + const [lng, setLng] = useState('') + const [lat, setLat] = useState('') + const [zoom, setZoom] = useState('12') + const [error, setError] = useState('') + + if (!showGoToCoordinates) return null + + const handleGo = () => { + const lngNum = parseFloat(lng) + const latNum = parseFloat(lat) + const zoomNum = parseFloat(zoom) + + if (isNaN(lngNum) || isNaN(latNum)) { + setError('Please enter valid coordinates') + return + } + + if (lngNum < -180 || lngNum > 180) { + setError('Longitude must be between -180 and 180') + return + } + + if (latNum < -90 || latNum > 90) { + setError('Latitude must be between -90 and 90') + return + } + + if (backend) { + backend.setView({ + center: [lngNum, latNum], + zoom: isNaN(zoomNum) ? 12 : zoomNum + }) + } + + setError('') + setShowGoToCoordinates(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleGo() + if (e.key === 'Escape') setShowGoToCoordinates(false) + } + + return ( +
setShowGoToCoordinates(false)}> +
e.stopPropagation()}> +
+
+ +

Go to Coordinates

+
+ +
+ +
+
+
+ + setLng(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="-73.985428" + autoFocus + className="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-500 focus:border-blue-500 focus:outline-none" + /> +
+
+ + setLat(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="40.748817" + className="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-500 focus:border-blue-500 focus:outline-none" + /> +
+
+ +
+ + setZoom(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="12" + className="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-500 focus:border-blue-500 focus:outline-none" + /> +
+ + {error && ( +

{error}

+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/components/common/SettingsPanel.tsx b/src/components/common/SettingsPanel.tsx new file mode 100644 index 0000000..df94a09 --- /dev/null +++ b/src/components/common/SettingsPanel.tsx @@ -0,0 +1,133 @@ +import { X, Settings } from 'lucide-react' +import { useUIStore } from '../../stores/uiStore' + +export function SettingsPanel() { + const { + showSettings, setShowSettings, + coordinateFormat, setCoordinateFormat, + statusBarVisible, setStatusBarVisible, + sidebarWidth, setSidebarWidth, + darkMode + } = useUIStore() + + if (!showSettings) return null + + return ( +
setShowSettings(false)}> +
e.stopPropagation()}> +
+
+ +

Settings

+
+ +
+ +
+ {/* Display */} +
+

Display

+ +
+
+ + +
+ +
+ + +
+ +
+ + setSidebarWidth(parseInt(e.target.value))} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-600" + /> +
+
+
+ + {/* Theme */} +
+

Theme

+
+
+
+ Dark +
+
+
+ Light (coming soon) +
+
+
+ + {/* About */} +
+

About

+
+
AnyMap Studio v0.1.0
+
Built with Electron, React, and MapLibre GL
+
MIT License
+
+
+ + {/* Keyboard Shortcuts */} +
+

Keyboard Shortcuts

+
+ {[ + ['Ctrl+Shift+P', 'Command Palette'], + ['Ctrl+G', 'Go to Coordinates'], + ['Ctrl+E', 'Export Map / Data'], + ['Ctrl+S', 'Save Project'], + ['Ctrl+B', 'Toggle Sidebar'], + ['Ctrl+,', 'Settings'], + ['I', 'Identify Tool'], + ['M', 'Measure Distance'], + ['Escape', 'Cancel Tool'], + ].map(([key, desc]) => ( +
+ {desc} + {key} +
+ ))} +
+
+
+ +
+ +
+
+
+ ) +} diff --git a/src/components/layers/AttributeTable.tsx b/src/components/layers/AttributeTable.tsx new file mode 100644 index 0000000..5a04c3a --- /dev/null +++ b/src/components/layers/AttributeTable.tsx @@ -0,0 +1,229 @@ +import { useState, useMemo } from 'react' +import { X, ArrowUp, ArrowDown, Search, BarChart3, Download } from 'lucide-react' +import { useUIStore } from '../../stores/uiStore' +import { useProjectStore } from '../../stores/projectStore' +import { getPropertyKeys, getFeatureRows, calculateFieldStats } from '../../utils/geo' +import { exportToCSV } from '../../utils/export' + +export function AttributeTable() { + const { showAttributeTable, attributeTableLayerId, setShowAttributeTable } = useUIStore() + const { layers } = useProjectStore() + const [sortColumn, setSortColumn] = useState(null) + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + const [filterText, setFilterText] = useState('') + const [selectedRows, setSelectedRows] = useState>(new Set()) + const [statsColumn, setStatsColumn] = useState(null) + + if (!showAttributeTable || !attributeTableLayerId) return null + + const layer = layers.find(l => l.id === attributeTableLayerId) + if (!layer || layer.type !== 'geojson' || !layer.source.data) return null + + const geojson = layer.source.data as GeoJSON.GeoJSON + const columns = getPropertyKeys(geojson) + const allRows = getFeatureRows(geojson) + + // Filter rows + const filteredRows = filterText + ? allRows.filter(row => + Object.values(row.properties).some(v => + String(v).toLowerCase().includes(filterText.toLowerCase()) + ) + ) + : allRows + + // Sort rows + const sortedRows = useMemo(() => { + if (!sortColumn) return filteredRows + return [...filteredRows].sort((a, b) => { + const aVal = a.properties[sortColumn] + const bVal = b.properties[sortColumn] + if (aVal === bVal) return 0 + if (aVal === null || aVal === undefined) return 1 + if (bVal === null || bVal === undefined) return -1 + const cmp = typeof aVal === 'number' && typeof bVal === 'number' + ? aVal - bVal + : String(aVal).localeCompare(String(bVal)) + return sortDirection === 'asc' ? cmp : -cmp + }) + }, [filteredRows, sortColumn, sortDirection]) + + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortDirection(d => d === 'asc' ? 'desc' : 'asc') + } else { + setSortColumn(column) + setSortDirection('asc') + } + } + + const handleRowSelect = (id: number, e: React.MouseEvent) => { + setSelectedRows(prev => { + const next = new Set(prev) + if (e.shiftKey || e.ctrlKey || e.metaKey) { + if (next.has(id)) next.delete(id) + else next.add(id) + } else { + if (next.has(id) && next.size === 1) { + next.clear() + } else { + next.clear() + next.add(id) + } + } + return next + }) + } + + const stats = useMemo(() => { + if (!statsColumn) return null + const values = allRows + .map(r => r.properties[statsColumn]) + .filter((v): v is number => typeof v === 'number' && !isNaN(v)) + if (values.length === 0) return null + return calculateFieldStats(values) + }, [statsColumn, allRows]) + + const handleExport = () => { + if (geojson.type === 'FeatureCollection') { + const features = selectedRows.size > 0 + ? geojson.features.filter((_, i) => selectedRows.has(i)) + : geojson.features + exportToCSV(features, layer.name) + } + } + + return ( +
+ {/* Backdrop - click to close */} +
setShowAttributeTable(false)} + /> + {/* Table panel */} +
+ {/* Header */} +
+
+

+ {layer.name} — Attributes +

+ + {sortedRows.length} of {allRows.length} features + {selectedRows.size > 0 && ` (${selectedRows.size} selected)`} + +
+
+
+ + setFilterText(e.target.value)} + className="h-7 w-48 rounded-md border border-slate-600 bg-slate-700 pl-7 pr-2 text-xs text-slate-200 placeholder:text-slate-500 focus:border-blue-500 focus:outline-none" + /> +
+ + +
+
+ + {/* Table */} +
+ + + + + {columns.map(col => ( + + ))} + + + + + {sortedRows.map((row) => ( + handleRowSelect(row.id, e)} + > + + {columns.map(col => ( + + ))} + + + ))} + +
# handleSort(col)} + > +
+ {col} + {sortColumn === col && ( + sortDirection === 'asc' + ? + : + )} + +
+
+ Geometry +
{row.id + 1} + {row.properties[col] !== null && row.properties[col] !== undefined + ? String(row.properties[col]) + : null + } + + {row.geometry?.type || '—'} +
+
+ + {/* Statistics panel */} + {stats && statsColumn && ( +
+ {statsColumn}: + Count: {stats.count} + Min: {stats.min.toFixed(2)} + Max: {stats.max.toFixed(2)} + Mean: {stats.mean.toFixed(2)} + Median: {stats.median.toFixed(2)} + Sum: {stats.sum.toFixed(2)} + StdDev: {stats.stdDev.toFixed(2)} + +
+ )} +
+
+ ) +} diff --git a/src/components/layers/LayerItem.tsx b/src/components/layers/LayerItem.tsx index 696ae69..a490520 100644 --- a/src/components/layers/LayerItem.tsx +++ b/src/components/layers/LayerItem.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { Eye, EyeOff, @@ -8,14 +8,27 @@ import { GripVertical, Map, Image, - Hexagon + Hexagon, + Copy, + Edit3, + Palette, + Table2, + ZoomIn, + Download } from 'lucide-react' import { useProjectStore } from '../../stores/projectStore' import { useMapStore } from '../../stores/mapStore' +import { useUIStore } from '../../stores/uiStore' import type { UnifiedLayerConfig } from '../../types/project' +import { calculateBounds } from '../../utils/geo' +import { exportToGeoJSON } from '../../utils/export' interface LayerItemProps { layer: UnifiedLayerConfig + index: number + onDragStart: (index: number) => void + onDragOver: (e: React.DragEvent, index: number) => void + onDragEnd: () => void } const typeIcons: Record = { @@ -29,15 +42,42 @@ const typeIcons: Record = { 'point-cloud': Hexagon } -export function LayerItem({ layer }: LayerItemProps) { +export function LayerItem({ layer, index, onDragStart, onDragOver, onDragEnd }: LayerItemProps) { const [expanded, setExpanded] = useState(false) - const { selectedLayerId, setSelectedLayer, toggleLayerVisibility, setLayerOpacity, removeLayer } = + const [isRenaming, setIsRenaming] = useState(false) + const [newName, setNewName] = useState(layer.name) + const [showContextMenu, setShowContextMenu] = useState(false) + const [contextMenuPos, setContextMenuPos] = useState({ x: 0, y: 0 }) + const { selectedLayerId, setSelectedLayer, toggleLayerVisibility, setLayerOpacity, removeLayer, updateLayer, addLayer } = useProjectStore() const { backend } = useMapStore() + const { setShowAttributeTable, setShowStyleEditor } = useUIStore() + const renameInputRef = useRef(null) + const contextMenuRef = useRef(null) const isSelected = selectedLayerId === layer.id const Icon = typeIcons[layer.type] || Map + useEffect(() => { + if (isRenaming && renameInputRef.current) { + renameInputRef.current.focus() + renameInputRef.current.select() + } + }, [isRenaming]) + + // Close context menu on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setShowContextMenu(false) + } + } + if (showContextMenu) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [showContextMenu]) + const handleToggleVisibility = (e: React.MouseEvent) => { e.stopPropagation() toggleLayerVisibility(layer.id) @@ -46,8 +86,8 @@ export function LayerItem({ layer }: LayerItemProps) { } } - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation() + const handleDelete = (e?: React.MouseEvent) => { + e?.stopPropagation() removeLayer(layer.id) if (backend) { backend.removeLayer(layer.id) @@ -66,95 +106,269 @@ export function LayerItem({ layer }: LayerItemProps) { setSelectedLayer(isSelected ? null : layer.id) } + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPos({ x: e.clientX, y: e.clientY }) + setShowContextMenu(true) + } + + const handleRename = () => { + setIsRenaming(true) + setNewName(layer.name) + setShowContextMenu(false) + } + + const handleRenameConfirm = () => { + if (newName.trim()) { + updateLayer(layer.id, { name: newName.trim() }) + } + setIsRenaming(false) + } + + const handleRenameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleRenameConfirm() + if (e.key === 'Escape') setIsRenaming(false) + } + + const handleDuplicate = () => { + setShowContextMenu(false) + const newLayer: UnifiedLayerConfig = { + ...layer, + id: `layer-${Date.now()}`, + name: `${layer.name} (copy)` + } + addLayer(newLayer) + if (backend) { + backend.addLayer(newLayer).catch(console.error) + } + } + + const handleZoomToLayer = () => { + setShowContextMenu(false) + if (layer.type === 'geojson' && layer.source.data && backend) { + const bounds = calculateBounds(layer.source.data as GeoJSON.GeoJSON) + if (bounds) { + backend.fitBounds(bounds, 50) + } + } + } + + const handleOpenAttributes = () => { + setShowContextMenu(false) + if (layer.type === 'geojson' && layer.source.data) { + setShowAttributeTable(true, layer.id) + } + } + + const handleOpenStyleEditor = () => { + setShowContextMenu(false) + setShowStyleEditor(true, layer.id) + } + + const handleExportLayer = () => { + setShowContextMenu(false) + if (layer.type === 'geojson' && layer.source.data) { + exportToGeoJSON(layer.source.data as GeoJSON.GeoJSON, layer.name) + } + } + return ( -
+ <>
onDragStart(index)} + onDragOver={(e) => onDragOver(e, index)} + onDragEnd={onDragEnd} + onContextMenu={handleContextMenu} > - - - + - + - - {layer.name} - + - - -
+ - {expanded && ( -
-
- -
- - - {Math.round(layer.opacity * 100)}% - -
-
+ +
-
-
Type: {layer.type}
- {layer.source.url && ( -
- Source: {layer.source.url} + {expanded && ( +
+
+ +
+ + + {Math.round(layer.opacity * 100)}% +
- )} +
+ + {/* Quick action buttons */} +
+ {layer.type === 'geojson' && ( + <> + + {layer.source.data && ( + + )} + + + )} +
+ +
+
Type: {layer.type}
+ {layer.source.url && ( +
+ Source: {layer.source.url} +
+ )} + {layer.style?.fillColor && ( +
+ Style: + + {layer.style.fillColor} +
+ )} +
+ )} +
+ + {/* Context Menu */} + {showContextMenu && ( +
+ + + +
+ {layer.type === 'geojson' && ( + <> + + + +
+ + )} + { handleToggleVisibility({ stopPropagation: () => {} } as React.MouseEvent); setShowContextMenu(false) }} + /> + { handleDelete(); setShowContextMenu(false) }} danger />
)} -
+ + ) +} + +function ContextMenuItem({ icon: Icon, label, onClick, disabled, danger }: { + icon: React.ElementType + label: string + onClick: () => void + disabled?: boolean + danger?: boolean +}) { + return ( + ) } diff --git a/src/components/layers/LayerPanel.tsx b/src/components/layers/LayerPanel.tsx index 6c610b8..2281884 100644 --- a/src/components/layers/LayerPanel.tsx +++ b/src/components/layers/LayerPanel.tsx @@ -4,15 +4,26 @@ import { LayerItem } from './LayerItem' import { useProjectStore } from '../../stores/projectStore' import { useMapStore } from '../../stores/mapStore' import type { UnifiedLayerConfig } from '../../types/project' -import { useState } from 'react' +import { useState, useRef } from 'react' +import { parseKML, parseCSV, getCSVHeaders, autoDetectCoordinateColumns } from '../../utils/parsers' +import { calculateBounds } from '../../utils/geo' export function LayerPanel() { - const { layers, addLayer } = useProjectStore() + const { layers, addLayer, reorderLayers } = useProjectStore() const { backend } = useMapStore() const [showUrlDialog, setShowUrlDialog] = useState(false) const [urlInput, setUrlInput] = useState('') - const [urlType, setUrlType] = useState<'geojson' | 'cog' | 'pmtiles' | 'xyz'>('geojson') + const [urlType, setUrlType] = useState<'geojson' | 'cog' | 'pmtiles' | 'xyz' | 'wms' | 'wmts'>('geojson') const [isLoading, setIsLoading] = useState(false) + const [showCsvDialog, setShowCsvDialog] = useState(false) + const [csvContent, setCsvContent] = useState('') + const [csvFileName, setCsvFileName] = useState('') + const [csvHeaders, setCsvHeaders] = useState([]) + const [csvLatCol, setCsvLatCol] = useState('') + const [csvLngCol, setCsvLngCol] = useState('') + const [wmsLayers, setWmsLayers] = useState('') + const dragItemRef = useRef(null) + const dragOverRef = useRef(null) const addLayerWithData = async (data: GeoJSON.GeoJSON, name: string) => { const layerConfig: UnifiedLayerConfig = { @@ -51,25 +62,23 @@ export function LayerPanel() { const result = await api.showOpenDialog({ filters: [ - { name: 'Geospatial Files', extensions: ['geojson', 'json', 'shp', 'zip'] }, + { name: 'Geospatial Files', extensions: ['geojson', 'json', 'shp', 'zip', 'kml', 'kmz', 'csv', 'tsv'] }, { name: 'GeoJSON', extensions: ['geojson', 'json'] }, - { name: 'Shapefile', extensions: ['shp', 'zip'] } + { name: 'Shapefile', extensions: ['shp', 'zip'] }, + { name: 'KML/KMZ', extensions: ['kml', 'kmz'] }, + { name: 'CSV/TSV', extensions: ['csv', 'tsv'] } ] }) if (result.canceled || !result.filePath) return const ext = result.filePath.split('.').pop()?.toLowerCase() - const fileName = result.filePath.split('/').pop()?.replace(/\.(geojson|json|shp|zip)$/i, '') || 'Untitled' + const fileName = result.filePath.split('/').pop()?.split('\\').pop()?.replace(/\.\w+$/i, '') || 'Untitled' setIsLoading(true) try { - if (ext === 'shp' || ext === 'zip') { - // Parse Shapefile using shpjs - result.buffer is base64 encoded - if (!result.buffer) { - throw new Error('Failed to read binary file') - } - // Convert base64 to ArrayBuffer + if (ext === 'shp' || (ext === 'zip' && !result.filePath.toLowerCase().endsWith('.kmz'))) { + if (!result.buffer) throw new Error('Failed to read binary file') const binaryString = atob(result.buffer) const bytes = new Uint8Array(binaryString.length) for (let i = 0; i < binaryString.length; i++) { @@ -77,8 +86,38 @@ export function LayerPanel() { } const data = await shp(bytes.buffer) as GeoJSON.GeoJSON await addLayerWithData(data, fileName) + } else if (ext === 'kml') { + if (!result.content) throw new Error('Failed to read KML file') + const data = parseKML(result.content) + await addLayerWithData(data, fileName) + } else if (ext === 'kmz') { + if (!result.buffer) throw new Error('Failed to read KMZ file') + const binaryString = atob(result.buffer) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + // KMZ is a ZIP containing KML - extract and parse + const kmlContent = extractKMLFromBuffer(bytes) + if (!kmlContent) throw new Error('No KML file found in KMZ archive') + const data = parseKML(kmlContent) + await addLayerWithData(data, fileName) + } else if (ext === 'csv' || ext === 'tsv') { + if (!result.content) throw new Error('Failed to read CSV file') + const headers = getCSVHeaders(result.content) + const detected = autoDetectCoordinateColumns(headers) + if (detected.lat && detected.lng) { + const data = parseCSV(result.content, { latColumn: detected.lat, lngColumn: detected.lng }) + await addLayerWithData(data, fileName) + } else { + setCsvContent(result.content) + setCsvFileName(fileName) + setCsvHeaders(headers) + setCsvLatCol(detected.lat || '') + setCsvLngCol(detected.lng || '') + setShowCsvDialog(true) + } } else { - // Parse GeoJSON if (!result.content) return const data = JSON.parse(result.content) as GeoJSON.GeoJSON await addLayerWithData(data, fileName) @@ -91,6 +130,23 @@ export function LayerPanel() { } } + const handleCsvConfirm = async () => { + if (!csvLatCol || !csvLngCol) { + alert('Please select both latitude and longitude columns') + return + } + setIsLoading(true) + try { + const data = parseCSV(csvContent, { latColumn: csvLatCol, lngColumn: csvLngCol }) + await addLayerWithData(data, csvFileName) + setShowCsvDialog(false) + } catch (e) { + alert(`Failed to parse CSV: ${e instanceof Error ? e.message : 'Unknown error'}`) + } finally { + setIsLoading(false) + } + } + const handleAddFromURL = async () => { if (!urlInput.trim()) return @@ -118,9 +174,7 @@ export function LayerPanel() { } } addLayer(layerConfig) - if (backend) { - await backend.addLayer(layerConfig) - } + if (backend) await backend.addLayer(layerConfig) } else if (urlType === 'pmtiles') { const layerConfig: UnifiedLayerConfig = { id: `layer-${Date.now()}`, @@ -134,13 +188,45 @@ export function LayerPanel() { } } addLayer(layerConfig) - if (backend) { - await backend.addLayer(layerConfig) + if (backend) await backend.addLayer(layerConfig) + } else if (urlType === 'wms') { + const layerNames = wmsLayers.trim() || '0' + const tileUrl = `${url}${url.includes('?') ? '&' : '?'}SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image/png&TRANSPARENT=true&LAYERS=${layerNames}&SRS=EPSG:3857&STYLES=&WIDTH=256&HEIGHT=256&BBOX={bbox-epsg-3857}` + const layerConfig: UnifiedLayerConfig = { + id: `layer-${Date.now()}`, + name: `WMS: ${layerNames}`, + type: 'raster', + visible: true, + opacity: 1, + source: { + type: 'raster', + tiles: [tileUrl], + attribution: url + } + } + addLayer(layerConfig) + if (backend) await backend.addLayer(layerConfig) + } else if (urlType === 'wmts') { + const layerNames = wmsLayers.trim() || '0' + const layerConfig: UnifiedLayerConfig = { + id: `layer-${Date.now()}`, + name: `WMTS: ${layerNames}`, + type: 'raster', + visible: true, + opacity: 1, + source: { + type: 'raster', + url: url, + tiles: [url] + } } + addLayer(layerConfig) + if (backend) await backend.addLayer(layerConfig) } setShowUrlDialog(false) setUrlInput('') + setWmsLayers('') } catch (e) { console.error('Failed to load from URL:', e) alert(`Failed to load from URL: ${e instanceof Error ? e.message : 'Unknown error'}`) @@ -149,6 +235,27 @@ export function LayerPanel() { } } + const handleDragStart = (index: number) => { + dragItemRef.current = index + } + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault() + dragOverRef.current = index + } + + const handleDragEnd = () => { + if (dragItemRef.current !== null && dragOverRef.current !== null && dragItemRef.current !== dragOverRef.current) { + // Convert from reversed display indices to actual layer indices + const totalLayers = layers.length + const sourceIndex = totalLayers - 1 - dragItemRef.current + const targetIndex = totalLayers - 1 - dragOverRef.current + reorderLayers(sourceIndex, targetIndex) + } + dragItemRef.current = null + dragOverRef.current = null + } + const reversedLayers = [...layers].reverse() return ( @@ -158,7 +265,7 @@ export function LayerPanel() { onClick={handleAddFile} disabled={isLoading} className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-slate-700 px-3 py-2.5 text-sm font-medium text-slate-200 hover:bg-slate-600 transition-colors disabled:opacity-50" - title="Add file (GeoJSON, Shapefile)" + title="Add file (GeoJSON, Shapefile, KML, CSV)" > {isLoading ? 'Loading...' : 'Add File'} @@ -191,10 +298,12 @@ export function LayerPanel() { + +
-
+
+ {(urlType === 'wms' || urlType === 'wmts') && ( +
+ + setWmsLayers(e.target.value)} + placeholder="layer1,layer2" + className="w-full rounded-lg border border-slate-500 bg-slate-700 px-4 py-3 text-sm text-slate-200 placeholder:text-slate-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20" + /> +

Comma-separated WMS layer names

+
+ )} +
)} + {/* CSV Column Selection Dialog */} + {showCsvDialog && ( +
+
+

Select Coordinate Columns

+

+ Could not auto-detect coordinate columns. Please select the latitude and longitude columns. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ )} + {layers.length === 0 ? (
No layers yet. Add a file or URL to get started.
) : (
- {reversedLayers.map((layer) => ( + {reversedLayers.map((layer, displayIndex) => ( ))}
@@ -250,69 +428,40 @@ export function LayerPanel() { ) } -function getRandomColor(): string { - const colors = [ - '#3b82f6', // blue - '#ef4444', // red - '#22c55e', // green - '#f59e0b', // amber - '#8b5cf6', // violet - '#ec4899', // pink - '#06b6d4', // cyan - '#f97316' // orange - ] - return colors[Math.floor(Math.random() * colors.length)] -} +function extractKMLFromBuffer(data: Uint8Array): string | null { + let offset = 0 + const decoder = new TextDecoder() -function calculateBounds(geojson: GeoJSON.GeoJSON): [[number, number], [number, number]] | null { - const coords: [number, number][] = [] - - function extractCoords(geometry: GeoJSON.Geometry) { - switch (geometry.type) { - case 'Point': - coords.push(geometry.coordinates as [number, number]) - break - case 'MultiPoint': - case 'LineString': - (geometry.coordinates as [number, number][]).forEach(c => coords.push(c)) - break - case 'MultiLineString': - case 'Polygon': - (geometry.coordinates as [number, number][][]).forEach(ring => - ring.forEach(c => coords.push(c)) - ) - break - case 'MultiPolygon': - (geometry.coordinates as [number, number][][][]).forEach(polygon => - polygon.forEach(ring => ring.forEach(c => coords.push(c))) - ) - break - case 'GeometryCollection': - geometry.geometries.forEach(g => extractCoords(g)) - break - } - } + while (offset < data.length - 4) { + if (data[offset] === 0x50 && data[offset + 1] === 0x4b && + data[offset + 2] === 0x03 && data[offset + 3] === 0x04) { - if (geojson.type === 'Feature') { - if (geojson.geometry) extractCoords(geojson.geometry) - } else if (geojson.type === 'FeatureCollection') { - geojson.features.forEach(f => { - if (f.geometry) extractCoords(f.geometry) - }) - } else { - extractCoords(geojson as GeoJSON.Geometry) - } + const compressionMethod = data[offset + 8] | (data[offset + 9] << 8) + const compressedSize = data[offset + 18] | (data[offset + 19] << 8) | + (data[offset + 20] << 16) | (data[offset + 21] << 24) + const fileNameLength = data[offset + 26] | (data[offset + 27] << 8) + const extraFieldLength = data[offset + 28] | (data[offset + 29] << 8) - if (coords.length === 0) return null + const fileName = decoder.decode(data.slice(offset + 30, offset + 30 + fileNameLength)) + const dataStart = offset + 30 + fileNameLength + extraFieldLength - let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity + if (fileName.toLowerCase().endsWith('.kml') && compressionMethod === 0) { + const fileData = data.slice(dataStart, dataStart + compressedSize) + return decoder.decode(fileData) + } - coords.forEach(([lng, lat]) => { - if (lng < minLng) minLng = lng - if (lng > maxLng) maxLng = lng - if (lat < minLat) minLat = lat - if (lat > maxLat) maxLat = lat - }) + offset = dataStart + compressedSize + } else { + offset++ + } + } + return null +} - return [[minLng, minLat], [maxLng, maxLat]] +function getRandomColor(): string { + const colors = [ + '#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', + '#ec4899', '#06b6d4', '#f97316' + ] + return colors[Math.floor(Math.random() * colors.length)] } diff --git a/src/components/layers/StyleEditor.tsx b/src/components/layers/StyleEditor.tsx new file mode 100644 index 0000000..93378be --- /dev/null +++ b/src/components/layers/StyleEditor.tsx @@ -0,0 +1,236 @@ +import { useState, useEffect } from 'react' +import { X, Palette } from 'lucide-react' +import { useUIStore } from '../../stores/uiStore' +import { useProjectStore } from '../../stores/projectStore' +import { useMapStore } from '../../stores/mapStore' +import type { LayerStyle } from '../../types/project' + +const PRESET_COLORS = [ + '#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', + '#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#a855f7', + '#6366f1', '#84cc16', '#e11d48', '#0ea5e9', '#d946ef', + '#fbbf24', '#34d399', '#fb923c', '#818cf8', '#f472b6' +] + +export function StyleEditor() { + const { showStyleEditor, styleEditorLayerId, setShowStyleEditor } = useUIStore() + const { layers, updateLayer } = useProjectStore() + const { backend } = useMapStore() + + const layer = layers.find(l => l.id === styleEditorLayerId) + const [style, setStyle] = useState({}) + + useEffect(() => { + if (layer?.style) { + setStyle({ ...layer.style }) + } + }, [layer?.id, layer?.style]) + + if (!showStyleEditor || !styleEditorLayerId || !layer) return null + + const isVector = layer.type === 'geojson' + + const applyStyle = (updates: Partial) => { + const newStyle = { ...style, ...updates } + setStyle(newStyle) + updateLayer(layer.id, { style: newStyle }) + + if (backend && isVector) { + const map = backend.getNativeMap() as maplibregl.Map | null + if (map) { + // Update fill + if (updates.fillColor !== undefined && map.getLayer(`${layer.id}-fill`)) { + map.setPaintProperty(`${layer.id}-fill`, 'fill-color', newStyle.fillColor || '#3b82f6') + } + if (updates.fillOpacity !== undefined && map.getLayer(`${layer.id}-fill`)) { + map.setPaintProperty(`${layer.id}-fill`, 'fill-opacity', (newStyle.fillOpacity ?? 0.5) * layer.opacity) + } + // Update stroke + if (updates.strokeColor !== undefined && map.getLayer(`${layer.id}-line`)) { + map.setPaintProperty(`${layer.id}-line`, 'line-color', newStyle.strokeColor || '#2563eb') + } + if (updates.strokeWidth !== undefined && map.getLayer(`${layer.id}-line`)) { + map.setPaintProperty(`${layer.id}-line`, 'line-width', newStyle.strokeWidth || 2) + } + if (updates.strokeOpacity !== undefined && map.getLayer(`${layer.id}-line`)) { + map.setPaintProperty(`${layer.id}-line`, 'line-opacity', (newStyle.strokeOpacity ?? 1) * layer.opacity) + } + // Update points + if ((updates.pointColor !== undefined || updates.fillColor !== undefined) && map.getLayer(`${layer.id}-point`)) { + map.setPaintProperty(`${layer.id}-point`, 'circle-color', newStyle.pointColor || newStyle.fillColor || '#3b82f6') + } + if (updates.pointRadius !== undefined && map.getLayer(`${layer.id}-point`)) { + map.setPaintProperty(`${layer.id}-point`, 'circle-radius', newStyle.pointRadius || 6) + } + if (updates.strokeColor !== undefined && map.getLayer(`${layer.id}-point`)) { + map.setPaintProperty(`${layer.id}-point`, 'circle-stroke-color', newStyle.strokeColor || '#1d4ed8') + } + } + } + } + + return ( +
+
+ {/* Header */} +
+
+ +

Style: {layer.name}

+
+ +
+ + {/* Content */} +
+ {isVector && ( + <> + {/* Fill Color */} +
+ +
+ applyStyle({ fillColor: e.target.value })} + className="h-10 w-10 cursor-pointer rounded border border-slate-600 bg-transparent" + /> + applyStyle({ fillColor: e.target.value })} + className="h-10 w-24 rounded-lg border border-slate-600 bg-slate-700 px-3 text-sm text-slate-200 focus:border-blue-500 focus:outline-none" + /> +
+
+ {PRESET_COLORS.map(color => ( +
+
+ + {/* Fill Opacity */} +
+ + applyStyle({ fillOpacity: parseFloat(e.target.value) })} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-600" + /> +
+ + {/* Stroke Color */} +
+ +
+ applyStyle({ strokeColor: e.target.value })} + className="h-10 w-10 cursor-pointer rounded border border-slate-600 bg-transparent" + /> + applyStyle({ strokeColor: e.target.value })} + className="h-10 w-24 rounded-lg border border-slate-600 bg-slate-700 px-3 text-sm text-slate-200 focus:border-blue-500 focus:outline-none" + /> +
+
+ + {/* Stroke Width */} +
+ + applyStyle({ strokeWidth: parseFloat(e.target.value) })} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-600" + /> +
+ + {/* Stroke Opacity */} +
+ + applyStyle({ strokeOpacity: parseFloat(e.target.value) })} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-600" + /> +
+ + {/* Point Radius */} +
+ + applyStyle({ pointRadius: parseFloat(e.target.value) })} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-600" + /> +
+ + {/* Point Color */} +
+ +
+ applyStyle({ pointColor: e.target.value })} + className="h-10 w-10 cursor-pointer rounded border border-slate-600 bg-transparent" + /> + applyStyle({ pointColor: e.target.value })} + className="h-10 w-24 rounded-lg border border-slate-600 bg-slate-700 px-3 text-sm text-slate-200 focus:border-blue-500 focus:outline-none" + /> +
+
+ + )} + + {!isVector && ( +
+ Style editor is currently available for vector (GeoJSON) layers only. +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index d348fab..54e5f44 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,14 +1,117 @@ +import { useEffect } from 'react' import { Toolbar } from './Toolbar' import { Sidebar } from './Sidebar' import { StatusBar } from './StatusBar' import { MapCanvas } from '../map/MapCanvas' import { MeasureTool } from '../tools/MeasureTool' import { DrawTool } from '../tools/DrawTool' +import { AttributeTable } from '../layers/AttributeTable' +import { StyleEditor } from '../layers/StyleEditor' +import { GoToCoordinates } from '../common/GoToCoordinates' +import { CommandPalette } from '../common/CommandPalette' +import { ExportDialog } from '../common/ExportDialog' +import { SettingsPanel } from '../common/SettingsPanel' import { useUIStore } from '../../stores/uiStore' export function AppShell() { const { sidebarOpen, statusBarVisible } = useUIStore() + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const state = useUIStore.getState() + + // Don't trigger shortcuts when typing in input fields + const target = e.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { + // Still allow Escape + if (e.key === 'Escape') { + state.setShowCommandPalette(false) + state.setShowGoToCoordinates(false) + state.setShowExportDialog(false) + state.setShowSettings(false) + state.setShowStyleEditor(false) + if (state.activeTool !== 'none') { + state.setActiveTool('none') + } + } + return + } + + // Command palette: Ctrl+Shift+P + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'P') { + e.preventDefault() + state.setShowCommandPalette(!state.showCommandPalette) + return + } + + // Go to coordinates: Ctrl+G + if ((e.ctrlKey || e.metaKey) && e.key === 'g') { + e.preventDefault() + state.setShowGoToCoordinates(true) + return + } + + // Export: Ctrl+E + if ((e.ctrlKey || e.metaKey) && e.key === 'e') { + e.preventDefault() + state.setShowExportDialog(true) + return + } + + // Toggle sidebar: Ctrl+B + if ((e.ctrlKey || e.metaKey) && e.key === 'b') { + e.preventDefault() + state.toggleSidebar() + return + } + + // Settings: Ctrl+, + if ((e.ctrlKey || e.metaKey) && e.key === ',') { + e.preventDefault() + state.setShowSettings(true) + return + } + + // Escape to cancel tool + if (e.key === 'Escape') { + state.setShowCommandPalette(false) + state.setShowGoToCoordinates(false) + state.setShowExportDialog(false) + state.setShowSettings(false) + state.setShowStyleEditor(false) + state.setShowAttributeTable(false) + if (state.activeTool !== 'none') { + if (state.activeTool.startsWith('measure')) { + state.clearMeasurement() + } else { + state.setActiveTool('none') + } + } + return + } + + // Single-key shortcuts (no modifiers) + if (!e.ctrlKey && !e.metaKey && !e.altKey) { + if (e.key === 'i' || e.key === 'I') { + state.setActiveTool(state.activeTool === 'identify' ? 'none' : 'identify') + return + } + if (e.key === 'm' || e.key === 'M') { + if (state.activeTool === 'measure-distance') { + state.clearMeasurement() + } else { + state.setActiveTool('measure-distance') + } + return + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + return (
@@ -24,6 +127,16 @@ export function AppShell() {
{statusBarVisible && } + + {/* Attribute Table - absolute positioned above status bar */} + + + {/* Modal overlays */} + + + + +
) } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 51a7cf7..5bde7d1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -20,7 +20,7 @@ const panels: PanelConfig[] = [ ] export function Sidebar() { - const { sidebarWidth } = useUIStore() + const { sidebarWidth, setShowSettings, coordinateFormat, setCoordinateFormat } = useUIStore() const [expandedPanels, setExpandedPanels] = useState>(new Set(['layers'])) const togglePanel = (panel: PanelType) => { @@ -65,13 +65,42 @@ export function Sidebar() { {panel.id === 'layers' && } {panel.id === 'basemaps' && } {panel.id === 'data' && ( -
- Data sources will appear here +
+

Supported formats:

+
    +
  • GeoJSON (.geojson, .json)
  • +
  • Shapefile (.shp, .zip)
  • +
  • KML / KMZ
  • +
  • CSV / TSV with coordinates
  • +
  • Cloud Optimized GeoTIFF (COG)
  • +
  • PMTiles (vector tiles)
  • +
  • XYZ tile services
  • +
  • WMS / WMTS services
  • +
+

+ Drag and drop files onto the map to add layers. +

)} {panel.id === 'settings' && ( -
- Project settings +
+
+ + +
+
)}
diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 7439af6..c94385b 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,14 +1,21 @@ import { useMapStore } from '../../stores/mapStore' import { useProjectStore } from '../../stores/projectStore' +import { useUIStore } from '../../stores/uiStore' import { BACKEND_INFO } from '../../backends/capabilities' +import { formatCoordinate, calculateScale, formatScale } from '../../utils/geo' export function StatusBar() { const { cursorPosition, zoom, backendType } = useMapStore() const { layers } = useProjectStore() + const { coordinateFormat } = useUIStore() const backendInfo = BACKEND_INFO[backendType] const visibleLayers = layers.filter((l) => l.visible).length + const scale = cursorPosition + ? calculateScale(cursorPosition.lat, zoom) + : calculateScale(0, zoom) + return (
@@ -17,18 +24,25 @@ export function StatusBar() { - {visibleLayers} / {layers.length} layers visible + {visibleLayers} / {layers.length} layers + + CRS: EPSG:3857
{cursorPosition && ( - - {cursorPosition.lng.toFixed(5)}, {cursorPosition.lat.toFixed(5)} + + {coordinateFormat === 'dms' + ? formatCoordinate(cursorPosition.lng, cursorPosition.lat, 'dms') + : `${cursorPosition.lng.toFixed(5)}, ${cursorPosition.lat.toFixed(5)}` + } )} Zoom: {zoom.toFixed(2)} + + {formatScale(scale)}
) diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index f3fbec2..73bf4c8 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -10,14 +10,19 @@ import { Square, Circle, Minus, - Pentagon + Pentagon, + MousePointer, + MapPin, + Download, + Command } from 'lucide-react' import { useUIStore } from '../../stores/uiStore' import { useProjectStore } from '../../stores/projectStore' import { useMapStore } from '../../stores/mapStore' +import { calculateBounds as computeBounds } from '../../utils/geo' export function Toolbar() { - const { sidebarOpen, toggleSidebar, setView, activeTool, setActiveTool, clearMeasurement } = useUIStore() + const { sidebarOpen, toggleSidebar, setView, activeTool, setActiveTool, clearMeasurement, setShowGoToCoordinates, setShowExportDialog, setShowCommandPalette } = useUIStore() const { name, isDirty, filePath } = useProjectStore() const { backend, zoom } = useMapStore() @@ -45,6 +50,14 @@ export function Toolbar() { } } + const handleIdentify = () => { + if (activeTool === 'identify') { + setActiveTool('none') + } else { + setActiveTool('identify') + } + } + const handleGoHome = async () => { const api = window.electronAPI if (!api) return @@ -78,7 +91,28 @@ export function Toolbar() { } const handleFitBounds = () => { - // Will implement based on layer extents + if (!backend) return + const projectLayers = useProjectStore.getState().layers + let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity + let hasBounds = false + + for (const layer of projectLayers) { + if (layer.type === 'geojson' && layer.source.data && layer.visible) { + const geojson = layer.source.data as GeoJSON.GeoJSON + const bounds = computeBounds(geojson) + if (bounds) { + hasBounds = true + if (bounds[0][0] < minLng) minLng = bounds[0][0] + if (bounds[0][1] < minLat) minLat = bounds[0][1] + if (bounds[1][0] > maxLng) maxLng = bounds[1][0] + if (bounds[1][1] > maxLat) maxLat = bounds[1][1] + } + } + } + + if (hasBounds) { + backend.fitBounds([[minLng, minLat], [maxLng, maxLat]], 50) + } } const handleSave = async () => { @@ -103,11 +137,11 @@ export function Toolbar() { return (
-
+
-
+
-
+
+ + @@ -152,7 +194,7 @@ export function Toolbar() { -
+
+ +
+ + + + + +
@@ -210,7 +278,7 @@ export function Toolbar() { diff --git a/src/components/map/MapCanvas.tsx b/src/components/map/MapCanvas.tsx index 83fafae..9dc3f30 100644 --- a/src/components/map/MapCanvas.tsx +++ b/src/components/map/MapCanvas.tsx @@ -1,15 +1,162 @@ -import { useEffect, useRef, useCallback } from 'react' +import { useEffect, useRef, useCallback, useState } from 'react' +import maplibregl from 'maplibre-gl' import { createBackend, destroyBackend } from '../../backends' import { useMapStore } from '../../stores/mapStore' import { useProjectStore } from '../../stores/projectStore' +import { useUIStore } from '../../stores/uiStore' import type { IMapBackend } from '../../backends/types' +import type { UnifiedLayerConfig } from '../../types/project' +import { parseKML, parseCSV, detectFileType, autoDetectCoordinateColumns, getCSVHeaders } from '../../utils/parsers' +import { calculateBounds } from '../../utils/geo' +import shp from 'shpjs' export function MapCanvas() { const containerRef = useRef(null) const backendRef = useRef(null) + const popupRef = useRef(null) const { backendType, setBackend, setCursorPosition, setZoom, setLoading, setError } = useMapStore() - const { view, layers } = useProjectStore() + const { view, layers, addLayer } = useProjectStore() + const { activeTool } = useUIStore() + + const [isDragOver, setIsDragOver] = useState(false) + + const getRandomColor = () => { + const colors = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316'] + return colors[Math.floor(Math.random() * colors.length)] + } + + const addLayerWithData = async (data: GeoJSON.GeoJSON, name: string) => { + const layerConfig: UnifiedLayerConfig = { + id: `layer-${Date.now()}`, + name, + type: 'geojson', + visible: true, + opacity: 1, + source: { type: 'geojson', data }, + style: { + fillColor: getRandomColor(), + fillOpacity: 0.5, + strokeColor: '#1d4ed8', + strokeWidth: 2, + pointRadius: 6 + } + } + + addLayer(layerConfig) + + const backend = backendRef.current + if (backend) { + await backend.addLayer(layerConfig) + const bounds = calculateBounds(data) + if (bounds) backend.fitBounds(bounds, 50) + } + } + + // Handle drag and drop + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(false) + }, []) + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(false) + + const files = Array.from(e.dataTransfer.files) + for (const file of files) { + const name = file.name.replace(/\.\w+$/, '') + const fileType = detectFileType(file.name) + + try { + if (fileType === 'geojson') { + const text = await file.text() + const data = JSON.parse(text) as GeoJSON.GeoJSON + await addLayerWithData(data, name) + } else if (fileType === 'kml') { + const text = await file.text() + const data = parseKML(text) + await addLayerWithData(data, name) + } else if (fileType === 'csv') { + const text = await file.text() + const headers = getCSVHeaders(text) + const detected = autoDetectCoordinateColumns(headers) + if (detected.lat && detected.lng) { + const data = parseCSV(text, { latColumn: detected.lat, lngColumn: detected.lng }) + await addLayerWithData(data, name) + } else { + alert(`CSV file "${file.name}": Could not auto-detect coordinate columns. Please use the Add File dialog.`) + } + } else if (fileType === 'shapefile') { + const buffer = await file.arrayBuffer() + const data = await shp(buffer) as GeoJSON.GeoJSON + await addLayerWithData(data, name) + } else { + alert(`Unsupported file format: ${file.name}`) + } + } catch (err) { + console.error(`Failed to load dropped file ${file.name}:`, err) + alert(`Failed to load ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`) + } + } + }, [addLayer]) + + // Handle identify click + const handleMapClick = useCallback((e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + const tool = useUIStore.getState().activeTool + if (tool !== 'identify') return + + const map = backendRef.current?.getNativeMap() as maplibregl.Map | null + if (!map) return + + // Remove existing popup + if (popupRef.current) { + popupRef.current.remove() + popupRef.current = null + } + + // Query features at the click point + const projectLayers = useProjectStore.getState().layers + const queryLayerIds: string[] = [] + for (const layer of projectLayers) { + if (layer.visible && layer.type === 'geojson') { + const ids = [`${layer.id}-fill`, `${layer.id}-line`, `${layer.id}-point`] + for (const id of ids) { + if (map.getLayer(id)) queryLayerIds.push(id) + } + } + } + + if (queryLayerIds.length === 0) return + + const features = map.queryRenderedFeatures(e.point, { layers: queryLayerIds }) + if (features.length === 0) return + + const feature = features[0] + const properties = feature.properties || {} + + // Build popup content + let html = '
' + html += '' + for (const [key, value] of Object.entries(properties)) { + html += `` + html += `` + } + html += '
${key}${value}
' + + popupRef.current = new maplibregl.Popup({ maxWidth: '360px' }) + .setLngLat(e.lngLat) + .setHTML(html) + .addTo(map) + }, []) const initializeMap = useCallback(async () => { if (!containerRef.current) return @@ -18,13 +165,11 @@ export function MapCanvas() { setError(null) try { - // Clean up existing backend if (backendRef.current) { backendRef.current.destroy() backendRef.current = null } - // Create new backend const backend = await createBackend(backendType, containerRef.current, { center: view.center, zoom: view.zoom, @@ -35,14 +180,12 @@ export function MapCanvas() { backendRef.current = backend setBackend(backend) - // Set up event handlers backend.on('viewchange', (newView: unknown) => { const v = newView as { zoom: number } setZoom(v.zoom) useProjectStore.getState().setView(newView as { center: [number, number]; zoom: number }) }) - // Track cursor position const nativeMap = backend.getNativeMap() as maplibregl.Map | null if (nativeMap && 'on' in nativeMap) { nativeMap.on('mousemove', (e: { lngLat: { lng: number; lat: number } }) => { @@ -52,9 +195,11 @@ export function MapCanvas() { nativeMap.on('mouseout', () => { setCursorPosition(null) }) + + // Identify click handler + nativeMap.on('click', handleMapClick as (e: maplibregl.MapMouseEvent) => void) } - // Add existing layers for (const layer of layers) { try { await backend.addLayer(layer) @@ -70,12 +215,16 @@ export function MapCanvas() { } finally { setLoading(false) } - }, [backendType, setBackend, setCursorPosition, setZoom, setLoading, setError]) + }, [backendType, setBackend, setCursorPosition, setZoom, setLoading, setError, handleMapClick]) useEffect(() => { initializeMap() return () => { + if (popupRef.current) { + popupRef.current.remove() + popupRef.current = null + } if (backendRef.current) { backendRef.current.destroy() backendRef.current = null @@ -84,6 +233,36 @@ export function MapCanvas() { } }, [backendType]) + // Handle container resize (e.g., when attribute table opens/closes) + useEffect(() => { + const container = containerRef.current + if (!container) return + + const resizeObserver = new ResizeObserver(() => { + const map = backendRef.current?.getNativeMap() as maplibregl.Map | null + if (map) { + map.resize() + } + }) + + resizeObserver.observe(container) + return () => resizeObserver.disconnect() + }, []) + + // Update cursor style based on active tool + useEffect(() => { + const map = backendRef.current?.getNativeMap() as maplibregl.Map | null + if (!map) return + + if (activeTool === 'identify') { + map.getCanvas().style.cursor = 'help' + } else if (activeTool.startsWith('measure') || activeTool.startsWith('draw')) { + map.getCanvas().style.cursor = 'crosshair' + } else { + map.getCanvas().style.cursor = '' + } + }, [activeTool]) + // Sync layer changes useEffect(() => { const backend = backendRef.current @@ -93,14 +272,12 @@ export function MapCanvas() { const currentIds = new Set(currentLayers.map((l) => l.id)) const newIds = new Set(layers.map((l) => l.id)) - // Remove layers that no longer exist for (const layer of currentLayers) { if (!newIds.has(layer.id)) { backend.removeLayer(layer.id) } } - // Add or update layers for (const layer of layers) { if (!currentIds.has(layer.id)) { backend.addLayer(layer).catch(console.error) @@ -111,7 +288,23 @@ export function MapCanvas() { }, [layers]) return ( -
+
+ {/* Drag overlay */} + {isDragOver && ( +
+
+

Drop files to add layers

+

GeoJSON, Shapefile, KML, CSV

+
+
+ )} + {useMapStore.getState().isLoading && (
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 4f52b7e..0f83eea 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' type ViewType = 'landing' | 'map' -type ActiveTool = 'none' | 'measure-distance' | 'measure-area' | 'draw-point' | 'draw-line' | 'draw-polygon' +type ActiveTool = 'none' | 'measure-distance' | 'measure-area' | 'draw-point' | 'draw-line' | 'draw-polygon' | 'identify' | 'select' interface MeasureResult { type: 'distance' | 'area' @@ -20,6 +20,17 @@ interface UIState { activeTool: ActiveTool measureResult: MeasureResult | null + // Dialog states + showCommandPalette: boolean + showGoToCoordinates: boolean + showExportDialog: boolean + showSettings: boolean + showAttributeTable: boolean + attributeTableLayerId: string | null + showStyleEditor: boolean + styleEditorLayerId: string | null + coordinateFormat: 'decimal' | 'dms' + setView: (view: ViewType) => void setSidebarOpen: (open: boolean) => void setSidebarWidth: (width: number) => void @@ -31,6 +42,15 @@ interface UIState { setActiveTool: (tool: ActiveTool) => void setMeasureResult: (result: MeasureResult | null) => void clearMeasurement: () => void + + // Dialog actions + setShowCommandPalette: (show: boolean) => void + setShowGoToCoordinates: (show: boolean) => void + setShowExportDialog: (show: boolean) => void + setShowSettings: (show: boolean) => void + setShowAttributeTable: (show: boolean, layerId?: string | null) => void + setShowStyleEditor: (show: boolean, layerId?: string | null) => void + setCoordinateFormat: (format: 'decimal' | 'dms') => void } export const useUIStore = create((set) => ({ @@ -43,6 +63,16 @@ export const useUIStore = create((set) => ({ activeTool: 'none', measureResult: null, + showCommandPalette: false, + showGoToCoordinates: false, + showExportDialog: false, + showSettings: false, + showAttributeTable: false, + attributeTableLayerId: null, + showStyleEditor: false, + styleEditorLayerId: null, + coordinateFormat: 'decimal', + setView: (view) => set({ view }), setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }), setSidebarWidth: (sidebarWidth) => set({ sidebarWidth }), @@ -53,5 +83,19 @@ export const useUIStore = create((set) => ({ toggleLayerPanel: () => set((state) => ({ layerPanelOpen: !state.layerPanelOpen })), setActiveTool: (activeTool) => set({ activeTool }), setMeasureResult: (measureResult) => set({ measureResult }), - clearMeasurement: () => set({ measureResult: null, activeTool: 'none' }) + clearMeasurement: () => set({ measureResult: null, activeTool: 'none' }), + + setShowCommandPalette: (showCommandPalette) => set({ showCommandPalette }), + setShowGoToCoordinates: (showGoToCoordinates) => set({ showGoToCoordinates }), + setShowExportDialog: (showExportDialog) => set({ showExportDialog }), + setShowSettings: (showSettings) => set({ showSettings }), + setShowAttributeTable: (show, layerId) => set({ + showAttributeTable: show, + attributeTableLayerId: layerId !== undefined ? layerId : null + }), + setShowStyleEditor: (show, layerId) => set({ + showStyleEditor: show, + styleEditorLayerId: layerId !== undefined ? layerId : null + }), + setCoordinateFormat: (coordinateFormat) => set({ coordinateFormat }) })) diff --git a/src/utils/export.ts b/src/utils/export.ts new file mode 100644 index 0000000..488cd6f --- /dev/null +++ b/src/utils/export.ts @@ -0,0 +1,93 @@ +/** + * Export utilities for map and layer data. + */ + +/** + * Export the map canvas to a PNG image. + */ +export async function exportMapToPNG(map: maplibregl.Map): Promise { + return new Promise((resolve, reject) => { + try { + map.once('render', () => { + const canvas = map.getCanvas() + canvas.toBlob((blob) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create PNG blob')) + } + }, 'image/png') + }) + map.triggerRepaint() + } catch (e) { + reject(e) + } + }) +} + +/** + * Export GeoJSON data to a downloadable file. + */ +export function exportToGeoJSON(data: GeoJSON.GeoJSON, fileName: string): void { + const json = JSON.stringify(data, null, 2) + downloadString(json, `${fileName}.geojson`, 'application/geo+json') +} + +/** + * Export data as CSV. + */ +export function exportToCSV(features: GeoJSON.Feature[], fileName: string): void { + if (features.length === 0) return + + // Collect all unique property keys + const keys = new Set() + features.forEach(f => { + if (f.properties) Object.keys(f.properties).forEach(k => keys.add(k)) + }) + const headers = Array.from(keys) + + // Add geometry columns + headers.push('geometry_type', 'geometry_coordinates') + + const rows = features.map(f => { + const vals = Array.from(keys).map(k => { + const v = f.properties?.[k] + if (v === null || v === undefined) return '' + const str = String(v) + return str.includes(',') || str.includes('"') || str.includes('\n') + ? `"${str.replace(/"/g, '""')}"` + : str + }) + vals.push(f.geometry?.type || '') + const coordStr = f.geometry && 'coordinates' in f.geometry + ? JSON.stringify(f.geometry.coordinates) + : '' + vals.push(coordStr.includes(',') ? `"${coordStr.replace(/"/g, '""')}"` : coordStr) + return vals.join(',') + }) + + const csv = [headers.join(','), ...rows].join('\n') + downloadString(csv, `${fileName}.csv`, 'text/csv') +} + +/** + * Save a blob as a file via download. + */ +export function downloadBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +/** + * Download a string as a file. + */ +export function downloadString(content: string, fileName: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }) + downloadBlob(blob, fileName) +} diff --git a/src/utils/geo.ts b/src/utils/geo.ts new file mode 100644 index 0000000..6c84f47 --- /dev/null +++ b/src/utils/geo.ts @@ -0,0 +1,183 @@ +/** + * Shared geospatial utility functions. + */ + +/** + * Calculate bounding box from GeoJSON data. + */ +export function calculateBounds(geojson: GeoJSON.GeoJSON): [[number, number], [number, number]] | null { + const coords: [number, number][] = [] + + function extractCoords(geometry: GeoJSON.Geometry) { + switch (geometry.type) { + case 'Point': + coords.push(geometry.coordinates as [number, number]) + break + case 'MultiPoint': + case 'LineString': + (geometry.coordinates as [number, number][]).forEach(c => coords.push(c)) + break + case 'MultiLineString': + case 'Polygon': + (geometry.coordinates as [number, number][][]).forEach(ring => + ring.forEach(c => coords.push(c)) + ) + break + case 'MultiPolygon': + (geometry.coordinates as [number, number][][][]).forEach(polygon => + polygon.forEach(ring => ring.forEach(c => coords.push(c))) + ) + break + case 'GeometryCollection': + geometry.geometries.forEach(g => extractCoords(g)) + break + } + } + + if (geojson.type === 'Feature') { + if (geojson.geometry) extractCoords(geojson.geometry) + } else if (geojson.type === 'FeatureCollection') { + geojson.features.forEach(f => { + if (f.geometry) extractCoords(f.geometry) + }) + } else { + extractCoords(geojson as GeoJSON.Geometry) + } + + if (coords.length === 0) return null + + let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity + + coords.forEach(([lng, lat]) => { + if (lng < minLng) minLng = lng + if (lng > maxLng) maxLng = lng + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + }) + + return [[minLng, minLat], [maxLng, maxLat]] +} + +/** + * Get the geometry type(s) present in a GeoJSON FeatureCollection. + */ +export function getGeometryTypes(geojson: GeoJSON.GeoJSON): string[] { + const types = new Set() + + if (geojson.type === 'Feature') { + if (geojson.geometry) types.add(geojson.geometry.type) + } else if (geojson.type === 'FeatureCollection') { + geojson.features.forEach(f => { + if (f.geometry) types.add(f.geometry.type) + }) + } else { + types.add((geojson as GeoJSON.Geometry).type) + } + + return Array.from(types) +} + +/** + * Get all unique property keys from a GeoJSON FeatureCollection. + */ +export function getPropertyKeys(geojson: GeoJSON.GeoJSON): string[] { + const keys = new Set() + + if (geojson.type === 'Feature') { + if (geojson.properties) Object.keys(geojson.properties).forEach(k => keys.add(k)) + } else if (geojson.type === 'FeatureCollection') { + geojson.features.forEach(f => { + if (f.properties) Object.keys(f.properties).forEach(k => keys.add(k)) + }) + } + + return Array.from(keys) +} + +/** + * Get features as rows for attribute table. + */ +export function getFeatureRows(geojson: GeoJSON.GeoJSON): { id: number; properties: Record; geometry: GeoJSON.Geometry | null }[] { + if (geojson.type === 'Feature') { + return [{ id: 0, properties: geojson.properties || {}, geometry: geojson.geometry }] + } else if (geojson.type === 'FeatureCollection') { + return geojson.features.map((f, i) => ({ + id: i, + properties: f.properties || {}, + geometry: f.geometry + })) + } + return [] +} + +/** + * Calculate basic statistics for a numeric field. + */ +export function calculateFieldStats(values: number[]): { + count: number + min: number + max: number + mean: number + median: number + sum: number + stdDev: number +} { + const sorted = [...values].sort((a, b) => a - b) + const count = sorted.length + const sum = sorted.reduce((a, b) => a + b, 0) + const mean = sum / count + const median = count % 2 === 0 + ? (sorted[count / 2 - 1] + sorted[count / 2]) / 2 + : sorted[Math.floor(count / 2)] + const variance = sorted.reduce((acc, val) => acc + (val - mean) ** 2, 0) / count + const stdDev = Math.sqrt(variance) + + return { count, min: sorted[0], max: sorted[count - 1], mean, median, sum, stdDev } +} + +/** + * Format coordinate display. + */ +export function formatCoordinate(lng: number, lat: number, format: 'decimal' | 'dms' = 'decimal'): string { + if (format === 'dms') { + return `${toDMS(lat, 'lat')} ${toDMS(lng, 'lng')}` + } + return `${lng.toFixed(6)}, ${lat.toFixed(6)}` +} + +function toDMS(decimal: number, type: 'lat' | 'lng'): string { + const absolute = Math.abs(decimal) + const degrees = Math.floor(absolute) + const minutesFloat = (absolute - degrees) * 60 + const minutes = Math.floor(minutesFloat) + const seconds = ((minutesFloat - minutes) * 60).toFixed(1) + + const direction = type === 'lat' + ? decimal >= 0 ? 'N' : 'S' + : decimal >= 0 ? 'E' : 'W' + + return `${degrees}°${minutes}'${seconds}"${direction}` +} + +/** + * Calculate approximate map scale at a given latitude and zoom level. + */ +export function calculateScale(lat: number, zoom: number): number { + const metersPerPixel = (156543.03392 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, zoom) + // Assume 96 DPI screen (0.0254 m per inch / 96 pixels per inch) + const dpi = 96 + const metersPerInch = 0.0254 + return Math.round(metersPerPixel * dpi / metersPerInch) +} + +/** + * Format scale as a readable string. + */ +export function formatScale(scale: number): string { + if (scale >= 1000000) { + return `1:${(scale / 1000000).toFixed(1)}M` + } else if (scale >= 1000) { + return `1:${(scale / 1000).toFixed(0)}K` + } + return `1:${scale}` +} diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts new file mode 100644 index 0000000..b6b641b --- /dev/null +++ b/src/utils/parsers.ts @@ -0,0 +1,355 @@ +/** + * File parsing utilities for KML, CSV, and other formats. + */ + +/** + * Parse KML string into GeoJSON. + */ +export function parseKML(kmlString: string): GeoJSON.FeatureCollection { + const parser = new DOMParser() + const doc = parser.parseFromString(kmlString, 'text/xml') + + const features: GeoJSON.Feature[] = [] + + // Parse Placemarks + const placemarks = doc.getElementsByTagName('Placemark') + for (let i = 0; i < placemarks.length; i++) { + const placemark = placemarks[i] + const feature = parsePlacemark(placemark) + if (feature) features.push(feature) + } + + return { type: 'FeatureCollection', features } +} + +function parsePlacemark(placemark: Element): GeoJSON.Feature | null { + const properties: Record = {} + + // Extract name + const name = placemark.getElementsByTagName('name')[0] + if (name?.textContent) properties.name = name.textContent + + // Extract description + const description = placemark.getElementsByTagName('description')[0] + if (description?.textContent) properties.description = description.textContent + + // Extract ExtendedData + const simpleData = placemark.getElementsByTagName('SimpleData') + for (let i = 0; i < simpleData.length; i++) { + const attr = simpleData[i].getAttribute('name') + if (attr) properties[attr] = simpleData[i].textContent + } + + const data = placemark.getElementsByTagName('Data') + for (let i = 0; i < data.length; i++) { + const attr = data[i].getAttribute('name') + const value = data[i].getElementsByTagName('value')[0] + if (attr && value?.textContent) properties[attr] = value.textContent + } + + // Extract geometry + const geometry = parseGeometry(placemark) + if (!geometry) return null + + return { type: 'Feature', properties, geometry } +} + +function parseGeometry(element: Element): GeoJSON.Geometry | null { + // Point + const point = element.getElementsByTagName('Point')[0] + if (point) { + const coords = parseCoordinateString( + point.getElementsByTagName('coordinates')[0]?.textContent || '' + ) + if (coords.length > 0) { + return { type: 'Point', coordinates: coords[0] } + } + } + + // LineString + const line = element.getElementsByTagName('LineString')[0] + if (line) { + const coords = parseCoordinateString( + line.getElementsByTagName('coordinates')[0]?.textContent || '' + ) + if (coords.length > 0) { + return { type: 'LineString', coordinates: coords } + } + } + + // Polygon + const polygon = element.getElementsByTagName('Polygon')[0] + if (polygon) { + const rings: number[][] [] = [] + const outerBoundary = polygon.getElementsByTagName('outerBoundaryIs')[0] + if (outerBoundary) { + const coords = parseCoordinateString( + outerBoundary.getElementsByTagName('coordinates')[0]?.textContent || '' + ) + if (coords.length > 0) rings.push(coords) + } + const innerBoundaries = polygon.getElementsByTagName('innerBoundaryIs') + for (let i = 0; i < innerBoundaries.length; i++) { + const coords = parseCoordinateString( + innerBoundaries[i].getElementsByTagName('coordinates')[0]?.textContent || '' + ) + if (coords.length > 0) rings.push(coords) + } + if (rings.length > 0) { + return { type: 'Polygon', coordinates: rings } + } + } + + // MultiGeometry + const multi = element.getElementsByTagName('MultiGeometry')[0] + if (multi) { + const geometries: GeoJSON.Geometry[] = [] + for (let i = 0; i < multi.children.length; i++) { + const geom = parseGeometry(multi.children[i] as Element) + if (geom) geometries.push(geom) + } + if (geometries.length > 0) { + return { type: 'GeometryCollection', geometries } + } + } + + return null +} + +function parseCoordinateString(coordString: string): number[][] { + return coordString + .trim() + .split(/\s+/) + .filter(Boolean) + .map((tuple) => { + const parts = tuple.split(',').map(Number) + // KML is lng,lat,alt + return parts.length >= 2 ? [parts[0], parts[1], ...(parts[2] !== undefined ? [parts[2]] : [])] : [] + }) + .filter((c) => c.length >= 2 && !isNaN(c[0]) && !isNaN(c[1])) +} + +/** + * Parse KMZ (zipped KML) buffer into GeoJSON. + * KMZ is a ZIP file containing doc.kml + */ +export async function parseKMZ(buffer: ArrayBuffer): Promise { + // KMZ files are ZIP archives. We need to extract the KML from the ZIP. + // Use the browser's built-in DecompressionStream for simple ZIP handling + // or a minimal ZIP parser + const bytes = new Uint8Array(buffer) + + // Find KML content in ZIP - look for PK header and file entries + const kmlContent = extractKMLFromZip(bytes) + if (!kmlContent) { + throw new Error('No KML file found in KMZ archive') + } + + return parseKML(kmlContent) +} + +function extractKMLFromZip(data: Uint8Array): string | null { + // Minimal ZIP parser - find local file headers and extract .kml files + let offset = 0 + const decoder = new TextDecoder() + + while (offset < data.length - 4) { + // Look for local file header signature (PK\x03\x04) + if (data[offset] === 0x50 && data[offset + 1] === 0x4b && + data[offset + 2] === 0x03 && data[offset + 3] === 0x04) { + + const compressionMethod = data[offset + 8] | (data[offset + 9] << 8) + const compressedSize = data[offset + 18] | (data[offset + 19] << 8) | + (data[offset + 20] << 16) | (data[offset + 21] << 24) + const fileNameLength = data[offset + 26] | (data[offset + 27] << 8) + const extraFieldLength = data[offset + 28] | (data[offset + 29] << 8) + + const fileName = decoder.decode(data.slice(offset + 30, offset + 30 + fileNameLength)) + const dataStart = offset + 30 + fileNameLength + extraFieldLength + + if (fileName.toLowerCase().endsWith('.kml') && compressionMethod === 0) { + // Stored (not compressed) + const fileData = data.slice(dataStart, dataStart + compressedSize) + return decoder.decode(fileData) + } + + offset = dataStart + compressedSize + } else { + offset++ + } + } + + return null +} + +/** + * Parse CSV string with coordinate columns into GeoJSON. + */ +export function parseCSV( + csvString: string, + options: { + latColumn?: string + lngColumn?: string + delimiter?: string + } = {} +): GeoJSON.FeatureCollection { + const delimiter = options.delimiter || detectDelimiter(csvString) + const lines = csvString.trim().split('\n') + + if (lines.length < 2) { + throw new Error('CSV must have at least a header row and one data row') + } + + const headers = parseCSVRow(lines[0], delimiter) + + // Auto-detect lat/lng columns + const latCol = options.latColumn || detectLatColumn(headers) + const lngCol = options.lngColumn || detectLngColumn(headers) + + if (!latCol || !lngCol) { + throw new Error( + `Could not detect coordinate columns. Found headers: ${headers.join(', ')}. ` + + `Please specify latitude and longitude column names.` + ) + } + + const latIdx = headers.indexOf(latCol) + const lngIdx = headers.indexOf(lngCol) + + const features: GeoJSON.Feature[] = [] + + for (let i = 1; i < lines.length; i++) { + const values = parseCSVRow(lines[i], delimiter) + if (values.length < Math.max(latIdx, lngIdx) + 1) continue + + const lat = parseFloat(values[latIdx]) + const lng = parseFloat(values[lngIdx]) + + if (isNaN(lat) || isNaN(lng)) continue + + const properties: Record = {} + headers.forEach((header, idx) => { + if (idx !== latIdx && idx !== lngIdx) { + const val = values[idx] + const num = Number(val) + properties[header] = val !== '' && !isNaN(num) && val === String(num) ? num : val + } + }) + + // Also include lat/lng in properties + properties[latCol] = lat + properties[lngCol] = lng + + features.push({ + type: 'Feature', + properties, + geometry: { + type: 'Point', + coordinates: [lng, lat] + } + }) + } + + if (features.length === 0) { + throw new Error('No valid coordinate rows found in CSV') + } + + return { type: 'FeatureCollection', features } +} + +function parseCSVRow(row: string, delimiter: string): string[] { + const result: string[] = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < row.length; i++) { + const char = row[i] + if (char === '"') { + if (inQuotes && row[i + 1] === '"') { + current += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (char === delimiter && !inQuotes) { + result.push(current.trim()) + current = '' + } else { + current += char + } + } + result.push(current.trim()) + return result +} + +function detectDelimiter(csv: string): string { + const firstLine = csv.split('\n')[0] + const commas = (firstLine.match(/,/g) || []).length + const tabs = (firstLine.match(/\t/g) || []).length + const semicolons = (firstLine.match(/;/g) || []).length + + if (tabs > commas && tabs > semicolons) return '\t' + if (semicolons > commas) return ';' + return ',' +} + +const LAT_PATTERNS = ['latitude', 'lat', 'y', 'lat_dd', 'latitude_dd', 'point_y', 'lat_d'] +const LNG_PATTERNS = ['longitude', 'lng', 'lon', 'long', 'x', 'lng_dd', 'longitude_dd', 'point_x', 'lon_d'] + +function detectLatColumn(headers: string[]): string | undefined { + const lower = headers.map(h => h.toLowerCase().trim()) + for (const pattern of LAT_PATTERNS) { + const idx = lower.indexOf(pattern) + if (idx !== -1) return headers[idx] + } + return undefined +} + +function detectLngColumn(headers: string[]): string | undefined { + const lower = headers.map(h => h.toLowerCase().trim()) + for (const pattern of LNG_PATTERNS) { + const idx = lower.indexOf(pattern) + if (idx !== -1) return headers[idx] + } + return undefined +} + +/** + * Detect file type from extension. + */ +export function detectFileType(fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase() || '' + const typeMap: Record = { + geojson: 'geojson', + json: 'geojson', + kml: 'kml', + kmz: 'kmz', + csv: 'csv', + tsv: 'csv', + shp: 'shapefile', + zip: 'shapefile', + tif: 'geotiff', + tiff: 'geotiff', + gpkg: 'geopackage', + } + return typeMap[ext] || 'unknown' +} + +/** + * Get CSV column headers for user selection. + */ +export function getCSVHeaders(csvString: string): string[] { + const delimiter = detectDelimiter(csvString) + const firstLine = csvString.split('\n')[0] + return parseCSVRow(firstLine, delimiter) +} + +/** + * Auto-detect lat/lng column names from CSV headers. + */ +export function autoDetectCoordinateColumns(headers: string[]): { lat?: string; lng?: string } { + return { + lat: detectLatColumn(headers), + lng: detectLngColumn(headers) + } +}