fix(web): render operational map as GPS points
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m38s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m55s
DH V2 CI / Promote verified main to deploy (push) Successful in 4s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m24s

This commit is contained in:
ChatGPT DH
2026-09-16 08:55:54 -03:00
parent a414d0ed36
commit fa6ba7f31e
14 changed files with 173 additions and 219 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.29.0-15",
"version": "0.29.0-16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.29.0-15",
"version": "0.29.0-16",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-15",
"version": "0.29.0-16",
"private": true,
"license": "UNLICENSED",
"scripts": {
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-15';
export const API_PHASE = 'F6.15';
export const API_VERSION = '0.29.0-16';
export const API_PHASE = 'F6.16';
+3 -3
View File
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.15 release', () => {
assert.equal(API_PHASE, 'F6.15');
test('health metadata reports the current F6.16 release', () => {
assert.equal(API_PHASE, 'F6.16');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-15');
assert.equal(API_VERSION, '0.29.0-16');
});
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.15/);
assert.match(version, /APP_PHASE\s*=\s*'F6\.16/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
@@ -14,7 +14,7 @@ test('F6.12 uses standard GeoJSON names in WEB and converts only at the API boun
assert.match(client, /type: 'LineString'/);
assert.match(client, /type: 'Polygon'/);
assert.match(client, /geometryPayload/);
assert.match(map, /feature\.geometry\.type === 'Point'/);
assert.match(map, /geometry\.type\.toLowerCase\(\) !== 'point'/);
assert.match(editor, /setType\(value\.geometryType\)/);
});
+13 -7
View File
@@ -37,7 +37,7 @@ test('F6.15 Acta uses real report workflow state instead of legacy generated PDF
assert.match(list, /act\.report\.status === 'OFFICIALIZED'/);
});
test('F6.15 map exposes filtered operational layers without inventing coordinates', () => {
test('F6.16 map renders operational locations as GPS point markers without filters', () => {
const controller = api('src/asset-master/asset-geometries.controller.ts');
const service = api('src/asset-master/asset-geometries.service.ts');
const page = web('pages/MapPage.tsx');
@@ -51,10 +51,16 @@ test('F6.15 map exposes filtered operational layers without inventing coordinate
assert.match(service, new RegExp(`'${kind}'::text`));
assert.match(page, new RegExp(`${kind}:`));
}
assert.match(page, /Buscar Yacimiento, Empresa, Acta, Hallazgo/);
assert.match(page, /sourceGeometries/);
assert.match(map, /yacimiento-points/);
assert.match(map, /company-points/);
assert.match(map, /act-points/);
assert.match(map, /finding-points/);
assert.match(page, /punto\{data\.meta\.count === 1 \? '' : 's'\} GPS/);
assert.match(page, /feature\.geometry\.type\.toLowerCase\(\) === 'point'/);
assert.match(page, /map-legend/);
assert.doesNotMatch(page, /SearchableSelect/);
assert.doesNotMatch(page, /mapSearch/);
assert.doesNotMatch(page, /toggleLayer/);
assert.match(map, /new Marker/);
assert.match(map, /pointCoordinates/);
assert.match(map, /visualOffsets/);
assert.match(map, /fitToPoints/);
assert.doesNotMatch(map, /addSource\('assets'/);
assert.doesNotMatch(map, /addLayer\(/);
});
+9
View File
@@ -0,0 +1,9 @@
# F6.16 · Mapa GPS puntual
La vista Mapa representa exclusivamente ubicaciones puntuales GPS.
- Se retiran temporalmente filtros, buscador y activación de capas.
- Se muestran siempre Inventario GPS, Yacimientos, Empresas, Actas y Hallazgos disponibles.
- El render usa marcadores DOM de MapLibre en vez de capas GeoJSON, evitando depender del procesamiento del worker para visualizar puntos.
- Las coordenadas siguen siendo las registradas; si varios registros comparten GPS, sólo se desplazan visualmente algunos píxeles para poder distinguirlos.
- El mapa se encuadra automáticamente sobre todos los puntos válidos.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-web",
"version": "0.23.0-11",
"version": "0.23.0-12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
"version": "0.23.0-11",
"version": "0.23.0-12",
"dependencies": {
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
"version": "0.23.0-11",
"version": "0.23.0-12",
"private": true,
"type": "module",
"engines": {
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-11';
export const APP_PHASE = 'F6.15 · Actas y mapa operativo';
export const APP_VERSION = '0.23.0-12';
export const APP_PHASE = 'F6.16 · Mapa GPS puntual';
+71 -149
View File
@@ -1,151 +1,80 @@
import { useEffect, useRef } from 'react';
import {
Map,
NavigationControl,
type GeoJSONSource,
type MapGeoJSONFeature,
} from 'maplibre-gl';
import type { MapAssetFeatureCollection } from '../../lib/api';
import { Map as MlMap, Marker, NavigationControl } from 'maplibre-gl';
import type { MapAssetFeature, MapAssetFeatureCollection, MapEntityKind } from '../../lib/api';
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',
},
},
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' }],
};
const interactiveLayers = ['assets-points', 'yacimiento-points', 'company-points', 'act-points', 'finding-points', 'assets-lines', 'assets-polygons'];
type GpsPoint = { feature: MapAssetFeature; coordinates: [number, number] };
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
const positions: Array<[number, number]> = [];
collection.features.forEach((feature) => {
if (feature.geometry.type === 'Point') positions.push(feature.geometry.coordinates);
if (feature.geometry.type === 'LineString') positions.push(...feature.geometry.coordinates);
if (feature.geometry.type === 'Polygon') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
});
if (!positions.length) return null;
return positions.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]),
], [positions[0]![0], positions[0]![1], positions[0]![0], positions[0]![1]]);
function pointCoordinates(feature: MapAssetFeature): [number, number] | null {
const geometry = feature.geometry as { type: string; coordinates: unknown };
if (geometry.type.toLowerCase() !== 'point' || !Array.isArray(geometry.coordinates)) return null;
const [longitude, latitude] = geometry.coordinates;
if (typeof longitude !== 'number' || typeof latitude !== 'number') return null;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) return null;
return [longitude, latitude];
}
export function DhMap({
data,
selectedId,
onSelect,
}: {
function gpsPoints(collection: MapAssetFeatureCollection): GpsPoint[] {
return collection.features.flatMap((feature) => {
const coordinates = pointCoordinates(feature);
return coordinates ? [{ feature, coordinates }] : [];
});
}
function fitToPoints(map: MlMap, points: GpsPoint[]) {
if (!points.length) return;
const bounds = points.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point.coordinates[0]), Math.min(result[1], point.coordinates[1]),
Math.max(result[2], point.coordinates[0]), Math.max(result[3], point.coordinates[1]),
], [points[0]!.coordinates[0], points[0]!.coordinates[1], points[0]!.coordinates[0], points[0]!.coordinates[1]]);
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) map.flyTo({ center: [bounds[0], bounds[1]], zoom: 15 });
else map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 15 });
}
function visualOffsets(points: GpsPoint[]) {
const groups = new Map<string, GpsPoint[]>();
points.forEach((point) => {
const key = `${point.coordinates[0].toFixed(5)}:${point.coordinates[1].toFixed(5)}`;
groups.set(key, [...(groups.get(key) ?? []), point]);
});
const offsets = new Map<string, [number, number]>();
groups.forEach((group) => {
group.forEach((point, index) => {
if (group.length === 1) { offsets.set(String(point.feature.id), [0, 0]); return; }
const angle = (Math.PI * 2 * index) / group.length;
const radius = Math.min(18, 8 + group.length);
offsets.set(String(point.feature.id), [Math.cos(angle) * radius, Math.sin(angle) * radius]);
});
});
return offsets;
}
export function DhMap({ data, selectedId, onSelect }: {
data: MapAssetFeatureCollection;
selectedId: string | null;
onSelect: (id: string | null) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const mapRef = useRef<MlMap | null>(null);
const markersRef = useRef<Marker[]>([]);
const onSelectRef = useRef(onSelect);
const dataRef = useRef(data);
onSelectRef.current = onSelect;
dataRef.current = data;
useEffect(() => {
if (!containerRef.current) return;
const map = new Map({
container: containerRef.current,
style: osmStyle,
center: [-68.8458, -32.8895],
zoom: 6,
});
const map = new MlMap({ container: containerRef.current, style: osmStyle, center: [-68.8458, -32.8895], zoom: 6 });
mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('assets', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'assets-polygons', type: 'fill', source: 'assets',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.22, 'fill-outline-color': '#184caf' },
});
map.addLayer({
id: 'assets-lines', type: 'line', source: 'assets',
filter: ['==', ['geometry-type'], 'LineString'],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'assets-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'ASSET']],
paint: { 'circle-radius': 7, 'circle-color': '#64748b', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
});
map.addLayer({
id: 'yacimiento-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'YACIMIENTO']],
paint: { 'circle-radius': 9, 'circle-color': '#2563eb', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
});
map.addLayer({
id: 'company-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'COMPANY']],
paint: { 'circle-radius': 8, 'circle-color': '#059669', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
});
map.addLayer({
id: 'act-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'ACT']],
paint: { 'circle-radius': 9, 'circle-color': '#7c3aed', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
});
map.addLayer({
id: 'finding-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'FINDING']],
paint: { 'circle-radius': 8, 'circle-color': '#dc2626', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
});
map.addLayer({
id: 'assets-selected-polygons', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 4 },
});
map.addLayer({
id: 'assets-selected-lines', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 6 },
});
map.addLayer({
id: 'assets-selected-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'id'], '']],
paint: {
'circle-radius': 10, 'circle-color': '#f59e0b',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 3,
},
});
const click = (event: { features?: MapGeoJSONFeature[] }) => {
const id = event.features?.[0]?.properties?.id;
onSelectRef.current(typeof id === 'string' ? id : null);
};
interactiveLayers.forEach((layer) => {
map.on('click', layer, click);
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
});
map.on('click', (event) => {
const hits = map.queryRenderedFeatures(event.point, { layers: interactiveLayers });
if (!hits.length) onSelectRef.current(null);
});
const bounds = boundsFromFeatures(dataRef.current);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
});
map.on('click', () => onSelectRef.current(null));
return () => {
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
mapRef.current = null;
map.remove();
};
@@ -153,31 +82,24 @@ export function DhMap({
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never);
const bounds = boundsFromFeatures(data);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
}, [data]);
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
const id = selectedId ?? '__none__';
const filters: Array<[string, string]> = [
['assets-selected-points', 'Point'],
['assets-selected-lines', 'LineString'],
['assets-selected-polygons', 'Polygon'],
];
filters.forEach(([layer, geometryType]) => {
map.setFilter(layer, ['all', ['==', ['geometry-type'], geometryType], ['==', ['get', 'id'], id]]);
if (!map) return;
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
const points = gpsPoints(data);
const offsets = visualOffsets(points);
points.forEach(({ feature, coordinates }) => {
const kind = (feature.properties.entityKind ?? 'ASSET') as MapEntityKind;
const element = document.createElement('button');
element.type = 'button';
element.className = `map-gps-marker kind-${kind.toLowerCase()}${String(feature.id) === selectedId ? ' selected' : ''}`;
element.title = `${feature.properties.typeName}: ${feature.properties.name}`;
element.setAttribute('aria-label', element.title);
element.addEventListener('click', (event) => { event.stopPropagation(); onSelectRef.current(String(feature.id)); });
const marker = new Marker({ element, offset: offsets.get(String(feature.id)) ?? [0, 0] }).setLngLat(coordinates).addTo(map);
markersRef.current.push(marker);
});
}, [selectedId]);
fitToPoints(map, points);
}, [data, selectedId]);
return <div ref={containerRef} className="map-canvas" />;
}
+48 -48
View File
@@ -1,4 +1,3 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../auth/AuthContext';
@@ -6,91 +5,92 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
import { DhMap } from '../features/map/DhMap';
import { assetStatusClass, assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
import { getMapAssets, getMapDocuments, getMapOperationalContext, listAssetTypes } from '../lib/api';
import type { AssetGeometryType, AssetInformationStatus, AssetType, MapAssetFeatureCollection, MapEntityKind } from '../lib/api';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import { getMapAssets, getMapDocuments, getMapOperationalContext } from '../lib/api';
import type { MapAssetFeatureCollection, MapEntityKind } from '../lib/api';
import { formatDate } from '../lib/format';
const emptyCollection: MapAssetFeatureCollection = { type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false } };
const layerLabels: Record<MapEntityKind, string> = {
ASSET: 'Inventario GPS', YACIMIENTO: 'Yacimientos', COMPANY: 'Empresas', ACT: 'Actas', FINDING: 'Hallazgos',
const emptyCollection: MapAssetFeatureCollection = {
type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false },
};
const kindLabels: Record<MapEntityKind, string> = {
ASSET: 'Inventario GPS', YACIMIENTO: 'Yacimiento', COMPANY: 'Empresa', ACT: 'Acta', FINDING: 'Hallazgo',
};
export function MapPage() {
const { hasPermission } = useAuth();
const canReadDocuments = hasPermission('inspection_acts.read') && hasPermission('inspection_findings.read');
const [types, setTypes] = useState<AssetType[]>([]);
const [assets, setAssets] = useState<MapAssetFeatureCollection>(emptyCollection);
const [context, setContext] = useState<MapAssetFeatureCollection>(emptyCollection);
const [documents, setDocuments] = useState<MapAssetFeatureCollection>(emptyCollection);
const [typeId, setTypeId] = useState('');
const [status, setStatus] = useState<AssetInformationStatus | ''>('');
const [geometryType, setGeometryType] = useState<AssetGeometryType | ''>('');
const [mapSearch, setMapSearch] = useState('');
const [layers, setLayers] = useState<Record<MapEntityKind, boolean>>({ ASSET: true, YACIMIENTO: true, COMPANY: true, ACT: true, FINDING: true });
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => { listAssetTypes().then(setTypes).catch(() => undefined); }, []);
useEffect(() => {
setLoading(true); setError('');
Promise.all([
getMapAssets({ typeId, status, geometryType }),
getMapAssets(),
getMapOperationalContext(),
canReadDocuments ? getMapDocuments() : Promise.resolve(emptyCollection),
]).then(([assetResult, contextResult, documentResult]) => {
setAssets(assetResult); setContext(contextResult); setDocuments(documentResult);
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, [typeId, status, geometryType, canReadDocuments]);
}, [canReadDocuments]);
const data = useMemo<MapAssetFeatureCollection>(() => {
const directAssets = assets.features.filter((feature) => !['yacimiento', 'empresa'].includes(feature.properties.typeCode?.toLowerCase() ?? ''));
const all = [...directAssets, ...context.features, ...documents.features];
const term = mapSearch.trim().toLocaleLowerCase('es-AR');
const features = all.filter((feature) => {
if (!layers[feature.properties.entityKind ?? 'ASSET']) return false;
if (!term) return true;
const searchable = [
feature.properties.code, feature.properties.name, feature.properties.typeName,
feature.properties.contextLine, feature.properties.departmentName, feature.properties.areaName,
feature.properties.yacimientoName, feature.properties.companyName, feature.properties.actCode,
feature.properties.assetName,
].filter(Boolean).join(' ').toLocaleLowerCase('es-AR');
return searchable.includes(term);
const directAssets = assets.features.filter((feature) => {
const code = feature.properties.typeCode?.toLowerCase() ?? '';
return !['yacimiento', 'empresa'].includes(code) && feature.geometry.type.toLowerCase() === 'point';
});
const features = [...directAssets, ...context.features, ...documents.features]
.filter((feature) => feature.geometry.type.toLowerCase() === 'point');
return { type: 'FeatureCollection', features, meta: { count: features.length, truncated: assets.meta.truncated } };
}, [assets, context, documents, layers, mapSearch]);
}, [assets, context, documents]);
useEffect(() => {
setSelectedId((current) => data.features.some((item) => item.id === current) ? current : null);
}, [data]);
const selected = useMemo(() => data.features.find((feature) => feature.id === selectedId) ?? null, [data, selectedId]);
const toggleLayer = (kind: MapEntityKind) => setLayers((current) => ({ ...current, [kind]: !current[kind] }));
const kind = selected?.properties.entityKind ?? 'ASSET';
return <section>
<div className="page-heading"><div><span className="eyebrow">TERRITORIO Y OPERACIÓN</span><h1>Mapa operativo</h1><p>Yacimientos, presencia de Empresas, Actas, Hallazgos e Inventario con ubicación real o derivada de geometrías registradas.</p></div><span className="map-count"><strong>{data.meta.count}</strong> elemento{data.meta.count === 1 ? '' : 's'}</span></div>
<div className="page-heading">
<div><span className="eyebrow">TERRITORIO Y OPERACIÓN</span><h1>Mapa operativo</h1><p>Ubicaciones GPS de Yacimientos, Empresas, Actas, Hallazgos e Inventario.</p></div>
<span className="map-count"><strong>{data.meta.count}</strong> punto{data.meta.count === 1 ? '' : 's'} GPS</span>
</div>
<AssetCenterTabs active="map" />
{error && <Alert>{error}</Alert>}
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 registros de Inventario. Aplicá filtros para reducir el resultado.</Alert>}
<div className="map-layout operational-map-layout">
<aside className="filters map-sidebar">
<div><span className="eyebrow">CAPAS</span><h2>Qué mostrar</h2></div>
<label className="search-field map-global-search"><Icon name="search" /><input value={mapSearch} onChange={(event) => setMapSearch(event.target.value)} placeholder="Buscar Yacimiento, Empresa, Acta, Hallazgo…" /></label>
<div className="map-layer-buttons">
{(Object.keys(layerLabels) as MapEntityKind[]).map((item) => item === 'ACT' || item === 'FINDING' ? (canReadDocuments && <button key={item} type="button" className={`button compact ${layers[item] ? 'primary' : 'secondary'}`} onClick={() => toggleLayer(item)}>{layerLabels[item]}</button>) : <button key={item} type="button" className={`button compact ${layers[item] ? 'primary' : 'secondary'}`} onClick={() => toggleLayer(item)}>{layerLabels[item]}</button>)}
</div>
<div><span className="eyebrow">FILTROS DE INVENTARIO</span></div>
<label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)} disabled={!layers.ASSET}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Estado de información</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus | '')} disabled={!layers.ASSET}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field"><span>Geometría</span><SearchableSelect value={geometryType} onChange={(event) => setGeometryType(event.target.value as AssetGeometryType | '')} disabled={!layers.ASSET}><option value="">Todas</option><option value="POINT">Puntos</option><option value="LINESTRING">Líneas</option><option value="POLYGON">Polígonos</option></SearchableSelect></label>
<button className="button secondary wide" onClick={() => { setTypeId(''); setStatus(''); setGeometryType(''); }}>Limpiar filtros de Inventario</button>
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 puntos GPS de Inventario.</Alert>}
{selected && <div className="map-selection"><span className="eyebrow">{layerLabels[kind]}</span><h3>{selected.properties.name}</h3><code>{selected.properties.code}</code><div className="map-selection-meta"><span className="tag">{selected.properties.typeName}</span>{kind === 'ASSET' && selected.properties.informationStatus && <span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span>}</div>{selected.properties.contextLine && <p>{selected.properties.contextLine}</p>}{selected.properties.departmentName && <p><strong>Departamento:</strong> {selected.properties.departmentName}</p>}{selected.properties.areaName && <p><strong>Área:</strong> {selected.properties.areaName}</p>}{selected.properties.yacimientoName && <p><strong>Yacimiento:</strong> {selected.properties.yacimientoName}</p>}{selected.properties.companyName && <p><strong>Empresa:</strong> {selected.properties.companyName}</p>}{selected.properties.assetName && <p><strong>Elemento:</strong> {selected.properties.assetName}</p>}<p>Ubicación actualizada {formatDate(selected.properties.updatedAt)}</p>{selected.properties.sourceGeometries != null && <p><small>Ubicación territorial derivada de {selected.properties.sourceGeometries} geometría{selected.properties.sourceGeometries === 1 ? '' : 's'} registrada{selected.properties.sourceGeometries === 1 ? '' : 's'} en el Yacimiento.</small></p>}{selected.properties.href && <Link className="button primary wide" to={selected.properties.href}>Abrir {layerLabels[kind].replace(/s$/, '')} <Icon name="chevron" /></Link>}</div>}
</aside>
<div className="map-stage">{loading && <div className="map-loading"><LoadingBlock label="Actualizando mapa…" /></div>}<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay ubicaciones para mostrar</strong><span>Las capas sólo muestran registros con una geometría real propia o derivable de su Yacimiento.</span></div>}</div>
<div className="map-legend" aria-label="Referencias del mapa">
{(Object.entries(kindLabels) as Array<[MapEntityKind, string]>).map(([item, label]) => (
<span key={item}><i className={`map-legend-dot kind-${item.toLowerCase()}`} />{label}</span>
))}
</div>
<div className="map-stage operational-map-stage">
{loading && <div className="map-loading"><LoadingBlock label="Cargando puntos GPS…" /></div>}
<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />
{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay puntos GPS para mostrar</strong><span>Los registros aparecen cuando cuentan con una ubicación GPS propia o derivable del Yacimiento.</span></div>}
{selected && <div className="map-selection map-selection-overlay">
<span className="eyebrow">{kindLabels[kind]}</span>
<h3>{selected.properties.name}</h3>
<code>{selected.properties.code}</code>
<div className="map-selection-meta">
<span className="tag">{selected.properties.typeName}</span>
{kind === 'ASSET' && selected.properties.informationStatus && <span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span>}
</div>
{selected.properties.departmentName && <p><strong>Departamento:</strong> {selected.properties.departmentName}</p>}
{selected.properties.areaName && <p><strong>Área:</strong> {selected.properties.areaName}</p>}
{selected.properties.yacimientoName && <p><strong>Yacimiento:</strong> {selected.properties.yacimientoName}</p>}
{selected.properties.companyName && <p><strong>Empresa:</strong> {selected.properties.companyName}</p>}
{selected.properties.assetName && <p><strong>Elemento:</strong> {selected.properties.assetName}</p>}
<p>GPS actualizado {formatDate(selected.properties.updatedAt)}</p>
{selected.properties.sourceGeometries != null && <p><small>Referencia derivada de {selected.properties.sourceGeometries} punto{selected.properties.sourceGeometries === 1 ? '' : 's'} GPS registrado{selected.properties.sourceGeometries === 1 ? '' : 's'} en el Yacimiento.</small></p>}
{selected.properties.href && <Link className="button primary wide" to={selected.properties.href}>Abrir registro <Icon name="chevron" /></Link>}
</div>}
</div>
</section>;
}
+17
View File
@@ -1442,3 +1442,20 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
.map-layer-buttons .button { flex: 1 1 calc(50% - 6px); justify-content: center; min-width: 104px; }
.map-global-search { width: 100%; margin: 0; }
.act-finding-context { margin: 10px 0 12px; }
/* F6.16 · Mapa GPS puntual */
.map-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin: 0 0 12px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 10px; background: white; }
.map-legend > span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 9px; font-weight: 750; }
.map-legend-dot { width: 9px; height: 9px; display: inline-block; flex: 0 0 auto; border: 2px solid white; border-radius: 50%; box-shadow: 0 0 0 1px rgba(15,23,42,.16); }
.map-gps-marker { width: 18px; height: 18px; display: block; padding: 0; border: 3px solid white; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 8px rgba(15,23,42,.28); transition: transform .15s ease, box-shadow .15s ease; }
.map-gps-marker:hover { transform: scale(1.2); box-shadow: 0 3px 12px rgba(15,23,42,.35); }
.map-gps-marker.selected { transform: scale(1.35); box-shadow: 0 0 0 3px rgba(245,158,11,.42), 0 4px 14px rgba(15,23,42,.32); }
.kind-asset { background: #64748b; }
.kind-yacimiento { background: #2563eb; }
.kind-company { background: #059669; }
.kind-act { background: #7c3aed; }
.kind-finding { background: #dc2626; }
.operational-map-stage { min-width: 0; position: relative; }
.operational-map-stage .map-canvas { height: 650px; }
.map-selection-overlay { position: absolute; left: 16px; bottom: 16px; z-index: 4; width: min(330px, calc(100% - 32px)); max-height: 52%; overflow-y: auto; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255,255,255,.96); box-shadow: var(--shadow); backdrop-filter: blur(5px); }
@media (max-width: 780px) { .operational-map-stage .map-canvas { height: 520px; } .map-selection-overlay { max-height: 62%; } }