const FEET_PER_METER = 3.280839895; const METERS_PER_FOOT = 1 / FEET_PER_METER; const EARTH_METERS_PER_DEGREE_LAT = 111320; function toRadians(value) { return (value * Math.PI) / 180; } function rotateLocalToTrueNorthEast(northMeters, eastMeters, assumedNorthClockwiseDegrees = 0) { const clockwiseRadians = toRadians(assumedNorthClockwiseDegrees); const trueNorthMeters = northMeters * Math.cos(clockwiseRadians) - eastMeters * Math.sin(clockwiseRadians); const trueEastMeters = northMeters * Math.sin(clockwiseRadians) + eastMeters * Math.cos(clockwiseRadians); return { northMeters: trueNorthMeters, eastMeters: trueEastMeters, }; } export function point(lat, lon) { return { lat, lon }; } export function lngLat(lon, lat) { return point(lat, lon); } export function anchor(lon, lat, options = {}) { return { ...lngLat(lon, lat), ...options, }; } export function pointsFromLngLat(coordinates) { return coordinates.map(([lon, lat]) => lngLat(lon, lat)); } export function ft(value) { return value * METERS_PER_FOOT; } function isPoint(value) { return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number"; } function isMove(value) { return ( Boolean(value) && typeof value.northMeters === "number" && typeof value.eastMeters === "number" ); } function isOffsetAnchor(value) { return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin); } function translate(origin, northMeters, eastMeters) { const latDelta = northMeters / EARTH_METERS_PER_DEGREE_LAT; const lonDelta = eastMeters / (EARTH_METERS_PER_DEGREE_LAT * Math.cos(toRadians(origin.lat))); return point(origin.lat + latDelta, origin.lon + lonDelta); } export function offset(origin, ...moves) { if (moves.length === 0) { return { kind: "offset-anchor", origin }; } const net = moves.reduce( (accumulator, move) => ({ northMeters: accumulator.northMeters + move.northMeters, eastMeters: accumulator.eastMeters + move.eastMeters, }), { northMeters: 0, eastMeters: 0 }, ); const adjusted = rotateLocalToTrueNorthEast( net.northMeters, net.eastMeters, origin.assumedNorthClockwiseDegrees ?? 0, ); return translate(origin, adjusted.northMeters, adjusted.eastMeters); } export function north(distanceMeters) { return { northMeters: distanceMeters, eastMeters: 0 }; } export function south(distanceMeters) { return { northMeters: -distanceMeters, eastMeters: 0 }; } export function east(distanceMeters) { return { northMeters: 0, eastMeters: distanceMeters }; } export function west(distanceMeters) { return { northMeters: 0, eastMeters: -distanceMeters }; } function buildFeature(type, geometryType, style = {}) { const coordinates = []; return { add(...values) { coordinates.push(resolveCoordinate(values)); return this; }, toGeoJSON() { if (geometryType === "Point") { const [first] = coordinates; return { type: "Feature", properties: { type, style }, geometry: { type: "Point", coordinates: [first.lon, first.lat], }, }; } const ring = coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]); const geometry = geometryType === "LineString" ? { type: "LineString", coordinates: ring } : { type: "Polygon", coordinates: [closeRing(ring)] }; return { type: "Feature", properties: { type, style }, geometry, }; }, }; } function resolveCoordinate(values) { if (values.length === 1 && isPoint(values[0])) { return values[0]; } const [first, ...rest] = values; if (isOffsetAnchor(first) && rest.every(isMove)) { return offset(first.origin, ...rest); } throw new Error("add(...) expects a point, or offset(origin) followed by directional moves"); } function closeRing(coordinates) { if (coordinates.length === 0) { return coordinates; } const [firstLon, firstLat] = coordinates[0]; const [lastLon, lastLat] = coordinates[coordinates.length - 1]; if (firstLon === lastLon && firstLat === lastLat) { return coordinates; } return [...coordinates, coordinates[0]]; } export function polygon(type, style) { return buildFeature(type, "Polygon", style); } export function path(type, style) { return buildFeature(type, "LineString", style); } export function marker(type, style) { return buildFeature(type, "Point", style); } export function sprinkler(name) { return marker(name, { color: "#2d7dd2", radius: 5, fillColor: "#88bdf2", fillOpacity: 0.95, }); } export function tree(name) { return marker(name, { color: "#2f6b3d", radius: 9, fillColor: "#5c9e64", fillOpacity: 0.85, }); } export function collectGeoJSON(...collections) { return { type: "FeatureCollection", features: collections.flat().map((entry) => entry.toGeoJSON()), }; }