From b9ae1c61809f7c06b26f4550129a46e2095cd069 Mon Sep 17 00:00:00 2001 From: Mehul Date: Wed, 14 Oct 2026 11:21:36 +0530 Subject: [PATCH] Help placing borewells, read a Location line, and an Excel log template - Placing a borewell opens the map on borewells already placed at the same site or colony, shown by name; the spot is always clicked, since borewells on one site are in different places. - The address is searched as written, then with other spellings the map uses, then by shorter parts; matches from elsewhere are not offered. Stored addresses are never changed. - The Excel reader takes a 'Location = ...' line (numbers or a Google Maps link), and no longer mistakes a title such as 'STRETA CHART OF TUBE WELL' for the chart's heading. - Save an Excel template on the Import screen: a blank log laid out the way the reader expects, tested by filling it in and reading it back. --- CHANGELOG.md | 21 +++++ app/src/components/map/MapPicker.tsx | 60 +++++++++++-- app/src/pages/ImportPage.tsx | 34 ++++++- app/src/pages/LocatePage.tsx | 10 ++- core/package.json | 3 +- core/src/export/logTemplate.test.ts | 90 +++++++++++++++++++ core/src/export/logTemplate.ts | 128 +++++++++++++++++++++++++++ core/src/index.ts | 1 + core/src/parser/strataFieldParser.ts | 17 +++- core/src/places.test.ts | 110 +++++++++++++++++++++++ core/src/places.ts | 113 +++++++++++++++++++++++ core/src/types.ts | 2 + e2e/run.mjs | 3 +- 13 files changed, 573 insertions(+), 19 deletions(-) create mode 100644 core/src/export/logTemplate.test.ts create mode 100644 core/src/export/logTemplate.ts create mode 100644 core/src/places.test.ts create mode 100644 core/src/places.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b72f0f5..f34247b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ for a version is copied into its GitHub release notes, so write it for the peopl ## [Unreleased] +### New +- **An Excel template for drilling logs.** On the Import screen, **Save an Excel template** gives a + blank log laid out exactly the way StrataField reads it, with a filled-in example, the soil and + pipe names to use, and instructions. Make one copy for each borewell; it imports with nothing to + correct. +- **A position written on the log is imported with it.** A line such as + `Location = 26.8912, 80.9876` (or a Google Maps link) puts the borewell on the map straight away. + The template has this line. +- **Placing borewells starts from where you have worked before.** When a borewell's name or address + shares a place with borewells already on the map (for example "Ekana"), the map opens there with + those borewells shown by name, so you only click the exact spot. Each borewell is still placed by + hand: borewells on one site are in different places. +- **Finding an address works better.** The address is searched as written, then with other + spellings the map uses (Haiderabad / Hyderabad, Chinhut / Chinhat, Dewa / Deva), then by its + colony or road alone. Matches from somewhere else (another city, or a road with a similar name) + are no longer offered. What is stored is never changed. + +### Changed +- **A title such as "STRETA CHART OF TUBE WELL" above the chart no longer stops a log from being + read.** Such logs needed their columns chosen by hand before. + ## [1.0.15] - 2026-10-08 ### Changed diff --git a/app/src/components/map/MapPicker.tsx b/app/src/components/map/MapPicker.tsx index 0777cec..cabbcde 100644 --- a/app/src/components/map/MapPicker.tsx +++ b/app/src/components/map/MapPicker.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import { CircleMarker, useMapEvents } from "react-leaflet"; +import { CircleMarker, Tooltip, useMapEvents } from "react-leaflet"; import type { Map as LeafletMap } from "leaflet"; import { Search } from "lucide-react"; -import type { Borewell, Place } from "@strata/core"; +import { placeFits, type Borewell, type Place, type PlaceHint } from "@strata/core"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -19,15 +19,38 @@ function ClickToPlace({ onPick }: { onPick: (p: Point) => void }) { return null; } -/** A search box over the map: type a place, colony or road and choose from the matches. */ -function PlaceSearch({ initial, onChoose }: { initial: string; onChoose: (p: Place) => void }) { - const [query, setQuery] = useState(initial); +/** + * A search box over the map: type a place, colony or road and choose from the matches. With + * `tries`, it first searches each in turn (the address as written, other spellings, shorter parts) + * and lists the matches of the first one that finds the place itself, not a loose match. + */ +function PlaceSearch({ initial, tries, onChoose }: { initial: string; tries?: string[]; onChoose: (p: Place) => void }) { + const [query, setQuery] = useState(tries?.length ? "" : initial); const [places, setPlaces] = useState([]); const [active, setActive] = useState(0); - const [status, setStatus] = useState(null); + const [status, setStatus] = useState(tries?.length ? "Searching for the address…" : null); /** The text put in the box by choosing a match, which must not start another search. */ const chosen = useRef(null); + useEffect(() => { + if (!tries?.length) return; + let stale = false; + (async () => { + for (const t of tries) { + const found = await api.searchPlaces(t).then((all) => all.filter((p) => placeFits(t, p)), () => null); + if (stale) return; + if (found === null) { chosen.current = t; setQuery(t); setStatus("Searching for a place needs an internet connection. You can still click the spot on the map."); return; } + if (found.length) { chosen.current = t; setQuery(t); setPlaces(found); setActive(0); setStatus(null); return; } + } + chosen.current = tries[0]; + setQuery(tries[0]); + setStatus("Nothing on the map matches this address. Try a colony or road name, or click the spot on the map."); + })(); + return () => { stale = true; }; + // Once per opening of the map: the picker is opened afresh for each borewell. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { const q = query.trim(); if (q.length < 3 || q === chosen.current) { setPlaces([]); setStatus(null); return; } @@ -101,7 +124,7 @@ function PlaceSearch({ initial, onChoose }: { initial: string; onChoose: (p: Pla * Choose a borewell's position: search for a place to jump there, then click the exact spot. * Other borewells are shown faintly for reference. */ -export function MapPicker({ open, initial, search = "", others, heading, detail, onClose, onPick, onSkip }: { +export function MapPicker({ open, initial, search = "", tries, hint, others, heading, detail, onClose, onPick, onSkip }: { open: boolean; initial: Point | null; /** When borewells are placed one after another: whose turn it is, and a way to leave it for later. */ @@ -110,24 +133,42 @@ export function MapPicker({ open, initial, search = "", others, heading, detail, onSkip?: () => void; /** What to search for as soon as the map opens, e.g. the address typed in the form. */ search?: string; + /** Searches to try in turn when the map opens (see `searchTries`); used instead of `search`. */ + tries?: string[]; + /** Borewells already placed at the same site or colony: the map opens on them. Only a view. */ + hint?: PlaceHint | null; others: Borewell[]; onClose: () => void; onPick: (p: Point) => void; }) { const [point, setPoint] = useState(initial); const map = useRef(null); + // Opened on the remembered place: close enough to click a spot, or to see a whole site. + const [[south, west], [north, east]] = hint?.bounds ?? [[0, 0], [0, 0]]; + const near = !initial && hint ? { center: [(south + north) / 2, (west + east) / 2] as [number, number], zoom: north - south < 0.004 && east - west < 0.004 ? 17 : 16 } : null; return ( !o && onClose()}> {heading ?? "Pick the borewell on the map"} - {detail && {detail}}Search for a place to go there, then zoom in and click exactly where the borewell is. Click again to move it. + + {detail && {detail}} + {near && hint + ? <>The map shows the {hint.borewells.length === 1 ? "borewell" : `${hint.borewells.length} borewells`} already placed that mention “{hint.word}”. Click exactly where this one is: each borewell on a site is in a different spot. + : "Search for a place to go there, then zoom in and click exactly where the borewell is. "} + Click again to move it. +
{open && ( <> - { map.current = m; }}> + { map.current = m; }}> + {near && hint?.borewells.map((b) => ( + + {b.borewellId} + + ))} {point && ( { setPoint({ latitude: p.latitude, longitude: p.longitude, found: [p.name, p.detail].filter(Boolean).join(", ") }); map.current?.setView([p.latitude, p.longitude], 17); diff --git a/app/src/pages/ImportPage.tsx b/app/src/pages/ImportPage.tsx index 06f5a4c..97dddb1 100644 --- a/app/src/pages/ImportPage.tsx +++ b/app/src/pages/ImportPage.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import type { Borewell, BorewellInput, ExcelParseResult, LocationSource, Material, ParseAnomaly, StrataLayer } from "@strata/core"; -import { parseCoordinatePair, parseNumber, splitPump } from "@strata/core"; +import { parseCoordinatePair, parseNumber, rememberedPlace, searchTries, splitPump } from "@strata/core"; import type { ColumnMapping } from "@strata/core/parser"; import { toast } from "sonner"; import { CircleCheck, FileSpreadsheet, MapPin, TriangleAlert, Upload, X, XCircle } from "lucide-react"; @@ -14,6 +14,7 @@ import { Field } from "@/components/app/Field"; import { Chip } from "@/components/app/Chip"; import { BorewellProfile } from "@/components/geology/BorewellProfile"; import { MapPicker } from "@/components/map/MapPicker"; +import { saveFile } from "@/lib/exporting"; import { api, files, isPreview } from "@/lib/api"; import { useBorewells, useDataVersion, useLoad } from "@/lib/data"; import { text } from "@/text"; @@ -90,7 +91,8 @@ function initialDetails(path: string, r: ExcelParseResult | null): Details { pumpModel: pump.model, pumpHp: n(m?.pumpHp), pumpLowering: n(m?.pumpLowering), - location: "", locationSource: "typed", + // A "Location = …" line on the log (the template has one): the position as written there. + location: m?.location ? `${m.location.latitude}, ${m.location.longitude}` : "", locationSource: "typed", }; } @@ -104,6 +106,12 @@ function fileNotes(f: ImportFile): string[] { ]; } +/** Where to look on the map for a borewell not imported yet: what to search for, and borewells already placed at the same site. */ +function whereToLook(d: Details, placed: Borewell[]) { + const asBorewell = { id: "import", borewellId: d.borewellId, ownerName: d.ownerName, houseNo: "", address: "", area: d.area, city: d.city, latitude: null, longitude: null } as Borewell; + return { tries: searchTries(d.area || d.ownerName || d.borewellId, d.city), hint: rememberedPlace(asBorewell, placed) }; +} + const unknownNames = (r: ExcelParseResult | null) => [...new Set((r?.strata ?? []).filter((l) => !l.materialId).map((l) => l.material.trim()).filter(Boolean))]; @@ -206,12 +214,27 @@ export function ImportPage() { const f = list[current]; + /** A blank drilling log laid out the way StrataField reads it, with an example and instructions. */ + const saveTemplate = async () => { + try { + const { logTemplateBytes } = await import("@strata/core/log-template"); + const path = await saveFile("StrataField drilling log template.xlsx", "excel", logTemplateBytes()); + if (path) toast.success("Template saved. Make one copy for each borewell and fill in the Log sheet."); + } catch (e) { + toast.error(String(e)); + } + }; + const lookList = picking != null && list[picking] ? whereToLook(list[picking].details, others) : null; + return ( {list.length ? "Add more files" : "Choose Excel files"}} + actions={<> + + + } /> {done && done.length > 0 && ( @@ -307,6 +330,8 @@ export function ImportPage() { open initial={parseCoordinatePair(list[picking].details.location)} search={parseCoordinatePair(list[picking].details.location) ? "" : [list[picking].details.area || list[picking].details.ownerName, list[picking].details.city].filter(Boolean).join(", ")} + tries={lookList?.tries} + hint={lookList?.hint} others={others} onClose={() => setPicking(null)} onPick={(at) => { detail(picking, { location: `${at.latitude.toFixed(6)}, ${at.longitude.toFixed(6)}`, locationSource: at.found ? "address" : "map" }); setPicking(null); }} @@ -360,6 +385,7 @@ function FileReview({ file: f, materials, takenIds, zones, others, onChange }: { const notes = (f.result?.anomalies ?? []).filter((a) => a.code !== "MATERIAL_UNKNOWN"); const idTaken = takenIds.has(d.borewellId.trim().toLowerCase()); const at = parseCoordinatePair(d.location); + const look = whereToLook(d, others); const strata: StrataLayer[] = (f.result?.strata ?? []).map((l, i) => ({ id: `${i}`, borewellId: "import", waterBearing: false, ...resolveLayer(l, f.resolutions, materials) })); const pipes = (f.result?.pipes ?? []).map((p, i) => ({ id: `p${i}`, borewellId: "import", startDepth: p.startDepth, endDepth: p.endDepth, pipeType: p.pipeType, pipeSubtype: p.pipeSubtype, diameter: null })); const preview = { @@ -434,6 +460,8 @@ function FileReview({ file: f, materials, takenIds, zones, others, onChange }: { open={picking} initial={at} search={at ? "" : [d.area, d.city].filter(Boolean).join(", ")} + tries={look.tries} + hint={look.hint} others={others} onClose={() => setPicking(false)} onPick={(p) => { set({ location: `${p.latitude.toFixed(6)}, ${p.longitude.toFixed(6)}`, locationSource: p.found ? "address" : "map" }); setPicking(false); }} diff --git a/app/src/pages/LocatePage.tsx b/app/src/pages/LocatePage.tsx index 14369d3..cacb01a 100644 --- a/app/src/pages/LocatePage.tsx +++ b/app/src/pages/LocatePage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Link } from "react-router-dom"; -import type { Borewell } from "@strata/core"; +import { rememberedPlace, searchTries, type Borewell } from "@strata/core"; import { toast } from "sonner"; import { CircleCheck, MapPin } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -31,6 +31,10 @@ export function LocatePage() { const [first, setFirst] = useState(null); const todo = waiting.filter((b) => !skipped.includes(b.id)).sort((x, y) => Number(y.id === first) - Number(x.id === first)); const b = todo[0]; + // Where to look for this one: borewells already placed at the same site, and what to search for. + // The address goes first; a site's name is often a landmark the map knows, so it is tried when there is no address. + const tries = b ? searchTries([b.houseNo, b.address, b.area].filter(Boolean).join(", ") || b.ownerName || b.borewellId, b.city) : []; + const hint = b ? rememberedPlace(b, borewells) : null; const place = async (target: Borewell, at: { latitude: number; longitude: number; found?: string }) => { setDone([...done, target.id]); @@ -77,8 +81,10 @@ export function LocatePage() { key={b.id} open={open} initial={null} - // A site's name is often a landmark the map knows (a hospital, an apartment block), so it is searched when there is no area. + // When the address has nothing particular to search for ("Station Road"), it is searched as it is. search={[b.area || b.address || b.ownerName, b.city].filter(Boolean).join(", ")} + tries={tries} + hint={hint} others={located} heading={`Where is ${b.borewellId}?`} detail={`${[b.ownerName !== b.borewellId && b.ownerName, where(b)].filter(Boolean).join(" · ") || "No address recorded"}. ${todo.length} to go.`} diff --git a/core/package.json b/core/package.json index 6590e60..7ad195c 100644 --- a/core/package.json +++ b/core/package.json @@ -21,6 +21,7 @@ "exports": { ".": "./src/index.ts", "./parser": "./src/parser/strataFieldParser.ts", - "./export": "./src/export/excel.ts" + "./export": "./src/export/excel.ts", + "./log-template": "./src/export/logTemplate.ts" } } diff --git a/core/src/export/logTemplate.test.ts b/core/src/export/logTemplate.test.ts new file mode 100644 index 0000000..09060e5 --- /dev/null +++ b/core/src/export/logTemplate.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import { parseStrataLogs, readSheetRows } from '../parser/strataFieldParser'; +import { logTemplateBytes, logTemplateWorkbook, logTemplateRows, TEMPLATE_EXAMPLE } from './logTemplate'; + +/** The log as it comes back after being saved as an .xlsx file and opened again. */ +const roundTrip = (wb: XLSX.WorkBook) => readSheetRows(new Uint8Array(XLSX.write(wb, { type: 'array', bookType: 'xlsx' }))); + +describe('the Excel log template', () => { + it('reads back every detail filled in, exactly', () => { + const [log] = parseStrataLogs(logTemplateRows(TEMPLATE_EXAMPLE)); + expect(log.success).toBe(true); + expect(log.anomalies.filter((a) => a.severity === 'critical')).toEqual([]); + expect(log.metadata).toMatchObject({ + siteName: 'Sharma Residence', ownerName: 'Sharma Residence', address: 'B-12, Sector 4, Vikas Nagar', city: 'Lucknow', + date: '2026-03-14', boreDia: 10, pipeDia: 4, totalDepth: 400, waterLevel: 95, pumpLowering: 220, + pumpHp: 15, pumpModel: 'KSB 3C/15', detectedUnit: 'ft', location: { latitude: 26.8912, longitude: 80.9876 }, + }); + }); + + it('reads back every layer and every pipe, in feet', () => { + const [log] = parseStrataLogs(logTemplateRows(TEMPLATE_EXAMPLE)); + expect(log.strata.map((l) => [l.startDepth, l.endDepth, l.materialId])).toEqual([ + [0, 20, 'clay'], [20, 50, 'medium_sand'], [50, 100, 'clay'], [100, 140, 'coarse_sand'], [140, 180, 'clay_kankar'], + [180, 230, 'medium_sand'], [230, 310, 'clay'], [310, 360, 'fine_sand'], [360, 400, 'clay'], + ]); + expect(log.pipes.map((p) => [p.startDepth, p.endDepth, p.pipeType])).toEqual([ + [0, 100, 'plain'], [100, 140, 'slotted'], [140, 180, 'plain'], [180, 230, 'slotted'], [230, 400, 'plain'], + ]); + expect(log.strata.every((l) => l.materialId)).toBe(true); // every soil name on the list is recognised + }); + + it('survives being saved and opened as a real Excel file', () => { + const [log] = parseStrataLogs(roundTrip(logTemplateWorkbook()).length ? roundTrip(exampleOnly()) : []); + expect(log.metadata.waterLevel).toBe(95); + expect(log.strata).toHaveLength(9); + }); + + it('opens on the blank log, which reads as an empty log rather than as something else', () => { + const rows = roundTrip(logTemplateWorkbook()); + const [log] = parseStrataLogs(rows); + expect(log.strata).toEqual([]); + expect(log.metadata.location).toBeNull(); + expect(log.anomalies.some((a) => a.code === 'NON_STANDARD_FORMAT')).toBe(false); + }); + + it('saves as an Excel file that opens on the blank log', () => { + const wb = XLSX.read(logTemplateBytes(), { type: 'array' }); + expect(wb.SheetNames[0]).toBe('Log'); + }); + + it('lists every built-in soil name on its own sheet, and how to fill it in', () => { + const wb = logTemplateWorkbook(); + expect(wb.SheetNames).toEqual(['Log', 'Example', 'Soil names', 'How to fill it in']); + const soils = XLSX.utils.sheet_to_json(wb.Sheets['Soil names'], { header: 1 }).flat(); + expect(soils).toEqual(expect.arrayContaining(['Clay', 'Fine Sand', 'Coarse Sand', 'Kankar', 'Plain pipe', 'Slotted pipe'])); + }); +}); + +/** The template with its example as the first sheet, as a driller's filled-in copy would be. */ +function exampleOnly() { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(logTemplateRows(TEMPLATE_EXAMPLE)), 'Log'); + return wb; +} + +describe('a Location line in any log', () => { + const log = (line: string) => parseStrataLogs(logTemplateRows({ ...TEMPLATE_EXAMPLE, location: '' }).map((r) => (String(r[0] ?? '').startsWith('Location') ? [line, ...r.slice(1)] : r)))[0]; + + it('reads numbers or a Google Maps link', () => { + expect(log('Location = 26.8512, 80.9467').metadata.location).toEqual({ latitude: 26.8512, longitude: 80.9467 }); + expect(log('Location = https://maps.google.com/?q=26.8512,80.9467').metadata.location).toEqual({ latitude: 26.8512, longitude: 80.9467 }); + expect(log('GPS : 26.8512 N 80.9467 E').metadata.location).toEqual({ latitude: 26.8512, longitude: 80.9467 }); + }); + + it('reads nothing from a line that is not a position', () => { + expect(log('Location = near the water tank').metadata.location).toBeNull(); + }); +}); + +describe('a title above the chart', () => { + it('is not taken for the chart heading, even when it says "Streta Chart"', () => { + const rows = logTemplateRows(TEMPLATE_EXAMPLE); + rows[0] = ['STRETA CHART OF TUBE WELL']; + const [log] = parseStrataLogs(rows); + expect(log.success).toBe(true); + expect(log.strata).toHaveLength(9); + expect(log.anomalies.some((a) => a.code === 'MULTI_BOREWELL_SHEET')).toBe(false); + }); +}); diff --git a/core/src/export/logTemplate.ts b/core/src/export/logTemplate.ts new file mode 100644 index 0000000..f12a0a9 --- /dev/null +++ b/core/src/export/logTemplate.ts @@ -0,0 +1,128 @@ +/** + * An Excel template for drilling logs, laid out exactly as StrataField's reader expects (the usual + * field log: details down column A, soil chart in B–D, pipe in F–H). Filled in and imported, every + * detail comes back without anything to check. Built from code so the template and the reader can + * be tested together (logTemplate.test.ts). + */ +import * as XLSX from 'xlsx'; +import { DEFAULT_MATERIALS } from '../constants'; + +type Cell = string | number | null; + +export interface TemplateValues { + name: string; + address: string; + city: string; + /** Inches. */ + boreDia: string; + pipeDia: string; + /** Feet. */ + totalDepth: string; + waterLevel: string; + pumpLowering: string; + pump: string; + /** DD-MM-YYYY. */ + date: string; + /** "26.8512, 80.9467" or a Google Maps link. */ + location: string; + /** Each layer by the depth of its bottom, in feet, top first. */ + layers: [number, string][]; + /** Each pipe piece by the depth of its bottom, in feet; each must end at a layer's bottom. */ + pipes: [number, string][]; +} + +export const BLANK: TemplateValues = { + name: '', address: '', city: 'Lucknow', boreDia: '', pipeDia: '', totalDepth: '', waterLevel: '', + pumpLowering: '', pump: '', date: '', location: '', layers: [], pipes: [], +}; + +export const TEMPLATE_EXAMPLE: TemplateValues = { + name: 'Sharma Residence', address: 'B-12, Sector 4, Vikas Nagar', city: 'Lucknow', + boreDia: '10', pipeDia: '4', totalDepth: '400', waterLevel: '95', pumpLowering: '220', pump: 'KSB 3C/15 , 15 HP', + date: '14-03-2026', location: '26.8912, 80.9876', + layers: [[20, 'Clay'], [50, 'Sand'], [100, 'Clay'], [140, 'Coarse Sand'], [180, 'Clay Kankar'], [230, 'Sand'], [310, 'Clay'], [360, 'Fine Sand'], [400, 'Clay']], + pipes: [[100, 'Plain pipe'], [140, 'Slotted pipe'], [180, 'Plain pipe'], [230, 'Slotted pipe'], [400, 'Plain pipe']], +}; + +/** Rows for the layer chart in a blank log: enough for a deep borewell with many layers. */ +const BLANK_LAYER_ROWS = 60; + +/** + * The log as rows of cells. The title must not contain "strata chart" or "lowering assembly": the + * reader looks for those words to find where the chart starts. + */ +export function logTemplateRows(v: TemplateValues = BLANK): Cell[][] { + const details: string[] = [ + v.name, v.address, v.city, '', + `Pump Lowering = ${v.pumpLowering}${v.pumpLowering ? ' ft' : ''}`, + `Pump = ${v.pump}`, + `Water Level = ${v.waterLevel}${v.waterLevel ? ' ft' : ''}`, + `Bore Dia = ${v.boreDia}${v.boreDia ? '"' : ''} / ${v.totalDepth} ft`, + `Tube Well = ${v.pipeDia}${v.pipeDia ? '"' : ''} / ${v.totalDepth} ft`, + `Date = ${v.date}`, + `Location = ${v.location}`, + ]; + const pipes = new Map(v.pipes); + const count = Math.max(v.layers.length || BLANK_LAYER_ROWS, details.length); + const rows: Cell[][] = [ + ['Tube well record — Drinking Water Organisation'], + [], + [null, null, 'Strata Chart', null, null, 'Lowering Assembly'], + ['Site:', 'G. L.', v.boreDia ? `${v.boreDia}"` : null, 'G. L.', null, 'G. L.', v.pipeDia ? `${v.pipeDia}"` : null], + ]; + for (let i = 0; i < count; i++) { + const [bottom, soil] = v.layers[i] ?? [null, null]; + const pipe = bottom != null ? pipes.get(bottom) : undefined; + rows.push([details[i] || null, bottom, null, soil, null, pipe ?? null, null, pipe ? bottom : null]); + } + return rows; +} + +const PIPE_NAMES = ['Plain pipe', 'Slotted pipe', 'Ribbed screen', 'Blind pipe']; + +const HOW_TO = [ + ['How to fill in the log'], + [], + ['Make one copy of this file for each borewell, and fill in the sheet called "Log". Keep it as the first sheet.'], + ['The sheet called "Example" shows a borewell filled in. StrataField reads the first sheet only.'], + [], + ['Column A, under "Site:"'], + [' Line 1: the site or owner name. Line 2: the address (house or plot number, sector, colony). Line 3: the city.'], + [' Then leave one line empty.'], + [' Then write each number after the "=" already there. Depths in feet.'], + [' Date: day-month-year, for example 14-03-2026.'], + [' Location: stand at the borewell, open Google Maps on the phone, press and hold on the blue dot,'], + [' and copy the numbers that appear (for example 26.8912, 80.9876). A Google Maps link also works.'], + [], + ['Columns B and D: the soil layers, one row per layer, from the top down'], + [' B: the depth in feet where the layer ends (its bottom). D: the soil, written as on the "Soil names" sheet.'], + [' Leave the remaining rows empty. Do not write depths below the bottom of the borewell.'], + [], + ['Columns F and H: the pipe lowered, one row per piece, from the top down'], + [' F: the kind of pipe (see "Soil names"). H: the depth in feet where that piece ends.'], + [' Write a pipe piece on the row of the layer whose bottom is at the same depth.'], + [], + ['Do not move the headings, add columns, or merge cells.'], +]; + +/** The template as an .xlsx file's bytes, ready to save. */ +export const logTemplateBytes = () => new Uint8Array(XLSX.write(logTemplateWorkbook(), { type: 'array', bookType: 'xlsx' })); + +/** The template workbook: the blank log first, then an example, the names to use, and instructions. */ +export function logTemplateWorkbook(): XLSX.WorkBook { + const wb = XLSX.utils.book_new(); + const widths = [{ wch: 42 }, { wch: 10 }, { wch: 4 }, { wch: 18 }, { wch: 4 }, { wch: 18 }, { wch: 6 }, { wch: 10 }]; + for (const [name, values] of [['Log', BLANK], ['Example', TEMPLATE_EXAMPLE]] as const) { + const sheet = XLSX.utils.aoa_to_sheet(logTemplateRows(values)); + sheet['!cols'] = widths; + XLSX.utils.book_append_sheet(wb, sheet, name); + } + const soils = DEFAULT_MATERIALS.filter((m) => m.lithologyFamily !== 'NONE').map((m) => [m.name]); + const names = XLSX.utils.aoa_to_sheet([['Soil names (column D)'], ...soils, [], ['Pipe names (column F)'], ...PIPE_NAMES.map((p) => [p])]); + names['!cols'] = [{ wch: 28 }]; + XLSX.utils.book_append_sheet(wb, names, 'Soil names'); + const how = XLSX.utils.aoa_to_sheet(HOW_TO); + how['!cols'] = [{ wch: 110 }]; + XLSX.utils.book_append_sheet(wb, how, 'How to fill it in'); + return wb; +} diff --git a/core/src/index.ts b/core/src/index.ts index ec712b8..6b9968f 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -13,3 +13,4 @@ export * from './pump'; export * from './waterMap'; export * from './section'; export * from './elevation'; +export * from './places'; diff --git a/core/src/parser/strataFieldParser.ts b/core/src/parser/strataFieldParser.ts index 528e7ab..4afa7bd 100644 --- a/core/src/parser/strataFieldParser.ts +++ b/core/src/parser/strataFieldParser.ts @@ -20,6 +20,7 @@ import * as xlsx from 'xlsx'; import { MATERIAL_NORMALISATION_MAP, PIPE_SUBTYPE_MAP, DEFAULT_MATERIALS, METRES_TO_FEET } from '../constants'; +import { coordinatesIn } from '../places'; import type { ExcelParseResult, ParsedBoreholeMetadata, ParsedStrataLayer, ParsedPipeSegment, ParseAnomaly, AnomalyCode, DepthUnit, PipeSubtype @@ -66,7 +67,12 @@ export function readSheetRows(data: Uint8Array | ArrayBuffer): any[][] { const num = (cell: unknown) => parseFloat(String(cell ?? '')); const round1 = (n: number) => Math.round(n * 10) / 10; -const cellAt = (row: any[] | undefined, re: RegExp) => (row || []).findIndex(c => typeof c === 'string' && re.test(c)); +/** + * A heading cell: a few words ("Streta Chart", "Lowering Assambly"). A longer line that mentions one, + * like the title "STRETA CHART OF TUBE WELL" above many logs, is not where the chart starts. + */ +const isHeading = (c: unknown, re: RegExp) => typeof c === 'string' && re.test(c) && c.trim().split(/\s+/).length <= 3; +const cellAt = (row: any[] | undefined, re: RegExp) => (row || []).findIndex(c => isHeading(c, re)); /** A size written like 15" (inches). */ const inches = (cell: unknown) => { const m = /^\s*([0-9.]+)\s*"/.exec(String(cell ?? '')); return m ? parseFloat(m[1]) : null; }; /** A soil or pipe name: a word, not a depth ("16 mt"), a size (15") or a ground-level mark ("G. L."). */ @@ -155,7 +161,7 @@ export function parseStrataRows(rows: any[][], mapping?: ColumnMapping): ExcelPa // The user has said where the layers are; rows above them are read for details. headerRowIdx = mapping.firstRow; } else { - const headers = rows.map((r, i) => (STRETA_HEADER_PATTERNS.some(p => p.test((r || []).join(' '))) ? i : -1)).filter(i => i >= 0); + const headers = rows.map((r, i) => ((r || []).some(c => STRETA_HEADER_PATTERNS.some(p => isHeading(c, p))) ? i : -1)).filter(i => i >= 0); headerRowIdx = headers.find(i => cellAt(rows[i], SOIL_HEADING) >= 0) ?? headers[0] ?? -1; if (headerRowIdx === -1) { addAnomaly(anomalies, 'NON_STANDARD_FORMAT', 'critical', @@ -415,6 +421,7 @@ function extractMetadata(rows: any[][], endIdx: number): ParsedBoreholeMetadata pumpLowering: null, pumpHp: null, pumpModel: null, + location: null, detectedUnit: 'ft', }; @@ -477,6 +484,10 @@ function extractMetadata(rows: any[][], endIdx: number): ParsedBoreholeMetadata } } + // Where the borewell is: "Location = 26.8512, 80.9467" or a Google Maps link. + const where = /^(?:location|gps|google\s*maps?)\s*[:=]\s*(.*)$/i.exec(col0); + if (where && meta.location === null) meta.location = coordinatesIn(where[1]); + // Date const dateMatch = col0.match(/date\s*[:=]\s*([0-9\-/.]+)/i); if (dateMatch) { @@ -651,7 +662,7 @@ function makeFailResult(message: string, anomalies: ParseAnomaly[]): ExcelParseR metadata: { siteName: null, ownerName: null, address: null, city: null, date: null, boreDia: null, pipeDia: null, totalDepth: null, waterLevel: null, - pumpLowering: null, pumpHp: null, pumpModel: null, + pumpLowering: null, pumpHp: null, pumpModel: null, location: null, detectedUnit: 'ft', }, strata: [], diff --git a/core/src/places.test.ts b/core/src/places.test.ts new file mode 100644 index 0000000..68767f2 --- /dev/null +++ b/core/src/places.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import type { Borewell, Place } from './types'; +import { coordinatesIn, placeFits, rememberedPlace, searchTries, tellingWords } from './places'; + +const bw = (id: string, over: Partial = {}) => + ({ id, borewellId: id, ownerName: '', houseNo: '', address: '', area: '', city: 'Lucknow', latitude: null, longitude: null, locationSource: 'unknown', ...over }) as Borewell; +const place = (name: string, detail: string, latitude = 26.85, longitude = 80.95): Place => ({ name, detail, latitude, longitude }); + +describe('tellingWords', () => { + it('keeps the words that say where a place is, and drops the ones that do not', () => { + expect(tellingWords('A- 705 , Sect - C, Mahanagar')).toEqual(['mahanagar']); + expect(tellingWords('Ekana stadium gate no 2 & 4')).toEqual(['ekana']); + expect(tellingWords('Bank of India Colony')).toEqual(['bank', 'india']); + }); +}); + +describe('searchTries', () => { + it('searches the address as written first, plot number and all', () => { + expect(searchTries('A- 705 , Sect - C, Mahanagar', 'Lucknow')[0]).toBe('A- 705 , Sect - C, Mahanagar, Lucknow'); + }); + + it('then tries other spellings of the same place, without changing what is stored', () => { + const tries = searchTries('Beverly Park, New Haidrabad', 'Lucknow'); + expect(tries.slice(0, 2)).toEqual(['Beverly Park, New Haidrabad, Lucknow', 'Beverly Park, New Hyderabad, Lucknow']); + expect(searchTries('Chinhut Kamta', 'Lucknow')).toContain('Chinhat Kamta, Lucknow'); + expect(searchTries('Dewa Road', 'Lucknow')).toContain('Deva Road, Lucknow'); + expect(searchTries('Sector 4, Gomti Nagar Extention', 'Lucknow')).toContain('Sector 4, Gomti Nagar Extension, Lucknow'); + }); + + it('then ever shorter parts, keeping sector and block', () => { + expect(searchTries('A- 705 , Sect - C, Mahanagar', 'Lucknow')).toEqual([ + 'A- 705 , Sect - C, Mahanagar, Lucknow', + 'A- 705 , Sector - C, Mahanagar, Lucknow', + 'Sect - C, Mahanagar, Lucknow', + 'Sector - C, Mahanagar, Lucknow', + 'Mahanagar, Lucknow', + ]); + }); + + it('never tries a part with nothing telling in it', () => { + expect(searchTries('21, Gate No. 4', 'Lucknow')).toEqual([]); + }); + + it('has nothing to try without an address', () => { + expect(searchTries('', 'Lucknow')).toEqual([]); + }); +}); + +describe('placeFits', () => { + it('takes a place that has every telling word of the search', () => { + expect(placeFits('Gokhale Marg, Lucknow', place('Gokhale Marg', 'Civil Lines, Lucknow'))).toBe(true); + }); + + it('refuses a place that only shares a common word', () => { + expect(placeFits('Prag Narain Road, Lucknow', place('Jagat Narain Road', 'Qaisarbagh, Lucknow'))).toBe(false); + expect(placeFits('U P East, Lucknow', place('The East End Mall', 'Indira Nagar, Lucknow'))).toBe(false); + }); + + it('refuses a place of the same name far from Lucknow', () => { + expect(placeFits('Talkatora Industrial Estate, Lucknow', place('Talkatora', 'Lucknow Estate, Karachi', 24.86, 67.0))).toBe(false); + }); +}); + +describe('rememberedPlace', () => { + const ekana1 = bw('Ekana stadium gate no 2', { latitude: 26.811, longitude: 81.018, locationSource: 'map' }); + const ekana2 = bw('Ekana Guest house', { latitude: 26.814, longitude: 81.021, locationSource: 'map' }); + const other = bw('Rajendra Nagar', { area: 'Rajendra Nagar', latitude: 26.86, longitude: 80.91, locationSource: 'map' }); + + it('frames the borewells already placed at the same site', () => { + const hint = rememberedPlace(bw('Ekana Boys hostel 8'), [ekana1, ekana2, other])!; + expect(hint.word).toBe('ekana'); + expect(hint.borewells.map((b) => b.borewellId)).toEqual(['Ekana stadium gate no 2', 'Ekana Guest house']); + expect(hint.bounds).toEqual([[26.811, 81.018], [26.814, 81.021]]); + }); + + it('is only ever a view: it never gives the borewell a position', () => { + expect(rememberedPlace(bw('Ekana Boys hostel 8'), [ekana1])).not.toHaveProperty('latitude'); + }); + + it('looks in the address too', () => { + expect(rememberedPlace(bw('Mr Kapoor', { area: 'Rajendra Nagar' }), [ekana1, other])?.word).toBe('rajendra'); + }); + + it('prefers the most particular word when several are shared', () => { + const gomti = [1, 2, 3].map((i) => bw(`House ${i}`, { area: 'Gomti Nagar', latitude: 26.85 + i / 100, longitude: 80.99, locationSource: 'map' })); + const vipul = bw('School', { area: 'Vipul Khand, Gomti Nagar', latitude: 26.86, longitude: 81.0, locationSource: 'map' }); + expect(rememberedPlace(bw('New one', { area: 'Vipul Khand, Gomti Nagar' }), [...gomti, vipul])?.word).toBe('vipul'); + }); + + it('finds nothing when nothing telling is shared, and never matches a borewell with itself', () => { + expect(rememberedPlace(bw('Bank of India Colony'), [ekana1, other])).toBeNull(); + expect(rememberedPlace(ekana1, [ekana1])).toBeNull(); + }); +}); + +describe('coordinatesIn', () => { + it('reads a pair of numbers as phones and map apps copy them', () => { + expect(coordinatesIn('26.851234, 80.946789')).toEqual({ latitude: 26.851234, longitude: 80.946789 }); + }); + + it('reads the position out of a Google Maps link', () => { + expect(coordinatesIn('https://www.google.com/maps/place/26.8512,80.9467/@26.8512,80.9467,17z')).toEqual({ latitude: 26.8512, longitude: 80.9467 }); + expect(coordinatesIn('https://maps.google.com/?q=26.8512,80.9467')).toEqual({ latitude: 26.8512, longitude: 80.9467 }); + }); + + it('reads nothing from text that is not a position', () => { + expect(coordinatesIn('Near the water tank')).toBeNull(); + expect(coordinatesIn('')).toBeNull(); + }); +}); diff --git a/core/src/places.ts b/core/src/places.ts new file mode 100644 index 0000000..5b4aa90 --- /dev/null +++ b/core/src/places.ts @@ -0,0 +1,113 @@ +/** + * Help with giving borewells a location: what to search for, which search results to trust, and + * which borewells already placed nearby to show. Nothing here ever gives a borewell a position by + * itself; a person always clicks the spot. + */ +import type { Borewell, Place } from './types'; +import { parseCoordinatePair } from './numbers'; +import { kmBetween } from './waterMap'; + +/** Words that say nothing about where a place is: sharing one of these is not sharing a place. */ +const COMMON = new Set([ + 'road', 'marg', 'nagar', 'colony', 'sector', 'sect', 'khand', 'block', 'phase', 'plot', 'house', 'near', 'the', 'and', 'opp', + 'east', 'west', 'north', 'south', 'new', 'old', 'gate', 'city', 'lucknow', 'tower', 'hall', 'apartment', 'apartments', 'apt', + 'residence', 'ground', 'campus', 'residential', 'industrial', 'estate', 'area', 'school', 'college', 'hospital', 'office', + 'station', 'stadium', 'tube', 'well', 'tubewell', 'chart', 'streta', 'strata', 'sterta', 'foot', 'ball', 'banquet', 'yojna', + 'enclave', 'boys', 'girls', 'hostel', 'guest', 'over', 'head', 'tank', 'capacity', 'rwh', 'site', 'mr', 'mrs', 'dr', 'shri', +]); + +/** The words of a name or address that say where it is, lower case, each once. */ +export function tellingWords(text: string): string[] { + return [...new Set((text.toLowerCase().match(/[a-z]{3,}/g) ?? []).filter((w) => !COMMON.has(w)))]; +} + +/** + * Other spellings of the same places, as the map knows them. These are not corrections: the + * address is always searched as written first, and what is stored is never changed. + */ +const OTHER_SPELLINGS: [RegExp, string][] = [ + [/\bhai?d[ae]?rabad\b/gi, 'Hyderabad'], + [/\bchinhut\b/gi, 'Chinhat'], + [/\bdewa\b/gi, 'Deva'], + [/\bextention\b/gi, 'Extension'], + [/\bsiultanpur\b/gi, 'Sultanpur'], + [/\bsect\b/gi, 'Sector'], +]; +const otherSpelling = (text: string) => OTHER_SPELLINGS.reduce((t, [from, to]) => t.replace(from, to), text); + +/** + * What to search for to find an address, best first: all of it as written (plot number and all), + * then with other spellings, then ever shorter parts ("Sect C, Mahanagar", then "Mahanagar"). A + * part with nothing telling in it is not searched. + */ +export function searchTries(address: string, city: string): string[] { + const parts = address.split(',').map((p) => p.trim()).filter(Boolean); + if (parts.length === 0) return []; + const town = city.trim(); + const withTown = (text: string) => (town && !parts[parts.length - 1].toLowerCase().includes(town.toLowerCase()) ? `${text}, ${town}` : text); + const tries: string[] = []; + parts.forEach((_, i) => { + const tail = i === 0 ? address.trim() : parts.slice(i).join(', '); + if (tellingWords(tail).length === 0) return; + for (const t of [tail, otherSpelling(tail)]) if (!tries.includes(withTown(t))) tries.push(withTown(t)); + }); + return tries; +} + +/** Search results from further than this from the middle of Lucknow are some other place. */ +const REACH_KM = 40; +const CENTRE = { latitude: 26.85, longitude: 80.95 }; + +/** + * Whether a search result really is what was searched for: it must have every telling word of the + * search in its name or surroundings, and be within reach of Lucknow. Searches fall back on loose + * matches ("Prag Narain Road" finds Jagat Narain Road), which are worse than nothing. + */ +export function placeFits(searched: string, place: Place): boolean { + const wanted = tellingWords(searched); + const found = `${place.name} ${place.detail}`.toLowerCase(); + return wanted.length > 0 && wanted.every((w) => found.includes(w)) + && kmBetween(place.latitude, place.longitude, CENTRE.latitude, CENTRE.longitude) <= REACH_KM; +} + +export interface PlaceHint { + /** The telling word the borewells share, e.g. "ekana". */ + word: string; + /** The borewells already placed that mention it. */ + borewells: Borewell[]; + /** South-west and north-east corners around them, for framing the map. */ + bounds: [[number, number], [number, number]]; +} + +const placed = (b: Borewell) => b.latitude != null && b.longitude != null; +const describe = (b: Borewell) => [b.borewellId, b.ownerName, b.houseNo, b.address, b.area].filter(Boolean).join(' '); + +/** + * Borewells already placed at the same site or colony as `target`, judged by the most particular + * telling word they share (the one fewest others have). This only says where to look: each + * borewell on a big site is somewhere different, so the spot is always clicked. + */ +export function rememberedPlace(target: Borewell, borewells: Borewell[]): PlaceHint | null { + const others = borewells.filter((b) => b.id !== target.id && placed(b)).map((b) => ({ b, words: tellingWords(describe(b)) })); + let best: { word: string; borewells: Borewell[] } | null = null; + for (const word of tellingWords(describe(target))) { + const sharing = others.filter((o) => o.words.includes(word)).map((o) => o.b); + if (sharing.length > 0 && (!best || sharing.length < best.borewells.length)) best = { word, borewells: sharing }; + } + if (!best) return null; + const lats = best.borewells.map((b) => b.latitude!), lons = best.borewells.map((b) => b.longitude!); + return { ...best, bounds: [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]] }; +} + +/** + * A position written as numbers ("26.8512, 80.9467") or inside a Google Maps link + * (".../@26.8512,80.9467,17z" or "?q=26.8512,80.9467"); null when there is none. + */ +export function coordinatesIn(text: string): { latitude: number; longitude: number } | null { + const pair = parseCoordinatePair(text); + if (pair) return pair; + const inLink = /(-?\d{1,2}\.\d{3,})\s*,\s*(-?\d{1,3}\.\d{3,})/.exec(text); + if (!inLink) return null; + const [latitude, longitude] = [Number(inLink[1]), Number(inLink[2])]; + return Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 ? { latitude, longitude } : null; +} diff --git a/core/src/types.ts b/core/src/types.ts index 36c5572..c6f038f 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -247,6 +247,8 @@ export interface ParsedBoreholeMetadata { pumpLowering: number | null; // always in feet pumpHp: number | null; pumpModel: string | null; + /** From a "Location = …" line: numbers or a Google Maps link. */ + location: { latitude: number; longitude: number } | null; detectedUnit: DepthUnit; } diff --git a/e2e/run.mjs b/e2e/run.mjs index c13c9d5..ad3a78f 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -518,7 +518,8 @@ try { // Choosing one from the list puts it next; a click on the map and "Use this location" saves it. [...document.querySelectorAll('main li')].find(li => /Two wells 2/.test(li.innerText)).querySelector('button').click(); await __t.until(() => title() === 'Where is Two wells 2?', 8000); - const prefilled = document.querySelector('[role="combobox"][aria-label="Search for a place"]').value; + // The address is searched in a few ways first, so the box fills in once that is done. + const prefilled = await __t.until(() => document.querySelector('[role="combobox"][aria-label="Search for a place"]')?.value, 20000); const m = await __t.until(() => document.querySelector('[role="dialog"] .leaflet-container')); await __t.wait(800); const box = m.getBoundingClientRect(); m.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: box.left + box.width / 2 + 40, clientY: box.top + box.height / 2 + 30 })); await __t.wait(300);