Files
iistwin/client/src/components/gps/marker-html.ts

126 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { GpsMapMarker } from './GpsMap';
/**
* Общая HTML-разметка кастомных маркеров GPS-модуля.
* Используется обеими реализациями карты: Leaflet (divIcon) и Яндекс.Карты 2.1
* (templateLayoutFactory) — визуально маркеры одинаковые на обеих подложках.
*
* ВАЖНО: разметка БЕЗ transform-центрирования. Каждый маркер — блок фиксированной
* ширины, провайдер позиционирует его через iconAnchor (Leaflet) / iconOffset
* (Яндекс) по данным gpsMarkerLayout(). Это нужно, чтобы кликабельная область
* совпадала с видимым маркером: у ymaps layout-контейнер ставится левым верхним
* углом в точку, и с translate(-50%,-50%) ~75% видимой площади маркера
* оказывалось вне контейнера — клики по центру точки не открывали balloon.
*/
const DEFAULT_COLOR = '#3b82f6';
// Иконки lucide (car / user) в виде SVG-строк: react-компоненты внутрь
// HTML-маркера карты вставить нельзя, разметка собирается вручную
const CAR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`;
const USER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
/** Стрелка направления (треугольник, при course=0 смотрит на север) */
function arrowSvg(color: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="${color}" stroke="#fff" stroke-width="1"><path d="M12 2 L19 21 L12 16.5 L5 21 Z"/></svg>`;
}
/** Экранирование пользовательского текста для HTML-разметки маркера */
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Геометрия маркера: размеры блока и точка привязки (якорь) к координате */
export interface GpsMarkerLayout {
width: number;
height: number;
/** Якорь: какая точка блока совпадает с координатой маркера */
anchorX: number;
anchorY: number;
}
const LABEL_HEIGHT = 18; // подпись под маркером (16px + отступ 2px)
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: 120, height: 28 + (marker.label ? LABEL_HEIGHT : 0), anchorX: 60, anchorY: 14 };
}
/**
* HTML маркера: цветной кружок с иконкой (vehicle/person), маленькая точка
* или стрелка курса (dot с course) + подпись снизу; offline — приглушённый.
* Корневой блок — фиксированной ширины, без transform (см. комментарий выше).
*/
export function gpsMarkerHtml(marker: GpsMapMarker): string {
const color = marker.color || DEFAULT_COLOR;
// approximate (позиция по вышкам) — полупрозрачный маркер; offline — приглушённый
const opacity = marker.offline ? 'opacity:0.5;' : marker.approximate ? 'opacity:0.55;' : '';
const layout = gpsMarkerLayout(marker);
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>`
: '';
let body: string;
if (marker.kind === 'dot') {
if (marker.approximate) {
// LBS-точка трека: полый серый кружок без стрелки (курс у LBS фейковый).
// Внешний блок 16px сохраняет якорь dot-маркера (anchorY=8)
body = `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;" title="${title}"><div style="width:10px;height:10px;border-radius:50%;border:2px solid #94a3b8;background:rgba(255,255,255,0.5);box-shadow:0 1px 2px rgba(0,0,0,0.2);"></div></div>`;
} else {
body =
marker.course != null
? `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;transform:rotate(${marker.course}deg);" title="${title}">${arrowSvg(color)}</div>`
: `<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${title}"></div>`;
}
} else {
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
if (marker.approximate) {
// Примерная позиция: пунктирная обводка + бейдж «~» в углу кружка
body = `<div style="position:relative;width:28px;height:28px;">` +
`<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px dashed #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>` +
`<div style="position:absolute;top:-4px;right:-4px;width:12px;height:12px;border-radius:50%;background:#f59e0b;border:1px solid #fff;color:#fff;font-size:9px;line-height:10px;font-weight:700;display:flex;align-items:center;justify-content:center;">~</div>` +
`</div>`;
} else {
body = `<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>`;
}
}
// data-gps-marker-id: делегированный обработчик контейнера карты ловит клики
// по любому маркеру (надёжнее нативных событий placemark/divIcon)
return `<div data-gps-marker-id="${escapeHtml(String(marker.id))}" style="display:flex;flex-direction:column;align-items:center;width:${layout.width}px;user-select:none;cursor:pointer;${opacity}">${body}${label}</div>`;
}
/**
* HTML кластера — кружок с числом объектов в стиле маркеров.
* Используется обеими реализациями (ymaps.Clusterer и leaflet.markercluster).
* Для ymaps templateLayoutFactory допустима строка-шаблон
* '{{ properties.geoObjects.length }}' вместо числа.
* (translate(-50%,-50%) здесь допустим: кликабельность кластера не критична
* по краям, центр кружка всегда попадает в контейнер.)
*/
export function gpsClusterHtml(count: number | string): string {
return `<div style="display:flex;align-items:center;justify-content:center;transform:translate(-50%,-50%);width:36px;height:36px;border-radius:50%;border:2px solid #fff;background:${DEFAULT_COLOR};box-shadow:0 1px 4px rgba(0,0,0,0.4);color:#fff;font-size:13px;font-weight:600;user-select:none;cursor:pointer;">${count}</div>`;
}
/**
* HTML списка объектов кластера (открывается по клику на кластер).
* Клики по пунктам разбирает делегированный обработчик провайдера
* (по атрибуту data-gps-marker-id) — вызывается onClick маркера.
*/
export function gpsClusterListHtml(markers: GpsMapMarker[]): string {
const rows = markers
.map(
(m) => `<button type="button" data-gps-marker-id="${escapeHtml(String(m.id))}" style="display:flex;align-items:center;gap:6px;width:100%;padding:4px 6px;border:0;background:none;cursor:pointer;font-size:12px;text-align:left;border-radius:4px;">
<span style="width:10px;height:10px;border-radius:50%;background:${m.color || DEFAULT_COLOR};border:1px solid #fff;box-shadow:0 0 1px rgba(0,0,0,0.4);flex-shrink:0;"></span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(m.label || 'Объект')}</span>
</button>`
)
.join('');
return `<div style="max-height:220px;overflow-y:auto;min-width:160px;">${rows}</div>`;
}