import GeoJSON from 'ol/format/GeoJSON'
import Feature from 'ol/Feature'
import * as shpwrite from '@mapbox/shp-write'
import JSZip from 'jszip'
/**
* Fonction récursive pour aplatir les objets imbriqués en utilisant un séparateur (ex: _)
* Cela permet de gérer les propriétés complexes en les transformant en champs plats pour le DBF
* @param {*} obj - objet à aplatir
* @param {*} prefix - préfixe pour les clés imbriquées (utilisé dans la récursion)
* @param {*} result - objet résultat qui accumule les paires clé-valeur aplaties
* @returns - objet aplati avec des clés composées pour les propriétés imbriquées
*/
function flattenObject (obj, prefix = '', result = {}) {
Object.entries(obj).forEach(([key, value]) => {
const newKey = prefix ? `${prefix}_${key}` : key
if (value === null || value === undefined) {
result[newKey] = ''
} else if (typeof value === 'number' || typeof value === 'string') {
result[newKey] = value
} else if (typeof value === 'boolean') {
result[newKey] = value ? 'T' : 'F'
} else if (value instanceof Date) {
result[newKey] = value.toISOString().split('T')[0]
} else if (Array.isArray(value)) {
// DBF ne supporte pas les arrays → stringify
result[newKey] = JSON.stringify(value)
} else if (typeof value === 'object') {
// récursif
flattenObject(value, newKey, result)
}
})
return result
}
// Gérer les doublons de noms de champs en ajoutant un suffixe numérique
function makeUniqueKey (key, used) {
const maxFieldLength = 10 // Limite de caractères pour les noms de champs dans les shapefiles
let newKey = key.substring(0, maxFieldLength)
if (used.has(newKey)) {
let i = 1
do {
const suffix = String(i)
const baseLength = Math.max(0, maxFieldLength - suffix.length)
const base = key.substring(0, baseLength)
newKey = `${base}${suffix}`
i++
} while (used.has(newKey))
}
used.add(newKey)
return newKey
}
// Normaliser les noms de champs pour respecter les limites de 10 caractères et éviter les caractères interdits
function normalizeFieldName (key) {
return key
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9_]/g, '')
.toUpperCase()
.substring(0, 10)
}
/**
* Normalise les noms de champs pour respecter les limites de 10 caractères et éviter les doublons
* Shapefile a une limite de 10 caractères pour les noms de champs et ne gère pas les doublons, cette fonction s'assure que les propriétés respectent ces contraintes
* @param {Object} props - Propriétés d'une feature à normaliser
* @param {Object} [attributeMap={}] - Map des noms de champs originaux aux nouveaux noms normalisés
* @returns {Object} Propriétés normalisées avec des noms de champs uniques et limités à 10 caractères
*/
function normalizeAttributes (props, attributeMap = {}) {
const result = {}
const used = new Set()
Object.entries(props).forEach(([key, value]) => {
// 1. normalisation du nom
const baseKey = normalizeFieldName(attributeMap[key] || key)
// 2. clé unique
const newKey = makeUniqueKey(baseKey, used)
// 3. gestion des valeurs
if (value && typeof value === 'object') {
const flat = flattenObject(value, newKey)
Object.entries(flat).forEach(([k, v]) => {
const normalized = normalizeFieldName(k)
const finalKey = makeUniqueKey(normalized, used)
result[finalKey] = v
})
} else {
result[newKey] = value ?? ''
}
})
return result
}
/**
* Normalise les géométries pour le format SHP
* @param {*} features - tableau de features GeoJSON à normaliser
* @param {*} type - type de géométrie (points, lignes, polygones) pour appliquer les règles de normalisation spécifiques à chaque type
* @returns -> tableau de géométries normalisées prêtes à être utilisées par shp-write
*/
function normalizeGeometries (features, type) {
return features.map(f => {
const geom = f.geometry
let coords = geom.coordinates
// POINT
if (type === 'points') {
return coords
}
// LINES → FORCER MULTIPART
if (type === 'lines') {
if (geom.type === 'LineString') {
coords = [coords]
}
return coords
}
// POLYGONS → FORCER MULTIPART
if (type === 'polygons') {
if (geom.type === 'Polygon') {
coords = [coords]
}
return coords
}
throw new Error('Unsupported geometry type: ' + type)
})
}
/**
* Retourne le type de géométrie SHP correspondant au type GeoJSON
* @param {*} type - type de géométrie (points, lignes, polygones)
* @returns - type de géométrie SHP
*/
function getShpType (type) {
if (type === 'points') return 'POINT'
if (type === 'lines') return 'POLYLINE'
if (type === 'polygons') return 'POLYGON'
throw new Error('Unsupported geometry type: ' + type)
}
/**
* Télécharge un fichier shapefile
* @param {*} result - Résultat de la génération du shapefile
* @param {*} filename - Nom du fichier à télécharger
*/
function downloadShapefile (result, filename) {
const zip = new JSZip()
zip.file(filename + '.shp', new Uint8Array(result.shp.buffer))
zip.file(filename + '.shx', new Uint8Array(result.shx.buffer))
zip.file(filename + '.dbf', new Uint8Array(result.dbf.buffer))
zip.file(filename + '.prj', result.prj)
zip.generateAsync({ type: 'blob' })
.then(blob => {
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = filename + '.zip'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
})
.catch(error => {
console.error('Erreur lors de la génération du ZIP shapefile :', error)
})
}
/**
* Export les couches OpenLayers au format Shapefile
* Cette fonction prend un tableau de configurations de couches, extrait les features, les groupe par type de géométrie,
* et utilise shp-write pour générer les fichiers SHP, SHX, DBF et PRJ.
* Les fichiers sont ensuite regroupés dans une archive ZIP pour le téléchargement.
* Les options permettent de spécifier les projections utilisées pour les données et les features, avec des valeurs par défaut adaptées à la plupart des cas.
* @param {Array<Object>} layerConfigs - tableau de configurations de couches à exporter. Par défaut, toutes les couches visibles avec des features sont exportées
* @param {ol.Layer} layerConfigs[].layer - layer OpenLayers à exporter
* @param {string} layerConfigs[].name - Nom du fichier exporté
* @param {Function} [layerConfigs[].filter] - Fonction de filtrage optionnelle pour les features pour cette couche. Ce callback est exécuté tel quel et doit donc provenir d'une source de confiance.
* @param {Object} [layerConfigs[].attributeMap] - Map optionnelle pour renommer les champs d'attributs (clé : nom original, valeur : nom normalisé)
* @param {Object} [options={}] - Options d'export
* * @param {Viewer} options.viewer instance active du viewer, nécessaire si layerConfigs n'est pas fourni pour récupérer les couches visibles
* * @param {string} options.featureProjection - projection des features dans OpenLayers (par défaut 'EPSG:3857')
* * @param {string} options.dataProjection - projection des données dans le shapefile (par défaut 'EPSG:4326')
*/
function exportLayersToShapefile (layerConfigs = [], options = {}) {
const geojsonFormat = new GeoJSON()
// recupérer les projections depuis les options ou utiliser les valeurs par défaut
const featureProjection = options.featureProjection || 'EPSG:3857'
const dataProjection = options.dataProjection || 'EPSG:4326'
if (layerConfigs.length === 0) {
layerConfigs = options.viewer.getMap()
.getLayers()
.getArray()
.filter(l => l.getVisible() && typeof l.getSource === 'function' && l.getSource() && typeof l.getSource().getFeatures === 'function')
.map(l => ({
layer: l,
name: (l.get('name') || l.get('title') || `layer-${l.get('id')}`).replace(/[^a-zA-Z0-9_-]/g, '_'),
}))
}
if (!layerConfigs.length) {
console.warn('addExportSHP : aucune couche vecteur disponible à exporter')
return
}
layerConfigs.forEach(config => {
const { layer, name, filter, attributeMap = {} } = config
if (!layer || !name) {
console.warn('Configuration de couche invalide :', config)
return
}
const source = typeof layer.getSource === 'function' ? layer.getSource() : null
if (!source || typeof source.getFeatures !== 'function') {
console.warn('La couche n\'a pas de source ou de features :', name)
return
}
let features = source.getFeatures()
// APPLIQUER LE FILTRE SI FOURNI
if (filter != null && typeof filter !== 'function') {
console.warn('Le filtre fourni n\'est pas une fonction pour la couche :', name)
} else if (typeof filter === 'function') {
features = features.filter(f => {
try {
return filter(f)
} catch (e) {
console.error('Erreur dans la fonction de filtre pour la couche :', name, e)
return false
}
})
}
if (!features.length) {
console.warn('Aucune feature pour la couche :', name)
return
}
const groups = {
points: [],
lines: [],
polygons: [],
}
// GROUPER LES FEATURES PAR TYPE DE GÉOMÉTRIE
features.forEach(f => {
const geom = f.getGeometry()
if (!geom) return
const type = geom.getType()
let key = null
if (type.includes('Point')) key = 'points'
else if (type.includes('Line')) key = 'lines'
else if (type.includes('Polygon')) key = 'polygons'
if (!key) return
const props = { ...f.getProperties() }
delete props.geometry
groups[key].push(
new Feature({
geometry: geom.clone(),
...normalizeAttributes(props, attributeMap),
})
)
})
Object.entries(groups).forEach(([type, feats]) => {
if (!feats.length) return
const geojson = geojsonFormat.writeFeaturesObject(feats, {
featureProjection,
dataProjection,
})
const geometrytype = getShpType(type)
// NORMALISATION CENTRALISÉE DES GÉOMÉTRIES POUR SHP-WRITE
const geometries = normalizeGeometries(geojson.features, type)
// Recuperer les propriétés normalisées pour le DBF
// const properties = geojson.features.map(f => normalizeAttributes(f.properties, attributeMap))
const properties = geojson.features.map(f => f.properties)
shpwrite.write(
properties,
geometrytype,
geometries,
(err, result) => {
if (err) {
console.error('Erreur shp-write :', err)
return
}
if (!result.shp || !result.dbf) {
console.error('Shapefile invalide')
return
}
downloadShapefile(result, `${name}_${type}`)
}
)
})
})
}
export { exportLayersToShapefile }