diff --git a/generate_map.py b/generate_map.py index d58f8bc..69de5cd 100755 --- a/generate_map.py +++ b/generate_map.py @@ -241,11 +241,11 @@ def compute_bbox(ns): class BoundingBox: - def __init__(self, left, top, right, bottom, meta=None, base_weight=0): + 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 - self.bottom = bottom + 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 @@ -270,7 +270,9 @@ class BoundingBox: 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) + 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): @@ -284,7 +286,9 @@ class BoundingBox: 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) + 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): @@ -314,7 +318,35 @@ class BoundingBox: 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, self.meta, self.base_weight) + 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): + 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, ns): w = 0 @@ -323,14 +355,18 @@ class BoundingBox: swc = self.with_margin(ns.dotsize_treff) swme = swm.with_margin(ns.dotsize_erfa) swmc = swm.with_margin(ns.dotsize_treff) + # I hope these weights are somewhat reasonably balanced... for o in other: if o.meta['city'] == self.meta['city']: continue if o in self: if o.finished: - w += 50 + w += 1000 else: - w += 3 + w += 50 + else: + w += max(ns.font_distance*2 - swc.chebyshev_distance(o), 0) + continue if o in swm: if o.finished: w += 3 @@ -340,48 +376,18 @@ class BoundingBox: if city == self.meta['city']: continue if location in swe: - w += 15 - if location in swme: - w += 2 + w += 1000 + else: + w += max(ns.font_distance*2 - swe.chebyshev_distance(location), 0) for city, location in chaostreffs.items(): if city == self.meta['city']: continue if location in swc: - w += 5 - if location in swmc: - w += 1 + w += 1000 + else: + w += max(ns.font_distance*2 - swc.chebyshev_distance(location), 0) 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 @@ -390,67 +396,92 @@ class BoundingBox: def is_optimal(self): return self._weight == 0 + 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): font = ImageFont.truetype(ns.font, ns.font_size) - pil = ImageDraw(Image.new('P', 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 candidates = {} for city, location in erfas.items(): text = city + erfax, erfay = location 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]: + + 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] + rbox = BoundingBox(erfax + ns.font_distance, erfay + voffset, width=mw, height=mh, meta=meta) + lbox = BoundingBox(erfax - ns.font_distance - mw, erfay + voffset, width=mw, height=mh, meta=meta) + if erfax > 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)] + candidates[city] = [lbox, rbox] + for i in range(-2, 3): + if i == 0: + continue + candidates[city].append(lbox + (0, ns.dotsize_erfa*i)) + candidates[city].append(rbox + (0, ns.dotsize_erfa*i)) + candidates[city].extend([ + BoundingBox(erfax - mw/2, erfay - ns.font_distance - mh, width=mw, height=mh, meta=meta, base_weight=0.001), + BoundingBox(erfax - mw/2, erfay + ns.font_distance, width=mw, height=mh, meta=meta, base_weight=0.001), + BoundingBox(erfax - ns.dotsize_erfa, erfay - ns.font_distance - mh, width=mw, height=mh, meta=meta, base_weight=0.002), + BoundingBox(erfax - ns.dotsize_erfa, erfay + ns.font_distance, width=mw, height=mh, meta=meta, base_weight=0.003), + BoundingBox(erfax + ns.dotsize_erfa - mw, erfay - ns.font_distance - mh, width=mw, height=mh, meta=meta, base_weight=0.001), + BoundingBox(erfax + ns.dotsize_erfa - mw, erfay + ns.font_distance, width=mw, height=mh, meta=meta, base_weight=0.001), + ]) rbox.base_weight -= 0.003 lbox.base_weight -= 0.003 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 = {} - while len(unfinished) > 0: + 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, 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) + 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 + + # 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))): @@ -460,7 +491,7 @@ def optimize_text_layout(ns, erfas, chaostreffs, size, svg): for city in order: all_boxes = set(finished.values()) for box in candidates[city]: - box.compute_finalized_weight(all_boxes, erfas, chaostreffs, ns) + box.compute_weight(all_boxes, erfas, chaostreffs, ns) minbox = min(candidates[city], key=lambda box: box.weight) if minbox is not finished[city]: changed = True @@ -607,10 +638,7 @@ def create_svg(ns, bbox): 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 = 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'] @@ -635,8 +663,8 @@ def main(): 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=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('--font-size', type=int, default=45, help='Size of the font used in the stylesheet.') + ap.add_argument('--font-distance', type=int, default=18, 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")') diff --git a/map.readme.png b/map.readme.png index d39d1fd..7caee76 100644 Binary files a/map.readme.png and b/map.readme.png differ diff --git a/style/erfamap.css b/style/erfamap.css index fec9c20..b9f0f08 100644 --- a/style/erfamap.css +++ b/style/erfamap.css @@ -29,7 +29,7 @@ circle.erfa, circle.chaostreff { text.erfalabel { font-family: concertone; - font-size: 40px; + font-size: 45px; } rect.debugleft {