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
57 changes: 48 additions & 9 deletions app/src-tauri/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StrataLayer> = 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(())
})
Expand Down
78 changes: 34 additions & 44 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<State>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<View>("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) } }),
);
}, []);
Expand All @@ -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 (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b px-4 py-2">
<header className="flex items-center gap-6 border-b px-4 py-2">
<h1 className="text-base font-semibold">{text.appName}</h1>
<Button variant="outline" size="sm" onClick={load}><RefreshCw />{text.refresh}</Button>
{records && (
<nav className="flex gap-1">
{(["map", "quality"] as const).map((v) => (
<Button key={v} variant={view === v ? "secondary" : "ghost"} size="sm" aria-current={view === v ? "page" : undefined} onClick={() => setView(v)}>
{text.tabs[v]}
</Button>
))}
</nav>
)}
<Button variant="outline" size="sm" className="ml-auto" onClick={load}><RefreshCw />{text.refresh}</Button>
</header>
<Body state={state} selectedId={selectedId} onSelect={setSelectedId} />
</div>
);
}

function Body({ state, selectedId, onSelect }: { state: State; selectedId: string | null; onSelect: (id: string | null) => void }) {
if (!state) return <Message>{text.loading}</Message>;
if ("problem" in state) {
const p = state.problem;
return <Message detail={p.kind === "other" ? p.message : undefined}>{text.problems[p.kind]}</Message>;
}
const { sites } = state.records;
if (sites.length === 0) return <Message>{text.empty}</Message>;

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 (
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-1"><SiteMap sites={sites} selectedId={selected?.id ?? null} onSelect={onSelect} /></div>
<aside className="w-80 shrink-0 space-y-4 overflow-y-auto border-l p-4 text-sm">
<div>
<p className="font-medium">{text.onMap(shown)}</p>
{sites.length > shown && <p className="text-muted-foreground">{text.notShown(sites.length - shown)}</p>}
</div>
{selected ? (
<div className="space-y-2 border-t pt-4">
<h2 className="text-base font-semibold">{selected.borewellId}</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt className="text-muted-foreground">{text.area}</dt><dd>{selected.area || text.notRecorded}</dd>
<dt className="text-muted-foreground">{text.depth}</dt><dd>{selected.totalDepth != null ? `${selected.totalDepth} ${selected.depthUnit}` : text.notRecorded}</dd>
<dt className="text-muted-foreground">{text.drilled}</dt><dd>{selected.date || text.notRecorded}</dd>
</dl>
<p className="text-muted-foreground">{text.openInStrataField}</p>
</div>
) : (
<p className="border-t pt-4 text-muted-foreground">{text.selectHint}</p>
{!state ? <Message>{text.loading}</Message>
: "problem" in state ? <Message detail={state.problem.kind === "other" ? state.problem.message : undefined}>{text.problems[state.problem.kind]}</Message>
: !records ? <Message>{text.empty}</Message>
: (
<>
{/* The map stays mounted behind Data quality, so it keeps its place and the picked spot. */}
<div className={view === "map" ? "flex min-h-0 flex-1" : "hidden"}><MapView records={records} model={model} onHowGood={() => setView("quality")} /></div>
{view === "quality" && <QualityView model={model} />}
</>
)}
</aside>
</div>
);
}
Expand Down
136 changes: 136 additions & 0 deletions app/src/components/ColumnDrawing.tsx
Original file line number Diff line number Diff line change
@@ -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.
<svg viewBox={`0 0 ${W} ${TOP + H + 10}`} width={W} role={clickable ? "group" : "img"} aria-label={label} className="max-w-full text-[11px]">
{ticks.map((d) => <line key={d} x1={COL_X - 5} x2={COL_X} y1={y(d)} y2={y(d)} stroke="var(--muted-foreground)" />)}
{numbered.map((d) => <text key={d} x={COL_X - 8} y={y(d) + 4} textAnchor="end" fill="var(--muted-foreground)">{Math.round(d)}</text>)}
{children(y, labelY)}
{water && (
<>
<line x1={COL_X - 5} x2={W - 4} y1={y(water.feet)} y2={y(water.feet)} stroke={WATER} strokeWidth="1.5" strokeDasharray={water.expected ? "6 4" : undefined} />
<text x={W - 4} y={y(water.feet) - 4} textAnchor="end" fill={WATER}>
{water.expected ? text.waterExpectedShort(Math.round(water.feet)) : text.waterMeasured(Math.round(water.feet), water.year)}
</text>
</>
)}
</svg>
);
}

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 (
<svg width="0" height="0" className="absolute" aria-hidden="true">
<defs>
<pattern id="nothing-proposed" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="var(--muted-foreground)" strokeOpacity="0.45" />
</pattern>
</defs>
</svg>
);
}

