Exemple #1
0
    def recompute_heatmap(self, points):
        if self.model is None or self.data is None:
            self.exposeObject('model_predictions', {})
            self.evalJS('draw_heatmap()')
            return

        latlons = np.array(points)
        table = Table(Domain([self.lat_attr, self.lon_attr]), latlons)
        try:
            predictions = self.model(table)
        except Exception as e:
            self._owwidget.Error.model_error(e)
            return
        else:
            self._owwidget.Error.model_error.clear()

        class_var = self.model.domain.class_var
        is_regression = class_var.is_continuous
        if is_regression:
            predictions = scale(np.round(predictions, 7))  # Avoid small errors
            kwargs = dict(
                extrema=self._legend_values(class_var, [np.nanmin(predictions),
                                                        np.nanmax(predictions)]))
        else:
            colorgen = ColorPaletteGenerator(len(class_var.values), class_var.colors)
            predictions = colorgen.getRGB(predictions)
            kwargs = dict(
                legend_labels=self._legend_values(class_var, range(len(class_var.values))),
                full_labels=list(class_var.values),
                colors=[color_to_hex(colorgen.getRGB(i))
                        for i in range(len(class_var.values))])
        self.exposeObject('model_predictions', dict(data=predictions, **kwargs))
        self.evalJS('draw_heatmap()')
Exemple #2
0
    def recompute_heatmap(self, points):
        if self.model is None or self.data is None:
            self.exposeObject('model_predictions', {})
            self.evalJS('draw_heatmap()')
            return

        latlons = np.array(points)
        table = Table(Domain([self.lat_attr, self.lon_attr]), latlons)
        try:
            predictions = self.model(table)
        except Exception as e:
            self._owwidget.Error.model_error(e)
            return
        else:
            self._owwidget.Error.model_error.clear()

        class_var = self.model.domain.class_var
        is_regression = class_var.is_continuous
        if is_regression:
            predictions = scale(np.round(predictions, 7))  # Avoid small errors
            kwargs = dict(
                extrema=self._legend_values(class_var, [np.nanmin(predictions),
                                                        np.nanmax(predictions)]))
        else:
            colorgen = ColorPaletteGenerator(len(class_var.values), class_var.colors)
            predictions = colorgen.getRGB(predictions)
            kwargs = dict(
                legend_labels=self._legend_values(class_var, range(len(class_var.values))),
                full_labels=list(class_var.values),
                colors=[color_to_hex(colorgen.getRGB(i))
                        for i in range(len(class_var.values))])
        self.exposeObject('model_predictions', dict(data=predictions, **kwargs))
        self.evalJS('draw_heatmap()')
