126 lines
9.3 KiB
TypeScript
126 lines
9.3 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
/** Геометрия маркера: размеры блока и точка привязки (якорь) к координате */
|
||
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>`;
|
||
}
|