diff --git a/majak_uistyleguide/templates/patterns/atoms/containers/container__medium.html b/majak_uistyleguide/templates/patterns/atoms/containers/container__medium.html
index 5b31707616971183be117411a47bfd34f4911551..08b46815724a8d9776df39cf2be7d598ff199249 100644
--- a/majak_uistyleguide/templates/patterns/atoms/containers/container__medium.html
+++ b/majak_uistyleguide/templates/patterns/atoms/containers/container__medium.html
@@ -5,4 +5,4 @@
     mpus purus at lorem. Etiam neque. Cras pede libmpus purus at lorem. Etiam neque. Cras pede libmpus purus at lorem. Etiam neque. Cras pede lib
     mpus purus at lorem. Etiam neque. Cras pede libmpus purus at lorem. Etiam neque. Cras pede libmpus purus at lorem. Etiam neque. Cras pede lib
     bmpus purus at lorem. Etiam neque. Cras pede lib</p>
-</div>
\ No newline at end of file
+</div>
diff --git a/majak_uistyleguide/templates/patterns/atoms/containers/container__narrow.html b/majak_uistyleguide/templates/patterns/atoms/containers/container__narrow.html
index 21ccdbb8c1b48baa66f3d848503a67e44f567743..69e9311a94e0d2ca653d2b5b39f80a351e3d1a75 100644
--- a/majak_uistyleguide/templates/patterns/atoms/containers/container__narrow.html
+++ b/majak_uistyleguide/templates/patterns/atoms/containers/container__narrow.html
@@ -6,4 +6,4 @@
   mpus purus at lorem. Etiam neque. Cras pede lib
   mpus purus at lorem. Etiam neque. Cras pede libmpus purus at lorem. Etiam neque. Cras pede lib mpus purus at lorem. Etiam neque. Cras pede lib mpus purus at lorem. Etiam neque. Cras pede lib
   </p>
-</div>
\ No newline at end of file
+</div>
diff --git a/majak_uistyleguide/templates/patterns/atoms/containers/container__wide.html b/majak_uistyleguide/templates/patterns/atoms/containers/container__wide.html
index a172c99d3a9f6505f3cd8084192f0baa6a1cdad1..65335723b4578ab7a4a492cb725d1ca79c47e797 100644
--- a/majak_uistyleguide/templates/patterns/atoms/containers/container__wide.html
+++ b/majak_uistyleguide/templates/patterns/atoms/containers/container__wide.html
@@ -6,4 +6,4 @@
   mpus purus at lorem. Etiam neque. Cras pede lib
   mpus purus at lorem. Etiam neque. Cras pede libmpus purus at lorem. Etiam neque. Cras pede lib mpus purus at lorem. Etiam neque. Cras pede lib mpus purus at lorem. Etiam neque. Cras pede lib
   </p>
-</div>
\ No newline at end of file
+</div>
diff --git a/majak_uistyleguide/templates/patterns/atoms/figure/figure.html b/majak_uistyleguide/templates/patterns/atoms/figure/figure.html
index abe270c67ad26d1c5aea904879b4b2e7f2e7abdc..9f25c0bf1123e6c032e323800a38aeec66d31423 100644
--- a/majak_uistyleguide/templates/patterns/atoms/figure/figure.html
+++ b/majak_uistyleguide/templates/patterns/atoms/figure/figure.html
@@ -1,4 +1,4 @@
-<figure class="flex flex-col gap-2 max-w-max p-4">
+<figure class="flex flex-col gap-2 max-w-max py-4">
   <img
     src="{{ source }}"
     alt="{{ alt }}"
diff --git a/majak_uistyleguide/templates/patterns/atoms/grids/grids.html b/majak_uistyleguide/templates/patterns/atoms/grids/grids.html
index 9a3107ad49837c0c1fc04d49ff85dab736b001a6..bc0e5cc32f436fa241bfcc46688a68b02286577c 100644
--- a/majak_uistyleguide/templates/patterns/atoms/grids/grids.html
+++ b/majak_uistyleguide/templates/patterns/atoms/grids/grids.html
@@ -109,4 +109,15 @@
       </div>
     </div>
   </div>