Exemple #3
0
class LeafletMap(WebviewWidget):
    selectionChanged = pyqtSignal(list)

    def __init__(self, parent=None):

        class Bridge(QObject):
            @pyqtSlot()
            def fit_to_bounds(_):
                return self.fit_to_bounds()

            @pyqtSlot(float, float, float, float)
            def selected_area(_, *args):
                return self.selected_area(*args)

            @pyqtSlot('QVariantList')
            def recompute_heatmap(_, *args):
                return self.recompute_heatmap(*args)

            @pyqtSlot(float, float, float, float, int, int, float, 'QVariantList', 'QVariantList')
            def redraw_markers_overlay_image(_, *args):
                return self.redraw_markers_overlay_image(*args)

        super().__init__(parent,
                         bridge=Bridge(),
                         url=QUrl(self.toFileURL(
                             os.path.join(os.path.dirname(__file__), '_leaflet', 'owmap.html'))),
                         debug=True,)
        self.jittering = 0
        self._jittering_offsets = None
        self._owwidget = parent
        self._opacity = 255
        self._sizes = None
        self._selected_indices = None

        self.lat_attr = None
        self.lon_attr = None
        self.data = None
        self.model = None
        self._domain = None
        self._latlon_data = None

        self._jittering = None
        self._color_attr = None
        self._label_attr = None
        self._shape_attr = None
        self._size_attr = None
        self._legend_colors = []
        self._legend_shapes = []
        self._legend_sizes = []

        self._drawing_args = None
        self._image_token = None
        self._prev_map_pane_pos = None
        self._prev_origin = None
        self._overlay_image_path = mkstemp(prefix='orange-Map-', suffix='.png')[1]
        self._subset_ids = np.array([])
        self.is_js_path = None

    def __del__(self):
        os.remove(self._overlay_image_path)
        self._image_token = np.nan

    def set_data(self, data, lat_attr, lon_attr):
        self.data = data
        self._image_token = np.nan  # Stop drawing previous image
        self._owwidget.progressBarFinished(None)

        if (data is None or not len(data) or
                lat_attr not in data.domain or
                lon_attr not in data.domain):
            self.data = None
            self.evalJS('clear_markers_js(); clear_markers_overlay_image();')
            self._legend_colors = []
            self._legend_shapes = []
            self._legend_sizes = []
            self._update_legend()
            return

        lat_attr = data.domain[lat_attr]
        lon_attr = data.domain[lon_attr]

        fit_bounds = (self._domain != data.domain or
                      self.lat_attr is not lat_attr or
                      self.lon_attr is not lon_attr)
        self.lat_attr = lat_attr
        self.lon_attr = lon_attr
        self._domain = data.domain

        self._latlon_data = np.array([
            self.data.get_column_view(self.lat_attr)[0],
            self.data.get_column_view(self.lon_attr)[0]],
            dtype=float, order='F').T

        self._recompute_jittering_offsets()

        if fit_bounds:
            QTimer.singleShot(1, self.fit_to_bounds)
        else:
            self.redraw_markers_overlay_image(new_image=True)

    def fit_to_bounds(self, fly=True):
        if self.data is None:
            return
        lat_data, lon_data = self._latlon_data.T
        north, south = np.nanmax(lat_data), np.nanmin(lat_data)
        east, west = np.nanmin(lon_data), np.nanmax(lon_data)
        script = ('map.%sBounds([[%f, %f], [%f, %f]], {padding: [0,0], minZoom: 2, maxZoom: 13})' %
                  ('flyTo' if fly else 'fit', south, west, north, east))
        self.evalJS(script)
        # Sometimes on first data, it doesn't zoom in enough. So let do it
        # once more for good measure!
        self.evalJS(script)

    def selected_area(self, north, east, south, west):
        indices = np.array([])
        prev_selected_indices = self._selected_indices
        if self.data is not None and (north != south and east != west):
            lat, lon = self._latlon_data.T
            indices = ((lat <= north) & (lat >= south) &
                       (lon <= east) & (lon >= west))
            if self._selected_indices is not None:
                indices |= self._selected_indices
            self._selected_indices = indices
        else:
            self._selected_indices = None
        if np.any(self._selected_indices != prev_selected_indices):
            self.selectionChanged.emit(indices.nonzero()[0].tolist())
            self.redraw_markers_overlay_image(new_image=True)

    def set_map_provider(self, provider):
        self.evalJS('set_map_provider("{}");'.format(provider))

    def set_clustering(self, cluster_points):
        self.evalJS('''
            window.cluster_points = {};
            set_cluster_points();
        '''.format(int(cluster_points)))

    def _recompute_jittering_offsets(self):
        if not self._jittering:
            self._jittering_offsets = None
        elif self.data:
            # Calculate offsets randomly distributed within a circle
            screen_size = max(100, min(qApp.desktop().screenGeometry().width(),
                                       qApp.desktop().screenGeometry().height()))
            n = len(self.data)
            r = np.random.random(n)
            theta = np.random.uniform(0, 2*np.pi, n)
            xy_offsets = screen_size * self._jittering * np.c_[r * np.cos(theta),
                                                               r * np.sin(theta)]
            self._jittering_offsets = xy_offsets

    def set_jittering(self, jittering):
        """ In percent, i.e. jittering=3 means 3% of screen height and width """
        self._jittering = jittering / 100
        self._recompute_jittering_offsets()
        self.redraw_markers_overlay_image(new_image=True)

    @staticmethod
    def _legend_values(variable, values):
        strs = [variable.repr_val(val) for val in values]
        if any(len(val) > 10 for val in strs):
            if isinstance(variable, TimeVariable):
                strs = [s.replace(' ', '<br>') for s in strs]
            elif variable.is_continuous:
                strs = ['{:.4e}'.format(val) for val in values]
            elif variable.is_discrete:
                strs = [s if len(s) <= 12 else (s[:8] + '…' + s[-3:])
                        for s in strs]
        return strs

    def set_marker_color(self, attr, update=True):
        try:
            self._color_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._color_attr = None
            self._legend_colors = []
        else:
            if variable.is_continuous:
                self._raw_color_values = values = self.data.get_column_view(variable)[0].astype(float)
                self._scaled_color_values = scale(values)
                self._colorgen = ContinuousPaletteGenerator(*variable.colors)
                min = np.nanmin(values)
                self._legend_colors = (['c',
                                        self._legend_values(variable, [min, np.nanmax(values)]),
                                        [color_to_hex(i) for i in variable.colors if i]]
                                       if not np.isnan(min) else [])
            elif variable.is_discrete:
                _values = np.asarray(self.data.domain[attr].values)
                __values = self.data.get_column_view(variable)[0].astype(np.uint16)
                self._raw_color_values = _values[__values]  # The joke's on you
                self._scaled_color_values = __values
                self._colorgen = ColorPaletteGenerator(len(variable.colors), variable.colors)
                self._legend_colors = ['d',
                                       self._legend_values(variable, range(len(_values))),
                                       list(_values),
                                       [color_to_hex(self._colorgen.getRGB(i))
                                        for i in range(len(_values))]]
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_label(self, attr, update=True):
        try:
            self._label_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._label_attr = None
        else:
            if variable.is_continuous or variable.is_string:
                self._label_values = self.data.get_column_view(variable)[0]
            elif variable.is_discrete:
                _values = np.asarray(self.data.domain[attr].values)
                __values = self.data.get_column_view(variable)[0].astype(np.uint16)
                self._label_values = _values[__values]  # The design had lead to poor code for ages
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_shape(self, attr, update=True):
        try:
            self._shape_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._shape_attr = None
            self._legend_shapes = []
        else:
            assert variable.is_discrete
            _values = np.asarray(self.data.domain[attr].values)
            self._shape_values = __values = self.data.get_column_view(variable)[0].astype(np.uint16)
            self._raw_shape_values = _values[__values]
            self._legend_shapes = [self._legend_values(variable, range(len(_values))),
                                   list(_values)]
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_size(self, attr, update=True):
        try:
            self._size_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._size_attr = None
            self._legend_sizes = []
        else:
            assert variable.is_continuous
            self._raw_sizes = values = self.data.get_column_view(variable)[0].astype(float)
            # Note, [5, 60] is also hardcoded in legend-size-indicator.svg
            self._sizes = scale(values, 5, 60).astype(np.uint8)
            min = np.nanmin(values)
            self._legend_sizes = self._legend_values(variable,
                                                     [min, np.nanmax(values)]) if not np.isnan(min) else []
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_size_coefficient(self, size):
        self._size_coef = size / 100
        self.evalJS('''set_marker_size_coefficient({});'''.format(size / 100))
        if not self.is_js_path:
            self.redraw_markers_overlay_image(new_image=True)

    def set_marker_opacity(self, opacity):
        self._opacity = 255 * opacity // 100
        self.evalJS('''set_marker_opacity({});'''.format(opacity / 100))
        if not self.is_js_path:
            self.redraw_markers_overlay_image(new_image=True)

    def set_model(self, model):
        self.model = model
        self.evalJS('clear_heatmap()' if model is None else 'reset_heatmap()')

    def recompute_heatmap(self, points):
        if self.model is None or self.data is None:
            self.exposeObject('model_predictions', {})
            self.evalJS('draw_heatmap()')
            return

        latlons = np.array(points)
        table = Table(Domain([self.lat_attr, self.lon_attr]), latlons)
        try:
            predictions = self.model(table)
        except Exception as e:
            self._owwidget.Error.model_error(e)
            return
        else:
            self._owwidget.Error.model_error.clear()

        class_var = self.model.domain.class_var
        is_regression = class_var.is_continuous
        if is_regression:
            predictions = scale(np.round(predictions, 7))  # Avoid small errors
            kwargs = dict(
                extrema=self._legend_values(class_var, [np.nanmin(predictions),
                                                        np.nanmax(predictions)]))
        else:
            colorgen = ColorPaletteGenerator(len(class_var.values), class_var.colors)
            predictions = colorgen.getRGB(predictions)
            kwargs = dict(
                legend_labels=self._legend_values(class_var, range(len(class_var.values))),
                full_labels=list(class_var.values),
                colors=[color_to_hex(colorgen.getRGB(i))
                        for i in range(len(class_var.values))])
        self.exposeObject('model_predictions', dict(data=predictions, **kwargs))
        self.evalJS('draw_heatmap()')

    def _update_legend(self, is_js_path=False):
        self.evalJS('''
            window.legend_colors = %s;
            window.legend_shapes = %s;
            window.legend_sizes  = %s;
            legendControl.remove();
            legendControl.addTo(map);
        ''' % (self._legend_colors,
               self._legend_shapes if is_js_path else [],
               self._legend_sizes))

    def _update_js_markers(self, visible, in_subset):
        self._visible = visible
        latlon = self._latlon_data
        self.exposeObject('latlon_data', dict(data=latlon[visible]))
        self.exposeObject('jittering_offsets',
                          self._jittering_offsets[visible] if self._jittering_offsets is not None else [])
        self.exposeObject('selected_markers', dict(data=(self._selected_indices[visible]
                                                         if self._selected_indices is not None else 0)))
        self.exposeObject('in_subset', in_subset.astype(np.int8))
        if not self._color_attr:
            self.exposeObject('color_attr', dict())
        else:
            colors = [color_to_hex(rgb)
                      for rgb in self._colorgen.getRGB(self._scaled_color_values[visible])]
            self.exposeObject('color_attr',
                              dict(name=str(self._color_attr), values=colors,
                                   raw_values=self._raw_color_values[visible]))
        if not self._label_attr:
            self.exposeObject('label_attr', dict())
        else:
            self.exposeObject('label_attr',
                              dict(name=str(self._label_attr),
                                   values=self._label_values[visible]))
        if not self._shape_attr:
            self.exposeObject('shape_attr', dict())
        else:
            self.exposeObject('shape_attr',
                              dict(name=str(self._shape_attr),
                                   values=self._shape_values[visible],
                                   raw_values=self._raw_shape_values[visible]))
        if not self._size_attr:
            self.exposeObject('size_attr', dict())
        else:
            self.exposeObject('size_attr',
                              dict(name=str(self._size_attr),
                                   values=self._sizes[visible],
                                   raw_values=self._raw_sizes[visible]))
        self.evalJS('''
            window.latlon_data = latlon_data.data;
            window.selected_markers = selected_markers.data;
            add_markers(latlon_data);
        ''')

    class Projection:
        """This should somewhat model Leaflet's Web Mercator (EPSG:3857).

        Reverse-engineered from L.Map.latlngToContainerPoint().
        """
        @staticmethod
        def latlon_to_easting_northing(lat, lon):
            R = 6378137
            MAX_LATITUDE = 85.0511287798
            DEG = np.pi / 180

            lat = np.clip(lat, -MAX_LATITUDE, MAX_LATITUDE)
            sin = np.sin(DEG * lat)
            x = R * DEG * lon
            y = R / 2 * np.log((1 + sin) / (1 - sin))
            return x, y

        @staticmethod
        def easting_northing_to_pixel(x, y, zoom_level, pixel_origin, map_pane_pos):
            R = 6378137
            PROJ_SCALE = .5 / (np.pi * R)

            zoom_scale = 256 * (2 ** zoom_level)
            x = (zoom_scale * (PROJ_SCALE * x + .5)).round() + (map_pane_pos[0] - pixel_origin[0])
            y = (zoom_scale * (-PROJ_SCALE * y + .5)).round() + (map_pane_pos[1] - pixel_origin[1])
            return x, y

    N_POINTS_PER_ITER = 666

    def redraw_markers_overlay_image(self, *args, new_image=False):
        if not args and not self._drawing_args or self.data is None:
            return

        if args:
            self._drawing_args = args
        north, east, south, west, width, height, zoom, origin, map_pane_pos = self._drawing_args

        lat, lon = self._latlon_data.T
        visible = ((lat <= north) & (lat >= south) &
                   (lon <= east) & (lon >= west)).nonzero()[0]
        in_subset = (np.in1d(self.data.ids, self._subset_ids)
                     if self._subset_ids.size else
                     np.tile(True, len(lon)))

        is_js_path = self.is_js_path = len(visible) < self.N_POINTS_PER_ITER

        self._update_legend(is_js_path)

        np.random.shuffle(visible)
        # Sort points in subset to be painted last
        visible = visible[np.lexsort((in_subset[visible],))]

        if is_js_path:
            self.evalJS('clear_markers_overlay_image()')
            self._update_js_markers(visible, in_subset[visible])
            self._owwidget.disable_some_controls(False)
            return

        self.evalJS('clear_markers_js();')
        self._owwidget.disable_some_controls(True)

        selected = (self._selected_indices
                    if self._selected_indices is not None else
                    np.zeros(len(lat), dtype=bool))
        cur = 0

        im = QImage(self._overlay_image_path)
        if im.isNull() or self._prev_origin != origin or new_image:
            im = QImage(width, height, QImage.Format_ARGB32)
            im.fill(Qt.transparent)
        else:
            dx, dy = self._prev_map_pane_pos - map_pane_pos
            im = im.copy(dx, dy, width, height)
        self._prev_map_pane_pos = np.array(map_pane_pos)
        self._prev_origin = origin

        painter = QPainter(im)
        painter.setRenderHint(QPainter.Antialiasing, True)
        self.evalJS('clear_markers_overlay_image(); markersImageLayer.setBounds(map.getBounds());0')

        self._image_token = image_token = np.random.random()

        n_iters = np.ceil(len(visible) / self.N_POINTS_PER_ITER)

        def add_points():
            nonlocal cur, image_token
            if image_token != self._image_token:
                return
            batch = visible[cur:cur + self.N_POINTS_PER_ITER]

            batch_lat = lat[batch]
            batch_lon = lon[batch]

            x, y = self.Projection.latlon_to_easting_northing(batch_lat, batch_lon)
            x, y = self.Projection.easting_northing_to_pixel(x, y, zoom, origin, map_pane_pos)

            if self._jittering:
                dx, dy = self._jittering_offsets[batch].T
                x, y = x + dx, y + dy

            colors = (self._colorgen.getRGB(self._scaled_color_values[batch]).tolist()
                      if self._color_attr else
                      repeat((0xff, 0, 0)))
            sizes = self._size_coef * \
                (self._sizes[batch] if self._size_attr else np.tile(10, len(batch)))

            opacity_subset, opacity_rest = self._opacity, int(.8 * self._opacity)
            for x, y, is_selected, size, color, _in_subset in \
                    zip(x, y, selected[batch], sizes, colors, in_subset[batch]):

                pensize2, selpensize2 = (.35, 1.5) if size >= 5 else (.15, .7)
                pensize2 *= self._size_coef
                selpensize2 *= self._size_coef

                size2 = size / 2
                if is_selected:
                    painter.setPen(QPen(QBrush(Qt.green), 2 * selpensize2))
                    painter.drawEllipse(x - size2 - selpensize2,
                                        y - size2 - selpensize2,
                                        size + selpensize2,
                                        size + selpensize2)
                color = QColor(*color)
                if _in_subset:
                    color.setAlpha(opacity_subset)
                    painter.setBrush(QBrush(color))
                    painter.setPen(QPen(QBrush(color.darker(180)), 2 * pensize2))
                else:
                    color.setAlpha(opacity_rest)
                    painter.setBrush(Qt.NoBrush)
                    painter.setPen(QPen(QBrush(color.lighter(120)), 2 * pensize2))

                painter.drawEllipse(x - size2 - pensize2,
                                    y - size2 - pensize2,
                                    size + pensize2,
                                    size + pensize2)

            im.save(self._overlay_image_path, 'PNG')
            self.evalJS('markersImageLayer.setUrl("{}#{}"); 0;'
                        .format(self.toFileURL(self._overlay_image_path),
                                np.random.random()))

            cur += self.N_POINTS_PER_ITER
            if cur < len(visible):
                QTimer.singleShot(10, add_points)
                self._owwidget.progressBarAdvance(100 / n_iters, None)
            else:
                self._owwidget.progressBarFinished(None)

        self._owwidget.progressBarFinished(None)
        self._owwidget.progressBarInit(None)
        QTimer.singleShot(10, add_points)

    def set_subset_ids(self, ids):
        self._subset_ids = ids
        self.redraw_markers_overlay_image(new_image=True)

    def toggle_legend(self, visible):
        self.evalJS('''
            $(".legend").{0}();
            window.legend_hidden = "{0}";
        '''.format('show' if visible else 'hide'))
