zipkin-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From adrianc...@apache.org
Subject [incubator-zipkin] branch master updated: Improve MiniTimeline (#2529)
Date Thu, 09 May 2019 06:50:37 GMT
This is an automated email from the ASF dual-hosted git repository.

adriancole pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-zipkin.git


The following commit(s) were added to refs/heads/master by this push:
     new ad9fab1  Improve MiniTimeline (#2529)
ad9fab1 is described below

commit ad9fab1a9e33e9404d0ad57321fc801b97a9356a
Author: tacigar <tacigar@users.noreply.github.com>
AuthorDate: Thu May 9 15:50:32 2019 +0900

    Improve MiniTimeline (#2529)
    
    
    
    * Add drag&drop feature
    
    * Fix bug not rendering time markers
    
    * Add step props to rc-clider range
    
    * Use corsor:col-resize to MiniTimelineGraph
---
 zipkin-lens/package-lock.json                      |  31 +++
 zipkin-lens/package.json                           |   1 +
 .../scss/components/_mini-timeline-graph.scss      |   7 +
 .../scss/components/_mini-timeline-label.scss      |  26 +++
 zipkin-lens/scss/components/_mini-timeline.scss    |  36 ---
 zipkin-lens/scss/custom/_rc-slider.scss            |   7 +
 zipkin-lens/scss/main.scss                         |   4 +
 .../components/MiniTimeline/MiniTimelineGraph.js   | 187 +++++++++++++++
 .../MiniTimeline/MiniTimelineGraph.test.js         |  37 +++
 .../components/MiniTimeline/MiniTimelineLabel.js   |  65 ++++++
 .../MiniTimeline/MiniTimelineLabel.test.js         |  42 ++++
 .../components/MiniTimeline/MiniTimelineSlider.js  |  78 +++++++
 .../MiniTimeline/MiniTimelineSlider.test.js        |  69 ++++++
 .../MiniTimeline/MiniTimelineTimeMarkers.js        |  48 ++++
 .../MiniTimeline/MiniTimelineTimeMarkers.test.js   |  30 +++
 zipkin-lens/src/components/MiniTimeline/index.js   | 257 +++------------------
 .../src/components/MiniTimeline/index.test.js      |  89 +++++++
 zipkin-lens/src/components/MiniTimeline/util.js    |  35 +++
 .../src/components/MiniTimeline/util.test.js       |  44 ++++
 19 files changed, 832 insertions(+), 261 deletions(-)

diff --git a/zipkin-lens/package-lock.json b/zipkin-lens/package-lock.json
index ed64abd..6db1a0b 100644
--- a/zipkin-lens/package-lock.json
+++ b/zipkin-lens/package-lock.json
@@ -10715,6 +10715,27 @@
         "react-lifecycles-compat": "^3.0.4"
       }
     },
+    "rc-slider": {
+      "version": "8.6.9",
+      "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-8.6.9.tgz",
+      "integrity": "sha512-v5XwSARCyKGkalV7c54jwiuPNh8pGUg0i1opVD8YJVd8zQqbxepRoGmEE4xwRTxjR7Goao6/ARc7l2dGoPwZsg==",
+      "requires": {
+        "babel-runtime": "6.x",
+        "classnames": "^2.2.5",
+        "prop-types": "^15.5.4",
+        "rc-tooltip": "^3.7.0",
+        "rc-util": "^4.0.4",
+        "shallowequal": "^1.0.1",
+        "warning": "^4.0.3"
+      },
+      "dependencies": {
+        "shallowequal": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+          "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
+        }
+      }
+    },
     "rc-time-picker": {
       "version": "3.6.2",
       "resolved": "https://registry.npmjs.org/rc-time-picker/-/rc-time-picker-3.6.2.tgz",
@@ -10726,6 +10747,16 @@
         "rc-trigger": "^2.2.0"
       }
     },
