The City Map Generator is a deterministic, print-grade city map rendering system built on top of OpenStreetMap (OSM) data.
The goal of the project is to create a scalable, webshop-ready map production pipeline that:
- 🖨️ Generates print-ready PDF files
- 🌐 Provides live web preview via API
- 🧠 Uses deterministic seed-based rendering
- 🏗️ Strictly separates rendering and layout layers
- 🛒 Is optimized for e-commerce integration
React Frontend
↓
FastAPI Backend
↓
OSMnx
↓
Matplotlib Render
↓
ReportLab Layout
↓
Print-ready PDF
The system operates in two separate repositories:
city_map_generator– Python backendcity-map-frontend– React configurator UI
cm → aspect ratio → render → layout → production
- Centimeter-based size logic
- Deterministic visual output
- Full separation of map and layout
- Print-grade PDF export
- Scalable webshop production pipeline
- OSMnx 2.x compatible pipeline
graph_from_pointretrievalpolygonize-based block generation- Bounding-box clipping
- Seed-based deterministic block coloring
Road classes:
- Highway
- Arterial
- Local
- Minor
Line width calculation:
base_widthmultipliers[class]- Extent-based scaling
This ensures visual consistency across all product sizes.
natural=waterretrieval from OSM- Clipping in projected CRS
- Subtraction from city blocks (
difference) - Palette-specific water color
By default, the following are not rendered:
footwaycyclewaypathpedestrianstepsbridleway
This eliminates OSM noise and unwanted parallel “hairline” artifacts.
Central configuration:
from generator.styles import get_palette_config
palette = get_palette_config("urban_modern")Available palettes (examples):
urban_modernvintage_atlasblack_minimal
Configurable parameters:
- background color
- block colors
- water color
- road color
- road style (base width + multipliers)
Fully version-controlled and deterministic.
POST /preview
{
"lat": 48.1351,
"lon": 11.5820,
"size_key": "30x40",
"extent_m": 2000,
"palette": "urban_modern"
}image/png
Enables:
- Live React preview
- Size switching
- Palette switching
- Dynamic map extent adjustment
- Deterministic seed
- DPI-aware rendering
- Full-bleed axes
- Engine-specific map layer generation
- Temporary SVG layer used as composition input
- Size-driven poster layout
- Engine-specific passepartout and typography rules
- Final composed poster SVG for inspection and frontend consistency
- Fade / bottom band / decorative subtitle lines handled at layout level
- The engine first renders a temporary map-only SVG layer.
- That temporary SVG is then composed into the final poster SVG.
- Final PNG and PDF are generated from the composed poster stage, never from the raw map layer directly.
- The temporary map SVG is written to a transient directory and is no longer saved into
output/.
For Line Engine posters, the browser-rendered final SVG remains the visual source of truth for layout and styling, but PNG/PDF export uses a dedicated stable print path.
Reason:
- In this workspace environment, Cairo runtime libraries may be unavailable.
- Under those conditions, generic SVG→PDF/PNG conversion backends can shift typography, fade overlays, or nested SVG placement.
Current solution:
- Final poster SVG is still produced for inspection and frontend consistency.
- Line Engine PNG/PDF exports are composed through a dedicated ReportLab-based print path that reproduces the same poster layout directly for stable output.
- PNG is rasterized from that dedicated PDF composition path, so PDF and PNG stay in sync with each other.
- This avoids intermediate converter regressions while keeping the final SVG available as the canonical visual reference.
- Fade is applied to the map area only, not to the passepartout.
- Passepartout remains visually untouched (top, sides, and bottom strip stay solid).
- Fade geometry and opacity profile are identical across SVG/PDF/PNG outputs.
- For dark Line Engine minimal palettes (Minimal Night, Blueprint), fade color is black while preserving the same fade geometry and opacity progression.
- Building Engine now draws water as a dissolved, seam-free surface to avoid mid-river artifacts caused by overlapping inner geometries.
waterwayoverlays are treated as fallback only when no polygonal water surface is available.- Non-water polygon layers are masked against the final water geometry to prevent accidental overlays inside river surfaces.
- Bridge segments are rendered as a dedicated top layer so crossings stay distinguishable from water and building fills.
Current bridge color rules in Building Engine:
architect_sage:#bfd4cfwarm_terracotta:#f5e8d6sandstone_beige: road colorluxury_gold:#111111midnight_blue:#183940mono_black:#e0e0e0royal_purple:#4b4779
- For Line Engine minimal styles, static placeholder PNG assets are used before the customer selects a custom location.
- Placeholder selection is palette+size specific (
{palette}_{size}.png) and sourced fromconfigurator/frontend/public/city-placeholders/. - Placeholder PNG is rendered as a full poster asset (no additional frontend passepartout/fade/text overlay is applied on top).
- After the customer selects a custom location, configurator preview switches from placeholder mode to the live map module and no longer renders placeholder PNG.
All product sizes follow:
cm → aspect ratio → extent_m → DPI → exact print PDF
Example output:
citymap_50x70_2026-02-16_21-45-12.pdf
python main.py \
--center-lat 47.4979 \
--center-lon 19.0402 \
--size-key 50x70 \
--extent-m 3000 \
--palette urban_modern \
--output-dir output/The star map backend is rebuilt around Skyfield and supports parameterized rendering by:
- place (name or latitude/longitude)
- local date and time
- limiting magnitude and field of view
Batch render entrypoint:
python batch_render_stars.pyThe script currently demonstrates these parameters:
- location_query: place-name geocoding
- when_local: local datetime input
- lat/lon: explicit coordinate override
- limiting_magnitude
- field_of_view_degrees
- max_star_size
city_map_generator/
│
├── api.py
├── service.py
├── main.py
├── requirements.txt
│
├── generator/
│ ├── render.py
│ ├── layout_composer.py
│ ├── specs.py
│ ├── styles.py
│ ├── relief.py
│ └── presets_loader.py
│
├── Fonts/
├── Logo/
└── output/
In the frontend configurator, selecting a new place from autocomplete updated the location label and coordinates, but the live Leaflet preview sometimes only zoomed out/in and did not reliably move to the new city center.
The map sync bridge created a feedback loop between:
- Programmatic map movement (
flyToBounds+ explicitsetZoom). - Continuous
move/zoomendcenter writes back into app state. - A new state-driven move command arriving while the previous animation was still in-flight.
This made center updates nondeterministic and occasionally kept the viewport near the old location.
File: configurator/frontend/src/components/preview/CityLiveMapPreview.tsx
- Switched controlled camera updates to explicit center+zoom operations:
- initial sync:
map.setView(center, zoom, { animate: false }) - subsequent sync:
map.flyTo(center, zoom, ...)
- initial sync:
- Removed high-frequency center propagation during animation:
- dropped
movethrottled emitter - dropped
zoomendemitter - kept only
moveendcenter propagation
- dropped
- Kept sync lock (
syncLockUntilRef) so programmatic camera changes are not immediately mirrored back into state.
- One source of truth for camera target (
center,zoom) avoids bounds/zoom drift combinations. - State is updated only after movement settles (
moveend), preventing mid-animation rewrites. - The two-way sync becomes deterministic instead of oscillating.
- Open the configurator and expand Helyszín.
- Select three far-apart cities in sequence (example: Budapest → London → New York).
- Confirm after each selection:
- location text updates,
- latitude/longitude fields update,
- the live map center visibly moves to the selected city (not only zoom changes).
- Prefer
setView/flyTofor controlled center transitions. - Avoid emitting state updates on every
moveevent for controlled maps. - Do not combine
flyToBoundswith a separatesetZoomin the same sync step. - Keep anti-feedback lock windows around programmatic camera transitions.
In map rendering with large extent (e.g., 2000m) near coastlines with harbors/archipelagos (e.g., Stockholm), island land masses were incorrectly classified as water, resulting in teal-colored (water) filling instead of parcel-colored (land) blocks.
Root Cause Chain:
- OSM water polygons (natural=water, water=*, bay, etc.) often cover large harbor areas as single polygons without explicit island holes.
- When water polygons are clipped to the render extent, islands may be fully contained inside the water geometry.
- Water-to-cell classification used a buffered overlap heuristic that would mark cells as water even when they had zero real overlap with unbuffered water (false positives).
- Explicit island OSM features (
place=island,place=islet,natural=island) existed but were not used to override water classification.
File: generator/engines/render_block.py
# ISLAND OVERRIDE
# Remove explicit island polygons from water surfaces so they are
# always rendered as land parcels.
try:
islands = ox.features_from_polygon(
clip_wgs,
tags={
"place": ["island", "islet"],
"natural": "island",
},
)
except Exception:
islands = None
if islands is not None and len(islands) > 0 and len(water_p) > 0:
islands = islands[islands.geometry.notnull()]
islands_p = islands.to_crs(edges_p.crs)
islands_p = islands_p[
islands_p.geom_type.isin(["Polygon", "MultiPolygon"])
]
if len(islands_p) > 0:
islands_p = gpd.clip(
islands_p,
gpd.GeoSeries([clip_rect], crs=edges_p.crs)
)
if len(islands_p) > 0:
island_union = unary_union(islands_p.geometry)
water_p = water_p.copy()
water_p["geometry"] = water_p.geometry.apply(
lambda geom: geom.difference(island_union)
)
water_p = water_p[
water_p.geometry.notnull() & (~water_p.geometry.is_empty)
]
water_p = water_p[
water_p.geom_type.isin(["Polygon", "MultiPolygon"])
]What it does:
- Queries OSM for explicit island/islet features within the render extent.
- Converts them to the same projection as the render bounds.
- Uses Shapely's
difference()operation to subtract island geometry from all water polygons. - Removes resulting empty/null geometries to keep water data clean.
if len(large_water) > 0:
water_union = unary_union(large_water.geometry)
# Small expansion helps fragmented shore segments, but only when
# there is already true (unbuffered) water overlap.
water_mask = water_union.buffer(5)
def is_water_cell(poly):
raw_inter = poly.intersection(water_union)
if raw_inter.is_empty:
# Never classify as water from buffered overlap only.
return False
poly_area = poly.area
if poly_area <= 0:
return False
raw_ratio = raw_inter.area / poly_area
if raw_ratio > 0.5:
return True
buffered_inter = poly.intersection(water_mask)
if buffered_inter.is_empty:
return False
buffered_ratio = buffered_inter.area / poly_area
return raw_ratio > 0.03 and buffered_ratio > 0.2
cells["is_water"] = cells.geometry.apply(is_water_cell)Classification Logic:
- Gate 1: Cell must have non-zero overlap with unbuffered water. If
raw_interis empty, return False (land). - Gate 2: If raw overlap > 50%, cell is definitely water.
- Gate 3: If raw overlap is low (0.03–0.5), allow buffered overlap only if both
raw_ratio > 0.03ANDbuffered_ratio > 0.2. - Result: Prevents false-positive water classification; islands with zero raw overlap stay land.
if island_union is not None:
def is_island_cell(poly):
inter = poly.intersection(island_union)
if inter.is_empty or poly.area <= 0:
return False
return (inter.area / poly.area) > 0.15
island_cells = cells.geometry.apply(is_island_cell)
cells.loc[island_cells, "is_water"] = FalseWhat it does:
- After all water classification, explicitly re-mark any cell with >15% island polygon overlap as land.
- Guarantees islands stay parcel-colored, even if coastal cells were previously marked water.
- Acts as a final deterministic override layer.
- Updated cache prefix from
block_v5_water→block_v6_waterto force fresh geometry rebuild. - Old cached (incorrect) geometries are not reused.
To avoid over-aggressive water detection:
Before:
tags={
"natural": ["water", "bay", "strait"],
"water": True,
"waterway": ["riverbank", "dock", "canal"],
"landuse": ["basin", "reservoir"],
"seamark:type": ["harbour", "anchorage"],
}After:
tags={
"natural": ["water", "bay", "strait"],
"water": True,
"waterway": ["riverbank", "canal"],
"landuse": ["basin", "reservoir"],
}Removed:
waterway=dock(can span islands)seamark:type=harbour/anchorage(harbor area polygons often cover islands)
Files: main.py, generator/core/render_dispatcher.py, generator/engines/render_block.py
Added explicit cache bypass:
python main.py \
--size-key 50x50 \
--extent-m 2000 \
--center-lat 59.3293 \
--center-lon 18.0686 \
--palette urban_modern \
--title "STOCKHOLM" \
--output-dir output \
--no-cache--no-cacheforces fresh OSM data fetch and geometry rebuild.- Useful for testing changes or avoiding stale cached data.
- Falls back to caching by default (no flag = use cache).
Test City: Stockholm (59.3293° N, 18.0686° E), 50×50 cm, urban_modern palette, 2000m extent.
Expected Behavior:
- ✅ Major islands (Södermalm, Kungsholmen, Djurgården, etc.) appear with parcel colors (orange, yellow, gray, black).
- ✅ Water (harbors, Mälaren lake, bays) remains teal/water color.
- ✅ No large land area is rendered as solid water.
- ✅ Road network visible on all islands.
How to Verify:
# With fresh geometry (no cache):
python main.py --size-key 50x50 --extent-m 2000 \
--center-lat 59.3293 --center-lon 18.0686 \
--palette urban_modern --title "STOCKHOLM" \
--output-dir output --no-cache
# Check output PDF:
# Islands should be parcel-colored (orange/yellow/gray/black blocks).
# Surrounding harbor should be teal.
# Visual comparison: [output/urban_modern_50x50_YYYY-MM-DD_HH-MM-SS.pdf]Numerical Verification (Python):
import pickle
from pathlib import Path
cache = Path("cache/block_v6_water_59.329300_18.068600_2000.pkl")
with open(cache, 'rb') as f:
data = pickle.load(f)
cells = data['cells']
print(f"Total cells: {len(cells)}")
print(f"Water cells: {int(cells['is_water'].sum())}")
print(f"Land cells: {int((~cells['is_water']).sum())}")
# For each named island, check water ratio:
# Södermalm should have water_ratio < 0.05 (>95% land)
# Kungsholmen should have water_ratio < 0.05
# etc.Cities to re-render:
- Amsterdam (52.3676° N, 4.9041° E) – No islands; water must stay correct.
- Bergen (60.3913° N, 5.3221° E) – Many small islands; all must be parcel-colored.
- Munich (default: 48.1365° N, 11.5768° E) – Inland; should be unchanged.
Acceptance Criteria:
- All three render without errors.
- Water/land color separation is visually correct.
- No regressions in road rendering or layout.
In coastal cities whose frame contains more than one landmass — a strait or river splitting the city (Istanbul / Bosphorus, New York / Hudson) or an archipelago (Helsinki, Stockholm) — large water areas were rendered as parcel-colored land blocks with roads drawn on top, instead of solid water.
Visual symptom: the opposite bank of a strait, or a big open bay/gulf, appears as one giant orange/yellow block instead of teal water.
The coastline step polygonizes the OSM natural=coastline lines together with
the frame boundary, producing several closed regions. The original logic kept
only the region containing the map center as land and flooded everything
else as sea:
if p.contains(center_p):
land_poly = p
sea_poly = clip_rect.difference(land_poly) # everything else = sea
breakThis is wrong in both directions when there are multiple landmasses:
- a genuine second landmass (Istanbul's Asian side) is not connected to the center, so it got flooded as sea — water with streets on top;
- conversely, when later tweaked, an entire open gulf could be kept as land.
File: generator/engines/render_block.py (COASTLINE block)
Each coastline-bounded region is now classified individually as land or sea by road-length density (metres of road per m² of region):
roads_union = unary_union(list(edges_p.geometry.values)) if len(edges_p) > 0 else None
sea_regions = []
for p in polys:
if p.contains(center_p):
continue # region with the map center is always land
density = 0.0
if roads_union is not None and p.area > 0:
road_inside = p.intersection(roads_union)
if not road_inside.is_empty:
density = road_inside.length / p.area
if density < 1e-2: # below threshold -> open water
sea_regions.append(p)
if sea_regions:
sea_poly = unary_union(sea_regions)Why it works (measured densities, 3000 m extent):
- Dense built-up land: ~3e-2 … 9e-2 m/m²
- Map-center mainland: ~6e-2 m/m²
- Open sea / gulf (only piers, breakwaters, shore footpaths): ~2.5e-3 m/m² or lower
The 1e-2 threshold sits in the wide gap between sea and land. Islands occupy
their own coastline regions (the sea face has a hole where each island sits),
so flagging a sea region never turns an island into water. The existing
island-override still runs afterwards as a final safety net.
- Do NOT revert to
sea_poly = clip_rect.difference(land_poly)— that is the original multi-landmass bug. - Do NOT buffer the whole road network (e.g.
roads_union.buffer(120)) and intersect it per region — it is far too slow and froze the render for >20 min. - Do NOT lower the threshold to
2e-3— Helsinki's open gulf measures ~2.46e-3 and leaks through as land. - Ferries / vessel routes are not a factor:
network_type="all"only pullshighway=*ways (0 ferry edges). Don't chase ferry routes.
Cache prefix bumped to block_v10_density. Older cached geometry
(block_v6_water, etc.) holds the old misclassification — re-render affected
cities with --no-cache, or clear cache/, or run the batch without
--skip-existing to rebuild.
# Strait-split city (Asian side must be land, Bosphorus + Marmara must be water):
python main.py --size-key 50x50 --extent-m 3000 \
--center-lat 41.0082 --center-lon 28.9784 \
--palette urban_modern --title "ISTANBUL" --output-dir output --no-cache
# Archipelago (gulf must be water, every island must stay parcel-colored):
python main.py --size-key 50x50 --extent-m 3000 \
--center-lat 60.1699 --center-lon 24.9384 \
--palette urban_modern --title "HELSINKI" --output-dir output --no-cacheIf a coastal city again shows water rendered as land blocks (or land rendered as water), paste this to the assistant:
In
generator/engines/render_block.py, the per-region land/sea classification in the COASTLINE block is misclassifying regions for<CITY>(lat<LAT>, lon<LON>, extent<EXTENT_m>). Water is showing as land blocks (or land as water). Add a temporary diagnostic that prints, for each polygonized coastline region, itsarea, road-lengthdensity(region ∩ roads_union.length / region.area), and whether it contains the center — then adjust the1e-2density threshold so the offending region lands on the correct side of the gap, without buffering the whole road network and without reverting toclip_rect.difference(land_poly). Bump theblock_v##_densitycache prefix and re-render with--no-cache.
This reproduces the exact debugging path used for the v10 fix.
- Laser cutting
- Engraving
- CNC workflow support
Frontend:
- Location selection
- Live preview
- Size and palette selection
Backend:
- Automated PDF generation
- Manufacturing file export
- Private production endpoint
- Snap-to-land logic
- Intelligent center correction
- Composition optimization
- Dynamic font scaling
- Small-format optimization
- Print visual balance refinement
- Stable render pipeline
- Working preview API
- Frontend integration completed
- Full separation of render and layout
- Deterministic output ensured
Norbert von Polyák
This project is not just a map renderer.
It is a deterministic, scalable, print-grade, webshop-integrated map production system designed with architectural clarity and real-world manufacturing in mind.