110 lines
4.5 KiB
TypeScript
110 lines
4.5 KiB
TypeScript
import { ReactNode, useEffect } from 'react';
|
|
import L from 'leaflet';
|
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
|
import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint } from '@patrol/shared';
|
|
import 'leaflet/dist/leaflet.css';
|
|
|
|
// Подложка — наш tileserver-gl (VITE_TILE_URL). Без переменной — публичный OSM, только для разработки.
|
|
export const TILE_URL: string = import.meta.env.VITE_TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
export const ALMATY: [number, number] = [43.2389, 76.8897];
|
|
|
|
export function BaseMap({ children, height = 480, center, zoom = 15, style }: {
|
|
children?: ReactNode;
|
|
height?: number | string;
|
|
center?: [number, number];
|
|
zoom?: number;
|
|
style?: React.CSSProperties;
|
|
}) {
|
|
return (
|
|
<MapContainer
|
|
center={center || ALMATY}
|
|
zoom={zoom}
|
|
style={{ height, width: '100%', borderRadius: 12, ...style }}
|
|
scrollWheelZoom
|
|
>
|
|
<AutoResize />
|
|
<TileLayer url={TILE_URL} maxZoom={19} attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' />
|
|
{children}
|
|
</MapContainer>
|
|
);
|
|
}
|
|
|
|
// Карта в flex/absolute-контейнере получает размер позже монтирования — пересчитываем.
|
|
function AutoResize() {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
const el = map.getContainer();
|
|
const ro = new ResizeObserver(() => map.invalidateSize());
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [map]);
|
|
return null;
|
|
}
|
|
|
|
type Geo = GeoJsonMultiPolygon | GeoJsonLineString | GeoJsonPoint | null | undefined;
|
|
|
|
export function boundsOf(...geos: Geo[]): L.LatLngBounds | null {
|
|
let b: L.LatLngBounds | null = null;
|
|
for (const g of geos) {
|
|
if (!g) continue;
|
|
const layer = L.geoJSON(g as GeoJSON.GeoJsonObject);
|
|
const lb = layer.getBounds();
|
|
if (!lb.isValid()) continue;
|
|
b = b ? b.extend(lb) : lb;
|
|
}
|
|
return b;
|
|
}
|
|
|
|
// Подогнать карту под геометрию один раз (или когда меняется key).
|
|
export function FitTo({ geos, padding = 32, maxZoom = 18 }: { geos: Geo[]; padding?: number; maxZoom?: number }) {
|
|
const map = useMap();
|
|
const key = JSON.stringify(geos.map((g) => (g ? JSON.stringify(g).length : 0)));
|
|
useEffect(() => {
|
|
const b = boundsOf(...geos);
|
|
if (!b) return;
|
|
const fit = () => {
|
|
map.invalidateSize();
|
|
map.fitBounds(b, { padding: [padding, padding], maxZoom });
|
|
};
|
|
fit();
|
|
// Если контейнер ещё не разложен, подгоняем ещё раз после раскладки.
|
|
const t = window.setTimeout(fit, 150);
|
|
return () => window.clearTimeout(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [map, key]);
|
|
return null;
|
|
}
|
|
|
|
export function numberIcon(n: number | string, opts: { color?: string; done?: boolean; active?: boolean; size?: number } = {}) {
|
|
const size = opts.size ?? 28;
|
|
const color = opts.done ? '#16a34a' : opts.color || '#2563eb';
|
|
const ring = opts.active ? `box-shadow:0 0 0 4px ${color}55;` : 'box-shadow:0 2px 6px rgba(15,23,42,0.3);';
|
|
return L.divIcon({
|
|
className: 'patrol-num-icon',
|
|
iconSize: [size, size],
|
|
iconAnchor: [size / 2, size / 2],
|
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};color:#fff;display:grid;place-items:center;font:600 ${Math.round(size * 0.46)}px Inter,system-ui,sans-serif;border:2px solid #fff;${ring}">${n}</div>`,
|
|
});
|
|
}
|
|
|
|
export function dotIcon(color: string, size = 14) {
|
|
return L.divIcon({
|
|
className: 'patrol-dot-icon',
|
|
iconSize: [size, size],
|
|
iconAnchor: [size / 2, size / 2],
|
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 4px rgba(15,23,42,0.35)"></div>`,
|
|
});
|
|
}
|
|
|
|
export function pinIcon(color = '#dc2626') {
|
|
return L.divIcon({
|
|
className: 'patrol-pin-icon',
|
|
iconSize: [30, 40],
|
|
iconAnchor: [15, 38],
|
|
html: `<svg width="30" height="40" viewBox="0 0 30 40"><path d="M15 1C7.3 1 1 7.2 1 14.9 1 25.3 15 39 15 39s14-13.7 14-24.1C29 7.2 22.7 1 15 1z" fill="${color}" stroke="#fff" stroke-width="2"/><circle cx="15" cy="15" r="5" fill="#fff"/></svg>`,
|
|
});
|
|
}
|
|
|
|
export const PERIMETER_STYLE: L.PathOptions = { color: '#2563eb', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.08 };
|
|
export const ROUTE_STYLE: L.PathOptions = { color: '#7c3aed', weight: 4, opacity: 0.85, dashArray: '8 6' };
|