You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@superset.apache.org by GitBox <gi...@apache.org> on 2018/02/16 01:55:15 UTC

[GitHub] mistercrunch closed pull request #4336: Play scrubber

mistercrunch closed pull request #4336: Play scrubber
URL: https://github.com/apache/incubator-superset/pull/4336
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/superset/assets/package.json b/superset/assets/package.json
index 12c9e66882..c68bd54f30 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -44,6 +44,7 @@
     "@data-ui/sparkline": "^0.0.49",
     "babel-register": "^6.24.1",
     "bootstrap": "^3.3.6",
+    "bootstrap-slider": "^10.0.0",
     "brace": "^0.10.0",
     "brfs": "^1.4.3",
     "cal-heatmap": "3.6.2",
@@ -70,6 +71,7 @@
     "mapbox-gl": "^0.43.0",
     "mathjs": "^3.20.2",
     "moment": "^2.20.1",
+    "mousetrap": "^1.6.1",
     "mustache": "^2.2.1",
     "nvd3": "1.8.6",
     "po2json": "^0.4.5",
@@ -80,6 +82,7 @@
     "react-addons-shallow-compare": "^15.4.2",
     "react-alert": "^2.3.0",
     "react-bootstrap": "^0.31.5",
+    "react-bootstrap-slider": "2.0.1",
     "react-bootstrap-table": "^4.0.2",
     "react-color": "^2.13.8",
     "react-datetime": "2.9.0",
