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 (
{children}
);
}
// Карта в 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: `
${n}
`,
});
}
export function dotIcon(color: string, size = 14) {
return L.divIcon({
className: 'patrol-dot-icon',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: ``,
});
}
export function pinIcon(color = '#dc2626') {
return L.divIcon({
className: 'patrol-pin-icon',
iconSize: [30, 40],
iconAnchor: [15, 38],
html: ``,
});
}
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' };