import type { LatLng } from './map.types'; /** Mean Earth radius in metres, the value the Maps spherical library uses. */ export const EARTH_RADIUS_M = 6378137; const toRad = (degrees: number) => (degrees * Math.PI) / 180; const toDeg = (radians: number) => (radians * 180) / Math.PI; /** * Point reached by travelling `distanceMeters` from `origin` on a `heading`. * * @description * Mirrors `google.maps.geometry.spherical.computeOffset`, and is used in its * place so sector geometry can be computed — and unit-tested — before the Maps * script exists. Headings are degrees clockwise from north, the convention * radio and survey data already use. */ export function computeOffset(origin: LatLng, distanceMeters: number, heading: number): LatLng { const angular = distanceMeters / EARTH_RADIUS_M; const bearing = toRad(heading); const lat = toRad(origin.lat); const lng = toRad(origin.lng); const sinLat = Math.sin(lat) * Math.cos(angular) + Math.cos(lat) * Math.sin(angular) * Math.cos(bearing); const destLat = Math.asin(sinLat); const destLng = lng + Math.atan2( Math.sin(bearing) * Math.sin(angular) * Math.cos(lat), Math.cos(angular) - Math.sin(lat) * sinLat ); // Normalize into [-180, 180] so a sector crossing the antimeridian stays sane. return { lat: toDeg(destLat), lng: ((toDeg(destLng) + 540) % 360) - 180 }; } export interface SectorGeometry { center: LatLng; /** Degrees clockwise from north. */ azimuth: number; /** Angular width of the beam, in degrees. */ beamWidth: number; radiusMeters: number; /** Arc resolution. @default 24 */ steps?: number; } /** * Builds the polygon ring of an antenna coverage sector. * * @description * A wedge with its vertex on the site, opening `beamWidth` degrees around * `azimuth` and reaching `radiusMeters`. This is domain symbology — no generic * circle or polygon substitutes for it, which is why it gets its own prop. * * A `beamWidth` of 360 or more degenerates into a full circle, and the vertex * is dropped in that case so the ring does not fold back on itself. */ export function sectorPath(sector: SectorGeometry): LatLng[] { const { center, azimuth, beamWidth, radiusMeters, steps = 24 } = sector; const isFullCircle = beamWidth >= 360; const width = isFullCircle ? 360 : beamWidth; const start = azimuth - width / 2; const arc: LatLng[] = []; const count = Math.max(2, Math.round((steps * width) / 90)); for (let i = 0; i <= count; i += 1) { arc.push(computeOffset(center, radiusMeters, start + (width * i) / count)); } // The vertex is what makes it read as a beam rather than a pie slice cut from // nowhere; a full circle has no vertex to draw. return isFullCircle ? arc : [center, ...arc, center]; } /** * Ray-casting point-in-polygon test. * * Used for the "who was inside this fence" question, and kept independent of * `google.maps.geometry.poly.containsLocation` so it works on plain data — in a * worker, on the server, or in a test. */ export function pointInRing(point: LatLng, ring: LatLng[]): boolean { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { const a = ring[i]; const b = ring[j]; const straddles = a.lat > point.lat !== b.lat > point.lat; if (!straddles) continue; const crossingLng = ((b.lng - a.lng) * (point.lat - a.lat)) / (b.lat - a.lat) + a.lng; if (point.lng < crossingLng) inside = !inside; } return inside; } /** Great-circle distance in metres. */ export function distanceBetween(a: LatLng, b: LatLng): number { const dLat = toRad(b.lat - a.lat); const dLng = toRad(b.lng - a.lng); const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2; return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h))); } /** A shape the user drew on the map. */ export type MapDrawnShape = | { type: 'polygon'; path: LatLng[] } | { type: 'circle'; center: LatLng; radiusMeters: number } | { type: 'rectangle'; bounds: { north: number; south: number; east: number; west: number } }; /** True when the point falls inside the drawn shape. */ export function pointInShape(point: LatLng, shape: MapDrawnShape): boolean { switch (shape.type) { case 'polygon': return pointInRing(point, shape.path); case 'circle': return distanceBetween(point, shape.center) <= shape.radiusMeters; case 'rectangle': return ( point.lat >= shape.bounds.south && point.lat <= shape.bounds.north && point.lng >= shape.bounds.west && point.lng <= shape.bounds.east ); default: return false; } } /** * Filters a set of points down to those inside a drawn shape. * * This is the payoff of the geofence tool: "who was inside" answered against * plain coordinates, with no Maps call. */ export function pointsInShape(points: T[], shape: MapDrawnShape): T[] { return points.filter(point => pointInShape(point, shape)); }