9.3 KiB
⬡ ParcelGen — Land Subdivision Tool
A full-stack GIS application for automatic land parcel generation from user-drawn site boundaries, road networks, and existing building footprints.
Architecture
┌──────────────────────────────────────────────────────┐
│ Docker Network │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Nginx + Front │────▶│ FastAPI Backend v3 │ │
│ │ (port 3000) │ │ (port 8000) │ │
│ │ OpenLayers UI │ │ Shapely / GEOS engine │ │
│ └──────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────┘
▲
│ Browser
http://localhost:3000
Quick Start
Prerequisites
- Docker and Docker Compose
Run
cd parcel-tool
docker compose up --build
Open http://localhost:3000 in your browser.
Stop
docker compose down
Feature Overview
Drawing Tools
| Tool | Description |
|---|---|
| ◇ Boundary | Draw the site boundary polygon. Double-click to finish. Only one boundary is kept at a time. |
| ⟋ Road | Draw road centrelines. Each line is buffered to the configured road width. Draw multiple; all are used. |
| ▦ Building | Draw existing building footprints. Carved out before subdivision; containing parcel flagged as "built". |
| 📏 Measure | Click to place measure points, double-click to finish. Shows live distance in the active unit system (m/km or ft/mi). |
| ✕ Stop | Exit any active draw or measure mode. |
Map Interaction
| Action | Result |
|---|---|
| Hover over a parcel | Floating tooltip: area (dual units), frontage, depth, status, road access |
| Click a parcel | Pins a detail panel bottom-left; highlights parcel in gold |
| Click empty map | Clears selection |
| Scroll / drag | Standard map pan and zoom |
Sidebar Tabs
- Draw — Drawing tools and Run Subdivision / Export buttons
- Config — Subdivision parameters with live unit conversion
- Layers — Visibility, opacity, basemap switcher, and legend
- Results — Stats dashboard and scrollable parcel list
UI Controls
Theme Toggle (sun/moon button)
Click the ☀️ / 🌙 button in the top-left of the sidebar header to switch between dark and light themes. The preference is saved in localStorage and restored on next visit. Switching theme also switches the default basemap automatically.
Unit System (m / ft)
Click the m or ft pill to toggle metric/imperial. All config inputs convert in-place. Results display both units. The backend always receives and returns SI (metres, m²).
Basemap Switcher
12 basemaps in 5 categories, accessible from the Layers tab or the floating Basemap panel (bottom-right):
| Category | Options |
|---|---|
| Style | Dark (CARTO), Light (CARTO), Night Lights |
| Street | OpenStreetMap |
| Terrain | OpenTopoMap, ESRI Topo, ESRI Shaded Relief, Ocean |
| Imagery | ESRI Satellite, ESRI Satellite + Labels |
| Artistic | Stamen Terrain, Stamen Watercolor |
Layer Switcher
Both the floating Layers panel and the Layers tab expose per-layer controls:
- Visibility checkbox
- Opacity slider (0–100%, live update)
- Colour swatch
Layers: Boundary · Buildings (drawn) · Roads (drawn) · Road surfaces · Cul-de-sacs · Blocks · Parcels
Subdivision Engine
Algorithm
1. Parse boundary polygon (WGS84 / EPSG:4326)
2. Parse user-drawn road centrelines + existing building footprints
3. Build road network:
a. Buffer user roads → road polygons
b. Auto-generate grid roads to fill blocks > max_block_length
(combined with user roads, never either/or)
c. Add perimeter access ring inside the boundary edge
4. Carve: buildable = boundary − roads − buildings
5. Extract contiguous buildable blocks
6. Generate cul-de-sacs for oversized blocks (if enabled)
7. Subdivide each block into rectangular parcels:
a. Detect dominant orientation via oriented bounding box
b. Double-bank parcels back-to-back where depth allows
c. Clip each cell to the actual block polygon
8. Quality passes:
a. Absorb undersized / badly-shaped parcels into neighbours
b. Enforce road access — merge landlocked parcels into accessed neighbour
9. Apply existing buildings → merge spanning parcels, mark status="built"
10. Re-number sequentially, assign addresses
11. Return GeoJSON + stats
Parcel Access Guarantee
Every parcel is checked for road adjacency. Landlocked parcels are merged into their nearest same-block neighbour that has road access. This loop runs until stable. Any remaining inaccessible parcels are flagged in red on the map and counted in the stats panel.
Metric Scaling
All internal geometry is in WGS84 degrees. A scale factor (metres-per-degree) is computed at the site centroid so that metre-based config values work correctly at any latitude:
m_per_deg = (111 320 + 111 320 × cos(lat)) / 2
Default Configuration
| Parameter | Default | Description |
|---|---|---|
min_frontage |
12 m | Minimum parcel road frontage |
min_depth |
25 m | Minimum parcel depth |
road_width |
9 m | Road right-of-way width |
max_block_length |
120 m | Max block length before road/cul-de-sac insertion |
allow_culdesac |
true | Generate cul-de-sacs for oversized blocks |
corner_radius |
3 m | Road corner rounding radius |
Parcel Properties (GeoJSON)
{
"parcel_id": "P0042",
"parcel_num": 42,
"block_id": 3,
"area_m2": 340.5,
"area_ha": 0.034,
"frontage_m": 12.3,
"depth_m": 27.7,
"address": "Block 3, Plot 42",
"zone": "Residential",
"status": "vacant",
"has_access": true,
"frontage_ok": true,
"area_ok": true,
"building_area_m2": 0
}
status is "built" when an existing building footprint overlaps the parcel.
API Reference
POST /subdivide
{
"boundary": { "type": "Polygon", "coordinates": [[...]] },
"roads": [
{ "type": "Feature", "geometry": { "type": "LineString", "coordinates": [[...]] }, "properties": {} }
],
"existing_features": [
{ "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[...]] }, "properties": {} }
],
"config": {
"min_frontage": 12,
"min_depth": 25,
"road_width": 9,
"max_block_length": 120,
"allow_culdesac": true,
"corner_radius": 3
}
}
Both bare geometry dicts and GeoJSON Feature wrappers are accepted.
Response:
{
"parcels": [ ],
"roads": [ ],
"blocks": [ ],
"culdesacs": [ ],
"stats": {
"total_parcels": 42,
"total_blocks": 4,
"total_roads": 3,
"culdesacs": 1,
"parcels_no_access": 0,
"parcels_built": 2,
"parcels_vacant": 40,
"boundary_area_m2": 18400.0,
"road_area_m2": 2100.0,
"buildable_area_m2": 14200.0,
"avg_parcel_area_m2": 338.1,
"existing_buildings": 2,
"user_roads_drawn": 1
}
}
GET /health
Returns {"status": "ok", "version": "3.0.0"}
GET /config/defaults
Returns the default SubdivisionConfig as JSON.
Map Colour Legend
| Colour | Meaning |
|---|---|
| Blue outline | Vacant parcel |
| Orange outline | Built parcel (existing building) |
| Red dashed outline | No road access ⚠ |
| Gold outline | Currently selected parcel |
| Yellow fill | Road surface |
| Purple fill | Cul-de-sac |
| Green dashed | Site boundary |
| Purple dashed | Drawn building footprint |
Export
Export GeoJSON in the Draw tab downloads a .geojson file with all parcels, roads, and cul-de-sacs plus a metadata block (timestamp, display units). All attribute values are always in SI units regardless of the unit switcher.
Development
Backend
cd backend
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
Frontend
Open frontend/index.html directly in a browser. API_URL auto-detects: port 3000 proxies to port 8000, otherwise uses same origin.
File Structure
parcel-tool/
├── docker-compose.yml
├── README.md
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── main.py ← Subdivision engine (v3)
└── frontend/
├── Dockerfile
├── nginx.conf
└── index.html ← OpenLayers UI
Technical Notes
- Coordinate system: EPSG:4326 for API I/O; EPSG:3857 for map display
- Geometry engine: Shapely 2.x + GEOS
- Basemaps: CARTO, OSM, Esri, Stamen — no API key needed
- Theme: Persisted in
localStorageunder keyparcelgen-theme - Imperial mode: Display only — backend always receives metres
- Performance: Sites up to ~50 ha typically process in under 2 s