+    "rc-tooltip": {
+      "version": "3.7.3",
+      "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-3.7.3.tgz",
+      "integrity": "sha512-dE2ibukxxkrde7wH9W8ozHKUO4aQnPZ6qBHtrTH9LoO836PjDdiaWO73fgPB05VfJs9FbZdmGPVEbXCeOP99Ww==",
+      "requires": {
+        "babel-runtime": "6.x",
+        "prop-types": "^15.5.8",
+        "rc-trigger": "^2.2.2"
+      }
+    },
     "rc-trigger": {
       "version": "2.6.2",
       "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-2.6.2.tgz",
diff --git a/zipkin-lens/package.json b/zipkin-lens/package.json
index c0cb37a..9c7d0df 100755
--- a/zipkin-lens/package.json
+++ b/zipkin-lens/package.json
@@ -68,6 +68,7 @@
     "prop-types": "^15.6.2",
     "query-string": "^6.1.0",
     "rc-calendar": "^9.7.10",
+    "rc-slider": "^8.6.9",
     "rc-time-picker": "^3.4.0",
     "react": "^16.4.1",
     "react-chartjs-2": "^2.7.4",
diff --git a/zipkin-lens/scss/components/_mini-timeline-graph.scss b/zipkin-lens/scss/components/_mini-timeline-graph.scss
new file mode 100644
index 0000000..c56234b
--- /dev/null
+++ b/zipkin-lens/scss/components/_mini-timeline-graph.scss
@@ -0,0 +1,7 @@
+.mini-timeline-graph {
+  background-color: $gray-10;
+  border: 1px solid $gray-9;
+  border-radius: 2px;
+  overflow: hidden;
+  cursor: col-resize;
+}
diff --git a/zipkin-lens/scss/components/_mini-timeline-label.scss b/zipkin-lens/scss/components/_mini-timeline-label.scss
new file mode 100644
index 0000000..5bb18be
--- /dev/null
+++ b/zipkin-lens/scss/components/_mini-timeline-label.scss
@@ -0,0 +1,26 @@
+.mini-timeline-label {
+  position: relative;
+  height: 14px;
+}
+
+.mini-timeline-label__label-wrapper {
+  position: absolute;
+}
+
+.mini-timeline-label__label {
+  color: $dark-2;
+  font-size: $font-size-xs;
+  position: absolute;
+  left: -20px;
+
+  &--first {
+    left: 2px;
+    position: absolute;
+  }
+
+  &--last {
+    left: initial;
+    right: 2px;
+    position: absolute;
+  }
+}
diff --git a/zipkin-lens/scss/components/_mini-timeline.scss b/zipkin-lens/scss/components/_mini-timeline.scss
index 13337f0..ceb34f7 100644
--- a/zipkin-lens/scss/components/_mini-timeline.scss
+++ b/zipkin-lens/scss/components/_mini-timeline.scss
@@ -20,39 +20,3 @@
   padding: 2px;
   margin: 5px;
 }
-
-.mini-timeline__time-marker-labels-wrapper {
-  position: relative;
-  height: 14px;
-}
-
-.mini-timeline__time-marker {
-  position: absolute;
-}
-
-.mini-timeline__time-marker-label {
-  color: $dark-2;
-  font-size: $font-size-xs;
-  position: absolute;
-  left: -20px;
-
-  &--first {
-    left: 2px;
-    position: absolute;
-  }
-
-  &--last {
-    left: initial;
-    right: 2px;
-    position: absolute;
-  }
-}
-
-.mini-timeline__graph {
-  padding: 2px;
-  background-color: $gray-10;
-  height: 75px;
-  border: 1px solid $gray-9;
-  border-radius: 2px;
-  cursor: pointer;
-}
diff --git a/zipkin-lens/scss/custom/_rc-slider.scss b/zipkin-lens/scss/custom/_rc-slider.scss
new file mode 100644
index 0000000..0b7bbd8
--- /dev/null
+++ b/zipkin-lens/scss/custom/_rc-slider.scss
@@ -0,0 +1,7 @@
+.rc-slider {
+  padding: 0;
+}
+
+.rc-slider-rail {
+  background-color: $gray-5;
+}
diff --git a/zipkin-lens/scss/main.scss b/zipkin-lens/scss/main.scss
index efa2d9f..191c929 100644
--- a/zipkin-lens/scss/main.scss
+++ b/zipkin-lens/scss/main.scss
@@ -16,6 +16,7 @@
 //
 
 @import '../node_modules/rc-calendar/assets/index.css';
+@import '../node_modules/rc-slider/assets/index.css';
 @import '../node_modules/rc-time-picker/assets/index.css';
 @import '../node_modules/react-table/react-table.css';
 
@@ -26,6 +27,7 @@
 @import 'base/form';
 
 @import 'custom/rc-calendar';
+@import 'custom/rc-slider';
 @import 'custom/rc-time-picker';
 @import 'custom/react-modal';
 @import 'custom/react-select';
@@ -47,6 +49,8 @@
 @import 'components/global-search';
 @import 'components/loading-overlay';
 @import 'components/mini-timeline';
