diff --git a/app/src-tauri/src/data.rs b/app/src-tauri/src/data.rs index 7a03cbd..17f4929 100644 --- a/app/src-tauri/src/data.rs +++ b/app/src-tauri/src/data.rs @@ -304,23 +304,62 @@ mod tests { ); let (db, _) = Database::open(&dir).unwrap(); db.with_tx(|tx| { - // A 5 by 5 grid across Lucknow (the middle one is at the city centre), plus two - // borewells whose location was never recorded. - for i in 0..27 { - let located = i < 25; - repo::create_with_layers( + // A 5 by 5 grid about 1 km apart (the middle one is at the city centre), two borewells + // whose location was never recorded, and one more at exactly the same spot as the + // middle one. Number 8 is logged upside down, so there is one that does not fit its + // surroundings. + for i in 0..28 { + let located = i < 25 || i == 27; + let at = if i == 27 { 12 } else { i }; + let clay_to = 40.0 + 5.0 * (i % 5) as f64; + let mut soils = vec![ + ("Clay", "clay", 0.0, clay_to), + ("Sand", "medium_sand", clay_to, 120.0), + ("Fine Sand", "fine_sand", 120.0, 160.0), + ("Kankar", "kankar", 160.0, 200.0), + ]; + if i == 7 { + soils = vec![ + ("Kankar", "kankar", 0.0, 40.0), + ("Fine Sand", "fine_sand", 40.0, 80.0), + ("Sand", "medium_sand", 80.0, 200.0 - clay_to), + ("Clay", "clay", 200.0 - clay_to, 200.0), + ]; + } + let strata: Vec = soils + .into_iter() + .map(|(material, id, start_depth, end_depth)| StrataLayer { + start_depth, + end_depth, + material: material.into(), + material_id: Some(id.into()), + ..Default::default() + }) + .collect(); + let b = repo::create_with_layers( tx, &BorewellInput { borewell_id: format!("BW-{:03}", i + 1), area: "Made-up area".into(), - latitude: located.then_some(26.75 + 0.05 * (i / 5) as f64), - longitude: located.then_some(80.85 + 0.05 * (i % 5) as f64), - total_depth: Some(200.0 + 10.0 * i as f64), + latitude: located.then_some(26.83 + 0.01 * (at / 5) as f64), + longitude: located.then_some(80.93 + 0.01 * (at % 5) as f64), + total_depth: Some(200.0), ..Default::default() }, - &[], + if located { &strata } else { &[] }, &[], )?; + if located { + repo::layers::add_water_reading( + tx, + &b.id, + &WaterReading { + measured_on: format!("2026-0{}-15", 1 + i % 9), + static_level: Some(50.0 + 2.0 * (i % 7) as f64), + ..Default::default() + }, + )?; + } } Ok(()) }) diff --git a/app/src/App.tsx b/app/src/App.tsx index d9342ff..0d2b922 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,20 +1,27 @@ import { useCallback, useEffect, useState } from "react"; import { RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { SiteMap, located } from "@/components/SiteMap"; import { loadData, type LoadError, type Records } from "@/lib/api"; +import { useModel } from "@/lib/useModel"; import { text } from "@/text"; +import { MapView } from "@/views/MapView"; +import { QualityView } from "@/views/QualityView"; -type State = { records: Records } | { problem: LoadError } | null; +type State = { records: Records; as: string } | { problem: LoadError } | null; +type View = "map" | "quality"; export default function App() { const [state, setState] = useState(null); - const [selectedId, setSelectedId] = useState(null); + const [view, setView] = useState("map"); // Reading again keeps what is on screen until the new records arrive, so the map does not blink. + // Records that turn out unchanged are kept as they are, so nothing is worked out again for them. const load = useCallback(() => { loadData().then( - (records) => setState({ records }), + (records) => { + const as = JSON.stringify(records); + setState((before) => (before && "records" in before && before.as === as ? before : { records, as })); + }, (e) => setState({ problem: isLoadError(e) ? e : { kind: "other", message: String(e) } }), ); }, []); @@ -26,51 +33,34 @@ export default function App() { return () => window.removeEventListener("focus", load); }, [load]); + const records = state && "records" in state && state.records.sites.length > 0 ? state.records : null; + const model = useModel(records); + return (
-
+

{text.appName}

- + {records && ( + + )} +
- -
- ); -} - -function Body({ state, selectedId, onSelect }: { state: State; selectedId: string | null; onSelect: (id: string | null) => void }) { - if (!state) return {text.loading}; - if ("problem" in state) { - const p = state.problem; - return {text.problems[p.kind]}; - } - const { sites } = state.records; - if (sites.length === 0) return {text.empty}; - - const shown = located(sites).length; - // A borewell deleted in StrataField since it was selected is simply no longer selected. - const selected = sites.find((s) => s.borewell.id === selectedId)?.borewell; - return ( -
-
-
); } diff --git a/app/src/components/ColumnDrawing.tsx b/app/src/components/ColumnDrawing.tsx new file mode 100644 index 0000000..4b0a0fb --- /dev/null +++ b/app/src/components/ColumnDrawing.tsx @@ -0,0 +1,136 @@ +import type { KeyboardEvent, ReactNode } from "react"; +import type { StrataLayer } from "@strata/core"; +import type { Step } from "@/model/model"; +import { GROUP_COLOUR, GROUP_NAME, blocks, type Block } from "@/model/words"; +import { text } from "@/text"; + +// One drawing size for both kinds of column, so a recorded and a proposed one compare by eye. +const W = 300, TOP = 10, H = 420, COL_X = 44, COL_W = 90, LABEL_X = COL_X + COL_W + 8; +const WATER = "#2f7fb8"; + +/** A water level to draw: expected (worked out, so dashed) or measured (solid, with its year if known). */ +export type Water = { feet: number; expected: true } | { feet: number; expected: false; year: string | null }; + +/** What both columns share: the depth ruler, the water line, and room for the layers. */ +function Frame({ depth, marks, water: given, label, clickable = false, children }: { + depth: number; marks: number[]; water?: Water | null; label: string; clickable?: boolean; + children: (y: (feet: number) => number, labelY: (top: number, bottom: number) => number | null) => ReactNode; +}) { + const y = (feet: number) => TOP + (feet / depth) * H; + const ticks = [...new Set(marks)].filter((d) => d >= 0 && d <= depth).sort((a, b) => a - b); + // Marks closer than a line of text keep their tick but only one of them is numbered. + const numbered = ticks.filter((d, i) => i === 0 || i === ticks.length - 1 || y(d) - y(ticks[i - 1]) >= 11) + .filter((d, i, kept) => i === kept.length - 1 || y(kept[i + 1]) - y(d) >= 11); + const water = given && given.feet >= 0 && given.feet <= depth ? given : null; + // A layer's name sits in the middle of the layer, moved clear of the water label, or left out + // when the layer is too thin to hold it. + const labelY = (top: number, bottom: number) => { + const height = y(bottom) - y(top); + if (height < 14) return null; + const centre = y(top) + height / 2 + 4; + if (!water || Math.abs(centre - (y(water.feet) - 4)) >= 13) return centre; + const below = y(water.feet) + 13; + return below <= y(bottom) - 3 ? below : null; + }; + return ( + // A group, not an image, when its layers can be clicked: inside an image they would be hidden from screen readers. + + {ticks.map((d) => )} + {numbered.map((d) => {Math.round(d)})} + {children(y, labelY)} + {water && ( + <> + + + {water.expected ? text.waterExpectedShort(Math.round(water.feet)) : text.waterMeasured(Math.round(water.feet), water.year)} + + + )} + + ); +} + +const onEnterOrSpace = (action: () => void) => (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + action(); + } +}; + +/** The hatch for "nothing is proposed here". Drawn once per page; see `NothingProposedPattern`. */ +export const NOTHING_PROPOSED = "url(#nothing-proposed)"; +export function NothingProposedPattern() { + return ( + + ); +} + +/** + * A proposed column. It must never look measured: faded, with dashed edges; paler still where it + * is only a rough guess; and left empty, hatched, where there is not enough data to propose anything. + * Its water level, being worked out too, is a dashed line. + */ +export function ProposedColumn({ column, waterFt, selected, onSelect }: { + column: readonly Step[]; waterFt?: number | null; selected: Block | null; onSelect: (block: Block) => void; +}) { + const all = blocks(column); + const depth = all[all.length - 1]?.bottom ?? 1; + return ( + b.bottom)]} water={waterFt != null ? { feet: waterFt, expected: true } : null} label={text.proposedHere} clickable> + {(y, labelY) => all.map((b) => { + const name = b.group ? GROUP_NAME[b.group] : text.levels.none; + const at = labelY(b.top, b.bottom); + const isSelected = selected?.top === b.top && selected.bottom === b.bottom; + const box = { x: COL_X, y: y(b.top), width: COL_W, height: y(b.bottom) - y(b.top) }; + return ( + onSelect(b)} onKeyDown={onEnterOrSpace(() => onSelect(b))} + > + {b.group ? ( + + ) : ( + + )} + {/* Selected: a solid frame. Reached by keyboard or mouse but not selected: a dashed one. */} + {isSelected + ? + : } + {at != null && ( + + {name}{b.level === "rough" && · {text.levels.rough.toLowerCase()}} + + )} + + ); + })} + + ); +} + +/** A recorded column: the borewell's own layers in their own colours, solid. Depths are feet. */ +export function RecordedColumn({ strata, water }: { strata: StrataLayer[]; water?: { feet: number; year: string | null } | null }) { + const layers = [...strata].sort((a, b) => a.startDepth - b.startDepth).filter((l) => l.endDepth > l.startDepth); + const depth = Math.max(1, ...layers.map((l) => l.endDepth)); + return ( + [l.startDepth, l.endDepth])]} water={water ? { ...water, expected: false } : null} label={text.recordedLayers}> + {(y, labelY) => layers.map((l) => { + const at = labelY(l.startDepth, l.endDepth); + return ( + + + {at != null && {l.material}} + + ); + })} + + ); +} diff --git a/app/src/components/SiteMap.tsx b/app/src/components/SiteMap.tsx index 3243dfe..a5b4bcb 100644 --- a/app/src/components/SiteMap.tsx +++ b/app/src/components/SiteMap.tsx @@ -12,25 +12,29 @@ export const located = (sites: SiteRecord[]) => sites.filter((s) => s.borewell.l /** * The borewells on OpenStreetMap's online tiles. Without internet the tiles are a plain background - * and the pins still show. + * and the pins still show. Clicking a pin selects that borewell; clicking anywhere else picks the spot. */ -export function SiteMap({ sites, selectedId, onSelect }: { +export function SiteMap({ sites, selectedId, picked, onSelect, onPick }: { sites: SiteRecord[]; selectedId: string | null; - onSelect: (id: string | null) => void; + picked: [number, number] | null; + onSelect: (id: string) => void; + onPick: (latitude: number, longitude: number) => void; }) { const pins = useMemo(() => located(sites), [sites]); return ( - + - onSelect(null)} /> + + {/* ponytail: no grouping, so borewells at exactly the same spot sit on top of each other and - only the top one can be clicked. Group them (as StrataField does) if that gets in the way. */} + only the top one can be clicked; the side panel then offers the others. Group the pins + (as StrataField does) if that is not enough. */} {pins.map(({ borewell: b }) => { const selected = b.id === selectedId; return ( @@ -46,6 +50,8 @@ export function SiteMap({ sites, selectedId, onSelect }: { ); })} + {/* The picked spot: an open, dashed ring, so it cannot be taken for a borewell. */} + {picked && } ); } @@ -63,7 +69,21 @@ function FitOnce({ pins }: { pins: SiteRecord[] }) { return null; } -function ClearOnMapClick({ onClear }: { onClear: () => void }) { - useMapEvents({ click: onClear }); +/** + * Leaflet remembers how big the map was. While Data quality is showing the map is hidden (no size + * at all), so it is measured again whenever its box changes, or it would come back blank or shifted. + */ +function KeepSize() { + const map = useMap(); + useEffect(() => { + const watcher = new ResizeObserver(() => map.invalidateSize()); + watcher.observe(map.getContainer()); + return () => watcher.disconnect(); + }, [map]); + return null; +} + +function PickOnMapClick({ onPick }: { onPick: (latitude: number, longitude: number) => void }) { + useMapEvents({ click: (e) => onPick(e.latlng.lat, e.latlng.lng) }); return null; } diff --git a/app/src/lib/ground.ts b/app/src/lib/ground.ts new file mode 100644 index 0000000..8959589 --- /dev/null +++ b/app/src/lib/ground.ts @@ -0,0 +1,16 @@ +import { parseElevationGrid, type ElevationGrid } from "@strata/core"; +// StrataField's own ground-height file for Lucknow, bundled from the submodule so both apps agree. +import url from "../../../strata/app/src/assets/lucknow-elevation.bin?url"; + +let ground: Promise | null = null; + +/** Ground heights above sea level around Lucknow. The file is read once. */ +export function loadGround(): Promise { + ground ??= fetch(url).then((r) => { + if (!r.ok) throw new Error(`ground heights: ${r.status}`); + return r.arrayBuffer(); + }).then(parseElevationGrid); + // A failed read is not kept, so Refresh can try again. + ground.catch(() => (ground = null)); + return ground; +} diff --git a/app/src/lib/useModel.ts b/app/src/lib/useModel.ts new file mode 100644 index 0000000..d067f53 --- /dev/null +++ b/app/src/lib/useModel.ts @@ -0,0 +1,46 @@ +import { useEffect, useMemo, useState } from "react"; +import type { ElevationGrid } from "@strata/core"; +import { checkModel, type CheckResult } from "@/model/check"; +import { prepare, type Prepared } from "@/model/model"; +import type { Records } from "./api"; +import { loadGround } from "./ground"; + +export interface Model { + grid: ElevationGrid; + /** The borewells made ready to vote. A new array whenever the records change. */ + prepared: Prepared[]; + /** How good the estimates are; null until it has been worked out for these records. */ + check: CheckResult | null; +} + +/** + * The model for the current records: null until the ground heights have loaded, "failed" if they + * could not be read. Prepared again whenever `records` changes. + */ +export function useModel(records: Records | null): Model | "failed" | null { + const [grid, setGrid] = useState(null); + useEffect(() => { + loadGround().then(setGrid, () => setGrid("failed")); + }, []); + + const prepared = useMemo( + () => (records && grid && grid !== "failed" ? prepare(records.sites, records.materials, grid) : null), + [records, grid], + ); + + const [checked, setChecked] = useState<{ of: Prepared[]; result: CheckResult } | null>(null); + useEffect(() => { + if (!prepared || !grid || grid === "failed") return; + // ponytail: on the main thread; well under a second at 500 borewells. Move to a Web Worker if records reach a few thousand. + // After a pause, so the screen draws first; a result for records that have since changed is never set. + const later = setTimeout(() => setChecked({ of: prepared, result: checkModel(prepared, grid) }), 0); + return () => clearTimeout(later); + }, [prepared, grid]); + + // The same object until something in it changes, so screens can tell when to work things out again. + return useMemo(() => { + if (grid === "failed") return "failed"; + if (!grid || !prepared) return null; + return { grid, prepared, check: checked?.of === prepared ? checked.result : null }; + }, [grid, prepared, checked]); +} diff --git a/app/src/model/check.test.ts b/app/src/model/check.test.ts index 313f8c0..31a605b 100644 --- a/app/src/model/check.test.ts +++ b/app/src/model/check.test.ts @@ -18,7 +18,7 @@ describe("checkModel", () => { const r = check(town(4, 0.5)); expect(r.canJudge).toBe(true); expect(r.compared).toBe(16); - expect(r.overall).toEqual({ steps: 320, right: 320 }); + expect(r.overall).toEqual({ steps: 320, right: 320, borewells: 16 }); expect(r.likely).toEqual(r.overall); expect(r.byDistance.under1).toEqual(r.overall); expect(r.oddOnesOut).toEqual([]); @@ -54,7 +54,7 @@ describe("checkModel", () => { const far = east(1.5 + 4); const r = check([...town(4, 0.5), site("edge-deep", north(0), far, [[0, 30, "clay"], [30, 100, "kankar"]]), site("edge-shallow", north(0.03), far, [[0, 30, "clay"]])]); expect(r.oddOnesOut).toEqual([]); - expect(r.byDistance.under1).toEqual({ steps: 320, right: 320 }); + expect(r.byDistance.under1).toEqual({ steps: 320, right: 320, borewells: 16 }); expect(r.byDistance.over3.steps).toBeGreaterThan(0); expect(r.byDistance.over3.right).toBeLessThan(r.byDistance.over3.steps); }); @@ -73,7 +73,7 @@ describe("checkModel", () => { const r = check(Array.from({ length: 12 }, (_, i) => site(`far${i}`, north((Math.floor(i / 4) - 1) * 6), east(((i % 4) - 1.5) * 6), CLAY_OVER_SAND))); expect(r.compared).toBe(0); expect(r.canJudge).toBe(false); - expect(r.overall).toEqual({ steps: 0, right: 0 }); + expect(r.overall).toEqual({ steps: 0, right: 0, borewells: 0 }); expect(share(r.overall)).toBeNull(); expect(r.oddOnesOut).toEqual([]); }); @@ -82,12 +82,12 @@ describe("checkModel", () => { // Three pairs, far from each other; the borewells of a pair are 0.5, 2 and 4 km apart. const pair = (name: string, y: number, gap: number) => [site(`${name}a`, north(y), east(-10), CLAY_OVER_SAND), site(`${name}b`, north(y), east(-10 + gap), CLAY_OVER_SAND)]; const r = check([...pair("near", -12, 0.5), ...pair("mid", 0, 2), ...pair("far", 12, 4)]); - const both = { steps: 40, right: 40 }; + const both = { steps: 40, right: 40, borewells: 2 }; expect(r.byDistance).toEqual({ under1: both, from1to3: both, over3: both }); // One borewell is one site, which the model never shows as an estimate; the check still scores it, // but keeps it out of the headline. - expect(r.none).toEqual({ steps: 120, right: 120 }); - expect(r.overall).toEqual({ steps: 0, right: 0 }); + expect(r.none).toEqual({ steps: 120, right: 120, borewells: 6 }); + expect(r.overall).toEqual({ steps: 0, right: 0, borewells: 0 }); }); it("compares by height, using the hidden borewell's own ground", () => { diff --git a/app/src/model/check.ts b/app/src/model/check.ts index d56882f..40a180e 100644 --- a/app/src/model/check.ts +++ b/app/src/model/check.ts @@ -13,10 +13,12 @@ import { METRES_TO_FEET, kmBetween, type ElevationGrid } from "@strata/core"; import { MIN_BOREWELLS_TO_JUDGE, ODD, SAME_SITE_KM } from "./constants"; import { groupAt, largest, predictColumn, type Prepared } from "./model"; -/** How many steps were compared, and in how many the best guess was right. */ +/** How many steps were compared, in how many the best guess was right, and from how many borewells. */ export interface Tally { steps: number; right: number; + /** Steps down one borewell are not independent, so a share is only as good as this number. */ + borewells: number; } export interface OddOne { @@ -52,7 +54,7 @@ export interface CheckResult { /** The share right, or null when nothing was compared. */ export const share = (t: Tally) => (t.steps === 0 ? null : t.right / t.steps); -const tally = (): Tally => ({ steps: 0, right: 0 }); +const tally = (): Tally => ({ steps: 0, right: 0, borewells: 0 }); const add = (t: Tally, right: number) => { t.steps++; t.right += right; @@ -81,7 +83,7 @@ export function checkModel(prepared: Prepared[], grid: ElevationGrid): CheckResu if (!column) continue; const own: Record = { under1: tally(), from1to3: tally(), over3: tally() }; - let any = false; + const touched = new Set(); for (const step of column) { const logged = groupAt(p, p.groundFt - (step.top + step.bottom) / 2); if (!step.shares || !logged) continue; // nobody else reaches this step, or it logged nothing here @@ -90,13 +92,14 @@ export function checkModel(prepared: Prepared[], grid: ElevationGrid): CheckResu // so the distance is taken step by step, not once for the borewell. const km = step.sources[0].km; const band: Band = km < 1 ? "under1" : km <= 3 ? "from1to3" : "over3"; - add(result[step.confidence], right); - if (step.confidence !== "none") add(result.overall, right); - add(result.byDistance[band], right); - add(own[band], right); - any = true; + const into = [result[step.confidence], result.byDistance[band], own[band], ...(step.confidence === "none" ? [] : [result.overall])]; + for (const t of into) { + add(t, right); + touched.add(t); + } } - if (any) each.push({ p, own }); + for (const t of touched) t.borewells++; + if (touched.size > 0) each.push({ p, own }); } result.compared = each.length; @@ -114,7 +117,7 @@ export function checkModel(prepared: Prepared[], grid: ElevationGrid): CheckResu const mine = share(own[band])!; const before = worst.get(p.id); if (mine < ODD.fraction * typical && (!before || mine < before.right / before.steps)) { - worst.set(p.id, { id: p.id, borewellId: p.borewellId, ...own[band], typical }); + worst.set(p.id, { id: p.id, borewellId: p.borewellId, steps: own[band].steps, right: own[band].right, typical }); } } } diff --git a/app/src/model/constants.ts b/app/src/model/constants.ts index 86ccdbb..7050d04 100644 --- a/app/src/model/constants.ts +++ b/app/src/model/constants.ts @@ -38,6 +38,13 @@ export const ROUGH = { nearestKm: 3, sites: 2 }; /** With fewer borewells than this to compare, the accuracy check says it cannot judge. */ export const MIN_BOREWELLS_TO_JUDGE = 10; +/** + * A share right is only put into words when it rests on at least this many compared steps, from at + * least this many borewells (steps down one borewell are not independent). + */ +export const MIN_STEPS_TO_SAY = 50; +export const MIN_BOREWELLS_TO_SAY = 5; + /** * A borewell is an odd one out when it matches its surroundings less than this fraction as often as * is typical at that distance from the nearest other site, judged on at least this many steps and diff --git a/app/src/model/quality.test.ts b/app/src/model/quality.test.ts new file mode 100644 index 0000000..945e5f2 --- /dev/null +++ b/app/src/model/quality.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { CheckResult } from "./check"; +import { matchedInWords, qualitySentences } from "./quality"; + +const t = (right: number, steps: number, borewells = 20) => ({ right, steps, borewells }); +const result = (over: Partial): CheckResult => ({ + canJudge: true, compared: 40, overall: t(150, 200), likely: t(90, 100), rough: t(60, 100), none: t(3, 10), + byDistance: { under1: t(95, 100), from1to3: t(0, 0), over3: t(25, 100) }, oddOnesOut: [], ...over, +}); + +describe("qualitySentences", () => { + it("says how often each kind of estimate was right, leaving out what was never tested", () => { + expect(qualitySentences(result({}))).toEqual([ + "Checked against 40 borewells: each was hidden in turn and worked out from the others.", + "Where it says Likely, it was right about 9 times in 10.", + "Where it says Rough guess, it was right about 2 times in 3.", + "With another borewell within 1 km, the best guess was right almost always.", + "With the nearest borewell more than 3 km away, about 1 time in 4.", + ]); + }); + + it("includes the middle distance when it was tested", () => { + const sentences = qualitySentences(result({ byDistance: { under1: t(0, 0), from1to3: t(50, 100), over3: t(0, 0) } })); + expect(sentences).toContain("With the nearest borewell 1 to 3 km away, about half the time."); + expect(sentences).toHaveLength(4); + }); + + it("leaves out a line that rests on too few steps to mean anything", () => { + const sentences = qualitySentences(result({ rough: t(49, 49), byDistance: { under1: t(95, 100), from1to3: t(3, 4), over3: t(50, 50) } })); + expect(sentences).toHaveLength(4); // checked, likely, under 1 km, over 3 km + expect(sentences.join(" ")).not.toContain("Rough guess"); + expect(sentences.join(" ")).not.toContain("1 to 3 km"); + }); + + it("leaves out a line that rests on one deep borewell, however many steps that is", () => { + const sentences = qualitySentences(result({ byDistance: { under1: t(95, 100), from1to3: t(0, 0), over3: t(60, 60, 1) } })); + expect(sentences.join(" ")).not.toContain("more than 3 km"); + }); + + it("says what the odd ones matched in feet", () => { + expect(matchedInWords({ right: 3, steps: 24 })).toBe("15 of 120 ft"); + }); + + it("says nothing when there are too few borewells to judge", () => { + expect(qualitySentences(result({ canJudge: false }))).toEqual([]); + }); +}); diff --git a/app/src/model/quality.ts b/app/src/model/quality.ts new file mode 100644 index 0000000..5fb9111 --- /dev/null +++ b/app/src/model/quality.ts @@ -0,0 +1,25 @@ +import { text } from "@/text"; +import { MIN_BOREWELLS_TO_SAY, MIN_STEPS_TO_SAY, STEP_FT } from "./constants"; +import { share, type CheckResult, type Tally } from "./check"; +import { howOften } from "./words"; + +/** + * What the Data quality screen says about the accuracy check, in order. A line is left out when + * too little of its kind was tested to mean anything; nothing is said when there are too few + * borewells to judge. + */ +export function qualitySentences(check: CheckResult): string[] { + if (!check.canJudge) return []; + const t = text.quality; + const lines: [Tally, (often: string) => string][] = [ + [check.likely, t.likely], [check.rough, t.rough], + [check.byDistance.under1, t.under1], [check.byDistance.from1to3, t.from1to3], [check.byDistance.over3, t.over3], + ]; + return [t.checked(check.compared), ...lines.flatMap(([tally, say]) => { + const right = share(tally); + return right == null || tally.steps < MIN_STEPS_TO_SAY || tally.borewells < MIN_BOREWELLS_TO_SAY ? [] : [say(howOften(right))]; + })]; +} + +/** How much of an odd borewell matched its surroundings, in feet: "15 of 120 ft". */ +export const matchedInWords = (odd: { right: number; steps: number }) => text.quality.matchedFeet(odd.right * STEP_FT, odd.steps * STEP_FT); diff --git a/app/src/model/words.test.ts b/app/src/model/words.test.ts new file mode 100644 index 0000000..28d6b9b --- /dev/null +++ b/app/src/model/words.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { Borewell } from "@strata/core"; +import type { Group } from "./groups"; +import type { Step } from "./model"; +import { CENTRE, east, north } from "./testGround"; +import { blocks, distanceInWords, howOften, sharesInWords, summary, waterHere } from "./words"; + +const shares = (clay: number, fineSand: number, waterBearing: number, hard: number) => ({ clay, fineSand, waterBearing, hard }); + +describe("sharesInWords", () => { + it.each([ + [shares(1, 0, 0, 0), "Clay"], + [shares(0.92, 0.08, 0, 0), "Clay"], + [shares(0.25, 0.05, 0.7, 0), "Mostly water-bearing sand, some clay"], + [shares(0.15, 0.15, 0.7, 0), "Mostly water-bearing sand, some clay, some fine sand"], + [shares(0.5, 0, 0.5, 0), "Clay or water-bearing sand"], + [shares(0.3, 0.1, 0.2, 0.4), "Hard ground or clay"], + ])("%o → %s", (s, words) => expect(sharesInWords(s)).toBe(words)); +}); + +describe("distanceInWords", () => { + it.each([[0.337, "340 m"], [0.004, "0 m"], [0.994, "990 m"], [0.999, "1.0 km"], [1.24, "1.2 km"], [12.06, "12.1 km"]])( + "%f km → %s", (km, words) => expect(distanceInWords(km)).toBe(words)); +}); + +describe("howOften", () => { + it.each([ + [1, "almost always"], [0.97, "almost always"], [0.88, "about 9 times in 10"], [0.8, "about 4 times in 5"], + [0.74, "about 3 times in 4"], [0.66, "about 2 times in 3"], [0.5, "about half the time"], + [0.34, "about 1 time in 3"], [0.28, "about 1 time in 4"], [0.1, "rarely"], [0, "rarely"], + ])("%f → %s", (share, words) => expect(howOften(share)).toBe(words)); +}); + +describe("blocks", () => { + const step = (top: number, likely: Group | null, confidence: Step["confidence"]): Step => + ({ top, bottom: top + 5, shares: likely ? { ...shares(0, 0, 0, 0), [likely]: 1 } : null, likely, confidence, sources: [] }); + + it("joins neighbouring steps of the same group and level", () => { + const got = blocks([step(0, "clay", "likely"), step(5, "clay", "likely"), step(10, "clay", "rough"), step(15, null, "none")]); + expect(got.map(({ top, bottom, group, level, steps }) => [top, bottom, group, level, steps.length])).toEqual([ + [0, 10, "clay", "likely", 2], [10, 15, "clay", "rough", 1], [15, 20, null, "none", 1], + ]); + }); + + it("a step the model would not show has no group, even if somebody voted", () => { + const voted: Step = { top: 0, bottom: 5, shares: shares(1, 0, 0, 0), likely: null, confidence: "none", sources: [] }; + expect(blocks([voted, step(5, null, "none")])).toMatchObject([{ top: 0, bottom: 10, group: null, level: "none" }]); + }); + + it("an empty column has no blocks", () => expect(blocks([])).toEqual([])); +}); + +describe("waterHere", () => { + const well = (km: number, waterLevel: number | null, waterLevelOn: string | null) => + ({ latitude: north(0), longitude: east(km), waterLevel, waterLevelOn }) as Borewell; + const here = (borewells: Borewell[]) => waterHere(CENTRE[0], CENTRE[1], borewells); + + it("estimates from a recent reading nearby, and says since when and how near", () => { + const got = here([well(0.5, 60, "2026-03-01")])!; + expect(got.feet).toBe(60); + expect(got.nearestKm).toBeCloseTo(0.5, 2); + expect(got.since).toBe("2023"); + }); + + it("leans towards the nearer reading", () => { + const got = here([well(0.3, 60, "2026-03-01"), well(-0.9, 100, "2026-01-01")])!; + expect(got.feet).toBeGreaterThan(60); + expect(got.feet).toBeLessThan(65); + }); + + it("says nothing when the nearest recent reading is more than 1 km away", () => { + expect(here([well(1.2, 60, "2026-03-01")])).toBeNull(); + }); + + it("says nothing with no water levels at all", () => { + expect(here([well(0.5, null, null)])).toBeNull(); + expect(here([])).toBeNull(); + }); + + it("does not mix old readings with recent ones", () => { + const got = here([well(0.2, 20, "2015-06-01"), well(0.5, 60, "2026-03-01")])!; + expect(got.feet).toBe(60); + }); + + it("does not use a reading with no date: nobody can say it is recent", () => { + const got = here([well(0.2, 20, null), well(0.5, 60, "2026-03-01")])!; + expect(got.feet).toBe(60); + expect(here([well(0.2, 20, null)])).toBeNull(); + }); + + it("ignores readings beyond 1 km even when a nearer one exists", () => { + const got = here([well(0.5, 60, "2026-03-01"), well(1.5, 500, "2026-03-01")])!; + expect(got.feet).toBe(60); + }); +}); + +describe("summary of a stretch", () => { + const source = (id: string, km: number) => ({ id, borewellId: id.toUpperCase(), km, weight: 1 }); + const step = (top: number, clay: number, sources: ReturnType[]): Step => + ({ top, bottom: top + 5, shares: shares(clay, 0, 1 - clay, 0), likely: "clay", confidence: "likely", sources }); + + it("averages what the steps say, so a mixed lower part is not lost", () => { + const [block] = blocks([step(0, 1, [source("a", 0.2)]), step(5, 1, [source("a", 0.2)]), step(10, 0.6, [source("a", 0.2), source("b", 0.5)]), step(15, 0.6, [source("b", 0.5)])]); + const got = summary(block); + expect(got.shares!.clay).toBeCloseTo(0.8, 6); + expect(got.shares!.waterBearing).toBeCloseTo(0.2, 6); + expect(sharesInWords(got.shares!)).toBe("Mostly clay, some water-bearing sand"); + }); + + it("lists every borewell behind the stretch once, nearest first", () => { + const [block] = blocks([step(0, 1, [source("far", 0.9), source("near", 0.2)]), step(5, 1, [source("near", 0.2), source("mid", 0.5)])]); + expect(summary(block).sources.map((s) => s.id)).toEqual(["near", "mid", "far"]); + }); + + it("proposes nothing, and names nobody, where there is not enough data", () => { + const lonely: Step = { top: 0, bottom: 5, shares: shares(1, 0, 0, 0), likely: null, confidence: "none", sources: [source("a", 4)] }; + expect(summary(blocks([lonely])[0])).toEqual({ shares: null, sources: [] }); + }); +}); diff --git a/app/src/model/words.ts b/app/src/model/words.ts new file mode 100644 index 0000000..a0dc497 --- /dev/null +++ b/app/src/model/words.ts @@ -0,0 +1,109 @@ +/** + * The model's numbers turned into what a person reads: names, plain phrases, and runs of steps to + * draw. Kept apart from the screens so the wording can be tested without a browser. + */ +import { NEIGHBOURHOOD_KM, estimateWater, kmBetween, recentCutoff, waterPoints, type Borewell } from "@strata/core"; +import { text } from "@/text"; +import { GROUPS, type Group } from "./groups"; +import type { Source, Step } from "./model"; + +export const GROUP_NAME: Record = text.groups; + +/** Earth colours, as in StrataField. Water-bearing sand is the strongest, since it is what is looked for. */ +export const GROUP_COLOUR: Record = { clay: "#A0785A", fineSand: "#E8D5A3", waterBearing: "#C49A3C", hard: "#7A7F85" }; + +const lower = (name: string) => name.charAt(0).toLowerCase() + name.slice(1); + +/** "Clay" · "Mostly clay, some fine sand" · "Clay or water-bearing sand" */ +export function sharesInWords(shares: Readonly>): string { + const [first, second, ...rest] = [...GROUPS].sort((a, b) => shares[b] - shares[a]); // a tie keeps the usual order + if (shares[first] >= 0.9) return GROUP_NAME[first]; + if (shares[first] >= 0.6) { + const some = [second, ...rest].filter((g) => shares[g] >= 0.1).map((g) => `, some ${lower(GROUP_NAME[g])}`); + return `Mostly ${lower(GROUP_NAME[first])}${some.join("")}`; + } + return `${GROUP_NAME[first]} or ${lower(GROUP_NAME[second])}`; +} + +/** "340 m" (to the nearest 10 m) under 1 km, otherwise "1.2 km". */ +export function distanceInWords(km: number): string { + const metres = Math.round(km * 100) * 10; + return metres < 1000 ? `${metres} m` : `${km.toFixed(1)} km`; +} + +const OFTEN: [number, string][] = [ + [0.9, "9 times in 10"], [0.8, "4 times in 5"], [0.75, "3 times in 4"], [0.67, "2 times in 3"], + [0.5, "half the time"], [0.33, "1 time in 3"], [0.25, "1 time in 4"], +]; + +/** A share right as a plain phrase: "almost always", "about 3 times in 4", "rarely". */ +export function howOften(shareRight: number): string { + if (shareRight >= 0.95) return "almost always"; + if (shareRight < 0.2) return "rarely"; + return `about ${OFTEN.reduce((a, b) => (Math.abs(b[0] - shareRight) < Math.abs(a[0] - shareRight) ? b : a))[1]}`; +} + +/** A run of neighbouring steps with the same likeliest group and the same level, for drawing and listing. */ +export interface Block { + top: number; + bottom: number; + /** Null where there is not enough data to propose anything. */ + group: Group | null; + level: Step["confidence"]; + steps: Step[]; +} + +export function blocks(column: readonly Step[]): Block[] { + const out: Block[] = []; + for (const step of column) { + const last = out[out.length - 1]; + if (last && last.group === step.likely && last.level === step.confidence && last.bottom === step.top) { + last.bottom = step.bottom; + last.steps.push(step); + } else { + // `likely` is null exactly when the level is "none", whatever was voted. + out.push({ top: step.top, bottom: step.bottom, group: step.likely, level: step.confidence, steps: [step] }); + } + } + return out; +} + +/** + * What a whole stretch says, for its details: the shares averaged over its steps (a block is joined + * on its likeliest group alone, so "some sand" in its lower part must not be lost), and every + * borewell behind any of its steps, once, nearest first. Where there is not enough data nothing is + * proposed and nobody is named. + */ +export function summary(block: Block): { shares: Record | null; sources: Source[] } { + if (block.level === "none") return { shares: null, sources: [] }; + const shares: Record = { clay: 0, fineSand: 0, waterBearing: 0, hard: 0 }; + const sources = new Map(); + for (const step of block.steps) { + for (const g of GROUPS) shares[g] += (step.shares?.[g] ?? 0) / block.steps.length; + for (const s of step.sources) if (!sources.has(s.id)) sources.set(s.id, s); + } + return { shares, sources: [...sources.values()].sort((a, b) => a.km - b.km) }; +} + +export interface WaterHere { + /** Expected depth to water, feet below ground. */ + feet: number; + /** How far away the nearest reading used is. */ + nearestKm: number; + /** The year from which readings were used, e.g. "2023". */ + since: string; +} + +/** + * The water level to expect at a spot, or null when no reading is good enough to say. Only dated + * readings from the last few years count (the water moves, and nobody can say an undated reading is + * recent), and only those within the same neighbourhood StrataField's water map uses. + */ +export function waterHere(latitude: number, longitude: number, borewells: Borewell[]): WaterHere | null { + const dated = borewells.filter((b) => b.waterLevelOn); + const since = recentCutoff(dated).slice(0, 4); + const points = waterPoints(dated).filter((p) => kmBetween(latitude, longitude, p.latitude, p.longitude) <= NEIGHBOURHOOD_KM); + const estimate = estimateWater(latitude, longitude, points); + if (!estimate || !since) return null; + return { feet: estimate.value, nearestKm: estimate.nearestKm, since }; +} diff --git a/app/src/text/index.ts b/app/src/text/index.ts index 0dc790b..ff66397 100644 --- a/app/src/text/index.ts +++ b/app/src/text/index.ts @@ -3,6 +3,7 @@ export const text = { appName: "StrataVision", loading: "Reading your borewell records…", refresh: "Refresh", + tabs: { map: "Map", quality: "Data quality" }, /** Why the records could not be shown, by the kind of problem the Rust side reports. */ problems: { @@ -16,10 +17,61 @@ export const text = { onMap: (n: number) => (n === 1 ? "1 borewell on the map" : `${n} borewells on the map`), notShown: (m: number) => (m === 1 ? "1 without a location is not shown" : `${m} without a location are not shown`), - selectHint: "Click a borewell on the map to see it here.", + /** The four kinds of ground the proposals are made in. */ + groups: { clay: "Clay", fineSand: "Fine sand", waterBearing: "Water-bearing sand", hard: "Hard ground" }, + /** How sure a proposal is. */ + levels: { likely: "Likely", rough: "Rough guess", none: "Not enough data" }, + levelMeaning: { + likely: "Likely: several borewells close by agree.", + rough: "Rough guess: the borewells are further away, few, or disagree.", + none: "Not enough data: nothing is proposed.", + }, + + pickHint: "Click a borewell to see what was recorded there, or anywhere else on the map to see what the ground is probably like.", area: "Area", depth: "Depth", drilled: "Drilled", notRecorded: "Not recorded", + recordedLayers: "Recorded layers", + noLayers: "No layers were recorded for this borewell.", openInStrataField: "To see everything about this borewell or change it, open it in StrataField.", + + proposedHere: "Proposed layers here", + readingGround: "Reading ground heights…", + groundFailed: "The ground heights could not be read, so layers cannot be proposed.", + notEnoughHere: "There are not enough borewells near this spot to say anything.", + fromBorewells: (n: number, nearest: string) => (n === 1 ? `From 1 borewell, ${nearest} away` : `From ${n} borewells, nearest ${nearest}`), + noGroundHere: "The ground height is not known at this spot, so layers cannot be proposed here.", + waterExpected: (feet: number, nearest: string, since: string) => + `Water level expected about ${feet} ft below ground (nearest reading ${nearest} away; levels measured since ${since})`, + noWater: "No recent water level within 1 km", + /** On the drawings. An expected level is worked out; a measured one was read off at the borewell. */ + waterExpectedShort: (feet: number) => `Water expected about ${feet} ft`, + waterMeasured: (feet: number, year: string | null) => (year ? `Water ${feet} ft (measured ${year})` : `Water ${feet} ft (date not recorded)`), + atThisSpot: (n: number) => `${n} borewells are at this spot:`, + clickLayer: "Click a layer to see which borewells it comes from.", + depths: (top: number, bottom: number) => `${top} to ${bottom} ft below ground`, + blockLabel: (name: string, level: string, top: number, bottom: number) => `${name}, ${level.toLowerCase()}, ${top} to ${bottom} feet`, + workedOutFrom: "Worked out from", + andMore: (k: number) => `and ${k} more`, + howGood: "How good are these estimates?", + + quality: { + checking: "Checking…", + notEnough: "There are not enough borewells with layers and a location to check yet (at least 10 are needed).", + checked: (n: number) => `Checked against ${n} borewells: each was hidden in turn and worked out from the others.`, + likely: (often: string) => `Where it says Likely, it was right ${often}.`, + rough: (often: string) => `Where it says Rough guess, it was right ${often}.`, + under1: (often: string) => `With another borewell within 1 km, the best guess was right ${often}.`, + from1to3: (often: string) => `With the nearest borewell 1 to 3 km away, ${often}.`, + over3: (often: string) => `With the nearest borewell more than 3 km away, ${often}.`, + oddTitle: "Borewells that do not fit their surroundings", + oddNone: "None found.", + oddNote: "These are worth a look in StrataField: most often a slip in typing or a pin in the wrong place.", + borewell: "Borewell", + matched: "Matched its surroundings", + matchedFeet: (right: number, of: number) => `${right} of ${of} ft`, + others: (often: string) => `others like it matched ${often}`, + fixed: "These are proposals worked out from past drilling. They are not measurements.", + }, }; diff --git a/app/src/views/MapView.tsx b/app/src/views/MapView.tsx new file mode 100644 index 0000000..90acfbb --- /dev/null +++ b/app/src/views/MapView.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from "react"; +import { kmBetween, type Borewell } from "@strata/core"; +import { NOTHING_PROPOSED, NothingProposedPattern, ProposedColumn, RecordedColumn } from "@/components/ColumnDrawing"; +import { SiteMap, located } from "@/components/SiteMap"; +import type { Records, SiteRecord } from "@/lib/api"; +import type { Model } from "@/lib/useModel"; +import { predictColumn } from "@/model/model"; +import { GROUP_COLOUR, blocks, distanceInWords, sharesInWords, summary, waterHere, type Block } from "@/model/words"; +import { text } from "@/text"; + +type Chosen = { borewell: string } | { spot: [number, number] } | null; + +/** Borewells closer than this are at the same spot on the map: their pins lie on top of each other. */ +const SAME_PIN_KM = 0.005; + +/** The map with its side panel: what was recorded at a borewell, or what is proposed at any other spot. */ +export function MapView({ records, model, onHowGood }: { records: Records; model: Model | "failed" | null; onHowGood: () => void }) { + const { sites } = records; + const [chosen, setChosen] = useState(null); + const borewells = useMemo(() => sites.map((s) => s.borewell), [sites]); + + const shown = located(sites).length; + // A borewell deleted in StrataField since it was selected is simply no longer selected. + const site = chosen && "borewell" in chosen ? sites.find((s) => s.borewell.id === chosen.borewell) : undefined; + const spot = chosen && "spot" in chosen ? chosen.spot : null; + return ( +
+ +
+ setChosen({ borewell: id })} onPick={(lat, lon) => setChosen({ spot: [lat, lon] })} /> +
+ +
+ ); +} + +function Recorded({ site: { borewell: b, strata }, sites, onSelect }: { site: SiteRecord; sites: SiteRecord[]; onSelect: (id: string) => void }) { + // Pins at one spot lie on top of each other and only the top one can be clicked, so the others are offered here. + const here = b.latitude == null || b.longitude == null ? [] : located(sites).filter((s) => kmBetween(b.latitude!, b.longitude!, s.borewell.latitude!, s.borewell.longitude!) <= SAME_PIN_KM); + return ( + <> + {here.length > 1 && ( +
+

{text.atThisSpot(here.length)}

+
    + {here.map((s) => ( +
  • + +
  • + ))} +
+
+ )} +

