From d119faa8046e7d1aaba1f365a9399cac7b734d8e Mon Sep 17 00:00:00 2001 From: s3lph Date: Fri, 25 Oct 2024 03:42:21 +0200 Subject: [PATCH] feat: add spaceap directory as an alternative data source, and optimize output for lasercutting --- generate_map.py | 143 +++++++++++++++++++++++++++++++++++++--------- style/erfamap.css | 4 ++ style/laser.css | 67 ++++++++++++++++++++++ 3 files changed, 186 insertions(+), 28 deletions(-) create mode 100644 style/laser.css diff --git a/generate_map.py b/generate_map.py index df4dcc2..14bc124 100755 --- a/generate_map.py +++ b/generate_map.py @@ -37,6 +37,7 @@ class CachePaths: self.shapes_countries = os.path.join(path, 'shapes_countries') self.erfa_info = os.path.join(path, 'erfa-info.json') self.chaostreff_info = os.path.join(path, 'chaostreff-info.json') + self.spaceapi_info = os.path.join(path, 'spaceapi-info.json') class OutputPaths: @@ -63,7 +64,7 @@ class CoordinateTransform: self._proj = pyproj.Transformer.from_crs('epsg:4326', projection) 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._scaley = scaley west = min(bbox[0][0], bbox[1][0]) @@ -71,6 +72,17 @@ class CoordinateTransform: east = max(bbox[0][0], bbox[1][0]) south = min(bbox[0][1], bbox[1][1]) 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) def __call__(self, lon, lat): @@ -413,6 +425,47 @@ class Chaostreff(Erfa): 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: 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 else: dv = min(abs(self.top - other[1]), abs(self.bottom - other[1])) - return max(dh, dv) + return max(dh, dv) else: raise TypeError() @@ -591,7 +644,12 @@ def compute_bbox(ns): print('Computing map bounding box') 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) for e in erfas: if len(bounds) == 0: @@ -669,7 +727,7 @@ def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg): svg.append(di) svg.append(dr) - + unfinished = {e.city for e in erfas} finished = {} @@ -685,7 +743,7 @@ def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg): 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] @@ -694,7 +752,7 @@ def optimize_text_layout(ns, font, erfas, chaostreffs, width, svg): 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['erfa'].city @@ -762,19 +820,24 @@ def create_svg(ns): # Convert from WGS84 lon, lat to chosen projection 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]] # Load everything from cached JSON files countries = Country.from_cache(ns, ns.cache_directory.shapes_countries) countries = [c for c in countries if c.filter_boundingbox(bbox)] states = FederalState.from_cache(ns, ns.cache_directory.shapes_states) - erfas = Erfa.from_cache(ns, ns.cache_directory.erfa_info, ns.dotsize_erfa) - chaostreffs = Chaostreff.from_cache(ns, ns.cache_directory.chaostreff_info, ns.dotsize_treff) - # 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] + erfas = [] + chaostreffs = [] + if ns.erfa_source == 'doku': + erfas = Erfa.from_cache(ns, ns.cache_directory.erfa_info, ns.dotsize_erfa) + chaostreffs = Chaostreff.from_cache(ns, ns.cache_directory.chaostreff_info, ns.dotsize_treff) + # 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 x, y in c.polygon: @@ -809,7 +872,7 @@ def create_svg(ns): with open(ns.stylesheet, 'r') as css: style.text = css.read() svg.append(style) - + bg = etree.Element('rect', id='background', x=str(rectbox[0]), @@ -819,9 +882,12 @@ def create_svg(ns): bg.set('class', 'background') svg.append(bg) - # This can take some time, especially if lots of candidates are generated - print('Layouting labels') - texts = optimize_text_layout(ns, font, erfas, chaostreffs, width=svg_box[0], svg=svg) + if not ns.without_labels: + # This can take some time, especially if lots of candidates are generated + 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 states = sorted(states, key=lambda x: -len(x)) @@ -833,13 +899,22 @@ def create_svg(ns): for erfa in erfas + chaostreffs: 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 print('Writing SVG') with open(ns.output_directory.svg_path, 'wb') as mapfile: root = etree.ElementTree(svg) root.write(mapfile) - + 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('--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('--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-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') @@ -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('--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('--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('--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:]) + print(ns.long_edge) if ns.update_borders or not 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') FederalState.fetch(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) - print('Retrieving Erfas information') - Erfa.fetch(ns, target=ns.cache_directory.erfa_info, radius=ns.dotsize_erfa) + if ns.erfa_source == 'doku': + 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) + 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 os.path.exists(ns.cache_directory.chaostreff_info): - os.unlink(ns.cache_directory.chaostreff_info) - print('Retrieving Chaostreffs information') - Chaostreff.fetch(ns, target=ns.cache_directory.chaostreff_info, radius=ns.dotsize_treff) + 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) + print('Retrieving Chaostreffs information') + 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) diff --git a/style/erfamap.css b/style/erfamap.css index aff96de..1c3340e 100644 --- a/style/erfamap.css +++ b/style/erfamap.css @@ -13,6 +13,10 @@ rect.background { stroke: none; } +rect.frame { + display: none; +} + polygon.country { fill: #c3c3c3; stroke: #ffffff; diff --git a/style/laser.css b/style/laser.css new file mode 100644 index 0000000..5e4e1e3 --- /dev/null +++ b/style/laser.css @@ -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; +}