erfamap/generate_map.py
s3lph 6159c7c984
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(sparql): consider P706 "located in" in addition to P361 "part of"
2023-06-01 04:23:45 +02:00

935 lines
37 KiB
Python
Executable file

#!/usr/bin/env python3
#
# https://git.kabelsalat.ch/s3lph/erfamap
import abc
import os
import urllib.request
import urllib.parse
import json
import base64
import sys
import argparse
import shutil
import random
import math
from lxml import etree
import pyproj
from geopy import Nominatim
from geopy.extra.rate_limiter import RateLimiter
import tqdm
import cairosvg
from PIL import Image, ImageFont
from PIL.ImageDraw import ImageDraw
USER_AGENT = 'Erfamap/v0.1 (https://git.kabelsalat.ch/s3lph/erfamap)'
class CachePaths:
def __init__(self, path: str):
if os.path.exists(path) and not os.path.isdir(path):
raise AttributeError(f'Path exists but is not a directory: {path}')
os.makedirs(path, exist_ok=True)
self.shapes_states = os.path.join(path, 'shapes_states')
self.shapes_countries = os.path.join(path, 'shapes_countries')
self.erfa_info = os.path.join(path, 'erfa-info.json')
self.chaostreff_info = os.path.join(path, 'chaostreff-info.json')
class OutputPaths:
def __init__(self, path: str):
if os.path.exists(path) and not os.path.isdir(path):
raise AttributeError(f'Path exists but is not a directory: {path}')
os.makedirs(path, exist_ok=True)
self.path = path
self.svg_path = os.path.join(path, 'map.svg')
self.font_path = os.path.join(path, 'style/font.ttf')
self.css_path = os.path.join(path, 'style/erfamap.css')
self.png_path = os.path.join(path, 'map.png')
self.html_path = os.path.join(path, 'erfamap.html')
self.imagemap_path = os.path.join(path, 'imagemap.html')
def rel(self, path: str):
return os.path.relpath(path, start=self.path)
class CoordinateTransform:
def __init__(self, projection: str):
self._proj = pyproj.Transformer.from_crs('epsg:4326', projection)
self.setup()
def setup(self, scalex=1.0, scaley=1.0, bbox=[(-180, -90), (180, 90)]):
self._scalex = scalex
self._scaley = scaley
west = min(bbox[0][0], bbox[1][0])
north = max(bbox[0][1], bbox[1][1])
east = max(bbox[0][0], bbox[1][0])
south = min(bbox[0][1], bbox[1][1])
self._ox, self._oy = self._proj.transform(west, north)
return self(east, south)
def __call__(self, lon, lat):
xt, yt = self._proj.transform(lon, lat)
return (
(xt - self._ox) * self._scalex,
(self._oy - yt) * self._scaley
)
class Drawable(abc.ABC):
def render(self, svg, texts):
pass
class Country(Drawable):
SVG_CLASS = 'country'
SPARQL_QUERY = '''
PREFIX wd: <http://www.wikidata.org/entity/>
PREFIX wdt: <http://www.wikidata.org/prop/direct/>
PREFIX p: <http://www.wikidata.org/prop/>
PREFIX ps: <http://www.wikidata.org/prop/statement/>
PREFIX wikibase: <http://wikiba.se/ontology#>
SELECT DISTINCT ?item ?map WHERE {
# ?item is instance of sovereign state, transitively part of europe and has geoshape ?map
# ?item is instance of country or sovereign state
?item wdt:P31 ?stateclass.
# ?item is transitively part of Europe (Contintent) or EEA
?item wdt:P361+ | (wdt:P706 / wdt:P361) ?euroclass.
# ?item has geoshape ?map (including all non-deprecated results)
?item p:P3896 ?st.
?st ps:P3896 ?map;
MINUS { ?st wikibase:rank wikibase:DeprecatedRank }
FILTER (?stateclass = wd:Q6256 || ?stateclass = wd:Q3624078).
FILTER (?euroclass = wd:Q46 || ?euroclass = wd:Q8932).
}
'''
def __init__(self, ns, name, polygon):
self.name = name
self.ns = ns
self.orig_polygon = polygon[0]
self._proj_polygon = None
@property
def polygon(self):
if self._proj_polygon is None:
self._proj_polygon = [self.ns.projection(lon, lat) for lon, lat in self.orig_polygon]
return self._proj_polygon
def __len__(self):
return len(self.polygon)
def filter_boundingbox(self, bbox):
for point in self.orig_polygon:
if point[0] >= bbox[0][0] and point[1] >= bbox[0][1] \
and point[0] <= bbox[1][0] and point[1] <= bbox[1][1]:
return True
return False
def render(self, svg, texts):
points = ' '.join([f'{x},{y}' for x, y in self.polygon])
poly = etree.Element('polygon', points=points)
poly.set('class', self.__class__.SVG_CLASS)
poly.set('data-country', self.name)
svg.append(poly)
@classmethod
def sparql_query(cls):
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT,
'Accept': 'application/sparql-results+json',
}
body = urllib.parse.urlencode({'query': cls.SPARQL_QUERY}).encode()
req = urllib.request.Request('https://query.wikidata.org/sparql', headers=headers, data=body)
with urllib.request.urlopen(req) as resp:
resultset = json.load(resp)
results = {}
for r in resultset.get('results', {}).get('bindings'):
basename = None
url = None
for k, v in r.items():
if k == 'item':
basename = os.path.basename(v['value'])
elif k == 'map':
url = v['value']
results[basename] = url
return results
@classmethod
def fetch(cls, target):
os.makedirs(target, exist_ok=True)
shape_urls = cls.sparql_query()
candidates = {}
keep = {}
for item, url in tqdm.tqdm(shape_urls.items()):
try:
with urllib.request.urlopen(url) as resp:
shape = json.load(resp)
if not shape.get('license', 'proprietary').startswith('CC0-'):
# Only include public domain data
continue
candidates.setdefault(item, []).append(shape)
except urllib.error.HTTPError as e:
print(e)
for item, ican in candidates.items():
# Prefer zoom level 4
keep[item] = min(ican, key=lambda x: abs(4-x.get('zoom', 1000)))
for item, shape in keep.items():
name = shape['description']['en']
geo = shape['data']['features'][0]['geometry']
if geo['type'] == 'Polygon':
polygons = [geo['coordinates']]
else:
polygons = geo['coordinates']
for i, polygon in enumerate(polygons):
with open(os.path.join(target, f'{item}.{i}.json'), 'w') as f:
json.dump({'name': name, 'i': i, 'polygon': polygon}, f)
@classmethod
def from_cache(cls, ns, source):
countries = []
files = os.listdir(source)
for f in files:
# Exclude Germany
if not f.endswith('.json') or f.startswith('Q183.'):
continue
path = os.path.join(source, f)
with open(path, 'r') as sf:
shape = json.load(sf)
name = shape['name']
polygon = shape['polygon']
countries.append(cls(ns, name, polygon))
return countries
class FederalState(Country):
SVG_CLASS = 'state'
SPARQL_QUERY = '''
PREFIX wd: <http://www.wikidata.org/entity/>
PREFIX wdt: <http://www.wikidata.org/prop/direct/>
SELECT DISTINCT ?item ?map WHERE {
# ?item is instance of federal state of germany and has geoshape ?map
?item wdt:P31 wd:Q1221156;
wdt:P3896 ?map
}
'''
class Erfa(Drawable):
SMW_URL = 'https://doku.ccc.de/index.php?title=Spezial:Semantische_Suche&x=-5B-5BKategorie%3A{category}-5D-5D-20-5B-5BChaostreff-2DActive%3A%3Awahr-5D-5D%2F-3FChaostreff-2DCity%2F-3FChaostreff-2DPhysical-2DAddress%2F-3FChaostreff-2DPhysical-2DHousenumber%2F-3FChaostreff-2DPhysical-2DPostcode%2F-3FChaostreff-2DPhysical-2DCity%2F-3FChaostreff-2DCountry%2F-3FPublic-2DWeb%2F-3FChaostreff-2DLongname%2F-3FChaostreff-2DNickname%2F-3FChaostreff-2DRealname&format=json&limit={limit}&link=all&headers=show&searchlabel=JSON&class=sortable+wikitable+smwtable&sort=&order=asc&offset={offset}&mainlabel=&prettyprint=true&unescape=true'
SMW_REQUEST_LIMIT = 50
SMW_CATEGORY = 'Erfa-2DKreise'
SVG_CLASS = 'erfa'
SVG_DATA = 'data-erfa'
SVG_LABEL = True
SVG_LABELCLASS = 'erfalabel'
def __init__(self, ns, name, city, lon, lat, display_name=None, web=None, radius=15):
self.name = name
self.city = city
self.lon = lon
self.lat = lat
self.x, self.y = ns.projection(lon, lat)
self.display_name = display_name if display_name is not None else name
if city.lower() not in self.display_name.lower():
self.display_name += f' ({city})'
self.web = web
self.radius = radius
def __eq__(self, o):
if not isinstance(o, Erfa):
return False
return self.name == o.name
def render(self, svg, texts):
cls = self.__class__
if self.web is not None:
group = etree.Element('a', href=self.web, target='_blank')
else:
group = etree.Element('g')
group.set(cls.SVG_DATA, self.city)
circle = etree.Element('circle', cx=str(self.x), cy=str(self.y), r=str(self.radius))
circle.set('class', cls.SVG_CLASS)
circle.set(cls.SVG_DATA, self.city)
title = etree.Element('title')
title.text = self.display_name
circle.append(title)
group.append(circle)
if self.city in texts:
box = texts[self.city]
text = etree.Element('text', x=str(box.left), y=str(box.top + box.meta['baseline']))
text.set('class', cls.SVG_LABELCLASS)
text.set(cls.SVG_DATA, self.city)
text.text = box.meta['text']
group.append(text)
svg.append(group)
@classmethod
def address_lookup(cls, attr):
# Nominatim's Usage Policy requires rate limiting to 1 request per seconds
nominatim = Nominatim(user_agent=USER_AGENT)
geocode = RateLimiter(nominatim.geocode, min_delay_seconds=1)
number = attr['Chaostreff-Physical-Housenumber']
street = attr['Chaostreff-Physical-Address']
zipcode = attr['Chaostreff-Physical-Postcode']
acity = attr['Chaostreff-Physical-City']
city = attr['Chaostreff-City'][0]
country = attr['Chaostreff-Country'][0]
# Try the most accurate address first, try increasingly inaccurate addresses on failure.
formats = [
# Muttenz, Schweiz
f'{city}, {country}'
]
if zipcode and acity:
# 4132 Muttenz, Schweiz
formats.insert(0, f'{zipcode[0]} {acity[0]}, {country}')
if zipcode and acity and number and street:
# Birsfelderstrasse 6, 4132 Muttenz, Schweiz
formats.insert(0, f'{street[0]} {number[0]}, {zipcode[0]} {acity[0]}, {country}')
for fmt in formats:
response = geocode(fmt)
if response is not None:
return response.longitude, response.latitude
print(f'No location found for {name}, tried the following address formats:')
for fmt in formats:
print(f' {fmt}')
return None
@classmethod
def from_api(cls, ns, name, attr, radius):
city = attr['Chaostreff-City'][0]
location = cls.address_lookup(attr)
if location is None:
raise ValueError(f'No location for {name}')
lon, lat = location
# There are up to 4 different names: 3 SMW attrs and the page name
if len(attr['Chaostreff-Longname']) > 0:
display_name = attr['Chaostreff-Longname'][0]
elif len(attr['Chaostreff-Nickname']) > 0:
display_name = attr['Chaostreff-Nickname'][0]
elif len(attr['Chaostreff-Realname']) > 0:
display_name = attr['Chaostreff-Realname'][0]
else:
display_name = name
if len(attr['Public-Web']) > 0:
web = attr['Public-Web'][0]
else:
web = None
return cls(ns, name, city, lon, lat, display_name, web, radius)
def to_dict(self):
return {
'name': self.name,
'city': self.city,
'web': self.web,
'display_name': self.display_name,
'location': [self.lon, self.lat]
}
@classmethod
def from_dict(cls, ns, dct, radius):
name = dct['name']
city = dct['city']
lon, lat = dct['location']
display_name = dct.get('display_name', name)
web = dct.get('web')
radius = radius
return Erfa(ns, name, city, lon, lat, display_name, web, radius)
@classmethod
def fetch(cls, ns, target, radius):
userpw = os.getenv('DOKU_CCC_DE_BASICAUTH')
if userpw is None:
print('Please set environment variable DOKU_CCC_DE_BASICAUTH=username:password')
exit(1)
auth = base64.b64encode(userpw.encode()).decode()
erfas = []
offset = 0
while True:
url = cls.SMW_URL.format(
category=cls.SMW_CATEGORY,
limit=cls.SMW_REQUEST_LIMIT,
offset=offset
)
req = urllib.request.Request(url, headers={'Authorization': f'Basic {auth}'})
with urllib.request.urlopen(req) as resp:
body = resp.read().decode()
if len(body) == 0:
# All pages read, new response is empty
break
erfadata = json.loads(body)
if erfadata['rows'] == 0:
break
offset += erfadata['rows']
for name, attr in erfadata['results'].items():
try:
erfa = cls.from_api(ns, name, attr['printouts'], radius)
erfas.append(erfa)
except BaseException as e:
print(e)
continue
# Save to cache
with open(target, 'w') as f:
json.dump([e.to_dict() for e in erfas], f)
return erfas
@classmethod
def from_cache(cls, ns, source, radius):
with open(source, 'r') as f:
data = json.load(f)
return [Erfa.from_dict(ns, d, radius) for d in data]
class Chaostreff(Erfa):
SMW_CATEGORY = 'Chaostreffs'
SVG_CLASS = 'chaostreff'
SVG_DATA = 'data-chaostreff'
SVG_DOTSIZE_ATTR = 'dotsize_treff'
SVG_LABEL = False
class BoundingBox:
def __init__(self, left, top, right=None, bottom=None, *, width=None, height=None, meta=None, base_weight=0):
self.left = left
self.top = top
self.right = right if width is None else left + width
self.bottom = bottom if height is None else top + height
self.meta = meta
self.base_weight = base_weight
self.finished = False
self._weight = 0
self._optimal = True
def __contains__(self, other):
if isinstance(other, BoundingBox):
if other.right < self.left or other.left > self.right:
return False
if other.bottom < self.top or other.top > self.bottom:
return False
return True
elif isinstance(other, tuple) or isinstance(other, list):
if len(other) != 2:
raise TypeError()
if self.left > other[0] or self.right < other[0]:
return False
if self.top > other[1] or self.bottom < other[1]:
return False
return True
raise TypeError()
def __add__(self, other):
if not isinstance(other, tuple) and not isinstance(other, list):
raise TypeError()
return BoundingBox(self.left + other[0], self.top + other[1],
self.right + other[0], self.bottom + other[1],
width=None, height=None, meta=self.meta, base_weight=self.base_weight)
def __iadd__(self, other):
if not isinstance(other, tuple) and not isinstance(other, list):
raise TypeError()
self.left += other[0]
self.top += other[1]
self.right += other[0]
self.bottom += other[1]
return self
def __sub__(self, other):
if not isinstance(other, tuple) and not isinstance(other, list):
raise TypeError()
return BoundingBox(self.left - other[0], self.top - other[1],
self.right - other[0], self.bottom - other[1],
width=None, height=None, meta=self.meta, base_weight=self.base_weight)
def __isub__(self, other):
if not isinstance(other, tuple) and not isinstance(other, list):
raise TypeError()
self.left -= other[0]
self.top -= other[1]
self.right -= other[0]
self.bottom -= other[1]
self._weight = None
return self
@property
def width(self):
return self.right - self.left
@property
def height(self):
return self.bottom - self.top
@property
def center(self):
return (self.left + self.width/2, self.top + self.height/2)
def distance2(self, other):
c1 = self.center
c2 = other.center
return math.sqrt((c1[0] - c2[0])**2 + (c1[1] - c2[1])**2)
def with_margin(self, margin):
return BoundingBox(self.left - margin, self.top - margin,
self.right + margin, self.bottom + margin,
width=None, height=None, meta=self.meta, base_weight=self.base_weight)
def chebyshev_distance(self, other):
# https://en.wikipedia.org/wiki/Chebyshev_distance
if other in self:
return 0
if isinstance(other, BoundingBox):
if other.left < self.left < other.right or other.left < self.right < other.right:
dh = 0
else:
dh = min(abs(self.left - other.right), abs(self.right - other.left))
if other.top < self.top < other.bottom or other.top < self.bottom < other.bottom:
dv = 0
else:
dv = min(abs(self.top - other.bottom), abs(self.bottom - other.top))
return max(dh, dv)
elif isinstance(other, tuple) or isinstance(other, list):
if self.left < other[0] < self.right:
dh = 0
else:
dh = min(abs(self.left - other[0]), abs(self.right - other[0]))
if self.top < other[1] < self.bottom:
dv = 0
else:
dv = min(abs(self.top - other[1]), abs(self.bottom - other[1]))
return max(dh, dv)
else:
raise TypeError()
def compute_weight(self, other, erfas, chaostreffs, pdist, ns):
w = 0
self._optimal = True
swm = self.with_margin(pdist)
swe = self.with_margin(ns.dotsize_erfa)
swc = self.with_margin(ns.dotsize_treff)
maxs = []
# I hope these weights are somewhat reasonably balanced...
# Basically the weights correspond to geometrical distances,
# except for an actual collision, which gets a huge extra weight.
for o in other:
if o.meta['erfa'] == self.meta['erfa']:
continue
if o in self:
if o.finished:
w += 1000
self._optimal = False
else:
w += 50
self._optimal = False
else:
maxs.append(max(pdist*2 - swm.chebyshev_distance(o), 0))
for erfa in erfas:
if erfa == self.meta['erfa']:
continue
location = (erfa.x, erfa.y)
if location in swe:
w += 1000
self._optimal = False
else:
maxs.append(max(pdist*2 - swe.chebyshev_distance(location), 0))
for treff in chaostreffs:
if treff == self.meta['erfa']:
continue
location = (treff.x, treff.y)
if location in swc:
w += 1000
self._optimal = False
else:
maxs.append(max(pdist*2 - swc.chebyshev_distance(location), 0))
if len(maxs) > 0:
w += max(maxs)
self._weight = w
@property
def weight(self):
return self._weight + self.base_weight
@property
def is_optimal(self):
# Candidate is considered optimal if it doesn't collide with anything else
return self._optimal
def __str__(self):
return f'(({int(self.left)}, {int(self.top)}, {int(self.right)}, {int(self.bottom)}), weight={self.weight})'
def compute_bbox(ns):
if ns.bbox is not None:
return [
(min(ns.bbox[0], ns.bbox[2]), min(ns.bbox[1], ns.bbox[3])),
(max(ns.bbox[0], ns.bbox[2]), max(ns.bbox[1], ns.bbox[3]))
]
print('Computing map bounding box')
bounds = []
for path in tqdm.tqdm([ns.cache_directory.erfa_info, ns.cache_directory.chaostreff_info]):
erfas = Erfa.from_cache(ns, path, radius=0)
for e in erfas:
if len(bounds) == 0:
bounds.append(e.lon)
bounds.append(e.lat)
bounds.append(e.lon)
bounds.append(e.lat)
else:
bounds[0] = min(bounds[0], e.lon)
bounds[1] = min(bounds[1], e.lat)
bounds[2] = max(bounds[2], e.lon)
bounds[3] = max(bounds[3], e.lat)
return [
(bounds[0] - ns.bbox_margin, bounds[1] - ns.bbox_margin),
(bounds[2] + ns.bbox_margin, bounds[3] + ns.bbox_margin)
]
def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg):
# Measure the various different font heights
pil = ImageDraw(Image.new('P', (1, 1)))
xheight = -pil.textbbox((0,0), 'x', font=font, anchor='ls')[1]
capheight = -pil.textbbox((0,0), 'A', font=font, anchor='ls')[1]
voffset = -capheight / 2
# Generate a discrete set of text placement candidates around each erfa dot
candidates = {}
for erfa in erfas:
city = erfa.city
text = erfa.city
for rfrom, to in ns.rename:
if rfrom == city:
text = to
break
meta = {'erfa': erfa, 'text': text, 'baseline': capheight}
textbox = pil.textbbox((0, 0), text, font=font, anchor='ls') # left, baseline at 0,0
mw, mh = textbox[2] - textbox[0], textbox[3] - textbox[1]
candidates[city] = []
# Iterate over the dot-to-text distance range in discrete steps
it = max(0, ns.font_step_distance)
for j in range(it+1):
dist = ns.font_min_distance + (ns.font_max_distance - ns.font_min_distance) * j / it
bw = dist
# Generate 5 candidates each left and right of the dot, with varying vertical offset
for i in range(-1, 2):
if i == 0:
bw -= 0.003
bwl, bwr = bw, bw
if erfa.x > 0.8 * width:
bwr = bw + 0.001
else:
bwl = bw + 0.001
candidates[city].append(BoundingBox(erfa.x - dist - mw, erfa.y + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwl))
candidates[city].append(BoundingBox(erfa.x + dist, erfa.y + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwr))
# Generate 3 candidates each above and beneath the dot, aligned left, centered and right
candidates[city].extend([
BoundingBox(erfa.x - mw/2, erfa.y - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.001),
BoundingBox(erfa.x - mw/2, erfa.y + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.001),
BoundingBox(erfa.x - ns.dotsize_erfa, erfa.y - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002),
BoundingBox(erfa.x - ns.dotsize_erfa, erfa.y + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003),
BoundingBox(erfa.x + ns.dotsize_erfa - mw, erfa.y - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002),
BoundingBox(erfa.x + ns.dotsize_erfa - mw, erfa.y + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003),
])
# If debugging is enabled, render one rectangle around each label's bounding box, and one rectangle around each label's median box
if ns.debug:
for c in candidates[city]:
di = etree.Element('rect', x=str(c.left), y=str(c.top + capheight - xheight), width=str(c.width), height=str(xheight))
di.set('class', 'debugright')
dr = etree.Element('rect', x=str(c.left), y=str(c.top), width=str(c.width), height=str(c.height))
dr.set('class', 'debugleft')
svg.append(di)
svg.append(dr)
unfinished = {e.city for e in erfas}
finished = {}
# Greedily choose a candidate for each label
with tqdm.tqdm(total=len(erfas)) as progress:
while len(unfinished) > 0:
progress.update(len(erfas) - len(unfinished))
all_boxes = set(finished.values())
unfinished_boxes = set()
for city in unfinished:
all_boxes.update(candidates[city])
unfinished_boxes.update(candidates[city])
for box in all_boxes:
box.compute_weight(all_boxes, erfas, chaostreffs, pdist=max(ns.font_min_distance, xheight), ns=ns)
# Get candidate with the least number of optimal solutions > 0
optcity = min(unfinished, key=lambda city: len([1 for box in candidates[city] if box.is_optimal]) or float('inf'))
optboxes = [box for box in candidates[optcity] if box.is_optimal]
if len(optboxes) > 0:
finished[optcity] = min(optboxes, key=lambda box: box.weight)
finished[optcity].finished = True
unfinished.discard(optcity)
continue
# If no candidate with at least one optimal solution is left, go by global minimum
minbox = min(unfinished_boxes, key=lambda box: box.weight)
mincity = minbox.meta['erfa'].city
finished[mincity] = minbox
minbox.finished = True
unfinished.discard(mincity)
# Iteratively improve the layout
for i in tqdm.tqdm(range(len(finished))):
changed = False
order = list(finished.keys())
random.shuffle(order)
for city in order:
all_boxes = set(finished.values())
for box in candidates[city]:
box.compute_weight(all_boxes, erfas, chaostreffs, pdist=max(ns.font_min_distance, xheight), ns=ns)
minbox = min(candidates[city], key=lambda box: box.weight)
if minbox is not finished[city]:
changed = True
finished[city] = minbox
minbox.finished = True
if not changed:
# Nothing changed in this iteration - no need to retry
break
return finished
def create_imagemap(ns, size, parent, erfas, chaostreffs, texts):
s = ns.png_scale
img = etree.Element('img',
src=ns.output_directory.rel(ns.output_directory.png_path),
usemap='#erfamap',
width=str(size[0]*s), height=str(size[1]*s))
imgmap = etree.Element('map', name='erfamap')
for erfa in erfas:
if erfa.web is None:
continue
area = etree.Element('area',
shape='circle',
coords=f'{erfa.x*s},{erfa.y*s},{erfa.radius*s}',
href=erfa.web,
title=erfa.display_name)
imgmap.append(area)
if erfa.city in texts:
box = texts[erfa.city]
area2 = etree.Element('area',
shape='rect',
coords=f'{box.left*s},{box.top*s},{box.right*s},{box.bottom*s}',
href=erfa.web,
title=erfa.display_name)
imgmap.append(area2)
parent.append(img)
parent.append(imgmap)
def create_svg(ns):
print('Creating SVG image')
# Load the font used for the labels
font = ImageFont.truetype(ns.font, ns.font_size)
# Convert from WGS84 lon, lat to chosen projection
bbox = compute_bbox(ns)
svg_box = ns.projection.setup(ns.scale_x, ns.scale_y, bbox=bbox)
rectbox = [0, 0, svg_box[0], svg_box[1]]
# Load everything from cached JSON files
countries = Country.from_cache(ns, ns.cache_directory.shapes_countries)
countries = [c for c in countries if c.filter_boundingbox(bbox)]
states = FederalState.from_cache(ns, ns.cache_directory.shapes_states)
erfas = Erfa.from_cache(ns, ns.cache_directory.erfa_info, ns.dotsize_erfa)
chaostreffs = Chaostreff.from_cache(ns, ns.cache_directory.chaostreff_info, ns.dotsize_treff)
# There is an edge case where when a space changed states between Erfa and Chaostreff, the
# Semantic MediaWiki engine returns this space as both an Erfa and a Chaostreff, resulting
# in glitches in the rendering. As a workaround, here we simply assume that it's an Erfa.
chaostreffs = [c for c in chaostreffs if c not in erfas]
for c in states + countries:
for x, y in c.polygon:
rectbox[0] = min(x, rectbox[0])
rectbox[1] = min(y, rectbox[1])
rectbox[2] = max(x, rectbox[2])
rectbox[3] = max(y, rectbox[3])
print('Copying stylesheet and font')
os.makedirs(os.path.dirname(ns.output_directory.font_path), exist_ok=True)
shutil.copyfile(ns.font, ns.output_directory.font_path)
os.makedirs(os.path.dirname(ns.output_directory.css_path), exist_ok=True)
fontrelpath = os.path.relpath(ns.output_directory.font_path,
start=os.path.dirname(ns.output_directory.css_path))
with open(ns.stylesheet, 'r') as src:
with open(ns.output_directory.css_path, 'w') as dst:
dst.write(f'''
@font-face {{
font-family: "{font.font.family}";
src: url("{fontrelpath}");
}}
''')
dst.write(src.read())
svg = etree.Element('svg',
xmlns='http://www.w3.org/2000/svg',
viewBox=f'0 0 {svg_box[0]} {svg_box[1]}',
width=str(svg_box[0]), height=str(svg_box[1]))
style = etree.Element('style', type='text/css')
with open(ns.stylesheet, 'r') as css:
style.text = css.read()
svg.append(style)
bg = etree.Element('rect',
id='background',
x=str(rectbox[0]),
y=str(rectbox[1]),
width=str(rectbox[3]-rectbox[1]),
height=str(rectbox[2]-rectbox[0]))
bg.set('class', 'background')
svg.append(bg)
# This can take some time, especially if lots of candidates are generated
print('Layouting labels')
texts = optimize_text_layout(ns, font, erfas, chaostreffs, width=svg_box[0], svg=svg)
# Render shortest shapes last s.t. Berlin, Hamburg and Bremen are rendered on top of their surrounding states
states = sorted(states, key=lambda x: -len(x))
# Render country and state borders
for c in countries + states:
c.render(svg, texts)
# Render Erfa dots and their labels
for erfa in erfas + chaostreffs:
erfa.render(svg, texts)
# Generate SVG, PNG and HTML output files
print('Writing SVG')
with open(ns.output_directory.svg_path, 'wb') as mapfile:
root = etree.ElementTree(svg)
root.write(mapfile)
print('Writing PNG')
cairosvg.svg2png(url=ns.output_directory.svg_path, write_to=ns.output_directory.png_path,
scale=ns.png_scale)
print('Writing HTML SVG page')
html = etree.Element('html')
head = etree.Element('head')
link = etree.Element('link', rel='stylesheet', href=ns.output_directory.rel(ns.output_directory.css_path))
head.append(link)
html.append(head)
body = etree.Element('body')
obj = etree.Element('object',
data=ns.output_directory.rel(ns.output_directory.svg_path),
width=str(svg_box[0]), height=str(svg_box[1]))
create_imagemap(ns, svg_box, obj, erfas, chaostreffs, texts)
body.append(obj)
html.append(body)
with open(ns.output_directory.html_path, 'wb') as f:
f.write(b'<!DOCTYLE html>\n')
etree.ElementTree(html).write(f)
print('Writing HTML Image Map')
html = etree.Element('html')
head = etree.Element('head')
link = etree.Element('link', rel='stylesheet', href=ns.output_directory.rel(ns.output_directory.css_path))
head.append(link)
html.append(head)
body = etree.Element('body')
html.append(body)
create_imagemap(ns, svg_box, body, erfas, chaostreffs, texts)
with open(ns.output_directory.imagemap_path, 'wb') as f:
f.write(b'<!DOCTYLE html>\n')
etree.ElementTree(html).write(f)
def main():
ap = argparse.ArgumentParser(sys.argv[0])
ap.add_argument('--cache-directory', type=CachePaths, default='cache', help='Path to the cache directory')
ap.add_argument('--output-directory', type=OutputPaths, default='out', help='Path to the output directory')
ap.add_argument('--update-borders', action='store_true', default=False, help='Update country borders from Wikidata')
ap.add_argument('--update-erfalist', action='store_true', default=False, help='Update Erfa/Chaostreff list from doku.ccc.de')
ap.add_argument('--bbox', type=float, nargs=4, metavar=('LON', 'LAT', 'LON', 'LAT') ,default=None, help='Override map bounding box')
ap.add_argument('--bbox-margin', type=float, default=0.3, help='Margin around the inferred bounding box. Ignored with --bbox')
ap.add_argument('--stylesheet', type=str, default='style/erfamap.css', help='Stylesheet for the generated SVG')
ap.add_argument('--font', type=str, default='style/concertone-regular.ttf', help='Name of the font used in the stylesheet.')
ap.add_argument('--font-size', type=int, default=45, help='Size of the font used in the stylesheet.')
ap.add_argument('--font-min-distance', type=int, default=18, help='Minimal distance of labels from their dots center')
ap.add_argument('--font-max-distance', type=int, default=40, help='Maximal distance of labels from their dots center')
ap.add_argument('--font-step-distance', type=int, default=3, help='Distance steps of labels from their dots center')
ap.add_argument('--dotsize-erfa', type=float, default=13, help='Radius of Erfa dots')
ap.add_argument('--dotsize-treff', type=float, default=8, help='Radius of Chaostreff dots')
ap.add_argument('--rename', type=str, action='append', nargs=2, metavar=('FROM', 'TO'), default=[], help='Rename a city with an overly long name (e.g. "Rothenburg ob der Tauber" to "Rothenburg")')
ap.add_argument('--projection', type=CoordinateTransform, default='epsg:4258', help='Map projection to convert the WGS84 coordinates to')
ap.add_argument('--scale-x', type=float, default=130, help='X axis scale to apply after projecting')
ap.add_argument('--scale-y', type=float, default=200, help='Y axis scale to apply after projecting')
ap.add_argument('--png-scale', type=float, default=1.0, help='Scale of the PNG image')
ap.add_argument('--debug', action='store_true', default=False, help='Add debug information to the produced SVG')
ns = ap.parse_args(sys.argv[1:])
if ns.update_borders or not os.path.isdir(ns.cache_directory.shapes_countries):
if os.path.isdir(ns.cache_directory.shapes_countries):
shutil.rmtree(ns.cache_directory.shapes_countries)
print('Retrieving country border shapes')
Country.fetch(target=ns.cache_directory.shapes_countries)
if ns.update_borders or not os.path.isdir(ns.cache_directory.shapes_states):
if os.path.isdir(ns.cache_directory.shapes_states):
shutil.rmtree(ns.cache_directory.shapes_states)
print('Retrieving state border shapes')
FederalState.fetch(target=ns.cache_directory.shapes_states)
if ns.update_erfalist or not os.path.isfile(ns.cache_directory.erfa_info):
if os.path.exists(ns.cache_directory.erfa_info):
os.unlink(ns.cache_directory.erfa_info)
print('Retrieving Erfas information')
Erfa.fetch(ns, target=ns.cache_directory.erfa_info, radius=ns.dotsize_erfa)
if ns.update_erfalist or not os.path.isfile(ns.cache_directory.chaostreff_info):
if os.path.exists(ns.cache_directory.chaostreff_info):
os.unlink(ns.cache_directory.chaostreff_info)
print('Retrieving Chaostreffs information')
Chaostreff.fetch(ns, target=ns.cache_directory.chaostreff_info, radius=ns.dotsize_treff)
create_svg(ns)
if __name__ == '__main__':
main()