Files
dh-inspeccion-v2/web-v2/src/features/map/DhMap.tsx
T
ChatGPT DH fa6ba7f31e
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
fix(web): render operational map as GPS points
2026-09-16 08:55:54 -03:00

106 lines
4.8 KiB
TypeScript

import { useEffect, useRef } from 'react';
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' } },
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
};
type GpsPoint = { feature: MapAssetFeature; coordinates: [number, number] };
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];
}
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<MlMap | null>(null);
const markersRef = useRef<Marker[]>([]);
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
useEffect(() => {
if (!containerRef.current) return;
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('click', () => onSelectRef.current(null));
return () => {
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
mapRef.current = null;
map.remove();
};
}, []);
useEffect(() => {
const map = mapRef.current;
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);
});
fitToPoints(map, points);
}, [data, selectedId]);
return <div ref={containerRef} className="map-canvas" />;
}