TL;DR: Try the live demo · Source on GitHub — draw a parking area on a map, pick a time window, and see which spots stay in shade. Powered by open building data, basic sun physics, and shadow math that runs entirely in the browser.
Parking in direct sun can turn a car into an oven.
In hot regions like Kerala, India, even 30 minutes of sunlight on a parked car can make the interior uncomfortably hot. I kept asking a simple question:
Could we estimate which parking spots will stay in shade?
That curiosity became ShadePark — a small web app that estimates how much of a parking area is in sun or shade over a selected time range. You can try it live or browse the source on GitHub.
You draw a parking polygon on a satellite map, choose a time window, and the app visualizes shade coverage as a heatmap over your selection.
Table of contents
- Demo workflow
- The core idea
- System architecture
- Step 1: Getting building data (GOBS)
- Step 2: Loading data into PostGIS
- Step 3: Fetching nearby buildings
- Step 4: Computing shadows in the browser
- From building footprints to shadows
- Step 5: Shade scoring with a parking grid
- Step 6: Map visualization
- Data quality limitations
- Design decisions
- Current limitations
- Future improvements
- Lessons learned
- FAQ
- Try it yourself
Demo workflow
Live demo: shadepark.vercel.app (Kozhikode only — zoom past level 17 to draw a parking area)
The user flow is intentionally simple:
- Zoom in to a location (zoom level must be greater than 17).
- Draw a parking polygon by clicking vertices on the map.
- Choose a time range (for example, 09:00–14:00).
- View a heatmap showing sun vs. shade coverage.
- Change time (optional) — pick a new window for the same parking area without redrawing; buildings stay cached in memory and only shade is recomputed.
| Color | Meaning |
|---|---|
| Green | Mostly shaded during the selected window |
| Yellow | Mixed sun and shade |
| Red | Mostly in direct sun |
The core idea
Shade happens when something blocks sunlight. For urban parking, the main blockers are buildings.
To estimate shade over time, you need three inputs:
- Building footprints (2D geometry)
- Building heights (to scale shadow length)
- Sun position (azimuth and altitude) at each timestep
From there you can project shadows and score how often each part of the parking area sits inside them.
The hard part is not the physics — it is making this interactive in the browser without melting your API server.
System architecture
ShadePark splits cleanly into a thin backend and a geometry-heavy frontend.
| Layer | Stack |
|---|---|
| Frontend | React 19, Vite, MapLibre GL, SunCalc, polygon-clipping, Tailwind CSS |
| Backend | Node.js (Express 5), PostgreSQL + PostGIS |
| Map tiles | ESRI World Imagery (satellite) |
User draws parking polygon
↓
Client POSTs polygon → /api/buildings
↓
PostGIS returns nearby buildings (≤ 500, 10 m buffer)
↓
Client computes swept shadow volumes every 15 minutes
↓
Grid cells scored by % time in shade
↓
MapLibre heatmap (red → yellow → green)
Key design decision: client-side shadow math
All shadow and shade scoring runs in the browser.
The API only answers one question: which buildings are near this parking polygon?
That keeps the server small, avoids heavy geospatial processing on every time-range change, and makes iteration fast during development.
Step 1: Getting building data (GOBS)
Shadows need footprints and heights. I used GOBS (Geospatial Open Building Data).
-
Source file:
KERALA.csv(fromKERALA.csv.gz) -
Scope for the prototype: Kozhikode district only (
district_name = Kozhikode_furtherin the source CSV)
Data prep pipeline (offline)
Tools: xsv for CSV work, GDAL (ogr2ogr) for GeoJSON and DB import.
1. Inspect columns
xsv headers KERALA.csv
2. Keep only what we need
xsv select latitude,longitude,geometry,building_height,district_name KERALA.csv > KERALA_REDUCED.csv
3. Filter to Kozhikode
xsv search -s district_name Kozhikode_further KERALA_REDUCED.csv > KERALA_KOZHIKODE_FURTHER.csv
4. Drop the district column
xsv select latitude,longitude,geometry,building_height KERALA_KOZHIKODE_FURTHER.csv > KOZHIKODE_REDUCED.csv
5. Convert to GeoJSON (WGS84)
ogr2ogr \
-f GeoJSON \
kozhikode.geojson \
KOZHIKODE_REDUCED.csv \
-oo GEOM_POSSIBLE_NAMES=geometry \
-oo KEEP_GEOM_COLUMNS=NO \
-a_srs EPSG:4326
Output: kozhikode.geojson, ready for PostGIS import.
Step 2: Loading data into PostGIS
Import with ogr2ogr into a buildings table:
ogr2ogr \
-f PostgreSQL \
"PG:host=HOST port=PORT dbname=DB user=USER password=PASSWORD sslmode=require" \
kozhikode.geojson \
-nln buildings \
-nlt MULTIPOLYGON \
-lco GEOMETRY_NAME=geom
Important columns:
-
geom—MULTIPOLYGONfootprint -
building_height— used for shadow length
Add a spatial index so intersection queries stay fast:
CREATE INDEX IF NOT EXISTS buildings_geom_idx
ON buildings
USING GIST (geom);
Having the index is not enough — the query must filter on geom (geometry) so Postgres can use it. More on that in Step 3.
Step 3: Fetching nearby buildings
When the user finishes drawing, the client sends a GeoJSON parking polygon to the API.
The server query uses a 10 m geography buffer (for accurate meters), then filters on geometry so the GIST index applies. Results are capped at 500 buildings:
WITH search AS (
SELECT ST_Buffer(
ST_GeomFromGeoJSON($1)::geography,
10
)::geometry AS area
)
SELECT
b.id,
b.building_height,
ST_AsGeoJSON(b.geom) AS geometry
FROM buildings b
CROSS JOIN search s
WHERE b.geom && s.area
AND ST_Intersects(b.geom, s.area)
LIMIT 500;
The && operator checks bounding-box overlap first (fast, index-friendly). ST_Intersects refines to the buffered area.
A performance trap I hit in production
My first version looked reasonable but was disastrously slow (~32 seconds on ~250k rows):
-- Do NOT do this if you have a GIST index on geom
WHERE ST_Intersects(geom::geography, ST_Buffer(...::geography, 10))
EXPLAIN ANALYZE showed a parallel sequential scan — the cast geom::geography on the column prevented Postgres from using the GIST index on geom. The index existed; the query just could not use it.
After switching to geom && area + ST_Intersects(geom, area), the same query dropped to milliseconds. If you build on PostGIS + Supabase, always check the query plan, not just whether an index exists.
Why buffer?
- Shadows extend beyond the parking outline.
- A small buffer pulls in adjacent buildings whose shadows may still reach the lot.
- The
LIMITkeeps client-side union work bounded.
Step 4: Computing shadows in the browser
The interesting work happens after the API responds.
Sun position with SunCalc
I use SunCalc to get sun altitude and azimuth for a lat/lng and timestamp.
When the sun is above the horizon, shadow length follows basic trigonometry:
shadow_length = building_height / tan(sun_altitude)
Each building vertex is projected opposite the sun direction to form a shadow polygon.
Swept volumes, not just shadow outlines
A shadow polygon alone is not enough for “was this point shaded at time t?”
For each building and timestamp, ShadePark builds a swept area: the union of
- the building footprint,
- the shadow footprint, and
- quadrilateral “side faces” connecting corresponding vertices.
That swept polygon represents the full region the building occludes as the sun moves between those two silhouettes for that instant.
The polygon-clipping library unions these pieces in the browser. At each 15-minute step, swept areas from all nearby buildings are unioned again into one multipolygon per timestamp.
From building footprints to shadows
Here is the mental model:
Inputs: 2D footprint + scalar height + sun vector
Output: per-cell % of timesteps in shade over your selected window
This is a deliberate simplification: we treat each building as a vertical extrusion with a flat roof — no roof pitch, no courtyards, no trees.
Step 5: Shade scoring with a parking grid
Instead of testing shade continuously, the app samples the parking polygon on a grid.
- Target cell size: about 3 meters (~one car width).
- Resolution scales with polygon size (clamped between 8 and 80 cells per axis).
- Timesteps: every 15 minutes between
fromTimeandtoTime.
For each cell center:
score = (number of timesteps in shade) / (total timesteps)
Example: 09:00–14:00 → 21 timestamps (15-minute steps). A cell shaded at 12 of them scores ~57% (displayed as 0–100% in the legend).
Night/low-sun edge case: when the sun is below the horizon, timesteps count as fully shaded — reasonable for “no direct sun.”
Step 6: Map visualization
Results render directly on the MapLibre map as a fill layer over the parking polygon, interpolated from red (more sun) through yellow to green (more shade).
A small legend explains the score: percentage of the selected time range spent in shade.
For debugging, URL flags like ?debug=all can show building fills, outlines, and swept-area layers — useful when validating geometry against satellite imagery.
Data quality limitations
GOBS is automatically generated, not hand-surveyed. That matters for shadow apps, because shadow shape follows footprint shape.
In practice I saw simplified footprints that do not match real structures. One example: a building that is effectively triangular on the ground, but GOBS (and Google Maps) show a rectangular box — likely from the same class of automated extraction pipelines many map products share.
What this means for ShadePark:
- Shadow direction and length can still be reasonable if height is in the right ballpark.
- Footprint shape errors skew the shadow outline — especially for irregular buildings.
- For parking-lot-scale decisions (“north side vs. south side of the lot”), the approximation is often still useful.
- For spot-level precision (e.g. one motorcycle bay), treat results as indicative, not ground truth.
Possible improvements:
- Blend OpenStreetMap building footprints with GOBS heights
- Cross-check multiple datasets and drop outliers
- Manual correction for high-traffic POIs
Calling this out openly is part of the engineering story: model quality is capped by data quality.
Design decisions
Why client-side computation?
| Reason | Detail |
|---|---|
| Interaction | Users change time ranges frequently; the client caches buildings in memory after the first fetch so “Change Time” only recomputes shade. |
| Scale | Hundreds of buildings × dozens of timesteps is workable in modern browsers with spatial filtering. |
| Simplicity | The API stays a thin PostGIS lookup service. |
Why spatial filtering + hard limits?
-
ST_Buffer(..., 10)on geography keeps the search area accurate in meters. -
geom && areaensures the GIST index is used before precise intersection. -
LIMIT 500prevents pathological payloads in dense urban cores.
Why a grid instead of continuous geometry?
- Easy to explain (% time in shade per ~3 m cell).
- Fast to render as a choropleth-style heatmap.
- Good enough for “pick a row / pick a corner” parking decisions.
Current limitations
Being honest about scope helps readers trust the prototype:
- Single region — building data covers Kozhikode only (for now).
- Single day — analysis uses today’s date; there is no date picker yet.
- Buildings only — no trees, awnings, or temporary structures.
- No persistence — refresh clears drawn polygons and results (buildings are kept in memory only for “Change Time” within the same session).
- Flat extrusion model — no roof pitch or complex 3D forms.
- GOBS imperfections — see Data quality limitations.
Future improvements
Ideas on the roadmap:
- Date selection (seasonal sun angles change everything in Kerala).
- Expand the GOBS pipeline to more districts/regions.
- Integrate vegetation / OSM layers where available.
- Cache frequent building queries server-side.
- Parking recommendation mode (“best cell in this lot for 12:00–15:00”).
- Optional save/export of analyses.
Lessons learned
This project was a practical tour of:
- Open geospatial pipelines — CSV → GeoJSON → PostGIS
-
Spatial indexing — why
GISTongeommatters, and whygeom::geographyin aWHEREclause can silently force a full table scan - Sun modelling — azimuth, altitude, and shadow length
- Browser geometry — unions, swept areas, and scoring grids
- Product honesty — automated building data is useful but imperfect
The biggest takeaway: simple physics + decent open data + smart architecture can produce a surprisingly useful map tool without a heavy GIS backend.
FAQ
Does ShadePark work outside Kozhikode?
Not yet. The deployed dataset is filtered to Kozhikode. The same GOBS pipeline can be repeated for other districts.
Does the server calculate shadows?
No. The server returns nearby building geometry. The React client runs SunCalc and polygon unions.
How accurate is the shade map?
It is a heuristic based on 2D footprints, estimated heights, and simplified shadow geometry — useful for comparison within a lot, not for engineering sign-off.
Why 15-minute intervals?
A balance between smooth time coverage and client performance. The interval is easy to change in code.
What happens when I change the time range?
After the first analysis, tap Change Time on the results screen. The app reuses the buildings already fetched for that polygon and only reruns the client-side shade simulation — no second trip to PostGIS.
Can I run it locally?
Yes. Clone the repo, import kozhikode.geojson into PostGIS, configure server/.env, then run the Express API and Vite client. See the README.
Try it yourself
- Live demo: shadepark.vercel.app
- Repository: github.com/Anaskp/shadepark
If you build something similar — or extend ShadePark to your city — I would love to hear what you changed (especially around data sources and accuracy).
Keywords for search: parking shade map, sun shadow calculator, PostGIS building query, MapLibre heatmap, GOBS Kerala buildings, client-side geospatial, ShadePark.
















