erfamap/generate_map.py

894 lines
36 KiB
Python
Raw Normal View History

2022-10-08 04:24:14 +02:00
#!/usr/bin/env python3
2022-10-09 02:26:47 +02:00
#
# https://git.kabelsalat.ch/s3lph/erfamap
2022-10-08 04:24:14 +02:00
import os
import urllib.request
2022-10-08 21:17:31 +02:00
import urllib.parse
2022-10-08 04:24:14 +02:00
import json
import base64
2022-10-08 21:17:31 +02:00
import sys
import argparse
import shutil
import random
import math
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
from lxml import etree
2022-10-08 04:24:14 +02:00
import pyproj
from geopy import Nominatim
2022-10-08 21:17:31 +02:00
import tqdm
import cairosvg
from PIL import Image, ImageFont
from PIL.ImageDraw import ImageDraw
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
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.shapes_filtered = os.path.join(path, 'shapes_filtered')
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.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)
2022-10-08 04:24:14 +02:00
ERFA_URL = 'https://doku.ccc.de/index.php?title=Spezial:Semantische_Suche&x=-5B-5BKategorie%3AErfa-2DKreise-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=200&link=all&headers=show&searchlabel=JSON&class=sortable+wikitable+smwtable&sort=&order=asc&offset=0&mainlabel=&prettyprint=true&unescape=true'
CHAOSTREFF_URL = 'https://doku.ccc.de/index.php?title=Spezial:Semantische_Suche&x=-5B-5BKategorie%3AChaostreffs-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=200&link=all&headers=show&searchlabel=JSON&class=sortable+wikitable+smwtable&sort=&order=asc&offset=0&mainlabel=&prettyprint=true&unescape=true'
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
def sparql_query(query):
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT,
'Accept': 'application/sparql-results+json',
}
body = urllib.parse.urlencode({'query': 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
def fetch_geoshapes(target, shape_urls):
os.makedirs(target, exist_ok=True)
candidates = {}
keep = {}
2022-10-08 21:17:31 +02:00
for item, url in tqdm.tqdm(shape_urls.items()):
try:
with urllib.request.urlopen(url) as resp:
shape = json.load(resp)
2022-10-09 02:26:47 +02:00
if not shape.get('license', 'proprietary').startswith('CC0-'):
# Only include public domain data
continue
candidates.setdefault(item, []).append(shape)
2022-10-08 21:17:31 +02:00
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():
with open(os.path.join(target, item + '.json'), 'w') as f:
json.dump(shape, f)
2022-10-08 21:17:31 +02:00
def fetch_wikidata_states(target):
shape_urls = sparql_query('''
2022-10-08 04:24:14 +02:00
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
}
''')
2022-10-08 21:17:31 +02:00
print('Retrieving state border shapes')
fetch_geoshapes(target, shape_urls)
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
def fetch_wikidata_countries(target):
shape_urls = sparql_query('''
2022-10-08 04:24:14 +02:00
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#>
2022-10-08 04:24:14 +02:00
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+ ?euroclass.
# ?item has geoshape ?map (including all non-deprecated results)
?item p:P3896 ?st.
?st ps:P3896 ?map;
MINUS { ?st wikibase:rank wikibase:DeprecatedRank }
2022-10-08 04:24:14 +02:00
FILTER (?stateclass = wd:Q6256 || ?stateclass = wd:Q3624078).
FILTER (?euroclass = wd:Q46 || ?euroclass = wd:Q8932).
}
''')
2022-10-08 21:17:31 +02:00
print('Retrieving country border shapes')
fetch_geoshapes(target, shape_urls)
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
def filter_boundingbox(source, target, bbox):
2022-10-08 04:24:14 +02:00
files = os.listdir(source)
os.makedirs(target, exist_ok=True)
2022-10-08 21:17:31 +02:00
print('Filtering countries outside the bounding box')
for f in tqdm.tqdm(files):
2022-10-08 04:24:14 +02:00
if not f.endswith('.json') or 'Q183.json' in f:
continue
path = os.path.join(source, f)
with open(path, 'r') as sf:
shapedata = sf.read()
shape = json.loads(shapedata)
keep = False
geo = shape['data']['features'][0]['geometry']
if geo['type'] == 'Polygon':
geo['coordinates'] = [geo['coordinates']]
for poly in geo['coordinates']:
for point in poly[0]:
2022-10-08 21:17:31 +02:00
if point[0] >= bbox[0][0] and point[1] >= bbox[0][1] \
and point[0] <= bbox[1][0] and point[1] <= bbox[1][1]:
2022-10-08 04:24:14 +02:00
keep = True
break
if keep:
break
if keep:
with open(os.path.join(target, f), 'w') as sf:
sf.write(shapedata)
2022-10-08 21:17:31 +02:00
def address_lookup(name, erfa):
locator = Nominatim(user_agent=USER_AGENT)
number = erfa['Chaostreff-Physical-Housenumber']
street = erfa['Chaostreff-Physical-Address']
zipcode = erfa['Chaostreff-Physical-Postcode']
acity = erfa['Chaostreff-Physical-City']
city = erfa['Chaostreff-City'][0]
country = erfa['Chaostreff-Country'][0]
# Try the most accurate address first, try increasingly inaccurate addresses on failure.
2022-10-08 21:17:31 +02:00
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}')
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
for fmt in formats:
response = locator.geocode(fmt)
if response is not None:
return response.longitude, response.latitude
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
print(f'No location found for {name}, tried the following address formats:')
for fmt in formats:
print(f' {fmt}')
return None
def fetch_erfas(target, url):
2022-10-08 04:24:14 +02:00
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 = {}
req = urllib.request.Request(url, headers={'Authorization': f'Basic {auth}'})
with urllib.request.urlopen(req) as resp:
erfadata = json.loads(resp.read().decode())
2022-10-08 21:17:31 +02:00
print('Looking up addresses')
for name, erfa in tqdm.tqdm(erfadata['results'].items()):
location = address_lookup(name, erfa['printouts'])
2022-10-08 04:24:14 +02:00
if location is None:
print(f'WARNING: No location for {name}')
city = erfa['printouts']['Chaostreff-City'][0]
2022-10-11 02:29:45 +02:00
erfas[city] = {'location': location}
if len(erfa['printouts']['Public-Web']) > 0:
erfas[city]['web'] = erfa['printouts']['Public-Web'][0]
if len(erfa['printouts']['Chaostreff-Longname']) > 0:
erfas[city]['name'] = erfa['printouts']['Chaostreff-Longname'][0]
elif len(erfa['printouts']['Chaostreff-Nickname']) > 0:
erfas[city]['name'] = erfa['printouts']['Chaostreff-Nickname'][0]
elif len(erfa['printouts']['Chaostreff-Realname']) > 0:
erfas[city]['name'] = erfa['printouts']['Chaostreff-Realname'][0]
else:
erfas[city]['name'] = name
2022-10-11 02:29:45 +02:00
2022-10-08 04:24:14 +02:00
with open(target, 'w') as f:
json.dump(erfas, f)
2022-10-08 21:17:31 +02:00
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]):
with open(path, 'r') as f:
erfadata = json.load(f)
2022-10-11 02:29:45 +02:00
for data in erfadata.values():
if 'location' not in data:
continue
lon, lat = data['location']
2022-10-08 21:17:31 +02:00
if len(bounds) == 0:
bounds.append(lon)
bounds.append(lat)
bounds.append(lon)
bounds.append(lat)
else:
bounds[0] = min(bounds[0], lon)
bounds[1] = min(bounds[1], lat)
bounds[2] = max(bounds[2], lon)
bounds[3] = max(bounds[3], lat)
return [
(bounds[0] - ns.bbox_margin, bounds[1] - ns.bbox_margin),
(bounds[2] + ns.bbox_margin, bounds[3] + ns.bbox_margin)
]
class BoundingBox:
2022-10-09 22:40:01 +02:00
def __init__(self, left, top, right=None, bottom=None, *, width=None, height=None, meta=None, base_weight=0):
2022-10-08 21:17:31 +02:00
self.left = left
self.top = top
2022-10-09 22:40:01 +02:00
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
2022-10-08 21:17:31 +02:00
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()
2022-10-09 22:40:01 +02:00
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()
2022-10-09 22:40:01 +02:00
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
2022-10-08 21:17:31 +02:00
@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)
2022-10-08 21:17:31 +02:00
def with_margin(self, margin):
2022-10-09 22:40:01 +02:00
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
2022-10-09 22:40:01 +02:00
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)
swme = swm.with_margin(ns.dotsize_erfa)
swmc = swm.with_margin(ns.dotsize_treff)
2022-10-09 22:40:01 +02:00
# 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['city'] == self.meta['city']:
continue
if o in self:
if o.finished:
2022-10-09 22:40:01 +02:00
w += 1000
self._optimal = False
else:
2022-10-09 22:40:01 +02:00
w += 50
self._optimal = False
2022-10-09 22:40:01 +02:00
else:
w += max(pdist*2 - swm.chebyshev_distance(o), 0)
2022-10-09 22:40:01 +02:00
continue
if o in swm:
if o.finished:
w += 3
else:
w += 1
for city, location in erfas.items():
if city == self.meta['city']:
continue
if location in swe:
2022-10-09 22:40:01 +02:00
w += 1000
self._optimal = False
2022-10-09 22:40:01 +02:00
else:
w += max(pdist*2 - swe.chebyshev_distance(location), 0)
for city, location in chaostreffs.items():
if city == self.meta['city']:
continue
if location in swc:
2022-10-09 22:40:01 +02:00
w += 1000
self._optimal = False
2022-10-09 22:40:01 +02:00
else:
w += max(pdist*2 - swc.chebyshev_distance(location), 0)
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
2022-10-08 21:17:31 +02:00
2022-10-09 22:40:01 +02:00
def __str__(self):
return f'(({int(self.left)}, {int(self.top)}, {int(self.right)}, {int(self.bottom)}), weight={self.weight})'
2022-10-08 21:17:31 +02:00
def optimize_text_layout(ns, erfas, chaostreffs, size, svg):
# Load the font and measure its various different heights
2022-10-08 21:17:31 +02:00
font = ImageFont.truetype(ns.font, ns.font_size)
2022-10-09 22:40:01 +02:00
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
2022-10-08 21:17:31 +02:00
# Generate a discrete set of text placement candidates around each erfa dot
candidates = {}
2022-10-08 21:17:31 +02:00
for city, location in erfas.items():
text = city
2022-10-09 22:40:01 +02:00
erfax, erfay = location
for rfrom, to in ns.rename:
if rfrom == city:
text = to
break
2022-10-09 22:40:01 +02:00
meta = {'city': city, '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 erfax > 0.8 * size[0]:
bwr = bw + 0.001
else:
bwl = bw + 0.001
candidates[city].append(BoundingBox(erfax - dist - mw, erfay + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwl))
candidates[city].append(BoundingBox(erfax + dist, erfay + 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(erfax - mw/2, erfay - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.001),
BoundingBox(erfax - mw/2, erfay + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.001),
BoundingBox(erfax - ns.dotsize_erfa, erfay - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002),
BoundingBox(erfax - ns.dotsize_erfa, erfay + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003),
BoundingBox(erfax + ns.dotsize_erfa - mw, erfay - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.001),
BoundingBox(erfax + ns.dotsize_erfa - mw, erfay + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.001),
])
# If debugging is enabled, render one rectangle around each label's bounding box, and one rectangle around each label's median box
2022-10-08 21:17:31 +02:00
if ns.debug:
for c in candidates[city]:
2022-10-09 22:40:01 +02:00
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')
2022-10-09 22:40:01 +02:00
svg.append(di)
svg.append(dr)
2022-10-08 21:17:31 +02:00
unfinished = {c for c in erfas.keys()}
finished = {}
# Greedily choose a candidate for each label
2022-10-09 22:40:01 +02:00
with tqdm.tqdm(total=len(erfas)) as progress:
while len(unfinished) > 0:
progress.update(len(erfas) - len(unfinished))
2022-10-09 22:40:01 +02:00
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)
2022-10-09 22:40:01 +02:00
# 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['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
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
return finished
def create_imagemap(ns, size, parent,
erfas, erfa_urls, erfa_names, texts,
chaostreffs, chaostreff_urls, chaostreff_names):
img = etree.Element('img',
src=ns.output_directory.rel(ns.output_directory.png_path),
usemap='#erfamap',
width=str(size[0]), height=str(size[1]))
imgmap = etree.Element('map', name='erfamap')
for city, location in erfas.items():
if city not in erfa_urls:
continue
box = texts[city]
area = etree.Element('area',
shape='circle',
coords=f'{location[0]},{location[1]},{ns.dotsize_erfa}',
href=erfa_urls[city])
area2 = etree.Element('area',
shape='rect',
coords=f'{box.left},{box.top},{box.right},{box.bottom}',
href=erfa_urls[city])
if city in erfa_names:
area.set('title', erfa_names[city])
area2.set('title', erfa_names[city])
imgmap.append(area)
imgmap.append(area2)
for city, location in chaostreffs.items():
if city not in chaostreff_urls:
continue
area = etree.Element('area',
shape='circle',
coords=f'{location[0]},{location[1]},{ns.dotsize_treff}',
href=chaostreff_urls[city])
if city in chaostreff_names:
area.set('title', chaostreff_names[city])
imgmap.append(area)
parent.append(img)
parent.append(imgmap)
2022-10-08 21:17:31 +02:00
def create_svg(ns, bbox):
print('Creating SVG image')
# Convert from WGS84 lon, lat to chosen projection
transformer = pyproj.Transformer.from_crs('epsg:4326', ns.projection)
scalex = ns.scale_x
scaley = ns.scale_y
blt = transformer.transform(*bbox[0])
trt = transformer.transform(*bbox[1])
trans_bounding_box = [
2022-10-08 04:24:14 +02:00
(scalex*blt[0], scaley*trt[1]),
(scalex*trt[0], scaley*blt[1])
]
2022-10-08 21:17:31 +02:00
origin = trans_bounding_box[0]
svg_box = (trans_bounding_box[1][0] - origin[0], origin[1] - trans_bounding_box[1][1])
# Load state border lines from cached JSON files
2022-10-08 04:24:14 +02:00
shapes_states = []
2022-10-08 21:17:31 +02:00
files = os.listdir(ns.cache_directory.shapes_states)
2022-10-08 04:24:14 +02:00
for f in files:
if not f.endswith('.json'):
continue
2022-10-08 21:17:31 +02:00
path = os.path.join(ns.cache_directory.shapes_states, f)
2022-10-08 04:24:14 +02:00
with open(path, 'r') as sf:
shapedata = sf.read()
shape = json.loads(shapedata)
name = shape['description']['en']
2022-10-08 04:24:14 +02:00
geo = shape['data']['features'][0]['geometry']
if geo['type'] == 'Polygon':
geo['coordinates'] = [geo['coordinates']]
for poly in geo['coordinates']:
ts = []
for x, y in poly[0]:
xt, yt = transformer.transform(x, y)
ts.append((xt*scalex - origin[0], origin[1] - yt*scaley))
shapes_states.append((name, ts))
2022-10-08 04:24:14 +02:00
# Load country border lines from cached JSON files
2022-10-08 04:24:14 +02:00
shapes_countries = []
2022-10-08 21:17:31 +02:00
files = os.listdir(ns.cache_directory.shapes_filtered)
2022-10-08 04:24:14 +02:00
for f in files:
if not f.endswith('.json'):
continue
2022-10-08 21:17:31 +02:00
path = os.path.join(ns.cache_directory.shapes_filtered, f)
2022-10-08 04:24:14 +02:00
with open(path, 'r') as sf:
shapedata = sf.read()
shape = json.loads(shapedata)
name = shape['description']['en']
2022-10-08 04:24:14 +02:00
geo = shape['data']['features'][0]['geometry']
if geo['type'] == 'Polygon':
geo['coordinates'] = [geo['coordinates']]
for poly in geo['coordinates']:
ts = []
for x, y in poly[0]:
xt, yt = transformer.transform(x, y)
ts.append((xt*scalex - origin[0], origin[1] - yt*scaley))
shapes_countries.append((name, ts))
2022-10-08 04:24:14 +02:00
# Load Erfa infos from cached JSON files
erfas = {}
2022-10-11 02:29:45 +02:00
erfa_urls = {}
erfa_names = {}
with open(ns.cache_directory.erfa_info, 'r') as f:
2022-10-08 04:24:14 +02:00
ctdata = json.load(f)
2022-10-11 02:29:45 +02:00
for city, data in ctdata.items():
location = data.get('location')
2022-10-08 04:24:14 +02:00
if location is None:
continue
xt, yt = transformer.transform(*location)
erfas[city] = (xt*scalex - origin[0], origin[1] - yt*scaley)
2022-10-11 02:29:45 +02:00
web = data.get('web')
if web is not None:
erfa_urls[city] = web
name = data.get('name')
if name is not None:
erfa_names[city] = name
2022-10-08 04:24:14 +02:00
# Load Chaostreff infos from cached JSON files
chaostreffs = {}
2022-10-11 02:29:45 +02:00
chaostreff_urls = {}
chaostreff_names = {}
with open(ns.cache_directory.chaostreff_info, 'r') as f:
2022-10-08 04:24:14 +02:00
ctdata = json.load(f)
2022-10-11 02:29:45 +02:00
for city, data in ctdata.items():
location = data.get('location')
2022-10-08 04:24:14 +02:00
if location is None:
continue
if city in erfas:
# 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.
continue
2022-10-08 04:24:14 +02:00
xt, yt = transformer.transform(*location)
chaostreffs[city] = (xt*scalex - origin[0], origin[1] - yt*scaley)
2022-10-11 02:29:45 +02:00
web = data.get('web')
if web is not None:
chaostreff_urls[city] = web
name = data.get('name')
if name is not None:
chaostreff_names[city] = name
2022-10-08 04:24:14 +02:00
rectbox = [0, 0, svg_box[0], svg_box[1]]
for name, shape in shapes_states + shapes_countries:
2022-10-08 04:24:14 +02:00
for lon, lat in shape:
rectbox[0] = min(lon, rectbox[0])
rectbox[1] = min(lat, rectbox[1])
rectbox[2] = max(lon, rectbox[2])
rectbox[3] = max(lat, rectbox[3])
print('Copying stylesheet and font')
dst = os.path.join(ns.output_directory.path, ns.stylesheet)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(ns.stylesheet, dst)
dst = os.path.join(ns.output_directory.path, ns.font)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(ns.font, dst)
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
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]))
2022-10-08 04:24:14 +02:00
2022-10-08 21:17:31 +02:00
style = etree.Element('style', type='text/css')
style.text = f'@import url({ns.stylesheet})'
svg.append(style)
2022-10-08 21:17:31 +02:00
bg = etree.Element('rect',
id='background',
2022-10-08 21:17:31 +02:00
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)
2022-10-08 04:24:14 +02:00
# Render country borders
for name, shape in shapes_countries:
2022-10-08 04:24:14 +02:00
points = ' '.join([f'{lon},{lat}' for lon, lat in shape])
2022-10-08 21:17:31 +02:00
poly = etree.Element('polygon', points=points)
poly.set('class', 'country')
poly.set('data-country', name)
2022-10-08 21:17:31 +02:00
svg.append(poly)
# Render state borders
2022-10-08 21:17:31 +02:00
# Render shortest shapes last s.t. Berlin, Hamburg and Bremen are rendered on top of their surrounding states
for name, shape in sorted(shapes_states, key=lambda x: -sum(len(s) for s in x[1])):
2022-10-08 21:17:31 +02:00
points = ' '.join([f'{lon},{lat}' for lon, lat in shape])
poly = etree.Element('polygon', points=points)
poly.set('class', 'state')
poly.set('data-state', name)
2022-10-08 21:17:31 +02:00
svg.append(poly)
2022-10-08 04:24:14 +02:00
# This can take some time, especially if lots of candidates are generated
print('Layouting labels')
texts = optimize_text_layout(ns, erfas, chaostreffs, (int(svg_box[0]), int(svg_box[1])), svg)
# Render Erfa dots and their labels
2022-10-08 04:24:14 +02:00
for city, location in erfas.items():
2022-10-11 02:29:45 +02:00
box = texts[city]
if city in erfa_urls:
group = etree.Element('a', href=erfa_urls[city], target='_blank')
else:
group = etree.Element('g')
group.set('data-erfa', city)
2022-10-08 21:17:31 +02:00
circle = etree.Element('circle', cx=str(location[0]), cy=str(location[1]), r=str(ns.dotsize_erfa))
circle.set('class', 'erfa')
circle.set('data-erfa', city)
2022-10-11 02:29:45 +02:00
if city in erfa_names:
title = etree.Element('title')
title.text = erfa_names[city]
circle.append(title)
group.append(circle)
text = etree.Element('text', x=str(box.left), y=str(box.top + box.meta['baseline']))
text.set('class', 'erfalabel')
text.set('data-erfa', city)
text.text = box.meta['text']
group.append(text)
svg.append(group)
2022-10-08 04:24:14 +02:00
# Render Chaostreff dots
2022-10-08 21:17:31 +02:00
for city, location in chaostreffs.items():
circle = etree.Element('circle', cx=str(location[0]), cy=str(location[1]), r=str(ns.dotsize_treff))
circle.set('class', 'chaostreff')
circle.set('data-chaostreff', city)
2022-10-11 02:29:45 +02:00
if city in chaostreff_names:
title = etree.Element('title')
title.text = chaostreff_names[city]
circle.append(title)
if city in chaostreff_urls:
a = etree.Element('a', href=chaostreff_urls[city], target='_blank')
2022-10-11 02:29:45 +02:00
a.append(circle)
svg.append(a)
else:
svg.append(circle)
2022-10-08 21:17:31 +02:00
# Generate SVG, PNG and HTML output files
print('Writing SVG')
with open(ns.output_directory.svg_path, 'wb') as mapfile:
2022-10-08 21:17:31 +02:00
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)
print('Writing HTML SVG page')
html = etree.Element('html')
head = etree.Element('head')
link = etree.Element('link', rel='stylesheet', href=ns.stylesheet)
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, erfa_urls, erfa_names, texts,
chaostreffs, chaostreff_urls, chaostreff_names)
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')
body = etree.Element('body')
html.append(body)
create_imagemap(ns, svg_box, body,
erfas, erfa_urls, erfa_names, texts,
chaostreffs, chaostreff_urls, chaostreff_names)
with open(ns.output_directory.imagemap_path, 'wb') as f:
f.write(b'<!DOCTYLE html>\n')
etree.ElementTree(html).write(f)
2022-10-08 21:17:31 +02:00
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')
2022-10-08 21:17:31 +02:00
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')
2022-10-08 21:17:31 +02:00
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.')
2022-10-09 22:40:01 +02:00
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')
2022-10-08 21:17:31 +02:00
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")')
2022-10-08 21:17:31 +02:00
ap.add_argument('--projection', type=str, 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('--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)
fetch_wikidata_countries(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)
fetch_wikidata_states(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)
fetch_erfas(target=ns.cache_directory.erfa_info, url=ERFA_URL)
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)
fetch_erfas(target=ns.cache_directory.chaostreff_info, url=CHAOSTREFF_URL)
bbox = compute_bbox(ns)
filter_boundingbox(ns.cache_directory.shapes_countries, ns.cache_directory.shapes_filtered, bbox)
create_svg(ns, bbox)
if __name__ == '__main__':
main()