From a4c1db9c2509ba29a3245f13699fa024693208b6 Mon Sep 17 00:00:00 2001 From: Mehul Date: Wed, 14 Oct 2026 12:25:20 +0530 Subject: [PATCH 1/3] Work out a section along a line --- app/src/model/city.test.ts | 9 +-- app/src/model/section.test.ts | 65 ++++++++++++++++++ app/src/model/section.ts | 66 +++++++++++++++++++ ...2026-10-14-stratavision-phase-5-section.md | 66 +++++++++++++++++++ 4 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 app/src/model/section.test.ts create mode 100644 app/src/model/section.ts create mode 100644 docs/superpowers/plans/2026-10-14-stratavision-phase-5-section.md diff --git a/app/src/model/city.test.ts b/app/src/model/city.test.ts index a10a446..04b6604 100644 --- a/app/src/model/city.test.ts +++ b/app/src/model/city.test.ts @@ -8,12 +8,13 @@ import { checkModel, share } from "./check"; import { madeUpCity, truth } from "./madeUpCity"; import { GROUPS, type Group } from "./groups"; import { predictColumn, prepare, type Prepared } from "./model"; +import { sectionAlong } from "./section"; const city = (count: number) => { const { sites, grid } = madeUpCity(count); - return { grid, prepared: prepare(sites, DEFAULT_MATERIALS, grid) }; + return { sites, grid, prepared: prepare(sites, DEFAULT_MATERIALS, grid) }; }; -const { grid, prepared } = city(500); +const { sites, grid, prepared } = city(500); const result = checkModel(prepared, grid); /** @@ -101,8 +102,8 @@ describe("a made-up city of 500 borewells", () => { it("is fast enough", { timeout: 30_000 }, () => { const times = { column: timed(() => predictColumn(26.87, 80.97, prepared, grid)), - // A section: 200 columns along a line across the city. - section: timed(() => { for (let i = 0; i < 200; i++) predictColumn(26.76 + i * 0.0009, 80.86 + i * 0.0009, prepared, grid); }), + // A section: 200 columns along a line across the city, with the borewells near it and the water. + section: timed(() => sectionAlong([[26.76, 80.86], [26.94, 81.04]], 0.5, prepared, grid, sites.map((s) => s.borewell))), // A city map: 100 × 100 points. map: timed(() => { for (let i = 0; i < 100; i++) for (let j = 0; j < 100; j++) predictColumn(26.75 + i * 0.002, 80.85 + j * 0.002, prepared, grid); }), check: timed(() => checkModel(prepared, grid)), diff --git a/app/src/model/section.test.ts b/app/src/model/section.test.ts new file mode 100644 index 0000000..958d6a3 --- /dev/null +++ b/app/src/model/section.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MATERIALS, METRES_TO_FEET, heightAt, type Borewell, type ElevationGrid } from "@strata/core"; +import { prepare, type ModelSite } from "./model"; +import { sectionAlong } from "./section"; +import { CENTRE, east, flatGrid, north, site, slopeGrid } from "./testGround"; + +const flat = flatGrid(120); +const CLAY: [number, number, string][] = [[0, 200, "clay"]]; +const along = (path: [number, number][], sites: ModelSite[], grid: ElevationGrid = flat, halfWidthKm = 0.5, count = 5) => + sectionAlong(path, halfWidthKm, prepare(sites, DEFAULT_MATERIALS, grid), grid, sites.map((s) => s.borewell), count); + +describe("sectionAlong", () => { + it("samples evenly along a line with a bend, by distance along the line", () => { + const s = along([CENTRE, [north(0), east(2)], [north(2), east(2)]], []); + expect(s.lengthKm).toBeCloseTo(4, 1); + expect(s.columns.map((c) => c.km)).toEqual([0, 1, 2, 3, 4].map((k) => expect.closeTo(k, 1))); + expect(s.columns[3].latitude).toBeCloseTo(north(1), 4); + expect(s.columns[3].longitude).toBeCloseTo(east(2), 4); + }); + + it("lines a layer up by height on sloping ground", () => { + // Clay down to 330 ft above sea level and water-bearing sand below it, at every borewell, while + // the ground climbs eastwards: the boundary is deeper below ground in the east. + const grid = slopeGrid(60, 180); + const boundaryFt = 330; + const at = (id: string, lat: number, lon: number) => { + const groundFt = heightAt(grid, lat, lon)! * METRES_TO_FEET; + return site(id, lat, lon, [[0, groundFt - boundaryFt, "clay"], [groundFt - boundaryFt, groundFt - boundaryFt + 100, "medium_sand"]]); + }; + const sites = [at("w", north(0.3), east(-1)), at("m", north(-0.3), east(0)), at("e", north(0.3), east(1))]; + const s = along([[north(0), east(-1)], [north(0), east(1)]], sites, grid); + for (const c of s.columns) { + const atHeight = (ft: number) => c.steps!.find((st) => st.top <= c.groundFt! - ft && c.groundFt! - ft < st.bottom)!.likely; + expect(atHeight(boundaryFt + 10)).toBe("clay"); + expect(atHeight(boundaryFt - 10)).toBe("waterBearing"); + } + expect(s.columns[4].groundFt! - s.columns[0].groundFt!).toBeGreaterThan(10); + }); + + it("takes only the borewells near the line, in order along it", () => { + const sites = [site("far", north(0.8), east(0.5), CLAY), site("b", north(-0.3), east(1.5), CLAY), site("a", north(0.2), east(0.5), CLAY)]; + const s = along([CENTRE, [north(0), east(2)]], sites); + expect(s.borewells.map((b) => b.p.id)).toEqual(["a", "b"]); + expect(s.borewells[0].alongKm).toBeCloseTo(0.5, 2); + expect(s.nearby).toBe(2); + }); + + it("leaves a gap where the ground height is not known", () => { + // The made-up ground ends at latitude 26.6. + const s = along([[26.59, 80.95], [26.61, 80.95]], [site("a", 26.605, 80.95, CLAY)]); + expect(s.columns[0]).toMatchObject({ groundFt: null, steps: null }); + expect(s.columns[4].groundFt).toBeCloseTo(120 * METRES_TO_FEET, 1); + }); + + it("gives the expected water level only where there is a recent reading nearby", () => { + const wet = site("wet", north(0.1), east(0), CLAY, { waterLevel: 40, waterLevelOn: "2026-03-01" } as Partial); + const s = along([CENTRE, [north(0), east(4)]], [wet]); + expect(s.columns[0].waterFt).toBeCloseTo(40, 5); + expect(s.columns[4].waterFt).toBeNull(); + }); + + it("is empty for fewer than two points", () => { + expect(along([CENTRE], [site("a", north(0), east(0), CLAY)])).toEqual({ lengthKm: 0, columns: [], borewells: [], nearby: 0 }); + }); +}); diff --git a/app/src/model/section.ts b/app/src/model/section.ts new file mode 100644 index 0000000..625247e --- /dev/null +++ b/app/src/model/section.ts @@ -0,0 +1,66 @@ +/** + * A section along a line with bends: the model's column at evenly spaced places along it, and the + * borewells near it, placed by StrataField's own rule. Heights are feet above sea level, so the + * drawing can line layers up by height whatever the ground does. + */ +import { METRES_TO_FEET, heightAt, kmBetween, placeAlongPath, thinAlongLine, type Borewell, type ElevationGrid, type LatLon } from "@strata/core"; +import { predictColumn, type Prepared, type Step } from "./model"; +import { waterHere } from "./words"; + +export interface SectionColumn { + /** Distance along the line from its start. */ + km: number; + latitude: number; + longitude: number; + /** Feet above sea level; null where the ground height is not known (and so nothing is proposed). */ + groundFt: number | null; + steps: Step[] | null; + /** Expected depth to water, feet below ground; null where there is no recent reading nearby. */ + waterFt: number | null; +} + +export interface SectionBorewell { + p: Prepared; + alongKm: number; + /** Distance from the line; positive is to the right walking from A to A′. */ + offsetKm: number; +} + +export interface Section { + lengthKm: number; + columns: SectionColumn[]; + /** The borewells drawn, nearest the line in each stretch when there are too many. */ + borewells: SectionBorewell[]; + /** How many borewells are within reach of the line, drawn or not. */ + nearby: number; +} + +/** `count` evenly spaced places along a line with bends, with their distance along it. */ +function placesAlong(path: LatLon[], count: number) { + const parts = path.slice(1).map((b, i) => ({ a: path[i], b, km: kmBetween(path[i][0], path[i][1], b[0], b[1]) })); + const total = parts.reduce((s, p) => s + p.km, 0); + return Array.from({ length: count }, (_, n) => { + const km = (n / (count - 1)) * total; + let rest = km, i = 0; + while (i < parts.length - 1 && rest > parts[i].km) rest -= parts[i++].km; + const { a, b, km: len } = parts[i], t = len ? Math.min(1, rest / len) : 0; + return { km, latitude: a[0] + (b[0] - a[0]) * t, longitude: a[1] + (b[1] - a[1]) * t }; + }); +} + +export function sectionAlong(path: LatLon[], halfWidthKm: number, prepared: Prepared[], grid: ElevationGrid, borewells: Borewell[], count = 200): Section { + if (path.length < 2) return { lengthKm: 0, columns: [], borewells: [], nearby: 0 }; + const columns = placesAlong(path, count).map(({ km, latitude, longitude }) => { + const groundM = heightAt(grid, latitude, longitude); + return { + km, latitude, longitude, + groundFt: groundM == null ? null : groundM * METRES_TO_FEET, + steps: predictColumn(latitude, longitude, prepared, grid, { groundM: groundM ?? undefined }), + waterFt: waterHere(latitude, longitude, borewells)?.feet ?? null, + }; + }); + const lengthKm = columns[columns.length - 1].km; + const { placed } = placeAlongPath(path, halfWidthKm, prepared); + const shown = thinAlongLine(placed, lengthKm); + return { lengthKm, columns, borewells: shown.map(({ item, alongKm, offsetKm }) => ({ p: item, alongKm, offsetKm })), nearby: placed.length }; +} diff --git a/docs/superpowers/plans/2026-10-14-stratavision-phase-5-section.md b/docs/superpowers/plans/2026-10-14-stratavision-phase-5-section.md new file mode 100644 index 0000000..44ae897 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-stratavision-phase-5-section.md @@ -0,0 +1,66 @@ +# StrataVision Phase 5 (Section) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Section screen: draw a line with bends on the map; see the ground along it from the model, with the real borewells near the line drawn solid; save it as a picture or a PDF. + +**Architecture:** A pure function `sectionAlong(path, halfWidthKm, prepared, grid, borewells)` in `app/src/model/section.ts` samples the line, asks `predictColumn` at each sample, and places the borewells near the line with StrataField's `placeAlongPath`. A `SectionDrawing` SVG draws heights above sea level (so sloping ground looks right), and a `SectionView` puts the map with the line above the drawing. Saving reuses StrataField's approach: SVG → PNG on a canvas, PDF with pdf-lib, the Save dialog and file writing through Tauri's dialog and fs plugins. + +**Tech Stack:** TypeScript, React, Vitest, react-leaflet; new: `pdf-lib`, `@tauri-apps/plugin-dialog`, `@tauri-apps/plugin-fs` (and their Rust crates), all already used by StrataField. + +**Spec:** `docs/superpowers/specs/2026-10-13-stratavision-design.md`, "Screens → Section". + +## Global Constraints + +- Branch `phase-5-section` from `main`. One pull request; the owner merges. No AI attribution lines. +- The drawing's height is feet above sea level. Model columns are placed by the ground height at their sample; recorded borewells by their own ground height (the same one the model uses). +- 200 model columns along the line, whatever its length. Same strengths as the new-site column: likely 0.6, rough 0.3, none hatched. Real borewells: solid, full colour, by group, with their ID. +- Borewells within the chosen distance of the line (0.25, 0.5, 1 or 2 km; 0.5 by default), at most `MAX_SECTION_BOREWELLS`, thinned as StrataField does. +- The expected water line is drawn only where `waterHere` gives a level; it breaks elsewhere. +- Saved files are built on this computer; the picture and the PDF use fixed light colours. +- All wording in `app/src/text/index.ts`. + +## Review Focus + +1. Sloping ground: a model column and a borewell at the same place must line up by height. (Task 1) +2. A line with bends: distances along the line, not straight from A. (Task 1) +3. Spots with no ground height or nothing nearby: a gap, not a crash or a zero-height column. (Tasks 1–2) +4. Saving: cancelled dialog does nothing; the saved picture does not depend on the app's theme. (Task 3) + +--- + +### Task 1: The section, as numbers + +**Files:** Create `app/src/model/section.ts`, `app/src/model/section.test.ts`. + +```ts +export interface SectionColumn { km: number; latitude: number; longitude: number; groundFt: number | null; steps: Step[] | null; waterFt: number | null } +export interface SectionBorewell { p: Prepared; alongKm: number; offsetKm: number } +export interface Section { lengthKm: number; columns: SectionColumn[]; borewells: SectionBorewell[]; nearby: number } +export function sectionAlong(path: LatLon[], halfWidthKm: number, prepared: Prepared[], grid: ElevationGrid, borewells: Borewell[], count = 200): Section; +``` + +- [ ] Tests first: samples evenly along a line with a bend, distances along the line; a flat layer between two borewells comes out at the same height on sloping ground; borewells outside the corridor left out, inside ordered along the line; no ground height → `groundFt: null, steps: null`; fewer than two points → empty section; the made-up city's section within the time limit. +- [ ] Implement; tests, typecheck, lint. Commit `Work out a section along a line`. + +### Task 2: The Section screen + +**Files:** Create `app/src/components/SectionDrawing.tsx`, `app/src/views/SectionView.tsx`; modify `app/src/App.tsx`, `app/src/components/SiteMap.tsx` (overlays as children), `app/src/text/index.ts`, `e2e/smoke.mjs`. + +- [ ] Tab "Section". Click the map to add points (a pin adds its own position); "Undo last point", "Clear". The line, its corridor and A / A′ are drawn on the map. +- [ ] The drawing: ground line, model columns, borewell columns with IDs, dashed water line, height ruler (ft above sea level), distance along the bottom, A and A′, a key. +- [ ] Under it: "N borewells within X of the line" (and "M shown" when thinned). +- [ ] Smoke test: on the Section tab, two clicks draw a section; expect "borewells within". +- [ ] Verify in the real app with a screenshot. Commit `Draw a section along a line`. + +### Task 3: Save as picture and PDF + +**Files:** Create `app/src/lib/saving.ts`; modify `app/src-tauri/Cargo.toml`, `app/src-tauri/src/lib.rs`, `app/src-tauri/capabilities/default.json`, `app/package.json`. + +- [ ] Picture: PNG at twice the size, light colours. PDF: one A4 landscape page with title, line length, corridor, the picture, the key in words, and the fixed line that these are proposals, not measurements. +- [ ] Verify both files open. Commit `Save a section as a picture or PDF`. + +### Task 4: Review and pull request + +- [ ] Fresh reviewer on the branch; fix Critical/Important with a failing test first. +- [ ] Pull request; Auto-fix on. From 895a3554f8850a6f21eddac7e38ca1209f29c0dd Mon Sep 17 00:00:00 2001 From: Mehul Date: Wed, 14 Oct 2026 12:34:05 +0530 Subject: [PATCH 2/3] Draw a section along a line, and save it as a picture or PDF --- Cargo.lock | 175 ++++++++++++++++++++++-- app/package.json | 3 + app/src-tauri/Cargo.toml | 2 + app/src-tauri/capabilities/default.json | 2 +- app/src-tauri/src/lib.rs | 3 + app/src/App.tsx | 11 +- app/src/components/SectionDrawing.tsx | 153 +++++++++++++++++++++ app/src/components/SiteMap.tsx | 7 +- app/src/lib/saving.test.ts | 25 ++++ app/src/lib/saving.ts | 82 +++++++++++ app/src/model/section.test.ts | 3 +- app/src/model/section.ts | 8 +- app/src/text/index.ts | 27 +++- app/src/views/SectionView.tsx | 106 ++++++++++++++ e2e/smoke.mjs | 14 +- package-lock.json | 63 +++++++++ 16 files changed, 664 insertions(+), 20 deletions(-) create mode 100644 app/src/components/SectionDrawing.tsx create mode 100644 app/src/lib/saving.test.ts create mode 100644 app/src/lib/saving.ts create mode 100644 app/src/views/SectionView.tsx diff --git a/Cargo.lock b/Cargo.lock index daeec69..7c8d42d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,6 +2066,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2544,6 +2545,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -2995,6 +3020,8 @@ dependencies = [ "strata_db", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", "tempfile", ] @@ -3274,6 +3301,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca325d6e66e9170f2e1b9631722e4e544bcbc51781575d266abdaba697bd51" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213fae2d85d4b76b06de5915d1862db9fa74541171f9d2c15da6ac9bd6e788c6" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -4169,7 +4254,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -4187,13 +4281,30 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4220,42 +4331,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/app/package.json b/app/package.json index 82d22c0..714ed47 100644 --- a/app/package.json +++ b/app/package.json @@ -16,10 +16,13 @@ "@fontsource/ibm-plex-sans": "^5.3.0", "@strata/core": "*", "@tauri-apps/api": "^2.12.0", + "@tauri-apps/plugin-dialog": "^2.8.2", + "@tauri-apps/plugin-fs": "^2.7.0", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "leaflet": "^1.9.4", "lucide-react": "^1.48.0", + "pdf-lib": "^1.17.1", "react": "^19.1.0", "react-dom": "^19.1.0", "react-leaflet": "^5.0.0", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 6664a0e..26eb921 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } strata_db = { path = "../../strata/core/db" } # Must stay the same version as strata_db's, so both use one SQLite and one Connection type. diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index c2ca36e..fd3e894 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": ["core:default"] + "permissions": ["core:default", "dialog:allow-save", "fs:allow-write-file"] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5f2fa7e..8426660 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -11,6 +11,9 @@ fn load_data(app: tauri::AppHandle) -> Result { pub fn run() { tauri::Builder::default() + // Saving a section as a picture or PDF: the Save dialog, and writing the file chosen there. + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) .setup(|app| Ok(open_main_window(app)?)) .invoke_handler(tauri::generate_handler![load_data]) .run(tauri::generate_context!()) diff --git a/app/src/App.tsx b/app/src/App.tsx index 0d2b922..a45394c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -6,13 +6,17 @@ import { useModel } from "@/lib/useModel"; import { text } from "@/text"; import { MapView } from "@/views/MapView"; import { QualityView } from "@/views/QualityView"; +import { SectionView } from "@/views/SectionView"; type State = { records: Records; as: string } | { problem: LoadError } | null; -type View = "map" | "quality"; +type View = "map" | "section" | "quality"; export default function App() { const [state, setState] = useState(null); const [view, setView] = useState("map"); + // The section's map is first drawn when it is first shown: a map set up while hidden has no size to fit the borewells to. + const [sectionSeen, setSectionSeen] = useState(false); + if (view === "section" && !sectionSeen) setSectionSeen(true); // 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. @@ -42,7 +46,7 @@ export default function App() {

{text.appName}

{records && (