diff --git a/DEPLOY.rst b/DEPLOY.rst
deleted file mode 100644
index 7d583877..00000000
--- a/DEPLOY.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-==========
-Deployment
-==========
-
-Put map directory into `~/flatmap-server/flatmaps` on `ubuntu@34.209.7.109`
-
-::
-
- $ scp MAP.tar.gz ubuntu@34.209.7.109:
- $ ssh ubuntu@34.209.7.109
- $ cd ~/flatmap-server/flatmaps
- $ tar xzf ~/MAP.tar.gz
- $ rm ~/MAP.tar.gz
- $ ^D
\ No newline at end of file
diff --git a/README.rst b/README.rst
index 492a54db..1ed6cb4c 100644
--- a/README.rst
+++ b/README.rst
@@ -113,8 +113,8 @@ Command line help
[--authoring] [--debug]
[--only-networks] [--save-drawml] [--save-geojson] [--tippecanoe]
[--initial-zoom N] [--max-zoom N]
- [--export-features EXPORT_FILE] [--export-neurons EXPORT_FILE] [--export-svg EXPORT_FILE]
- [--single-file {celldl,svg}]
+ [--export-bondgraphs] [--export-features EXPORT_FILE] [--export-neurons EXPORT_FILE]
+ [--export-svg EXPORT_FILE] [--single-file {celldl,svg}]
--output OUTPUT --source SOURCE
Generate a flatmap from its source manifest.
@@ -163,6 +163,8 @@ Command line help
--max-zoom N Maximum zoom level (defaults to 10)
Miscellaneous:
+ --export-bondgraphs
+ Export functional modelling components as CellDL bondgraphs
--export-features EXPORT_FILE
Export identifiers and anatomical terms of labelled features as JSON
--export-neurons EXPORT_FILE
diff --git a/mapmaker/__init__.py b/mapmaker/__init__.py
index 9aa474f0..f8e04d5d 100644
--- a/mapmaker/__init__.py
+++ b/mapmaker/__init__.py
@@ -31,6 +31,9 @@
MIN_ZOOM = 2 #: Default minimum zoom level for generated flatmaps
MAX_ZOOM = 10 #: Default maximum zoom level for generated flatmaps
+# For showing rasterised image of detailed map on its base map before the details show
+ZOOM_OFFSET_FROM_BASE = 2
+
#===============================================================================
from .maker import MapMaker
diff --git a/mapmaker/__main__.py b/mapmaker/__main__.py
index 0d1a62d9..6da9569f 100644
--- a/mapmaker/__main__.py
+++ b/mapmaker/__main__.py
@@ -34,7 +34,7 @@ def arg_parser():
log_options = parser.add_argument_group('Logging')
log_options.add_argument('--log', dest='logFile', metavar='LOG_FILE',
- help="Append messages to a log file")
+ help="Append messages to a log file as JSON")
log_options.add_argument('--silent', action='store_true',
help='Suppress all messages to screen')
log_options.add_argument('--verbose', action='store_true',
@@ -93,6 +93,8 @@ def arg_parser():
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')
+ misc_options.add_argument('--export-bondgraphs', dest='exportBondgraphs', action='store_true',
+ help='Export functional modelling components as CellDL bondgraphs')
misc_options.add_argument('--export-features', dest='exportFeatures', metavar='EXPORT_FILE',
help='Export identifiers and anatomical terms of labelled features as JSON')
misc_options.add_argument('--export-neurons', dest='exportNeurons', metavar='EXPORT_FILE',
diff --git a/mapmaker/annotation/__init__.py b/mapmaker/annotation/__init__.py
index 9546fc10..919eedb0 100644
--- a/mapmaker/annotation/__init__.py
+++ b/mapmaker/annotation/__init__.py
@@ -26,7 +26,7 @@
from mapmaker.knowledgebase.celldl import FC_CLASS
from mapmaker.knowledgebase.sckan import PATH_TYPE
from mapmaker.shapes import Shape
-from mapmaker.sources.fc_powerpoint.components import is_connector
+from mapmaker.shapes.types import is_connector
from mapmaker.utils import log
from .json_annotations import JsonAnnotations
diff --git a/mapmaker/flatmap/__init__.py b/mapmaker/flatmap/__init__.py
index 8e07dfd2..7424a482 100644
--- a/mapmaker/flatmap/__init__.py
+++ b/mapmaker/flatmap/__init__.py
@@ -18,7 +18,7 @@
#
#===============================================================================
-from collections import OrderedDict
+from collections import defaultdict, OrderedDict
from datetime import datetime, timezone
import os
from typing import Optional, TYPE_CHECKING
@@ -45,7 +45,7 @@
from .layers import FEATURES_TILE_LAYER, MapLayer
# Exports
-from .manifest import Manifest, SourceManifest
+from .manifest import Manifest, SourceBackground, SourceManifest
if TYPE_CHECKING:
from mapmaker.annotation import Annotator
@@ -63,9 +63,7 @@ def __init__(self, manifest: Manifest, maker: 'MapMaker', annotator: Optional['A
self.__id = maker.id
self.__uuid = maker.uuid
self.__map_dir = maker.map_dir
- self.__map_kind = (MAP_KIND.FUNCTIONAL if manifest.kind == 'functional'
- else MAP_KIND.CENTRELINE if manifest.kind == 'centreline'
- else MAP_KIND.ANATOMICAL)
+ self.__map_kind = manifest.map_kind
self.__manifest = manifest
self.__local_id = manifest.id
self.__models = manifest.models
@@ -195,6 +193,13 @@ def initialise(self):
knowledge = get_knowledge(self.__models)
if 'label' in knowledge:
self.__metadata['describes'] = knowledge['label']
+ if self.map_kind == MAP_KIND.FUNCTIONAL:
+ self.__metadata['style'] = 'functional'
+ elif self.map_kind == MAP_KIND.CENTRELINE:
+ self.__metadata['style'] = 'centreline'
+ else:
+ self.__metadata['style'] = 'anatomical'
+ self.__metadata['map-kinds'] = self.__manifest.map_kinds
self.__entities = set()
@@ -211,6 +216,7 @@ def initialise(self):
self.__features_with_name: dict[str, Feature] = {}
self.__last_geojson_id = 0
self.__features_by_geojson_id: dict[int, Feature] = {}
+ self.__associated_layers: defaultdict[str, list[int]] = defaultdict(list)
# Used to find annotated features containing a region
self.__feature_search = None
@@ -272,11 +278,11 @@ def has_feature(self, feature_id: str) -> bool:
def get_feature(self, feature_id: str) -> Optional[Feature]:
#===========================================================
- return self.__features_with_id.get(feature_id)
-
- def get_feature_by_name(self, full_name: str) -> Optional[Feature]:
- #==================================================================
- return self.__features_with_name.get(full_name.replace(" ", "_"))
+ if self.map_kind == MAP_KIND.FUNCTIONAL:
+ return self.__features_with_name.get(feature_id.replace(" ", "_"),
+ self.__features_with_id.get(feature_id))
+ else:
+ return self.__features_with_id.get(feature_id)
def get_feature_by_geojson_id(self, geojson_id: int) -> Optional[Feature]:
#=========================================================================
@@ -285,17 +291,21 @@ def get_feature_by_geojson_id(self, geojson_id: int) -> Optional[Feature]:
def new_feature(self, layer_id: str, geometry, properties, is_group=False) -> Feature:
#=====================================================================================
self.__last_geojson_id += 1
+ properties['layer'] = layer_id
self.properties_store.update_properties(properties) # Update from JSON properties file
feature = Feature(self.__last_geojson_id, geometry, properties, is_group=is_group)
- feature.set_property('layer', layer_id)
- if (name := properties.get('name', properties.get('label', ''))) != '':
- self.__features_with_name[f'{layer_id}/{name.replace(" ", "_")}'] = feature
self.__features_by_geojson_id[feature.geojson_id] = feature
if feature.id:
if feature.id in self.__features_with_id:
pass
else:
self.__features_with_id[feature.id] = feature
+ if self.map_kind == MAP_KIND.FUNCTIONAL:
+ if (name := properties.get('name', '')) != '':
+ self.__features_with_name[f'{layer_id}/{name.replace(" ", "_")}'] = feature
+ if (associated_layers := properties.get('associated-details')) is not None:
+ for layer in associated_layers:
+ self.__associated_layers[layer].append(feature.geojson_id)
return feature
def network_feature(self, feature: Feature) -> bool:
@@ -364,7 +374,7 @@ def add_source_layers(self, layer_number: int, source: 'MapSource'):
for layer in source.layers:
self.add_layer(layer)
if layer.exported:
- layer.add_raster_layer(layer.id, source.extent, source)
+ layer.add_raster_layers(source.extent, source)
# The first layer is used as the base map
if layer_number == 0:
if source.kind == 'details':
@@ -377,6 +387,38 @@ def add_source_layers(self, layer_number: int, source: 'MapSource'):
and source.kind not in SOURCE_DETAIL_KINDS):
raise ValueError('Can only have a single base map')
+ """
+ The ``feature`` has details that appear when zoomed.
+ """
+ def add_details_layer(self, feature: Feature, details_layer: str, description: Optional[str]=None) -> Optional[int]:
+ #===================================================================================================================
+ if feature.layer is not None:
+ feature.set_property('details-layer', details_layer)
+ zoom_point = self.add_zoom_point(feature, description)
+ if zoom_point is not None:
+ zoom_point.set_property('details-layer', details_layer)
+ # Set the ``associated-details`` property for connections to features associated with the details layer
+ for geojson_id in self.__associated_layers.get(details_layer, []):
+ if (associated_feature := self.get_feature_by_geojson_id(geojson_id)) is not None:
+ for connection_id in associated_feature.get_property('connections', []):
+ if (connection := self.get_feature(connection_id)) is not None:
+ connection.append_property('associated-details', details_layer)
+ return zoom_point.geojson_id if zoom_point else None
+
+ def add_zoom_point(self, feature: Feature, description: Optional[str]=None) -> Optional[Feature]:
+ #================================================================================================
+ if feature.layer is not None:
+ zoom_point = self.new_feature(feature.properties['layer'], feature.geometry.centroid, {
+ 'kind': 'zoom-point',
+ 'tile-layer': feature.properties['tile-layer']
+ })
+ if description is not None:
+ zoom_point.set_property('label', description)
+ if (models := feature.models) is not None:
+ zoom_point.set_property('models', models)
+ feature.layer.add_feature(zoom_point)
+ return zoom_point
+
def layer_metadata(self):
#========================
metadata = []
@@ -390,7 +432,9 @@ def layer_metadata(self):
{ 'id': raster_layer.id,
'options': {
'max-zoom': raster_layer.max_zoom,
- 'min-zoom': raster_layer.min_zoom
+ 'min-zoom': raster_layer.min_zoom,
+ 'background': raster_layer.background_layer,
+ 'detail-layer': layer.detail_layer
}
} for raster_layer in layer.raster_layers
]
@@ -399,6 +443,10 @@ def layer_metadata(self):
map_layer['min-zoom'] = layer.min_zoom
if layer.max_zoom is not None:
map_layer['max-zoom'] = layer.max_zoom
+ if layer.parent_layer is not None:
+ map_layer['extent'] = layer.extent
+ map_layer['parent-layer'] = layer.parent_layer
+ map_layer['zoom-point'] = layer.zoom_point_id
metadata.append(map_layer)
return metadata
@@ -482,11 +530,11 @@ def __add_detail_features(self, layer, detail_layer, lowres_features):
else: # nerve
feature.pop_property('maxzoom')
- if hires_layer.source.raster_source is not None:
+ if len(hires_layer.source.raster_sources):
extent = transform.transform_extent(hires_layer.source.extent)
- layer.add_raster_layer('{}_{}'.format(detail_layer.id, hires_layer.id),
- extent, hires_layer.source, minzoom,
- local_world_to_base=transform)
+ layer.add_raster_layers(extent, hires_layer.source,
+ id=f'{detail_layer.id}_{hires_layer.id}',
+ min_zoom=minzoom, local_world_to_base=transform)
# The detail layer gets a scaled copy of each high-resolution feature
for hires_feature in hires_layer.features:
diff --git a/mapmaker/flatmap/layers.py b/mapmaker/flatmap/layers.py
index e0ddf786..93960bf3 100644
--- a/mapmaker/flatmap/layers.py
+++ b/mapmaker/flatmap/layers.py
@@ -29,16 +29,17 @@
#===============================================================================
-from mapmaker import MIN_ZOOM
+from mapmaker import ZOOM_OFFSET_FROM_BASE
from mapmaker.exceptions import GroupValueError
-from mapmaker.geometry import connect_dividers, extend_line, make_boundary
-from mapmaker.geometry import save_geometry
-from mapmaker.settings import settings
-from mapmaker.utils import log
+from mapmaker.geometry import bounds_to_extent, connect_dividers, extend_line, make_boundary
+from mapmaker.geometry import bounds_centroid, MapBounds, MapExtent, merge_bounds, translate_extent
+from mapmaker.geometry import save_geometry, Transform
+from mapmaker.settings import MAP_KIND, settings
+from mapmaker.utils import FilePath, log
if TYPE_CHECKING:
- from mapmaker.sources import MapSource
- from . import FlatMap
+ from mapmaker.sources import MapSource, RasterSource
+ from . import FlatMap, SourceBackground
from .feature import Feature
@@ -98,6 +99,14 @@ def max_zoom(self) -> Optional[int]:
def min_zoom(self) -> Optional[int]:
return None
+ @property
+ def offset(self) -> tuple[float, float]:
+ return (0.0, 0.0)
+
+ @property
+ def parent_layer(self) -> Optional[str]:
+ return None
+
@property
def raster_layers(self) -> list['RasterLayer']:
return []
@@ -142,17 +151,18 @@ def __init__(self, id: str, source: 'MapSource', exported=False, min_zoom: Optio
self.__raster_layers: list[RasterLayer] = []
self.__min_zoom = min_zoom if min_zoom is not None else source.min_zoom
self.__max_zoom = source.max_zoom
+ self.__offset = (0.0, 0.0)
+ self.__zoom_point_id = None
@property
- def boundary_feature(self):
+ def boundary_feature(self) -> Optional[Feature]:
return self.__boundary_feature
-
@boundary_feature.setter
- def boundary_feature(self, value):
+ def boundary_feature(self, value: Feature):
self.__boundary_feature = value
@property
- def bounds(self) -> tuple[float, float, float, float]:
+ def bounds(self) -> MapBounds:
return self.__bounds
@property
@@ -164,10 +174,14 @@ def detail_layer(self) -> bool:
return (self.__source.base_feature is not None)
@property
- def max_zoom(self) -> Optional[int]:
+ def extent(self) -> MapExtent:
+ return bounds_to_extent(self.__bounds)
+
+ @property
+ def max_zoom(self) -> int:
return self.__max_zoom
@max_zoom.setter
- def max_zoom(self, zoom):
+ def max_zoom(self, zoom: int):
self.__max_zoom = zoom
@property
@@ -178,6 +192,12 @@ def min_zoom(self) -> int:
def outer_geometry(self) -> BaseGeometry:
return self.__outer_geometry
+ @property
+ def parent_layer(self) -> Optional[str]:
+ if (base_feature := self.__source.base_feature) is not None:
+ return base_feature.get_property('layer')
+ return None
+
@property
def raster_layers(self) -> list['RasterLayer']:
return self.__raster_layers
@@ -186,6 +206,10 @@ def raster_layers(self) -> list['RasterLayer']:
def source(self) -> 'MapSource':
return self.__source
+ @property
+ def zoom_point_id(self) -> Optional[int]:
+ return self.source.zoom_point_id
+
def add_feature(self, feature: Feature): # type: ignore
#=======================================
if self.__min_zoom is not None and not feature.has_property('minzoom'):
@@ -193,21 +217,79 @@ def add_feature(self, feature: Feature): # type: ignore
super().add_feature(feature, map_layer=self)
if feature.has_property('details'):
self.__detail_features.append(feature)
-
- def add_raster_layer(self, layer_id: str, extent, map_source: 'MapSource', min_zoom: Optional[int]=None, local_world_to_base=None):
- #==================================================================================================================================
- if map_source.raster_source is not None:
- if min_zoom is not None:
- min_zoom += 1
+ if self.flatmap.map_kind == MAP_KIND.FUNCTIONAL:
+ if (hyperlinks := feature.get_property('hyperlinks')) is not None:
+ if 'flatmap' in hyperlinks and (zoom_point := self.flatmap.add_zoom_point(feature)) is not None:
+ zoom_point.set_property('hyperlinks', hyperlinks)
+
+ def __find_feature(self, feature_id: str) -> Optional[Feature]:
+ #==============================================================
+ return self.flatmap.get_feature(feature_id.replace(" ", "_"))
+
+ def align_layer(self, feature_alignment: list[tuple[str, str]]):
+ #===============================================================
+ base_feature_bounds = None
+ layer_feature_bounds = None
+ for (base_feature_id, layer_feature_id) in feature_alignment:
+ if (base_feature := self.__find_feature(base_feature_id)) is None:
+ log.warning('Cannot find base feature for layer alignment', layer=self.id, feature=base_feature_id)
+ elif base_feature_bounds is None:
+ base_feature_bounds = base_feature.bounds
+ else:
+ base_feature_bounds = merge_bounds(base_feature_bounds, base_feature.bounds)
+ if (layer_feature := self.__find_feature(layer_feature_id)) is None:
+ log.warning("Cannot find layer's feature for alignment", layer=self.id, feature=layer_feature_id)
+ elif layer_feature_bounds is None:
+ layer_feature_bounds = layer_feature.bounds
else:
- min_zoom = self.min_zoom
- if map_source.base_feature is not None:
- min_zoom -= 3
- self.__raster_layers.append(RasterLayer(layer_id.replace('/', '_'), extent, map_source, min_zoom,
- local_world_to_base))
-
- def add_group_features(self, group_name: str, features: list[Feature], tile_layer=FEATURES_TILE_LAYER, outermost=False) -> Optional[Feature]:
- #===========================================================================================================================================
+ layer_feature_bounds = merge_bounds(layer_feature_bounds, layer_feature.bounds)
+ if base_feature_bounds is not None and layer_feature_bounds is not None:
+ base_centroid = bounds_centroid(base_feature_bounds)
+ layer_centroid = bounds_centroid(layer_feature_bounds)
+ self.__offset = ((base_centroid[0] - layer_centroid[0]),
+ (base_centroid[1] - layer_centroid[1]))
+ if self.__offset != (0.0, 0.0):
+ for feature in self.features:
+ feature.geometry = shapely.affinity.translate(feature.geometry, xoff=self.__offset[0], yoff=self.__offset[1])
+ self.__bounds = (self.__bounds[0] + self.__offset[0], self.__bounds[1] + self.__offset[1],
+ self.__bounds[2] + self.__offset[0], self.__bounds[3] + self.__offset[1])
+
+ def create_feature_groups(self):
+ #===============================
+ for (group_id, feature_ids) in self.flatmap.properties_store.feature_groups(self.id).items():
+ if len(feature_ids):
+ group_bounds = None
+ for feature_id in feature_ids:
+ if (feature := self.__find_feature(feature_id)) is None:
+ log.warning('Cannot find source feature for feature group', layer=self.id, group=group_id, feature=feature_id)
+ elif group_bounds is None:
+ group_bounds = feature.bounds
+ else:
+ group_bounds = merge_bounds(group_bounds, feature.bounds)
+ if group_bounds is not None:
+ self.flatmap.new_feature(self.id, shapely.box(*group_bounds), {
+ 'id': group_id,
+ 'exclude': True
+ })
+
+ def add_raster_layers(self, extent: MapBounds, map_source: 'MapSource', layer_id: Optional[str]=None,
+ min_zoom: Optional[int]=None, local_world_to_base: Optional[Transform]=None):
+ #==================================================================================================
+ if min_zoom is not None:
+ min_zoom += 1
+ else:
+ min_zoom = self.min_zoom
+ if map_source.base_feature is not None:
+ min_zoom -= ZOOM_OFFSET_FROM_BASE
+ if self.__offset != (0.0, 0.0):
+ extent = translate_extent(extent, self.__offset)
+ self.__raster_layers = [RasterLayer(raster_source, extent,
+ min_zoom=min_zoom, local_world_to_base=local_world_to_base)
+ for raster_source in map_source.raster_sources]
+
+ def add_group_features(self, group_name: str, features: list[Feature],
+ tile_layer=FEATURES_TILE_LAYER, outermost=False) -> Optional[Feature]:
+ #============================================================================================
base_properties = {
'tile-layer': tile_layer
}
@@ -402,78 +484,83 @@ class RasterLayer(object):
"""
Details of layer for creating raster tiles.
- :param id: the ``id`` of the source layer to rasterise
- :type id: str
+ :param raster_source: the source to be rasterised
:param extent: the extent of the base map in which the layer is to be rasterised
as decimal latitude and longitude coordinates.
- :type extent: tuple(south, west, north, east)
- :param map_source: the source of the layer's data
- :type map_source: :class:`~mapmaker.sources.MapSource`
:param min_zoom: The minimum zoom level to generate tiles for.
Optional, defaults to ``min_zoom`` of ``map_source``.
- :type map_zoom: int
:param local_world_to_base: an optional transform from the raster layer's
local world coordinates to the base map's
world coordinates. Defaults to ``None``, meaning
the :class:`~mapmaker.geometry.Transform.Identity()` transform
- :type local_world_to_base: :class:`~mapmaker.geometry.Transform`
"""
- def __init__(self, id: str, extent, map_source: 'MapSource',
- min_zoom:Optional[int]=None, max_zoom: Optional[int]=None, local_world_to_base=None):
- self.__id = '{}_image'.format(id)
+ def __init__(self, raster_source: 'RasterSource', extent: MapBounds,
+ min_zoom:Optional[int]=None, max_zoom: Optional[int]=None,
+ local_world_to_base: Optional[Transform]=None):
+ self.__id = raster_source.id
self.__extent = extent
- self.__map_source = map_source
- self.__flatmap = map_source.flatmap
- self.__max_zoom = max_zoom if max_zoom is not None else settings.get('maxRasterZoom', map_source.max_zoom)
- self.__min_zoom = min_zoom if min_zoom is not None else map_source.min_zoom
+ self.__raster_source = raster_source
+ self.__map_source = raster_source.map_source
+ self.__flatmap = self.__map_source.flatmap
+ self.__max_zoom = max_zoom if max_zoom is not None else self.__map_source.max_zoom
+ self.__min_zoom = min_zoom if min_zoom is not None else self.__map_source.min_zoom
self.__local_world_to_base = local_world_to_base
+ self.__background_layer = raster_source.background_layer
+
+ @property
+ def background_layer(self) -> bool:
+ return self.__background_layer
@property
- def extent(self):
+ def extent(self) -> MapBounds:
return self.__extent
@property
- def flatmap(self):
+ def flatmap(self) -> 'FlatMap':
return self.__flatmap
@property
- def id(self):
+ def id(self) -> str:
return self.__id
@property
- def local_world_to_base(self):
+ def local_world_to_base(self) -> Optional[Transform]:
return self.__local_world_to_base
@property
- def map_source(self):
+ def map_source(self) -> 'MapSource':
return self.__map_source
@property
- def max_zoom(self):
+ def max_zoom(self) -> int:
return self.__max_zoom
@property
- def min_zoom(self):
+ def min_zoom(self) -> int:
return self.__min_zoom
@property
def source_data(self) -> bytes:
- return self.__map_source.raster_source.data
+ return self.__raster_source.data
@property
- def source_extent(self):
+ def source_extent(self) -> MapBounds:
return self.__map_source.extent
@property
- def source_kind(self):
- return self.__map_source.raster_source.kind
+ def source_kind(self) -> str:
+ return self.__raster_source.kind
@property
- def source_path(self):
- return self.__map_source.raster_source.source_path
+ def source_path(self) -> Optional[FilePath]:
+ return self.__raster_source.source_path
@property
- def source_range(self):
+ def source_range(self) -> Optional[list[int]]:
return self.__map_source.source_range
+ @property
+ def transform(self) -> Optional[Transform]:
+ return self.__raster_source.transform
+
#===============================================================================
diff --git a/mapmaker/flatmap/manifest.py b/mapmaker/flatmap/manifest.py
index 9784dda5..9bb15104 100644
--- a/mapmaker/flatmap/manifest.py
+++ b/mapmaker/flatmap/manifest.py
@@ -20,6 +20,7 @@
from collections import namedtuple
from copy import deepcopy
+from dataclasses import dataclass
from datetime import datetime
from enum import Enum
import multiprocessing
@@ -59,6 +60,7 @@ def clone_from_with_timeout(repo_path: str, working_directory: str) -> git.Repo:
#===============================================================================
+from mapmaker.settings import MAP_KIND
from mapmaker.utils import log, FilePath
#===============================================================================
@@ -166,31 +168,65 @@ def path_blob_url(self, path):
#===============================================================================
+@dataclass
+class SourceBackground:
+ href: str
+ scale: float
+ translate: tuple[float, float]
+
+#===============================================================================
+
class SourceManifest:
def __init__(self, description: dict, manifest: 'Manifest'):
self.__id = description['id']
- href = manifest.check_and_normalise_path(description['href'], 'Flatmap source file')
- if href is None:
- raise ValueError('Source in manifest has no `href`')
+ if (href := manifest.check_and_normalise_path(description.get('href'), 'Flatmap source file')) is None:
+ raise ValueError(f'Source {self.__id} in manifest has no `href`')
self.__href = href
self.__kind = description.get('kind', '')
self.__boundary = description.get('boundary')
+ self.__description = description.get('description')
self.__detail_fit = description.get('detail-fit')
+ self.__details = description.get('details')
self.__feature = description.get('feature')
+ self.__alignment = [(features[0], features[1]) for features in description.get('alignment', [])]
self.__source_range = (([int(n) for n in source_range] if isinstance(source_range, list)
else [int(source_range)])
if (source_range := description.get('slides')) is not None
else None)
self.__zoom = description['zoom'] if self.__feature is not None else 0
+ if (background := description.get('background')) is not None:
+ if (href := manifest.check_and_normalise_path(background.get('href'), 'Background source file')) is None:
+ raise ValueError(f'Background for source {self.__id} has no `href`')
+ self.__background_source = SourceBackground(href,
+ float(background.get('scale', 1.0)),
+ tuple(float(t) for t in background.get('translate', [0, 0]))[0:2]) # type: ignore
+ else:
+ self.__background_source = None
+
+ @property
+ def alignment(self) -> list[tuple[str, str]]:
+ return self.__alignment
+
+ @property
+ def background_source(self) -> Optional[SourceBackground]:
+ return self.__background_source
@property
def boundary(self) -> Optional[str]:
return self.__boundary
+ @property
+ def description(self) -> Optional[str]:
+ return self.__description
+
@property
def detail_fit(self) -> Optional[str]:
return self.__detail_fit
+ @property
+ def details(self) -> Optional[str]:
+ return self.__details
+
@property
def feature(self) -> Optional[str]:
return self.__feature
@@ -301,6 +337,11 @@ def __init__(self, manifest_path, single_file=None, id=None, ignore_git=False, m
if not ignore_git and self.__uncommitted:
raise ValueError("Not all sources are commited into git -- was the '--authoring' or '--ignore-git' option intended?")
+ self.__map_kinds = self.__manifest.get('kind', 'anatomical').split()
+ self.__map_kind = (MAP_KIND.FUNCTIONAL if 'functional' in self.__map_kinds
+ else MAP_KIND.CENTRELINE if 'centreline' in self.__map_kinds
+ else MAP_KIND.ANATOMICAL)
+
@property
def anatomical_map(self):
return self.__manifest.get('anatomicalMap')
@@ -358,8 +399,12 @@ def id(self):
return self.__manifest['id']
@property
- def kind(self): #! Either ``anatomical`` or ``functional``
- return self.__manifest.get('kind', 'anatomical')
+ def map_kinds(self) -> list[str]:
+ return self.__map_kinds
+
+ @property
+ def map_kind(self) -> MAP_KIND:
+ return self.__map_kind
@property
def raw_manifest(self):
@@ -397,9 +442,9 @@ def clean_up(self):
if self.__temp_directory is not None:
shutil.rmtree(self.__temp_directory)
- def check_and_normalise_path(self, path: str, desc: str='') -> str|None:
- #=======================================================================
- if path.strip() == '':
+ def check_and_normalise_path(self, path: Optional[str], desc: str='') -> str|None:
+ #=================================================================================
+ if path is None or path.strip() == '':
return None
normalised_path = self.__path.join_url(path)
if not self.__ignore_git:
diff --git a/mapmaker/geometry/__init__.py b/mapmaker/geometry/__init__.py
index b085c13e..d77e8034 100644
--- a/mapmaker/geometry/__init__.py
+++ b/mapmaker/geometry/__init__.py
@@ -19,6 +19,7 @@
#===============================================================================
from math import acos, cos, sin, sqrt, pi as PI
+from typing import Self
import warnings
#===============================================================================
@@ -67,25 +68,32 @@ def save_geometry(geo, file):
warnings.simplefilter(action='default', category=FutureWarning)
-# (SE, NW) bounds as decimal coordinates
+# (SW, NE) bounds as decimal coordinates
MapBounds = tuple[float, float, float, float]
+# (SW, NE) bounds as lng/lat coordinates
+MapExtent = tuple[float, float, float, float]
+
#===============================================================================
-def bounds_to_extent(bounds):
-#============================
+def bounds_centroid(bounds: MapBounds) -> tuple[float, float]:
+#=============================================================
+ return ((bounds[0] + bounds[2])/2, (bounds[1] + bounds[3])/2)
+
+def bounds_to_extent(bounds: MapBounds) -> MapExtent:
+#====================================================
sw = mercator_transformer.transform(*bounds[:2])
ne = mercator_transformer.transform(*bounds[2:])
return (sw[0], sw[1], ne[0], ne[1])
-def extent_to_bounds(extent):
-#============================
+def extent_to_bounds(extent: MapExtent) -> MapBounds:
+#====================================================
sw = mercator_transformer.transform(*extent[:2], direction=pyproj.enums.TransformDirection.INVERSE) # type: ignore
ne = mercator_transformer.transform(*extent[2:], direction=pyproj.enums.TransformDirection.INVERSE) # type: ignore
return (sw[0], sw[1], ne[0], ne[1])
-def mercator_transform(geometry):
-#================================
+def mercator_transform(geometry: BaseGeometry) -> BaseGeometry:
+#==============================================================
return shapely.ops.transform(mercator_transformer.transform, geometry)
def merge_bounds(bounds_0: MapBounds, bounds_1: MapBounds) -> MapBounds:
@@ -93,6 +101,13 @@ def merge_bounds(bounds_0: MapBounds, bounds_1: MapBounds) -> MapBounds:
return (min(bounds_0[0], bounds_1[0]), min(bounds_0[1], bounds_1[1]),
max(bounds_0[2], bounds_1[2]), max(bounds_0[3], bounds_1[3]))
+def translate_extent(extent: MapExtent, offset: tuple[float, float]) -> MapBounds:
+#=================================================================================
+ bounds = extent_to_bounds(extent)
+ bds = (bounds[0] + offset[0], bounds[1] + offset[1],
+ bounds[2] + offset[0], bounds[3] + offset[1])
+ return bounds_to_extent(bds)
+
#===============================================================================
class Transform(object):
@@ -102,7 +117,7 @@ def __init__(self, matrix):
self.__matrix[1, 0:2],
self.__matrix[0:2, 2]), axis=None).tolist()
- def __matmul__(self, transform):
+ def __matmul__(self, transform) -> 'Transform':
if isinstance(transform, Transform):
return Transform(self.__matrix@np.array(transform.__matrix))
else:
@@ -112,33 +127,39 @@ def __str__(self):
return str(self.__matrix)
@classmethod
- def Identity(cls):
+ def Identity(cls) -> Self:
return cls(np.identity(3))
@classmethod
- def scale(cls, scale: float):
+ def Scale(cls, scale: float) -> Self:
return cls([[scale, 0, 0], [0, scale, 0], [0, 0, 1]])
@classmethod
- def translate(cls, tx: float, ty: float):
- return cls([[1, 0, tx], [0, 1, ty], [0, 0, 1]])
+ def Translate(cls, translate: tuple[float, float]) -> Self:
+ return cls([[1, 0, translate[0]],
+ [0, 1, translate[1]],
+ [0, 0, 1]])
@property
- def matrix(self):
+ def is_identity(self) -> bool:
+ return np.allclose(self.__matrix, np.identity(3))
+
+ @property
+ def matrix(self) -> np.ndarray:
return self.__matrix
@property
- def svg_matrix(self):
+ def svg_matrix(self) -> np.ndarray:
return np.array([self.__matrix[0, 0], self.__matrix[1, 0],
self.__matrix[0, 1], self.__matrix[1, 1],
self.__matrix[0, 2], self.__matrix[1, 2]])
- def flatten(self):
- #=================
+ def flatten(self) -> np.ndarray:
+ #===============================
return self.__matrix.flatten()
- def inverse(self):
- #=================
+ def inverse(self) -> 'Transform':
+ #================================
return Transform(np.linalg.inv(self.__matrix))
def rotate_angle(self, angle):
@@ -152,6 +173,12 @@ def rotate_angle(self, angle):
angle -= 2*PI
return angle
+ def scale(self, scale: float) -> 'Transform':
+ #================================
+ return Transform([[scale*self.__matrix[0, 0], self.__matrix[0, 1], self.__matrix[0, 2]],
+ [ self.__matrix[1, 0], scale*self.__matrix[1, 1], self.__matrix[1, 2]],
+ [ self.__matrix[2, 0], self.__matrix[2, 1], self.__matrix[2, 2]]])
+
def scale_length(self, length):
#==============================
scaling = transforms3d.affines.decompose(self.__matrix)[2]
@@ -172,6 +199,12 @@ def transform_point(self, point) -> tuple[float, float]:
#=======================================================
return tuple(self.__matrix@[point[0], point[1], 1.0])[:2]
+ def translate(self, translation: tuple[float, float]) -> 'Transform':
+ #====================================================================
+ return Transform([[self.__matrix[0, 0], self.__matrix[0, 1], translation[0] + self.__matrix[0, 2]],
+ [self.__matrix[1, 0], self.__matrix[1, 1], translation[1] + self.__matrix[1, 2]],
+ [self.__matrix[2, 0], self.__matrix[2, 1], self.__matrix[2, 2]]])
+
#===============================================================================
def ellipse_point(a, b, theta):
diff --git a/mapmaker/geometry/beziers.py b/mapmaker/geometry/beziers.py
index 92a2b647..f52ce890 100644
--- a/mapmaker/geometry/beziers.py
+++ b/mapmaker/geometry/beziers.py
@@ -32,14 +32,18 @@
from shapely.coords import CoordinateSequence
from shapely.geometry import LineString, MultiLineString
from shapely.geometry.base import BaseGeometry
-import shapely.geometry
+import shapely
#===============================================================================
-def coords_to_point(pt: tuple[float, float]) -> BezierPoint:
+type Coordinate = tuple[float, float]
+
+#===============================================================================
+
+def coords_to_point(pt: Coordinate) -> BezierPoint:
return BezierPoint(*pt)
-def point_to_coords(pt: BezierPoint) -> tuple[float, float]:
+def point_to_coords(pt: BezierPoint) -> Coordinate:
return (pt.x, pt.y)
#===============================================================================
@@ -51,12 +55,12 @@ def width_along_line(geometry: BaseGeometry, point: BezierPoint, dirn: BezierPoi
point in a given direction.
"""
bounds = geometry.bounds
- max_width = shapely.geometry.Point(*bounds[0:2]).distance(shapely.geometry.Point(*bounds[2:4]))
- line = shapely.geometry.LineString([point_to_coords(point - dirn*max_width),
+ max_width = shapely.Point(*bounds[0:2]).distance(shapely.Point(*bounds[2:4]))
+ line = shapely.LineString([point_to_coords(point - dirn*max_width),
point_to_coords(point + dirn*max_width)])
if geometry.intersects(line):
intersection = geometry.boundary.intersection(line)
- if isinstance(intersection, shapely.geometry.MultiPoint):
+ if isinstance(intersection, shapely.MultiPoint):
intersecting_points = intersection.geoms
if len(intersecting_points) == 2:
return intersecting_points[0].distance(intersecting_points[1])
@@ -64,18 +68,20 @@ def width_along_line(geometry: BaseGeometry, point: BezierPoint, dirn: BezierPoi
#===============================================================================
-def bezier_sample(bz, num_points=100):
-#=====================================
+def bezier_sample(bz, num_points=100) -> list[Coordinate]:
+#=========================================================
return [(pt.x, pt.y) for pt in bz.sample(num_points)]
def bezier_to_linestring(bz, num_points=100, offset=0) -> LineString|MultiLineString:
#====================================================================================
- line = shapely.geometry.LineString(bezier_sample(bz, num_points))
+ line = LineString(bezier_sample(bz, num_points))
if offset == 0:
return line
else:
return line.parallel_offset(abs(offset), 'left' if offset >= 0 else 'right')
+#===============================================================================
+
def bezier_to_line_coords(bz, num_points=100, offset=0) -> CoordinateSequence:
#=============================================================================
line = bezier_to_linestring(bz, num_points=num_points, offset=offset)
@@ -102,7 +108,7 @@ def bezier_connect(a: BezierPoint, b: BezierPoint,
#===============================================================================
-def closest_time_distance(bz: BezierPath|BezierSegment, pt: BezierPoint, steps: int=100) -> tuple[float, float]:
+def closest_time_distance(bz: BezierPath|BezierSegment, pt: BezierPoint, steps: int=100) -> Coordinate:
def subdivide_search(t0: float, t1: float, steps: int) -> tuple[float, float, float]:
closest_d = -1
closest_t = t0
@@ -141,7 +147,7 @@ def set_bezier_path_end_to_point(bz_path: BezierPath, point: BezierPoint) -> flo
#===============================================================================
-def split_bezier_path_at_point(bz_path: BezierPath, point: BezierPoint):
+def split_bezier_path_at_point(bz_path: BezierPath, point: BezierPoint) -> tuple[BezierPath, BezierPath]:
segments = bz_path.asSegments()
# Find segment that is closest to the point
closest_distance = None
diff --git a/mapmaker/knowledgebase/celldl.py b/mapmaker/knowledgebase/celldl.py
index 4b784ed2..ca4bbaeb 100644
--- a/mapmaker/knowledgebase/celldl.py
+++ b/mapmaker/knowledgebase/celldl.py
@@ -1,6 +1,6 @@
#===============================================================================
#
-# Flatmap viewer and annotation tools
+# Flatmap maker and annotation tools
#
# Copyright (c) 2019 - 2023 David Brooks
#
@@ -19,6 +19,8 @@
#===============================================================================
import base64
+from datetime import datetime, UTC
+from typing import Optional
import zlib
#===============================================================================
@@ -27,12 +29,12 @@
#===============================================================================
-from mapmaker.shapes import Shape
+from mapmaker.shapes import Shape, SHAPE_TYPE
from mapmaker.utils.svg import svg_id
#===============================================================================
-CELLDL_SCHEMA_VERSION = '1.0'
+CELLDL_SCHEMA_VERSION = '2.0'
#===============================================================================
@@ -40,12 +42,16 @@ class CD_CLASS:
UNKNOWN = 'celldl:Unknown'
LAYER = 'celldl:Layer'
+ ANNOTATION = 'celldl:Annotation'
+
COMPONENT = 'celldl:Component' # What has CONNECTORs
CONNECTOR = 'celldl:Connector' # What a CONNECTION connects to
CONNECTION = 'celldl:Connection' # The path between CONNECTORS
CONDUIT = 'celldl:Conduit' # A container for CONNECTIONs
+ PORT = 'celldl:UnconnectedPort'
+
MEMBRANE = 'celldl:Membrane' # A boundary around a collection of COMPONENTS
ANNOTATION = 'celldl:Annotation' # Additional information about something
@@ -107,21 +113,35 @@ class FC_KIND:
#===============================================================================
-RDF = rdflib.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#')
-RDFS = rdflib.Namespace('http://www.w3.org/2000/01/rdf-schema#')
+DCT_NS = rdflib.Namespace('http://purl.org/dc/terms/')
+RDF_NS = rdflib.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#')
+RDFS_NS = rdflib.Namespace('http://www.w3.org/2000/01/rdf-schema#')
-CELLDL = rdflib.Namespace('http://celldl.org/ontologies/celldl#')
-FC = rdflib.Namespace('http://celldl.org/ontologies/functional-connectivity#')
+#===============================================================================
-FLATMAP = rdflib.Namespace('#')
+BG_NS = rdflib.Namespace('http://celldl.org/ontologies/bond-graph#')
+CELLDL_NS = rdflib.Namespace('http://celldl.org/ontologies/celldl#')
+FC_NS = rdflib.Namespace('http://celldl.org/ontologies/functional-connectivity#')
+
+STANDARD_NAMESPACES = {
+ 'celldl': CELLDL_NS,
+ 'dct': DCT_NS
+}
+
+KNOWN_NAMESPACES = {
+ 'bg': BG_NS,
+ 'fc': FC_NS,
+}
#===============================================================================
-CELLDL_TYPE = {
- CD_CLASS.CONDUIT: CELLDL.Conduit,
- CD_CLASS.CONNECTION: CELLDL.Connection,
- CD_CLASS.CONNECTOR: CELLDL.Connector,
- CD_CLASS.COMPONENT: CELLDL.Component,
+CELLDL_TYPE_FROM_CLASS = {
+ CD_CLASS.ANNOTATION: CELLDL_NS.Annotation,
+ CD_CLASS.CONDUIT: CELLDL_NS.Conduit,
+ CD_CLASS.CONNECTION: CELLDL_NS.Connection,
+ CD_CLASS.CONNECTOR: CELLDL_NS.Connector,
+ CD_CLASS.COMPONENT: CELLDL_NS.Component,
+ CD_CLASS.PORT: CELLDL_NS.UnconnectedPort,
}
#===============================================================================
@@ -130,47 +150,76 @@ class FC_KIND:
#===============================================================================
+CELLDL_CLASS_FROM_SHAPE_TYPE = {
+ SHAPE_TYPE.ANNOTATION: CD_CLASS.ANNOTATION,
+ SHAPE_TYPE.COMPONENT: CD_CLASS.COMPONENT,
+ SHAPE_TYPE.CONNECTION: CD_CLASS.CONNECTION,
+ SHAPE_TYPE.CONTAINER: CD_CLASS.COMPONENT,
+ SHAPE_TYPE.PORT: CD_CLASS.PORT,
+}
+
+#===============================================================================
+
+DIAGRAM_NS = rdflib.Namespace('#')
+
+def make_uri(shape_id: str) -> rdflib.URIRef:
+ return DIAGRAM_NS[svg_id(shape_id)]
+
+#===============================================================================
+
class CellDLGraph:
- def __init__(self):
+ def __init__(self, diagram_type: Optional[rdflib.URIRef]=None):
self.__graph = rdflib.Graph()
- self.__graph.bind('celldl', str(CELLDL))
- self.__graph.bind('fc', str(FC))
- this = FLATMAP['']
- self.__graph.add((this, RDF.type, CELLDL.Document))
- self.__graph.add((this, CELLDL.schema, rdflib.Literal(CELLDL_SCHEMA_VERSION)))
- self.__graph.add((this, RDF.type, FC.Diagram))
-
- def add_metadata(self, shape: Shape):
- #====================================
- if shape.exclude or shape.cd_class not in CELLDL_TYPE:
+ self.__diagram = make_uri('')
+ self.__graph.bind('', str(DIAGRAM_NS))
+ for (prefix, ns) in STANDARD_NAMESPACES.items():
+ self.__graph.bind(prefix, str(ns))
+ self.__graph.add((self.__diagram, RDF_NS.type, CELLDL_NS.Document))
+ self.__graph.add((self.__diagram, CELLDL_NS.schema, rdflib.Literal(CELLDL_SCHEMA_VERSION)))
+ if diagram_type is not None:
+ for (prefix, ns) in KNOWN_NAMESPACES.items():
+ if str(diagram_type).startswith(str(ns)):
+ self.__graph.bind(prefix, str(ns))
+ self.__graph.add((self.__diagram, RDF_NS.type, diagram_type))
+ self.__graph.add((self.__diagram, DCT_NS.created, rdflib.Literal(datetime.now(UTC).isoformat())))
+
+ def add_shape(self, shape: Shape) -> Optional[str]:
+ #==================================================
+ if (shape.exclude
+ or shape.shape_type not in CELLDL_CLASS_FROM_SHAPE_TYPE
+ or shape.id is None):
return
- this = FLATMAP[svg_id(shape.id)]
- self.__graph.add((this, RDF.type, CELLDL_TYPE[shape.cd_class]))
- self.__graph.add((this, RDF.type, FC[shape.fc_class.split(':')[-1]]))
+ this = make_uri(shape.id)
+ celldl_class = CELLDL_CLASS_FROM_SHAPE_TYPE[shape.shape_type]
+ self.__graph.add((this, RDF_NS.type, CELLDL_TYPE_FROM_CLASS[celldl_class]))
if shape.label:
- self.__graph.add((this, RDFS.label, rdflib.Literal(shape.label))) ## add port/node in XXX ??
+ self.__graph.add((this, RDFS_NS.label, rdflib.Literal(shape.label))) ## add port/node in XXX ??
if (shape.name
and (shape.label is None
or shape.name.lower() != shape.label.lower())):
- self.__graph.add((this, RDFS.comment, rdflib.Literal(shape.name)))
- ## models (layers...)
- if shape.cd_class == CD_CLASS.CONNECTION:
- if shape.fc_class == FC_CLASS.NEURAL:
- self.__graph.add((this, FC.connectionType, rdflib.Literal(shape.path_type.name)))
- for id in shape.connector_ids:
- self.__graph.add((this, CELLDL.hasConnector, FLATMAP[svg_id(id)]))
- for id in shape.intermediate_connectors:
- self.__graph.add((this, CELLDL.hasIntermediate, FLATMAP[svg_id(id)]))
- for id in shape.intermediate_components:
- self.__graph.add((this, CELLDL.hasIntermediate, FLATMAP[svg_id(id)]))
+ self.__graph.add((this, RDFS_NS.comment, rdflib.Literal(shape.name)))
+ if celldl_class == CD_CLASS.CONNECTION:
+ if (source := shape.get_property('source')) is not None:
+ self.__graph.add((this, CELLDL_NS.hasSource, make_uri(source)))
+ if (target := shape.get_property('target')) is not None:
+ self.__graph.add((this, CELLDL_NS.hasTarget, make_uri(target)))
+ return celldl_class.replace(':', '-')
def as_encoded_turtle(self):
#===========================
turtle = self.__graph.serialize(format='turtle', encoding='utf-8')
return f'{GZIP_BASE64_DATA_URI}{base64.b64encode(zlib.compress(turtle)).decode()}'
- def as_xml(self):
- #================
+ def as_turtle(self) -> bytes:
+ #============================
+ return self.__graph.serialize(format='turtle', encoding='utf-8')
+
+ def as_xml(self) -> bytes:
+ #=========================
return self.__graph.serialize(format='xml', encoding='utf-8')
+ def set_property(self, property: rdflib.URIRef, value: rdflib.Literal|rdflib.URIRef):
+ #====================================================================================
+ self.__graph.add((self.__diagram, property, value))
+
#===============================================================================
diff --git a/mapmaker/maker.py b/mapmaker/maker.py
index b5f93ea6..78d2bd16 100644
--- a/mapmaker/maker.py
+++ b/mapmaker/maker.py
@@ -21,10 +21,12 @@
import json
import os
import pathlib
+import multiprocessing
import multiprocessing.connection
import shutil
import subprocess
import uuid
+from typing import Any
#===============================================================================
@@ -77,8 +79,8 @@
#===============================================================================
-class MapMaker(object):
- def __init__(self, options):
+class MapMaker:
+ def __init__(self, options: dict[str, Any]):
# ``silent`` implies not ``verbose``
if options.get('silent', False):
options['verbose'] = False
@@ -86,6 +88,11 @@ def __init__(self, options):
if options.get('sckanVersion') is not None and not options.get('ignoreGit', False):
raise ValueError('`--ignore-git` must be set when `--sckan-version` is used')
+ # For when we try to make() an invalid configuration
+ process_log_queue = options.pop('logQueue', None)
+ self.__tell_manager = process_log_queue is not None
+ self.__valid_configuration = False
+
# Setup logging
if (log_file := options.get('logFile')) is None:
if (log_path := options.get('logPath')) is not None:
@@ -93,10 +100,12 @@ def __init__(self, options):
if options.get('silent', False) and log_file is None:
raise ValueError('`--silent` option requires `--log LOG_FILE` to be given')
- self.__file_log = configure_logging(log_file,
+ self.__file_log = configure_logging(
+ log_json_file=log_file,
verbose=options.get('verbose', False),
silent=options.get('silent', False),
- debug=options.get('debug', False))
+ debug=options.get('debug', False),
+ log_queue=process_log_queue)
log.info('Mapmaker', version=__version__)
# Default base output directory to ``./flatmaps``.
@@ -156,6 +165,7 @@ def __init__(self, options):
# Make sure our top-level directory exists
map_base = options.get('output')
+ assert map_base is not None
if not os.path.exists(map_base):
os.makedirs(map_base)
@@ -214,11 +224,12 @@ def __init__(self, options):
if os.path.exists(self.__map_dir):
if os.path.exists(self.__maker_sentinel):
self.__clean_up(remove_sentinel=False)
- raise MakerException('Last making of map failed -- use `--force` to re-make')
- log.info('Map already exists -- use `--force` to re-make', id=self.id, uuid=self.uuid, path=self.__map_dir)
- self.__clean_up()
- exit(0)
+ log.error('Last making of map failed -- use `--force` to re-make', id=self.id, uuid=self.uuid, path=self.__map_dir)
+ else:
+ log.info('Map already exists -- use `--force` to re-make', id=self.id, uuid=self.uuid, path=self.__map_dir)
+ return
else:
+ self.__valid_configuration = True
os.makedirs(self.__map_dir)
# Create an empty sentinel
@@ -266,6 +277,10 @@ def zoom(self):
def make(self):
#==============
+ if self.__tell_manager and not self.__valid_configuration:
+ log.critical('Mapmaker failed')
+ return
+
self.__begin_make()
# Process flatmap's sources to create MapLayers
@@ -298,6 +313,14 @@ def make(self):
# Save the flatmap's metadata
self.__save_metadata()
+ # We now have successfully generated the flatmap
+ generated_map = {'id': self.id, 'uuid': self.uuid, 'path': self.__map_dir}
+ if self.__flatmap.models is not None:
+ generated_map['models'] = self.__flatmap.models
+ log.info('Generated map', **generated_map)
+ if self.__tell_manager:
+ log.critical('Mapmaker succeeded', **generated_map)
+
# Write out details of FC neurons if option set
if (export_file := settings.get('exportNeurons')) is not None:
with open(export_file, 'w') as fp:
@@ -318,12 +341,6 @@ def make(self):
sparc_dataset.generate()
sparc_dataset.save(sds_output)
- # Show what the map is about
- log_details = {'id': self.id, 'uuid': self.uuid, 'path': self.__map_dir}
- if self.__flatmap.models is not None:
- log_details['models'] = self.__flatmap.models
- log.info('Generated map', **log_details)
-
# Tidy up
self.__clean_up()
@@ -351,6 +368,7 @@ def __clean_up(self, remove_sentinel=True):
# Remove any temporary directory created for the map's sources
self.__manifest.clean_up()
+ # Copy the log file into the generated map's directory
if self.__file_log is not None:
maker_log = os.path.join(self.__map_dir, MAKER_LOG)
if not os.path.exists(maker_log):
@@ -417,8 +435,11 @@ def __check_raster_tiles(self):
tilemakers = []
for layer in self.__flatmap.layers:
for raster_layer in layer.raster_layers:
- tilemaker = RasterTileMaker(raster_layer, self.__map_dir,
- settings.get('maxRasterZoom', layer.max_zoom))
+ max_zoom = layer.max_zoom
+ if layer.source.kind == 'base':
+ # maxRasterZoom is only for base maps
+ max_zoom = settings.get('maxRasterZoom', max_zoom)
+ tilemaker = RasterTileMaker(raster_layer, self.__map_dir, max_zoom)
tilemakers.append(tilemaker)
if settings.get('backgroundTiles', False):
tilemaker_process = tilemaker.make_tiles()
@@ -550,6 +571,8 @@ def __save_metadata(self):
'bounds': self.__flatmap.extent,
'version': FLATMAP_VERSION,
'image-layers': len(self.__raster_layers) > 0,
+ 'style': metadata['style'],
+ 'map-kinds': metadata['map-kinds'],
'created': metadata['created']
}
if self.__uuid is not None:
@@ -559,12 +582,6 @@ def __save_metadata(self):
if self.__manifest.biological_sex is not None:
map_index['biologicalSex'] = self.__manifest.biological_sex
map_index['authoring'] = settings.get('authoring', False)
- if self.__flatmap.map_kind == MAP_KIND.FUNCTIONAL:
- map_index['style'] = 'functional'
- elif self.__flatmap.map_kind == MAP_KIND.CENTRELINE:
- map_index['style'] = 'centreline'
- else:
- map_index['style'] = 'anatomical'
if git_status is not None:
map_index['git-status'] = git_status
if len(self.__sckan_provenance):
diff --git a/mapmaker/output/__init__.py b/mapmaker/output/__init__.py
index 10915a3f..baee6ada 100644
--- a/mapmaker/output/__init__.py
+++ b/mapmaker/output/__init__.py
@@ -23,17 +23,17 @@
# is saved as GeoJSON.
EXPORTED_FEATURE_PROPERTIES = [
- 'cd-class', # str
+ 'associated-details', # Optional[str|list[str]] Identifiers of detailed layers associated with the feature
'centreline', # bool
'children', # list[int]
'class',
- 'colour',
- 'description',
+ 'colour', # str
+ 'description', # str
+ 'details-layer', # str The identifier of the layer with details about the feature
'error',
- 'fc-class',
- 'fc-kind',
'featureId', # int
'group',
+ 'hyperlink', # Optional[str]
'hyperlinks', # Optional[list[dict[str, str]]] # id, url
'id', # Optional[str]
'invisible', # bool
@@ -50,12 +50,13 @@
'nodeId',
'opacity', # float
'parents', # list[int]
+ 'path-ids', # list[str]
'scale',
'sckan',
- 'source',
+ 'source', # str
'stroke', # str
'stroke-width', # float
- 'path-ids', # list[str]
+ 'target', # str
'taxons', # list[str]
'tile-layer',
'type',
@@ -70,7 +71,8 @@
#===============================================================================
ENCODED_FEATURE_PROPERTIES = [
- 'hyperlinks', # Optional[list[dict[str, str]]] # id, url
+ 'associated-details', # Optional[str|list[str]]
+ 'hyperlinks', # Optional[list[dict[str, str]]] # id, url
]
#===============================================================================
diff --git a/mapmaker/output/bondgraph/__init__.py b/mapmaker/output/bondgraph/__init__.py
new file mode 100644
index 00000000..f3da9a06
--- /dev/null
+++ b/mapmaker/output/bondgraph/__init__.py
@@ -0,0 +1,205 @@
+#===============================================================================
+#
+# Flatmap maker and annotation tools
+#
+# Copyright (c) 2019 - 2025 David Brooks
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#===============================================================================
+
+from datetime import datetime, UTC
+
+#===============================================================================
+
+import lark
+from lark import Lark, UnexpectedInput
+import rdflib
+
+#===============================================================================
+
+from mapmaker.shapes import Shape, SHAPE_TYPE
+from mapmaker.shapes.colours import ColourMatcherDict
+from mapmaker.utils import log, TreeList
+from mapmaker.utils.svg import svg_id
+
+from .namespaces import NAMESPACES
+from .namespaces import BG, BGF, DCT, RDF, MODEL
+
+#===============================================================================
+
+BG_FRAMEWORK_VERSION = "1.0"
+
+#===============================================================================
+
+NODE_BORDER_TYPES = ColourMatcherDict({
+ '#042433': 'bgf:OneNode', # dark green
+ '#00B050': 'bgf:OneResistance', # green
+ '#061B27': 'bgf:OneReaction', # navy blue
+ '#FF0000': 'bgf:ZeroStorage', # red
+})
+
+#===============================================================================
+#===============================================================================
+
+LATEX_GRAMMAR = """
+ start: _name
+
+ _name: TEXT super? sub?
+ | TEXT sub? super?
+
+ super: "^" _block
+ sub: "_" _block
+ _block: _brackets | TEXT
+
+ _brackets: "{" [ _name+ | _chem ] "}"
+ _chem: "\\ce{" _formula "}"
+ _formula: [ SYMBOL _exponent? ]+
+ SYMBOL: /[A-Za-z]+/
+ _exponent: NUMBER? _sign?
+ _sign: "+" | "-"
+
+ NUMBER: /[0-9]+/
+ TEXT: /[A-Za-z0-9\\-\\+\\.]+/
+"""
+
+latex_parser = Lark(LATEX_GRAMMAR)
+
+#===============================================================================
+
+def get_text(tokens: list) -> str:
+#=================================
+ text = []
+ for token in tokens:
+ if isinstance(token, lark.Token):
+ if token.type == 'TEXT':
+ text.append(token.value.replace('+', '').replace('-', ''))
+ elif token.type in ['NUMBER', 'SYMBOL']:
+ text.append(token.value)
+ elif isinstance(token, lark.Tree):
+ text.append(get_text(token.children))
+ return ''.join(text)
+
+def latex_to_symbol(name: str) -> str:
+#====================================
+ try:
+ children = latex_parser.parse(name).children
+ except UnexpectedInput:
+ raise ValueError(f'Cannot parse LaTeX name of shape: {name}')
+ base = None
+ sub_text = None
+ super_text = None
+ for n, child in enumerate(children):
+ if n == 0:
+ if isinstance(child, lark.Token) and child.type == 'TEXT':
+ base = child.value
+ elif isinstance(child, lark.Tree):
+ if child.data == 'super':
+ super_text = get_text(child.children)
+ elif child.data == 'sub':
+ sub_text = get_text(child.children)
+
+ if base is None:
+ return name
+ else:
+ text = [base]
+ if sub_text is not None:
+ text.append(sub_text)
+ if super_text is not None:
+ text.append(super_text)
+ return '_'.join(text)
+
+def name_to_symbols(name: str) -> tuple[str]:
+#============================================
+ if name.startswith('$') and name.endswith('$'):
+ (latex_to_symbol(latex) for latex in name[1:-1].split(','))
+ return (name,)
+
+#===============================================================================
+#===============================================================================
+
+class BondgraphModel:
+ def __init__(self, id: str, shapes: TreeList[Shape]):
+ self.__graph = rdflib.Graph()
+
+ ## This could be embedded into a CellDL diagram, separate to its
+ ## CellDL structure.
+
+ self.__uri = MODEL[id]
+ for (prefix, ns) in NAMESPACES.items():
+ self.__graph.bind(prefix, str(ns))
+ self.__graph.add((self.__uri, RDF.type, BG.BondGraph))
+ self.__graph.add((self.__uri, BGF.hasSchema, rdflib.Literal(BG_FRAMEWORK_VERSION)))
+ self.__graph.add((self.__uri, DCT.created, rdflib.Literal(datetime.now(UTC).isoformat())))
+ self.__process_shape_list(shapes.flatten())
+
+ def as_turtle(self) -> bytes:
+ #============================
+ ttl = self.__graph.serialize(format='turtle', encoding='unicode')
+ return ttl
+
+ def as_xml(self) -> bytes:
+ #=========================
+ return self.__graph.serialize(format='xml', encoding='unicode')
+
+ def set_property(self, property: rdflib.URIRef, value: rdflib.Literal|rdflib.URIRef):
+ #====================================================================================
+ self.__graph.add((self.__uri, property, value))
+
+ def __process_shape_list(self, shapes: list[Shape]):
+ #===================================================
+ nodes: dict[str, tuple[str, tuple[str]]] = {}
+ connections: dict[str, tuple[str, str]] = {}
+ for shape in shapes:
+ if not shape.properties.get('exclude', False):
+ if shape.shape_type == SHAPE_TYPE.COMPONENT:
+ if shape.has_property('stroke'):
+ stroke = shape.get_property('stroke')
+ component_type = str(NODE_BORDER_TYPES.lookup(stroke, 'unknown'))
+ elif shape.name.startswith('$q^'):
+ component_type = 'bgf:ZeroStorage'
+ elif shape.name.startswith('$u^'):
+ component_type = 'bgf:ZeroNode'
+ elif shape.name.startswith('$v^'):
+ component_type = 'bgf:OneNode'
+ else:
+ component_type = 'unknown'
+ #
+ if component_type != 'unknown':
+ names = name_to_symbols(shape.name)
+ if component_type in ['bgf:OneNode', 'bgf:ZeroNode']:
+ self.__graph.add((MODEL[names[0]], RDF.type, BGF[component_type[4:]]))
+ nodes[shape.id] = (component_type, name_to_symbols(shape.name))
+ elif shape.shape_type == SHAPE_TYPE.CONNECTION:
+ connections[shape.id] = (shape.source, shape.target)
+ elif shape.shape_type == SHAPE_TYPE.ANNOTATION:
+ pass
+ for type, names in nodes.values():
+ if type.startswith('bgf:'):
+ self.__graph.add((MODEL[names[0]], RDF.type, BGF[type[4:]]))
+ missing_node = MODEL.MISSING
+ for shape_id, connection in connections.items():
+ if connection[0] in nodes:
+ source = MODEL[nodes[connection[0]][1][0]]
+ else:
+ log.warning(f'Missing source node shape `{connection[0]}/` for connection')
+ source = missing_node
+ if connection[1] in nodes:
+ target = MODEL[nodes[connection[1]][1][0]]
+ else:
+ log.warning(f'Missing target node shape `{connection[1]}` for connection')
+ target = missing_node
+ self.__graph.add((MODEL[svg_id(shape_id)], BGF.hasSource, source))
+ self.__graph.add((MODEL[svg_id(shape_id)], BGF.hasSource, target))
+
+#===============================================================================
diff --git a/mapmaker/output/bondgraph/namespaces.py b/mapmaker/output/bondgraph/namespaces.py
new file mode 100644
index 00000000..a26198c3
--- /dev/null
+++ b/mapmaker/output/bondgraph/namespaces.py
@@ -0,0 +1,47 @@
+#===============================================================================
+#
+# Flatmap maker and annotation tools
+#
+# Copyright (c) 2019 - 2025 David Brooks
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#===============================================================================
+
+import rdflib
+
+#===============================================================================
+
+DCT = rdflib.Namespace('http://purl.org/dc/terms/')
+CDT = rdflib.Namespace('https://w3id.org/cdt/')
+RDF = rdflib.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#')
+RDFS = rdflib.Namespace('http://www.w3.org/2000/01/rdf-schema#')
+
+#===============================================================================
+
+BG = rdflib.Namespace('http://celldl.org/ontologies/bondgraph#')
+BGF = rdflib.Namespace('http://celldl.org/ontologies/bondgraph-framework#')
+MODEL = rdflib.Namespace('#')
+
+#===============================================================================
+
+NAMESPACES = {
+ 'bg': BG,
+ 'bgf': BGF,
+ 'cdt': CDT,
+ 'dct': DCT,
+ 'rdfs': RDFS,
+ '': MODEL
+}
+
+#===============================================================================
diff --git a/mapmaker/output/geojson.py b/mapmaker/output/geojson.py
index 7e830131..ea1d8c15 100644
--- a/mapmaker/output/geojson.py
+++ b/mapmaker/output/geojson.py
@@ -25,6 +25,7 @@
#===============================================================================
+import shapely.affinity
import shapely.geometry
#===============================================================================
@@ -89,10 +90,6 @@ def __save_features(self, features):
if (value := feature.get_property(name)) is not None
and value != ''
}
- properties.update({
- name: json.dumps(value) for (name, value) in properties.items()
- if name in ENCODED_FEATURE_PROPERTIES
- })
geometry = feature.geometry
area = geometry.area
mercator_geometry = mercator_transform(geometry)
@@ -140,6 +137,13 @@ def __save_features(self, features):
if self.__flatmap.map_kind == MAP_KIND.CENTRELINE and feature.properties.get('kind') == 'centreline':
properties['coordinates'] = geojson['geometry']['coordinates']
+ # We don't want encoded JSON in ``geojson['properties']`` so ``properties`` encoding has to be
+ # after GeoJSON has been updated
+ properties.update({
+ name: json.dumps(value) for (name, value) in properties.items()
+ if name in ENCODED_FEATURE_PROPERTIES
+ })
+
# Output the anatomical nodes associated with the feature
if len(feature.anatomical_nodes):
properties['anatomical-nodes'] = feature.anatomical_nodes
diff --git a/mapmaker/properties/__init__.py b/mapmaker/properties/__init__.py
index 50ff3eed..f04f965c 100644
--- a/mapmaker/properties/__init__.py
+++ b/mapmaker/properties/__init__.py
@@ -109,6 +109,16 @@ def __init__(self, flatmap: "FlatMap", manifest: "Manifest"):
if manifest.proxy_features is not None
else [])
+ # Feature groups by layer
+ self.__feature_groups: dict[str, dict[str, list[str]]] = {}
+ for group_defns in properties_dict.get('feature-groups', []):
+ if 'layer' in group_defns:
+ feature_groups: dict[str, list[str]] = {}
+ for group in group_defns.get('groups', []):
+ if 'id' in group:
+ feature_groups[group['id']] = group.get('features', [])
+ self.__feature_groups[group_defns['layer']] = feature_groups
+
@property
def connectivity(self):
return self.__pathways.connectivity
@@ -133,6 +143,13 @@ def proxies(self):
def node_hierarchy(self):
return self.__pathways.node_hierarchy
+ """
+ Get the feature group definitions for a layer
+ """
+ def feature_groups(self, layer_id: str) -> dict[str, list[str]]:
+ #==============================================================
+ return self.__feature_groups.get(layer_id, {})
+
def network_feature(self, feature):
#==================================
# Is the ``feature`` included in some network?
@@ -151,6 +168,10 @@ def __set_feature_properties(self, features):
#============================================
if isinstance(features, dict):
for id, properties in features.items():
+ id = id.replace(' ', '_')
+ if (associated_details := properties.get('associated-details')) is not None:
+ if isinstance(associated_details, str):
+ properties['associated-details'] = [associated_details]
self.__properties_by_id[id].update(properties)
if (properties.get('type') == 'nerve'
and (entity := properties.get('models')) is not None):
@@ -199,6 +220,10 @@ def update_properties(self, feature_properties):
#===============================================
classes = feature_properties.get('class', '').split()
id = feature_properties.get('id')
+ if self.__flatmap.map_kind == MAP_KIND.FUNCTIONAL and id not in self.__properties_by_id:
+ # Use the feature's name to lookup properties when the feature has no ID
+ if (name := feature_properties.get('name', '').replace(' ', '_')) != '':
+ id = f'{feature_properties.get('layer', '')}/{name}'
if id is not None:
classes.extend(self.__properties_by_id.get(id, {}).get('class', '').split())
for cls in classes:
diff --git a/mapmaker/properties/markup.py b/mapmaker/properties/markup.py
index c2dd40b0..76c4c270 100644
--- a/mapmaker/properties/markup.py
+++ b/mapmaker/properties/markup.py
@@ -74,7 +74,8 @@
FEATURE_PROPERTIES = CLASS | CHILDCLASSES | IDENTIFIER | NAME | STYLE
-SHAPE_FLAGS = Group(Keyword('boundary')
+SHAPE_FLAGS = Group(Keyword('background')
+ | Keyword('boundary')
| Keyword('closed')
| Keyword('exterior')
| Keyword('interior')
diff --git a/mapmaker/shapes/__init__.py b/mapmaker/shapes/__init__.py
index d376466a..aa1e4e9d 100644
--- a/mapmaker/shapes/__init__.py
+++ b/mapmaker/shapes/__init__.py
@@ -25,7 +25,6 @@
#===============================================================================
-from mapmaker.settings import settings
from mapmaker.utils import log, PropertyMixin
#===============================================================================
@@ -38,26 +37,41 @@ class SHAPE_TYPE(str, Enum): ## Or IntEnum ??
CONTAINER = 'container'
GROUP = 'group'
IMAGE = 'image'
+ PORT = 'port'
TEXT = 'text'
UNKNOWN = 'unknown'
#===============================================================================
KnownProperties = ['name', 'cd-class', 'fc-class', 'fc-kind']
+HiddenProperties = [
+ 'area',
+ 'aspect',
+ 'bbox-coverage',
+ 'coverage',
+ 'fill',
+ 'geometry',
+ 'stroke-width',
+ 'svg-element',
+ 'tile-layer',
+]
class Shape(PropertyMixin):
__attributes = ['id', 'geometry', 'parents', 'children']
__shape_id_prefix: str = ''
__last_shape_id: int = 0
+ __last_shape_number: int = 0
def __init__(self, id: Optional[str], geometry: BaseGeometry, properties=None, **kwds):
self.__initialising = True
super().__init__(properties)
for key, value in kwds.items():
self.set_property(key.replace('_', '-'), value)
+ Shape.__last_shape_number += 1
+ self.__number: int = Shape.__last_shape_number
if self.has_property('id'):
- id = self.get_property('id')
+ id = self.get_property('id', '')
if Shape.__shape_id_prefix == '':
self.__id = id
else:
@@ -73,6 +87,7 @@ def __init__(self, id: Optional[str], geometry: BaseGeometry, properties=None, *
self.__geometry = geometry
if geometry is not None:
self.set_property('geometry', geometry.geom_type)
+ self.__bounds = geometry.bounds
self.__children: list[Shape] = []
self.__parents: list[Shape] = []
self.__metadata: dict[str, str] = {} # kw_only=True field for Python 3.10
@@ -93,7 +108,7 @@ def __setattr__(self, key: str, value: Any=None):
def __str__(self):
properties = {key: value for key, value in self.properties.items()
- if key in KnownProperties}
+ if key != 'id' and key not in HiddenProperties}
return f'Shape {self.id}: {properties}'
@staticmethod
@@ -138,9 +153,13 @@ def geojson_id(self) -> int:
def global_shape(self) -> 'Shape': # The shape that excluded this one via a filter
return self.get_property('global-shape', self)
+ @property
+ def height(self) -> float:
+ return abs(self.__bounds[3] - self.__bounds[1])
+
@property
def id(self) -> str:
- return self.__id
+ return self.__id # pyright: ignore[reportReturnType]
@property
def kind(self) -> Optional[str]: # The geometric name of the shape or, for an image,
@@ -154,6 +173,10 @@ def metadata(self) -> dict[str, str]:
def name(self) -> str: # Any text content associated with the shape: e.g. ``Bladder``
return self.get_property('name', '')
+ @property
+ def number(self) -> int:
+ return self.__number
+
@property
def opacity(self) -> float:
return self.get_property('opacity', 1.0)
@@ -174,6 +197,10 @@ def shape_name(self) -> str: # The name of the shape in the s
def shape_type(self) -> SHAPE_TYPE:
return self.get_property('shape-type', SHAPE_TYPE.UNKNOWN)
+ @property
+ def width(self) -> float:
+ return abs(self.__bounds[2] - self.__bounds[0])
+
def add_parent(self, parent):
self.parents.append(parent)
parent.children.append(self)
diff --git a/mapmaker/shapes/classify.py b/mapmaker/shapes/classify.py
index b71dc294..8c8c832a 100644
--- a/mapmaker/shapes/classify.py
+++ b/mapmaker/shapes/classify.py
@@ -35,12 +35,14 @@
from mapmaker.flatmap.layers import PATHWAYS_TILE_LAYER
from mapmaker.settings import settings
from mapmaker.shapes import Shape, SHAPE_TYPE
-from mapmaker.sources.fc_powerpoint.components import VASCULAR_KINDS
from mapmaker.utils import log
from .constants import COMPONENT_BORDER_WIDTH, CONNECTION_STROKE_WIDTH, MAX_LINE_WIDTH
+from .constants import MIN_TEXT_INSIDE, SHAPE_ERROR_COLOUR, SHAPE_ERROR_BORDER
+
from .line_finder import Line, LineFinder, XYPair
from .text_finder import TextFinder
+from .types import VASCULAR_KINDS
#===============================================================================
@@ -86,9 +88,9 @@ def line_string(self):
def end_line(self, end: int) -> Line:
if end == 0:
- return Line.from_coords((self.__coords[0], self.__coords[1]))
+ return Line.from_coords((self.__coords[0], self.__coords[1])) # pyright: ignore[reportArgumentType]
else:
- return Line.from_coords((self.__coords[-2], self.__coords[-1]))
+ return Line.from_coords((self.__coords[-2], self.__coords[-1])) # pyright: ignore[reportArgumentType]
#===============================================================================
@@ -103,8 +105,11 @@ def __init__(self, shapes: list[Shape], map_area: float, metres_per_pixel: float
self.__connection_ends_to_shape: dict[int, ConnectionEnd] = {}
self.__max_line_width = metres_per_pixel*MAX_LINE_WIDTH
connection_joiners: list[Shape] = []
- geometries = []
+ component_geometries = []
for n, shape in enumerate(shapes):
+ if shape.get_property('background', False):
+ shape.set_property('exclude', True)
+ continue
geometry = shape.geometry
area = geometry.area
self.__bounds = geometry.bounds
@@ -129,88 +134,92 @@ def __init__(self, shapes: list[Shape], map_area: float, metres_per_pixel: float
elif ((n < len(shapes) - 1) and shapes[n+1].shape_type == SHAPE_TYPE.TEXT
and coverage < 0.5 and bbox_coverage < 0.001):
shape.properties['exclude'] = True
- elif coverage < 0.4 or 'LineString' in geometry.geom_type:
+ elif 'LineString' in geometry.geom_type or coverage < 0.4 and 'Multi' not in geometry.geom_type:
if not self.__add_connection(shape):
log.warning('Cannot extract line from polygon', shape=shape.id)
+ elif 'Multi' not in geometry.boundary.geom_type and len(shape.geometry.boundary.coords) == 4: # A triangle
+ connection_joiners.append(shape)
+ shape.properties['shape-type'] = SHAPE_TYPE.PORT
elif bbox_coverage > 0.001 and coverage > 0.9:
- shape.properties['shape-type'] = SHAPE_TYPE.CONTAINER
- elif bbox_coverage < 0.0005 and aspect > 0.9 and 0.7 < coverage <= 0.85:
- shape.properties['shape-type'] = SHAPE_TYPE.COMPONENT
- elif bbox_coverage < 0.001 and coverage > 0.85:
+ shape.properties['shape-type'] = SHAPE_TYPE.CONTAINER if bbox_coverage > 0.2 else SHAPE_TYPE.COMPONENT
+ elif bbox_coverage < 0.0003 and 0.7 < coverage <= 0.8:
+ shape.properties['shape-type'] = SHAPE_TYPE.ANNOTATION
+ elif bbox_coverage < 0.001 and coverage > 0.75:
shape.properties['shape-type'] = SHAPE_TYPE.COMPONENT
- elif len(shape.geometry.boundary.coords) == 4: # A triangle
- connection_joiners.append(shape)
elif not self.__add_connection(shape):
log.warning('Unclassifiable shape', shape=shape.id)
- shape.properties['colour'] = 'yellow'
+ if settings.get('authoring', False):
+ shape.properties['exclude'] = True
+ else:
+ shape.properties['colour'] = SHAPE_ERROR_COLOUR
if not shape.properties.get('exclude', False):
self.__shapes_by_type[shape.shape_type].append(shape)
- if shape.shape_type != SHAPE_TYPE.CONNECTION:
+ if shape.shape_type in [SHAPE_TYPE.ANNOTATION,
+ SHAPE_TYPE.COMPONENT,
+ SHAPE_TYPE.PORT,
+ SHAPE_TYPE.TEXT]:
self.__geometry_to_shape[id(shape.geometry)] = shape
- geometries.append(shape.geometry)
+ component_geometries.append(shape.geometry)
+ if shape.shape_type in [SHAPE_TYPE.COMPONENT,
+ SHAPE_TYPE.PORT]:
shape.properties['stroke-width'] = COMPONENT_BORDER_WIDTH
- connection_index = shapely.strtree.STRtree(self.__connection_ends)
+ # An index for component geometries
+ self.__component_index = shapely.strtree.STRtree(component_geometries)
+ self.__component_geometries: list[BaseGeometry] = self.__component_index.geometries # type: ignore
- joined_connection_graph = nx.Graph()
- for joiner in connection_joiners:
- ends = connection_index.query_nearest(joiner.geometry) #, max_distance=10*metres_per_pixel*MAX_LINE_WIDTH)
- if len(ends) == 2:
- joiner.properties['exclude'] = True
- (connection_0, connection_1) = self.__extend_joined_connections(ends)
- joined_connection_graph.add_edge(connection_0, connection_1)
- else:
- joiner.properties['colour'] = 'yellow'
- joiner.properties['stroke'] = 'red'
- joiner.properties['stroke-width'] = COMPONENT_BORDER_WIDTH
- joiner.geometry = joiner.geometry.buffer(self.__max_line_width)
- for joined_connection in nx.connected_components(joined_connection_graph):
- connections = list(joined_connection)
- connected_line = shapely.line_merge(shapely.unary_union([conn.geometry for conn in connections]))
- assert connected_line.geom_type == 'LineString', f'Cannot join connections: {[conn.id for conn in connections]}'
- connections[0].geometry = connected_line
- for connection in connections[1:]:
- if connection.properties.get('directional', False):
- connections[0].properties['directional'] = True
- connection.properties['exclude'] = True
+ # If possible, join connections that share a triangular joiner
+ self.__join_connections(connection_joiners)
- self.__str_index = shapely.strtree.STRtree(geometries)
- geometries: list[BaseGeometry] = self.__str_index.geometries # type: ignore
- parent_child = []
- for geometry in geometries:
- if geometry.area > 0:
- parent = self.__geometry_to_shape[id(geometry)]
- for child in [self.__geometry_to_shape[id(geometries[c])]
- for c in self.__str_index.query(geometry, predicate='contains_properly')
- if geometries[c].area > 0]:
- parent_child.append((parent, child))
- last_child_id = None
- for (parent, child) in sorted(parent_child, key=lambda s: (s[1].id, s[0].geometry.area)):
- if child.id != last_child_id:
- child.add_parent(parent)
- last_child_id = child.id
+ # Set parent/child relationship for components
+ self.__set_parent_relationships()
+
+ # Assign text labels to components and source and target of connections
+ for shape in self.__shapes:
+ if shape.shape_type in [SHAPE_TYPE.ANNOTATION, SHAPE_TYPE.COMPONENT]:
+ if (name_and_shapes := self.__text_finder.get_text(shape)) is not None:
+ shape.properties['name'] = name_and_shapes[0]
+ shape.properties['text-shapes'] = name_and_shapes[1]
+ # Although we do want their text, we don't want annotations to be active features
+ elif shape.shape_type == SHAPE_TYPE.CONNECTION:
+ line_ends: shapely.geometry.base.GeometrySequence[shapely.MultiPoint] = shape.geometry.boundary.geoms # type: ignore
+ self.__connect_line_end(shape, line_ends[0], 'source')
+ self.__connect_line_end(shape, line_ends[1], 'target')
+ elif shape.shape_type == SHAPE_TYPE.CONTAINER:
+ shape.properties['exclude'] = True
+
+ @property
+ def shapes(self) -> list[Shape]:
+ #===============================
+ return [s for s in self.__shapes if not s.exclude]
def __add_connection(self, shape: Shape) -> bool:
#================================================
- if 'Polygon' in shape.geometry.geom_type:
+ if shape.geometry.geom_type == 'MultiPolygon':
+ return False
+ elif 'Polygon' in shape.geometry.geom_type:
if (line := self.__line_finder.get_line(shape)) is None:
- shape.properties['exclude'] = not settings.get('authoring', False)
- shape.properties['colour'] = 'yellow'
+ if settings.get('authoring', False):
+ shape.properties['exclude'] = True
+ else:
+ shape.properties['colour'] = SHAPE_ERROR_COLOUR
return False
shape.geometry = line
- kind = VASCULAR_KINDS.lookup(shape.properties.get('fill'))
+ colour = shape.properties.get('fill')
else:
- kind = VASCULAR_KINDS.lookup(shape.properties.get('stroke'))
- assert shape.geometry.geom_type == 'LineString', f'Connection not a LineString: {shape.id}'
- line_ends: shapely.geometry.base.GeometrySequence[shapely.MultiPoint] = shape.geometry.boundary.geoms # type: ignore
- self.__append_connection_ends(line_ends[0], shape, 0)
- self.__append_connection_ends(line_ends[1], shape, -1)
- if kind is not None:
+ colour = shape.properties.get('stroke')
+ if colour is not None:
+ shape.properties['colour'] = colour
+ if (kind := VASCULAR_KINDS.lookup(colour)) is not None:
shape.properties['kind'] = kind
shape.properties['shape-type'] = SHAPE_TYPE.CONNECTION
shape.properties['tile-layer'] = PATHWAYS_TILE_LAYER
shape.properties['stroke-width'] = CONNECTION_STROKE_WIDTH
- shape.properties['type'] = 'line' ## or 'line-dash'
+ shape.properties['type'] = 'line-dash' if shape.get_property('dashed', False) else 'line'
+ assert shape.geometry.geom_type == 'LineString', f'Connection not a LineString: {shape.id}'
+ line_ends: shapely.geometry.base.GeometrySequence[shapely.MultiPoint] = shape.geometry.boundary.geoms # type: ignore
+ self.__append_connection_ends(line_ends[0], shape, 0)
+ self.__append_connection_ends(line_ends[1], shape, -1)
return True
def __append_connection_ends(self, end: shapely.Point, shape: Shape, index: int):
@@ -219,10 +228,19 @@ def __append_connection_ends(self, end: shapely.Point, shape: Shape, index: int)
self.__connection_ends.append(end_circle)
self.__connection_ends_to_shape[id(end_circle)] = ConnectionEnd(shape, index)
+ def __connect_line_end(self, shape: Shape, end: shapely.Point, property: str):
+ #=============================================================================
+ for child in [self.__geometry_to_shape[id(self.__component_geometries[c])]
+ for c in self.__component_index.query(end.buffer(self.__max_line_width), predicate='intersects')
+ if self.__component_geometries[c].area > 0]:
+ child.append_property('connections', shape.id)
+ if not child.exclude and child.shape_type in [SHAPE_TYPE.COMPONENT, SHAPE_TYPE.PORT]:
+ shape.set_property(property, child.id)
+ return
+
def __extend_joined_connections(self, ends: ndarray) -> tuple[Shape, Shape]:
#===========================================================================
# Extend connection line ends so that they touch...
-
c0 = self.__connection_ends_to_shape[id(self.__connection_ends[ends[0]])]
c1 = self.__connection_ends_to_shape[id(self.__connection_ends[ends[1]])]
l0 = LineString(c0.shape.geometry)
@@ -237,12 +255,57 @@ def __extend_joined_connections(self, ends: ndarray) -> tuple[Shape, Shape]:
c1.shape.geometry = l1.line_string
return (c0.shape, c1.shape)
- def classify(self) -> list[Shape]:
- #=================================
- for shape in self.__shapes:
- if shape.shape_type in [SHAPE_TYPE.COMPONENT, SHAPE_TYPE.CONTAINER]:
- if (label := self.__text_finder.get_text(shape)) is not None:
- shape.properties['label'] = label
- return [s for s in self.__shapes if not s.exclude]
+ def __join_connections(self, connection_joiners):
+ #================================================
+ connection_index = shapely.strtree.STRtree(self.__connection_ends)
+ joined_connection_graph = nx.Graph()
+ for joiner in connection_joiners:
+ ends = connection_index.query_nearest(joiner.geometry)
+ if len(ends) == 1:
+ continue
+ elif len(ends) == 2:
+ joiner.properties['exclude'] = True
+ (connection_0, connection_1) = self.__extend_joined_connections(ends)
+ joined_connection_graph.add_edge(connection_0, connection_1)
+ else:
+ joiner.properties['colour'] = SHAPE_ERROR_COLOUR
+ joiner.properties['stroke'] = SHAPE_ERROR_BORDER
+ joiner.properties['stroke-width'] = COMPONENT_BORDER_WIDTH
+ joiner.geometry = joiner.geometry.buffer(self.__max_line_width)
+ for joined_connection in nx.connected_components(joined_connection_graph):
+ connections = list(joined_connection)
+ connected_line = shapely.line_merge(shapely.unary_union([conn.geometry for conn in connections]))
+ assert connected_line.geom_type == 'LineString', f'Cannot join connections: {[conn.id for conn in connections]}'
+
+ # Need to check all segments have the same colour...
+
+ connections[0].geometry = connected_line
+ for connection in connections[1:]:
+ if connection.properties.get('directional', False):
+ connections[0].properties['directional'] = True
+ connection.properties['exclude'] = True
+
+ def __set_parent_relationships(self):
+ #====================================
+ parent_child = []
+ for geometry in self.__component_geometries:
+ if geometry.area > 0:
+ parent = self.__geometry_to_shape[id(geometry)]
+ bbox_intersecting_shapes = [self.__geometry_to_shape[id(self.__component_geometries[c])]
+ for c in self.__component_index.query(geometry)
+ if self.__component_geometries[c].area > 0]
+ for shape in bbox_intersecting_shapes:
+ if parent.shape_type != SHAPE_TYPE.TEXT and parent.id != shape.id:
+ # A text shape is always a child even when not properly contained
+ if (shapely.contains_properly(parent.geometry, shape.geometry)
+ or (shape.shape_type == SHAPE_TYPE.TEXT
+ and parent.geometry.intersection(shape.geometry).area/shape.geometry.area > MIN_TEXT_INSIDE)):
+ parent_child.append((parent, shape))
+ last_child_id = None
+ for (parent, child) in sorted(parent_child, key=lambda s: (s[1].id, s[0].geometry.area)):
+ # Sorted by child id with smallest parent first when there are multiple parents
+ if child.id != last_child_id:
+ child.add_parent(parent)
+ last_child_id = child.id
#===============================================================================
diff --git a/mapmaker/sources/fc_powerpoint/colours.py b/mapmaker/shapes/colours.py
similarity index 96%
rename from mapmaker/sources/fc_powerpoint/colours.py
rename to mapmaker/shapes/colours.py
index b1ae9c93..b4e0ac3d 100644
--- a/mapmaker/sources/fc_powerpoint/colours.py
+++ b/mapmaker/shapes/colours.py
@@ -67,11 +67,12 @@ def __init__(self, lookup_table: dict[str, Any]):
for key, value in lookup_table.items()
}
- def lookup(self, colour: Optional[str]) -> Optional[Any]:
+ def lookup(self, colour: Optional[str], default: Optional[Any]=None) -> Optional[Any]:
if colour is not None and colour != 'none':
lab_colour = convert_color(sRGBColor.new_from_rgb_hex(colour), LabColor)
for key, value in self.__lookup_table.items():
if delta_e_cie2000(lab_colour, key) < CLOSE_COLOUR_DISTANCE:
return value
+ return default
#===============================================================================
diff --git a/mapmaker/shapes/constants.py b/mapmaker/shapes/constants.py
index 97a06788..ca40f973 100644
--- a/mapmaker/shapes/constants.py
+++ b/mapmaker/shapes/constants.py
@@ -23,6 +23,11 @@
#===============================================================================
+# The ratio of the difference between the lengths of a candidate arrow's point
+# edges and their sum must be less than 0.001
+
+ARROW_POINT_EPSILON = 1e-3
+
# The ratio of the actual overlap to the combined length of parallel edges needs
# to be at least 0.6 for them to be candidates for merging into a line
@@ -37,9 +42,14 @@
MAX_LINE_WIDTH = 20 # Close together parallel edges a polygons are converted to lines
-MAX_TEXT_VERTICAL_OFFSET = 5 # Between cluster baseline and baselines of text in the cluster
+MAX_TEXT_VERTICAL_OFFSET = 3 # Between cluster baseline and baselines of text in the cluster
TEXT_BASELINE_OFFSET = -14.5 # From vertical centre of a component
+TEXT_COMPONENT_HEIGHT = 75000 # World metres height of an "average" component
+
+# Text shapes need at least 80% containment in their parent
+MIN_TEXT_INSIDE = 0.8
+
#===============================================================================
# Scaling factors for styling components and connections in map viewers
@@ -48,3 +58,8 @@
CONNECTION_STROKE_WIDTH = 2
#===============================================================================
+
+SHAPE_ERROR_COLOUR = 'yellow'
+SHAPE_ERROR_BORDER = 'red'
+
+#===============================================================================
diff --git a/mapmaker/shapes/line_finder.py b/mapmaker/shapes/line_finder.py
index d76d8c81..f4db0dc2 100644
--- a/mapmaker/shapes/line_finder.py
+++ b/mapmaker/shapes/line_finder.py
@@ -34,9 +34,11 @@
from mapmaker.utils import log
from mapmaker.shapes import Shape
-from mapmaker.shapes.constants import EPSILON, LINE_OVERLAP_RATIO
+from mapmaker.shapes.constants import ARROW_POINT_EPSILON, EPSILON, LINE_OVERLAP_RATIO
from mapmaker.shapes.constants import MAX_PARALLEL_SKEW, MAX_LINE_WIDTH, MIN_LINE_ASPECT_RATIO
+from mapmaker.shapes.constants import SHAPE_ERROR_COLOUR
+
#===============================================================================
type Coordinate = tuple[float, float]
@@ -273,35 +275,53 @@ def __init__(self, scaling: float):
def get_line(self, shape: Shape) -> Optional[LineString]:
#========================================================
+ if 'Multi' in shape.geometry.boundary.geom_type:
+ return
ends_graph = nx.Graph()
used_lines: set[Line] = set()
mid_lines: list[Line] = []
+ unused_boundary_lines: set[Line] = set()
boundary_coords = shape.geometry.boundary.simplify(self.__epsilon).coords
- shapely.prepare(shape.geometry)
- boundary_line_coords = zip(boundary_coords, boundary_coords[1:])
- for (line0, line1) in itertools.combinations(boundary_line_coords, 2):
- l0 = Line.from_coords(line0)
- l1 = Line.from_coords(line1)
- if l0.parallel(l1):
- p0 = HorizontalLine.from_line(l0)
- p1 = p0.project(l1)
-
- if trace:
- print('PAR', p0.separation(p1), self.__max_line_width, p0.overlap(p1), shape.id, p0, p1)
+ shapely.prepare(shape.geometry)
+ boundary_line_coord_pairs = zip(boundary_coords, boundary_coords[1:])
+ boundary_lines = [Line.from_coords(coords) for coords in boundary_line_coord_pairs] # pyright: ignore[reportArgumentType]
+ unused_boundary_lines = set(boundary_lines)
+ for (line0, line1) in itertools.combinations(boundary_lines, 2):
+ # Iterate over all pairs of line segments that make up the shape's boundary
+ if line0.parallel(line1):
+ # Two line segments are parallel -- are they adjacent sides of a centreline?
+ p0 = HorizontalLine.from_line(line0)
+ p1 = p0.project(line1)
# reject if centroid of overlapping region isn't inside the shape's polygon
if ((pt := p0.mid_point(p1)) is not None
- and shapely.contains_xy(shape.geometry, pt.x, pt.y)):
- if ((w := p0.separation(p1)) <= self.__max_line_width
- and p0.overlap(p1, True) > MIN_LINE_ASPECT_RATIO*w
- and p0.overlap(p1, False)/p0.overlap(p1, True) >= LINE_OVERLAP_RATIO):
+ and shapely.contains_xy(shape.geometry, pt.x, pt.y)
+ and p0.separation(p1)) <= self.__max_line_width:
+ # Centroid of overlapping region is inside the shape's polygon
+ # and distance between lines is less than scaled MAX_LINE_WIDTH
+ if (p0.overlap(p1, False) > 0
+ and p0.overlap(p1, False)/p0.overlap(p1, True) >= LINE_OVERLAP_RATIO):
mid_lines.append(p0.mid_line(p1))
- used_lines.update([l0, l1])
- elif (pt := l0.intersection(l1)) is not None:
- ends_graph.add_edge(l0, l1, intersection=pt)
+ used_lines.update([line0, line1])
+ unused_boundary_lines.remove(line0)
+ unused_boundary_lines.remove(line1)
+ elif (pt := line0.intersection(line1)) is not None:
+ # Non parallel line pair that intersect without extension
+ ends_graph.add_edge(line0, line1, intersection=pt)
+
+ # ``Use`` any boundary line parallel to a mid-line and within
+ # MAX_LINE_WIDTH/2 of it
+ for ml in mid_lines:
+ for ul in unused_boundary_lines:
+ if ul.parallel(ml):
+ mh = HorizontalLine.from_line(ml)
+ uh = mh.project(ul)
+ if (uh.separation(mh) < self.__max_line_width/2
+ and uh.overlap(mh, False) > self.__epsilon):
+ used_lines.add(ul)
+
ends_graph.remove_nodes_from(used_lines)
- if len(mid_lines) == 1:
- # Only a single line segment
+ if len(mid_lines) == 1: # Only a single line segment
line_points = [mid_lines[0].p0, mid_lines[0].p1]
elif len(mid_lines) == 0:
line_points = []
@@ -319,9 +339,6 @@ def get_line(self, shape: Shape) -> Optional[LineString]:
if connecting_line:
i0 = l0.intersection(connecting_line, True)
i1 = l1.intersection(connecting_line, True)
- if trace:
- print(i0, l0.string, connecting_line.string)
- print(i1, connecting_line.string, l1.string)
if i0 is not None and i1 is not None:
G.add_edge(l0, connecting_line, intersection=i0)
G.add_edge(connecting_line, l1, intersection=i1)
@@ -362,7 +379,7 @@ def get_line(self, shape: Shape) -> Optional[LineString]:
distances = [ p0.distance(p1)
for (p0, p1) in itertools.pairwise(points + [points[0]])]
for (n, (d0, d1)) in enumerate(itertools.pairwise([distances[-1]] + distances)):
- if abs(d0 - d1) <= EPSILON:
+ if abs(d0 - d1)/(d0 + d1) <= ARROW_POINT_EPSILON:
arrow_line = Line(points[n-2].midpoint(points[n-1]), points[n])
if arrow_line.p0.distance(line_points[0]) < arrow_line.p0.distance(line_points[-1]):
line_points[0] = arrow_line.p1
@@ -371,6 +388,9 @@ def get_line(self, shape: Shape) -> Optional[LineString]:
line_points[-1] = arrow_line.p1
shape.properties['directional'] = True
break
+ elif len(end_nodes) != 1:
+ shape.properties['stroke'] = SHAPE_ERROR_COLOUR
+ log.warning('Bad arrow shape?', shape=shape.id, nodes=len(end_nodes))
return LineString([pt.coords for pt in line_points]) if len(line_points) >= 2 else None
diff --git a/mapmaker/shapes/text_finder.py b/mapmaker/shapes/text_finder.py
index 472bf0be..2c73ccf2 100644
--- a/mapmaker/shapes/text_finder.py
+++ b/mapmaker/shapes/text_finder.py
@@ -18,12 +18,13 @@
#
#===============================================================================
+import re
from typing import Optional
#===============================================================================
from . import Shape, SHAPE_TYPE
-from .constants import MAX_TEXT_VERTICAL_OFFSET, TEXT_BASELINE_OFFSET
+from .constants import MAX_TEXT_VERTICAL_OFFSET, TEXT_BASELINE_OFFSET, TEXT_COMPONENT_HEIGHT
#===============================================================================
@@ -36,7 +37,11 @@ def __init__(self, shape: Optional[Shape]=None):
@property
def baseline(self) -> float:
- return self.__baselines/len(self.__shapes) if len(self.__shapes) else 0
+ return self.__shapes[0].baseline
+
+ @property
+ def left(self) -> float:
+ return self.__shapes[0].left
@property
def shapes(self) -> list[Shape]:
@@ -51,47 +56,148 @@ def left_sort_shapes(self):
#===============================================================================
+SUBSCRIPT_CHAR = '_'
+SUPERSCRIPT_CHAR = '^'
+
+#===============================================================================
+
+CHEMICAL_SYMBOLS = {
+ 'Ca^{2+}': 'Ca2+',
+ 'CO_{2}': 'CO2',
+ 'Glc': 'Glc',
+ 'H_{2}O': 'H2O',
+ 'K^{+}': 'K+',
+ 'Na': 'Na',
+ 'Na^{+}': 'Na+',
+ 'O_{2}': 'O2',
+}
+
+#===============================================================================
+
+class LatexMaker:
+ def __init__(self):
+ self.__latex = []
+ self.__state = 0
+ self.__subscripted = False
+ self.__text = []
+
+ @property
+ def latex(self) -> str:
+ #======================
+ self.__make_latex()
+ latex = ''.join(self.__latex)
+ if (chem := CHEMICAL_SYMBOLS.get(latex)) is not None:
+ latex = f'\\ce{{{chem}}}'
+ return latex
+
+ def add_text(self, text: str, state: int):
+ #=========================================
+ if state != self.__state:
+ self.__make_latex()
+ self.__state = state
+ if text != '':
+ self.__text.append(text)
+
+ def __make_latex(self):
+ #======================
+ if len(self.__text):
+ if self.__state < 0:
+ self.__latex.append(f'{SUBSCRIPT_CHAR}{{{''.join(self.__text)}}}')
+ self.__subscripted = True
+ elif self.__state > 0:
+ superscript = f'{SUPERSCRIPT_CHAR}{{{''.join(self.__text)}}}'
+ if self.__subscripted:
+ self.__latex.insert(-1, superscript)
+ else:
+ self.__latex.append(superscript)
+ else:
+ self.__latex.append(''.join(self.__text))
+ self.__subscripted = False
+ self.__text = []
+
+#===============================================================================
+
class TextFinder:
def __init__(self, scaling: float):
+ self.__latex_re = re.compile(f'{SUBSCRIPT_CHAR}|\\{SUPERSCRIPT_CHAR}|{{')
self.__max_text_vertical_offset = scaling * MAX_TEXT_VERTICAL_OFFSET
self.__text_baseline_offset = scaling * TEXT_BASELINE_OFFSET
+ self.__shape_scaling = 1.0
- def get_text(self, shape: Shape) -> Optional[str]:
- #=================================================
+ def get_text(self, shape: Shape) -> Optional[tuple[str, list[Shape]]]:
+ #=====================================================================
+ self.__shape_scaling = shape.height/TEXT_COMPONENT_HEIGHT
text_shapes = [s for s in shape.children if s.shape_type == SHAPE_TYPE.TEXT]
text_clusters = self.__cluster_text(text_shapes)
- text_baseline = (shape.geometry.bounds[1] + shape.geometry.bounds[3])/2 + self.__text_baseline_offset
- base_text = superscript = subscript = ''
- for cluster in text_clusters:
- if (cluster.baseline + self.__max_text_vertical_offset) < text_baseline:
- subscript = self.__text_block_to_text(cluster.shapes)
- elif (cluster.baseline - self.__max_text_vertical_offset) > text_baseline:
- superscript = self.__text_block_to_text(cluster.shapes)
- else:
- base_text = self.__text_block_to_text(cluster.shapes)
- text = f'${base_text}{f"_{{{subscript}}}" if subscript != "" else ""}{f"^{{{superscript}}}" if superscript != "" else ""}$'
- return text if text != '' else None
+ if len(text_clusters) == 0:
+ return None
+ offset = self.__shape_scaling*self.__max_text_vertical_offset
+ baseline = text_clusters[0].baseline
+ state = 0
+ clusters = []
+ latex = LatexMaker()
+ used_text_shapes = []
+ if len(text_clusters) == 1:
+ latex.add_text(self.__text_block_to_text(text_clusters[0].shapes), 0)
+ used_text_shapes.extend(text_clusters[0].shapes)
+ else:
+ for cluster in text_clusters:
+ if cluster.baseline < (baseline - offset):
+ if state > 0 and len(clusters):
+ latex.add_text(self.__text_clusters_to_text(clusters), state)
+ clusters = []
+ clusters.append(cluster)
+ state = -1
+ elif cluster.baseline > (baseline + offset):
+ if state < 0 and len(clusters):
+ latex.add_text(self.__text_clusters_to_text(clusters), state)
+ clusters = []
+ clusters.append(cluster)
+ state = 1
+ else:
+ if state != 0 and len(clusters):
+ latex.add_text(self.__text_clusters_to_text(clusters), state)
+ clusters = []
+ latex.add_text(self.__text_block_to_text(cluster.shapes), 0)
+ state = 0
+ used_text_shapes.extend(cluster.shapes)
+ if len(clusters):
+ latex.add_text(self.__text_clusters_to_text(clusters), state)
+ text = f'${text}$' if self.__latex_re.search(text:=latex.latex) else text.replace('\\ ', ' ')
+ return (text, used_text_shapes) if text != '' else None
def __text_block_to_text(self, text_block: list[Shape]) -> str:
#==============================================================
return f'{''.join([s.text for s in text_block])}'.replace(' ', '\\ ')
+ def __text_clusters_to_text(self, text_clusters: list[TextShapeCluster]) -> str:
+ #===============================================================================
+ baseline = text_clusters[0].baseline
+ offset = 0.9*self.__shape_scaling*self.__max_text_vertical_offset
+ latex = LatexMaker()
+ for cluster in text_clusters:
+ if cluster.baseline < (baseline - offset):
+ latex.add_text(self.__text_block_to_text(cluster.shapes), -1)
+ elif cluster.baseline > (baseline + offset):
+ latex.add_text(self.__text_block_to_text(cluster.shapes), 1)
+ else:
+ latex.add_text(self.__text_block_to_text(cluster.shapes), 0)
+ return latex.latex
+
def __cluster_text(self, text_shapes: list[Shape]) -> list[TextShapeCluster]:
#============================================================================
- baseline_ordered_shapes = sorted(text_shapes, key=lambda s: s.baseline)
+ offset = self.__shape_scaling*self.__max_text_vertical_offset
+ shapes_seen_order = sorted(text_shapes, key=lambda s: s.number)
clusters: list[TextShapeCluster] = []
current_cluster = None
- for shape in baseline_ordered_shapes:
+ for shape in shapes_seen_order:
if (current_cluster is None
- or (shape.baseline - current_cluster.baseline) > self.__max_text_vertical_offset):
+ or abs(shape.baseline - current_cluster.baseline) > offset):
current_cluster = TextShapeCluster(shape)
clusters.append(current_cluster)
else:
- # Note: ``current_cluster.baseline`` is monotonically increasing
current_cluster.add_shape(shape)
shape.properties['exclude'] = True
- for cluster in clusters:
- cluster.left_sort_shapes()
return clusters
#===============================================================================
diff --git a/mapmaker/sources/fc_powerpoint/components.py b/mapmaker/shapes/types.py
similarity index 99%
rename from mapmaker/sources/fc_powerpoint/components.py
rename to mapmaker/shapes/types.py
index 2d10b3f5..525aafc3 100644
--- a/mapmaker/sources/fc_powerpoint/components.py
+++ b/mapmaker/shapes/types.py
@@ -20,8 +20,8 @@
from mapmaker.knowledgebase.sckan import PATH_TYPE
from mapmaker.knowledgebase.celldl import CD_CLASS, FC_CLASS, FC_KIND
-from mapmaker.shapes import SHAPE_TYPE
+from .import SHAPE_TYPE
from .colours import ColourMatcher, ColourMatcherDict
#===============================================================================
diff --git a/mapmaker/sources/__init__.py b/mapmaker/sources/__init__.py
index 86a49ec1..12ab7c5e 100644
--- a/mapmaker/sources/__init__.py
+++ b/mapmaker/sources/__init__.py
@@ -18,7 +18,6 @@
#
#===============================================================================
-from io import BytesIO
from typing import Callable, Optional, TYPE_CHECKING
#===============================================================================
@@ -29,13 +28,14 @@
#===============================================================================
from mapmaker.geometry import bounds_to_extent, MapBounds, Transform
-from mapmaker.flatmap import SourceManifest, SOURCE_DETAIL_KINDS
-from mapmaker.flatmap.layers import PATHWAYS_TILE_LAYER
+from mapmaker.flatmap import SourceBackground, SourceManifest, SOURCE_DETAIL_KINDS
+from mapmaker.flatmap.layers import MapLayer, PATHWAYS_TILE_LAYER
from mapmaker.properties.markup import parse_markup
+from mapmaker.shapes import Shape
from mapmaker.utils import FilePath
if TYPE_CHECKING:
- from mapmaker.flatmap import FlatMap, MapLayer
+ from mapmaker.flatmap import FlatMap
#===============================================================================
@@ -113,7 +113,7 @@ def mask_image(image, mask_polygon):
if image.shape[2] == 4:
mask[:, :, 3] = 0
mask_color = (0,)*image.shape[2]
- cv2.fillPoly(mask, np.array([mask_polygon.exterior.coords], dtype=np.int32),
+ cv2.fillPoly(mask, np.array([mask_polygon.exterior.coords], dtype=np.int32), # type: ignore
color=mask_color, lineType=cv2.LINE_AA)
return cv2.bitwise_or(image, mask)
@@ -131,29 +131,39 @@ def __init__(self, flatmap: 'FlatMap', source_manifest: SourceManifest):
self.__kind = source_manifest.kind
self.__source_range = source_manifest.source_range
self.__errors: list[tuple[str, str]] = []
- self.__layers: list['MapLayer'] = []
+ self.__layers: list[MapLayer] = []
self.__bounds: MapBounds = (0, 0, 0, 0)
- self.__raster_source = None
+ self.__raster_sources = None
+ self.__background_raster_source = source_manifest.background_source
+ self.__zoom_point_id = None
if self.__kind in SOURCE_DETAIL_KINDS:
if source_manifest.feature is None:
raise ValueError('A `detail` source must specify an existing `feature`')
if source_manifest.zoom < 1:
raise ValueError('A `detail` source must specify `zoom`')
- if ((feature := flatmap.get_feature_by_name(source_manifest.feature)) is None
- and (feature := flatmap.get_feature(source_manifest.feature)) is None):
+ if (feature := flatmap.get_feature(source_manifest.feature)) is None:
raise ValueError(f'Unknown source feature: {source_manifest.feature}')
feature.set_property('maxzoom', source_manifest.zoom-1)
- feature.set_property('kind', 'expandable')
+ if source_manifest.kind == 'functional':
+ details_for = source_manifest.details if source_manifest.details is not None else source_manifest.feature
+ if (detail_feature := flatmap.get_feature(details_for)) is None:
+ raise ValueError(f'Unknown source feature: {details_for}')
+ self.__zoom_point_id = flatmap.add_details_layer(detail_feature, self.id, source_manifest.description)
self.__min_zoom = source_manifest.zoom
self.__base_feature = feature
else:
self.__min_zoom = flatmap.min_zoom
self.__base_feature = None
+ self.__feature_alignment = source_manifest.alignment
@property
def annotator(self):
return None
+ @property
+ def background_raster_source(self) -> Optional[SourceBackground]:
+ return self.__background_raster_source
+
@property
def base_feature(self):
return self.__base_feature
@@ -195,7 +205,7 @@ def kind(self) -> str:
return self.__kind
@property
- def layers(self) -> list['MapLayer']:
+ def layers(self) -> list[MapLayer]:
return self.__layers
@property
@@ -207,10 +217,10 @@ def min_zoom(self):
return self.__min_zoom
@property
- def raster_source(self) -> 'RasterSource':
- if self.__raster_source is None:
- self.__raster_source = self.get_raster_source()
- return self.__raster_source # type: ignore
+ def raster_sources(self) -> list['RasterSource']:
+ if self.__raster_sources is None:
+ self.__raster_sources = self.get_raster_sources()
+ return self.__raster_sources # type: ignore
@property
def href(self):
@@ -224,8 +234,15 @@ def source_range(self) -> Optional[list[int]]:
def transform(self) -> Optional[Transform]:
return None
- def add_layer(self, layer: 'MapLayer'):
- #======================================
+ @property
+ def zoom_point_id(self):
+ return self.__zoom_point_id
+
+ def add_layer(self, layer: MapLayer):
+ #====================================
+ layer.create_feature_groups()
+ if len(self.__feature_alignment):
+ layer.align_layer(self.__feature_alignment)
self.__layers.append(layer)
def create_preview(self):
@@ -236,8 +253,8 @@ def error(self, kind: str, msg: str):
#====================================
self.__errors.append((kind, msg))
- def filter_map_shape(self, shape):
- #=================================
+ def filter_map_shape(self, shape: Shape):
+ #========================================
return
def map_area(self) -> float:
@@ -272,18 +289,28 @@ def process(self) -> None:
#=========================
raise TypeError('`process()` must be implemented by `MapSource` sub-class')
- def get_raster_source(self) -> Optional['RasterSource']:
- #=======================================================
- return None
+ def get_raster_sources(self) -> list['RasterSource']:
+ #====================================================
+ return []
#===============================================================================
class RasterSource(object):
- def __init__(self, kind: str, get_data: Callable[[], bytes], source_path: Optional[FilePath]=None):
+ def __init__(self, id: str, kind: str, get_data: Callable[[], bytes],
+ map_source: MapSource, source_path: Optional[FilePath]=None,
+ background_layer: bool=False, transform: Optional[Transform]=None):
+ self.__id = id
self.__kind = kind
self.__get_data = get_data
self.__data = None
+ self.__map_source = map_source
self.__source_path = source_path
+ self.__background_layer = background_layer
+ self.__transform = transform
+
+ @property
+ def background_layer(self):
+ return self.__background_layer
@property
def data(self) -> bytes:
@@ -291,14 +318,26 @@ def data(self) -> bytes:
self.__data = self.__get_data()
return self.__data
+ @property
+ def id(self) -> str:
+ return self.__id
+
@property
def kind(self) -> str:
return self.__kind
+ @property
+ def map_source(self):
+ return self.__map_source
+
@property
def source_path(self) -> Optional[FilePath]:
return self.__source_path
+ @property
+ def transform(self) -> Optional[Transform]:
+ return self.__transform
+
#===============================================================================
# Export our sources here to avoid circular imports
diff --git a/mapmaker/sources/celldl/__init__.py b/mapmaker/sources/celldl/__init__.py
new file mode 100644
index 00000000..08cdadb6
--- /dev/null
+++ b/mapmaker/sources/celldl/__init__.py
@@ -0,0 +1,24 @@
+#===============================================================================
+#
+# Flatmap viewer and annotation tools
+#
+# Copyright (c) 2020 - 2025 David Brooks
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#===============================================================================
+
+# Exports
+from .exporter import CellDLExporter
+
+#===============================================================================
diff --git a/mapmaker/sources/celldl/definitions.py b/mapmaker/sources/celldl/definitions.py
new file mode 100644
index 00000000..b6f7d018
--- /dev/null
+++ b/mapmaker/sources/celldl/definitions.py
@@ -0,0 +1,110 @@
+#===============================================================================
+#
+# Flatmap viewer and annotation tools
+#
+# Copyright (c) 2020 - 2025 David Brooks
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#===============================================================================
+
+import lxml.etree as etree
+
+#===============================================================================
+
+from mapmaker.utils import SVG_NS
+
+#===============================================================================
+
+EM_SIZE = 16 # Pixels, sets ``font-size`` in CellDLStylesheet
+INTERFACE_PORT_RADIUS = 4 # pixels
+
+#===============================================================================
+
+ERROR_COLOUR = 'yellow'
+
+#===============================================================================
+
+DIAGRAM_LAYER = 'diagram-layer'
+
+CELLDL_BACKGROUND_CLASS = 'celldl-background'
+CELLDL_LAYER_CLASS = 'celldl-Layer'
+
+CELLDL_DEFINITIONS_ID = "celldl-svg-definitions"
+CELLDL_METADATA_ID = "celldl-rdf-metadata"
+CELLDL_STYLESHEET_ID = 'celldl-svg-stylesheet'
+
+#===============================================================================
+
+CellDLStylesheet = '\n'.join([ # Copied from ``@renderer/styles/stylesheet.ts``
+ f'svg{{font-size:{EM_SIZE}px}}',
+ # Conduits
+ '.celldl-Conduit{z-index:9999}',
+ # Connections
+ '.celldl-Connection{stroke-width:2;opacity:0.7;fill:none;stroke:currentcolor}',
+ '.celldl-Connection.dashed{stroke-dasharray:5}',
+ # Compartments
+ '.celldl-Compartment>rect.compartment{fill:#CCC;opacity:0.6;stroke:#444;rx:10px;ry:10px}',
+ # Interfaces
+ f'.celldl-InterfacePort{{fill:red;r:{INTERFACE_PORT_RADIUS}px}}',
+ f'.celldl-Unconnected{{fill:red;fill-opacity:0.1;stroke:red;r:{INTERFACE_PORT_RADIUS}px}}'
+])
+
+#===============================================================================
+
+def arrow_marker_definition(markerId: str, markerType: str) -> str:
+#==================================================================
+ # see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/marker
+ return f"""
+
+"""
+
+#===============================================================================
+#===============================================================================
+
+def bondgraph_arrow_definition(domain: str) -> str:
+#==================================================
+ return arrow_marker_definition(f'connection-end-arrow-{domain}', domain)
+
+#===============================================================================
+
+BondgraphSvgDefinitions: list[etree.Element] = etree.fromstring(
+ '\n'.join([
+ f'',
+ bondgraph_arrow_definition('bondgraph'),
+ bondgraph_arrow_definition('mechanical'),
+ bondgraph_arrow_definition('electrical'),
+ bondgraph_arrow_definition('biochemical'),
+ ''
+ ])
+).getchildren()
+
+#===============================================================================
+
+BondgraphStylesheet = '\n'.join([
+ # Bondgraph specific
+ 'svg{--biochemical:#2F6EBA;--electrical:#DE8344;--mechanical:#4EAD5B}',
+ '.bondgraph{color:pink}'
+ '.biochemical{color:var(--biochemical)}',
+ '.electrical{color:var(--electrical)}',
+ '.mechanical{color:var(--mechanical)}',
+ # use var(--colour), setting them in master stylesheet included in (along with MathJax styles)
+ '.celldl-Connection.bondgraph{marker-end:url(#connection-end-arrow-bondgraph)}',
+ '.celldl-Connection.bondgraph.biochemical{marker-end:url(#connection-end-arrow-biochemical)}',
+ '.celldl-Connection.bondgraph.electrical{marker-end:url(#connection-end-arrow-electrical)}',
+ '.celldl-Connection.bondgraph.mechanical{marker-end:url(#connection-end-arrow-mechanical)}',
+])
+
+#===============================================================================
+
diff --git a/mapmaker/sources/celldl/exporter.py b/mapmaker/sources/celldl/exporter.py
new file mode 100644
index 00000000..89a92b8b
--- /dev/null
+++ b/mapmaker/sources/celldl/exporter.py
@@ -0,0 +1,202 @@
+#===============================================================================
+#
+# Flatmap viewer and annotation tools
+#
+# Copyright (c) 2020 - 2025 David Brooks
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#===============================================================================
+
+import copy
+import itertools
+from pathlib import Path
+from typing import Optional
+
+#===============================================================================
+
+import lxml.etree as etree
+import rdflib
+
+#===============================================================================
+
+from mapmaker.geometry import Transform
+from mapmaker.knowledgebase.celldl import BG_NS, CellDLGraph, DCT_NS
+from mapmaker.properties.markup import parse_markup
+from mapmaker.shapes import Shape, SHAPE_TYPE
+from mapmaker.sources.svg.utils import length_as_pixels, svg_markup
+from mapmaker.utils import TreeList, SVG_NS
+from mapmaker.utils.svg import svg_id
+
+from .definitions import CELLDL_BACKGROUND_CLASS, CELLDL_DEFINITIONS_ID, CELLDL_LAYER_CLASS
+from .definitions import CELLDL_METADATA_ID, CELLDL_STYLESHEET_ID, DIAGRAM_LAYER
+from .definitions import BondgraphStylesheet, BondgraphSvgDefinitions, CellDLStylesheet
+
+#===============================================================================
+
+class EXPORT_TYPE:
+ BONDGRAPH = 'bondgraph'
+
+KIND_TO_SVG_CLASS = {
+ 'arterial'
+ 'venous'
+}
+
+#===============================================================================
+
+MAXIMIMUM_SMALLEST_SVG_DIMENSION = 1200
+
+#===============================================================================
+
+# If successive x-coordinates (y-coordinates) of a path are within this distance then the
+# connecting line segment is considered to be horizontal (vertical)
+
+PATH_EPSILON = 0.5 # pixels
+
+#===============================================================================
+
+etree.register_namespace('svg', str(SVG_NS))
+
+#===============================================================================
+
+class CellDLExporter:
+ def __init__(self, svg_root: etree.Element, source_href: str, world_to_pixels: Transform,
+ export_type: Optional[str]=EXPORT_TYPE.BONDGRAPH):
+ self.__celldl = CellDLGraph(BG_NS.Model if export_type == EXPORT_TYPE.BONDGRAPH else None)
+ self.__celldl.set_property(DCT_NS.source, rdflib.URIRef(source_href))
+ self.__svg_root = svg_root
+ self.__world_to_pixels = world_to_pixels
+ self.__export_type = export_type
+
+ celldl_defs = svg_root.find(f'.//{SVG_NS.defs}[@id="{CELLDL_DEFINITIONS_ID}"]')
+ if celldl_defs is None:
+ celldl_defs = svg_root.find(f'.//{SVG_NS.defs}')
+ if celldl_defs is not None:
+ celldl_defs.attrib['id'] = CELLDL_DEFINITIONS_ID
+ else:
+ celldl_defs = etree.SubElement(svg_root, SVG_NS.defs, {
+ 'id': CELLDL_DEFINITIONS_ID
+ })
+ if export_type == EXPORT_TYPE.BONDGRAPH:
+ celldl_defs.extend(BondgraphSvgDefinitions)
+
+ celldl_style = celldl_defs.find(f'.//{SVG_NS.style}[@id="{CELLDL_STYLESHEET_ID}"]')
+ if celldl_style is None:
+ celldl_style = etree.SubElement(celldl_defs, SVG_NS.style, {
+ 'id': CELLDL_STYLESHEET_ID
+ })
+ stylesheets = [CellDLStylesheet]
+ if export_type == EXPORT_TYPE.BONDGRAPH:
+ stylesheets.append(BondgraphStylesheet)
+ celldl_style.text = '\n'.join(stylesheets)
+
+ celldl_diagram = etree.Element(SVG_NS.g, {
+ 'id': DIAGRAM_LAYER,
+ 'class': CELLDL_LAYER_CLASS
+ })
+
+ for child in svg_root:
+ if child.tag != SVG_NS.defs:
+ markup = svg_markup(child)
+ if markup.startswith('.'):
+ properties = parse_markup(markup)
+ if properties.get('background', False):
+ # ``.background`` elements stay where they are in the SVG
+ classes = child.attrib.get('class', [])
+ classes.append(CELLDL_BACKGROUND_CLASS)
+ child.attrib['class'] = ' '.join(classes)
+ continue
+ # Other elements are moved to the ``celldl_diagram`` group
+ celldl_diagram.append(child)
+
+ svg_root.append(celldl_diagram) # Need to append after above copy/move
+ self.__connection_group = etree.SubElement(celldl_diagram, SVG_NS.g)
+ self.__annotation_group = etree.SubElement(celldl_diagram, SVG_NS.g)
+ self.__metadata_element = etree.Element(SVG_NS.metadata, {
+ 'id': CELLDL_METADATA_ID,
+ 'data-content-type': 'text/turtle'
+ })
+ svg_root.insert(0, self.__metadata_element)
+
+ def process(self, shapes: TreeList[Shape]):
+ #==========================================
+ self.__process_shape_list(shapes) ##, self.__diagram)
+
+ def save(self, path: Path):
+ #==========================
+ self.__metadata_element.text = etree.CDATA(self.__celldl.as_turtle())
+ svg_tree = etree.ElementTree(self.__svg_root)
+ svg_tree.write(path,
+ encoding='utf-8', #inclusive_ns_prefixes=['svg'],
+ pretty_print=True, xml_declaration=True)
+
+ def __check_viewbox(self) -> tuple[float, float, float, float]:
+ #==============================================================
+ width = self.__svg_root.attrib.pop('width', None)
+ height = self.__svg_root.attrib.pop('height', None)
+ if 'viewBox' not in self.__svg_root.attrib:
+ self.__svg_root.attrib['viewBox'] = f'0 0 {length_as_pixels(width):.1f} {length_as_pixels(height):.1f}'
+ return tuple(float(i) for i in self.__svg_root.attrib['viewBox'].split()) # type: ignore
+
+ def __process_shape_list(self, shapes: TreeList[Shape]): #, group: etree.Element):
+ #=======================================================
+ for shape in shapes[0:]:
+ if isinstance(shape, TreeList):
+ self.__process_shape_list(shape)
+ elif not shape.properties.get('exclude', False):
+ if (svg_element := shape.get_property('svg-element')) is not None:
+ if (T := shape.get_property('svg-transform')) is not None and not T.is_identity:
+ transform = T.svg_matrix
+ element_id = svg_id(shape.id)
+ if (svg_class := self.__celldl.add_shape(shape)) is not None:
+ if shape.shape_type == SHAPE_TYPE.CONNECTION:
+ geometry = self.__world_to_pixels.transform_geometry(shape.geometry)
+ coords = [f'{coord[0]} {coord[1]}' for coord in geometry.coords]
+ attributes = {
+ 'id': element_id,
+ 'd': f'M{coords[0]}L{"L".join(coords[1:])}'
+ }
+ classes = [svg_class]
+ connection_style = 'rectilinear'
+ for (c1, c2) in itertools.pairwise(geometry.coords):
+ if (abs(c1[0] - c2[0]) > PATH_EPSILON
+ and abs(c1[1] - c2[1]) > PATH_EPSILON):
+ connection_style = 'linear'
+ break
+ classes.append(connection_style)
+ if shape.properties.get('directional', False):
+ classes.append('arrow')
+ if self.__export_type == EXPORT_TYPE.BONDGRAPH:
+ classes.append('bondgraph')
+ if (colour := shape.properties.get('colour')) is not None:
+ attributes['style'] = f'stroke: {colour}'
+ attributes['class'] = ' '.join(classes)
+ svg_element.getparent().remove(svg_element)
+ svg_element = etree.SubElement(self.__connection_group, SVG_NS.path, attributes)
+ elif (text_shapes := shape.get_property('text-shapes')) is not None:
+ shape_element = copy.deepcopy(svg_element)
+ svg_element.tag = SVG_NS.g
+ svg_element.attrib.clear()
+ svg_element.attrib['id'] = element_id
+ svg_element.attrib['class'] = svg_class
+ svg_element.append(shape_element)
+ svg_element.extend([element for text_shape in text_shapes
+ if (element := text_shape.get_property('svg-element')) is not None])
+ shape.set_property('svg-element', svg_element)
+ if shape.shape_type == SHAPE_TYPE.ANNOTATION:
+ self.__annotation_group.append(svg_element)
+ else:
+ svg_element.attrib['id'] = element_id
+ svg_element.attrib['class'] = ' '.join(svg_element.attrib.get('class', '').split() + [svg_class])
+
+#===============================================================================
diff --git a/mapmaker/sources/fc_powerpoint/__init__.py b/mapmaker/sources/fc_powerpoint/__init__.py
index 8f55a774..37392b96 100644
--- a/mapmaker/sources/fc_powerpoint/__init__.py
+++ b/mapmaker/sources/fc_powerpoint/__init__.py
@@ -37,20 +37,21 @@
from mapmaker.settings import settings
from mapmaker.shapes import Shape, SHAPE_TYPE
from mapmaker.shapes.shapefilter import ShapeFilter
+from mapmaker.shapes.types import make_annotation, make_component, make_connection, make_connector
+from mapmaker.shapes.types import is_annotation, is_component, is_connector, is_system_name
+from mapmaker.shapes.types import ensure_parent_system
+from mapmaker.shapes.types import HYPERLINK_KINDS, HYPERLINK_IDENTIFIERS
+from mapmaker.shapes.types import NERVE_FEATURE_KINDS, NEURON_PATH_TYPES
+from mapmaker.shapes.types import ORGAN_COLOUR, ORGAN_KINDS
+from mapmaker.shapes.types import VASCULAR_KINDS, VASCULAR_REGION_COLOUR, VASCULAR_VESSEL_KINDS
from mapmaker.utils import log
+#===============================================================================
+
+from ..import RasterSource
from ..powerpoint import PowerpointSource, Slide
from ..powerpoint.colour import ColourTheme
-#===============================================================================
-
-from .components import make_annotation, make_component, make_connection, make_connector
-from .components import is_annotation, is_component, is_connector, is_system_name
-from .components import ensure_parent_system
-from .components import HYPERLINK_KINDS, HYPERLINK_IDENTIFIERS
-from .components import NERVE_FEATURE_KINDS, NEURON_PATH_TYPES
-from .components import ORGAN_COLOUR, ORGAN_KINDS
-from .components import VASCULAR_KINDS, VASCULAR_REGION_COLOUR, VASCULAR_VESSEL_KINDS
from .connections import ConnectionClassifier
if TYPE_CHECKING:
@@ -84,9 +85,9 @@ def __init__(self, flatmap, source_manifest: SourceManifest,
),
**kwds)
- def get_raster_source(self):
- #===========================
- return None # We don't rasterise FC maps
+ def get_raster_sources(self) -> list[RasterSource]:
+ #==================================================
+ return [] # We don't rasterise FC maps
#===============================================================================
diff --git a/mapmaker/sources/fc_powerpoint/connections.py b/mapmaker/sources/fc_powerpoint/connections.py
index 5cd73f4f..ae7877d8 100644
--- a/mapmaker/sources/fc_powerpoint/connections.py
+++ b/mapmaker/sources/fc_powerpoint/connections.py
@@ -35,12 +35,11 @@
from mapmaker.knowledgebase.sckan import PATH_TYPE
from mapmaker.settings import settings
from mapmaker.shapes import Shape, SHAPE_TYPE
+from mapmaker.shapes.types import is_component, is_connector, make_connector, system_ids
+from mapmaker.shapes.types import NEURON_PATH_TYPES, VASCULAR_KINDS
+from mapmaker.shapes.types import MAX_CONNECTION_GAP
from mapmaker.utils import log
-from .components import is_component, is_connector, make_connector, system_ids
-from .components import NEURON_PATH_TYPES, VASCULAR_KINDS
-from .components import MAX_CONNECTION_GAP
-
#===============================================================================
def direction(coords):
diff --git a/mapmaker/sources/mbfbioscience/__init__.py b/mapmaker/sources/mbfbioscience/__init__.py
index 9160194f..9453e01d 100644
--- a/mapmaker/sources/mbfbioscience/__init__.py
+++ b/mapmaker/sources/mbfbioscience/__init__.py
@@ -159,8 +159,8 @@ def process(self):
self.__image = mask_image(self.__image,
self.__world_to_image.transform_geometry(boundary_geometry))
- def get_raster_source(self):
- #============================
- return RasterSource('image', lambda: self.__image)
+ def get_raster_sources(self) -> list[RasterSource]:
+ #==================================================
+ return [RasterSource(f'{self.id}_image', 'image', lambda: self.__image.tobytes(), self)]
#===============================================================================
diff --git a/mapmaker/sources/powerpoint/__init__.py b/mapmaker/sources/powerpoint/__init__.py
index 9ff333ba..bc4ad48e 100644
--- a/mapmaker/sources/powerpoint/__init__.py
+++ b/mapmaker/sources/powerpoint/__init__.py
@@ -175,13 +175,14 @@ def process(self):
if 'exportSVG' in settings:
self.__make_svg()
- def get_raster_source(self):
- #===========================
+ def get_raster_sources(self) -> list[RasterSource]:
+ #==================================================
if self.kind == 'base': # Only rasterise base source layer
- return RasterSource('svg', self.get_raster_data)
+ return [RasterSource(f'{self.id}_image', 'svg', self.__get_raster_data, self)]
+ return []
- def get_raster_data(self):
- #=========================
+ def __get_raster_data(self) -> bytes:
+ #====================================
svg_maker = SvgMaker(self.__powerpoint)
svg_maker.add_slides(self.__slides)
svg_data = BytesIO(svg_maker.svg_bytes())
diff --git a/mapmaker/sources/powerpoint/powerpoint.py b/mapmaker/sources/powerpoint/powerpoint.py
index dbae10b5..5d2819f5 100644
--- a/mapmaker/sources/powerpoint/powerpoint.py
+++ b/mapmaker/sources/powerpoint/powerpoint.py
@@ -48,12 +48,11 @@
from mapmaker.geometry import MapBounds, Transform
from mapmaker.properties.markup import parse_layer_directive, parse_markup
from mapmaker.shapes import Shape, SHAPE_TYPE
+from mapmaker.shapes.colours import ColourMatcher
+from mapmaker.shapes.types import is_system_name
from mapmaker.sources import WORLD_METRES_PER_EMU
from mapmaker.utils import FilePath, log, ProgressBar, TreeList
-from ..fc_powerpoint.colours import ColourMatcher
-from ..fc_powerpoint.components import is_system_name
-
from .colour import ColourMap, ColourTheme
from .geometry import get_shape_geometry
from .presets import CT_TextMath, DRAWINGML, PPTX_NAMESPACE, pptx_resolve, pptx_uri
diff --git a/mapmaker/sources/powerpoint/svgutils.py b/mapmaker/sources/powerpoint/svgutils.py
index f432a878..06bda48a 100644
--- a/mapmaker/sources/powerpoint/svgutils.py
+++ b/mapmaker/sources/powerpoint/svgutils.py
@@ -307,7 +307,7 @@ def __process_shape(self, shape: Shape, svg_parent: SvgElement,
shape.id, shape.geometry.bounds, shape.properties,
pptx_shape)
if self.__celldl is not None:
- self.__celldl.add_metadata(shape)
+ self.__celldl.add_shape(shape)
def __process_svg_shape(self, svg_shape, svg_kind, svg_parent, group_colour,
shape_id, bounds, properties, pptx_shape):
diff --git a/mapmaker/sources/svg/__init__.py b/mapmaker/sources/svg/__init__.py
index 3a08e325..4e43ebf8 100644
--- a/mapmaker/sources/svg/__init__.py
+++ b/mapmaker/sources/svg/__init__.py
@@ -2,7 +2,7 @@
#
# Flatmap viewer and annotation tools
#
-# Copyright (c) 2020 David Brooks
+# Copyright (c) 2020 - 2025 David Brooks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -42,13 +42,15 @@
from mapmaker.flatmap import Feature, FlatMap, SourceManifest, SOURCE_DETAIL_KINDS
from mapmaker.flatmap.layers import FEATURES_TILE_LAYER, MapLayer
from mapmaker.geometry import Transform
-from mapmaker.settings import MAP_KIND
+from mapmaker.output.bondgraph import BondgraphModel
+from mapmaker.settings import MAP_KIND, settings
from mapmaker.shapes import Shape, SHAPE_TYPE
from mapmaker.shapes.classify import ShapeClassifier
-from mapmaker.utils import FilePath, ProgressBar, log, TreeList
+from mapmaker.utils import FilePath, pathlib_path, ProgressBar, log, TreeList
from .. import MapSource, RasterSource
from .. import WORLD_METRES_PER_PIXEL
+from ..celldl import CellDLExporter
from .cleaner import SVGCleaner
from .definitions import DefinitionStore, ObjectStore
@@ -59,7 +61,8 @@
#===============================================================================
-FUNCTIONAL_MAP_MARGIN = 200 # pixels
+DETAILED_MAP_BORDER = 50 # pixels
+FUNCTIONAL_MAP_MARGIN = 500 # pixels in SVG source space
#===============================================================================
@@ -90,7 +93,7 @@ def __init__(self, flatmap: FlatMap, source_manifest: SourceManifest): # maker
super().__init__(flatmap, source_manifest)
self.__source_file = FilePath(source_manifest.href)
self.__exported = (self.kind == 'base' or self.kind in SOURCE_DETAIL_KINDS)
- svg = etree.parse(self.__source_file.get_fp()).getroot()
+ svg: etree.Element = etree.parse(self.__source_file.get_fp()).getroot()
if 'viewBox' in svg.attrib:
viewbox = [float(x) for x in svg.attrib.get('viewBox').split()]
(left, top) = tuple(viewbox[:2])
@@ -114,16 +117,18 @@ def __init__(self, flatmap: FlatMap, source_manifest: SourceManifest): # maker
scale = max(scale_x, scale_y)
self.__transform = (Transform([[1, 0, bounds[0]+(bounds[2]-bounds[0])/2],
[0, 1, bounds[1]+(bounds[3]-bounds[1])/2],
- [0, 0, 1]])
+ [0, 0, 1]])
@np.array([[scale, 0, 0],
[ 0, scale, 0],
[ 0, 0, 1]])
@np.array([[1.0, 0.0, -left-width/2],
- [0.0, -1.0, top+height/2],
- [0.0, 0.0, 1.0]]))
+ [0.0, -1.0, top+height/2],
+ [0.0, 0.0, 1.0]]))
self.__metres_per_pixel = scale
else:
- if self.flatmap.map_kind == MAP_KIND.FUNCTIONAL:
+ # Add a margin around the base layer of a functional map
+ if (self.flatmap.map_kind == MAP_KIND.FUNCTIONAL
+ and self.kind == 'base'):
left -= FUNCTIONAL_MAP_MARGIN
top -= FUNCTIONAL_MAP_MARGIN
width += 2*FUNCTIONAL_MAP_MARGIN
@@ -142,7 +147,6 @@ def __init__(self, flatmap: FlatMap, source_manifest: SourceManifest): # maker
# southwest and northeast corners
self.bounds = (top_left[0], bottom_right[1], bottom_right[0], top_left[1])
self.__layer = SVGLayer(self.id, self, svg, exported=self.__exported, min_zoom=self.min_zoom)
- self.add_layer(self.__layer)
self.__boundary_geometry = None
@property
@@ -162,11 +166,12 @@ def process(self):
self.__layer.process()
if self.__layer.boundary_feature is not None:
self.__boundary_geometry = self.__layer.boundary_feature.geometry
+ self.add_layer(self.__layer)
def create_preview(self):
#========================
# Save a cleaned copy of the SVG in the map's output directory. Call after
- # connectivity has been generated otherwise thno paths will be in the saved SVG
+ # connectivity has been generated otherwise no paths will be in the saved SVG
cleaner = SVGCleaner(self.__source_file, self.flatmap.properties_store, all_layers=True)
cleaner.clean()
cleaner.add_connectivity_group(self.flatmap, self.__transform)
@@ -175,9 +180,17 @@ def create_preview(self):
with open(cleaned_svg, 'wb') as fp:
cleaner.save(fp)
- def get_raster_source(self):
- #===========================
- return RasterSource('svg', self.__get_raster_data, source_path=self.__source_file)
+ def get_raster_sources(self) -> list[RasterSource]:
+ #==================================================
+ raster_sources = []
+ if (background := self.background_raster_source) is not None:
+ background_path = FilePath(background.href)
+ raster_sources.append(RasterSource(f'{self.id}_background', 'svg', background_path.get_data, self,
+ source_path=background_path, background_layer=True,
+ transform=Transform.Translate(background.translate)@Transform.Scale(background.scale)))
+ raster_sources.append(RasterSource(f'{self.id}_image', 'svg', self.__get_raster_data, self,
+ source_path=self.__source_file))
+ return raster_sources
def __get_raster_data(self) -> bytes:
#====================================
@@ -213,20 +226,31 @@ def process(self):
self.__transform,
properties,
None, show_progress=True)
- features = self.__process_shapes(shapes)
+ self.__process_shapes(shapes)
def __process_shapes(self, shapes: TreeList[Shape]) -> list[Feature]:
#====================================================================
- if (self.flatmap.map_kind == MAP_KIND.FUNCTIONAL and not self.source.kind == 'anatomical'):
+ if (self.flatmap.map_kind == MAP_KIND.FUNCTIONAL
+ and not self.source.kind == 'anatomical'):
# CellDL conversion mode...
- sc = ShapeClassifier(shapes.flatten(), self.source.map_area(), self.source.metres_per_pixel)
- shapes = TreeList(sc.classify())
- if self.source.base_feature is not None:
+ shape_classifier = ShapeClassifier(shapes.flatten(), self.source.map_area(), self.source.metres_per_pixel)
+ shapes = TreeList(shape_classifier.shapes)
+ if settings.get('exportBondgraphs', False):
+ bondgraph_file = pathlib_path(self.source.href).with_suffix('.bondgraph.ttl')
+ log.info(f'Exporting layer `{self.id}` to `{str(bondgraph_file)}`...')
+ bondgraph = BondgraphModel(self.id, shapes)
+ with open(bondgraph_file, 'wb') as fp:
+ fp.write(bondgraph.as_turtle())
+ # Add a background shape behind a detailed functional map
+ if (self.flatmap.map_kind == MAP_KIND.FUNCTIONAL
+ and self.source.kind == 'functional'):
bounds = self.source.bounds
- margin = 0.02*(abs(bounds[2]-bounds[0])
- + abs(bounds[3]-bounds[1]))
- bbox = shapely.geometry.box(*bounds).buffer(margin)
- shapes.insert(0, Shape('background', bbox, {
+ margin = self.source.metres_per_pixel*DETAILED_MAP_BORDER
+ bounds = (bounds[0] + margin, bounds[1] - margin,
+ bounds[2] - margin, bounds[3] - margin)
+ bbox = shapely.geometry.box(*bounds).buffer(2*margin)
+ shapes.insert(0, Shape(None, bbox, {
+ 'id': 'background',
'tooltip': False,
'colour': 'white',
'kind': 'background'
@@ -353,6 +377,9 @@ def __add_clip_geometry(self, clip_path_element, transform):
and (geometry := self.__get_clip_geometry(clip_path_element, transform)) is not None):
self.__clip_geometries.add(clip_id, geometry)
+ """
+ Get the geometry described by the children of a ``clipPath`` element
+ """
def __get_clip_geometry(self, clip_path_element, transform) -> Optional[BaseGeometry]:
#====================================================================================
geometries = []
@@ -378,6 +405,8 @@ def __process_element(self, wrapped_element: ElementWrapper, transform, parent_p
properties = parent_properties.copy()
for name in NON_INHERITED_PROPERTIES:
properties.pop(name, None)
+ if 'id' in element.attrib:
+ properties['id'] = element.attrib.get('id')
properties.update(properties_from_markup)
shape_id = properties.get('id') ## versus element.attrib.get('id')
if 'path' in properties:
@@ -414,7 +443,6 @@ def __process_element(self, wrapped_element: ElementWrapper, transform, parent_p
return self.__process_group(wrapped_element, properties, transform, parent_style)
elif element.tag == SVG_TAG('text'):
geometry = self.__process_text(element, properties, transform)
-
if geometry is not None:
return Shape(shape_id, geometry, properties, shape_type=SHAPE_TYPE.TEXT, svg_element=element)
else:
@@ -541,12 +569,21 @@ def __process_text(self, element, properties, transform: Transform) -> Optional[
if element_text == '':
element_text = ' '
font_manager = skia.FontMgr()
- for font_family in style_rules.get('font-family', 'Calibri').split(','):
+ font_families = style_rules.get('font-family', 'Calibri')
+ for font_family in font_families.split(','):
type_face = font_manager.matchFamilyStyle(font_family, font_style)
if type_face is not None:
break
if type_face is None:
- type_face = font_manager.matchFamilyStyle(None, font_style)
+ if 'Calibri' not in font_families:
+ log.warning('Cannot get font information (missing fonts?), trying Calibri', font=font_families, text=element_text)
+ type_face = font_manager.matchFamilyStyle('Calibri', font_style)
+ if type_face is None:
+ log.warning('Cannot get font information for Calibri', font='Calibri')
+ type_face = font_manager.matchFamilyStyle(None, font_style)
+ else:
+ log.warning('Cannot get font information (missing fonts?)', font=font_families)
+ type_face = font_manager.matchFamilyStyle(None, font_style)
font = skia.Font(type_face, length_as_points(style_rules.get('font-size', 10)))
bounds = skia.Rect()
width = font.measureText(element_text, skia.TextEncoding.kUTF8, bounds)
diff --git a/mapmaker/sources/svg/rasteriser.py b/mapmaker/sources/svg/rasteriser.py
index 38b2719a..25a0c234 100644
--- a/mapmaker/sources/svg/rasteriser.py
+++ b/mapmaker/sources/svg/rasteriser.py
@@ -44,7 +44,7 @@
from mapmaker.settings import MAP_KIND
from mapmaker.utils import FilePath, ProgressBar, log
-from . import FUNCTIONAL_MAP_MARGIN, SVGSource
+from . import DETAILED_MAP_BORDER, FUNCTIONAL_MAP_MARGIN, SVGSource
from .definitions import DefinitionStore, ObjectStore
from .styling import ElementStyleDict, StyleMatcher, wrap_element
from .transform import SVGTransform
@@ -237,12 +237,18 @@ def __init__(self, text, attribs, parent_transform: Transform,
skia.FontStyle.kUpright_Slant)
type_face = None
font_manager = skia.FontMgr()
- for font_family in style_rules.get('font-family', 'Calibri').split(','):
+ font_families = style_rules.get('font-family', 'Calibri')
+ for font_family in font_families.split(','):
type_face = font_manager.matchFamilyStyle(font_family, font_style)
if type_face is not None:
break
if type_face is None:
- type_face = font_manager.matchFamilyStyle(None, font_style)
+ if 'Calibri' not in font_families:
+ type_face = font_manager.matchFamilyStyle('Calibri', font_style)
+ if type_face is None:
+ type_face = font_manager.matchFamilyStyle(None, font_style)
+ else:
+ type_face = font_manager.matchFamilyStyle(None, font_style)
self.__font = skia.Font(type_face,
length_as_points(style_rules.get('font-size', 10)))
self.__pos = [float(attribs.get('x', 0)), float(attribs.get('y', 0))]
@@ -309,13 +315,18 @@ def __init__(self, raster_layer: 'RasterLayer', tile_set: 'TileSet'):
(left, top) = (0, 0)
self.__size = (length_as_pixels(self.__svg.attrib['width']),
length_as_pixels(self.__svg.attrib['height']))
- if (raster_layer.map_source.base_feature is None
- and raster_layer.flatmap.map_kind == MAP_KIND.FUNCTIONAL):
- left -= FUNCTIONAL_MAP_MARGIN
- top -= FUNCTIONAL_MAP_MARGIN
- self.__size = (self.__size[0] + 2*FUNCTIONAL_MAP_MARGIN,
- self.__size[1] + 2*FUNCTIONAL_MAP_MARGIN)
-
+ self.__add_background_rect = False
+ if raster_layer.flatmap.map_kind == MAP_KIND.FUNCTIONAL:
+ margin = 0
+ if raster_layer.map_source.kind == 'base':
+ if not raster_layer.background_layer:
+ margin = FUNCTIONAL_MAP_MARGIN
+ elif raster_layer.map_source.kind == 'functional':
+ self.__add_background_rect = (raster_layer.map_source.base_feature is not None)
+ if margin:
+ left -= margin
+ top -= margin
+ self.__size = (self.__size[0] + 2*margin, self.__size[1] + 2*margin)
self.__left_top = (left, top)
self.__scaling = (tile_set.pixel_rect.width/self.__size[0],
tile_set.pixel_rect.height/self.__size[1])
@@ -331,6 +342,7 @@ def __init__(self, raster_layer: 'RasterLayer', tile_set: 'TileSet'):
[0.0, 0.0, 1.0]])
self.__svg_source = typing.cast(SVGSource, raster_layer.map_source)
metres_per_pixel = self.__svg_source.metres_per_pixel
+
# Transform from SVG pixels to world coordinates
self.__image_to_world = (Transform([
[metres_per_pixel/self.__scaling[0], 0, 0],
@@ -339,18 +351,6 @@ def __init__(self, raster_layer: 'RasterLayer', tile_set: 'TileSet'):
@np.array([[1.0, 0.0, -self.__scaling[0]*self.__size[0]/2.0],
[0.0, -1.0, self.__scaling[1]*self.__size[1]/2.0],
[0.0, 0.0, 1.0]]))
-## ``image_to_world`` is used for rasterising details and may be wrong, esp. if the
-## SVG's viewport origin is not (0, 0).
-##
-## The following might be correct, but needs testing...
-##
-## svg_origin = (left+self.__size[0]/2.0, top+self.__size[1]/2)
-## @np.array([[1.0, 0.0, -svg_origin[0]],
-## [0.0, -1.0, svg_origin[1]],
-## [0.0, 0.0, 1.0]]))
-##
-## And do we need to multiply by scaling??
-##
self.__tile_size = tile_set.tile_size
self.__tile_origin = tile_set.start_coords
self.__pixel_offset = tuple(tile_set.pixel_rect)[0:2]
@@ -427,11 +427,10 @@ def __draw_svg(self, svg_to_tile_transform, show_progress=False) -> CanvasGroup:
svg_to_tile_transform if transform is None else svg_to_tile_transform@transform,
None,
show_progress=show_progress)
- if self.__svg_source.base_feature is not None:
- margin = 0.02*(self.__size[0] + self.__size[1])
- path = skia.Path.RRect((self.__left_top[0] - margin, self.__left_top[1] - margin,
- self.__size[0] + 2*margin, self.__size[1] + 2*margin),
- margin, margin)
+ if self.__add_background_rect:
+ path = skia.Path.RRect((self.__left_top[0], self.__left_top[1],
+ self.__size[0], self.__size[1]),
+ DETAILED_MAP_BORDER/2, DETAILED_MAP_BORDER/2)
paint = skia.Paint(AntiAlias=True, Color=make_colour('#FEFEFE', 1.0))
drawing_objects.insert(0, CanvasPath(path, paint, svg_to_tile_transform, transform, None))
return CanvasGroup(drawing_objects, svg_to_tile_transform, transform, None, outermost=True)
diff --git a/mapmaker/sources/svg/utils.py b/mapmaker/sources/svg/utils.py
index 8620697e..d786ffcc 100644
--- a/mapmaker/sources/svg/utils.py
+++ b/mapmaker/sources/svg/utils.py
@@ -33,7 +33,7 @@
from beziers.quadraticbezier import QuadraticBezier
from beziers.segment import Segment as BezierSegment
-import shapely.geometry
+import shapely
from shapely.geometry.base import BaseGeometry
import lxml.etree as etree
@@ -44,7 +44,7 @@
from mapmaker.exceptions import MakerException
from mapmaker.flatmap import Feature
from mapmaker.geometry import Transform, reflect_point
-from mapmaker.geometry.beziers import bezier_sample
+from mapmaker.geometry.beziers import bezier_sample, Coordinate
from mapmaker.geometry.arc_to_bezier import bezier_segments_from_arc_endpoints, tuple2
from mapmaker.output.path_colours import get_path_colour
from mapmaker.utils import log
@@ -177,8 +177,8 @@ def svg_markup(element):
#===============================================================================
def circle_from_bounds(bounds):
- centre = shapely.geometry.Point((bounds[0] + bounds[2])/2.0,
- (bounds[1] + bounds[3])/2.0)
+ centre = shapely.Point((bounds[0] + bounds[2])/2.0,
+ (bounds[1] + bounds[3])/2.0)
return centre.buffer(math.sqrt(abs((bounds[2] - bounds[0])*(bounds[3] - bounds[1])))/2.0)
#===============================================================================
@@ -234,10 +234,48 @@ def element_from_etree(svg: etree.Element) -> etree.Element:
#===============================================================================
+def __geometry_from_coordinates(coordinates: list[Coordinate], closed: bool, must_close: Optional[bool]) -> Optional[BaseGeometry]:
+ if must_close == False and closed:
+ raise ValueError("Shape can't have closed geometry")
+ elif must_close == True and not closed:
+ raise ValueError("Shape must have closed geometry")
+
+ if closed and len(coordinates) >= 3:
+ geometry = shapely.Polygon(coordinates).buffer(0)
+ elif must_close == True and len(coordinates) >= 3:
+ # Return a polygon if flagged as `closed`
+ coordinates.append(coordinates[0])
+ geometry = shapely.Polygon(coordinates).buffer(0)
+ elif len(coordinates) >= 2:
+ ## Warn if start and end point are ``close`` wrt to the length of the line as shape
+ ## may be intended to be closed... (test with ``cardio_8-1``)
+ geometry = shapely.LineString(coordinates)
+ else:
+ geometry = None
+
+ if geometry is not None and not geometry.is_valid:
+ if 'Polygon' in geometry.geom_type:
+ # Try smoothing out boundary irregularities
+ geometry = geometry.buffer(20)
+ if not geometry.is_valid:
+ log.error(f'{geometry.geom_type} geometry is invalid')
+ geometry = None
+
+ return geometry
+
+#===============================================================================
+
+type GeometricObject = tuple[Optional[BaseGeometry], list[BezierSegment]]
+
+#===============================================================================
+
def geometry_from_svg_path(path_tokens: list[str|float], transform: Transform,
- must_close: Optional[bool]=None) -> tuple[Optional[BaseGeometry], list[BezierSegment]]:
- coordinates = []
- bezier_segments = []
+ must_close: Optional[bool]=None) -> GeometricObject:
+
+ geometries: list[BaseGeometry] = []
+
+ coordinates: list[Coordinate] = []
+ bezier_segments: list[BezierSegment] = []
closed = False
moved = False
@@ -333,6 +371,12 @@ def geometry_from_svg_path(path_tokens: list[str|float], transform: Transform,
current_point = pt
elif cmd in ['m', 'M']:
+ if len(coordinates):
+ if (geometry := __geometry_from_coordinates(coordinates, closed, must_close)) is not None:
+ geometries.append(geometry)
+ coordinates: list[Coordinate] = []
+ closed = False
+
params = [float(x) for x in path_tokens[pos:pos+2]]
pos += 2
pt = params[0:2]
@@ -386,25 +430,12 @@ def geometry_from_svg_path(path_tokens: list[str|float], transform: Transform,
elif must_close == True and not closed:
raise ValueError("Shape must have closed geometry")
- if closed and len(coordinates) >= 3:
- geometry = shapely.geometry.Polygon(coordinates).buffer(0)
- elif must_close == True and len(coordinates) >= 3:
- # Return a polygon if flagged as `closed`
- coordinates.append(coordinates[0])
- geometry = shapely.geometry.Polygon(coordinates).buffer(0)
- elif len(coordinates) >= 2:
- ## Warn if start and end point are ``close`` wrt to the length of the line as shape
- ## may be intended to be closed... (test with ``cardio_8-1``)
- geometry = shapely.geometry.LineString(coordinates)
- else:
- geometry = None
+ if (geometry := __geometry_from_coordinates(coordinates, closed, must_close)) is not None:
+ geometries.append(geometry)
- if geometry is not None and not geometry.is_valid:
- if 'Polygon' in geometry.geom_type:
- # Try smoothing out boundary irregularities
- geometry = geometry.buffer(20)
- if not geometry.is_valid:
- raise ValueError(f'{geometry.geom_type} geometry is invalid')
+ geometry = (None if len(geometries) == 0
+ else geometries[0] if len(geometries) == 1
+ else shapely.unary_union(geometries))
return (geometry, bezier_segments)
diff --git a/mapmaker/utils/__init__.py b/mapmaker/utils/__init__.py
index 14ad4919..8ef06383 100644
--- a/mapmaker/utils/__init__.py
+++ b/mapmaker/utils/__init__.py
@@ -37,6 +37,25 @@
#===============================================================================
+"""
+For generating ``lxml`` element tags
+"""
+class XmlNamespace:
+ def __init__(self, ns: str):
+ self.__ns = ns
+
+ def __str__(self):
+ return self.__ns
+
+ def __getattr__(self, attr: str) -> str:
+ return f'{{{self.__ns}}}{attr}'
+
+#===============================================================================
+
+SVG_NS = XmlNamespace('http://www.w3.org/2000/svg')
+
+#===============================================================================
+
def relative_path(path: str | pathlib.Path) -> bool:
return str(path).split(':', 1)[0] not in ['file', 'http', 'https']
diff --git a/mapmaker/utils/logging.py b/mapmaker/utils/logging.py
index f7846449..03821cf6 100644
--- a/mapmaker/utils/logging.py
+++ b/mapmaker/utils/logging.py
@@ -21,7 +21,8 @@
import json
import logging
import logging.config
-import typing
+import logging.handlers
+import multiprocessing
from typing import Any, Callable, Optional
#===============================================================================
@@ -36,6 +37,14 @@
#===============================================================================
+class QueueHandlerJSON(logging.handlers.QueueHandler):
+ def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
+ record = super().prepare(record)
+ record.msg = json.dumps(record.msg)
+ return record
+
+#===============================================================================
+
class RenameJSONRenderer:
def __init__(self,
to: str, replace_by: str | None = None,
@@ -48,52 +57,68 @@ def __call__(self, logger: WrappedLogger, name: str, event_dict: EventDict) -> s
#===============================================================================
-def configure_logging(log_file=None, verbose=False, silent=False, debug=False) -> Optional[logging.FileHandler]:
+def configure_logging(log_json_file=None, verbose=False, silent=False, debug=False,
+#==================================================================================
+ log_queue: Optional[multiprocessing.Queue]=None) -> Optional[logging.FileHandler]:
log_level = logging.DEBUG if debug else logging.INFO
- logging_config = {
+ # Configure standard logger with a null configuration
+ logging.config.dictConfig({
'version': 1,
'handlers': {
- 'stream': {
- 'class': 'logging.StreamHandler',
- 'level': log_level,
- 'formatter': 'structured'
+ 'null': {
+ 'class': 'logging.NullHandler',
+ 'level': log_level
}
},
'formatters': {
- 'json': {
- '()': structlog.stdlib.ProcessorFormatter,
- "processor": RenameJSONRenderer('msg'),
- },
- 'structured': {
- '()': structlog.stdlib.ProcessorFormatter,
- 'processor': structlog.dev.ConsoleRenderer(colors=True),
- },
},
'loggers': {
'': {
- 'handlers': ['stream'],
+ 'handlers': ['null'],
'level': log_level,
'propagate': True
},
}
- }
-
- if silent:
- logging_config['handlers']['stream']['level'] = logging.CRITICAL
+ })
+
+ # Get our logger
+ logger = logging.getLogger('mapmaker')
+
+ # Log to the console if not sending logs to a queue
+ if log_queue is None:
+ stream_handler = logging.StreamHandler()
+ stream_handler.setLevel(logging.CRITICAL if silent else log_level)
+ structured_formatter = structlog.stdlib.ProcessorFormatter(
+ processors=[
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+ structlog.dev.ConsoleRenderer(colors=True)
+ ],
+ )
+ stream_handler.setFormatter(structured_formatter)
+ logger.addHandler(stream_handler)
+
+ json_formatter = structlog.stdlib.ProcessorFormatter(
+ processors=[
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+ RenameJSONRenderer('msg')
+ ],
+ )
- if log_file is not None:
- logging_config['handlers']['jsonfile'] = {
- 'class': 'logging.FileHandler',
- 'level': log_level,
- 'formatter': 'json',
- 'filename': log_file
- }
- logging_config['loggers']['']['handlers'].append('jsonfile')
+ # Log as JSON to a file if requested
+ json_handler = None
+ if log_json_file is not None:
+ json_handler = logging.FileHandler(log_json_file)
+ json_handler.setLevel(log_level)
+ json_handler.setFormatter(json_formatter)
+ logger.addHandler(json_handler)
- # Configure standard logger
- logging.config.dictConfig(logging_config)
+ # For when mapmaker is run as a process by a flatmap server.
+ if log_queue is not None:
+ queue_handler = logging.handlers.QueueHandler(log_queue)
+ queue_handler.setFormatter(json_formatter)
+ logger.addHandler(queue_handler)
# Configure structlog
structlog.configure(
@@ -111,12 +136,11 @@ def configure_logging(log_file=None, verbose=False, silent=False, debug=False) -
cache_logger_on_first_use=True,
)
- if log_file is not None:
- return typing.cast(logging.FileHandler, logging.getLogger().handlers[1])
+ return json_handler
#===============================================================================
-log: structlog.BoundLogger = structlog.get_logger()
+log: structlog.BoundLogger = structlog.get_logger('mapmaker')
#===============================================================================
diff --git a/mapmaker/utils/svg.py b/mapmaker/utils/svg.py
index dd734a64..3f74a912 100644
--- a/mapmaker/utils/svg.py
+++ b/mapmaker/utils/svg.py
@@ -24,9 +24,12 @@
In practice, feature IDs are have the form ``LAYER_NAME/Slide-N/NNNNN`` and
won't contain any embedded periods.
"""
-def svg_id(id):
-#==============
- return id.replace('/', '.')
+def svg_id(shape_id: str) -> str:
+#================================
+ shape_id = shape_id.split('/')[-1]
+ if shape_id.startswith('SHAPE_'):
+ shape_id = f'ID-{shape_id[6:].zfill(8)}'
+ return shape_id
def name_from_id(id):
#====================
diff --git a/pyproject.toml b/pyproject.toml
index d33abccb..022a67e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,8 +32,8 @@ dependencies = [
"cssselect2>=0.6.0",
"webcolors>=1.12",
"xmltodict>=0.12.0",
- "flatmapknowledge @ https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.2/flatmapknowledge-2.5.2-py3-none-any.whl",
- "mapknowledge @ https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.2/mapknowledge-1.3.2-py3-none-any.whl",
+ "flatmapknowledge @ https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.3/flatmapknowledge-2.5.3-py3-none-any.whl",
+ "mapknowledge @ https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.3/mapknowledge-1.3.3-py3-none-any.whl",
"Pyomo>=6.8",
"svgwrite>=1.4.3",
"XlsxWriter>=3.0.3",
@@ -47,6 +47,7 @@ dependencies = [
"structlog>=24.4.0",
"rich>=13.9.4",
"tippecanoe>=2.72.0",
+ "lark>=1.2.2",
]
[dependency-groups]
@@ -92,4 +93,3 @@ include = ['mapmaker']
pythonVersion = "3.12"
venvPath = "."
venv = ".venv"
-
diff --git a/uv.lock b/uv.lock
index caeec067..32d32a64 100644
--- a/uv.lock
+++ b/uv.lock
@@ -256,17 +256,17 @@ wheels = [
[[package]]
name = "flatmapknowledge"
-version = "2.5.2"
-source = { url = "https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.2/flatmapknowledge-2.5.2-py3-none-any.whl" }
+version = "2.5.3"
+source = { url = "https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.3/flatmapknowledge-2.5.3-py3-none-any.whl" }
dependencies = [
{ name = "mapknowledge" },
]
wheels = [
- { url = "https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.2/flatmapknowledge-2.5.2-py3-none-any.whl", hash = "sha256:3bf0693f3cfa3b79915a0cd5fae89c05d6fbf53cc9b927e4b8eab83a03beda00" },
+ { url = "https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.3/flatmapknowledge-2.5.3-py3-none-any.whl", hash = "sha256:b52164c3e4dd0510a98a32ff7177575e70c620cc40d8062d2681af0a018a1a01" },
]
[package.metadata]
-requires-dist = [{ name = "mapknowledge", url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.2/mapknowledge-1.3.2-py3-none-any.whl" }]
+requires-dist = [{ name = "mapknowledge", url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.3/mapknowledge-1.3.3-py3-none-any.whl" }]
[[package]]
name = "frozendict"
@@ -510,6 +510,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/57/38c47753c67ad67f76ba04ea673c9b77431a19e7b2601937e6872a99e841/jsonasobj-1.3.1-py3-none-any.whl", hash = "sha256:b9e329dc1ceaae7cf5d5b214684a0b100e0dad0be6d5bbabac281ec35ddeca65", size = 4388 },
]
+[[package]]
+name = "lark"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/60/bc7622aefb2aee1c0b4ba23c1446d3e30225c8770b38d7aedbfb65ca9d5a/lark-1.2.2.tar.gz", hash = "sha256:ca807d0162cd16cef15a8feecb862d7319e7a09bdb13aef927968e45040fed80", size = 252132 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/00/d90b10b962b4277f5e64a78b6609968859ff86889f5b898c1a778c06ec00/lark-1.2.2-py3-none-any.whl", hash = "sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c", size = 111036 },
+]
+
[[package]]
name = "lxml"
version = "5.4.0"
@@ -537,8 +546,8 @@ wheels = [
[[package]]
name = "mapknowledge"
-version = "1.3.2"
-source = { url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.2/mapknowledge-1.3.2-py3-none-any.whl" }
+version = "1.3.3"
+source = { url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.3/mapknowledge-1.3.3-py3-none-any.whl" }
dependencies = [
{ name = "networkx" },
{ name = "neurondm" },
@@ -550,7 +559,7 @@ dependencies = [
{ name = "structlog" },
]
wheels = [
- { url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.2/mapknowledge-1.3.2-py3-none-any.whl", hash = "sha256:3240fd88a5bdb7333f93910ca112affc8429bacf91f034f462241e0c2958560b" },
+ { url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.3/mapknowledge-1.3.3-py3-none-any.whl", hash = "sha256:ede53f90d027030738fae8af0891374d3bf14915b89fe26c740d9e44182fcfa8" },
]
[package.metadata]
@@ -578,6 +587,7 @@ dependencies = [
{ name = "flatmapknowledge" },
{ name = "gitpython" },
{ name = "giturlparse" },
+ { name = "lark" },
{ name = "lxml" },
{ name = "mapknowledge" },
{ name = "mbutil" },
@@ -627,11 +637,12 @@ requires-dist = [
{ name = "beziers", specifier = ">=0.5.0" },
{ name = "colormath", specifier = ">=3.0.0" },
{ name = "cssselect2", specifier = ">=0.6.0" },
- { name = "flatmapknowledge", url = "https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.2/flatmapknowledge-2.5.2-py3-none-any.whl" },
+ { name = "flatmapknowledge", url = "https://github.com/AnatomicMaps/flatmap-knowledge/releases/download/v2.5.3/flatmapknowledge-2.5.3-py3-none-any.whl" },
{ name = "gitpython", specifier = ">=3.1.41" },
{ name = "giturlparse", specifier = ">=0.12.0" },
+ { name = "lark", specifier = ">=1.2.2" },
{ name = "lxml", specifier = ">=5.2.2" },
- { name = "mapknowledge", url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.2/mapknowledge-1.3.2-py3-none-any.whl" },
+ { name = "mapknowledge", url = "https://github.com/AnatomicMaps/map-knowledge/releases/download/v1.3.3/mapknowledge-1.3.3-py3-none-any.whl" },
{ name = "mbutil", specifier = ">=0.3.0" },
{ name = "mercantile", specifier = ">=1.2.1" },
{ name = "multiprocess", specifier = ">=0.70.13" },