Improve text layouting (closes #1)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
eedb3fde4d
commit
adad7d7878
3 changed files with 113 additions and 85 deletions
196
generate_map.py
196
generate_map.py
|
@ -241,11 +241,11 @@ def compute_bbox(ns):
|
||||||
|
|
||||||
class BoundingBox:
|
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.left = left
|
||||||
self.top = top
|
self.top = top
|
||||||
self.right = right
|
self.right = right if width is None else left + width
|
||||||
self.bottom = bottom
|
self.bottom = bottom if height is None else top + height
|
||||||
self.meta = meta
|
self.meta = meta
|
||||||
self.base_weight = base_weight
|
self.base_weight = base_weight
|
||||||
self.finished = False
|
self.finished = False
|
||||||
|
@ -270,7 +270,9 @@ class BoundingBox:
|
||||||
def __add__(self, other):
|
def __add__(self, other):
|
||||||
if not isinstance(other, tuple) and not isinstance(other, list):
|
if not isinstance(other, tuple) and not isinstance(other, list):
|
||||||
raise TypeError()
|
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):
|
def __iadd__(self, other):
|
||||||
if not isinstance(other, tuple) and not isinstance(other, list):
|
if not isinstance(other, tuple) and not isinstance(other, list):
|
||||||
|
@ -284,7 +286,9 @@ class BoundingBox:
|
||||||
def __sub__(self, other):
|
def __sub__(self, other):
|
||||||
if not isinstance(other, tuple) and not isinstance(other, list):
|
if not isinstance(other, tuple) and not isinstance(other, list):
|
||||||
raise TypeError()
|
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):
|
def __isub__(self, other):
|
||||||
if not isinstance(other, tuple) and not isinstance(other, list):
|
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
|
return (c1[0] - c2[0])**2 + (c1[1] - c2[1])**2
|
||||||
|
|
||||||
def with_margin(self, margin):
|
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):
|
def compute_weight(self, other, erfas, chaostreffs, ns):
|
||||||
w = 0
|
w = 0
|
||||||
|
@ -323,14 +355,18 @@ class BoundingBox:
|
||||||
swc = self.with_margin(ns.dotsize_treff)
|
swc = self.with_margin(ns.dotsize_treff)
|
||||||
swme = swm.with_margin(ns.dotsize_erfa)
|
swme = swm.with_margin(ns.dotsize_erfa)
|
||||||
swmc = swm.with_margin(ns.dotsize_treff)
|
swmc = swm.with_margin(ns.dotsize_treff)
|
||||||
|
# I hope these weights are somewhat reasonably balanced...
|
||||||
for o in other:
|
for o in other:
|
||||||
if o.meta['city'] == self.meta['city']:
|
if o.meta['city'] == self.meta['city']:
|
||||||
continue
|
continue
|
||||||
if o in self:
|
if o in self:
|
||||||
if o.finished:
|
if o.finished:
|
||||||
w += 50
|
w += 1000
|
||||||
else:
|
else:
|
||||||
w += 3
|
w += 50
|
||||||
|
else:
|
||||||
|
w += max(ns.font_distance*2 - swc.chebyshev_distance(o), 0)
|
||||||
|
continue
|
||||||
if o in swm:
|
if o in swm:
|
||||||
if o.finished:
|
if o.finished:
|
||||||
w += 3
|
w += 3
|
||||||
|
@ -340,48 +376,18 @@ class BoundingBox:
|
||||||
if city == self.meta['city']:
|
if city == self.meta['city']:
|
||||||
continue
|
continue
|
||||||
if location in swe:
|
if location in swe:
|
||||||
w += 15
|
w += 1000
|
||||||
if location in swme:
|
else:
|
||||||
w += 2
|
w += max(ns.font_distance*2 - swe.chebyshev_distance(location), 0)
|
||||||
for city, location in chaostreffs.items():
|
for city, location in chaostreffs.items():
|
||||||
if city == self.meta['city']:
|
if city == self.meta['city']:
|
||||||
continue
|
continue
|
||||||
if location in swc:
|
if location in swc:
|
||||||
w += 5
|
w += 1000
|
||||||
if location in swmc:
|
else:
|
||||||
w += 1
|
w += max(ns.font_distance*2 - swc.chebyshev_distance(location), 0)
|
||||||
self._weight = w
|
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
|
@property
|
||||||
def weight(self):
|
def weight(self):
|
||||||
return self._weight + self.base_weight
|
return self._weight + self.base_weight
|
||||||
|
@ -390,67 +396,92 @@ class BoundingBox:
|
||||||
def is_optimal(self):
|
def is_optimal(self):
|
||||||
return self._weight == 0
|
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):
|
def optimize_text_layout(ns, erfas, chaostreffs, size, svg):
|
||||||
font = ImageFont.truetype(ns.font, ns.font_size)
|
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 = {}
|
candidates = {}
|
||||||
for city, location in erfas.items():
|
for city, location in erfas.items():
|
||||||
text = city
|
text = city
|
||||||
|
erfax, erfay = location
|
||||||
for rfrom, to in ns.rename:
|
for rfrom, to in ns.rename:
|
||||||
if rfrom == city:
|
if rfrom == city:
|
||||||
text = to
|
text = to
|
||||||
break
|
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})
|
meta = {'city': city, 'text': text, 'baseline': capheight}
|
||||||
rbox.right += 1.5 * ns.font_distance
|
textbox = pil.textbbox((0, 0), text, font=font, anchor='ls') # left, baseline at 0,0
|
||||||
lbox = pil.textbbox((location[0] - ns.font_distance, location[1] - ns.font_size/2), text, font=font, anchor='rt')
|
mw, mh = textbox[2] - textbox[0], textbox[3] - textbox[1]
|
||||||
lbox = BoundingBox(*lbox, meta={'city': city, 'side': 'l', 'text': text})
|
rbox = BoundingBox(erfax + ns.font_distance, erfay + voffset, width=mw, height=mh, meta=meta)
|
||||||
lbox.left -= 1.5 * ns.font_distance
|
lbox = BoundingBox(erfax - ns.font_distance - mw, erfay + voffset, width=mw, height=mh, meta=meta)
|
||||||
if location[0] > 0.8 * size[0]:
|
if erfax > 0.8 * size[0]:
|
||||||
rbox.base_weight += 0.001
|
rbox.base_weight += 0.001
|
||||||
else:
|
else:
|
||||||
lbox.base_weight += 0.001
|
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
|
rbox.base_weight -= 0.003
|
||||||
lbox.base_weight -= 0.003
|
lbox.base_weight -= 0.003
|
||||||
|
|
||||||
if ns.debug:
|
if ns.debug:
|
||||||
for c in candidates[city]:
|
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 = etree.Element('rect', x=str(c.left), y=str(c.top), width=str(c.width), height=str(c.height))
|
||||||
dr.set('class', 'debugleft')
|
dr.set('class', 'debugleft')
|
||||||
|
svg.append(di)
|
||||||
svg.append(dr)
|
svg.append(dr)
|
||||||
|
|
||||||
|
|
||||||
unfinished = {c for c in erfas.keys()}
|
unfinished = {c for c in erfas.keys()}
|
||||||
finished = {}
|
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())
|
all_boxes = set(finished.values())
|
||||||
unfinished_boxes = set()
|
unfinished_boxes = set()
|
||||||
for city in unfinished:
|
for city in unfinished:
|
||||||
all_boxes.update(candidates[city])
|
all_boxes.update(candidates[city])
|
||||||
unfinished_boxes.update(candidates[city])
|
unfinished_boxes.update(candidates[city])
|
||||||
for box in all_boxes:
|
for box in all_boxes:
|
||||||
box.compute_weight(all_boxes, erfas, chaostreffs, ns)
|
box.compute_weight(all_boxes, erfas, chaostreffs, ns)
|
||||||
|
|
||||||
# Get candidate with the least number of optimal solutions > 0
|
# 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'))
|
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]
|
optboxes = [box for box in candidates[optcity] if box.is_optimal]
|
||||||
if len(optboxes) > 0:
|
if len(optboxes) > 0:
|
||||||
finished[optcity] = min(optboxes, key=lambda box: box.weight)
|
finished[optcity] = min(optboxes, key=lambda box: box.weight)
|
||||||
finished[optcity].finished = True
|
finished[optcity].finished = True
|
||||||
unfinished.discard(optcity)
|
unfinished.discard(optcity)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If no candidate with at least one optimal solution is left, go by global minimum
|
# If no candidate with at least one optimal solution is left, go by global minimum
|
||||||
minbox = min(unfinished_boxes, key=lambda box: box.weight)
|
minbox = min(unfinished_boxes, key=lambda box: box.weight)
|
||||||
mincity = minbox.meta['city']
|
mincity = minbox.meta['city']
|
||||||
finished[mincity] = minbox
|
finished[mincity] = minbox
|
||||||
minbox.finished = True
|
minbox.finished = True
|
||||||
unfinished.discard(mincity)
|
unfinished.discard(mincity)
|
||||||
|
|
||||||
# Iteratively improve the layout
|
# Iteratively improve the layout
|
||||||
for i in tqdm.tqdm(range(len(finished))):
|
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:
|
for city in order:
|
||||||
all_boxes = set(finished.values())
|
all_boxes = set(finished.values())
|
||||||
for box in candidates[city]:
|
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)
|
minbox = min(candidates[city], key=lambda box: box.weight)
|
||||||
if minbox is not finished[city]:
|
if minbox is not finished[city]:
|
||||||
changed = True
|
changed = True
|
||||||
|
@ -607,10 +638,7 @@ def create_svg(ns, bbox):
|
||||||
print('Layouting labels')
|
print('Layouting labels')
|
||||||
texts = optimize_text_layout(ns, erfas, chaostreffs, (int(svg_box[0]), int(svg_box[1])), svg)
|
texts = optimize_text_layout(ns, erfas, chaostreffs, (int(svg_box[0]), int(svg_box[1])), svg)
|
||||||
for city, box in texts.items():
|
for city, box in texts.items():
|
||||||
if box.meta['side'] == 'l':
|
text = etree.Element('text', x=str(box.left), y=str(box.top + box.meta['baseline']))
|
||||||
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.set('class', 'erfalabel')
|
||||||
text.set('data-erfa', city)
|
text.set('data-erfa', city)
|
||||||
text.text = box.meta['text']
|
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('--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('--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', 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-size', type=int, default=45, 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-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-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('--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('--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")')
|
||||||
|
|
BIN
map.readme.png
BIN
map.readme.png
Binary file not shown.
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 421 KiB |
|
@ -29,7 +29,7 @@ circle.erfa, circle.chaostreff {
|
||||||
|
|
||||||
text.erfalabel {
|
text.erfalabel {
|
||||||
font-family: concertone;
|
font-family: concertone;
|
||||||
font-size: 40px;
|
font-size: 45px;
|
||||||
}
|
}
|
||||||
|
|
||||||
rect.debugleft {
|
rect.debugleft {
|
||||||
|
|
Loading…
Reference in a new issue