935 lines
37 KiB
Python
Executable file
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()
|