mirror of
https://github.com/apache/superset.git
synced 2026-04-21 17:14:57 +00:00
[SIP-5] Refactor MapBox (#5783)
* Break MapBox into smaller pieces * Replace React.createElement with regular jsx * detach setControlValue * enable render trigger * Pass explicit props rather than pass all that exists in payload.data. Also use formData when possible. * Rename sliceWidth, sliceHeight to width, height. Use deconstructor. Extract function. * use arrow function * fix linting and remove css
This commit is contained in:
committed by
Chris Williams
parent
b461287290
commit
bebbdb85d2
@@ -1802,6 +1802,7 @@ export const controls = {
|
||||
viewport_zoom: {
|
||||
type: 'TextControl',
|
||||
label: t('Zoom'),
|
||||
renderTrigger: true,
|
||||
isFloat: true,
|
||||
default: 11,
|
||||
description: t('Zoom level of the map'),
|
||||
@@ -1813,6 +1814,7 @@ export const controls = {
|
||||
viewport_latitude: {
|
||||
type: 'TextControl',
|
||||
label: t('Default latitude'),
|
||||
renderTrigger: true,
|
||||
default: 37.772123,
|
||||
isFloat: true,
|
||||
description: t('Latitude of default viewport'),
|
||||
@@ -1824,6 +1826,7 @@ export const controls = {
|
||||
viewport_longitude: {
|
||||
type: 'TextControl',
|
||||
label: t('Default longitude'),
|
||||
renderTrigger: true,
|
||||
default: -122.405293,
|
||||
isFloat: true,
|
||||
description: t('Longitude of default viewport'),
|
||||
|
||||
3
superset/assets/src/visualizations/MapBox/MapBox.css
Normal file
3
superset/assets/src/visualizations/MapBox/MapBox.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.mapbox .slice_container div {
|
||||
padding-top: 0px;
|
||||
}
|
||||
225
superset/assets/src/visualizations/MapBox/MapBox.jsx
Normal file
225
superset/assets/src/visualizations/MapBox/MapBox.jsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import MapGL from 'react-map-gl';
|
||||
import Immutable from 'immutable';
|
||||
import supercluster from 'supercluster';
|
||||
import ViewportMercator from 'viewport-mercator-project';
|
||||
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
|
||||
|
||||
import {
|
||||
DEFAULT_LONGITUDE,
|
||||
DEFAULT_LATITUDE,
|
||||
DEFAULT_ZOOM,
|
||||
} from '../../utils/common';
|
||||
import './MapBox.css';
|
||||
|
||||
const NOOP = () => {};
|
||||
const DEFAULT_POINT_RADIUS = 60;
|
||||
const DEFAULT_MAX_ZOOM = 16;
|
||||
|
||||
const propTypes = {
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
aggregatorName: PropTypes.string,
|
||||
clusterer: PropTypes.object,
|
||||
globalOpacity: PropTypes.number,
|
||||
mapStyle: PropTypes.string,
|
||||
mapboxApiKey: PropTypes.string,
|
||||
onViewportChange: PropTypes.func,
|
||||
pointRadius: PropTypes.number,
|
||||
pointRadiusUnit: PropTypes.string,
|
||||
renderWhileDragging: PropTypes.bool,
|
||||
rgb: PropTypes.array,
|
||||
viewportLatitude: PropTypes.number,
|
||||
viewportLongitude: PropTypes.number,
|
||||
viewportZoom: PropTypes.number,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
globalOpacity: 1,
|
||||
onViewportChange: NOOP,
|
||||
pointRadius: DEFAULT_POINT_RADIUS,
|
||||
pointRadiusUnit: 'Pixels',
|
||||
viewportLatitude: DEFAULT_LATITUDE,
|
||||
viewportLongitude: DEFAULT_LONGITUDE,
|
||||
viewportZoom: DEFAULT_ZOOM,
|
||||
};
|
||||
|
||||
class MapBox extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
viewportLatitude: latitude,
|
||||
viewportLongitude: longitude,
|
||||
viewportZoom: zoom,
|
||||
} = this.props;
|
||||
|
||||
this.state = {
|
||||
viewport: {
|
||||
longitude,
|
||||
latitude,
|
||||
zoom,
|
||||
startDragLngLat: [longitude, latitude],
|
||||
},
|
||||
};
|
||||
this.onViewportChange = this.onViewportChange.bind(this);
|
||||
}
|
||||
|
||||
onViewportChange(viewport) {
|
||||
this.setState({ viewport });
|
||||
this.props.onViewportChange(viewport);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
aggregatorName,
|
||||
globalOpacity,
|
||||
mapStyle,
|
||||
mapboxApiKey,
|
||||
pointRadius,
|
||||
pointRadiusUnit,
|
||||
renderWhileDragging,
|
||||
rgb,
|
||||
} = this.props;
|
||||
const { viewport } = this.state;
|
||||
const { latitude, longitude, zoom } = viewport;
|
||||
const mercator = new ViewportMercator({
|
||||
width,
|
||||
height,
|
||||
longitude,
|
||||
latitude,
|
||||
zoom,
|
||||
});
|
||||
const topLeft = mercator.unproject([0, 0]);
|
||||
const bottomRight = mercator.unproject([width, height]);
|
||||
const bbox = [topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]];
|
||||
const clusters = this.props.clusterer.getClusters(bbox, Math.round(zoom));
|
||||
const isDragging = viewport.isDragging === undefined ? false :
|
||||
viewport.isDragging;
|
||||
return (
|
||||
<MapGL
|
||||
{...this.state.viewport}
|
||||
mapStyle={mapStyle}
|
||||
width={width}
|
||||
height={height}
|
||||
mapboxApiAccessToken={mapboxApiKey}
|
||||
onViewportChange={this.onViewportChange}
|
||||
>
|
||||
<ScatterPlotGlowOverlay
|
||||
{...viewport}
|
||||
isDragging={isDragging}
|
||||
width={width}
|
||||
height={height}
|
||||
locations={Immutable.fromJS(clusters)}
|
||||
dotRadius={pointRadius}
|
||||
pointRadiusUnit={pointRadiusUnit}
|
||||
rgb={rgb}
|
||||
globalOpacity={globalOpacity}
|
||||
compositeOperation={'screen'}
|
||||
renderWhileDragging={renderWhileDragging}
|
||||
aggregatorName={aggregatorName}
|
||||
lngLatAccessor={(location) => {
|
||||
const coordinates = location.get('geometry').get('coordinates');
|
||||
return [coordinates.get(0), coordinates.get(1)];
|
||||
}}
|
||||
/>
|
||||
</MapGL>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MapBox.propTypes = propTypes;
|
||||
MapBox.defaultProps = defaultProps;
|
||||
|
||||
function createReducer(aggregatorName, customMetric) {
|
||||
if (aggregatorName === 'sum' || !customMetric) {
|
||||
return (a, b) => a + b;
|
||||
} else if (aggName === 'min') {
|
||||
return Math.min;
|
||||
} else if (aggName === 'max') {
|
||||
return Math.max;
|
||||
}
|
||||
return function (a, b) {
|
||||
if (a instanceof Array) {
|
||||
if (b instanceof Array) {
|
||||
return a.concat(b);
|
||||
}
|
||||
a.push(b);
|
||||
return a;
|
||||
}
|
||||
if (b instanceof Array) {
|
||||
b.push(a);
|
||||
return b;
|
||||
}
|
||||
return [a, b];
|
||||
};
|
||||
}
|
||||
|
||||
function mapbox(slice, payload, setControlValue) {
|
||||
const { formData, selector } = slice;
|
||||
const {
|
||||
customMetric,
|
||||
geoJSON,
|
||||
mapboxApiKey,
|
||||
} = payload.data;
|
||||
const {
|
||||
clustering_radius: clusteringRadius,
|
||||
global_opacity: globalOpacity,
|
||||
mapbox_color: color,
|
||||
mapbox_style: mapStyle,
|
||||
pandas_aggfunc: aggregatorName,
|
||||
point_radius: pointRadius,
|
||||
point_radius_unit: pointRadiusUnit,
|
||||
render_while_dragging: renderWhileDragging,
|
||||
viewport_latitude: viewportLatitude,
|
||||
viewport_longitude: viewportLongitude,
|
||||
viewport_zoom: viewportZoom,
|
||||
} = formData;
|
||||
|
||||
// Validate mapbox color
|
||||
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/
|
||||
.exec(color);
|
||||
if (rgb === null) {
|
||||
slice.error('Color field must be of form \'rgb(%d, %d, %d)\'');
|
||||
return;
|
||||
}
|
||||
|
||||
const clusterer = supercluster({
|
||||
radius: clusteringRadius,
|
||||
maxZoom: DEFAULT_MAX_ZOOM,
|
||||
metricKey: 'metric',
|
||||
metricReducer: createReducer(aggregatorName, customMetric),
|
||||
});
|
||||
clusterer.load(geoJSON.features);
|
||||
|
||||
ReactDOM.render(
|
||||
<MapBox
|
||||
width={slice.width()}
|
||||
height={slice.height()}
|
||||
aggregatorName={aggregatorName}
|
||||
clusterer={clusterer}
|
||||
globalOpacity={globalOpacity}
|
||||
mapStyle={mapStyle}
|
||||
mapboxApiKey={mapboxApiKey}
|
||||
onViewportChange={({ latitude, longitude, zoom }) => {
|
||||
setControlValue('viewport_longitude', longitude);
|
||||
setControlValue('viewport_latitude', latitude);
|
||||
setControlValue('viewport_zoom', zoom);
|
||||
}}
|
||||
pointRadius={pointRadius === 'Auto' ? DEFAULT_POINT_RADIUS : pointRadius}
|
||||
pointRadiusUnit={pointRadiusUnit}
|
||||
renderWhileDragging={renderWhileDragging}
|
||||
rgb={rgb}
|
||||
viewportLatitude={viewportLatitude}
|
||||
viewportLongitude={viewportLongitude}
|
||||
viewportZoom={viewportZoom}
|
||||
/>,
|
||||
document.querySelector(selector),
|
||||
);
|
||||
}
|
||||
|
||||
export default mapbox;
|
||||
@@ -1,28 +1,46 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
import d3 from 'd3';
|
||||
import Immutable from 'immutable';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import MapGL from 'react-map-gl';
|
||||
import Immutable from 'immutable';
|
||||
import supercluster from 'supercluster';
|
||||
import ViewportMercator from 'viewport-mercator-project';
|
||||
|
||||
import {
|
||||
kmToPixels,
|
||||
rgbLuminance,
|
||||
isNumeric,
|
||||
MILES_PER_KM,
|
||||
DEFAULT_LONGITUDE,
|
||||
DEFAULT_LATITUDE,
|
||||
DEFAULT_ZOOM,
|
||||
} from '../utils/common';
|
||||
import './mapbox.css';
|
||||
} from '../../utils/common';
|
||||
|
||||
const NOOP = () => {};
|
||||
const propTypes = {
|
||||
locations: PropTypes.instanceOf(Immutable.List).isRequired,
|
||||
lngLatAccessor: PropTypes.func,
|
||||
renderWhileDragging: PropTypes.bool,
|
||||
globalOpacity: PropTypes.number,
|
||||
dotRadius: PropTypes.number,
|
||||
dotFill: PropTypes.string,
|
||||
compositeOperation: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
lngLatAccessor: location => [location.get(0), location.get(1)],
|
||||
renderWhileDragging: true,
|
||||
dotRadius: 4,
|
||||
dotFill: '#1FBAD6',
|
||||
globalOpacity: 1,
|
||||
// Same as browser default.
|
||||
compositeOperation: 'source-over',
|
||||
};
|
||||
|
||||
const contextTypes = {
|
||||
viewport: PropTypes.object,
|
||||
isDragging: PropTypes.bool,
|
||||
};
|
||||
|
||||
class ScatterPlotGlowOverlay extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.setCanvasRef = this.setCanvasRef.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.redraw();
|
||||
}
|
||||
@@ -30,6 +48,11 @@ class ScatterPlotGlowOverlay extends React.Component {
|
||||
componentDidUpdate() {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
setCanvasRef(element) {
|
||||
this.canvas = element;
|
||||
}
|
||||
|
||||
drawText(ctx, pixel, options = {}) {
|
||||
const IS_DARK_THRESHOLD = 110;
|
||||
const { fontHeight = 0, label = '', radius = 0, rgb = [0, 0, 0], shadow = false } = options;
|
||||
@@ -62,8 +85,7 @@ class ScatterPlotGlowOverlay extends React.Component {
|
||||
redraw() {
|
||||
const props = this.props;
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const canvas = this.refs.overlay;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
const radius = props.dotRadius;
|
||||
const mercator = new ViewportMercator(props);
|
||||
const rgb = props.rgb;
|
||||
@@ -185,9 +207,9 @@ class ScatterPlotGlowOverlay extends React.Component {
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
render() {
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
@@ -198,11 +220,11 @@ class ScatterPlotGlowOverlay extends React.Component {
|
||||
const { globalOpacity } = this.props;
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
return (
|
||||
React.createElement('canvas', {
|
||||
ref: 'overlay',
|
||||
width: width * pixelRatio,
|
||||
height: height * pixelRatio,
|
||||
style: {
|
||||
<canvas
|
||||
ref={this.setCanvasRef}
|
||||
width={width * pixelRatio}
|
||||
height={height * pixelRatio}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
position: 'absolute',
|
||||
@@ -210,184 +232,14 @@ class ScatterPlotGlowOverlay extends React.Component {
|
||||
opacity: globalOpacity,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
ScatterPlotGlowOverlay.propTypes = {
|
||||
locations: PropTypes.instanceOf(Immutable.List).isRequired,
|
||||
lngLatAccessor: PropTypes.func,
|
||||
renderWhileDragging: PropTypes.bool,
|
||||
globalOpacity: PropTypes.number,
|
||||
dotRadius: PropTypes.number,
|
||||
dotFill: PropTypes.string,
|
||||
compositeOperation: PropTypes.string,
|
||||
};
|
||||
|
||||
ScatterPlotGlowOverlay.defaultProps = {
|
||||
lngLatAccessor: location => [location.get(0), location.get(1)],
|
||||
renderWhileDragging: true,
|
||||
dotRadius: 4,
|
||||
dotFill: '#1FBAD6',
|
||||
globalOpacity: 1,
|
||||
// Same as browser default.
|
||||
compositeOperation: 'source-over',
|
||||
};
|
||||
ScatterPlotGlowOverlay.contextTypes = {
|
||||
viewport: PropTypes.object,
|
||||
isDragging: PropTypes.bool,
|
||||
};
|
||||
ScatterPlotGlowOverlay.propTypes = propTypes;
|
||||
ScatterPlotGlowOverlay.defaultProps = defaultProps;
|
||||
ScatterPlotGlowOverlay.contextTypes = contextTypes;
|
||||
|
||||
class MapboxViz extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const longitude = this.props.viewportLongitude || DEFAULT_LONGITUDE;
|
||||
const latitude = this.props.viewportLatitude || DEFAULT_LATITUDE;
|
||||
|
||||
this.state = {
|
||||
viewport: {
|
||||
longitude,
|
||||
latitude,
|
||||
zoom: this.props.viewportZoom || DEFAULT_ZOOM,
|
||||
startDragLngLat: [longitude, latitude],
|
||||
},
|
||||
};
|
||||
this.onViewportChange = this.onViewportChange.bind(this);
|
||||
}
|
||||
|
||||
onViewportChange(viewport) {
|
||||
this.setState({ viewport });
|
||||
this.props.setControlValue('viewport_longitude', viewport.longitude);
|
||||
this.props.setControlValue('viewport_latitude', viewport.latitude);
|
||||
this.props.setControlValue('viewport_zoom', viewport.zoom);
|
||||
}
|
||||
|
||||
render() {
|
||||
const mercator = new ViewportMercator({
|
||||
width: this.props.sliceWidth,
|
||||
height: this.props.sliceHeight,
|
||||
longitude: this.state.viewport.longitude,
|
||||
latitude: this.state.viewport.latitude,
|
||||
zoom: this.state.viewport.zoom,
|
||||
});
|
||||
const topLeft = mercator.unproject([0, 0]);
|
||||
const bottomRight = mercator.unproject([this.props.sliceWidth, this.props.sliceHeight]);
|
||||
const bbox = [topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]];
|
||||
const clusters = this.props.clusterer.getClusters(bbox, Math.round(this.state.viewport.zoom));
|
||||
const isDragging = this.state.viewport.isDragging === undefined ? false :
|
||||
this.state.viewport.isDragging;
|
||||
return (
|
||||
<MapGL
|
||||
{...this.state.viewport}
|
||||
mapStyle={this.props.mapStyle}
|
||||
width={this.props.sliceWidth}
|
||||
height={this.props.sliceHeight}
|
||||
mapboxApiAccessToken={this.props.mapboxApiKey}
|
||||
onViewportChange={this.onViewportChange}
|
||||
>
|
||||
<ScatterPlotGlowOverlay
|
||||
{...this.state.viewport}
|
||||
isDragging={isDragging}
|
||||
width={this.props.sliceWidth}
|
||||
height={this.props.sliceHeight}
|
||||
locations={Immutable.fromJS(clusters)}
|
||||
dotRadius={this.props.pointRadius}
|
||||
pointRadiusUnit={this.props.pointRadiusUnit}
|
||||
rgb={this.props.rgb}
|
||||
globalOpacity={this.props.globalOpacity}
|
||||
compositeOperation={'screen'}
|
||||
renderWhileDragging={this.props.renderWhileDragging}
|
||||
aggregatorName={this.props.aggregatorName}
|
||||
lngLatAccessor={function (location) {
|
||||
const coordinates = location.get('geometry').get('coordinates');
|
||||
return [coordinates.get(0), coordinates.get(1)];
|
||||
}}
|
||||
/>
|
||||
</MapGL>
|
||||
);
|
||||
}
|
||||
}
|
||||
MapboxViz.propTypes = {
|
||||
aggregatorName: PropTypes.string,
|
||||
clusterer: PropTypes.object,
|
||||
setControlValue: PropTypes.func,
|
||||
globalOpacity: PropTypes.number,
|
||||
mapStyle: PropTypes.string,
|
||||
mapboxApiKey: PropTypes.string,
|
||||
pointRadius: PropTypes.number,
|
||||
pointRadiusUnit: PropTypes.string,
|
||||
renderWhileDragging: PropTypes.bool,
|
||||
rgb: PropTypes.array,
|
||||
sliceHeight: PropTypes.number,
|
||||
sliceWidth: PropTypes.number,
|
||||
viewportLatitude: PropTypes.number,
|
||||
viewportLongitude: PropTypes.number,
|
||||
viewportZoom: PropTypes.number,
|
||||
};
|
||||
|
||||
function mapbox(slice, json, setControlValue) {
|
||||
const div = d3.select(slice.selector);
|
||||
const DEFAULT_POINT_RADIUS = 60;
|
||||
const DEFAULT_MAX_ZOOM = 16;
|
||||
|
||||
// Validate mapbox color
|
||||
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(json.data.color);
|
||||
if (rgb === null) {
|
||||
slice.error('Color field must be of form \'rgb(%d, %d, %d)\'');
|
||||
return;
|
||||
}
|
||||
|
||||
const aggName = json.data.aggregatorName;
|
||||
let reducer;
|
||||
|
||||
if (aggName === 'sum' || !json.data.customMetric) {
|
||||
reducer = function (a, b) {
|
||||
return a + b;
|
||||
};
|
||||
} else if (aggName === 'min') {
|
||||
reducer = Math.min;
|
||||
} else if (aggName === 'max') {
|
||||
reducer = Math.max;
|
||||
} else {
|
||||
reducer = function (a, b) {
|
||||
if (a instanceof Array) {
|
||||
if (b instanceof Array) {
|
||||
return a.concat(b);
|
||||
}
|
||||
a.push(b);
|
||||
return a;
|
||||
}
|
||||
if (b instanceof Array) {
|
||||
b.push(a);
|
||||
return b;
|
||||
}
|
||||
return [a, b];
|
||||
};
|
||||
}
|
||||
|
||||
const clusterer = supercluster({
|
||||
radius: json.data.clusteringRadius,
|
||||
maxZoom: DEFAULT_MAX_ZOOM,
|
||||
metricKey: 'metric',
|
||||
metricReducer: reducer,
|
||||
});
|
||||
clusterer.load(json.data.geoJSON.features);
|
||||
|
||||
div.selectAll('*').remove();
|
||||
ReactDOM.render(
|
||||
<MapboxViz
|
||||
{...json.data}
|
||||
rgb={rgb}
|
||||
sliceHeight={slice.height()}
|
||||
sliceWidth={slice.width()}
|
||||
clusterer={clusterer}
|
||||
pointRadius={DEFAULT_POINT_RADIUS}
|
||||
aggregatorName={aggName}
|
||||
setControlValue={setControlValue || NOOP}
|
||||
/>,
|
||||
div.node(),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = mapbox;
|
||||
export default ScatterPlotGlowOverlay;
|
||||
@@ -89,7 +89,7 @@ const vizMap = {
|
||||
[VIZ_TYPES.line_multi]: () =>
|
||||
loadVis(import(/* webpackChunkName: "line_multi" */ './line_multi.js')),
|
||||
[VIZ_TYPES.time_pivot]: loadNvd3,
|
||||
[VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './mapbox.jsx')),
|
||||
[VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './MapBox/MapBox.jsx')),
|
||||
[VIZ_TYPES.markup]: () => loadVis(import(/* webpackChunkName: "markup" */ './markup.js')),
|
||||
[VIZ_TYPES.para]: () =>
|
||||
loadVis(import(/* webpackChunkName: "parallel_coordinates" */ './parallel_coordinates.js')),
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
.mapbox div.widget .slice_container {
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mapbox div.widget .slice_container:active {
|
||||
cursor: grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
|
||||
.mapbox .slice_container div {
|
||||
padding-top: 0px;
|
||||
}
|
||||
Reference in New Issue
Block a user