feat(gps): настройки слоя «Объекты» сохраняются (форма/поле/названия), подписи в две строки: объект / заказчик

This commit is contained in:
2026-09-22 21:39:27 +03:00
parent fe174535b7
commit dfedb3ebfa
3 changed files with 63 additions and 24 deletions

View File

@@ -24,6 +24,8 @@ export interface GpsMapMarker {
/** vehicle/person — кружок с иконкой; dot — маленькая точка (старт/финиш/вершина/точка трека) */
kind: 'vehicle' | 'person' | 'dot';
label?: string;
/** Вторая строка подписи (заказчик) */
label2?: string;
/** Приглушённый маркер для offline-объектов */
offline?: boolean;
/** Примерная позиция (LBS по вышкам, нет GPS): полупрозрачный маркер с пунктирной обводкой */

View File

@@ -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,8 +61,12 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string {
const title = escapeHtml(marker.label || '');
// Подпись выходит за ширину маркера (для dot 24px) — overflow виден по умолчанию,
// max-width не ставим, иначе «Старт»/«Финиш» обрезались до «С…»/«Ф…»
const label = marker.label
? `<div style="margin-top:2px;padding:0 4px;border-radius:4px;font-size:10px;line-height:16px;font-weight:500;background:rgba(255,255,255,0.92);border:1px solid #d4d4d8;white-space:nowrap;color:#18181b;">${title}</div>`
const title2 = escapeHtml(marker.label2 || '');
// Подпись: одна или две строки (объект / заказчик). Выходит за ширину маркера,
// overflow виден по умолчанию, max-width не ставим
const label =
marker.label || marker.label2
? `<div style="margin-top:2px;padding:0 4px;border-radius:4px;font-size:10px;line-height:14px;font-weight:500;background:rgba(255,255,255,0.92);border:1px solid #d4d4d8;white-space:nowrap;color:#18181b;text-align:center;">${title}${marker.label2 ? `<div style="font-weight:400;color:#52525b;font-size:9px;line-height:13px;">${title2}</div>` : ''}</div>`
: '';
let body: string;

View File

@@ -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<number | null>(null);
const [mapFieldId, setMapFieldId] = useState<number | null>(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<typeof layerPref>) => {
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 })}
>
<MapPinPlus className="w-3.5 h-3.5 mr-1" />
Объекты
@@ -326,7 +359,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
<>
<Select
value={mapFormId !== null ? String(mapFormId) : ''}
onValueChange={(v) => { setMapFormId(Number(v)); }}
onValueChange={(v) => { setLayerPrefPart({ formId: Number(v) }); }}
>
<SelectTrigger className="h-7 w-44 text-xs">
<SelectValue placeholder="Форма" />
@@ -340,7 +373,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
{geoFields.length > 1 && (
<Select
value={mapFieldId !== null ? String(mapFieldId) : ''}
onValueChange={(v) => setMapFieldId(Number(v))}
onValueChange={(v) => setLayerPrefPart({ fieldId: Number(v) })}
>
<SelectTrigger className="h-7 w-44 text-xs">
<SelectValue placeholder="Поле (адрес)" />
@@ -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 })}
>
<Tag className="w-3.5 h-3.5 mr-1" />
Названия