# Field Boundary Drawing Tool

> A concept project with a working demo: a field boundary editor on a Leaflet map. You draw farm fields, drag or nudge corners, and see geodesic area in hectares and acres plus perimeter. Self-intersecting shapes are refused, and fields round-trip through RFC 7946 GeoJSON. No drawing plugin; the editing and geometry code is my own.

- Author: Sami Ullah (https://samiullah.tech/about)
- Type: Concept project
- Role: Full build
- Stack: TypeScript, React.js, Next.js, Leaflet, GeoJSON, zod
- Year: 2026
- Updated: 2026-11-13
- Canonical: https://samiullah.tech/work/field-boundary-tool

## Overview

Almost every agricultural platform starts with the same step: someone has to tell the system where the field is. Weather advice, satellite crop health, yield estimates and input plans all hang off that one polygon. If the polygon is wrong, everything downstream is wrong too.

This concept is a self-contained version of that step. The [live demo](/lab/field-boundary-tool) shows a few invented fields on farmland near Sargodha, Punjab. You can draw new ones, reshape them, and import or export the whole set as GeoJSON. It is the kind of [geospatial](/skills/geospatial) front-end work I do day to day, rebuilt here with public tiles and made-up data.

## My role

I built all of it: the geometry module, the validation rules, the GeoJSON import and export, the map editing interaction and this write-up. I chose not to use a drawing plugin such as leaflet-draw or Leaflet-Geoman, because I wanted the editing rules (what counts as a valid field, when a change is rolled back) to be code I own and can test.

## The problem

A field boundary tool looks simple, but there are a few ways to get it quietly wrong:

- **Area in the wrong units.** Leaflet works in latitude and longitude. A shoelace formula on raw degrees, scaled by 111 km per degree, overstates area by `1/cos(latitude)`. At Sargodha (about 32° N) that is roughly 18 percent. A farmer who pays for fertiliser per acre notices.
- **Invalid shapes.** A boundary that crosses itself (a "bow tie") has no meaningful area and breaks spatial queries later. PostGIS will store it but `ST_IsValid` returns false and many operations fail.
- **Coordinate order.** GeoJSON is `[longitude, latitude]`. Leaflet is `[lat, lng]`. Mixing them up puts a Punjab field in the Indian Ocean, and it is easy to do.
- **Editing must feel direct.** Users drag corners while watching the number change. A tool that only validates on "Save" makes them guess.

## Approach & architecture

```flow
# Editing a boundary
Pointer or arrow key -> working ring -> validateRing -> live area + red outline if invalid
Drag end -> validateRing -> commit (undo stack) or roll back -> localStorage
```

```flow
# Exchange
Fields -> toGeoJSON (closed CCW rings, 7 decimals) -> .geojson file
.geojson file -> zod parse -> validateRing per feature -> new fields + skip report
```

The code is split so the map is only a view. `geo.ts` and `fields.ts` are plain TypeScript with no React or Leaflet imports. The React component keeps the list of fields in state and redraws Leaflet layers from it in effects.

Area uses the spherical polygon formula from Chamberlain and Duquette, the same one Turf.js uses, on the authalic Earth radius (the sphere with the same surface area as WGS84):

```ts
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;
}
```

The sign tells me the winding order. Export uses it to write counter-clockwise exterior rings, which RFC 7946 asks for.

Validation checks every pair of non-adjacent edges for an intersection, including collinear overlaps. It is O(n²), which is fine for hand-drawn fields with tens of corners.

In the production design, the same rules sit in a NestJS service and the database enforces them again:

```sql
CREATE TABLE fields (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  farm_id    uuid NOT NULL REFERENCES farms(id),
  name       text NOT NULL,
  crop       text NOT NULL,
  boundary   geometry(Polygon, 4326) NOT NULL CHECK (ST_IsValid(boundary)),
  area_ha    numeric GENERATED ALWAYS AS (ST_Area(boundary::geography) / 10000) STORED
);
CREATE INDEX fields_boundary_gix ON fields USING gist (boundary);
```

## Key challenges

- **Live feedback without re-rendering the map.** While a corner is dragged, I update the Leaflet polygon directly and only push the working ring into React state for the stats panel. The real commit happens on drag end. This keeps dragging smooth and keeps the undo stack clean (one entry per drag, not one per pixel).
- **Rolling back bad edits.** If a drag creates a crossing, the outline turns red and dashed during the drag. On release the change is refused and the stored geometry is redrawn, with a message that names the crossing edges.
- **Double-click to finish.** A double-click also fires two clicks, which adds two duplicate corners. `finishDraw` drops consecutive points closer than 0.5 m before validating.
- **Keyboard editing.** Dragging is not available to everyone. A selected corner moves 1 m per arrow key (10 m with Shift) and Delete removes it. I capture the key event before Leaflet sees it, otherwise the arrows pan the map.
- **Forgiving import.** Real GeoJSON files are messy. Import accepts a FeatureCollection, a Feature or a bare Polygon, validates each feature with zod, and skips bad ones with a reason ("Feature 3: MultiPolygon is not supported") instead of failing the whole file.

## Tech stack

- **Demo:** TypeScript, [React](/skills/react) and Next.js, Leaflet with OpenStreetMap tiles, zod for import parsing. Geometry is my own code in `components/lab/geo/geo.ts`. State is kept in `localStorage`.
- **Production design:** a NestJS API with the same validation module, [PostgreSQL](/skills/postgresql) with PostGIS for storage, `ST_IsValid` as a constraint and `ST_Area(geography)` for the stored area, plus a satellite base layer for tracing.

## Results

- A working editor: draw, reshape, add and remove corners, rename, set the crop, delete and undo, with fields kept across reloads.
- Area, perimeter and validity update during a drag, not only on save.
- Self-intersecting boundaries cannot be saved by drawing, dragging, nudging or importing.
- Export then re-import ("Paste current export" in the import panel) gives back the same fields, with coordinates longitude first.
- Every line of the geometry code is visible under "View source" on the demo page.

I have not compared the area numbers against QGIS or PostGIS for this write-up, so I do not quote an accuracy figure. The spherical formula differs from an ellipsoidal one by well under one percent for fields this size. This is a concept build without real users.

## Try the demo

[Open the field boundary demo](/lab/field-boundary-tool). Things to try:

1. Press **D**, click five or six points on the map, then press **Enter**. Watch the area appear in hectares and acres.
2. Select "Block A · North" and drag one corner across the opposite edge. The outline turns red and the change is refused when you let go.
3. Click a corner, then use the arrow keys to nudge it 1 m at a time.
4. Open **Export GeoJSON**, then **Import GeoJSON** and press "Paste current export" to run a round trip.

## What I would do next

- Add a satellite layer and snapping to neighbouring field edges, so shared boundaries line up exactly.
- Check overlaps between fields, not only within one field.
- Put the validation module behind a NestJS endpoint and add unit tests for the geometry against PostGIS results.
- Support holes (a pond inside a field) and MultiPolygon fields split by a road.
