Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · Leaflet

Field Boundary Drawing Tool Demo

Draw farm fields on an OpenStreetMap base near Sargodha, Punjab, and edit their corners by dragging handles or with the arrow keys. Area in hectares and acres, perimeter and validity update while you drag. Boundaries that cross themselves are refused, and the whole set round-trips through RFC 7946 GeoJSON.

Read the case study →

Interactive demo

samiullah.tech/lab/field-boundary-tool

Click a field to edit its corners. Drag white handles to move, grey midpoints to add, right-click or Delete to remove.

## How it works

Fields are stored as open rings of [lng, lat] pairs, the GeoJSON order. All geometry lives in fields.ts and geo.ts, which have no React or Leaflet imports, so the same validation could run in a Node.js API before a write.

Area is the spherical polygon formula from Chamberlain and Duquette (the one Turf.js uses) on the authalic Earth radius. A shoelace on raw degrees scaled by 111 km per degree would overstate it by 1/cos(latitude), about 18 percent at Sargodha.

Every edit goes through validateRing: fewer than three corners or any pair of crossing edges is an error and the change is rolled back; fields under 0.05 ha get a warning. Export writes closed, counter-clockwise rings as RFC 7946 requires, and import accepts a FeatureCollection, Feature or bare Polygon, skipping bad features with a reason.

Leaflet is loaded with a dynamic import after mount, so nothing touches window during server rendering. Editing handles are plain draggable markers rather than a drawing plugin.

## View source

The code running in the window above, exactly as deployed.

app/lab/field-boundary-tool/fields.ts207 lines
/**
 * Field boundary domain logic: validation, measurement, GeoJSON import/export.
 * No React and no Leaflet here, so the same module could run in a Node.js API
 * that validates boundaries before they are written to PostGIS.
 */
import { z } from "zod";
import {
  acres,
  hectares,
  openRing,
  ringAreaM2,
  ringFromMetres,
  ringPerimeterM,
  selfIntersections,
  toCounterClockwise,
  type LngLat,
} from "@/components/lab/geo/geo";

export const CROPS = ["Wheat", "Cotton", "Rice", "Sugarcane", "Maize", "Fodder", "Fallow"] as const;
export type Crop = (typeof CROPS)[number];

/** Fixed colours: they sit on top of map tiles, so they do not follow the site theme. */
export const CROP_COLOURS: Record<Crop, string> = {
  Wheat: "#eab308",
  Cotton: "#e879f9",
  Rice: "#22d3ee",
  Sugarcane: "#22c55e",
  Maize: "#f97316",
  Fodder: "#84cc16",
  Fallow: "#a8a29e",
};

export type Field = {
  id: string;
  name: string;
  crop: Crop;
  /** Open ring (no repeated closing vertex), [lng, lat]. */
  ring: LngLat[];
};

export const MIN_VERTICES = 3;
export const SMALL_FIELD_HA = 0.05;

export type Measure = { areaM2: number; ha: number; ac: number; perimeterM: number; vertices: number };

export function measure(ring: LngLat[]): Measure {
  const areaM2 = ringAreaM2(ring);
  return { areaM2, ha: hectares(areaM2), ac: acres(areaM2), perimeterM: ringPerimeterM(ring), vertices: openRing(ring).length };
}

export type Issue = { level: "error" | "warning"; message: string };

/** Everything that stops a boundary from being saved (errors) or deserves a second look (warnings). */
export function validateRing(ring: LngLat[]): Issue[] {
  const r = openRing(ring);
  const issues: Issue[] = [];
  if (r.length < MIN_VERTICES) {
    issues.push({ level: "error", message: `A field needs at least ${MIN_VERTICES} corners (has ${r.length}).` });
    return issues;
  }
  const crossings = selfIntersections(r);
  if (crossings.length) {
    const [a, b] = crossings[0]!;
    issues.push({
      level: "error",
      message: `Boundary crosses itself: edge ${a + 1}–${a + 2} intersects edge ${b + 1}–${((b + 1) % r.length) + 1}.`,
    });
  }
  const ha = hectares(ringAreaM2(r));
  if (!crossings.length && ha < SMALL_FIELD_HA) {
    issues.push({ level: "warning", message: `Very small field (${ha.toFixed(3)} ha). Check it is not a drawing slip.` });
  }
  return issues;
}

export const hasErrors = (issues: Issue[]) => issues.some((i) => i.level === "error");

export function formatArea(m2: number) {
  const ha = hectares(m2);
  return { ha: ha < 10 ? ha.toFixed(2) : ha.toFixed(1), ac: acres(m2).toFixed(acres(m2) < 10 ? 2 : 1) };
}

/* ------------------------------------------------------------------ */
/* GeoJSON                                                             */
/* ------------------------------------------------------------------ */

const round7 = (n: number) => Math.round(n * 1e7) / 1e7;

/** RFC 7946 FeatureCollection: closed, counter-clockwise rings, longitude first, 7 decimals (~1 cm). */
export function toGeoJSON(fields: Field[]) {
  return {
    type: "FeatureCollection" as const,
    features: fields.map((f) => {
      const ccw = toCounterClockwise(openRing(f.ring)).map(([x, y]) => [round7(x), round7(y)] as LngLat);
      const m = measure(f.ring);
      return {
        type: "Feature" as const,
        id: f.id,
        properties: { name: f.name, crop: f.crop, area_ha: Number(m.ha.toFixed(4)) },
        geometry: { type: "Polygon" as const, coordinates: [[...ccw, ccw[0]!]] },
      };
    }),
  };
}

const position = z
  .array(z.number())
  .min(2)
  .refine(([lng, lat]) => Math.abs(lng!) <= 180 && Math.abs(lat!) <= 90, {
    message: "coordinates out of range (expected [longitude, latitude])",
  });

const featureSchema = z.object({
  type: z.literal("Feature"),
  id: z.union([z.string(), z.number()]).optional(),
  properties: z.record(z.unknown()).nullable().optional(),
  geometry: z.object({
    type: z.string(),
    coordinates: z.unknown(),
  }),
});

