Repository: CartoDB/cartoframes
Pull request #1597 (2020-03-16 15:56:35)

Fix layout issues
This PR solves the following issues:

- Change default layout ordering

![Screenshot 2020-03-16 at 13 04 23](https://user-images.githubusercontent.com/3824953/76776239-c83e9c80-67a6-11ea-91ca-9b2a2aee08cb.png)

- Fix titles in visualizations using style helpers
- Add legends to layout visualizations

![Screenshot 2020-03-16 at 15 01 59](https://user-images.githubusercontent.com/3824953/76776407-01770c80-67a7-11ea-83fe-15d08bfaf28f.png)





Patch:
diff --git a/cartoframes/assets/init_layout.js.j2 b/cartoframes/assets/init_layout.js.j2
index 4efb5be0..9078ea99 100644
--- a/cartoframes/assets/init_layout.js.j2
+++ b/cartoframes/assets/init_layout.js.j2
@@ -2,11 +2,6 @@ const maps = {{ maps|tojson }};
 const show_info = '{{show_info}}' === 'True';
 const is_static = '{{is_static}}' === 'True';
 
-maps.forEach((map) => {
-  map.has_legends = false;
-  map.layer_selector = false;
-});
-
 init({
   show_info,
   is_static,
diff --git a/cartoframes/assets/src/bundle.js b/cartoframes/assets/src/bundle.js
index a54f8dfe..1740b635 100644
--- a/cartoframes/assets/src/bundle.js
+++ b/cartoframes/assets/src/bundle.js
@@ -481,7 +481,6 @@ var init = (function () {
       throw e;
     }
 
-
     mapLayer.addTo(map);
 
     setLayerLegend(layer, mapLayerIndex, mapLayer, mapIndex, hasLegends);
@@ -590,7 +589,7 @@ var init = (function () {
     );
 
     if (settings.layer_selector) {
-      addLayersSelector(layers.reverse(), mapLayers.reverse());
+      addLayersSelector(layers.reverse(), mapLayers.reverse(), mapIndex);
     }
 
     setInteractiveLayers(map, layers, mapLayers);
@@ -628,16 +627,17 @@ var init = (function () {
     }
   }
 
-  function addLayersSelector(layers, mapLayers) {
-    const layerSelector$ = document.querySelector(`#layer-selector`);
-      const layersInfo = mapLayers.map((layer, index) => {
-        return {
-          title: layers[index].title || `Layer ${index}`,
-          id: layer.id,
-          checked: true
-        };
-      });
-    
+  function addLayersSelector(layers, mapLayers, mapIndex) {
+    const layerSelectorId = mapIndex !== undefined ? `#layer-selector-${mapIndex}` : '#layer-selector';
+    const layerSelector$ = document.querySelector(layerSelectorId);
+    const layersInfo = mapLayers.map((layer, index) => {
+      return {
+        title: layers[index].title || `Layer ${index}`,
+        id: layer.id,
+        checked: true
+      };
+    });
+
     const layerSelector = new AsBridge.VL.Layers(layerSelector$, carto, layersInfo, mapLayers);
     
     layerSelector.build();
diff --git a/cartoframes/assets/src/layers.js b/cartoframes/assets/src/layers.js
index 7b207d96..4fb00dda 100644
--- a/cartoframes/assets/src/layers.js
+++ b/cartoframes/assets/src/layers.js
@@ -17,7 +17,6 @@ export function initMapLayer(layer, layerIndex, numLayers, hasLegends, map, mapI
     throw e;
   }
 
-
   mapLayer.addTo(map);
 
   setLayerLegend(layer, mapLayerIndex, mapLayer, mapIndex, hasLegends);
diff --git a/cartoframes/assets/src/map.js b/cartoframes/assets/src/map.js
index 0f6fe4d8..3aca1433 100644
--- a/cartoframes/assets/src/map.js
+++ b/cartoframes/assets/src/map.js
@@ -49,7 +49,7 @@ export function initLayers(map, settings, mapIndex) {
   );
 
   if (settings.layer_selector) {
-    addLayersSelector(layers.reverse(), mapLayers.reverse());
+    addLayersSelector(layers.reverse(), mapLayers.reverse(), mapIndex);
   }
 
   setInteractiveLayers(map, layers, mapLayers);
@@ -87,16 +87,17 @@ export function setInteractiveLayers(map, layers, mapLayers) {
   }
 }
 
-export function addLayersSelector(layers, mapLayers) {
-  const layerSelector$ = document.querySelector(`#layer-selector`);
-    const layersInfo = mapLayers.map((layer, index) => {
-      return {
-        title: layers[index].title || `Layer ${index}`,
-        id: layer.id,
-        checked: true
-      };
-    });
-  
+export function addLayersSelector(layers, mapLayers, mapIndex) {
+  const layerSelectorId = mapIndex !== undefined ? `#layer-selector-${mapIndex}` : '#layer-selector';
+  const layerSelector$ = document.querySelector(layerSelectorId);
+  const layersInfo = mapLayers.map((layer, index) => {
+    return {
+      title: layers[index].title || `Layer ${index}`,
+      id: layer.id,
+      checked: true
+    };
+  });
+
   const layerSelector = new AsBridge.VL.Layers(layerSelector$, carto, layersInfo, mapLayers);
   
   layerSelector.build();
diff --git a/cartoframes/assets/style/common.html.j2 b/cartoframes/assets/style/common.html.j2
index d96aca23..37d9f922 100644
--- a/cartoframes/assets/style/common.html.j2
+++ b/cartoframes/assets/style/common.html.j2
@@ -13,4 +13,9 @@
     max-width: 100%;
     height: auto;
   }
+
+  as-layer-selector-slot .as-layer-selector-slot--wrapper .as-caption { // FIXME
+    font-size: 14px;
+    line-height: 14px;
+  }
 </style>
\ No newline at end of file
diff --git a/cartoframes/assets/style/layout.html.j2 b/cartoframes/assets/style/layout.html.j2
index 532d3d2a..78ca7c16 100644
--- a/cartoframes/assets/style/layout.html.j2
+++ b/cartoframes/assets/style/layout.html.j2
@@ -15,24 +15,14 @@
   height: 250px;
   justify-content: center;
   width: 100%;
+  height: 100%;
   padding: 8px;
   position: relative;
 }
 
-.layout-map-cell-legend {
-  flex-direction: row;
-}
-
-h2.as-widget-header__header {
-  font-size: 12px;
-  line-height: 12px;
-  padding: 8px 0;
-}
-
 .layout-map {
   background-color: silver;
   height: 100%;
-  width: 85%;
 }
 
 .layout-map > .as-map-area,
@@ -40,23 +30,35 @@ h2.as-widget-header__header {
   height: 100%;
 }
 
+.layer-selector {
+  margin-bottom: 12px;
+}
+
 .layout-map-container {
   align-items: center;
   display: flex;
-  flex-direction: row;
+  flex-direction: column;
   justify-content: space-around;
   width: 100%;
+  height: 100%;
   padding: 0 8px;
 }
 
 .layout-map-row {
   display: flex;
-  flex-direction: column;
+  flex-direction: row;
   justify-content: space-around;
   width: 100%;
+  height: 100%;
 }
 
 .layout-legends {
   background-color: white;
 }
+
+.layout-map-cell--section {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
 </style>
\ No newline at end of file
diff --git a/cartoframes/assets/templates/viz/legends_layout.html.j2 b/cartoframes/assets/templates/viz/legends_layout.html.j2
index 3f980081..61dbdaca 100644
--- a/cartoframes/assets/templates/viz/legends_layout.html.j2
+++ b/cartoframes/assets/templates/viz/legends_layout.html.j2
@@ -1,20 +1,37 @@
-{% macro createLegend(legend_data, id) -%}
-  <as-legend-{{legend_data.type}} id="{{id}}" slot="legends"></as-legend-{{legend_data.type}}>
+{% macro createLegend(legend, map_index, id) -%}
+  <as-legend-{{legend.type}} id="{{id}}" slot="legends"></as-legend-{{legend.type}}>
 {%- endmacro %}
 
-<div id="legends" class="legends layout-legends">
-  {% for layer in layers|reverse %}
-    {% if layer.legend %}
-      <as-legend
-        class="layout-legend"
-        heading="{{layer.legend.title}}"
-        description="{{layer.legend.description}}"
-      >
-        {{ createLegend(layer.legend, 'layer%d_map%d_legend' | format(loop.index0, index)) }}
-        {% if layer.legend.footer %}
-          <span slot="footer">{{layer.legend.footer | safe }}</span>
-        {% endif %}
-      </as-legend>
-    {% endif %}
-  {% endfor %}
+<div class="as-panel__element" id="legends-{{map_index}}">
+  <as-layer-selector id="layer-selector-{{map_index}}" class="layer-selector">
+    {% for layer in layers|reverse %}
+      {% if layer.legends %}
+        {% set layer_index = loop.index0 %}
+        {% set slot = 'as-checkbox-layer-%d-slot' | format(layer_index) %}
+        <div slot="{{slot}}">
+          {% if layer.has_legend_list %}
+            {% for legend in layer.legends %}
+              <as-legend
+                heading="{{legend.title}}"
+                description="{{legend.description}}">
+                {{ createLegend(legend, map_index, 'layer%d_map%d_legend%d' | format(layer_index, map_index, loop.index0)) }}
+                {% if legend.footer %}
+                  <span slot="footer">{{legend.footer | safe }}</span>
+                {% endif %}
+              </as-legend>
+            {% endfor %}
+          {% else %}
+            <as-legend
+              heading="{{layer.legends.title}}"
+              description="{{layer.legends.description}}">
+              {{ createLegend(layer.legends, map_index, 'layer%d_map%d_legend%d' | format(layer_index, map_index, 0)) }}
+              {% if layer.legends.footer %}
+                <span slot="footer">{{layer.legends.footer | safe }}</span>
+              {% endif %}
+            </as-legend>
+          {% endif %}
+        </div>
+      {% endif %}
+    {% endfor %}
+  </as-layer-selector>
 </div>
\ No newline at end of file
diff --git a/cartoframes/assets/templates/viz/main.html.j2 b/cartoframes/assets/templates/viz/main.html.j2
index 137db916..3d26c3a5 100644
--- a/cartoframes/assets/templates/viz/main.html.j2
+++ b/cartoframes/assets/templates/viz/main.html.j2
@@ -53,7 +53,7 @@
         {% if show_info %}
           {% include 'templates/viz/info.html.j2' %}
         {% endif %}
-        {% if has_legends or layer_selector%}
+        {% if has_legends or layer_selector %}
           <div class="as-map-panels" data-name="Legends">
             <div class="as-panel as-panel--vertical as-panel--left as-panel--top">
               {% include 'templates/viz/legends.html.j2' %}
diff --git a/cartoframes/assets/templates/viz/main_layout.html.j2 b/cartoframes/assets/templates/viz/main_layout.html.j2
index e82bc536..9f371c40 100644
--- a/cartoframes/assets/templates/viz/main_layout.html.j2
+++ b/cartoframes/assets/templates/viz/main_layout.html.j2
@@ -42,25 +42,38 @@
 
 <body class="as-app-body as-app">
   <main id="main-container" class="layout-map-container">
-  {% for i in range(n) %}
+  {% for i in range(m) %}
     <div class="layout-map-row">
-    {% for j in range(m) %}
-      {% set index = i * m + j %}
-      {% set title = maps[index]['title'] %}
-      {% set has_legends = maps[index]['has_legends'] %}
+    {% for j in range(n) %}
+      {% set map_index = i * n + j %}
+      {% set title = maps[map_index]['title'] %}
+      {% set has_legends = maps[map_index]['has_legends'] %}
+      {% set layer_selector = maps[map_index]['layer_selector'] %}
       
-      <div class="layout-map-cell {{'layout-map-cell-legend' if has_legends }}" style="height: {{ map_height }}px">
-          {% if title %}<p>{{ title }}</p>{% endif %}
-          {% if has_legends %}
-            {% set layers = maps[index]['layers'] %}
-            {% include 'templates/viz/legends_layout.html.j2' %}
-          {% endif %}
-          <img id="map-image-{{ index }}" class="map-image" alt="Static Map {{index}}" />
-          <div class="as-main layout-map" id="main-container-{{index}}">
+      <div class="layout-map-cell {{'layout-map-cell-legend' if has_legends }}" style="height: {{ map_height }}">
+        {% if title %}
+          <header class="layout-map-cell--header">
+            <p>{{ title }}</p>
+          </header>
+        {% endif %}
+
+        <section class="layout-map-cell--section">
+          <img id="map-image-{{ map_index }}" class="map-image" alt="Static Map {{map_index}}" />
+          <as-responsive-content id="main-container-{{map_index}}" class="layout-map">
             <div class="as-map-area">
-              <div id="map-{{ index }}" class="map"></div>
-            </div> <!-- as-map-area -->
-          </div> <!-- as-main -->
+              <div id="map-{{ map_index }}" class="map"></div>
+            </div>
+            {% if has_legends or layer_selector %}
+              {% set layers = maps[map_index]['layers'] %}
+              <div class="as-map-panels" data-name="Legends">
+                <div class="as-panel as-panel--vertical as-panel--top as-panel--left">
+                  {% include 'templates/viz/legends_layout.html.j2' %}
+                </div>
+              </div>
+            {% endif %}
+
+          </as-responsive-content>
+        </section>
       </div>
     {% endfor %}
     </div>
diff --git a/cartoframes/viz/constants.py b/cartoframes/viz/constants.py
index 1ef10a06..64dddec9 100644
--- a/cartoframes/viz/constants.py
+++ b/cartoframes/viz/constants.py
@@ -2,7 +2,7 @@ CARTO_VL_VERSION = 'v1.4'
 CARTO_VL_DEV = '/dist/carto-vl.js'
 CARTO_VL_URL = 'https://libs.cartocdn.com/carto-vl/{}/carto-vl.min.js'.format(CARTO_VL_VERSION)
 
-AIRSHIP_VERSION = 'v2.3'
+AIRSHIP_VERSION = 'v2.3.1-rc.0'
 AIRSHIP_COMPONENTS_DEV = '/packages/components/dist/airship.js'
 AIRSHIP_BRIDGE_DEV = '/packages/bridge/dist/asbridge.js'
 AIRSHIP_MODULE_DEV = '/packages/components/dist/airship/airship.esm.js'
diff --git a/cartoframes/viz/html/html_layout.py b/cartoframes/viz/html/html_layout.py
index 37591b19..b259c10f 100644
--- a/cartoframes/viz/html/html_layout.py
+++ b/cartoframes/viz/html/html_layout.py
@@ -21,14 +21,14 @@ class HTMLLayout(object):
 
     def set_content(self, maps, size=None, show_info=None, theme=None, _carto_vl_path=None,
                     _airship_path=None, title='CARTOframes', is_embed=False,
-                    is_static=False, map_height=None, n_size=None, m_size=None):
+                    is_static=False, map_height=None, full_height=False, n_size=None, m_size=None):
         self.html = self._parse_html_content(
             maps, size, show_info, theme, _carto_vl_path, _airship_path, title,
-            is_embed, is_static, map_height, n_size, m_size)
+            is_embed, is_static, map_height, full_height, n_size, m_size)
 
     def _parse_html_content(self, maps, size, show_info=None, theme=None, _carto_vl_path=None,
                             _airship_path=None, title=None, is_embed=False, is_static=False,
-                            map_height=None, n_size=None, m_size=None):
+                            map_height=None, full_height=False, n_size=None, m_size=None):
 
         if _carto_vl_path is None:
             carto_vl_path = constants.CARTO_VL_URL
@@ -64,6 +64,7 @@ class HTMLLayout(object):
             is_embed=is_embed,
             is_static=is_static,
             map_height=map_height,
+            full_height=full_height,
             n=n_size,
             m=m_size
         )
diff --git a/cartoframes/viz/layer.py b/cartoframes/viz/layer.py
index b0759d26..ccbab710 100644
--- a/cartoframes/viz/layer.py
+++ b/cartoframes/viz/layer.py
@@ -122,6 +122,7 @@ class Layer:
         popups_variables = self.popups.get_variables()
         widget_variables = self.widgets.get_variables()
         external_variables = merge_dicts(popups_variables, widget_variables)
+        self._map_index = 0
 
         self.viz = self.style.compute_viz(self.geom_type, external_variables)
         viz_columns = extract_viz_columns(self.viz)
@@ -186,10 +187,37 @@ class Layer:
 
         return {}
 
+    def _get_layer_def(self):
+        return {
+            'credentials': self.credentials,
+            'interactivity': self.interactivity,
+            'legends': self.legends_info,
+            'has_legend_list': self.has_legend_list,
+            'encode_data': self.encode_data,
+            'widgets': self.widgets_info,
+            'data': self.source_data,
+            'type': self.source_type,
+            'title': self.title,
+            'options': self.options,
+            'map_index': self.map_index,
+            'source': self.source_data,
+            'viz': self.viz
+        }
+
     def _repr_html_(self):
         from .map import Map
         return Map(self)._repr_html_()
 
+    @property
+    def map_index(self):
+        """Layer map index"""
+        return self._map_index
+
+    @map_index.setter
+    def map_index(self, map_index):
+        """Set session"""
+        self._map_index = map_index
+
     def reset_legends(self, parent_map):
         if parent_map.layer_selector:
             if self.style.default_legend:
diff --git a/cartoframes/viz/layout.py b/cartoframes/viz/layout.py
index a0e00284..4d2d2b22 100644
--- a/cartoframes/viz/layout.py
+++ b/cartoframes/viz/layout.py
@@ -1,7 +1,9 @@
 from . import constants
 from .map import Map
 from .html import HTMLLayout
-from ..utils.utils import get_center
+from ..utils.utils import get_center, get_credentials
+from ..utils.metrics import send_metrics
+from .kuviz import KuvizPublisher
 
 
 class Layout:
@@ -14,6 +16,14 @@ class Layout:
         M_SIZE (number, optional): Number of rows of the layout
         viewport (dict, optional): Properties for display of the maps viewport.
             Keys can be `bearing` or `pitch`.
+        is_static (boolean, optional): By default, all the maps in each visualization
+            are static images due to performance reasons. In order to set them interactive,
+            set `is_static` to True.
+        map_height (number, optional): Height in pixels for each visualization.
+            Default is 250.
+        full_height (boolean, optional): When a layout visualization is published, it
+            will fit the screen height. Otherwise, each visualization height will be
+            `map_height`. Default True.
 
     Raises:
         ValueError: if the input elements are not instances of :py:class:`Map <cartoframes.viz.Map>`.
@@ -56,6 +66,13 @@ class Layout:
         ...     Map(Layer('table_in_your_account'))
         >>> ], viewport={ 'zoom': 2 })
 
+        Create an interactive layout
+
+        >>> Layout([
+        ...    Map(Layer('table_in_your_account')), Map(Layer('table_in_your_account')),
+        ...    Map(Layer('table_in_your_account')), Map(Layer('table_in_your_account'))
+        >>> ], is_static=False)
+
     """
     def __init__(self,
                  maps,
@@ -63,41 +80,167 @@ class Layout:
                  m_size=None,
                  viewport=None,
                  map_height=250,
-                 is_static=True):
-        self._layout = _init_layout(maps, is_static, viewport)
+                 full_height=True,
+                 is_static=True,
+                 **kwargs):
+
+        self._maps = maps
+        self._layout = _init_layout(self._maps, is_static, viewport)
         self._n_size = n_size if n_size is not None else len(self._layout)
         self._m_size = m_size if m_size is not None else constants.DEFAULT_LAYOUT_M_SIZE
         self._viewport = viewport
         self._is_static = is_static
         self._map_height = map_height
+        self._full_height = full_height
+        self._publisher = None
+        self._carto_vl_path = kwargs.get('_carto_vl_path', None)
+        self._airship_path = kwargs.get('_airship_path', None)
 
     def _repr_html_(self):
         self._html_layout = HTMLLayout()
+        map_height = '100%' if self._full_height else '{}px'.format(self._map_height)
+
         self._html_layout.set_content(
             maps=self._layout,
             size=['100%', self._map_height * self._m_size],
             n_size=self._n_size,
             m_size=self._m_size,
             is_static=self._is_static,
-            map_height=self._map_height
+            map_height=map_height,
+            full_height=self._full_height,
+            _carto_vl_path=self._carto_vl_path,
+            _airship_path=self._airship_path
         )
 
         return self._html_layout.html
 
+    @send_metrics('map_published')
+    def publish(self, name, password, credentials=None, if_exists='fail', maps_api_key=None):
+        """Publish the layout visualization as a CARTO custom visualization.
+
+        Args:
+            name (str): The visualization name on CARTO.
+            password (str): By setting it, your visualization will be protected by
+                password. When someone tries to show the visualization, the password
+                will be requested. To disable password you must set it to None.
+            credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
+                A Credentials instance. If not provided, the credentials will be automatically
+                obtained from the default credentials if available. It is used to create the
+                publication and also to save local data (if exists) into your CARTO account.
+            if_exists (str, optional): 'fail' or 'replace'. Behavior in case a publication with
+                the same name already exists in your account. Default is 'fail'.
+            maps_api_key (str, optional): The Maps API key used for private datasets.
+
+        Example:
+            Publishing the map visualization.
+
+            >>> tlayout = Layout([
+            ...    Map(Layer('table_in_your_account')), Map(Layer('table_in_your_account')),
+            ...    Map(Layer('table_in_your_account')), Map(Layer('table_in_your_account'))
+            >>> ])
+
+            >>> tlayout.publish('Custom Map Title', password=None)
+        """
+
+        _credentials = get_credentials(credentials)
+        layers = []
+
+        for viz_map in self._maps:
+            for layer in viz_map.layers:
+                layers.append(layer)
+
+        self._publisher = _get_publisher(_credentials)
+        self._publisher.set_layers(layers, maps_api_key)
+
+        html = self._get_publication_html()
+        return self._publisher.publish(html, name, password, if_exists)
+
+    def delete_publication(self):
+        """Delete the published layout visualization."""
+        return self._publisher.delete()
+
+    def update_publication(self, name, password, if_exists='fail'):
+        """Update the published layout visualization.
+
+        Args:
+            name (str): The visualization name on CARTO.
+            password (str): setting it your visualization will be protected by
+                password and using `None` the visualization will be public.
+            if_exists (str, optional): 'fail' or 'replace'. Behavior in case a publication with the same name already
+                exists in your account. Default is 'fail'.
+
+        Raises:
+            PublishError: if the map has not been published yet.
+
+        """
+        html = self._get_publication_html()
+        return self._publisher.update(html, name, password, if_exists)
+
+    @staticmethod
+    def all_publications(credentials=None):
+        """Get all map visualization published by the current user.
+
+        Args:
+            credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
+                A Credentials instance. If not provided, the credentials will be automatically
+                obtained from the default credentials if available.
+
+        """
+        _credentials = get_credentials(credentials)
+
+        return KuvizPublisher.all(_credentials)
+
+    def _get_publication_html(self):
+        if not self._publisher:
+            _credentials = get_credentials(None)
+            self._publisher = _get_publisher(_credentials)
+
+        html_layout = HTMLLayout('templates/viz/main_layout.html.j2')
+
+        layers = self._publisher.get_layers()
+
+        for viz_map in self._maps:
+            viz_map.layer_defs = []
+
+        for layer in layers:
+            layer.reset_legends(self._maps[layer.map_index])
+            layer_def = layer._get_layer_def()
+            self._maps[layer.map_index].layer_defs.append(layer_def)
+
+        maps = _init_layout(self._maps, self._is_static, self._viewport)
+        map_height = '100%' if self._full_height else '{}px'.format(self._map_height)
+
+        html_layout.set_content(
+            maps=maps,
+            size=['100%', self._map_height * self._m_size],
+            n_size=self._n_size,
+            m_size=self._m_size,
+            is_static=self._is_static,
+            is_embed=True,
+            map_height=map_height
+        )
+
+        return html_layout.html
+
 
 def _init_layout(maps, is_static, viewport):
     layout = []
 
-    for _, viz in enumerate(maps):
+    for map_index, viz in enumerate(maps):
         if not isinstance(viz, Map):
             raise ValueError('All the elements in the Layout should be an instance of Map.')
-        map_settings = _get_map_settings(viz, is_static, viewport)
+
+        for layer in viz.layers:
+            layer.map_index = map_index
+
+        map_settings = _get_map_settings(viz, is_static, viewport, map_index)
+
         layout.append(map_settings)
 
     return layout
 
 
-def _get_map_settings(viz, is_static, viewport):
+def _get_map_settings(viz, is_static, viewport, map_index):
     map_settings = viz.get_content()
 
     map_settings['viewport'] = _get_viewport(map_settings['viewport'], viewport)
@@ -131,3 +274,7 @@ def _get_is_static(map_settings_is_static, layout_is_static):
         return map_settings_is_static
 
     return layout_is_static
+
+
+def _get_publisher(credentials):
+    return KuvizPublisher(credentials)
diff --git a/cartoframes/viz/map.py b/cartoframes/viz/map.py
index f42600d7..f1f36ddd 100644
--- a/cartoframes/viz/map.py
+++ b/cartoframes/viz/map.py
@@ -304,19 +304,7 @@ def _get_layer_defs(layers):
 
 
 def _get_layer_def(layer):
-    return {
-        'credentials': layer.credentials,
-        'interactivity': layer.interactivity,
-        'legends': layer.legends_info,
-        'has_legend_list': layer.has_legend_list,
-        'encode_data': layer.encode_data,
-        'widgets': layer.widgets_info,
-        'data': layer.source_data,
-        'type': layer.source_type,
-        'title': layer.title,
-        'options': layer.options,
-        'viz': layer.viz
-    }
+    return layer._get_layer_def()
 
 
 def _format_bounds(bounds):
diff --git a/cartoframes/viz/widget.py b/cartoframes/viz/widget.py
index cca3f58f..482ce756 100644
--- a/cartoframes/viz/widget.py
+++ b/cartoframes/viz/widget.py
@@ -15,7 +15,7 @@ class Widget:
 
     """
     def __init__(self, widget_type, value=None, title=None, description=None,
-                 footer=None, prop=None, read_only=False, buckets=20):
+                 footer=None, prop=None, read_only=False, buckets=20, weight=1):
         self._check_type(widget_type)
 
         self._type = widget_type
@@ -26,6 +26,7 @@ class Widget:
         self._prop = self._get_prop(prop)
         self._read_only = read_only
         self._buckets = buckets
+        self._weight = weight
         self._variable_name = gen_variable_name(self._value) if self._value else ''
         self._options = self._build_options()
 
@@ -68,7 +69,8 @@ class Widget:
     def _build_options(self):
         options = {
             'read_only': self._read_only,
-            'buckets': self._buckets
+            'buckets': self._buckets,
+            'weight': self._weight
         }
 
         return camel_dictionary(options)
diff --git a/cartoframes/viz/widgets/category_widget.py b/cartoframes/viz/widgets/category_widget.py
index 750170ee..3c28bea7 100644
--- a/cartoframes/viz/widgets/category_widget.py
+++ b/cartoframes/viz/widgets/category_widget.py
@@ -1,7 +1,7 @@
 from ..widget import Widget
 
 
-def category_widget(value, title=None, description=None, footer=None, read_only=False):
+def category_widget(value, title=None, description=None, footer=None, read_only=False, weight=1):
     """Helper function for quickly creating a category widget.
 
     Args:
@@ -24,4 +24,4 @@ def category_widget(value, title=None, description=None, footer=None, read_only=
 
     """
     return Widget('category', value, title, description, footer,
-                  read_only=read_only)
+                  read_only=read_only, weight=weight)
diff --git a/cartoframes/viz/widgets/histogram_widget.py b/cartoframes/viz/widgets/histogram_widget.py
index 8babe93a..ecc12b36 100644
--- a/cartoframes/viz/widgets/histogram_widget.py
+++ b/cartoframes/viz/widgets/histogram_widget.py
@@ -1,7 +1,7 @@
 from ..widget import Widget
 
 
