pwaLUPMIS2/src/units.js
ekke 631b316118 Adjust a measured circle's radius from its tooltip, in ground metres
Officers asked to enter an exact distance rather than drag for it. The tooltip
that appears when the second click lands now carries the adjustment: minus, the
figure itself, plus. Tapping the figure swaps in a numeric input, so an exact
radius can be typed; inputmode="decimal" brings up a keypad rather than a full
keyboard on a phone.

Not a dial. A radius runs from a few metres to several kilometres and no dial
gives usable resolution across that range on a tooltip this size. Stepping snaps
to an increment that grows with the value — 1 m below 50, 5 m below 200, 10 m
below 1 km, 50 m below 5 km — so one tap means something at either end, and
repeated taps land on round numbers instead of carrying the fraction the drag
happened to finish on.

Everything writes through MapTools.setCircleRadius(). Measurements were made
read-only earlier precisely because a draggable handle moved the geometry while
the popup went on reporting the radius captured at draw time; a single path
means the geometry, the radius line, the tooltip and the analysis popup cannot
drift apart again.

The figures are now ground distances. The geometry stores a Web Mercator radius,
which overstates the ground by 1/cos(latitude) — a 200 m circle at Wa was 197 m
on the ground. That was invisible while dragging, because the label and the
shape agreed with each other, but typing 200 is a claim about the ground. The
conversion lives in units.js and is applied in the live tooltip, the adjuster
and the Circle Analysis popup, so all three state the same distance.

Two deliberate choices worth knowing before the usability check:

  - The tooltip is frozen where the draw finished. It is anchored to the
    circumference, so re-positioning on every change would slide the buttons out
    from under the officer's thumb. A large adjustment therefore leaves it
    sitting inside the circle rather than on its edge.
  - No measure-complete event fires on adjustment. Consumers read that as "a new
    measurement was drawn", and stepping would emit a stream of false ones.

The overlay is created with stopEvent:false so the map keeps working through the
label; the buttons stop their own events, or a tap would also pan the map.

Service Worker version deliberately unchanged, so reverting does not leave
clients on a newer cache than main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 17:06:14 +02:00

173 lines
6.7 KiB
JavaScript

/**
* 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;
}