Produce a CARTO map visualizing data layers.

        Examples:
            Create a map with two data :py:class:`Layer
            <cartoframes.layer.Layer>`\s, and one :py:class:`BaseMap
            <cartoframes.layer.BaseMap>` layer::

                import cartoframes
                from cartoframes import Layer, BaseMap, styling
                cc = cartoframes.CartoContext(BASEURL, APIKEY)
                cc.map(layers=[BaseMap(),
                               Layer('acadia_biodiversity',
                                     color={'column': 'simpson_index',
                                            'scheme': styling.tealRose(7)}),
                               Layer('peregrine_falcon_nest_sites',
                                     size='num_eggs',
                                     color={'column': 'bird_id',
                                            'scheme': styling.vivid(10))],
                       interactive=True)

            Create a snapshot of a map at a specific zoom and center::

                cc.map(layers=Layer('acadia_biodiversity',
                                    color='simpson_index'),
                       interactive=False,
                       zoom=14,
                       lng=-68.3823549,
                       lat=44.3036906)
        Args:
            layers (list, optional): List of zero or more of the following:

                - :py:class:`Layer <cartoframes.layer.Layer>`: cartoframes
                  :py:class:`Layer <cartoframes.layer.Layer>` object for
                  visualizing data from a CARTO table. See :py:class:`Layer
                  <cartoframes.layer.Layer>` for all styling options.
                - :py:class:`BaseMap <cartoframes.layer.BaseMap>`: Basemap for
                  contextualizng data layers. See :py:class:`BaseMap
                  <cartoframes.layer.BaseMap>` for all styling options.
                - :py:class:`QueryLayer <cartoframes.layer.QueryLayer>`: Layer
                  from an arbitrary query. See :py:class:`QueryLayer
                  <cartoframes.layer.QueryLayer>` for all styling options.

            interactive (bool, optional): Defaults to ``True`` to show an
                interactive slippy map. Setting to ``False`` creates a static
                map.
            zoom (int, optional): Zoom level of map. Acceptable values are
                usually in the range 0 to 19. 0 has the entire earth on a
                single tile (256px square). Zoom 19 is the size of a city
                block. Must be used in conjunction with ``lng`` and ``lat``.
                Defaults to a view to have all data layers in view.
            lat (float, optional): Latitude value for the center of the map.
                Must be used in conjunction with ``zoom`` and ``lng``. Defaults
                to a view to have all data layers in view.
            lng (float, optional): Longitude value for the center of the map.
                Must be used in conjunction with ``zoom`` and ``lat``. Defaults
                to a view to have all data layers in view.
            size (tuple, optional): List of pixel dimensions for the map.
                Format is ``(width, height)``. Defaults to ``(800, 400)``.
            ax: matplotlib axis on which to draw the image. Only used when
                ``interactive`` is ``False``.

        Returns:
            IPython.display.HTML or matplotlib Axes: Interactive maps are
            rendered as HTML in an `iframe`, while static maps are returned as
            matplotlib Axes objects or IPython Image.

def map(self, layers=None, interactive=True,
            zoom=None, lat=None, lng=None, size=(800, 400),
            ax=None):
        """Produce a CARTO map visualizing data layers.

        Examples:
            Create a map with two data :py:class:`Layer
            <cartoframes.layer.Layer>`\s, and one :py:class:`BaseMap
            <cartoframes.layer.BaseMap>` layer::

                import cartoframes
                from cartoframes import Layer, BaseMap, styling
                cc = cartoframes.CartoContext(BASEURL, APIKEY)
                cc.map(layers=[BaseMap(),
                               Layer('acadia_biodiversity',
                                     color={'column': 'simpson_index',
                                            'scheme': styling.tealRose(7)}),
                               Layer('peregrine_falcon_nest_sites',
                                     size='num_eggs',
                                     color={'column': 'bird_id',
                                            'scheme': styling.vivid(10))],
                       interactive=True)

            Create a snapshot of a map at a specific zoom and center::

                cc.map(layers=Layer('acadia_biodiversity',
                                    color='simpson_index'),
                       interactive=False,
                       zoom=14,
                       lng=-68.3823549,
                       lat=44.3036906)
        Args:
            layers (list, optional): List of zero or more of the following:

                - :py:class:`Layer <cartoframes.layer.Layer>`: cartoframes
                  :py:class:`Layer <cartoframes.layer.Layer>` object for
                  visualizing data from a CARTO table. See :py:class:`Layer
                  <cartoframes.layer.Layer>` for all styling options.
                - :py:class:`BaseMap <cartoframes.layer.BaseMap>`: Basemap for
                  contextualizng data layers. See :py:class:`BaseMap
                  <cartoframes.layer.BaseMap>` for all styling options.
                - :py:class:`QueryLayer <cartoframes.layer.QueryLayer>`: Layer
                  from an arbitrary query. See :py:class:`QueryLayer
                  <cartoframes.layer.QueryLayer>` for all styling options.

            interactive (bool, optional): Defaults to ``True`` to show an
                interactive slippy map. Setting to ``False`` creates a static
                map.
            zoom (int, optional): Zoom level of map. Acceptable values are
                usually in the range 0 to 19. 0 has the entire earth on a
                single tile (256px square). Zoom 19 is the size of a city
                block. Must be used in conjunction with ``lng`` and ``lat``.
                Defaults to a view to have all data layers in view.
            lat (float, optional): Latitude value for the center of the map.
                Must be used in conjunction with ``zoom`` and ``lng``. Defaults
                to a view to have all data layers in view.
            lng (float, optional): Longitude value for the center of the map.
                Must be used in conjunction with ``zoom`` and ``lat``. Defaults
                to a view to have all data layers in view.
            size (tuple, optional): List of pixel dimensions for the map.
                Format is ``(width, height)``. Defaults to ``(800, 400)``.
            ax: matplotlib axis on which to draw the image. Only used when
                ``interactive`` is ``False``.

        Returns:
            IPython.display.HTML or matplotlib Axes: Interactive maps are
            rendered as HTML in an `iframe`, while static maps are returned as
            matplotlib Axes objects or IPython Image.
        """
        # TODO: add layers preprocessing method like
        #       layers = process_layers(layers)
        #       that uses up to layer limit value error
        if layers is None:
            layers = []
        elif not isinstance(layers, collections.Iterable):
            layers = [layers]
        else:
            layers = list(layers)

        if len(layers) > 8:
            raise ValueError('Map can have at most 8 layers')

        nullity = [zoom is None, lat is None, lng is None]
        if any(nullity) and not all(nullity):
            raise ValueError('Zoom, lat, and lng must all or none be provided')

        # When no layers are passed, set default zoom
        if ((len(layers) == 0 and zoom is None) or
                (len(layers) == 1 and layers[0].is_basemap)):
            [zoom, lat, lng] = [1, 0, 0]
        has_zoom = zoom is not None

        # Check for a time layer, if it exists move it to the front
        time_layers = [idx for idx, layer in enumerate(layers)
                       if not layer.is_basemap and layer.time]
        time_layer = layers[time_layers[0]] if len(time_layers) > 0 else None
        if len(time_layers) > 1:
            raise ValueError('Map can at most take 1 Layer with time '
                             'column/field')
        if time_layer:
            if not interactive:
                raise ValueError('Map cannot display a static image with a '
                                 'time column')
            layers.append(layers.pop(time_layers[0]))

        base_layers = [idx for idx, layer in enumerate(layers)
                       if layer.is_basemap]
        # Check basemaps, add one if none exist
        if len(base_layers) > 1:
            raise ValueError('Map can at most take one BaseMap layer')
        elif len(base_layers) == 1:
            # move baselayer to first position
            layers.insert(0, layers.pop(base_layers[0]))

            # add labels layer if requested
            if layers[0].is_basic() and layers[0].labels == 'front':
                layers.append(BaseMap(layers[0].source,
                                      labels='front',
                                      only_labels=True))
                layers[0].labels = None
        elif not base_layers:
            # default basemap is dark with labels in back
            # labels will be changed if all geoms are non-point
            layers.insert(0, BaseMap())
            geoms = set()

        # Setup layers
        for idx, layer in enumerate(layers):
            if not layer.is_basemap:
                # get schema of style columns
                if layer.style_cols:
                    resp = self.sql_client.send(
                        utils.minify_sql((
                            'SELECT {cols}',
                            'FROM ({query}) AS _wrap',
                            'LIMIT 0',
                        )).format(cols=','.join(layer.style_cols),
                                  comma=',' if layer.style_cols else '',
                                  query=layer.orig_query),
                        **DEFAULT_SQL_ARGS)
                    self._debug_print(layer_fields=resp)
                    for stylecol, coltype in utils.dict_items(resp['fields']):
                        layer.style_cols[stylecol] = coltype['type']
                layer.geom_type = self._geom_type(layer)
                if not base_layers:
                    geoms.add(layer.geom_type)
                # update local style schema to help build proper defaults
            layer._setup(layers, idx)

        # set labels on top if there are no point geometries and a basemap
        #  is not specified
        if not base_layers and 'point' not in geoms:
            layers[0] = BaseMap(labels='front')

        # If basemap labels are on front, add labels layer
        basemap = layers[0]
        if basemap.is_basic() and basemap.labels == 'front':
            layers.append(BaseMap(basemap.source,
                                  labels=basemap.labels,
                                  only_labels=True))

        nb_layers = non_basemap_layers(layers)
        if time_layer and len(nb_layers) > 1:
            raise ValueError('Maps with a time element can only consist of a '
                             'time layer and a basemap. This constraint will '
                             'be removed in the future.')
        options = {'basemap_url': basemap.url}

        for idx, layer in enumerate(nb_layers):
            self._check_query(layer.query,
                              style_cols=layer.style_cols)
            options['cartocss_' + str(idx)] = layer.cartocss
            options['sql_' + str(idx)] = layer.query

        params = {
            'config': json.dumps(options),
            'anti_cache': random.random(),
        }

        if has_zoom:
            params.update({'zoom': zoom, 'lat': lat, 'lon': lng})
            options.update({'zoom': zoom, 'lat': lat, 'lng': lng})
        else:
            bounds = self._get_bounds(nb_layers)
            options.update(bounds)
            bbox = '{west},{south},{east},{north}'.format(**bounds)
            params.update(dict(bbox=bbox))

        map_name = self._send_map_template(layers, has_zoom=has_zoom)
        api_url = utils.join_url(self.creds.base_url(), 'api/v1/map')

        static_url = ('{url}.png?{params}').format(
            url=utils.join_url(api_url, 'static/named',
                               map_name, size[0], size[1]),
            params=urlencode(params))

        html = '<img src="{url}" />'.format(url=static_url)
        self._debug_print(static_url=static_url)

        # TODO: write this as a private method
        if interactive:
            netloc = urlparse(self.creds.base_url()).netloc
            domain = 'carto.com' if netloc.endswith('.carto.com') else netloc

            config = {
                'user_name': self.creds.username(),
                'maps_api_template': self.creds.base_url(),
                'sql_api_template': self.creds.base_url(),
                'tiler_protocol': 'https',
                'tiler_domain': domain,
                'tiler_port': '80',
                'type': 'torque' if time_layer else 'namedmap',
                'named_map': {
                    'name': map_name,
                    'params': {
                        k: utils.safe_quotes(v, escape_single_quotes=True)
                        for k, v in utils.dict_items(options)
                    },
                },
            }

            map_options = {
                'filter': ['mapnik', 'torque', ],
                'https': True,
            }

            if time_layer:
                # get turbo-carto processed cartocss
                resp = self._auth_send(
                    'api/v1/map/named/{}'.format(map_name),
                    'POST',
                    data=params['config'],
                    headers={'Content-Type': 'application/json'})

                # check if errors in cartocss (already turbo-carto processed)
                if 'errors' not in resp:
                    # replace previous cartocss with turbo-carto processed
                    #  version
                    layer.cartocss = (resp['metadata']
                                          ['layers']
                                          [1]
                                          ['meta']
                                          ['cartocss'])
                config.update({
                    'order': 1,
                    'options': {
                        'query': time_layer.query,
                        'user_name': self.creds.username(),
                        'tile_style': layer.cartocss
                    }
                })
                config['named_map'].update({
                    'layers': [{
                        'layer_name': 't',
                    }],
                })
                map_options.update({
                    'time_slider': True,
                    'loop': True,
                })
            bounds = [] if has_zoom else [[options['north'], options['east']],
                                          [options['south'], options['west']]]

            content = self._get_iframe_srcdoc(
                config=config,
                bounds=bounds,
                options=options,
                map_options=map_options,
                top_layer_url=top_basemap_layer_url(layers)
            )

            img_html = html
            html = (
                '<iframe srcdoc="{content}" width="{width}" height="{height}">'
                '  Preview image: {img_html}'
                '</iframe>'
            ).format(content=utils.safe_quotes(content),
                     width=size[0],
                     height=size[1],
                     img_html=img_html)
            return HTML(html)
        elif HAS_MATPLOTLIB:
            raw_data = mpi.imread(static_url, format='png')
            if ax is None:
                dpi = mpi.rcParams['figure.dpi']
                mpl_size = (size[0] / dpi, size[1] / dpi)
                fig = plt.figure(figsize=mpl_size, dpi=dpi, frameon=False)
                fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
                ax = plt.gca()
            ax.imshow(raw_data)
            ax.axis('off')
            return ax
        else:
            return Image(url=static_url,
                         embed=True,
                         format='png',
                         width=size[0],
                         height=size[1],
                         metadata=dict(origin_url=static_url))