const collectionSchema = z.object({ type: z.literal("FeatureCollection"), features: z.array(z.unknown()) });

export type ImportResult = { fields: Field[]; skipped: string[] };

/**
 * Parse user-supplied GeoJSON. Accepts a FeatureCollection, a single Feature or a bare Polygon.
 * Each invalid feature is skipped with a reason instead of failing the whole file.
 */
export function parseGeoJSON(text: string, makeId: () => string): ImportResult | { error: string } {
  let json: unknown;
  try {
    json = JSON.parse(text);
  } catch {
    return { error: "Not valid JSON." };
  }
  const t = (json as { type?: unknown } | null)?.type;
  const items: unknown[] =
    t === "FeatureCollection"
      ? (collectionSchema.safeParse(json).data?.features ?? [])
      : t === "Feature"
        ? [json]
        : t === "Polygon"
          ? [{ type: "Feature", properties: {}, geometry: json }]
          : [];
  if (!items.length) return { error: "Expected a GeoJSON FeatureCollection, Feature or Polygon with at least one feature." };

  const fields: Field[] = [];
  const skipped: string[] = [];
  items.forEach((raw, i) => {
    const label = `Feature ${i + 1}`;
    const f = featureSchema.safeParse(raw);
    if (!f.success) return skipped.push(`${label}: not a GeoJSON Feature.`);
    const g = f.data.geometry;
    if (g.type !== "Polygon") return skipped.push(`${label}: ${g.type} is not supported (Polygon only).`);
    const rings = z.array(z.array(position).min(4)).min(1).safeParse(g.coordinates);
    if (!rings.success) return skipped.push(`${label}: ${rings.error.issues[0]?.message ?? "bad coordinates"}.`);
    if (rings.data.length > 1) skipped.push(`${label}: holes are ignored (outer ring kept).`);
    const ring = openRing(rings.data[0]!.map((p) => [p[0]!, p[1]!] as LngLat));
    const errors = validateRing(ring).filter((x) => x.level === "error");
    if (errors.length) return skipped.push(`${label}: ${errors[0]!.message}`);
    const props = f.data.properties ?? {};
    const crop = CROPS.find((c) => c.toLowerCase() === String(props.crop ?? "").toLowerCase()) ?? "Fallow";
    const name = typeof props.name === "string" && props.name.trim() ? props.name.trim().slice(0, 40) : `Imported field ${i + 1}`;
    fields.push({ id: makeId(), name, crop, ring });
  });
  return { fields, skipped };
}

/* ------------------------------------------------------------------ */
/* Sample data                                                         */
/* ------------------------------------------------------------------ */

/** Fictional fields drawn on farmland north-west of Sargodha, Punjab. Shapes are invented. */
export const SARGODHA: LngLat = [72.596, 32.118];

export function seedFields(): Field[] {
  const o = SARGODHA;
  return [
    {
      id: "f-101",
      name: "Block A · North",
      crop: "Wheat",
      ring: ringFromMetres(o, [[0, 0], [310, 12], [318, 225], [6, 214]]),
    },
    {
      id: "f-102",
      name: "Block A · South",
      crop: "Wheat",
      ring: ringFromMetres(o, [[4, -20], [316, -8], [322, -240], [150, -262], [8, -236]]),
    },
    {
      id: "f-103",
      name: "Canal strip",
      crop: "Sugarcane",
      ring: ringFromMetres(o, [[350, 10], [520, 30], [508, -190], [342, -205]]),
    },
    {
      id: "f-104",
      name: "West paddock",
      crop: "Fodder",
      ring: ringFromMetres(o, [[-60, 215], [-250, 230], [-262, 70], [-150, 40], [-70, 60]]),
    },
  ];
}
components/lab/geo/geo.ts190 lines
/**
 * Small, dependency-free geodesy helpers shared by the geospatial lab demos.
 *
 * Coordinates follow GeoJSON (RFC 7946): [longitude, latitude] in WGS84 degrees.
 * Distances use a spherical Earth. For field-sized polygons (a few hectares to a few
 * km²) the difference from an ellipsoidal calculation is well under 1 percent.
 */

export type LngLat = [lng: number, lat: number];

/** IUGG mean radius, used for distances (haversine). */
export const EARTH_MEAN_RADIUS_M = 6_371_008.8;
/** Authalic radius: the sphere with the same surface area as the WGS84 ellipsoid, used for areas. */
export const EARTH_AUTHALIC_RADIUS_M = 6_371_007.2;

export const M2_PER_HECTARE = 10_000;
export const M2_PER_ACRE = 4_046.856_422_4;

const rad = (d: number) => (d * Math.PI) / 180;
const deg = (r: number) => (r * 180) / Math.PI;

/** Great-circle distance in metres (haversine formula). */
export function haversineM(a: LngLat, b: LngLat): number {
  const dLat = rad(b[1] - a[1]);
  const dLng = rad(b[0] - a[0]);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLng / 2) ** 2;
  return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}

/** Drop a repeated closing vertex so rings are stored "open" internally. */
export function openRing(ring: LngLat[]): LngLat[] {
  if (ring.length > 1) {
    const f = ring[0]!;
    const l = ring[ring.length - 1]!;
    if (f[0] === l[0] && f[1] === l[1]) return ring.slice(0, -1);
  }
  return ring;
}

/**
 * Signed spherical area of a ring in m² (positive = counter-clockwise).
 * Chamberlain & Duquette, "Some algorithms for polygons on a sphere" (JPL, 2007),
 * the same formula Turf.js and OpenLayers use.
 */
export function ringSignedAreaM2(ringIn: LngLat[]): number {
  const ring = openRing(ringIn);
  const n = ring.length;
  if (n < 3) return 0;
  let sum = 0;
  for (let i = 0; i < n; i++) {
    const lower = ring[i]!;
    const middle = ring[(i + 1) % n]!;
    const upper = ring[(i + 2) % n]!;
    sum += (rad(upper[0]) - rad(lower[0])) * Math.sin(rad(middle[1]));
  }
  return (-sum * EARTH_AUTHALIC_RADIUS_M * EARTH_AUTHALIC_RADIUS_M) / 2;
}

