feat: add vector layers and GeoJSON support for polygons and lines on mobile map
This commit is contained in:
parent
35a20a2936
commit
d33b49647b
5 changed files with 496 additions and 137 deletions
|
|
@ -724,6 +724,124 @@
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* MapLibre layer definition for polygon asset fills (surfacic elements: green surfaces, parking spots, etc.).
|
||||
*/
|
||||
function getAssetsFillLayer(sourceId = 'assets') {
|
||||
return {
|
||||
id: 'assets-fill',
|
||||
type: 'fill',
|
||||
source: sourceId,
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#fef08a',
|
||||
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||
[
|
||||
'match', ['get', 'structure_type'],
|
||||
['massif drainant', 'tranchee drainante', 'collecteur en beton poreux', 'empierrement sedimentation'], '#8d6e63',
|
||||
['jardin de pluie', 'noue', 'toiture verte', 'bande filtrante', 'espace vert sur dalle'], '#009688',
|
||||
['bassin sec/infiltration', 'bassin en eau', 'bassin orage', 'citerne', 'toiture stockante', 'SAUL-vide', 'SAUL-terre'], '#0277bd',
|
||||
'#0288d1'
|
||||
],
|
||||
['==', ['get', 'thematic'], 'nature'],
|
||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||
['==', ['get', 'thematic'], 'parking'],
|
||||
getParkingColorExpr(),
|
||||
getThematicColorExpr()
|
||||
],
|
||||
'fill-opacity': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
|
||||
['==', ['get', 'thematic'], 'parking'], 0.38,
|
||||
0.65
|
||||
],
|
||||
'fill-antialias': false,
|
||||
'fill-outline-color': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#eab308',
|
||||
'#00000000'
|
||||
],
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MapLibre layer definition for polygon asset outlines.
|
||||
*/
|
||||
function getAssetsPolygonOutlineLayer(sourceId = 'assets') {
|
||||
return {
|
||||
id: 'assets-polygon-outline',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#ca8a04',
|
||||
['==', ['get', 'thematic'], 'nature'],
|
||||
['coalesce', ['get', 'color'], '#1b5e20'],
|
||||
['==', ['get', 'thematic'], 'parking'],
|
||||
getParkingColorExpr(),
|
||||
getThematicColorExpr()
|
||||
],
|
||||
'line-width': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
|
||||
2
|
||||
],
|
||||
'line-opacity': 0.95,
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MapLibre layer definition for linear assets (roads, sidewalks, ditches, cables, etc.).
|
||||
*/
|
||||
function getAssetsLineLayer(sourceId = 'assets') {
|
||||
return {
|
||||
id: 'assets-line',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'LineString'],
|
||||
['==', ['geometry-type'], 'MultiLineString']
|
||||
],
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#eab308',
|
||||
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||
[
|
||||
'match', ['get', 'structure_type'],
|
||||
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif'], '#8d6e63',
|
||||
['noue', 'jardin de pluie', 'bande filtrante'], '#009688',
|
||||
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein'], '#0277bd',
|
||||
'#0288d1'
|
||||
],
|
||||
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
|
||||
['match', ['get', 'asset_type'], 'roadway', '#334155', 'sidewalk', '#64748b', getThematicColorExpr()]
|
||||
],
|
||||
'line-width': [
|
||||
'case',
|
||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 6,
|
||||
3.5
|
||||
],
|
||||
'line-opacity': 0.88,
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 6. Public Export ───────────────────────────────────────────────────────
|
||||
global.AssetSymbology = {
|
||||
THEMATIC_CONFIG,
|
||||
|
|
@ -738,6 +856,9 @@
|
|||
getThematicColorExpr,
|
||||
getIconImageExpr,
|
||||
getParkingColorExpr,
|
||||
getAssetsFillLayer,
|
||||
getAssetsPolygonOutlineLayer,
|
||||
getAssetsLineLayer,
|
||||
};
|
||||
|
||||
// Ensure UNIFIED_ICON_URLS is globally available if not already defined
|
||||
|
|
|
|||
|
|
@ -311,54 +311,39 @@ const _unified_locations_fill = {
|
|||
};
|
||||
|
||||
// ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
|
||||
const _unified_assets_fill = {
|
||||
id: 'assets-fill',
|
||||
type: 'fill',
|
||||
source: 'assets',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
paint: {
|
||||
'fill-color': [
|
||||
// Nature surfaces use a per-feature 'color' property; all others
|
||||
// use the thematic colour.
|
||||
'case',
|
||||
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||
[
|
||||
'match', ['get', 'structure_type'],
|
||||
['massif drainant', 'tranchee drainante', 'collecteur en beton poreux', 'empierrement sedimentation'], '#8d6e63', // earthy gravel brown
|
||||
['jardin de pluie', 'noue', 'toiture verte', 'bande filtrante', 'espace vert sur dalle'], '#009688', // teal vegetated
|
||||
['bassin sec/infiltration', 'bassin en eau', 'bassin orage', 'citerne', 'toiture stockante', 'SAUL-vide', 'SAUL-terre'], '#0277bd', // deep water basins
|
||||
'#0288d1' // fallback blue
|
||||
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
|
||||
? AssetSymbology.getAssetsFillLayer('assets')
|
||||
: {
|
||||
id: 'assets-fill',
|
||||
type: 'fill',
|
||||
source: 'assets',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'case',
|
||||
['==', ['get', 'thematic'], 'nature'],
|
||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||
['==', ['get', 'thematic'], 'parking'],
|
||||
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
|
||||
THEMATIC_COLOR_EXPR
|
||||
],
|
||||
['==', ['get', 'thematic'], 'nature'],
|
||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||
['==', ['get', 'thematic'], 'parking'],
|
||||
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : [
|
||||
'match', ['get', 'spot_type'],
|
||||
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
|
||||
'delivery', '#f59e0b', // Orange/Ambre camion livraison
|
||||
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
|
||||
]),
|
||||
THEMATIC_COLOR_EXPR
|
||||
],
|
||||
|
||||
'fill-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'thematic'], 'parking'], 0.35,
|
||||
0.7
|
||||
],
|
||||
|
||||
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
||||
'fill-outline-color': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||
'#00000000'
|
||||
],
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
'fill-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'thematic'], 'parking'], 0.35,
|
||||
0.7
|
||||
],
|
||||
'fill-antialias': false,
|
||||
'fill-outline-color': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||
'#00000000'
|
||||
],
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
|
||||
// ─── Layer 3 — location outline (line layer for polygon location borders) ─────
|
||||
const _unified_locations_outline = {
|
||||
|
|
@ -698,37 +683,31 @@ const _unified_sign_poles_orientation = {
|
|||
};
|
||||
|
||||
// ─── Layer 3b — asset line (line geometries for linear assets like cables, ditches) ──
|
||||
const _unified_assets_line = {
|
||||
id: 'assets-line',
|
||||
type: 'line',
|
||||
source: 'assets',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'LineString'],
|
||||
['==', ['geometry-type'], 'MultiLineString']
|
||||
],
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||
[
|
||||
'match', ['get', 'structure_type'],
|
||||
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif'], '#8d6e63',
|
||||
['noue', 'jardin de pluie', 'bande filtrante'], '#009688',
|
||||
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein'], '#0277bd',
|
||||
'#0288d1'
|
||||
const _unified_assets_line = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsLineLayer)
|
||||
? AssetSymbology.getAssetsLineLayer('assets')
|
||||
: {
|
||||
id: 'assets-line',
|
||||
type: 'line',
|
||||
source: 'assets',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'LineString'],
|
||||
['==', ['geometry-type'], 'MultiLineString']
|
||||
],
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
|
||||
THEMATIC_COLOR_EXPR
|
||||
],
|
||||
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
|
||||
THEMATIC_COLOR_EXPR
|
||||
],
|
||||
'line-width': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], 5,
|
||||
3
|
||||
],
|
||||
'line-opacity': 0.8,
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
'line-width': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], 5,
|
||||
3
|
||||
],
|
||||
'line-opacity': 0.8,
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
|
||||
// ─── Layer 4b — road assets circles (point roadway/sidewalk assets) ──────────
|
||||
const _unified_assets_point = {
|
||||
|
|
@ -772,37 +751,33 @@ const _unified_assets_point = {
|
|||
};
|
||||
|
||||
// ─── Layer 3c — asset polygon outline (line layer for polygon asset borders) ───
|
||||
const _unified_assets_polygon_outline = {
|
||||
id: 'assets-polygon-outline',
|
||||
type: 'line',
|
||||
source: 'assets',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||
['==', ['get', 'thematic'], 'parking'],
|
||||
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : [
|
||||
'match', ['get', 'spot_type'],
|
||||
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
|
||||
'delivery', '#f59e0b', // Orange/Ambre camion livraison
|
||||
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
|
||||
]),
|
||||
THEMATIC_COLOR_EXPR
|
||||
const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsPolygonOutlineLayer)
|
||||
? AssetSymbology.getAssetsPolygonOutlineLayer('assets')
|
||||
: {
|
||||
id: 'assets-polygon-outline',
|
||||
type: 'line',
|
||||
source: 'assets',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
|
||||
'line-width': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], 4,
|
||||
2
|
||||
],
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||
['==', ['get', 'thematic'], 'parking'],
|
||||
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
|
||||
THEMATIC_COLOR_EXPR
|
||||
],
|
||||
'line-width': [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'highlighted'], false], 4,
|
||||
2
|
||||
],
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
minzoom: 8,
|
||||
};
|
||||
|
||||
// ─── Ordered layer list ───────────────────────────────────────────────────────
|
||||
// MapLibre renders layers in insertion order, so the array below defines the
|
||||
|
|
|
|||
|
|
@ -641,8 +641,11 @@
|
|||
let noResultsAlertTimeout = null;
|
||||
let mapMoveTimeout = null;
|
||||
let lastResults = [];
|
||||
let lastGeojson = { type: 'FeatureCollection', features: [] };
|
||||
let lastLimitReached = false;
|
||||
let lastTotalCount = 0;
|
||||
let lastSelectedFeatureId = null;
|
||||
let layersConfigured = false;
|
||||
|
||||
// Éléments DOM
|
||||
const searchInput = document.getElementById('assetSearchInput');
|
||||
|
|
@ -821,7 +824,57 @@
|
|||
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-left');
|
||||
|
||||
map.on('click', () => {
|
||||
// Couches vectorielles pour les éléments surfaciques (polygones) et linéaires (lignes)
|
||||
function setupMapLayers() {
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
if (!map.getSource('assets')) {
|
||||
map.addSource('assets', {
|
||||
type: 'geojson',
|
||||
data: lastGeojson || { type: 'FeatureCollection', features: [] },
|
||||
promoteId: 'feature_id'
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof AssetSymbology !== 'undefined') {
|
||||
if (!map.getLayer('assets-fill')) {
|
||||
map.addLayer(AssetSymbology.getAssetsFillLayer('assets'));
|
||||
}
|
||||
if (!map.getLayer('assets-polygon-outline')) {
|
||||
map.addLayer(AssetSymbology.getAssetsPolygonOutlineLayer('assets'));
|
||||
}
|
||||
if (!map.getLayer('assets-line')) {
|
||||
map.addLayer(AssetSymbology.getAssetsLineLayer('assets'));
|
||||
}
|
||||
|
||||
if (!layersConfigured) {
|
||||
layersConfigured = true;
|
||||
['assets-fill', 'assets-line'].forEach(layerId => {
|
||||
map.on('click', layerId, (e) => {
|
||||
if (e.features && e.features.length > 0) {
|
||||
const feature = e.features[0];
|
||||
const props = feature.properties || {};
|
||||
const featId = feature.id || props.feature_id;
|
||||
const asset = lastResults.find(a => String(a.feature_id) === String(featId) || (a.model_key === props.model_key && String(a.id) === String(props.id))) || props;
|
||||
if (e.originalEvent) {
|
||||
e.originalEvent._handledByAssetLayer = true;
|
||||
}
|
||||
openBottomSheet(asset);
|
||||
}
|
||||
});
|
||||
map.on('mouseenter', layerId, () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
map.on('mouseleave', layerId, () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.on('click', (e) => {
|
||||
if (e.originalEvent && e.originalEvent._handledByAssetLayer) return;
|
||||
closeBottomSheet();
|
||||
});
|
||||
|
||||
|
|
@ -853,8 +906,21 @@
|
|||
});
|
||||
|
||||
map.on('load', () => {
|
||||
if (lastResults.length > 0 && activeMarkers.length === 0) {
|
||||
renderMapView(lastResults, isBboxFiltered);
|
||||
layersConfigured = false;
|
||||
setupMapLayers();
|
||||
if (lastResults.length > 0) {
|
||||
renderMapView(lastResults, lastGeojson, isBboxFiltered);
|
||||
}
|
||||
});
|
||||
|
||||
map.on('styledata', () => {
|
||||
if (map.isStyleLoaded() && !map.getSource('assets')) {
|
||||
layersConfigured = false;
|
||||
setupMapLayers();
|
||||
if (lastGeojson && lastGeojson.features && lastGeojson.features.length > 0) {
|
||||
const source = map.getSource('assets');
|
||||
if (source) source.setData(lastGeojson);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -916,6 +982,10 @@
|
|||
assetsListContainer.innerHTML = '';
|
||||
activeMarkers.forEach(m => m.remove());
|
||||
activeMarkers = [];
|
||||
lastGeojson = { type: 'FeatureCollection', features: [] };
|
||||
if (map && map.getSource('assets')) {
|
||||
map.getSource('assets').setData(lastGeojson);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -981,6 +1051,7 @@
|
|||
// 4. Rendu des résultats sur Carte et dans la Liste
|
||||
function renderResults(data, fromMapMove = false, options = {}) {
|
||||
lastResults = data.results || [];
|
||||
lastGeojson = data.geojson || { type: 'FeatureCollection', features: [] };
|
||||
lastLimitReached = Boolean(data.limit_reached);
|
||||
lastTotalCount = data.total_count || 0;
|
||||
|
||||
|
|
@ -1003,8 +1074,8 @@
|
|||
// Synchronisation de la Liste (toujours à jour avec la zone zoomée ou les filtres)
|
||||
renderListView(lastResults);
|
||||
|
||||
// Rendu Carte (mise à jour des marqueurs, sans modifier la visualisation)
|
||||
renderMapView(lastResults, fromMapMove);
|
||||
// Rendu Carte (mise à jour des marqueurs et des couches vectorielles)
|
||||
renderMapView(lastResults, lastGeojson, fromMapMove);
|
||||
|
||||
// Gestion du message invitant à revenir à la vue initiale en cas de recherche sans résultat
|
||||
handleNoResultsInZoneAlert(data, options);
|
||||
|
|
@ -1078,16 +1149,33 @@
|
|||
});
|
||||
}
|
||||
|
||||
function renderMapView(results, fromMapMove = false) {
|
||||
function renderMapView(results = lastResults, geojson = lastGeojson, fromMapMove = false) {
|
||||
if (!map) return;
|
||||
if (typeof geojson === 'boolean') {
|
||||
fromMapMove = geojson;
|
||||
geojson = lastGeojson;
|
||||
}
|
||||
if (!geojson) geojson = lastGeojson;
|
||||
|
||||
// Mettre à jour la source vectorielle pour les polygones et polylignes
|
||||
if (map.isStyleLoaded()) {
|
||||
const source = map.getSource('assets');
|
||||
if (source) {
|
||||
source.setData(geojson || { type: 'FeatureCollection', features: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer les anciens marqueurs
|
||||
activeMarkers.forEach(m => m.remove());
|
||||
activeMarkers = [];
|
||||
|
||||
results.forEach(asset => {
|
||||
(results || []).forEach(asset => {
|
||||
if (asset.lon === null || asset.lat === null) return;
|
||||
|
||||
// N'afficher un marqueur DOM HTML que pour les géométries ponctuelles
|
||||
const isPunctual = !asset.geom_type || asset.geom_type === 'Point' || asset.geom_type === 'MultiPoint';
|
||||
if (!isPunctual) return;
|
||||
|
||||
const symbology = (typeof AssetSymbology !== 'undefined')
|
||||
? AssetSymbology.getAssetSymbology(asset)
|
||||
: { color: THEMATIC_COLORS[asset.thematic] || '#2f3e8b', iconClass: asset.thematic_icon || 'bi-pin-map' };
|
||||
|
|
@ -1119,6 +1207,24 @@
|
|||
|
||||
// 5. Volet d'aperçu inférieur (Bottom Sheet)
|
||||
function openBottomSheet(asset) {
|
||||
if (!asset) return;
|
||||
|
||||
// Mettre en surbrillance sur la carte (pour surfaces polygonales et polylignes)
|
||||
if (map && map.getSource('assets')) {
|
||||
if (lastSelectedFeatureId) {
|
||||
try {
|
||||
map.setFeatureState({ source: 'assets', id: lastSelectedFeatureId }, { selected: false });
|
||||
} catch (e) {}
|
||||
}
|
||||
const featId = asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : null);
|
||||
if (featId) {
|
||||
lastSelectedFeatureId = featId;
|
||||
try {
|
||||
map.setFeatureState({ source: 'assets', id: featId }, { selected: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
sheetCode.textContent = asset.code || '-';
|
||||
sheetName.textContent = asset.name || asset.element_type || '';
|
||||
sheetThematicName.textContent = asset.thematic_name || '';
|
||||
|
|
@ -1139,16 +1245,16 @@
|
|||
sheetStatusBadge.textContent = asset.status_display || asset.status || 'Actif';
|
||||
sheetElementType.textContent = asset.element_type || '';
|
||||
|
||||
if (asset.distance_m !== null) {
|
||||
if (asset.distance_m !== null && asset.distance_m !== undefined) {
|
||||
sheetDistanceBadge.classList.remove('d-none');
|
||||
sheetDistanceText.textContent = formatDistance(asset.distance_m);
|
||||
} else {
|
||||
sheetDistanceBadge.classList.add('d-none');
|
||||
}
|
||||
|
||||
sheetDetailLink.href = asset.detail_url;
|
||||
sheetDetailLink.href = asset.detail_url || '#';
|
||||
|
||||
if (asset.lat !== null && asset.lon !== null) {
|
||||
if (asset.lat !== null && asset.lat !== undefined && asset.lon !== null && asset.lon !== undefined) {
|
||||
sheetRouteLink.href = `https://www.google.com/maps/dir/?api=1&destination=${asset.lat},${asset.lon}`;
|
||||
sheetRouteLink.classList.remove('d-none');
|
||||
} else {
|
||||
|
|
@ -1157,14 +1263,37 @@
|
|||
|
||||
bottomSheet.classList.add('active');
|
||||
|
||||
// Centrer la carte sur le marqueur
|
||||
if (map && asset.lon !== null && asset.lat !== null) {
|
||||
map.easeTo({ center: [asset.lon, asset.lat], zoom: Math.max(map.getZoom(), 15), offset: [0, 80] });
|
||||
// Cadrage ou recentrage sur la carte
|
||||
if (map) {
|
||||
let bbox = asset.bbox;
|
||||
if (typeof bbox === 'string') {
|
||||
try { bbox = JSON.parse(bbox); } catch (e) { bbox = null; }
|
||||
}
|
||||
|
||||
if (Array.isArray(bbox) && bbox.length === 4 &&
|
||||
isFinite(bbox[0]) && isFinite(bbox[1]) && isFinite(bbox[2]) && isFinite(bbox[3]) &&
|
||||
(bbox[2] > bbox[0] || bbox[3] > bbox[1])) {
|
||||
// Surface ou ligne : zoomer sur son emprise
|
||||
isProgrammaticMove = true;
|
||||
map.fitBounds(
|
||||
[[bbox[0], bbox[1]], [bbox[2], bbox[3]]],
|
||||
{ padding: { top: 60, bottom: 220, left: 40, right: 40 }, maxZoom: 18 }
|
||||
);
|
||||
} else if (asset.lon !== null && asset.lat !== null && isFinite(asset.lon) && isFinite(asset.lat)) {
|
||||
isProgrammaticMove = true;
|
||||
map.easeTo({ center: [asset.lon, asset.lat], zoom: Math.max(map.getZoom(), 16), offset: [0, 80] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeBottomSheet() {
|
||||
bottomSheet.classList.remove('active');
|
||||
if (map && map.getSource('assets') && lastSelectedFeatureId) {
|
||||
try {
|
||||
map.setFeatureState({ source: 'assets', id: lastSelectedFeatureId }, { selected: false });
|
||||
} catch (e) {}
|
||||
lastSelectedFeatureId = null;
|
||||
}
|
||||
}
|
||||
|
||||
btnSheetClose.addEventListener('click', closeBottomSheet);
|
||||
|
|
@ -1247,6 +1376,10 @@
|
|||
|
||||
hideNoResultsInZoneAlert();
|
||||
closeBottomSheet();
|
||||
lastGeojson = { type: 'FeatureCollection', features: [] };
|
||||
if (map && map.getSource('assets')) {
|
||||
map.getSource('assets').setData(lastGeojson);
|
||||
}
|
||||
|
||||
// Supprimer la persistance localStorage
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ from django.test import TestCase, Client
|
|||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.gis.geos import Polygon
|
||||
from django.contrib.gis.geos import Polygon, MultiPolygon
|
||||
from django.utils import translation
|
||||
import json
|
||||
|
||||
from common.models import UserConfig, Role, Thematic, UserThematics
|
||||
from assets.models import NatureTree, NatureLocation, NatureShrub, ParkingSpot
|
||||
from assets.models import NatureTree, NatureLocation, NatureShrub, ParkingSpot, NatureGreenSurface
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -76,6 +76,15 @@ class MobileAssetDetailTests(TestCase):
|
|||
geom=poly,
|
||||
)
|
||||
|
||||
# Create test green surface asset (MultiPolygon)
|
||||
poly_surf = Polygon(((648050, 670050), (648080, 670050), (648080, 670080), (648050, 670080), (648050, 670050)), srid=3812)
|
||||
self.green_surface = NatureGreenSurface.objects.create(
|
||||
code="S075-2138",
|
||||
name_fr="Pelouse parc central",
|
||||
status="active",
|
||||
geom=MultiPolygon([poly_surf]),
|
||||
)
|
||||
|
||||
def test_anonymous_user_redirected_to_login(self):
|
||||
"""Anonymous user trying to access mobile asset detail is redirected to login."""
|
||||
url = reverse("mobile:mobile_asset_detail_direct", args=["naturetree", self.tree.id])
|
||||
|
|
@ -337,13 +346,14 @@ class MobileAssetDetailTests(TestCase):
|
|||
codes = [item["code"] for item in data["results"]]
|
||||
self.assertIn("ARB-TEST-001", codes)
|
||||
self.assertIn("ARB-SHRUB-001", codes)
|
||||
self.assertIn("S075-2138", codes)
|
||||
# Check asset_type in nature results and GeoJSON properties
|
||||
for item in data["results"]:
|
||||
self.assertIn("asset_type", item)
|
||||
self.assertIn(item["asset_type"], ["naturetree", "natureshrub"])
|
||||
self.assertIn(item["asset_type"], ["naturetree", "natureshrub", "naturegreensurface"])
|
||||
for feature in data["geojson"]["features"]:
|
||||
self.assertIn("asset_type", feature["properties"])
|
||||
self.assertIn(feature["properties"]["asset_type"], ["naturetree", "natureshrub"])
|
||||
self.assertIn(feature["properties"]["asset_type"], ["naturetree", "natureshrub", "naturegreensurface"])
|
||||
|
||||
# 3. Test limit reached logic with limit=1: when limit is reached, return empty results and empty geojson
|
||||
response = self.client.get(search_url, {"thematic": "nature", "limit": "1"})
|
||||
|
|
@ -425,3 +435,58 @@ class MobileAssetDetailTests(TestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "assets/naturetree_detail.html")
|
||||
|
||||
def test_search_linear_and_surfacic_geometries(self):
|
||||
"""Surfacic and linear assets return their real Polygon/MultiPolygon geometry, bbox, and geom_type."""
|
||||
user = User.objects.create_user(username="geo_search_user", password="password")
|
||||
user_config = UserConfig.objects.create(user=user, is_intern=True)
|
||||
user_config.roles.add(self.admin_role)
|
||||
UserThematics.objects.create(
|
||||
user_config=user_config,
|
||||
thematic=self.thematic,
|
||||
can_view_assets=True
|
||||
)
|
||||
UserThematics.objects.create(
|
||||
user_config=user_config,
|
||||
thematic=self.parking_thematic,
|
||||
can_view_assets=True
|
||||
)
|
||||
|
||||
self.client.login(username="geo_search_user", password="password")
|
||||
search_url = reverse("mobile:mobile_assets_search")
|
||||
|
||||
# 1. Test nature search with green surface (MultiPolygon)
|
||||
response = self.client.get(search_url, {"thematic": "nature", "q": "S075-2138"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
|
||||
result_item = data["results"][0]
|
||||
self.assertEqual(result_item["code"], "S075-2138")
|
||||
self.assertEqual(result_item["geom_type"], "MultiPolygon")
|
||||
self.assertIsNotNone(result_item["bbox"])
|
||||
self.assertEqual(len(result_item["bbox"]), 4)
|
||||
self.assertIsNotNone(result_item["lat"])
|
||||
self.assertIsNotNone(result_item["lon"])
|
||||
|
||||
feature = data["geojson"]["features"][0]
|
||||
self.assertEqual(feature["geometry"]["type"], "MultiPolygon")
|
||||
self.assertEqual(feature["properties"]["code"], "S075-2138")
|
||||
self.assertEqual(feature["properties"]["geom_type"], "MultiPolygon")
|
||||
self.assertEqual(feature["properties"]["bbox"], result_item["bbox"])
|
||||
self.assertEqual(feature["id"], f"naturegreensurface_{self.green_surface.id}")
|
||||
|
||||
# 2. Test parking search with parking spot (Polygon)
|
||||
response = self.client.get(search_url, {"thematic": "parking", "q": "PK-SPOT-001"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
|
||||
spot_res = data["results"][0]
|
||||
self.assertEqual(spot_res["geom_type"], "Polygon")
|
||||
self.assertIsNotNone(spot_res["bbox"])
|
||||
self.assertEqual(len(spot_res["bbox"]), 4)
|
||||
|
||||
spot_feat = data["geojson"]["features"][0]
|
||||
self.assertEqual(spot_feat["geometry"]["type"], "Polygon")
|
||||
self.assertEqual(spot_feat["properties"]["geom_type"], "Polygon")
|
||||
|
||||
|
|
|
|||
|
|
@ -1194,6 +1194,7 @@ def mobile_assets_search(request):
|
|||
Supports proximity sorting if lat/lon parameters are provided.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
|
@ -1418,22 +1419,55 @@ def mobile_assets_search(request):
|
|||
for item in items:
|
||||
lat = None
|
||||
lon = None
|
||||
geometry = None
|
||||
geom_wgs84 = None
|
||||
|
||||
# 1. Vérifier si un GeoJSON complet existe déjà sur l'asset (via GeoSyncMixin)
|
||||
raw_geojson = getattr(item, 'geojson', None)
|
||||
if raw_geojson:
|
||||
if isinstance(raw_geojson, dict):
|
||||
geometry = raw_geojson
|
||||
elif isinstance(raw_geojson, str):
|
||||
try:
|
||||
geometry = json.loads(raw_geojson)
|
||||
except Exception:
|
||||
geometry = None
|
||||
|
||||
# 2. Vérifier si l'asset a un champ geom pour obtenir la géométrie 4326 et son centroïde
|
||||
if hasattr(item, 'geom') and item.geom:
|
||||
try:
|
||||
geom_wgs84 = item.geom.clone()
|
||||
if geom_wgs84.srid != 4326:
|
||||
geom_wgs84.transform(4326)
|
||||
if not geometry:
|
||||
geometry = json.loads(geom_wgs84.geojson)
|
||||
except Exception:
|
||||
geom_wgs84 = None
|
||||
|
||||
# 3. Récupérer les coordonnées ponctuelles lon/lat (pour tri de distance et centroïde)
|
||||
if hasattr(item, 'lat') and item.lat is not None and hasattr(item, 'lon') and item.lon is not None:
|
||||
try:
|
||||
lat = float(str(item.lat).replace(',', '.'))
|
||||
lon = float(str(item.lon).replace(',', '.'))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif hasattr(item, 'geom') and item.geom:
|
||||
|
||||
if (lon is None or lat is None) and geom_wgs84:
|
||||
try:
|
||||
g = item.geom.clone()
|
||||
g.transform(4326)
|
||||
centroid = g.centroid
|
||||
lon = float(centroid.x)
|
||||
lat = float(centroid.y)
|
||||
c = geom_wgs84.centroid
|
||||
lon = float(c.x)
|
||||
lat = float(c.y)
|
||||
except Exception:
|
||||
pass
|
||||
elif hasattr(item, 'location') and item.location:
|
||||
|
||||
if (lon is None or lat is None) and geometry and geometry.get('type') == 'Point' and geometry.get('coordinates'):
|
||||
try:
|
||||
lon = float(geometry['coordinates'][0])
|
||||
lat = float(geometry['coordinates'][1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (lon is None or lat is None) and hasattr(item, 'location') and item.location:
|
||||
loc = item.location
|
||||
if hasattr(loc, 'lat') and loc.lat and hasattr(loc, 'lon') and loc.lon:
|
||||
try:
|
||||
|
|
@ -1442,6 +1476,24 @@ def mobile_assets_search(request):
|
|||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 4. Fallback si geometry est absent mais qu'on a un point lon/lat
|
||||
if not geometry and lon is not None and lat is not None:
|
||||
geometry = {
|
||||
'type': 'Point',
|
||||
'coordinates': [lon, lat],
|
||||
}
|
||||
|
||||
geom_type = geometry.get('type', 'Point') if geometry else 'Point'
|
||||
|
||||
# 5. Calcul de l'emprise bbox [min_lon, min_lat, max_lon, max_lat] pour zoom/cadrage
|
||||
feature_bbox = None
|
||||
if geom_wgs84 and geom_type != 'Point':
|
||||
try:
|
||||
ext = geom_wgs84.extent # (xmin, ymin, xmax, ymax)
|
||||
feature_bbox = [float(ext[0]), float(ext[1]), float(ext[2]), float(ext[3])]
|
||||
except Exception:
|
||||
feature_bbox = None
|
||||
|
||||
distance_m = None
|
||||
if user_lat is not None and user_lon is not None and lat is not None and lon is not None:
|
||||
try:
|
||||
|
|
@ -1461,9 +1513,13 @@ def mobile_assets_search(request):
|
|||
|
||||
detail_url = reverse('mobile:mobile_asset_detail_direct', kwargs={'asset_model': model_key, 'asset_id': item.id})
|
||||
spot_type = getattr(item, 'spot_type', None)
|
||||
color_val = getattr(getattr(item, 'model', None), 'color', None) or getattr(item, 'color', None)
|
||||
structure_type_val = getattr(item, 'structure_type', None)
|
||||
feature_id = f"{model_key}_{item.id}"
|
||||
|
||||
res_item = {
|
||||
'id': item.id,
|
||||
'feature_id': feature_id,
|
||||
'code': getattr(item, 'code', f'#{item.id}'),
|
||||
'name': item_name,
|
||||
'thematic': t_code,
|
||||
|
|
@ -1473,6 +1529,10 @@ def mobile_assets_search(request):
|
|||
'asset_type': model_key,
|
||||
'spot_type': spot_type,
|
||||
'element_type': element_type,
|
||||
'geom_type': geom_type,
|
||||
'color': color_val,
|
||||
'structure_type': structure_type_val,
|
||||
'bbox': feature_bbox,
|
||||
'status': status_val,
|
||||
'status_display': status_display,
|
||||
'lat': lat,
|
||||
|
|
@ -1482,16 +1542,15 @@ def mobile_assets_search(request):
|
|||
}
|
||||
results.append(res_item)
|
||||
|
||||
if lon is not None and lat is not None:
|
||||
if geometry:
|
||||
features.append({
|
||||
'type': 'Feature',
|
||||
'id': f"{model_key}_{item.id}",
|
||||
'geometry': {
|
||||
'type': 'Point',
|
||||
'coordinates': [lon, lat],
|
||||
},
|
||||
'id': feature_id,
|
||||
'geometry': geometry,
|
||||
'bbox': feature_bbox,
|
||||
'properties': {
|
||||
'id': item.id,
|
||||
'feature_id': feature_id,
|
||||
'code': getattr(item, 'code', f'#{item.id}'),
|
||||
'name': item_name,
|
||||
'thematic': t_code,
|
||||
|
|
@ -1501,10 +1560,16 @@ def mobile_assets_search(request):
|
|||
'asset_type': model_key,
|
||||
'spot_type': spot_type,
|
||||
'element_type': element_type,
|
||||
'geom_type': geom_type,
|
||||
'color': color_val,
|
||||
'structure_type': structure_type_val,
|
||||
'bbox': feature_bbox,
|
||||
'status': status_val,
|
||||
'status_display': status_display,
|
||||
'distance_m': distance_m,
|
||||
'detail_url': detail_url,
|
||||
'lon': lon,
|
||||
'lat': lat,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue