From 1b2032232b03638256e5bf9226b7d54b3f96fb77 Mon Sep 17 00:00:00 2001 From: napakalas Date: Wed, 15 Apr 2026 12:39:27 +1200 Subject: [PATCH 1/4] Add options to set path minimum coverage and path maximum coverage (#191). --- mapmaker/__main__.py | 4 ++++ mapmaker/maker.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/mapmaker/__main__.py b/mapmaker/__main__.py index cee64534..d5a9ad4f 100644 --- a/mapmaker/__main__.py +++ b/mapmaker/__main__.py @@ -91,6 +91,10 @@ def arg_parser(): help='Maximum zoom level (defaults to 10)') zoom_options.add_argument('--max-raster-zoom', dest='maxRasterZoom', metavar='N', type=int, help='Maximum zoom level of rasterised tiles (defaults to maximum zoom level)') + zoom_options.add_argument('--path-min-coverage', dest='pathMinCoverage', metavar='R', type=float, default=0.7, + help='Lower coverage factor used to derive path minzoom (defaults to 0.7)') + zoom_options.add_argument('--path-max-coverage', dest='pathMaxCoverage', metavar='R', type=float, default=5.0, + help='Upper coverage factor used to derive path maxzoom (defaults to 5.0)') misc_options = parser.add_argument_group('Miscellaneous') misc_options.add_argument('--commit', metavar='GIT_COMMIT', diff --git a/mapmaker/maker.py b/mapmaker/maker.py index b115b60c..63e8539d 100644 --- a/mapmaker/maker.py +++ b/mapmaker/maker.py @@ -121,6 +121,14 @@ def __init__(self, options: dict[str, Any], logger_port: Optional[int]=None, raise ValueError(f'Max raster zoom cannot be greater than max zoom ({max_zoom})') if initial_zoom < min_zoom or initial_zoom > max_zoom: raise ValueError(f'Initial zoom cannot be greater than max zoom ({max_zoom})') + + path_min_coverage = options.get('pathMinCoverage', 0.7) + path_max_coverage = options.get('pathMaxCoverage', 5.0) + if path_min_coverage <= 0 or path_max_coverage <= 0: + raise ValueError('Path coverage values must be greater than 0') + if path_max_coverage < path_min_coverage: + raise ValueError('Path max coverage cannot be less than path min coverage') + self.__zoom = (min_zoom, max_zoom, initial_zoom) if options.get('publish'): From cbe98e4b7ca5e9a646a0adfc7024e139dd0bc8f1 Mon Sep 17 00:00:00 2001 From: napakalas Date: Wed, 15 Apr 2026 12:41:36 +1200 Subject: [PATCH 2/4] Calculate and assign minzoom and maxzoom values for path properties (#191). --- mapmaker/output/geojson.py | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/mapmaker/output/geojson.py b/mapmaker/output/geojson.py index 1d408156..71531893 100644 --- a/mapmaker/output/geojson.py +++ b/mapmaker/output/geojson.py @@ -39,6 +39,11 @@ #=============================================================================== +# Earth circumference (WGS84 equatorial radius for Web Mercator EPSG:3857) +EARTH_CIRCUMFERENCE = 2 * math.pi * 6378137.0 # meters + +#=============================================================================== + class GeoJSONOutput(object): def __init__(self, flatmap: FlatMap, layer: MapLayer, output_dir: str): #====================================================================== @@ -120,6 +125,10 @@ def __save_features(self, features): if scale > 6 and 'group' not in properties and 'minzoom' not in properties: geojson['tippecanoe']['minzoom'] = 4 else: + if (nodes:=self.__flatmap.connectivity()['paths'].get(feature.models)): + zoom_range = self.__get_path_zoom_range(nodes) + geojson['properties']['minzoom'] = zoom_range[0] + geojson['properties']['maxzoom'] = zoom_range[1] geojson['properties']['scale'] = 10 geojson['properties'].update(properties) @@ -158,3 +167,43 @@ def __save_features(self, features): progress_bar.update(1) progress_bar.close() + + def __get_path_zoom_range(self, nodes): + path_min_coverage = settings.get('pathMinCoverage', 0.7) + path_max_coverage = settings.get('pathMaxCoverage', 5.0) + + points = [geom.centroid + for node in nodes.get('nodes', []) + if (f := self.__flatmap.get_feature_by_geojson_id(node)) is not None + and (geom := f.geometry) is not None] + + if len(points) > 1: + # extent + xs = [p.x for p in points] + ys = [p.y for p in points] + # World-space extents (meters, EPSG:3857) + x_extent = max(xs) - min(xs) + y_extent = max(ys) - min(ys) + max_dist = 0.0 + for i, p1 in enumerate(points): + for p2 in points[i+1:]: + dist = math.hypot(p2.x - p1.x, p2.y - p1.y) + if dist > max_dist: + max_dist = dist + extent = max(x_extent, y_extent, max_dist) + if not math.isfinite(extent) or extent <= 0: + return self.__flatmap.min_zoom, self.__flatmap.max_zoom + + # minzoom and maxzoom + z_min = math.ceil(math.log2((path_min_coverage * EARTH_CIRCUMFERENCE) / extent)) + z_max = math.floor(math.log2((path_max_coverage * EARTH_CIRCUMFERENCE) / extent)) + if z_max < z_min: + z_max = z_min + z_min = max(self.__flatmap.min_zoom, z_min) + z_max = min(self.__flatmap.max_zoom, z_max) + if z_min >= self.__flatmap.max_zoom: + z_min = self.__flatmap.max_zoom - 1 + z_max = self.__flatmap.max_zoom + return z_min, z_max + + return self.__flatmap.min_zoom, self.__flatmap.max_zoom From df8927a739b55775de9e3b86d6feb123fdfb789d Mon Sep 17 00:00:00 2001 From: napakalas Date: Mon, 18 May 2026 11:28:33 +1200 Subject: [PATCH 3/4] Gate path zoom range by manifest enablePathZoomRange (#191). --- docs/Manifest.rst | 1 + mapmaker/flatmap/manifest.py | 4 ++++ mapmaker/output/geojson.py | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/Manifest.rst b/docs/Manifest.rst index b69739b7..d45e829b 100644 --- a/docs/Manifest.rst +++ b/docs/Manifest.rst @@ -15,6 +15,7 @@ It MAY optionally have: * a taxon identifier for what the flatmap ``"models"``. * the ``"biological-sex"`` of what the flatmap represents. * a ``"description"`` JSON file specifying a description of the map as a SPARC dataset. +* ``"enablePathZoomRange"`` to enable path-based minzoom/maxzoom calculation. * the ``"kind"`` of map to generate. Allowable values are ``"anatomical"`` (the default) or ``"functional"``. * a ``"properties"`` JSON file specifying properties of features. diff --git a/mapmaker/flatmap/manifest.py b/mapmaker/flatmap/manifest.py index b80a6466..a3f95b27 100644 --- a/mapmaker/flatmap/manifest.py +++ b/mapmaker/flatmap/manifest.py @@ -378,6 +378,10 @@ def connectivity_terms(self): def description(self): return self.__manifest.get('description') + @property + def enable_path_zoom_range(self) -> bool: + return bool(self.__manifest.get('enablePathZoomRange', False)) + @property def exported_properties(self) -> list[str]: return self.__manifest.get('exported-properties', []) diff --git a/mapmaker/output/geojson.py b/mapmaker/output/geojson.py index 71531893..2f865fac 100644 --- a/mapmaker/output/geojson.py +++ b/mapmaker/output/geojson.py @@ -125,7 +125,8 @@ def __save_features(self, features): if scale > 6 and 'group' not in properties and 'minzoom' not in properties: geojson['tippecanoe']['minzoom'] = 4 else: - if (nodes:=self.__flatmap.connectivity()['paths'].get(feature.models)): + if (self.__flatmap.manifest.enable_path_zoom_range + and (nodes:=self.__flatmap.connectivity()['paths'].get(feature.models))): zoom_range = self.__get_path_zoom_range(nodes) geojson['properties']['minzoom'] = zoom_range[0] geojson['properties']['maxzoom'] = zoom_range[1] From 4cfd4b56f7453394e3d0a65d89f7f78281fc5650 Mon Sep 17 00:00:00 2001 From: napakalas Date: Mon, 8 Jun 2026 11:06:27 +1200 Subject: [PATCH 4/4] Move zoom configuration to manifest and consume it consistently (#191). --- docs/Manifest.rst | 7 +++++- mapmaker/__main__.py | 12 ---------- mapmaker/flatmap/manifest.py | 24 ++++++++++++++++++-- mapmaker/maker.py | 44 ++++++++++++++++++------------------ mapmaker/output/geojson.py | 6 ++--- 5 files changed, 53 insertions(+), 40 deletions(-) diff --git a/docs/Manifest.rst b/docs/Manifest.rst index d45e829b..b21041a4 100644 --- a/docs/Manifest.rst +++ b/docs/Manifest.rst @@ -15,7 +15,6 @@ It MAY optionally have: * a taxon identifier for what the flatmap ``"models"``. * the ``"biological-sex"`` of what the flatmap represents. * a ``"description"`` JSON file specifying a description of the map as a SPARC dataset. -* ``"enablePathZoomRange"`` to enable path-based minzoom/maxzoom calculation. * the ``"kind"`` of map to generate. Allowable values are ``"anatomical"`` (the default) or ``"functional"``. * a ``"properties"`` JSON file specifying properties of features. @@ -27,6 +26,12 @@ It MAY optionally have: identifiers to features. These are additional to any assigned by the ``properties`` file. * for functional connectivity maps, an ``"annotation"`` JSON file assigning anatomical terms to features based on their label and anatomical type (System, Organ, FTU). +* ``"path-zoom-range"`` to enable path-based minzoom/maxzoom calculation (default ``true``). +* ``"initial-zoom"`` for the initial zoom level (default ``4``). +* ``"max-zoom"`` for the maximum zoom level (default ``10``). +* ``"max-raster-zoom"`` for the maximum zoom level for raster tiles (defaults to ``"max-zoom"``). +* ``"path-min-coverage"`` for path zoom range calculation lower coverage bound (default ``0.7``). +* ``"path-max-coverage"`` for path zoom range calculation upper coverage bound (default ``5.0``). * a ``"connectivityTerms"`` JSON file specifying equvalences between historical anatomical terms used in SCKAN to standard terms (e.g. between FMA and ILX identifiers). **DEPRECATED** * a ``"connectivity"`` JSON file specifying manually defined neuron paths. **DEPRECATED** diff --git a/mapmaker/__main__.py b/mapmaker/__main__.py index d5a9ad4f..a7f1130c 100644 --- a/mapmaker/__main__.py +++ b/mapmaker/__main__.py @@ -84,18 +84,6 @@ def arg_parser(): debug_options.add_argument('--tippecanoe', dest='showTippe', action='store_true', help='Show command used to run Tippecanoe') - zoom_options = parser.add_argument_group('Zoom level') - zoom_options.add_argument('--initial-zoom', dest='initialZoom', metavar='N', type=int, default=4, - help='Initial zoom level (defaults to 4)') - zoom_options.add_argument('--max-zoom', dest='maxZoom', metavar='N', type=int, default=10, - help='Maximum zoom level (defaults to 10)') - zoom_options.add_argument('--max-raster-zoom', dest='maxRasterZoom', metavar='N', type=int, - help='Maximum zoom level of rasterised tiles (defaults to maximum zoom level)') - zoom_options.add_argument('--path-min-coverage', dest='pathMinCoverage', metavar='R', type=float, default=0.7, - help='Lower coverage factor used to derive path minzoom (defaults to 0.7)') - zoom_options.add_argument('--path-max-coverage', dest='pathMaxCoverage', metavar='R', type=float, default=5.0, - help='Upper coverage factor used to derive path maxzoom (defaults to 5.0)') - misc_options = parser.add_argument_group('Miscellaneous') misc_options.add_argument('--commit', metavar='GIT_COMMIT', help='The branch/tag/commit to use when the source is a Git repository') diff --git a/mapmaker/flatmap/manifest.py b/mapmaker/flatmap/manifest.py index a3f95b27..1c53526c 100644 --- a/mapmaker/flatmap/manifest.py +++ b/mapmaker/flatmap/manifest.py @@ -379,8 +379,28 @@ def description(self): return self.__manifest.get('description') @property - def enable_path_zoom_range(self) -> bool: - return bool(self.__manifest.get('enablePathZoomRange', False)) + def path_zoom_range(self) -> bool: + return bool(self.__manifest.get('path-zoom-range', True)) + + @property + def initial_zoom(self): + return self.__manifest.get('initial-zoom', 4) + + @property + def max_zoom(self): + return self.__manifest.get('max-zoom', 10) + + @property + def max_raster_zoom(self): + return self.__manifest.get('max-raster-zoom', self.max_zoom) + + @property + def path_min_coverage(self): + return self.__manifest.get('path-min-coverage', 0.7) + + @property + def path_max_coverage(self): + return self.__manifest.get('path-max-coverage', 5.0) @property def exported_properties(self) -> list[str]: diff --git a/mapmaker/maker.py b/mapmaker/maker.py index 63e8539d..4fcf28c6 100644 --- a/mapmaker/maker.py +++ b/mapmaker/maker.py @@ -109,28 +109,6 @@ def __init__(self, options: dict[str, Any], logger_port: Optional[int]=None, if 'output' not in options: options['output'] = './flatmaps' - # Check zoom settings are valid - min_zoom = 0 - max_zoom = options.get('maxZoom', 10) - max_raster_zoom = options.get('maxRasterZoom', max_zoom) - - initial_zoom = options.get('initialZoom', 4) - if max_zoom < min_zoom or max_zoom > 15: - raise ValueError('Max zoom must be between {} and 15'.format(min_zoom)) - if max_raster_zoom > max_zoom: - raise ValueError(f'Max raster zoom cannot be greater than max zoom ({max_zoom})') - if initial_zoom < min_zoom or initial_zoom > max_zoom: - raise ValueError(f'Initial zoom cannot be greater than max zoom ({max_zoom})') - - path_min_coverage = options.get('pathMinCoverage', 0.7) - path_max_coverage = options.get('pathMaxCoverage', 5.0) - if path_min_coverage <= 0 or path_max_coverage <= 0: - raise ValueError('Path coverage values must be greater than 0') - if path_max_coverage < path_min_coverage: - raise ValueError('Path max coverage cannot be less than path min coverage') - - self.__zoom = (min_zoom, max_zoom, initial_zoom) - if options.get('publish'): # Check the given options are compatible with SDS publishing errors = False @@ -161,6 +139,28 @@ def __init__(self, options: dict[str, Any], logger_port: Optional[int]=None, if self.__id is None: raise ValueError('No id given for map') + # Check zoom settings are valid + min_zoom = 0 + max_zoom = self.__manifest.max_zoom + max_raster_zoom = self.__manifest.max_raster_zoom + + initial_zoom = self.__manifest.initial_zoom + if max_zoom < min_zoom or max_zoom > 15: + raise ValueError('Max zoom must be between {} and 15'.format(min_zoom)) + if max_raster_zoom > max_zoom: + raise ValueError(f'Max raster zoom cannot be greater than max zoom ({max_zoom})') + if initial_zoom < min_zoom or initial_zoom > max_zoom: + raise ValueError(f'Initial zoom cannot be greater than max zoom ({max_zoom})') + + path_min_coverage = self.__manifest.path_min_coverage + path_max_coverage = self.__manifest.path_max_coverage + if path_min_coverage <= 0 or path_max_coverage <= 0: + raise ValueError('Path coverage values must be greater than 0') + if path_max_coverage < path_min_coverage: + raise ValueError('Path max coverage cannot be less than path min coverage') + + self.__zoom = (min_zoom, max_zoom, initial_zoom) + # Publishing requires a ``description.json`` if options.get('publish') and self.__manifest.description is None: raise ValueError('The manifest must specify a JSON `description` file if publishing') diff --git a/mapmaker/output/geojson.py b/mapmaker/output/geojson.py index 2f865fac..44fa7d19 100644 --- a/mapmaker/output/geojson.py +++ b/mapmaker/output/geojson.py @@ -125,7 +125,7 @@ def __save_features(self, features): if scale > 6 and 'group' not in properties and 'minzoom' not in properties: geojson['tippecanoe']['minzoom'] = 4 else: - if (self.__flatmap.manifest.enable_path_zoom_range + if (self.__flatmap.manifest.path_zoom_range and (nodes:=self.__flatmap.connectivity()['paths'].get(feature.models))): zoom_range = self.__get_path_zoom_range(nodes) geojson['properties']['minzoom'] = zoom_range[0] @@ -170,8 +170,8 @@ def __save_features(self, features): progress_bar.close() def __get_path_zoom_range(self, nodes): - path_min_coverage = settings.get('pathMinCoverage', 0.7) - path_max_coverage = settings.get('pathMaxCoverage', 5.0) + path_min_coverage = self.__flatmap.manifest.path_min_coverage + path_max_coverage = self.__flatmap.manifest.path_max_coverage points = [geom.centroid for node in nodes.get('nodes', [])