+@import 'components/mini-timeline-graph';
+@import 'components/mini-timeline-label';
 @import 'components/search-condition';
 @import 'components/service-name-badge';
 @import 'components/sidebar';
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineGraph.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineGraph.js
new file mode 100644
index 0000000..6e68b5f
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineGraph.js
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import MiniTimelineTimeMarkers from './MiniTimelineTimeMarkers';
+import { getGraphHeight, getGraphLineHeight } from './util';
+import { getServiceNameColor } from '../../util/color';
+import { detailedSpansPropTypes } from '../../prop-types';
+
+const leftMouseButton = 0;
+
+const propTypes = {
+  spans: detailedSpansPropTypes.isRequired,
+  startTs: PropTypes.number.isRequired,
+  endTs: PropTypes.number.isRequired,
+  duration: PropTypes.number.isRequired,
+  onStartAndEndTsChange: PropTypes.func.isRequired,
+  numTimeMarkers: PropTypes.number.isRequired,
+};
+
+class MiniTimelineGraph extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isDragging: false,
+      dragStartX: null,
+      dragCurrentX: null,
+    };
+    this.element = undefined;
+    this.handleMouseDown = this.handleMouseDown.bind(this);
+    this.handleMouseMove = this.handleMouseMove.bind(this);
+    this.handleMouseUp = this.handleMouseUp.bind(this);
+  }
+
+  getPositionX(clientX) {
+    const { left, width } = this.element.getBoundingClientRect();
+    return (clientX - left) / width;
+  }
+
+  handleMouseDown(event) {
+    if (event.button !== leftMouseButton) {
+      return;
+    }
+    const currentX = this.getPositionX(event.clientX);
+    this.setState({
+      isDragging: true,
+      dragStartX: currentX,
+      dragCurrentX: currentX,
+    });
+    window.addEventListener('mousemove', this.handleMouseMove);
+    window.addEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleMouseMove(event) {
+    this.setState({ dragCurrentX: this.getPositionX(event.clientX) });
+  }
+
+  handleMouseUp(event) {
+    const { duration, onStartAndEndTsChange } = this.props;
+    const { dragStartX } = this.state;
+    this.setState({ isDragging: false });
+
+    let startTs;
+    let endTs;
+    const currentX = this.getPositionX(event.clientX);
+    if (currentX > dragStartX) {
+      startTs = Math.max(dragStartX * duration, 0);
+      endTs = Math.min(currentX * duration, duration);
+    } else {
+      startTs = Math.max(currentX * duration, 0);
+      endTs = Math.min(dragStartX * duration, duration);
+    }
+    onStartAndEndTsChange(startTs, endTs);
+
+    window.removeEventListener('mousemove', this.handleMouseMove);
+    window.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  render() {
+    const {
+      spans, startTs, endTs, duration, numTimeMarkers,
+    } = this.props;
+    const { isDragging, dragStartX, dragCurrentX } = this.state;
+    const graphHeight = getGraphHeight(spans.length);
+    const graphLineHeight = getGraphLineHeight(spans.length);
+    return (
+      <div
+        className="mini-timeline-graph"
+        style={{ height: graphHeight }}
+        ref={(element) => { this.element = element; }}
+        role="presentation"
+        onMouseDown={this.handleMouseDown}
+      >
+        <svg version="1.1" width="100%" height={graphHeight} xmlns="http://www.w3.org/2000/svg">
+          <MiniTimelineTimeMarkers
+            height={graphHeight}
+            numTimeMarkers={numTimeMarkers}
+          />
+          {
+            spans.map((span, i) => (
+              <rect
+                key={span.spanId}
+                width={`${span.width}%`}
+                height={graphLineHeight}
+                x={`${span.left}%`}
+                y={i * graphLineHeight}
+                fill={getServiceNameColor(span.serviceName)}
+              />
+            ))
+          }
+          {
+            isDragging
+              ? (
+                <g stroke="#999" strokeWidth="1">
+                  <line
+                    x1={`${dragStartX * 100}%`}
+                    x2={`${dragStartX * 100}%`}
+                    y1={0}
+                    y2={graphHeight}
+                  />
+                  <line
+                    x1={`${dragStartX * 100}%`}
+                    x2={`${dragCurrentX * 100}%`}
+                    y1={graphHeight / 2}
+                    y2={graphHeight / 2}
+
+                  />
+                  <line
+                    x1={`${dragCurrentX * 100}%`}
+                    x2={`${dragCurrentX * 100}%`}
+                    y1={0}
+                    y2={graphHeight}
+                  />
+                </g>
+              )
+              : null
+          }
+          {
+            startTs
+              ? (
+                <rect
+                  width={`${startTs / duration * 100}%`}
+                  height={graphHeight}
+                  x="0"
+                  y="0"
+                  fill="rgba(50, 50, 50, 0.2)"
+                />
+              )
+              : null
+          }
+          {
+            endTs
+              ? (
+                <rect
+                  width={`${(duration - endTs) / duration * 100}%`}
+                  height={graphHeight}
+                  x={`${endTs / duration * 100}%`}
+                  y="0"
+                  fill="rgba(50, 50, 50, 0.2)"
+                />
+              )
+              : null
+          }
+        </svg>
+      </div>
+    );
+  }
+}
+
+MiniTimelineGraph.propTypes = propTypes;
+
+export default MiniTimelineGraph;
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineGraph.test.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineGraph.test.js
new file mode 100644
index 0000000..390f55f
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineGraph.test.js
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import MiniTimelineGraph from './MiniTimelineGraph';
+
+// TODO: need more tests.
+describe('<MiniTimelineGraph />', () => {
+  it('should be rendered', () => {
+    const wrapper = shallow(
+      <MiniTimelineGraph
+        spans={[]}
+        startTs={0}
+        endTs={10}
+        duration={10}
+        onStartAndEndTsChange={jest.fn()}
+        numTimeMarkers={5}
+      />,
+    );
+    expect(wrapper.find('.mini-timeline-graph').length).toBe(1);
+  });
+});
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineLabel.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineLabel.js
new file mode 100644
index 0000000..8ec3244
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineLabel.js
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { formatDuration } from '../../util/timestamp';
+
+const propTypes = {
+  numTimeMarkers: PropTypes.number.isRequired,
+  duration: PropTypes.number.isRequired,
+};
+
+const MiniTimelineLabel = ({ numTimeMarkers, duration }) => {
+  const timeMarkers = [];
+  for (let i = 0; i < numTimeMarkers; i += 1) {
+    const label = formatDuration((i / (numTimeMarkers - 1)) * duration);
+    const portion = i / (numTimeMarkers - 1);
+
+    let modifier = '';
+    if (portion === 0) {
+      modifier = '--first';
+    } else if (portion >= 1) {
+      modifier = '--last';
+    }
+
+    timeMarkers.push(
+      <div
+        key={portion}
+        className="mini-timeline-label__label-wrapper"
+        style={{ left: `${portion * 100}%` }}
+        data-test="label-wrapper"
+      >
+        <span
+          className={`mini-timeline-label__label mini-timeline-label__label${modifier}`}
+          data-test="label"
+        >
+          {label}
+        </span>
+      </div>,
+    );
+  }
+  return (
+    <div className="mini-timeline-label">
+      {timeMarkers}
+    </div>
+  );
+};
+
+MiniTimelineLabel.propTypes = propTypes;
+
+export default MiniTimelineLabel;
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineLabel.test.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineLabel.test.js
new file mode 100644
index 0000000..0e5f97c
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineLabel.test.js
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import MiniTimelineLabel from './MiniTimelineLabel';
+
+describe('<MiniTimelineLabel />', () => {
+  it('should set proper positions', () => {
+    const wrapper = shallow(<MiniTimelineLabel numTimeMarkers={5} duration={300} />);
+    const labelWrappers = wrapper.find('[data-test="label-wrapper"]');
+    expect(labelWrappers.at(0).prop('style')).toEqual({ left: '0%' });
+    expect(labelWrappers.at(1).prop('style')).toEqual({ left: '25%' });
+    expect(labelWrappers.at(2).prop('style')).toEqual({ left: '50%' });
+    expect(labelWrappers.at(3).prop('style')).toEqual({ left: '75%' });
+    expect(labelWrappers.at(4).prop('style')).toEqual({ left: '100%' });
+  });
+
+  it('should set proper modifiers', () => {
+    const wrapper = shallow(<MiniTimelineLabel numTimeMarkers={5} duration={300} />);
+    const labelWrappers = wrapper.find('[data-test="label"]');
+    expect(labelWrappers.at(0).hasClass('mini-timeline-label__label--first'));
+    expect(labelWrappers.at(1).hasClass('mini-timeline-label__label--first'));
+    expect(labelWrappers.at(2).hasClass('mini-timeline-label__label--first'));
+    expect(labelWrappers.at(3).hasClass('mini-timeline-label__label--first'));
+    expect(labelWrappers.at(4).hasClass('mini-timeline-label__label--last'));
+  });
+});
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineSlider.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineSlider.js
new file mode 100644
index 0000000..8542c5b
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineSlider.js
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import PropTypes from 'prop-types';
+import React from 'react';
+import Slider from 'rc-slider';
+
+const { Range } = Slider;
+
+const propTypes = {
+  duration: PropTypes.number.isRequired,
+  startTs: PropTypes.number.isRequired,
+  endTs: PropTypes.number.isRequired,
+  onStartAndEndTsChange: PropTypes.func.isRequired,
+};
+
+class MiniTimelineSlider extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { isDragging: false };
+    this.handleBeforeRangeChange = this.handleBeforeRangeChange.bind(this);
+    this.handleAfterRangeChange = this.handleAfterRangeChange.bind(this);
+  }
+
+  handleBeforeRangeChange() {
+    this.setState({ isDragging: true });
+  }
+
+  handleAfterRangeChange(value) {
+    const { duration, onStartAndEndTsChange } = this.props;
+    onStartAndEndTsChange(
+      value[0] * duration / 100,
+      value[1] * duration / 100,
+    );
+    this.setState({ isDragging: false });
+  }
+
+  render() {
+    const { duration, startTs, endTs } = this.props;
+    const { isDragging } = this.state;
+
+    const props = {
+      allowCase: false,
+      defaultValue: [0, 100],
+      step: 0.01,
+      onBeforeChange: this.handleBeforeRangeChange,
+      onAfterChange: this.handleAfterRangeChange,
+    };
+    if (!isDragging) {
+      props.value = [
+        startTs / duration * 100,
+        endTs / duration * 100,
+      ];
+    }
+    return (
+      <div className="mini-timeline-slider">
+        <Range {...props} />
+      </div>
+    );
+  }
+}
+
+MiniTimelineSlider.propTypes = propTypes;
+
+export default MiniTimelineSlider;
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineSlider.test.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineSlider.test.js
new file mode 100644
index 0000000..d4921ac
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineSlider.test.js
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+import Slider from 'rc-slider';
+
+import MiniTimelineSlider from './MiniTimelineSlider';
+
+const { Range } = Slider;
+
+describe('<MiniTimelineSlider />', () => {
+  it('should change isDragging state before changing the range', () => {
+    const wrapper = shallow(
+      <MiniTimelineSlider
+        duration={10}
+        startTs={0}
+        endTs={10}
+        onStartAndEndTsChange={() => {}}
+      />,
+    );
+    wrapper.find(Range).prop('onBeforeChange')();
+    expect(wrapper.state('isDragging')).toEqual(true);
+  });
+
+  it('should change isDragging state after changing the range', () => {
+    const wrapper = shallow(
+      <MiniTimelineSlider
+        duration={10}
+        startTs={0}
+        endTs={10}
+        onStartAndEndTsChange={() => {}}
+      />,
+    );
+    wrapper.find(Range).prop('onBeforeChange')(); // isDragging === true
+    wrapper.find(Range).prop('onAfterChange')([2, 6]);
+    expect(wrapper.state('isDragging')).toEqual(false);
+  });
+
+  it('should call onStartAndEndTsChange after range change', () => {
+    const onStartAndEndTsChange = jest.fn();
+    const wrapper = shallow(
+      <MiniTimelineSlider
+        duration={10}
+        startTs={0}
+        endTs={10}
+        onStartAndEndTsChange={onStartAndEndTsChange}
+      />,
+    );
+    wrapper.find(Range).prop('onAfterChange')([2, 6]);
+    expect(onStartAndEndTsChange).toHaveBeenCalledWith(
+      2 / 10,
+      6 / 10,
+    );
+  });
+});
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineTimeMarkers.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineTimeMarkers.js
new file mode 100644
index 0000000..b2656d7
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineTimeMarkers.js
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const propTypes = {
+  height: PropTypes.number.isRequired,
+  numTimeMarkers: PropTypes.number.isRequired,
+};
+
+const MiniTimelineTimeMarkers = ({ height, numTimeMarkers }) => {
+  const timeMarkers = [];
+  for (let i = 1; i < numTimeMarkers - 1; i += 1) {
+    const portion = 100 / (numTimeMarkers - 1) * i;
+    timeMarkers.push(
+      <line
+        key={portion}
+        x1={`${portion}%`}
+        x2={`${portion}%`}
+        y1="0"
+        y2={height}
+      />,
+    );
+  }
+  return (
+    <g stroke="#888" strokeWidth="1">
+      {timeMarkers}
+    </g>
+  );
+};
+
+MiniTimelineTimeMarkers.propTypes = propTypes;
+
+export default MiniTimelineTimeMarkers;
diff --git a/zipkin-lens/src/components/MiniTimeline/MiniTimelineTimeMarkers.test.js b/zipkin-lens/src/components/MiniTimeline/MiniTimelineTimeMarkers.test.js
new file mode 100644
index 0000000..78b9b84
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/MiniTimelineTimeMarkers.test.js
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import MiniTimelineTimeMarkers from './MiniTimelineTimeMarkers';
+
+describe('<MiniTimelineTimeMarkers />', () => {
+  it('should set proper positions', () => {
+    const wrapper = shallow(<MiniTimelineTimeMarkers height={75} numTimeMarkers={5} />);
+    const timeMarkers = wrapper.find('line');
+    expect(timeMarkers.at(0).prop('x1')).toEqual('25%');
+    expect(timeMarkers.at(1).prop('x1')).toEqual('50%');
+    expect(timeMarkers.at(2).prop('x1')).toEqual('75%');
+  });
+});
diff --git a/zipkin-lens/src/components/MiniTimeline/index.js b/zipkin-lens/src/components/MiniTimeline/index.js
index ba4510d..d85435b 100644
--- a/zipkin-lens/src/components/MiniTimeline/index.js
+++ b/zipkin-lens/src/components/MiniTimeline/index.js
@@ -17,10 +17,13 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 
-import { formatDuration } from '../../util/timestamp';
-import { getServiceNameColor } from '../../util/color';
+import MiniTimelineGraph from './MiniTimelineGraph';
+import MiniTimelineLabel from './MiniTimelineLabel';
+import MiniTimelineSlider from './MiniTimelineSlider';
 import { detailedTraceSummaryPropTypes } from '../../prop-types';
 
