Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 51 additions & 9 deletions app/src/components/map/MapPicker.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Place[]>([]);
const [active, setActive] = useState(0);
const [status, setStatus] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(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<string | null>(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; }
Expand Down Expand Up @@ -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. */
Expand All @@ -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<Point | null>(initial);
const map = useRef<LeafletMap | null>(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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{heading ?? "Pick the borewell on the map"}</DialogTitle>
<DialogDescription>{detail && <span className="mb-1 block text-foreground">{detail}</span>}Search for a place to go there, then zoom in and click exactly where the borewell is. Click again to move it.</DialogDescription>
<DialogDescription>
{detail && <span className="mb-1 block text-foreground">{detail}</span>}
{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.
</DialogDescription>
</DialogHeader>
<div className="relative h-[60vh] min-h-[360px] overflow-hidden rounded-md border border-border">
{open && (
<>
<BaseMap center={initial ? [initial.latitude, initial.longitude] : undefined} zoom={initial ? 16 : 12} onReady={(m) => { map.current = m; }}>
<BaseMap center={initial ? [initial.latitude, initial.longitude] : near?.center} zoom={initial ? 16 : near?.zoom ?? 12} onReady={(m) => { map.current = m; }}>
<BorewellPins borewells={others} faint />
{near && hint?.borewells.map((b) => (
<CircleMarker key={b.id} center={[b.latitude!, b.longitude!]} radius={6} interactive={false} pathOptions={{ color: "#16202a", weight: 2, fill: false }}>
<Tooltip permanent direction="top" offset={[0, -6]}>{b.borewellId}</Tooltip>
</CircleMarker>
))}
<ClickToPlace onPick={setPoint} />
{point && (
<CircleMarker
Expand All @@ -139,6 +180,7 @@ export function MapPicker({ open, initial, search = "", others, heading, detail,
</BaseMap>
<PlaceSearch
initial={search}
tries={initial ? undefined : tries}
onChoose={(p) => {
setPoint({ latitude: p.latitude, longitude: p.longitude, found: [p.name, p.detail].filter(Boolean).join(", ") });
map.current?.setView([p.latitude, p.longitude], 17);
Expand Down
34 changes: 31 additions & 3 deletions app/src/pages/ImportPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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",
};
}

Expand All @@ -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))];

Expand Down Expand Up @@ -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 (
<Page className="max-w-[1400px]">
<PageHeader
title={text.pages.import.title}
sub="Add borewells from Excel drilling logs. Check each one before importing. The original file is kept with the borewell."
actions={<Button variant="outline" onClick={choose} disabled={busy || isPreview}><Upload />{list.length ? "Add more files" : "Choose Excel files"}</Button>}
actions={<>
<Button variant="ghost" onClick={saveTemplate} disabled={isPreview}><FileSpreadsheet />Save an Excel template</Button>
<Button variant="outline" onClick={choose} disabled={busy || isPreview}><Upload />{list.length ? "Add more files" : "Choose Excel files"}</Button>
</>}
/>

{done && done.length > 0 && (
Expand Down Expand Up @@ -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); }}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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); }}
Expand Down
10 changes: 8 additions & 2 deletions app/src/pages/LocatePage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,6 +31,10 @@ export function LocatePage() {
const [first, setFirst] = useState<string | null>(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]);
Expand Down Expand Up @@ -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.`}
Expand Down
3 changes: 2 additions & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading
Loading