diff --git a/__tests__/DataProcessor.js b/__tests__/DataProcessor.js index 42a9d32..4ab81f7 100644 --- a/__tests__/DataProcessor.js +++ b/__tests__/DataProcessor.js @@ -104,4 +104,43 @@ describe('DataProcessor', () => { expect(DataProcessor.median(data)).to.eq(3668.810244) }) }); + + describe('isGapValue', () => { + it('finite numbers are not gaps', () => { + expect(DataProcessor.isGapValue(0)).to.eq(false); + expect(DataProcessor.isGapValue(1.5)).to.eq(false); + }); + + it('anything else is a gap', () => { + expect(DataProcessor.isGapValue(Infinity)).to.eq(true); + expect(DataProcessor.isGapValue(-Infinity)).to.eq(true); + expect(DataProcessor.isGapValue(NaN)).to.eq(true); + expect(DataProcessor.isGapValue('0')).to.eq(true); + expect(DataProcessor.isGapValue('1.5')).to.eq(true); + expect(DataProcessor.isGapValue({})).to.eq(true); + expect(DataProcessor.isGapValue(null)).to.eq(true); + expect(DataProcessor.isGapValue(undefined)).to.eq(true); + expect(DataProcessor.isGapValue([])).to.eq(true); + }); + }); + + describe('pointsToSegments', () => { + it('without gap', () => { + expect(DataProcessor.pointsToSegments([{x: 0, y: 0}])).to.eql([{isGap: false, points: [{x: 0, y: 0}]}]); + expect(DataProcessor.pointsToSegments([{x: 0, y: 0}, {x: 1, y: 1}, {x: 2, y: 1}])).to.eql([{isGap: false, points: [{x: 0, y: 0}, {x: 1, y: 1}, {x: 2, y: 1}]}]); + }); + it('only gap', () => { + expect(DataProcessor.pointsToSegments([{x: 0, y: null}])).to.eql([{isGap: true, points: [{x: 0, y: null}]}]); + expect(DataProcessor.pointsToSegments([{x: 0, y: null}, {x: 1, y: null}, {x: 2, y: null}])).to.eql([{isGap: true, points: [{x: 0, y: null}, {x: 1, y: null}, {x: 2, y: null}]}]); + }); + it('gap-nongap', () => { + expect(DataProcessor.pointsToSegments([{x: 0, y: null}, {x: 1, y: 2}, {x: 2, y: 3}])).to.eql([{isGap: true, points: [{x: 0, y: null}]}, {isGap: false, points: [{x: 1, y: 2}, {x: 2, y: 3}]}]); + }); + it('nongap-gap', () => { + expect(DataProcessor.pointsToSegments([{x: 0, y: 1}, {x: 1, y: null}, {x: 2, y: null}])).to.eql([{isGap: false, points: [{x: 0, y: 1}]}, {isGap: true, points: [{x: 1, y: null}, {x: 2, y: null}]}]); + }); + it('gap-nongap-gap', () => { + expect(DataProcessor.pointsToSegments([{x: 0, y: null}, {x: 1, y: 2}, {x: 2, y: null}])).to.eql([{isGap: true, points: [{x: 0, y: null}]}, {isGap: false, points: [{x: 1, y: 2}]}, {isGap: true, points: [{x: 2, y: null}]}]); + }); + }); }); diff --git a/__tests__/data.json b/__tests__/data.json index 02de8e4..60201cb 100644 --- a/__tests__/data.json +++ b/__tests__/data.json @@ -63,5 +63,25 @@ 0.7154759857504911, -2.8727571205730915, -1.3501468729225303, -0.0865770144109483, 0.505651174224522, -2.2111682240498753, 2.035381345199811 + ], + "sampleDataGaps": [0.26789611283279424, + null, -0.46848826820269196, -0.02429709108986638, -0.07347501430506465, + 0.938722048681125, -0.02488170176918398, + 0.014511315562131895, + null, + null, + 0.6777968350341455, + null, + 1.402853678778828, + null, -0.37025235972161835, -0.10254054014867667, + 0.3709902985604339, + 2.5285657626539253, -0.18958673659343403, + 0.8578243085059141, + 1.7395812075504404, + 0.9723534409914075, -0.6799757002898873, + 1.153081489500828, + 1.3851189843556257, + 0.19355625368483506, + 1.262069965103209, -0.8628137671385424, -0.6118030618030503, -0.25257403618789087 ] } \ No newline at end of file diff --git a/__tests__/fixtures.js b/__tests__/fixtures.js index f178336..a9a2349 100644 --- a/__tests__/fixtures.js +++ b/__tests__/fixtures.js @@ -2,13 +2,15 @@ import React from 'react'; import { Sparklines, SparklinesBars, SparklinesLine, SparklinesCurve, SparklinesNormalBand, SparklinesReferenceLine, SparklinesSpots } from '../src/Sparklines'; -import { sampleData, sampleData100 } from './data.json'; +import { sampleData, sampleData100, sampleDataGaps } from './data.json'; export default { // AUTO-GENERATED PART STARTS HERE "Header": {jsx: (), svg: ""}, "Simple": {jsx: (), svg: ""}, "SimpleCurve": {jsx: (), svg: ""}, + "SimpleGaps": {jsx: (), svg: ""}, + "SimpleCurveGaps": {jsx: (), svg: ""}, "Customizable1": {jsx: (), svg: ""}, "Customizable2": {jsx: (), svg: ""}, "Customizable3": {jsx: (), svg: ""}, @@ -21,14 +23,17 @@ export default { "Spots3": {jsx: (), svg: ""}, "Bars1": {jsx: (), svg: ""}, "Bars2": {jsx: (), svg: ""}, + "BarsGaps": {jsx: (), svg: ""}, "ReferenceLine1": {jsx: (), svg: ""}, "ReferenceLine2": {jsx: (), svg: ""}, "ReferenceLine3": {jsx: (), svg: ""}, "ReferenceLine4": {jsx: (), svg: ""}, "ReferenceLine5": {jsx: (), svg: ""}, "ReferenceLine6": {jsx: (), svg: ""}, + "ReferenceLineGaps": {jsx: (), svg: ""}, "NormalBand1": {jsx: (), svg: ""}, "NormalBand2": {jsx: (), svg: ""}, + "NormalBandGaps": {jsx: (), svg: ""}, "RealWorld1": {jsx: (), svg: ""}, "RealWorld2": {jsx: (), svg: ""}, "RealWorld3": {jsx: (), svg: ""}, diff --git a/bootstrap-tests.js b/bootstrap-tests.js index 10cd82a..b3756cf 100644 --- a/bootstrap-tests.js +++ b/bootstrap-tests.js @@ -23,9 +23,9 @@ const dynamicPartEndSignal = '// AUTO-GENERATED PART ENDS HERE'; const fixtures = require(fixturesFile).default; // Handle recurring data constants -import {sampleData, sampleData100} from './__tests__/data.json'; +import {sampleData, sampleData100, sampleDataGaps} from './__tests__/data.json'; const recognizedDataConstants = { - sampleData, sampleData100 + sampleData, sampleData100, sampleDataGaps }; const recognizedDataStrings = {}; for (let dataKey of Object.keys(recognizedDataConstants)) { diff --git a/demo/demo.js b/demo/demo.js index 48f58b8..0742898 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -29,6 +29,7 @@ function randomData(n = 30) { const sampleData = randomData(30); const sampleData100 = randomData(100); +const sampleDataGaps = sampleData.map(x => x < -1 ? NaN : x); const Header = () => @@ -41,11 +42,21 @@ const Simple = () => +const SimpleGaps = () => + + + + const SimpleCurve = () => +const SimpleCurveGaps = () => + + + + const Customizable1 = () => @@ -110,6 +121,12 @@ const Bars2 = () => +const BarsGaps = () => + + + + + class Dynamic1 extends Component { constructor(props) { @@ -230,6 +247,12 @@ const ReferenceLine6 = () => +const ReferenceLineGaps = () => + + + + + const NormalBand1 = () => @@ -243,6 +266,13 @@ const NormalBand2 = () => +const NormalBandGaps = () => + + + + + + const RealWorld1 = () => @@ -295,7 +325,9 @@ const RealWorld9 = () => const demos = { 'headersparklines': Header, 'simple': Simple, + 'simpleGaps': SimpleGaps, 'simpleCurve': SimpleCurve, + 'simpleCurveGaps': SimpleCurveGaps, 'customizable1': Customizable1, 'customizable2': Customizable2, 'customizable3': Customizable3, @@ -308,6 +340,7 @@ const demos = { 'bounds1': Bounds1, 'bars1': Bars1, 'bars2': Bars2, + 'barsGaps': BarsGaps, 'dynamic1': Dynamic1, 'dynamic2': Dynamic2, 'dynamic3': Dynamic3, @@ -318,8 +351,10 @@ const demos = { 'referenceline4': ReferenceLine4, 'referenceline5': ReferenceLine5, 'referenceline6': ReferenceLine6, + 'referencelineGaps': ReferenceLineGaps, 'normalband1': NormalBand1, 'normalband2': NormalBand2, + 'normalbandGaps': NormalBandGaps, 'realworld1': RealWorld1, 'realworld2': RealWorld2, 'realworld3': RealWorld3, diff --git a/demo/index.html b/demo/index.html index f417aa7..e72cd52 100644 --- a/demo/index.html +++ b/demo/index.html @@ -114,6 +114,17 @@

Simple

+

Simple w/Gaps

+ +
+
+

+<Sparklines data={sampleDataGaps}>
+    <SparklinesLine />
+</Sparklines>
+            
+
+

Simple Curve

@@ -125,6 +136,17 @@

Simple Curve

+

Simple Curve w/Gaps

+ +
+
+

+<Sparklines data={sampleDataGaps}>
+    <SparklinesCurve />
+</Sparklines>
+            
+
+

Customizable

@@ -208,6 +230,16 @@

Bars

+
+
+

+<Sparklines data={sampleDataGaps}>
+    <SparklinesBars style={{ stroke: "white", fill: "#41c3f9", fillOpacity: ".25" }} />
+    <SparklinesLine style={{ stroke: "#41c3f9", fill: "none" }} />
+</Sparklines>
+            
+
+

Dynamic

@@ -310,6 +342,16 @@

Reference Line

+
+
+

+<Sparklines data={sampleDataGaps}>
+    <SparklinesBars style={{ fill: 'slategray', fillOpacity: ".5" }} />
+    <SparklinesReferenceLine />
+</Sparklines>
+            
+
+

Normal Band

@@ -333,6 +375,17 @@

Normal Band

+
+
+

+<Sparklines data={sampleData}>
+    <SparklinesLine style={{ fill: "none" }}/>
+    <SparklinesNormalBand />
+    <SparklinesReferenceLine type="mean" />
+</Sparklines>
+            
+
+

Real world examples

diff --git a/src/DataProcessor.js b/src/DataProcessor.js index 8914957..ce9cd1d 100644 --- a/src/DataProcessor.js +++ b/src/DataProcessor.js @@ -14,38 +14,47 @@ export default class DataProcessor { return data.map((d, i) => { return { x: i * hfactor + margin, - y: (max === min ? 1 : (max - d)) * vfactor + margin + y: this.isGapValue(d) ? d : ((max === min ? 1 : (max - d)) * vfactor + margin) } }); } + static nonGapValues(data) { + return data.filter(d => !this.isGapValue(d)); + } + static max(data) { - return Math.max.apply(Math, data); + return Math.max.apply(Math, this.nonGapValues(data)); } static min(data) { - return Math.min.apply(Math, data); + return Math.min.apply(Math, this.nonGapValues(data)); } static mean(data) { + data = this.nonGapValues(data); return (this.max(data) - this.min(data)) / 2; } static avg(data) { + data = this.nonGapValues(data); return data.reduce((a, b) => a + b) / data.length; } static median(data) { + data = this.nonGapValues(data); return data.sort((a,b) => a - b)[Math.floor(data.length / 2)]; } static variance(data) { + data = this.nonGapValues(data); const mean = this.mean(data); const sq = data.map(n => Math.pow(n - mean, 2)); return this.mean(sq); } static stdev(data) { + data = this.nonGapValues(data); const mean = this.mean(data); const sqDiff = data.map(n => Math.pow(n - mean, 2)); const avgSqDiff = this.avg(sqDiff); @@ -55,4 +64,28 @@ export default class DataProcessor { static calculateFromData(data, calculationType) { return this[calculationType].call(this, data); } + + static pointsToSegments(points) { + let segment, segments = []; + const newSegment = (isGap) => ({points: [], isGap}); + for (let point of points) { + if (!segment) + segment = newSegment(false); + const isGapHere = this.isGapValue(point.y); + if (segment.isGap != isGapHere) { + if (segment.points.length) + segments.push(segment); + segment = newSegment(isGapHere); + } + segment.points.push(point); + } + if (segment && segment.points.length) + segments.push(segment); + return segments; + } + + static isGapValue(y) + { + return !Number.isFinite(y); + } } diff --git a/src/Sparklines.js b/src/Sparklines.js index 32a28d8..0128168 100644 --- a/src/Sparklines.js +++ b/src/Sparklines.js @@ -39,7 +39,7 @@ class Sparklines extends React.Component { nextProps.max != this.props.max || nextProps.limit != this.props.limit || nextProps.data.length != this.props.data.length || - nextProps.data.some((d, i) => d !== this.props.data[i]); + nextProps.data.some((d, i) => !Object.is(d, this.props.data[i])); } render() { @@ -47,7 +47,7 @@ class Sparklines extends React.Component { if (data.length === 0) return null; - const points = DataProcessor.dataToPoints(data, limit, width, height, margin, max, min); + const points = DataProcessor.dataToPoints(data, limit, width, height, margin, max, min ); return ( diff --git a/src/SparklinesBars.js b/src/SparklinesBars.js index 2672188..921a779 100644 --- a/src/SparklinesBars.js +++ b/src/SparklinesBars.js @@ -1,4 +1,5 @@ import React from 'react'; +import DataProcessor from './DataProcessor'; export default class SparklinesBars extends React.Component { @@ -22,13 +23,13 @@ export default class SparklinesBars extends React.Component { return ( {points.map((p, i) => - + style={style} />) )} ) diff --git a/src/SparklinesCurve.js b/src/SparklinesCurve.js index 13bc69d..8419bfb 100644 --- a/src/SparklinesCurve.js +++ b/src/SparklinesCurve.js @@ -1,4 +1,55 @@ import React from 'react'; +import * as defaults from './defaults'; +import SparklinesSegmentContainer from './SparklinesSegmentContainer'; + +function SparklinesCurveSegment({ points, width, height, margin, color, style, divisor = 0.25 }) { + let prev; + const curve = (p) => { + let res; + if (!prev) { + res = [p.x, p.y] + } else { + const len = (p.x - prev.x) * divisor; + res = [ "C", + //x1 + prev.x + len, + //y1 + prev.y, + //x2, + p.x - len, + //y2, + p.y, + //x, + p.x, + //y + p.y + ]; + } + prev = p; + return res; + + } + const linePoints = points + .map((p) => curve(p)) + .reduce((a, b) => a.concat(b)); + const closePolyPoints = [ + "L" + points[points.length - 1].x, height - margin, + points[0].x, height - margin, + points[0].x, points[0].y + ]; + const fillPoints = linePoints.concat(closePolyPoints); + + const lineStyle = defaults.getLineStyle({color, style}); + + const fillStyle = defaults.getFillStyle({color, style}); + + return ( + + + + + ); +} export default class SparklinesCurve extends React.Component { @@ -12,62 +63,8 @@ export default class SparklinesCurve extends React.Component { }; render() { - const { points, width, height, margin, color, style, divisor = 0.25 } = this.props; - let prev; - const curve = (p) => { - let res; - if (!prev) { - res = [p.x, p.y] - } else { - const len = (p.x - prev.x) * divisor; - res = [ "C", - //x1 - prev.x + len, - //y1 - prev.y, - //x2, - p.x - len, - //y2, - p.y, - //x, - p.x, - //y - p.y - ]; - } - prev = p; - return res; - - } - const linePoints = points - .map((p) => curve(p)) - .reduce((a, b) => a.concat(b)); - const closePolyPoints = [ - "L" + points[points.length - 1].x, height - margin, - margin, height - margin, - margin, points[0].y - ]; - const fillPoints = linePoints.concat(closePolyPoints); - - const lineStyle = { - stroke: color || style.stroke || 'slategray', - strokeWidth: style.strokeWidth || '1', - strokeLinejoin: style.strokeLinejoin || 'round', - strokeLinecap: style.strokeLinecap || 'round', - fill: 'none' - }; - const fillStyle = { - stroke: style.stroke || 'none', - strokeWidth: '0', - fillOpacity: style.fillOpacity || '.1', - fill: style.fill || color || 'slategray' - }; - - return ( - - - - - ) + return ( + + ); } } diff --git a/src/SparklinesLine.js b/src/SparklinesLine.js index dfc3f4d..fae26ae 100644 --- a/src/SparklinesLine.js +++ b/src/SparklinesLine.js @@ -1,4 +1,32 @@ import React from 'react'; +import * as defaults from './defaults'; +import SparklinesSegmentContainer from './SparklinesSegmentContainer'; + +function SparklinesLineSegment({points, width, height, margin, color, style}) { + if (!style) + style = {}; + + const linePoints = points + .map((p) => [p.x, p.y]) + .reduce((a, b) => a.concat(b)); + const closePolyPoints = [ + points[points.length - 1].x, height - margin, + points[0].x, height - margin, + points[0].x, points[0].y + ]; + const fillPoints = linePoints.concat(closePolyPoints); + + const lineStyle = defaults.getLineStyle({color, style}); + + const fillStyle = defaults.getFillStyle({color, style}); + + return ( + + + + + ) +} export default class SparklinesLine extends React.Component { @@ -12,37 +40,8 @@ export default class SparklinesLine extends React.Component { }; render() { - const { points, width, height, margin, color, style } = this.props; - - const linePoints = points - .map((p) => [p.x, p.y]) - .reduce((a, b) => a.concat(b)); - const closePolyPoints = [ - points[points.length - 1].x, height - margin, - margin, height - margin, - margin, points[0].y - ]; - const fillPoints = linePoints.concat(closePolyPoints); - - const lineStyle = { - stroke: color || style.stroke || 'slategray', - strokeWidth: style.strokeWidth || '1', - strokeLinejoin: style.strokeLinejoin || 'round', - strokeLinecap: style.strokeLinecap || 'round', - fill: 'none' - }; - const fillStyle = { - stroke: style.stroke || 'none', - strokeWidth: '0', - fillOpacity: style.fillOpacity || '.1', - fill: style.fill || color || 'slategray' - }; - - return ( - - - - - ) + return ( + + ); } -} +} \ No newline at end of file diff --git a/src/SparklinesSegmentContainer.js b/src/SparklinesSegmentContainer.js new file mode 100644 index 0000000..41dde98 --- /dev/null +++ b/src/SparklinesSegmentContainer.js @@ -0,0 +1,18 @@ +import React from 'react'; +import DataProcessor from './DataProcessor'; + +export default class SparklinesSegmentContainer extends React.Component { + render() { + const { points, children, ...props } = this.props; + const groups = DataProcessor.pointsToSegments(points) + .filter(segment => !segment.isGap) + .map((segment, i) => React.cloneElement(React.Children.only(children), { + key: i, + points: segment.points, + ...props + })); + if (groups.length === 1) + return groups[0]; + return {groups}; + } +} diff --git a/src/defaults.js b/src/defaults.js new file mode 100644 index 0000000..7ee47fa --- /dev/null +++ b/src/defaults.js @@ -0,0 +1,40 @@ +export const lineStyle = { + stroke: 'slategray', + strokeWidth: '1', + strokeLinejoin: 'round', + strokeLinecap: 'round', + fill: 'none' +}; + +export const fillStyle = { + stroke: 'none', + strokeWidth: '0', + fillOpacity: '.1', + fill: 'slategray' +}; + +export const getLineStyle = ({color, style}) => { + const defaultStyle = lineStyle; + style = style || {}; + + return { + stroke: color || style.stroke || defaultStyle.stroke, + strokeWidth: style.strokeWidth || defaultStyle.strokeWidth, + strokeLinejoin: style.strokeLinejoin || defaultStyle.strokeLinejoin, + strokeLinecap: style.strokeLinecap || defaultStyle.strokeLinecap, + fill: defaultStyle.fill + }; +} + +export const getFillStyle = ({color, style}) => { + const defaultStyle = fillStyle; + style = style || {}; + + return { + stroke: style.stroke || defaultStyle.stroke, + strokeWidth: defaultStyle.strokeWidth, + fillOpacity: style.fillOpacity || defaultStyle.fillOpacity, + fill: style.fill || color || defaultStyle.fill + }; +} +