-</div>
\ No newline at end of file
+</div>
+
+<div class="grid md:grid-cols-2 gap-8 py-4">
+  <div class="space-y-4">A</div>
+  <div class="space-y-4">B</div>
+</div>
+
+<div class="grid md:grid-cols-3 gap-8 py-4">
+  <div class="space-y-4">A</div>
+  <div class="space-y-4">B</div>
+  <div class="space-y-4">C</div>
+</div>
diff --git a/majak_uistyleguide/templates/patterns/atoms/youtube_video/youtube_video.html b/majak_uistyleguide/templates/patterns/atoms/youtube_video/youtube_video.html
new file mode 100644
index 0000000000000000000000000000000000000000..1ccfee339f1149e9886d46801a02774d7ab853cf
--- /dev/null
+++ b/majak_uistyleguide/templates/patterns/atoms/youtube_video/youtube_video.html
@@ -0,0 +1,66 @@
+<div
+  class="
+    flex flex-col gap-2 mb-10
+
+    relative max-w-[600px]
+
+    group
+
+    text-center rounded cursor-pointer
+  "
+  id="ytVideo{{ video_id }}PosterContainer"
+>
+  <img
+    src="{{ image_source }}"
+    alt="{{ image_alt }}"
+    class="
+      object-cover mb-2 aspect-video rounded opacity-50 duration-200
+
+      group-hover:blur-sm
+    "
+  >
+
+  <div
+    class="absolute top-0 left-0 flex justify-center items-center w-full h-full"
+  >
+    <div class="relative">
+      <i
+        class="
+          relative text-red-600 ico--youtube text-7xl z-10 duration-200
+
+          group-hover:text-8xl
+        "
+      ></i>
+
+      <div class="absolute top-[30%] left-[30%] w-10 h-8 bg-white z-0"></div>
+    </div>
+  </div>
+
+  <small class="font-bold">
+    Spuštěním videa dojde k načtení obsahu třetích stran z portálu YouTube.
+  </small>
+</div>
+
+<div
+  class="content-block aspect-video mb-10 max-w-[600px]"
+  id="ytVideo{{ video_id }}IframeContainer"
+  style="display: none"
+>
+
+</div>
+
+{# Script, který při kliknutí na poster načte iframe s YouTube videem (v Enhanced Privacy Mode) #}
+{# Záměrně je přímo na bloku, protože málokterý článek bude mít více videí a naopak většina článků YT videa nemá #}
+<script>
+  (function () {
+    const posterContainer = document.getElementById('ytVideo{{ video_id }}PosterContainer')
+    const videoContainer = document.getElementById('ytVideo{{ video_id }}IframeContainer')
+
+    posterContainer.onclick = function () {
+      videoContainer.innerHTML = '<iframe class="rounded w-full h-full !border-0" src="https://www.youtube-nocookie.com/embed/{{ video_id }}?autoplay=1" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen ></iframe>'
+
+      videoContainer.style.display = 'block'
+      posterContainer.style.display = 'none'
+    }
+  })()
+</script>
diff --git a/majak_uistyleguide/templates/patterns/atoms/youtube_video/youtube_video.yaml b/majak_uistyleguide/templates/patterns/atoms/youtube_video/youtube_video.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..93dc876bebbe36517f4c7ca7e974ea8082c24c7b
--- /dev/null
+++ b/majak_uistyleguide/templates/patterns/atoms/youtube_video/youtube_video.yaml
@@ -0,0 +1,4 @@
+context:
+  video_id: 'G6N943NlCbw'
+  image_source: 'https://picsum.photos/536/354'
+  image_alt: 'Image alt'
diff --git a/src/css/atoms/geo_feature_collection.pcss b/src/css/atoms/geo_feature_collection.pcss
new file mode 100644
index 0000000000000000000000000000000000000000..e0123932def423a1a02ca3bff6aa48048565ed30
--- /dev/null
+++ b/src/css/atoms/geo_feature_collection.pcss
@@ -0,0 +1,162 @@
+.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.5);
+}
+.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;
+    width: 16rem;
+    background: var(--color-white);
+    border-radius: .5rem;
+    pointer-events: all;
+}
+
+.geo-feature-collection__legend-item {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+@media only screen and (min-width: 30rem) {
+    .geo-feature-collection__legend {
+        width: 20rem;
+    }
+}
+
+.geo-feature-collection-tooltip {
+    background: none;
+    border-radius: 0;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    margin-top: -.25rem;
+    box-shadow: none;
+    font-size: .875rem;
+    font-weight: bold;
+    color: var(--color-grey-500);
+    text-align: center;
+    text-shadow:
+    -1px -1px 0 white,
+    1px -1px 0 white,
+    -1px 1px 0 white,
+    1px 1px 0 white;
+    width: 12em;
+    white-space: normal;
+    height: 8rem;
+    display: flex;
+    align-items: flex-end;
+    justify-content: center;
+    line-height: 1;
+}
+
+.geo-feature-collection-tooltip:before {
+    display: none;
+}
+
+.geo-feature-collection .leaflet-touch .leaflet-bar {
+    border: none;
+    box-shadow: 0 6px 6px -3px rgb(221 221 221 / 43%), 0 10px 14px 1px rgb(221 221 221 / 37%), 0 4px 18px 3px rgb(221 221 221 / 35%)
+}
+
+.geo-feature-collection .leaflet-touch .leaflet-control-zoom-in,
+.geo-feature-collection .leaflet-touch .leaflet-control-zoom-out {
+    line-height: 2rem;
+    height: 2rem;
+    width: 2rem;
+    font-weight: bold;
+    font-size: 24px;
+    font-family: var(--font-body);
+}
+
+.geo-feature-collection .leaflet-touch .leaflet-control-zoom-in:hover,
+.geo-feature-collection .leaflet-touch .leaflet-control-zoom-out:hover {
+    background-color: var(--color-grey-125);
+}
+
+.geo-feature-collection .leaflet-touch .leaflet-control-zoom-in {
+    border-top-left-radius: .5rem !important;
+    border-top-right-radius: .5rem !important;
+    border-bottom: 1px var(--color-grey-125) solid !important;
+}
+.geo-feature-collection .leaflet-touch .leaflet-control-zoom-out {
+    border-bottom-left-radius: .5rem !important;
+    border-bottom-right-radius: .5rem !important;
+}
+
diff --git a/src/js/components/geo_feature_collection/GeoFeatureCollection.vue b/src/js/components/geo_feature_collection/GeoFeatureCollection.vue
new file mode 100644
index 0000000000000000000000000000000000000000..09a35114e790e0d7d40707745c7381cf111c33df
--- /dev/null
+++ b/src/js/components/geo_feature_collection/GeoFeatureCollection.vue
@@ -0,0 +1,739 @@
+import { LMap, LTileLayer, LMarker, LControl, LIcon, LFeatureGroup, LMarkerClusterGroup, LGeoJson } from "@vue-leaflet/vue-leaflet";
+import Vue from "vue";
+
+Vue.component("l-map", LMap);
+Vue.component("l-tile-layer", LTileLayer);
+Vue.component("l-marker", LMarker);
+Vue.component("l-control", LControl);
+
+const buildMarkerIcon = (color, number) => {
+    const iconUrl =
+        number !== undefined
+            ? `/maps/marker/${color}/${number}/`
+            : `/maps/marker/${color}/`;
+
+    return new Licon({
+        iconUrl,
+        shadowUrl: `/maps/marker-shadow/`,
+        iconSize: [44, 64],
+        iconAnchor: [22, 64],
+        shadowSize:   [48, 68],
+        shadowAnchor: [22, 68],
+    });
+};
+
+const layerUrl = (tileServerConfig, baseLayerName, gridName) => {
+    return `${tileServerConfig.url}/tiles/${baseLayerName}/${gridName || 'webmercator'}/{z}/{x}/{y}.png`;
+}
+
+const retinaAwareLayerUrl = (tileServerConfig, baseLayerName, gridName, gridNameHq) => {
+    const isRetina = false;  // TODO
+    const ln = isRetina ? baseLayerName + '_hq' : baseLayerName;
+    const gn = isRetina ? (gridNameHq || "webmercator") : (gridName || "webmercator");
+    return layerUrl(tileServerConfig, ln, gn);
+}
+
+const mapboxStyle = (tileServerConfig, id) => ({
+    url: retinaAwareLayerUrl(tileServerConfig, id, "mapbox_webmercator", "mapbox_webmercator_hq"),
+    attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
+    // @see: https://github.com/Leaflet/Leaflet/issues/6059#issuecomment-367007392
+    mapMaxZoom: 20,
+    maxZoom: 22,
+    maxNativeZoom: 22,
+    detectRetina: true,
+    tileSize: false ? 1024 : 512,  // TODO
+    zoomOffset: false ? -2 : -1,  // TODO
+});
+
+const tileStyles = {
+    "osm-mapnik": (tileServerConfig) => ({
+        url: layerUrl(tileServerConfig, "osm_mapnik"),
+        attribution:
+            '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
+        maxZoom: 19,
+        maxNativeZoom: 19,
+    }),
+    "stamen-toner": (tileServerConfig) => ({
+        url: layerUrl(tileServerConfig, "stamen_toner"),
+        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": (tileServerConfig) => ({
+        url: layerUrl(tileServerConfig, "stamen_terrain"),
+        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": (tileServerConfig) => ({
+        url: retinaAwareLayerUrl(tileServerConfig, "stadia_osm_bright"),
+        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": (tileServerConfig) => ({
+        url: retinaAwareLayerUrl(tileServerConfig, "stadia_outdoors"),
+        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,
+    }),
+    "mapbox-streets": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_streets"),
+    "mapbox-outdoors": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_outdoors"),
+    "mapbox-light": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_light"),
+    "mapbox-dark": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_dark"),
+    "mapbox-satellite": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_satellite"),
+    "mapbox-pirate": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_pirate"),
+};
+
+const GeoFeatureCollection = Vue.component(
+    "GeoFeatureCollection",
+    {
+      props: {
+          tileServerConfig: {
+              type: Object,
+              default: {},
+          },
+          geojson: {
+              type: Array,
+              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,
+          },
+          handleClicks: {
+              type: Boolean,
+              default: true,
+          },
+          height: {
+              type: String,
+              default: "50rem",
+          },
+      },
+      data: function () {
+          const tileOpts = tileStyles[this.tileStyle](this.tileServerConfig);
+
+          return {
+              // Future map reference
+              map: null,
+              categories: [],
+              entityMap: {},
+              collections: [],
+              featureGroup: LFeatureGroup(),
+              zoom: this.initialZoom,
+              currentItem: null,
+              // See `mapboxStyle` implementation above.
+              mapMaxZoom: tileOpts.mapMaxZoom || tileOpts.maxZoom,
+              mapOptions: {
+                  dragging: true,  // TODO
+                  tap: true,  // TODO
+                  zoomSnap: 1,
+                  zoomControl: this.displayZoomControl,
+
+              },
+              tileLayerOptions: {
+                  ...tileOpts
+              },
+          };
+      },
+      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, featureCollections) {
+              const allFeatures = [];
+
+              // Preprocess all the features and collections.
+              // - prepare link to layer and feature collection
+              // - prepare latlng
+              // - prepare marker shats
+              // - add `muted` to featureCollection to control opacity
+              featureCollections.forEach((featureCollection) => {
+                  // Muted property to control opacity.
+                  featureCollection.properties.muted = false;
+
+                  featureCollection.features.forEach(f => {
+                      // Marker stash.
+                      f.properties.markers = [];
+                      // LatLng info (for points).
+                      f.properties.latlng = null;
+                      // Layer link (for other types).
+                      f.properties.layer = null;
+                      // Parent FeatureCollection link./
+                      f.properties.collection = featureCollection;
+
+                      // Preload images.
+                      if (f.properties.image) {
+                          const i = new Image();
+                          i.src = f.properties.image;
+                      }
+
+                      // Keep reference for easier searches.
+                      this.entityMap[f.properties.slug] = f;
+
+                      allFeatures.push(f);
+                  })
+              });
+
+
+              if (categories) {
+                  // Build category list.
+                  this.initCategories(categories, featureCollections);
+
+                  // Bind categories to features.
+                  allFeatures.forEach((f) => {
+                      if (f.properties.category && this.categories[f.properties.category]) {
+                          f.properties.categoryObj = this.categories[f.properties.category];
+                      }
+                  });
+
+                  // Bind categories to featureCollections.
+                  featureCollections.forEach(fc => {
+                      if (fc.properties.category && this.categories[fc.properties.category]) {
+                          fc.properties.categoryObj = this.categories[fc.properties.category];
+                      }
+                  });
+              }
+
+              this.collections = featureCollections;
+
+              // Draw the map.
+              this.initMap(featureCollections);
+          },
+          /**
+          * Traverse list of features a build list of categories
+          * with features assigned.
+          *
+          * @param {GeoJSON features} features
+          */
+          initCategories(inputCategories, featureCollections) {
+              const categories = {};
+
+              inputCategories.forEach((cat, index) => {
+                  categories[cat.name] = {
+                      expanded: false,
+                      name: cat.name,
+                      color: cat.color,
+                      featureCollections: [],
+                      muted: false,
+                  };
+              });
+
+              featureCollections.forEach((featureCollection) => {
+                  if (featureCollection.properties.category) {
+                      categories[featureCollection.properties.category].featureCollections.push(featureCollection);
+                  }
+              });
+
+              this.categories = categories;
+
+              if (this.categories.length === 0) {
+                  this.displayLegend = false;
+              }
+          },
+          /**
+          * Given a list of feature collection objects, add layers to map.
+          * @param {GeoJSON featureCollections} featureCollections
+          */
+          initMap(featureCollections) {
+              featureCollections.forEach((featureCollection) => {
+                  const {geoJSONLayer, markersLayer} = this.createFeatureCollectionLayers(featureCollection);
+
+                  geoJSONLayer.addTo(this.featureGroup);
+                  markersLayer.addTo(this.featureGroup);
+
+                  // Keep links.
+                  featureCollection.properties.geoJSONLayer = geoJSONLayer;
+                  featureCollection.properties.markersLayer = markersLayer;
+
+                  this.entityMap[featureCollection.properties.slug] = featureCollection;
+              });
+
+              this.map.addLayer(this.featureGroup);
+
+              if (featureCollections.length == 1) {
+                  // Pan to single feature, likely a point.
+                  this.map.panTo(this.featureGroup.getBounds().getCenter());
+              } else {
+                  // Fit multiple features in a view.
+                  this.map.fitBounds(this.featureGroup.getBounds());
+              }
+
+              // Initial check - open item detail if in URL.
+              this.onUserNavigation();
+
+              // Listen to URL changes when user triggers browser navigation.
+              window.addEventListener("popstate", this.onUserNavigation);
+
+          },
+          /**
+          * Creates two new layers for a given FeatureCollection object: GeoJSON and markers.
+          * @param {Object} featureCollection
+          * @returns
+          */
+          createFeatureCollectionLayers(featureCollection) {
+              // Markers are clustered for easier orientation.
+              const markers = LMarkerClusterGroup({
+                  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: 3,
+                  opacity: 0.8,
+                  color: colorForFeature(feature),
+                  fillOpacity: 0.6,
+              });
+
+              const markerIconForCategory = (categoryName, number) =>
+                  categoryName && this.categories[categoryName]
+                      ? buildMarkerIcon(
+                            this.categories[categoryName].color,
+                            number
+                        )
+                      : buildMarkerIcon("000000", number);
+
+              const addMarker = (feature, markerPosLatLng, onClick) => {
+                  const tooltipTitle = [feature.properties.title, feature.properties.collectionTitle].filter(i => !!i).join(' - ');
+
+                  // add marker
+                  const featureMarker = new LMarker(markerPosLatLng, {
+                      icon: feature.properties.color
+                          ? buildMarkerIcon(
+                                feature.properties.color,
+                                feature.properties.index
+                            )
+                          : markerIconForCategory(
+                                feature.properties.category,
+                                feature.properties.index
+                            ),
+                  })
+                      .on("click", onClick)
+                      .bindTooltip(tooltipTitle, {
+                          className: "geo-feature-collection-tooltip",
+                          direction: "top",
+                          offset: [0, -64],
+                      });
+
+                  // Add marker to feature marker list.
+                  feature.properties.markers.push(featureMarker);
+
+                  // Add item marker to the cluster.
+                  markers.addLayer(featureMarker);
+
+                  return featureMarker;
+              };
+
+              /**
+              * Process Point/MultiPoint type GeoJSON features.
+              * Called for each such feature when building the map.
+              */
+              const pointToLayer = (feature, latlng) => {
+                  const onClick = (evt) => {
+                      if (this.handleClicks) {
+                          this.zoomToPoint(evt.latlng, feature);
+                      }
+                  };
+                  feature.properties.latlng = latlng;
+                  return addMarker(feature, latlng, onClick);
+              };
+
+              /**
+              * Process Polygon/MultiPolygon/LineString/MultiLineString type GeoJSON features.
+              * Called for each such feature when building the map.
+              */
+              const onEachFeature = (feature, layer) => {
+                  const markerPosLatLng = [];
+
+                  const markerForPolyCoords = (coords) => {
+                      // Find pole of inaccessibility (not centroid) for the polygon
+                      // @see: https://github.com/mapbox/polylabel
+                      const markerPos = polylabel(coords, 1);
+                      markerPosLatLng.push(L.latLng(markerPos[1], markerPos[0]));
+                  };
+
+                  const markerForLineStringCoords = (coords) => {
+                      // Find a middle sector of LineString and set position to middle of it
+                      const sectorCount = coords.length;
+                      const sectorIndex = Math.floor((sectorCount - 1) / 2);
+
+                      markerPosLatLng.push(
+                          L.latLng(
+                              (coords[sectorIndex][1] +
+                                  coords[sectorIndex + 1][1]) /
+                                  2,
+                              (coords[sectorIndex][0] +
+                                  coords[sectorIndex + 1][0]) /
+                                  2
+                          )
+                      );
+                  };
+
+                  /**
+                  * Supported GeoJSON features: Polygon, MultiPolygon, LineString, MultiLineString.
+                  * Point/MultiPoint 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) as
+                  * bounds and zoom methods are not supported for those.
+                  **/
+                  if (feature.geometry.type == "Polygon") {
+                      markerForPolyCoords(feature.geometry.coordinates);
+                      feature.properties.layer = layer;
+                  } else if (feature.geometry.type == "MultiPolygon") {
+                      feature.geometry.coordinates.forEach(markerForPolyCoords);
+                      feature.properties.layer = layer;
+                  } else if (feature.geometry.type == "LineString") {
+                      markerForLineStringCoords(feature.geometry.coordinates);
+                      feature.properties.layer = layer;
+                  } else if (feature.geometry.type == "MultiLineString") {
+                      feature.geometry.coordinates.forEach(
+                          markerForLineStringCoords
+                      );
+                      feature.properties.layer = layer;
+                  } else if (feature.geometry.type == "MultiPoint" || feature.geometry.type == "Point") {
+                      // Supported via `pointToLayer`, noop here.
+                  } else {
+                      console.warn(
+                          `GeoJSON feature type unsupported: ${feature.geometry.type}`
+                      );
+                  }
+
+                  if (markerPosLatLng.length) {
+                      const onMarkerClick = (evt) => {
+                          if (this.handleClicks) {
+                              this.zoomToFeature(feature, true, false);
+                          }
+                      }
+
+                      markerPosLatLng.forEach((pos) => {
+                          addMarker(feature, pos, onMarkerClick);
+
+                      });
+
+                      if (this.handleClicks) {
+                          // Bind click event on the layer target item.
+                          layer.on({
+                              click: (evt) => {
+                                  this.zoomToFeature(feature, true, false);
+                              }
+                          });
+                      }
+                  }
+
+              };
+
+              return {
+                  geoJSONLayer: LGeoJson(featureCollection, {
+                      style,
+                      onEachFeature,
+                      pointToLayer,
+                  }),
+                  markersLayer: markers,
+              };
+          },
+          /**
+          * Stores slug in URL query param.
+          *
+          * @param {Object} item
+          */
+          pushToUrl(slug) {
+              const url = new URL(window.location);
+
+              if (slug === null) {
+                  url.searchParams.delete("item");
+                  history.pushState({}, "", url);
+              } else {
+                  url.searchParams.set("item", slug);
+                  history.pushState({}, "", url);
+              }
+          },
+          /**
+          * Called when user navigates. Will display detail
+          * of corresponding item if such exist.
+          *
+          * @param {Event} evt
+          */
+          onUserNavigation(evt) {
+              const urlParams = new URLSearchParams(window.location.search);
+
+              // If query is present when starting, locate the item and zoom to it.
+              if (urlParams.has("item")) {
+                  this.focusOnEntity(urlParams.get("item"), false, true);
+              } else if (this.currentItem) {
+                  this.closeItemInfo(false);
+              }
+          },
+          /**
+          * Hide current item detail, drop it from URL.
+          * @param {Boolean} updateUrl whether to push new state to history.
+          */
+          closeItemInfo(updateUrl = true) {
+              if (this.currentItem) {
+                  this.currentItem = null;
+
+                  if (updateUrl) {
+                      this.pushToUrl(null);
+                  }
+              }
+          },
+          /**
+          * Expand category, show list of FeatureCollection objects belonging to it
+          * @param {String} category
+          */
+          toggleExpandCategory(category) {
+              category.expanded = !category.expanded;
+          },
+          /**
+          * Set muted state for whole feature collection category.
+          * @param {String} category
+          */
+          setMutedCategory(category, muted) {
+              category.featureCollections.forEach(featureCollection => this.setMuted(featureCollection, muted));
+          },
+          /**
+          * Set muted state for collection.
+          * When featureCollection is muted, it will be less opaque in the list. Used for
+          * better orientation when there are lots of features.
+          * @param {Object} featureCollection
+          */
+          setMuted(featureCollection, muted) {
+              featureCollection.properties.muted = muted;
+
+              if (featureCollection.properties.categoryObj) {
+                  // Update muted flag on category this featureCollection belongs to.
+                  // Category is muted when all featureCollections in it are muted.
+                  featureCollection.properties.categoryObj.muted = featureCollection
+                      .properties
+                      .categoryObj
+                      .featureCollections
+                      .map(featureCollection => featureCollection.properties.muted)
+                      .reduce((a, b) => a && b, true);
+              }
+
+              if (featureCollection.properties.muted) {
+                  if (this.featureGroup.hasLayer(featureCollection.properties.geoJSONLayer)) {
+                      this.featureGroup.removeLayer(featureCollection.properties.geoJSONLayer);
+                  }
+                  if (this.featureGroup.hasLayer(featureCollection.properties.markersLayer)) {
+                      this.featureGroup.removeLayer(featureCollection.properties.markersLayer);
+                  }
+              } else {
+                  this.featureGroup.addLayer(featureCollection.properties.geoJSONLayer);
+                  this.featureGroup.addLayer(featureCollection.properties.markersLayer);
+              }
+          },
+          /**
+          * Toggle solo view for a FeatureCollection object.
+          * If collection is in solo mode, all other collections are muted. When solo is toggled off, all collections
+          * will get unmuted.
+          * @param {Object} featureCollection
+          */
+          toggleSolo(featureCollection) {
+              const wasSolo = this.collections
+                  .filter(c => c !== featureCollection)
+                  .map(c => c.properties.muted)
+                  .reduce((a, b) => a && b, true);
+
+              if (wasSolo) {
+                  // Unset solo - unmute all items.
+                  this.collections.forEach((c) => this.setMuted(c, false));
+
+              } else {
+                  // Set solo.
+                  this.collections.forEach((c) => this.setMuted(c, c !== featureCollection));
+              }
+          },
+          /**
+          * Zoom to a layer.
+          *
+          * @param {L.Layer} layer
+          * @param {Boolean} zoom whether to zoom to the items
+          */
+          zoomToLayer(layer, zoom = false) {
+              if (zoom) {
+                  this.map.flyToBounds(layer.getBounds());
+              } else {
+                  this.map.panInsideBounds(layer.getBounds());
+              }
+          },
+          /**
+          * Zoom to a point.
+          *
+          * @param {L.Latlng} Latlng
+          * @param {Object} feature
+          * @param {Boolean} updateUrl whether to push new state to history.
+          * @param {Boolean} zoom whether to zoom to the item
+          */
+          zoomToPoint(latlng, feature, updateUrl = true, zoom = false) {
+              if (zoom) {
+                  this.map.flyTo(latlng, 18);
+              } else {
+                  this.map.panTo(latlng);
+              }
+              this.currentItem = feature.properties;
+
+              if (updateUrl) {
+                  this.pushToUrl(this.currentItem.slug);
+              }
+
+              // Zooming always unmutes.
+              this.setMuted(feature.properties.collection, false);
+          },
+          /**
+          * Zoom to a feature.
+          *
+          * @param {Object} feature
+          * @param {Boolean} updateUrl whether to push new state to history.
+          * @param {Boolean} zoom whether to zoom to the items
+          */
+          zoomToFeature(feature, updateUrl = true, zoom = false) {
+              this.zoomToLayer(feature.properties.layer, zoom);
+              this.currentItem = feature.properties;
+
+              if (updateUrl) {
+                  this.pushToUrl(this.currentItem.slug);
+              }
+
+              // Zooming always unmutes.
+              this.setMuted(feature.properties.collection, false);
+          },
+          /**
+          * Zoom to a feature collection.
+          *
+          * @param {Object} featureCollection
+          * @param {Boolean} updateUrl whether to push new state to history.
+          * @param {Boolean} zoom whether to zoom to the items
+          */
+          zoomToFeatureCollection(fetureCollection, updateUrl = true, zoom = false) {
+              this.zoomToLayer(fetureCollection.properties.geoJSONLayer, zoom);
+              this.currentItem = fetureCollection.properties;
+
+              if (updateUrl) {
+                  this.pushToUrl(this.currentItem.slug);
+              }
+
+              // Zooming always unmutes.
+              this.setMuted(fetureCollection, false);
+          },
+          /**
+          * Focus on a entity with corresponding slugified URL.
+          *
+          * @param {String} slugUrl entity slug
+          * @param {Boolean} updateUrl whether to push new state to history.
+          * @param {Boolean} zoom whether to zoom to the items
+          */
+          focusOnEntity(slugUrl, updateUrl = true, zoom = false) {
+              const entity = this.entityMap[slugUrl];
+
+              if (entity) {
+                  if (entity.properties.geoJSONLayer) {
+                      // Whole FeatureCollection.
+                      this.zoomToFeatureCollection(entity, zoom);
+                  } else if (entity.properties.layer) {
+                      // Feature - other than Point type.
+                      this.zoomToFeature(entity, updateUrl, zoom);
+                  } else if (entity.properties.latlng) {
+                      // Feature - Point type.
+                      this.zoomToPoint(entity.properties.latlng, entity, updateUrl, zoom);
+                  }
+              }
+          },
+          /**
+          * Stop event propagation, utility fn.
+          * @param {Event} evt
+          */
+          stopPropagation(evt) {
+              evt.stopPropagation();
+          },
+      },
+      template: `
+        <template>
+
+        </template>
+      `,
+    }
+);
+
+export { GeoFeatureCollection }