"""
GlyphX ChoroplethSeries -- geographic choropleth map.
Renders SVG path-based choropleth maps from GeoJSON data. No tile server,
no CDN dependency -- pure SVG paths projected from GeoJSON coordinates.
from glyphx import Figure
from glyphx.choropleth import ChoroplethSeries, load_world_geojson
import json
# Load GeoJSON (user-supplied)
geo = json.load(open("world.geojson"))
# Attach data: map feature property -> numeric value
data = {"USA": 63000, "GBR": 42000, "DEU": 51000, "FRA": 45000}
fig = Figure(width=900, height=500, auto_display=False)
fig.add(ChoroplethSeries(geo, data, key="iso_a3", cmap="viridis",
label="GDP per capita"))
fig.show()
"""
from __future__ import annotations
import math
from typing import Any
import numpy as np
from .colormaps import apply_colormap, colormap_colors
from .utils import svg_escape, _format_tick
# ---------------------------------------------------------------------------
# Mercator projection
# ---------------------------------------------------------------------------
def _mercator_xy(lon: float, lat: float) -> tuple[float, float]:
"""Convert (lon, lat) degrees to Mercator (x, y) in [-π, π]."""
x = math.radians(lon)
lat_r = math.radians(max(-85.05, min(85.05, lat)))
y = math.log(math.tan(math.pi / 4 + lat_r / 2))
return x, y
def _project_coords(
coords: list,
lon_min: float, lon_max: float,
y_min: float, y_max: float,
width: float, height: float,
pad: float = 10,
) -> list[tuple[float, float]]:
"""Project GeoJSON coordinate pairs to pixel (x, y)."""
result = []
plot_w = width - 2 * pad
plot_h = height - 2 * pad
for pair in coords:
try:
lon, lat = float(pair[0]), float(pair[1])
except (TypeError, IndexError):
continue
mx, my = _mercator_xy(lon, lat)
px = pad + (mx - lon_min) / max(lon_max - lon_min, 1e-9) * plot_w
py = pad + (y_max - my) / max(y_max - y_min, 1e-9) * plot_h
result.append((px, py))
return result
def _coord_bounds(features: list) -> tuple[float, float, float, float]:
"""Find mercator (x_min, x_max, y_min, y_max) across all features."""
all_lons: list[float] = []
all_ys: list[float] = []
def _walk(coords, depth=0):
if not coords:
return
if isinstance(coords[0], (int, float)):
try:
mx, my = _mercator_xy(float(coords[0]), float(coords[1]))
all_lons.append(mx); all_ys.append(my)
except Exception:
pass
else:
for sub in coords:
_walk(sub, depth + 1)
for feat in features:
geo = feat.get("geometry") or {}
_walk(geo.get("coordinates", []))
if not all_lons:
return -math.pi, math.pi, -2.0, 2.0
return min(all_lons), max(all_lons), min(all_ys), max(all_ys)
def _coords_to_path(ring: list[tuple[float, float]]) -> str:
"""Convert a list of pixel (x,y) points to an SVG path 'd' string."""
if not ring:
return ""
parts = [f"M {ring[0][0]:.2f},{ring[0][1]:.2f}"]
for x, y in ring[1:]:
parts.append(f"L {x:.2f},{y:.2f}")
parts.append("Z")
return " ".join(parts)
# ---------------------------------------------------------------------------
# ChoroplethSeries
# ---------------------------------------------------------------------------
[docs]
class ChoroplethSeries:
"""
SVG-path choropleth map from GeoJSON.
No tiles, no CDN -- renders as pure SVG paths projected via Mercator.
Args:
geojson: GeoJSON FeatureCollection dict (``{"type":"FeatureCollection",
"features":[...]}``) or a list of feature dicts.
data: ``{feature_key: numeric_value}`` mapping.
key: GeoJSON feature property name that matches ``data`` keys.
cmap: Colormap name.
missing_color: Fill color for features with no matching data entry.
stroke: Border stroke color.
stroke_width: Border stroke width.
alpha: Fill opacity.
label: Legend / tooltip label.
title: Chart title (forwarded to Figure).
"""
def __init__(
self,
geojson: dict | list,
data: dict[str, float],
key: str = "name",
cmap: str = "viridis",
missing_color: str = "#e0e0e0",
stroke: str = "#ffffff",
stroke_width: float = 0.4,
alpha: float = 0.90,
label: str | None = None,
title: str | None = None,
) -> None:
self.cmap = cmap
self.data = data
self.key = key
self.missing_color = missing_color
self.stroke = stroke
self.stroke_width = float(stroke_width)
self.alpha = float(alpha)
self.label = label
self.title = title
self.css_class = f"series-{id(self) % 100000}"
# Extract feature list
if isinstance(geojson, dict):
self._features = geojson.get("features", [])
else:
self._features = list(geojson)
# Value range
vals = [v for v in data.values() if v is not None]
self._vmin = min(vals) if vals else 0
self._vmax = max(vals) if vals else 1
self._vspan = (self._vmax - self._vmin) or 1
# Axis stubs (axis-free series)
self.x = None
self.y = None
def _feature_color(self, feature: dict) -> str:
props = feature.get("properties") or {}
k = props.get(self.key)
val = self.data.get(k) if k is not None else None
if val is None:
return self.missing_color
norm = (float(val) - self._vmin) / self._vspan
return apply_colormap(norm, self.cmap)
[docs]
def to_svg(self, ax: object = None) -> str: # type: ignore
W = getattr(ax, "width", 800) if ax else 800
H = getattr(ax, "height", 500) if ax else 500
font = ax.theme.get("font", "sans-serif") if ax else "sans-serif" # type: ignore
tc = ax.theme.get("text_color", "#000") if ax else "#000" # type: ignore
lon_min, lon_max, y_min, y_max = _coord_bounds(self._features)
elements: list[str] = []
def _render_ring(ring_coords, color):
pts = _project_coords(ring_coords, lon_min, lon_max,
y_min, y_max, W, H)
return _coords_to_path(pts)
for feat in self._features:
geo = feat.get("geometry") or {}
color = self._feature_color(feat)
props = feat.get("properties") or {}
name = props.get(self.key, "")
val = self.data.get(name)
tip = f'{svg_escape(str(name))}: {_format_tick(val)}' if val is not None else svg_escape(str(name))
gtype = geo.get("type", "")
coords = geo.get("coordinates", [])
paths: list[str] = []
if gtype == "Polygon":
for ring in coords:
d = _render_ring(ring, color)
if d:
paths.append(d)
elif gtype == "MultiPolygon":
for poly in coords:
for ring in poly:
d = _render_ring(ring, color)
if d:
paths.append(d)
if paths:
combined = " ".join(paths)
elements.append(
f'<path class="glyphx-point {self.css_class}" '
f'd="{combined}" '
f'fill="{color}" fill-opacity="{self.alpha}" '
f'stroke="{self.stroke}" stroke-width="{self.stroke_width}" '
f'data-label="{tip}"/>'
)
# Colorbar
if self.data:
cb_x = W - 28
cb_y = H // 4
cb_h = H // 2
steps = 40
sh = cb_h / steps
for k in range(steps):
norm = 1 - k / steps
col = apply_colormap(norm, self.cmap)
elements.append(
f'<rect x="{cb_x}" y="{cb_y + k * sh:.1f}" '
f'width="12" height="{sh + 0.5:.1f}" fill="{col}"/>'
)
elements.append(
f'<text x="{cb_x + 14}" y="{cb_y + 8}" '
f'font-size="9" font-family="{font}" fill="{tc}">'
f'{_format_tick(self._vmax)}</text>'
)
elements.append(
f'<text x="{cb_x + 14}" y="{cb_y + cb_h}" '
f'font-size="9" font-family="{font}" fill="{tc}">'
f'{_format_tick(self._vmin)}</text>'
)
return "\n".join(elements)