feat: add spaceap directory as an alternative data source, and optimize output for lasercutting
All checks were successful
/ test (push) Successful in 2m18s
All checks were successful
/ test (push) Successful in 2m18s
This commit is contained in:
parent
fc17f0cf9b
commit
d119faa804
3 changed files with 186 additions and 28 deletions
143
generate_map.py
143
generate_map.py
|
@ -37,6 +37,7 @@ class CachePaths:
|
||||||
self.shapes_countries = os.path.join(path, 'shapes_countries')
|
self.shapes_countries = os.path.join(path, 'shapes_countries')
|
||||||
self.erfa_info = os.path.join(path, 'erfa-info.json')
|
self.erfa_info = os.path.join(path, 'erfa-info.json')
|
||||||
self.chaostreff_info = os.path.join(path, 'chaostreff-info.json')
|
self.chaostreff_info = os.path.join(path, 'chaostreff-info.json')
|
||||||
|
self.spaceapi_info = os.path.join(path, 'spaceapi-info.json')
|
||||||
|
|
||||||
|
|
||||||
class OutputPaths:
|
class OutputPaths:
|
||||||
|
@ -63,7 +64,7 @@ class CoordinateTransform:
|
||||||
self._proj = pyproj.Transformer.from_crs('epsg:4326', projection)
|
self._proj = pyproj.Transformer.from_crs('epsg:4326', projection)
|
||||||
self.setup()
|
self.setup()
|
||||||
|
|
||||||
def setup(self, scalex=1.0, scaley=1.0, bbox=[(-180, -90), (180, 90)]):
|
def setup(self, scalex=1.0, scaley=1.0, bbox=[(-180, -90), (180, 90)], longedge=None):
|
||||||
self._scalex = scalex
|
self._scalex = scalex
|
||||||
self._scaley = scaley
|
self._scaley = scaley
|
||||||
west = min(bbox[0][0], bbox[1][0])
|
west = min(bbox[0][0], bbox[1][0])
|
||||||
|
@ -71,6 +72,17 @@ class CoordinateTransform:
|
||||||
east = max(bbox[0][0], bbox[1][0])
|
east = max(bbox[0][0], bbox[1][0])
|
||||||
south = min(bbox[0][1], bbox[1][1])
|
south = min(bbox[0][1], bbox[1][1])
|
||||||
self._ox, self._oy = self._proj.transform(west, north)
|
self._ox, self._oy = self._proj.transform(west, north)
|
||||||
|
if longedge is not None:
|
||||||
|
self._scalex = 130
|
||||||
|
self._scaley = 200
|
||||||
|
w, h = self(east, south)
|
||||||
|
if w > h:
|
||||||
|
self._scalex = 130 * longedge / w
|
||||||
|
self._scaley = 200 * longedge / w
|
||||||
|
else:
|
||||||
|
self._scalex = 130 * longedge / h
|
||||||
|
self._scaley = 200 * longedge / h
|
||||||
|
print(self._scalex, self(east, south))
|
||||||
return self(east, south)
|
return self(east, south)
|
||||||
|
|
||||||
def __call__(self, lon, lat):
|
def __call__(self, lon, lat):
|
||||||
|
@ -413,6 +425,47 @@ class Chaostreff(Erfa):
|
||||||
SVG_LABEL = False
|
SVG_LABEL = False
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceApiSpace(Erfa):
|
||||||
|
|
||||||
|
SVG_CLASS = 'chaostreff'
|
||||||
|
SVG_DATA = 'data-chaostreff'
|
||||||
|
SVG_DOTSIZE_ATTR = 'dotsize_treff'
|
||||||
|
SVG_LABEL = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fetch(cls, ns, target, radius):
|
||||||
|
directory_url = 'https://directory.spaceapi.io/'
|
||||||
|
directory_req = urllib.request.urlopen(directory_url)
|
||||||
|
directory_resp = json.loads(directory_req.read().decode())
|
||||||
|
|
||||||
|
spaces = []
|
||||||
|
for name, url in directory_resp.items():
|
||||||
|
print(name, url)
|
||||||
|
try:
|
||||||
|
url_req = urllib.request.urlopen(url, timeout=15)
|
||||||
|
api = json.loads(url_req.read().decode())
|
||||||
|
if 'open' not in api['state'] or api['state']['open'] is None:
|
||||||
|
continue
|
||||||
|
if 'lastchange' in api['state'] and api['state']['lastchange'] < 1700000000:
|
||||||
|
continue
|
||||||
|
space = SpaceApiSpace(ns,
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
api['location']['lon'],
|
||||||
|
api['location']['lat'],
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
radius)
|
||||||
|
spaces.append(space)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
# Save to cache
|
||||||
|
with open(target, 'w') as f:
|
||||||
|
json.dump([s.to_dict() for s in spaces], f)
|
||||||
|
return spaces
|
||||||
|
|
||||||
|
|
||||||
class BoundingBox:
|
class BoundingBox:
|
||||||
|
|
||||||
def __init__(self, left, top, right=None, bottom=None, *, width=None, height=None, meta=None, base_weight=0):
|
def __init__(self, left, top, right=None, bottom=None, *, width=None, height=None, meta=None, base_weight=0):
|
||||||
|
@ -521,7 +574,7 @@ class BoundingBox:
|
||||||
dv = 0
|
dv = 0
|
||||||
else:
|
else:
|
||||||
dv = min(abs(self.top - other[1]), abs(self.bottom - other[1]))
|
dv = min(abs(self.top - other[1]), abs(self.bottom - other[1]))
|
||||||
return max(dh, dv)
|
return max(dh, dv)
|
||||||
else:
|
else:
|
||||||
raise TypeError()
|
raise TypeError()
|
||||||
|
|
||||||
|
@ -591,7 +644,12 @@ def compute_bbox(ns):
|
||||||
|
|
||||||
print('Computing map bounding box')
|
print('Computing map bounding box')
|
||||||
bounds = []
|
bounds = []
|
||||||
for path in tqdm.tqdm([ns.cache_directory.erfa_info, ns.cache_directory.chaostreff_info]):
|
sources = []
|
||||||
|
if ns.erfa_source == 'doku':
|
||||||
|
sources = [ns.cache_directory.erfa_info, ns.cache_directory.chaostreff_info]
|
||||||
|
elif ns.erfa_source == 'spaceapi':
|
||||||
|
sources = [ns.cache_directory.spaceapi_info]
|
||||||
|
for path in tqdm.tqdm(sources):
|
||||||
erfas = Erfa.from_cache(ns, path, radius=0)
|
erfas = Erfa.from_cache(ns, path, radius=0)
|
||||||
for e in erfas:
|
for e in erfas:
|
||||||
if len(bounds) == 0:
|
if len(bounds) == 0:
|
||||||
|
@ -669,7 +727,7 @@ def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg):
|
||||||
svg.append(di)
|
svg.append(di)
|
||||||
svg.append(dr)
|
svg.append(dr)
|
||||||
|
|
||||||
|
|
||||||
unfinished = {e.city for e in erfas}
|
unfinished = {e.city for e in erfas}
|
||||||
finished = {}
|
finished = {}
|
||||||
|
|
||||||
|
@ -685,7 +743,7 @@ def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg):
|
||||||
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, pdist=max(ns.font_min_distance, xheight), ns=ns)
|
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
|
# 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]
|
||||||
|
@ -694,7 +752,7 @@ def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg):
|
||||||
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['erfa'].city
|
mincity = minbox.meta['erfa'].city
|
||||||
|
@ -762,19 +820,24 @@ def create_svg(ns):
|
||||||
|
|
||||||
# Convert from WGS84 lon, lat to chosen projection
|
# Convert from WGS84 lon, lat to chosen projection
|
||||||
bbox = compute_bbox(ns)
|
bbox = compute_bbox(ns)
|
||||||
svg_box = ns.projection.setup(ns.scale_x, ns.scale_y, bbox=bbox)
|
svg_box = ns.projection.setup(ns.scale_x, ns.scale_y, bbox=bbox, longedge=ns.long_edge)
|
||||||
rectbox = [0, 0, svg_box[0], svg_box[1]]
|
rectbox = [0, 0, svg_box[0], svg_box[1]]
|
||||||
|
|
||||||
# Load everything from cached JSON files
|
# Load everything from cached JSON files
|
||||||
countries = Country.from_cache(ns, ns.cache_directory.shapes_countries)
|
countries = Country.from_cache(ns, ns.cache_directory.shapes_countries)
|
||||||
countries = [c for c in countries if c.filter_boundingbox(bbox)]
|
countries = [c for c in countries if c.filter_boundingbox(bbox)]
|
||||||
states = FederalState.from_cache(ns, ns.cache_directory.shapes_states)
|
states = FederalState.from_cache(ns, ns.cache_directory.shapes_states)
|
||||||
erfas = Erfa.from_cache(ns, ns.cache_directory.erfa_info, ns.dotsize_erfa)
|
erfas = []
|
||||||
chaostreffs = Chaostreff.from_cache(ns, ns.cache_directory.chaostreff_info, ns.dotsize_treff)
|
chaostreffs = []
|
||||||
# There is an edge case where when a space changed states between Erfa and Chaostreff, the
|
if ns.erfa_source == 'doku':
|
||||||
# Semantic MediaWiki engine returns this space as both an Erfa and a Chaostreff, resulting
|
erfas = Erfa.from_cache(ns, ns.cache_directory.erfa_info, ns.dotsize_erfa)
|
||||||
# in glitches in the rendering. As a workaround, here we simply assume that it's an Erfa.
|
chaostreffs = Chaostreff.from_cache(ns, ns.cache_directory.chaostreff_info, ns.dotsize_treff)
|
||||||
chaostreffs = [c for c in chaostreffs if c not 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.
|
||||||
|
chaostreffs = [c for c in chaostreffs if c not in erfas]
|
||||||
|
elif ns.erfa_source == 'spaceapi':
|
||||||
|
erfas = SpaceApiSpace.from_cache(ns, ns.cache_directory.spaceapi_info, ns.dotsize_treff)
|
||||||
|
|
||||||
for c in states + countries:
|
for c in states + countries:
|
||||||
for x, y in c.polygon:
|
for x, y in c.polygon:
|
||||||
|
@ -809,7 +872,7 @@ def create_svg(ns):
|
||||||
with open(ns.stylesheet, 'r') as css:
|
with open(ns.stylesheet, 'r') as css:
|
||||||
style.text = css.read()
|
style.text = css.read()
|
||||||
svg.append(style)
|
svg.append(style)
|
||||||
|
|
||||||
bg = etree.Element('rect',
|
bg = etree.Element('rect',
|
||||||
id='background',
|
id='background',
|
||||||
x=str(rectbox[0]),
|
x=str(rectbox[0]),
|
||||||
|
@ -819,9 +882,12 @@ def create_svg(ns):
|
||||||
bg.set('class', 'background')
|
bg.set('class', 'background')
|
||||||
svg.append(bg)
|
svg.append(bg)
|
||||||
|
|
||||||
# This can take some time, especially if lots of candidates are generated
|
if not ns.without_labels:
|
||||||
print('Layouting labels')
|
# This can take some time, especially if lots of candidates are generated
|
||||||
texts = optimize_text_layout(ns, font, erfas, chaostreffs, width=svg_box[0], svg=svg)
|
print('Layouting labels')
|
||||||
|
texts = optimize_text_layout(ns, font, erfas, chaostreffs, width=svg_box[0], svg=svg)
|
||||||
|
else:
|
||||||
|
texts = {}
|
||||||
|
|
||||||
# Render shortest shapes last s.t. Berlin, Hamburg and Bremen are rendered on top of their surrounding states
|
# 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))
|
states = sorted(states, key=lambda x: -len(x))
|
||||||
|
@ -833,13 +899,22 @@ def create_svg(ns):
|
||||||
for erfa in erfas + chaostreffs:
|
for erfa in erfas + chaostreffs:
|
||||||
erfa.render(svg, texts)
|
erfa.render(svg, texts)
|
||||||
|
|
||||||
|
frame = etree.Element('rect',
|
||||||
|
id='frame',
|
||||||
|
x='0',
|
||||||
|
y='0',
|
||||||
|
width=str(svg_box[0]),
|
||||||
|
height=str(svg_box[1]))
|
||||||
|
frame.set('class', 'frame')
|
||||||
|
svg.append(frame)
|
||||||
|
|
||||||
# Generate SVG, PNG and HTML output files
|
# Generate SVG, PNG and HTML output files
|
||||||
|
|
||||||
print('Writing SVG')
|
print('Writing SVG')
|
||||||
with open(ns.output_directory.svg_path, 'wb') as mapfile:
|
with open(ns.output_directory.svg_path, 'wb') as mapfile:
|
||||||
root = etree.ElementTree(svg)
|
root = etree.ElementTree(svg)
|
||||||
root.write(mapfile)
|
root.write(mapfile)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
print('Writing PNG')
|
print('Writing PNG')
|
||||||
|
@ -885,6 +960,7 @@ def main():
|
||||||
ap.add_argument('--output-directory', type=OutputPaths, default='out', help='Path to the output 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-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('--update-erfalist', action='store_true', default=False, help='Update Erfa/Chaostreff list from doku.ccc.de')
|
||||||
|
ap.add_argument('--erfa-source', choices=['doku', 'spaceapi'], default='doku', help='Data source for the list of Erfas, either doku.ccc.de or spaceapi.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', 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('--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')
|
||||||
|
@ -899,10 +975,13 @@ def main():
|
||||||
ap.add_argument('--projection', type=CoordinateTransform, default='epsg:4258', help='Map projection to convert the WGS84 coordinates to')
|
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-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('--scale-y', type=float, default=200, help='Y axis scale to apply after projecting')
|
||||||
|
ap.add_argument('--long-edge', type=float, default=None, help='Compute scale-x and scale-y based on a set value for the longest edge. Feature sizes are not scaled.')
|
||||||
ap.add_argument('--png-scale', type=float, default=1.0, help='Scale of the PNG image')
|
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')
|
ap.add_argument('--debug', action='store_true', default=False, help='Add debug information to the produced SVG')
|
||||||
|
ap.add_argument('--without-labels', action='store_true', default=False, help='Disable labels placement')
|
||||||
|
|
||||||
ns = ap.parse_args(sys.argv[1:])
|
ns = ap.parse_args(sys.argv[1:])
|
||||||
|
print(ns.long_edge)
|
||||||
|
|
||||||
if ns.update_borders or not os.path.isdir(ns.cache_directory.shapes_countries):
|
if ns.update_borders or not os.path.isdir(ns.cache_directory.shapes_countries):
|
||||||
if os.path.isdir(ns.cache_directory.shapes_countries):
|
if os.path.isdir(ns.cache_directory.shapes_countries):
|
||||||
|
@ -916,17 +995,25 @@ def main():
|
||||||
print('Retrieving state border shapes')
|
print('Retrieving state border shapes')
|
||||||
FederalState.fetch(target=ns.cache_directory.shapes_states)
|
FederalState.fetch(target=ns.cache_directory.shapes_states)
|
||||||
|
|
||||||
if ns.update_erfalist or not os.path.isfile(ns.cache_directory.erfa_info):
|
if ns.erfa_source == 'doku':
|
||||||
if os.path.exists(ns.cache_directory.erfa_info):
|
if ns.update_erfalist or not os.path.isfile(ns.cache_directory.erfa_info):
|
||||||
os.unlink(ns.cache_directory.erfa_info)
|
if os.path.exists(ns.cache_directory.erfa_info):
|
||||||
print('Retrieving Erfas information')
|
os.unlink(ns.cache_directory.erfa_info)
|
||||||
Erfa.fetch(ns, target=ns.cache_directory.erfa_info, radius=ns.dotsize_erfa)
|
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 ns.update_erfalist or not os.path.isfile(ns.cache_directory.chaostreff_info):
|
||||||
if os.path.exists(ns.cache_directory.chaostreff_info):
|
if os.path.exists(ns.cache_directory.chaostreff_info):
|
||||||
os.unlink(ns.cache_directory.chaostreff_info)
|
os.unlink(ns.cache_directory.chaostreff_info)
|
||||||
print('Retrieving Chaostreffs information')
|
print('Retrieving Chaostreffs information')
|
||||||
Chaostreff.fetch(ns, target=ns.cache_directory.chaostreff_info, radius=ns.dotsize_treff)
|
Chaostreff.fetch(ns, target=ns.cache_directory.chaostreff_info, radius=ns.dotsize_treff)
|
||||||
|
|
||||||
|
elif ns.erfa_source == 'spaceapi':
|
||||||
|
if ns.update_erfalist or not os.path.isfile(ns.cache_directory.spaceapi_info):
|
||||||
|
if os.path.exists(ns.cache_directory.spaceapi_info):
|
||||||
|
os.unlink(ns.cache_directory.spaceapi_info)
|
||||||
|
print('Retrieving SpaceAPI information')
|
||||||
|
SpaceApiSpace.fetch(ns, target=ns.cache_directory.spaceapi_info, radius=ns.dotsize_treff)
|
||||||
|
|
||||||
create_svg(ns)
|
create_svg(ns)
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,10 @@ rect.background {
|
||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rect.frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
polygon.country {
|
polygon.country {
|
||||||
fill: #c3c3c3;
|
fill: #c3c3c3;
|
||||||
stroke: #ffffff;
|
stroke: #ffffff;
|
||||||
|
|
67
style/laser.css
Normal file
67
style/laser.css
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
rect.background {
|
||||||
|
fill: white;
|
||||||
|
z-index: -3;
|
||||||
|
}
|
||||||
|
|
||||||
|
rect.frame {
|
||||||
|
fill: none;
|
||||||
|
stroke: black;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
polygon.country {
|
||||||
|
stroke: blue;
|
||||||
|
stroke-width: 1;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
polygon.state {
|
||||||
|
stroke: blue;
|
||||||
|
stroke-width: 1;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle.erfa {
|
||||||
|
z-index: 1;
|
||||||
|
stroke: black;
|
||||||
|
stroke-width: 1;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle.chaostreff {
|
||||||
|
stroke: none;
|
||||||
|
fill: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
text.erfalabel {
|
||||||
|
font-family: "Concert One";
|
||||||
|
font-size: 6px;
|
||||||
|
z-index: 100;
|
||||||
|
user-select: none;
|
||||||
|
fill: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
rect.debugleft {
|
||||||
|
stroke: red;
|
||||||
|
stroke-width: 1;
|
||||||
|
fill: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
rect.debugright {
|
||||||
|
stroke: green;
|
||||||
|
stroke-width: 1;
|
||||||
|
fill: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
Loading…
Reference in a new issue