+const defaultNumTimeMarkers = 5;
+
 const propTypes = {
   startTs: PropTypes.number.isRequired,
   endTs: PropTypes.number.isRequired,
@@ -28,234 +31,38 @@ const propTypes = {
   onStartAndEndTsChange: PropTypes.func.isRequired,
 };
 
-const graphHeight = 75;
-const numTimeMarkers = 5;
-const leftMouseButton = 0;
-
-const renderTimeMarkers = () => {
-  const timeMarkers = [];
-  for (let i = 1; i < numTimeMarkers - 1; i += 1) {
-    const portion = 100 / (numTimeMarkers - 1) * i;
-    timeMarkers.push(
-      <line
-        key={portion}
-        x1={`${portion}%`}
-        x2={`${portion}%`}
-        y1="0"
-        y2={graphHeight}
-      />,
-    );
+const MiniTimeline = ({
+  startTs, endTs, traceSummary, onStartAndEndTsChange,
+}) => {
+  const { spans, duration } = traceSummary;
+  if (spans.length <= 1) {
+    return null;
   }
+
   return (
-    <g stroke="#888" strokeWidth="1">
-      {timeMarkers}
-    </g>
+    <div className="mini-timeline">
+      <MiniTimelineLabel
+        numTimeMarkers={defaultNumTimeMarkers}
+        duration={duration}
+      />
+      <MiniTimelineGraph
+        spans={spans}
+        duration={duration}
+        startTs={startTs}
+        endTs={endTs}
+        onStartAndEndTsChange={onStartAndEndTsChange}
+        numTimeMarkers={defaultNumTimeMarkers}
+      />
+      <MiniTimelineSlider
+        duration={duration}
+        startTs={startTs}
+        endTs={endTs}
+        onStartAndEndTsChange={onStartAndEndTsChange}
+      />
+    </div>
   );
 };
 
-class MiniTimeline extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      isDragging: false,
-      dragStartX: null,
-      dragCurrentX: null,
-    };
-    this._graphElement = undefined;
-
-    this.setGraphElement = this.setGraphElement.bind(this);
-    this.handleMouseDown = this.handleMouseDown.bind(this);
-    this.handleMouseMove = this.handleMouseMove.bind(this);
-    this.handleMouseUp = this.handleMouseUp.bind(this);
-    this.handleDoubleClick = this.handleDoubleClick.bind(this);
-  }
-
-  setGraphElement(element) {
-    this._graphElement = element;
-  }
-
-  getPosition(clientX) {
-    const { left, width } = this._graphElement.getBoundingClientRect();
-    return (clientX - left) / width;
-  }
-
-  handleMouseDown(event) {
-    if (event.button !== leftMouseButton) {
-      return;
-    }
-    const currentX = this.getPosition(event.clientX);
-    this.setState({
-      isDragging: true,
-      dragStartX: currentX,
-      dragCurrentX: currentX,
-    });
-    window.addEventListener('mousemove', this.handleMouseMove);
-    window.addEventListener('mouseup', this.handleMouseUp);
-  }
-
-  handleMouseMove(event) {
-    this.setState({
-      dragCurrentX: this.getPosition(event.clientX),
-    });
-  }
-
-  handleMouseUp(event) {
-    const { traceSummary, onStartAndEndTsChange } = this.props;
-    const { dragStartX } = this.state;
-    this.setState({ isDragging: false });
-
-    let startTs;
-    let endTs;
-    const currentX = this.getPosition(event.clientX);
-    if (currentX > dragStartX) {
-      startTs = Math.max(dragStartX * traceSummary.duration, 0);
-      endTs = Math.min(currentX * traceSummary.duration, traceSummary.duration);
-    } else {
-      startTs = Math.max(currentX * traceSummary.duration, 0);
-      endTs = Math.min(dragStartX * traceSummary.duration, traceSummary.duration);
-    }
-    onStartAndEndTsChange(startTs, endTs);
-
-    window.removeEventListener('mousemove', this.handleMouseMove);
-    window.removeEventListener('mouseup', this.handleMouseUp);
-  }
-
-  handleDoubleClick() {
-    const { traceSummary, onStartAndEndTsChange } = this.props;
-    onStartAndEndTsChange(0, traceSummary.duration);
-  }
-
-  renderTimeMarkerLabels() {
-    const { traceSummary } = this.props;
-
-    const timeMarkers = [];
-    for (let i = 0; i < numTimeMarkers; i += 1) {
-      const label = formatDuration((i / (numTimeMarkers - 1)) * (traceSummary.duration));
-
-      const portion = i / (numTimeMarkers - 1);
-
-      let modifier = '';
-      if (portion === 0) {
-        modifier = '--first';
-      } else if (portion >= 1) {
-        modifier = '--last';
-      }
-
-      timeMarkers.push(
-        <div
-          key={portion}
-          className="mini-timeline__time-marker"
-          style={{
-            left: `${portion * 100}%`,
-          }}
-        >
-          <span className={
-            `mini-timeline__time-marker-label mini-timeline__time-marker-label${modifier}`}
-          >
-            {label}
-          </span>
-        </div>,
-      );
-    }
-    return (
-      <div>
-        {timeMarkers}
-      </div>
-    );
-  }
-
-  render() {
-    const { traceSummary, startTs, endTs } = this.props;
-    const { isDragging, dragStartX, dragCurrentX } = this.state;
-    const { spans } = traceSummary;
-    const lineHeight = graphHeight / spans.length;
-
-    return (
-      <div className="mini-timeline">
-        <div className="mini-timeline__time-marker-labels-wrapper">
-          {this.renderTimeMarkerLabels()}
-        </div>
-        <div
-          className="mini-timeline__graph"
-          ref={this.setGraphElement}
-          role="presentation"
-          onMouseDown={this.handleMouseDown}
-          onDoubleClick={this.handleDoubleClick}
-        >
-          <svg version="1.1" width="100%" height={graphHeight} xmlns="http://www.w3.org/2000/svg">
-            {renderTimeMarkers()}
-            {
-              spans.map((span, i) => (
-                <rect
-                  key={span.spanId}
-                  width={`${span.width}%`}
-                  height={lineHeight}
-                  x={`${span.left}%`}
-                  y={i * lineHeight}
-                  fill={getServiceNameColor(span.serviceName)}
-                />
-              ))
-            }
-            {
-              isDragging
-                ? (
-                  <g stroke="#999" strokeWidth="1">
-                    <line
-                      x1={`${dragStartX * 100}%`}
-                      x2={`${dragStartX * 100}%`}
-                      y1={0}
-                      y2={graphHeight}
-                    />
-                    <line
-                      x1={`${dragStartX * 100}%`}
-                      x2={`${dragCurrentX * 100}%`}
-                      y1={graphHeight / 2}
-                      y2={graphHeight / 2}
-
-                    />
-                    <line
-                      x1={`${dragCurrentX * 100}%`}
-                      x2={`${dragCurrentX * 100}%`}
-                      y1={0}
-                      y2={graphHeight}
-                    />
-                  </g>
-                )
-                : null
-            }
-            {
-              startTs
-                ? (
-                  <rect
-                    width={`${startTs / traceSummary.duration * 100}%`}
-                    height={graphHeight}
-                    x="0"
-                    y="0"
-                    fill="rgba(50, 50, 50, 0.2)"
-                  />
-                )
-                : null
-            }
-            {
-              endTs
-                ? (
-                  <rect
-                    width={`${(traceSummary.duration - endTs) / traceSummary.duration * 100}%`}
-                    height={graphHeight}
-                    x={`${endTs / traceSummary.duration * 100}%`}
-                    y="0"
-                    fill="rgba(50, 50, 50, 0.2)"
-                  />
-                )
-                : null
-            }
-          </svg>
-        </div>
-      </div>
-    );
-  }
-}
-
 MiniTimeline.propTypes = propTypes;
 
 export default MiniTimeline;