diff --git a/superset/assets/visualizations/PlaySlider.css b/superset/assets/visualizations/PlaySlider.css
new file mode 100644
index 0000000000..e4338d9634
--- /dev/null
+++ b/superset/assets/visualizations/PlaySlider.css
@@ -0,0 +1,21 @@
+.play-slider {
+    height: 100px; 
+    margin-top: -5px;
+}
+
+.slider-selection {
+    background: #efefef;
+}
+
+.slider-handle {
+    background: #b3b3b3;
+}
+
+.slider.slider-horizontal {
+    width: 100% !important;
+}
+
+.slider-button {
+    color: #b3b3b3;
+    margin-right: 5px;
+}
diff --git a/superset/assets/visualizations/PlaySlider.jsx b/superset/assets/visualizations/PlaySlider.jsx
new file mode 100644
index 0000000000..d2e0e41303
--- /dev/null
+++ b/superset/assets/visualizations/PlaySlider.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Row, Col } from 'react-bootstrap';
+
+import Mousetrap from 'mousetrap';
+
+import 'bootstrap-slider/dist/css/bootstrap-slider.min.css';
+import ReactBootstrapSlider from 'react-bootstrap-slider';
+import './PlaySlider.css';
+
+import { t } from '../javascripts/locales';
+
+const propTypes = {
+  start: PropTypes.number.isRequired,
+  step: PropTypes.number.isRequired,
+  end: PropTypes.number.isRequired,
+  values: PropTypes.array.isRequired,
+  onChange: PropTypes.func,
+  loopDuration: PropTypes.number,
+  maxFrames: PropTypes.number,
+  orientation: PropTypes.oneOf(['horizontal', 'vertical']),
+  reversed: PropTypes.bool,
+  disabled: PropTypes.bool,
+};
+
+const defaultProps = {
+  onChange: () => {},
+  loopDuration: 15000,
+  maxFrames: 100,
+  orientation: 'horizontal',
+  reversed: false,
+  disabled: false,
+};
+
+export default class PlaySlider extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = { intervalId: null };
+
+    const range = props.end - props.start;
+    const frames = Math.min(props.maxFrames, range / props.step);
+    const width = range / frames;
+    this.intervalMilliseconds = props.loopDuration / frames;
+    this.increment = width < props.step ? props.step : width - (width % props.step);
+
+    this.onChange = this.onChange.bind(this);
+    this.play = this.play.bind(this);
+    this.pause = this.pause.bind(this);
+    this.step = this.step.bind(this);
+    this.getPlayClass = this.getPlayClass.bind(this);
+    this.formatter = this.formatter.bind(this);
+  }
+  componentDidMount() {
+    Mousetrap.bind(['space'], this.play);
+  }
+  componentWillUnmount() {
+    Mousetrap.unbind(['space']);
+  }
+  onChange(event) {
+    this.props.onChange(event.target.value);
+    if (this.state.intervalId != null) {
+      this.pause();
+    }
+  }
+  getPlayClass() {
+    if (this.state.intervalId == null) {
+      return 'fa fa-play fa-lg slider-button';
+    }
+    return 'fa fa-pause fa-lg slider-button';
+  }
+  play() {
+    if (this.props.disabled) {
+      return;
+    }
+    if (this.state.intervalId != null) {
+      this.pause();
+    } else {
+      const id = setInterval(this.step, this.intervalMilliseconds);
+      this.setState({ intervalId: id });
+    }
+  }
+  pause() {
+    clearInterval(this.state.intervalId);
+    this.setState({ intervalId: null });
+  }
+  step() {
+    if (this.props.disabled) {
+      return;
+    }
+    let values = this.props.values.map(value => value + this.increment);
+    if (values[1] > this.props.end) {
+      const cr = values[0] - this.props.start;
+      values = values.map(value => value - cr);
+    }
+    this.props.onChange(values);
+  }
+  formatter(values) {
+    if (this.props.disabled) {
+      return t('Data has no time steps');
+    }
+
+    let parts = values;
+    if (!Array.isArray(values)) {
+      parts = [values];
+    } else if (values[0] === values[1]) {
+      parts = [values[0]];
+    }
+    return parts.map(value => (new Date(value)).toUTCString()).join(' : ');
+  }
+  render() {
+    return (
+      <Row className="play-slider">
+        <Col md={1} className="padded">
+          <i className={this.getPlayClass()} onClick={this.play} />
+          <i className="fa fa-step-forward fa-lg slider-button " onClick={this.step} />
+        </Col>
+        <Col md={11} className="padded">
+          <ReactBootstrapSlider
+            value={this.props.values}
+            formatter={this.formatter}
+            change={this.onChange}
+            min={this.props.start}
+            max={this.props.end}
+            step={this.props.step}
+            orientation={this.props.orientation}
+            reversed={this.props.reversed}
+            disabled={this.props.disabled ? 'disabled' : 'enabled'}
+          />
+        </Col>
+      </Row>
+    );
+  }
+}
+
+PlaySlider.propTypes = propTypes;
+PlaySlider.defaultProps = defaultProps;
diff --git a/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx b/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx
new file mode 100644
index 0000000000..8d9e03e3fa
--- /dev/null
+++ b/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DeckGLContainer from './DeckGLContainer';
+import PlaySlider from '../PlaySlider';
+
+const propTypes = {
+  getLayers: PropTypes.func.isRequired,
+  start: PropTypes.number.isRequired,
+  end: PropTypes.number.isRequired,
+  step: PropTypes.number.isRequired,
+  values: PropTypes.array.isRequired,
+  disabled: PropTypes.bool,
+  viewport: PropTypes.object.isRequired,
+};
+
+const defaultProps = {
+  disabled: false,
+};
+
+export default class AnimatableDeckGLContainer extends React.Component {
+  constructor(props) {
+    super(props);
+    const { getLayers, start, end, step, values, disabled, viewport, ...other } = props;
+    this.state = { values, viewport };
+    this.other = other;
+  }
+  componentWillReceiveProps(nextProps) {
+    this.setState({ values: nextProps.values, viewport: nextProps.viewport });
+  }
+  render() {
+    const layers = this.props.getLayers(this.state.values);
+    return (
+      <div>
+        <DeckGLContainer
+          {...this.other}
+          viewport={this.state.viewport}
+          layers={layers}
+          onViewportChange={newViewport => this.setState({ viewport: newViewport })}
+        />
+        {!this.props.disabled &&
+        <PlaySlider
+          start={this.props.start}
+          end={this.props.end}
+          step={this.props.step}
+          values={this.state.values}
+          onChange={newValues => this.setState({ values: newValues })}
+        />
+        }
+      </div>
+    );
+  }
+}
+
+AnimatableDeckGLContainer.propTypes = propTypes;
+AnimatableDeckGLContainer.defaultProps = defaultProps;
diff --git a/superset/assets/visualizations/deckgl/layers/scatter.jsx b/superset/assets/visualizations/deckgl/layers/scatter.jsx
index 646a9afef7..5022fbccda 100644
--- a/superset/assets/visualizations/deckgl/layers/scatter.jsx
+++ b/superset/assets/visualizations/deckgl/layers/scatter.jsx
@@ -1,18 +1,45 @@
+/* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */
+
 import React from 'react';
 import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+
 import { ScatterplotLayer } from 'deck.gl';
 
-import DeckGLContainer from './../DeckGLContainer';
+import AnimatableDeckGLContainer from '../AnimatableDeckGLContainer';
+
 import * as common from './common';
 import { getColorFromScheme, hexToRGB } from '../../../javascripts/modules/colors';
 import { unitToRadius } from '../../../javascripts/modules/geo';
 import sandboxedEval from '../../../javascripts/modules/sandbox';
 
+function getStep(timeGrain) {
+  // grain in microseconds
+  const MINUTE = 60 * 1000;
+  const HOUR = 60 * MINUTE;
+  const DAY = 24 * HOUR;
+  const WEEK = 7 * DAY;
+  const MONTH = 30 * DAY;
+  const YEAR = 365 * DAY;
+
+  const milliseconds = {
+    'Time Column': MINUTE,
+    min: MINUTE,
+    hour: HOUR,
+    day: DAY,
+    week: WEEK,
+    month: MONTH,
+    year: YEAR,
+  };
+
+  return milliseconds[timeGrain];
+}
+
 function getPoints(data) {
   return data.map(d => d.position);
 }
 
