From c1d9918abeb3bca33721fc5af9be59e89fffccbf Mon Sep 17 00:00:00 2001 From: Alanna Scott Date: Tue, 4 Apr 2017 20:05:36 -0700 Subject: [PATCH] [vis] bug num improvements (#2523) * fix js error, make should render viz more readable, add tooltips, style axis and add min/max to xaxis * put getTextWidth in utils * add document to eslint globals config --- superset/assets/.eslintrc | 3 + .../explorev2/components/ChartContainer.jsx | 21 +- superset/assets/javascripts/modules/utils.js | 8 + superset/assets/visualizations/big_number.css | 24 +- superset/assets/visualizations/big_number.js | 229 +++++++++++------- 5 files changed, 183 insertions(+), 102 deletions(-) diff --git a/superset/assets/.eslintrc b/superset/assets/.eslintrc index 95137e26db..9f93351a2c 100644 --- a/superset/assets/.eslintrc +++ b/superset/assets/.eslintrc @@ -14,5 +14,8 @@ "func-names": 0, "react/jsx-no-bind": 0, "no-confusing-arrow": 0, + }, + "globals": { + "document": true, } } diff --git a/superset/assets/javascripts/explorev2/components/ChartContainer.jsx b/superset/assets/javascripts/explorev2/components/ChartContainer.jsx index e1adb777db..33b134e9da 100644 --- a/superset/assets/javascripts/explorev2/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ChartContainer.jsx @@ -56,19 +56,22 @@ class ChartContainer extends React.PureComponent { } componentDidUpdate(prevProps) { - if ( - ( - prevProps.queryResponse !== this.props.queryResponse || - prevProps.height !== this.props.height || - this.props.triggerRender - ) && !this.props.queryResponse.error - && this.props.chartStatus !== 'failed' - && this.props.chartStatus !== 'stopped' - ) { + if (this.shouldRenderViz(prevProps)) { this.renderViz(); } } + shouldRenderViz(prevProps) { + const hasHeightChanged = prevProps.height !== this.props.height; + const hasQueryChanged = prevProps.queryResponse !== this.props.queryResponse; + const hasErrors = this.props.queryResponse && this.props.queryResponse.error; + + return (hasQueryChanged || hasHeightChanged || this.props.triggerRender) + && !hasErrors + && this.props.chartStatus !== 'failed' + && this.props.chartStatus !== 'stopped'; + } + getMockedSliceObject() { const props = this.props; const getHeight = () => { diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js index 03e71d58d2..d518b45524 100644 --- a/superset/assets/javascripts/modules/utils.js +++ b/superset/assets/javascripts/modules/utils.js @@ -188,3 +188,11 @@ export function customizeToolTip(chart, xAxisFormatter, yAxisFormatters) { return tooltip; }); } + +export function getTextWidth(text, fontDetails) { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.font = fontDetails; + const metrics = context.measureText(text); + return metrics.width; +} diff --git a/superset/assets/visualizations/big_number.css b/superset/assets/visualizations/big_number.css index e490395914..db34a45a40 100644 --- a/superset/assets/visualizations/big_number.css +++ b/superset/assets/visualizations/big_number.css @@ -2,18 +2,18 @@ .big_number_total g.axis text { font-size: 10px; font-weight: normal; - color: gray; - fill: gray; + color: #333333; + fill: #333333; text-anchor: middle; alignment-baseline: middle; - font-weight: none; + font-family: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif; } .big_number text.big, .big_number_total text.big{ - stroke: black; + stroke: #333333; text-anchor: middle; - fill: black; + fill: #333333; } .big_number g.tick line, @@ -25,6 +25,16 @@ .big_number .domain, .big_number_total .domain{ fill: none; - stroke: black; - stroke-width: 1; + stroke: #333333; } + +.line-tooltip { + position: absolute; + text-align: left; + padding: 10px; + background: #ffffff; + border: 1px solid #ccc; + border-radius: 2px; + pointer-events: none; +} + diff --git a/superset/assets/visualizations/big_number.js b/superset/assets/visualizations/big_number.js index af943a2fac..4a2170c70e 100644 --- a/superset/assets/visualizations/big_number.js +++ b/superset/assets/visualizations/big_number.js @@ -1,8 +1,18 @@ import d3 from 'd3'; import { formatDate } from '../javascripts/modules/dates'; +import { getTextWidth } from '../javascripts/modules/utils'; require('./big_number.css'); +function getNumTicks(data, slice, margin) { + let numTicks = parseInt((slice.width() - margin) * 0.01, 10); + // if numTicks is greater than the total num of data points, show all data points + if (numTicks > data.length) { + numTicks = data.length; + } + return numTicks; +} + function bigNumberVis(slice, payload) { const div = d3.select(slice.selector); // Define the percentage bounds that define color from red to green @@ -38,70 +48,72 @@ function bigNumberVis(slice, payload) { } const dateExt = d3.extent(data, (d) => d[0]); const valueExt = d3.extent(data, (d) => d[1]); + const yAxisLabelWidths = valueExt.map(value => getTextWidth(f(value), '10px Roboto')); + const yAxisMaxWidth = Math.max(...yAxisLabelWidths); + const margin = yAxisMaxWidth + (yAxisMaxWidth / 2); - const margin = 20; const scaleX = d3.time.scale.utc().domain(dateExt).range([margin, width - margin]); const scaleY = d3.scale.linear().domain(valueExt).range([height - (margin), margin]); const colorRange = [d3.hsl(0, 1, 0.3), d3.hsl(120, 1, 0.3)]; const scaleColor = d3.scale - .linear().domain([-1, 1]) - .interpolate(d3.interpolateHsl) - .range(colorRange) - .clamp(true); + .linear().domain([-1, 1]) + .interpolate(d3.interpolateHsl) + .range(colorRange) + .clamp(true); const line = d3.svg.line() - .x(function (d) { - return scaleX(d[0]); - }) - .y(function (d) { - return scaleY(d[1]); - }) - .interpolate('basis'); + .x(d => scaleX(d[0])) + .y(d => scaleY(d[1])) + .interpolate('basis'); let y = height / 2; let g = svg.append('g'); + + const formattedNumber = f(v); + // Printing big number - g.append('g').attr('class', 'digits') - .attr('opacity', 1) - .append('text') - .attr('x', width / 2) - .attr('y', y) - .attr('class', 'big') - .attr('alignment-baseline', 'middle') - .attr('id', 'bigNumber') - .style('font-weight', 'bold') - .style('cursor', 'pointer') - .text(f(v)) - .style('font-size', d3.min([height, width]) / 3.5) - .style('text-anchor', 'middle') - .attr('fill', 'black'); + g.append('g') + .attr('class', 'digits') + .attr('opacity', 1) + .append('text') + .attr('x', width / 2) + .attr('y', y) + .attr('class', 'big') + .attr('alignment-baseline', 'middle') + .attr('id', 'bigNumber') + .style('font-weight', 'bold') + .style('cursor', 'pointer') + .text(formattedNumber) + .attr('font-family', 'Roboto') + .attr('font-size', (width / formattedNumber.length) * 1.3) + .style('text-anchor', 'middle') + .attr('fill', 'black'); // Printing big number subheader text - if (json.subheader !== null) { + if (json.subheader) { + const fontSize = (width / json.subheader.length) * 1.5; g.append('text') - .attr('x', width / 2) - .attr('y', (height / 16) * 12) - .text(json.subheader) - .attr('id', 'subheader_text') - .style('font-size', d3.min([height, width]) / 8) - .style('text-anchor', 'middle'); + .attr('x', width / 2) + .attr('y', (height / 16) * 12) + .text(json.subheader) + .attr('id', 'subheader_text') + .attr('font-family', 'Roboto') + .attr('font-size', fontSize) + .style('text-anchor', 'middle'); } if (fd.viz_type === 'big_number') { // Drawing trend line - g.append('path') - .attr('d', function () { - return line(data); - }) - .attr('stroke-width', 5) - .attr('opacity', 0.5) - .attr('fill', 'none') - .attr('stroke-linecap', 'round') - .attr('stroke', 'grey'); + .attr('d', () => line(data)) + .attr('stroke-width', 5) + .attr('opacity', 0.5) + .attr('fill', 'none') + .attr('stroke-linecap', 'round') + .attr('stroke', 'grey'); g = svg.append('g') - .attr('class', 'digits') - .attr('opacity', 1); + .attr('class', 'digits') + .attr('opacity', 1); if (vCompare !== null) { y = (height / 8) * 3; @@ -112,71 +124,116 @@ function bigNumberVis(slice, payload) { // Printing compare % if (vCompare) { g.append('text') - .attr('x', width / 2) - .attr('y', (height / 16) * 12) - .text(fp(vCompare) + json.compare_suffix) - .style('font-size', d3.min([height, width]) / 8) - .style('text-anchor', 'middle') - .attr('fill', c) - .attr('stroke', c); + .attr('x', width / 2) + .attr('y', (height / 16) * 12) + .text(fp(vCompare) + json.compare_suffix) + .style('font-size', d3.min([height, width]) / 8) + .style('text-anchor', 'middle') + .attr('fill', c) + .attr('stroke', c); } + // axes const gAxis = svg.append('g').attr('class', 'axis').attr('opacity', 0); g = gAxis.append('g'); + const minMaxTickValues = scaleX.domain(); + // prepend the min value, and append the max value to the list of tick values + const tickValues = + [minMaxTickValues[0]] + .concat(scaleX.ticks(getNumTicks(data, slice, margin))) + .concat([minMaxTickValues[1]]); const xAxis = d3.svg.axis() - .scale(scaleX) - .orient('bottom') - .ticks(4) - .tickFormat(formatDate); + .scale(scaleX) + .orient('bottom') + .tickValues(tickValues) + .tickFormat(formatDate); g.call(xAxis); - g.attr('transform', 'translate(0,' + (height - margin) + ')'); + g.attr('transform', 'translate(0,' + (height - margin) + ')').attr('class', 'xAxis'); - g = gAxis.append('g').attr('transform', 'translate(' + (width - margin) + ',0)'); + g = gAxis.append('g').attr('transform', `translate(${margin}, 0)`).attr('class', 'yAxis'); const yAxis = d3.svg.axis() - .scale(scaleY) - .orient('left') - .tickFormat(d3.format(fd.y_axis_format)) - .tickValues(valueExt); + .scale(scaleY) + .orient('left') + .tickFormat(d3.format(fd.y_axis_format)) + .tickValues(valueExt); g.call(yAxis); g.selectAll('text') - .style('text-anchor', 'end') - .attr('y', '-7') - .attr('x', '-4'); + .style('text-anchor', 'end') + .attr('y', '-7') + .attr('x', '-4'); - g.selectAll('text') - .style('font-size', '10px'); + // Define the div for the tooltip + const tooltipEl = + d3.select('body') + .append('div') + .attr('class', 'line-tooltip') + .attr('width', 200) + .attr('height', 200) + .style('opacity', 0); + const renderTooltip = (d) => { + const date = formatDate(d[0]); + const value = f(d[1]); + return ` +
+ ${date} + ${value} +
+ `; + }; + + // Add the scatterplot and trigger the mouse events for the tooltips + svg + .selectAll('dot') + .data(data) + .enter() + .append('circle') + .attr('r', 10) + .attr('cx', d => scaleX(d[0])) + .attr('cy', d => scaleY(d[1])) + .attr('fill-opacity', '0') + .on('mouseover', (d) => { + tooltipEl.html(renderTooltip(d)) + .style('left', (d3.event.pageX) + 'px') + .style('top', (d3.event.pageY - 28) + 'px'); + tooltipEl.transition().duration(200).style('opacity', 0.9); + }) + .on('mouseout', () => { + tooltipEl.transition().duration(500).style('opacity', 0); + }); + + // show hide x/y axis on mouseover/out div.on('mouseover', function () { const el = d3.select(this); el.selectAll('path') - .transition() - .duration(500) - .attr('opacity', 1) - .style('stroke-width', '2px'); + .transition() + .duration(500) + .attr('opacity', 1) + .style('stroke-width', '2px'); el.selectAll('g.digits') - .transition() - .duration(500) - .attr('opacity', 0.1); + .transition() + .duration(500) + .attr('opacity', 0.1); el.selectAll('g.axis') - .transition() - .duration(500) - .attr('opacity', 1); + .transition() + .duration(500) + .attr('opacity', 1); }) .on('mouseout', function () { const el = d3.select(this); el.select('path') - .transition() - .duration(500) - .attr('opacity', 0.5) - .style('stroke-width', '5px'); + .transition() + .duration(500) + .attr('opacity', 0.5) + .style('stroke-width', '5px'); el.selectAll('g.digits') - .transition() - .duration(500) - .attr('opacity', 1); + .transition() + .duration(500) + .attr('opacity', 1); el.selectAll('g.axis') - .transition() - .duration(500) - .attr('opacity', 0); + .transition() + .duration(500) + .attr('opacity', 0); }); } }