#!/usr/bin/env python3 # # https://git.kabelsalat.ch/s3lph/erfamap 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 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.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) 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' 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 = {} 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(): with open(os.path.join(target, item + '.json'), 'w') as f: json.dump(shape, f) def fetch_wikidata_states(target): shape_urls = sparql_query(''' PREFIX wd: PREFIX wdt: 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 } ''') print('Retrieving state border shapes') fetch_geoshapes(target, shape_urls) def fetch_wikidata_countries(target): shape_urls = sparql_query(''' PREFIX wd: PREFIX wdt: PREFIX p: PREFIX ps: PREFIX wikibase: 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 } FILTER (?stateclass = wd:Q6256 || ?stateclass = wd:Q3624078). FILTER (?euroclass = wd:Q46 || ?euroclass = wd:Q8932). } ''') print('Retrieving country border shapes') fetch_geoshapes(target, shape_urls) def filter_boundingbox(source, target, bbox): files = os.listdir(source) os.makedirs(target, exist_ok=True) print('Filtering countries outside the bounding box') for f in tqdm.tqdm(files): 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]: if point[0] >= bbox[0][0] and point[1] >= bbox[0][1] \ and point[0] <= bbox[1][0] and point[1] <= bbox[1][1]: keep = True break if keep: break if keep: with open(os.path.join(target, f), 'w') as sf: sf.write(shapedata) 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. 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 = locator.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 def fetch_erfas(target, url): 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()) print('Looking up addresses') for name, erfa in tqdm.tqdm(erfadata['results'].items()): location = address_lookup(name, erfa['printouts']) if location is None: print(f'WARNING: No location for {name}') city = erfa['printouts']['Chaostreff-City'][0] 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 with open(target, 'w') as f: json.dump(erfas, f) 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) for data in erfadata.values(): if 'location' not in data: continue lon, lat = data['location'] 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: 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['city'] == self.meta['city']: 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 city, location in erfas.items(): if city == self.meta['city']: continue if location in swe: w += 1000 self._optimal = False else: maxs.append(max(pdist*2 - swe.chebyshev_distance(o), 0)) for city, location in chaostreffs.items(): if city == self.meta['city']: continue 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 optimize_text_layout(ns, erfas, chaostreffs, size, svg): # Load the font and measure its various different heights font = ImageFont.truetype(ns.font, ns.font_size) 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 city, location in erfas.items(): text = city erfax, erfay = location for rfrom, to in ns.rename: if rfrom == city: text = to break 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.002), BoundingBox(erfax + ns.dotsize_erfa - mw, erfay + 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 = {c for c in erfas.keys()} 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['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, erfa_urls, erfa_names, texts, chaostreffs, chaostreff_urls, chaostreff_names): 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 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]*s},{location[1]*s},{ns.dotsize_erfa*s}', href=erfa_urls[city]) area2 = etree.Element('area', shape='rect', coords=f'{box.left*s},{box.top*s},{box.right*s},{box.bottom*s}', 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]*s},{location[1]*s},{ns.dotsize_treff*s}', href=chaostreff_urls[city]) if city in chaostreff_names: area.set('title', chaostreff_names[city]) imgmap.append(area) parent.append(img) parent.append(imgmap) 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 = [ (scalex*blt[0], scaley*trt[1]), (scalex*trt[0], scaley*blt[1]) ] 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 shapes_states = [] files = os.listdir(ns.cache_directory.shapes_states) for f in files: if not f.endswith('.json'): continue path = os.path.join(ns.cache_directory.shapes_states, f) with open(path, 'r') as sf: shapedata = sf.read() shape = json.loads(shapedata) name = shape['description']['en'] 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)) # Load country border lines from cached JSON files shapes_countries = [] files = os.listdir(ns.cache_directory.shapes_filtered) for f in files: if not f.endswith('.json'): continue path = os.path.join(ns.cache_directory.shapes_filtered, f) with open(path, 'r') as sf: shapedata = sf.read() shape = json.loads(shapedata) name = shape['description']['en'] 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)) # Load Erfa infos from cached JSON files erfas = {} erfa_urls = {} erfa_names = {} with open(ns.cache_directory.erfa_info, 'r') as f: ctdata = json.load(f) for city, data in ctdata.items(): location = data.get('location') if location is None: continue xt, yt = transformer.transform(*location) erfas[city] = (xt*scalex - origin[0], origin[1] - yt*scaley) 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 # Load Chaostreff infos from cached JSON files chaostreffs = {} chaostreff_urls = {} chaostreff_names = {} with open(ns.cache_directory.chaostreff_info, 'r') as f: ctdata = json.load(f) for city, data in ctdata.items(): location = data.get('location') 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 xt, yt = transformer.transform(*location) chaostreffs[city] = (xt*scalex - origin[0], origin[1] - yt*scaley) 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 rectbox = [0, 0, svg_box[0], svg_box[1]] for name, shape in shapes_states + shapes_countries: 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) 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') style.text = f'@import url({ns.stylesheet})' 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) # Render country borders for name, shape in shapes_countries: points = ' '.join([f'{lon},{lat}' for lon, lat in shape]) poly = etree.Element('polygon', points=points) poly.set('class', 'country') poly.set('data-country', name) svg.append(poly) # Render state borders # 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])): 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) svg.append(poly) # 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 for city, location in erfas.items(): 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) 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) 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) # Render Chaostreff dots 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) 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') a.append(circle) svg.append(a) else: svg.append(circle) # 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.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'\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.stylesheet) head.append(link) html.append(head) 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'\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=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('--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) 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()