diff --git a/src/Humans.Application/Interfaces/ICityPlanningService.cs b/src/Humans.Application/Interfaces/ICityPlanningService.cs index 94c34fdc..665e6d51 100644 --- a/src/Humans.Application/Interfaces/ICityPlanningService.cs +++ b/src/Humans.Application/Interfaces/ICityPlanningService.cs @@ -48,12 +48,15 @@ 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, + 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 30fd946e..21b19325 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), s.SoundZone)).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 @@ -82,7 +101,7 @@ public async Task> GetCampPolygonHistoryAsync(G return rows.Select(h => new CampPolygonHistoryEntryDto( h.Id, - h.ModifiedByUser.UserName ?? h.ModifiedByUserId.ToString(), + h.ModifiedByUser.DisplayName ?? h.ModifiedByUserId.ToString(), h.ModifiedAt.InZone(DateTimeZone.Utc).ToDateTimeUnspecified() .ToString("d MMM yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture), h.AreaSqm, 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"]
- - - - } - @if (isMapAdmin) - { - } 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 b34f277d..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,25 @@ // 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); + 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,17 +38,28 @@ 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 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 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}${editBtn}`) + .setHTML(`
${escHtml(props.campName || 'Camp')}
${area}${warning}${overlapWarn}${sizeWarn}${soundZoneWarn}
${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 --- @@ -40,6 +67,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(); @@ -52,7 +80,6 @@ export function startEditing(campSeasonId) { appState.draw.changeMode('direct_select', { featureId: f.id }); } - document.getElementById('history-btn').disabled = false; setEditingControlsVisible(true); updateSaveButton(); } @@ -62,7 +89,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 +103,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 +114,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'); @@ -123,10 +145,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() { @@ -153,9 +175,21 @@ export function updateSaveButton() { ? { type: 'FeatureCollection', features: [poly] } : { type: 'FeatureCollection', features: [] }); + const editId = appState.activeCampSeasonId ?? appState.previewCampSeasonId; + const spaceReqSqm = getSpaceRequirementSqm(editId); + 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 soundZoneMismatch = getSoundZoneOutOfRange(poly, getCampSoundZone(editId)); const warnings = [ - ...(outside ? ['\n⚠️ Outside limits'] : []), - ...(overlap ? ['\n⚠️ Overlaps with another barrio'] : []), + ...(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] }); @@ -176,14 +210,20 @@ 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 (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}`; + + const list = document.getElementById('history-list'); if (!history.length) { list.innerHTML = '

No history yet.

'; } else { @@ -197,7 +237,7 @@ export async function loadHistory() {
- ${CONFIG.IS_MAP_ADMIN ? `` : ''} + ${canEdit ? `` : ''}
@@ -205,25 +245,31 @@ export async function loadHistory() { list.querySelectorAll('.preview-btn').forEach(btn => { btn.addEventListener('click', () => { + appState.previewCampSeasonId = id; appState.draw.deleteAll(); appState.draw.add(JSON.parse(decodeURIComponent(btn.dataset.geojson))); }); }); 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(); + 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) { - const campSeasonId = appState.activeCampSeasonId; - if (!campSeasonId) return; - if (!confirm('Restore this polygon version? The current version will be saved to history first.')) return; +export async function restoreVersion(historyId, campSeasonId) { + const id = campSeasonId ?? appState.activeCampSeasonId; + if (!id) return; + if (!confirm('Restore this version?')) 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/geometry.js b/src/Humans.Web/wwwroot/js/city-planning/geometry.js index aed9ed28..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); @@ -21,14 +43,22 @@ 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; + 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, + soundZoneOutOfRange: getSoundZoneOutOfRange(f, soundZoneVal), }); return f; }); @@ -48,8 +78,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/layers.js b/src/Humans.Web/wwwroot/js/city-planning/layers.js index 0559e1d8..95add0ef 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] }, }); } @@ -187,7 +187,12 @@ 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], + ['boolean', ['get', 'soundZoneOutOfRange'], false], + ], ['concat', '⚠️ ', ['get', 'campName']], ['get', 'campName'], ], 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(); 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, };