Repository: CartoDB/cartoframes
Pull request #1445 (2020-01-07 14:01:10)

Fix date format when using Dataframes
Related issue https://github.com/CartoDB/cartoframes/issues/1358

This PR adds the code needed to detect which column fields are dates when using local data in order to visualize dates correctly.

- [x] Convert datetime field to strings (VL)
- [x] Add datetime colum names as layer options (VL)
- [x] Add spec
- [x] Add example

![Screenshot 2020-01-07 at 14 57 33](https://user-images.githubusercontent.com/3824953/71900541-4dca1e80-315e-11ea-897b-2ef177e53464.png)



Patch:
diff --git a/cartoframes/assets/src/bundle.js b/cartoframes/assets/src/bundle.js
index 631a14b5..567f4ba5 100644
--- a/cartoframes/assets/src/bundle.js
+++ b/cartoframes/assets/src/bundle.js
@@ -421,7 +421,10 @@ var init = (function () {
   }
 
   function GeoJSON(layer) {
-    return new carto.source.GeoJSON(_decodeJSONData(layer.data));
+    const options = JSON.parse(JSON.stringify(layer.options));
+    const data = _decodeJSONData(layer.data);
+
+    return new carto.source.GeoJSON(data, options);
   }
 
   function Query(layer) {
diff --git a/cartoframes/assets/src/map/SourceFactory.js b/cartoframes/assets/src/map/SourceFactory.js
index 9f20eb72..15c9ef70 100644
--- a/cartoframes/assets/src/map/SourceFactory.js
+++ b/cartoframes/assets/src/map/SourceFactory.js
@@ -7,7 +7,10 @@ export default function SourceFactory() {
 }
 
 function GeoJSON(layer) {
-  return new carto.source.GeoJSON(_decodeJSONData(layer.data));
+  const options = JSON.parse(JSON.stringify(layer.options));
+  const data = _decodeJSONData(layer.data);
+
+  return new carto.source.GeoJSON(data, options);
 }
 
 function Query(layer) {
diff --git a/cartoframes/utils/utils.py b/cartoframes/utils/utils.py
index 3c981622..07925d31 100644
--- a/cartoframes/utils/utils.py
+++ b/cartoframes/utils/utils.py
@@ -9,6 +9,7 @@ import decimal
 import hashlib
 import requests
 import geopandas
+
 import numpy as np
 import pkg_resources
 import semantic_version
@@ -16,6 +17,7 @@ import semantic_version
 from functools import wraps
 from warnings import catch_warnings, filterwarnings
 from pyrestcli.exceptions import ServerErrorException
+from pandas.api.types import is_datetime64_any_dtype as is_datetime
 
 from ..core.logger import log
 
@@ -427,6 +429,15 @@ def check_do_enabled(method):
     return fn
 
 
+def get_datetime_column_names(df):
+    column_names = []
+    for column in df.columns:
+        if is_datetime(df[column]):
+            column_names.append(column)
+
+    return column_names
+
+
 def is_ipython_notebook():
     """
     Detect whether we are in a Jupyter notebook.
diff --git a/cartoframes/viz/layer.py b/cartoframes/viz/layer.py
index 7a46d887..6834d271 100644
--- a/cartoframes/viz/layer.py
+++ b/cartoframes/viz/layer.py
@@ -110,6 +110,7 @@ class Layer:
         self.interactivity = self.popups.get_interactivity()
         self.widgets_info = self.widgets.get_widgets_info()
         self.legends_info = self.legends.get_info() if self.legends is not None else None
+        self.options = self._set_options()
         self.has_legend_list = isinstance(self.legends, LegendList)
 
     def _init_legends(self, legends):
@@ -138,6 +139,14 @@ class Layer:
             popups['hover'] = hover_popup
         return _set_popups(popups)
 
+    def _set_options(self):
+        date_column_names = self.source.get_datetime_column_names()
+
+        if isinstance(date_column_names, list):
+            return {'dateColumns': date_column_names}
+
+        return {}
+
     def _repr_html_(self):
         from .map import Map
         return Map(self)._repr_html_()
diff --git a/cartoframes/viz/map.py b/cartoframes/viz/map.py
index 2b54a69e..29b13a5b 100644
--- a/cartoframes/viz/map.py
+++ b/cartoframes/viz/map.py
@@ -381,6 +381,7 @@ def _get_layer_def(layer):
         'widgets': layer.widgets_info,
         'data': layer.source_data,
         'type': layer.source_type,
+        'options': layer.options,
         'viz': layer.viz
     }
 
diff --git a/cartoframes/viz/source.py b/cartoframes/viz/source.py
index 8a430689..508683a2 100644
--- a/cartoframes/viz/source.py
+++ b/cartoframes/viz/source.py
@@ -2,7 +2,11 @@ import pandas
 
 from ..core.cartodataframe import CartoDataFrame
 from ..core.managers.context_manager import ContextManager
-from ..utils.utils import encode_geodataframe, get_geodataframe_bounds, get_geodataframe_geom_type
+from ..utils.utils import (encode_geodataframe, get_geodataframe_bounds, get_geodataframe_geom_type,
+                           get_datetime_column_names)
+
+
+RFC_2822_DATETIME_FORMAT = "%a, %d %b %Y %T %z"
 
 
 class SourceType:
@@ -77,6 +81,7 @@ class Source(object):
 
     def __init__(self, source, credentials=None, geom_col=None):
         self.credentials = None
+        self.datetime_column_names = None
 
         if isinstance(source, str):
             # Table, SQL query
@@ -88,6 +93,7 @@ class Source(object):
             # DataFrame, GeoDataFrame, CartoDataFrame
             self.type = SourceType.GEOJSON
             self.cdf = CartoDataFrame(source, copy=True)
+            self.set_datetime_columns()
 
             if geom_col:
                 self.cdf.set_geometry(geom_col, inplace=True)
@@ -108,6 +114,17 @@ class Source(object):
                 'base_url': self.credentials.base_url
             }
 
+    def set_datetime_columns(self):
+        if self.type == SourceType.GEOJSON:
+            self.datetime_column_names = get_datetime_column_names(self.cdf)
+
+            if self.datetime_column_names:
+                for column in self.datetime_column_names:
+                    self.cdf[column] = self.cdf[column].dt.strftime(RFC_2822_DATETIME_FORMAT)
+
+    def get_datetime_column_names(self):
+        return self.datetime_column_names
+
     def get_geom_type(self):
         if self.type == SourceType.QUERY:
             return self.manager.get_geom_type(self.query) or 'point'
diff --git a/docs/developer-center/examples/examples.json b/docs/developer-center/examples/examples.json
index c3b67298..c93a6998 100644
--- a/docs/developer-center/examples/examples.json
+++ b/docs/developer-center/examples/examples.json
@@ -39,6 +39,11 @@
             "desc": "Change table privacy",
             "file": "change_dataset_privacy",
             "path": "data_workflows"
+        }, {
+            "title": "Animate a DataFrame by date",
+            "desc": "Animate a DataFrame by date",
+            "file": "date_columns_in_dataframes",
+            "path": "data_workflows"
         }]
     }, {
         "title": "Data Enrichment",
diff --git a/examples/data_workflows/date_columns_in_dataframes.ipynb b/examples/data_workflows/date_columns_in_dataframes.ipynb
new file mode 100644
index 00000000..3dfe5f3b
--- /dev/null
+++ b/examples/data_workflows/date_columns_in_dataframes.ipynb
@@ -0,0 +1,1030 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Animate a visualization using date columns\n",
+    "\n",
+    "When animating data from a Dataframe, the columns that need to be treated as **Dates** should be `datetime` format."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "import geopandas"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### DataFrame"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "data = {\n",
+    "    'date_column': ['2019-11-10', '2019-11-11', '2019-11-12', '2019-11-13', '2019-11-14', '2019-11-15'],\n",
+    "    'lon': [50, 40, 30, 50, 30, 40],\n",
+    "    'lat': [0, 1, 2, 3, 2, 1]\n",
+    "}\n",
+    "\n",
+    "df = pd.DataFrame(data)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Convert dates from string to datetime"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df['date_column']= pd.to_datetime(df['date_column'])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Visualize the data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<iframe\n",
+       "  frameborder=\"0\"\n",
+       "  style=\"\n",
+       "    border: 1px solid #cfcfcf;\n",
+       "    width: 100%;\n",
+       "    height: 632px;\n",
+       "    \"\n",
+       "  srcDoc=\"\n",
+       "  <!DOCTYPE html>\n",
+       "<html lang=&quot;en&quot;>\n",
+       "<head>\n",
+       "  <title>None</title>\n",
+       "  <meta name=&quot;description&quot; content=&quot;None&quot;>\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.2.0-rc.2.1/airship.js&quot;></script>\n",
+       "  <script type=&quot;module&quot; src=&quot;https://libs.cartocdn.com/airship-components/v2.2.0-rc.2.1/airship/airship.esm.js&quot;></script>\n",
+       "  <script src=&quot;https://libs.cartocdn.com/airship-bridge/v2.2.0-rc.2.1/asbridge.min.js&quot;></script>\n",
+       "  <link href=&quot;https://libs.cartocdn.com/airship-style/v2.2.0-rc.2.1/airship.min.css&quot; rel=&quot;stylesheet&quot;>\n",
+       "  <link href=&quot;https://libs.cartocdn.com/airship-icons/v2.2.0-rc.2.1/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",
+       "  <!-- html2canvas -->\n",
+       "  \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",
+       "</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",
+       "    #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",
+       "  <style>\n",
+       "  as-widget-header .as-widget-header__header {\n",
+       "    margin-bottom: 8px;\n",
+       "    overflow-wrap: break-word;\n",
+       "  }\n",
+       "\n",
+       "  as-widget-header .as-widget-header__subheader {\n",
+       "    margin-bottom: 12px;\n",
+       "  }\n",
+       "\n",
+       "  as-category-widget {\n",
+       "    max-height: 250px;\n",
+       "  }\n",
+       "</style>\n",
+       "</head>\n",
+       "\n",
+       "<body class=&quot;as-app-body as-app&quot;>\n",
+       "  <img id=&quot;map-image&quot; class=&quot;map-image&quot; alt='Static map image' />\n",
+       "  <as-responsive-content id=&quot;main-container&quot;>\n",
+       "    \n",
+       "      \n",
+       "\n",
+       "<aside class=&quot;as-sidebar as-sidebar--right&quot; id=&quot;widgets&quot; data-name=&quot;Widgets&quot;>\n",
+       "  \n",
+       "    \n",
+       "      \n",
+       "        \n",
+       "          <div class=&quot;as-box&quot;>\n",
+       "            <section class=&quot;as-body&quot;>\n",
+       "    \n",
+       "      \n",
+       "<as-animation-controls-widget\n",
+       "  animated\n",
+       "  id='layer0_widget0'\n",
+       "  heading='November'\n",
+       "  description=''\n",
+       "></as-animation-controls-widget>\n",
+       "    \n",
+       "  </section>\n",
+       "          </div>\n",
+       "        \n",
+       "    \n",
+       "  \n",
+       "</aside>\n",
+       "    \n",
+       "    <main class=&quot;as-main&quot;>\n",
+       "      <div class=&quot;as-map-area&quot;>\n",
+       "        <div id=&quot;map&quot; class=&quot;map&quot;></div>\n",
+       "        \n",
+       "        \n",
+       "      </div> <!-- as-map-area -->\n",
+       "    </main> <!-- as-main -->\n",
+       "  </as-responsive-content>\n",
+       "\n",
+       "  \n",
+       "\n",
+       "  <div id=&quot;error-container&quot; class=&quot;error&quot;>\n",
+       "  <p>There is a <span class=&quot;errors&quot; id=&quot;error-highlight&quot;></span>\n",
+       "  from the <a href=&quot;https://carto.com/developers/carto-vl/&quot; target=&quot;_blank&quot;>CARTO VL</a> library:</p>\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",
+       "\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.name;\n",
+       "    errors$[2].innerHTML = e.type;\n",
+       "    errors$[3].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, popup, interactivity, attrs) {\n",
+       "    interactivity.on('featureClick', (event) => {\n",
+       "      updatePopup(map, popup, event, attrs);\n",
+       "    });\n",
+       "  }\n",
+       "\n",
+       "  function setPopupsHover(map, popup, interactivity, attrs) {\n",
+       "    interactivity.on('featureHover', (event) => {\n",
+       "      updatePopup(map, popup, 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",
+       "    const popup = 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, popup, interactivity, clickAttrs);\n",
+       "    }\n",
+       "\n",
+       "    if (hoverAttrs.length > 0) {\n",
+       "      setPopupsHover(map, popup, 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 variable = legend.variable;\n",
+       "      const config = { othersLabel, variable };\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);\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(b64Data) {\n",
+       "    return JSON.parse(pako.inflate(atob(b64Data), { to: 'string' }));\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",
+       "\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",
+       "    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 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",
+       "  document\n",
+       "  .querySelector('as-responsive-content')\n",
+       "  .addEventListener('ready', () => {\n",
+       "    const basecolor = '';\n",
+       "    const basemap = 'Positron';\n",
+       "    const bounds = [[30.0, 0.0], [50.0, 3.0]];\n",
+       "    const camera = null;\n",
+       "    const has_legends = 'False' === 'true';\n",
+       "    const is_static = 'None' === 'true';\n",
+       "    const layers = [{&quot;credentials&quot;: null, &quot;data&quot;: &quot;H4sIAECOFF4C/7XSzQrCMAwA4FcpORdp93NwV8GbIih4EBHZohZmM2oryNi7m6qXHYsK7SUk6UfSHvyjQ6hgjkcfHM6obbH2hixIOL1jN6h2PZiGsxRHxwUc6Bx16LyJiT00R4+HmtpwtZy1DlYKrcSS7iJTeiqUql5HwCDhjHRF7x6x7tN2RcZ6bloTucZYbhafL9VESb77YZAfik6lLChS9LeUIlL0iJKlUjYBmZJ9S8kjJRtR8lTKFhum5D9ZUD6iFMlTuQSmFH+YSplKmTvDlPKHf2U/PAFybOLgawMAAA==&quot;, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [{&quot;attrs&quot;: {&quot;name&quot;: &quot;vaf15fd&quot;, &quot;title&quot;: &quot;date_column&quot;}, &quot;event&quot;: &quot;hover&quot;}], &quot;legends&quot;: [], &quot;options&quot;: {&quot;dateColumns&quot;: [&quot;date_column&quot;]}, &quot;type&quot;: &quot;GeoJSON&quot;, &quot;viz&quot;: &quot;@vaf15fd: $date_column\\ncolor: opacity(blue, 1)\\nwidth: 20\\nstrokeWidth: ramp(linear(zoom(),0,18),[0,1])\\nstrokeColor: opacity(#222,ramp(linear(zoom(),0,18),[0,0.6]))\\nfilter: animation(linear($date_column), 20, fade(1, 1))\\n&quot;, &quot;widgets&quot;: [{&quot;description&quot;: &quot;&quot;, &quot;footer&quot;: &quot;&quot;, &quot;has_bridge&quot;: true, &quot;options&quot;: {&quot;buckets&quot;: 20, &quot;readOnly&quot;: false}, &quot;prop&quot;: &quot;filter&quot;, &quot;title&quot;: &quot;November&quot;, &quot;type&quot;: &quot;animation&quot;, &quot;value&quot;: &quot;&quot;, &quot;variable_name&quot;: &quot;&quot;}]}];\n",
+       "    const mapboxtoken = '';\n",
+       "    const show_info = 'None' === 'true';\n",
+       "\n",
+       "    init({\n",
+       "      basecolor,\n",
+       "      basemap,\n",
+       "      bounds,\n",
+       "      camera,\n",
+       "      has_legends,\n",
+       "      is_static,\n",
+       "      layers,\n",
+       "      mapboxtoken,\n",
+       "      show_info\n",
+       "    });\n",
+       "});\n",
+       "</script>\n",
+       "</html>\n",
+       "\">\n",
+       "\n",
+       "</iframe>"
+      ],
+      "text/plain": [
+       "<cartoframes.viz.map.Map at 0x119896150>"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "from cartoframes.viz import Map, Layer, animation_widget, animation_style\n",
+    "\n",
+    "gdf = geopandas.GeoDataFrame(df, geometry=geopandas.points_from_xy(df.lon, df.lat))\n",
+    "\n",
+    "Map(\n",
+    "    Layer(\n",
+    "        gdf,\n",
+    "        style=animation_style('date_column', color='blue', size=20, duration=20),\n",
+    "        widgets=animation_widget(title='November')\n",
+    "    )\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
+}
diff --git a/examples/widgets/animation_widget.ipynb b/examples/widgets/animation_widget.ipynb
index e2f96312..3709afdb 100644
--- a/examples/widgets/animation_widget.ipynb
+++ b/examples/widgets/animation_widget.ipynb
@@ -15,19 +15,19 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 1,
+   "execution_count": 4,
    "metadata": {},
    "outputs": [],
    "source": [
     "from cartoframes.auth import set_default_credentials\n",
-    "from cartoframes.viz import Layer, animation_widget\n",
+    "from cartoframes.viz import Layer, animation_widget, animation_style\n",
     "\n",
     "set_default_credentials('cartoframes')"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 2,
+   "execution_count": 5,
    "metadata": {},
    "outputs": [
     {
@@ -696,7 +696,10 @@
        "  }\n",
        "\n",
        "  function GeoJSON(layer) {\n",
-       "    return new carto.source.GeoJSON(_decodeJSONData(layer.data));\n",
+       "    const options = JSON.parse(JSON.stringify(layer.options));\n",
+       "    const data = _decodeJSONData(layer.data);\n",
+       "\n",
+       "    return new carto.source.GeoJSON(data, options);\n",
        "  }\n",
        "\n",
        "  function Query(layer) {\n",
@@ -918,7 +921,7 @@
        "    const camera = null;\n",
        "    const has_legends = 'False' === 'true';\n",
        "    const is_static = 'None' === 'true';\n",
-       "    const layers = [{&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 \\&quot;cartoframes\\&quot;.\\&quot;seattle_collisions\\&quot;&quot;, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [], &quot;legends&quot;: [], &quot;type&quot;: &quot;Query&quot;, &quot;viz&quot;: &quot;color: hex(\\&quot;#EE4D5A\\&quot;)\\nwidth: ramp(linear(zoom(),0,18),[2,10])\\nstrokeWidth: ramp(linear(zoom(),0,18),[0,1])\\nstrokeColor: opacity(#222,ramp(linear(zoom(),0,18),[0,0.6]))\\nfilter: animation($incdate, 20, fade(0.5,0.5))&quot;, &quot;widgets&quot;: [{&quot;description&quot;: &quot;Play, pause, or select the range of the animation&quot;, &quot;footer&quot;: &quot;&quot;, &quot;has_bridge&quot;: true, &quot;options&quot;: {&quot;buckets&quot;: 20, &quot;readOnly&quot;: false}, &quot;prop&quot;: &quot;filter&quot;, &quot;title&quot;: &quot;Collision Date&quot;, &quot;type&quot;: &quot;animation&quot;, &quot;value&quot;: &quot;&quot;, &quot;variable_name&quot;: &quot;&quot;}]}];\n",
+       "    const layers = [{&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 \\&quot;cartoframes\\&quot;.\\&quot;seattle_collisions\\&quot;&quot;, &quot;has_legend_list&quot;: true, &quot;interactivity&quot;: [{&quot;attrs&quot;: {&quot;name&quot;: &quot;v4fc0cb&quot;, &quot;title&quot;: &quot;incdate&quot;}, &quot;event&quot;: &quot;hover&quot;}], &quot;legends&quot;: [], &quot;options&quot;: {}, &quot;type&quot;: &quot;Query&quot;, &quot;viz&quot;: &quot;@v4fc0cb: $incdate\\ncolor: opacity(blue, 1)\\nwidth: ramp(linear(zoom(),0,18),[2,10])\\nstrokeWidth: ramp(linear(zoom(),0,18),[0,1])\\nstrokeColor: opacity(#222,ramp(linear(zoom(),0,18),[0,0.6]))\\nfilter: animation(linear($incdate), 20, fade(1, 1))\\n&quot;, &quot;widgets&quot;: [{&quot;description&quot;: &quot;Play, pause, or select the range of the animation&quot;, &quot;footer&quot;: &quot;&quot;, &quot;has_bridge&quot;: true, &quot;options&quot;: {&quot;buckets&quot;: 20, &quot;readOnly&quot;: false}, &quot;prop&quot;: &quot;filter&quot;, &quot;title&quot;: &quot;Collision Date&quot;, &quot;type&quot;: &quot;animation&quot;, &quot;value&quot;: &quot;&quot;, &quot;variable_name&quot;: &quot;&quot;}]}];\n",
        "    const mapboxtoken = '';\n",
        "    const show_info = 'None' === 'true';\n",
        "\n",
@@ -941,10 +944,10 @@
        "</iframe>"
       ],
       "text/plain": [
-       "<cartoframes.viz.layer.Layer at 0x7fbba9594f28>"
+       "<cartoframes.viz.layer.Layer at 0x117e089d0>"
       ]
      },
-     "execution_count": 2,
+     "execution_count": 5,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -952,7 +955,7 @@
    "source": [
     "Layer(\n",
     "    'seattle_collisions',\n",
-    "    'filter: animation($incdate, 20, fade(0.5,0.5))',\n",
+    "    style=animation_style('incdate', color='blue', duration=20),\n",
     "    widgets=[\n",
     "        animation_widget(\n",
     "            title='Collision Date',\n",
@@ -978,7 +981,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.6.7"
+   "version": "3.7.4"
   }
  },
  "nbformat": 4,
diff --git a/examples/widgets/animation_widget_style_property.ipynb b/examples/widgets/animation_widget_style_property.ipynb
index 68efa2d9..2e895461 100644
--- a/examples/widgets/animation_widget_style_property.ipynb
+++ b/examples/widgets/animation_widget_style_property.ipynb
@@ -981,7 +981,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.6.7"
+   "version": "3.7.4"
   }
  },
  "nbformat": 4,


Test patch:
diff --git a/tests/unit/viz/test_source.py b/tests/unit/viz/test_source.py
index b4b65bf6..485a3a89 100644
--- a/tests/unit/viz/test_source.py
+++ b/tests/unit/viz/test_source.py
@@ -3,6 +3,9 @@ import pytest
 from cartoframes.auth import Credentials
 from cartoframes.viz.source import Source
 from cartoframes.core.managers.context_manager import ContextManager
+import pandas as pd
+import geopandas as gpd
+import numpy as np
 
 
 def setup_mocks(mocker):
@@ -46,3 +49,19 @@ class TestSource(object):
         assert str(e.value) == ('Credentials attribute is required. '
                                 'Please pass a `Credentials` instance or use '
                                 'the `set_default_credentials` function.')
+
+    def test_dates_in_source(self):
+        df = pd.DataFrame({
+            'date_column': ['2019-11-10', '2019-11-11'],
+            'lat': [1, 2],
+            'lon': [1, 2]
+        })
+        df['date_column'] = pd.to_datetime(df['date_column'])
+        gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.lon, df.lat))
+
+        assert df.dtypes['date_column'] == np.dtype('datetime64[ns]')
+
+        source = Source(gdf)
+
+        assert source.datetime_column_names == ['date_column']
+        assert source.cdf.dtypes['date_column'] == np.object