diff --git a/server/package.json b/server/package.json index 8fba82e85..46762a92b 100644 --- a/server/package.json +++ b/server/package.json @@ -76,6 +76,7 @@ "pdf-lib": "1.17.1", "pg": "8.16.3", "pg-query-stream": "4.10.3", + "proj4": "^2.20.2", "puppeteer": "22.15.0", "qs": "6.14.0", "randomstring": "1.3.1", @@ -116,6 +117,7 @@ "@types/node": "20.19.10", "@types/nodemailer": "6.4.17", "@types/pg": "8.15.5", + "@types/proj4": "^2.19.0", "@types/qs": "6.14.0", "@types/randomstring": "1.3.0", "@types/shapefile": "0", diff --git a/server/src/controllers/geoController.ts b/server/src/controllers/geoController.ts index a1c5b50f1..e8a6ce967 100644 --- a/server/src/controllers/geoController.ts +++ b/server/src/controllers/geoController.ts @@ -1,11 +1,19 @@ import * as turf from '@turf/turf'; +import AdmZip from 'adm-zip'; import async from 'async'; import { Request, Response } from 'express'; import { AuthenticatedRequest } from 'express-jwt'; import { body, param } from 'express-validator'; -import { Feature, Geometry, MultiPolygon } from 'geojson'; +import { + Feature, + FeatureCollection, + Geometry, + MultiPolygon, + Position +} from 'geojson'; import { constants } from 'http2'; -import shpjs from 'shpjs'; +import proj4 from 'proj4'; +import shapefile from 'shapefile'; import { v4 as uuidv4 } from 'uuid'; import { match, Pattern } from 'ts-pattern'; @@ -14,6 +22,310 @@ import { isArrayOf, isUUID } from '~/utils/validators'; import { logger } from '~/infra/logger'; import { GeoPerimeterApi, toGeoPerimeterDTO } from '~/models/GeoPerimeterApi'; +// Common projections for French territories +const KNOWN_PROJECTIONS: Record = { + // Metropolitan France + 'EPSG:2154': '+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + // Réunion (standard UTM zone 40S) + 'EPSG:2975': '+proj=utm +zone=40 +south +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + // Martinique + 'EPSG:5490': '+proj=utm +zone=20 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + // Guadeloupe + 'EPSG:32620': '+proj=utm +zone=20 +datum=WGS84 +units=m +no_defs +type=crs', + // Guyane + 'EPSG:2972': '+proj=utm +zone=22 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + // Mayotte + 'EPSG:4471': '+proj=utm +zone=38 +south +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs' +}; + +// Special projections with coordinate offsets (non-standard grids) +// Some shapefiles use modified coordinate systems with additional offsets +interface ProjectionWithOffset { + proj4: string; + xOffset: number; + yOffset: number; +} + +const OFFSET_PROJECTIONS: Record = { + // Réunion with non-standard offsets (X+1.35M, Y+1.57M added to standard UTM 40S) + // Detected from shapefiles with X: ~1.6M-1.9M, Y: ~9.0M-9.5M + 'REUNION_OFFSET': { + proj4: '+proj=utm +zone=40 +south +datum=WGS84 +units=m +no_defs +type=crs', + xOffset: -1350000, + yOffset: -1570000 + } +}; + +// WGS84 definition +const WGS84 = '+proj=longlat +datum=WGS84 +no_defs +type=crs'; + +/** + * Detect projection from coordinate ranges when .prj file is missing + */ +function detectProjectionFromCoordinates( + xMin: number, + xMax: number, + yMin: number, + yMax: number +): string | null { + // WGS84 - already in lat/lng + if (xMin >= -180 && xMax <= 180 && yMin >= -90 && yMax <= 90) { + return null; // No reprojection needed + } + + // Lambert 93 (Metropolitan France) + if (xMin > 100000 && xMax < 1300000 && yMin > 6000000 && yMax < 7200000) { + return 'EPSG:2154'; + } + + // Réunion with non-standard offsets (some local tools add extra offsets) + // Detected pattern: X ~1.6M-1.9M, Y ~9.0M-9.5M + // This needs special handling with coordinate offsets before reprojection + if (xMin > 1600000 && xMax < 1900000 && yMin > 9000000 && yMax < 9500000) { + return 'REUNION_OFFSET'; + } + + // Standard UTM zone 40S (Réunion) - X around 300-500k, Y around 7.6-7.8M + if (xMin > 300000 && xMax < 600000 && yMin > 7600000 && yMax < 7800000) { + return 'EPSG:2975'; + } + + // UTM zone 20N (Martinique/Guadeloupe) + if (xMin > 400000 && xMax < 800000 && yMin > 1600000 && yMax < 1900000) { + return 'EPSG:5490'; + } + + // UTM zone 22N (Guyane) + if (xMin > 100000 && xMax < 400000 && yMin > 200000 && yMax < 700000) { + return 'EPSG:2972'; + } + + // UTM zone 38S (Mayotte) + if (xMin > 400000 && xMax < 600000 && yMin > 8500000 && yMax < 8700000) { + return 'EPSG:4471'; + } + + logger.warn('Unable to detect projection from coordinates', { + xMin, + xMax, + yMin, + yMax + }); + return null; +} + +/** + * Parse WKT projection string to detect EPSG code + */ +function parseWktProjection(wkt: string): string | null { + // Look for EPSG code in AUTHORITY + const authorityMatch = wkt.match(/AUTHORITY\s*\[\s*"EPSG"\s*,\s*"(\d+)"\s*\]/i); + if (authorityMatch) { + return `EPSG:${authorityMatch[1]}`; + } + + // Look for common projection names + if (wkt.includes('Lambert_Conformal_Conic') && wkt.includes('RGF93')) { + return 'EPSG:2154'; + } + if (wkt.includes('UTM') && wkt.includes('zone 40S')) { + return 'EPSG:2975'; + } + if (wkt.includes('RGR92')) { + return 'EPSG:2975'; + } + + return null; +} + +/** + * Reproject coordinates from source CRS to WGS84 + * Handles both standard EPSG projections and special offset projections + */ +function reprojectCoordinates( + coordinates: Position[], + sourceProj: string +): Position[] { + // Check for offset projections first + const offsetProj = OFFSET_PROJECTIONS[sourceProj]; + if (offsetProj) { + return coordinates.map((coord) => { + const [x, y, ...rest] = coord; + // Apply coordinate offsets before reprojection + const adjustedX = x + offsetProj.xOffset; + const adjustedY = y + offsetProj.yOffset; + const [lng, lat] = proj4(offsetProj.proj4, WGS84, [adjustedX, adjustedY]); + return [lng, lat, ...rest]; + }); + } + + // Standard EPSG projection + const projDef = KNOWN_PROJECTIONS[sourceProj]; + if (!projDef) { + logger.warn('Unknown projection, cannot reproject', { sourceProj }); + return coordinates; + } + + return coordinates.map((coord) => { + const [x, y, ...rest] = coord; + const [lng, lat] = proj4(projDef, WGS84, [x, y]); + return [lng, lat, ...rest]; + }); +} + +/** + * Recursively reproject all coordinates in a geometry + * Handles both standard EPSG projections and special offset projections + */ +function reprojectGeometry(geometry: Geometry, sourceProj: string): Geometry { + if (geometry.type === 'Point') { + const [x, y] = geometry.coordinates as [number, number]; + + // Check for offset projections first + const offsetProj = OFFSET_PROJECTIONS[sourceProj]; + if (offsetProj) { + const adjustedX = x + offsetProj.xOffset; + const adjustedY = y + offsetProj.yOffset; + const [lng, lat] = proj4(offsetProj.proj4, WGS84, [adjustedX, adjustedY]); + return { ...geometry, coordinates: [lng, lat] }; + } + + // Standard EPSG projection + const projDef = KNOWN_PROJECTIONS[sourceProj]; + if (projDef) { + const [lng, lat] = proj4(projDef, WGS84, [x, y]); + return { ...geometry, coordinates: [lng, lat] }; + } + + return geometry; + } + + if (geometry.type === 'LineString' || geometry.type === 'MultiPoint') { + return { + ...geometry, + coordinates: reprojectCoordinates(geometry.coordinates, sourceProj) + }; + } + + if (geometry.type === 'Polygon' || geometry.type === 'MultiLineString') { + return { + ...geometry, + coordinates: geometry.coordinates.map((ring) => + reprojectCoordinates(ring, sourceProj) + ) + }; + } + + if (geometry.type === 'MultiPolygon') { + return { + ...geometry, + coordinates: geometry.coordinates.map((polygon) => + polygon.map((ring) => reprojectCoordinates(ring, sourceProj)) + ) + }; + } + + return geometry; +} + +interface ParsedShapefile { + featureCollection: FeatureCollection; + sourceProjection: string | null; +} + +/** + * Parse a shapefile from a ZIP buffer using the shapefile library. + * This correctly handles Null Shapes (type 0) which shpjs cannot parse. + * Also detects the source projection for reprojection to WGS84. + */ +async function parseShapefileFromZip( + fileBuffer: Buffer +): Promise { + const zip = new AdmZip(fileBuffer); + const zipEntries = zip.getEntries(); + + const shpEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.shp') + ); + const dbfEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.dbf') + ); + const prjEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.prj') + ); + + if (!shpEntry || !dbfEntry) { + throw new Error('Missing required shapefile components (.shp and .dbf)'); + } + + const shpBuffer = shpEntry.getData(); + const dbfBuffer = dbfEntry.getData(); + + // Try to detect projection from .prj file + let sourceProjection: string | null = null; + if (prjEntry) { + const prjContent = prjEntry.getData().toString('utf-8'); + sourceProjection = parseWktProjection(prjContent); + if (sourceProjection) { + logger.info('Detected projection from .prj file', { sourceProjection }); + } + } + + const features: Feature[] = []; + const source = await shapefile.open(shpBuffer, dbfBuffer); + + // Track coordinate bounds for projection detection + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + + let result = await source.read(); + while (!result.done) { + const feature = result.value; + // Filter out Null Shapes (features with null geometry) + if (feature.geometry !== null) { + features.push(feature as Feature); + + // Update bounds from coordinates (skip GeometryCollection) + if (feature.geometry.type !== 'GeometryCollection') { + const coords = JSON.stringify(feature.geometry.coordinates); + const numbers = coords.match(/-?\d+\.?\d*/g)?.map(Number) || []; + for (let i = 0; i < numbers.length; i += 2) { + const x = numbers[i]; + const y = numbers[i + 1]; + if (x !== undefined && y !== undefined) { + xMin = Math.min(xMin, x); + xMax = Math.max(xMax, x); + yMin = Math.min(yMin, y); + yMax = Math.max(yMax, y); + } + } + } + } + result = await source.read(); + } + + // If no projection detected from .prj, try to detect from coordinates + if (!sourceProjection && features.length > 0) { + sourceProjection = detectProjectionFromCoordinates(xMin, xMax, yMin, yMax); + if (sourceProjection) { + logger.info('Detected projection from coordinate ranges', { + sourceProjection, + bounds: { xMin, xMax, yMin, yMax } + }); + } + } + + return { + featureCollection: { + type: 'FeatureCollection', + features + }, + sourceProjection + }; +} + async function listGeoPerimeters(request: Request, response: Response) { const { auth } = request as AuthenticatedRequest; @@ -42,15 +354,30 @@ async function createGeoPerimeter( name: file.originalname }); - const geojson = await shpjs(file.buffer); - const featureCollections = Array.isArray(geojson) ? geojson : [geojson]; - - const features = featureCollections.flatMap( - (featureCollection) => featureCollection.features + const { featureCollection, sourceProjection } = await parseShapefileFromZip( + file.buffer ); + + // Log reprojection info + if (sourceProjection) { + logger.info('Reprojecting shapefile coordinates to WGS84', { + sourceProjection, + featureCount: featureCollection.features.length + }); + } + + const features = featureCollection.features; const perimeters = await async.map(features, async (feature: Feature) => { + // Reproject geometry if source projection was detected + let geometry = feature.geometry; + const hasKnownProjection = sourceProjection && KNOWN_PROJECTIONS[sourceProjection]; + const hasOffsetProjection = sourceProjection && OFFSET_PROJECTIONS[sourceProjection]; + if (hasKnownProjection || hasOffsetProjection) { + geometry = reprojectGeometry(geometry, sourceProjection); + } + // TODO: ask if it necessary to create one perimeter by feature - const multiPolygon: MultiPolygon = to2D(toMultiPolygon(feature.geometry)); + const multiPolygon: MultiPolygon = to2D(toMultiPolygon(geometry)); const perimeter: GeoPerimeterApi = { id: uuidv4(), kind: feature.properties?.type ?? '', diff --git a/tools/analyze-shapefile.ts b/tools/analyze-shapefile.ts new file mode 100644 index 000000000..f9e0c247c --- /dev/null +++ b/tools/analyze-shapefile.ts @@ -0,0 +1,315 @@ +/** + * Script d'analyse de fichier shapefile ZIP + * Usage: npx tsx tools/analyze-shapefile.ts "tools/perimetre ACV.zip" + */ + +import AdmZip from 'adm-zip'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as shapefile from 'shapefile'; +import shpjs from 'shpjs'; + +async function analyzeShapefile(zipPath: string): Promise { + console.log('='.repeat(60)); + console.log('ANALYSE DU FICHIER SHAPEFILE'); + console.log('='.repeat(60)); + console.log(); + + // Vérifier que le fichier existe + if (!fs.existsSync(zipPath)) { + console.error(`❌ Fichier non trouvé: ${zipPath}`); + process.exit(1); + } + + const stats = fs.statSync(zipPath); + console.log(`📁 Fichier: ${path.basename(zipPath)}`); + console.log(`📏 Taille: ${(stats.size / 1024).toFixed(2)} KB`); + console.log(); + + // Lire le fichier ZIP + let zip: AdmZip; + let zipEntries: AdmZip.IZipEntry[]; + + try { + const fileBuffer = fs.readFileSync(zipPath); + zip = new AdmZip(fileBuffer); + zipEntries = zip.getEntries(); + } catch (error) { + console.error('❌ Erreur lors de la lecture du ZIP:', error); + process.exit(1); + } + + // Lister le contenu + console.log('📦 CONTENU DU ZIP:'); + console.log('-'.repeat(40)); + let hasSpacesInNames = false; + let hasSubfolders = false; + zipEntries.forEach((entry) => { + const ext = path.extname(entry.entryName).toLowerCase(); + const size = entry.header.size; + const hasSpace = entry.entryName.includes(' '); + const hasFolder = entry.entryName.includes('/'); + if (hasSpace) hasSpacesInNames = true; + if (hasFolder) hasSubfolders = true; + console.log(` ${entry.entryName} (${size} bytes) [${ext}]${hasSpace ? ' ⚠️ ESPACE' : ''}`); + }); + console.log(); + + if (hasSpacesInNames) { + console.log('⚠️ ATTENTION: Noms de fichiers avec espaces détectés!'); + console.log(' Certaines bibliothèques (comme shpjs) peuvent mal gérer les espaces.'); + console.log(' → Renommez les fichiers sans espaces avant de zipper.'); + console.log(); + } + + if (hasSubfolders) { + console.log('⚠️ ATTENTION: Sous-dossiers détectés dans le ZIP!'); + console.log(' Le shapefile doit être à la racine du ZIP.'); + console.log(); + } + + // Vérifier les composants requis + const shpEntry = zipEntries.find((e) => + e.entryName.toLowerCase().endsWith('.shp') + ); + const dbfEntry = zipEntries.find((e) => + e.entryName.toLowerCase().endsWith('.dbf') + ); + const shxEntry = zipEntries.find((e) => + e.entryName.toLowerCase().endsWith('.shx') + ); + const prjEntry = zipEntries.find((e) => + e.entryName.toLowerCase().endsWith('.prj') + ); + + console.log('🔍 COMPOSANTS SHAPEFILE:'); + console.log('-'.repeat(40)); + console.log(` .shp (géométrie): ${shpEntry ? '✅ Présent' : '❌ MANQUANT'}`); + console.log(` .dbf (attributs): ${dbfEntry ? '✅ Présent' : '❌ MANQUANT'}`); + console.log(` .shx (index): ${shxEntry ? '✅ Présent' : '⚠️ Optionnel'}`); + console.log(` .prj (projection): ${prjEntry ? '✅ Présent' : '⚠️ Optionnel'}`); + console.log(); + + if (!shpEntry || !dbfEntry) { + console.error('❌ ERREUR: Composants requis manquants (.shp et .dbf)'); + process.exit(1); + } + + // Analyser le shapefile + console.log('📊 ANALYSE DU SHAPEFILE:'); + console.log('-'.repeat(40)); + + try { + const shpBuffer = shpEntry.getData(); + const dbfBuffer = dbfEntry.getData(); + + console.log(` Taille .shp: ${shpBuffer.length} bytes`); + console.log(` Taille .dbf: ${dbfBuffer.length} bytes`); + + // Lire le header du shapefile pour détecter le type et les dimensions + // https://www.esri.com/content/dam/esrisites/sitecore-archive/Files/Pdfs/library/whitepapers/pdfs/shapefile.pdf + const shapeTypeCode = shpBuffer.readInt32LE(32); + const shapeTypes: Record = { + 0: 'Null Shape', + 1: 'Point (2D)', + 3: 'PolyLine (2D)', + 5: 'Polygon (2D)', + 8: 'MultiPoint (2D)', + 11: 'PointZ (3D avec Z)', + 13: 'PolyLineZ (3D avec Z)', + 15: 'PolygonZ (3D avec Z)', + 18: 'MultiPointZ (3D avec Z)', + 21: 'PointM (2D avec M)', + 23: 'PolyLineM (2D avec M)', + 25: 'PolygonM (2D avec M)', + 28: 'MultiPointM (2D avec M)', + 31: 'MultiPatch' + }; + + const shapeTypeName = shapeTypes[shapeTypeCode] || `Inconnu (${shapeTypeCode})`; + const is3D = [11, 13, 15, 18].includes(shapeTypeCode); + const hasM = [21, 23, 25, 28].includes(shapeTypeCode) || [11, 13, 15, 18].includes(shapeTypeCode); + + console.log(); + console.log(' 📐 HEADER DU SHAPEFILE:'); + console.log(` Type de géométrie: ${shapeTypeName} (code: ${shapeTypeCode})`); + console.log(` 3D (Z): ${is3D ? '⚠️ OUI' : '✅ Non'}`); + console.log(` Mesure (M): ${hasM ? '⚠️ OUI' : '✅ Non'}`); + + // Bounding box du header + const xMin = shpBuffer.readDoubleLE(36); + const yMin = shpBuffer.readDoubleLE(44); + const xMax = shpBuffer.readDoubleLE(52); + const yMax = shpBuffer.readDoubleLE(60); + + console.log(); + console.log(' 📍 BOUNDING BOX (header):'); + console.log(` X: ${xMin.toFixed(6)} à ${xMax.toFixed(6)}`); + console.log(` Y: ${yMin.toFixed(6)} à ${yMax.toFixed(6)}`); + + if (is3D) { + const zMin = shpBuffer.readDoubleLE(68); + const zMax = shpBuffer.readDoubleLE(76); + console.log(` Z: ${zMin.toFixed(6)} à ${zMax.toFixed(6)}`); + console.log(); + console.log(' ⚠️ ATTENTION: Shapefile 3D détecté!'); + console.log(' shpjs peut avoir des problèmes avec les shapefiles 3D.'); + console.log(' → Convertissez en 2D avec: ogr2ogr -dim 2 output.shp input.shp'); + } + + // Ouvrir et lire le shapefile + const source = await shapefile.open(shpBuffer, dbfBuffer); + + let featureCount = 0; + const geometryTypes = new Set(); + let firstFeature: any = null; + const allProperties: Set = new Set(); + + let result = await source.read(); + while (!result.done) { + featureCount++; + const feature = result.value; + + if (feature.geometry) { + geometryTypes.add(feature.geometry.type); + } + + if (feature.properties) { + Object.keys(feature.properties).forEach((key) => allProperties.add(key)); + } + + if (!firstFeature) { + firstFeature = feature; + } + + result = await source.read(); + } + + console.log(); + console.log(` Nombre de features: ${featureCount}`); + console.log(` Types de géométrie: ${Array.from(geometryTypes).join(', ') || 'Aucun'}`); + console.log(` Attributs: ${Array.from(allProperties).join(', ') || 'Aucun'}`); + console.log(); + + // Afficher le premier feature en détail + if (firstFeature) { + console.log('📝 PREMIER FEATURE (exemple):'); + console.log('-'.repeat(40)); + console.log(' Géométrie:', JSON.stringify(firstFeature.geometry, null, 2).split('\n').map((l, i) => i === 0 ? l : ' ' + l).join('\n')); + console.log(' Propriétés:', JSON.stringify(firstFeature.properties, null, 2)); + } + + // Vérification des limites + console.log(); + console.log('⚙️ VÉRIFICATION DES LIMITES:'); + console.log('-'.repeat(40)); + const maxFeatures = 500; // Valeur par défaut de config + if (featureCount > maxFeatures) { + console.log(` ❌ ERREUR: ${featureCount} features > limite de ${maxFeatures}`); + } else { + console.log(` ✅ OK: ${featureCount} features <= limite de ${maxFeatures}`); + } + + // Vérifier la projection + console.log(); + console.log('🌍 ANALYSE DE LA PROJECTION:'); + console.log('-'.repeat(40)); + + if (prjEntry) { + const prjContent = prjEntry.getData().toString('utf-8'); + console.log(' Fichier .prj trouvé:'); + console.log(` ${prjContent.substring(0, 200)}${prjContent.length > 200 ? '...' : ''}`); + } else { + console.log(' ⚠️ Fichier .prj MANQUANT - projection inconnue'); + } + + // Analyser les coordonnées + if (firstFeature?.geometry?.coordinates) { + const coords = firstFeature.geometry.coordinates; + const flatCoords = JSON.stringify(coords).match(/-?\d+\.?\d*/g)?.map(Number) || []; + const xCoords = flatCoords.filter((_, i) => i % 2 === 0); + const yCoords = flatCoords.filter((_, i) => i % 2 === 1); + + const minX = Math.min(...xCoords); + const maxX = Math.max(...xCoords); + const minY = Math.min(...yCoords); + const maxY = Math.max(...yCoords); + + console.log(); + console.log(' Étendue des coordonnées:'); + console.log(` X: ${minX.toFixed(2)} à ${maxX.toFixed(2)}`); + console.log(` Y: ${minY.toFixed(2)} à ${maxY.toFixed(2)}`); + + // Détecter le système de coordonnées + const isWGS84 = minX >= -180 && maxX <= 180 && minY >= -90 && maxY <= 90; + const isLambert93 = minX > 100000 && maxX < 1300000 && minY > 6000000 && maxY < 7200000; + const isLambert93Extended = minX > 1600000 && maxX < 1800000 && minY > 9200000 && maxY < 9300000; + + console.log(); + if (isWGS84) { + console.log(' ✅ Projection détectée: WGS84 (EPSG:4326) - Compatible'); + } else if (isLambert93) { + console.log(' ⚠️ Projection détectée: Lambert 93 (EPSG:2154)'); + console.log(' Le serveur attend des coordonnées en WGS84 (longitude/latitude)'); + console.log(' → Reprojetez le shapefile en WGS84 avant l\'upload'); + } else if (isLambert93Extended) { + console.log(' ⚠️ Projection détectée: Probablement Lambert 93 étendu ou UTM'); + console.log(' Le serveur attend des coordonnées en WGS84 (longitude/latitude)'); + console.log(' → Reprojetez le shapefile en WGS84 avant l\'upload'); + } else { + console.log(' ⚠️ Projection inconnue - les coordonnées ne semblent pas en WGS84'); + console.log(` Plage X: ${minX} - ${maxX}`); + console.log(` Plage Y: ${minY} - ${maxY}`); + console.log(' → Vérifiez la projection et convertissez en WGS84 si nécessaire'); + } + } + + // Tester avec shpjs (comme le fait le serveur) + console.log(); + console.log('🔧 TEST AVEC SHPJS (simulation serveur):'); + console.log('-'.repeat(40)); + + try { + const fileBuffer = fs.readFileSync(zipPath); + const geojson = await shpjs(fileBuffer); + const featureCollections = Array.isArray(geojson) ? geojson : [geojson]; + const allFeatures = featureCollections.flatMap((fc) => fc.features); + + console.log(` ✅ shpjs a parsé ${allFeatures.length} features avec succès`); + + if (allFeatures.length > 0 && allFeatures[0].geometry) { + const geom = allFeatures[0].geometry as any; + const firstCoord = JSON.stringify(geom.coordinates?.[0]?.[0] || geom.coordinates?.[0]); + console.log(` Premier point après parsing: ${firstCoord}`); + } + } catch (shpjsError) { + console.log(' ❌ ERREUR shpjs:', shpjsError instanceof Error ? shpjsError.message : String(shpjsError)); + console.log(); + console.log(' C\'est probablement l\'erreur que vous voyez lors de l\'upload!'); + } + + console.log(); + console.log('='.repeat(60)); + console.log('✅ ANALYSE TERMINÉE'); + console.log('='.repeat(60)); + + } catch (error) { + console.error(); + console.error('❌ ERREUR lors de l\'analyse du shapefile:'); + console.error(error); + + // Analyser l'erreur plus en détail + if (error instanceof Error) { + console.error(); + console.error('📋 Détails de l\'erreur:'); + console.error(` Message: ${error.message}`); + console.error(` Stack: ${error.stack}`); + } + + process.exit(1); + } +} + +// Exécution +const zipPath = process.argv[2] || 'tools/perimetre ACV.zip'; +analyzeShapefile(zipPath).catch(console.error); diff --git a/tools/test-reprojection.ts b/tools/test-reprojection.ts new file mode 100644 index 000000000..cb4516dc8 --- /dev/null +++ b/tools/test-reprojection.ts @@ -0,0 +1,284 @@ +/** + * Script de test de la reprojection des coordonnées + * Usage: npx tsx tools/test-reprojection.ts "tools/perimetre ACV.zip" + */ + +import AdmZip from 'adm-zip'; +import * as fs from 'fs'; +import proj4 from 'proj4'; +import * as shapefile from 'shapefile'; +import { Feature, Geometry, Position } from 'geojson'; + +// Common projections for French territories +const KNOWN_PROJECTIONS: Record = { + 'EPSG:2154': '+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + 'EPSG:2975': '+proj=utm +zone=40 +south +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + 'EPSG:5490': '+proj=utm +zone=20 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + 'EPSG:32620': '+proj=utm +zone=20 +datum=WGS84 +units=m +no_defs +type=crs', + 'EPSG:2972': '+proj=utm +zone=22 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', + 'EPSG:4471': '+proj=utm +zone=38 +south +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs' +}; + +// Special projections with coordinate offsets +interface ProjectionWithOffset { + proj4: string; + xOffset: number; + yOffset: number; +} + +const OFFSET_PROJECTIONS: Record = { + 'REUNION_OFFSET': { + proj4: '+proj=utm +zone=40 +south +datum=WGS84 +units=m +no_defs +type=crs', + xOffset: -1350000, + yOffset: -1570000 + } +}; + +const WGS84 = '+proj=longlat +datum=WGS84 +no_defs +type=crs'; + +// Known location bounding boxes +const KNOWN_LOCATIONS: Record = { + 'Metropolitan France': { minLng: -5, maxLng: 10, minLat: 41, maxLat: 51 }, + 'La Réunion': { minLng: 55.2, maxLng: 55.9, minLat: -21.5, maxLat: -20.8 }, + 'Mayotte': { minLng: 45.0, maxLng: 45.3, minLat: -13.0, maxLat: -12.6 }, + 'Guadeloupe': { minLng: -61.9, maxLng: -61.0, minLat: 15.8, maxLat: 16.5 }, + 'Martinique': { minLng: -61.3, maxLng: -60.8, minLat: 14.4, maxLat: 14.9 }, + 'Guyane': { minLng: -54.5, maxLng: -51.5, minLat: 2.0, maxLat: 6.0 }, +}; + +function getLocationName(lng: number, lat: number): string | null { + for (const [name, bounds] of Object.entries(KNOWN_LOCATIONS)) { + if (lng >= bounds.minLng && lng <= bounds.maxLng && + lat >= bounds.minLat && lat <= bounds.maxLat) { + return name; + } + } + return null; +} + +function detectProjectionFromCoordinates( + xMin: number, + xMax: number, + yMin: number, + yMax: number +): string | null { + if (xMin >= -180 && xMax <= 180 && yMin >= -90 && yMax <= 90) { + return null; + } + if (xMin > 100000 && xMax < 1300000 && yMin > 6000000 && yMax < 7200000) { + return 'EPSG:2154'; + } + // Réunion with non-standard offsets + if (xMin > 1600000 && xMax < 1900000 && yMin > 9000000 && yMax < 9500000) { + return 'REUNION_OFFSET'; + } + // Standard UTM zone 40S (Réunion) + if (xMin > 300000 && xMax < 600000 && yMin > 7600000 && yMax < 7800000) { + return 'EPSG:2975'; + } + return null; +} + +function reprojectCoordinates(coordinates: Position[], sourceProj: string): Position[] { + // Check for offset projections first + const offsetProj = OFFSET_PROJECTIONS[sourceProj]; + if (offsetProj) { + return coordinates.map((coord) => { + const [x, y, ...rest] = coord; + const adjustedX = x + offsetProj.xOffset; + const adjustedY = y + offsetProj.yOffset; + const [lng, lat] = proj4(offsetProj.proj4, WGS84, [adjustedX, adjustedY]); + return [lng, lat, ...rest]; + }); + } + + const projDef = KNOWN_PROJECTIONS[sourceProj]; + if (!projDef) return coordinates; + + return coordinates.map((coord) => { + const [x, y, ...rest] = coord; + const [lng, lat] = proj4(projDef, WGS84, [x, y]); + return [lng, lat, ...rest]; + }); +} + +function reprojectGeometry(geometry: Geometry, sourceProj: string): Geometry { + if (geometry.type === 'Point') { + const [x, y] = geometry.coordinates as [number, number]; + + const offsetProj = OFFSET_PROJECTIONS[sourceProj]; + if (offsetProj) { + const adjustedX = x + offsetProj.xOffset; + const adjustedY = y + offsetProj.yOffset; + const [lng, lat] = proj4(offsetProj.proj4, WGS84, [adjustedX, adjustedY]); + return { ...geometry, coordinates: [lng, lat] }; + } + + const projDef = KNOWN_PROJECTIONS[sourceProj]; + if (projDef) { + const [lng, lat] = proj4(projDef, WGS84, [x, y]); + return { ...geometry, coordinates: [lng, lat] }; + } + return geometry; + } + + if (geometry.type === 'Polygon') { + return { + ...geometry, + coordinates: geometry.coordinates.map((ring) => + reprojectCoordinates(ring, sourceProj) + ) + }; + } + + if (geometry.type === 'MultiPolygon') { + return { + ...geometry, + coordinates: geometry.coordinates.map((polygon) => + polygon.map((ring) => reprojectCoordinates(ring, sourceProj)) + ) + }; + } + + return geometry; +} + +async function testReprojection(zipPath: string) { + console.log('='.repeat(60)); + console.log('TEST DE REPROJECTION'); + console.log('='.repeat(60)); + console.log(); + + const fileBuffer = fs.readFileSync(zipPath); + const zip = new AdmZip(fileBuffer); + const zipEntries = zip.getEntries(); + + const shpEntry = zipEntries.find((e) => e.entryName.toLowerCase().endsWith('.shp')); + const dbfEntry = zipEntries.find((e) => e.entryName.toLowerCase().endsWith('.dbf')); + + if (!shpEntry || !dbfEntry) { + console.error('Fichiers .shp ou .dbf manquants'); + return; + } + + const shpBuffer = shpEntry.getData(); + const dbfBuffer = dbfEntry.getData(); + + const features: Feature[] = []; + const source = await shapefile.open(shpBuffer, dbfBuffer); + + let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; + + let result = await source.read(); + while (!result.done) { + if (result.value.geometry !== null) { + features.push(result.value as Feature); + + if (result.value.geometry.type !== 'GeometryCollection') { + const coords = JSON.stringify(result.value.geometry.coordinates); + const numbers = coords.match(/-?\d+\.?\d*/g)?.map(Number) || []; + for (let i = 0; i < numbers.length; i += 2) { + const x = numbers[i], y = numbers[i + 1]; + if (x !== undefined && y !== undefined) { + xMin = Math.min(xMin, x); + xMax = Math.max(xMax, x); + yMin = Math.min(yMin, y); + yMax = Math.max(yMax, y); + } + } + } + } + result = await source.read(); + } + + console.log(`${features.length} features trouvees`); + console.log(); + console.log('COORDONNEES ORIGINALES:'); + console.log('-'.repeat(40)); + console.log(` X: ${xMin.toFixed(2)} a ${xMax.toFixed(2)}`); + console.log(` Y: ${yMin.toFixed(2)} a ${yMax.toFixed(2)}`); + + const sourceProjection = detectProjectionFromCoordinates(xMin, xMax, yMin, yMax); + + console.log(); + console.log('DETECTION DE PROJECTION:'); + console.log('-'.repeat(40)); + + if (sourceProjection) { + console.log(` Projection detectee: ${sourceProjection}`); + + if (OFFSET_PROJECTIONS[sourceProjection]) { + const offset = OFFSET_PROJECTIONS[sourceProjection]; + console.log(` Type: Projection avec offset`); + console.log(` Offset X: ${offset.xOffset} metres`); + console.log(` Offset Y: ${offset.yOffset} metres`); + } + + console.log(); + console.log('REPROJECTION VERS WGS84:'); + console.log('-'.repeat(40)); + + const firstFeature = features[0]; + if (firstFeature && firstFeature.geometry) { + const originalCoord = (firstFeature.geometry as any).coordinates?.[0]?.[0]; + console.log(` Avant: [${originalCoord[0].toFixed(2)}, ${originalCoord[1].toFixed(2)}]`); + + const reprojected = reprojectGeometry(firstFeature.geometry, sourceProjection); + const newCoord = (reprojected as any).coordinates?.[0]?.[0]; + console.log(` Apres: [${newCoord[0].toFixed(6)}, ${newCoord[1].toFixed(6)}]`); + + // Validate WGS84 range + const isValidLng = newCoord[0] >= -180 && newCoord[0] <= 180; + const isValidLat = newCoord[1] >= -90 && newCoord[1] <= 90; + const location = getLocationName(newCoord[0], newCoord[1]); + + console.log(); + if (isValidLng && isValidLat) { + console.log(' Coordonnees WGS84 valides!'); + console.log(` Longitude: ${newCoord[0].toFixed(6)} (${newCoord[0] > 0 ? 'E' : 'W'})`); + console.log(` Latitude: ${newCoord[1].toFixed(6)} (${newCoord[1] > 0 ? 'N' : 'S'})`); + + if (location) { + console.log(); + console.log(` LOCALISATION: ${location}`); + } + } else { + console.log(' Coordonnees hors plage WGS84'); + } + + // Test all features + console.log(); + console.log('VERIFICATION DE TOUTES LES FEATURES:'); + console.log('-'.repeat(40)); + + let allValid = true; + let allInLocation = true; + + for (const feature of features) { + if (feature.geometry) { + const reproj = reprojectGeometry(feature.geometry, sourceProjection); + const coords = (reproj as any).coordinates?.[0]?.[0]; + if (coords) { + const loc = getLocationName(coords[0], coords[1]); + if (!loc) { + allInLocation = false; + console.log(` Feature hors zone connue: [${coords[0].toFixed(6)}, ${coords[1].toFixed(6)}]`); + } + } + } + } + + if (allValid && allInLocation) { + console.log(` Toutes les ${features.length} features sont dans une zone connue`); + } + } + } else { + console.log(' Deja en WGS84 ou projection inconnue'); + } + + console.log(); + console.log('='.repeat(60)); +} + +const zipPath = process.argv[2] || 'tools/perimetre ACV.zip'; +testReprojection(zipPath).catch(console.error); diff --git a/tools/test-shapefile-fix.ts b/tools/test-shapefile-fix.ts new file mode 100644 index 000000000..65acd3c57 --- /dev/null +++ b/tools/test-shapefile-fix.ts @@ -0,0 +1,170 @@ +/** + * Script de test avant/après pour vérifier le correctif des Null Shapes + * Usage: npx tsx tools/test-shapefile-fix.ts "tools/perimetre ACV.zip" + */ + +import AdmZip from 'adm-zip'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as shapefile from 'shapefile'; +import shpjs from 'shpjs'; +import { Feature, FeatureCollection, Geometry } from 'geojson'; + +async function testWithShpjs(zipPath: string): Promise<{ success: boolean; error?: string; featureCount?: number }> { + try { + const fileBuffer = fs.readFileSync(zipPath); + const geojson = await shpjs(fileBuffer); + const featureCollections = Array.isArray(geojson) ? geojson : [geojson]; + const features = featureCollections.flatMap((fc) => fc.features); + return { success: true, featureCount: features.length }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function parseShapefileFromZip(fileBuffer: Buffer): Promise { + const zip = new AdmZip(fileBuffer); + const zipEntries = zip.getEntries(); + + const shpEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.shp') + ); + const dbfEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.dbf') + ); + + if (!shpEntry || !dbfEntry) { + throw new Error('Missing required shapefile components (.shp and .dbf)'); + } + + const shpBuffer = shpEntry.getData(); + const dbfBuffer = dbfEntry.getData(); + + const features: Feature[] = []; + const source = await shapefile.open(shpBuffer, dbfBuffer); + + let result = await source.read(); + while (!result.done) { + const feature = result.value; + // Filter out Null Shapes (features with null geometry) + if (feature.geometry !== null) { + features.push(feature as Feature); + } + result = await source.read(); + } + + return { + type: 'FeatureCollection', + features + }; +} + +async function testWithShapefileLib(zipPath: string): Promise<{ success: boolean; error?: string; featureCount?: number; nullShapesFiltered?: number }> { + try { + const fileBuffer = fs.readFileSync(zipPath); + + // Count total features including null shapes + const zip = new AdmZip(fileBuffer); + const zipEntries = zip.getEntries(); + const shpEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.shp') + ); + const dbfEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.dbf') + ); + + if (!shpEntry || !dbfEntry) { + return { success: false, error: 'Missing shapefile components' }; + } + + const shpBuffer = shpEntry.getData(); + const dbfBuffer = dbfEntry.getData(); + + let totalCount = 0; + let nullCount = 0; + const source = await shapefile.open(shpBuffer, dbfBuffer); + let result = await source.read(); + while (!result.done) { + totalCount++; + if (result.value.geometry === null) { + nullCount++; + } + result = await source.read(); + } + + // Now parse with the fix + const featureCollection = await parseShapefileFromZip(fileBuffer); + + return { + success: true, + featureCount: featureCollection.features.length, + nullShapesFiltered: nullCount + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function main(zipPath: string) { + console.log('='.repeat(60)); + console.log('TEST AVANT/APRÈS - CORRECTIF NULL SHAPES'); + console.log('='.repeat(60)); + console.log(); + console.log(`Fichier: ${path.basename(zipPath)}`); + console.log(); + + // Test AVANT (avec shpjs - méthode actuelle) + console.log('1. TEST AVANT (shpjs - méthode actuelle):'); + console.log('-'.repeat(40)); + const beforeResult = await testWithShpjs(zipPath); + if (beforeResult.success) { + console.log(` ✅ Succès: ${beforeResult.featureCount} features parsées`); + } else { + console.log(` ❌ ÉCHEC: ${beforeResult.error}`); + } + console.log(); + + // Test APRÈS (avec shapefile library - correctif) + console.log('2. TEST APRÈS (shapefile library - correctif):'); + console.log('-'.repeat(40)); + const afterResult = await testWithShapefileLib(zipPath); + if (afterResult.success) { + console.log(` ✅ Succès: ${afterResult.featureCount} features parsées`); + if (afterResult.nullShapesFiltered && afterResult.nullShapesFiltered > 0) { + console.log(` ℹ️ ${afterResult.nullShapesFiltered} Null Shapes filtrées`); + } + } else { + console.log(` ❌ ÉCHEC: ${afterResult.error}`); + } + console.log(); + + // Résumé + console.log('='.repeat(60)); + console.log('RÉSUMÉ:'); + console.log('='.repeat(60)); + + if (!beforeResult.success && afterResult.success) { + console.log('✅ Le correctif résout le problème!'); + console.log(` - Avant: ÉCHEC (${beforeResult.error})`); + console.log(` - Après: SUCCÈS (${afterResult.featureCount} features)`); + } else if (beforeResult.success && afterResult.success) { + console.log('ℹ️ Les deux méthodes fonctionnent pour ce fichier'); + console.log(` - shpjs: ${beforeResult.featureCount} features`); + console.log(` - shapefile: ${afterResult.featureCount} features`); + } else if (!beforeResult.success && !afterResult.success) { + console.log('❌ Les deux méthodes échouent - problème différent'); + console.log(` - shpjs: ${beforeResult.error}`); + console.log(` - shapefile: ${afterResult.error}`); + } else { + console.log('⚠️ Résultat inattendu'); + } +} + +const zipPath = process.argv[2] || 'tools/perimetre ACV.zip'; +main(zipPath).catch(console.error); diff --git a/yarn.lock b/yarn.lock index 7569fccb0..9103fc77b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11761,6 +11761,15 @@ __metadata: languageName: node linkType: hard +"@types/proj4@npm:^2.19.0": + version: 2.19.0 + resolution: "@types/proj4@npm:2.19.0" + dependencies: + proj4: "npm:*" + checksum: 10c0/3df1d7a3613461f3f1c5e0369668e50b02c0149233e235bf4b82af53a1f6c61acea633c9087b0a57ed211c873397c005af31082e202ac642b001d3c866d66871 + languageName: node + linkType: hard + "@types/prop-types@npm:*, @types/prop-types@npm:15.7.15, @types/prop-types@npm:^15.7.15": version: 15.7.15 resolution: "@types/prop-types@npm:15.7.15" @@ -13199,6 +13208,7 @@ __metadata: "@types/node": "npm:20.19.10" "@types/nodemailer": "npm:6.4.17" "@types/pg": "npm:8.15.5" + "@types/proj4": "npm:^2.19.0" "@types/qs": "npm:6.14.0" "@types/randomstring": "npm:1.3.0" "@types/shapefile": "npm:0" @@ -13264,6 +13274,7 @@ __metadata: pdf-lib: "npm:1.17.1" pg: "npm:8.16.3" pg-query-stream: "npm:4.10.3" + proj4: "npm:^2.20.2" puppeteer: "npm:22.15.0" qs: "npm:6.14.0" randomstring: "npm:1.3.1" @@ -27960,6 +27971,16 @@ __metadata: languageName: node linkType: hard +"proj4@npm:*, proj4@npm:^2.20.2": + version: 2.20.2 + resolution: "proj4@npm:2.20.2" + dependencies: + mgrs: "npm:1.0.0" + wkt-parser: "npm:^1.5.1" + checksum: 10c0/7b65fcb12a896a110779f6a0e28e947c4c208ccb4a2e381052f1b7c8bad5306d4315a1dac7b4d6316687a48cfbce63d6ff6017c9946c5ef12f440cbf67d8f0e5 + languageName: node + linkType: hard + "proj4@npm:^2.1.4": version: 2.19.10 resolution: "proj4@npm:2.19.10"