diff --git a/zipkin-lens/src/components/MiniTimeline/index.test.js b/zipkin-lens/src/components/MiniTimeline/index.test.js
new file mode 100644
index 0000000..1139bdf
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/index.test.js
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import MiniTimeline from './index';
+
+describe('<MiniTimeline />', () => {
+  const commonProps = {
+    startTs: 0,
+    endTs: 10,
+    onStartAndEndTsChange: () => {},
+  };
+
+  const dummySpan = {
+    spanId: '1',
+    spanName: 'span',
+    parentId: '2',
+    childIds: [],
+    serviceName: 'service',
+    serviceNames: [],
+    timestamp: 0,
+    duration: 10,
+    durationStr: '10μs',
+    tags: [],
+    annotations: [],
+    errorType: 'none',
+    depth: 1,
+    width: 1,
+    left: 1,
+  };
+
+  it('should return null if the number of spans is less than 2', () => {
+    let props = {
+      ...commonProps,
+      traceSummary: {
+        traceId: '12345',
+        spans: [],
+        serviceNameAndSpanCounts: [],
+        duration: 10,
+        durationStr: '10μs',
+      },
+    };
+    let wrapper = shallow(<MiniTimeline {...props} />);
+    expect(wrapper.type()).toEqual(null);
+
+    props = {
+      ...commonProps,
+      traceSummary: {
+        traceId: '12345',
+        spans: [dummySpan],
+        serviceNameAndSpanCounts: [],
+        duration: 10,
+        durationStr: '10μs',
+      },
+    };
+    wrapper = shallow(<MiniTimeline {...props} />);
+    expect(wrapper.type()).toEqual(null);
+  });
+
+  it('should return mini timeline otherwise', () => {
+    const props = {
+      ...commonProps,
+      traceSummary: {
+        traceId: '12345',
+        spans: [dummySpan, dummySpan],
+        serviceNameAndSpanCounts: [],
+        duration: 10,
+        durationStr: '10μs',
+      },
+    };
+    const wrapper = shallow(<MiniTimeline {...props} />);
+    expect(wrapper.find('.mini-timeline').length).toBe(1);
+  });
+});
diff --git a/zipkin-lens/src/components/MiniTimeline/util.js b/zipkin-lens/src/components/MiniTimeline/util.js
new file mode 100644
index 0000000..847c851
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/util.js
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const getGraphHeight = (numSpans) => {
+  if (numSpans <= 1) {
+    return 0;
+  }
+  if (numSpans <= 14) {
+    return numSpans * 5;
+  }
+  return 75;
+};
+
+export const getGraphLineHeight = (numSpans) => {
+  if (numSpans <= 1) {
+    return 0;
+  }
+  if (numSpans <= 14) {
+    return 5;
+  }
+  return Math.max(75 / numSpans, 1);
+};
diff --git a/zipkin-lens/src/components/MiniTimeline/util.test.js b/zipkin-lens/src/components/MiniTimeline/util.test.js
new file mode 100644
index 0000000..bd1423e
--- /dev/null
+++ b/zipkin-lens/src/components/MiniTimeline/util.test.js
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { getGraphHeight, getGraphLineHeight } from './util';
+
+describe('getGraphHeight', () => {
+  it('should return proper value', () => {
+    expect(getGraphHeight(-1)).toEqual(0);
+    expect(getGraphHeight(0)).toEqual(0);
+    expect(getGraphHeight(1)).toEqual(0);
+    expect(getGraphHeight(2)).toEqual(2 * 5);
+    expect(getGraphHeight(14)).toEqual(14 * 5);
+    expect(getGraphHeight(15)).toEqual(75);
+    expect(getGraphHeight(16)).toEqual(75);
+    expect(getGraphHeight(100)).toEqual(75);
+  });
+});
+
+describe('getGraphLineHeight', () => {
+  it('should return proper value', () => {
+    expect(getGraphLineHeight(-1)).toEqual(0);
+    expect(getGraphLineHeight(0)).toEqual(0);
+    expect(getGraphLineHeight(1)).toEqual(0);
+    expect(getGraphLineHeight(2)).toEqual(5);
+    expect(getGraphLineHeight(14)).toEqual(5);
+    expect(getGraphLineHeight(15)).toEqual(5);
+    expect(getGraphLineHeight(16)).toEqual(75 / 16);
+    expect(getGraphLineHeight(20)).toEqual(75 / 20);
+    expect(getGraphLineHeight(100)).toEqual(1);
+  });
+});


Mime
View raw message