-def histogram_widget(value, title=None, description=None, footer=None, read_only=False, buckets=20):
+def histogram_widget(value, title=None, description=None, footer=None, read_only=False, buckets=20, weight=1):
     """Helper function for quickly creating a histogram widget.
 
     Histogram widgets display the distribution of a numeric attribute, in buckets, to group
@@ -32,4 +32,4 @@ def histogram_widget(value, title=None, description=None, footer=None, read_only
 
     """
     return Widget('histogram', value, title, description, footer,
-                  read_only=read_only, buckets=buckets)
+                  read_only=read_only, buckets=buckets, weight=weight)
diff --git a/cartoframes/viz/widgets/time_series_widget.py b/cartoframes/viz/widgets/time_series_widget.py
index 2d5ff8d8..16da40a7 100644
--- a/cartoframes/viz/widgets/time_series_widget.py
+++ b/cartoframes/viz/widgets/time_series_widget.py
@@ -1,7 +1,8 @@
 from ..widget import Widget
 
 
-def time_series_widget(value, title=None, description=None, footer=None, read_only=False, buckets=20):
+def time_series_widget(value, title=None, description=None, footer=None, read_only=False,
+                       buckets=20, prop='filter', weight=1):
     """Helper function for quickly creating a time series widget.
 
     The time series widget enables you to display animated data (by aggregation) over a specified date or numeric field.
@@ -30,4 +31,4 @@ def time_series_widget(value, title=None, description=None, footer=None, read_on
 
     """
     return Widget('time-series', value, title, description, footer,
-                  read_only=read_only, buckets=buckets, prop='filter')
+                  read_only=read_only, buckets=buckets, prop=prop, weight=weight)
diff --git a/docs/developer-center/examples/examples.json b/docs/developer-center/examples/examples.json
index c367d326..e4e34447 100644
--- a/docs/developer-center/examples/examples.json
+++ b/docs/developer-center/examples/examples.json
@@ -385,6 +385,11 @@
             "desc": "Publish a visualization from a GeoDataFrame",
             "file": "publish_visualization_gdf",
             "path": "publish_and_share"
