import { useEffect, useMemo, useRef, useState } from 'react'; import { Map, NavigationControl, type GeoJSONSource, } from 'maplibre-gl'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Icon } from '../../components/Icon'; import { getAssetGeometry, removeAssetGeometry, upsertAssetGeometry, } from '../../lib/api'; import type { AssetGeometry, AssetGeometryType, GeoJsonGeometry, Position, } from '../../lib/api'; import { formatDate } from '../../lib/format'; const osmStyle = { version: 8 as const, sources: { osm: { type: 'raster' as const, tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors', }, }, layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }], }; function localDateTime(value: string | number | Date): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); return local.toISOString().slice(0, 16); } function geometryVertices(geometry: GeoJsonGeometry | null): Position[] { if (!geometry) return []; if (geometry.type === 'Point') return [geometry.coordinates]; if (geometry.type === 'LineString') return geometry.coordinates; return geometry.coordinates[0]?.slice(0, -1) ?? []; } function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null { if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null; if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null; if (vertices.length < 3) return null; return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] }; } function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) { const features: unknown[] = []; if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry }); if (geometry?.type !== 'Point') { vertices.forEach((coordinates, index) => features.push({ type: 'Feature', properties: { kind: 'vertex', index: index + 1 }, geometry: { type: 'Point', coordinates }, })); } return { type: 'FeatureCollection', features }; } function geometryBounds(geometry: GeoJsonGeometry | null) { const points = geometryVertices(geometry); if (!points.length) return null; return points.reduce<[number, number, number, number]>((result, point) => [ Math.min(result[0], point[0]), Math.min(result[1], point[1]), Math.max(result[2], point[0]), Math.max(result[3], point[1]), ], [points[0]![0], points[0]![1], points[0]![0], points[0]![1]]); } function GeometryDrawingMap({ geometry, vertices, editable, onAddVertex, }: { geometry: GeoJsonGeometry | null; vertices: Position[]; editable: boolean; onAddVertex: (position: Position) => void; }) { const containerRef = useRef(null); const mapRef = useRef(null); const addVertexRef = useRef(onAddVertex); const dataRef = useRef(drawingCollection(geometry, vertices)); addVertexRef.current = onAddVertex; dataRef.current = drawingCollection(geometry, vertices); useEffect(() => { if (!containerRef.current) return; const bounds = geometryBounds(geometry); const map = new Map({ container: containerRef.current, style: osmStyle, center: bounds ? [bounds[0], bounds[1]] : [-68.8458, -32.8895], zoom: bounds ? 12 : 6, }); mapRef.current = map; map.addControl(new NavigationControl(), 'top-right'); map.on('load', () => { map.addSource('drawing', { type: 'geojson', data: dataRef.current as never }); map.addLayer({ id: 'drawing-fill', type: 'fill', source: 'drawing', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.2 }, }); map.addLayer({ id: 'drawing-line', type: 'line', source: 'drawing', filter: ['in', ['geometry-type'], ['literal', ['LineString', 'Polygon']]], paint: { 'line-color': '#2864dc', 'line-width': 3 }, }); map.addLayer({ id: 'drawing-points', type: 'circle', source: 'drawing', filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': ['case', ['==', ['get', 'kind'], 'vertex'], 5, 8], 'circle-color': ['case', ['==', ['get', 'kind'], 'vertex'], '#f59e0b', '#2864dc'], 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, }, }); if (bounds) { if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) { map.flyTo({ center: [bounds[0], bounds[1]], zoom: 14 }); } else { map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 45, maxZoom: 16 }); } } }); if (editable) { map.getCanvas().style.cursor = 'crosshair'; map.on('click', (event) => addVertexRef.current([ Number(event.lngLat.lng.toFixed(7)), Number(event.lngLat.lat.toFixed(7)), ])); } return () => { mapRef.current = null; map.remove(); }; }, [editable]); useEffect(() => { const source = mapRef.current?.getSource('drawing') as GeoJSONSource | undefined; source?.setData(drawingCollection(geometry, vertices) as never); }, [geometry, vertices]); return
; } const geometryNames: Record = { POINT: 'Punto', LINESTRING: 'Línea', POLYGON: 'Polígono', }; export function AssetGeometryEditor({ assetId, assetName, canEdit, onChanged, }: { assetId: string; assetName: string; canEdit: boolean; onChanged?: () => void; }) { const [stored, setStored] = useState(null); const [type, setType] = useState('POINT'); const [vertices, setVertices] = useState([]); const [accuracyM, setAccuracyM] = useState(''); const [capturedAt, setCapturedAt] = useState(''); const [deviceLabel, setDeviceLabel] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [locating, setLocating] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const geometry = useMemo(() => draftGeometry(type, vertices), [type, vertices]); const applyStored = (value: AssetGeometry | null) => { setStored(value); if (value) { setType(value.geometryType); setVertices(geometryVertices(value.geometry)); setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM)); setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : ''); setDeviceLabel(value.deviceLabel ?? ''); } }; useEffect(() => { getAssetGeometry(assetId) .then(applyStored) .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); }, [assetId]); const changeType = (next: AssetGeometryType) => { setType(next); setVertices([]); setSuccess(''); setError(''); }; const addVertex = (position: Position) => { if (!canEdit) return; setVertices((current) => type === 'POINT' ? [position] : [...current, position]); setSuccess(''); }; const useDeviceLocation = () => { if (!navigator.geolocation) { setError('Este navegador no permite obtener la ubicación del dispositivo.'); return; } setLocating(true); setError(''); navigator.geolocation.getCurrentPosition( (position) => { setType('POINT'); setVertices([[ Number(position.coords.longitude.toFixed(7)), Number(position.coords.latitude.toFixed(7)), ]]); setAccuracyM(Number(position.coords.accuracy.toFixed(3)).toString()); setCapturedAt(localDateTime(position.timestamp)); setDeviceLabel('Navegador web'); setLocating(false); }, (locationError) => { setError(locationError.code === 1 ? 'No se otorgó permiso para acceder a la ubicación.' : 'No fue posible obtener una ubicación precisa.'); setLocating(false); }, { enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 }, ); }; const save = async () => { if (!geometry) return; setSaving(true); setError(''); setSuccess(''); try { const updated = await upsertAssetGeometry(assetId, { geometry, accuracyM: accuracyM ? Number(accuracyM) : null, capturedAt: capturedAt ? new Date(capturedAt).toISOString() : null, deviceLabel: deviceLabel.trim() || null, }); applyStored(updated); setSuccess('Ubicación guardada correctamente'); onChanged?.(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }; const remove = async () => { if (!window.confirm(`¿Quitar la geometría actual de ${assetName}? El cambio quedará auditado.`)) return; setSaving(true); setError(''); setSuccess(''); try { await removeAssetGeometry(assetId); setStored(null); setVertices([]); setAccuracyM(''); setCapturedAt(''); setDeviceLabel(''); setSuccess('La geometría fue retirada y el cambio quedó auditado'); onChanged?.(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }; if (loading) return
; const minimum = type === 'POINT' ? 1 : type === 'LINESTRING' ? 2 : 3; return
POSTGIS · WGS 84

Ubicación geográfica

{stored ? {geometryNames[stored.geometryType]} : Sin geometría}
{error && {error}}{success && {success}}

Elegí el tipo y marcá {type === 'POINT' ? 'la posición' : 'los vértices'} directamente sobre el mapa. Las coordenadas se guardan en EPSG:4326.

{canEdit &&
{(['POINT', 'LINESTRING', 'POLYGON'] as AssetGeometryType[]).map((item) => )}
}
{vertices.length} vértice{vertices.length === 1 ? '' : 's'}{geometry ? 'Geometría lista para guardar' : `Faltan ${Math.max(0, minimum - vertices.length)} vértices`}{canEdit && vertices.length > 0 &&
}
{stored &&
Fuente: {stored.source}Actualizada: {formatDate(stored.updatedAt)}{stored.accuracyM != null && Precisión: {stored.accuracyM} m}
} {canEdit &&
{stored && }
}
; }