From fa6ba7f31ef1e55804f40caeb267ebde2a76d3e9 Mon Sep 17 00:00:00 2001 From: ChatGPT DH Date: Wed, 16 Sep 2026 08:55:54 -0300 Subject: [PATCH] fix(web): render operational map as GPS points --- api-v3/package-lock.json | 4 +- api-v3/package.json | 2 +- api-v3/src/version.ts | 4 +- api-v3/test/unit/f4-health-metadata.test.ts | 6 +- .../f6-1-presentation-ready-contract.test.ts | 2 +- .../unit/f6-12-map-smtp-admin-fixes.test.ts | 2 +- .../test/unit/f6-15-act-map-context.test.ts | 20 +- docs/PHASE_F6_16_MAP_GPS_POINTS.md | 9 + web-v2/package-lock.json | 4 +- web-v2/package.json | 2 +- web-v2/src/config/version.ts | 4 +- web-v2/src/features/map/DhMap.tsx | 220 ++++++------------ web-v2/src/pages/MapPage.tsx | 96 ++++---- web-v2/src/styles.css | 17 ++ 14 files changed, 173 insertions(+), 219 deletions(-) create mode 100644 docs/PHASE_F6_16_MAP_GPS_POINTS.md diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 6a64119..bf41247 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -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", diff --git a/api-v3/package.json b/api-v3/package.json index 45e901e..a01bf24 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-15", + "version": "0.29.0-16", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index b8c7f05..604a661 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -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'; diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 158a56f..0d112ff 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -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'); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index b341592..8be86c2 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -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', () => { diff --git a/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts b/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts index 89cb078..3e6cabd 100644 --- a/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts +++ b/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts @@ -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\)/); }); diff --git a/api-v3/test/unit/f6-15-act-map-context.test.ts b/api-v3/test/unit/f6-15-act-map-context.test.ts index 0ab14d3..f943480 100644 --- a/api-v3/test/unit/f6-15-act-map-context.test.ts +++ b/api-v3/test/unit/f6-15-act-map-context.test.ts @@ -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\(/); }); diff --git a/docs/PHASE_F6_16_MAP_GPS_POINTS.md b/docs/PHASE_F6_16_MAP_GPS_POINTS.md new file mode 100644 index 0000000..0d7036d --- /dev/null +++ b/docs/PHASE_F6_16_MAP_GPS_POINTS.md @@ -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. diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index 1c5e05c..ebfed0e 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -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", diff --git a/web-v2/package.json b/web-v2/package.json index 14fb4ae..e4c3a14 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-11", + "version": "0.23.0-12", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 0385aae..0e4a5d9 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -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'; diff --git a/web-v2/src/features/map/DhMap.tsx b/web-v2/src/features/map/DhMap.tsx index 3f39c28..31df7b5 100644 --- a/web-v2/src/features/map/DhMap.tsx +++ b/web-v2/src/features/map/DhMap.tsx @@ -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(); + 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(); + 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(null); - const mapRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); 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
; } diff --git a/web-v2/src/pages/MapPage.tsx b/web-v2/src/pages/MapPage.tsx index a9cbcdb..51d7c2d 100644 --- a/web-v2/src/pages/MapPage.tsx +++ b/web-v2/src/pages/MapPage.tsx @@ -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 = { - 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 = { + 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([]); const [assets, setAssets] = useState(emptyCollection); const [context, setContext] = useState(emptyCollection); const [documents, setDocuments] = useState(emptyCollection); - const [typeId, setTypeId] = useState(''); - const [status, setStatus] = useState(''); - const [geometryType, setGeometryType] = useState(''); - const [mapSearch, setMapSearch] = useState(''); - const [layers, setLayers] = useState>({ ASSET: true, YACIMIENTO: true, COMPANY: true, ACT: true, FINDING: true }); const [selectedId, setSelectedId] = useState(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(() => { - 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
-
TERRITORIO Y OPERACIÓN

Mapa operativo

Yacimientos, presencia de Empresas, Actas, Hallazgos e Inventario con ubicación real o derivada de geometrías registradas.

{data.meta.count} elemento{data.meta.count === 1 ? '' : 's'}
+
+
TERRITORIO Y OPERACIÓN

Mapa operativo

Ubicaciones GPS de Yacimientos, Empresas, Actas, Hallazgos e Inventario.

+ {data.meta.count} punto{data.meta.count === 1 ? '' : 's'} GPS +
{error && {error}} - {data.meta.truncated && Se muestran los primeros 5000 registros de Inventario. Aplicá filtros para reducir el resultado.} -
- -
{loading &&
}{!loading && data.features.length === 0 &&
No hay ubicaciones para mostrarLas capas sólo muestran registros con una geometría real propia o derivable de su Yacimiento.
}
+
+ {(Object.entries(kindLabels) as Array<[MapEntityKind, string]>).map(([item, label]) => ( + {label} + ))} +
+ +
+ {loading &&
} + + {!loading && data.features.length === 0 &&
No hay puntos GPS para mostrarLos registros aparecen cuando cuentan con una ubicación GPS propia o derivable del Yacimiento.
} + {selected &&
+ {kindLabels[kind]} +

{selected.properties.name}

+ {selected.properties.code} +
+ {selected.properties.typeName} + {kind === 'ASSET' && selected.properties.informationStatus && {assetStatusLabel(selected.properties.informationStatus)}} +
+ {selected.properties.departmentName &&

Departamento: {selected.properties.departmentName}

} + {selected.properties.areaName &&

Área: {selected.properties.areaName}

} + {selected.properties.yacimientoName &&

Yacimiento: {selected.properties.yacimientoName}

} + {selected.properties.companyName &&

Empresa: {selected.properties.companyName}

} + {selected.properties.assetName &&

Elemento: {selected.properties.assetName}

} +

GPS actualizado {formatDate(selected.properties.updatedAt)}

+ {selected.properties.sourceGeometries != null &&

Referencia derivada de {selected.properties.sourceGeometries} punto{selected.properties.sourceGeometries === 1 ? '' : 's'} GPS registrado{selected.properties.sourceGeometries === 1 ? '' : 's'} en el Yacimiento.

} + {selected.properties.href && Abrir registro } +
}
; } diff --git a/web-v2/src/styles.css b/web-v2/src/styles.css index c9ef881..cf5f597 100644 --- a/web-v2/src/styles.css +++ b/web-v2/src/styles.css @@ -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%; } }