Exemple #4
0
class LeafletMap(WebviewWidget):
    selectionChanged = pyqtSignal(list)

    def __init__(self, parent=None):

        class Bridge(QObject):
            @pyqtSlot()
            def fit_to_bounds(_):
                return self.fit_to_bounds()

            @pyqtSlot(float, float, float, float)
            def selected_area(_, *args):
                return self.selected_area(*args)

            @pyqtSlot('QVariantList')
            def recompute_heatmap(_, *args):
                return self.recompute_heatmap(*args)

            @pyqtSlot(float, float, float, float, int, int, float, 'QVariantList', 'QVariantList')
            def redraw_markers_overlay_image(_, *args):
                return self.redraw_markers_overlay_image(*args)

        super().__init__(parent,
                         bridge=Bridge(),
                         url=QUrl(self.toFileURL(
                             os.path.join(os.path.dirname(__file__), '_owmap', 'owmap.html'))),
                         debug=True,)
        self.jittering = 0
        self._jittering_offsets = None
        self._owwidget = parent
        self._opacity = 255
        self._sizes = None
        self._selected_indices = None

        self.lat_attr = None
        self.lon_attr = None
        self.data = None
        self.model = None
        self._domain = None
        self._latlon_data = None

        self._jittering = None
        self._color_attr = None
        self._label_attr = None
        self._shape_attr = None
        self._size_attr = None
        self._legend_colors = []
        self._legend_shapes = []
        self._legend_sizes = []

        self._drawing_args = None
        self._image_token = None
        self._prev_map_pane_pos = None
        self._prev_origin = None
        self._overlay_image_path = mkstemp(prefix='orange-Map-', suffix='.png')[1]
        self._subset_ids = np.array([])
        self.is_js_path = None

    def __del__(self):
        os.remove(self._overlay_image_path)
        self._image_token = np.nan

    def set_data(self, data, lat_attr, lon_attr):
        self.data = data
        self._image_token = np.nan  # Stop drawing previous image
        self._owwidget.progressBarFinished(None)

        if (data is None or not len(data) or
                lat_attr not in data.domain or
                lon_attr not in data.domain):
            self.data = None
            self.evalJS('clear_markers_js(); clear_markers_overlay_image();')
            self._legend_colors = []
            self._legend_shapes = []
            self._legend_sizes = []
            self._update_legend()
            return

        lat_attr = data.domain[lat_attr]
        lon_attr = data.domain[lon_attr]

        fit_bounds = (self._domain != data.domain or
                      self.lat_attr is not lat_attr or
                      self.lon_attr is not lon_attr)
        self.lat_attr = lat_attr
        self.lon_attr = lon_attr
        self._domain = data.domain

        self._latlon_data = np.array([
            self.data.get_column_view(self.lat_attr)[0],
            self.data.get_column_view(self.lon_attr)[0]],
            dtype=float, order='F').T

        self._recompute_jittering_offsets()

        if fit_bounds:
            QTimer.singleShot(1, self.fit_to_bounds)
        else:
            self.redraw_markers_overlay_image(new_image=True)

    def fit_to_bounds(self, fly=True):
        if self.data is None:
            return
        lat_data, lon_data = self._latlon_data.T
        north, south = np.nanmax(lat_data), np.nanmin(lat_data)
        east, west = np.nanmin(lon_data), np.nanmax(lon_data)
        script = ('map.%sBounds([[%f, %f], [%f, %f]], {padding: [0,0], minZoom: 2, maxZoom: 13})' %
                  ('flyTo' if fly else 'fit', south, west, north, east))
        self.evalJS(script)
        # Sometimes on first data, it doesn't zoom in enough. So let do it
        # once more for good measure!
        self.evalJS(script)

    def selected_area(self, north, east, south, west):
        indices = np.array([])
        prev_selected_indices = self._selected_indices
        if self.data is not None and (north != south and east != west):
            lat, lon = self._latlon_data.T
            indices = ((lat <= north) & (lat >= south) &
                       (lon <= east) & (lon >= west))
            if self._selected_indices is not None:
                indices |= self._selected_indices
            self._selected_indices = indices
        else:
            self._selected_indices = None
        if np.any(self._selected_indices != prev_selected_indices):
            self.selectionChanged.emit(indices.nonzero()[0].tolist())
            self.redraw_markers_overlay_image(new_image=True)

    def set_map_provider(self, provider):
        self.evalJS('set_map_provider("{}");'.format(provider))

    def set_clustering(self, cluster_points):
        self.evalJS('''
            window.cluster_points = {};
            set_cluster_points();
        '''.format(int(cluster_points)))

    def _recompute_jittering_offsets(self):
        if not self._jittering:
            self._jittering_offsets = None
        elif self.data:
            # Calculate offsets randomly distributed within a circle
            screen_size = max(100, min(qApp.desktop().screenGeometry().width(),
                                       qApp.desktop().screenGeometry().height()))
            n = len(self.data)
            r = np.random.random(n)
            theta = np.random.uniform(0, 2*np.pi, n)
            xy_offsets = screen_size * self._jittering * np.c_[r * np.cos(theta),
                                                               r * np.sin(theta)]
            self._jittering_offsets = xy_offsets

    def set_jittering(self, jittering):
        """ In percent, i.e. jittering=3 means 3% of screen height and width """
        self._jittering = jittering / 100
        self._recompute_jittering_offsets()
        self.redraw_markers_overlay_image(new_image=True)

    @staticmethod
    def _legend_values(variable, values):
        strs = [variable.repr_val(val) for val in values]
        if any(len(val) > 10 for val in strs):
            if isinstance(variable, TimeVariable):
                strs = [s.replace(' ', '<br>') for s in strs]
            elif variable.is_continuous:
                strs = ['{:.4e}'.format(val) for val in values]
            elif variable.is_discrete:
                strs = [s if len(s) <= 12 else (s[:8] + '…' + s[-3:])
                        for s in strs]
        return strs

    def set_marker_color(self, attr, update=True):
        try:
            self._color_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._color_attr = None
            self._legend_colors = []
        else:
            if variable.is_continuous:
                self._raw_color_values = values = self.data.get_column_view(variable)[0].astype(float)
                self._scaled_color_values = scale(values)
                self._colorgen = ContinuousPaletteGenerator(*variable.colors)
                min = np.nanmin(values)
                self._legend_colors = (['c',
                                        self._legend_values(variable, [min, np.nanmax(values)]),
                                        [color_to_hex(i) for i in variable.colors if i]]
                                       if not np.isnan(min) else [])
            elif variable.is_discrete:
                _values = np.asarray(self.data.domain[attr].values)
                __values = self.data.get_column_view(variable)[0].astype(np.uint16)
                self._raw_color_values = _values[__values]  # The joke's on you
                self._scaled_color_values = __values
                self._colorgen = ColorPaletteGenerator(len(variable.colors), variable.colors)
                self._legend_colors = ['d',
                                       self._legend_values(variable, range(len(_values))),
                                       list(_values),
                                       [color_to_hex(self._colorgen.getRGB(i))
                                        for i in range(len(_values))]]
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_label(self, attr, update=True):
        try:
            self._label_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._label_attr = None
        else:
            if variable.is_continuous or variable.is_string:
                self._label_values = self.data.get_column_view(variable)[0]
            elif variable.is_discrete:
                _values = np.asarray(self.data.domain[attr].values)
                __values = self.data.get_column_view(variable)[0].astype(np.uint16)
                self._label_values = _values[__values]  # The design had lead to poor code for ages
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_shape(self, attr, update=True):
        try:
            self._shape_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._shape_attr = None
            self._legend_shapes = []
        else:
            assert variable.is_discrete
            _values = np.asarray(self.data.domain[attr].values)
            self._shape_values = __values = self.data.get_column_view(variable)[0].astype(np.uint16)
            self._raw_shape_values = _values[__values]
            self._legend_shapes = [self._legend_values(variable, range(len(_values))),
                                   list(_values)]
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_size(self, attr, update=True):
        try:
            self._size_attr = variable = self.data.domain[attr]
            if len(self.data) == 0:
                raise Exception
        except Exception:
            self._size_attr = None
            self._legend_sizes = []
        else:
            assert variable.is_continuous
            self._raw_sizes = values = self.data.get_column_view(variable)[0].astype(float)
            # Note, [5, 60] is also hardcoded in legend-size-indicator.svg
            self._sizes = scale(values, 5, 60).astype(np.uint8)
            min = np.nanmin(values)
            self._legend_sizes = self._legend_values(variable,
                                                     [min, np.nanmax(values)]) if not np.isnan(min) else []
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_size_coefficient(self, size):
        self._size_coef = size / 100
        self.evalJS('''set_marker_size_coefficient({});'''.format(size / 100))
        if not self.is_js_path:
            self.redraw_markers_overlay_image(new_image=True)

    def set_marker_opacity(self, opacity):
        self._opacity = 255 * opacity // 100
        self.evalJS('''set_marker_opacity({});'''.format(opacity / 100))
        if not self.is_js_path:
            self.redraw_markers_overlay_image(new_image=True)

    def set_model(self, model):
        self.model = model
        self.evalJS('clear_heatmap()' if model is None else 'reset_heatmap()')

    def recompute_heatmap(self, points):
        if self.model is None or self.data is None:
            self.exposeObject('model_predictions', {})
            self.evalJS('draw_heatmap()')
            return

        latlons = np.array(points)
        table = Table(Domain([self.lat_attr, self.lon_attr]), latlons)
        try:
            predictions = self.model(table)
        except Exception as e:
            self._owwidget.Error.model_error(e)
            return
        else:
            self._owwidget.Error.model_error.clear()

        class_var = self.model.domain.class_var
        is_regression = class_var.is_continuous
        if is_regression:
            predictions = scale(np.round(predictions, 7))  # Avoid small errors
            kwargs = dict(
                extrema=self._legend_values(class_var, [np.nanmin(predictions),
                                                        np.nanmax(predictions)]))
        else:
            colorgen = ColorPaletteGenerator(len(class_var.values), class_var.colors)
            predictions = colorgen.getRGB(predictions)
            kwargs = dict(
                legend_labels=self._legend_values(class_var, range(len(class_var.values))),
                full_labels=list(class_var.values),
                colors=[color_to_hex(colorgen.getRGB(i))
                        for i in range(len(class_var.values))])
        self.exposeObject('model_predictions', dict(data=predictions, **kwargs))
        self.evalJS('draw_heatmap()')

    def _update_legend(self, is_js_path=False):
        self.evalJS('''
            window.legend_colors = %s;
            window.legend_shapes = %s;
            window.legend_sizes  = %s;
            legendControl.remove();
            legendControl.addTo(map);
        ''' % (self._legend_colors,
               self._legend_shapes if is_js_path else [],
               self._legend_sizes))

    def _update_js_markers(self, visible, in_subset):
        self._visible = visible
        latlon = self._latlon_data
        self.exposeObject('latlon_data', dict(data=latlon[visible]))
        self.exposeObject('jittering_offsets',
                          self._jittering_offsets[visible] if self._jittering_offsets is not None else [])
        self.exposeObject('selected_markers', dict(data=(self._selected_indices[visible]
                                                         if self._selected_indices is not None else 0)))
        self.exposeObject('in_subset', in_subset.astype(np.int8))
        if not self._color_attr:
            self.exposeObject('color_attr', dict())
        else:
            colors = [color_to_hex(rgb)
                      for rgb in self._colorgen.getRGB(self._scaled_color_values[visible])]
            self.exposeObject('color_attr',
                              dict(name=str(self._color_attr), values=colors,
                                   raw_values=self._raw_color_values[visible]))
        if not self._label_attr:
            self.exposeObject('label_attr', dict())
        else:
            self.exposeObject('label_attr',
                              dict(name=str(self._label_attr),
                                   values=self._label_values[visible]))
        if not self._shape_attr:
            self.exposeObject('shape_attr', dict())
        else:
            self.exposeObject('shape_attr',
                              dict(name=str(self._shape_attr),
                                   values=self._shape_values[visible],
                                   raw_values=self._raw_shape_values[visible]))
        if not self._size_attr:
            self.exposeObject('size_attr', dict())
        else:
            self.exposeObject('size_attr',
                              dict(name=str(self._size_attr),
                                   values=self._sizes[visible],
                                   raw_values=self._raw_sizes[visible]))
        self.evalJS('''
            window.latlon_data = latlon_data.data;
            window.selected_markers = selected_markers.data;
            add_markers(latlon_data);
        ''')

    class Projection:
        """This should somewhat model Leaflet's Web Mercator (EPSG:3857).

        Reverse-engineered from L.Map.latlngToContainerPoint().
        """
        @staticmethod
        def latlon_to_easting_northing(lat, lon):
            R = 6378137
            MAX_LATITUDE = 85.0511287798
            DEG = np.pi / 180

            lat = np.clip(lat, -MAX_LATITUDE, MAX_LATITUDE)
            sin = np.sin(DEG * lat)
            x = R * DEG * lon
            y = R / 2 * np.log((1 + sin) / (1 - sin))
            return x, y

        @staticmethod
        def easting_northing_to_pixel(x, y, zoom_level, pixel_origin, map_pane_pos):
            R = 6378137
            PROJ_SCALE = .5 / (np.pi * R)

            zoom_scale = 256 * (2 ** zoom_level)
            x = (zoom_scale * (PROJ_SCALE * x + .5)).round() + (map_pane_pos[0] - pixel_origin[0])
            y = (zoom_scale * (-PROJ_SCALE * y + .5)).round() + (map_pane_pos[1] - pixel_origin[1])
            return x, y

    N_POINTS_PER_ITER = 666

    def redraw_markers_overlay_image(self, *args, new_image=False):
        if not args and not self._drawing_args or self.data is None:
            return

        if args:
            self._drawing_args = args
        north, east, south, west, width, height, zoom, origin, map_pane_pos = self._drawing_args

        lat, lon = self._latlon_data.T
        visible = ((lat <= north) & (lat >= south) &
                   (lon <= east) & (lon >= west)).nonzero()[0]
        in_subset = (np.in1d(self.data.ids, self._subset_ids)
                     if self._subset_ids.size else
                     np.tile(True, len(lon)))

        is_js_path = self.is_js_path = len(visible) < self.N_POINTS_PER_ITER

        self._update_legend(is_js_path)

        np.random.shuffle(visible)
        # Sort points in subset to be painted last
        visible = visible[np.lexsort((in_subset[visible],))]

        if is_js_path:
            self.evalJS('clear_markers_overlay_image()')
            self._update_js_markers(visible, in_subset[visible])
            self._owwidget.disable_some_controls(False)
            return

        self.evalJS('clear_markers_js();')
        self._owwidget.disable_some_controls(True)

        selected = (self._selected_indices
                    if self._selected_indices is not None else
                    np.zeros(len(lat), dtype=bool))
        cur = 0

        im = QImage(self._overlay_image_path)
        if im.isNull() or self._prev_origin != origin or new_image:
            im = QImage(width, height, QImage.Format_ARGB32)
            im.fill(Qt.transparent)
        else:
            dx, dy = self._prev_map_pane_pos - map_pane_pos
            im = im.copy(dx, dy, width, height)
        self._prev_map_pane_pos = np.array(map_pane_pos)
        self._prev_origin = origin

        painter = QPainter(im)
        painter.setRenderHint(QPainter.Antialiasing, True)
        self.evalJS('clear_markers_overlay_image(); markersImageLayer.setBounds(map.getBounds());0')

        self._image_token = image_token = np.random.random()

        n_iters = np.ceil(len(visible) / self.N_POINTS_PER_ITER)

        def add_points():
            nonlocal cur, image_token
            if image_token != self._image_token:
                return
            batch = visible[cur:cur + self.N_POINTS_PER_ITER]

            batch_lat = lat[batch]
            batch_lon = lon[batch]

            x, y = self.Projection.latlon_to_easting_northing(batch_lat, batch_lon)
            x, y = self.Projection.easting_northing_to_pixel(x, y, zoom, origin, map_pane_pos)

            if self._jittering:
                dx, dy = self._jittering_offsets[batch].T
                x, y = x + dx, y + dy

            colors = (self._colorgen.getRGB(self._scaled_color_values[batch]).tolist()
                      if self._color_attr else
                      repeat((0xff, 0, 0)))
            sizes = self._size_coef * \
                (self._sizes[batch] if self._size_attr else np.tile(10, len(batch)))

            opacity_subset, opacity_rest = self._opacity, int(.8 * self._opacity)
            for x, y, is_selected, size, color, _in_subset in \
                    zip(x, y, selected[batch], sizes, colors, in_subset[batch]):

                pensize2, selpensize2 = (.35, 1.5) if size >= 5 else (.15, .7)
                pensize2 *= self._size_coef
                selpensize2 *= self._size_coef

                size2 = size / 2
                if is_selected:
                    painter.setPen(QPen(QBrush(Qt.green), 2 * selpensize2))
                    painter.drawEllipse(x - size2 - selpensize2,
                                        y - size2 - selpensize2,
                                        size + selpensize2,
                                        size + selpensize2)
                color = QColor(*color)
                if _in_subset:
                    color.setAlpha(opacity_subset)
                    painter.setBrush(QBrush(color))
                    painter.setPen(QPen(QBrush(color.darker(180)), 2 * pensize2))
                else:
                    color.setAlpha(opacity_rest)
                    painter.setBrush(Qt.NoBrush)
                    painter.setPen(QPen(QBrush(color.lighter(120)), 2 * pensize2))

                painter.drawEllipse(x - size2 - pensize2,
                                    y - size2 - pensize2,
                                    size + pensize2,
                                    size + pensize2)

            im.save(self._overlay_image_path, 'PNG')
            self.evalJS('markersImageLayer.setUrl("{}#{}"); 0;'
                        .format(self.toFileURL(self._overlay_image_path),
                                np.random.random()))

            cur += self.N_POINTS_PER_ITER
            if cur < len(visible):
                QTimer.singleShot(10, add_points)
                self._owwidget.progressBarAdvance(100 / n_iters, None)
            else:
                self._owwidget.progressBarFinished(None)
                self._image_token = None

        self._owwidget.progressBarFinished(None)
        self._owwidget.progressBarInit(None)
        QTimer.singleShot(10, add_points)

    def set_subset_ids(self, ids):
        self._subset_ids = ids
        self.redraw_markers_overlay_image(new_image=True)

    def toggle_legend(self, visible):
        self.evalJS('''
            $(".legend").{0}();
            window.legend_hidden = "{0}";
        '''.format('show' if visible else 'hide'))
