improved version
This commit is contained in:
commit
f38bb4d136
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.8" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/parcelFiles.iml" filepath="$PROJECT_DIR$/.idea/parcelFiles.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
12
.idea/parcelFiles.iml
generated
Normal file
12
.idea/parcelFiles.iml
generated
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.8" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
226
README.md
Normal file
226
README.md
Normal file
@ -0,0 +1,226 @@
|
||||
# 🗺 Parcel Subdivision Tool
|
||||
|
||||
A full-stack GIS application for automatic land parcel generation from user-drawn site boundaries and road networks.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Docker Network │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Nginx │────▶│ FastAPI Backend │ │
|
||||
│ │ (port 80) │ │ (port 8000) │ │
|
||||
│ │ + Frontend │ │ Shapely/GIS engine │ │
|
||||
│ └─────────────┘ └──────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ :8080
|
||||
Browser
|
||||
(OpenLayers UI)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| **Draw Boundary** | Freehand polygon drawing on map |
|
||||
| **Draw Roads** | Road centerline drawing (buffered to configured width) |
|
||||
| **Auto Road Grid** | Automatic internal road generation if no roads drawn |
|
||||
| **Block Detection** | Remaining buildable areas become blocks |
|
||||
| **Cul-de-sacs** | Auto-generated for blocks exceeding max length |
|
||||
| **Parcel Subdivision** | Rectangular parcel generation respecting min frontage/depth |
|
||||
| **Shape Optimization** | Bad/small parcels absorbed by neighbors |
|
||||
| **Addressing** | Auto address assignment per block/plot |
|
||||
| **GeoJSON Export** | Download all results |
|
||||
| **Layer Controls** | Toggle parcels, roads, blocks, cul-de-sacs |
|
||||
| **Hover Tooltips** | Area, frontage, depth, address per parcel |
|
||||
|
||||
## Default Parameters
|
||||
|
||||
```
|
||||
min_frontage = 12 m
|
||||
min_depth = 25 m
|
||||
road_width = 9 m
|
||||
max_block_length = 120 m
|
||||
allow_culdesac = true
|
||||
corner_radius = 3 m
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose installed
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# Clone / extract project
|
||||
cd parcel-tool
|
||||
|
||||
# Build and start all services
|
||||
docker compose up --build
|
||||
|
||||
# Access at:
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Step 1 — Draw Boundary
|
||||
1. Click **Draw Boundary** in the toolbar
|
||||
2. Click on the map to add polygon vertices
|
||||
3. **Double-click** to finish the polygon
|
||||
4. Press **Escape** to cancel
|
||||
|
||||
### Step 2 — (Optional) Draw Roads
|
||||
1. Click **Draw Road**
|
||||
2. Click to add road centerline vertices
|
||||
3. Double-click to finish
|
||||
4. Repeat for additional roads
|
||||
|
||||
> If no roads are drawn, the engine auto-generates an internal road grid based on `max_block_length`.
|
||||
|
||||
### Step 3 — Configure Parameters
|
||||
Adjust the configuration panel:
|
||||
- **Min Frontage** — minimum plot road frontage
|
||||
- **Min Depth** — minimum plot depth
|
||||
- **Road Width** — road right-of-way width
|
||||
- **Max Block Length** — triggers cul-de-sac or cross-road insertion
|
||||
- **Corner Radius** — road intersection corner rounding
|
||||
- **Allow Cul-de-sacs** — toggle cul-de-sac generation
|
||||
|
||||
### Step 4 — Generate
|
||||
Click **⚡ Generate Subdivision**
|
||||
|
||||
Results appear color-coded:
|
||||
- 🔵 **Blue** — parcels
|
||||
- ⚫ **Dark grey** — roads
|
||||
- 🟢 **Green dashed** — block boundaries
|
||||
- 🟡 **Yellow** — cul-de-sacs
|
||||
|
||||
### Step 5 — Inspect & Export
|
||||
- Hover over parcels to see attributes
|
||||
- Click parcels to highlight/select
|
||||
- Export all data as GeoJSON
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### POST `/api/subdivide`
|
||||
|
||||
```json
|
||||
{
|
||||
"boundary": { /* GeoJSON Polygon */ },
|
||||
"roads": [ /* GeoJSON LineStrings */ ],
|
||||
"config": {
|
||||
"min_frontage": 12,
|
||||
"min_depth": 25,
|
||||
"road_width": 9,
|
||||
"max_block_length": 120,
|
||||
"allow_culdesac": true,
|
||||
"corner_radius": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"parcels": [ /* GeoJSON Features with properties */ ],
|
||||
"roads": [ /* GeoJSON Features */ ],
|
||||
"blocks": [ /* GeoJSON Features */ ],
|
||||
"culdesacs": [ /* GeoJSON Features */ ],
|
||||
"stats": {
|
||||
"total_parcels": 42,
|
||||
"total_blocks": 4,
|
||||
"avg_parcel_area_m2": 340.5,
|
||||
"culdesacs": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/health`
|
||||
Returns `{"status": "ok"}`
|
||||
|
||||
### GET `/api/config/defaults`
|
||||
Returns default configuration values.
|
||||
|
||||
---
|
||||
|
||||
## Subdivision Algorithm
|
||||
|
||||
```
|
||||
1. Parse boundary polygon (EPSG:4326 → WGS84)
|
||||
2. Buffer user roads → road polygons
|
||||
└─ If no roads: auto-generate grid roads at max_block_length intervals
|
||||
3. Subtract roads from boundary → buildable blocks
|
||||
4. For each oversized block (> max_block_length):
|
||||
└─ Insert cul-de-sac if allow_culdesac=true
|
||||
5. For each block:
|
||||
a. Detect dominant orientation via OBB
|
||||
b. Determine double/single frontage layout
|
||||
c. Calculate parcel columns and rows
|
||||
d. Clip each parcel cell to block boundary
|
||||
6. Shape QC:
|
||||
- Compactness check (reject triangular/complex shapes)
|
||||
- Minimum area check
|
||||
- Absorb rejected parcels into neighboring plots
|
||||
7. Assign addresses: "Plot N, Block B Road"
|
||||
8. Return GeoJSON FeatureCollection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Backend (Python/FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
Pure HTML/JS — just open `frontend/index.html` in a browser (update API_BASE to `http://localhost:8000`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
parcel-tool/
|
||||
├── docker-compose.yml
|
||||
├── README.md
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ └── main.py ← Subdivision engine
|
||||
├── frontend/
|
||||
│ └── index.html ← OpenLayers UI
|
||||
└── nginx/
|
||||
└── nginx.conf ← Reverse proxy config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The map uses CartoDB Dark Matter basemap (no API key needed)
|
||||
- Coordinate system: EPSG:4326 (WGS84) for I/O, EPSG:3857 for display
|
||||
- Geometry engine: Shapely 2.x with GEOS backend
|
||||
- For large sites (>100ha), processing may take a few seconds
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libgeos-dev \
|
||||
libgeos-c1v5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
|
||||
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
4
backend/entrypoint.sh
Normal file
4
backend/entrypoint.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "Starting Parcel Subdivision Backend on port 5000..."
|
||||
exec uvicorn main:app --host 0.0.0.0 --port 5000
|
||||
651
backend/main.py
Normal file
651
backend/main.py
Normal file
@ -0,0 +1,651 @@
|
||||
"""
|
||||
Parcel Subdivision Engine - FastAPI Backend v3
|
||||
- User roads + auto-fill roads combined (not either/or)
|
||||
- Existing building footprints respected: carved from buildable area,
|
||||
containing parcel flagged status=built
|
||||
- Perimeter access road always present
|
||||
- Access enforcement pass
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
import math, uuid, traceback
|
||||
from shapely.geometry import (
|
||||
Polygon, MultiPolygon, LineString, MultiLineString,
|
||||
Point, GeometryCollection, mapping, shape
|
||||
)
|
||||
from shapely.ops import unary_union
|
||||
from shapely.affinity import rotate
|
||||
from shapely.validation import make_valid
|
||||
|
||||
app = FastAPI(title="Parcel Subdivision Engine", version="3.0.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Models ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class SubdivisionConfig(BaseModel):
|
||||
min_frontage: float = 12.0
|
||||
min_depth: float = 25.0
|
||||
road_width: float = 9.0
|
||||
max_block_length: float = 120.0
|
||||
allow_culdesac: bool = True
|
||||
corner_radius: float = 3.0
|
||||
min_area: Optional[float] = None
|
||||
|
||||
def model_post_init(self, _):
|
||||
if self.min_area is None:
|
||||
self.min_area = self.min_frontage * self.min_depth
|
||||
|
||||
class SubdivisionRequest(BaseModel):
|
||||
boundary: Dict[str, Any]
|
||||
roads: Optional[List[Dict[str, Any]]] = []
|
||||
existing_features: Optional[List[Dict[str, Any]]] = []
|
||||
zones: Optional[List[Dict[str, Any]]] = []
|
||||
config: Optional[SubdivisionConfig] = None
|
||||
|
||||
# ─── Geometry helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def valid(geom):
|
||||
if geom is None or geom.is_empty:
|
||||
return geom
|
||||
if not geom.is_valid:
|
||||
geom = make_valid(geom)
|
||||
return geom
|
||||
|
||||
def extract_geometry(obj: Dict) -> Any:
|
||||
"""Accept GeoJSON Feature wrapper or bare geometry dict."""
|
||||
if obj is None:
|
||||
raise ValueError("Null geometry")
|
||||
geo = obj.get("geometry", obj) if obj.get("type") == "Feature" else obj
|
||||
if geo is None or not geo.get("type"):
|
||||
raise ValueError(f"Cannot extract geometry from: {list(obj.keys())}")
|
||||
return shape(geo)
|
||||
|
||||
def flatten_polygons(geom) -> List[Polygon]:
|
||||
"""Recursively extract all Polygon parts from any geometry."""
|
||||
if geom is None or geom.is_empty:
|
||||
return []
|
||||
if isinstance(geom, Polygon):
|
||||
return [geom]
|
||||
if isinstance(geom, (MultiPolygon, GeometryCollection)):
|
||||
out = []
|
||||
for g in geom.geoms:
|
||||
out.extend(flatten_polygons(g))
|
||||
return out
|
||||
return []
|
||||
|
||||
def obb(polygon: Polygon) -> Tuple[float, float, float]:
|
||||
"""Oriented bounding box → (short, long, angle_deg)."""
|
||||
mbr = polygon.minimum_rotated_rectangle
|
||||
coords = list(mbr.exterior.coords)
|
||||
e1 = math.hypot(coords[1][0]-coords[0][0], coords[1][1]-coords[0][1])
|
||||
e2 = math.hypot(coords[2][0]-coords[1][0], coords[2][1]-coords[1][1])
|
||||
ang = math.degrees(math.atan2(coords[1][1]-coords[0][1], coords[1][0]-coords[0][0]))
|
||||
return (min(e1,e2), max(e1,e2), ang)
|
||||
|
||||
def estimate_scale(boundary: Polygon) -> float:
|
||||
"""Metres per degree at boundary centroid latitude."""
|
||||
lat = boundary.centroid.y
|
||||
return (111_320.0 + 111_320.0 * math.cos(math.radians(lat))) / 2.0
|
||||
|
||||
def deg(metres: float, sc: float) -> float:
|
||||
"""Convert metres → degrees using scale factor."""
|
||||
return metres / sc
|
||||
|
||||
def buffer_line(line, width_deg: float) -> Polygon:
|
||||
return line.buffer(width_deg / 2, cap_style=2, join_style=2)
|
||||
|
||||
# ─── Road network ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_road_network(
|
||||
boundary: Polygon,
|
||||
user_roads: List[LineString],
|
||||
config: SubdivisionConfig,
|
||||
sc: float
|
||||
) -> Tuple[List[Polygon], Polygon]:
|
||||
"""
|
||||
Build the complete road network:
|
||||
1. Buffer every user-drawn road centreline.
|
||||
2. Auto-generate grid roads for any block that exceeds max_block_length
|
||||
(regardless of whether the user drew some roads already).
|
||||
3. Add a perimeter half-road ring so every outer block has access.
|
||||
Returns (road_polygons_for_display, road_union_polygon).
|
||||
"""
|
||||
rw = deg(config.road_width, sc)
|
||||
bl = deg(config.max_block_length, sc)
|
||||
step = bl + rw # spacing between road centrelines
|
||||
|
||||
road_polys: List[Polygon] = []
|
||||
|
||||
# ── Step 1: user roads ────────────────────────────────────────────────────
|
||||
for line in user_roads:
|
||||
cl = valid(line.intersection(boundary))
|
||||
if cl and not cl.is_empty:
|
||||
rp = valid(buffer_line(cl, rw).intersection(boundary))
|
||||
if rp and not rp.is_empty:
|
||||
road_polys.append(rp)
|
||||
|
||||
# ── Step 2: auto-fill grid roads ──────────────────────────────────────────
|
||||
# Always run auto-generation, but only add roads that fall in areas NOT
|
||||
# already covered by user roads (avoids duplicate overlapping roads).
|
||||
existing_road_union = valid(unary_union(road_polys)) if road_polys else Polygon()
|
||||
|
||||
minx, miny, maxx, maxy = boundary.bounds
|
||||
site_w = maxx - minx
|
||||
site_h = maxy - miny
|
||||
|
||||
def try_add(line: LineString):
|
||||
cl = valid(line.intersection(boundary))
|
||||
if not cl or cl.is_empty or cl.length < deg(config.min_frontage * 2, sc):
|
||||
return
|
||||
rp = valid(buffer_line(cl, rw).intersection(boundary))
|
||||
if not rp or rp.is_empty:
|
||||
return
|
||||
# Skip if this road is already substantially covered by user roads
|
||||
if not existing_road_union.is_empty:
|
||||
overlap = rp.intersection(existing_road_union).area / rp.area
|
||||
if overlap > 0.6:
|
||||
return
|
||||
road_polys.append(rp)
|
||||
|
||||
# Vertical auto-roads
|
||||
x_positions = []
|
||||
x = minx + step
|
||||
while x < maxx - rw:
|
||||
x_positions.append(x)
|
||||
x += step
|
||||
if not x_positions and site_w > deg(config.min_depth * 2 + config.road_width, sc):
|
||||
x_positions = [minx + site_w / 2]
|
||||
for x in x_positions:
|
||||
try_add(LineString([(x, miny - 1), (x, maxy + 1)]))
|
||||
|
||||
# Horizontal auto-roads
|
||||
y_positions = []
|
||||
y = miny + step
|
||||
while y < maxy - rw:
|
||||
y_positions.append(y)
|
||||
y += step
|
||||
if not y_positions and site_h > deg(config.min_depth * 2 + config.road_width, sc):
|
||||
y_positions = [miny + site_h / 2]
|
||||
for y in y_positions:
|
||||
try_add(LineString([(minx - 1, y), (maxx + 1, y)]))
|
||||
|
||||
# ── Step 3: perimeter access ring ────────────────────────────────────────
|
||||
perim_width = deg(config.road_width * 0.5, sc)
|
||||
inner = valid(boundary.buffer(-perim_width))
|
||||
if inner and not inner.is_empty and inner.area > 0:
|
||||
perim = valid(boundary.difference(inner))
|
||||
if perim and not perim.is_empty:
|
||||
road_polys.append(perim)
|
||||
|
||||
# Final road union
|
||||
road_union = valid(unary_union(road_polys)) if road_polys else Polygon()
|
||||
return road_polys, road_union
|
||||
|
||||
# ─── Existing features (buildings) ───────────────────────────────────────────
|
||||
|
||||
def process_existing_features(
|
||||
building_geoms: List[Polygon],
|
||||
buildable: Polygon,
|
||||
parcels: List[Dict],
|
||||
sc: float,
|
||||
config: SubdivisionConfig
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
For each existing building footprint:
|
||||
- Find which parcel(s) it overlaps.
|
||||
- If it spans multiple parcels, merge them into one.
|
||||
- Mark the containing parcel status='built', store building info.
|
||||
- Remove building area from the parcel geometry is NOT done
|
||||
(building sits inside the parcel, that's realistic).
|
||||
Buildings that fall entirely outside any parcel are ignored.
|
||||
"""
|
||||
if not building_geoms or not parcels:
|
||||
return parcels
|
||||
|
||||
tol = deg(0.5, sc)
|
||||
|
||||
for bldg in building_geoms:
|
||||
bldg_buf = bldg.buffer(tol)
|
||||
hit_indices = []
|
||||
for i, p in enumerate(parcels):
|
||||
pg = shape(p["geometry"])
|
||||
if pg.intersects(bldg_buf):
|
||||
hit_indices.append(i)
|
||||
|
||||
if not hit_indices:
|
||||
continue # building outside all parcels — skip
|
||||
|
||||
# Merge all hit parcels into one
|
||||
while len(hit_indices) > 1:
|
||||
i, j = hit_indices[0], hit_indices[1]
|
||||
gi = shape(parcels[i]["geometry"])
|
||||
gj = shape(parcels[j]["geometry"])
|
||||
merged = valid(gi.union(gj))
|
||||
if isinstance(merged, MultiPolygon):
|
||||
merged = max(merged.geoms, key=lambda x: x.area)
|
||||
area_m2 = round(merged.area * sc**2, 1)
|
||||
pw, ph, _ = obb(merged)
|
||||
parcels[i]["geometry"] = mapping(merged)
|
||||
parcels[i]["properties"].update({
|
||||
"area_m2": area_m2,
|
||||
"frontage_m": round(min(pw,ph)*sc, 1),
|
||||
"depth_m": round(max(pw,ph)*sc, 1),
|
||||
"has_access": (parcels[i]["properties"].get("has_access")
|
||||
or parcels[j]["properties"].get("has_access")),
|
||||
"area_ok": area_m2 >= config.min_area * 0.8,
|
||||
})
|
||||
parcels.pop(j)
|
||||
# Rebuild indices after pop
|
||||
hit_indices = [hit_indices[0]] + [
|
||||
k - (1 if k > j else 0) for k in hit_indices[2:]
|
||||
]
|
||||
|
||||
# Mark the surviving parcel as built
|
||||
target = hit_indices[0]
|
||||
parcels[target]["properties"]["status"] = "built"
|
||||
parcels[target]["properties"]["building_area_m2"] = round(bldg.area * sc**2, 1)
|
||||
|
||||
return parcels
|
||||
|
||||
# ─── Cul-de-sac generator ─────────────────────────────────────────────────────
|
||||
|
||||
def generate_culdesacs(
|
||||
blocks: List[Polygon],
|
||||
road_union: Polygon,
|
||||
config: SubdivisionConfig,
|
||||
sc: float
|
||||
) -> Tuple[List[Polygon], List[Polygon]]:
|
||||
cds_polys, new_blocks = [], []
|
||||
bl = deg(config.max_block_length, sc)
|
||||
rw = deg(config.road_width, sc)
|
||||
|
||||
for block in blocks:
|
||||
_, h, _ = obb(block)
|
||||
if h > bl and config.allow_culdesac:
|
||||
cds, trimmed = _try_culdesac(block, rw, config, sc)
|
||||
if cds and trimmed and not trimmed.is_empty:
|
||||
cds_polys.append(cds)
|
||||
new_blocks.append(trimmed)
|
||||
continue
|
||||
new_blocks.append(block)
|
||||
|
||||
return cds_polys, new_blocks
|
||||
|
||||
def _try_culdesac(block, rw_deg, config, sc):
|
||||
cx, _ = block.centroid.x, block.centroid.y
|
||||
minx, miny, maxx, maxy = block.bounds
|
||||
radius = deg(config.road_width * 1.8, sc)
|
||||
stub_len = deg(config.max_block_length * 0.45, sc)
|
||||
|
||||
stub = LineString([(cx, miny), (cx, miny + stub_len)]).intersection(block)
|
||||
if stub.is_empty or stub.length < rw_deg:
|
||||
return None, None
|
||||
|
||||
stub_road = valid(buffer_line(stub, rw_deg).intersection(block))
|
||||
end_pt = list(stub.coords)[-1]
|
||||
circle = valid(Point(end_pt).buffer(radius).intersection(block))
|
||||
cds_area = valid(unary_union([stub_road, circle]).intersection(block))
|
||||
trimmed = valid(block.difference(cds_area))
|
||||
|
||||
if trimmed.is_empty or trimmed.area * sc**2 < config.min_area:
|
||||
return None, None
|
||||
return cds_area, trimmed
|
||||
|
||||
# ─── Block subdivider ─────────────────────────────────────────────────────────
|
||||
|
||||
def subdivide_block(
|
||||
block: Polygon,
|
||||
config: SubdivisionConfig,
|
||||
road_union: Polygon,
|
||||
block_id: int,
|
||||
start_num: int,
|
||||
sc: float
|
||||
) -> List[Dict]:
|
||||
min_f = deg(config.min_frontage, sc)
|
||||
min_d = deg(config.min_depth, sc)
|
||||
min_a = config.min_area / sc**2
|
||||
|
||||
_, _, angle = obb(block)
|
||||
centroid = block.centroid
|
||||
|
||||
rotated = valid(rotate(block, -angle, origin=centroid))
|
||||
rminx, rminy, rmaxx, rmaxy = rotated.bounds
|
||||
bw = rmaxx - rminx
|
||||
bh = rmaxy - rminy
|
||||
|
||||
num = start_num
|
||||
parcels = []
|
||||
|
||||
def emit(rect_in_rotated):
|
||||
nonlocal num
|
||||
p = valid(rect_in_rotated.intersection(rotated))
|
||||
if p is None or p.is_empty or p.area < min_a * 0.5:
|
||||
return
|
||||
p = valid(rotate(p, angle, origin=centroid))
|
||||
feats = _make_parcel_features(p, config, block_id, num, sc, road_union)
|
||||
parcels.extend(feats)
|
||||
num += 1
|
||||
|
||||
if bw >= bh:
|
||||
# Wide block: frontage along X, depth along Y
|
||||
n_cols = max(1, round(bw / min_f))
|
||||
col_w = bw / n_cols
|
||||
rows = ([(rminy, rminy + bh/2), (rminy + bh/2, rmaxy)]
|
||||
if bh >= min_d * 2 else [(rminy, rmaxy)])
|
||||
for y0, y1 in rows:
|
||||
for c in range(n_cols):
|
||||
x0 = rminx + c * col_w
|
||||
emit(Polygon([(x0,y0),(x0+col_w,y0),(x0+col_w,y1),(x0,y1)]))
|
||||
else:
|
||||
# Tall block: frontage along Y, depth along X
|
||||
n_rows = max(1, round(bh / min_f))
|
||||
row_h = bh / n_rows
|
||||
cols = ([(rminx, rminx + bw/2), (rminx + bw/2, rmaxx)]
|
||||
if bw >= min_d * 2 else [(rminx, rmaxx)])
|
||||
for x0, x1 in cols:
|
||||
for r in range(n_rows):
|
||||
y0 = rminy + r * row_h
|
||||
emit(Polygon([(x0,y0),(x1,y0),(x1,y0+row_h),(x0,y0+row_h)]))
|
||||
|
||||
return parcels
|
||||
|
||||
def _make_parcel_features(geom, config, block_id, num, sc, road_union):
|
||||
out = []
|
||||
parts = flatten_polygons(geom) if not isinstance(geom, Polygon) else [geom]
|
||||
tol = deg(0.5, sc)
|
||||
|
||||
for g in parts:
|
||||
if g.is_empty:
|
||||
continue
|
||||
area_m2 = round(g.area * sc**2, 1)
|
||||
if area_m2 < config.min_area * 0.4:
|
||||
continue
|
||||
pw, ph, _ = obb(g)
|
||||
has_access = (road_union is not None
|
||||
and not road_union.is_empty
|
||||
and g.buffer(tol).intersects(road_union))
|
||||
out.append({
|
||||
"type": "Feature",
|
||||
"geometry": mapping(g),
|
||||
"properties": {
|
||||
"parcel_id": f"P{num:04d}",
|
||||
"parcel_num": num,
|
||||
"block_id": block_id,
|
||||
"area_m2": area_m2,
|
||||
"area_ha": round(area_m2 / 10000, 4),
|
||||
"frontage_m": round(min(pw,ph) * sc, 1),
|
||||
"depth_m": round(max(pw,ph) * sc, 1),
|
||||
"address": f"Block {block_id}, Plot {num}",
|
||||
"zone": "Residential",
|
||||
"status": "vacant",
|
||||
"has_access": has_access,
|
||||
"frontage_ok": round(min(pw,ph)*sc,1) >= config.min_frontage * 0.85,
|
||||
"area_ok": area_m2 >= config.min_area * 0.80,
|
||||
}
|
||||
})
|
||||
return out
|
||||
|
||||
# ─── Quality passes ───────────────────────────────────────────────────────────
|
||||
|
||||
def absorb_bad_parcels(parcels, config, sc):
|
||||
"""Merge undersized / badly-shaped parcels into best same-block neighbour."""
|
||||
tol = deg(0.5, sc)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
geoms = [shape(p["geometry"]) for p in parcels]
|
||||
for i, p in enumerate(parcels):
|
||||
g = geoms[i]
|
||||
area_m2 = g.area * sc**2
|
||||
w, h, _ = obb(g)
|
||||
compact = 4 * math.pi * g.area / (g.length**2) if g.length else 0
|
||||
ok = (area_m2 >= config.min_area * 0.8
|
||||
and compact >= 0.18
|
||||
and (h == 0 or w/h >= 0.12))
|
||||
if ok:
|
||||
continue
|
||||
best_j, best_touch = -1, 0.0
|
||||
for j, q in enumerate(parcels):
|
||||
if j == i:
|
||||
continue
|
||||
if p["properties"]["block_id"] != q["properties"]["block_id"]:
|
||||
continue
|
||||
touch = g.buffer(tol).intersection(geoms[j]).area
|
||||
if touch > best_touch:
|
||||
best_touch, best_j = touch, j
|
||||
if best_j >= 0 and best_touch > 0:
|
||||
merged = valid(g.union(geoms[best_j]))
|
||||
if isinstance(merged, MultiPolygon):
|
||||
merged = max(merged.geoms, key=lambda x: x.area)
|
||||
am2 = round(merged.area * sc**2, 1)
|
||||
pw, ph, _ = obb(merged)
|
||||
acc = (p["properties"].get("has_access")
|
||||
or parcels[best_j]["properties"].get("has_access"))
|
||||
parcels[best_j]["geometry"] = mapping(merged)
|
||||
parcels[best_j]["properties"].update({
|
||||
"area_m2": am2, "frontage_m": round(min(pw,ph)*sc,1),
|
||||
"depth_m": round(max(pw,ph)*sc,1), "has_access": acc,
|
||||
"area_ok": am2 >= config.min_area * 0.8,
|
||||
})
|
||||
geoms[best_j] = merged
|
||||
parcels.pop(i)
|
||||
changed = True
|
||||
break
|
||||
return parcels
|
||||
|
||||
def enforce_access(parcels, road_union, config, sc):
|
||||
"""Merge no-access parcels into nearest accessed neighbour in same block."""
|
||||
if not road_union or road_union.is_empty:
|
||||
return parcels
|
||||
tol = deg(1.0, sc)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
geoms = [shape(p["geometry"]) for p in parcels]
|
||||
for i, p in enumerate(parcels):
|
||||
if p["properties"].get("has_access"):
|
||||
continue
|
||||
g = geoms[i]
|
||||
best_j, best_shared = -1, 0.0
|
||||
for j, q in enumerate(parcels):
|
||||
if j == i:
|
||||
continue
|
||||
if p["properties"]["block_id"] != q["properties"]["block_id"]:
|
||||
continue
|
||||
if not q["properties"].get("has_access"):
|
||||
continue
|
||||
shared = g.buffer(tol).intersection(geoms[j]).area
|
||||
if shared > best_shared:
|
||||
best_shared, best_j = shared, j
|
||||
if best_j >= 0:
|
||||
merged = valid(g.union(geoms[best_j]))
|
||||
if isinstance(merged, MultiPolygon):
|
||||
merged = max(merged.geoms, key=lambda x: x.area)
|
||||
am2 = round(merged.area * sc**2, 1)
|
||||
pw, ph, _ = obb(merged)
|
||||
parcels[best_j]["geometry"] = mapping(merged)
|
||||
parcels[best_j]["properties"].update({
|
||||
"area_m2": am2, "frontage_m": round(min(pw,ph)*sc,1),
|
||||
"depth_m": round(max(pw,ph)*sc,1), "has_access": True,
|
||||
"area_ok": am2 >= config.min_area * 0.8,
|
||||
})
|
||||
geoms[best_j] = merged
|
||||
parcels.pop(i)
|
||||
changed = True
|
||||
break
|
||||
# Final access recheck
|
||||
for p in parcels:
|
||||
g = shape(p["geometry"])
|
||||
p["properties"]["has_access"] = (
|
||||
not road_union.is_empty and g.buffer(tol).intersects(road_union)
|
||||
)
|
||||
return parcels
|
||||
|
||||
# ─── Main endpoint ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/subdivide")
|
||||
async def subdivide(request: SubdivisionRequest):
|
||||
try:
|
||||
config = request.config or SubdivisionConfig()
|
||||
|
||||
# 1. Parse & validate boundary
|
||||
boundary = valid(extract_geometry(request.boundary))
|
||||
if isinstance(boundary, MultiPolygon):
|
||||
boundary = max(boundary.geoms, key=lambda g: g.area)
|
||||
if not isinstance(boundary, Polygon):
|
||||
raise HTTPException(400, "Boundary must be a polygon")
|
||||
|
||||
sc = estimate_scale(boundary)
|
||||
|
||||
# 2. Parse user-drawn road centrelines
|
||||
user_roads: List[LineString] = []
|
||||
for r in (request.roads or []):
|
||||
try:
|
||||
g= shape(r["geometry"])
|
||||
#g = extract_geometry(r)
|
||||
if isinstance(g, LineString):
|
||||
user_roads.append(g)
|
||||
elif isinstance(g, MultiLineString):
|
||||
user_roads.extend(g.geoms)
|
||||
except Exception:
|
||||
pass
|
||||
# print("roads")
|
||||
# print(request.roads)
|
||||
# print(user_roads)
|
||||
# 3. Parse existing building footprints
|
||||
print(request.existing_features)
|
||||
building_geoms: List[Polygon] = []
|
||||
for f in (request.existing_features or []):
|
||||
try:
|
||||
#g = extract_geometry(f)
|
||||
g = shape(f["geometry"])
|
||||
# Clip to boundary
|
||||
g = valid(g.intersection(boundary))
|
||||
building_geoms.extend(flatten_polygons(g))
|
||||
except Exception:
|
||||
pass
|
||||
#print("buildings")
|
||||
#print( building_geoms)
|
||||
# 4. Build road network (user + auto-fill + perimeter)
|
||||
road_polys, road_union = build_road_network(boundary, user_roads, config, sc)
|
||||
|
||||
# 5. Buildable area = boundary − roads − existing buildings
|
||||
# Buildings are carved out so subdivision doesn't cut through them.
|
||||
obstacles = road_union
|
||||
if building_geoms:
|
||||
bldg_union = valid(unary_union(building_geoms))
|
||||
obstacles = valid(unary_union([road_union, bldg_union]))
|
||||
|
||||
buildable = valid(boundary.difference(obstacles))
|
||||
|
||||
# 6. Extract blocks
|
||||
min_block_area = config.min_area / sc**2
|
||||
blocks = [b for b in flatten_polygons(buildable) if b.area > min_block_area]
|
||||
|
||||
# 7. Cul-de-sacs
|
||||
cds_polys: List[Polygon] = []
|
||||
if config.allow_culdesac and blocks:
|
||||
cds_polys, blocks = generate_culdesacs(blocks, road_union, config, sc)
|
||||
if cds_polys:
|
||||
road_union = valid(unary_union([road_union] + cds_polys))
|
||||
|
||||
# 8. Subdivide every block
|
||||
all_parcels: List[Dict] = []
|
||||
num = 1
|
||||
for bid, block in enumerate(blocks, 1):
|
||||
bp = subdivide_block(block, config, road_union, bid, num, sc)
|
||||
all_parcels.extend(bp)
|
||||
num += len(bp)
|
||||
|
||||
# 9. Quality passes
|
||||
all_parcels = absorb_bad_parcels(all_parcels, config, sc)
|
||||
all_parcels = enforce_access(all_parcels, road_union, config, sc)
|
||||
|
||||
# 10. Apply existing building footprints → merge spanning parcels, mark built
|
||||
if building_geoms:
|
||||
all_parcels = process_existing_features(
|
||||
building_geoms, buildable, all_parcels, sc, config
|
||||
)
|
||||
|
||||
# 11. Re-number
|
||||
for i, p in enumerate(all_parcels, 1):
|
||||
p["properties"]["parcel_num"] = i
|
||||
p["properties"]["parcel_id"] = f"P{i:04d}"
|
||||
|
||||
# 12. Build response collections
|
||||
# Roads: exclude the perimeter ring (last element) from display features
|
||||
# because it is very thin and would look ugly — the boundary line already
|
||||
# shows where the perimeter is.
|
||||
display_road_polys = road_polys[:-1] if road_polys else road_polys
|
||||
road_features = [
|
||||
{"type": "Feature", "geometry": mapping(rp),
|
||||
"properties": {"type": "road_surface", "id": f"R{i+1:03d}",
|
||||
"width_m": config.road_width}}
|
||||
for i, rp in enumerate(display_road_polys)
|
||||
]
|
||||
|
||||
block_features = [
|
||||
{"type": "Feature", "geometry": mapping(b),
|
||||
"properties": {"block_id": i+1, "area_m2": round(b.area*sc**2, 1)}}
|
||||
for i, b in enumerate(blocks)
|
||||
]
|
||||
|
||||
cds_features = [
|
||||
{"type": "Feature", "geometry": mapping(c),
|
||||
"properties": {"type": "culdesac", "id": f"CDS{i+1:03d}"}}
|
||||
for i, c in enumerate(cds_polys)
|
||||
]
|
||||
|
||||
# 13. Stats
|
||||
areas = [p["properties"]["area_m2"] for p in all_parcels]
|
||||
no_acc = sum(1 for p in all_parcels if not p["properties"].get("has_access"))
|
||||
built = sum(1 for p in all_parcels if p["properties"].get("status") == "built")
|
||||
|
||||
stats = {
|
||||
"total_parcels": len(all_parcels),
|
||||
"total_blocks": len(blocks),
|
||||
"total_roads": len(display_road_polys),
|
||||
"culdesacs": len(cds_polys),
|
||||
"parcels_no_access": no_acc,
|
||||
"parcels_built": built,
|
||||
"parcels_vacant": len(all_parcels) - built,
|
||||
"boundary_area_m2": round(boundary.area * sc**2, 1),
|
||||
"road_area_m2": round(road_union.area * sc**2, 1) if not road_union.is_empty else 0,
|
||||
"buildable_area_m2": round(sum(b.area * sc**2 for b in blocks), 1),
|
||||
"avg_parcel_area_m2": round(sum(areas)/len(areas), 1) if areas else 0,
|
||||
"min_parcel_area_m2": round(min(areas), 1) if areas else 0,
|
||||
"existing_buildings": len(building_geoms),
|
||||
"user_roads_drawn": len(user_roads),
|
||||
"config": {
|
||||
"min_frontage": config.min_frontage, "min_depth": config.min_depth,
|
||||
"road_width": config.road_width, "max_block_length": config.max_block_length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"parcels": all_parcels,
|
||||
"roads": road_features,
|
||||
"blocks": block_features,
|
||||
"culdesacs": cds_features,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"{e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "3.0.0"}
|
||||
|
||||
@app.get("/config/defaults")
|
||||
async def get_defaults():
|
||||
return SubdivisionConfig().model_dump()
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
pydantic==2.7.4
|
||||
shapely==2.0.4
|
||||
numpy==1.26.4
|
||||
python-multipart==0.0.9
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@ -0,0 +1,40 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: parcel-backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- FLASK_DEBUG=true
|
||||
- PYTHONUNBUFFERED=1
|
||||
- LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
networks:
|
||||
- parcel-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: parcel-frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- parcel-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
parcel-network:
|
||||
driver: bridge
|
||||
25
frontend/Dockerfile
Normal file
25
frontend/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Create directory structure
|
||||
RUN mkdir -p /usr/share/nginx/html
|
||||
|
||||
# Copy all frontend files
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
#COPY styles.css /usr/share/nginx/html/
|
||||
#COPY parcel-ui.js /usr/share/nginx/html/
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Ensure correct permissions
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chmod -R 755 /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
1603
frontend/index.html
Normal file
1603
frontend/index.html
Normal file
File diff suppressed because it is too large
Load Diff
92
frontend/nginx.conf
Normal file
92
frontend/nginx.conf
Normal file
@ -0,0 +1,92 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/javascript application/xml+rss
|
||||
application/json;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers (adjusted)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Frontend - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Static assets with correct MIME types
|
||||
location ~* \.(css|js)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
|
||||
# Ensure correct MIME types
|
||||
types {
|
||||
text/css css;
|
||||
application/javascript js;
|
||||
}
|
||||
}
|
||||
|
||||
# Images and fonts
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API Proxy - remove from frontend nginx
|
||||
# Backend will be accessed directly by frontend JavaScript
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
nginx/nginx.conf
Normal file
40
nginx/nginx.conf
Normal file
@ -0,0 +1,40 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Upstream servers
|
||||
upstream backend {
|
||||
server backend:5000;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user