export const ringAreaM2 = (ring: LngLat[]) => Math.abs(ringSignedAreaM2(ring));

/** Perimeter of a closed ring (or length of a line when `closed` is false), in metres. */
export function ringPerimeterM(ringIn: LngLat[], closed = true): number {
  const ring = openRing(ringIn);
  let total = 0;
  const last = closed ? ring.length : ring.length - 1;
  for (let i = 0; i < last; i++) total += haversineM(ring[i]!, ring[(i + 1) % ring.length]!);
  return ring.length < 2 ? 0 : total;
}

/** RFC 7946 §3.1.6: exterior rings are counter-clockwise. */
export function toCounterClockwise(ring: LngLat[]): LngLat[] {
  return ringSignedAreaM2(ring) < 0 ? [...ring].reverse() : ring;
}

/* ------------------------------------------------------------------ */
/* Validity: self-intersection                                         */
/* ------------------------------------------------------------------ */

const orient = (a: LngLat, b: LngLat, c: LngLat) => {
  const v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
  return Math.abs(v) < 1e-18 ? 0 : Math.sign(v);
};
const onSegment = (a: LngLat, b: LngLat, p: LngLat) =>
  Math.min(a[0], b[0]) <= p[0] && p[0] <= Math.max(a[0], b[0]) && Math.min(a[1], b[1]) <= p[1] && p[1] <= Math.max(a[1], b[1]);

export function segmentsIntersect(p1: LngLat, p2: LngLat, q1: LngLat, q2: LngLat): boolean {
  const o1 = orient(p1, p2, q1);
  const o2 = orient(p1, p2, q2);
  const o3 = orient(q1, q2, p1);
  const o4 = orient(q1, q2, p2);
  if (o1 !== o2 && o3 !== o4) return true;
  if (o1 === 0 && onSegment(p1, p2, q1)) return true;
  if (o2 === 0 && onSegment(p1, p2, q2)) return true;
  if (o3 === 0 && onSegment(q1, q2, p1)) return true;
  if (o4 === 0 && onSegment(q1, q2, p2)) return true;
  return false;
}

/**
 * Pairs of edge indexes that cross each other. Edge i runs from vertex i to vertex i+1.
 * Adjacent edges share a vertex by definition and are skipped. O(n²), which is fine
 * for hand-drawn fields (tens of vertices); a sweep line would be the next step.
 */
export function selfIntersections(ringIn: LngLat[]): Array<[number, number]> {
  const ring = openRing(ringIn);
  const n = ring.length;
  const out: Array<[number, number]> = [];
  if (n < 4) return out;
  for (let i = 0; i < n; i++) {
    for (let j = i + 2; j < n; j++) {
      if (i === 0 && j === n - 1) continue; // first and last edge share vertex 0
      if (segmentsIntersect(ring[i]!, ring[(i + 1) % n]!, ring[j]!, ring[(j + 1) % n]!)) out.push([i, j]);
    }
  }
  return out;
}

/* ------------------------------------------------------------------ */
/* Bounding boxes, centroids, offsets                                  */
/* ------------------------------------------------------------------ */

export type BBox = { minLng: number; minLat: number; maxLng: number; maxLat: number };

/**
 * Lat/lng box that fully contains a circle of `radiusM` around `center`.
 * Cheap prefilter before an exact distance check (what a GiST index does for ST_DWithin).
 */
export function bboxAround(center: LngLat, radiusM: number): BBox {
  const dLat = deg(radiusM / EARTH_MEAN_RADIUS_M);
  const dLng = deg(radiusM / (EARTH_MEAN_RADIUS_M * Math.cos(rad(center[1]))));
  return { minLng: center[0] - dLng, minLat: center[1] - dLat, maxLng: center[0] + dLng, maxLat: center[1] + dLat };
}

export const inBBox = (p: LngLat, b: BBox) => p[0] >= b.minLng && p[0] <= b.maxLng && p[1] >= b.minLat && p[1] <= b.maxLat;

export function ringBBox(ring: LngLat[]): BBox {
  const b: BBox = { minLng: Infinity, minLat: Infinity, maxLng: -Infinity, maxLat: -Infinity };
  for (const [x, y] of ring) {
    b.minLng = Math.min(b.minLng, x);
    b.maxLng = Math.max(b.maxLng, x);
    b.minLat = Math.min(b.minLat, y);
    b.maxLat = Math.max(b.maxLat, y);
  }
  return b;
}

/** Vertex average. Good enough for label placement on convex-ish fields. */
export function ringCenter(ring: LngLat[]): LngLat {
  const r = openRing(ring);
  const s = r.reduce<[number, number]>((a, p) => [a[0] + p[0], a[1] + p[1]], [0, 0]);
  return [s[0] / Math.max(1, r.length), s[1] / Math.max(1, r.length)];
}

/** Move a point by metres east/north (local flat approximation; fine below a few km). */
export function offsetM(p: LngLat, eastM: number, northM: number): LngLat {
  const dLat = deg(northM / EARTH_MEAN_RADIUS_M);
  const dLng = deg(eastM / (EARTH_MEAN_RADIUS_M * Math.cos(rad(p[1]))));
  return [p[0] + dLng, p[1] + dLat];
}

/** Build a ring from metre offsets around an origin. Used to author realistic sample fields. */
export const ringFromMetres = (origin: LngLat, pts: Array<[number, number]>): LngLat[] =>
  pts.map(([e, n]) => round6(offsetM(origin, e, n)));

export const round6 = (p: LngLat): LngLat => [Math.round(p[0] * 1e6) / 1e6, Math.round(p[1] * 1e6) / 1e6];

/* ------------------------------------------------------------------ */
/* Formatting                                                          */
/* ------------------------------------------------------------------ */

