import { t, tn } from '../i18n/index.js'; /** * Manual whiteboard drawing: the pure half. * * The voice whiteboard resolves NAMES to geometry. This module is for the other * way in: a person clicks the vertices themselves. It holds a draw session (the * shape being drawn and its vertices), decides when a shape is finishable, and * turns a finished session into the SAME annotation spec the engine already * accepts (`type: area | route | pin`, geometry supplied, `manual: true`), so a * hand-drawn mark renders, persists, de-dups and clears exactly like a spoken * one. * * A vertex is a lon/lat and nothing more. The world renderer drapes every mark * onto the photoreal surface (`clampToGround` + `CESIUM_3D_TILE`), so there is * no such thing as a vertex at a height here: the height under the click is * used for the live preview and is deliberately dropped at finish. * * No Cesium, no DOM — importable under `node --test`. The Cesium/DOM half is * `drawTool.js`. */ import { greatCircleMeters } from '../geoDistance.js'; export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']); export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); /** * Hard ceiling on vertices in one shape. Every vertex is a live preview entity * and a position in the finished geometry, so a stuck mouse button or a script * must not be able to grow one shape without limit. */ export const MAX_VERTICES = 512; /** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */ const MIN_VERTEX_SEPARATION_M = 0.5; /** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */ const MIN_PATH_LENGTH_M = 1; const MIN_AREA_M2 = 1; /** @param {string} shape @returns {'area'|'line'|'pin'} */ export function normalizeShape(shape) { const s = String(shape || '').toLowerCase(); if (s === 'line' || s === 'path' || s === 'route') return 'line'; if (s === 'pin' || s === 'point' || s === 'marker') return 'pin'; return 'area'; } /** @param {string} [shape] @returns {{shape: 'area'|'line'|'pin', vertices: Array<{lon:number, lat:number, height?:number}>}} */ export function createDrawSession(shape = 'area') { return { shape: normalizeShape(shape), vertices: [] }; } /** Great-circle distance in metres between two {lon, lat} points. */ function greatCircleM(a, b) { return greatCircleMeters(a, b); } /** * Add a vertex. A vertex that is not a finite lon/lat is refused; one within * MIN_VERTEX_SEPARATION_M of the previous vertex is treated as the same click * (the second half of a double-click) and refused as a duplicate. A pin holds * exactly one vertex: a later click moves it. * @returns {{added: boolean, reason?: 'invalid'|'duplicate'}} */ export function addVertex( session, vertex, { minSeparationM = MIN_VERTEX_SEPARATION_M } = {}, ) { if (!session || !isFiniteCoordinate(vertex)) return { added: false, reason: 'invalid' }; const v = { lon: vertex.lon, lat: vertex.lat, height: Number.isFinite(vertex.height) ? vertex.height : 0, }; if (session.shape === 'pin') { session.vertices = [v]; return { added: true }; } if (session.vertices.length >= MAX_VERTICES) return { added: false, reason: 'full' }; const last = session.vertices[session.vertices.length - 1]; if (last && greatCircleM(last, v) < minSeparationM) return { added: false, reason: 'duplicate' }; session.vertices.push(v); return { added: true }; } /** A usable click position: finite, and on the globe rather than past its edges. */ export function isFiniteCoordinate(vertex) { if (!vertex) return false; const { lon, lat } = vertex; return ( Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lon) <= 180 && Math.abs(lat) <= 90 ); } /** Remove the last vertex. @returns {boolean} whether one was removed */ export function removeLastVertex(session) { if (!session?.vertices?.length) return false; session.vertices.pop(); return true; } /** * Why a session can or cannot become an annotation. * - `too-few`: not enough vertices yet. * - `degenerate`: enough vertices, but they describe nothing — three collinear * points enclose no area, and a line that doubles back on itself has no * length. Drawing one of those and calling it a mark would put an invisible * entity on the board that the person cannot see, select or explain. * @returns {'ok'|'too-few'|'degenerate'|'invalid'} */ export function finishReason(session) { if (!session || !Array.isArray(session.vertices)) return 'invalid'; if (session.vertices.some((vertex) => !isFiniteCoordinate(vertex))) return 'invalid'; if (session.vertices.length < (MIN_VERTICES[session.shape] || 1)) return 'too-few'; if ( session.shape === 'line' && pathLengthM(session.vertices) < MIN_PATH_LENGTH_M ) return 'degenerate'; if (session.shape === 'area' && ringAreaM2(session.vertices) < MIN_AREA_M2) return 'degenerate'; return 'ok'; } /** @returns {boolean} whether the session can become an annotation right now */ export function canFinish(session) { return finishReason(session) === 'ok'; } /** Length of an open path in metres. */ export function pathLengthM(vertices) { let m = 0; for (let i = 1; i < (vertices?.length || 0); i += 1) m += greatCircleM(vertices[i - 1], vertices[i]); return m; } /** * The same vertices with longitudes made CONTINUOUS relative to the first one, * so planar maths does not tear at the antimeridian. A small shape straddling * 180° has longitudes like [179.999, -179.999]; subtracting those raw gives * 359.998° of width instead of 0.002°, which is how a 25,000 m² rectangle * measured 4,461 km² and reported its centre on the Greenwich meridian. * * Values may leave the [-180, 180] range on purpose — that is what "continuous" * means. Re-wrap with `wrapLongitude` before handing one back as a coordinate. * @param {Array<{lon:number, lat:number}>} vertices */ export function unwrapLongitudes(vertices) { if (!vertices?.length) return []; const reference = vertices[0].lon; return vertices.map((vertex) => { let lon = vertex.lon; while (lon - reference > 180) lon -= 360; while (lon - reference < -180) lon += 360; return { ...vertex, lon }; }); } /** A continuous longitude brought back into [-180, 180]. */ export function wrapLongitude(lon) { if (!Number.isFinite(lon)) return lon; // A value already in range is returned UNCHANGED rather than pushed through // the modulo, which is only exact in binary for some inputs: 0.0005 came back // as 0.0004999999999881766. The seam is the only place the arithmetic is // needed, so it is the only place that pays for it. if (lon >= -180 && lon < 180) return lon; const value = ((((lon + 180) % 360) + 360) % 360) - 180; return Object.is(value, -0) ? 0 : value; } /** Planar shoelace area of a ring in square metres (local metre grid; fine at whiteboard scale). */ export function ringAreaM2(vertices) { if (!vertices || vertices.length < 3) return 0; const ring = unwrapLongitudes(vertices); const lat0 = ring.reduce((s, v) => s + v.lat, 0) / ring.length; const kx = 111320 * Math.cos((lat0 * Math.PI) / 180); const ky = 111320; let twice = 0; for (let i = 0; i < ring.length; i += 1) { const a = ring[i]; const b = ring[(i + 1) % ring.length]; twice += a.lon * kx * (b.lat * ky) - b.lon * kx * (a.lat * ky); } return Math.abs(twice) / 2; } /** Vertex-average centroid of a ring, {lon, lat}, safe across the antimeridian. */ export function ringCentroid(vertices) { if (!vertices?.length) return null; const ring = unwrapLongitudes(vertices); return { lon: wrapLongitude(ring.reduce((s, v) => s + v.lon, 0) / ring.length), lat: ring.reduce((s, v) => s + v.lat, 0) / ring.length, }; } /** Distance or area, formatted for a label suffix. */ export function formatMeasure(session) { if (!session) return ''; if (session.shape === 'line') { const m = pathLengthM(session.vertices); return m >= 1000 ? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km` : `${Math.round(m)} m`; } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`; if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`; return `${Math.round(m2)} m²`; } return ''; } /** * The annotation spec for a finished session, in the shape `annotationEngine.annotate()` * takes. Null when the session cannot finish. Geometry is supplied outright and * `manual: true` tells the engine to skip name resolution. * * The per-vertex height from the click is dropped here, deliberately and in one * place: the world renderer drapes areas and routes onto the photoreal surface, * so a height carried this far would be discarded further downstream instead, * silently. What the person gets is the outline they drew, lying on the * surface under it. * @param {object} session * @param {{label?: string, color?: string}} [opts] */ export function finishSpec(session, { label = '', color = 'primary' } = {}) { if (!canFinish(session)) return null; const text = String(label || '').trim(); const pts = session.vertices.map((v) => [v.lon, v.lat]); if (session.shape === 'area') { // CLOSED explicitly. A polygon fill closes itself, but the outline beside it // is a polyline, and an open ring draws every edge except the one back to // the first vertex — the shape reads as a shape with one side missing. return { type: 'area', manual: true, ring: closeRing(pts), label: text || null, color, }; } if (session.shape === 'line') { return { type: 'route', manual: true, path: pts, label: text || null, color, }; } const [lon, lat] = pts[0]; return { type: 'pin', manual: true, latitude: lat, longitude: lon, label: text || null, color, }; } /** A ring whose last position repeats its first, so an outline has no gap. */ export function closeRing(pairs) { if (!Array.isArray(pairs) || pairs.length < 3) return pairs; const [firstLon, firstLat] = pairs[0]; const [lastLon, lastLat] = pairs[pairs.length - 1]; if (firstLon === lastLon && firstLat === lastLat) return pairs; return [...pairs, [firstLon, firstLat]]; } /** One line of guidance for the person drawing, by state. */ export function drawHint(session) { if (!session) return t('Pick a shape, then click the map.'); const n = session.vertices.length; if (session.shape === 'pin') return n ? t('Enter to place the pin, Esc to cancel.') : t('Click where the pin goes.'); const need = MIN_VERTICES[session.shape] - n; if (need > 0) return tn(need, 'Click {count} more point.', 'Click {count} more points.'); if (finishReason(session) === 'degenerate') { return session.shape === 'area' ? t('Those points are in a line — move one off it to enclose an area.') : t('That line has no length — click somewhere further away.'); } const full = n >= MAX_VERTICES ? t(' · {max}-point limit reached', { max: MAX_VERTICES }) : ''; return t( '{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}', { measure: formatMeasure(session), full }, ); }