Issue #91: support automatic icon generation.

Use regular expressions to match tags and construct icons using matched
substrings.
This commit is contained in:
Sergey Vartanov 2021-09-26 18:23:22 +03:00
parent 4644d38166
commit 39acfc6ff6
4 changed files with 102 additions and 88 deletions

View file

@ -50,12 +50,13 @@ class IconCollection:
""" """
icons: list[Icon] = [] icons: list[Icon] = []
def add() -> Icon: def add() -> Optional[Icon]:
"""Construct icon and add it to the list.""" """Construct icon and add it to the list."""
specifications: list[ShapeSpecification] = [ specifications: list[ShapeSpecification] = []
scheme.get_shape_specification(x, extractor) for shape_specification in current_set:
for x in current_set if "#" in shape_specification["shape"]:
] return None
scheme.get_shape_specification(shape_specification, extractor)
constructed_icon: Icon = Icon(specifications) constructed_icon: Icon = Icon(specifications)
constructed_icon.recolor(color, white=background_color) constructed_icon.recolor(color, white=background_color)
if constructed_icon not in icons: if constructed_icon not in icons:

View file

@ -2,6 +2,7 @@
Map Machine drawing scheme. Map Machine drawing scheme.
""" """
import logging import logging
import re
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@ -49,13 +50,14 @@ class MatchingType(Enum):
MATCHED_BY_SET = 1 MATCHED_BY_SET = 1
MATCHED_BY_WILDCARD = 2 MATCHED_BY_WILDCARD = 2
MATCHED = 3 MATCHED = 3
MATCHED_BY_REGEX = 4
def is_matched_tag( def is_matched_tag(
matcher_tag_key: str, matcher_tag_key: str,
matcher_tag_value: Union[str, list], matcher_tag_value: Union[str, list],
tags: dict[str, str], tags: dict[str, str],
) -> MatchingType: ) -> tuple[MatchingType, list[str]]:
""" """
Check whether element tags contradict tag matcher. Check whether element tags contradict tag matcher.
@ -63,20 +65,20 @@ def is_matched_tag(
:param matcher_tag_value: tag value, tag value list, or "*" :param matcher_tag_value: tag value, tag value list, or "*"
:param tags: element tags to check :param tags: element tags to check
""" """
if matcher_tag_key in tags: if matcher_tag_key not in tags:
if matcher_tag_value == "*": return MatchingType.NOT_MATCHED, []
return MatchingType.MATCHED_BY_WILDCARD
if ( if matcher_tag_value == "*":
isinstance(matcher_tag_value, str) return MatchingType.MATCHED_BY_WILDCARD, []
and tags[matcher_tag_key] == matcher_tag_value if tags[matcher_tag_key] == matcher_tag_value:
): return MatchingType.MATCHED, []
return MatchingType.MATCHED matcher: Optional[re.Match] = re.match(
if ( matcher_tag_value, tags[matcher_tag_key]
isinstance(matcher_tag_value, list) )
and tags[matcher_tag_key] in matcher_tag_value if matcher:
): return MatchingType.MATCHED_BY_REGEX, list(matcher.groups())
return MatchingType.MATCHED_BY_SET
return MatchingType.NOT_MATCHED return MatchingType.NOT_MATCHED, []
def get_selector(key: str, value: str, prefix: str = "") -> str: def get_selector(key: str, value: str, prefix: str = "") -> str:
@ -139,13 +141,15 @@ class Matcher:
self, self,
tags: dict[str, str], tags: dict[str, str],
configuration: Optional[MapConfiguration] = None, configuration: Optional[MapConfiguration] = None,
) -> bool: ) -> tuple[bool, dict[str, str]]:
""" """
Check whether element tags matches tag matcher. Check whether element tags matches tag matcher.
:param tags: element tags to be matched :param tags: element tags to be matched
:param configuration: current map configuration to be matched :param configuration: current map configuration to be matched
""" """
groups: dict[str, str] = {}
if ( if (
configuration is not None configuration is not None
and self.location_restrictions and self.location_restrictions
@ -153,28 +157,29 @@ class Matcher:
self.location_restrictions, configuration.country self.location_restrictions, configuration.country
) )
): ):
return False return False, {}
for config_tag_key in self.tags: for config_tag_key in self.tags:
config_tag_key: str config_tag_key: str
tag_matcher = self.tags[config_tag_key] is_matched, matched_groups = is_matched_tag(
if ( config_tag_key, self.tags[config_tag_key], tags
is_matched_tag(config_tag_key, tag_matcher, tags) )
== MatchingType.NOT_MATCHED if is_matched == MatchingType.NOT_MATCHED:
): return False, {}
return False elif matched_groups:
for index, element in enumerate(matched_groups):
groups[f"#{config_tag_key}{index}"] = element
if self.exception: if self.exception:
for config_tag_key in self.exception: for config_tag_key in self.exception:
config_tag_key: str config_tag_key: str
tag_matcher = self.exception[config_tag_key] is_matched, matched_groups = is_matched_tag(
if ( config_tag_key, self.exception[config_tag_key], tags
is_matched_tag(config_tag_key, tag_matcher, tags) )
!= MatchingType.NOT_MATCHED if is_matched != MatchingType.NOT_MATCHED:
): return False, {}
return False
return True return True, groups
def get_mapcss_selector(self, prefix: str = "") -> str: def get_mapcss_selector(self, prefix: str = "") -> str:
""" """
@ -195,6 +200,19 @@ class Matcher:
return {} return {}
def get_shape_specifications(
structure: list[Union[str, dict[str, Any]]]
) -> list[dict]:
"""Parse shape specification from scheme."""
shapes: list[dict] = []
for shape_specification in structure:
if isinstance(shape_specification, str):
shapes.append({"shape": shape_specification})
else:
shapes.append(shape_specification)
return shapes
class NodeMatcher(Matcher): class NodeMatcher(Matcher):
""" """
Tag specification matcher. Tag specification matcher.
@ -212,15 +230,15 @@ class NodeMatcher(Matcher):
self.shapes: Optional[IconDescription] = None self.shapes: Optional[IconDescription] = None
if "shapes" in structure: if "shapes" in structure:
self.shapes = structure["shapes"] self.shapes = get_shape_specifications(structure["shapes"])
self.over_icon: Optional[IconDescription] = None self.over_icon: Optional[IconDescription] = None
if "over_icon" in structure: if "over_icon" in structure:
self.over_icon = structure["over_icon"] self.over_icon = get_shape_specifications(structure["over_icon"])
self.add_shapes: Optional[IconDescription] = None self.add_shapes: Optional[IconDescription] = None
if "add_shapes" in structure: if "add_shapes" in structure:
self.add_shapes = structure["add_shapes"] self.add_shapes = get_shape_specifications(structure["add_shapes"])
self.set_main_color: Optional[str] = None self.set_main_color: Optional[str] = None
if "set_main_color" in structure: if "set_main_color" in structure:
@ -232,17 +250,17 @@ class NodeMatcher(Matcher):
self.under_icon: Optional[IconDescription] = None self.under_icon: Optional[IconDescription] = None
if "under_icon" in structure: if "under_icon" in structure:
self.under_icon = structure["under_icon"] self.under_icon = get_shape_specifications(structure["under_icon"])
self.with_icon: Optional[IconDescription] = None self.with_icon: Optional[IconDescription] = None
if "with_icon" in structure: if "with_icon" in structure:
self.with_icon = structure["with_icon"] self.with_icon = get_shape_specifications(structure["with_icon"])
def get_clean_shapes(self) -> Optional[list[str]]: def get_clean_shapes(self) -> Optional[list[str]]:
"""Get list of shape identifiers for shapes.""" """Get list of shape identifiers for shapes."""
if not self.shapes: if not self.shapes:
return None return None
return [(x if isinstance(x, str) else x["shape"]) for x in self.shapes] return [x["shape"] for x in self.shapes]
class WayMatcher(Matcher): class WayMatcher(Matcher):
@ -288,6 +306,7 @@ class RoadMatcher(Matcher):
self.priority = structure["priority"] self.priority = structure["priority"]
def get_priority(self, tags: dict[str, str]) -> float: def get_priority(self, tags: dict[str, str]) -> float:
"""Get priority for drawing order."""
layer: float = 0 layer: float = 0
if "layer" in tags: if "layer" in tags:
layer = float(tags.get("layer")) layer = float(tags.get("layer"))
@ -349,6 +368,7 @@ class Scheme:
try: try:
return Color(color) return Color(color)
except (ValueError, AttributeError): except (ValueError, AttributeError):
logging.debug(f"Unknown color `{color}`.")
return DEFAULT_COLOR return DEFAULT_COLOR
def is_no_drawable(self, key: str) -> bool: def is_no_drawable(self, key: str) -> bool:
@ -410,7 +430,8 @@ class Scheme:
for index, matcher in enumerate(self.node_matchers): for index, matcher in enumerate(self.node_matchers):
if not matcher.replace_shapes and main_icon: if not matcher.replace_shapes and main_icon:
continue continue
if not matcher.is_matched(tags, configuration): matching, groups = matcher.is_matched(tags, configuration)
if not matching:
continue continue
if ( if (
not configuration.ignore_level_matching not configuration.ignore_level_matching
@ -423,7 +444,7 @@ class Scheme:
processed |= matcher_tags processed |= matcher_tags
if matcher.shapes: if matcher.shapes:
specifications = [ specifications = [
self.get_shape_specification(x, extractor) self.get_shape_specification(x, extractor, groups)
for x in matcher.shapes for x in matcher.shapes
] ]
main_icon = Icon(specifications) main_icon = Icon(specifications)
@ -437,7 +458,9 @@ class Scheme:
processed |= matcher_tags processed |= matcher_tags
if matcher.add_shapes: if matcher.add_shapes:
specifications = [ specifications = [
self.get_shape_specification(x, extractor, Color("#888888")) self.get_shape_specification(
x, extractor, color=Color("#888888")
)
for x in matcher.add_shapes for x in matcher.add_shapes
] ]
extra_icons += [Icon(specifications)] extra_icons += [Icon(specifications)]
@ -493,7 +516,8 @@ class Scheme:
line_styles = [] line_styles = []
for matcher in self.way_matchers: for matcher in self.way_matchers:
if not matcher.is_matched(tags): matching, _ = matcher.is_matched(tags)
if not matching:
continue continue
line_styles.append(LineStyle(matcher.style, matcher.priority)) line_styles.append(LineStyle(matcher.style, matcher.priority))
@ -503,7 +527,8 @@ class Scheme:
def get_road(self, tags: dict[str, Any]) -> Optional[RoadMatcher]: def get_road(self, tags: dict[str, Any]) -> Optional[RoadMatcher]:
"""Get road matcher if tags are matched.""" """Get road matcher if tags are matched."""
for matcher in self.road_matchers: for matcher in self.road_matchers:
if not matcher.is_matched(tags): matching, _ = matcher.is_matched(tags)
if not matching:
continue continue
return matcher return matcher
return None return None
@ -586,7 +611,8 @@ class Scheme:
def is_area(self, tags: dict[str, str]) -> bool: def is_area(self, tags: dict[str, str]) -> bool:
"""Check whether way described by tags is area.""" """Check whether way described by tags is area."""
for matcher in self.area_matchers: for matcher in self.area_matchers:
if matcher.is_matched(tags): matching, _ = matcher.is_matched(tags)
if matching:
return True return True
return False return False
@ -605,6 +631,7 @@ class Scheme:
self, self,
structure: Union[str, dict[str, Any]], structure: Union[str, dict[str, Any]],
extractor: ShapeExtractor, extractor: ShapeExtractor,
groups: dict[str, str] = None,
color: Color = DEFAULT_COLOR, color: Color = DEFAULT_COLOR,
) -> ShapeSpecification: ) -> ShapeSpecification:
""" """
@ -619,25 +646,25 @@ class Scheme:
flip_vertically: bool = False flip_vertically: bool = False
use_outline: bool = True use_outline: bool = True
if isinstance(structure, str): structure: dict[str, Any]
shape = extractor.get_shape(structure) if "shape" in structure:
elif isinstance(structure, dict): shape_id: str = structure["shape"]
if "shape" in structure: if groups:
shape = extractor.get_shape(structure["shape"]) for key in groups:
else: shape_id = shape_id.replace(key, groups[key])
logging.error( shape = extractor.get_shape(shape_id)
"Invalid shape specification: `shape` key expected." else:
) logging.error("Invalid shape specification: `shape` key expected.")
if "color" in structure: if "color" in structure:
color = self.get_color(structure["color"]) color = self.get_color(structure["color"])
if "offset" in structure: if "offset" in structure:
offset = np.array(structure["offset"]) offset = np.array(structure["offset"])
if "flip_horizontally" in structure: if "flip_horizontally" in structure:
flip_horizontally = structure["flip_horizontally"] flip_horizontally = structure["flip_horizontally"]
if "flip_vertically" in structure: if "flip_vertically" in structure:
flip_vertically = structure["flip_vertically"] flip_vertically = structure["flip_vertically"]
if "outline" in structure: if "outline" in structure:
use_outline = structure["outline"] use_outline = structure["outline"]
return ShapeSpecification( return ShapeSpecification(
shape, shape,

View file

@ -1267,29 +1267,11 @@ node_icons:
shapes: [buffer_stop] shapes: [buffer_stop]
- tags: {traffic_sign: city_limit} - tags: {traffic_sign: city_limit}
shapes: [city_limit_sign] shapes: [city_limit_sign]
- tags: {traffic_sign: maxspeed, maxspeed: "30"} - tags: {traffic_sign: maxspeed, maxspeed: "(\\d)(\\d)"}
shapes: [ shapes: [
circle_11, circle_11,
{shape: digit_3, offset: [-2, 0], color: "#FFFFFF"}, {shape: digit_#maxspeed0, offset: [-2, 0], color: "#FFFFFF"},
{shape: digit_0, offset: [2, 0], color: "#FFFFFF"}, {shape: digit_#maxspeed1, offset: [2, 0], color: "#FFFFFF"},
]
- tags: {traffic_sign: maxspeed, maxspeed: "40"}
shapes: [
circle_11,
{shape: digit_4, offset: [-2, 0], color: "#FFFFFF"},
{shape: digit_0, offset: [2, 0], color: "#FFFFFF"},
]
- tags: {traffic_sign: maxspeed, maxspeed: "50"}
shapes: [
circle_11,
{shape: digit_5, offset: [-2, 0], color: "#FFFFFF"},
{shape: digit_0, offset: [2, 0], color: "#FFFFFF"},
]
- tags: {traffic_sign: maxspeed, maxspeed: "60"}
shapes: [
circle_11,
{shape: digit_6, offset: [-2, 0], color: "#FFFFFF"},
{shape: digit_0, offset: [2, 0], color: "#FFFFFF"},
] ]
- tags: {traffic_sign: maxspeed, maxspeed: "40_mph"} - tags: {traffic_sign: maxspeed, maxspeed: "40_mph"}
shapes: [ shapes: [

View file

@ -2,6 +2,7 @@
Test icon generation for nodes. Test icon generation for nodes.
""" """
import pytest import pytest
from colour import Color
from map_machine.grid import IconCollection from map_machine.grid import IconCollection
from map_machine.icon import IconSet from map_machine.icon import IconSet
@ -53,8 +54,11 @@ def test_icon() -> None:
Tags that should be visualized with single main icon and without extra Tags that should be visualized with single main icon and without extra
icons. icons.
""" """
icon = get_icon({"natural": "tree"}) icon: IconSet = get_icon({"natural": "tree"})
assert not icon.main_icon.is_default() assert not icon.main_icon.is_default()
assert len(icon.main_icon.shape_specifications) == 1
assert icon.main_icon.shape_specifications[0].shape.id_ == "tree"
assert icon.main_icon.shape_specifications[0].color == Color("#98AC64")
assert not icon.extra_icons assert not icon.extra_icons