/**
* 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 (
<Frame depth={depth} marks={[0, ...all.map((b) => 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 (
<g
key={b.top} role="button" tabIndex={0} className="cursor-pointer outline-none [&:focus-visible>.reach]:opacity-100 [&:hover>.reach]:opacity-60"
aria-label={text.blockLabel(name, text.levels[b.level], b.top, b.bottom)} aria-pressed={isSelected}
onClick={() => onSelect(b)} onKeyDown={onEnterOrSpace(() => onSelect(b))}
>
{b.group ? (
<rect {...box} fill={GROUP_COLOUR[b.group]} fillOpacity={b.level === "likely" ? 0.6 : 0.3}
stroke="var(--foreground)" strokeOpacity="0.55" strokeDasharray={b.level === "likely" ? "6 4" : "1.5 4"} />
) : (
<rect {...box} fill={NOTHING_PROPOSED} stroke="var(--muted-foreground)" strokeOpacity="0.3" strokeDasharray="1.5 4" />
)}
{/* Selected: a solid frame. Reached by keyboard or mouse but not selected: a dashed one. */}
{isSelected
? <rect {...box} fill="none" stroke="var(--primary)" strokeWidth="2.5" />
: <rect {...box} className="reach" fill="none" stroke="var(--primary)" strokeWidth="2" strokeDasharray="3 3" opacity="0" />}
{at != null && (
<text x={LABEL_X} y={at} fill={b.group ? "var(--foreground)" : "var(--muted-foreground)"}>
{name}{b.level === "rough" && <tspan fill="var(--muted-foreground)"> · {text.levels.rough.toLowerCase()}</tspan>}
</text>
)}
</g>
);
})}
</Frame>
);
}

/** 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 (
<Frame depth={depth} marks={[0, ...layers.flatMap((l) => [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 (
<g key={l.id}>
<rect x={COL_X} y={y(l.startDepth)} width={COL_W} height={y(l.endDepth) - y(l.startDepth)} fill={l.color} stroke="var(--foreground)" strokeOpacity="0.4" />
{at != null && <text x={LABEL_X} y={at} fill="var(--foreground)">{l.material}</text>}
</g>
);
})}
</Frame>
);
}
36 changes: 28 additions & 8 deletions app/src/components/SiteMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<MapContainer center={CITY_CENTRE} zoom={12} preferCanvas className="strata-map z-0 h-full w-full bg-map-land">
<MapContainer center={CITY_CENTRE} zoom={12} preferCanvas className="strata-map z-0 h-full w-full cursor-crosshair bg-map-land">
<TileLayer
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
maxZoom={19}
/>
<FitOnce pins={pins} />
<ClearOnMapClick onClear={() => onSelect(null)} />
<KeepSize />
<PickOnMapClick onPick={onPick} />
{/* 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 (
Expand All @@ -46,6 +50,8 @@ export function SiteMap({ sites, selectedId, onSelect }: {
</CircleMarker>
);
})}
{/* The picked spot: an open, dashed ring, so it cannot be taken for a borewell. */}
{picked && <CircleMarker center={picked} radius={11} interactive={false} pathOptions={{ color: "#16202a", weight: 2, dashArray: "4 4", fill: false }} />}
</MapContainer>
);
}
Expand All @@ -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;
}
Loading
Loading