export function formatDistance(m: number): string {
  if (m < 1000) return `${Math.round(m)} m`;
  return `${(m / 1000).toFixed(m < 10_000 ? 2 : 1)} km`;
}

export const hectares = (m2: number) => m2 / M2_PER_HECTARE;
export const acres = (m2: number) => m2 / M2_PER_ACRE;

/** Deterministic PRNG (mulberry32) so seeded sample data is identical on server and client. */
export function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
  };
}
app/lab/field-boundary-tool/Demo.tsx737 lines
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type * as Leaflet from "leaflet";
import { MAP_CLASS, useLeafletMap } from "@/components/lab/geo/useLeafletMap";
import { formatDistance, haversineM, offsetM, ringBBox, type LngLat } from "@/components/lab/geo/geo";
import {
  CROPS,
  CROP_COLOURS,
  MIN_VERTICES,
  SARGODHA,
  formatArea,
  hasErrors,
  measure,
  parseGeoJSON,
  seedFields,
  toGeoJSON,
  validateRing,
  type Crop,
  type Field,
} from "./fields";

const KEY = "lab:field-boundary-tool:v1";
const toLatLng = (p: LngLat): [number, number] => [p[1], p[0]];
const fromLatLng = (ll: Leaflet.LatLng): LngLat => [Math.round(ll.lng * 1e7) / 1e7, Math.round(ll.lat * 1e7) / 1e7];

type Mode = "select" | "draw";

const btn =
  "inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-elev px-3 py-1.5 font-mono text-xs hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40";
const kbd = "rounded border border-border bg-bg-muted px-1 font-mono text-[10px] text-fg-muted";

