/** * Measurement unit formatting — Metric / Imperial. * * The active system is persisted in localStorage('measurement-system'). * Every formatter reads the current setting so the UI updates immediately * after the user flips the toggle. * * All input values are in metres (length) or square metres (area). */ // ── Conversion constants ──────────────────────────────────────────────────── const M_TO_FT = 3.28084; const M_TO_MI = 0.000621371; const SQM_TO_SQFT = 10.7639; const SQM_TO_ACRE = 0.000247105; const SQM_TO_SQMI = 3.861e-7; // ── System accessor ───────────────────────────────────────────────────────── /** @returns {'metric'|'imperial'} */ export function getSystem() { return localStorage.getItem('measurement-system') || 'metric'; } // ── Length / distance ─────────────────────────────────────────────────────── /** * Format a length value (in metres) for display. * Metric: m / km * Imperial: ft / mi */ export function formatLength(metres) { if (getSystem() === 'imperial') { const ft = metres * M_TO_FT; if (ft >= 5280) { return (Math.round(metres * M_TO_MI * 100) / 100) + ' mi'; } return Math.round(ft) + ' ft'; } // metric if (metres > 1000) { return (Math.round(metres / 1000 * 100) / 100) + ' km'; } return (Math.round(metres * 100) / 100) + ' m'; } /** * Format a length with both large and small units (for info popups). * Metric: "1.23 km (1,230 m)" or "456 m" * Imperial: "1.23 mi (6,494 ft)" or "456 ft" */ export function formatLengthFull(metres) { if (getSystem() === 'imperial') { const ft = metres * M_TO_FT; const mi = metres * M_TO_MI; if (ft >= 5280) { return `${mi.toFixed(2)} mi (${ft.toLocaleString('en', { maximumFractionDigits: 0 })} ft)`; } return `${ft.toLocaleString('en', { maximumFractionDigits: 1 })} ft`; } if (metres >= 1000) { return `${(metres / 1000).toFixed(2)} km (${metres.toLocaleString('en', { maximumFractionDigits: 0 })} m)`; } return `${metres.toLocaleString('en', { maximumFractionDigits: 1 })} m`; } // ── Area ──────────────────────────────────────────────────────────────────── /** * Format an area value (in square metres) for display. * Metric: m² / km² * Imperial: ft² / acres / mi² */ export function formatArea(sqMetres) { if (getSystem() === 'imperial') { const acres = sqMetres * SQM_TO_ACRE; if (acres >= 640) { return (Math.round(sqMetres * SQM_TO_SQMI * 100) / 100) + ' mi²'; } if (acres >= 1) { return (Math.round(acres * 100) / 100) + ' acres'; } return Math.round(sqMetres * SQM_TO_SQFT).toLocaleString('en') + ' ft²'; } // metric if (sqMetres > 1000000) { return (Math.round(sqMetres / 1000000 * 100) / 100) + ' km²'; } return (Math.round(sqMetres * 100) / 100) + ' m²'; } /** * Format an area with both large and small units (for info popups). * Metric: "1.23 km² (1,230,000 m²)" or "456 m²" * Imperial: "1.23 mi² (787 acres)" or "2.5 acres" or "456 ft²" */ export function formatAreaFull(sqMetres) { if (getSystem() === 'imperial') { const sqft = sqMetres * SQM_TO_SQFT; const acres = sqMetres * SQM_TO_ACRE; const sqmi = sqMetres * SQM_TO_SQMI; if (acres >= 640) { return `${sqmi.toFixed(2)} mi² (${acres.toLocaleString('en', { maximumFractionDigits: 0 })} acres)`; } if (acres >= 1) { return `${acres.toLocaleString('en', { maximumFractionDigits: 1 })} acres (${sqft.toLocaleString('en', { maximumFractionDigits: 0 })} ft²)`; } return `${sqft.toLocaleString('en', { maximumFractionDigits: 0 })} ft²`; } if (sqMetres > 1000000) { return `${(sqMetres / 1000000).toFixed(2)} km² (${sqMetres.toLocaleString('en', { maximumFractionDigits: 0 })} m²)`; } return `${sqMetres.toLocaleString('en', { maximumFractionDigits: 0 })} m²`; } // ── Circle helper ─────────────────────────────────────────────────────────── /** * Format the area of a circle given its radius (in metres). */ export function formatCircleExtent(radiusMetres) { return formatArea(Math.PI * radiusMetres * radiusMetres); } // --------------------------------------------------------------------------- // Web Mercator scale correction // --------------------------------------------------------------------------- // The map works in EPSG:3857, where a "metre" is not a metre on the ground: the // projection stretches distance by 1/cos(latitude). The Circle tool reads its // radius straight from geometry.getRadius(), so the figure it reports has // always been the projected value. Nobody noticed while dragging, because the // label and the shape agreed with each other — but the moment an officer types // 200 they are asserting a ground distance, and 200 projected metres is 197 m // on the ground at Wa and 199 m at Accra. // // ground = projected x cos(latitude) (see groundFromProjected) // projected = ground / cos(latitude) (see projectedFromGround) // // The latitude of the circle's centre is used for the whole circle. Over a few // hundred metres the variation across the shape is far below the precision // anyone reads off the screen. import { toLonLat } from 'ol/proj'; /** Scale factor at a map coordinate: ground metres per projected metre. */ export function mercatorScaleAt(coordinate3857) { const lat = toLonLat(coordinate3857)[1]; return Math.cos((lat * Math.PI) / 180); } /** Projected length -> true ground length, at the given map coordinate. */ export function groundFromProjected(projectedMetres, coordinate3857) { return projectedMetres * mercatorScaleAt(coordinate3857); } /** Ground length the user typed -> the projected length to give the geometry. */ export function projectedFromGround(groundMetres, coordinate3857) { const scale = mercatorScaleAt(coordinate3857); return scale === 0 ? groundMetres : groundMetres / scale; } /** * A sensible step for nudging a radius, so one tap means something at 20 m and * at 2 km. Values snap to the step, so repeated taps land on round numbers. */ export function radiusStep(groundMetres) { if (groundMetres < 50) return 1; if (groundMetres < 200) return 5; if (groundMetres < 1000) return 10; if (groundMetres < 5000) return 50; return 100; }