diff --git a/.woodpecker.yml b/.woodpecker.yml index cd00243..8b66d21 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -5,4 +5,7 @@ pipeline: image: python:3.9 commands: - pip3 install -r requirements.txt - - ./generate_map.py --cache-directory cache.example + - >- + ./generate_map.py + --cache-directory cache.example + --rename 'Frankfurt am Main' 'Frankfurt' diff --git a/generate_map.py b/generate_map.py index e8fe35e..d58f8bc 100755 --- a/generate_map.py +++ b/generate_map.py @@ -10,6 +10,7 @@ import base64 import sys import argparse import shutil +import random from lxml import etree import pyproj @@ -240,11 +241,14 @@ def compute_bbox(ns): class BoundingBox: - def __init__(self, left, top, right, bottom): + def __init__(self, left, top, right, bottom, meta=None, base_weight=0): self.left = left self.top = top self.right = right self.bottom = bottom + self.meta = meta + self.base_weight = base_weight + self.finished = False def __contains__(self, other): if isinstance(other, BoundingBox): @@ -263,6 +267,35 @@ class BoundingBox: 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], self.meta, 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], self.meta, 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 @@ -271,90 +304,171 @@ class BoundingBox: 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 (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) + return BoundingBox(self.left - margin, self.top - margin, self.right + margin, self.bottom + margin, self.meta, self.base_weight) + + def compute_weight(self, other, erfas, chaostreffs, ns): + w = 0 + swm = self.with_margin(ns.font_distance) + 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) + for o in other: + if o.meta['city'] == self.meta['city']: + continue + if o in self: + if o.finished: + w += 50 + else: + w += 3 + 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: + w += 15 + if location in swme: + w += 2 + for city, location in chaostreffs.items(): + if city == self.meta['city']: + continue + if location in swc: + w += 5 + if location in swmc: + w += 1 + self._weight = w + + def compute_finalized_weight(self, other, erfas, chaostreffs, ns): + w = 0 + swm = self.with_margin(ns.font_distance) + 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) + for o in other: + if o.meta['city'] == self.meta['city']: + continue + if o in self: + w += 100 + if o in swm: + w += 15 + for city, location in erfas.items(): + if city == self.meta['city']: + continue + if location in swe: + w += 100 + if location in swme: + w += 10 + for city, location in chaostreffs.items(): + if city == self.meta['city']: + continue + if location in swc: + w += 100 + if location in swmc: + w += 5 + self._weight = w + + @property + def weight(self): + return self._weight + self.base_weight + + @property + def is_optimal(self): + return self._weight == 0 def optimize_text_layout(ns, erfas, chaostreffs, size, svg): font = ImageFont.truetype(ns.font, ns.font_size) pil = ImageDraw(Image.new('P', size)) - lboxes = {} - rboxes = {} + candidates = {} for city, location in erfas.items(): - lbox = pil.textbbox((location[0] + ns.font_distance, location[1] - ns.font_size/2), city, font=font, anchor='lt') - lbox = BoundingBox(*lbox) - rbox = pil.textbbox((location[0] - ns.font_distance, location[1] - ns.font_size/2), city, font=font, anchor='rt') - rbox = BoundingBox(*rbox) - lboxes[city] = lbox - rboxes[city] = rbox + text = city + for rfrom, to in ns.rename: + if rfrom == city: + text = to + break + rbox = pil.textbbox((location[0] + ns.font_distance, location[1] - ns.font_size/2), text, font=font, anchor='lt') + rbox = BoundingBox(*rbox, meta={'city': city, 'side': 'r', 'text': text}) + rbox.right += 1.5 * ns.font_distance + lbox = pil.textbbox((location[0] - ns.font_distance, location[1] - ns.font_size/2), text, font=font, anchor='rt') + lbox = BoundingBox(*lbox, meta={'city': city, 'side': 'l', 'text': text}) + lbox.left -= 1.5 * ns.font_distance + if location[0] > 0.8 * size[0]: + rbox.base_weight += 0.001 + else: + lbox.base_weight += 0.001 + candidates[city] = [lbox, lbox + (0, ns.dotsize_erfa), lbox + (0, ns.dotsize_erfa*2), lbox - (0, ns.dotsize_erfa), lbox - (0, ns.dotsize_erfa*2), rbox, rbox + (0, ns.dotsize_erfa), rbox + (0, ns.dotsize_erfa*2), rbox - (0, ns.dotsize_erfa), rbox - (0, ns.dotsize_erfa*2)] + rbox.base_weight -= 0.003 + lbox.base_weight -= 0.003 if ns.debug: - dr1 = etree.Element('rect', x=str(lbox.left), y=str(lbox.top), width=str(lbox.width), height=str(lbox.height)) - dr1.set('class', 'debugleft') - dr2 = etree.Element('rect', x=str(rbox.left), y=str(rbox.top), width=str(rbox.width), height=str(rbox.height)) - dr2.set('class', 'debugright') - svg.append(dr1) - svg.append(dr2) + for c in candidates[city]: + 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(dr) unfinished = {c for c in erfas.keys()} finished = {} - for city in list(unfinished): - margin_left = lboxes[city].with_margin(ns.dotsize_erfa) - i_left = sum([1 for c, b in lboxes.items() if c != city and b in margin_left]) - i_left += sum([1 for c, b in rboxes.items() if c != city and b in margin_left]) - i_left += sum([0.5 for c, loc in chaostreffs.items() if loc in margin_left]) - margin_right = rboxes[city].with_margin(ns.dotsize_erfa) - i_right = sum([1 for c, b in lboxes.items() if c != city and b in margin_right]) - i_right += sum([1 for c, b in rboxes.items() if c != city and b in margin_right]) - i_right += sum([0.5 for c, loc in chaostreffs.items() if loc in margin_right]) - if i_right == 0 and i_left > 0: - finished[city] = rboxes[city] - unfinished.discard(city) - elif i_left == 0: - if lboxes[city].left > size[0] * 0.8: - finished[city] = rboxes[city] - else: - finished[city] = lboxes[city] - unfinished.discard(city) + while len(unfinished) > 0: - for a in list(unfinished): - if a not in 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, 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 - mla = lboxes[a].with_margin(ns.dotsize_erfa) - mra = rboxes[a].with_margin(ns.dotsize_erfa) - for b in list(unfinished.difference({a})): - if b not in unfinished: - continue - mlb = lboxes[b] - mrb = rboxes[b] - if mla not in mlb and mla not in mlb and mra not in mlb and mra not in mrb: - continue - if mra in mlb: - finished[a] = lboxes[a] - finished[b] = rboxes[b] - unfinished.discard(a) - unfinished.discard(b) - elif mla in mrb: - finished[a] = rboxes[a] - finished[b] = lboxes[b] - unfinished.discard(a) - unfinished.discard(b) - for city in list(unfinished): - margin_left = lboxes[city].with_margin(ns.dotsize_erfa) - margin_right = rboxes[city].with_margin(ns.dotsize_erfa) - i_left = sum([1 for c, b in finished.items() if c != city and b in margin_left]) - i_left += sum([0.5 for c, loc in chaostreffs.items() if loc in margin_left]) - i_right = sum([1 for c, b in finished.items() if c != city and b in margin_right]) - i_right += sum([0.5 for c, loc in chaostreffs.items() if loc in margin_right]) - if i_left <= i_right: - finished[city] = lboxes[city] - else: - finished[city] = rboxes[city] - unfinished.discard(city) + # 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_finalized_weight(all_boxes, erfas, chaostreffs, 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 @@ -384,6 +498,7 @@ def create_svg(ns, bbox): 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']] @@ -392,7 +507,7 @@ def create_svg(ns, bbox): 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(ts) + shapes_states.append((name, ts)) shapes_countries = [] files = os.listdir(ns.cache_directory.shapes_filtered) @@ -403,6 +518,7 @@ def create_svg(ns, bbox): 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']] @@ -411,7 +527,7 @@ def create_svg(ns, bbox): 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(ts) + shapes_countries.append((name, ts)) chaostreffs = {} with open(ns.cache_directory.chaostreff_info, 'r') as f: @@ -432,7 +548,7 @@ def create_svg(ns, bbox): erfas[city] = (xt*scalex - origin[0], origin[1] - yt*scaley) rectbox = [0, 0, svg_box[0], svg_box[1]] - for shape in shapes_states + shapes_countries: + 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]) @@ -452,6 +568,7 @@ def create_svg(ns, bbox): svg.append(defs) bg = etree.Element('rect', + id='background', x=str(rectbox[0]), y=str(rectbox[1]), width=str(rectbox[3]-rectbox[1]), @@ -459,37 +576,44 @@ def create_svg(ns, bbox): bg.set('class', 'background') svg.append(bg) - for shape in shapes_countries: + 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 shortest shapes last s.t. Berlin, Hamburg and Bremen are rendered on top of their surrounding states - for shape in sorted(shapes_states, key=lambda x: -sum(len(s) for s in x)): + 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) for city, location in erfas.items(): 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) svg.append(circle) 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) svg.append(circle) print('Layouting labels') texts = optimize_text_layout(ns, erfas, chaostreffs, (int(svg_box[0]), int(svg_box[1])), svg) for city, box in texts.items(): + if box.meta['side'] == 'l': + box += (1.5 * ns.font_distance, 0) text = etree.Element('text', x=str(box.left), y=str(box.top)) text.set('alignment-baseline', 'hanging') text.set('class', 'erfalabel') - text.text = city + text.set('data-erfa', city) + text.text = box.meta['text'] svg.append(text) print('Done, writing SVG') @@ -502,20 +626,20 @@ def create_svg(ns, bbox): print('Done') - 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('--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, default=None, help='Override map bounding box with ') + 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=30, help='Size of the font used in the stylesheet.') - ap.add_argument('--font-distance', type=int, default=25, help='Distance of labels from their dots center') + ap.add_argument('--font-size', type=int, default=40, help='Size of the font used in the stylesheet.') + ap.add_argument('--font-distance', type=int, default=15, help='Distance 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') diff --git a/map.readme.png b/map.readme.png index ee8cccc..d39d1fd 100644 Binary files a/map.readme.png and b/map.readme.png differ diff --git a/style/erfamap.css b/style/erfamap.css index 1aa30f2..fec9c20 100644 --- a/style/erfamap.css +++ b/style/erfamap.css @@ -29,7 +29,7 @@ circle.erfa, circle.chaostreff { text.erfalabel { font-family: concertone; - font-size: 30px; + font-size: 40px; } rect.debugleft {