-function getLayer(formData, payload, slice) {
+function getLayer(formData, payload, slice, inFrame) {
   const fd = formData;
   const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
   const fixedColor = [c.r, c.g, c.b, 255 * c.a];
@@ -41,6 +68,10 @@ function getLayer(formData, payload, slice) {
     data = jsFnMutator(data);
   }
 
+  if (inFrame != null) {
+    data = data.filter(inFrame);
+  }
+
   return new ScatterplotLayer({
     id: `scatter-layer-${fd.slice_id}`,
     data,
@@ -50,6 +81,78 @@ function getLayer(formData, payload, slice) {
   });
 }
 
+const propTypes = {
+  slice: PropTypes.object.isRequired,
+  payload: PropTypes.object.isRequired,
+  setControlValue: PropTypes.func.isRequired,
+  viewport: PropTypes.object.isRequired,
+};
+
+class DeckGLScatter extends React.PureComponent {
+  /* eslint-disable no-unused-vars */
+  static getDerivedStateFromProps(nextProps, prevState) {
+    const fd = nextProps.slice.formData;
+    const timeGrain = fd.time_grain_sqla || fd.granularity || 'min';
+
+    // find start and end based on the data
+    const timestamps = nextProps.payload.data.features.map(f => f.__timestamp);
+    let start = Math.min(...timestamps);
+    let end = Math.max(...timestamps);
+
+    // lock start and end to the closest steps
+    const step = getStep(timeGrain);
+    start -= start % step;
+    end += step - end % step;
+
+    const values = timeGrain != null ? [start, start + step] : [start, end];
+    const disabled = timestamps.every(timestamp => timestamp === null);
+
+    return { start, end, step, values, disabled };
+  }
+  constructor(props) {
+    super(props);
+    this.state = DeckGLScatter.getDerivedStateFromProps(props);
+
+    this.getLayers = this.getLayers.bind(this);
+  }
+  componentWillReceiveProps(nextProps) {
+    this.setState(DeckGLScatter.getDerivedStateFromProps(nextProps, this.state));
+  }
+  getLayers(values) {
+    let inFrame;
+    if (values[0] === values[1] || values[1] === this.end) {
+      inFrame = t => t.__timestamp >= values[0] && t.__timestamp <= values[1];
+    } else {
+      inFrame = t => t.__timestamp >= values[0] && t.__timestamp < values[1];
+    }
+    const layer = getLayer(
+      this.props.slice.formData,
+      this.props.payload,
+      this.props.slice,
+      inFrame);
+
+    return [layer];
+  }
+  render() {
+    return (
+      <AnimatableDeckGLContainer
+        getLayers={this.getLayers}
+        start={this.state.start}
+        end={this.state.end}
+        step={this.state.step}
+        values={this.state.values}
+        disabled={this.state.disabled}
+        viewport={this.props.viewport}
+        mapboxApiAccessToken={this.props.payload.data.mapboxApiKey}
+        mapStyle={this.props.slice.formData.mapbox_style}
+        setControlValue={this.props.setControlValue}
+      />
+    );
+  }
+}
+
+DeckGLScatter.propTypes = propTypes;
+
 function deckScatter(slice, payload, setControlValue) {
   const layer = getLayer(slice.formData, payload, slice);
   const fd = slice.formData;
@@ -66,12 +169,11 @@ function deckScatter(slice, payload, setControlValue) {
   }
 
   ReactDOM.render(
-    <DeckGLContainer
-      mapboxApiAccessToken={payload.data.mapboxApiKey}
-      viewport={viewport}
-      layers={[layer]}
-      mapStyle={fd.mapbox_style}
+    <DeckGLScatter
+      slice={slice}
+      payload={payload}
       setControlValue={setControlValue}
+      viewport={viewport}
     />,
     document.getElementById(slice.containerId),
   );
diff --git a/superset/viz.py b/superset/viz.py
index c09b3cf51a..94daaa85af 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -2003,9 +2003,11 @@ class DeckScatterViz(BaseDeckGLViz):
     viz_type = 'deck_scatter'
     verbose_name = _('Deck.gl - Scatter plot')
     spatial_control_keys = ['spatial']
+    is_timeseries = True
 
     def query_obj(self):
         fd = self.form_data
+        self.is_timeseries = fd.get('time_grain_sqla') or fd.get('granularity')
         self.point_radius_fixed = (
             fd.get('point_radius_fixed') or {'type': 'fix', 'value': 500})
         return super(DeckScatterViz, self).query_obj()
@@ -2022,6 +2024,7 @@ def get_properties(self, d):
             'radius': self.fixed_value if self.fixed_value else d.get(self.metric),
             'cat_color': d.get(self.dim) if self.dim else None,
             'position': d.get('spatial'),
+            '__timestamp': d.get('__timestamp'),
         }
 
     def get_data(self, df):


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
users@infra.apache.org


With regards,
Apache Git Services