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] = []
def add() -> Icon:
def add() -> Optional[Icon]:
"""Construct icon and add it to the list."""
specifications: list[ShapeSpecification] = [
scheme.get_shape_specification(x, extractor)
for x in current_set
]
specifications: list[ShapeSpecification] = []
for shape_specification in current_set:
if "#" in shape_specification["shape"]:
return None
scheme.get_shape_specification(shape_specification, extractor)
constructed_icon: Icon = Icon(specifications)
constructed_icon.recolor(color, white=background_color)
if constructed_icon not in icons:

View file

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

View file

@ -1267,29 +1267,11 @@ node_icons:
shapes: [buffer_stop]
- tags: {traffic_sign: city_limit}
shapes: [city_limit_sign]
- tags: {traffic_sign: maxspeed, maxspeed: "30"}
- tags: {traffic_sign: maxspeed, maxspeed: "(\\d)(\\d)"}
shapes: [
circle_11,
{shape: digit_3, offset: [-2, 0], color: "#FFFFFF"},
{shape: digit_0, 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"},
{shape: digit_#maxspeed0, offset: [-2, 0], color: "#FFFFFF"},
{shape: digit_#maxspeed1, offset: [2, 0], color: "#FFFFFF"},
]
- tags: {traffic_sign: maxspeed, maxspeed: "40_mph"}
shapes: [

View file

@ -2,6 +2,7 @@
Test icon generation for nodes.
"""
import pytest
from colour import Color
from map_machine.grid import IconCollection
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
icons.
"""
icon = get_icon({"natural": "tree"})
icon: IconSet = get_icon({"natural": "tree"})
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