{b.borewellId}

+
+
{text.area}
{b.area || text.notRecorded}
+
{text.depth}
{b.totalDepth != null ? `${b.totalDepth} ${b.depthUnit}` : text.notRecorded}
+
{text.drilled}
{b.date || text.notRecorded}
+
+

{text.recordedLayers}

+ {strata.length > 0 + ? + :

{text.noLayers}

} +

{text.openInStrataField}

+ + ); +} + +function Proposed({ spot, model, borewells, onHowGood }: { spot: [number, number]; model: Model | "failed" | null; borewells: Borewell[]; onHowGood: () => void }) { + const ready = model && model !== "failed" ? model : null; + // Worked out again when the records change, so a reload shows the new answer for the same spot. + const column = useMemo(() => (ready ? predictColumn(spot[0], spot[1], ready.prepared, ready.grid) : null), [spot, ready]); + const water = useMemo(() => waterHere(spot[0], spot[1], borewells), [spot, borewells]); + const [picked, setPicked] = useState<{ top: number; bottom: number } | null>(null); + + const heading =

{text.proposedHere}

; + const howGood = ; + const waterLine =

{water ? text.waterExpected(Math.round(water.feet), distanceInWords(water.nearestKm), water.since) : text.noWater}

; + if (model === "failed") return <>{heading}

{text.groundFailed}

; + if (!ready) return <>{heading}

{text.readingGround}

; + // Outside the ground-height data nothing can be placed by height, whatever borewells are near. + if (!column) return <>{heading}

{text.noGroundHere}

{waterLine}{howGood}; + if (!column.some((s) => s.likely)) return <>{heading}

{text.notEnoughHere}

{waterLine}{howGood}; + + // Only the borewells behind what is actually proposed, not those behind stretches left empty. + const behind = new Map(); + for (const step of column) if (step.likely) for (const s of step.sources) behind.set(s.id, s.km); + // The selected stretch, if it still exists: after a reload the layers may have moved. + const selected = picked ? blocks(column).find((b) => b.top === picked.top && b.bottom === picked.bottom) ?? null : null; + return ( + <> + {heading} +

{text.fromBorewells(behind.size, distanceInWords(Math.min(...behind.values())))}

+ {waterLine} + setPicked({ top: b.top, bottom: b.bottom })} /> +
    +
  • {text.levelMeaning.likely}
  • +
  • {text.levelMeaning.rough}
  • +
  • {text.levelMeaning.none}
  • +