+        }, {
+            "title": "Publish Layout Visualization",
+            "desc": "Publish a layout visualization",
+            "file": "publish_layout",
+            "path": "publish_and_share"
         }]
     }, {
         "title": "Use cases",
diff --git a/examples/publish_and_share/publish_layout.ipynb b/examples/publish_and_share/publish_layout.ipynb
new file mode 100644
index 00000000..8af1a665
--- /dev/null
+++ b/examples/publish_and_share/publish_layout.ipynb
@@ -0,0 +1,1200 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Publish a Layout Visualization\n",
+    "\n",
+    "This example illustrate how to publish a layout of map visualizations.\n",
+    "\n",
+    "> Note: CARTO Account credentials are needed to reproduce this example. https://carto.com/signup"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from cartoframes.auth import set_default_credentials\n",
+    "from cartoframes.viz import Map, Layer, Layout, basic_style, color_category_style\n",
+    "\n",
+    "set_default_credentials('cartoframes')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 28,
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<iframe\n",
+       "  frameborder=\"0\"\n",
+       "  style=\"\n",
+       "    border: 1px solid #cfcfcf;\n",
+       "    width: 100%;\n",
+       "    height: 1000px;;\n",
+       "    \"\n",
+       "  srcDoc=\"\n",
+       "  <!DOCTYPE html>\n",
+       "<html lang=&quot;en&quot;>\n",
+       "<head>\n",
+       "  <title>CARTOframes</title>\n",
+       "  <meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;>\n",
+       "  <meta charset=&quot;UTF-8&quot;>\n",
+       "  <!-- Include CARTO VL JS -->\n",
+       "  <script src=&quot;https://libs.cartocdn.com/carto-vl/v1.4/carto-vl.min.js&quot;></script>\n",
+       "  <!-- Include Mapbox GL JS -->\n",
+       "  <script src=&quot;https://api.tiles.mapbox.com/mapbox-gl-js/v1.0.0/mapbox-gl.js&quot;></script>\n",
+       "  <!-- Include Mapbox GL CSS -->\n",
+       "  <link href=&quot;https://api.tiles.mapbox.com/mapbox-gl-js/v1.0.0/mapbox-gl.css&quot; rel=&quot;stylesheet&quot; />\n",
+       "\n",
+       "  <!-- Include Airship -->\n",
+       "  <script nomodule=&quot;&quot; src=&quot;https://libs.cartocdn.com/airship-components/v2.3.1-rc.0/airship.js&quot;></script>\n",
+       "  <script type=&quot;module&quot; src=&quot;https://libs.cartocdn.com/airship-components/v2.3.1-rc.0/airship/airship.esm.js&quot;></script>\n",
+       "  <script src=&quot;https://libs.cartocdn.com/airship-bridge/v2.3.1-rc.0/asbridge.min.js&quot;></script>\n",
+       "  <link href=&quot;https://libs.cartocdn.com/airship-style/v2.3.1-rc.0/airship.min.css&quot; rel=&quot;stylesheet&quot;>\n",
+       "  <link href=&quot;https://libs.cartocdn.com/airship-icons/v2.3.1-rc.0/icons.css&quot; rel=&quot;stylesheet&quot;>\n",
+       "\n",
+       "  <link href=&quot;https://fonts.googleapis.com/css?family=Roboto&quot; rel=&quot;stylesheet&quot; type=&quot;text/css&quot;>\n",
+       "\n",
+       "  <!-- External libraries -->\n",
+       "  \n",
+       "  <!-- pako -->\n",
+       "  <script src=&quot;https://libs.cartocdn.com/cartoframes/dependencies/pako_inflate.min.js&quot;></script>\n",
+       "\n",
+       "  <!-- base64-js -->\n",
+       "  <script src='https://libs.cartocdn.com/cartoframes/dependencies/base64.js'></script>\n",
+       "  <!-- html2canvas -->\n",
+       "  <script src=&quot;https://libs.cartocdn.com/cartoframes/dependencies/html2canvas.min.js&quot;></script>\n",
+       "\n",
+       "  \n",
+       "  <style>\n",
+       "  body {\n",
+       "    margin: 0;\n",
+       "    padding: 0;\n",
+       "  }\n",
+       "\n",
+       "  aside.as-sidebar {\n",
+       "    min-width: 300px;\n",
+       "  }\n",
+       "\n",
+       "  .map-image {\n",
+       "    display: none;\n",
+       "    max-width: 100%;\n",
+       "    height: auto;\n",
+       "  }\n",
+       "\n",
+       "  as-layer-selector-slot .as-layer-selector-slot--wrapper .as-caption { // FIXME\n",
+       "    font-size: 14px;\n",
+       "    line-height: 14px;\n",
+       "  }\n",
+       "</style>\n",
+       "  <style>\n",
+       "  .map {\n",
+       "    position: absolute;\n",
+       "    height: 100%;\n",
+       "    width: 100%;\n",
+       "  }\n",
+       "\n",
+       "  .map-info {\n",
+       "    position: absolute;\n",
+       "    bottom: 0;\n",
+       "    padding: 0 5px;\n",
+       "    background-color: rgba(255, 255, 255, 0.5);\n",
+       "    margin: 0;\n",
+       "    color: rgba(0, 0, 0, 0.75);\n",
+       "    font-size: 12px;\n",
+       "    width: auto;\n",
+       "    height: 18px;\n",
+       "    font-family: 'Open Sans';\n",
+       "  }\n",
+       "\n",
+       "  .map-footer {\n",
+       "    background: #F2F6F9;\n",
+       "    font-family: Roboto;\n",
+       "    font-size: 12px;\n",
+       "    line-height: 24px;\n",
+       "    color: #162945;\n",
+       "    text-align: center;\n",
+       "    z-index: 2;\n",
+       "  }\n",
+       "\n",
+       "  .map-footer a {\n",
+       "    text-decoration: none;\n",
+       "  }\n",
+       "\n",
+       "  .map-footer a:hover {\n",
+       "    text-decoration: underline;\n",
+       "  }\n",
+       "</style>\n",
+       "  <style>\n",
+       ".layout-map-cell p {\n",
+       "  font-family: Roboto;\n",
+       "  font-size: 14px;\n",
+       "}\n",
+       "\n",
+       ".layout-map.as-main {\n",
+       "  width: 100%;\n",
+       "}\n",
+       "\n",
+       ".layout-map-cell {\n",
+       "  align-items: center;\n",
+       "  display: flex;\n",
+       "  flex-direction: column;\n",
+       "  height: 250px;\n",
+       "  justify-content: center;\n",
+       "  width: 100%;\n",
+       "  height: 100%;\n",
+       "  padding: 8px;\n",
+       "  position: relative;\n",
+       "}\n",
+       "\n",
+       ".layout-map {\n",
+       "  background-color: silver;\n",
+       "  height: 100%;\n",
+       "}\n",
+       "\n",
+       ".layout-map > .as-map-area,\n",
+       ".map-image > .as-map-area {\n",
+       "  height: 100%;\n",
+       "}\n",
+       "\n",
+       ".layer-selector {\n",
+       "  margin-bottom: 12px;\n",
+       "}\n",
+       "\n",
+       ".layout-map-container {\n",
+       "  align-items: center;\n",
+       "  display: flex;\n",
+       "  flex-direction: column;\n",
+       "  justify-content: space-around;\n",
+       "  width: 100%;\n",
+       "  height: 100%;\n",
+       "  padding: 0 8px;\n",
+       "}\n",
+       "\n",
+       ".layout-map-row {\n",
+       "  display: flex;\n",
+       "  flex-direction: row;\n",
+       "  justify-content: space-around;\n",
+       "  width: 100%;\n",
+       "  height: 100%;\n",
+       "}\n",
+       "\n",
+       ".layout-legends {\n",
+       "  background-color: white;\n",
+       "}\n",
+       "\n",
+       ".layout-map-cell--section {\n",
+       "  position: relative;\n",
+       "  width: 100%;\n",
+       "  height: 100%;\n",
+       "}\n",
+       "</style>\n",
+       "    <style>\n",
+       "    #error-container {\n",
+       "      position: absolute;\n",
+       "      width: 100%;\n",
+       "      height: 100%;\n",
+       "      background-color: white;\n",
+       "      visibility: hidden;\n",
+       "      padding: 1em;\n",
+       "      font-family: &quot;Courier New&quot;, Courier, monospace;\n",
+       "      margin: 0 auto;\n",
+       "      font-size: 14px;\n",
+       "      overflow: auto;\n",
+       "      z-index: 1000;\n",
+       "      color: black;\n",
+       "    }\n",
+       "\n",
+       "    .error-section {\n",
+       "      padding: 1em;\n",
+       "      border-radius: 5px;\n",
+       "      background-color: #fee;\n",
+       "    }\n",
+       "\n",
+       "    #error-container #error-highlight {\n",
+       "      font-weight: bold;\n",
+       "      color: inherit;\n",
+       "    }\n",
+       "\n",
+       "    #error-container #error-type {\n",
+       "      color: #008000;\n",
+       "    }\n",
+       "\n",
+       "    #error-container #error-name {\n",
+       "      color: #ba2121;\n",
+       "    }\n",
+       "\n",
+       "    #error-container #error-content {\n",
+       "      margin-top: 0.4em;\n",
+       "    }\n",
+       "\n",
+       "    .error-details {\n",
+       "      margin-top: 1em;\n",
+       "    }\n",
+       "\n",
+       "    #error-stacktrace {\n",
+       "      list-style: none;\n",
+       "    }\n",
+       "</style>\n",
+       "  <style>\n",
+       "    .popup-content {\n",
+       "      display: flex;\n",
+       "      flex-direction: column;\n",
+       "      padding: 8px;\n",
+       "    }\n",
+       "\n",
+       "    .popup-name {\n",
+       "      font-size: 12px;\n",
+       "      font-weight: 400;\n",
+       "      line-height: 20px;\n",
+       "      margin-bottom: 4px;\n",
+       "    }\n",
+       "\n",
+       "    .popup-value {\n",
+       "      font-size: 16px;\n",
+       "      font-weight: 600;\n",
+       "      line-height: 20px;\n",
+       "    }\n",
+       "\n",
+       "    .popup-value:not(:last-of-type) {\n",
+       "      margin-bottom: 16px;\n",
+       "    }\n",
+       "</style>\n",
+       "</head>\n",
+       "\n",
+       "<body class=&quot;as-app-body as-app&quot;>\n",
+       "  <main id=&quot;main-container&quot; class=&quot;layout-map-container&quot;>\n",
+       "  \n",
+       "    <div class=&quot;layout-map-row&quot;>\n",
+       "    \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      <div class=&quot;layout-map-cell layout-map-cell-legend&quot; style=&quot;height: 100%&quot;>\n",
+       "        \n",
+       "          <header class=&quot;layout-map-cell--header&quot;>\n",
+       "            <p>Drought Areas</p>\n",
+       "          </header>\n",
+       "        \n",
+       "\n",
+       "        <section class=&quot;layout-map-cell--section&quot;>\n",
+       "          <img id=&quot;map-image-0&quot; class=&quot;map-image&quot; alt=&quot;Static Map 0&quot; />\n",
+       "          <as-responsive-content id=&quot;main-container-0&quot; class=&quot;layout-map&quot;>\n",
+       "            <div class=&quot;as-map-area&quot;>\n",
+       "              <div id=&quot;map-0&quot; class=&quot;map&quot;></div>\n",
+       "            </div>\n",
+       "            \n",
+       "              \n",
+       "              <div class=&quot;as-map-panels&quot; data-name=&quot;Legends&quot;>\n",
+       "                <div class=&quot;as-panel as-panel--vertical as-panel--top as-panel--left&quot;>\n",
+       "                  \n",
+       "\n",
+       "<div class=&quot;as-panel__element&quot; id=&quot;legends-0&quot;>\n",
+       "  <as-layer-selector id=&quot;layer-selector-0&quot; class=&quot;layer-selector&quot;>\n",
+       "    \n",
+       "      \n",
+       "        \n",
+       "        \n",
+       "        <div slot=&quot;as-checkbox-layer-0-slot&quot;>\n",
+       "          \n",
+       "            \n",
+       "              <as-legend\n",
+       "                heading=&quot;area&quot;\n",
+       "                description=&quot;&quot;>\n",
+       "                <as-legend-color-category id=&quot;layer0_map0_legend0&quot; slot=&quot;legends&quot;></as-legend-color-category>\n",
+       "                \n",
+       "              </as-legend>\n",
+       "            \n",
+       "          \n",
+       "        </div>\n",
+       "      \n",
+       "    \n",
+       "  </as-layer-selector>\n",
+       "</div>\n",
+       "                </div>\n",
+       "              </div>\n",
+       "            \n",
+       "\n",
+       "          </as-responsive-content>\n",
+       "        </section>\n",
+       "      </div>\n",
+       "    \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      <div class=&quot;layout-map-cell &quot; style=&quot;height: 100%&quot;>\n",
+       "        \n",
+       "          <header class=&quot;layout-map-cell--header&quot;>\n",
+       "            <p>Area 3</p>\n",
+       "          </header>\n",
+       "        \n",
+       "\n",
+       "        <section class=&quot;layout-map-cell--section&quot;>\n",
+       "          <img id=&quot;map-image-1&quot; class=&quot;map-image&quot; alt=&quot;Static Map 1&quot; />\n",
+       "          <as-responsive-content id=&quot;main-container-1&quot; class=&quot;layout-map&quot;>\n",
+       "            <div class=&quot;as-map-area&quot;>\n",
+       "              <div id=&quot;map-1&quot; class=&quot;map&quot;></div>\n",
+       "            </div>\n",
+       "            \n",
+       "\n",
+       "          </as-responsive-content>\n",
+       "        </section>\n",
+       "      </div>\n",
+       "    \n",
+       "    </div>\n",
+       "  \n",
+       "    <div class=&quot;layout-map-row&quot;>\n",
+       "    \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      <div class=&quot;layout-map-cell &quot; style=&quot;height: 100%&quot;>\n",
+       "        \n",
+       "          <header class=&quot;layout-map-cell--header&quot;>\n",
+       "            <p>Area 2</p>\n",
+       "          </header>\n",
+       "        \n",
+       "\n",
+       "        <section class=&quot;layout-map-cell--section&quot;>\n",
+       "          <img id=&quot;map-image-2&quot; class=&quot;map-image&quot; alt=&quot;Static Map 2&quot; />\n",
+       "          <as-responsive-content id=&quot;main-container-2&quot; class=&quot;layout-map&quot;>\n",
+       "            <div class=&quot;as-map-area&quot;>\n",
+       "              <div id=&quot;map-2&quot; class=&quot;map&quot;></div>\n",
+       "            </div>\n",
+       "            \n",
+       "\n",
+       "          </as-responsive-content>\n",
+       "        </section>\n",
+       "      </div>\n",
+       "    \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      \n",
+       "      <div class=&quot;layout-map-cell &quot; style=&quot;height: 100%&quot;>\n",
+       "        \n",
+       "          <header class=&quot;layout-map-cell--header&quot;>\n",
+       "            <p>Area 1</p>\n",
+       "          </header>\n",
+       "        \n",
+       "\n",
+       "        <section class=&quot;layout-map-cell--section&quot;>\n",
+       "          <img id=&quot;map-image-3&quot; class=&quot;map-image&quot; alt=&quot;Static Map 3&quot; />\n",
+       "          <as-responsive-content id=&quot;main-container-3&quot; class=&quot;layout-map&quot;>\n",
+       "            <div class=&quot;as-map-area&quot;>\n",
+       "              <div id=&quot;map-3&quot; class=&quot;map&quot;></div>\n",
+       "            </div>\n",
+       "            \n",
+       "\n",
+       "          </as-responsive-content>\n",
+       "        </section>\n",
+       "      </div>\n",
+       "    \n",
+       "    </div>\n",
+       "  \n",
+       "  </main>\n",
+       "\n",
+       "  \n",
+       "\n",
+       "  <div id=&quot;error-container&quot; class=&quot;error&quot;>\n",
+       "  <section class=&quot;error-section&quot;>\n",
+       "    <span class=&quot;errors&quot; id=&quot;error-name&quot;></span>:\n",
+       "    <section id=&quot;error-content&quot;>\n",
+       "      <span class=&quot;errors&quot; id=&quot;error-type&quot;></span>\n",
+       "      <span class=&quot;errors&quot; id=&quot;error-message&quot;></span>\n",
+       "    </section>\n",
+       "  </section>\n",
+       "\n",
+       "  <details class=&quot;error-details&quot;>\n",
+       "    <summary>StackTrace</summary>\n",
+       "    <ul id=&quot;error-stacktrace&quot;></ul>\n",
+       "  </details>\n",
+       "</div>\n",
+       "</body>\n",
+       "<script>\n",
+       "  var init = (function () {\n",
+       "  'use strict';\n",
+       "\n",
+       "  const BASEMAPS = {\n",
+       "    DarkMatter: carto.basemaps.darkmatter,\n",
+       "    Voyager: carto.basemaps.voyager,\n",
+       "    Positron: carto.basemaps.positron\n",
+       "  };\n",
+       "\n",
+       "  const attributionControl = new mapboxgl.AttributionControl({\n",
+       "    compact: false\n",
+       "  });\n",
+       "\n",
+       "  const FIT_BOUNDS_SETTINGS = { animate: false, padding: 50, maxZoom: 16 };\n",
+       "\n",
+       "  /** From https://github.com/errwischt/stacktrace-parser/blob/master/src/stack-trace-parser.js */\n",
+       "\n",
+       "  /**\n",
+       "   * This parses the different stack traces and puts them into one format\n",
+       "   * This borrows heavily from TraceKit (https://github.com/csnover/TraceKit)\n",
+       "   */\n",
+       "\n",
+       "  const UNKNOWN_FUNCTION = '<unknown>';\n",
+       "  const chromeRe = /^\\s*at (.*?) ?\\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\\/).*?)(?::(\\d+))?(?::(\\d+))?\\)?\\s*$/i;\n",
+       "  const chromeEvalRe = /\\((\\S*)(?::(\\d+))(?::(\\d+))\\)/;\n",
+       "  const winjsRe = /^\\s*at (?:((?:\\[object object\\])?.+) )?\\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\\d+)(?::(\\d+))?\\)?\\s*$/i;\n",
+       "  const geckoRe = /^\\s*(.*?)(?:\\((.*?)\\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\\[native).*?|[^@]*bundle)(?::(\\d+))?(?::(\\d+))?\\s*$/i;\n",
+       "  const geckoEvalRe = /(\\S+) line (\\d+)(?: > eval line \\d+)* > eval/i;\n",
+       "\n",
+       "  function parse(stackString) {\n",
+       "    const lines = stackString.split('\\n');\n",
+       "\n",
+       "    return lines.reduce((stack, line) => {\n",
+       "      const parseResult =\n",
+       "        parseChrome(line) ||\n",
+       "        parseWinjs(line) ||\n",
+       "        parseGecko(line);\n",
+       "\n",
+       "      if (parseResult) {\n",
+       "        stack.push(parseResult);\n",
+       "      }\n",
+       "\n",
+       "      return stack;\n",
+       "    }, []);\n",
+       "  }\n",
+       "\n",
+       "  function parseChrome(line) {\n",
+       "    const parts = chromeRe.exec(line);\n",
+       "\n",
+       "    if (!parts) {\n",
+       "      return null;\n",
+       "    }\n",
+       "\n",
+       "    const isNative = parts[2] && parts[2].indexOf('native') === 0; // start of line\n",
+       "    const isEval = parts[2] && parts[2].indexOf('eval') === 0; // start of line\n",
+       "\n",
+       "    const submatch = chromeEvalRe.exec(parts[2]);\n",
+       "    if (isEval && submatch != null) {\n",
+       "      // throw out eval line/column and use top-most line/column number\n",
+       "      parts[2] = submatch[1]; // url\n",
+       "      parts[3] = submatch[2]; // line\n",
+       "      parts[4] = submatch[3]; // column\n",
+       "    }\n",
+       "\n",
+       "    return {\n",
+       "      file: !isNative ? parts[2] : null,\n",
+       "      methodName: parts[1] || UNKNOWN_FUNCTION,\n",
+       "      arguments: isNative ? [parts[2]] : [],\n",
+       "      lineNumber: parts[3] ? +parts[3] : null,\n",
+       "      column: parts[4] ? +parts[4] : null,\n",
+       "    };\n",
+       "  }\n",
+       "\n",
+       "  function parseWinjs(line) {\n",
+       "    const parts = winjsRe.exec(line);\n",
+       "\n",
+       "    if (!parts) {\n",
+       "      return null;\n",
+       "    }\n",
+       "\n",
+       "    return {\n",
+       "      file: parts[2],\n",
+       "      methodName: parts[1] || UNKNOWN_FUNCTION,\n",
+       "      arguments: [],\n",
+       "      lineNumber: +parts[3],\n",
+       "      column: parts[4] ? +parts[4] : null,\n",
+       "    };\n",
+       "  }\n",
+       "\n",
+       "  function parseGecko(line) {\n",
+       "    const parts = geckoRe.exec(line);\n",
+       "\n",
+       "    if (!parts) {\n",
+       "      return null;\n",
+       "    }\n",
+       "\n",
+       "    const isEval = parts[3] && parts[3].indexOf(' > eval') > -1;\n",
+       "\n",
+       "    const submatch = geckoEvalRe.exec(parts[3]);\n",
+       "    if (isEval && submatch != null) {\n",
+       "      // throw out eval line/column and use top-most line number\n",
+       "      parts[3] = submatch[1];\n",
+       "      parts[4] = submatch[2];\n",
+       "      parts[5] = null; // no column when eval\n",
+       "    }\n",
+       "\n",
+       "    return {\n",
+       "      file: parts[3],\n",
+       "      methodName: parts[1] || UNKNOWN_FUNCTION,\n",
+       "      arguments: parts[2] ? parts[2].split(',') : [],\n",
+       "      lineNumber: parts[4] ? +parts[4] : null,\n",
+       "      column: parts[5] ? +parts[5] : null,\n",
+       "    };\n",
+       "  }\n",
+       "\n",
+       "  function displayError(e) {\n",
+       "    const error$ = document.getElementById('error-container');\n",
+       "    const errors$ = error$.getElementsByClassName('errors');\n",
+       "    const stacktrace$ = document.getElementById('error-stacktrace');\n",
+       "\n",
+       "    errors$[0].innerHTML = e.name;\n",
+       "    errors$[1].innerHTML = e.type;\n",
+       "    errors$[2].innerHTML = e.message.replace(e.type, '');\n",
+       "\n",
+       "    error$.style.visibility = 'visible';\n",
+       "\n",
+       "    const stack = parse(e.stack);\n",
+       "    const list = stack.map(item => {\n",
+       "      return `<li>\n",
+       "      at <span class=&quot;stacktrace-method&quot;>${item.methodName}:</span>\n",
+       "      (${item.file}:${item.lineNumber}:${item.column})\n",
+       "    </li>`;\n",
+       "    });\n",
+       "\n",
+       "    stacktrace$.innerHTML = list.join('\\n');\n",
+       "  }\n",
+       "\n",
+       "  function format(value) {\n",
+       "    if (Array.isArray(value)) {\n",
+       "      const [first, second] = value;\n",
+       "      if (first === -Infinity) {\n",
+       "        return `< ${formatValue(second)}`;\n",
+       "      }\n",
+       "      if (second === Infinity) {\n",
+       "        return `> ${formatValue(first)}`;\n",
+       "      }\n",
+       "      return `${formatValue(first)} - ${formatValue(second)}`;\n",
+       "    }\n",
+       "    return formatValue(value);\n",
+       "  }\n",
+       "\n",
+       "  function formatValue(value) {\n",
+       "    if (typeof value === 'number') {\n",
+       "      return formatNumber(value);\n",
+       "    }\n",
+       "    return value;\n",
+       "  }\n",
+       "\n",
+       "  function formatNumber(value) {\n",
+       "    const log = Math.log10(Math.abs(value));\n",
+       "\n",
+       "    if ((log > 4 || log < -2.00000001) && value) {\n",
+       "      return value.toExponential(2);\n",
+       "    }\n",
+       "    \n",
+       "    if (!Number.isInteger(value)) {\n",
+       "      return value.toLocaleString(undefined, {\n",
+       "        minimumFractionDigits: 2,\n",
+       "        maximumFractionDigits: 3\n",
+       "      });\n",
+       "    }\n",
+       "    \n",
+       "    return value.toLocaleString();\n",
+       "  }\n",
+       "\n",
+       "  function updateViewport(map) {\n",
+       "    function updateMapInfo() {\n",
+       "      const mapInfo$ = document.getElementById('map-info');\n",
+       "      const center = map.getCenter();\n",
+       "      const lat = center.lat.toFixed(6);\n",
+       "      const lng = center.lng.toFixed(6);\n",
+       "      const zoom = map.getZoom().toFixed(2);\n",
+       "    \n",
+       "      mapInfo$.innerText = `viewport={'zoom': ${zoom}, 'lat': ${lat}, 'lng': ${lng}}`;\n",
+       "    }\n",
+       "\n",
+       "    updateMapInfo();\n",
+       "\n",
+       "    map.on('zoom', updateMapInfo);\n",
+       "    map.on('move', updateMapInfo); \n",
+       "  }\n",
+       "\n",
+       "  function getBasecolorSettings(basecolor) {\n",
+       "    return {\n",
+       "      'version': 8,\n",
+       "      'sources': {},\n",
+       "      'layers': [{\n",
+       "          'id': 'background',\n",
+       "          'type': 'background',\n",
+       "          'paint': {\n",
+       "              'background-color': basecolor\n",
+       "          }\n",
+       "      }]\n",
+       "    };\n",
+       "  }\n",
+       "\n",
+       "  function getImageElement(mapIndex) {\n",
+       "    const id = mapIndex !== undefined ? `map-image-${mapIndex}` : 'map-image';\n",
+       "    return document.getElementById(id);\n",
+       "  }\n",
+       "\n",
+       "  function getContainerElement(mapIndex) {\n",
+       "    const id = mapIndex !== undefined ? `main-container-${mapIndex}` : 'main-container';\n",
+       "    return document.getElementById(id);\n",
+       "  }\n",
+       "\n",
+       "  function saveImage(mapIndex) {\n",
+       "    const img = getImageElement(mapIndex);\n",
+       "    const container = getContainerElement(mapIndex);\n",
+       "\n",
+       "    html2canvas(container)\n",
+       "      .then((canvas) => setMapImage(canvas, img, container));\n",
+       "  }\n",
+       "\n",
+       "  function setMapImage(canvas, img, container) {\n",
+       "    const src = canvas.toDataURL();\n",
+       "    img.setAttribute('src', src);\n",
+       "    img.style.display = 'block';\n",
+       "    container.style.display = 'none';\n",
+       "  }\n",
+       "\n",
+       "  function resetPopupClick(interactivity) {\n",
+       "    interactivity.off('featureClick');\n",
+       "  }\n",
+       "\n",
+       "  function resetPopupHover(interactivity) {\n",
+       "    interactivity.off('featureHover');\n",
+       "  }\n",
+       "\n",
+       "  function setPopupsClick(map, clickPopup, hoverPopup, interactivity, attrs) {\n",
+       "    interactivity.on('featureClick', (event) => {\n",
+       "      updatePopup(map, clickPopup, event, attrs);\n",
+       "      hoverPopup.remove();\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function setPopupsHover(map, hoverPopup, interactivity, attrs) {\n",
+       "    interactivity.on('featureHover', (event) => {\n",
+       "      updatePopup(map, hoverPopup, event, attrs);\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function updatePopup(map, popup, event, attrs) {\n",
+       "    if (event.features.length > 0) {\n",
+       "      let popupHTML = '';\n",
+       "      const layerIDs = [];\n",
+       "\n",
+       "      for (const feature of event.features) {\n",
+       "        if (layerIDs.includes(feature.layerId)) {\n",
+       "          continue;\n",
+       "        }\n",
+       "        // Track layers to add only one feature per layer\n",
+       "        layerIDs.push(feature.layerId);\n",
+       "    \n",
+       "        for (const item of attrs) {\n",
+       "          const variable = feature.variables[item.name];\n",
+       "          if (variable) {\n",
+       "            let value = variable.value;\n",
+       "            value = formatValue(value);\n",
+       "\n",
+       "            popupHTML = `\n",
+       "            <span class=&quot;popup-name&quot;>${item.title}</span>\n",
+       "            <span class=&quot;popup-value&quot;>${value}</span>\n",
+       "          ` + popupHTML;\n",
+       "          }\n",
+       "        }\n",
+       "      }\n",
+       "\n",
+       "      popup\n",
+       "          .setLngLat([event.coordinates.lng, event.coordinates.lat])\n",
+       "          .setHTML(`<div class=&quot;popup-content&quot;>${popupHTML}</div>`);\n",
+       "\n",
+       "      if (!popup.isOpen()) {\n",
+       "        popup.addTo(map);\n",
+       "      }\n",
+       "    } else {\n",
+       "      popup.remove();\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function setInteractivity(map, interactiveLayers, interactiveMapLayers) {\n",
+       "    const interactivity = new carto.Interactivity(interactiveMapLayers);\n",
+       "\n",
+       "    const clickPopup = new mapboxgl.Popup({\n",
+       "      closeButton: true,\n",
+       "      closeOnClick: false\n",
+       "    });\n",
+       "\n",
+       "    const hoverPopup = new mapboxgl.Popup({\n",
+       "      closeButton: false,\n",
+       "      closeOnClick: false\n",
+       "    });\n",
+       "\n",
+       "    const { clickAttrs, hoverAttrs } = _setInteractivityAttrs(interactiveLayers);\n",
+       "\n",
+       "    resetPopupClick(map);\n",
+       "    resetPopupHover(map);\n",
+       "\n",
+       "    if (clickAttrs.length > 0) {\n",
+       "      setPopupsClick(map, clickPopup, hoverPopup, interactivity, clickAttrs);\n",
+       "    }\n",
+       "\n",
+       "    if (hoverAttrs.length > 0) {\n",
+       "      setPopupsHover(map, hoverPopup, interactivity, hoverAttrs);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function _setInteractivityAttrs(interactiveLayers) {\n",
+       "    let clickAttrs = [];\n",
+       "    let hoverAttrs = [];\n",
+       "\n",
+       "    interactiveLayers.forEach((interactiveLayer) => {\n",
+       "      interactiveLayer.interactivity.forEach((interactivityDef) => {\n",
+       "        if (interactivityDef.event === 'click') {\n",
+       "          clickAttrs = clickAttrs.concat(interactivityDef.attrs);\n",
+       "        } else if (interactivityDef.event === 'hover') {\n",
+       "          hoverAttrs = hoverAttrs.concat(interactivityDef.attrs);\n",
+       "        }\n",
+       "      });\n",
+       "    });\n",
+       "\n",
+       "    return { clickAttrs, hoverAttrs };\n",
+       "  }\n",
+       "\n",
+       "  function renderWidget(widget, value) {\n",
+       "    widget.element = widget.element || document.querySelector(`#${widget.id}-value`);\n",
+       "\n",
+       "    if (value && widget.element) {\n",
+       "      widget.element.innerText = typeof value === 'number' ? format(value) : value;\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function renderBridge(bridge, widget, mapLayer) {\n",
+       "    widget.element = widget.element || document.querySelector(`#${widget.id}`);\n",
+       "\n",
+       "    switch (widget.type) {\n",
+       "      case 'histogram':\n",
+       "        const type = _getWidgetType(mapLayer, widget.value, widget.prop);\n",
+       "        const histogram = type === 'category' ? 'categoricalHistogram' : 'numericalHistogram';\n",
+       "        bridge[histogram](widget.element, widget.value, widget.options);\n",
+       "\n",
+       "        break;\n",
+       "      case 'category':\n",
+       "        bridge.category(widget.element, widget.value, widget.options);\n",
+       "        break;\n",
+       "      case 'animation':\n",
+       "        widget.options.propertyName = widget.prop;\n",
+       "        bridge.animationControls(widget.element, widget.value, widget.options);\n",
+       "        break;\n",
+       "      case 'time-series':\n",
+       "        widget.options.propertyName = widget.prop;\n",
+       "        bridge.timeSeries(widget.element, widget.value, widget.options);\n",
+       "        break;\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function bridgeLayerWidgets(map, mapLayer, mapSource, widgets) {\n",
+       "    const bridge = new AsBridge.VL.Bridge({\n",
+       "      carto: carto,\n",
+       "      layer: mapLayer,\n",
+       "      source: mapSource,\n",
+       "      map: map\n",
+       "    });\n",
+       "\n",
+       "    widgets\n",
+       "      .filter((widget) => widget.has_bridge)\n",
+       "      .forEach((widget) => renderBridge(bridge, widget, mapLayer));\n",
+       "\n",
+       "    bridge.build();\n",
+       "  }\n",
+       "\n",
+       "  function _getWidgetType(layer, property, value) {\n",
+       "    return layer.metadata && layer.metadata.properties[value] ?\n",
+       "      layer.metadata.properties[value].type\n",
+       "      : _getWidgetPropertyType(layer, property);\n",
+       "  }\n",
+       "\n",
+       "  function _getWidgetPropertyType(layer, property) {\n",
+       "    return layer.metadata && layer.metadata.properties[property] ?\n",
+       "      layer.metadata.properties[property].type\n",
+       "      : null;\n",
+       "  }\n",
+       "\n",
+       "  function createLegends(layer, legends, layerIndex, mapIndex=0) {\n",
+       "    if (legends.length) {\n",
+       "      legends.forEach((legend, legendIndex) => _createLegend(layer, legend, layerIndex, legendIndex, mapIndex));\n",
+       "    } else {\n",
+       "      _createLegend(layer, legends, layerIndex, 0, mapIndex);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function _createLegend(layer, legend, layerIndex, legendIndex, mapIndex=0) {\n",
+       "    const element = document.querySelector(`#layer${layerIndex}_map${mapIndex}_legend${legendIndex}`);\n",
+       "\n",
+       "    if (legend.prop) {\n",
+       "      const othersLabel = 'Others';   // TODO: i18n\n",
+       "      const prop = legend.prop;\n",
+       "      const dynamic = legend.dynamic;\n",
+       "      const order = legend.ascending ? 'ASC' : 'DESC';\n",
+       "      const variable = legend.variable;\n",
+       "      const config = { othersLabel, variable, order };\n",
+       "      const options = { format, config, dynamic };\n",
+       "\n",
+       "      if (legend.type.startsWith('size-continuous')) {\n",
+       "        config.samples = 4;\n",
+       "      }\n",
+       "\n",
+       "      AsBridge.VL.Legends.rampLegend(element, layer, prop, options);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function SourceFactory() {\n",
+       "    const sourceTypes = { GeoJSON, Query, MVT };\n",
+       "\n",
+       "    this.createSource = (layer) => {\n",
+       "      return sourceTypes[layer.type](layer);\n",
+       "    };\n",
+       "  }\n",
+       "\n",
+       "  function GeoJSON(layer) {\n",
+       "    const options = JSON.parse(JSON.stringify(layer.options));\n",
+       "    const data = _decodeJSONData(layer.data, layer.encode_data);\n",
+       "\n",
+       "    return new carto.source.GeoJSON(data, options);\n",
+       "  }\n",
+       "\n",
+       "  function Query(layer) {\n",
+       "    const auth = {\n",
+       "      username: layer.credentials.username,\n",
+       "      apiKey: layer.credentials.api_key || 'default_public'\n",
+       "    };\n",
+       "\n",
+       "    const config = {\n",
+       "      serverURL: layer.credentials.base_url || `https://${layer.credentials.username}.carto.com/`\n",
+       "    };\n",
+       "\n",
+       "    return new carto.source.SQL(layer.data, auth, config);\n",
+       "  }\n",
+       "\n",
+       "  function MVT(layer) {\n",
+       "    return new carto.source.MVT(layer.data.file, JSON.parse(layer.data.metadata));\n",
+       "  }\n",
+       "\n",
+       "  function _decodeJSONData(data, encodeData) {\n",
+       "    try {\n",
+       "      if (encodeData) {\n",
+       "        const decodedJSON = pako.inflate(atob(data), { to: 'string' });\n",
+       "        return JSON.parse(decodedJSON);\n",
+       "      } else {\n",
+       "        return JSON.parse(data);\n",
+       "      }\n",
+       "    } catch(error) {\n",
+       "      throw new Error(`\n",
+       "      Error: &quot;${error}&quot;. CARTOframes is not able to parse your local data because it is too large.\n",
+       "      Please, disable the data compresion with encode_data=False in your Layer class.\n",
+       "    `);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  const factory = new SourceFactory();\n",
+       "\n",
+       "  function initMapLayer(layer, layerIndex, numLayers, hasLegends, map, mapIndex) {\n",
+       "    const mapSource = factory.createSource(layer);\n",
+       "    const mapViz = new carto.Viz(layer.viz);\n",
+       "    const mapLayer = new carto.Layer(`layer${layerIndex}`, mapSource, mapViz);\n",
+       "    const mapLayerIndex = numLayers - layerIndex - 1;\n",
+       "\n",
+       "    try {\n",
+       "      mapLayer._updateLayer.catch(displayError);\n",
+       "    } catch (e) {\n",
+       "      throw e;\n",
+       "    }\n",
+       "\n",
+       "    mapLayer.addTo(map);\n",
+       "\n",
+       "    setLayerLegend(layer, mapLayerIndex, mapLayer, mapIndex, hasLegends);\n",
+       "    setLayerWidgets(map, layer, mapLayer, mapLayerIndex, mapSource);\n",
+       "\n",
+       "    return mapLayer;\n",
+       "  }\n",
+       "\n",
+       "  function getInteractiveLayers(layers, mapLayers) {\n",
+       "    const interactiveLayers = [];\n",
+       "    const interactiveMapLayers = [];\n",
+       "\n",
+       "    layers.forEach((layer, index) => {\n",
+       "      if (layer.interactivity) {\n",
+       "        interactiveLayers.push(layer);\n",
+       "        interactiveMapLayers.push(mapLayers[index]);\n",
+       "      }\n",
+       "    });\n",
+       "\n",
+       "    return { interactiveLayers, interactiveMapLayers };\n",
+       "  }\n",
+       "\n",
+       "  function setLayerLegend(layer, mapLayerIndex, mapLayer, mapIndex, hasLegends) {\n",
+       "    if (hasLegends && layer.legends) {\n",
+       "      createLegends(mapLayer, layer.legends, mapLayerIndex, mapIndex);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function setLayerWidgets(map, layer, mapLayer, mapLayerIndex, mapSource) {\n",
+       "    if (layer.widgets.length) {\n",
+       "      initLayerWidgets(layer.widgets, mapLayerIndex);\n",
+       "      updateLayerWidgets(layer.widgets, mapLayer);\n",
+       "      bridgeLayerWidgets(map, mapLayer, mapSource, layer.widgets);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function initLayerWidgets(widgets, mapLayerIndex) {\n",
+       "    widgets.forEach((widget, widgetIndex) => {\n",
+       "      const id = `layer${mapLayerIndex}_widget${widgetIndex}`;\n",
+       "      widget.id = id;\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function updateLayerWidgets(widgets, mapLayer) {\n",
+       "    mapLayer.on('updated', () => renderLayerWidgets(widgets, mapLayer));\n",
+       "  }\n",
+       "\n",
+       "  function renderLayerWidgets(widgets, mapLayer) {\n",
+       "    const variables = mapLayer.viz.variables;\n",
+       "\n",
+       "    widgets\n",
+       "      .filter((widget) => !widget.has_bridge)\n",
+       "      .forEach((widget) => {\n",
+       "        const name = widget.variable_name;\n",
+       "        const value = getWidgetValue(name, variables);\n",
+       "        renderWidget(widget, value);\n",
+       "      });\n",
+       "  }\n",
+       "\n",
+       "  function getWidgetValue(name, variables) {\n",
+       "    return name && variables[name] ? variables[name].value : null;\n",
+       "  }\n",
+       "\n",
+       "  function setReady(settings) {\n",
+       "    try {\n",
+       "      return settings.maps ? initMaps(settings.maps) : initMap(settings);\n",
+       "    } catch (e) {\n",
+       "      displayError(e);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function initMaps(maps) {\n",
+       "    return maps.map((mapSettings, mapIndex) => {\n",
+       "      return initMap(mapSettings, mapIndex);\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function initMap(settings, mapIndex) {\n",
+       "    const basecolor = getBasecolorSettings(settings.basecolor);\n",
+       "    const basemapStyle =  BASEMAPS[settings.basemap] || settings.basemap || basecolor;\n",
+       "    const container = mapIndex !== undefined ? `map-${mapIndex}` : 'map';\n",
+       "    const map = createMap(container, basemapStyle, settings.bounds, settings.mapboxtoken);\n",
+       "\n",
+       "    if (settings.show_info) {\n",
+       "      updateViewport(map);\n",
+       "    }\n",
+       "\n",
+       "    if (settings.camera) {\n",
+       "      map.flyTo(settings.camera);\n",
+       "    }\n",
+       "\n",
+       "    return initLayers(map, settings, mapIndex);\n",
+       "  }\n",
+       "\n",
+       "  function initLayers(map, settings, mapIndex) {\n",
+       "    const numLayers = settings.layers.length;\n",
+       "    const hasLegends = settings.has_legends;\n",
+       "    const isStatic = settings.is_static;\n",
+       "    const layers = settings.layers;\n",
+       "    const mapLayers = getMapLayers(\n",
+       "      layers,\n",
+       "      numLayers,\n",
+       "      hasLegends,\n",
+       "      map,\n",
+       "      mapIndex\n",
+       "    );\n",
+       "\n",
+       "    if (settings.layer_selector) {\n",
+       "      addLayersSelector(layers.reverse(), mapLayers.reverse(), mapIndex);\n",
+       "    }\n",
+       "\n",
+       "    setInteractiveLayers(map, layers, mapLayers);\n",
+       "\n",
+       "    return waitForMapLayersLoad(isStatic, mapIndex, mapLayers);\n",
+       "  }\n",
+       "\n",
+       "  function waitForMapLayersLoad(isStatic, mapIndex, mapLayers) {\n",
+       "    return new Promise((resolve) => {\n",
+       "      carto.on('loaded', mapLayers, onMapLayersLoaded.bind(\n",
+       "        this, isStatic, mapIndex, mapLayers, resolve)\n",
+       "      );\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function onMapLayersLoaded(isStatic, mapIndex, mapLayers, resolve) {\n",
+       "    if (isStatic) {\n",
+       "      saveImage(mapIndex);\n",
+       "    }\n",
+       "\n",
+       "    resolve(mapLayers);\n",
+       "  }\n",
+       "\n",
+       "  function getMapLayers(layers, numLayers, hasLegends, map, mapIndex) {\n",
+       "    return layers.map((layer, layerIndex) => {\n",
+       "      return initMapLayer(layer, layerIndex, numLayers, hasLegends, map, mapIndex);\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function setInteractiveLayers(map, layers, mapLayers) {\n",
+       "    const { interactiveLayers, interactiveMapLayers } = getInteractiveLayers(layers, mapLayers);\n",
+       "\n",
+       "    if (interactiveLayers && interactiveLayers.length > 0) {\n",
+       "      setInteractivity(map, interactiveLayers, interactiveMapLayers);\n",
+       "    }\n",
+       "  }\n",
+       "\n",
+       "  function addLayersSelector(layers, mapLayers, mapIndex) {\n",
+       "    const layerSelectorId = mapIndex !== undefined ? `#layer-selector-${mapIndex}` : '#layer-selector';\n",
+       "    const layerSelector$ = document.querySelector(layerSelectorId);\n",
+       "    const layersInfo = mapLayers.map((layer, index) => {\n",
+       "      return {\n",
+       "        title: layers[index].title || `Layer ${index}`,\n",
+       "        id: layer.id,\n",
+       "        checked: true\n",
+       "      };\n",
+       "    });\n",
+       "\n",
+       "    const layerSelector = new AsBridge.VL.Layers(layerSelector$, carto, layersInfo, mapLayers);\n",
+       "    \n",
+       "    layerSelector.build();\n",
+       "  }\n",
+       "\n",
+       "  function createMap(container, basemapStyle, bounds, accessToken) {\n",
+       "    const map = createMapboxGLMap(container, basemapStyle, accessToken);\n",
+       "\n",
+       "    map.addControl(attributionControl);\n",
+       "    map.fitBounds(bounds, FIT_BOUNDS_SETTINGS);\n",
+       "\n",
+       "    return map;\n",
+       "  }\n",
+       "\n",
+       "  function createMapboxGLMap(container, style, accessToken) {\n",
+       "    if (accessToken) {\n",
+       "      mapboxgl.accessToken = accessToken;\n",
+       "    }\n",
+       "\n",
+       "    return new mapboxgl.Map({\n",
+       "      container,\n",
+       "      style,\n",
+       "      zoom: 9,\n",
+       "      dragRotate: false,\n",
+       "      attributionControl: false\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function init(settings) {\n",
+       "    setReady(settings);\n",
+       "  }\n",
+       "\n",
+       "  return init;\n",
+       "\n",
+       "}());\n",
+       "</script>\n",
+       "<script>\n",
+       "  const maps = [{&quot;_airship_path&quot;: null, &quot;_carto_vl_path&quot;: null, &quot;basecolor&quot;: &quot;&quot;, &quot;basemap&quot;: &quot;Positron&quot;, &quot;bounds&quot;: [[-159.63811577, 17.89670672], [-66.07909045, 62.33132677]], &quot;camera&quot;: null, &quot;description&quot;: null, &quot;has_legends&quot;: true, &quot;has_widgets&quot;: false, &quot;is_static&quot;: false, &quot;layer_selector&quot;: false, &quot;layers&quot;: [{&quot;credentials&quot;: {&quot;api_key&quot;: &quot;default_public&quot;, &quot;base_url&quot;: &quot;https://cartoframes.carto.com&quot;, &quot;username&quot;: &quot;cartoframes&quot;}, &quot;data&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm != 3&quot;, &quot;encode_data&quot;: true, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [{&quot;attrs&quot;: {&quot;name&quot;: &quot;v65d770&quot;, &quot;title&quot;: &quot;area&quot;}, &quot;event&quot;: &quot;hover&quot;}], &quot;legends&quot;: [{&quot;ascending&quot;: true, &quot;description&quot;: &quot;&quot;, &quot;dynamic&quot;: true, &quot;footer&quot;: &quot;&quot;, &quot;prop&quot;: &quot;color&quot;, &quot;title&quot;: &quot;area&quot;, &quot;type&quot;: &quot;color-category&quot;, &quot;variable&quot;: null}], &quot;map_index&quot;: 0, &quot;options&quot;: {}, &quot;source&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm != 3&quot;, &quot;title&quot;: null, &quot;type&quot;: &quot;Query&quot;, &quot;viz&quot;: &quot;@v65d770: $area\\ncolor: opacity(ramp(top($area, 11), [#e15383, #fa8a76, #ffc285]), 0.9)\\nstrokeWidth: ramp(linear(zoom(),2,18),[0.5,1])\\nstrokeColor: opacity(#2c2c2c,ramp(linear(zoom(),2,18),[0.2,0.6]))\\nfilter: 1\\n&quot;, &quot;widgets&quot;: []}], &quot;show_info&quot;: null, &quot;size&quot;: null, &quot;theme&quot;: null, &quot;title&quot;: &quot;Drought Areas&quot;, &quot;token&quot;: &quot;&quot;, &quot;viewport&quot;: null}, {&quot;_airship_path&quot;: null, &quot;_carto_vl_path&quot;: null, &quot;basecolor&quot;: &quot;&quot;, &quot;basemap&quot;: &quot;Positron&quot;, &quot;bounds&quot;: [[-157.30625915, 18.92503978], [-77.70684579, 57.16890916]], &quot;camera&quot;: null, &quot;description&quot;: null, &quot;has_legends&quot;: false, &quot;has_widgets&quot;: false, &quot;is_static&quot;: false, &quot;layer_selector&quot;: false, &quot;layers&quot;: [{&quot;credentials&quot;: {&quot;api_key&quot;: &quot;default_public&quot;, &quot;base_url&quot;: &quot;https://cartoframes.carto.com&quot;, &quot;username&quot;: &quot;cartoframes&quot;}, &quot;data&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm = 2&quot;, &quot;encode_data&quot;: true, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [], &quot;legends&quot;: [], &quot;map_index&quot;: 0, &quot;options&quot;: {}, &quot;source&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm = 2&quot;, &quot;title&quot;: null, &quot;type&quot;: &quot;Query&quot;, &quot;viz&quot;: &quot;color: opacity(#e15383, 0.9)\\nstrokeWidth: ramp(linear(zoom(),2,18),[0.5,1])\\nstrokeColor: opacity(#2c2c2c,ramp(linear(zoom(),2,18),[0.2,0.6]))\\n&quot;, &quot;widgets&quot;: []}], &quot;show_info&quot;: null, &quot;size&quot;: null, &quot;theme&quot;: null, &quot;title&quot;: &quot;Area 3&quot;, &quot;token&quot;: &quot;&quot;, &quot;viewport&quot;: null}, {&quot;_airship_path&quot;: null, &quot;_carto_vl_path&quot;: null, &quot;basecolor&quot;: &quot;&quot;, &quot;basemap&quot;: &quot;Positron&quot;, &quot;bounds&quot;: [[-158.20678423, 17.92720413], [-66.17713216, 62.13021477]], &quot;camera&quot;: null, &quot;description&quot;: null, &quot;has_legends&quot;: false, &quot;has_widgets&quot;: false, &quot;is_static&quot;: false, &quot;layer_selector&quot;: false, &quot;layers&quot;: [{&quot;credentials&quot;: {&quot;api_key&quot;: &quot;default_public&quot;, &quot;base_url&quot;: &quot;https://cartoframes.carto.com&quot;, &quot;username&quot;: &quot;cartoframes&quot;}, &quot;data&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm = 1&quot;, &quot;encode_data&quot;: true, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [], &quot;legends&quot;: [], &quot;map_index&quot;: 0, &quot;options&quot;: {}, &quot;source&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm = 1&quot;, &quot;title&quot;: null, &quot;type&quot;: &quot;Query&quot;, &quot;viz&quot;: &quot;color: opacity(#fa8a76, 0.9)\\nstrokeWidth: ramp(linear(zoom(),2,18),[0.5,1])\\nstrokeColor: opacity(#2c2c2c,ramp(linear(zoom(),2,18),[0.2,0.6]))\\n&quot;, &quot;widgets&quot;: []}], &quot;show_info&quot;: null, &quot;size&quot;: null, &quot;theme&quot;: null, &quot;title&quot;: &quot;Area 2&quot;, &quot;token&quot;: &quot;&quot;, &quot;viewport&quot;: null}, {&quot;_airship_path&quot;: null, &quot;_carto_vl_path&quot;: null, &quot;basecolor&quot;: &quot;&quot;, &quot;basemap&quot;: &quot;Positron&quot;, &quot;bounds&quot;: [[-159.63811577, 17.89670672], [-66.07909045, 62.33132677]], &quot;camera&quot;: null, &quot;description&quot;: null, &quot;has_legends&quot;: false, &quot;has_widgets&quot;: false, &quot;is_static&quot;: false, &quot;layer_selector&quot;: false, &quot;layers&quot;: [{&quot;credentials&quot;: {&quot;api_key&quot;: &quot;default_public&quot;, &quot;base_url&quot;: &quot;https://cartoframes.carto.com&quot;, &quot;username&quot;: &quot;cartoframes&quot;}, &quot;data&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm = 0&quot;, &quot;encode_data&quot;: true, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [], &quot;legends&quot;: [], &quot;map_index&quot;: 0, &quot;options&quot;: {}, &quot;source&quot;: &quot;SELECT * FROM drought_wk_1_areas WHERE dm = 0&quot;, &quot;title&quot;: null, &quot;type&quot;: &quot;Query&quot;, &quot;viz&quot;: &quot;color: opacity(#ffc285, 0.9)\\nstrokeWidth: ramp(linear(zoom(),2,18),[0.5,1])\\nstrokeColor: opacity(#2c2c2c,ramp(linear(zoom(),2,18),[0.2,0.6]))\\n&quot;, &quot;widgets&quot;: []}], &quot;show_info&quot;: null, &quot;size&quot;: null, &quot;theme&quot;: null, &quot;title&quot;: &quot;Area 1&quot;, &quot;token&quot;: &quot;&quot;, &quot;viewport&quot;: null}];\n",
+       "const show_info = 'None' === 'true';\n",
+       "const is_static = 'False' === 'true';\n",
+       "\n",
+       "init({\n",
+       "  show_info,\n",
+       "  is_static,\n",
+       "  maps\n",
+       "});\n",
+       "</script>\n",
+       "</html>\n",
+       "\">\n",
+       "\n",
+       "</iframe>"
+      ],
+      "text/plain": [
+       "<cartoframes.viz.layout.Layout at 0x1195d0210>"
+      ]
+     },
+     "execution_count": 28,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "area_0_color = '#ffc285'\n",
+    "area_1_color = '#fa8a76'\n",
+    "area_2_color = '#e15383'\n",
+    "\n",
+    "palette= '[{2}, {1}, {0}]'.format(area_0_color, area_1_color, area_2_color)\n",
+    "\n",
+    "viz_map = Layout([\n",
+    "    Map(Layer('SELECT * FROM drought_wk_1_areas WHERE dm != 3', color_category_style('area', palette=palette)), title='Drought Areas'),\n",
+    "    Map(Layer('SELECT * FROM drought_wk_1_areas WHERE dm = 2', basic_style(color=area_2_color)), title='Area 3'),\n",
+    "    Map(Layer('SELECT * FROM drought_wk_1_areas WHERE dm = 1', basic_style(color=area_1_color)), title='Area 2'),\n",
+    "    Map(Layer('SELECT * FROM drought_wk_1_areas WHERE dm = 0', basic_style(color=area_0_color)), title='Area 1')\n",
+    "],\n",
+    "map_height=500,\n",
+    "n_size=2,\n",
+    "m_size=2,\n",
+    "is_static=False)\n",
+    "\n",
+    "viz_map"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 29,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'id': 'e84ac83f-f55e-4474-b790-6c9912ed6ba9',\n",
+       " 'url': 'https://team.carto.com/u/elena-carto/kuviz/e84ac83f-f55e-4474-b790-6c9912ed6ba9',\n",
+       " 'name': 'drought_wk_1_areas_layout',\n",
+       " 'privacy': 'public'}"
+      ]
+     },
+     "execution_count": 29,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "viz_map.publish(\n",
+    "    'drought_wk_1_areas_layout',\n",
+    "    password=None\n",
+    ")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.7.4"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}


