etape laptop
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebCarto</title>
|
||||
</head>
|
||||
<body class="bg-gruvbox-bg text-gruvbox-fg">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API vers le backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
}
|
||||
4394
frontend/package-lock.json
generated
Normal file
4394
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tmcw/togeojson": "^7.1.2",
|
||||
"dompurify": "^3.3.1",
|
||||
"maplibre-gl": "^5.17.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
110
frontend/src/App.tsx
Normal file
110
frontend/src/App.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Header from './components/Header'
|
||||
import MapView from './components/MapView'
|
||||
import LayerPanel from './components/LayerPanel'
|
||||
import PropertyPanel from './components/PropertyPanel'
|
||||
import StatusBar from './components/StatusBar'
|
||||
import ToastContainer from './components/ToastContainer'
|
||||
import ImportDialog from './components/ImportDialog'
|
||||
import { useMapStore } from './stores/mapStore'
|
||||
import { api } from './api/client'
|
||||
import type { FeatureCollection } from 'geojson'
|
||||
|
||||
export default function App() {
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const selectedDatasetId = useMapStore((s) => s.selectedDatasetId)
|
||||
const datasets = useMapStore((s) => s.datasets)
|
||||
const addToast = useMapStore((s) => s.addToast)
|
||||
const setDatasets = useMapStore((s) => s.setDatasets)
|
||||
const setFeatureCollection = useMapStore((s) => s.setFeatureCollection)
|
||||
const toggleDatasetVisibility = useMapStore((s) => s.toggleDatasetVisibility)
|
||||
const setStatusMessage = useMapStore((s) => s.setStatusMessage)
|
||||
|
||||
// Charger les datasets existants depuis le backend au démarrage
|
||||
useEffect(() => {
|
||||
const loadDatasets = async () => {
|
||||
try {
|
||||
setStatusMessage('Chargement des données...')
|
||||
const list = await api.listDatasets()
|
||||
if (list.length === 0) {
|
||||
setStatusMessage('Prêt')
|
||||
return
|
||||
}
|
||||
setDatasets(list)
|
||||
|
||||
// Charger les features de chaque dataset
|
||||
for (const ds of list) {
|
||||
const detail = await api.getDataset(ds.id)
|
||||
const fc: FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: detail.features.map((f) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: f.geometry,
|
||||
properties: { ...f.properties, id: f.id },
|
||||
})),
|
||||
}
|
||||
setFeatureCollection(ds.id, fc)
|
||||
toggleDatasetVisibility(ds.id)
|
||||
}
|
||||
setStatusMessage('Prêt')
|
||||
} catch (e) {
|
||||
addToast(`Erreur de chargement: ${e instanceof Error ? e.message : 'inconnu'}`, 'error')
|
||||
setStatusMessage('Erreur de chargement')
|
||||
}
|
||||
}
|
||||
loadDatasets()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleExport = async () => {
|
||||
const dsId = selectedDatasetId ?? datasets[0]?.id
|
||||
if (!dsId) return
|
||||
try {
|
||||
const blob = await api.exportDataset(dsId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `export-${dsId}.geojson`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
addToast('Export téléchargé', 'success')
|
||||
} catch (e) {
|
||||
addToast(`Erreur export: ${e instanceof Error ? e.message : 'inconnu'}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
addToast('Toutes les modifications sont sauvegardées automatiquement', 'info')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen">
|
||||
<Header
|
||||
onImport={() => setImportOpen(true)}
|
||||
onExport={handleExport}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Volet gauche */}
|
||||
<div className="w-64 shrink-0">
|
||||
<LayerPanel />
|
||||
</div>
|
||||
|
||||
{/* Carte */}
|
||||
<div className="flex-1 relative">
|
||||
<MapView />
|
||||
</div>
|
||||
|
||||
{/* Volet droit */}
|
||||
<div className="w-72 shrink-0">
|
||||
<PropertyPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusBar />
|
||||
<ToastContainer />
|
||||
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
frontend/src/api/client.ts
Normal file
97
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`API ${res.status}: ${body}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface DatasetResponse {
|
||||
id: number
|
||||
name: string
|
||||
feature_count: number
|
||||
created_at: string
|
||||
bbox: [number, number, number, number] | null
|
||||
}
|
||||
|
||||
export interface DatasetDetailResponse extends DatasetResponse {
|
||||
features: Array<{
|
||||
id: number
|
||||
geometry: GeoJSON.Geometry
|
||||
properties: Record<string, unknown>
|
||||
}>
|
||||
raw_filename: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listDatasets: () => request<DatasetResponse[]>('/datasets'),
|
||||
|
||||
getDataset: (id: number) => request<DatasetDetailResponse>(`/datasets/${id}`),
|
||||
|
||||
importDataset: async (file: File, geojson: GeoJSON.FeatureCollection) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('geojson', JSON.stringify(geojson))
|
||||
const res = await fetch(`${API_BASE}/datasets/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`API ${res.status}: ${body}`)
|
||||
}
|
||||
return res.json() as Promise<DatasetResponse>
|
||||
},
|
||||
|
||||
updateFeature: (id: number, data: { geometry?: GeoJSON.Geometry; properties?: Record<string, unknown> }) =>
|
||||
request<{ id: number; version: number }>(`/features/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
exportDataset: async (id: number, format: string = 'geojson') => {
|
||||
const res = await fetch(`${API_BASE}/datasets/${id}/export?format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export failed: ${res.status}`)
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
uploadImage: async (featureId: number, file: File): Promise<{ images: string[] }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch(`${API_BASE}/images/features/${featureId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
deleteImage: async (featureId: number, filename: string): Promise<{ images: string[] }> => {
|
||||
const res = await fetch(`${API_BASE}/images/features/${featureId}/${filename}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
deleteFeature: (id: number) =>
|
||||
request<{ ok: boolean }>(`/features/${id}`, { method: 'DELETE' }),
|
||||
|
||||
deleteDataset: (id: number) =>
|
||||
request<{ ok: boolean }>(`/datasets/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getMapSettings: () =>
|
||||
request<{ center_lng: number; center_lat: number; zoom: number; base_layer: string }>('/settings/map'),
|
||||
|
||||
saveMapSettings: (data: { center_lng?: number; center_lat?: number; zoom?: number; base_layer?: string }) =>
|
||||
request<{ center_lng: number; center_lat: number; zoom: number; base_layer: string }>('/settings/map', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
}
|
||||
126
frontend/src/components/Header.tsx
Normal file
126
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
import { api } from '../api/client'
|
||||
import type { BaseLayerType } from '../stores/mapStore'
|
||||
|
||||
interface HeaderProps {
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const LAYER_GROUPS: Array<{ label: string; layers: Array<{ key: BaseLayerType; label: string }> }> = [
|
||||
{
|
||||
label: 'OSM',
|
||||
layers: [
|
||||
{ key: 'vector', label: 'Vecteur' },
|
||||
{ key: 'satellite', label: 'Satellite' },
|
||||
{ key: 'hybrid', label: 'Hybride' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Google',
|
||||
layers: [
|
||||
{ key: 'google-roadmap', label: 'Routes' },
|
||||
{ key: 'google-satellite', label: 'Satellite' },
|
||||
{ key: 'google-hybrid', label: 'Hybride' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Header({ onImport, onExport, onSave }: HeaderProps) {
|
||||
const baseLayer = useMapStore((s) => s.baseLayer)
|
||||
const setBaseLayer = useMapStore((s) => s.setBaseLayer)
|
||||
const datasets = useMapStore((s) => s.datasets)
|
||||
const undoStack = useMapStore((s) => s.undoStack)
|
||||
const popUndo = useMapStore((s) => s.popUndo)
|
||||
const featureCollections = useMapStore((s) => s.featureCollections)
|
||||
const setFeatureCollection = useMapStore((s) => s.setFeatureCollection)
|
||||
const addToast = useMapStore((s) => s.addToast)
|
||||
|
||||
const handleUndo = async () => {
|
||||
const entry = popUndo()
|
||||
if (!entry) return
|
||||
const fc = featureCollections[entry.datasetId]
|
||||
if (!fc) return
|
||||
const updatedFeatures = fc.features.map((f) => {
|
||||
if (String(f.properties?.id ?? f.id) !== entry.featureId) return f
|
||||
return { ...f, geometry: entry.previousGeometry }
|
||||
})
|
||||
setFeatureCollection(entry.datasetId, { ...fc, features: updatedFeatures })
|
||||
|
||||
// Persister le undo via API
|
||||
const feature = updatedFeatures.find((f) => String(f.properties?.id ?? f.id) === entry.featureId)
|
||||
if (feature?.properties?.id) {
|
||||
try {
|
||||
await api.updateFeature(feature.properties.id as number, { geometry: entry.previousGeometry })
|
||||
addToast('Undo effectué', 'success')
|
||||
} catch {
|
||||
addToast('Erreur lors du undo', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2 bg-gruvbox-bg0-h border-b border-gruvbox-bg3">
|
||||
<h1 className="text-gruvbox-yellow font-bold text-lg tracking-wide">WebCarto</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Sélecteur de fond de carte */}
|
||||
{LAYER_GROUPS.map((group) => (
|
||||
<div key={group.label} className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-gruvbox-fg4">{group.label}</span>
|
||||
<div className="flex bg-gruvbox-bg1 rounded overflow-hidden border border-gruvbox-bg3">
|
||||
{group.layers.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setBaseLayer(key)}
|
||||
className={`px-2 py-1 text-[11px] transition-colors ${
|
||||
baseLayer === key
|
||||
? 'bg-gruvbox-bg3 text-gruvbox-fg0'
|
||||
: 'text-gruvbox-fg4 hover:bg-gruvbox-bg2 hover:text-gruvbox-fg2'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="w-px h-6 bg-gruvbox-bg3 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="px-3 py-1 text-xs bg-gruvbox-blue text-gruvbox-bg0-h rounded hover:brightness-110 transition"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={datasets.length === 0}
|
||||
className="px-3 py-1 text-xs bg-gruvbox-aqua text-gruvbox-bg0-h rounded hover:brightness-110 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={datasets.length === 0}
|
||||
className="px-3 py-1 text-xs bg-gruvbox-green text-gruvbox-bg0-h rounded hover:brightness-110 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-gruvbox-bg3 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
disabled={undoStack.length === 0}
|
||||
className="px-3 py-1 text-xs bg-gruvbox-purple text-gruvbox-bg0-h rounded hover:brightness-110 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Annuler le dernier déplacement"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
138
frontend/src/components/ImportDialog.tsx
Normal file
138
frontend/src/components/ImportDialog.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { parseKml } from '../utils/kmlParser'
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
import { api } from '../api/client'
|
||||
import type { FeatureCollection } from 'geojson'
|
||||
|
||||
interface ImportDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ImportDialog({ open, onClose }: ImportDialogProps) {
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const addDataset = useMapStore((s) => s.addDataset)
|
||||
const setFeatureCollection = useMapStore((s) => s.setFeatureCollection)
|
||||
const toggleDatasetVisibility = useMapStore((s) => s.toggleDatasetVisibility)
|
||||
const visibleDatasets = useMapStore((s) => s.visibleDatasets)
|
||||
const addToast = useMapStore((s) => s.addToast)
|
||||
const setStatusMessage = useMapStore((s) => s.setStatusMessage)
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
setImporting(true)
|
||||
setStatusMessage(`Import de ${file.name}...`)
|
||||
try {
|
||||
const text = await file.text()
|
||||
let geojson: FeatureCollection
|
||||
|
||||
if (file.name.endsWith('.kml')) {
|
||||
geojson = parseKml(text)
|
||||
} else {
|
||||
geojson = JSON.parse(text) as FeatureCollection
|
||||
if (geojson.type !== 'FeatureCollection') {
|
||||
throw new Error('Le fichier doit être un FeatureCollection GeoJSON valide')
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter un ID à chaque feature si absent
|
||||
geojson.features.forEach((f, i) => {
|
||||
if (!f.properties) f.properties = {}
|
||||
if (!f.properties.id) f.properties._localIndex = i
|
||||
})
|
||||
|
||||
const dataset = await api.importDataset(file, geojson)
|
||||
addDataset(dataset)
|
||||
|
||||
// Re-fetch pour avoir les IDs serveur
|
||||
const detail = await api.getDataset(dataset.id)
|
||||
const fc: FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: detail.features.map((f) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: f.geometry,
|
||||
properties: { ...f.properties, id: f.id },
|
||||
})),
|
||||
}
|
||||
setFeatureCollection(dataset.id, fc)
|
||||
if (!visibleDatasets.has(dataset.id)) {
|
||||
toggleDatasetVisibility(dataset.id)
|
||||
}
|
||||
|
||||
addToast(`${file.name} importé (${dataset.feature_count} features)`, 'success')
|
||||
setStatusMessage('Prêt')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Erreur inconnue'
|
||||
addToast(`Erreur d'import: ${msg}`, 'error')
|
||||
setStatusMessage('Erreur lors de l\'import')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [addDataset, setFeatureCollection, toggleDatasetVisibility, visibleDatasets, addToast, setStatusMessage, onClose])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-gruvbox-bg1 border border-gruvbox-bg3 rounded-lg shadow-2xl p-6 w-[420px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-gruvbox-yellow font-bold mb-4">Importer un fichier</h2>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragOver
|
||||
? 'border-gruvbox-blue bg-gruvbox-bg2'
|
||||
: 'border-gruvbox-bg3 hover:border-gruvbox-bg4'
|
||||
}`}
|
||||
>
|
||||
{importing ? (
|
||||
<p className="text-gruvbox-fg3 text-sm">Import en cours...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gruvbox-fg3 text-sm mb-2">
|
||||
Glissez un fichier ici
|
||||
</p>
|
||||
<p className="text-gruvbox-fg4 text-xs mb-3">GeoJSON, KML</p>
|
||||
<label className="px-4 py-2 bg-gruvbox-blue text-gruvbox-bg0-h rounded cursor-pointer text-sm hover:brightness-110 transition">
|
||||
Parcourir
|
||||
<input
|
||||
type="file"
|
||||
accept=".geojson,.json,.kml"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1 text-sm text-gruvbox-fg4 hover:text-gruvbox-fg transition"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
frontend/src/components/LayerPanel.tsx
Normal file
243
frontend/src/components/LayerPanel.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react'
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function LayerPanel() {
|
||||
const datasets = useMapStore((s) => s.datasets)
|
||||
const visibleDatasets = useMapStore((s) => s.visibleDatasets)
|
||||
const toggleVisibility = useMapStore((s) => s.toggleDatasetVisibility)
|
||||
const selectedDatasetId = useMapStore((s) => s.selectedDatasetId)
|
||||
const selectedFeatureId = useMapStore((s) => s.selectedFeatureId)
|
||||
const selectFeature = useMapStore((s) => s.selectFeature)
|
||||
const featureCollections = useMapStore((s) => s.featureCollections)
|
||||
const removeDataset = useMapStore((s) => s.removeDataset)
|
||||
const removeFeature = useMapStore((s) => s.removeFeature)
|
||||
const hiddenFeatures = useMapStore((s) => s.hiddenFeatures)
|
||||
const toggleFeatureVisibility = useMapStore((s) => s.toggleFeatureVisibility)
|
||||
const addToast = useMapStore((s) => s.addToast)
|
||||
|
||||
const [expandedDatasets, setExpandedDatasets] = useState<Set<number>>(new Set())
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const handleDeleteDataset = async (ds: { id: number; name: string }) => {
|
||||
if (!window.confirm(`Supprimer le dataset "${ds.name}" et toutes ses features ?`)) return
|
||||
try {
|
||||
await api.deleteDataset(ds.id)
|
||||
removeDataset(ds.id)
|
||||
addToast(`Dataset "${ds.name}" supprimé`, 'success')
|
||||
} catch (e) {
|
||||
addToast(`Erreur: ${e instanceof Error ? e.message : 'inconnue'}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFeature = async (dsId: number, featureId: string, featureName: string) => {
|
||||
if (!window.confirm(`Supprimer "${featureName}" ?`)) return
|
||||
try {
|
||||
await api.deleteFeature(Number(featureId))
|
||||
removeFeature(dsId, featureId)
|
||||
addToast(`"${featureName}" supprimé`, 'success')
|
||||
} catch (e) {
|
||||
addToast(`Erreur: ${e instanceof Error ? e.message : 'inconnue'}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpanded = (dsId: number) => {
|
||||
const next = new Set(expandedDatasets)
|
||||
if (next.has(dsId)) next.delete(dsId)
|
||||
else next.add(dsId)
|
||||
setExpandedDatasets(next)
|
||||
}
|
||||
|
||||
const toggleFolder = (key: string) => {
|
||||
const next = new Set(expandedFolders)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
setExpandedFolders(next)
|
||||
}
|
||||
|
||||
// Grouper les features d'un dataset par dossier
|
||||
const groupByFolder = (dsId: number) => {
|
||||
const fc = featureCollections[dsId]
|
||||
if (!fc) return {}
|
||||
const groups: Record<string, typeof fc.features> = {}
|
||||
for (const f of fc.features) {
|
||||
const folder = (f.properties?._folder as string) || '_sans_dossier'
|
||||
if (!groups[folder]) groups[folder] = []
|
||||
const name = String(f.properties?.name ?? '')
|
||||
if (search && !name.toLowerCase().includes(search.toLowerCase())) continue
|
||||
groups[folder].push(f)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gruvbox-bg0-h border-r border-gruvbox-bg3">
|
||||
<div className="px-3 py-2 border-b border-gruvbox-bg3">
|
||||
<h2 className="text-xs font-bold text-gruvbox-fg4 uppercase tracking-wider mb-2">Couches</h2>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher..."
|
||||
className="w-full px-2 py-1 text-xs bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg placeholder-gruvbox-bg4 focus:border-gruvbox-blue outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{datasets.length === 0 ? (
|
||||
<p className="p-3 text-xs text-gruvbox-gray italic">
|
||||
Aucune couche. Importez un fichier GeoJSON ou KML.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{datasets.map((ds) => {
|
||||
const isExpanded = expandedDatasets.has(ds.id)
|
||||
const groups = isExpanded ? groupByFolder(ds.id) : {}
|
||||
const folderNames = Object.keys(groups).sort((a, b) =>
|
||||
a === '_sans_dossier' ? 1 : b === '_sans_dossier' ? -1 : a.localeCompare(b)
|
||||
)
|
||||
const hasFolders = folderNames.length > 1 || (folderNames.length === 1 && folderNames[0] !== '_sans_dossier')
|
||||
|
||||
return (
|
||||
<div key={ds.id} className="border-b border-gruvbox-bg2">
|
||||
{/* En-tête du dataset */}
|
||||
<div
|
||||
className={`group/ds flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors ${
|
||||
selectedDatasetId === ds.id && !selectedFeatureId
|
||||
? 'bg-gruvbox-bg2'
|
||||
: 'hover:bg-gruvbox-bg1'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpanded(ds.id) }}
|
||||
className="text-gruvbox-fg4 text-[10px] w-3"
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleVisibility(ds.id) }}
|
||||
className="shrink-0 transition-colors"
|
||||
title={visibleDatasets.has(ds.id) ? 'Masquer' : 'Afficher'}
|
||||
>
|
||||
{visibleDatasets.has(ds.id) ? (
|
||||
<svg className="w-4 h-4 text-gruvbox-fg4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-gruvbox-bg4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => { selectFeature(ds.id, null); if (!isExpanded) toggleExpanded(ds.id) }}
|
||||
>
|
||||
<p className="text-sm text-gruvbox-fg truncate">{ds.name}</p>
|
||||
<p className="text-[10px] text-gruvbox-fg4">{ds.feature_count} features</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteDataset(ds) }}
|
||||
className="text-gruvbox-fg4 hover:text-gruvbox-red text-xs opacity-0 group-hover/ds:opacity-100 transition-opacity shrink-0"
|
||||
title="Supprimer le dataset"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Liste des features groupées par dossier */}
|
||||
{isExpanded && (
|
||||
<div className="pb-1">
|
||||
{folderNames.map((folderName) => {
|
||||
const features = groups[folderName]
|
||||
if (!features || features.length === 0) return null
|
||||
const folderKey = `${ds.id}:${folderName}`
|
||||
const isFolderExpanded = expandedFolders.has(folderKey) || !hasFolders
|
||||
|
||||
return (
|
||||
<div key={folderKey}>
|
||||
{/* En-tête du dossier (seulement si plusieurs dossiers) */}
|
||||
{hasFolders && (
|
||||
<div
|
||||
onClick={() => toggleFolder(folderKey)}
|
||||
className="flex items-center gap-1 px-5 py-1 cursor-pointer hover:bg-gruvbox-bg1"
|
||||
>
|
||||
<span className="text-[10px] text-gruvbox-fg4 w-3">
|
||||
{isFolderExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span className="text-xs text-gruvbox-yellow truncate">
|
||||
{folderName === '_sans_dossier' ? 'Sans dossier' : folderName}
|
||||
</span>
|
||||
<span className="text-[10px] text-gruvbox-fg4 ml-auto">{features.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des features */}
|
||||
{isFolderExpanded && features.map((f) => {
|
||||
const fId = String(f.properties?.id ?? f.id)
|
||||
const isSelected = selectedDatasetId === ds.id && selectedFeatureId === fId
|
||||
const geomType = f.geometry?.type
|
||||
const geomIcon = geomType === 'Point' ? '●' : geomType === 'Polygon' ? '⬡' : '━'
|
||||
|
||||
const featureName = String(f.properties?.name ?? `Feature ${fId}`)
|
||||
const isHidden = hiddenFeatures.has(`${ds.id}-${fId}`)
|
||||
return (
|
||||
<div
|
||||
key={fId}
|
||||
onClick={() => selectFeature(ds.id, fId, true)}
|
||||
className={`group/feat flex items-center gap-2 py-1 cursor-pointer transition-colors ${
|
||||
hasFolders ? 'pl-9 pr-3' : 'pl-7 pr-3'
|
||||
} ${isSelected ? 'bg-gruvbox-bg2' : 'hover:bg-gruvbox-bg1'}`}
|
||||
>
|
||||
<span className={`text-[10px] ${
|
||||
geomType === 'Point' ? 'text-gruvbox-orange' :
|
||||
geomType === 'Polygon' ? 'text-gruvbox-green' : 'text-gruvbox-blue'
|
||||
}`}>
|
||||
{geomIcon}
|
||||
</span>
|
||||
<span className={`text-xs truncate flex-1 ${isHidden ? 'text-gruvbox-bg4' : 'text-gruvbox-fg2'}`}>
|
||||
{featureName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleFeatureVisibility(ds.id, fId) }}
|
||||
className="opacity-0 group-hover/feat:opacity-100 transition-opacity shrink-0"
|
||||
title={isHidden ? 'Afficher' : 'Masquer'}
|
||||
>
|
||||
{isHidden ? (
|
||||
<svg className="w-3 h-3 text-gruvbox-bg4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3 text-gruvbox-fg4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteFeature(ds.id, fId, featureName) }}
|
||||
className="text-gruvbox-fg4 hover:text-gruvbox-red text-[10px] opacity-0 group-hover/feat:opacity-100 transition-opacity shrink-0"
|
||||
title="Supprimer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
567
frontend/src/components/MapView.tsx
Normal file
567
frontend/src/components/MapView.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
import { api } from '../api/client'
|
||||
import type { BaseLayerType } from '../stores/mapStore'
|
||||
|
||||
const BASE_LAYERS: Record<BaseLayerType, { style: maplibregl.StyleSpecification }> = {
|
||||
vector: {
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'OSM Vector',
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||
},
|
||||
},
|
||||
satellite: {
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'Satellite',
|
||||
sources: {
|
||||
satellite: {
|
||||
type: 'raster',
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'satellite', type: 'raster', source: 'satellite' }],
|
||||
},
|
||||
},
|
||||
hybrid: {
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'Hybride',
|
||||
sources: {
|
||||
satellite: {
|
||||
type: 'raster',
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri',
|
||||
},
|
||||
labels: {
|
||||
type: 'raster',
|
||||
tiles: ['https://stamen-tiles.a.ssl.fastly.net/toner-labels/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© Stamen Design',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{ id: 'satellite', type: 'raster', source: 'satellite' },
|
||||
{ id: 'labels', type: 'raster', source: 'labels' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'google-roadmap': {
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'Google Roadmap',
|
||||
sources: {
|
||||
google: {
|
||||
type: 'raster',
|
||||
tiles: ['https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Google',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'google', type: 'raster', source: 'google' }],
|
||||
},
|
||||
},
|
||||
'google-satellite': {
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'Google Satellite',
|
||||
sources: {
|
||||
google: {
|
||||
type: 'raster',
|
||||
tiles: ['https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Google',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'google', type: 'raster', source: 'google' }],
|
||||
},
|
||||
},
|
||||
'google-hybrid': {
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'Google Hybride',
|
||||
sources: {
|
||||
google: {
|
||||
type: 'raster',
|
||||
tiles: ['https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Google',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'google', type: 'raster', source: 'google' }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default function MapView() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const draggingRef = useRef<{
|
||||
dsId: number
|
||||
featureId: string
|
||||
previousCoords: number[]
|
||||
} | null>(null)
|
||||
const baseLayer = useMapStore((s) => s.baseLayer)
|
||||
const featureCollections = useMapStore((s) => s.featureCollections)
|
||||
const visibleDatasets = useMapStore((s) => s.visibleDatasets)
|
||||
const selectFeature = useMapStore((s) => s.selectFeature)
|
||||
const selectedDatasetId = useMapStore((s) => s.selectedDatasetId)
|
||||
const selectedFeatureId = useMapStore((s) => s.selectedFeatureId)
|
||||
const selectionTrigger = useMapStore((s) => s.selectionTrigger)
|
||||
const mapReady = useMapStore((s) => s.mapReady)
|
||||
const hiddenFeatures = useMapStore((s) => s.hiddenFeatures)
|
||||
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Initialiser la carte avec les settings persistés
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const init = async () => {
|
||||
let center: [number, number] = [2.35, 48.85]
|
||||
let zoom = 5
|
||||
let savedBaseLayer: BaseLayerType | null = null
|
||||
|
||||
try {
|
||||
const settings = await api.getMapSettings()
|
||||
center = [settings.center_lng, settings.center_lat]
|
||||
zoom = settings.zoom
|
||||
if (settings.base_layer in BASE_LAYERS) {
|
||||
savedBaseLayer = settings.base_layer as BaseLayerType
|
||||
}
|
||||
} catch {
|
||||
// Utiliser les valeurs par défaut
|
||||
}
|
||||
|
||||
if (cancelled || !containerRef.current) return
|
||||
|
||||
if (savedBaseLayer && savedBaseLayer !== useMapStore.getState().baseLayer) {
|
||||
useMapStore.getState().setBaseLayer(savedBaseLayer)
|
||||
}
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: BASE_LAYERS[savedBaseLayer ?? baseLayer].style,
|
||||
center,
|
||||
zoom,
|
||||
})
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
map.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
||||
|
||||
// Sauvegarder la position avec debounce sur moveend
|
||||
map.on('moveend', () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const c = map.getCenter()
|
||||
api.saveMapSettings({
|
||||
center_lng: c.lng,
|
||||
center_lat: c.lat,
|
||||
zoom: map.getZoom(),
|
||||
}).catch(() => {})
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
mapRef.current = map
|
||||
// Signaler mapReady seulement après le chargement du style
|
||||
map.once('style.load', () => {
|
||||
if (!cancelled) useMapStore.getState().setMapReady(true)
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
useMapStore.getState().setMapReady(false)
|
||||
mapRef.current?.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Changer de fond de carte + persister
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
map.setStyle(BASE_LAYERS[baseLayer].style)
|
||||
api.saveMapSettings({ base_layer: baseLayer }).catch(() => {})
|
||||
}, [baseLayer])
|
||||
|
||||
// Mettre à jour les couches de données quand les features ou la visibilité changent
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
|
||||
const handler = () => {
|
||||
const style = map.getStyle()
|
||||
if (!style) return
|
||||
// Supprimer les anciennes couches de données
|
||||
for (const layer of style.layers ?? []) {
|
||||
if (layer.id.startsWith('dataset-')) {
|
||||
map.removeLayer(layer.id)
|
||||
}
|
||||
}
|
||||
for (const sourceId of Object.keys(style.sources ?? {})) {
|
||||
if (sourceId.startsWith('dataset-')) {
|
||||
map.removeSource(sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter les couches visibles
|
||||
for (const [dsIdStr, fc] of Object.entries(featureCollections)) {
|
||||
const dsId = Number(dsIdStr)
|
||||
if (!visibleDatasets.has(dsId)) continue
|
||||
|
||||
const sourceId = `dataset-${dsId}`
|
||||
const filteredFc = {
|
||||
...fc,
|
||||
features: fc.features.filter((f) => {
|
||||
const fId = String(f.properties?.id ?? f.id)
|
||||
return !hiddenFeatures.has(`${dsId}-${fId}`)
|
||||
}),
|
||||
}
|
||||
map.addSource(sourceId, { type: 'geojson', data: filteredFc })
|
||||
|
||||
// Points
|
||||
map.addLayer({
|
||||
id: `${sourceId}-points`,
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#fe8019',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ebdbb2',
|
||||
},
|
||||
})
|
||||
|
||||
// Lignes
|
||||
map.addLayer({
|
||||
id: `${sourceId}-lines`,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'LineString'],
|
||||
paint: {
|
||||
'line-color': '#83a598',
|
||||
'line-width': 3,
|
||||
},
|
||||
})
|
||||
|
||||
// Polygones
|
||||
map.addLayer({
|
||||
id: `${sourceId}-polygons-fill`,
|
||||
type: 'fill',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Polygon'],
|
||||
paint: {
|
||||
'fill-color': '#b8bb26',
|
||||
'fill-opacity': 0.3,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: `${sourceId}-polygons-outline`,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Polygon'],
|
||||
paint: {
|
||||
'line-color': '#b8bb26',
|
||||
'line-width': 2,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Appliquer après que le style soit chargé
|
||||
if (map.isStyleLoaded()) {
|
||||
handler()
|
||||
} else {
|
||||
map.once('style.load', handler)
|
||||
}
|
||||
}, [featureCollections, visibleDatasets, baseLayer, mapReady, hiddenFeatures])
|
||||
|
||||
// Surlignage de la feature sélectionnée
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
|
||||
const update = () => {
|
||||
const sourceId = 'selected-feature'
|
||||
const emptyFc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }
|
||||
|
||||
// Trouver la feature sélectionnée
|
||||
let selectedFeature: GeoJSON.Feature | null = null
|
||||
if (selectedDatasetId && selectedFeatureId) {
|
||||
const fc = featureCollections[selectedDatasetId]
|
||||
selectedFeature = fc?.features.find(
|
||||
(f) => String(f.properties?.id ?? f.id) === selectedFeatureId
|
||||
) ?? null
|
||||
}
|
||||
|
||||
const data: GeoJSON.FeatureCollection = selectedFeature
|
||||
? { type: 'FeatureCollection', features: [selectedFeature] }
|
||||
: emptyFc
|
||||
|
||||
if (map.getSource(sourceId)) {
|
||||
(map.getSource(sourceId) as maplibregl.GeoJSONSource).setData(data)
|
||||
} else {
|
||||
map.addSource(sourceId, { type: 'geojson', data })
|
||||
// Halo pour les points
|
||||
map.addLayer({
|
||||
id: 'selected-point-halo',
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 14,
|
||||
'circle-color': 'transparent',
|
||||
'circle-stroke-width': 3,
|
||||
'circle-stroke-color': '#fabd2f',
|
||||
},
|
||||
})
|
||||
// Contour pour les lignes
|
||||
map.addLayer({
|
||||
id: 'selected-line',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'LineString'],
|
||||
paint: {
|
||||
'line-color': '#fabd2f',
|
||||
'line-width': 5,
|
||||
},
|
||||
})
|
||||
// Remplissage + contour pour les polygones
|
||||
map.addLayer({
|
||||
id: 'selected-polygon-fill',
|
||||
type: 'fill',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Polygon'],
|
||||
paint: {
|
||||
'fill-color': '#fabd2f',
|
||||
'fill-opacity': 0.15,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'selected-polygon-outline',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Polygon'],
|
||||
paint: {
|
||||
'line-color': '#fabd2f',
|
||||
'line-width': 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (map.isStyleLoaded()) {
|
||||
update()
|
||||
} else {
|
||||
map.once('style.load', update)
|
||||
}
|
||||
}, [selectedDatasetId, selectedFeatureId, featureCollections, baseLayer, mapReady])
|
||||
|
||||
// Fly-to quand la sélection vient du LayerPanel
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !selectedDatasetId || !selectedFeatureId || selectionTrigger === 0) return
|
||||
|
||||
const fc = featureCollections[selectedDatasetId]
|
||||
const feature = fc?.features.find(
|
||||
(f) => String(f.properties?.id ?? f.id) === selectedFeatureId
|
||||
)
|
||||
if (!feature) return
|
||||
|
||||
const geom = feature.geometry
|
||||
if (geom.type === 'Point') {
|
||||
const coords = (geom as GeoJSON.Point).coordinates
|
||||
map.flyTo({ center: [coords[0], coords[1]], zoom: Math.max(map.getZoom(), 16) })
|
||||
} else {
|
||||
// Calculer le bbox manuellement
|
||||
const allCoords: number[][] = []
|
||||
const extractCoords = (c: unknown): void => {
|
||||
if (Array.isArray(c) && typeof c[0] === 'number') {
|
||||
allCoords.push(c as number[])
|
||||
} else if (Array.isArray(c)) {
|
||||
for (const sub of c) extractCoords(sub)
|
||||
}
|
||||
}
|
||||
extractCoords((geom as GeoJSON.Polygon | GeoJSON.LineString).coordinates)
|
||||
if (allCoords.length > 0) {
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity
|
||||
for (const [lng, lat] of allCoords) {
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lng > maxLng) maxLng = lng
|
||||
if (lat > maxLat) maxLat = lat
|
||||
}
|
||||
map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60 })
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectionTrigger])
|
||||
|
||||
// Clic sur feature
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||
const style = map.getStyle()
|
||||
if (!style) return
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: style.layers
|
||||
?.filter((l) => l.id.startsWith('dataset-'))
|
||||
.map((l) => l.id) ?? [],
|
||||
})
|
||||
if (features.length > 0) {
|
||||
const f = features[0]
|
||||
const sourceId = f.source as string
|
||||
const dsId = Number(sourceId.replace('dataset-', ''))
|
||||
const featureId = f.properties?.id ?? f.id
|
||||
selectFeature(dsId, String(featureId))
|
||||
} else {
|
||||
selectFeature(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
map.on('click', onClick)
|
||||
return () => { map.off('click', onClick) }
|
||||
}, [selectFeature])
|
||||
|
||||
// Drag des points sélectionnés
|
||||
const onDragMove = useCallback((e: maplibregl.MapMouseEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
const { dsId, featureId } = draggingRef.current
|
||||
const fc = useMapStore.getState().featureCollections[dsId]
|
||||
if (!fc) return
|
||||
|
||||
const updatedFeatures = fc.features.map((f) => {
|
||||
if (String(f.properties?.id ?? f.id) !== featureId) return f
|
||||
if (f.geometry.type !== 'Point') return f
|
||||
return {
|
||||
...f,
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat, (f.geometry as GeoJSON.Point).coordinates[2] ?? 0],
|
||||
},
|
||||
}
|
||||
})
|
||||
useMapStore.getState().setFeatureCollection(dsId, { ...fc, features: updatedFeatures })
|
||||
}, [])
|
||||
|
||||
const onDragEnd = useCallback(async (e: maplibregl.MapMouseEvent) => {
|
||||
const map = mapRef.current
|
||||
if (!map || !draggingRef.current) return
|
||||
|
||||
const { dsId, featureId, previousCoords } = draggingRef.current
|
||||
draggingRef.current = null
|
||||
map.getCanvas().style.cursor = ''
|
||||
map.off('mousemove', onDragMove)
|
||||
|
||||
// Sauvegarder via API
|
||||
const fc = useMapStore.getState().featureCollections[dsId]
|
||||
const feature = fc?.features.find((f) => String(f.properties?.id ?? f.id) === featureId)
|
||||
if (!feature || !feature.properties?.id) return
|
||||
|
||||
const newGeometry: GeoJSON.Point = {
|
||||
type: 'Point',
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat, previousCoords[2] ?? 0],
|
||||
}
|
||||
|
||||
// Push undo
|
||||
useMapStore.getState().pushUndo({
|
||||
datasetId: dsId,
|
||||
featureId,
|
||||
previousGeometry: { type: 'Point', coordinates: [...previousCoords] },
|
||||
})
|
||||
|
||||
try {
|
||||
await api.updateFeature(feature.properties.id as number, { geometry: newGeometry })
|
||||
} catch {
|
||||
useMapStore.getState().addToast('Erreur lors du déplacement', 'error')
|
||||
}
|
||||
}, [onDragMove])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
const style = map.getStyle()
|
||||
const pointLayers = style?.layers
|
||||
?.filter((l) => l.id.endsWith('-points'))
|
||||
.map((l) => l.id) ?? []
|
||||
|
||||
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||
if (!selectedDatasetId || !selectedFeatureId) return
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: pointLayers })
|
||||
if (features.length === 0) return
|
||||
|
||||
const f = features[0]
|
||||
const fId = String(f.properties?.id ?? f.id)
|
||||
if (fId !== selectedFeatureId) return
|
||||
|
||||
e.preventDefault()
|
||||
map.getCanvas().style.cursor = 'grabbing'
|
||||
|
||||
const fc = useMapStore.getState().featureCollections[selectedDatasetId]
|
||||
const feature = fc?.features.find((feat) => String(feat.properties?.id ?? feat.id) === fId)
|
||||
if (!feature || feature.geometry.type !== 'Point') return
|
||||
|
||||
const coords = (feature.geometry as GeoJSON.Point).coordinates
|
||||
draggingRef.current = {
|
||||
dsId: selectedDatasetId,
|
||||
featureId: fId,
|
||||
previousCoords: [coords[0], coords[1], coords[2] ?? 0],
|
||||
}
|
||||
|
||||
map.on('mousemove', onDragMove)
|
||||
map.once('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
// Curseur grab quand on survole un point sélectionné
|
||||
const onMouseEnterPoint = () => {
|
||||
if (selectedFeatureId) map.getCanvas().style.cursor = 'grab'
|
||||
}
|
||||
const onMouseLeavePoint = () => {
|
||||
if (!draggingRef.current) map.getCanvas().style.cursor = ''
|
||||
}
|
||||
|
||||
for (const layerId of pointLayers) {
|
||||
map.on('mousedown', layerId, onMouseDown)
|
||||
map.on('mouseenter', layerId, onMouseEnterPoint)
|
||||
map.on('mouseleave', layerId, onMouseLeavePoint)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const layerId of pointLayers) {
|
||||
map.off('mousedown', layerId, onMouseDown)
|
||||
map.off('mouseenter', layerId, onMouseEnterPoint)
|
||||
map.off('mouseleave', layerId, onMouseLeavePoint)
|
||||
}
|
||||
}
|
||||
}, [selectedDatasetId, selectedFeatureId, featureCollections, onDragMove, onDragEnd])
|
||||
|
||||
return <div ref={containerRef} className="w-full h-full" />
|
||||
}
|
||||
357
frontend/src/components/PropertyPanel.tsx
Normal file
357
frontend/src/components/PropertyPanel.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const INTERNAL_PROPS = new Set(['id', '_localIndex', '_style'])
|
||||
|
||||
export default function PropertyPanel() {
|
||||
const selectedDatasetId = useMapStore((s) => s.selectedDatasetId)
|
||||
const selectedFeatureId = useMapStore((s) => s.selectedFeatureId)
|
||||
const featureCollections = useMapStore((s) => s.featureCollections)
|
||||
const setFeatureCollection = useMapStore((s) => s.setFeatureCollection)
|
||||
const addToast = useMapStore((s) => s.addToast)
|
||||
|
||||
const [editProps, setEditProps] = useState<Record<string, string>>({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [newFieldKey, setNewFieldKey] = useState('')
|
||||
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const feature = selectedDatasetId && selectedFeatureId
|
||||
? featureCollections[selectedDatasetId]?.features.find(
|
||||
(f) => String(f.properties?.id ?? f.id) === selectedFeatureId
|
||||
)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (feature?.properties) {
|
||||
const props: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(feature.properties)) {
|
||||
if (!INTERNAL_PROPS.has(key) && key !== '_images') {
|
||||
props[key] = String(value ?? '')
|
||||
}
|
||||
}
|
||||
if (feature.geometry.type === 'Point') {
|
||||
const coords = (feature.geometry as GeoJSON.Point).coordinates
|
||||
props['_lat'] = String(coords[1])
|
||||
props['_lng'] = String(coords[0])
|
||||
}
|
||||
setEditProps(props)
|
||||
setDirty(false)
|
||||
}
|
||||
}, [feature])
|
||||
|
||||
const updateProp = (key: string, value: string) => {
|
||||
setEditProps((prev) => ({ ...prev, [key]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const removeProp = (key: string) => {
|
||||
setEditProps((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
if (!newFieldKey.trim()) return
|
||||
setEditProps((prev) => ({ ...prev, [newFieldKey.trim()]: '' }))
|
||||
setNewFieldKey('')
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!feature || !selectedDatasetId || !feature.properties?.id) return
|
||||
try {
|
||||
const newProperties: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(editProps)) {
|
||||
if (key === '_lat' || key === '_lng') continue
|
||||
newProperties[key] = value
|
||||
}
|
||||
if (feature.properties._images) newProperties._images = feature.properties._images
|
||||
if (feature.properties._style) newProperties._style = feature.properties._style
|
||||
newProperties.id = feature.properties.id
|
||||
|
||||
let newGeometry: GeoJSON.Geometry | undefined
|
||||
if (feature.geometry.type === 'Point' && editProps['_lat'] && editProps['_lng']) {
|
||||
const lat = parseFloat(editProps['_lat'])
|
||||
const lng = parseFloat(editProps['_lng'])
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
const oldCoords = (feature.geometry as GeoJSON.Point).coordinates
|
||||
if (lat !== oldCoords[1] || lng !== oldCoords[0]) {
|
||||
newGeometry = { type: 'Point', coordinates: [lng, lat, oldCoords[2] ?? 0] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.updateFeature(feature.properties.id as number, {
|
||||
properties: newProperties,
|
||||
...(newGeometry ? { geometry: newGeometry } : {}),
|
||||
})
|
||||
|
||||
const fc = featureCollections[selectedDatasetId]
|
||||
const updatedFeatures = fc.features.map((f) => {
|
||||
if (String(f.properties?.id ?? f.id) !== selectedFeatureId) return f
|
||||
return {
|
||||
...f,
|
||||
geometry: newGeometry ?? f.geometry,
|
||||
properties: newProperties,
|
||||
}
|
||||
})
|
||||
setFeatureCollection(selectedDatasetId, { ...fc, features: updatedFeatures })
|
||||
setDirty(false)
|
||||
addToast('Sauvegardé', 'success')
|
||||
} catch (e) {
|
||||
addToast(`Erreur: ${e instanceof Error ? e.message : 'inconnu'}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !feature?.properties?.id || !selectedDatasetId) return
|
||||
try {
|
||||
const data = await api.uploadImage(feature.properties.id as number, file)
|
||||
const fc = featureCollections[selectedDatasetId]
|
||||
const updatedFeatures = fc.features.map((f) => {
|
||||
if (String(f.properties?.id ?? f.id) !== selectedFeatureId) return f
|
||||
return { ...f, properties: { ...f.properties, _images: data.images } }
|
||||
})
|
||||
setFeatureCollection(selectedDatasetId, { ...fc, features: updatedFeatures })
|
||||
addToast('Image ajoutée', 'success')
|
||||
} catch (e) {
|
||||
addToast(`Erreur: ${e instanceof Error ? e.message : 'inconnu'}`, 'error')
|
||||
}
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleImageDelete = async (imageUrl: string) => {
|
||||
if (!feature?.properties?.id || !selectedDatasetId) return
|
||||
const filename = imageUrl.split('/').pop()
|
||||
if (!filename) return
|
||||
try {
|
||||
const data = await api.deleteImage(feature.properties.id as number, filename)
|
||||
const fc = featureCollections[selectedDatasetId]
|
||||
const updatedFeatures = fc.features.map((f) => {
|
||||
if (String(f.properties?.id ?? f.id) !== selectedFeatureId) return f
|
||||
return { ...f, properties: { ...f.properties, _images: data.images } }
|
||||
})
|
||||
setFeatureCollection(selectedDatasetId, { ...fc, features: updatedFeatures })
|
||||
addToast('Image supprimée', 'success')
|
||||
} catch (e) {
|
||||
addToast(`Erreur: ${e instanceof Error ? e.message : 'inconnu'}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!feature) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gruvbox-bg0-h border-l border-gruvbox-bg3">
|
||||
<div className="px-3 py-2 border-b border-gruvbox-bg3">
|
||||
<h2 className="text-xs font-bold text-gruvbox-fg4 uppercase tracking-wider">Propriétés</h2>
|
||||
</div>
|
||||
<p className="p-3 text-xs text-gruvbox-gray italic">
|
||||
Sélectionnez une feature sur la carte ou dans la liste.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const images = (feature.properties?._images as string[]) ?? []
|
||||
const description = editProps['description'] ?? ''
|
||||
const sanitizedHtml = DOMPurify.sanitize(description, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'br', 'p', 'ul', 'li', 'img', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt'],
|
||||
})
|
||||
|
||||
const customFields = Object.entries(editProps).filter(
|
||||
([k]) => !['name', 'description', '_folder', '_lat', '_lng'].includes(k) && !INTERNAL_PROPS.has(k)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gruvbox-bg0-h border-l border-gruvbox-bg3">
|
||||
<div className="px-3 py-2 border-b border-gruvbox-bg3 flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold text-gruvbox-fg4 uppercase tracking-wider">Propriétés</h2>
|
||||
{dirty && (
|
||||
<button onClick={handleSave} className="px-2 py-0.5 text-[10px] bg-gruvbox-green text-gruvbox-bg rounded">
|
||||
Sauver
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{/* Nom */}
|
||||
<Field label="Nom" value={editProps['name'] ?? ''} onChange={(v) => updateProp('name', v)} />
|
||||
|
||||
{/* Dossier */}
|
||||
<Field label="Dossier" value={editProps['_folder'] ?? ''} onChange={(v) => updateProp('_folder', v)} />
|
||||
|
||||
{/* Coordonnées */}
|
||||
{feature.geometry.type === 'Point' && (
|
||||
<div>
|
||||
<label className="block text-[10px] text-gruvbox-fg4 uppercase mb-1">Coordonnées</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[9px] text-gruvbox-fg4">Lat</label>
|
||||
<input
|
||||
value={editProps['_lat'] ?? ''}
|
||||
onChange={(e) => updateProp('_lat', e.target.value)}
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full px-2 py-1 text-xs bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg font-mono focus:border-gruvbox-blue outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[9px] text-gruvbox-fg4">Lng</label>
|
||||
<input
|
||||
value={editProps['_lng'] ?? ''}
|
||||
onChange={(e) => updateProp('_lng', e.target.value)}
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full px-2 py-1 text-xs bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg font-mono focus:border-gruvbox-blue outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feature.geometry.type === 'Polygon' && (
|
||||
<div>
|
||||
<label className="block text-[10px] text-gruvbox-fg4 uppercase mb-1">Polygone</label>
|
||||
<p className="text-xs text-gruvbox-fg3">
|
||||
{(feature.geometry as GeoJSON.Polygon).coordinates[0]?.length ?? 0} vertices
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[10px] text-gruvbox-fg4 uppercase mb-1">Description</label>
|
||||
<textarea
|
||||
value={editProps['description'] ?? ''}
|
||||
onChange={(e) => updateProp('description', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-2 py-1 text-xs bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg focus:border-gruvbox-blue outline-none resize-y"
|
||||
/>
|
||||
{sanitizedHtml && (
|
||||
<div
|
||||
className="mt-1 p-2 bg-gruvbox-bg1 rounded border border-gruvbox-bg3 text-xs text-gruvbox-fg2"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] text-gruvbox-fg4 uppercase">Images ({images.length})</label>
|
||||
<label className="px-2 py-0.5 text-[10px] bg-gruvbox-blue text-gruvbox-bg0-h rounded cursor-pointer">
|
||||
+ Ajouter
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
{images.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{images.map((url, i) => (
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={url}
|
||||
alt={`Image ${i + 1}`}
|
||||
className="w-full h-20 object-cover rounded border border-gruvbox-bg3 cursor-pointer hover:brightness-110 transition"
|
||||
loading="lazy"
|
||||
onClick={() => setLightboxUrl(url)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleImageDelete(url)}
|
||||
className="absolute top-1 right-1 w-5 h-5 bg-gruvbox-red text-gruvbox-bg rounded-full text-[10px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gruvbox-gray italic">Aucune image</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Champs custom */}
|
||||
<div>
|
||||
<label className="block text-[10px] text-gruvbox-fg4 uppercase mb-1">Attributs</label>
|
||||
<div className="space-y-1">
|
||||
{customFields.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-gruvbox-aqua min-w-[70px] shrink-0 truncate">{key}</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => updateProp(key, e.target.value)}
|
||||
className="flex-1 px-1 py-0.5 text-xs bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg focus:border-gruvbox-blue outline-none"
|
||||
/>
|
||||
<button onClick={() => removeProp(key)} className="text-gruvbox-red text-[10px] hover:brightness-125">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<input
|
||||
value={newFieldKey}
|
||||
onChange={(e) => setNewFieldKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addField()}
|
||||
placeholder="Nouveau champ..."
|
||||
className="flex-1 px-1 py-0.5 text-xs bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg placeholder-gruvbox-bg4 focus:border-gruvbox-blue outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={addField}
|
||||
disabled={!newFieldKey.trim()}
|
||||
className="px-2 py-0.5 text-[10px] bg-gruvbox-aqua text-gruvbox-bg rounded disabled:opacity-40"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-[10px] text-gruvbox-fg4 uppercase mb-1">Type</label>
|
||||
<p className="text-xs text-gruvbox-fg3">{feature.geometry.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
onClick={() => setLightboxUrl(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setLightboxUrl(null)}
|
||||
className="absolute top-4 right-4 text-white text-2xl hover:text-gruvbox-red transition"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img
|
||||
src={lightboxUrl}
|
||||
alt="Image en grand"
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[10px] text-gruvbox-fg4 uppercase mb-1">{label}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm bg-gruvbox-bg1 border border-gruvbox-bg3 rounded text-gruvbox-fg focus:border-gruvbox-blue outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/StatusBar.tsx
Normal file
16
frontend/src/components/StatusBar.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
|
||||
export default function StatusBar() {
|
||||
const statusMessage = useMapStore((s) => s.statusMessage)
|
||||
const datasets = useMapStore((s) => s.datasets)
|
||||
const totalFeatures = datasets.reduce((sum, d) => sum + d.feature_count, 0)
|
||||
|
||||
return (
|
||||
<footer className="flex items-center justify-between px-4 py-1 bg-gruvbox-bg0-h border-t border-gruvbox-bg3 text-[10px] text-gruvbox-fg4">
|
||||
<span>{statusMessage}</span>
|
||||
<span>
|
||||
{datasets.length} couche{datasets.length !== 1 ? 's' : ''} · {totalFeatures} features
|
||||
</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/ToastContainer.tsx
Normal file
34
frontend/src/components/ToastContainer.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMapStore } from '../stores/mapStore'
|
||||
|
||||
const TYPE_STYLES = {
|
||||
info: 'border-gruvbox-blue text-gruvbox-blue',
|
||||
success: 'border-gruvbox-green text-gruvbox-green',
|
||||
error: 'border-gruvbox-red text-gruvbox-red',
|
||||
warning: 'border-gruvbox-yellow text-gruvbox-yellow',
|
||||
}
|
||||
|
||||
export default function ToastContainer() {
|
||||
const toasts = useMapStore((s) => s.toasts)
|
||||
const removeToast = useMapStore((s) => s.removeToast)
|
||||
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed top-14 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-4 py-2 bg-gruvbox-bg1 border-l-4 rounded shadow-lg text-sm flex items-start gap-2 ${TYPE_STYLES[toast.type]}`}
|
||||
>
|
||||
<span className="flex-1 text-gruvbox-fg">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-gruvbox-fg4 hover:text-gruvbox-fg text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
frontend/src/index.css
Normal file
73
frontend/src/index.css
Normal file
@@ -0,0 +1,73 @@
|
||||
@import "tailwindcss";
|
||||
@import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
@theme {
|
||||
/* Gruvbox Dark palette */
|
||||
--color-gruvbox-bg: #282828;
|
||||
--color-gruvbox-bg0-h: #1d2021;
|
||||
--color-gruvbox-bg0-s: #32302f;
|
||||
--color-gruvbox-bg1: #3c3836;
|
||||
--color-gruvbox-bg2: #504945;
|
||||
--color-gruvbox-bg3: #665c54;
|
||||
--color-gruvbox-bg4: #7c6f64;
|
||||
--color-gruvbox-fg: #ebdbb2;
|
||||
--color-gruvbox-fg0: #fbf1c7;
|
||||
--color-gruvbox-fg1: #ebdbb2;
|
||||
--color-gruvbox-fg2: #d5c4a1;
|
||||
--color-gruvbox-fg3: #bdae93;
|
||||
--color-gruvbox-fg4: #a89984;
|
||||
--color-gruvbox-red: #fb4934;
|
||||
--color-gruvbox-green: #b8bb26;
|
||||
--color-gruvbox-yellow: #fabd2f;
|
||||
--color-gruvbox-blue: #83a598;
|
||||
--color-gruvbox-purple: #d3869b;
|
||||
--color-gruvbox-aqua: #8ec07c;
|
||||
--color-gruvbox-orange: #fe8019;
|
||||
--color-gruvbox-gray: #928374;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-gruvbox-bg);
|
||||
color: var(--color-gruvbox-fg);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* Scrollbar gruvbox */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-gruvbox-bg1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-gruvbox-bg4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-gruvbox-gray);
|
||||
}
|
||||
|
||||
/* Override MapLibre controls pour le thème */
|
||||
.maplibregl-ctrl-group {
|
||||
background-color: var(--color-gruvbox-bg1) !important;
|
||||
border: 1px solid var(--color-gruvbox-bg3) !important;
|
||||
}
|
||||
.maplibregl-ctrl-group button {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.maplibregl-ctrl-group button + button {
|
||||
border-top: 1px solid var(--color-gruvbox-bg3) !important;
|
||||
}
|
||||
.maplibregl-ctrl-attrib {
|
||||
background-color: rgba(40, 40, 40, 0.8) !important;
|
||||
color: var(--color-gruvbox-fg4) !important;
|
||||
}
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: var(--color-gruvbox-blue) !important;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
184
frontend/src/stores/mapStore.ts
Normal file
184
frontend/src/stores/mapStore.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { create } from 'zustand'
|
||||
import type { FeatureCollection, Feature } from 'geojson'
|
||||
|
||||
export type BaseLayerType = 'vector' | 'satellite' | 'hybrid' | 'google-roadmap' | 'google-satellite' | 'google-hybrid'
|
||||
|
||||
export interface Dataset {
|
||||
id: number
|
||||
name: string
|
||||
feature_count: number
|
||||
created_at: string
|
||||
bbox: [number, number, number, number] | null
|
||||
}
|
||||
|
||||
export interface DatasetWithFeatures extends Dataset {
|
||||
features: Feature[]
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
message: string
|
||||
type: 'info' | 'success' | 'error' | 'warning'
|
||||
}
|
||||
|
||||
interface MapState {
|
||||
// Carte prête
|
||||
mapReady: boolean
|
||||
setMapReady: (ready: boolean) => void
|
||||
|
||||
// Fond de carte
|
||||
baseLayer: BaseLayerType
|
||||
setBaseLayer: (layer: BaseLayerType) => void
|
||||
|
||||
// Datasets
|
||||
datasets: Dataset[]
|
||||
setDatasets: (datasets: Dataset[]) => void
|
||||
addDataset: (dataset: Dataset) => void
|
||||
|
||||
// Features affichées sur la carte
|
||||
featureCollections: Record<number, FeatureCollection>
|
||||
setFeatureCollection: (datasetId: number, fc: FeatureCollection) => void
|
||||
removeFeatureCollection: (datasetId: number) => void
|
||||
|
||||
// Sélection
|
||||
selectedFeatureId: string | null
|
||||
selectedDatasetId: number | null
|
||||
selectionTrigger: number
|
||||
selectFeature: (datasetId: number | null, featureId: string | null, flyTo?: boolean) => void
|
||||
|
||||
// Visibilité des couches
|
||||
visibleDatasets: Set<number>
|
||||
toggleDatasetVisibility: (datasetId: number) => void
|
||||
hiddenFeatures: Set<string>
|
||||
toggleFeatureVisibility: (datasetId: number, featureId: string) => void
|
||||
|
||||
// Undo
|
||||
undoStack: Array<{ datasetId: number; featureId: string; previousGeometry: Feature['geometry'] }>
|
||||
pushUndo: (entry: { datasetId: number; featureId: string; previousGeometry: Feature['geometry'] }) => void
|
||||
popUndo: () => { datasetId: number; featureId: string; previousGeometry: Feature['geometry'] } | undefined
|
||||
|
||||
// Toasts
|
||||
toasts: Toast[]
|
||||
addToast: (message: string, type: Toast['type']) => void
|
||||
removeToast: (id: string) => void
|
||||
|
||||
// Suppression
|
||||
removeDataset: (id: number) => void
|
||||
removeFeature: (datasetId: number, featureId: string) => void
|
||||
|
||||
// Status
|
||||
statusMessage: string
|
||||
setStatusMessage: (message: string) => void
|
||||
}
|
||||
|
||||
export const useMapStore = create<MapState>((set, get) => ({
|
||||
mapReady: false,
|
||||
setMapReady: (ready) => set({ mapReady: ready }),
|
||||
|
||||
baseLayer: 'vector',
|
||||
setBaseLayer: (layer) => set({ baseLayer: layer }),
|
||||
|
||||
datasets: [],
|
||||
setDatasets: (datasets) => set({ datasets }),
|
||||
addDataset: (dataset) => set((s) => ({ datasets: [...s.datasets, dataset] })),
|
||||
|
||||
featureCollections: {},
|
||||
setFeatureCollection: (datasetId, fc) =>
|
||||
set((s) => ({ featureCollections: { ...s.featureCollections, [datasetId]: fc } })),
|
||||
removeFeatureCollection: (datasetId) =>
|
||||
set((s) => {
|
||||
const { [datasetId]: _, ...rest } = s.featureCollections
|
||||
return { featureCollections: rest }
|
||||
}),
|
||||
|
||||
selectedFeatureId: null,
|
||||
selectedDatasetId: null,
|
||||
selectionTrigger: 0,
|
||||
selectFeature: (datasetId, featureId, flyTo) =>
|
||||
set((s) => ({
|
||||
selectedDatasetId: datasetId,
|
||||
selectedFeatureId: featureId,
|
||||
selectionTrigger: flyTo ? s.selectionTrigger + 1 : s.selectionTrigger,
|
||||
})),
|
||||
|
||||
visibleDatasets: new Set(),
|
||||
toggleDatasetVisibility: (datasetId) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.visibleDatasets)
|
||||
if (next.has(datasetId)) next.delete(datasetId)
|
||||
else next.add(datasetId)
|
||||
return { visibleDatasets: next }
|
||||
}),
|
||||
|
||||
hiddenFeatures: new Set(),
|
||||
toggleFeatureVisibility: (datasetId, featureId) =>
|
||||
set((s) => {
|
||||
const key = `${datasetId}-${featureId}`
|
||||
const next = new Set(s.hiddenFeatures)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return { hiddenFeatures: next }
|
||||
}),
|
||||
|
||||
undoStack: [],
|
||||
pushUndo: (entry) => set((s) => ({ undoStack: [...s.undoStack, entry] })),
|
||||
popUndo: () => {
|
||||
const stack = get().undoStack
|
||||
if (stack.length === 0) return undefined
|
||||
const entry = stack[stack.length - 1]
|
||||
set({ undoStack: stack.slice(0, -1) })
|
||||
return entry
|
||||
},
|
||||
|
||||
toasts: [],
|
||||
addToast: (message, type) => {
|
||||
const id = crypto.randomUUID()
|
||||
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }))
|
||||
setTimeout(() => get().removeToast(id), 4000)
|
||||
},
|
||||
removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
|
||||
removeDataset: (id) =>
|
||||
set((s) => {
|
||||
const { [id]: _, ...restFc } = s.featureCollections
|
||||
const nextVisible = new Set(s.visibleDatasets)
|
||||
nextVisible.delete(id)
|
||||
const nextHidden = new Set(s.hiddenFeatures)
|
||||
for (const key of nextHidden) {
|
||||
if (key.startsWith(`${id}-`)) nextHidden.delete(key)
|
||||
}
|
||||
return {
|
||||
datasets: s.datasets.filter((d) => d.id !== id),
|
||||
featureCollections: restFc,
|
||||
visibleDatasets: nextVisible,
|
||||
hiddenFeatures: nextHidden,
|
||||
selectedDatasetId: s.selectedDatasetId === id ? null : s.selectedDatasetId,
|
||||
selectedFeatureId: s.selectedDatasetId === id ? null : s.selectedFeatureId,
|
||||
}
|
||||
}),
|
||||
|
||||
removeFeature: (datasetId, featureId) =>
|
||||
set((s) => {
|
||||
const fc = s.featureCollections[datasetId]
|
||||
if (!fc) return s
|
||||
const newFc: FeatureCollection = {
|
||||
...fc,
|
||||
features: fc.features.filter((f) => String(f.properties?.id ?? f.id) !== featureId),
|
||||
}
|
||||
return {
|
||||
featureCollections: { ...s.featureCollections, [datasetId]: newFc },
|
||||
datasets: s.datasets.map((d) =>
|
||||
d.id === datasetId ? { ...d, feature_count: d.feature_count - 1 } : d
|
||||
),
|
||||
selectedFeatureId:
|
||||
s.selectedDatasetId === datasetId && s.selectedFeatureId === featureId
|
||||
? null : s.selectedFeatureId,
|
||||
selectedDatasetId:
|
||||
s.selectedDatasetId === datasetId && s.selectedFeatureId === featureId
|
||||
? null : s.selectedDatasetId,
|
||||
}
|
||||
}),
|
||||
|
||||
statusMessage: 'Prêt',
|
||||
setStatusMessage: (message) => set({ statusMessage: message }),
|
||||
}))
|
||||
164
frontend/src/utils/kmlParser.ts
Normal file
164
frontend/src/utils/kmlParser.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { kml } from '@tmcw/togeojson'
|
||||
import type { FeatureCollection, Feature } from 'geojson'
|
||||
|
||||
/**
|
||||
* Parser KML amélioré qui extrait en plus de togeojson :
|
||||
* - Images base64 depuis gx:Carousel/gx:Image/gx:imageUrl → _images
|
||||
* - Dossier parent (Folder) → _folder
|
||||
* - Champs ExtendedData/SimpleData → propriétés directes
|
||||
* - Styles KML → _style
|
||||
*/
|
||||
export function parseKml(text: string): FeatureCollection {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(text, 'text/xml')
|
||||
|
||||
// 1. Conversion de base via togeojson
|
||||
const fc = kml(doc) as FeatureCollection
|
||||
|
||||
// 2. Indexer les Placemarks du DOM XML pour enrichir les features
|
||||
const placemarks = Array.from(doc.getElementsByTagName('Placemark'))
|
||||
|
||||
// Construire un mapping nom → données enrichies
|
||||
const enrichments = placemarks.map((pm) => {
|
||||
const name = pm.getElementsByTagName('name')[0]?.textContent?.trim() ?? ''
|
||||
|
||||
// Compter les images (gx:imageUrl) — les data URIs base64 sont trop volumineuses
|
||||
// pour transiter dans le JSON. L'extraction se fait côté backend depuis le fichier KML brut.
|
||||
const imageCount = pm.getElementsByTagNameNS('http://www.google.com/kml/ext/2.2', 'imageUrl').length
|
||||
|
||||
// Extraire le dossier parent
|
||||
let folder: string | null = null
|
||||
let parent = pm.parentElement
|
||||
while (parent) {
|
||||
if (parent.tagName === 'Folder') {
|
||||
const folderName = parent.getElementsByTagName('name')[0]?.textContent?.trim()
|
||||
if (folderName) {
|
||||
folder = folderName
|
||||
break
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
|
||||
// Extraire les ExtendedData / SimpleData
|
||||
const extendedData: Record<string, string> = {}
|
||||
const simpleDataElements = pm.getElementsByTagName('SimpleData')
|
||||
for (let i = 0; i < simpleDataElements.length; i++) {
|
||||
const el = simpleDataElements[i]
|
||||
const attrName = el.getAttribute('name')
|
||||
const value = el.textContent?.trim() ?? ''
|
||||
if (attrName && value) {
|
||||
// Chercher le displayName dans le Schema
|
||||
const displayName = findDisplayName(doc, attrName)
|
||||
extendedData[displayName || attrName] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire les styles (couleurs de ligne et polygone)
|
||||
const style = extractStyle(doc, pm)
|
||||
|
||||
return { name, imageCount, folder, extendedData, style }
|
||||
})
|
||||
|
||||
// 3. Enrichir les features du GeoJSON
|
||||
// Matcher par index (togeojson produit les features dans le même ordre que les Placemarks)
|
||||
fc.features.forEach((feature: Feature, i: number) => {
|
||||
if (i >= enrichments.length) return
|
||||
const enrich = enrichments[i]
|
||||
if (!feature.properties) feature.properties = {}
|
||||
|
||||
if (enrich.imageCount > 0) {
|
||||
feature.properties._imageCount = enrich.imageCount
|
||||
}
|
||||
if (enrich.folder) {
|
||||
feature.properties._folder = enrich.folder
|
||||
}
|
||||
if (enrich.style) {
|
||||
feature.properties._style = enrich.style
|
||||
}
|
||||
// Ajouter les champs ExtendedData
|
||||
for (const [key, value] of Object.entries(enrich.extendedData)) {
|
||||
if (!(key in feature.properties)) {
|
||||
feature.properties[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return fc
|
||||
}
|
||||
|
||||
function findDisplayName(doc: Document, fieldName: string): string | null {
|
||||
const simpleFields = doc.getElementsByTagName('SimpleField')
|
||||
for (let i = 0; i < simpleFields.length; i++) {
|
||||
if (simpleFields[i].getAttribute('name') === fieldName) {
|
||||
const dn = simpleFields[i].getElementsByTagName('displayName')[0]
|
||||
return dn?.textContent?.trim() ?? null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractStyle(doc: Document, placemark: Element): Record<string, string> | null {
|
||||
// Chercher le styleUrl du Placemark
|
||||
const styleUrlEl = placemark.getElementsByTagName('styleUrl')[0]
|
||||
if (!styleUrlEl) return null
|
||||
|
||||
const styleUrl = styleUrlEl.textContent?.trim()?.replace('#', '') ?? ''
|
||||
if (!styleUrl) return null
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
// Chercher dans les StyleMap → Pair → normal → styleUrl → Style
|
||||
const styleMaps = doc.getElementsByTagName('StyleMap')
|
||||
for (let i = 0; i < styleMaps.length; i++) {
|
||||
if (styleMaps[i].getAttribute('id') === styleUrl) {
|
||||
const pairs = styleMaps[i].getElementsByTagName('Pair')
|
||||
for (let j = 0; j < pairs.length; j++) {
|
||||
const key = pairs[j].getElementsByTagName('key')[0]?.textContent?.trim()
|
||||
if (key === 'normal') {
|
||||
const url = pairs[j].getElementsByTagName('styleUrl')[0]?.textContent?.trim()?.replace('#', '')
|
||||
if (url) {
|
||||
const styleData = findStyleById(doc, url)
|
||||
if (styleData) Object.assign(result, styleData)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher directement dans les CascadingStyle ou Style
|
||||
const styleData = findStyleById(doc, styleUrl)
|
||||
return styleData
|
||||
}
|
||||
|
||||
function findStyleById(doc: Document, id: string): Record<string, string> | null {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
// Chercher dans gx:CascadingStyle
|
||||
const cascading = doc.getElementsByTagNameNS('http://www.google.com/kml/ext/2.2', 'CascadingStyle')
|
||||
for (let i = 0; i < cascading.length; i++) {
|
||||
const csId = cascading[i].getAttributeNS('http://www.opengis.net/kml/2.2', 'id') ||
|
||||
cascading[i].getAttribute('kml:id') || cascading[i].getAttribute('id')
|
||||
if (csId === id) {
|
||||
const lineColor = cascading[i].getElementsByTagName('LineStyle')[0]
|
||||
?.getElementsByTagName('color')[0]?.textContent?.trim()
|
||||
const polyColor = cascading[i].getElementsByTagName('PolyStyle')[0]
|
||||
?.getElementsByTagName('color')[0]?.textContent?.trim()
|
||||
if (lineColor) result.lineColor = kmlColorToCss(lineColor)
|
||||
if (polyColor) result.polyColor = kmlColorToCss(polyColor)
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Convertir couleur KML (aabbggrr) en CSS (#rrggbb) */
|
||||
function kmlColorToCss(kmlColor: string): string {
|
||||
if (kmlColor.length !== 8) return kmlColor
|
||||
const r = kmlColor.substring(6, 8)
|
||||
const g = kmlColor.substring(4, 6)
|
||||
const b = kmlColor.substring(2, 4)
|
||||
return `#${r}${g}${b}`
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
frontend/vite.config.ts
Normal file
17
frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user