DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
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<HTMLDivElement | null>(null);
|
|
const mapRef = useRef<Map | null>(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 <div ref={containerRef} className="geometry-map" />;
|
|
}
|
|
|
|
const geometryNames: Record<AssetGeometryType, string> = {
|
|
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<AssetGeometry | null>(null);
|
|
const [type, setType] = useState<AssetGeometryType>('POINT');
|
|
const [vertices, setVertices] = useState<Position[]>([]);
|
|
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 <div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>;
|
|
|
|
const minimum = type === 'POINT' ? 1 : type === 'LINESTRING' ? 2 : 3;
|
|
return <article className="panel geometry-editor">
|
|
<div className="panel-heading"><div><span className="eyebrow">POSTGIS · WGS 84</span><h2>Ubicación geográfica</h2></div>{stored ? <span className="tag">{geometryNames[stored.geometryType]}</span> : <span className="tag">Sin geometría</span>}</div>
|
|
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
|
<p className="section-copy">Elegí el tipo y marcá {type === 'POINT' ? 'la posición' : 'los vértices'} directamente sobre el mapa. Las coordenadas se guardan en EPSG:4326.</p>
|
|
|
|
{canEdit && <div className="geometry-toolbar"><div className="geometry-type-buttons">{(['POINT', 'LINESTRING', 'POLYGON'] as AssetGeometryType[]).map((item) => <button type="button" key={item} className={`button ${type === item ? 'primary' : 'secondary'}`} onClick={() => changeType(item)}>{geometryNames[item]}</button>)}</div><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Ubicando…' : 'Usar mi ubicación'}</button></div>}
|
|
|
|
<GeometryDrawingMap geometry={geometry} vertices={vertices} editable={canEdit} onAddVertex={addVertex} />
|
|
|
|
<div className="geometry-progress"><strong>{vertices.length} vértice{vertices.length === 1 ? '' : 's'}</strong><span>{geometry ? 'Geometría lista para guardar' : `Faltan ${Math.max(0, minimum - vertices.length)} vértices`}</span>{canEdit && vertices.length > 0 && <div><button type="button" className="button text" onClick={() => setVertices((current) => current.slice(0, -1))}>Deshacer último</button><button type="button" className="button text" onClick={() => setVertices([])}>Limpiar</button></div>}</div>
|
|
|
|
<div className="form-grid geometry-metadata"><label className="field"><span>Precisión GPS <em>metros · opcional</em></span><input type="number" min="0" max="100000" step="0.001" value={accuracyM} onChange={(event) => setAccuracyM(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Fecha y hora de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Dispositivo <em>opcional</em></span><input value={deviceLabel} onChange={(event) => setDeviceLabel(event.target.value)} disabled={!canEdit} maxLength={255} placeholder="Tablet, navegador, GPS…" /></label></div>
|
|
|
|
{stored && <div className="geometry-audit-note"><span>Fuente: {stored.source}</span><span>Actualizada: {formatDate(stored.updatedAt)}</span>{stored.accuracyM != null && <span>Precisión: {stored.accuracyM} m</span>}</div>}
|
|
{canEdit && <div className="form-actions">{stored && <button type="button" className="button danger-outline" onClick={remove} disabled={saving}>Quitar geometría</button>}<button type="button" className="button primary" onClick={save} disabled={!geometry || saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar ubicación'}</button></div>}
|
|
</article>;
|
|
}
|