diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 83a5478..29551d2 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -77,6 +77,8 @@ interface GpsMapProps { onMapClick?: (lat: number, lng: number) => void; /** Правый клик по карте (создание объекта с координатами) */ onMapContextMenu?: (lat: number, lng: number) => void; + /** Клик по кластеру — id маркеров в нём (свой список вместо попапов карты) */ + onClusterClick?: (markerIds: Array) => void; className?: string; } @@ -131,6 +133,7 @@ export function GpsMap({ polylines, onMapClick, onMapContextMenu, + onClusterClick, className, }: GpsMapProps) { @@ -255,6 +258,7 @@ export function GpsMap({ polylines={polylines} onMapClick={onMapClick} onMapContextMenu={onMapContextMenu} + onClusterClick={onClusterClick} onReady={() => setYandexReady(true)} onError={(message) => triggerFallback(message)} className="h-full w-full" @@ -269,6 +273,7 @@ export function GpsMap({ polylines={polylines} onMapClick={onMapClick} onMapContextMenu={onMapContextMenu} + onClusterClick={onClusterClick} className="h-full w-full rounded-md overflow-hidden" /> )} diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx index 9ff1a3e..4980724 100644 --- a/client/src/components/gps/OsmMap.tsx +++ b/client/src/components/gps/OsmMap.tsx @@ -5,7 +5,7 @@ import 'leaflet.markercluster'; import 'leaflet.markercluster/dist/MarkerCluster.css'; import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap'; -import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; +import { gpsClusterHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; /** * Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка, @@ -33,6 +33,8 @@ interface OsmMapProps { onMapClick?: (lat: number, lng: number) => void; /** Правый клик по карте (создание объекта с координатами) */ onMapContextMenu?: (lat: number, lng: number) => void; + /** Клик по кластеру — id маркеров (свой список вместо popup) */ + onClusterClick?: (markerIds: Array) => void; className?: string; } @@ -48,6 +50,7 @@ export function OsmMap({ polylines = [], onMapClick, onMapContextMenu, + onClusterClick, className, }: OsmMapProps) { const containerRef = useRef(null); @@ -61,10 +64,12 @@ export function OsmMap({ const markersByIdRef = useRef(new Map()); const onMapClickRef = useRef(onMapClick); const onMapContextMenuRef = useRef(onMapContextMenu); + const onClusterClickRef = useRef(onClusterClick); useEffect(() => { markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); onMapClickRef.current = onMapClick; onMapContextMenuRef.current = onMapContextMenu; + onClusterClickRef.current = onClusterClick; }); // Создание карты (один раз) @@ -92,15 +97,13 @@ export function OsmMap({ clusterLayer.on('clusterclick', (e: L.LeafletEvent) => { const cluster = (e as L.LeafletEvent & { layer?: L.MarkerCluster }).layer; - const latlng = (e as L.LeafletEvent & { latlng?: L.LatLng }).latlng; - if (!cluster || !latlng) return; - const items = cluster + if (!cluster) return; + // Свой список в MapTab (попапы карты ненадёжны в этой связке) + const ids = cluster .getAllChildMarkers() .map((cm: L.Marker) => (cm.options as GpsMarkerOptions).gpsMarkerId) - .map((id: string | number | undefined) => (id !== undefined ? markersByIdRef.current.get(String(id)) : undefined)) - .filter((m: GpsMapMarker | undefined): m is GpsMapMarker => !!m); - if (items.length === 0) return; - L.popup({ maxWidth: 260 }).setLatLng(latlng).setContent(gpsClusterListHtml(items)).openOn(map); + .filter((id: string | number | undefined): id is string | number => id !== undefined); + if (ids.length > 0) onClusterClickRef.current?.(ids); }); map.on('click', (e: L.LeafletMouseEvent) => { diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 1e73ab8..71bfe9f 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Loader2 } from 'lucide-react'; import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap'; import { loadYandexMaps } from './ymaps-loader'; -import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; +import { gpsClusterHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; /** * Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps, @@ -28,6 +28,8 @@ interface Yandex21MapProps { onMapClick?: (lat: number, lng: number) => void; /** Правый клик по карте (создание объекта с координатами) */ onMapContextMenu?: (lat: number, lng: number) => void; + /** Клик по кластеру — id маркеров (свой список вместо balloon) */ + onClusterClick?: (markerIds: Array) => void; /** Карта создана и готова к работе */ onReady?: () => void; /** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */ @@ -47,6 +49,7 @@ export function Yandex21Map({ onReady, onError, className, onMapContextMenu, + onClusterClick, }: Yandex21MapProps) { const containerRef = useRef(null); @@ -61,6 +64,7 @@ export function Yandex21Map({ const onErrorRef = useRef(onError); const onMapClickRef = useRef(onMapClick); const onMapContextMenuRef = useRef(onMapContextMenu); + const onClusterClickRef = useRef(onClusterClick); const placemarksRef = useRef(new Map()); const markersByIdRef = useRef(new Map()); useEffect(() => { @@ -69,6 +73,7 @@ export function Yandex21Map({ onMapClickRef.current = onMapClick; markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); onMapContextMenuRef.current = onMapContextMenu; + onClusterClickRef.current = onClusterClick; }); // Загрузка скрипта и создание карты (один раз на apiKey) @@ -119,23 +124,19 @@ export function Yandex21Map({ // по декларациям, хотя рантайм принимает) — приводим. map.geoObjects.add(clusterer as unknown as ymaps.IGeoObject); - // Клик по кластеру → balloon со списком объектов; клик по пункту - // списка обрабатывается делегированием (data-gps-marker-id) + // Клик по кластеру → id маркеров в свой список (balloon карты + // в связке с кастомными layout-маркерами ведёт себя ненадёжно) clusterer.events.add('click', (e: ymaps.IEvent) => { const target = e.get('target') as { getGeoObjects?: () => ymaps.IGeoObject[]; - geometry?: { getCoordinates?: () => number[] }; } | undefined; // Клик по обычному маркеру (не кластеру) — у него нет getGeoObjects if (!target || typeof target.getGeoObjects !== 'function') return; - const items = target + const ids = target .getGeoObjects() .map((g) => (g.properties as unknown as { get(path: string): unknown }).get('gpsMarkerId') as string | number | null) - .map((id) => (id != null ? markersByIdRef.current.get(String(id)) : undefined)) - .filter((m): m is GpsMapMarker => !!m); - if (items.length === 0) return; - const coords = target.geometry?.getCoordinates?.() ?? map.getCenter(); - map.balloon.open(coords, gpsClusterListHtml(items)); + .filter((id): id is string | number => id != null); + if (ids.length > 0) onClusterClickRef.current?.(ids); }); mapRef.current = map; diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx index 2e83b35..920547f 100644 --- a/client/src/pages/gps/MapTab.tsx +++ b/client/src/pages/gps/MapTab.tsx @@ -160,6 +160,10 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v }); }; const [createAt, setCreateAt] = useState<{ lat: number; lng: number } | null>(null); + // Свои панели поверх карты (попапы/balloon карт в этой связке ненадёжны): + // выбранная задача-слоя и список кластера + const [selectedTask, setSelectedTask] = useState<{ id: number; title: string } | null>(null); + const [clusterItems, setClusterItems] = useState | null>(null); useEffect(() => { if (mapFormId !== null) setLayerPrefPart({ formId: mapFormId }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -228,15 +232,11 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v const taskMarkers: GpsMapMarker[] = useMemo(() => { if (!tasksLayer || mapFieldId === null) return []; - const esc = (t: string) => t.replace(/&/g, '&').replace(//g, '>'); const markers: GpsMapMarker[] = []; for (const t of tasksData?.tasks ?? []) { const geo = parseGeo(t.fieldValues?.[mapFieldId]); if (!geo) continue; const st = shortTitle(t.title); - // Попап по клику: название (две строки) + кнопка «Открыть задачу». - // Работает и при выключенных подписях — название видно по клику. - const popup = `
${esc(st.line1)}${st.line2 ? `
${esc(st.line2)}
` : ''}
`; markers.push({ id: `task-${t.id}`, lat: geo.lat, @@ -248,16 +248,34 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v ? { label: st.line1, ...(st.line2 ? { label2: st.line2 } : {}) } : {}), // Кластеризация как у машин: при отдалении — кружок с числом, - // клик по кластеру — вертикальный список названий + // клик по кластеру — свой вертикальный список названий clusterable: true, clusterLabel: t.title, zIndex: 40, - popup, - onClick: rulerMode ? undefined : () => setLocation(`/forms/${mapFormId}/tasks/${t.id}`), + // Клик по точке — своя карточка с названием (и при скрытых подписях) + onClick: rulerMode ? undefined : () => setSelectedTask({ id: t.id, title: t.title }), }); } return markers; - }, [tasksLayer, mapFieldId, tasksData, rulerMode, setLocation, mapFormId, showLabels]); + }, [tasksLayer, mapFieldId, tasksData, rulerMode, showLabels]); + + // Клик по кластеру (машины или задачи) — свой вертикальный список + const handleClusterClick = (ids: Array) => { + const items: Array<{ key: string; title: string; taskId?: number; assetId?: number }> = []; + for (const id of ids) { + const str = String(id); + if (str.startsWith('task-')) { + const taskId = Number(str.slice(5)); + const t = tasksData?.tasks.find((x) => x.id === taskId); + items.push({ key: str, title: t?.title ?? `Задача #${taskId}`, taskId }); + } else { + const assetId = Number(id); + const pos = positions.find((p) => p.assetId === assetId); + if (pos) items.push({ key: str, title: pos.name, assetId }); + } + } + if (items.length > 0) setClusterItems(items); + }; // Правый клик по карте — создать объект выбранной формы с этими координатами const handleMapContextMenu = (lat: number, lng: number) => { @@ -428,9 +446,70 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v polylines={mapPolylines} onMapClick={rulerMode ? (lat, lng) => setRulerPoints((p) => [...p, [lat, lng]]) : undefined} onMapContextMenu={handleMapContextMenu} + onClusterClick={handleClusterClick} className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border" /> + {/* Панель выбранной задачи-слоя (клик по точке) */} + {selectedTask && mapFormId !== null && ( + +
+
+ {(() => { + const st = shortTitle(selectedTask.title); + return ( + <> +
{st.line1}
+ {st.line2 &&
{st.line2}
} + + ); + })()} +
+ +
+ +
+ )} + + {/* Вертикальный список кластера (клик по кружку с числом) */} + {clusterItems && ( + +
+ Объектов: {clusterItems.length} + +
+
+ {clusterItems.map((it) => ( + + ))} +
+
+ )} + {/* Панель выбранного объекта поверх карты */} {selectedLive && (