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:
+            '&copy; <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:
+            '&copy; <a href="https://stadiamaps.com/">Stadia Maps</a>, &copy; <a href="https://openmaptiles.org/">OpenMapTiles</a> &copy; <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:
+            '&copy; <a href="https://stadiamaps.com/">Stadia Maps</a>, &copy; <a href="https://openmaptiles.org/">OpenMapTiles</a> &copy; <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",
+            ),
+        ),
+    ]