feat: make polygon support geojson feature and fix autozoom (#11)

* feat: support standard geojson feature in polygon

* fix: viewport autozoom

* fix: type

* fix: lint

* refactor: renames

* fix: travis

* build: add yarn.lock

* fix: travis

* fix: error message

* fix: storybook

* fix: improt

* fix: address comments

* fix: storybook

* fix: remove yarn.lock

* refactor: viewport

* fix: extension

* fix: extension
This commit is contained in:
Krist Wongsuphasawat 2020-03-05 15:27:40 -08:00 committed by Yongjie Zhao
parent 1a93f58550
commit 940e449bbe
16 changed files with 18352 additions and 98 deletions

View File

@ -50,7 +50,7 @@ webpack.config.js
# Lock files, libs should not have lock files
npm-shrinkwrap.json
package-lock.json
yarn.lock
old-yarn.lock
.*.swp
_gh-pages
_gh-pages
# Now only allow yarn.lock

View File

@ -26,6 +26,7 @@
"access": "public"
},
"dependencies": {
"@types/d3-array": "^2.0.0",
"bootstrap-slider": "^10.0.0",
"d3-array": "^1.2.4",
"d3-color": "^1.2.0",
@ -41,7 +42,7 @@
"react-map-gl": "^4.0.10",
"underscore": "^1.8.3",
"urijs": "^1.18.10",
"viewport-mercator-project": "^6.1.1"
"@math.gl/web-mercator": "^3.1.3"
},
"peerDependencies": {
"@superset-ui/chart": "^0.12.0",

View File

@ -32,7 +32,8 @@ import Legend from './components/Legend';
import { hexToRGB } from './utils/colors';
import { getPlaySliderParams } from './utils/time';
import sandboxedEval from './utils/sandbox';
import { fitViewport } from './layers/common';
// eslint-disable-next-line import/extensions
import fitViewport from './utils/fitViewport';
const { getScale } = CategoricalColorNamespace;
@ -119,9 +120,15 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
const { start, end, getStep, values, disabled } = getPlaySliderParams(timestamps, granularity);
const viewport = props.formData.autozoom
? fitViewport(props.viewport, props.getPoints(features))
: props.viewport;
const { width, height, formData } = props;
let { viewport } = props;
if (formData.autozoom) {
viewport = fitViewport(viewport, {
width,
height,
points: props.getPoints(features),
});
}
return {
start,

View File

@ -26,7 +26,8 @@ import { isEqual } from 'lodash';
import DeckGLContainer from './DeckGLContainer';
import CategoricalDeckGLContainer from './CategoricalDeckGLContainer';
import { fitViewport } from './layers/common';
// eslint-disable-next-line import/extensions
import fitViewport from './utils/fitViewport';
const propTypes = {
formData: PropTypes.object.isRequired,
@ -48,10 +49,17 @@ export function createDeckGLComponent(getLayer, getPoints) {
class Component extends React.PureComponent {
constructor(props) {
super(props);
const originalViewport = props.viewport;
const viewport = props.formData.autozoom
? fitViewport(originalViewport, getPoints(props.payload.data.features))
: originalViewport;
const { width, height, formData } = props;
let { viewport } = props;
if (formData.autozoom) {
viewport = fitViewport(viewport, {
width,
height,
points: getPoints(props.payload.data.features),
});
}
this.state = {
viewport,
layer: this.computeLayer(props),

View File

@ -31,16 +31,16 @@ import Legend from '../../components/Legend';
import TooltipRow from '../../TooltipRow';
import { getBuckets, getBreakPointColorScaler } from '../../utils';
import { commonLayerProps, fitViewport } from '../common';
import { commonLayerProps } from '../common';
import { getPlaySliderParams } from '../../utils/time';
import sandboxedEval from '../../utils/sandbox';
// eslint-disable-next-line import/extensions
import getPointsFromPolygon from '../../utils/getPointsFromPolygon';
// eslint-disable-next-line import/extensions
import fitViewport from '../../utils/fitViewport';
const DOUBLE_CLICK_TRESHOLD = 250; // milliseconds
function getPoints(features) {
return features.flatMap(d => d.polygon);
}
function getElevation(d, colorScaler) {
/* in deck.gl 5.3.4 (used in Superset as of 2018-10-24), if a polygon has
* opacity zero it will make everything behind it have opacity zero,
@ -114,7 +114,7 @@ export function getLayer(formData, payload, onAddFilter, setTooltip, selected, o
pickable: true,
filled: fd.filled,
stroked: fd.stroked,
getPolygon: d => d.polygon,
getPolygon: getPointsFromPolygon,
getFillColor: colorScaler,
getLineColor: [sc.r, sc.g, sc.b, 255 * sc.a],
getLineWidth: fd.line_width,
@ -154,26 +154,32 @@ class DeckGLPolygon extends React.Component {
}
static getDerivedStateFromProps(props, state) {
const { width, height, formData, payload } = props;
// the state is computed only from the payload; if it hasn't changed, do
// not recompute state since this would reset selections and/or the play
// slider position due to changes in form controls
if (state && props.payload.form_data === state.formData) {
if (state && payload.form_data === state.formData) {
return null;
}
const features = props.payload.data.features || [];
const features = payload.data.features || [];
const timestamps = features.map(f => f.__timestamp);
// the granularity has to be read from the payload form_data, not the
// props formData which comes from the instantaneous controls state
const granularity =
props.payload.form_data.time_grain_sqla || props.payload.form_data.granularity || 'P1D';
const granularity = payload.form_data.time_grain_sqla || payload.form_data.granularity || 'P1D';
const { start, end, getStep, values, disabled } = getPlaySliderParams(timestamps, granularity);
const viewport = props.formData.autozoom
? fitViewport(props.viewport, getPoints(features))
: props.viewport;
let { viewport } = props;
if (formData.autozoom) {
viewport = fitViewport(viewport, {
width,
height,
points: features.flatMap(getPointsFromPolygon),
});
}
return {
start,
@ -184,7 +190,7 @@ class DeckGLPolygon extends React.Component {
viewport,
selected: [],
lastClick: 0,
formData: props.payload.form_data,
formData: payload.form_data,
};
}

View File

@ -27,8 +27,10 @@ import { t } from '@superset-ui/translation';
import AnimatableDeckGLContainer from '../../AnimatableDeckGLContainer';
import { getPlaySliderParams } from '../../utils/time';
import sandboxedEval from '../../utils/sandbox';
import { commonLayerProps, fitViewport } from '../common';
import { commonLayerProps } from '../common';
import TooltipRow from '../../TooltipRow';
// eslint-disable-next-line import/extensions
import fitViewport from '../../utils/fitViewport';
function getPoints(data) {
return data.map(d => d.position);
@ -123,10 +125,16 @@ class DeckGLScreenGrid extends React.PureComponent {
props.payload.form_data.time_grain_sqla || props.payload.form_data.granularity || 'P1D';
const { start, end, getStep, values, disabled } = getPlaySliderParams(timestamps, granularity);
const { width, height, formData } = props;
const viewport = props.formData.autozoom
? fitViewport(props.viewport, getPoints(features))
: props.viewport;
let { viewport } = props;
if (formData.autozoom) {
viewport = fitViewport(viewport, {
width,
height,
points: getPoints(features),
});
}
return {
start,

View File

@ -16,77 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fitBounds } from 'viewport-mercator-project';
import * as d3array from 'd3-array';
import sandboxedEval from '../utils/sandbox';
const PADDING = 0.25;
const GEO_BOUNDS = {
LAT_MAX: 90,
LAT_MIN: -90,
LNG_MAX: 180,
LNG_MIN: -180,
};
/**
* Get the latitude bounds if latitude is a single coordinate
* @param latExt Latitude range
*/
function getLatBoundsForSingleCoordinate(latExt) {
const latMin =
latExt[0] - PADDING < GEO_BOUNDS.LAT_MIN ? GEO_BOUNDS.LAT_MIN : latExt[0] - PADDING;
const latMax =
latExt[1] + PADDING > GEO_BOUNDS.LAT_MAX ? GEO_BOUNDS.LAT_MAX : latExt[1] + PADDING;
return [latMin, latMax];
}
/**
* Get the longitude bounds if longitude is a single coordinate
* @param lngExt Longitude range
*/
function getLngBoundsForSingleCoordinate(lngExt) {
const lngMin =
lngExt[0] - PADDING < GEO_BOUNDS.LNG_MIN ? GEO_BOUNDS.LNG_MIN : lngExt[0] - PADDING;
const lngMax =
lngExt[1] + PADDING > GEO_BOUNDS.LNG_MAX ? GEO_BOUNDS.LNG_MAX : lngExt[1] + PADDING;
return [lngMin, lngMax];
}
export function getBounds(points) {
const latExt = d3array.extent(points, d => d[1]);
const lngExt = d3array.extent(points, d => d[0]);
const latBounds = latExt[0] === latExt[1] ? getLatBoundsForSingleCoordinate(latExt) : latExt;
const lngBounds = lngExt[0] === lngExt[1] ? getLngBoundsForSingleCoordinate(lngExt) : lngExt;
return [
[lngBounds[0], latBounds[0]],
[lngBounds[1], latBounds[1]],
];
}
export function fitViewport(viewport, points, padding = 10) {
try {
const bounds = getBounds(points);
return {
...viewport,
...fitBounds({
bounds,
height: viewport.height,
padding,
width: viewport.width,
}),
};
} catch (error) {
/* eslint no-console: 0 */
console.error('Could not auto zoom', error);
return viewport;
}
}
export function commonLayerProps(formData, setTooltip, setTooltipContent, onSelect) {
const fd = formData;
let onHover;

View File

@ -0,0 +1,23 @@
import { extent as d3Extent } from 'd3-array';
import { Point, Range } from './types';
const LAT_LIMIT: Range = [-90, 90];
const LNG_LIMIT: Range = [-180, 180];
/**
* Expand a coordinate range by `padding` and within limits, if needed
*/
function expandIfNeeded([curMin, curMax]: Range, [minBound, maxBound]: Range, padding = 0.25) {
return curMin < curMax
? [curMin, curMax]
: [Math.max(minBound, curMin - padding), Math.min(maxBound, curMax + padding)];
}
export default function computeBoundsFromPoints(points: Point[]) {
const latBounds = expandIfNeeded(d3Extent(points, (x: Point) => x[1]) as Range, LAT_LIMIT);
const lngBounds = expandIfNeeded(d3Extent(points, (x: Point) => x[0]) as Range, LNG_LIMIT);
return [
[lngBounds[0], latBounds[0]],
[lngBounds[1], latBounds[1]],
];
}

View File

@ -0,0 +1,50 @@
import { fitBounds } from '@math.gl/web-mercator';
import computeBoundsFromPoints from './computeBoundsFromPoints';
import { Point } from './types';
type Viewport = {
longtitude: number;
latitude: number;
zoom: number;
bearing?: number;
pitch?: number;
};
type FitViewportOptions = {
points: Point[];
width: number;
height: number;
minExtent?: number;
maxZoom?: number;
offset?: [number, number];
padding?: number;
};
export default function fitViewport(
originalViewPort: Viewport,
{ points, width, height, minExtent, maxZoom, offset, padding = 20 }: FitViewportOptions,
) {
const { bearing, pitch } = originalViewPort;
const bounds = computeBoundsFromPoints(points);
try {
return {
...fitBounds({
bounds,
width,
height,
minExtent,
maxZoom,
offset,
padding,
}),
bearing,
pitch,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error('Could not fit viewport', error);
}
return originalViewPort;
}

View File

@ -0,0 +1,26 @@
import { Point } from './types';
/** Format originally used by the Polygon plugin */
type CustomPolygonFeature = {
polygon: Point[];
};
/**
* Format that is geojson standard
* https://geojson.org/geojson-spec.html
*/
type GeojsonPolygonFeature = {
polygon: {
type: 'Feature';
geometry: {
type: 'Polygon';
coordinates: Point[][];
};
};
};
export default function getPointsFromPolygon(
feature: CustomPolygonFeature | GeojsonPolygonFeature,
) {
return 'geometry' in feature.polygon ? feature.polygon.geometry.coordinates[0] : feature.polygon;
}

View File

@ -0,0 +1,5 @@
// range and point actually have different value ranges
// and also are different concept-wise
export type Range = [number, number];
export type Point = [number, number];

View File

@ -0,0 +1,38 @@
import getPointsFromPolygon from '../../src/utils/getPointsFromPolygon';
describe('getPointsFromPolygon', () => {
it('handle original input', () => {
expect(
getPointsFromPolygon({
polygon: [
[1, 2],
[3, 4],
],
}),
).toEqual([
[1, 2],
[3, 4],
]);
});
it('handle geojson features', () => {
expect(
getPointsFromPolygon({
polygon: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[1, 2],
[3, 4],
],
],
},
},
}),
).toEqual([
[1, 2],
[3, 4],
]);
});
});

View File

@ -3,6 +3,7 @@
import React from 'react';
import { SuperChart } from '@superset-ui/chart';
import payload from './payload';
import geojsonPayload from './geojsonPayload';
import dummyDatasource from '../../../shared/dummyDatasource';
export default [
@ -72,4 +73,52 @@ export default [
storyName: 'Basic',
storyPath: 'legacy-|preset-chart-deckgl|PolygonChartPlugin',
},
{
renderStory: () => (
<SuperChart
chartType="deck_polygon"
width={400}
height={400}
datasource={dummyDatasource}
queryData={geojsonPayload}
formData={{
datasource: '9__table',
viz_type: 'deck_polygon',
time_range: '+:+',
line_column: 'contour',
line_type: 'json',
adhoc_filters: [],
metric: 'count',
point_radius_fixed: { type: 'fix', value: 1000 },
row_limit: 10000,
reverse_long_lat: false,
filter_nulls: true,
mapbox_style: 'mapbox://styles/mapbox/light-v9',
viewport: {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
zoom: 1,
bearing: 0,
pitch: 0,
},
autozoom: true,
fill_color_picker: { a: 1, b: 73, g: 65, r: 3 },
stroke_color_picker: { a: 1, b: 135, g: 122, r: 0 },
filled: true,
stroked: false,
extruded: true,
multiplier: 1,
line_width: 10,
linear_color_scheme: 'blue_white_yellow',
opacity: 80,
num_buckets: 5,
table_filter: false,
toggle_polygons: true,
legend_position: 'tr',
}}
/>
),
storyName: 'Single Polygon in geojson format',
storyPath: 'legacy-|preset-chart-deckgl|PolygonChartPlugin',
},
];

View File

@ -0,0 +1,112 @@
export default {
cache_key: '31946c4488d1899827d283b668d83281',
cached_dttm: '2020-03-04T22:40:59',
cache_timeout: 129600,
error: null,
form_data: {
datasource: '93829__table',
viz_type: 'deck_polygon',
url_params: {},
time_range_endpoints: ['inclusive', 'exclusive'],
granularity_sqla: null,
time_range: '100 years ago : ',
line_column: 'geometry',
line_type: 'json',
adhoc_filters: [
{
clause: 'WHERE',
expressionType: 'SIMPLE',
filterOptionName: '4ea1a468-43f9-45f0-9655-576d2ab04bc1',
comparator: '',
operator: 'IS NOT NULL',
subject: 'geometry',
},
],
metric: 'count',
point_radius_fixed: {
type: 'fix',
value: 1000,
},
row_limit: 1000,
reverse_long_lat: false,
filter_nulls: true,
mapbox_style: 'mapbox://styles/mapbox/light-v9',
viewport: {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
zoom: 1,
bearing: 0,
pitch: 0,
},
autozoom: true,
fill_color_picker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
stroke_color_picker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
filled: true,
stroked: false,
extruded: true,
multiplier: 1,
line_width: 10,
linear_color_scheme: 'blue_white_yellow',
opacity: 80,
num_buckets: 5,
table_filter: false,
toggle_polygons: true,
legend_position: 'tr',
legend_format: null,
js_columns: [],
where: '',
having: '',
having_filters: [],
filters: [
{
col: 'geometry',
op: 'IS NOT NULL',
val: '',
},
],
},
is_cached: true,
status: 'success',
stacktrace: null,
rowcount: 1,
data: {
features: [
{
count: 1,
polygon: {
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [
[
[-149.95132113447022, 61.1310423022678],
[-149.95386039742468, 61.12975642234931],
[-149.9529189574033, 61.128200857856946],
[-149.94943860572158, 61.12793112735274],
[-149.9468993514573, 61.12921688848983],
[-149.94784044016444, 61.1307724989074],
[-149.95132113447022, 61.1310423022678],
],
],
},
},
__timestamp: null,
elevation: 0,
},
],
mapboxApiKey:
'pk.eyJ1IjoiZ2tlZWUiLCJhIjoiY2lvbmN5dXhtMDA4NXRybTJjZWU2ZHVxOSJ9.CJG_6Oz52y5yI5cr3Ct_aQ',
metricLabels: ['count'],
},
};

File diff suppressed because it is too large Load Diff