From 1a799cb8b7566d1b70996b25fe0c72edbd8e50e9 Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 08:14:18 +0200 Subject: [PATCH 01/11] Change sound_zone to SoundZone in expected layers properties --- src/Humans.Web/wwwroot/js/city-planning/layers.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Humans.Web/wwwroot/js/city-planning/layers.js b/src/Humans.Web/wwwroot/js/city-planning/layers.js index 0559e1d8..aaaead06 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/layers.js +++ b/src/Humans.Web/wwwroot/js/city-planning/layers.js @@ -99,9 +99,9 @@ export function renderMap(onCampPolygonClick) { map.addLayer({ id: 'limit-zone-fill', type: 'fill', source: 'limit-zone', paint: { 'fill-color': '#ffffff', 'fill-opacity': 0.08 } }); const ZONE_COLORS = { blue: '#2266cc', green: '#229944', yellow: '#cc9900', orange: '#cc6600', red: '#cc1111' }; - const soundZones = [...new Set((limitZoneData.features || []).map(f => f.properties?.sound_zone).filter(Boolean))]; + const SoundZones = [...new Set((limitZoneData.features || []).map(f => f.properties?.SoundZone).filter(Boolean))]; - for (const zone of soundZones) { + for (const zone of SoundZones) { const colors = zone.split('_').map(c => ZONE_COLORS[c]).filter(Boolean); if (colors.length === 0) colors.push('#ffffff'); const n = colors.length; @@ -113,16 +113,16 @@ export function renderMap(onCampPolygonClick) { map.addLayer({ id: `limit-zone-line-${zone}-${i}`, type: 'line', source: 'limit-zone', - filter: ['==', ['get', 'sound_zone'], zone], + filter: ['==', ['get', 'SoundZone'], zone], paint: { 'line-color': color, 'line-width': 2, 'line-dasharray': dashArray }, }); }); } - // Fallback: features with no sound_zone property + // Fallback: features with no SoundZone property map.addLayer({ id: 'limit-zone-line-fallback', type: 'line', source: 'limit-zone', - filter: ['!', ['has', 'sound_zone']], + filter: ['!', ['has', 'SoundZone']], paint: { 'line-color': '#ffffff', 'line-width': 2, 'line-dasharray': [4, 2] }, }); } From 575b304c5f6e1f4c0a61b69091e071cfbbdb055f Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 08:32:41 +0200 Subject: [PATCH 02/11] Add GeoJSON upload hints for limit zone and official zones Co-Authored-By: Claude Sonnet 4.6 --- src/Humans.Web/Resources/SharedResource.ca.resx | 2 ++ src/Humans.Web/Resources/SharedResource.de.resx | 2 ++ src/Humans.Web/Resources/SharedResource.es.resx | 2 ++ src/Humans.Web/Resources/SharedResource.fr.resx | 2 ++ src/Humans.Web/Resources/SharedResource.it.resx | 2 ++ src/Humans.Web/Resources/SharedResource.resx | 2 ++ src/Humans.Web/Views/CityPlanning/Admin.cshtml | 2 ++ 7 files changed, 14 insertions(+) diff --git a/src/Humans.Web/Resources/SharedResource.ca.resx b/src/Humans.Web/Resources/SharedResource.ca.resx index 5e5ae67a..aafcb957 100644 --- a/src/Humans.Web/Resources/SharedResource.ca.resx +++ b/src/Humans.Web/Resources/SharedResource.ca.resx @@ -1399,10 +1399,12 @@ Zona límit pujada Eliminar la zona límit? Encara no s'ha pujat cap zona límit. + Les entitats poden incloure una propietat SoundZone opcional. Valors vàlids: blue, green, yellow, orange, red, o dos combinats amb un guió baix (p. ex. yellow_orange). Zones oficials Zones oficials pujades Eliminar les zones oficials? Encara no s'han pujat zones oficials. + L'etiqueta de cada entitat es llegeix de la propietat Name, si és present. Pujar arxiu GeoJSON Pujar Descarregar diff --git a/src/Humans.Web/Resources/SharedResource.de.resx b/src/Humans.Web/Resources/SharedResource.de.resx index 4454a0de..7db5a01a 100644 --- a/src/Humans.Web/Resources/SharedResource.de.resx +++ b/src/Humans.Web/Resources/SharedResource.de.resx @@ -1398,10 +1398,12 @@ Grenzzone hochgeladen Grenzzone löschen? Noch keine Grenzzone hochgeladen. + Features können eine optionale SoundZone-Eigenschaft enthalten. Gültige Werte: blue, green, yellow, orange, red, oder zwei kombiniert mit Unterstrich (z. B. yellow_orange). Offizielle Zonen Offizielle Zonen hochgeladen Offizielle Zonen löschen? Noch keine offiziellen Zonen hochgeladen. + Das Label jedes Features wird aus der Eigenschaft Name gelesen, falls vorhanden. GeoJSON-Datei hochladen Hochladen Herunterladen diff --git a/src/Humans.Web/Resources/SharedResource.es.resx b/src/Humans.Web/Resources/SharedResource.es.resx index dec56f1d..fb3f2d09 100644 --- a/src/Humans.Web/Resources/SharedResource.es.resx +++ b/src/Humans.Web/Resources/SharedResource.es.resx @@ -1400,10 +1400,12 @@ Zona límite subida ¿Eliminar la zona límite? Aún no se ha subido ninguna zona límite. + Las entidades pueden incluir una propiedad SoundZone opcional. Valores válidos: blue, green, yellow, orange, red, o dos combinados con guión bajo (p. ej. yellow_orange). Zonas Oficiales Zonas oficiales subidas ¿Eliminar las zonas oficiales? Aún no se han subido zonas oficiales. + La etiqueta de cada entidad se lee de la propiedad Name, si está presente. Subir archivo GeoJSON Subir Descargar diff --git a/src/Humans.Web/Resources/SharedResource.fr.resx b/src/Humans.Web/Resources/SharedResource.fr.resx index 5b00e4d1..0f62ce1b 100644 --- a/src/Humans.Web/Resources/SharedResource.fr.resx +++ b/src/Humans.Web/Resources/SharedResource.fr.resx @@ -1398,10 +1398,12 @@ Zone limite importée Supprimer la zone limite ? Aucune zone limite importée. + Les entités peuvent inclure une propriété SoundZone optionnelle. Valeurs valides : blue, green, yellow, orange, red, ou deux combinées avec un tiret bas (ex. yellow_orange). Zones officielles Zones officielles importées Supprimer les zones officielles ? Aucune zone officielle importée. + Le libellé de chaque entité est lu depuis la propriété Name, si elle est présente. Importer un fichier GeoJSON Importer Télécharger diff --git a/src/Humans.Web/Resources/SharedResource.it.resx b/src/Humans.Web/Resources/SharedResource.it.resx index 34a1cbfd..4146e489 100644 --- a/src/Humans.Web/Resources/SharedResource.it.resx +++ b/src/Humans.Web/Resources/SharedResource.it.resx @@ -1398,10 +1398,12 @@ Zona limite caricata Eliminare la zona limite? Nessuna zona limite ancora caricata. + Le feature possono includere una proprietà SoundZone opzionale. Valori validi: blue, green, yellow, orange, red, o due combinati con un underscore (es. yellow_orange). Zone ufficiali Zone ufficiali caricate Eliminare le zone ufficiali? Nessuna zona ufficiale ancora caricata. + L'etichetta di ogni feature viene letta dalla proprietà Name, se presente. Carica file GeoJSON Carica Scarica diff --git a/src/Humans.Web/Resources/SharedResource.resx b/src/Humans.Web/Resources/SharedResource.resx index 910590c4..f70ffb80 100644 --- a/src/Humans.Web/Resources/SharedResource.resx +++ b/src/Humans.Web/Resources/SharedResource.resx @@ -1416,10 +1416,12 @@ Limit zone uploaded Delete the limit zone? No limit zone uploaded yet. + Features may include an optional SoundZone property. Valid values: blue, green, yellow, orange, red, or two combined with an underscore (e.g. yellow_orange). Official Zones Official zones uploaded Delete the official zones? No official zones uploaded yet. + Each feature's label is read from the Name property, if present. Upload GeoJSON file Upload Download diff --git a/src/Humans.Web/Views/CityPlanning/Admin.cshtml b/src/Humans.Web/Views/CityPlanning/Admin.cshtml index 5bf350ba..d90372b1 100644 --- a/src/Humans.Web/Views/CityPlanning/Admin.cshtml +++ b/src/Humans.Web/Views/CityPlanning/Admin.cshtml @@ -120,6 +120,7 @@
+
@Localizer["CityPlanning_Admin_LimitZoneHint"]
- } diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index b34f277d..224436af 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -22,17 +22,20 @@ export function onCampPolygonClick(e) { const area = props.areaSqm ? `
${Math.round(props.areaSqm).toLocaleString()} m²
` : ''; const warning = props.outsideZone ? `
⚠️ Outside limits
` : ''; const overlapWarn = props.overlaps ? `
⚠️ Overlaps with another barrio
` : ''; - const editBtn = canEdit ? `` : ''; + const editBtn = canEdit ? `` : ''; + const historyBtn = ``; if (appState.currentPopup) appState.currentPopup.remove(); appState.currentPopup = new maplibregl.Popup().setLngLat(e.lngLat) - .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}${editBtn}`) + .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}
${editBtn}${historyBtn}
`) .addTo(appState.map); if (canEdit) { appState.currentPopup.getElement().querySelector('.js-edit-barrio-btn') .addEventListener('click', () => startEditing(campSeasonId)); } + appState.currentPopup.getElement().querySelector('.js-history-barrio-btn') + .addEventListener('click', () => loadHistory(campSeasonId, canEdit)); } // --- Edit mode lifecycle --- @@ -52,7 +55,6 @@ export function startEditing(campSeasonId) { appState.draw.changeMode('direct_select', { featureId: f.id }); } - document.getElementById('history-btn').disabled = false; setEditingControlsVisible(true); updateSaveButton(); } @@ -62,7 +64,6 @@ export function exitEditMode() { setActivePolygonDim(null); appState.activeCampSeasonId = null; document.getElementById('save-btn').disabled = true; - document.getElementById('history-btn').disabled = true; const cancelBtn = document.getElementById('cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; clearDrawLabel(); @@ -77,7 +78,6 @@ export function onDrawDelete() { setActivePolygonDim(null); appState.activeCampSeasonId = null; document.getElementById('save-btn').disabled = true; - document.getElementById('history-btn').disabled = true; const cancelBtn = document.getElementById('cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; clearDrawLabel(); @@ -89,18 +89,15 @@ export function onDrawDelete() { export function setEditingControlsVisible(visible) { const toolbar = document.getElementById('main-toolbar'); if (!toolbar) return; - const saveBtn = document.getElementById('save-btn'); - const historyBtn = document.getElementById('history-btn'); + const saveBtn = document.getElementById('save-btn'); if (visible) { toolbar.style.display = ''; const addMyBarrioBtn = document.getElementById('add-my-barrio-btn'); if (addMyBarrioBtn) addMyBarrioBtn.style.display = 'none'; - if (saveBtn) saveBtn.style.display = ''; - if (historyBtn) historyBtn.style.display = ''; + if (saveBtn) saveBtn.style.display = ''; return; } - if (saveBtn) saveBtn.style.display = 'none'; - if (historyBtn) historyBtn.style.display = 'none'; + if (saveBtn) saveBtn.style.display = 'none'; updateAddMyBarrioVisibility(); const addMyBarrioVisible = document.getElementById('add-my-barrio-btn')?.style.display !== 'none'; const addBarrioPresent = !!document.getElementById('add-barrio-container'); @@ -176,14 +173,13 @@ export function updateSaveButton() { // --- History --- -export async function loadHistory() { - const campSeasonId = appState.activeCampSeasonId; - if (!campSeasonId) return; +export async function loadHistory(campSeasonId, canEdit = false) { + const id = campSeasonId ?? appState.activeCampSeasonId; + if (!id) return; - const resp = await fetch(`/api/city-planning/camp-polygons/${campSeasonId}/history`); + const resp = await fetch(`/api/city-planning/camp-polygons/${id}/history`); const history = await resp.json(); const list = document.getElementById('history-list'); - if (!history.length) { list.innerHTML = '

