Loëck Vézien
7 years ago
committed by
GitHub
20 changed files with 994 additions and 64 deletions
@ -0,0 +1,67 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
import React, { Fragment } from 'react' |
||||
|
|
||||
|
import { colors as themeColors } from 'styles/theme' |
||||
|
import { TooltipContainer } from 'components/base/Tooltip' |
||||
|
|
||||
|
import type { Item } from './types' |
||||
|
|
||||
|
/** |
||||
|
* we use inline style for more perfs, as tooltip may re-render numerous times |
||||
|
*/ |
||||
|
|
||||
|
const Arrow = () => ( |
||||
|
<svg |
||||
|
style={{ |
||||
|
display: 'block', |
||||
|
position: 'absolute', |
||||
|
left: '50%', |
||||
|
bottom: 0, |
||||
|
marginBottom: -10, |
||||
|
transform: 'translate(-50%, 0)', |
||||
|
}} |
||||
|
viewBox="0 0 14 6.2" |
||||
|
width={16} |
||||
|
height={16} |
||||
|
> |
||||
|
<path fill={themeColors.dark} d="m14 0-5.5 5.6c-0.8 0.8-2 0.8-2.8 0l-5.7-5.6" /> |
||||
|
</svg> |
||||
|
) |
||||
|
|
||||
|
const Tooltip = ({ d, renderTooltip }: { d: Item, renderTooltip?: Function }) => ( |
||||
|
<div style={{ position: 'relative' }}> |
||||
|
<div |
||||
|
style={{ |
||||
|
position: 'absolute', |
||||
|
bottom: '100%', |
||||
|
left: 0, |
||||
|
transform: `translate3d(-50%, 0, 0)`, |
||||
|
whiteSpace: 'nowrap', |
||||
|
marginBottom: 10, |
||||
|
}} |
||||
|
> |
||||
|
<TooltipContainer style={{ textAlign: 'center' }}> |
||||
|
{renderTooltip ? ( |
||||
|
renderTooltip(d) |
||||
|
) : ( |
||||
|
<Fragment> |
||||
|
<div style={{ fontSize: 14 }}> |
||||
|
<b>{Math.round(d.value)}</b> |
||||
|
</div> |
||||
|
<span>{d.date}</span> |
||||
|
</Fragment> |
||||
|
)} |
||||
|
</TooltipContainer> |
||||
|
<div style={{ background: 'red' }}> |
||||
|
<Arrow /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
|
||||
|
Tooltip.defaultProps = { |
||||
|
renderTooltip: undefined, |
||||
|
} |
||||
|
|
||||
|
export default Tooltip |
@ -0,0 +1,116 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
import React from 'react' |
||||
|
import * as d3 from 'd3' |
||||
|
import { renderToString } from 'react-dom/server' |
||||
|
import { ThemeProvider } from 'styled-components' |
||||
|
|
||||
|
import theme from 'styles/theme' |
||||
|
|
||||
|
import type { Props } from '.' |
||||
|
import type { CTX } from './types' |
||||
|
|
||||
|
import Tooltip from './Tooltip' |
||||
|
|
||||
|
export default function handleMouseEvents({ |
||||
|
ctx, |
||||
|
props, |
||||
|
shouldTooltipUpdate, |
||||
|
onTooltipUpdate, |
||||
|
renderTooltip, |
||||
|
}: { |
||||
|
ctx: CTX, |
||||
|
props: Props, |
||||
|
shouldTooltipUpdate: Function, |
||||
|
onTooltipUpdate: Function, |
||||
|
renderTooltip?: Function, |
||||
|
}) { |
||||
|
const { MARGINS, HEIGHT, WIDTH, NODES, DATA, x, y } = ctx |
||||
|
const { hideAxis } = props |
||||
|
|
||||
|
const bisectDate = d3.bisector(d => d.parsedDate).left |
||||
|
|
||||
|
const node = NODES.wrapper |
||||
|
.append('rect') |
||||
|
.attr('fill', 'none') |
||||
|
.attr('pointer-events', 'all') |
||||
|
.attr('class', 'overlay') |
||||
|
.attr('width', WIDTH) |
||||
|
.attr('height', HEIGHT) |
||||
|
|
||||
|
node |
||||
|
.on('mousemove', mouseMove) |
||||
|
.on('mouseenter', mouseEnter) |
||||
|
.on('mouseout', mouseOut) |
||||
|
|
||||
|
function getStep() { |
||||
|
const x0 = x.invert(d3.mouse(this)[0]) |
||||
|
const i = bisectDate(DATA, x0, 1) |
||||
|
const d0 = DATA[i - 1] |
||||
|
const d1 = DATA[i] |
||||
|
if (!d0 || !d1) { |
||||
|
return null |
||||
|
} |
||||
|
return x0 - d0.parsedDate > d1.parsedDate - x0 ? d1 : d0 |
||||
|
} |
||||
|
|
||||
|
function mouseEnter() { |
||||
|
const d = getStep.call(this) |
||||
|
if (!d) { |
||||
|
return |
||||
|
} |
||||
|
NODES.tooltip |
||||
|
.style('transition', '100ms cubic-bezier(.61,1,.53,1) opacity') |
||||
|
.style('opacity', 1) |
||||
|
.style('transform', `translate3d(${MARGINS.left + x(d.parsedDate)}px, ${y(d.value)}px, 0)`) |
||||
|
NODES.focus.style('opacity', 1) |
||||
|
if (!hideAxis) { |
||||
|
NODES.xBar.style('opacity', 1) |
||||
|
NODES.yBar.style('opacity', 1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function mouseOut() { |
||||
|
NODES.tooltip.style('opacity', 0).style('transition', '100ms linear opacity') |
||||
|
NODES.focus.style('opacity', 0) |
||||
|
if (!hideAxis) { |
||||
|
NODES.xBar.style('opacity', 0) |
||||
|
NODES.yBar.style('opacity', 0) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function mouseMove() { |
||||
|
const d = getStep.call(this) |
||||
|
if (!d) { |
||||
|
return |
||||
|
} |
||||
|
if (!shouldTooltipUpdate(d)) { |
||||
|
return |
||||
|
} |
||||
|
onTooltipUpdate(d) |
||||
|
NODES.focus.attr('transform', `translate(${x(d.parsedDate)},${y(d.value)})`) |
||||
|
NODES.tooltip |
||||
|
.html( |
||||
|
renderToString( |
||||
|
<ThemeProvider theme={theme}> |
||||
|
<Tooltip renderTooltip={renderTooltip} d={d.ref} /> |
||||
|
</ThemeProvider>, |
||||
|
), |
||||
|
) |
||||
|
.style('transform', `translate3d(${MARGINS.left + x(d.parsedDate)}px, ${y(d.value)}px, 0)`) |
||||
|
if (!hideAxis) { |
||||
|
NODES.xBar |
||||
|
.attr('x1', x(d.parsedDate)) |
||||
|
.attr('x2', x(d.parsedDate)) |
||||
|
.attr('y1', HEIGHT) |
||||
|
.attr('y2', y(d.value)) |
||||
|
NODES.yBar |
||||
|
.attr('x1', 0) |
||||
|
.attr('x2', x(d.parsedDate)) |
||||
|
.attr('y1', y(d.value)) |
||||
|
.attr('y2', y(d.value)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return node |
||||
|
} |
@ -0,0 +1,49 @@ |
|||||
|
import c from 'color' |
||||
|
|
||||
|
export function enrichData(data, parseTime) { |
||||
|
return data.map((d, i) => ({ |
||||
|
...d, |
||||
|
ref: d, |
||||
|
index: i, |
||||
|
parsedDate: parseTime(d.date), |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
export function generateColors(color) { |
||||
|
const cColor = c(color) |
||||
|
return { |
||||
|
line: color, |
||||
|
focus: color, |
||||
|
gradientStart: cColor.fade(0.7), |
||||
|
gradientStop: cColor.fade(1), |
||||
|
focusBar: cColor.fade(0.5), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function generateMargins(hideAxis) { |
||||
|
const margins = { |
||||
|
top: hideAxis ? 5 : 10, |
||||
|
bottom: hideAxis ? 5 : 30, |
||||
|
right: hideAxis ? 5 : 40, |
||||
|
left: hideAxis ? 5 : 80, |
||||
|
} |
||||
|
|
||||
|
// FIXME: Forced to "use" margins here to prevent babel/uglify to believe
|
||||
|
// there is a constant variable re-assignment. I don't get it, but it
|
||||
|
// works, so, eh.
|
||||
|
void margins |
||||
|
|
||||
|
return margins |
||||
|
} |
||||
|
|
||||
|
export function observeResize(node, cb) { |
||||
|
const ro = new ResizeObserver(() => { |
||||
|
if (!node) { |
||||
|
return |
||||
|
} |
||||
|
const { width } = node.getBoundingClientRect() |
||||
|
cb(width) |
||||
|
}) |
||||
|
|
||||
|
ro.observe(node) |
||||
|
} |
@ -0,0 +1,184 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
/** |
||||
|
* Chart |
||||
|
* ----- |
||||
|
* |
||||
|
* XX |
||||
|
* XXXX |
||||
|
* X XX X |
||||
|
* XXXX XX X |
||||
|
* XX X XX X |
||||
|
* X XXXX X XX X |
||||
|
* XX XX X XX XX XX |
||||
|
* XX XX XX XXXX |
||||
|
* XX |
||||
|
* XX |
||||
|
* Usage: |
||||
|
* |
||||
|
* <Chart |
||||
|
* data={data} |
||||
|
* interactive // Handle mouse events, display tooltip etc.
|
||||
|
* color="#5f8ced" // Main color for line, gradient, etc.
|
||||
|
* height={300} // Fix height. Width is responsive to container.
|
||||
|
* /> |
||||
|
* |
||||
|
* `data` looks like: |
||||
|
* |
||||
|
* [ |
||||
|
* { date: '2018-01-01', value: 10 }, |
||||
|
* { date: '2018-01-02', value: 25 }, |
||||
|
* { date: '2018-01-03', value: 50 }, |
||||
|
* ] |
||||
|
* |
||||
|
*/ |
||||
|
|
||||
|
import React, { PureComponent } from 'react' |
||||
|
import * as d3 from 'd3' |
||||
|
import noop from 'lodash/noop' |
||||
|
|
||||
|
import refreshNodes from './refreshNodes' |
||||
|
import refreshDraw from './refreshDraw' |
||||
|
import handleMouseEvents from './handleMouseEvents' |
||||
|
import { enrichData, generateColors, generateMargins, observeResize } from './helpers' |
||||
|
|
||||
|
import type { Data } from './types' |
||||
|
|
||||
|
export type Props = { |
||||
|
data: Data, // eslint-disable-line react/no-unused-prop-types
|
||||
|
color?: string, // eslint-disable-line react/no-unused-prop-types
|
||||
|
hideAxis?: boolean, // eslint-disable-line react/no-unused-prop-types
|
||||
|
interactive?: boolean, // eslint-disable-line react/no-unused-prop-types
|
||||
|
height: number, |
||||
|
dateFormat?: string, // eslint-disable-line react/no-unused-prop-types
|
||||
|
id?: string, // eslint-disable-line react/no-unused-prop-types
|
||||
|
nbTicksX: number, // eslint-disable-line react/no-unused-prop-types
|
||||
|
renderTooltip?: Function, // eslint-disable-line react/no-unused-prop-types
|
||||
|
renderTickX?: Function, // eslint-disable-line react/no-unused-prop-types
|
||||
|
renderTickY?: Function, // eslint-disable-line react/no-unused-prop-types
|
||||
|
} |
||||
|
|
||||
|
class Chart extends PureComponent<Props> { |
||||
|
static defaultProps = { |
||||
|
color: '#000', |
||||
|
hideAxis: false, |
||||
|
interactive: true, |
||||
|
height: 400, |
||||
|
dateFormat: '%Y-%m-%d', |
||||
|
id: 'chart', |
||||
|
nbTicksX: 5, |
||||
|
} |
||||
|
|
||||
|
componentDidMount() { |
||||
|
const { width } = this._ruler.getBoundingClientRect() |
||||
|
this._width = width |
||||
|
this.createChart() |
||||
|
observeResize(this._ruler, width => { |
||||
|
if (width !== this._width) { |
||||
|
this._width = width |
||||
|
this.refreshChart(this.props) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
componentDidUpdate(prevProps: Props) { |
||||
|
this.refreshChart(prevProps) |
||||
|
} |
||||
|
|
||||
|
_ruler: any |
||||
|
_node: any |
||||
|
_width: number |
||||
|
refreshChart: Function |
||||
|
|
||||
|
createChart() { |
||||
|
const ctx = { |
||||
|
NODES: {}, |
||||
|
INVALIDATED: {}, |
||||
|
MARGINS: {}, |
||||
|
COLORS: {}, |
||||
|
DATA: [], |
||||
|
WIDTH: 0, |
||||
|
HEIGHT: 0, |
||||
|
x: noop, |
||||
|
y: noop, |
||||
|
} |
||||
|
|
||||
|
let firstRender = true |
||||
|
|
||||
|
// Keep reference to mouse handler to allow destroy when refresh
|
||||
|
let mouseHandler = null |
||||
|
|
||||
|
this.refreshChart = prevProps => { |
||||
|
const { _node: node, props } = this |
||||
|
const { data: raw, color, dateFormat, height, hideAxis, interactive, renderTooltip } = props |
||||
|
|
||||
|
ctx.DATA = enrichData(raw, d3.timeParse(dateFormat)) |
||||
|
|
||||
|
// Detect what needs to be updated
|
||||
|
ctx.INVALIDATED = { |
||||
|
color: firstRender || (prevProps && color !== prevProps.color), |
||||
|
margin: firstRender || (prevProps && hideAxis !== prevProps.hideAxis), |
||||
|
} |
||||
|
firstRender = false |
||||
|
|
||||
|
// Reset color if needed
|
||||
|
if (ctx.INVALIDATED.color) { |
||||
|
ctx.COLORS = generateColors(color) |
||||
|
} |
||||
|
|
||||
|
// Reset margins if needed
|
||||
|
if (ctx.INVALIDATED.margin) { |
||||
|
ctx.MARGINS = generateMargins(hideAxis) |
||||
|
} |
||||
|
|
||||
|
// Derived draw variables
|
||||
|
ctx.HEIGHT = Math.max(0, height - ctx.MARGINS.top - ctx.MARGINS.bottom) |
||||
|
ctx.WIDTH = Math.max(0, this._width - ctx.MARGINS.left - ctx.MARGINS.right) |
||||
|
|
||||
|
// Scales and areas
|
||||
|
const x = d3.scaleTime().range([0, ctx.WIDTH]) |
||||
|
const y = d3.scaleLinear().range([ctx.HEIGHT, 0]) |
||||
|
x.domain(d3.extent(ctx.DATA, d => d.parsedDate)) |
||||
|
y.domain([0, d3.max(ctx.DATA, d => d.value)]) |
||||
|
ctx.x = x |
||||
|
ctx.y = y |
||||
|
|
||||
|
// Reference to last tooltip, to prevent un-necessary re-render
|
||||
|
let lastDisplayedTooltip = null |
||||
|
|
||||
|
// Add/remove nodes depending on props
|
||||
|
refreshNodes({ ctx, node, props }) |
||||
|
|
||||
|
// Redraw
|
||||
|
refreshDraw({ ctx, props }) |
||||
|
|
||||
|
// Mouse handler
|
||||
|
mouseHandler && mouseHandler.remove() // eslint-disable-line no-unused-expressions
|
||||
|
if (interactive) { |
||||
|
mouseHandler = handleMouseEvents({ |
||||
|
ctx, |
||||
|
props, |
||||
|
shouldTooltipUpdate: d => d !== lastDisplayedTooltip, |
||||
|
onTooltipUpdate: d => (lastDisplayedTooltip = d), |
||||
|
renderTooltip, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
this.refreshChart() |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
const { height } = this.props |
||||
|
return ( |
||||
|
<div style={{ position: 'relative', height }} ref={n => (this._ruler = n)}> |
||||
|
<div |
||||
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} |
||||
|
ref={n => (this._node = n)} |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default Chart |
@ -0,0 +1,116 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
import * as d3 from 'd3' |
||||
|
|
||||
|
import { colors as themeColors } from 'styles/theme' |
||||
|
|
||||
|
import type { Props } from '.' |
||||
|
import type { CTX } from './types' |
||||
|
|
||||
|
export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props }) { |
||||
|
const { NODES, WIDTH, HEIGHT, MARGINS, COLORS, INVALIDATED, DATA, x, y } = ctx |
||||
|
const { hideAxis, interactive, renderTickX, renderTickY, nbTicksX } = props |
||||
|
|
||||
|
const area = d3 |
||||
|
.area() |
||||
|
.x(d => x(d.parsedDate)) |
||||
|
.y0(HEIGHT) |
||||
|
.y1(d => y(d.value)) |
||||
|
|
||||
|
const valueline = d3 |
||||
|
.line() |
||||
|
.x(d => x(d.parsedDate)) |
||||
|
.y(d => y(d.value)) |
||||
|
|
||||
|
// Resize container
|
||||
|
NODES.svg |
||||
|
.attr('width', WIDTH + MARGINS.left + MARGINS.right) |
||||
|
.attr('height', HEIGHT + MARGINS.top + MARGINS.bottom) |
||||
|
|
||||
|
// Resize wrapper & axis
|
||||
|
NODES.wrapper.attr('transform', `translate(${MARGINS.left},${MARGINS.top})`) |
||||
|
|
||||
|
// Resize axis
|
||||
|
if (!hideAxis) { |
||||
|
NODES.axisBot.attr('transform', `translate(0,${HEIGHT + 10})`) |
||||
|
} |
||||
|
|
||||
|
if (INVALIDATED.color) { |
||||
|
if (interactive) { |
||||
|
// Update focus bar colors
|
||||
|
NODES.xBar.attr('stroke', COLORS.focusBar) |
||||
|
NODES.yBar.attr('stroke', COLORS.focusBar) |
||||
|
// Update dot color
|
||||
|
NODES.focus.attr('fill', COLORS.focus) |
||||
|
} |
||||
|
// Update gradient color
|
||||
|
NODES.gradientStart.attr('stop-color', COLORS.gradientStart) |
||||
|
NODES.gradientStop.attr('stop-color', COLORS.gradientStop) |
||||
|
|
||||
|
// Update line color
|
||||
|
NODES.line.attr('stroke', COLORS.line) |
||||
|
} |
||||
|
|
||||
|
// Hide interactive things
|
||||
|
if (interactive) { |
||||
|
NODES.focus.style('opacity', 0) |
||||
|
NODES.tooltip.style('opacity', 0) |
||||
|
NODES.xBar.style('opacity', 0) |
||||
|
NODES.yBar.style('opacity', 0) |
||||
|
} |
||||
|
|
||||
|
// Draw axis
|
||||
|
if (!hideAxis) { |
||||
|
NODES.axisLeft.call( |
||||
|
d3 |
||||
|
.axisLeft(y) |
||||
|
.ticks(3) |
||||
|
.tickFormat(val => (renderTickY ? renderTickY(val) : val)), |
||||
|
) |
||||
|
NODES.axisBot.call( |
||||
|
d3 |
||||
|
.axisBottom(x) |
||||
|
.ticks(nbTicksX) |
||||
|
.tickFormat(val => (renderTickX ? renderTickX(val) : val)), |
||||
|
) |
||||
|
stylizeAxis(NODES.axisLeft) |
||||
|
stylizeAxis(NODES.axisBot) |
||||
|
} |
||||
|
|
||||
|
// Draw ticks
|
||||
|
if (!hideAxis) { |
||||
|
const yTicks = d3 |
||||
|
.axisLeft(y) |
||||
|
.ticks(3) |
||||
|
.tickSize(-WIDTH) |
||||
|
.tickFormat('') |
||||
|
|
||||
|
NODES.yTicks.call(yTicks) |
||||
|
NODES.yTicks.select('.domain').remove() |
||||
|
|
||||
|
NODES.yTicks |
||||
|
.selectAll('.tick line') |
||||
|
.attr('stroke', 'rgba(0, 0, 0, 0.1)') |
||||
|
.attr('stroke-dasharray', '5, 5') |
||||
|
|
||||
|
NODES.yTicks |
||||
|
.selectAll('.tick:first-of-type line') |
||||
|
.attr('stroke-width', '2px') |
||||
|
.attr('stroke-dasharray', 'none') |
||||
|
} |
||||
|
|
||||
|
// Draw line and gradient
|
||||
|
NODES.fillArea.data([DATA]).attr('d', area) |
||||
|
NODES.line.data([DATA]).attr('d', valueline) |
||||
|
} |
||||
|
|
||||
|
function stylizeAxis(axis) { |
||||
|
axis.selectAll('.tick line').attr('stroke', 'none') |
||||
|
axis.selectAll('path').attr('stroke', 'none') |
||||
|
axis |
||||
|
.selectAll('text') |
||||
|
.attr('stroke', themeColors.grey) |
||||
|
.style('font-size', '12px') |
||||
|
.style('font-family', 'Open Sans') |
||||
|
.style('font-weight', 300) |
||||
|
} |
@ -0,0 +1,129 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
import * as d3 from 'd3' |
||||
|
import d from 'debug' |
||||
|
|
||||
|
import type { Props } from '.' |
||||
|
import type { CTX } from './types' |
||||
|
|
||||
|
const debug = d('Chart') |
||||
|
|
||||
|
export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any, props: Props }) { |
||||
|
const { NODES, COLORS } = ctx |
||||
|
const { hideAxis, interactive, id } = props |
||||
|
|
||||
|
// Container
|
||||
|
|
||||
|
ensure({ NODES, key: 'svg' }, () => d3.select(node).append('svg')) |
||||
|
ensure({ NODES, key: 'wrapper' }, () => NODES.svg.append('g')) |
||||
|
ensure({ NODES, key: 'defs' }, () => NODES.wrapper.append('defs')) |
||||
|
|
||||
|
// Axis & ticks
|
||||
|
|
||||
|
ensure({ onlyIf: !hideAxis, NODES, key: 'axisBot' }, () => NODES.wrapper.append('g')) |
||||
|
ensure({ onlyIf: !hideAxis, NODES, key: 'axisLeft' }, () => NODES.wrapper.append('g')) |
||||
|
ensure({ onlyIf: !hideAxis, NODES, key: 'yTicks' }, () => NODES.wrapper.append('g')) |
||||
|
|
||||
|
// Focus bars
|
||||
|
|
||||
|
ensure({ onlyIf: interactive, NODES, key: 'xBar' }, () => |
||||
|
NODES.wrapper |
||||
|
.append('line') |
||||
|
.attr('stroke', COLORS.focusBar) |
||||
|
.attr('stroke-width', '1px') |
||||
|
.attr('stroke-dasharray', '3, 2'), |
||||
|
) |
||||
|
|
||||
|
ensure({ onlyIf: interactive, NODES, key: 'yBar' }, () => |
||||
|
NODES.wrapper |
||||
|
.append('line') |
||||
|
.attr('stroke', COLORS.focusBar) |
||||
|
.attr('stroke-width', '1px') |
||||
|
.attr('stroke-dasharray', '3, 2'), |
||||
|
) |
||||
|
|
||||
|
// Gradient
|
||||
|
|
||||
|
ensure({ NODES, key: 'gradient' }, () => |
||||
|
NODES.defs |
||||
|
.append('linearGradient') |
||||
|
.attr('id', `gradient-${id || ''}`) |
||||
|
.attr('x1', '0%') |
||||
|
.attr('x2', '0%') |
||||
|
.attr('y1', '0%') |
||||
|
.attr('y2', '100%'), |
||||
|
) |
||||
|
|
||||
|
ensure({ NODES, key: 'gradientStart' }, () => |
||||
|
NODES.gradient |
||||
|
.append('stop') |
||||
|
.attr('stop-color', COLORS.gradientStart) |
||||
|
.attr('offset', '0'), |
||||
|
) |
||||
|
|
||||
|
ensure({ NODES, key: 'gradientStop' }, () => |
||||
|
NODES.gradient |
||||
|
.append('stop') |
||||
|
.attr('stop-color', COLORS.gradientStop) |
||||
|
.attr('offset', '1'), |
||||
|
) |
||||
|
|
||||
|
ensure({ NODES, key: 'fillArea' }, () => |
||||
|
NODES.wrapper.append('path').attr('fill', `url(#gradient-${id || ''})`), |
||||
|
) |
||||
|
|
||||
|
// Line
|
||||
|
|
||||
|
ensure({ NODES, key: 'line' }, () => |
||||
|
NODES.wrapper |
||||
|
.append('path') |
||||
|
.attr('fill', 'none') |
||||
|
.attr('stroke', COLORS.line) |
||||
|
.attr('stroke-width', '2px'), |
||||
|
) |
||||
|
|
||||
|
// Tooltip & focus point
|
||||
|
|
||||
|
ensure({ onlyIf: interactive, NODES, key: 'tooltip' }, () => |
||||
|
d3 |
||||
|
.select(node) |
||||
|
.append('div') |
||||
|
.style('position', 'absolute') |
||||
|
.style('top', '0') |
||||
|
.style('left', '0') |
||||
|
.style('pointer-events', 'none'), |
||||
|
) |
||||
|
|
||||
|
ensure({ onlyIf: interactive, NODES, key: 'focus' }, () => |
||||
|
NODES.wrapper |
||||
|
.append('g') |
||||
|
.append('circle') |
||||
|
.attr('fill', COLORS.focus) |
||||
|
.attr('r', 4), |
||||
|
) |
||||
|
|
||||
|
ctx.NODES = NODES |
||||
|
} |
||||
|
|
||||
|
function ensure( |
||||
|
{ onlyIf: condition = true, NODES, key }: { onlyIf?: boolean, NODES: Object, key: string }, |
||||
|
create, |
||||
|
) { |
||||
|
if (!condition && NODES[key]) { |
||||
|
remove(NODES, key) |
||||
|
} |
||||
|
if (condition && !NODES[key]) { |
||||
|
append(NODES, key, create()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function remove(NODES, key) { |
||||
|
debug(`Destroying ${key}`) |
||||
|
NODES[key].remove() |
||||
|
NODES[key] = null |
||||
|
} |
||||
|
|
||||
|
function append(NODES, key, node) { |
||||
|
debug(`Appending ${key}`) |
||||
|
NODES[key] = node |
||||
|
} |
@ -0,0 +1,70 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
import React, { Component, Fragment } from 'react' |
||||
|
import Chance from 'chance' |
||||
|
import moment from 'moment' |
||||
|
import { storiesOf } from '@storybook/react' |
||||
|
import { boolean, number } from '@storybook/addon-knobs' |
||||
|
import { color } from '@storybook/addon-knobs/react' |
||||
|
|
||||
|
import Chart from 'components/base/NewChart' |
||||
|
|
||||
|
const stories = storiesOf('Components/Chart', module) |
||||
|
|
||||
|
const data = generateRandomData(365) |
||||
|
|
||||
|
type State = { |
||||
|
start: number, |
||||
|
stop: number, |
||||
|
} |
||||
|
|
||||
|
class Wrapper extends Component<any, State> { |
||||
|
state = { |
||||
|
start: 0, |
||||
|
stop: 365, |
||||
|
} |
||||
|
|
||||
|
handleChange = key => e => this.setState({ [key]: Number(e.target.value) }) |
||||
|
|
||||
|
render() { |
||||
|
const { start, stop } = this.state |
||||
|
return ( |
||||
|
<Fragment> |
||||
|
<input type="range" value={start} onChange={this.handleChange('start')} min={0} max={365} /> |
||||
|
<input |
||||
|
type="range" |
||||
|
value={stop} |
||||
|
style={{ marginLeft: 10 }} |
||||
|
onChange={this.handleChange('stop')} |
||||
|
min={0} |
||||
|
max={365} |
||||
|
/> |
||||
|
|
||||
|
<Chart |
||||
|
interactive={boolean('interactive', true)} |
||||
|
hideAxis={boolean('hideAxis', false)} |
||||
|
color={color('color', '#5f8ced')} |
||||
|
data={data.slice(start, stop)} |
||||
|
height={number('height', 300, { min: 100, max: 900 })} |
||||
|
/> |
||||
|
</Fragment> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
stories.add('default', () => <Wrapper />) |
||||
|
|
||||
|
function generateRandomData(n) { |
||||
|
const today = moment() |
||||
|
const day = moment(today).subtract(n, 'days') |
||||
|
const data = [] |
||||
|
const chance = new Chance() |
||||
|
while (!day.isSame(today)) { |
||||
|
data.push({ |
||||
|
date: day.format('YYYY-MM-DD'), |
||||
|
value: chance.integer({ min: 0, max: 50e3 }), |
||||
|
}) |
||||
|
day.add(1, 'day') |
||||
|
} |
||||
|
return data |
||||
|
} |
@ -0,0 +1,28 @@ |
|||||
|
// @flow
|
||||
|
|
||||
|
export type Item = { |
||||
|
date: string, |
||||
|
value: number, |
||||
|
} |
||||
|
|
||||
|
type EnrichedItem = { |
||||
|
date: string, |
||||
|
value: number, |
||||
|
parsedDate: Date, |
||||
|
ref: Item, |
||||
|
} |
||||
|
|
||||
|
export type Data = Array<Item> |
||||
|
export type EnrichedData = Array<EnrichedItem> |
||||
|
|
||||
|
export type CTX = { |
||||
|
NODES: Object, |
||||
|
MARGINS: Object, |
||||
|
COLORS: Object, |
||||
|
INVALIDATED: Object, |
||||
|
HEIGHT: number, |
||||
|
WIDTH: number, |
||||
|
DATA: EnrichedData, |
||||
|
x: Function, |
||||
|
y: Function, |
||||
|
} |
Loading…
Reference in new issue