Merge pull request #1679 from influxdata/get-legend-rekt

Update legend functionality
pull/10616/head
Andrew Watkins 2017-07-07 16:53:25 -07:00 committed by GitHub
commit 0a80cac953
5 changed files with 517 additions and 194 deletions

View File

@ -7,104 +7,34 @@ import _ from 'lodash'
import Dygraphs from 'src/external/dygraph' import Dygraphs from 'src/external/dygraph'
import getRange from 'shared/parsing/getRangeForDygraph' import getRange from 'shared/parsing/getRangeForDygraph'
const LINE_COLORS = [ import {LINE_COLORS, multiColumnBarPlotter} from 'src/shared/graphs/helpers'
'#00C9FF', import DygraphLegend from 'src/shared/components/DygraphLegend'
'#9394FF',
'#4ED8A0',
'#ff0054',
'#ffcc00',
'#33aa99',
'#9dfc5d',
'#92bcc3',
'#ca96fb',
'#ff00f0',
'#38b94a',
'#3844b9',
'#a0725b',
]
const darkenColor = colorStr => {
// Defined in dygraph-utils.js
const color = Dygraphs.toRGB_(colorStr)
color.r = Math.floor((255 + color.r) / 2)
color.g = Math.floor((255 + color.g) / 2)
color.b = Math.floor((255 + color.b) / 2)
return `rgb(${color.r},${color.g},${color.b})`
}
// Bar Graph code below is from http://dygraphs.com/tests/plotters.html
const multiColumnBarPlotter = e => {
// We need to handle all the series simultaneously.
if (e.seriesIndex !== 0) {
return
}
const g = e.dygraph
const ctx = e.drawingContext
const sets = e.allSeriesPoints
const yBottom = e.dygraph.toDomYCoord(0)
// Find the minimum separation between x-values.
// This determines the bar width.
let minSep = Infinity
for (let j = 0; j < sets.length; j++) {
const points = sets[j]
for (let i = 1; i < points.length; i++) {
const sep = points[i].canvasx - points[i - 1].canvasx
if (sep < minSep) {
minSep = sep
}
}
}
const barWidth = Math.floor(2.0 / 3 * minSep)
const fillColors = []
const strokeColors = g.getColors()
for (let i = 0; i < strokeColors.length; i++) {
fillColors.push(darkenColor(strokeColors[i]))
}
for (let j = 0; j < sets.length; j++) {
ctx.fillStyle = fillColors[j]
ctx.strokeStyle = strokeColors[j]
for (let i = 0; i < sets[j].length; i++) {
const p = sets[j][i]
const centerX = p.canvasx
const xLeft = sets.length === 1
? centerX - barWidth / 2
: centerX - barWidth / 2 * (1 - j / (sets.length - 1))
ctx.fillRect(
xLeft,
p.canvasy,
barWidth / sets.length,
yBottom - p.canvasy
)
ctx.strokeRect(
xLeft,
p.canvasy,
barWidth / sets.length,
yBottom - p.canvasy
)
}
}
}
export default class Dygraph extends Component { export default class Dygraph extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
legend: {
x: null,
series: [],
},
sortType: '',
filterText: '',
isSynced: false, isSynced: false,
isHidden: true,
isAscending: true,
isSnipped: false,
isFilterVisible: false,
} }
// optional workaround for dygraph.updateOptions breaking legends
// a la http://stackoverflow.com/questions/38371876/dygraph-dynamic-update-legend-values-disappear
// this.lastMouseMoveEvent = null
// this.isMouseOverGraph = false
this.getTimeSeries = ::this.getTimeSeries
this.sync = ::this.sync this.sync = ::this.sync
this.getTimeSeries = ::this.getTimeSeries
this.handleSortLegend = ::this.handleSortLegend
this.handleLegendInputChange = ::this.handleLegendInputChange
this.handleSnipLabel = ::this.handleSnipLabel
this.handleHideLegend = ::this.handleHideLegend
this.handleToggleFilter = ::this.handleToggleFilter
this.visibility = ::this.visibility
} }
static defaultProps = { static defaultProps = {
@ -133,8 +63,8 @@ export default class Dygraph extends Component {
options, options,
} = this.props } = this.props
const graphContainerNode = this.graphContainer const graphRef = this.graphRef
const legendContainerNode = this.legendContainer const legendRef = this.legendRef
let finalLineColors = overrideLineColors let finalLineColors = overrideLineColors
if (finalLineColors === null) { if (finalLineColors === null) {
@ -148,7 +78,6 @@ export default class Dygraph extends Component {
}), }),
], ],
labelsSeparateLines: false, labelsSeparateLines: false,
labelsDiv: legendContainerNode,
labelsKMB: true, labelsKMB: true,
rightGap: 0, rightGap: 0,
highlightSeriesBackgroundAlpha: 1.0, highlightSeriesBackgroundAlpha: 1.0,
@ -158,6 +87,7 @@ export default class Dygraph extends Component {
gridLineWidth: 1, gridLineWidth: 1,
highlightCircleSize: 3, highlightCircleSize: 3,
animatedZooms: true, animatedZooms: true,
hideOverlayOnMouseOut: false,
colors: finalLineColors, colors: finalLineColors,
series: dygraphSeries, series: dygraphSeries,
axes: { axes: {
@ -172,20 +102,30 @@ export default class Dygraph extends Component {
strokeWidth: 2, strokeWidth: 2,
highlightCircleSize: 5, highlightCircleSize: 5,
}, },
unhighlightCallback: () => { legendFormatter: legend => {
legendContainerNode.className = 'container--dygraph-legend hidden' // hide if (!legend.x) {
return ''
}
// part of optional workaround for preventing updateOptions from breaking legend const {state: {legend: prevLegend}} = this
// this.isMouseOverGraph = false const highlighted = legend.series.find(s => s.isHighlighted)
const prevHighlighted = prevLegend.series.find(s => s.isHighlighted)
const y = highlighted && highlighted.y
const prevY = prevHighlighted && prevHighlighted.y
if (legend.x === prevLegend.x && y === prevY) {
return ''
}
this.setState({legend})
return ''
}, },
highlightCallback: e => { highlightCallback: e => {
// don't make visible yet, but render on DOM to capture position for calcs
legendContainerNode.style.visibility = 'hidden'
legendContainerNode.className = 'container--dygraph-legend'
// Move the Legend on hover // Move the Legend on hover
const graphRect = graphContainerNode.getBoundingClientRect() const graphRect = graphRef.getBoundingClientRect()
const legendRect = legendContainerNode.getBoundingClientRect() const legendRect = legendRef.getBoundingClientRect()
const graphWidth = graphRect.width + 32 // Factoring in padding from parent const graphWidth = graphRect.width + 32 // Factoring in padding from parent
const graphHeight = graphRect.height const graphHeight = graphRect.height
const graphBottom = graphRect.bottom const graphBottom = graphRect.bottom
@ -211,16 +151,28 @@ export default class Dygraph extends Component {
? graphHeight + 8 - legendHeight ? graphHeight + 8 - legendHeight
: graphHeight + 8 : graphHeight + 8
legendContainerNode.style.visibility = 'visible' // show legendRef.style.left = `${legendLeft}px`
legendContainerNode.style.left = `${legendLeft}px` legendRef.style.top = `${legendTop}px`
legendContainerNode.style.top = `${legendTop}px`
// part of optional workaround for preventing updateOptions from breaking legend this.setState({isHidden: false})
// this.isMouseOverGraph = true
// this.lastMouseMoveEvent = e
}, },
drawCallback: () => { unhighlightCallback: e => {
legendContainerNode.className = 'container--dygraph-legend hidden' // hide const {top, bottom, left, right} = legendRef.getBoundingClientRect()
const mouseY = e.clientY
const mouseX = e.clientX
const mouseInLegendY = mouseY <= bottom && mouseY >= top
const mouseInLegendX = mouseX <= right && mouseX >= left
const isMouseHoveringLegend = mouseInLegendY && mouseInLegendX
if (!isMouseHoveringLegend) {
this.setState({isHidden: true})
if (!this.visibility().find(bool => bool === true)) {
this.setState({filterText: ''})
}
}
}, },
} }
@ -228,7 +180,7 @@ export default class Dygraph extends Component {
defaultOptions.plotter = multiColumnBarPlotter defaultOptions.plotter = multiColumnBarPlotter
} }
this.dygraph = new Dygraphs(graphContainerNode, timeSeries, { this.dygraph = new Dygraphs(graphRef, timeSeries, {
...defaultOptions, ...defaultOptions,
...options, ...options,
}) })
@ -264,6 +216,22 @@ export default class Dygraph extends Component {
return shallowCompare(this, nextProps, nextState) return shallowCompare(this, nextProps, nextState)
} }
visibility() {
const timeSeries = this.getTimeSeries()
const {filterText, legend} = this.state
const series = _.get(timeSeries, '0', [])
const numSeries = series.length
return Array(numSeries ? numSeries - 1 : numSeries)
.fill(true)
.map((s, i) => {
if (!legend.series[i]) {
return true
}
return !!legend.series[i].label.match(filterText)
})
}
componentDidUpdate() { componentDidUpdate() {
const { const {
labels, labels,
@ -273,6 +241,7 @@ export default class Dygraph extends Component {
ruleValues, ruleValues,
isBarGraph, isBarGraph,
} = this.props } = this.props
const dygraph = this.dygraph const dygraph = this.dygraph
if (!dygraph) { if (!dygraph) {
throw new Error( throw new Error(
@ -281,11 +250,7 @@ export default class Dygraph extends Component {
} }
const timeSeries = this.getTimeSeries() const timeSeries = this.getTimeSeries()
const updateOptions = {
const legendContainerNode = this.legendContainer
legendContainerNode.className = 'container--dygraph-legend hidden' // hide
dygraph.updateOptions({
labels, labels,
file: timeSeries, file: timeSeries,
axes: { axes: {
@ -301,12 +266,10 @@ export default class Dygraph extends Component {
underlayCallback: options.underlayCallback, underlayCallback: options.underlayCallback,
series: dygraphSeries, series: dygraphSeries,
plotter: isBarGraph ? multiColumnBarPlotter : null, plotter: isBarGraph ? multiColumnBarPlotter : null,
}) visibility: this.visibility(),
// part of optional workaround for preventing updateOptions from breaking legend }
// if (this.lastMouseMoveEvent) {
// dygraph.mouseMove_(this.lastMouseMoveEvent)
// }
dygraph.updateOptions(updateOptions)
dygraph.resize() dygraph.resize()
const {w} = this.dygraph.getArea() const {w} = this.dygraph.getArea()
this.props.setResolution(w) this.props.setResolution(w)
@ -319,21 +282,77 @@ export default class Dygraph extends Component {
} }
} }
handleSortLegend(sortType) {
this.setState({sortType, isAscending: !this.state.isAscending})
}
handleLegendInputChange(e) {
this.setState({filterText: e.target.value})
}
handleSnipLabel() {
this.setState({isSnipped: !this.state.isSnipped})
}
handleToggleFilter() {
this.setState({
isFilterVisible: !this.state.isFilterVisible,
filterText: '',
})
}
handleHideLegend(e) {
const {top, bottom, left, right} = this.graphRef.getBoundingClientRect()
const mouseY = e.clientY
const mouseX = e.clientX
const mouseInGraphY = mouseY <= bottom && mouseY >= top
const mouseInGraphX = mouseX <= right && mouseX >= left
const isMouseHoveringGraph = mouseInGraphY && mouseInGraphX
if (!isMouseHoveringGraph) {
this.setState({isHidden: true})
if (!this.visibility().find(bool => bool === true)) {
this.setState({filterText: ''})
}
}
}
render() { render() {
const {
legend,
filterText,
isAscending,
sortType,
isHidden,
isSnipped,
isFilterVisible,
} = this.state
return ( return (
<div className="dygraph-child"> <div className="dygraph-child">
<div <DygraphLegend
ref={r => { {...legend}
this.graphContainer = r sortType={sortType}
}} onHide={this.handleHideLegend}
style={this.props.containerStyle} isHidden={isHidden}
className="dygraph-child-container" isFilterVisible={isFilterVisible}
isSnipped={isSnipped}
filterText={filterText}
isAscending={isAscending}
onSnip={this.handleSnipLabel}
onSort={this.handleSortLegend}
legendRef={el => (this.legendRef = el)}
onInputChange={this.handleLegendInputChange}
onToggleFilter={this.handleToggleFilter}
/> />
<div <div
ref={r => { ref={r => {
this.legendContainer = r this.graphRef = r
}} }}
className={'container--dygraph-legend hidden'} style={this.props.containerStyle}
className="dygraph-child-container"
/> />
</div> </div>
) )

View File

@ -0,0 +1,148 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import classnames from 'classnames'
const removeMeasurement = (label = '') => {
const [measurement] = label.match(/^(.*)[.]/g) || ['']
return label.replace(measurement, '')
}
const DygraphLegend = ({
series,
onSort,
onSnip,
onHide,
isHidden,
isFilterVisible,
isSnipped,
sortType,
legendRef,
filterText,
isAscending,
onInputChange,
onToggleFilter,
xHTML,
}) => {
const sorted = _.sortBy(
series,
({y, label}) => (sortType === 'numeric' ? y : label)
)
const ordered = isAscending ? sorted : sorted.reverse()
const filtered = ordered.filter(s => s.label.match(filterText))
const hidden = isHidden ? 'hidden' : ''
const renderSortAlpha = (
<div
className={classnames('sort-btn btn btn-sm btn-square', {
'btn-primary': sortType !== 'numeric',
'btn-default': sortType === 'numeric',
'sort-btn--asc': isAscending && sortType !== 'numeric',
'sort-btn--desc': !isAscending && sortType !== 'numeric',
})}
onClick={() => onSort('alphabetic')}
>
<div className="sort-btn--arrow" />
<div className="sort-btn--top">A</div>
<div className="sort-btn--bottom">Z</div>
</div>
)
const renderSortNum = (
<button
className={classnames('sort-btn btn btn-sm btn-square', {
'btn-primary': sortType === 'numeric',
'btn-default': sortType !== 'numeric',
'sort-btn--asc': isAscending && sortType === 'numeric',
'sort-btn--desc': !isAscending && sortType === 'numeric',
})}
onClick={() => onSort('numeric')}
>
<div className="sort-btn--arrow" />
<div className="sort-btn--top">0</div>
<div className="sort-btn--bottom">9</div>
</button>
)
return (
<div
className={`dygraph-legend ${hidden}`}
ref={legendRef}
onMouseLeave={onHide}
>
<div className="dygraph-legend--header">
<div className="dygraph-legend--timestamp">{xHTML}</div>
{renderSortAlpha}
{renderSortNum}
<button
className={classnames('btn btn-square btn-sm', {
'btn-default': !isFilterVisible,
'btn-primary': isFilterVisible,
})}
onClick={onToggleFilter}
>
<span className="icon search" />
</button>
<button className="btn btn-default btn-sm" onClick={onSnip}>
Snip
</button>
</div>
{isFilterVisible
? <input
className="dygraph-legend--filter form-control input-sm"
type="text"
value={filterText}
onChange={onInputChange}
placeholder="Filter items..."
autoFocus={true}
/>
: null}
<div className="dygraph-legend--divider" />
<div className="dygraph-legend--contents">
{filtered.map(({label, color, yHTML, isHighlighted}) => {
const seriesClass = isHighlighted
? 'dygraph-legend--row highlight'
: 'dygraph-legend--row'
return (
<div key={label + color} className={seriesClass}>
<span style={{color}}>
{isSnipped ? removeMeasurement(label) : label}
</span>
<figure>{yHTML || 'no value'}</figure>
</div>
)
})}
</div>
</div>
)
}
const {arrayOf, bool, func, number, shape, string} = PropTypes
DygraphLegend.propTypes = {
x: number,
xHTML: string,
series: arrayOf(
shape({
color: string,
dashHTML: string,
isVisible: bool,
label: string,
y: number,
yHTML: string,
})
),
dygraph: shape(),
onSnip: func.isRequired,
onHide: func.isRequired,
onSort: func.isRequired,
onInputChange: func.isRequired,
onToggleFilter: func.isRequired,
filterText: string.isRequired,
isAscending: bool.isRequired,
sortType: string.isRequired,
isHidden: bool.isRequired,
legendRef: func.isRequired,
isSnipped: bool.isRequired,
isFilterVisible: bool.isRequired,
}
export default DygraphLegend

View File

@ -0,0 +1,87 @@
/* eslint-disable no-magic-numbers */
import Dygraphs from 'src/external/dygraph'
export const LINE_COLORS = [
'#00C9FF',
'#9394FF',
'#4ED8A0',
'#ff0054',
'#ffcc00',
'#33aa99',
'#9dfc5d',
'#92bcc3',
'#ca96fb',
'#ff00f0',
'#38b94a',
'#3844b9',
'#a0725b',
]
export const darkenColor = colorStr => {
// Defined in dygraph-utils.js
const color = Dygraphs.toRGB_(colorStr)
color.r = Math.floor((255 + color.r) / 2)
color.g = Math.floor((255 + color.g) / 2)
color.b = Math.floor((255 + color.b) / 2)
return `rgb(${color.r},${color.g},${color.b})`
}
// Bar Graph code below is from http://dygraphs.com/tests/plotters.html
export const multiColumnBarPlotter = e => {
// We need to handle all the series simultaneously.
if (e.seriesIndex !== 0) {
return
}
const g = e.dygraph
const ctx = e.drawingContext
const sets = e.allSeriesPoints
const yBottom = e.dygraph.toDomYCoord(0)
// Find the minimum separation between x-values.
// This determines the bar width.
let minSep = Infinity
for (let j = 0; j < sets.length; j++) {
const points = sets[j]
for (let i = 1; i < points.length; i++) {
const sep = points[i].canvasx - points[i - 1].canvasx
if (sep < minSep) {
minSep = sep
}
}
}
const barWidth = Math.floor(2.0 / 3 * minSep)
const fillColors = []
const strokeColors = g.getColors()
for (let i = 0; i < strokeColors.length; i++) {
fillColors.push(darkenColor(strokeColors[i]))
}
for (let j = 0; j < sets.length; j++) {
ctx.fillStyle = fillColors[j]
ctx.strokeStyle = strokeColors[j]
for (let i = 0; i < sets[j].length; i++) {
const p = sets[j][i]
const centerX = p.canvasx
const xLeft = sets.length === 1
? centerX - barWidth / 2
: centerX - barWidth / 2 * (1 - j / (sets.length - 1))
ctx.fillRect(
xLeft,
p.canvasy,
barWidth / sets.length,
yBottom - p.canvasy
)
ctx.strokeRect(
xLeft,
p.canvasy,
barWidth / sets.length,
yBottom - p.canvasy
)
}
}
}

View File

@ -6,7 +6,6 @@
} }
} }
.graph-vertical-marker { .graph-vertical-marker {
top: 0; top: 0;
bottom: 0; bottom: 0;
@ -21,69 +20,6 @@
background: linear-gradient(to bottom, fade-out($g20-white, 1) 0%,fade-out($g20-white, 0.71) 6%,fade-out($g20-white, 0.71) 80%,fade-out($g20-white, 1) 100%); background: linear-gradient(to bottom, fade-out($g20-white, 1) 0%,fade-out($g20-white, 0.71) 6%,fade-out($g20-white, 0.71) 80%,fade-out($g20-white, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='fade-out($g20-white, 0.71)', endColorstr='fade-out($g20-white, 0.71)',GradientType=0 ); filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='fade-out($g20-white, 0.71)', endColorstr='fade-out($g20-white, 0.71)',GradientType=0 );
} }
.container--dygraph-legend {
transform: translateX(-50%);
background-color: $g0-obsidian;
display: block !important;
position: absolute;
padding: 11px;
z-index: 500;
font-size: 13px;
color: $g12-forge;
border-radius: 3px;
font-weight: 600;
line-height: 13px;
pointer-events: none;
&.hidden {
display: none !important;
}
/*
* Only animate position that's controlled during rendering.
* See http://stackoverflow.com/a/17117992
*/
// transition: all 0.1s ease;
// transition-property: top, right, bottom, left;
/* Row */
/* Styles for Key go here, get overrided by > b */
> span {
width: 100%;
justify-content: space-between;
align-items: center;
display: flex;
opacity: 0.5;
padding-top: 4px;
font-size: 13px;
line-height: 13px;
font-weight: 600 !important;
color: $g19-ghost;
margin: 0;
/* Border on top of first row */
&:first-child {
border-top: 2px solid $g4-onyx;
padding-top: 6px;
margin-top: 6px;
}
/* Legend Key */
> b {
font-weight: 600 !important;
}
}
.highlight {
font-weight: 600;
opacity: 1;
> b {
font-weight: 600;
}
}
}
/* Axis Labels */ /* Axis Labels */
.dygraph-axis-label { .dygraph-axis-label {
@ -136,7 +72,7 @@
} }
/* Single Stat Cells */
.single-stat { .single-stat {
position: absolute; position: absolute;
width: 100%; width: 100%;
@ -187,3 +123,135 @@
.single-stat--small .single-stat--shadow:after { .single-stat--small .single-stat--shadow:after {
box-shadow: fade-out($g2-kevlar, 0.3) 0 0 30px 10px; box-shadow: fade-out($g2-kevlar, 0.3) 0 0 30px 10px;
} }
/*
Legend Styles
------------------------------------------------------------------------------
*/
.dygraph-child-container .dygraph-legend {
display: none !important; // hide default legend
}
.dygraph-legend {
background-color: $g0-obsidian;
display: block !important;
position: absolute;
padding: 11px;
z-index: 500;
border-radius: 3px;
min-width: 350px;
user-select: text;
transform: translateX(-50%);
box-shadow: 0 0 10px 2px $g2-kevlar;
&.hidden {
display: none !important;
}
}
.dygraph-legend--header {
display: flex;
align-items: center;
flex-wrap: nowrap;
> .btn { margin-left: 4px; }
}
.dygraph-legend--timestamp {
margin-right: 8px;
height: 30px;
line-height: 30px;
font-weight: 600;
color: $g13-mist;
flex: 1 0 0;
}
.dygraph-legend--filter {
flex: 1 0 0;
margin-top: 8px;
}
.dygraph-legend--divider {
width: 100%;
margin: 8px 0;
height: 2px;
background-color: $g5-pepper;
}
.dygraph-legend--contents {
font-size: 13px;
color: $g15-platinum;
font-weight: 600;
line-height: 13px;
max-height: 123px;
overflow-y: auto;
@include custom-scrollbar-round($g0-obsidian,$g3-castle);
}
.dygraph-legend--row {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: nowrap;
opacity: 0.5;
font-size: 13px;
line-height: 13px;
padding: 3px 0;
span {
font-weight: 600;
padding: 0;
white-space: nowrap;
}
figure {
padding-left: 10px;
font-family: $code-font;
}
&.highlight {
opacity: 1;
background-color: $g3-castle;
figure {color: $g20-white;}
}
&.highlight:only-child {
background-color: transparent;
}
}
/* Sorting Buttons */
.sort-btn {
position: relative;
}
.sort-btn--arrow {
position: absolute;
top: 8px;
right: 8px;
height: calc(100% - 16px);
width: 2px;
background-color: $g20-white;
transform: rotate(0deg);
transition: transform 0.25s ease;
&:after {
content: '';
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%) scaleX(0.7);
border-style: solid;
border-width: 6px;
border-color: transparent;
border-bottom-color: $g20-white;
}
}
.sort-btn--asc .sort-btn--arrow {
transform: rotate(180deg);
}
.sort-btn--top,
.sort-btn--bottom {
position: absolute;
font-size: 10px;
font-weight: 900;
color: $g20-white;
left: 6px;
}
.sort-btn--top {
top: -5px;
}
.sort-btn--bottom {
bottom: -6px;
}

View File

@ -246,6 +246,7 @@ $rule-builder--radius-lg: 5px;
left: (($rule-builder--dot / 2) - $rule-builder--left-gutter); left: (($rule-builder--dot / 2) - $rule-builder--left-gutter);
} }
.container--dygraph-legend { .container--dygraph-legend {
transform: translateX(-50%);
background-color: $g5-pepper; background-color: $g5-pepper;
> span:first-child { > span:first-child {