From dfedb3ebfac14b1923a323cdc2530dcfbd1271a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 22 Sep 2026 21:39:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D1=81=D0=BB=D0=BE=D1=8F=20=C2=AB?= =?UTF-8?q?=D0=9E=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D1=8B=C2=BB=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20(?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0/=D0=BF=D0=BE=D0=BB=D0=B5/=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F),=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B2=20=D0=B4=D0=B2=D0=B5=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8:=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20/=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D1=87?= =?UTF-8?q?=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/GpsMap.tsx | 2 + client/src/components/gps/marker-html.ts | 14 +++-- client/src/pages/gps/MapTab.tsx | 71 +++++++++++++++++------- 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 37e5be3..6b98d53 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -24,6 +24,8 @@ export interface GpsMapMarker { /** vehicle/person — кружок с иконкой; dot — маленькая точка (старт/финиш/вершина/точка трека) */ kind: 'vehicle' | 'person' | 'dot'; label?: string; + /** Вторая строка подписи (заказчик) */ + label2?: string; /** Приглушённый маркер для offline-объектов */ offline?: boolean; /** Примерная позиция (LBS по вышкам, нет GPS): полупрозрачный маркер с пунктирной обводкой */ diff --git a/client/src/components/gps/marker-html.ts b/client/src/components/gps/marker-html.ts index 62ca25b..fa09507 100644 --- a/client/src/components/gps/marker-html.ts +++ b/client/src/components/gps/marker-html.ts @@ -43,9 +43,9 @@ const LABEL_HEIGHT = 18; // подпись под маркером (16px + от export function gpsMarkerLayout(marker: GpsMapMarker): GpsMarkerLayout { if (marker.kind === 'dot') { - return { width: 24, height: 16 + (marker.label ? LABEL_HEIGHT : 0), anchorX: 12, anchorY: 8 }; + return { width: 24, height: 16 + (marker.label ? LABEL_HEIGHT : 0) + (marker.label2 ? LABEL_HEIGHT : 0), anchorX: 12, anchorY: 8 }; } - return { width: 120, height: 28 + (marker.label ? LABEL_HEIGHT : 0), anchorX: 60, anchorY: 14 }; + return { width: 120, height: 28 + (marker.label ? LABEL_HEIGHT : 0) + (marker.label2 ? LABEL_HEIGHT : 0), anchorX: 60, anchorY: 14 }; } /** @@ -61,9 +61,13 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string { const title = escapeHtml(marker.label || ''); // Подпись выходит за ширину маркера (для dot 24px) — overflow виден по умолчанию, // max-width не ставим, иначе «Старт»/«Финиш» обрезались до «С…»/«Ф…» - const label = marker.label - ? `
${title}
` - : ''; + const title2 = escapeHtml(marker.label2 || ''); + // Подпись: одна или две строки (объект / заказчик). Выходит за ширину маркера, + // overflow виден по умолчанию, max-width не ставим + const label = + marker.label || marker.label2 + ? `
${title}${marker.label2 ? `
${title2}
` : ''}
` + : ''; let body: string; if (marker.kind === 'dot') { diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx index 1fc5359..f304bdd 100644 --- a/client/src/pages/gps/MapTab.tsx +++ b/client/src/pages/gps/MapTab.tsx @@ -131,13 +131,43 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v // === Слой задач формы: выбранная форма + её geo-поле → задачи на карте === const [, setLocation] = useLocation(); - const [tasksLayer, setTasksLayer] = useState(false); - // Отображение названий объектов на карте (по умолчанию скрыты — длинные - // заголовки перекрывают друг друга; включаем кнопкой «Названия») - const [showLabels, setShowLabels] = useState(false); - const [mapFormId, setMapFormId] = useState(null); - const [mapFieldId, setMapFieldId] = useState(null); + // Настройки слоя сохраняются в localStorage — иначе при переходе между + // табами компонент размонтируется и выбор формы/поля слетает + const LAYER_STORAGE_KEY = 'gps-tasks-layer'; + const [layerPref, setLayerPref] = useState<{ layer: boolean; formId: number | null; fieldId: number | null; labels: boolean }>(() => { + try { + const raw = JSON.parse(localStorage.getItem(LAYER_STORAGE_KEY) || 'null'); + if (raw && typeof raw === 'object') { + return { + layer: raw.layer === true, + formId: typeof raw.formId === 'number' ? raw.formId : null, + fieldId: typeof raw.fieldId === 'number' ? raw.fieldId : null, + labels: raw.labels === true, + }; + } + } catch { /* ignore */ } + return { layer: false, formId: null, fieldId: null, labels: false }; + }); + const tasksLayer = layerPref.layer; + const mapFormId = layerPref.formId; + const mapFieldId = layerPref.fieldId; + const showLabels = layerPref.labels; + const setLayerPrefPart = (part: Partial) => { + setLayerPref((prev) => { + const next = { ...prev, ...part }; + try { localStorage.setItem(LAYER_STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore */ } + return next; + }); + }; const [createAt, setCreateAt] = useState<{ lat: number; lng: number } | null>(null); + useEffect(() => { + if (mapFormId !== null) setLayerPrefPart({ formId: mapFormId }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapFormId]); + useEffect(() => { + if (mapFieldId !== null) setLayerPrefPart({ fieldId: mapFieldId }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapFieldId]); const { data: formsData } = useQuery<{ success: boolean; forms: Array<{ id: number; name: string }> }>({ queryKey: ['/api/forms'], @@ -156,7 +186,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v // geo-поле по умолчанию — первое (при смене формы) useEffect(() => { if (mapFieldId === null || !geoFields.some((f) => f.id === mapFieldId)) { - setMapFieldId(geoFields[0]?.id ?? null); + setLayerPrefPart({ fieldId: geoFields[0]?.id ?? null }); } }, [geoFields, mapFieldId]); @@ -186,13 +216,14 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v return { lat, lng }; }; - // Короткое название для подписи: «объект · заказчик» (первая и последняя - // части заголовка через «;»), длину ограничиваем, иначе подписи перекрывают карту - const shortTitle = (title: string): string => { + // Двухстрочная подпись: первая строка — объект, вторая — заказчик + // (первая и последняя части заголовка через «;»), длину ограничиваем + const shortTitle = (title: string): { line1: string; line2?: string } => { const parts = title.split(';').map((x) => x.trim()).filter(Boolean); - let text = parts.length > 1 ? `${parts[0]} · ${parts[parts.length - 1]}` : (parts[0] ?? title); - if (text.length > 40) text = `${text.slice(0, 39)}…`; - return text; + const line1 = parts[0] ?? title; + const line2 = parts.length > 1 ? parts[parts.length - 1] : undefined; + const cut = (t: string) => (t.length > 34 ? `${t.slice(0, 33)}…` : t); + return { line1: cut(line1), line2: line2 ? cut(line2) : undefined }; }; const taskMarkers: GpsMapMarker[] = useMemo(() => { @@ -207,8 +238,10 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v lng: geo.lng, kind: 'dot', color: '#8b5cf6', - // Подпись — только при включённом режиме «Названия» - label: showLabels ? shortTitle(t.title) : undefined, + // Подпись — только при включённом режиме «Названия», в две строки + ...(showLabels + ? (() => { const st = shortTitle(t.title); return { label: st.line1, ...(st.line2 ? { label2: st.line2 } : {}) }; })() + : {}), noCluster: true, zIndex: 40, onClick: rulerMode ? undefined : () => setLocation(`/forms/${mapFormId}/tasks/${t.id}`), @@ -317,7 +350,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v size="sm" variant={tasksLayer ? 'default' : 'outline'} className="h-7 text-xs" - onClick={() => setTasksLayer((v) => !v)} + onClick={() => setLayerPrefPart({ layer: !tasksLayer })} > Объекты @@ -326,7 +359,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v <> setMapFieldId(Number(v))} + onValueChange={(v) => setLayerPrefPart({ fieldId: Number(v) })} > @@ -356,7 +389,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v size="sm" variant={showLabels ? 'default' : 'outline'} className="h-7 text-xs" - onClick={() => setShowLabels((v) => !v)} + onClick={() => setLayerPrefPart({ labels: !showLabels })} > Названия