fix(gps): свои панели вместо попапов карт — клик по точке показывает карточку задачи, клик по кластеру — вертикальный список (устранена ненадёжность balloon/popup)

This commit is contained in:
2026-09-23 09:16:55 +03:00
parent 6c1cea39b4
commit dadd49c15f
4 changed files with 114 additions and 26 deletions

View File

@@ -77,6 +77,8 @@ interface GpsMapProps {
onMapClick?: (lat: number, lng: number) => void;
/** Правый клик по карте (создание объекта с координатами) */
onMapContextMenu?: (lat: number, lng: number) => void;
/** Клик по кластеру — id маркеров в нём (свой список вместо попапов карты) */
onClusterClick?: (markerIds: Array<string | number>) => 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"
/>
)}

View File

@@ -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<string | number>) => void;
className?: string;
}
@@ -48,6 +50,7 @@ export function OsmMap({
polylines = [],
onMapClick,
onMapContextMenu,
onClusterClick,
className,
}: OsmMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -61,10 +64,12 @@ export function OsmMap({
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
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) => {

View File

@@ -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<string | number>) => void;
/** Карта создана и готова к работе */
onReady?: () => void;
/** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */
@@ -47,6 +49,7 @@ export function Yandex21Map({
onReady,
onError,
className, onMapContextMenu,
onClusterClick,
}: Yandex21MapProps) {
const containerRef = useRef<HTMLDivElement>(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<string, any>());
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
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;

View File

@@ -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<Array<{ key: string; title: string; taskId?: number; assetId?: number }> | 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 = `<div style="max-width:220px;"><b>${esc(st.line1)}</b>${st.line2 ? `<div style="color:#52525b;font-size:11px;">${esc(st.line2)}</div>` : ''}<button type="button" data-gps-action="open:task-${t.id}" style="margin-top:6px;font-size:11px;color:#2563eb;background:none;border:0;cursor:pointer;padding:0;">Открыть задачу</button></div>`;
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<string | number>) => {
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 && (
<Card className="absolute top-2 left-2 w-64 p-3 space-y-2 shadow-lg z-[1002]">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
{(() => {
const st = shortTitle(selectedTask.title);
return (
<>
<div className="font-medium text-sm truncate">{st.line1}</div>
{st.line2 && <div className="text-xs text-muted-foreground truncate">{st.line2}</div>}
</>
);
})()}
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setSelectedTask(null)}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
<Button
size="sm"
className="w-full h-8"
onClick={() => setLocation(`/forms/${mapFormId}/tasks/${selectedTask.id}`)}
>
Открыть задачу
</Button>
</Card>
)}
{/* Вертикальный список кластера (клик по кружку с числом) */}
{clusterItems && (
<Card className="absolute top-2 left-2 w-72 p-2 shadow-lg z-[1002]">
<div className="flex items-center justify-between px-1 pb-1">
<span className="text-xs text-muted-foreground">Объектов: {clusterItems.length}</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setClusterItems(null)}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
<div className="max-h-60 overflow-y-auto space-y-0.5">
{clusterItems.map((it) => (
<button
key={it.key}
type="button"
className="w-full text-left px-2 py-1.5 text-xs rounded-md hover:bg-muted truncate"
onClick={() => {
setClusterItems(null);
if (it.taskId !== undefined && mapFormId !== null) {
setLocation(`/forms/${mapFormId}/tasks/${it.taskId}`);
} else if (it.assetId !== undefined) {
handleSelectAsset(assets.find((a) => a.id === it.assetId) ?? ({} as GpsAsset));
}
}}
>
{it.title}
</button>
))}
</div>
</Card>
)}
{/* Панель выбранного объекта поверх карты */}
{selectedLive && (
<Card className="absolute top-2 right-2 w-72 p-3 space-y-2 shadow-lg z-[1002]">