diff --git a/.isort.cfg b/.isort.cfg index 8e0a2515bdc98c46c94b1548a80e56d49de232c3..575531da30e31c9665804a4f78d3a638174897c9 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -3,4 +3,4 @@ line_length = 88 multi_line_output = 3 include_trailing_comma = true -known_third_party = PyPDF2,arrow,bleach,bs4,captcha,celery,django,environ,faker,ics,markdown,modelcluster,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,wagtail,wagtailmetadata,weasyprint,yaml +known_third_party = PyPDF2,arrow,bleach,bs4,captcha,celery,django,environ,faker,fastjsonschema,ics,markdown,modelcluster,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,wagtail,wagtailmetadata,weasyprint,yaml diff --git a/district/migrations/0062_districtgeofeaturecollectioncategory_and_more.py b/district/migrations/0062_districtgeofeaturecollectioncategory_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..0f83eac1b408f6d93881c831069230ebc54300ec --- /dev/null +++ b/district/migrations/0062_districtgeofeaturecollectioncategory_and_more.py @@ -0,0 +1,396 @@ +# Generated by Django 4.0.3 on 2022-05-01 09:49 + +import django.db.models.deletion +import modelcluster.fields +import wagtail.contrib.table_block.blocks +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.images.blocks +import wagtailmetadata.models +from django.db import migrations, models + +import shared.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("wagtailcore", "0066_collection_management_permissions"), + ("wagtailimages", "0023_add_choose_permissions"), + ("district", "0061_alter_districtarticlepage_content_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DistrictGeoFeatureCollectionCategory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ("name", models.CharField(max_length=100, verbose_name="Název")), + ( + "hex_color", + models.CharField( + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + max_length=6, + verbose_name="Barva (HEX)", + ), + ), + ], + options={ + "verbose_name": "Kategorie mapové kolekce", + }, + ), + migrations.AddField( + model_name="districthomepage", + name="stadia_apikey", + field=models.CharField( + blank=True, + help_text="API klíč pro Stadia mapy. Získáte po registraci stadiamaps.com.", + max_length=128, + null=True, + verbose_name="Stadia API key", + ), + ), + migrations.AlterField( + model_name="districtcenterpage", + name="sidebar_content", + field=wagtail.core.fields.StreamField( + [ + ( + "map", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ] + ), + ), + ( + "address", + wagtail.core.blocks.StructBlock( + [ + ( + "title", + wagtail.core.blocks.CharBlock( + label="Titulek", required=True + ), + ), + ( + "map_image", + wagtail.images.blocks.ImageChooserBlock( + label="Obrázek mapy", required=False + ), + ), + ( + "map_link", + wagtail.core.blocks.URLBlock( + label="Odkaz na detail mapy", required=False + ), + ), + ( + "address", + wagtail.core.blocks.TextBlock( + label="Adresa", required=True + ), + ), + ( + "address_info", + wagtail.core.blocks.TextBlock( + label="Info k adrese", required=False + ), + ), + ] + ), + ), + ( + "contact", + wagtail.core.blocks.StructBlock( + [ + ( + "title", + wagtail.core.blocks.CharBlock( + label="Titulek", required=True + ), + ), + ( + "contact_list", + wagtail.core.blocks.ListBlock( + wagtail.core.blocks.StructBlock( + [ + ( + "person", + wagtail.core.blocks.PageChooserBlock( + label="Osoba", + page_type=[ + "district.DistrictPersonPage" + ], + ), + ), + ( + "position", + wagtail.core.blocks.CharBlock( + label="Pozice", required=False + ), + ), + ] + ) + ), + ), + ] + ), + ), + ], + blank=True, + verbose_name="Obsah bočního panelu", + ), + ), + migrations.CreateModel( + name="DistrictGeoFeatureDetailPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ("perex", models.TextField(verbose_name="Perex")), + ( + "geojson", + models.TextField( + help_text="Vložte surový GeoJSON objekt typu 'Feature'. Vyrobit jej můžete např. pomocí online služby geojson.io.", + verbose_name="Geodata", + ), + ), + ( + "content", + wagtail.core.fields.StreamField( + [ + ("text", wagtail.core.blocks.RichTextBlock()), + ("table", wagtail.contrib.table_block.blocks.TableBlock()), + ], + blank=True, + verbose_name="Obsah", + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="district.districtgeofeaturecollectioncategory", + verbose_name="Kategorie", + ), + ), + ( + "guarantor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="district.districtpersonpage", + verbose_name="Garant", + ), + ), + ( + "image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="wagtailimages.image", + verbose_name="obrázek", + ), + ), + ( + "search_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + verbose_name="Search image", + ), + ), + ], + options={ + "verbose_name": "Položka mapové kolekce", + }, + bases=( + wagtailmetadata.models.WagtailImageMetadataMixin, + shared.models.SubpageMixin, + "wagtailcore.page", + models.Model, + ), + ), + migrations.CreateModel( + name="DistrictGeoFeatureCollectionPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ("perex", models.TextField(null=True, verbose_name="Perex")), + ( + "content", + wagtail.core.fields.StreamField( + [ + ("text", wagtail.core.blocks.RichTextBlock()), + ("table", wagtail.contrib.table_block.blocks.TableBlock()), + ], + blank=True, + verbose_name="Obsah", + ), + ), + ( + "style", + models.CharField( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ("stadia-outdoors", "Stadia Outdoors (vyžaduje API klíč)"), + ], + default="osm-mapnik", + max_length=50, + verbose_name="Styl mapy", + ), + ), + ( + "map_title", + models.TextField( + blank=True, null=True, verbose_name="Titulek mapy" + ), + ), + ( + "category_list_title", + models.TextField( + blank=True, + null=True, + verbose_name="Titulek přehledu dle kategorie", + ), + ), + ( + "image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="wagtailimages.image", + verbose_name="obrázek", + ), + ), + ( + "search_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + verbose_name="Search image", + ), + ), + ], + options={ + "verbose_name": "Stránka s mapovou kolekcí", + }, + bases=( + shared.models.SubpageMixin, + wagtailmetadata.models.WagtailImageMetadataMixin, + "wagtailcore.page", + models.Model, + ), + ), + migrations.AddField( + model_name="districtgeofeaturecollectioncategory", + name="page", + field=modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="categories", + to="district.districtgeofeaturecollectionpage", + ), + ), + ] diff --git a/district/migrations/0063_alter_districtarticlepage_content.py b/district/migrations/0063_alter_districtarticlepage_content.py new file mode 100644 index 0000000000000000000000000000000000000000..9952f09e49738d7ed8c6876807100b482fe9ae96 --- /dev/null +++ b/district/migrations/0063_alter_districtarticlepage_content.py @@ -0,0 +1,228 @@ +# Generated by Django 4.0.3 on 2022-05-01 10:04 + +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.images.blocks +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("district", "0062_districtgeofeaturecollectioncategory_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="districtarticlepage", + name="content", + field=wagtail.core.fields.StreamField( + [ + ( + "text", + wagtail.core.blocks.RichTextBlock( + features=[ + "h2", + "h3", + "h4", + "bold", + "italic", + "ol", + "embed", + "ul", + "link", + "document-link", + "image", + ], + label="Textový editor", + ), + ), + ( + "gallery", + wagtail.core.blocks.StructBlock( + [ + ( + "gallery_items", + wagtail.core.blocks.ListBlock( + wagtail.images.blocks.ImageChooserBlock( + label="obrázek", required=True + ), + group="ostatní", + icon="image", + label="Galerie", + ), + ) + ], + label="Galerie", + ), + ), + ( + "youtube", + wagtail.core.blocks.StructBlock( + [ + ( + "poster_image", + wagtail.images.blocks.ImageChooserBlock( + help_text="Není třeba vyplňovat, náhled bude dohledán automaticky.", + label="Náhled videa (automatické pole)", + required=False, + ), + ), + ( + "video_url", + wagtail.core.blocks.URLBlock( + help_text="Odkaz na YouTube video bude automaticky zkonvertován na ID videa a NEBUDE uložen.", + label="Odkaz na video", + required=False, + ), + ), + ( + "video_id", + wagtail.core.blocks.CharBlock( + help_text="Není třeba vyplňovat, bude automaticky načteno z odkazu.", + label="ID videa (automatické pole)", + required=False, + ), + ), + ], + label="YouTube video", + ), + ), + ( + "map_point", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ], + label="Špendlík na mapě", + ), + ), + ( + "map_collection", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ], + label="Mapová kolekce", + ), + ), + ], + blank=True, + verbose_name="Článek", + ), + ), + ] diff --git a/district/migrations/0064_alter_districtgeofeaturedetailpage_options_and_more.py b/district/migrations/0064_alter_districtgeofeaturedetailpage_options_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..4feb9e08208bb17f2795cd3d081c9817cd0a2794 --- /dev/null +++ b/district/migrations/0064_alter_districtgeofeaturedetailpage_options_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.3 on 2022-05-01 12:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("district", "0063_alter_districtarticlepage_content"), + ] + + operations = [ + migrations.AlterModelOptions( + name="districtgeofeaturedetailpage", + options={ + "ordering": ["sort_order"], + "verbose_name": "Položka mapové kolekce", + }, + ), + migrations.AddField( + model_name="districtgeofeaturedetailpage", + name="sort_order", + field=models.IntegerField( + blank=True, + help_text="Čím větší hodnotu zadáte, tím později ve výpise položk abude.", + null=True, + verbose_name="Index řazení", + ), + ), + ] diff --git a/district/models.py b/district/models.py index 51863f642ffc98e8a98940813bb97b620a2eac20..d7a17f6535302b828033257cd9166f65e5368e07 100644 --- a/district/models.py +++ b/district/models.py @@ -1,3 +1,4 @@ +import json import random from django.core.exceptions import ValidationError @@ -11,6 +12,7 @@ from taggit.models import Tag, TaggedItemBase from wagtail.admin.edit_handlers import ( FieldPanel, HelpPanel, + InlinePanel, MultiFieldPanel, ObjectList, PageChooserPanel, @@ -21,11 +23,14 @@ from wagtail.admin.forms import WagtailAdminPageForm from wagtail.contrib.table_block.blocks import TableBlock from wagtail.core.blocks import RichTextBlock from wagtail.core.fields import RichTextField, StreamField -from wagtail.core.models import Page +from wagtail.core.models import Orderable, Page from wagtail.images.edit_handlers import ImageChooserPanel from wagtailmetadata.models import MetadataPageMixin from calendar_utils.models import CalendarMixin +from maps_utils.blocks import MapPointBlock +from maps_utils.const import DEFAULT_MAP_STYLE, MAP_STYLES, SUPPORTED_FEATURE_TYPES +from maps_utils.validation import validators as maps_validators from shared.models import ( ArticleMixin, ExtendedMetadataHomePageMixin, @@ -182,6 +187,15 @@ class DistrictHomePage( related_name="+", ) + # API keys + stadia_apikey = models.CharField( + "Stadia API key", + max_length=128, + help_text="API klíč pro Stadia mapy. Získáte po registraci stadiamaps.com.", + null=True, + blank=True, + ) + ### PANELS content_panels = Page.content_panels + [ @@ -240,6 +254,12 @@ class DistrictHomePage( ], gettext_lazy("Nastavení lišty s kalendářem a mapou"), ), + MultiFieldPanel( + [ + FieldPanel("stadia_apikey"), + ], + gettext_lazy("API klíče"), + ), ImageChooserPanel("fallback_image"), ] @@ -266,6 +286,7 @@ class DistrictHomePage( "district.DistrictPeoplePage", "district.DistrictProgramPage", "district.DistrictTagsPage", + "district.DistrictGeoFeatureCollectionPage", ] ### OTHERS @@ -329,6 +350,12 @@ class DistrictHomePage( def has_calendar(self): return self.calendar_id is not None + def get_api_keys(self): + """Get collection of all API keys.""" + return { + "stadia": self.stadia_apikey, + } + class DistrictArticleTag(TaggedItemBase): content_object = ParentalKey( @@ -929,7 +956,11 @@ class DistrictCenterPage( ) text = RichTextField("Text", blank=True, null=True) sidebar_content = StreamField( - [("address", blocks.AddressBlock()), ("contact", blocks.CenterContactBlock())], + [ + ("map", MapPointBlock()), + ("address", blocks.AddressBlock()), + ("contact", blocks.CenterContactBlock()), + ], verbose_name="Obsah bočního panelu", blank=True, ) @@ -1023,6 +1054,7 @@ class DistrictCrossroadPage( "district.DistrictPersonPage", "district.DistrictProgramPage", "district.DistrictTagsPage", + "district.DistrictGeoFeatureCollectionPage", ] ### OTHERS @@ -1065,3 +1097,275 @@ class DistrictCustomPage( class Meta: verbose_name = "Libovolná vlastní stránka" + + +def _build_geojson_feature_with_props(feature, request): + fwp = json.loads(feature.geojson) + fwp["properties"] = { + "id": feature.pk, + "index": feature.index, + "slug": f"{feature.pk}-{feature.slug}", + "title": feature.title, + "description": feature.perex, + "image": feature.image.get_rendition("fill-800x450|jpegquality-80").url + if feature.image + else None, + "link": feature.get_url(request), + "category": feature.category.name, + } + return fwp + + +class DistrictGeoFeatureCollectionPage( + ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page +): + ### FIELDS + perex = models.TextField("Perex", null=True) + content = StreamField( + [ + ("text", RichTextBlock()), + ("table", TableBlock()), + ], + verbose_name="Obsah", + blank=True, + ) + image = models.ForeignKey( + "wagtailimages.Image", + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name="obrázek", + ) + style = models.CharField( + "Styl mapy", choices=MAP_STYLES, max_length=50, default=DEFAULT_MAP_STYLE + ) + map_title = models.TextField("Titulek mapy", blank=True, null=True) + category_list_title = models.TextField( + "Titulek přehledu dle kategorie", blank=True, null=True + ) + + ### PANELS + + content_panels = Page.content_panels + [ + MultiFieldPanel( + [ + FieldPanel("perex"), + StreamFieldPanel("content"), + ImageChooserPanel("image"), + ], + "Obsah hlavní stránky kolekce", + ), + MultiFieldPanel( + [ + InlinePanel("categories"), + FieldPanel("category_list_title"), + ], + "Kategorie", + ), + MultiFieldPanel( + [ + FieldPanel("map_title"), + FieldPanel("style"), + ], + "Nastavení mapy", + ), + ] + + parent_page_types = ["district.DistrictHomePage"] + subpage_types = ["district.DistrictGeoFeatureDetailPage"] + + class Meta: + verbose_name = "Stránka s mapovou kolekcí" + + def get_features_by_category(self): + features = self.get_children().live().specific().select_related("category") + categories = sorted(set(f.category for f in features), key=lambda c: c.name) + + return [ + (category, [f for f in features if f.category == category]) + for category in categories + ] + + def get_context(self, request): + context = super().get_context(request) + features_by_category = self.get_features_by_category() + + context["features_by_category"] = features_by_category + context["js_map"] = { + "apikeys": json.dumps(self.root_page.get_api_keys()), + "style": self.style, + "categories": json.dumps( + [ + # Gather all categories used in collection + {"name": c.name, "color": c.hex_color} + for c, f in features_by_category + ] + ), + "geojson": json.dumps( + { + # Dump individual features in collection + "type": "FeatureCollection", + "features": [ + _build_geojson_feature_with_props(f, request) + for c, features in features_by_category + for f in features + ], + } + ), + } + + return context + + +class DistrictGeoFeatureCollectionCategory(Orderable): + name = models.CharField("Název", max_length=100) + hex_color = models.CharField( + "Barva (HEX)", + max_length=6, + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + ) + page = ParentalKey( + DistrictGeoFeatureCollectionPage, + on_delete=models.CASCADE, + related_name="categories", + ) + + ### PANELS + + panels = [ + FieldPanel("name"), + FieldPanel("hex_color"), + ] + + class Meta: + verbose_name = "Kategorie mapové kolekce" + + def __str__(self) -> str: + return f"{self.page} / {self.name}" + + +class DistrictGeoFeatureDetailPage( + ExtendedMetadataPageMixin, MetadataPageMixin, SubpageMixin, Page, Orderable +): + perex = models.TextField("Perex", null=False) + geojson = models.TextField( + "Geodata", + help_text="Vložte surový GeoJSON objekt typu 'Feature'. Vyrobit jej můžete např. pomocí online služby geojson.io.", + null=False, + ) + category = models.ForeignKey( + "district.DistrictGeoFeatureCollectionCategory", + verbose_name="Kategorie", + blank=False, + null=False, + on_delete=models.PROTECT, + ) + guarantor = models.ForeignKey( + "district.DistrictPersonPage", + verbose_name="Garant", + on_delete=models.PROTECT, + null=True, + blank=True, + ) + image = models.ForeignKey( + "wagtailimages.Image", + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name="obrázek", + ) + content = StreamField( + [ + ("text", RichTextBlock()), + ("table", TableBlock()), + ], + verbose_name="Obsah", + blank=True, + ) + sort_order = models.IntegerField( + "Index řazení", + null=True, + blank=True, + help_text="Čím větší hodnotu zadáte, tím později ve výpise položk abude.", + ) + sort_order_field = "sort_order" + + ### PANELS + + content_panels = [ + MultiFieldPanel( + [ + FieldPanel("title"), + FieldPanel("perex"), + StreamFieldPanel("content"), + ImageChooserPanel("image"), + FieldPanel("category"), + ], + "Základní informace", + ), + FieldPanel("geojson"), + PageChooserPanel("guarantor"), + FieldPanel("sort_order"), + ] + + promote_panels = make_promote_panels( + admin_help.build(admin_help.NO_SEO_TITLE, admin_help.NO_DESCRIPTION_USE_PEREX), + search_image=False, + ) + + parent_page_types = ["district.DistrictGeoFeatureCollectionPage"] + subpage_types = [] + + class Meta: + verbose_name = "Položka mapové kolekce" + ordering = ["sort_order"] + + @property + def index(self): + if not hasattr(self, "__index"): + self.__index = ( + list( + self.get_siblings(inclusive=True).values_list("pk", flat=True) + ).index(self.pk) + + 1 + ) + return self.__index + + def get_meta_image(self): + return self.image + + def get_meta_description(self): + if self.search_description: + return self.search_description + if len(self.perex) > 150: + return str(self.perex)[:150] + "..." + return self.perex + + def get_context(self, request): + context = super().get_context(request) + context[ + "features_by_category" + ] = self.get_parent().specific.get_features_by_category() + + context["js_map"] = { + "apikeys": json.dumps(self.root_page.get_api_keys()), + "style": self.get_parent().specific.style, + "categories": json.dumps( + [{"name": self.category.name, "color": self.category.hex_color}] + ), + "geojson": json.dumps( + { + "type": "FeatureCollection", + "features": [_build_geojson_feature_with_props(self, request)], + } + ), + } + return context + + def clean(self): + try: + self.geojson = maps_validators.normalize_geojson_feature( + self.geojson, allowed_types=SUPPORTED_FEATURE_TYPES + ) + except ValueError as exc: + raise ValidationError({"geojson": str(exc)}) from exc diff --git a/district/templates/district/district_geo_feature_collection_page.html b/district/templates/district/district_geo_feature_collection_page.html new file mode 100644 index 0000000000000000000000000000000000000000..1bddbc2ce8b49198f801cbf38d1c6950b6282b0b --- /dev/null +++ b/district/templates/district/district_geo_feature_collection_page.html @@ -0,0 +1,99 @@ +{% extends "district/base.html" %} +{% load static wagtailcore_tags wagtailimages_tags %} + + +{% block subheader %} + {% image page.image width-1920 as bg_img %} + <header class="hero hero--image py-16 " style="--image-url: url({{ bg_img.url }})"> + <div class="container container--default"> + <h1 class="head-alt-md md:head-alt-lg max-w-2xl"> + {{ page.title }} + </h1> + <h2 class="head-xs mt-4"> + {{ page.perex }} + </h2> + </div> + </header> +{% endblock %} + +{% block container_class %}container--default{% endblock %} +{% block container_spacing %}py-8 pb-0 lg:py-16{% endblock %} + +{% block content %} + <article> + <div class="md:space-y-8 lg:space-y-16"> + <div class="lg:flex lg:space-x-16"> + <section class="lg:w-2/3"> + <div class="content-block mb-4"> + {% for block in page.content %} + {% include_block block %} + {% endfor %} + </div> + </section> + + <div class="lg:w-1/3"> + <aside class="sharebox pt-4 md:card md:elevation-10"> + <div class="md:card__body"> + <span class="head-alt-base md:head-alt-md">Sdílení je aktem lásky</span> + <div class="flex w-full space-x-4 pt-4 md:pt-8 text-center text-white"> + <a + href="https://www.facebook.com/sharer/sharer.php?u={{ page.full_url|urlencode }}" + onclick="window.open(this.href, 'pop-up', 'left=20,top=20,width=500,height=500,toolbar=1,resizable=0'); return false;" + class="bg-brands-facebook px-8 py-3 text-2xl w-full hover:no-underline" + ><i class="ico--facebook"></i></a> + <a + href="https://twitter.com/intent/tweet?text={{ page.title|urlencode }}&url={{ page.full_url|urlencode }}" + onclick="window.open(this.href, 'pop-up', 'left=20,top=20,width=500,height=500,toolbar=1,resizable=0'); return false;" + class="bg-brands-twitter px-8 py-3 text-2xl w-full hover:no-underline" + ><i class="ico--twitter"></i></a> + </div> + </div> + <div class="h-52 overflow-hidden hidden md:block"> + <img src="{% static "shared/img/flag.png" %}" alt="Pirátská strana" class="w-80 object-cover m-auto"/> + </div> + </aside> + </div> + </div> + + <section> + <h2 class="head-heavy-base mt-8 mb-4">{{ page.category_list_title|default:"Přehled dle kategorií" }}</h2> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"> + {% for category, features in features_by_category %} + <div class="card md:elevation-2" style="border-right: 4px #{{ category.hex_color }} solid;"> + <div class="card__body p-4"> + <h2 class="head-allcaps-heavy-2xs mb-2">{{ category.name }}</h2> + <ul class="leading-normal {% if not forloop.last %}mb-4{% endif %} md:mb-0"> + {% for feature in features %} + <li> + <span class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-xs w-5 h-5 mr-2 no-underline"> + <a href="#{{feature.pk}}-{{feature.slug}}" class="no-underline">{{ feature.index }}</a> + </span> + <a href="#{{feature.pk}}-{{feature.slug}}"><span class="text-sm underline">{{ feature.title }}</span></a> + </a> + </li> + {% endfor %} + </ul> + </div> + </div> + {% endfor %} + </div> + </section> + + <section> + <h2 id="mapa" class="head-heavy-base mt-8 mb-4">{{ page.map_title|default:"Interaktivní mapa" }}</h2> + <div + class="v-geo-feature-collection" + data-wrapper-class="container-padding--zero lg:container-padding--auto" + data-initial-zoom="14" + data-api-keys="{{ js_map.apikeys }}" + data-tile-style="{{ js_map.style }}" + data-categories="{{ js_map.categories }}" + data-geojson="{{ js_map.geojson }}" + ></div> + </section> + + {% include "shared/followus_snippet.html" %} + </div> + </article> +{% endblock %} diff --git a/district/templates/district/district_geo_feature_detail_page.html b/district/templates/district/district_geo_feature_detail_page.html new file mode 100644 index 0000000000000000000000000000000000000000..55365afba20c1babc53f1518c8836245c645fce0 --- /dev/null +++ b/district/templates/district/district_geo_feature_detail_page.html @@ -0,0 +1,137 @@ +{% extends "district/base.html" %} +{% load static wagtailcore_tags wagtailimages_tags %} + +{% block content %} + <article> + <link itemprop="mainEntityOfPage" href="{{ page.url }}"> + <meta itemprop="datePublished" content="{{ page.last_published_at }}"> + <meta itemprop="dateModified" content="{{ page.latest_revision_created_at }}"> + + <h2 class="head-heavy-xs sm:head-heavy-sm md:head-heavy-base max-w-5xl mb-2"> + <a href="{% pageurl page.get_parent %}">{{ page.get_parent.title }}</a> + </h2> + + <h1 itemprop="headline" class="flex items-center justify-between"> + <span class="head-alt-sm sm:head-alt-md md:head-alt-lg max-w-5xl">{{ page.title }}</span> + <span v-if="page.index" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-sm sm:text-lg md:text-4xl w-6 h-6 sm:w-8 sm:h-8 md:w-16 md:h-16 ml-4 md:mb-2">{{ page.index }}</span> + </h1> + + <div class="lg:flex mt-2 lg:space-x-16"> + <div class="lg:w-2/3"> + <figure class="figure mb-4"> + {% image page.image width-2000 as img %} + <img src="{{ img.url }}" alt="{{ page.title }}" /> + </figure> + + <div class="content-block mb-8"> + <p> + <strong>{{ page.perex }}</strong> + </p> + </div> + {% for block in page.content %} + <div class="content-block {% if not forloop.last %}mb-8{% endif %}"> + {% include_block block %} + </div> + {% endfor %} + </div> + + <div class="pt-8 lg:w-1/3 md:pt-0"> + <div class="space-y-8"> + <div class="lg:card lg:elevation-10"> + <div class="lg:card__body"> + <h2 class="head-heavy-sm mb-4"<a href="{% pageurl page.get_parent %}">{{ page.get_parent.title }}</a></h2> + + <div + class="v-geo-feature-collection" + data-height="250px" + data-display-zoom-control="false" + data-display-legend="false" + data-display-popups="false" + data-initial-zoom="15" + data-api-keys="{{ js_map.apikeys }}" + data-tile-style="{{ js_map.style }}" + data-categories="{{ js_map.categories }}" + data-geojson="{{ js_map.geojson }}" + ></div> + + <a href="{% pageurl page.get_parent %}" class="block md:inline-block text-sm mt-4 btn btn--fullwidth btn--grey-125 btn--icon btn--hoveractive"> + <div class="btn__body-wrap"> + <div class="btn__body">Zobrazit celou mapu</div> + <div class="btn__icon"><i class="ico--map"></i></div> + </div> + </a> + + <hr /> + + <h2 class="head-heavy-xs mb-2">Za tohle ručí</h2> + <div itemprop="author" itemtype="http://schema.org/Person" itemscope=""> + <meta itemprop="name" content="{{ page.guarantor }}"> + {% include "shared/person_badge_snippet.html" with person_page=page.guarantor %} + </div> + + <hr /> + + <div class="space-y-4"> + {% for category, features in features_by_category %} + <div class="js-feature-collection" style="border-right: 4px #{{ category.hex_color }} solid;"> + <div class="flex items-center space-x-2" > + <h2 class="head-allcaps-4xs cursor-pointer js-feature-collection-toggle" data-target="{{ category.pk }}-items">{{ category.name }} ({{ features|length }})</h2> + </div> + <ul id="{{ category.pk }}-items" class="leading-normal text-sm js-feature-collection-items {% if page not in features %}hidden{% endif %}"> + {% for feature in features %} + <li class="pr-2"> + {% if feature.pk == page.pk %} + <span v-if="feature.index" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-2xs w-4 h-4 mr-1 no-underline">{{ feature.index }}</span> + <span class="font-bold">{{ feature.title }}</span> + {% else %} + <a v-if="feature.index" href="{% pageurl feature %}" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-2xs w-4 h-4 mr-1 no-underline">{{ feature.index }}</a> + <a href="{% pageurl feature %}" class="underline">{{ feature.title }}</a> + {% endif%} + </li> + {% endfor %} + </ul> + </div> + {% endfor %} + </div> + + </div> + </div> + + <div class="sharebox md:card md:elevation-10 "> + <div class="md:card__body"> + <span class="head-alt-base md:head-alt-md">Sdílení je aktem lásky</span> + <div class="flex w-full space-x-4 pt-4 md:pt-8 text-center text-white"> + <a + href="https://www.facebook.com/sharer/sharer.php?u={{ page.full_url|urlencode }}" + onclick="window.open(this.href, 'pop-up', 'left=20,top=20,width=500,height=500,toolbar=1,resizable=0'); return false;" + class="bg-brands-facebook px-8 py-3 text-2xl w-full hover:no-underline" + ><i class="ico--facebook"></i></a> + <a + href="https://twitter.com/intent/tweet?text={{ page.title|urlencode }}&url={{ page.full_url|urlencode }}" + onclick="window.open(this.href, 'pop-up', 'left=20,top=20,width=500,height=500,toolbar=1,resizable=0'); return false;" + class="bg-brands-twitter px-8 py-3 text-2xl w-full hover:no-underline" + ><i class="ico--twitter"></i></a> + </div> + </div> + <div class="h-52 overflow-hidden hidden md:block"> + <img src="{% static "shared/img/flag.png" %}" alt="Pirátská strana" class="w-80 object-cover m-auto"/> + </div> + </div> + </div> + </div> + + </div> + </article> +{% endblock %} + +{% block scripts %} + <script> + document.querySelectorAll(".js-feature-collection-toggle").forEach((el) => { + const target = document.getElementById(el.dataset.target); + el.addEventListener("click", () => { + target.classList.toggle("hidden"); + }); + + }) + </script +{% endblock %} diff --git a/majak/settings/base.py b/majak/settings/base.py index 4f0b1440ee6a9ea5ed0573ef4f84023622d4eb89..14300aeec21eaa90f853a822483c8cfafab9a0a9 100644 --- a/majak/settings/base.py +++ b/majak/settings/base.py @@ -44,6 +44,7 @@ INSTALLED_APPS = [ "czech_inspirational", "shared", "calendar_utils", + "maps_utils", "redmine_utils", "users", "pirates", diff --git a/majak/urls.py b/majak/urls.py index a310707833e3ae528542660fc2bdca6044ede7d0..52ab71403cc698fbb313cefad666a27b5178fdaa 100644 --- a/majak/urls.py +++ b/majak/urls.py @@ -8,6 +8,7 @@ from wagtail.core import urls as wagtail_urls from wagtail.documents import urls as wagtaildocs_urls from elections2021 import views as elections2021_views +from maps_utils import urls as maps_utils_urls handler404 = "shared.views.page_not_found" @@ -20,6 +21,7 @@ urlpatterns = [ elections2021_views.banner_orders_csv, name="elections2021_banner_orders_csv", ), + path("maps/", include(maps_utils_urls)), path("captcha/", include(captcha.urls)), ] + pirates_urlpatterns diff --git a/maps_utils/__init__.py b/maps_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/maps_utils/apps.py b/maps_utils/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..d6e45be88e949aba41a893fae21c946b28e2745f --- /dev/null +++ b/maps_utils/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MapsUtilsConfig(AppConfig): + name = "maps_utils" diff --git a/maps_utils/blocks.py b/maps_utils/blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..00a1bdb0bc3a9a0dd630b42305e1f4889edffdae --- /dev/null +++ b/maps_utils/blocks.py @@ -0,0 +1,165 @@ +import json +from typing import Mapping, Optional +from uuid import uuid4 + +from django.forms.utils import ErrorList +from django.utils.text import slugify +from wagtail.core import blocks +from wagtail.core.blocks.struct_block import StructBlockValidationError +from wagtail.images.blocks import ImageChooserBlock + +from .const import DEFAULT_MAP_STYLE, MAP_STYLES, SUPPORTED_FEATURE_TYPES +from .validation import validators + + +def _apikeys_from_parent_context(parent_context: Optional[Mapping]): + if parent_context: + root_page = parent_context["page"].root_page + + if hasattr(root_page, "get_api_keys"): + return root_page.get_api_keys() + + return {} + + +class MapPointBlock(blocks.StructBlock): + lat = blocks.DecimalBlock(label="Zeměpisná šířka", help_text="Např. 50.04075") + lon = blocks.DecimalBlock(label="Zeměpisná délka", help_text="Např. 15.77659") + hex_color = blocks.CharBlock( + label="Barva špendlíku (HEX)", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + default="000000", + ) + zoom = blocks.IntegerBlock( + label="Výchozí zoom", min_value=1, max_value=18, default=15 + ) + style = blocks.ChoiceBlock( + choices=MAP_STYLES, label="Styl", default=DEFAULT_MAP_STYLE + ) + height = blocks.IntegerBlock(label="Výška v px", min_value=100, max_value=1000) + + class Meta: + label = "Mapa se špendlíkem" + template = "maps_utils/blocks/map_point.html" + icon = "thumbtack" + + def get_context(self, value, parent_context=None): + context = super().get_context(value, parent_context) + feature_id = str(uuid4()) + context["js_map"] = { + "apikeys": json.dumps(_apikeys_from_parent_context(parent_context)), + "geojson": json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + float(value["lon"]), + float(value["lat"]), + ], + }, + "properties": { + "id": feature_id, + "slug": feature_id, + "title": "", + "description": "", + "image": None, + "color": value["hex_color"], + }, + }, + ], + } + ), + } + + return context + + +class MapFeatureBlock(blocks.StructBlock): + title = blocks.CharBlock(label="Titulek", required=True) + description = blocks.TextBlock(label="Popisek", required=False) + geojson = blocks.TextBlock( + label="Geodata", + help_text="Vložte surový GeoJSON objekt typu 'Feature'. Vyrobit jej můžete např. pomocí online služby geojson.io.", + required=True, + ) + image = ImageChooserBlock(label="Obrázek", required=False) + link = blocks.URLBlock(label="Odkaz", required=False) + hex_color = blocks.CharBlock( + label="Barva (HEX)", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + default="000000", + ) + + class Meta: + label = "Položka mapové kolekce" + icon = "list-ul" + + def clean(self, value): + errors = {} + + if value["geojson"]: + try: + value["geojson"] = validators.normalize_geojson_feature( + value["geojson"], allowed_types=SUPPORTED_FEATURE_TYPES + ) + except ValueError as exc: + errors["geojson"] = ErrorList(str(exc)) + + if errors: + raise StructBlockValidationError(errors) + + return super().clean(value) + + +class MapFeatureCollectionBlock(blocks.StructBlock): + features = blocks.ListBlock(MapFeatureBlock(required=True), label="Součásti") + + zoom = blocks.IntegerBlock( + label="Výchozí zoom", min_value=1, max_value=18, default=15 + ) + style = blocks.ChoiceBlock( + choices=MAP_STYLES, label="Styl", default=DEFAULT_MAP_STYLE + ) + height = blocks.IntegerBlock(label="Výška v px", min_value=100, max_value=1000) + + class Meta: + label = "Mapová kolekce" + template = "maps_utils/blocks/map_feature_collection.html" + icon = "site" + + def get_context(self, value, parent_context=None): + def _geojson_feature_with_props(feature_id, feature): + fwp = json.loads(feature["geojson"]) + fwp["properties"] = { + "id": feature_id, + "slug": f"{feature_id}-{slugify(feature['title'])}", + "title": feature["title"], + "description": feature["description"], + "image": feature["image"] + .get_rendition("fill-800x450|jpegquality-80") + .url, + "link": feature["link"], + "color": feature["hex_color"], + } + return fwp + + context = super().get_context(value, parent_context) + context["js_map"] = { + "apikeys": json.dumps(_apikeys_from_parent_context(parent_context)), + "geojson": json.dumps( + { + # Dump individual features in collection + "type": "FeatureCollection", + "features": [ + _geojson_feature_with_props(i, f) + for i, f in enumerate(value["features"]) + ], + } + ), + } + + return context diff --git a/maps_utils/const.py b/maps_utils/const.py new file mode 100644 index 0000000000000000000000000000000000000000..c54e06a70c2d99e89a18e9c5ce69ba9322320f38 --- /dev/null +++ b/maps_utils/const.py @@ -0,0 +1,11 @@ +MAP_STYLES = ( + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ("stadia-osm-bright", "Stadia OSM Bright (vyžaduje API klíč)"), + ("stadia-outdoors", "Stadia Outdoors (vyžaduje API klíč)"), +) + +DEFAULT_MAP_STYLE = "osm-mapnik" + +SUPPORTED_FEATURE_TYPES = ("Point", "Polygon", "LineString") diff --git a/maps_utils/static/maps_utils/geo-feature-collection.css b/maps_utils/static/maps_utils/geo-feature-collection.css new file mode 100644 index 0000000000000000000000000000000000000000..5a3c181d3109a9308ba87687cbe46327104f9955 --- /dev/null +++ b/maps_utils/static/maps_utils/geo-feature-collection.css @@ -0,0 +1,90 @@ +.marker-cluster { + border-radius: 50%; + background-clip: padding-box; +} +.marker-cluster div { + color: #fff; + font-family: Roboto; + font-weight: bold; + text-align: center; + font-size: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + margin-left:0; + margin-top: 0; +} +.marker-cluster span { + line-height: 40px; +} +.marker-cluster-small, +.marker-cluster-medium, +.marker-cluster-small:hover, +.marker-cluster-medium:hover { + background-color: transparent; +} +.marker-cluster-small div, +.marker-cluster-medium div{ + background-color: rgba(0, 0, 0, 0.7); +} +.marker-cluster-small:hover div, +.marker-cluster-medium:hover div { + background-color: rgba(0, 0, 0, 0.9); +} + +.geo-feature-collection { + position: relative; +} + +.geo-feature-collection__map-layer { + width: 100%; + z-index: -1; +} + +.geo-feature-collection .modal__overlay { + z-index: 9999; +} + +.modal__container-body { + position: relative; +} + +.geo-feature-collection .modal__close { + position: absolute; + right: 0; + top: 0; + height: 2rem; + width: 2rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + margin: auto; + color: var(--color-black); + background: var(--color-white); +} + +.geo-feature-collection .card__body { + position: relative; +} + +.geo-feature-collection-item__category { + position: absolute; + left: 1rem; + top: -2rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: .5rem 1rem 1rem; + background: var(--color-white); +} + +.geo-feature-collection__legend { + padding: 1rem; + min-width: 18em; + background: var(--color-white); + /* border: 1px var(--color-grey-200) solid; */ + border-radius: .5rem; + pointer-events: all; +} diff --git a/maps_utils/static/maps_utils/geo-feature-collection.js b/maps_utils/static/maps_utils/geo-feature-collection.js new file mode 100644 index 0000000000000000000000000000000000000000..5f2826ecb5ad3509c0e7542a54f504107a7711bc --- /dev/null +++ b/maps_utils/static/maps_utils/geo-feature-collection.js @@ -0,0 +1,566 @@ +Vue.component("l-map", window.Vue2Leaflet.LMap); +Vue.component("l-tile-layer", window.Vue2Leaflet.LTileLayer); +Vue.component("l-marker", window.Vue2Leaflet.LMarker); +Vue.component("l-control", window.Vue2Leaflet.LControl); + +const buildMarkerIcon = (color, number) => { + const iconUrl = + number !== undefined + ? `/maps/marker/${color}/${number}/` + : `/maps/marker/${color}/`; + + return new L.Icon({ + iconUrl, + iconSize: [40, 64], + iconAnchor: [20, 64], + }); +}; + +const tileStyles = { + "osm-mapnik": (apiKeys) => ({ + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: + '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors', + maxZoom: 19, + maxNativeZoom: 19, + }), + "stamen-toner": (apiKeys) => ({ + url: "https://stamen-tiles.a.ssl.fastly.net/toner/{z}/{x}/{y}.png", + attribution: + 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.', + ext: "png", + maxZoom: 20, + maxNativeZoom: 20, + }), + "stamen-terrain": (apiKeys) => ({ + url: "https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.png", + attribution: + 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.', + ext: "png", + maxZoom: 20, + maxNativeZoom: 20, + }), + "stadia-osm-bright": (apiKeys) => ({ + url: + "https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.png?api_key=" + + apiKeys.stadia, + attribution: + '© <a href="https://stadiamaps.com/">Stadia Maps</a>, © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors', + maxZoom: 20, + maxNativeZoom: 20, + }), + "stadia-outdoors": (apiKeys) => ({ + url: + "https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png?api_key=" + + apiKeys.stadia, + attribution: + '© <a href="https://stadiamaps.com/">Stadia Maps</a>, © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors', + maxZoom: 20, + maxNativeZoom: 20, + }), +}; + +const GeoFeatureCollection = Vue.component("GeoFeatureCollection", { + props: { + apiKeys: { + type: Object, + default: {}, + }, + geojson: { + type: Object, + required: true, + }, + categoryList: { + type: Array, + required: false, + }, + wrapperClass: { + type: String, + default: "", + }, + tileStyle: { + type: String, + default: "osm-mapnik", + }, + initialZoom: { + type: Number, + default: 13, + }, + displayLegend: { + type: Boolean, + default: true, + }, + displayZoomControl: { + type: Boolean, + default: true, + }, + displayPopups: { + type: Boolean, + default: true, + }, + height: { + type: String, + default: "50rem", + }, + }, + data: function () { + return { + // Future map reference + map: null, + // Future geojson layer for the map + layer: null, + currentItem: null, + categories: [], + zoom: this.initialZoom, + currentItem: null, + ...tileStyles[this.tileStyle](this.apiKeys), + }; + }, + computed: { + categoryListExpanded: function () { + return Object.values(this.categories).reduce((result, category) => { + return result || category.expanded; + }, false); + }, + categoryCount: function () { + return Object.keys(this.categories).length; + }, + }, + mounted() { + this.map = this.$refs.map.mapObject; + this.initialize(this.categoryList, this.geojson); + + window.addEventListener("keydown", (event) => { + if (event.defaultPrevented) { + return; // Should do nothing if the default action has been cancelled + } + + let handled = false; + let key; + + if (event.key !== undefined) { + // Handle the event with KeyboardEvent.key and set handled true. + key = event.key; + } else if (event.keyCode !== undefined) { + // Handle the event with KeyboardEvent.keyCode and set handled true. + key = event.keyCode; + } + + if (key === 27 || key === "Esc" || key === "Escape") { + handled = true; + this.closeItemInfo(); + } + + if (handled) { + // Suppress "double action" if event handled + event.preventDefault(); + } + }); + }, + // Unbind event listener on component destroy. + beforeDestroy() { + window.removeEventListener("hashchange", this.onHashChange); + }, + methods: { + initialize(categories, geojson) { + // Annotate with proper id, slug and composed url. + const annotatedFeatures = geojson.features.map((f) => { + f.properties.url = `${f.id}-${f.slug}`; + return f; + }); + + if (categories) { + // Build category list. + this.initCategories(categories, annotatedFeatures); + + annotatedFeatures.forEach((f) => { + if ( + f.properties.category && + this.categories[f.properties.category] + ) { + f.properties.categoryObj = + this.categories[f.properties.category]; + } + }); + } + + // Draw the map. + this.initMap({ + type: "FeatureCollection", + features: annotatedFeatures, + }); + }, + /** + * Traverse list of features a build list of categories + * with features assigned. + * + * @param {GeoJSON features} features + */ + initCategories(inputCategories, features) { + const categories = {}; + + inputCategories.forEach((cat, index) => { + categories[cat.name] = { + expanded: false, + name: cat.name, + color: cat.color, + items: [], + }; + }); + + features.forEach((feature) => { + if (feature.properties.category) { + categories[feature.properties.category].items.push(feature); + } + }); + + this.categories = categories; + + if (this.categories.length === 0) { + this.displayLegend = false; + } + }, + initMap(geojson) { + // Markers are clustered for easier orientation. + const markers = L.markerClusterGroup({ + showCoverageOnHover: false, + maxClusterRadius: 48, + }); + + // Get color for feature - either from category or fall back to default. + const colorForFeature = (feature) => { + if (feature.properties.color) { + return "#" + feature.properties.color; + } + const cat = feature.properties.categoryObj; + return cat ? "#" + cat.color : "#000000"; + }; + + // Get style for given feature. + const style = (feature) => ({ + fillColor: colorForFeature(feature), + weight: 2, + opacity: 0.7, + color: colorForFeature(feature), + fillOpacity: 0.5, + }); + + const markerIconForCategory = (categoryName, number) => + categoryName && this.categories[categoryName] + ? buildMarkerIcon(this.categories[categoryName].color, number) + : buildMarkerIcon("000000", number); + + /** + * Process Point type GeoJSON features. + * Called for each such feature when building the map. + */ + const pointToLayer = (feature, latlng) => { + const markerPosLatLng = L.latLng( + feature.geometry.coordinates[1], + feature.geometry.coordinates[0] + ); + + // add marker + const featureMarker = new L.marker(markerPosLatLng, { + icon: feature.properties.color + ? buildMarkerIcon( + feature.properties.color, + feature.properties.index + ) + : markerIconForCategory( + feature.properties.category, + feature.properties.index + ), + }).on("click", (evt) => { + this.zoomToPoint(evt.latlng, feature); + }); + + // Add item marker to the cluster. + markers.addLayer(featureMarker); + + return false; + }; + + /** + * Process Polygon/LineString type GeoJSON features. + * Called for each such feature when building the map. + */ + const onEachFeature = (feature, layer) => { + let markerPosLatLng; + + /** + * Supported GeoJSON features: Polygon, LineString. + * Point features are better handled by pointToLayer function. + * It's better idea to convert Points to small Polygons (ask marek.forster@pirati.cz for conversion tool) + **/ + // Polygons and LineString features are supported, try to get rid of Points (bounds and zoom methods not supported) + if (feature.geometry.type == "Polygon") { + // Find pole of inaccessibility (not centroid) for the polygon + // @see: https://github.com/mapbox/polylabel + const markerPos = _mapbox_polylabel( + feature.geometry.coordinates, + 1 + ); + markerPosLatLng = L.latLng(markerPos[1], markerPos[0]); + } else if (feature.geometry.type == "LineString") { + // Find a middle sector of LineString and set position to middle of it + const sectorCount = feature.geometry.coordinates.length; + const sectorIndex = Math.floor((sectorCount - 1) / 2); + markerPosLatLng = L.latLng( + (feature.geometry.coordinates[sectorIndex][1] + + feature.geometry.coordinates[sectorIndex + 1][1]) / + 2, + (feature.geometry.coordinates[sectorIndex][0] + + feature.geometry.coordinates[sectorIndex + 1][0]) / + 2 + ); + } else { + console.warn( + `GeoJSON feature type unsupported: ${feature.geometry.type}` + ); + } + + if (markerPosLatLng) { + // add marker + const featureMarker = new L.marker(markerPosLatLng, { + icon: feature.properties.color + ? buildMarkerIcon( + feature.properties.color, + feature.properties.index + ) + : markerIconForCategory( + feature.properties.category, + feature.properties.index + ), + }).on("click", (evt) => this.zoomToLayer(layer)); + // Add item marker to the cluster. + markers.addLayer(featureMarker); + // Bind click event on the layer. + layer.on({ click: (evt) => this.zoomToLayer(evt.target) }); + } + }; + + this.layer = L.geoJSON(geojson, { + style, + onEachFeature, + pointToLayer, + }); + this.layer.addTo(this.map); + this.map.addLayer(markers); + + // Pan the map view. + if ( + geojson.features.length == 1 && + geojson.features[0].geometry.type === "Point" + ) { + // Pan to the single point. + const coords = geojson.features[0].geometry.coordinates; + this.map.panTo([coords[1], coords[0]]); + } else { + // Pan to the bounds center. + this.map.panTo(this.layer.getBounds().getCenter()); + } + + // If hash is present when starting, locate the item and zoom to it. + if (window.location.hash) { + this.zoomToItemBySlugUrl(window.location.hash.substring(1)); + } + + // Listen to hashbang changes when user users browser history navigation. + window.addEventListener("hashchange", this.onHashChange, false); + }, + /** + * Stores item's url in the hashbang. + * + * @param {Object} item + */ + setUrlHash(item) { + if (item === null) { + history.replaceState({}, null, "#"); + } else { + history.pushState({}, item.title, "#" + item.slug); + } + }, + /** + * Called when URL hash changes. Will display detail + * of corresponding item if such exist. + * + * @param {Event} evt + */ + onHashChange(evt) { + const urlBits = evt.newURL.split("#"); + + if (urlBits.length == 2 && urlBits[1]) { + this.zoomToItemBySlugUrl(urlBits[1]); + } else { + this.closeItemInfo(); + } + }, + /** + * Hide current item detail, drop it from URL. + */ + closeItemInfo() { + if (this.currentItem) { + this.currentItem = null; + this.setUrlHash(null); + } + }, + /** + * Expand category, show list of items belonging to id. + * @param {String} category + */ + toggleExpandCategory(category) { + category.expanded = !category.expanded; + }, + /** + * Zoom to a point. + * + * @param {L.Latlng} Latlng + * @param {Boolean} pushState whether to push new state to history. + */ + zoomToPoint(latlng, feature, updateUrl = true) { + this.map.panTo(latlng); + this.currentItem = feature.properties; + + if (updateUrl) { + this.setUrlHash(this.currentItem); + } + }, + /** + * Zoom to a detail of a layer. + * + * @param {L.Layer} layer + * @param {Boolean} pushState whether to push new state to history. + */ + zoomToLayer(layer, updateUrl = true) { + this.map.fitBounds(layer.getBounds()); + this.currentItem = layer.feature.properties; + + if (updateUrl) { + this.setUrlHash(this.currentItem); + } + }, + /** + * Zoom to a cateogry item. Wraps `zoomTo`. + * + * @param {Object} category + * @param {Object} item + */ + zoomToCategoryItem(category, item) { + const layer = Object.values(this.layer._layers).find( + (l) => l.feature.properties.id == item.properties.id + ); + if (layer) { + this.zoomToLayer(layer); + } + }, + /** + * Zoom to a item with corresponding slugified URL. + * + * @param {String} slugUrl + */ + zoomToItemBySlugUrl(slugUrl) { + const layer = Object.values(this.layer._layers).find( + (l) => l.feature.properties.slug == slugUrl + ); + if (layer) { + this.zoomToLayer(layer, false); + } + }, + /** + * Stop event propagation, utility fn. + * @param {Event} evt + */ + stopPropagation(evt) { + evt.stopPropagation(); + }, + }, + template: ` + <div :class="'geo-feature-collection' + ' ' + wrapperClass"> + <div class="geo-feature-collection__map-layer"> + <l-map + ref="map" + :style="{height: height}" + :zoom="zoom" + :maxZoom="maxZoom" + :options="{maxNativeZoom: maxNativeZoom, zoomSnap: 0.5, zoomControl: displayZoomControl}" + > + <l-tile-layer + :url="url" + :attribution="attribution" + :options="{maxZoom: maxZoom, maxNativeZoom: maxNativeZoom}" + ></l-tile-layer> + <l-control v-if="displayLegend" class="geo-feature-collection__legend text-sm leading-normal elevation-10 space-y-1"> + <div v-for="(category, name, index) in categories" :key="category.name"> + <div class="flex items-center space-x-2"> + <div class="w-3 h-3 rounded-full" :style="{backgroundColor: '#' + category.color}"></div> + <button class="head-allcaps-4xs cursor-pointer" @click="toggleExpandCategory(category)">{{ category.name }} ({{ category.items.length }})</button> + </div> + <ul v-show="category.expanded" :class="{'mb-2': index != categoryCount - 1}"> + <li v-for="item in category.items" :key="item.properties.title"> + <button @click="zoomToCategoryItem(category, item)" class="text-left leading-tight text-xs" style="max-width: 17em;"> + <span v-if="item.properties.index" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-2xs w-4 h-4 mr-1">{{ item.properties.index }}</span> + <span>{{ item.properties.title }}</span> + </button> + </li> + </ul> + </div> + </l-control> + </l-map> + </div> + + <div class="modal__overlay" v-if="displayPopups && currentItem" @click="closeItemInfo" tabindex="1"> + <div class="modal__content"> + <div class="modal__container w-full max-w-xl" role="dialog" @click="stopPropagation"> + <div class="modal__container-body elevation-10"> + <button @click="closeItemInfo" class="modal__close" title="Zavřít"><i class="ico--cross" /></button> + <div class="card"> + <img v-if="currentItem.image" :src="currentItem.image" :alt="currentItem.name"> + <div class="card__body"> + <div class="geo-feature-collection-item__category flex items-center space-x-2" v-if="currentItem.categoryObj"> + <div class="w-3 h-3 rounded-full" :style="{backgroundColor: '#' + currentItem.categoryObj.color}"></div> + <span class="head-allcaps-4xs">{{ currentItem.categoryObj.name }}</span> + </div> + <div class="card-headline flex items-center mb-2"> + <span v-if="currentItem.index" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-sm w-6 h-6 mr-2">{{ currentItem.index }}</span> + <span>{{ currentItem.title }}</span> + </div> + + <div class="card-body-text"> + {{ currentItem.description }} + </div> + + <a v-if="currentItem.link" :href="currentItem.link" class="mt-4 text-sm btn btn--grey-125 btn--hoveractive"><div class="btn__body">Zjistit více</div></a> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + `, +}); + +Array.from(document.getElementsByClassName("v-geo-feature-collection")).forEach( + (el) => { + new Vue({ + el, + render: (h) => + h(GeoFeatureCollection, { + props: { + apiKeys: JSON.parse(el.dataset.apiKeys || "{}"), + categoryList: JSON.parse(el.dataset.categories || "[]"), + geojson: JSON.parse(el.dataset.geojson), + displayLegend: el.dataset.displayLegend != "false", + displayZoomControl: + el.dataset.displayZoomControl != "false", + displayPopups: el.dataset.displayPopups != "false", + height: el.dataset.height, + tileStyle: el.dataset.tileStyle, + initialZoom: parseInt(el.dataset.initialZoom), + wrapperClass: el.dataset.wrapperClass, + }, + }), + }); + } +); diff --git a/maps_utils/static/marker-numbered.svg b/maps_utils/static/marker-numbered.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea560c5f6797f1069fd66cf793e4c5717aff58c3 --- /dev/null +++ b/maps_utils/static/marker-numbered.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 40 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(1,0,0,1,0.830095,-0.855097)"> + <g transform="matrix(1.6,0,0,1.70554,-26.0301,-28.3709)"> + <circle cx="28.25" cy="27.25" r="8.75" style="fill:#FFFFFF;"/> + <text text-anchor="middle" dominant-baseline="central" x="28.25" y="27.25" style="font-family:'Roboto-Bold', 'Roboto';font-weight:700;font-size:10px;">16</text> + </g> + <g transform="matrix(1,0,0,1,-0.830095,0.855097)"> + <path d="M20,64L18,60L22,60L20,64ZM18,34.385C9.421,33.393 2.75,26.095 2.75,17.25C2.75,7.729 10.479,0 20,0C29.521,0 37.25,7.729 37.25,17.25C37.25,26.095 30.579,33.393 22,34.385L22,60L18,60L18,34.385ZM20,3.536C27.569,3.536 33.714,9.681 33.714,17.25C33.714,24.819 27.569,30.964 20,30.964C12.431,30.964 6.286,24.819 6.286,17.25C6.286,9.681 12.431,3.536 20,3.536Z" style="fill:#$FILLCOLOR$;"/> + </g> + </g> +</svg> diff --git a/maps_utils/static/marker.svg b/maps_utils/static/marker.svg new file mode 100644 index 0000000000000000000000000000000000000000..a4aa4d3878ef49750cc7ff04c62b57bbd86e6be5 --- /dev/null +++ b/maps_utils/static/marker.svg @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 40 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(1,0,0,1,0.830095,-0.855097)"> + <g transform="matrix(1.4663,0,0,1.56302,-22.253,-24.4872)"> + <ellipse cx="28.25" cy="27.25" rx="8.75" ry="8.25" style="fill:rgb(242,242,242);"/> + </g> + <g transform="matrix(1,0,0,1,-0.830095,0.855097)"> + <path d="M20,64L18,60L22,60L20,64ZM18,34.385C9.421,33.393 2.75,26.095 2.75,17.25C2.75,7.729 10.479,0 20,0C29.521,0 37.25,7.729 37.25,17.25C37.25,26.095 30.579,33.393 22,34.385L22,60L18,60L18,34.385ZM20,3.536C27.569,3.536 33.714,9.681 33.714,17.25C33.714,24.819 27.569,30.964 20,30.964C12.431,30.964 6.286,24.819 6.286,17.25C6.286,9.681 12.431,3.536 20,3.536Z" style="fill:rgb(255,31,110);"/> + </g> + <g id="g4674" transform="matrix(0.631511,0,0,0.629547,3.63473,-0.246209)"> + <path id="path4657" d="M24.7,7.2C18.8,7.2 13.3,9.5 9.2,13.6C5,17.7 2.7,23.3 2.7,29.1C2.7,35 5,40.5 9.1,44.7C13.3,48.8 18.8,51.1 24.6,51.1C30.5,51.1 36,48.8 40.1,44.7C44.3,40.6 46.5,35 46.5,29.2C46.5,23.3 44.2,17.8 40.1,13.7C36.1,9.4 30.6,7.2 24.7,7.2M24.7,49C13.7,49 4.8,40.1 4.8,29.1C4.8,18.1 13.7,9.2 24.7,9.2C35.7,9.2 44.6,18.1 44.6,29.1C44.6,40.1 35.7,49 24.7,49" style="fill-rule:nonzero;"/> + <path id="path4659" d="M18.1,16.1L18.1,13L16.2,13L16.2,16.6C14.9,17 14.1,17.4 14.3,17.7C14.7,17.6 15.4,17.5 16.2,17.6L16.2,36C14.2,39.8 17.1,45.7 17.1,45.7C17.1,45.7 15,39.4 19.7,36.4C24,33.7 39,34.9 38.9,26.5C38.8,14.6 25,14.6 18.1,16.1M24.2,27.7C23.5,30.9 20.1,32.5 18,33.9L18,17.8C21.5,18.6 25.6,21.3 24.2,27.7" style="fill-rule:nonzero;"/> + </g> + </g> +</svg> diff --git a/maps_utils/templates/maps_utils/blocks/map_feature_collection.html b/maps_utils/templates/maps_utils/blocks/map_feature_collection.html new file mode 100644 index 0000000000000000000000000000000000000000..d514566fea9aaaf01818db727f72ca07ced362d7 --- /dev/null +++ b/maps_utils/templates/maps_utils/blocks/map_feature_collection.html @@ -0,0 +1,12 @@ +<div + class="v-geo-feature-collection" + data-tile-style="{{ self.style }}" + data-wrapper-class="container-padding--zero lg:container-padding--auto" + data-initial-zoom="{{ self.zoom }}" + data-display-zoom-control="true" + data-display-legend="false" + data-display-popups="true" + data-height="{{ self.height }}px" + data-api-keys="{{ js_map.apikeys }}" + data-geojson="{{ js_map.geojson }}" +></div> diff --git a/maps_utils/templates/maps_utils/blocks/map_point.html b/maps_utils/templates/maps_utils/blocks/map_point.html new file mode 100644 index 0000000000000000000000000000000000000000..f3c033aab70c0d4447c0eb75e79025e6f3feef9a --- /dev/null +++ b/maps_utils/templates/maps_utils/blocks/map_point.html @@ -0,0 +1,12 @@ +<div + class="v-geo-feature-collection" + data-tile-style="{{ self.style }}" + data-wrapper-class="container-padding--zero lg:container-padding--auto" + data-initial-zoom="{{ self.zoom }}" + data-display-zoom-control="false" + data-display-legend="false" + data-display-popups="false" + data-height="{{ self.height }}px" + data-api-keys="{{ js_map.apikeys }}" + data-geojson="{{ js_map.geojson }}" +></div> diff --git a/maps_utils/templates/maps_utils/marker-numbered.svg.template b/maps_utils/templates/maps_utils/marker-numbered.svg.template new file mode 100644 index 0000000000000000000000000000000000000000..8541024c34108bdb2107d3bca13395e459199dd0 --- /dev/null +++ b/maps_utils/templates/maps_utils/marker-numbered.svg.template @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 40 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(1,0,0,1,0.830095,-0.855097)"> + <g transform="matrix(1.6,0,0,1.70554,-26.0301,-28.3709)"> + <circle cx="28.25" cy="27.25" r="8.75" style="fill:#FFFFFF;"/> + <text text-anchor="middle" dominant-baseline="central" x="28.25" y="27.25" style="font-family:'Roboto-Bold', 'Roboto';font-weight:700;font-size:10px;">$FILLNUMBER$</text> + </g> + <g transform="matrix(1,0,0,1,-0.830095,0.855097)"> + <path d="M20,64L18,60L22,60L20,64ZM18,34.385C9.421,33.393 2.75,26.095 2.75,17.25C2.75,7.729 10.479,0 20,0C29.521,0 37.25,7.729 37.25,17.25C37.25,26.095 30.579,33.393 22,34.385L22,60L18,60L18,34.385ZM20,3.536C27.569,3.536 33.714,9.681 33.714,17.25C33.714,24.819 27.569,30.964 20,30.964C12.431,30.964 6.286,24.819 6.286,17.25C6.286,9.681 12.431,3.536 20,3.536Z" style="fill:#$FILLCOLOR$;"/> + </g> + </g> +</svg> diff --git a/maps_utils/templates/maps_utils/marker.svg.template b/maps_utils/templates/maps_utils/marker.svg.template new file mode 100644 index 0000000000000000000000000000000000000000..e68d8b167bb977c9d1255c248dd98e5e86016f4f --- /dev/null +++ b/maps_utils/templates/maps_utils/marker.svg.template @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 40 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(1,0,0,1,0.830095,-0.855097)"> + <g transform="matrix(1.4663,0,0,1.56302,-22.253,-24.4872)"> + <ellipse cx="28.25" cy="27.25" rx="8.75" ry="8.25" style="fill:#FFFFFF;"/> + </g> + <g transform="matrix(1,0,0,1,-0.830095,0.855097)"> + <path d="M20,64L18,60L22,60L20,64ZM18,34.385C9.421,33.393 2.75,26.095 2.75,17.25C2.75,7.729 10.479,0 20,0C29.521,0 37.25,7.729 37.25,17.25C37.25,26.095 30.579,33.393 22,34.385L22,60L18,60L18,34.385ZM20,3.536C27.569,3.536 33.714,9.681 33.714,17.25C33.714,24.819 27.569,30.964 20,30.964C12.431,30.964 6.286,24.819 6.286,17.25C6.286,9.681 12.431,3.536 20,3.536Z" style="fill:#$FILLCOLOR$;"/> + </g> + <g id="g4674" transform="matrix(0.631511,0,0,0.629547,3.63473,-0.246209)"> + <path id="path4657" d="M24.7,7.2C18.8,7.2 13.3,9.5 9.2,13.6C5,17.7 2.7,23.3 2.7,29.1C2.7,35 5,40.5 9.1,44.7C13.3,48.8 18.8,51.1 24.6,51.1C30.5,51.1 36,48.8 40.1,44.7C44.3,40.6 46.5,35 46.5,29.2C46.5,23.3 44.2,17.8 40.1,13.7C36.1,9.4 30.6,7.2 24.7,7.2M24.7,49C13.7,49 4.8,40.1 4.8,29.1C4.8,18.1 13.7,9.2 24.7,9.2C35.7,9.2 44.6,18.1 44.6,29.1C44.6,40.1 35.7,49 24.7,49" style="fill-rule:nonzero;"/> + <path id="path4659" d="M18.1,16.1L18.1,13L16.2,13L16.2,16.6C14.9,17 14.1,17.4 14.3,17.7C14.7,17.6 15.4,17.5 16.2,17.6L16.2,36C14.2,39.8 17.1,45.7 17.1,45.7C17.1,45.7 15,39.4 19.7,36.4C24,33.7 39,34.9 38.9,26.5C38.8,14.6 25,14.6 18.1,16.1M24.2,27.7C23.5,30.9 20.1,32.5 18,33.9L18,17.8C21.5,18.6 25.6,21.3 24.2,27.7" style="fill-rule:nonzero;"/> + </g> + </g> +</svg> diff --git a/maps_utils/urls.py b/maps_utils/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..51866b11f2c057f2580878847806e7adea90690d --- /dev/null +++ b/maps_utils/urls.py @@ -0,0 +1,11 @@ +from django.urls import re_path + +from .views import serve_colored_marker + +urlpatterns = [ + re_path( + r"^marker/(?P<color>.{6})/((?P<number>\d+)/)?$", + serve_colored_marker, + name="maps_utils_serve_colored_marker", + ), +] diff --git a/maps_utils/validation/__init__.py b/maps_utils/validation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/maps_utils/validation/schemas/Feature.json b/maps_utils/validation/schemas/Feature.json new file mode 100644 index 0000000000000000000000000000000000000000..30151f53a97b779ef873ff837c80aec2cd0a9e93 --- /dev/null +++ b/maps_utils/validation/schemas/Feature.json @@ -0,0 +1,505 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://geojson.org/schema/Feature.json", + "title": "GeoJSON Feature", + "type": "object", + "required": [ + "type", + "properties", + "geometry" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "id": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "properties": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object" + } + ] + }, + "geometry": { + "oneOf": [ + { + "type": "null" + }, + { + "title": "GeoJSON Point", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON LineString", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "LineString" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON Polygon", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON MultiPoint", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPoint" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON MultiLineString", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiLineString" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON MultiPolygon", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON GeometryCollection", + "type": "object", + "required": [ + "type", + "geometries" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "GeometryCollection" + ] + }, + "geometries": { + "type": "array", + "items": { + "oneOf": [ + { + "title": "GeoJSON Point", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON LineString", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "LineString" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON Polygon", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON MultiPoint", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPoint" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON MultiLineString", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiLineString" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + }, + { + "title": "GeoJSON MultiPolygon", + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + } + ] + } + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + } + ] + }, + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } +} diff --git a/maps_utils/validation/validators.py b/maps_utils/validation/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..58a22f955e4b0db29b3a6064cc1d5809d69833e9 --- /dev/null +++ b/maps_utils/validation/validators.py @@ -0,0 +1,49 @@ +import json +from os import path +from typing import Optional, Sequence + +import fastjsonschema + + +def _validator(schema_file_path: str): + """Build a JSON schema validator.""" + with open(schema_file_path) as f: + schema = f.read() + + return fastjsonschema.compile(json.loads(schema)) + + +feature_validator = _validator( + path.join(path.dirname(__file__), "schemas/Feature.json") +) + + +def normalize_geojson_feature( + value: str, allowed_types: Optional[Sequence[str]] = None +): + """Given a string, verify it's valid GeoJSON of a `Feature` type and normalize it. + + :param value: string to verify & normalize + :return: cleaned geojson value + """ + try: + parsed = json.loads(value) + # Strip whitespace & normalize + value = json.dumps(parsed) + except json.JSONDecodeError as exc: + raise ValueError("Hodnota není platný JSON.") from exc + else: + try: + feature_validator(parsed) + except fastjsonschema.JsonSchemaException as exc: + raise ValueError( + f"Hodnota není platný GeoJSON Feature objekt: '{str(exc)}'." + ) from exc + + if allowed_types: + if parsed["geometry"]["type"] not in allowed_types: + raise ValueError( + f"Typ GeoJSON feature objektu '{parsed['geometry']['type']}' není podporován. Musí být jeden z následujících: {', '.join(allowed_types)}." + ) + + return value diff --git a/maps_utils/views.py b/maps_utils/views.py new file mode 100644 index 0000000000000000000000000000000000000000..4c69568e2e1f9e00178d3a850c1e2b4252ddf50a --- /dev/null +++ b/maps_utils/views.py @@ -0,0 +1,30 @@ +from functools import cache +from os import path +from typing import Optional + +from django.http import HttpResponse + + +@cache +def get_marker_template(template_name: str): + marker_path = path.join( + path.dirname(__file__), f"templates/maps_utils/{template_name}" + ) + + with open(marker_path) as f: + return f.read() + + +def serve_colored_marker(request, color: str, number: Optional[int] = None): + template = get_marker_template( + "marker-numbered.svg.template" if number is not None else "marker.svg.template" + ) + value = template.replace("$FILLCOLOR$", color) + + if number is not None: + value = value.replace("$FILLNUMBER$", number) + + return HttpResponse( + value, + content_type="image/svg+xml", + ) diff --git a/region/migrations/0037_alter_regionarticlepage_content.py b/region/migrations/0037_alter_regionarticlepage_content.py new file mode 100644 index 0000000000000000000000000000000000000000..4dcde9ce341bd305d7889e4a288404ed871b671b --- /dev/null +++ b/region/migrations/0037_alter_regionarticlepage_content.py @@ -0,0 +1,228 @@ +# Generated by Django 4.0.3 on 2022-05-01 10:03 + +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.images.blocks +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("region", "0036_alter_regionarticlepage_content_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="regionarticlepage", + name="content", + field=wagtail.core.fields.StreamField( + [ + ( + "text", + wagtail.core.blocks.RichTextBlock( + features=[ + "h2", + "h3", + "h4", + "bold", + "italic", + "ol", + "embed", + "ul", + "link", + "document-link", + "image", + ], + label="Textový editor", + ), + ), + ( + "gallery", + wagtail.core.blocks.StructBlock( + [ + ( + "gallery_items", + wagtail.core.blocks.ListBlock( + wagtail.images.blocks.ImageChooserBlock( + label="obrázek", required=True + ), + group="ostatní", + icon="image", + label="Galerie", + ), + ) + ], + label="Galerie", + ), + ), + ( + "youtube", + wagtail.core.blocks.StructBlock( + [ + ( + "poster_image", + wagtail.images.blocks.ImageChooserBlock( + help_text="Není třeba vyplňovat, náhled bude dohledán automaticky.", + label="Náhled videa (automatické pole)", + required=False, + ), + ), + ( + "video_url", + wagtail.core.blocks.URLBlock( + help_text="Odkaz na YouTube video bude automaticky zkonvertován na ID videa a NEBUDE uložen.", + label="Odkaz na video", + required=False, + ), + ), + ( + "video_id", + wagtail.core.blocks.CharBlock( + help_text="Není třeba vyplňovat, bude automaticky načteno z odkazu.", + label="ID videa (automatické pole)", + required=False, + ), + ), + ], + label="YouTube video", + ), + ), + ( + "map_point", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ], + label="Špendlík na mapě", + ), + ), + ( + "map_collection", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ], + label="Mapová kolekce", + ), + ), + ], + blank=True, + verbose_name="Článek", + ), + ), + ] diff --git a/requirements/base.in b/requirements/base.in index ff32bae56ac13fe3597231cccafdc17fb833aba7..4dfa9baf895c985c772b00840c05a387b42bc724 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -22,3 +22,4 @@ ipython weasyprint pypdf2 pyyaml +fastjsonschema diff --git a/requirements/base.txt b/requirements/base.txt index 80ba7e7f423252438733212c5397146fa473acc2..bd320a3f7be49cfbc36a7c3e10da488c783ec1fc 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,14 +1,15 @@ # -# This file is autogenerated by pip-compile with python 3.10 +# This file is autogenerated by pip-compile with python 3.9 # To update, run: # # pip-compile base.in # - amqp==5.1.0 # via kombu anyascii==0.3.0 # via wagtail +appnope==0.1.3 + # via ipython arrow==0.14.7 # via # -r base.in @@ -107,6 +108,8 @@ et-xmlfile==1.1.0 # via openpyxl executing==0.8.3 # via stack-data +fastjsonschema==2.15.3 + # via -r base.in fonttools[woff]==4.30.0 # via weasyprint html5lib==1.1 @@ -117,6 +120,8 @@ ics==0.7 # via -r base.in idna==3.3 # via requests +importlib-metadata==4.11.3 + # via markdown ipython==8.1.1 # via -r base.in jedi==0.18.1 @@ -267,6 +272,8 @@ xlsxwriter==3.0.3 # via wagtail xlwt==1.3.0 # via tablib +zipp==3.8.0 + # via importlib-metadata zopfli==0.2.1 # via fonttools diff --git a/shared/models.py b/shared/models.py index 827df2668691cb4deee95a4ef62894ea5f1343df..88ab752468d8c17bf25f21d7c18fc015db550f94 100644 --- a/shared/models.py +++ b/shared/models.py @@ -13,6 +13,7 @@ from wagtail.core.fields import StreamField from wagtail.core.models import Page from wagtail.images.edit_handlers import ImageChooserPanel +from maps_utils.blocks import MapFeatureCollectionBlock, MapPointBlock from shared.blocks import ( GalleryBlock, MenuItemBlock, @@ -71,6 +72,8 @@ class ArticleMixin(models.Model): ), ("gallery", GalleryBlock(label="Galerie")), ("youtube", YouTubeVideoBlock(label="YouTube video")), + ("map_point", MapPointBlock(label="Špendlík na mapě")), + ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")), ], verbose_name="Článek", blank=True, diff --git a/tests/maps_utils/__init__.py b/tests/maps_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/maps_utils/test_geojson_validation.py b/tests/maps_utils/test_geojson_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..4dcd47650b3d9a2ae646024e7df354cfb3ca9109 --- /dev/null +++ b/tests/maps_utils/test_geojson_validation.py @@ -0,0 +1,76 @@ +from typing import Sequence + +import pytest + +from maps_utils.validation.validators import normalize_geojson_feature + + +@pytest.mark.parametrize( + "value, allowed_types, valid", + ( + ("{}", None, False), + ( + '{"type": "Feature", "geometry": {"type": "MultiPoint", "coordinates": []}}', + None, + False, + ), + ( + '{"type": "Feature", "geometry": {"type": "Point"}, "properties": {}}', + None, + False, + ), + ( + '{"type": "Feature", "geometry": {"type": "Point", "coordinates": []}, "properties": {}}', + None, + False, + ), + ( + '{"type": "Feature", "geometry": {"type": "Point", "coordinates": [15.7420242, 50.0432227]}, "properties": {}}', + None, + True, + ), + ( + '{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1]]}, "properties": {}}', + None, + False, + ), + ( + '{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[15.7625981, 50.0344817, 0], [15.7626987, 50.0343396, 0], [15.762751, 50.0343559, 0], [15.7626531, 50.0344963, 0], [15.7625981, 50.0344817, 0]]]}, "properties": {}}', + None, + True, + ), + ( + '{"type": "Feature", "geometry": {"type": "Point", "coordinates": [15.7420242, 50.0432227]}, "properties": {}}', + ("Polygon",), + False, + ), + ( + '{"type": "Feature", "geometry": {"type": "Point", "coordinates": [15.7420242, 50.0432227]}, "properties": {}}', + ( + "Polygon", + "Point", + ), + True, + ), + ( + '{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[15.7625981, 50.0344817, 0], [15.7626987, 50.0343396, 0], [15.762751, 50.0343559, 0], [15.7626531, 50.0344963, 0], [15.7625981, 50.0344817, 0]]]}, "properties": {}}', + ("Point",), + False, + ), + ( + '{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[15.7625981, 50.0344817, 0], [15.7626987, 50.0343396, 0], [15.762751, 50.0343559, 0], [15.7626531, 50.0344963, 0], [15.7625981, 50.0344817, 0]]]}, "properties": {}}', + ( + "Point", + "Polygon", + ), + True, + ), + ), +) +def test_normalize(value: str, allowed_types: Sequence[str], valid: bool): + if valid: + assert normalize_geojson_feature(value) + + else: + with pytest.raises(ValueError): + normalize_geojson_feature(value, allowed_types=allowed_types) diff --git a/uniweb/migrations/0027_alter_uniwebarticlepage_content.py b/uniweb/migrations/0027_alter_uniwebarticlepage_content.py new file mode 100644 index 0000000000000000000000000000000000000000..44c37d5cba1aa279f457018e6e66e6345cc552ec --- /dev/null +++ b/uniweb/migrations/0027_alter_uniwebarticlepage_content.py @@ -0,0 +1,228 @@ +# Generated by Django 4.0.3 on 2022-05-01 10:03 + +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.images.blocks +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("uniweb", "0026_alter_uniwebarticlepage_content"), + ] + + operations = [ + migrations.AlterField( + model_name="uniwebarticlepage", + name="content", + field=wagtail.core.fields.StreamField( + [ + ( + "text", + wagtail.core.blocks.RichTextBlock( + features=[ + "h2", + "h3", + "h4", + "bold", + "italic", + "ol", + "embed", + "ul", + "link", + "document-link", + "image", + ], + label="Textový editor", + ), + ), + ( + "gallery", + wagtail.core.blocks.StructBlock( + [ + ( + "gallery_items", + wagtail.core.blocks.ListBlock( + wagtail.images.blocks.ImageChooserBlock( + label="obrázek", required=True + ), + group="ostatní", + icon="image", + label="Galerie", + ), + ) + ], + label="Galerie", + ), + ), + ( + "youtube", + wagtail.core.blocks.StructBlock( + [ + ( + "poster_image", + wagtail.images.blocks.ImageChooserBlock( + help_text="Není třeba vyplňovat, náhled bude dohledán automaticky.", + label="Náhled videa (automatické pole)", + required=False, + ), + ), + ( + "video_url", + wagtail.core.blocks.URLBlock( + help_text="Odkaz na YouTube video bude automaticky zkonvertován na ID videa a NEBUDE uložen.", + label="Odkaz na video", + required=False, + ), + ), + ( + "video_id", + wagtail.core.blocks.CharBlock( + help_text="Není třeba vyplňovat, bude automaticky načteno z odkazu.", + label="ID videa (automatické pole)", + required=False, + ), + ), + ], + label="YouTube video", + ), + ), + ( + "map_point", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ], + label="Špendlík na mapě", + ), + ), + ( + "map_collection", + wagtail.core.blocks.StructBlock( + [ + ( + "lat", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 50.04075", + label="Zeměpisná šířka", + ), + ), + ( + "lon", + wagtail.core.blocks.DecimalBlock( + help_text="Např. 15.77659", + label="Zeměpisná délka", + ), + ), + ( + "hex_color", + wagtail.core.blocks.CharBlock( + default="000000", + help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).", + label="Barva špendlíku (HEX)", + ), + ), + ( + "zoom", + wagtail.core.blocks.IntegerBlock( + default=15, + label="Výchozí zoom", + max_value=18, + min_value=1, + ), + ), + ( + "style", + wagtail.core.blocks.ChoiceBlock( + choices=[ + ("osm-mapnik", "OSM Mapnik"), + ("stamen-toner", "Stamen Toner"), + ("stamen-terrain", "Stamen Terrain"), + ( + "stadia-osm-bright", + "Stadia OSM Bright (vyžaduje API klíč)", + ), + ( + "stadia-outdoors", + "Stadia Outdoors (vyžaduje API klíč)", + ), + ], + label="Styl", + ), + ), + ( + "height", + wagtail.core.blocks.IntegerBlock( + label="Výška v px", + max_value=1000, + min_value=100, + ), + ), + ], + label="Mapová kolekce", + ), + ), + ], + blank=True, + verbose_name="Článek", + ), + ), + ]