Test patch:
diff --git a/tests/unit/viz/test_map.py b/tests/unit/viz/test_map.py
index 91a51071..74cd8c34 100644
--- a/tests/unit/viz/test_map.py
+++ b/tests/unit/viz/test_map.py
@@ -252,6 +252,7 @@ class TestMapPublication(object):
             bounds={'west': 1, 'east': 2, 'north': 3, 'south': 4},
             viewport={'zoom': 5, 'lat': 50, 'lng': -10},
             is_static=True,
+            layer_selector=False,
             theme='dark',
             title='title',
             description='description'
diff --git a/tests/unit/viz/test_widget.py b/tests/unit/viz/test_widget.py
index 66ac46c3..d55b6844 100644
--- a/tests/unit/viz/test_widget.py
+++ b/tests/unit/viz/test_widget.py
@@ -32,7 +32,8 @@ class TestWidget(object):
             'variable_name': 'v9cb6ff',
             'options': {
                 'readOnly': False,
-                'buckets': 20
+                'buckets': 20,
+                'weight': 1
             }
         }
 
@@ -60,7 +61,8 @@ class TestWidget(object):
             'variable_name': '',
             'options': {
                 'readOnly': False,
-                'buckets': 20
+                'buckets': 20,
+                'weight': 1
             }
         }
 
@@ -79,6 +81,7 @@ class TestWidget(object):
             'variable_name': '',
             'options': {
                 'readOnly': False,
-                'buckets': 20
+                'buckets': 20,
+                'weight': 1
             }
         }
diff --git a/tests/unit/viz/test_widget_list.py b/tests/unit/viz/test_widget_list.py
index f3422c53..165b107c 100644
--- a/tests/unit/viz/test_widget_list.py
+++ b/tests/unit/viz/test_widget_list.py
@@ -77,7 +77,8 @@ class TestWidgetList(object):
                 'variable_name': 'vdb8cc1',
                 'options': {
                     'readOnly': False,
-                    'buckets': 20
+                    'buckets': 20,
+                    'weight': 1
                 }
             }, {
                 'type': 'basic',
@@ -90,6 +91,7 @@ class TestWidgetList(object):
                 'variable_name': '',
                 'options': {
                     'readOnly': False,
-                    'buckets': 20
+                    'buckets': 20,
+                    'weight': 1
                 }
             }]