No history yet.

'; } else { @@ -197,7 +193,7 @@ export async function loadHistory() {
- ${CONFIG.IS_MAP_ADMIN ? `` : ''} + ${canEdit ? `` : ''}
@@ -210,20 +206,20 @@ export async function loadHistory() { }); }); list.querySelectorAll('.restore-btn').forEach(btn => { - btn.addEventListener('click', () => restoreVersion(btn.dataset.id)); + btn.addEventListener('click', () => restoreVersion(btn.dataset.id, id)); }); } bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('history-panel')).show(); } -export async function restoreVersion(historyId) { - const campSeasonId = appState.activeCampSeasonId; - if (!campSeasonId) return; +export async function restoreVersion(historyId, campSeasonId) { + const id = campSeasonId ?? appState.activeCampSeasonId; + if (!id) return; if (!confirm('Restore this polygon version? The current version will be saved to history first.')) return; const token = document.querySelector('input[name="__RequestVerificationToken"]').value; - const resp = await fetch(`/api/city-planning/camp-polygons/${campSeasonId}/restore/${historyId}`, { + const resp = await fetch(`/api/city-planning/camp-polygons/${id}/restore/${historyId}`, { method: 'POST', headers: { 'RequestVerificationToken': token }, }); diff --git a/src/Humans.Web/wwwroot/js/city-planning/main.js b/src/Humans.Web/wwwroot/js/city-planning/main.js index a10c8154..feaaf4f0 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/main.js +++ b/src/Humans.Web/wwwroot/js/city-planning/main.js @@ -5,7 +5,7 @@ import { parseLimitZoneGeom } from './geometry.js'; import { DRAW_STYLES, generateRainbowPattern, generateCrosshatchPattern, generateDashedHorizontalPattern, renderMap } from './layers.js'; import { onCampPolygonClick, exitEditMode, onDrawChange, onDrawDelete, - setEditingControlsVisible, updateAddMyBarrioVisibility, loadHistory, + setEditingControlsVisible, updateAddMyBarrioVisibility, } from './edit.js'; import { initSignalR } from './signalr.js'; @@ -125,6 +125,4 @@ document.getElementById('cancel-btn')?.addEventListener('click', () => { exitEditMode(); }); -document.getElementById('history-btn')?.addEventListener('click', loadHistory); - init(); From bcebd8165f21128727ecee618bb3d141864f3d05 Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 10:17:55 +0200 Subject: [PATCH 05/11] Simplify restore alert text --- src/Humans.Web/wwwroot/js/city-planning/edit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index 224436af..105daed6 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -216,7 +216,7 @@ export async function loadHistory(campSeasonId, canEdit = false) { export async function restoreVersion(historyId, campSeasonId) { const id = campSeasonId ?? appState.activeCampSeasonId; if (!id) return; - if (!confirm('Restore this polygon version? The current version will be saved to history first.')) return; + if (!confirm('Restore this version?')) return; const token = document.querySelector('input[name="__RequestVerificationToken"]').value; const resp = await fetch(`/api/city-planning/camp-polygons/${id}/restore/${historyId}`, { From 3c264475e74e000a7ae62cc880665094649f56c9 Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 10:36:06 +0200 Subject: [PATCH 06/11] Fix history preview falsely triggering overlap warning for own barrio Track previewCampSeasonId in state so overlapsOtherCamps excludes the barrio whose history is being previewed, matching the existing behavior for edit mode via activeCampSeasonId. Co-Authored-By: Claude Sonnet 4.6 --- src/Humans.Web/wwwroot/js/city-planning/edit.js | 9 ++++++++- src/Humans.Web/wwwroot/js/city-planning/geometry.js | 3 ++- src/Humans.Web/wwwroot/js/city-planning/state.js | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index 105daed6..c9cefda9 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -43,6 +43,7 @@ export function onCampPolygonClick(e) { export function startEditing(campSeasonId) { if (appState.currentPopup) { appState.currentPopup.remove(); appState.currentPopup = null; } + appState.previewCampSeasonId = null; appState.activeCampSeasonId = campSeasonId; setActivePolygonDim(campSeasonId); appState.draw.deleteAll(); @@ -201,6 +202,7 @@ export async function loadHistory(campSeasonId, canEdit = false) { list.querySelectorAll('.preview-btn').forEach(btn => { btn.addEventListener('click', () => { + appState.previewCampSeasonId = id; appState.draw.deleteAll(); appState.draw.add(JSON.parse(decodeURIComponent(btn.dataset.geojson))); }); @@ -210,7 +212,12 @@ export async function loadHistory(campSeasonId, canEdit = false) { }); } - bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('history-panel')).show(); + const panel = document.getElementById('history-panel'); + panel.addEventListener('hidden.bs.offcanvas', () => { + appState.previewCampSeasonId = null; + if (!appState.activeCampSeasonId) appState.draw.deleteAll(); + }, { once: true }); + bootstrap.Offcanvas.getOrCreateInstance(panel).show(); } export async function restoreVersion(historyId, campSeasonId) { diff --git a/src/Humans.Web/wwwroot/js/city-planning/geometry.js b/src/Humans.Web/wwwroot/js/city-planning/geometry.js index aed9ed28..cfeccd0e 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/geometry.js +++ b/src/Humans.Web/wwwroot/js/city-planning/geometry.js @@ -48,8 +48,9 @@ export function buildCampPolygonFeatures(campPolygons) { } export function overlapsOtherCamps(feature) { + const excludeId = appState.activeCampSeasonId ?? appState.previewCampSeasonId; return appState.campMap.campPolygons - .filter(p => p.campSeasonId !== appState.activeCampSeasonId) + .filter(p => p.campSeasonId !== excludeId) .some(p => { try { return !!turf.intersect(turf.featureCollection([feature, JSON.parse(p.geoJson)])); } catch { return false; } diff --git a/src/Humans.Web/wwwroot/js/city-planning/state.js b/src/Humans.Web/wwwroot/js/city-planning/state.js index b145b94e..f833e84d 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/state.js +++ b/src/Humans.Web/wwwroot/js/city-planning/state.js @@ -5,7 +5,8 @@ export const appState = { connection: null, campMap: null, // fetched from /api/city-planning/state limitZoneGeom: null, // parsed turf geometry for isOutsideZone checks - activeCampSeasonId: null, // non-null while a polygon is being edited + activeCampSeasonId: null, // non-null while a polygon is being edited + previewCampSeasonId: null, // non-null while previewing a historical version remoteCursors: {}, currentPopup: null, }; From ab988093b291a712476854369c14d2bcecc1d5fb Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 10:57:21 +0200 Subject: [PATCH 07/11] Show barrio name in history offcanvas title Co-Authored-By: Claude Sonnet 4.6 --- .../Views/CityPlanning/_HistoryOffcanvas.cshtml | 2 +- src/Humans.Web/wwwroot/js/city-planning/edit.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Humans.Web/Views/CityPlanning/_HistoryOffcanvas.cshtml b/src/Humans.Web/Views/CityPlanning/_HistoryOffcanvas.cshtml index d89959ea..28488a85 100644 --- a/src/Humans.Web/Views/CityPlanning/_HistoryOffcanvas.cshtml +++ b/src/Humans.Web/Views/CityPlanning/_HistoryOffcanvas.cshtml @@ -1,6 +1,6 @@
-
@Localizer["CityPlanning_HistoryTitle"]
+
@Localizer["CityPlanning_HistoryTitle"]
diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index c9cefda9..0496b00e 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -121,10 +121,10 @@ export function updateAddMyBarrioVisibility() { export function clearDrawLabel() { const { map } = appState; - map.getSource('draw-label').setData({ type: 'FeatureCollection', features: [] }); - map.getSource('draw-edge-labels').setData({ type: 'FeatureCollection', features: [] }); - map.getSource('draw-warning-error').setData({ type: 'FeatureCollection', features: [] }); - map.getSource('draw-warning-overlap').setData({ type: 'FeatureCollection', features: [] }); + map.getSource('draw-label')?.setData({ type: 'FeatureCollection', features: [] }); + map.getSource('draw-edge-labels')?.setData({ type: 'FeatureCollection', features: [] }); + map.getSource('draw-warning-error')?.setData({ type: 'FeatureCollection', features: [] }); + map.getSource('draw-warning-overlap')?.setData({ type: 'FeatureCollection', features: [] }); } export function updateSaveButton() { @@ -180,6 +180,11 @@ export async function loadHistory(campSeasonId, canEdit = false) { const resp = await fetch(`/api/city-planning/camp-polygons/${id}/history`); const history = await resp.json(); + + const campName = appState.campMap.campPolygons.find(p => p.campSeasonId === id)?.campName; + const titleEl = document.getElementById('history-panel-title'); + if (titleEl && campName) titleEl.textContent = `History of ${campName}`; + const list = document.getElementById('history-list'); if (!history.length) { list.innerHTML = '

No history yet.

'; From 19a8b53267d46e3cc4026da83448b85be77aeff8 Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 11:02:22 +0200 Subject: [PATCH 08/11] Close popup when opening history panel Co-Authored-By: Claude Sonnet 4.6 --- src/Humans.Web/wwwroot/js/city-planning/edit.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index 0496b00e..2f3a9c66 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -181,6 +181,8 @@ export async function loadHistory(campSeasonId, canEdit = false) { const resp = await fetch(`/api/city-planning/camp-polygons/${id}/history`); const history = await resp.json(); + if (appState.currentPopup) { appState.currentPopup.remove(); appState.currentPopup = null; } + const campName = appState.campMap.campPolygons.find(p => p.campSeasonId === id)?.campName; const titleEl = document.getElementById('history-panel-title'); if (titleEl && campName) titleEl.textContent = `History of ${campName}`; From 3dfdede29c13125c9650ec68607143a4090f7707 Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 11:30:11 +0200 Subject: [PATCH 09/11] Move admin panel link to top-right corner of city planning map Co-Authored-By: Claude Sonnet 4.6 --- .../Views/CityPlanning/Index.cshtml | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Humans.Web/Views/CityPlanning/Index.cshtml b/src/Humans.Web/Views/CityPlanning/Index.cshtml index 94e9b464..e7968d90 100644 --- a/src/Humans.Web/Views/CityPlanning/Index.cshtml +++ b/src/Humans.Web/Views/CityPlanning/Index.cshtml @@ -65,6 +65,22 @@
} + + @if (isMapAdmin) + { + + } +
@if (isPlacementOpen || isMapAdmin) @@ -98,16 +114,6 @@
} - @if (isMapAdmin) - { - - } From a170232b4eacbfc1576399e8ea9108a6cc3494fd Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 12:08:09 +0200 Subject: [PATCH 10/11] Warn when barrio area deviates >50% from space requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a warning in the click popup, the live draw label, and the static polygon label (⚠️ prefix) when a barrio's drawn area is more than 50% larger or smaller than the space requested in the camp form. Co-Authored-By: Claude Sonnet 4.6 --- .../Interfaces/ICityPlanningService.cs | 6 ++- .../Services/CityPlanningService.cs | 39 ++++++++++++++----- .../wwwroot/js/city-planning/edit.js | 30 ++++++++++++-- .../wwwroot/js/city-planning/geometry.js | 20 ++++++---- .../wwwroot/js/city-planning/layers.js | 6 ++- 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/Humans.Application/Interfaces/ICityPlanningService.cs b/src/Humans.Application/Interfaces/ICityPlanningService.cs index 94c34fdc..19188976 100644 --- a/src/Humans.Application/Interfaces/ICityPlanningService.cs +++ b/src/Humans.Application/Interfaces/ICityPlanningService.cs @@ -48,12 +48,14 @@ public record CampPolygonDto( string CampSlug, string GeoJson, double AreaSqm, - SoundZone? SoundZone); + SoundZone? SoundZone, + double? SpaceRequirementSqm); public record CampSeasonSummaryDto( Guid CampSeasonId, string CampName, - string CampSlug); + string CampSlug, + double? SpaceRequirementSqm = null); public record CampPolygonHistoryEntryDto( Guid Id, diff --git a/src/Humans.Infrastructure/Services/CityPlanningService.cs b/src/Humans.Infrastructure/Services/CityPlanningService.cs index 3aa3fb26..7f1e10a1 100644 --- a/src/Humans.Infrastructure/Services/CityPlanningService.cs +++ b/src/Humans.Infrastructure/Services/CityPlanningService.cs @@ -25,17 +25,19 @@ public CityPlanningService(HumansDbContext dbContext, IClock clock, IOptions> GetCampPolygonsAsync(int year, CancellationToken cancellationToken = default) { - return await _dbContext.CampPolygons + var polygons = await _dbContext.CampPolygons .Include(p => p.CampSeason).ThenInclude(s => s.Camp) .Where(p => p.CampSeason.Year == year) - .Select(p => new CampPolygonDto( - p.CampSeasonId, - p.CampSeason.Name, - p.CampSeason.Camp.Slug, - p.GeoJson, - p.AreaSqm, - p.CampSeason.SoundZone)) .ToListAsync(cancellationToken); + + return polygons.Select(p => new CampPolygonDto( + p.CampSeasonId, + p.CampSeason.Name, + p.CampSeason.Camp.Slug, + p.GeoJson, + p.AreaSqm, + p.CampSeason.SoundZone, + SpaceSizeToSqm(p.CampSeason.SpaceRequirement))).ToList(); } public async Task GetCampSeasonSoundZoneAsync(Guid campSeasonId, CancellationToken cancellationToken = default) @@ -64,14 +66,31 @@ public async Task> GetCampPolygonsAsync(int year, Cancellat public async Task> GetCampSeasonsWithoutCampPolygonAsync(int year, CancellationToken cancellationToken = default) { - return await _dbContext.CampSeasons + var seasons = await _dbContext.CampSeasons .Include(s => s.Camp) .Where(s => s.Year == year && !_dbContext.CampPolygons.Any(p => p.CampSeasonId == s.Id)) - .Select(s => new CampSeasonSummaryDto(s.Id, s.Name, s.Camp.Slug)) .ToListAsync(cancellationToken); + + return seasons.Select(s => new CampSeasonSummaryDto(s.Id, s.Name, s.Camp.Slug, SpaceSizeToSqm(s.SpaceRequirement))).ToList(); } + private static double? SpaceSizeToSqm(SpaceSize? size) => size switch + { + SpaceSize.Sqm150 => 150, + SpaceSize.Sqm300 => 300, + SpaceSize.Sqm450 => 450, + SpaceSize.Sqm600 => 600, + SpaceSize.Sqm800 => 800, + SpaceSize.Sqm1000 => 1000, + SpaceSize.Sqm1200 => 1200, + SpaceSize.Sqm1500 => 1500, + SpaceSize.Sqm1800 => 1800, + SpaceSize.Sqm2200 => 2200, + SpaceSize.Sqm2800 => 2800, + _ => null + }; + public async Task> GetCampPolygonHistoryAsync(Guid campSeasonId, CancellationToken cancellationToken = default) { var rows = await _dbContext.CampPolygonHistories diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index 2f3a9c66..5f124e5f 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -4,6 +4,14 @@ import { CONFIG } from './config.js'; import { isOutsideZone, overlapsOtherCamps } from './geometry.js'; import { setActivePolygonDim } from './layers.js'; +function getSpaceRequirementSqm(campSeasonId) { + if (!campSeasonId) return null; + const poly = appState.campMap?.campPolygons?.find(p => p.campSeasonId === campSeasonId); + if (poly) return poly.spaceRequirementSqm ?? null; + const season = appState.campMap?.campSeasonsWithoutPolygon?.find(s => s.campSeasonId === campSeasonId); + return season?.spaceRequirementSqm ?? null; +} + function escHtml(s) { const d = document.createElement('div'); d.textContent = s; @@ -22,12 +30,19 @@ export function onCampPolygonClick(e) { const area = props.areaSqm ? `
${Math.round(props.areaSqm).toLocaleString()} m²
` : ''; const warning = props.outsideZone ? `
⚠️ Outside limits
` : ''; const overlapWarn = props.overlaps ? `
⚠️ Overlaps with another barrio
` : ''; + const sizeWarn = (() => { + if (!props.spaceRequirementSqm || !props.areaSqm) return ''; + const ratio = props.areaSqm / props.spaceRequirementSqm; + if (ratio > 1.5) return `
⚠️ Area much larger than requested (${Math.round(props.spaceRequirementSqm).toLocaleString()} m²)
`; + if (ratio < 0.5) return `
⚠️ Area much smaller than requested (${Math.round(props.spaceRequirementSqm).toLocaleString()} m²)
`; + return ''; + })(); const editBtn = canEdit ? `` : ''; const historyBtn = ``; if (appState.currentPopup) appState.currentPopup.remove(); appState.currentPopup = new maplibregl.Popup().setLngLat(e.lngLat) - .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}
${editBtn}${historyBtn}
`) + .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}${sizeWarn}
${editBtn}${historyBtn}
`) .addTo(appState.map); if (canEdit) { @@ -151,9 +166,18 @@ export function updateSaveButton() { ? { type: 'FeatureCollection', features: [poly] } : { type: 'FeatureCollection', features: [] }); + const spaceReqSqm = getSpaceRequirementSqm(appState.activeCampSeasonId ?? appState.previewCampSeasonId); + const sizeWarning = (() => { + if (!spaceReqSqm) return ''; + const ratio = area / spaceReqSqm; + if (ratio > 1.5) return `\n⚠️ Area larger than requested (${Math.round(spaceReqSqm).toLocaleString()} m²)`; + if (ratio < 0.5) return `\n⚠️ Area smaller than requested (${Math.round(spaceReqSqm).toLocaleString()} m²)`; + return ''; + })(); const warnings = [ - ...(outside ? ['\n⚠️ Outside limits'] : []), - ...(overlap ? ['\n⚠️ Overlaps with another barrio'] : []), + ...(outside ? ['\n⚠️ Outside limits'] : []), + ...(overlap ? ['\n⚠️ Overlaps with another barrio'] : []), + ...(sizeWarning ? [sizeWarning] : []), ]; centroid.properties = { label: Math.round(area).toLocaleString() + ' m²' + warnings.join('') }; map.getSource('draw-label').setData({ type: 'FeatureCollection', features: [centroid] }); diff --git a/src/Humans.Web/wwwroot/js/city-planning/geometry.js b/src/Humans.Web/wwwroot/js/city-planning/geometry.js index cfeccd0e..878559eb 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/geometry.js +++ b/src/Humans.Web/wwwroot/js/city-planning/geometry.js @@ -21,14 +21,20 @@ export function parseLimitZoneGeom(geoJson) { export function buildCampPolygonFeatures(campPolygons) { const features = campPolygons.map(p => { const f = JSON.parse(p.geoJson); + const spaceReq = p.spaceRequirementSqm ?? null; + const spaceOutOfRange = spaceReq && p.areaSqm + ? (p.areaSqm > spaceReq * 1.5 || p.areaSqm < spaceReq * 0.5) + : false; f.properties = Object.assign(f.properties || {}, { - campSeasonId: p.campSeasonId, - campName: p.campName, - areaSqm: p.areaSqm, - isOwn: p.campSeasonId === CONFIG.USER_CAMP_SEASON_ID, - soundZone: (p.soundZone !== undefined && p.soundZone !== null) ? p.soundZone : -1, - outsideZone: isOutsideZone(f), - overlaps: false, + campSeasonId: p.campSeasonId, + campName: p.campName, + areaSqm: p.areaSqm, + isOwn: p.campSeasonId === CONFIG.USER_CAMP_SEASON_ID, + soundZone: (p.soundZone !== undefined && p.soundZone !== null) ? p.soundZone : -1, + outsideZone: isOutsideZone(f), + overlaps: false, + spaceRequirementSqm: spaceReq, + spaceOutOfRange: spaceOutOfRange, }); return f; }); diff --git a/src/Humans.Web/wwwroot/js/city-planning/layers.js b/src/Humans.Web/wwwroot/js/city-planning/layers.js index aaaead06..56d442b4 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/layers.js +++ b/src/Humans.Web/wwwroot/js/city-planning/layers.js @@ -187,7 +187,11 @@ export function renderMap(onCampPolygonClick) { id: 'camp-polygons-labels', type: 'symbol', source: 'camp-polygons', layout: { 'text-field': ['case', - ['any', ['boolean', ['get', 'outsideZone'], false], ['boolean', ['get', 'overlaps'], false]], + ['any', + ['boolean', ['get', 'outsideZone'], false], + ['boolean', ['get', 'overlaps'], false], + ['boolean', ['get', 'spaceOutOfRange'], false], + ], ['concat', '⚠️ ', ['get', 'campName']], ['get', 'campName'], ], From 8aecf2fea04be1e3fd9f178dac8077effdb2d3f1 Mon Sep 17 00:00:00 2001 From: David Nowinsky Date: Fri, 10 Apr 2026 12:21:11 +0200 Subject: [PATCH 11/11] Warn when barrio sound zone doesn't match the limit zone area it's placed in Co-Authored-By: Claude Sonnet 4.6 --- .../Interfaces/ICityPlanningService.cs | 3 +- .../Services/CityPlanningService.cs | 2 +- .../wwwroot/js/city-planning/edit.js | 24 ++++++++--- .../wwwroot/js/city-planning/geometry.js | 40 +++++++++++++++---- .../wwwroot/js/city-planning/layers.js | 1 + 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/Humans.Application/Interfaces/ICityPlanningService.cs b/src/Humans.Application/Interfaces/ICityPlanningService.cs index 19188976..665e6d51 100644 --- a/src/Humans.Application/Interfaces/ICityPlanningService.cs +++ b/src/Humans.Application/Interfaces/ICityPlanningService.cs @@ -55,7 +55,8 @@ public record CampSeasonSummaryDto( Guid CampSeasonId, string CampName, string CampSlug, - double? SpaceRequirementSqm = null); + double? SpaceRequirementSqm = null, + SoundZone? SoundZone = null); public record CampPolygonHistoryEntryDto( Guid Id, diff --git a/src/Humans.Infrastructure/Services/CityPlanningService.cs b/src/Humans.Infrastructure/Services/CityPlanningService.cs index 7f1e10a1..21b19325 100644 --- a/src/Humans.Infrastructure/Services/CityPlanningService.cs +++ b/src/Humans.Infrastructure/Services/CityPlanningService.cs @@ -72,7 +72,7 @@ public async Task> GetCampSeasonsWithoutCampPolygonAs && !_dbContext.CampPolygons.Any(p => p.CampSeasonId == s.Id)) .ToListAsync(cancellationToken); - return seasons.Select(s => new CampSeasonSummaryDto(s.Id, s.Name, s.Camp.Slug, SpaceSizeToSqm(s.SpaceRequirement))).ToList(); + return seasons.Select(s => new CampSeasonSummaryDto(s.Id, s.Name, s.Camp.Slug, SpaceSizeToSqm(s.SpaceRequirement), s.SoundZone)).ToList(); } private static double? SpaceSizeToSqm(SpaceSize? size) => size switch diff --git a/src/Humans.Web/wwwroot/js/city-planning/edit.js b/src/Humans.Web/wwwroot/js/city-planning/edit.js index 5f124e5f..3e76628f 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/edit.js +++ b/src/Humans.Web/wwwroot/js/city-planning/edit.js @@ -1,9 +1,17 @@ // Editing mode: toolbar state, draw label updates, popup, button handlers. import { appState } from './state.js'; import { CONFIG } from './config.js'; -import { isOutsideZone, overlapsOtherCamps } from './geometry.js'; +import { isOutsideZone, overlapsOtherCamps, getSoundZoneOutOfRange } from './geometry.js'; import { setActivePolygonDim } from './layers.js'; +function getCampSoundZone(campSeasonId) { + if (!campSeasonId) return -1; + const poly = appState.campMap?.campPolygons?.find(p => p.campSeasonId === campSeasonId); + if (poly !== undefined) return poly.soundZone ?? -1; + const season = appState.campMap?.campSeasonsWithoutPolygon?.find(s => s.campSeasonId === campSeasonId); + return season?.soundZone ?? -1; +} + function getSpaceRequirementSqm(campSeasonId) { if (!campSeasonId) return null; const poly = appState.campMap?.campPolygons?.find(p => p.campSeasonId === campSeasonId); @@ -37,12 +45,13 @@ export function onCampPolygonClick(e) { if (ratio < 0.5) return `
⚠️ Area much smaller than requested (${Math.round(props.spaceRequirementSqm).toLocaleString()} m²)
`; return ''; })(); + const soundZoneWarn = props.soundZoneOutOfRange ? `
⚠️ Sound zone doesn't match this area
` : ''; const editBtn = canEdit ? `` : ''; const historyBtn = ``; if (appState.currentPopup) appState.currentPopup.remove(); appState.currentPopup = new maplibregl.Popup().setLngLat(e.lngLat) - .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}${sizeWarn}
${editBtn}${historyBtn}
`) + .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}${sizeWarn}${soundZoneWarn}
${editBtn}${historyBtn}
`) .addTo(appState.map); if (canEdit) { @@ -166,7 +175,8 @@ export function updateSaveButton() { ? { type: 'FeatureCollection', features: [poly] } : { type: 'FeatureCollection', features: [] }); - const spaceReqSqm = getSpaceRequirementSqm(appState.activeCampSeasonId ?? appState.previewCampSeasonId); + const editId = appState.activeCampSeasonId ?? appState.previewCampSeasonId; + const spaceReqSqm = getSpaceRequirementSqm(editId); const sizeWarning = (() => { if (!spaceReqSqm) return ''; const ratio = area / spaceReqSqm; @@ -174,10 +184,12 @@ export function updateSaveButton() { if (ratio < 0.5) return `\n⚠️ Area smaller than requested (${Math.round(spaceReqSqm).toLocaleString()} m²)`; return ''; })(); + const soundZoneMismatch = getSoundZoneOutOfRange(poly, getCampSoundZone(editId)); const warnings = [ - ...(outside ? ['\n⚠️ Outside limits'] : []), - ...(overlap ? ['\n⚠️ Overlaps with another barrio'] : []), - ...(sizeWarning ? [sizeWarning] : []), + ...(outside ? ['\n⚠️ Outside limits'] : []), + ...(overlap ? ['\n⚠️ Overlaps with another barrio'] : []), + ...(sizeWarning ? [sizeWarning] : []), + ...(soundZoneMismatch ? ['\n⚠️ Sound zone doesn\'t match this area'] : []), ]; centroid.properties = { label: Math.round(area).toLocaleString() + ' m²' + warnings.join('') }; map.getSource('draw-label').setData({ type: 'FeatureCollection', features: [centroid] }); diff --git a/src/Humans.Web/wwwroot/js/city-planning/geometry.js b/src/Humans.Web/wwwroot/js/city-planning/geometry.js index 878559eb..16bceb29 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/geometry.js +++ b/src/Humans.Web/wwwroot/js/city-planning/geometry.js @@ -7,6 +7,28 @@ export function isOutsideZone(feature) { try { return !!turf.difference(turf.featureCollection([feature, appState.limitZoneGeom])); } catch { return false; } } +const SOUND_ZONE_NAMES = { 0: 'blue', 1: 'green', 2: 'yellow', 3: 'orange', 4: 'red' }; + +export function getSoundZoneOutOfRange(feature, campSoundZone) { + if (campSoundZone === undefined || campSoundZone === null || campSoundZone === -1 || campSoundZone === 5) return false; + const campZoneName = SOUND_ZONE_NAMES[campSoundZone]; + if (!campZoneName) return false; + if (!appState.campMap?.limitZoneGeoJson) return false; + let limitZoneData; + try { limitZoneData = JSON.parse(appState.campMap.limitZoneGeoJson); } catch { return false; } + const features = limitZoneData.type === 'FeatureCollection' ? limitZoneData.features : [limitZoneData]; + const centroid = turf.centroid(feature); + for (const zf of features) { + if (!zf.properties?.SoundZone) continue; + try { + if (turf.booleanPointInPolygon(centroid, zf)) { + return !zf.properties.SoundZone.split('_').includes(campZoneName); + } + } catch { /* ignore */ } + } + return false; +} + export function parseLimitZoneGeom(geoJson) { if (!geoJson) return null; const lz = JSON.parse(geoJson); @@ -25,16 +47,18 @@ export function buildCampPolygonFeatures(campPolygons) { const spaceOutOfRange = spaceReq && p.areaSqm ? (p.areaSqm > spaceReq * 1.5 || p.areaSqm < spaceReq * 0.5) : false; + const soundZoneVal = (p.soundZone !== undefined && p.soundZone !== null) ? p.soundZone : -1; f.properties = Object.assign(f.properties || {}, { - campSeasonId: p.campSeasonId, - campName: p.campName, - areaSqm: p.areaSqm, - isOwn: p.campSeasonId === CONFIG.USER_CAMP_SEASON_ID, - soundZone: (p.soundZone !== undefined && p.soundZone !== null) ? p.soundZone : -1, - outsideZone: isOutsideZone(f), - overlaps: false, + campSeasonId: p.campSeasonId, + campName: p.campName, + areaSqm: p.areaSqm, + isOwn: p.campSeasonId === CONFIG.USER_CAMP_SEASON_ID, + soundZone: soundZoneVal, + outsideZone: isOutsideZone(f), + overlaps: false, spaceRequirementSqm: spaceReq, - spaceOutOfRange: spaceOutOfRange, + spaceOutOfRange: spaceOutOfRange, + soundZoneOutOfRange: getSoundZoneOutOfRange(f, soundZoneVal), }); return f; }); diff --git a/src/Humans.Web/wwwroot/js/city-planning/layers.js b/src/Humans.Web/wwwroot/js/city-planning/layers.js index 56d442b4..95add0ef 100644 --- a/src/Humans.Web/wwwroot/js/city-planning/layers.js +++ b/src/Humans.Web/wwwroot/js/city-planning/layers.js @@ -191,6 +191,7 @@ export function renderMap(onCampPolygonClick) { ['boolean', ['get', 'outsideZone'], false], ['boolean', ['get', 'overlaps'], false], ['boolean', ['get', 'spaceOutOfRange'], false], + ['boolean', ['get', 'soundZoneOutOfRange'], false], ], ['concat', '⚠️ ', ['get', 'campName']], ['get', 'campName'],