fix(gps): свои панели вместо попапов карт — клик по точке показывает карточку задачи, клик по кластеру — вертикальный список (устранена ненадёжность balloon/popup)
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '&').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 = `<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]">
|
||||
|
||||
Reference in New Issue
Block a user