+ {selected ? :

{text.clickLayer}

} + {howGood} + + ); +} + +/** What a selected stretch of the column is made of, and the borewells it was worked out from. */ +function BlockDetails({ block }: { block: Block }) { + const { shares, sources } = summary(block); + const first = sources.slice(0, 8); + return ( +
+

{text.depths(block.top, block.bottom)}

+

{shares ? sharesInWords(shares) : text.levels.none}

+

{text.levelMeaning[block.level]}

+ {first.length > 0 && ( + <> +

{text.workedOutFrom}

+
    + {first.map((s) =>
  • {s.borewellId} · {distanceInWords(s.km)}
  • )} + {sources.length > first.length &&
  • {text.andMore(sources.length - first.length)}
  • } +
+ + )} +
+ ); +} + +/** A small sample of how a level is drawn, for the key under the column. */ +function Swatch({ opacity, dash }: { opacity?: number; dash?: string }) { + return ( + + ); +} diff --git a/app/src/views/QualityView.tsx b/app/src/views/QualityView.tsx new file mode 100644 index 0000000..a83e7a7 --- /dev/null +++ b/app/src/views/QualityView.tsx @@ -0,0 +1,49 @@ +import type { Model } from "@/lib/useModel"; +import { matchedInWords, qualitySentences } from "@/model/quality"; +import { howOften } from "@/model/words"; +import { text } from "@/text"; + +/** How often the estimates are right, in plain words, and the borewells that do not fit their surroundings. */ +export function QualityView({ model }: { model: Model | "failed" | null }) { + const t = text.quality; + const check = model && model !== "failed" ? model.check : null; + return ( +
+
+

{text.howGood}

+ {model === "failed" ?

{text.groundFailed}

+ : !check ?

{t.checking}

+ : !check.canJudge ?

{t.notEnough}

+ : ( + <> +
    + {qualitySentences(check).map((sentence) =>
  • {sentence}
  • )} +
+
+

{t.oddTitle}

+ {check.oddOnesOut.length === 0 ?

{t.oddNone}

: ( + <> + + + + + + {check.oddOnesOut.map((o) => ( + + + + + ))} + +
{t.borewell}{t.matched}
{o.borewellId}{matchedInWords(o)} ({t.others(howOften(o.typical))})
+

{t.oddNote}

+ + )} +
+ + )} +

{t.fixed}

+
+
+ ); +} diff --git a/docs/superpowers/plans/2026-10-14-stratavision-phase-3-new-site.md b/docs/superpowers/plans/2026-10-14-stratavision-phase-3-new-site.md new file mode 100644 index 0000000..2eca4fe --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-stratavision-phase-3-new-site.md @@ -0,0 +1,217 @@ +# StrataVision Phase 3 (New Site and Data Quality) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Click anywhere on the map and see the proposed layers there, with how sure each is and which borewells it came from; and a Data quality screen that says in plain words how often the model is right and which borewells do not fit their surroundings. + +**Architecture:** The model from phase 2 is prepared once per load in a React hook, together with the ground-height grid (StrataField's own file, bundled from the submodule). Two views in one window, switched by tabs in the header: the Map (pins, a picked spot, a side panel with a drawn column) and Data quality. Everything a user reads is produced by small pure functions that turn numbers into plain words, so the wording is tested without a browser. + +**Tech Stack:** React 19, TypeScript, Leaflet via react-leaflet, inline SVG for the column, Vitest. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-10-13-stratavision-design.md`, sections "Screens → Map: new site", "Screens → Data quality", "Edge cases", and phase 3 of "Phases". + +## Global Constraints + +- Branch `phase-3-new-site`, cut from `phase-2-model` (or `main` once #2 is merged). One pull request; the owner merges. No AI attribution lines. +- StrataField (`strata/`) is not changed. +- All wording lives in `app/src/text/index.ts`. Plain language. The screens never say "model", "confidence", "share", "vote", "weight" or "leave-one-out". The three levels are called **Likely**, **Rough guess** and **Not enough data**. +- Estimates never look measured: a proposed column is drawn faded with dashed edges, a recorded one solid. Likely steps are drawn at 0.6 opacity, rough ones at 0.3, and "not enough data" steps are left empty with a hatch. +- Group names shown to users: Clay, Fine sand, Water-bearing sand, Hard ground. Group colours: clay `#A0785A`, fine sand `#E8D5A3`, water-bearing sand `#C49A3C`, hard ground `#7A7F85`. +- The accuracy check runs on the main thread, after the screen has first drawn. This departs from the spec's "off the main thread": it measured 0.1 s on 500 borewells, so a worker is not worth its weight yet. It carries a `ponytail:` comment naming the ceiling (a few thousand borewells) and the fix (a Web Worker). +- The numbers in `model/constants.ts` are **not** tuned in this phase's code: tuning needs the real records, which are not on the development computer. The Data quality screen is the tool for it. See Task 7. +- Not in this phase: the drilling suggestion, sections, city maps, saving or printing anything. + +## Review Focus + +1. A spot with nothing nearby, or outside the ground-height data: the panel must say "Not enough data" plainly, with no empty drawing and no error. (Task 4) +2. Borewells with layers but no location, or with a location but no layers: the first cannot be on the map and the second has nothing to draw; neither may break the panel or the model. (Task 2, Task 4) +3. The records change while a spot is picked (Refresh or returning to the window): the panel must show the new answer for the same spot, not a stale one. (Task 4) +4. Fewer than 10 usable borewells: Data quality says it cannot judge yet and shows no percentages. (Task 5) +5. Water level: a spot far from any recent reading must not show a number. Old readings must not be mixed with recent ones, and the period used is always stated. (Task 1, Task 4) + +--- + +### Task 1: Numbers into plain words + +**Files:** +- Create: `app/src/model/words.ts`, `app/src/model/words.test.ts` +- Modify: `app/src/text/index.ts` + +**Interfaces:** +- Consumes: `Group`, `GROUPS` (groups.ts); `Step`, `Source` (model.ts); `CheckResult`, `Tally`, `share` (check.ts); `estimateWater`, `waterPoints`, `recentCutoff`, `Borewell` from `@strata/core`. +- Produces: + +```ts +export const GROUP_NAME: Record; // "Clay", "Fine sand", "Water-bearing sand", "Hard ground" +export const GROUP_COLOUR: Record; +/** "Clay" · "Mostly clay, some fine sand" · "Clay or water-bearing sand" */ +export function sharesInWords(shares: Record): string; +/** "340 m" (to the nearest 10 m) under 1 km, otherwise "1.2 km" */ +export function distanceInWords(km: number): string; +/** "almost always", "about 9 times in 10", … "rarely" */ +export function howOften(shareRight: number): string; +/** Runs of neighbouring steps with the same likeliest group and the same level, for drawing and listing. */ +export interface Block { top: number; bottom: number; group: Group | null; level: "likely" | "rough" | "none"; steps: Step[] } +export function blocks(column: Step[]): Block[]; +/** The water level to expect at a spot, or null when no recent reading is near enough to say. */ +export interface WaterHere { feet: number; nearestKm: number; since: string | null } // since: a year, e.g. "2023" +export function waterHere(latitude: number, longitude: number, borewells: Borewell[]): WaterHere | null; +``` + +**Rules:** +- `sharesInWords`: order groups by share, largest first (ties in `GROUPS` order). Largest ≥ 0.9 → its name. Largest ≥ 0.6 → "Mostly {largest}", then ", some {name}" for each other group with a share ≥ 0.1, in order. Otherwise → "{largest} or {second}". Names after the first word are in lower case. +- `howOften`: ≥ 0.95 "almost always"; < 0.2 "rarely"; otherwise "about " + the nearest of 9 times in 10 (0.9), 4 times in 5 (0.8), 3 times in 4 (0.75), 2 times in 3 (0.67), half the time (0.5), 1 time in 3 (0.33), 1 time in 4 (0.25). +- `blocks`: a step with `likely` null is level "none" and group null, whatever its shares. +- `waterHere`: `waterPoints(borewells)` (core's default of the last 3 years before the newest reading), then `estimateWater`. Null when there are no points or the nearest is more than `ROUGH.nearestKm` (3 km) away. `since` is the year part of `recentCutoff(borewells)`, or null when it is empty. + +- [ ] **Step 1: Write `words.test.ts`** + +```ts +sharesInWords({ clay: 1, fineSand: 0, waterBearing: 0, hard: 0 }) → "Clay" +sharesInWords({ clay: 0.92, fineSand: 0.08, waterBearing: 0, hard: 0 }) → "Clay" +sharesInWords({ clay: 0.25, fineSand: 0.05, waterBearing: 0.7, hard: 0 }) → "Mostly water-bearing sand, some clay" +sharesInWords({ clay: 0.15, fineSand: 0.15, waterBearing: 0.7, hard: 0 }) → "Mostly water-bearing sand, some clay, some fine sand" +sharesInWords({ clay: 0.5, fineSand: 0, waterBearing: 0.5, hard: 0 }) → "Clay or water-bearing sand" +sharesInWords({ clay: 0.3, fineSand: 0.1, waterBearing: 0.2, hard: 0.4 }) → "Hard ground or clay" +distanceInWords(0.337) → "340 m"; distanceInWords(0.004) → "0 m"; distanceInWords(0.999) → "1.0 km"; distanceInWords(1.24) → "1.2 km" +howOften(0.97) → "almost always"; 0.88 → "about 9 times in 10"; 0.74 → "about 3 times in 4"; 0.5 → "about half the time"; 0.28 → "about 1 time in 4"; 0.1 → "rarely" +blocks: four steps clay/likely, clay/likely, clay/rough, none → three blocks: 0–10 clay likely, 10–15 clay rough, 15–20 null none +blocks([]) → [] +waterHere: one borewell 0.5 km away, level 60, measured 2026-03-01 → { feet: 60, nearestKm ≈ 0.5, since: "2023" } +waterHere: that borewell 4 km away → null; no water levels at all → null +waterHere: a reading from 2015 beside a reading from 2026 → only the 2026 one counts +``` + +- [ ] **Step 2: Run** `npm test`. Expected: fails, module `./words` not found. +- [ ] **Step 3: Implement `words.ts`** and add the names to `text/index.ts` (`words.ts` reads them from there). +- [ ] **Step 4: Run** `npm test`, `npm run typecheck`, `npm run lint`. Expected: all pass. +- [ ] **Step 5: Commit** — `Turn the model's numbers into plain words` + +--- + +### Task 2: The model, ready for the screens + +**Files:** +- Create: `app/src/lib/ground.ts`, `app/src/lib/useModel.ts` +- Modify: `app/src-tauri/src/data.rs` (the made-up data helper only), `app/src/vite-env.d.ts` if the `?url` import needs a type + +**Interfaces:** +- Consumes: `Records` (api.ts); `prepare`, `Prepared` (model.ts); `checkModel`, `CheckResult` (check.ts); `parseElevationGrid`, `ElevationGrid` from `@strata/core`. +- Produces: + +```ts +// lib/ground.ts +/** StrataField's ground-height file for Lucknow, read once. */ +export function loadGround(): Promise; + +// lib/useModel.ts +export interface Model { grid: ElevationGrid; prepared: Prepared[]; check: CheckResult | null } // check is null until worked out +/** null until the ground heights have loaded. Prepared again whenever `records` changes. */ +export function useModel(records: Records | null): Model | null; +``` + +- [ ] **Step 1: Implement `ground.ts`.** `import url from "../../../strata/app/src/assets/lucknow-elevation.bin?url"`, `fetch`, `parseElevationGrid`; the promise is kept so the file is read once. +- [ ] **Step 2: Implement `useModel`.** `prepared` in a `useMemo` on `records` and the grid. `check` is worked out in an effect, inside a `setTimeout(…, 0)` so the screen draws first, and is cleared and worked out again when `prepared` changes; a late result for old records is ignored. Leave: `// ponytail: on the main thread; 0.1 s at 500 borewells. Move to a Web Worker if records reach a few thousand.` +- [ ] **Step 3: Make the made-up data useful for these screens.** In `seed_for_manual_runs`, each located borewell gets layers (clay from 0 to `40 + 5 × (i % 5)` ft, medium sand to 120 ft, fine sand to 160 ft, kankar to 200 ft), a water reading (`static_level` `50 + 2 × (i % 7)`, measured `2026-0{1 + i % 9}-15`), and one borewell (`i == 7`) is logged upside down so there is an odd one out. The two unlocated ones keep no layers. +- [ ] **Step 4: Run** `cargo test -p stratavision` (8 pass, 1 ignored), the seed command into a fresh folder, `cargo clippy --workspace --all-targets -- -D warnings`, `npm run typecheck`, `npm run lint`, `npm run build -w app`. Expected: all pass, and the build output contains the `.bin` file. +- [ ] **Step 5: Commit** — `Prepare the model when records load` + +--- + +### Task 3: Drawing a column + +**Files:** +- Create: `app/src/components/ColumnDrawing.tsx` + +**Interfaces:** +- Consumes: `Block`, `blocks`, `GROUP_COLOUR`, `GROUP_NAME` (words.ts); `Step` (model.ts); `StrataLayer` from `@strata/core`; `text`. +- Produces: + +```tsx +/** A proposed column: faded, dashed, with empty hatched stretches where there is not enough data. */ +export function ProposedColumn(props: { column: Step[]; waterFt?: number | null; selected: Block | null; onSelect: (block: Block) => void }): JSX.Element; +/** A recorded column: the borewell's own layers in their own colours, solid. */ +export function RecordedColumn(props: { strata: StrataLayer[]; waterFt?: number | null }): JSX.Element; // layer depths are always feet +``` + +Both: an SVG 260 wide, a depth ruler on the left with a mark at every block or layer boundary (numbers left out where they would overlap), the column 90 wide, and the name of each block beside it when the block is at least 14 px tall. Scale: the column's full depth over 420 px. A blue line and "Water about N ft" at `waterFt` when given and within the column. In `ProposedColumn` each block is a button (click, or Tab and Enter) with an accessible label such as "Water-bearing sand, likely, 60 to 95 feet"; the selected block has a 2 px outline in the accent colour. + +- [ ] **Step 1: Implement both components** following the constraints for opacity, dashes and hatch. +- [ ] **Step 2: Run** `npm run typecheck`, `npm run lint`. Expected: pass. (They are seen working in Task 4.) +- [ ] **Step 3: Commit** — `Draw proposed and recorded columns` + +--- + +### Task 4: Pick a spot on the map + +**Files:** +- Create: `app/src/views/MapView.tsx` +- Modify: `app/src/components/SiteMap.tsx`, `app/src/App.tsx`, `app/src/text/index.ts` + +**Interfaces:** +- Consumes: `useModel`/`Model`; `predictColumn`; `waterHere`, `sharesInWords`, `distanceInWords`, `blocks`; `ProposedColumn`, `RecordedColumn`. +- Produces: `SiteMap` props become `{ sites, selectedId, picked: [number, number] | null, onSelect(id), onPick(latitude, longitude) }`; `MapView({ records, model, onHowGood })`. + +**Behaviour:** +- Clicking a pin selects that borewell: the panel shows its ID, area, depth, date, its `RecordedColumn`, and the "open it in StrataField" line. Clicking the map anywhere else picks that spot, shown by a ring marker, and the panel shows **Proposed layers here**. +- Proposed panel, top to bottom: the heading; "From {n} borewells, nearest {distance}" (n is the number of different borewells across the column's sources); the water line "Water level expected about {feet} ft below ground (from levels measured since {year})" or "No recent water level nearby"; the `ProposedColumn`; a three-line key (Likely, Rough guess, Not enough data); for the selected block: its depths, `sharesInWords` of its first step, its level in words, and the borewells behind it as "{ID} · {distance}", nearest first, at most 8 then "and {k} more"; a link "How good are these estimates?" calling `onHowGood`. +- No answer: when `predictColumn` returns null, an empty column, or a column with no step other than "none", the panel shows only the heading and "There are not enough borewells near this spot to say anything." and the same link. +- While the model is not ready the panel says "Reading ground heights…". +- The column is worked out in a `useMemo` on the picked spot and `model.prepared`, so a reload shows the new answer for the same spot. + +- [ ] **Step 1: Implement** the changes above. +- [ ] **Step 2: Run** `npm run typecheck`, `npm run lint`, `npm test`. +- [ ] **Step 3: Verify in the real app** on the made-up data from Task 2: start the debug build with `STRATA_DATA_DIR` on a freshly seeded folder and, through the debugging port: click the map between pins and read the panel (expect the heading, a "From … borewells" line, a water line with "since 2023", and block names including "Clay" and "Water-bearing sand"); take a screenshot and look at it; click a pin and read the recorded panel; click a far corner of the map and expect the "not enough borewells" sentence. +- [ ] **Step 4: Commit** — `Show proposed layers for any spot on the map` + +--- + +### Task 5: Data quality + +**Files:** +- Create: `app/src/views/QualityView.tsx`, `app/src/model/quality.ts`, `app/src/model/quality.test.ts` +- Modify: `app/src/App.tsx` (two tabs in the header: Map, Data quality), `app/src/text/index.ts` + +**Interfaces:** +- Consumes: `CheckResult`, `share` (check.ts); `howOften` (words.ts). +- Produces: + +```ts +/** The sentences the Data quality screen shows, in order. Empty when the check cannot judge. */ +export function qualitySentences(check: CheckResult): string[]; +``` + +**Sentences** (each only when its tally has at least one step): +1. "Checked against {compared} borewells: each was hidden in turn and worked out from the others." +2. "Where it says Likely, it was right {howOften}." (`check.likely`) +3. "Where it says Rough guess, it was right {howOften}." +4. "With another borewell within 1 km, the best guess was right {howOften}." (`check.byDistance`, which scores every step, shown or not) +5. "With the nearest borewell 1 to 3 km away, {howOften}." +6. "With the nearest borewell more than 3 km away, {howOften}." + +**Screen:** heading "How good are these estimates?"; while `check` is null "Checking…"; when it cannot judge "There are not enough borewells with layers and a location to check yet (at least 10 are needed)."; otherwise the sentences, then "Borewells that do not fit their surroundings" with either "None found." or a table (ID, matched "{right} of {steps} steps", "usually {howOften} near here") and the note "These are worth a look in StrataField: most often a slip in typing or a pin in the wrong place."; and always the fixed line "These are proposals worked out from past drilling. They are not measurements." + +- [ ] **Step 1: Write `quality.test.ts`:** a result (`overall` is likely and rough together and is not shown on its own) with likely 90/100, rough 60/100, under1 95/100, from1to3 0/0, over3 1/4, compared 40 gives sentences 1, 2 ("about 9 times in 10"), 3 ("about 2 times in 3"), 4 ("almost always"), 6 ("about 1 time in 4") and not 5; `canJudge: false` gives `[]`. +- [ ] **Step 2: Run** `npm test`. Expected: fails, module not found. +- [ ] **Step 3: Implement** `quality.ts`, `QualityView`, the tabs, and wire `onHowGood` to switch to it. +- [ ] **Step 4: Run** `npm test`, `npm run typecheck`, `npm run lint`. **Verify in the real app** on the seeded data: the Data quality tab shows the sentences and lists the upside-down borewell (`BW-008`); on a folder seeded with fewer than 10 the "not enough borewells" sentence. Take a screenshot and look at it. +- [ ] **Step 5: Commit** — `Add the Data quality screen` + +--- + +### Task 6: The real app, end to end + +**Files:** +- Modify: `e2e/smoke.mjs` + +- [ ] **Step 1: Add a second scenario** after the existing one: seed a fresh temporary folder by running `cargo test -p stratavision seed_for_manual_runs -- --ignored` with `STRATA_SEED_DIR`; start the app on it; wait for "25 borewells on the map"; click the middle of the map a little off the centre pin; pass when the panel contains "Proposed layers here" and "Clay"; then open the Data quality tab and pass when it contains "Checked against". Remove the folder afterwards. Each scenario reports its own failure with the screen's text. +- [ ] **Step 2: Run** the debug build and `npm run smoke`. Expected: both scenarios OK. Break one expected string on purpose, confirm it fails with the screen text, restore it. +- [ ] **Step 3: Commit** — `Test picking a spot in the real app` + +--- + +### Task 7: Pull request, and what tuning needs + +- [ ] **Step 1: Run everything:** `npm test`, `npm run typecheck`, `npm run lint`, `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, the debug build, `npm run smoke`. +- [ ] **Step 2: Push and open the pull request**, with screenshots of the proposed-layers panel and the Data quality screen described in words. Turn Auto-fix on. The owner merges. +- [ ] **Step 3: Tell the owner what tuning needs.** The constants are still the starting values. To tune them: run this build on the computer with the real records (or bring a copy of `%APPDATA%\Strata\strata.db` here), open Data quality, and send back what it says. The aim from the spec is "Likely" right at least 7 times in 10. diff --git a/e2e/smoke.mjs b/e2e/smoke.mjs index 2083b3b..173cd9f 100644 --- a/e2e/smoke.mjs +++ b/e2e/smoke.mjs @@ -1,32 +1,31 @@ -// Starts the real app on a data folder that does not exist and checks two things: it explains, -// in plain words, that there are no records yet; and it leaves no trace (the folder is not created). +// Tests of the real app, driven through its window. +// +// 1. Started on a data folder that does not exist, it explains in plain words that there are no +// records yet, and leaves no trace (the folder is not created). +// 2. Started on made-up borewells, clicking a spot on the map shows proposed layers there, and +// Data quality reports on them. +// // Build first: npm run tauri -w app -- build --debug --no-bundle -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import process from "node:process"; -const PORT = 9333; -const EXPECTED = "Install StrataField and add borewells first"; -const exe = resolve(import.meta.dirname, "../target/debug/stratavision.exe"); -const dataDir = join(tmpdir(), `stratavision-smoke-${process.pid}`); +// Each start of the app gets its own port: the last one's window can take a moment to let go of its own. +let port = 9332; +const root = resolve(import.meta.dirname, ".."); +const exe = join(root, "target/debug/stratavision.exe"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); if (!existsSync(exe)) { console.error(`The app has not been built: ${exe} is missing.`); process.exit(1); } -const app = spawn(exe, [], { - stdio: ["ignore", "ignore", "inherit"], // a crash at start-up should be visible in the log - // Development builds open the window's debugging port when asked (see open_main_window in - // app/src-tauri/src/lib.rs). - env: { ...process.env, STRATA_DATA_DIR: dataDir, STRATA_E2E_DEBUG_PORT: String(PORT) }, -}); - -/** The text on the app's page, read through the WebView's debugging connection. */ -async function pageText() { - const pages = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); +/** Sends one command to the app's page through the WebView's debugging connection. */ +async function send(method, params = {}) { + const pages = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); const page = pages.find((p) => p.type === "page" && p.webSocketDebuggerUrl); if (!page) throw new Error("no page yet"); const ws = new WebSocket(page.webSocketDebuggerUrl); @@ -34,34 +33,100 @@ async function pageText() { return await new Promise((done, fail) => { ws.onerror = () => fail(new Error("could not connect to the page")); setTimeout(() => fail(new Error("the page did not answer")), 5000); - ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "document.body.innerText" } })); - ws.onmessage = (m) => done(JSON.parse(m.data).result?.result?.value ?? ""); + ws.onopen = () => ws.send(JSON.stringify({ id: 1, method, params })); + ws.onmessage = (m) => { + const answer = JSON.parse(m.data); + if (answer.id === 1) done(answer.result); + }; }); } finally { ws.close(); } } -let seen = null; -let lastError = "the app's page never answered"; -let ok = false; -for (const deadline = Date.now() + 30_000; Date.now() < deadline && !ok; ) { - await new Promise((r) => setTimeout(r, 500)); - try { - seen = await pageText(); - ok = seen.includes(EXPECTED); - } catch (e) { - lastError = e.message; +const js = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true }))?.result?.value; +const screen = () => js("document.body.innerText"); + +async function click(x, y) { + for (const type of ["mousePressed", "mouseReleased"]) await send("Input.dispatchMouseEvent", { type, x, y, button: "left", clickCount: 1 }); +} + +/** Waits up to 30 seconds for the screen to say `expected`; fails with what it said instead. */ +async function expectScreen(expected) { + let seen = null, lastError = "the app's page never answered"; + for (const deadline = Date.now() + 30_000; Date.now() < deadline; ) { + await sleep(500); + try { + seen = await screen(); + if (seen.includes(expected)) return; + } catch (e) { + lastError = e.message; + } } + throw new Error(`expected the screen to say "${expected}". It said:\n${seen ?? `(nothing: ${lastError})`}`); } -app.kill(); -if (!ok) { - console.error(`FAILED: expected the screen to say "${EXPECTED}". It said:\n${seen ?? `(nothing: ${lastError})`}`); - process.exit(1); +/** Runs `steps` against the app started on `dataDir`, and always stops the app afterwards. */ +async function withApp(dataDir, steps) { + port++; + const app = spawn(exe, [], { + stdio: ["ignore", "ignore", "inherit"], // a crash at start-up should be visible in the log + // Development builds open the window's debugging port when asked (see open_main_window in + // app/src-tauri/src/lib.rs). + env: { ...process.env, STRATA_DATA_DIR: dataDir, STRATA_E2E_DEBUG_PORT: String(port) }, + }); + try { + await steps(); + } finally { + app.kill(); + await sleep(1000); + } } -if (existsSync(dataDir)) { - console.error(`FAILED: the app created ${dataDir}. It must never create the data folder.`); - process.exit(1); + +const scenarios = { + "no records: says so, and creates nothing": async () => { + const dataDir = join(tmpdir(), `stratavision-smoke-${process.pid}`); + await withApp(dataDir, () => expectScreen("Install StrataField and add borewells first")); + if (existsSync(dataDir)) throw new Error(`the app created ${dataDir}. It must never create the data folder.`); + }, + + "made-up borewells: a picked spot shows proposed layers": async () => { + const dataDir = join(tmpdir(), `stratavision-smoke-seeded-${process.pid}`); + execFileSync("cargo", ["test", "-p", "stratavision", "seed_for_manual_runs", "--", "--ignored"], { + cwd: root, stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, STRATA_SEED_DIR: dataDir }, + }); + try { + await withApp(dataDir, async () => { + await expectScreen("26 borewells on the map"); + await sleep(2500); // the map settles on the borewells + // The middle of the map is the middle borewell; a little to one side is open ground. + const [x, y] = await js("(() => { const r = document.querySelector('.leaflet-container').getBoundingClientRect(); return [r.x + r.width / 2 + 60, r.y + r.height / 2 + 45]; })()"); + await click(x, y); + await expectScreen("Proposed layers here"); + await expectScreen("Water-bearing sand"); + await expectScreen("Water level expected about"); + await expectScreen("levels measured since 2023"); + // The middle pin is two borewells on top of each other; the panel offers both. + await click(x - 60, y - 45); + await expectScreen("2 borewells are at this spot:"); + await js("[...document.querySelectorAll('header button')].find((b) => b.innerText.trim() === 'Data quality').click()"); + await expectScreen("Checked against 26 borewells"); + await expectScreen("BW-008"); // the one logged upside down + }); + } finally { + rmSync(dataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); // the app may still be letting go + } + }, +}; + +let failed = false; +for (const [name, run] of Object.entries(scenarios)) { + try { + await run(); + console.log(`OK: ${name}`); + } catch (e) { + failed = true; + console.error(`FAILED: ${name}\n${e.message}`); + } } -console.log("OK: the app starts, explains that there are no records, and creates nothing."); +process.exit(failed ? 1 : 0);