Exemple #5
0
class LeafletMap(WebviewWidget):
    selectionChanged = pyqtSignal(list)

    def __init__(self, parent=None):
        super().__init__(
            parent,
            url=QUrl(
                self.toFileURL(
                    os.path.join(os.path.dirname(__file__), '_owmap',
                                 'owmap.html'))),
            debug=True,
        )
        self.jittering = 0
        self._owwidget = parent
        self._opacity = 255
        self._sizes = None
        self._selected_indices = None

        self.lat_attr = None
        self.lon_attr = None
        self.data = None
        self.model = None

        self._color_attr = None
        self._label_attr = None
        self._shape_attr = None
        self._size_attr = None
        self._legend_colors = []
        self._legend_shapes = []
        self._legend_sizes = []

        self._drawing_args = None
        self._image_token = None
        self._prev_map_pane_pos = None
        self._prev_origin = None
        self._overlay_image_path = mkstemp(prefix='orange-Map-',
                                           suffix='.png')[1]

    def __del__(self):
        os.remove(self._overlay_image_path)
        self._image_token = np.nan

    def set_data(self, data, lat_attr, lon_attr):
        self.data = data
        self.lat_attr = None
        self.lon_attr = None

        if data is None or not (len(data) and lat_attr and lon_attr):
            self.evalJS('clear_markers_js(); clear_markers_overlay_image();')
            return

        self.lat_attr = data.domain[lat_attr]
        self.lon_attr = data.domain[lon_attr]
        self.fit_to_bounds(False)

    def showEvent(self, *args):
        super().showEvent(*args)
        QTimer.singleShot(10, self.fit_to_bounds)

    @pyqtSlot()
    def fit_to_bounds(self, fly=True):
        if self.data is None:
            return
        lat_data = self.data.get_column_view(self.lat_attr)[0]
        lon_data = self.data.get_column_view(self.lon_attr)[0]
        north, south = np.nanmax(lat_data), np.nanmin(lat_data)
        east, west = np.nanmin(lon_data), np.nanmax(lon_data)
        self.evalJS(
            'map.%sBounds([[%f, %f], [%f, %f]], {padding: [0, 0], maxZoom: 9})'
            % ('flyTo' if fly else 'fit', south, west, north, east))

    @pyqtSlot(float, float, float, float)
    def selected_area(self, north, east, south, west):
        indices = np.array([])
        if north != south and east != west:
            lat = self.data.get_column_view(self.lat_attr)[0]
            lon = self.data.get_column_view(self.lon_attr)[0]
            indices = ((lat <= north) & (lat >= south) & (lon <= east) &
                       (lon >= west))
            if self._selected_indices is not None:
                indices |= self._selected_indices
            self._selected_indices = indices
        else:
            self._selected_indices = None
        self.selectionChanged.emit(indices.nonzero()[0].tolist())
        self.redraw_markers_overlay_image(new_image=True)

    def set_map_provider(self, provider):
        self.evalJS('set_map_provider("{}");'.format(provider))

    def set_clustering(self, cluster_points):
        self.evalJS('''
            window.cluster_points = {};
            set_cluster_points();
        '''.format(int(cluster_points)))

    def set_jittering(self, jittering):
        """ In percent, i.e. jittering=3 means 3% of screen height and width """
        self._jittering = jittering / 100
        self.evalJS('''
            window.jittering_percent = {};
            set_jittering();
            if (window.jittering_percent == 0)
                clear_jittering();
        '''.format(jittering))
        self.redraw_markers_overlay_image(new_image=True)

    def _legend_values(self, variable, values):
        strs = [variable.repr_val(val) for val in values]
        if any(len(val) > 10 for val in strs):
            if isinstance(variable, TimeVariable):
                strs = [s.replace(' ', '<br>') for s in strs]
            elif variable.is_continuous:
                strs = ['{:.4e}'.format(val) for val in values]
            elif variable.is_discrete:
                strs = [
                    s if len(s) <= 12 else (s[:6] + '…' + s[-5:]) for s in strs
                ]
        return strs

    def set_marker_color(self, attr, update=True):
        try:
            self._color_attr = variable = self.data.domain[attr]
        except Exception:
            self._color_attr = None
            self._legend_colors = []
        else:
            if variable.is_continuous:
                self._raw_color_values = values = self.data.get_column_view(
                    variable)[0]
                self._scaled_color_values = scale(values)
                self._colorgen = ContinuousPaletteGenerator(*variable.colors)
                min = np.nanmin(values)
                self._legend_colors = ([
                    'c',
                    self._legend_values(variable, [min, np.nanmax(values)]),
                    [color_to_hex(i) for i in variable.colors if i]
                ] if not np.isnan(min) else [])
            elif variable.is_discrete:
                _values = np.asarray(self.data.domain[attr].values)
                __values = self.data.get_column_view(variable)[0].astype(
                    np.uint16)
                self._raw_color_values = _values[__values]  # The joke's on you
                self._scaled_color_values = __values
                self._colorgen = ColorPaletteGenerator(len(variable.colors),
                                                       variable.colors)
                self._legend_colors = [
                    'd',
                    self._legend_values(variable, range(len(_values))),
                    [
                        color_to_hex(self._colorgen.getRGB(i))
                        for i in range(len(_values))
                    ]
                ]
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_label(self, attr, update=True):
        try:
            self._label_attr = variable = self.data.domain[attr]
        except Exception:
            self._label_attr = None
        else:
            if variable.is_continuous:
                self._label_values = self.data.get_column_view(variable)[0]
            elif variable.is_discrete:
                _values = np.asarray(self.data.domain[attr].values)
                __values = self.data.get_column_view(variable)[0].astype(
                    np.uint16)
                self._label_values = _values[
                    __values]  # The design had lead to poor code for ages
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_shape(self, attr, update=True):
        try:
            self._shape_attr = variable = self.data.domain[attr]
        except Exception:
            self._shape_attr = None
            self._legend_shapes = []
        else:
            assert variable.is_discrete
            _values = np.asarray(self.data.domain[attr].values)
            self._shape_values = __values = self.data.get_column_view(
                variable)[0].astype(np.uint16)
            self._raw_shape_values = _values[__values]
            self._legend_shapes = self._legend_values(variable,
                                                      range(len(_values)))
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_size(self, attr, update=True):
        try:
            self._size_attr = variable = self.data.domain[attr]
        except Exception:
            self._size_attr = None
            self._legend_sizes = []
        else:
            assert variable.is_continuous
            self._raw_sizes = values = self.data.get_column_view(variable)[0]
            # Note, [5, 60] is also hardcoded in legend-size-indicator.svg
            self._sizes = scale(values, 5, 60).astype(np.uint8)
            min = np.nanmin(values)
            self._legend_sizes = self._legend_values(
                variable,
                [min, np.nanmax(values)]) if not np.isnan(min) else []
        finally:
            if update:
                self.redraw_markers_overlay_image(new_image=True)

    def set_marker_size_coefficient(self, size):
        self._size_coef = size / 100
        self.evalJS('''set_marker_size_coefficient({});'''.format(size / 100))
        self.redraw_markers_overlay_image(new_image=True)

    def set_marker_opacity(self, opacity):
        self._opacity = 255 * opacity // 100
        self.evalJS('''set_marker_opacity({});'''.format(opacity / 100))
        self.redraw_markers_overlay_image(new_image=True)

    def set_model(self, model):
        self.model = model
        self.evalJS('clear_heatmap()' if model is None else 'reset_heatmap()')

    @pyqtSlot('QVariantList')
    def recompute_heatmap(self, points):
        if self.model is None or not self.data or not self.lat_attr or not self.lon_attr:
            return

        latlons = np.array(points)
        table = Table(Domain([self.lat_attr, self.lon_attr]), latlons)
        try:
            predictions = self.model(table)
        except Exception as e:
            self._owwidget.Error.model_error(e)
            return
        else:
            self._owwidget.Error.model_error.clear()
        predictions = scale(np.round(predictions, 7))  # Avoid small errors
        self.exposeObject('model_predictions', dict(data=predictions))
        self.evalJS('draw_heatmap()')

    def _update_js_markers(self, visible):
        self._visible = visible
        data = Table(Domain([self.lat_attr, self.lon_attr]), self.data)
        self.exposeObject('latlon_data', dict(data=data.X[visible]))
        self.exposeObject(
            'selected_markers',
            dict(data=(self._selected_indices[visible] if self.
                       _selected_indices is not None else 0)))
        if not self._color_attr:
            self.exposeObject('color_attr', dict())
        else:
            colors = [
                color_to_hex(rgb) for rgb in self._colorgen.getRGB(
                    self._scaled_color_values[visible])
            ]
            self.exposeObject(
                'color_attr',
                dict(name=str(self._color_attr),
                     values=colors,
                     raw_values=self._raw_color_values[visible]))
        if not self._label_attr:
            self.exposeObject('label_attr', dict())
        else:
            self.exposeObject(
                'label_attr',
                dict(name=str(self._label_attr),
                     values=self._label_values[visible]))
        if not self._shape_attr:
            self.exposeObject('shape_attr', dict())
        else:
            self.exposeObject(
                'shape_attr',
                dict(name=str(self._shape_attr),
                     values=self._shape_values[visible],
                     raw_values=self._raw_shape_values[visible]))
        if not self._size_attr:
            self.exposeObject('size_attr', dict())
        else:
            self.exposeObject(
                'size_attr',
                dict(name=str(self._size_attr),
                     values=self._sizes[visible],
                     raw_values=self._raw_sizes[visible]))
        self.evalJS('''
            window.latlon_data = latlon_data.data;
            window.selected_markers = selected_markers.data
            add_markers(latlon_data);
        ''')

    class Projection:
        """This should somewhat model Leaflet's Web Mercator (EPSG:3857).

        Reverse-engineered from L.Map.latlngToContainerPoint().
        """
        @staticmethod
        def latlon_to_easting_northing(lat, lon):
            R = 6378137
            MAX_LATITUDE = 85.0511287798
            DEG = np.pi / 180

            lat = np.clip(lat, -MAX_LATITUDE, MAX_LATITUDE)
            sin = np.sin(DEG * lat)
            x = R * DEG * lon
            y = R / 2 * np.log((1 + sin) / (1 - sin))
            return x, y

        @staticmethod
        def easting_northing_to_pixel(x, y, zoom_level, pixel_origin,
                                      map_pane_pos):
            R = 6378137
            PROJ_SCALE = .5 / (np.pi * R)

            zoom_scale = 256 * (2**zoom_level)
            x = (zoom_scale *
                 (PROJ_SCALE * x + .5)).round() + (map_pane_pos[0] -
                                                   pixel_origin[0])
            y = (zoom_scale *
                 (-PROJ_SCALE * y + .5)).round() + (map_pane_pos[1] -
                                                    pixel_origin[1])
            return x, y

    @pyqtSlot(float, float, float, float, int, int, float, 'QVariantList',
              'QVariantList')
    def redraw_markers_overlay_image(self, *args, new_image=False):
        if (not args and not self._drawing_args or self.lat_attr is None
                or self.lon_attr is None):
            return

        if args:
            self._drawing_args = args
        north, east, south, west, width, height, zoom, origin, map_pane_pos = self._drawing_args

        lat = self.data.get_column_view(self.lat_attr)[0]
        lon = self.data.get_column_view(self.lon_attr)[0]
        visible = ((lat <= north) & (lat >= south) & (lon <= east) &
                   (lon >= west)).nonzero()[0]

        is_js_path = len(visible) <= 500

        self.evalJS('''
            window.legend_colors = %s;
            window.legend_shapes = %s;
            window.legend_sizes  = %s;
            legendControl.remove();
            legendControl.addTo(map);
        ''' % (self._legend_colors, self._legend_shapes if is_js_path else [],
               self._legend_sizes))

        if is_js_path:
            self.evalJS('clear_markers_overlay_image()')
            self._update_js_markers(visible)
            self._owwidget.disable_some_controls(False)
            return

        self.evalJS('clear_markers_js();')
        self._owwidget.disable_some_controls(True)

        np.random.shuffle(visible)

        selected = (self._selected_indices if self._selected_indices
                    is not None else np.zeros(len(lat), dtype=bool))

        N_POINTS_PER_ITER = 1000
        cur = 0

        im = QImage(self._overlay_image_path)
        if im.isNull() or self._prev_origin != origin or new_image:
            im = QImage(width, height, QImage.Format_ARGB32)
            im.fill(Qt.transparent)
        else:
            dx, dy = self._prev_map_pane_pos - map_pane_pos
            im = im.copy(dx, dy, width, height)
        self._prev_map_pane_pos = np.array(map_pane_pos)
        self._prev_origin = origin

        painter = QPainter(im)
        painter.setRenderHint(QPainter.Antialiasing, True)
        self.evalJS(
            'clear_markers_overlay_image(); markersImageLayer.setBounds(map.getBounds());0'
        )

        self._image_token = image_token = np.random.random()

        n_iters = np.ceil(len(visible) / N_POINTS_PER_ITER)

        def add_points():
            nonlocal cur, image_token
            if image_token != self._image_token:
                return
            batch = visible[cur:cur + N_POINTS_PER_ITER]

            batch_lat = lat[batch]
            batch_lon = lon[batch]
            batch_selected = selected[batch]

            x, y = self.Projection.latlon_to_easting_northing(
                batch_lat, batch_lon)
            x, y = self.Projection.easting_northing_to_pixel(
                x, y, zoom, origin, map_pane_pos)

            if self._jittering:
                x += (np.random.random(len(x)) - .5) * (self._jittering *
                                                        width)
                y += (np.random.random(len(x)) - .5) * (self._jittering *
                                                        height)

            colors = (self._colorgen.getRGB(
                self._scaled_color_values[batch]).tolist()
                      if self._color_attr else repeat((0xff, 0, 0)))
            sizes = self._sizes[batch] if self._size_attr else repeat(10)

            zipped = zip(x, y, batch_selected, sizes, colors)
            sortkey, penkey, sizekey, brushkey = itemgetter(
                2, 3, 4), itemgetter(2), itemgetter(3), itemgetter(4)
            for is_selected, points in groupby(sorted(zipped, key=sortkey),
                                               key=penkey):
                for size, points in groupby(points, key=sizekey):
                    pensize, pencolor = ((3, Qt.green) if is_selected else
                                         (.7, QColor(0, 0, 0, self._opacity)))
                    size *= self._size_coef
                    if size < 5:
                        pensize /= 3
                    size += pensize
                    size2 = size / 2
                    painter.setPen(Qt.NoPen if size < 5 and not is_selected
                                   else QPen(QBrush(pencolor), pensize))

                    for color, points in groupby(points, key=brushkey):
                        color = tuple(color) + (self._opacity, )
                        painter.setBrush(QBrush(QColor(*color)))
                        for x, y, *_ in points:
                            painter.drawEllipse(x - size2, y - size2, size,
                                                size)

            im.save(self._overlay_image_path, 'PNG')
            self.evalJS('markersImageLayer.setUrl("{}#{}"); 0;'.format(
                self._overlay_image_path, np.random.random()))

            cur += N_POINTS_PER_ITER
            if cur < len(visible):
                QTimer.singleShot(10, add_points)
                self._owwidget.progressBarAdvance(100 / n_iters, None)
            else:
                self._owwidget.progressBarFinished(None)

        self._owwidget.progressBarFinished(None)
        self._owwidget.progressBarInit(None)
        QTimer.singleShot(10, add_points)