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
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:
@@ -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" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user