Spaces:
Runtime error
Runtime error
feat(ui): click en parcela vuela a su polígono + dibuja parcelas en el mapa
Browse files- /api/fields incluye geojson (ST_AsGeoJSON) por parcela.
- Mapa de Creación de Parcelas dibuja los polígonos guardados (capa verde) y hace
fitBounds al cargar; click en un item de la lista -> flyToBounds del polígono.
- Verificado con Playwright (click en 'Lote NDVI' vuela a su polígono; 0 errores).
- backend/api/fields.py +12 -1
- backend/db/repositories.py +1 -1
- frontend/src/pages/index.astro +29 -2
backend/api/fields.py
CHANGED
|
@@ -20,6 +20,8 @@ Ejemplo de Integración:
|
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
|
|
|
|
|
|
| 23 |
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
| 24 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 25 |
|
|
@@ -47,7 +49,16 @@ async def create_field(
|
|
| 47 |
async def list_fields(session: AsyncSession = Depends(get_db)) -> list[dict]:
|
| 48 |
"""Lista las parcelas registradas."""
|
| 49 |
rows = await parcels.list_parcels(session)
|
| 50 |
-
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
@router.get("/{field_id}")
|
|
|
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
+
import json
|
| 24 |
+
|
| 25 |
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
| 26 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 27 |
|
|
|
|
| 49 |
async def list_fields(session: AsyncSession = Depends(get_db)) -> list[dict]:
|
| 50 |
"""Lista las parcelas registradas."""
|
| 51 |
rows = await parcels.list_parcels(session)
|
| 52 |
+
return [
|
| 53 |
+
{
|
| 54 |
+
"id": str(r.id),
|
| 55 |
+
"name": r.name,
|
| 56 |
+
"lon": r.lon,
|
| 57 |
+
"lat": r.lat,
|
| 58 |
+
"geojson": json.loads(r.geojson) if r.geojson else None,
|
| 59 |
+
}
|
| 60 |
+
for r in rows
|
| 61 |
+
]
|
| 62 |
|
| 63 |
|
| 64 |
@router.get("/{field_id}")
|
backend/db/repositories.py
CHANGED
|
@@ -121,7 +121,7 @@ async def list_fields(session: AsyncSession, user_id: str | None = None) -> list
|
|
| 121 |
"""Lista las parcelas (filtradas por usuario si se indica), ordenadas por nombre."""
|
| 122 |
cols = (
|
| 123 |
"select id, name, ST_X(ST_Centroid(geom)) as lon, "
|
| 124 |
-
"ST_Y(ST_Centroid(geom)) as lat from fields"
|
| 125 |
)
|
| 126 |
if user_id is None:
|
| 127 |
result = await session.execute(text(f"{cols} order by name"))
|
|
|
|
| 121 |
"""Lista las parcelas (filtradas por usuario si se indica), ordenadas por nombre."""
|
| 122 |
cols = (
|
| 123 |
"select id, name, ST_X(ST_Centroid(geom)) as lon, "
|
| 124 |
+
"ST_Y(ST_Centroid(geom)) as lat, ST_AsGeoJSON(geom) as geojson from fields"
|
| 125 |
)
|
| 126 |
if user_id is None:
|
| 127 |
result = await session.execute(text(f"{cols} order by name"))
|
frontend/src/pages/index.astro
CHANGED
|
@@ -289,7 +289,7 @@ import "../styles/global.css";
|
|
| 289 |
let creds = {};
|
| 290 |
let parcels = [];
|
| 291 |
let drawnGeojson = null;
|
| 292 |
-
let map = null, drawnItems = null;
|
| 293 |
let chartResumen = null, chartSatelite = null;
|
| 294 |
const sessionId = "sess-" + Math.random().toString(36).slice(2);
|
| 295 |
|
|
@@ -389,8 +389,9 @@ import "../styles/global.css";
|
|
| 389 |
? parcels.map((p) => `<option value="${p.id}">${p.name}</option>`).join("")
|
| 390 |
: '<option value="">(sin parcelas)</option>';
|
| 391 |
$("parcelas-list").innerHTML = parcels.length
|
| 392 |
-
? parcels.map((p) => `<li class="flex items-center justify-between bg-stone-50 border border-stone-200 rounded-lg px-3 py-2"><span class="font-semibold text-stone-700"><i class="fa-solid fa-location-dot text-emerald-600 mr-1"></i>${p.name}</span></li>`).join("")
|
| 393 |
: '<li class="text-stone-500">Sin parcelas aún.</li>';
|
|
|
|
| 394 |
}
|
| 395 |
function initMap() {
|
| 396 |
if (map) { map.invalidateSize(); return; }
|
|
@@ -400,6 +401,11 @@ import "../styles/global.css";
|
|
| 400 |
map = L.map("map", { center: [-11.37, -77.37], zoom: 11, layers: [sat] }); // Lomas de Lachay
|
| 401 |
L.control.layers({ "Satélite": sat, "Normal": osm, "Relieve": topo }).addTo(map);
|
| 402 |
drawnItems = new L.FeatureGroup().addTo(map);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
const drawControl = new L.Control.Draw({
|
| 404 |
draw: { polygon: true, marker: false, polyline: false, circle: false, rectangle: false, circlemarker: false },
|
| 405 |
edit: { featureGroup: drawnItems, edit: false, remove: true },
|
|
@@ -410,6 +416,21 @@ import "../styles/global.css";
|
|
| 410 |
drawnGeojson = e.layer.toGeoJSON().geometry;
|
| 411 |
});
|
| 412 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
$("btn-save-parcela").addEventListener("click", async () => {
|
| 414 |
const name = $("parcela-name").value.trim();
|
| 415 |
if (!drawnGeojson) return notify("Falta el polígono", "Dibuja un polígono en el mapa.");
|
|
@@ -430,6 +451,12 @@ import "../styles/global.css";
|
|
| 430 |
} catch (e) { notify("Error de red", String(e)); }
|
| 431 |
});
|
| 432 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
// ===== Resumen =====
|
| 434 |
async function loadResumen() {
|
| 435 |
const fid = $("select-parcela").value;
|
|
|
|
| 289 |
let creds = {};
|
| 290 |
let parcels = [];
|
| 291 |
let drawnGeojson = null;
|
| 292 |
+
let map = null, drawnItems = null, parcelsLayer = null;
|
| 293 |
let chartResumen = null, chartSatelite = null;
|
| 294 |
const sessionId = "sess-" + Math.random().toString(36).slice(2);
|
| 295 |
|
|
|
|
| 389 |
? parcels.map((p) => `<option value="${p.id}">${p.name}</option>`).join("")
|
| 390 |
: '<option value="">(sin parcelas)</option>';
|
| 391 |
$("parcelas-list").innerHTML = parcels.length
|
| 392 |
+
? parcels.map((p) => `<li data-pid="${p.id}" title="Ir a la parcela" class="flex items-center justify-between bg-stone-50 border border-stone-200 rounded-lg px-3 py-2 cursor-pointer hover:bg-stone-100"><span class="font-semibold text-stone-700"><i class="fa-solid fa-location-dot text-emerald-600 mr-1"></i>${p.name}</span><i class="fa-solid fa-crosshairs text-stone-400"></i></li>`).join("")
|
| 393 |
: '<li class="text-stone-500">Sin parcelas aún.</li>';
|
| 394 |
+
renderParcelsOnMap();
|
| 395 |
}
|
| 396 |
function initMap() {
|
| 397 |
if (map) { map.invalidateSize(); return; }
|
|
|
|
| 401 |
map = L.map("map", { center: [-11.37, -77.37], zoom: 11, layers: [sat] }); // Lomas de Lachay
|
| 402 |
L.control.layers({ "Satélite": sat, "Normal": osm, "Relieve": topo }).addTo(map);
|
| 403 |
drawnItems = new L.FeatureGroup().addTo(map);
|
| 404 |
+
parcelsLayer = L.geoJSON(null, {
|
| 405 |
+
style: { color: "#15803d", weight: 2, fillColor: "#15803d", fillOpacity: 0.15 },
|
| 406 |
+
onEachFeature: (f, layer) => layer.bindPopup(f.properties.name),
|
| 407 |
+
}).addTo(map);
|
| 408 |
+
renderParcelsOnMap();
|
| 409 |
const drawControl = new L.Control.Draw({
|
| 410 |
draw: { polygon: true, marker: false, polyline: false, circle: false, rectangle: false, circlemarker: false },
|
| 411 |
edit: { featureGroup: drawnItems, edit: false, remove: true },
|
|
|
|
| 416 |
drawnGeojson = e.layer.toGeoJSON().geometry;
|
| 417 |
});
|
| 418 |
}
|
| 419 |
+
function renderParcelsOnMap() {
|
| 420 |
+
if (!parcelsLayer) return;
|
| 421 |
+
parcelsLayer.clearLayers();
|
| 422 |
+
const feats = parcels
|
| 423 |
+
.filter((p) => p.geojson)
|
| 424 |
+
.map((p) => ({ type: "Feature", properties: { name: p.name, id: p.id }, geometry: p.geojson }));
|
| 425 |
+
if (!feats.length) return;
|
| 426 |
+
parcelsLayer.addData({ type: "FeatureCollection", features: feats });
|
| 427 |
+
try { map.fitBounds(parcelsLayer.getBounds(), { maxZoom: 14, padding: [25, 25] }); } catch {}
|
| 428 |
+
}
|
| 429 |
+
function flyToParcel(id) {
|
| 430 |
+
const p = parcels.find((x) => x.id === id);
|
| 431 |
+
if (!p || !p.geojson || !map) return;
|
| 432 |
+
try { map.flyToBounds(L.geoJSON(p.geojson).getBounds(), { maxZoom: 15, padding: [25, 25] }); } catch {}
|
| 433 |
+
}
|
| 434 |
$("btn-save-parcela").addEventListener("click", async () => {
|
| 435 |
const name = $("parcela-name").value.trim();
|
| 436 |
if (!drawnGeojson) return notify("Falta el polígono", "Dibuja un polígono en el mapa.");
|
|
|
|
| 451 |
} catch (e) { notify("Error de red", String(e)); }
|
| 452 |
});
|
| 453 |
|
| 454 |
+
// Click en una parcela de la lista -> volar a su polígono
|
| 455 |
+
$("parcelas-list").addEventListener("click", (e) => {
|
| 456 |
+
const li = e.target.closest("li[data-pid]");
|
| 457 |
+
if (li) flyToParcel(li.dataset.pid);
|
| 458 |
+
});
|
| 459 |
+
|
| 460 |
// ===== Resumen =====
|
| 461 |
async function loadResumen() {
|
| 462 |
const fid = $("select-parcela").value;
|