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' };