etape laptop

This commit is contained in:
2026-02-09 00:01:29 +01:00
commit 805fef0cdc
144 changed files with 15295 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View 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
View 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
View 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>
)
}

View 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),
}),
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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: '&copy; <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: '&copy; 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: '&copy; Esri',
},
labels: {
type: 'raster',
tiles: ['https://stamen-tiles.a.ssl.fastly.net/toner-labels/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; 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: '&copy; 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: '&copy; 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: '&copy; 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" />
}

View 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>
)
}

View 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' : ''} &middot; {totalFeatures} features
</span>
</footer>
)
}

View 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
View 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
View 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>,
)

View 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 }),
}))

View 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}`
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
})