export default function Demo() {
  const { ref, ctx } = useLeafletMap({ center: [SARGODHA[1], SARGODHA[0]], zoom: 16 });
  const [fields, setFields] = useState<Field[]>([]);
  const [past, setPast] = useState<Field[][]>([]);
  const [loaded, setLoaded] = useState(false);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [mode, setMode] = useState<Mode>("select");
  const [draft, setDraft] = useState<LngLat[]>([]);
  const [liveRing, setLiveRing] = useState<LngLat[] | null>(null);
  const [vertex, setVertex] = useState<number | null>(null);
  const [status, setStatus] = useState("");
  const [geoText, setGeoText] = useState("");
  const [geoPanel, setGeoPanel] = useState<"none" | "export" | "import">("none");
  const [importReport, setImportReport] = useState<string[]>([]);
  const polygons = useRef(new Map<string, Leaflet.Polygon>());
  const fitted = useRef(false);
  const idSeq = useRef(0);

  const newId = useCallback(() => `f-${Date.now().toString(36)}-${(idSeq.current++).toString(36)}`, []);

  /* ---------------- persistence ---------------- */
  useEffect(() => {
    let initial: Field[] | null = null;
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) initial = (JSON.parse(raw) as { fields: Field[] }).fields;
    } catch {
      /* blocked or corrupted storage: use the seed */
    }
    const list = Array.isArray(initial) ? initial : seedFields();
    setFields(list);
    setSelectedId(list[0]?.id ?? null);
    setLoaded(true);
  }, []);

  useEffect(() => {
    if (!loaded) return;
    try {
      localStorage.setItem(KEY, JSON.stringify({ fields }));
    } catch {
      /* ignore */
    }
  }, [fields, loaded]);

  const commit = useCallback(
    (next: Field[], message: string) => {
      setPast((p) => [...p.slice(-29), fields]);
      setFields(next);
      setStatus(message);
    },
    [fields],
  );

  const undo = useCallback(() => {
    setPast((p) => {
      const prev = p[p.length - 1];
      if (!prev) {
        setStatus("Nothing to undo.");
        return p;
      }
      setFields(prev);
      setVertex(null);
      setStatus("Undone.");
      return p.slice(0, -1);
    });
  }, []);

  const selected = fields.find((f) => f.id === selectedId) ?? null;

  const updateRing = useCallback(
    (id: string, ring: LngLat[], message: string) => {
      const issues = validateRing(ring);
      if (hasErrors(issues)) {
        setStatus(`Change rejected. ${issues[0]!.message}`);
        setFields((f) => [...f]); // redraw the stored geometry
        return false;
      }
      commit(
        fields.map((f) => (f.id === id ? { ...f, ring } : f)),
        message,
      );
      return true;
    },
    [commit, fields],
  );

  /* ---------------- draw mode ---------------- */
  const startDraw = useCallback(() => {
    setMode("draw");
    setDraft([]);
    setVertex(null);
    setStatus("Drawing: click the map to add corners. Enter or double-click to finish, Esc to cancel.");
  }, []);

  const cancelDraw = useCallback(() => {
    setMode("select");
    setDraft([]);
    setStatus("Drawing cancelled.");
  }, []);

  const finishDraw = useCallback(() => {
    // A double-click also fires two clicks: drop near-duplicate consecutive points.
    const ring = draft.filter((p, i) => i === 0 || haversineM(p, draft[i - 1]!) > 0.5);
    if (ring.length > 1 && haversineM(ring[0]!, ring[ring.length - 1]!) < 0.5) ring.pop();
    const issues = validateRing(ring);
    if (hasErrors(issues)) {
      setStatus(`Cannot save: ${issues[0]!.message}`);
      return;
    }
    const n = fields.length + 1;
    const field: Field = { id: newId(), name: `New field ${n}`, crop: "Wheat", ring };
    commit([...fields, field], `Saved "${field.name}", ${formatArea(measure(ring).areaM2).ha} ha.${issues[0] ? ` ${issues[0].message}` : ""}`);
    setSelectedId(field.id);
    setMode("select");
    setDraft([]);
  }, [commit, draft, fields, newId]);

  /* ---------------- keyboard ---------------- */
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const t = e.target as HTMLElement | null;
      if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT")) return;
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
        e.preventDefault();
        undo();
        return;
      }
      if (mode === "draw") {
        if (e.key === "Enter") finishDraw();
        else if (e.key === "Escape") cancelDraw();
        else if (e.key === "Backspace") {
          e.preventDefault();
          setDraft((d) => d.slice(0, -1));
        }
      } else if (e.key.toLowerCase() === "d" && !e.ctrlKey && !e.metaKey) {
        startDraw();
      }
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [mode, finishDraw, cancelDraw, startDraw, undo]);

  /** Arrow keys nudge the selected vertex; captured before Leaflet's own arrow-key panning. */
  function onMapKeyDownCapture(e: React.KeyboardEvent) {
    if (!selected || vertex === null || mode !== "select") return;
    const step = e.shiftKey ? 10 : 1;
    const moves: Record<string, [number, number]> = {
      ArrowUp: [0, step],
      ArrowDown: [0, -step],
      ArrowLeft: [-step, 0],
      ArrowRight: [step, 0],
    };
    const mv = moves[e.key];
    if (mv) {
      e.preventDefault();
      e.stopPropagation();
      const ring = selected.ring.map((p, i) => (i === vertex ? offsetM(p, mv[0], mv[1]) : p));
      updateRing(selected.id, ring, `Moved corner ${vertex + 1} by ${step} m.`);
    } else if (e.key === "Delete" || e.key === "Backspace") {
      e.preventDefault();
      e.stopPropagation();
      deleteVertex(vertex);
    } else if (e.key === "Escape") {
      setVertex(null);
    }
  }

  function deleteVertex(i: number) {
    if (!selected) return;
    if (selected.ring.length <= MIN_VERTICES) {
      setStatus(`A field needs at least ${MIN_VERTICES} corners.`);
      return;
    }
    if (updateRing(selected.id, selected.ring.filter((_, j) => j !== i), `Removed corner ${i + 1}.`)) setVertex(null);
  }

  /* ---------------- map: field polygons ---------------- */
  useEffect(() => {
    if (!ctx) return;
    const { L, map } = ctx;
    const group = L.featureGroup().addTo(map);
    polygons.current.clear();
    for (const f of fields) {
      const isSel = f.id === selectedId;
      const invalid = hasErrors(validateRing(f.ring));
      const poly = L.polygon(f.ring.map(toLatLng), {
        color: invalid ? "#ef4444" : isSel ? "#ffffff" : CROP_COLOURS[f.crop],
        weight: isSel ? 3 : 2,
        fillColor: CROP_COLOURS[f.crop],
        fillOpacity: isSel ? 0.35 : 0.25,
        dashArray: invalid ? "6 4" : undefined,
        bubblingMouseEvents: false,
      }).addTo(group);
      poly.bindTooltip(`${f.name} · ${formatArea(measure(f.ring).areaM2).ha} ha`, {
        permanent: true,
        direction: "center",
        className: "!text-[11px] !px-1.5 !py-0.5",
      });
      poly.on("click", () => {
        if (mode !== "select") return;
        setSelectedId(f.id);
        setVertex(null);
      });
      polygons.current.set(f.id, poly);
    }
    if (!fitted.current && fields.length) {
      fitted.current = true;
      map.fitBounds(group.getBounds(), { padding: [30, 30] });
    }
    return () => {
      group.remove();
    };
  }, [ctx, fields, selectedId, mode]);

  /* ---------------- map: vertex editing handles ---------------- */
  useEffect(() => {
    if (!ctx || !selected || mode !== "select") return;
    const { L, map } = ctx;
    const group = L.layerGroup().addTo(map);
    const mids = L.layerGroup().addTo(map);
    const ring = selected.ring;
    const poly = polygons.current.get(selected.id);
    const working: LngLat[] = [...ring];

    const handle = (active: boolean) =>
      L.divIcon({
        className: "",
        html: `<div style="width:14px;height:14px;border-radius:9999px;background:${active ? "#4ade80" : "#fff"};border:2px solid #111;box-shadow:0 0 0 1px #fff"></div>`,
        iconSize: [14, 14],
        iconAnchor: [7, 7],
      });
    const midIcon = L.divIcon({
      className: "",
      html: `<div style="width:10px;height:10px;border-radius:9999px;background:rgba(255,255,255,.65);border:1px solid #111"></div>`,
      iconSize: [10, 10],
      iconAnchor: [5, 5],
    });

    const paint = () => {
      const bad = hasErrors(validateRing(working));
      poly?.setLatLngs(working.map(toLatLng));
      poly?.setStyle({ color: bad ? "#ef4444" : "#ffffff", dashArray: bad ? "6 4" : undefined });
    };

    ring.forEach((p, i) => {
      const m = L.marker(toLatLng(p), {
        icon: handle(i === vertex),
        draggable: true,
        keyboard: true,
        title: `Corner ${i + 1}. Drag, or select and use arrow keys. Right-click or Delete to remove.`,
        alt: `Corner ${i + 1}`,
      }).addTo(group);
      m.on("dragstart", () => mids.clearLayers());
      m.on("drag", () => {
        working[i] = fromLatLng(m.getLatLng());
        paint();
        setLiveRing([...working]);
      });
      m.on("dragend", () => {
        setLiveRing(null);
        setVertex(i);
        updateRing(selected.id, [...working], `Moved corner ${i + 1}.`);
      });
      m.on("click", () => setVertex(i));
      m.on("contextmenu", () => deleteVertexRef.current(i));
    });

    ring.forEach((p, i) => {
      const q = ring[(i + 1) % ring.length]!;
      const mid: LngLat = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
      const m = L.marker(toLatLng(mid), { icon: midIcon, draggable: true, title: "Drag or click to add a corner here" }).addTo(mids);
      const insert = () => {
        const next = [...ring];
        next.splice(i + 1, 0, fromLatLng(m.getLatLng()));
        setVertex(i + 1);
        updateRing(selected.id, next, `Added corner ${i + 2}.`);
      };
      m.on("dragend", insert);
      m.on("click", insert);
    });

    return () => {
      group.remove();
      mids.remove();
    };
    // `selected` changes identity whenever its ring changes, which is exactly when handles must rebuild.
  }, [ctx, selected, mode, vertex, updateRing]);

  const deleteVertexRef = useRef(deleteVertex);
  useEffect(() => {
    deleteVertexRef.current = deleteVertex;
  });

  /* ---------------- map: drawing ---------------- */
  useEffect(() => {
    if (!ctx || mode !== "draw") return;
    const { map } = ctx;
    const container = map.getContainer();
    container.style.cursor = "crosshair";
    map.doubleClickZoom.disable();
    const onClick = (e: Leaflet.LeafletMouseEvent) => setDraft((d) => [...d, fromLatLng(e.latlng)]);
    const onDbl = () => finishRef.current();
    map.on("click", onClick);
    map.on("dblclick", onDbl);
    return () => {
      container.style.cursor = "";
      map.doubleClickZoom.enable();
      map.off("click", onClick);
      map.off("dblclick", onDbl);
    };
  }, [ctx, mode]);

  const finishRef = useRef(finishDraw);
  useEffect(() => {
    finishRef.current = finishDraw;
  });

  useEffect(() => {
    if (!ctx || mode !== "draw" || !draft.length) return;
    const { L, map } = ctx;
    const g = L.layerGroup().addTo(map);
    const bad = draft.length >= 4 && hasErrors(validateRing(draft));
    const colour = bad ? "#ef4444" : "#4ade80";
    if (draft.length >= 3) L.polygon(draft.map(toLatLng), { color: colour, weight: 0, fillOpacity: 0.2, interactive: false }).addTo(g);
    L.polyline(draft.map(toLatLng), { color: colour, weight: 2, interactive: false }).addTo(g);
    if (draft.length >= 2)
      L.polyline([toLatLng(draft[draft.length - 1]!), toLatLng(draft[0]!)], { color: colour, weight: 1, dashArray: "4 4", interactive: false }).addTo(g);
    draft.forEach((p, i) => {
      const c = L.circleMarker(toLatLng(p), {
        radius: i === 0 ? 7 : 4,
        color: "#111",
        weight: 1,
        fillColor: i === 0 ? colour : "#fff",
        fillOpacity: 1,
        bubblingMouseEvents: false,
      }).addTo(g);
      if (i === 0) {
        c.bindTooltip("Click to close the field");
        c.on("click", () => finishRef.current());
      }
    });
    return () => {
      g.remove();
    };
  }, [ctx, mode, draft]);

  /* ---------------- side panel actions ---------------- */
  function zoomTo(f: Field) {
    setSelectedId(f.id);
    setVertex(null);
    const b = ringBBox(f.ring);
    ctx?.map.fitBounds(
      [
        [b.minLat, b.minLng],
        [b.maxLat, b.maxLng],
      ],
      { padding: [60, 60], maxZoom: 18 },
    );
  }

  function patch(id: string, p: Partial<Pick<Field, "name" | "crop">>, message: string) {
    commit(
      fields.map((f) => (f.id === id ? { ...f, ...p } : f)),
      message,
    );
  }

  function remove(f: Field) {
    commit(
      fields.filter((x) => x.id !== f.id),
      `Deleted "${f.name}". Press Ctrl+Z to undo.`,
    );
    if (selectedId === f.id) setSelectedId(null);
  }

  function reset() {
    const s = seedFields();
    setFields(s);
    setPast([]);
    setSelectedId(s[0]!.id);
    setMode("select");
    setDraft([]);
    setVertex(null);
    setGeoPanel("none");
    setImportReport([]);
    fitted.current = false;
    setStatus("Demo reset to the sample fields.");
  }

  const exportText = useMemo(() => JSON.stringify(toGeoJSON(fields), null, 2), [fields]);

  function download() {
    const blob = new Blob([exportText], { type: "application/geo+json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "fields.geojson";
    a.click();
    URL.revokeObjectURL(url);
    setStatus(`Exported ${fields.length} fields as fields.geojson.`);
  }

  function runImport(text: string) {
    const res = parseGeoJSON(text, newId);
    if ("error" in res) {
      setImportReport([res.error]);
      setStatus(`Import failed: ${res.error}`);
      return;
    }
    setImportReport(res.skipped);
    if (!res.fields.length) {
      setStatus("Import found no valid polygons.");
      return;
    }
    commit([...fields, ...res.fields], `Imported ${res.fields.length} field(s)${res.skipped.length ? `, ${res.skipped.length} note(s)` : ""}.`);
    fitted.current = false;
    setSelectedId(res.fields[0]!.id);
  }

  async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    if (file.size > 2_000_000) {
      setImportReport(["File is larger than 2 MB."]);
      return;
    }
    const text = await file.text();
    setGeoText(text);
    runImport(text);
    e.target.value = "";
  }

  /* ---------------- derived ---------------- */
  const shownRing = liveRing ?? selected?.ring ?? null;
  const sel = shownRing ? measure(shownRing) : null;
  const selIssues = shownRing ? validateRing(shownRing) : [];
  const draftM = draft.length >= 2 ? measure(draft) : null;
  const draftIssues = draft.length >= 3 ? validateRing(draft) : [];
  const total = fields.reduce((s, f) => s + measure(f.ring).areaM2, 0);
  const totalFmt = formatArea(total);

  return (
    <div className="grid lg:grid-cols-[1fr_22rem]">
      <div className="min-w-0">
        <div role="toolbar" aria-label="Map tools" className="flex flex-wrap items-center gap-2 border-b border-border p-3">
          {mode === "select" ? (
            <button type="button" className={`${btn} border-accent text-accent`} onClick={startDraw}>
              + Draw field <span className={kbd}>D</span>
            </button>
          ) : (
            <>
              <button type="button" className={`${btn} border-accent text-accent`} onClick={finishDraw} disabled={draft.length < MIN_VERTICES}>
                Finish <span className={kbd}>Enter</span>
              </button>
              <button type="button" className={btn} onClick={() => setDraft((d) => d.slice(0, -1))} disabled={!draft.length}>
                Remove last <span className={kbd}>⌫</span>
              </button>
              <button type="button" className={btn} onClick={cancelDraw}>
                Cancel <span className={kbd}>Esc</span>
              </button>
            </>
          )}
          <button type="button" className={btn} onClick={undo} disabled={!past.length}>
            Undo <span className={kbd}>Ctrl Z</span>
          </button>
          <span className="mx-1 hidden h-5 w-px bg-border sm:block" aria-hidden="true" />
          <button type="button" className={btn} aria-expanded={geoPanel === "import"} onClick={() => setGeoPanel((p) => (p === "import" ? "none" : "import"))}>
            Import GeoJSON
          </button>
          <button type="button" className={btn} aria-expanded={geoPanel === "export"} onClick={() => setGeoPanel((p) => (p === "export" ? "none" : "export"))}>
            Export GeoJSON
          </button>
          <button type="button" className={`${btn} ml-auto`} onClick={reset}>
            Reset demo
          </button>
        </div>

        <div onKeyDownCapture={onMapKeyDownCapture} className="relative">
          <div ref={ref} className={`${MAP_CLASS} h-[440px] sm:h-[560px]`} role="application" aria-label="Field map. Use the field list for a text alternative." />
          {mode === "draw" && (
            <div className="pointer-events-none absolute top-3 left-1/2 z-[500] w-[min(92%,26rem)] -translate-x-1/2 rounded-md border border-[#27272a] bg-[#0b0b0d]/90 px-3 py-2 font-mono text-[12px] text-[#e4e4e7] shadow-lg">
              <p>
                <span className="text-[#4ade80]">drawing</span> · {draft.length} corner{draft.length === 1 ? "" : "s"}
                {draftM && draft.length >= 3 && (
                  <>
                    {" "}
                    · {formatArea(draftM.areaM2).ha} ha · {formatArea(draftM.areaM2).ac} ac
                  </>
                )}
                {draftM && <> · {formatDistance(measure(draft).perimeterM)}</>}
              </p>
              {draftIssues.filter((i) => i.level === "error").map((i) => (
                <p key={i.message} className="text-[#f87171]">
                  {i.message}
                </p>
              ))}
              {draft.length === 0 && <p className="text-[#a1a1aa]">Click the map to place the first corner.</p>}
            </div>
          )}
        </div>

        <p role="status" aria-live="polite" className="min-h-9 border-t border-border px-3 py-2 font-mono text-xs text-fg-muted">
          {status || "Click a field to edit its corners. Drag white handles to move, grey midpoints to add, right-click or Delete to remove."}
        </p>

        {geoPanel === "export" && (
          <section aria-label="Export GeoJSON" className="space-y-2 border-t border-border p-3">
            <div className="flex flex-wrap items-center gap-2">
              <button type="button" className={btn} onClick={download}>
                Download fields.geojson
              </button>
              <button
                type="button"
                className={btn}
                onClick={() =>
                  navigator.clipboard?.writeText(exportText).then(
                    () => setStatus("GeoJSON copied to the clipboard."),
                    () => setStatus("Clipboard blocked by the browser; select the text instead."),
                  )
                }
              >
                Copy
              </button>
              <span className="text-xs text-fg-muted">RFC 7946: [lng, lat], closed counter-clockwise rings, 7 decimals.</span>
            </div>
            <label className="sr-only" htmlFor="geo-export">
              Exported GeoJSON
            </label>
            <textarea
              id="geo-export"
              readOnly
              value={exportText}
              className="h-56 w-full rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[11px] text-[#a1a1aa]"
            />
          </section>
        )}

        {geoPanel === "import" && (
          <section aria-label="Import GeoJSON" className="space-y-2 border-t border-border p-3">
            <div className="flex flex-wrap items-center gap-2">
              <label className={`${btn} cursor-pointer`}>
                Choose .geojson file
                <input type="file" accept=".geojson,.json,application/geo+json,application/json" className="sr-only" onChange={onFile} />
              </label>
              <span className="text-xs text-fg-muted">or paste below. Polygon features only; invalid ones are skipped with a reason.</span>
            </div>
            <label className="sr-only" htmlFor="geo-import">
              GeoJSON to import
            </label>
            <textarea
              id="geo-import"
              value={geoText}
              onChange={(e) => setGeoText(e.target.value)}
              placeholder='{"type":"FeatureCollection","features":[…]}'
              className="h-40 w-full rounded-md border border-border bg-bg-elev p-3 font-mono text-[11px] outline-none focus:border-accent"
            />
            <div className="flex flex-wrap gap-2">
              <button type="button" className={`${btn} border-accent text-accent`} onClick={() => runImport(geoText)} disabled={!geoText.trim()}>
                Import
              </button>
              <button type="button" className={btn} onClick={() => setGeoText(exportText)}>
                Paste current export (round-trip test)
              </button>
            </div>
            {importReport.length > 0 && (
              <ul role="alert" className="space-y-1 text-xs text-warn-fg">
                {importReport.map((r) => (
                  <li key={r}>• {r}</li>
                ))}
              </ul>
            )}
          </section>
        )}
      </div>

      <aside className="space-y-5 border-t border-border bg-bg-elev p-4 lg:border-t-0 lg:border-l">
        <section aria-labelledby="sel-h">
          <h3 id="sel-h" className="font-mono text-sm font-semibold">
            selected field
          </h3>
          {selected && sel ? (
            <div className="mt-2 space-y-3">
              <label className="block text-xs">
                <span className="text-fg-muted">Name</span>
                <input
                  key={selected.id}
                  defaultValue={selected.name}
                  maxLength={40}
                  onBlur={(e) => {
                    const v = e.target.value.trim();
                    if (v && v !== selected.name) patch(selected.id, { name: v }, `Renamed to "${v}".`);
                  }}
                  onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
                  className="mt-1 w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm outline-none focus:border-accent"
                />
              </label>
              <label className="block text-xs">
                <span className="text-fg-muted">Crop</span>
                <select
                  value={selected.crop}
                  onChange={(e) => patch(selected.id, { crop: e.target.value as Crop }, `Crop set to ${e.target.value}.`)}
                  className="mt-1 w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm"
                >
                  {CROPS.map((c) => (
                    <option key={c}>{c}</option>
                  ))}
                </select>
              </label>
              <dl className="grid grid-cols-2 gap-2 font-mono text-xs">
                {[
                  ["area", `${formatArea(sel.areaM2).ha} ha`],
                  ["", `${formatArea(sel.areaM2).ac} acres`],
                  ["perimeter", formatDistance(sel.perimeterM)],
                  ["corners", String(sel.vertices)],
                ].map(([k, v], i) => (
                  <div key={i} className="rounded-md border border-border bg-bg p-2">
                    <dt className="text-[10px] text-fg-muted">{k || "area"}</dt>
                    <dd className="text-sm text-fg">{v}</dd>
                  </div>
                ))}
              </dl>
              {selIssues.map((i) => (
                <p key={i.message} className={`text-xs ${i.level === "error" ? "text-danger" : "text-warn-fg"}`}>
                  {i.level === "error" ? "✕ " : "! "}
                  {i.message}
                </p>
              ))}
              {vertex !== null && (
                <p className="text-xs text-fg-muted">
                  Corner {vertex + 1} selected: <span className={kbd}>←↑→↓</span> nudge 1 m (<span className={kbd}>Shift</span> 10 m),{" "}
                  <span className={kbd}>Del</span> remove.
                </p>
              )}
              <div className="flex gap-2">
                <button type="button" className={btn} onClick={() => zoomTo(selected)}>
                  Zoom to
                </button>
                <button type="button" className={`${btn} hover:border-danger hover:text-danger`} onClick={() => remove(selected)}>
                  Delete field
                </button>
              </div>
            </div>
          ) : (
            <p className="mt-2 text-sm text-fg-muted">Select a field on the map or in the list, or press D to draw one.</p>
          )}
        </section>

        <section aria-labelledby="list-h">
          <h3 id="list-h" className="flex items-baseline justify-between font-mono text-sm font-semibold">
            fields <span className="text-xs font-normal text-fg-muted">{fields.length} · {totalFmt.ha} ha</span>
          </h3>
          {fields.length === 0 ? (
            <p className="mt-2 text-sm text-fg-muted">No fields yet. Draw one or import GeoJSON.</p>
          ) : (
            <ul className="mt-2 divide-y divide-border rounded-md border border-border bg-bg">
              {fields.map((f) => {
                const m = measure(f.ring);
                const bad = hasErrors(validateRing(f.ring));
                return (
                  <li key={f.id}>
                    <button
                      type="button"
                      onClick={() => zoomTo(f)}
                      aria-current={f.id === selectedId}
                      className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-bg-muted ${f.id === selectedId ? "bg-accent-soft/60" : ""}`}
                    >
                      <span className="size-3 shrink-0 rounded-sm" style={{ background: CROP_COLOURS[f.crop] }} aria-hidden="true" />
                      <span className="min-w-0 flex-1">
                        <span className="block truncate">{f.name}</span>
                        <span className="block text-xs text-fg-muted">
                          {f.crop} · {formatArea(m.areaM2).ha} ha · {formatArea(m.areaM2).ac} ac
                        </span>
                      </span>
                      {bad && <span className="text-xs text-danger">invalid</span>}
                    </button>
                  </li>
                );
              })}
            </ul>
          )}
        </section>

        <section aria-labelledby="crop-h">
          <h3 id="crop-h" className="font-mono text-sm font-semibold">
            area by crop
          </h3>
          <ul className="mt-2 space-y-1">
            {CROPS.map((c) => {
              const a = fields.filter((f) => f.crop === c).reduce((s, f) => s + measure(f.ring).areaM2, 0);
              if (!a) return null;
              return (
                <li key={c} className="flex items-center gap-2 text-xs">
                  <span className="w-20 shrink-0">{c}</span>
                  <span className="h-2 rounded" style={{ width: `${(a / Math.max(total, 1)) * 100}%`, background: CROP_COLOURS[c] }} />
                  <span className="ml-auto shrink-0 font-mono text-fg-muted">{formatArea(a).ha} ha</span>
                </li>
              );
            })}
          </ul>
        </section>
      </aside>
    </div>
  );
}
components/lab/geo/useLeafletMap.ts52 lines
"use client";

import "leaflet/dist/leaflet.css";
import { useEffect, useRef, useState } from "react";
import type * as Leaflet from "leaflet";

export type LeafletNS = typeof Leaflet;
export type MapCtx = { L: LeafletNS; map: Leaflet.Map };

export const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';

/**
 * Creates a Leaflet map with OpenStreetMap tiles inside the returned ref.
 * Leaflet touches `window` on import, so it is loaded with a dynamic import after mount
 * (never during server rendering). `ctx` is null until the map exists.
 */
export function useLeafletMap(opts: { center: [lat: number, lng: number]; zoom: number; options?: Leaflet.MapOptions }) {
  const ref = useRef<HTMLDivElement>(null);
  const [ctx, setCtx] = useState<MapCtx | null>(null);
  const init = useRef(opts);

  useEffect(() => {
    let cancelled = false;
    let map: Leaflet.Map | null = null;
    import("leaflet").then((mod) => {
      const L = ((mod as unknown as { default?: LeafletNS }).default ?? mod) as LeafletNS;
      if (cancelled || !ref.current) return;
      map = L.map(ref.current, {
        center: init.current.center,
        zoom: init.current.zoom,
        zoomControl: true,
        ...init.current.options,
      });
      L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
        maxZoom: 19,
        attribution: OSM_ATTRIBUTION,
      }).addTo(map);
      setCtx({ L, map });
    });
    return () => {
      cancelled = true;
      map?.remove();
      setCtx(null);
    };
  }, []);

  return { ref, ctx };
}

/** Tailwind classes for a map container: isolates Leaflet's z-indexes from the site header. */
export const MAP_CLASS = "relative z-0 isolate w-full bg-bg-muted [&_.leaflet-container]:font-sans";