src/modules/market-charts/components/market-outcome-charts--candlestick/market-outcome-charts--candlestick.jsx
import React from "react";
import PropTypes from "prop-types";
import CustomPropTypes from "utils/custom-prop-types";
import * as d3 from "d3";
import ReactFauxDOM from "react-faux-dom";
import { map } from "lodash/fp";
import { sortBy, maxBy } from "lodash";
import findPeriodSeriesBounds from "modules/markets/helpers/find-period-series-bounds";
import MarketOutcomeChartsHeaderCandlestick from "modules/market-charts/components/market-outcome-charts--header-candlestick/market-outcome-charts--header-candlestick";
import { ONE } from "modules/trades/constants/numbers";
import { BUY, SELL } from "modules/transactions/constants/types";
import Styles from "modules/market-charts/components/market-outcome-charts--candlestick/market-outcome-charts--candlestick.styles";
import { createBigNumber } from "utils/create-big-number";
import { getTickIntervalForRange } from "modules/markets/helpers/range";
class MarketOutcomeCandlestick extends React.Component {
static propTypes = {
currentTimeInSeconds: PropTypes.number,
fixedPrecision: PropTypes.number.isRequired,
isMobile: PropTypes.bool,
isMobileSmall: PropTypes.bool,
marketMax: CustomPropTypes.bigNumber.isRequired,
marketMin: CustomPropTypes.bigNumber.isRequired,
orderBookKeys: PropTypes.object.isRequired,
outcomeName: PropTypes.string.isRequired,
priceTimeSeries: PropTypes.array.isRequired,
selectedPeriod: PropTypes.number.isRequired,
selectedRange: PropTypes.number.isRequired,
updateSelectedPeriod: PropTypes.func.isRequired,
updateSelectedRange: PropTypes.func.isRequired,
updateSelectedOrderProperties: PropTypes.func.isRequired,
pricePrecision: PropTypes.number.isRequired
};
static defaultProps = {
currentTimeInSeconds: null,
isMobile: false,
isMobileSmall: false
};
static getDerivedStateFromProps(nextProps, prevState) {
const {
currentTimeInSeconds,
pricePrecision,
marketMax,
marketMin,
orderBookKeys,
priceTimeSeries,
selectedPeriod,
selectedRange,
isMobileSmall
} = nextProps;
const { candleDim, containerHeight, containerWidth } = prevState;
const outcomeBounds = findPeriodSeriesBounds(
priceTimeSeries,
marketMin,
marketMax
);
const drawParams = determineDrawParams({
candleDim,
containerHeight,
containerWidth,
currentTimeInSeconds,
pricePrecision,
marketMax,
marketMin,
orderBookKeys,
outcomeBounds,
priceTimeSeries,
selectedPeriod,
selectedRange,
isMobileSmall
});
return {
...prevState,
...drawParams
};
}
constructor(props) {
super(props);
this.state = MarketOutcomeCandlestick.getDerivedStateFromProps(props, {
chartDim: {
right: 0,
left: props.isMobileSmall ? 20 : 50,
stick: 5,
tickOffset: 10
},
candleDim: {
width: 6,
gap: 9
},
containerHeight: 0,
containerWidth: 0,
yScale: null,
hoveredPrice: null,
hoveredPeriod: {}
});
this.getContainerWidths = this.getContainerWidths.bind(this);
this.updateContainerWidths = this.updateContainerWidths.bind(this);
this.updateHoveredPrice = this.updateHoveredPrice.bind(this);
this.updateHoveredPeriod = this.updateHoveredPeriod.bind(this);
this.clearCrosshairs = this.clearCrosshairs.bind(this);
}
componentDidMount() {
window.addEventListener("resize", this.updateContainerWidths);
this.drawContainer.addEventListener("mouseout", this.clearCrosshairs);
}
componentWillReceiveProps(nextProps) {
const containerWidths = this.getContainerWidths();
const drawParams = MarketOutcomeCandlestick.getDerivedStateFromProps(
nextProps,
{
...this.state,
...containerWidths
}
);
this.setState({
...drawParams
});
}
componentWillUnmount() {
window.removeEventListener("resize", this.updateContainerWidths);
this.drawContainer.removeEventListener("mouseout", this.clearCrosshairs);
}
getContainerWidths() {
return {
containerWidth: this.drawContainer.clientWidth,
containerHeight: this.drawContainer.clientHeight
};
}
updateContainerWidths() {
this.setState(this.getContainerWidths());
}
updateHoveredPrice(hoveredPrice) {
this.setState({
hoveredPrice
});
}
updateHoveredPeriod(hoveredPeriod) {
this.setState({
hoveredPeriod
});
}
clearCrosshairs() {
this.updateHoveredPrice(null);
this.updateHoveredPeriod({});
updateHoveredPriceCrosshair(null);
}
render() {
const {
currentTimeInSeconds,
fixedPrecision,
pricePrecision,
isMobile,
marketMax,
marketMin,
orderBookKeys,
outcomeName,
priceTimeSeries,
selectedPeriod,
selectedRange,
updateSelectedPeriod,
updateSelectedRange,
updateSelectedOrderProperties
} = this.props;
const {
boundDiff,
candleDim,
chartDim,
containerHeight,
containerWidth,
drawableWidth,
xScale,
yDomain,
yScale,
hoveredPrice,
hoveredPeriod
} = this.state;
const candleChartContainer = ReactFauxDOM.createElement("div");
const candleTicksContainer = ReactFauxDOM.createElement("div");
// Faux DOM
// Tick Element (Fixed)
candleTicksContainer.setAttribute(
"class",
`${Styles["MarketOutcomeCandlestick__ticks-container"]}`
);
candleTicksContainer.setAttribute("key", "candlestick_ticks_container");
// Chart Element (Scrollable)
candleChartContainer.setAttribute("key", "candlestick_chart_container");
candleChartContainer.setAttribute("id", "candlestick_chart_container");
candleChartContainer.setAttribute(
"class",
`${Styles["MarketOutcomeCandlestick__chart-container"]}`
);
candleChartContainer.setAttribute("style", {
width: `${containerWidth - chartDim.left}px`,
left: chartDim.left
});
if (containerHeight > 0 && containerWidth > 0 && currentTimeInSeconds) {
const candleTicks = d3
.select(candleTicksContainer)
.append("svg")
.attr("width", containerWidth)
.attr("height", containerHeight);
const candleChart = d3
.select(candleChartContainer)
.append("svg")
.attr("id", "candlestick_chart")
.attr("height", containerHeight)
.attr("width", drawableWidth);
drawTicks({
boundDiff,
candleChart,
candleDim,
candleTicks,
chartDim,
containerHeight,
containerWidth,
pricePrecision,
marketMax,
marketMin,
orderBookKeys,
priceTimeSeries,
xScale,
yDomain,
yScale
});
drawCandles({
boundDiff,
candleChart,
candleDim,
chartDim,
containerHeight,
containerWidth,
priceTimeSeries,
xScale,
yDomain,
yScale
});
drawVolume({
boundDiff,
candleChart,
candleDim,
chartDim,
containerHeight,
containerWidth,
priceTimeSeries,
xScale,
yDomain,
candleTicks
});
const tickInterval = getTickIntervalForRange(selectedRange);
drawXAxisLabels({
priceTimeSeries,
candleChart,
containerWidth,
containerHeight,
chartDim,
candleDim,
boundDiff,
tickInterval,
yDomain,
xScale
});
drawCrosshairs({
candleTicks
});
attachHoverClickHandlers({
candleChart,
candleDim,
chartDim,
containerHeight,
containerWidth,
pricePrecision,
marketMax,
marketMin,
orderBookKeys,
priceTimeSeries,
updateHoveredPeriod: this.updateHoveredPeriod,
updateHoveredPrice: this.updateHoveredPrice,
updateSelectedOrderProperties,
yScale,
xScale,
clearCrosshairs: this.clearCrosshairs
});
updateHoveredPriceCrosshair(
hoveredPrice,
yScale,
containerWidth,
pricePrecision
);
}
return (
<section className={Styles.MarketOutcomeCandlestick}>
<MarketOutcomeChartsHeaderCandlestick
outcomeName={outcomeName}
isMobile={isMobile}
volume={hoveredPeriod.volume}
open={hoveredPeriod.open}
high={hoveredPeriod.high}
low={hoveredPeriod.low}
close={hoveredPeriod.close}
priceTimeSeries={priceTimeSeries}
fixedPrecision={fixedPrecision}
pricePrecision={pricePrecision}
selectedPeriod={selectedPeriod}
selectedRange={selectedRange}
updateSelectedPeriod={updateSelectedPeriod}
updateSelectedRange={updateSelectedRange}
/>
<div
ref={drawContainer => {
this.drawContainer = drawContainer;
}}
className={Styles.MarketOutcomeCandlestick__container}
>
{candleTicksContainer.toReact()}
{candleChartContainer.toReact()}
</div>
</section>
);
}
}
function determineDrawParams({
containerHeight,
containerWidth,
currentTimeInSeconds,
marketMax,
marketMin,
priceTimeSeries,
selectedRange,
isMobileSmall
}) {
// Dimensions/Positioning
const chartDim = {
top: 0,
bottom: 30,
right: 0,
left: isMobileSmall ? 20 : 50,
stick: 5,
tickOffset: 10
};
// Domain
// X
const xDomain = [
new Date((currentTimeInSeconds - selectedRange) * 1000),
new Date(currentTimeInSeconds * 1000)
];
const drawableWidth = containerWidth;
// Y
const highValues = sortBy(priceTimeSeries, ["high"]);
const lowValues = sortBy(priceTimeSeries, ["low"]);
const max = highValues.length
? highValues[highValues.length - 1].high
: marketMax.toNumber();
const min = lowValues.length ? lowValues[0].low : marketMin.toNumber();
const bnMax = createBigNumber(max);
const bnMin = createBigNumber(min);
const priceRange = bnMax.minus(bnMin);
const buffer = priceRange.times(".10");
const marketPriceRange = marketMax.minus(marketMin).dividedBy(2);
const ltHalfRange = marketPriceRange.gt(priceRange);
const maxPrice = ltHalfRange
? bnMax.plus(marketPriceRange)
: bnMax.plus(buffer);
const minPrice = ltHalfRange
? bnMin.minus(marketPriceRange)
: bnMin.minus(buffer);
let yDomain = [
(maxPrice.gt(marketMax) ? marketMax : maxPrice).toNumber(),
(minPrice.lt(marketMin) ? marketMin : minPrice).toNumber()
];
// common case with low volume
if (yDomain[0] === yDomain[1]) {
if (yDomain[0] === 0) {
yDomain = [marketMax.toNumber(), marketMin.toNumber()];
} else {
yDomain = [
createBigNumber(yDomain[0])
.times(1.5)
.toNumber(),
createBigNumber(yDomain[0])
.times(0.5)
.toNumber()
];
}
}
// sigment y into 10 to show prices
const boundDiff = createBigNumber(yDomain[0])
.minus(createBigNumber(yDomain[1]))
.dividedBy(2);
// Scale
const xScale = d3
.scaleTime()
.domain(d3.extent(xDomain))
.range([chartDim.left, drawableWidth - chartDim.left - chartDim.right]);
const yScale = d3
.scaleLinear()
.clamp(true)
.domain(d3.extent(yDomain))
.range([containerHeight - chartDim.bottom, chartDim.top]);
return {
chartDim,
drawableWidth,
boundDiff,
yDomain,
xScale,
yScale
};
}
function drawTicks({
boundDiff,
candleTicks,
chartDim,
containerWidth,
pricePrecision,
yDomain,
yScale
}) {
// Ticks
const offsetTicks = [
yDomain[0],
yDomain[1],
createBigNumber(yDomain[1])
.plus(boundDiff)
.toNumber(),
createBigNumber(yDomain[0])
.minus(boundDiff.dividedBy(2))
.toNumber(),
createBigNumber(yDomain[1])
.plus(boundDiff.dividedBy(2))
.toNumber()
];
const yTicks = candleTicks.append("g").attr("id", "depth_y_ticks");
yTicks
.selectAll("line")
.data(offsetTicks)
.enter()
.append("line")
.attr("class", "tick-line")
.attr("x1", 0)
.attr("x2", containerWidth)
.attr("y1", d => yScale(d))
.attr("y2", d => yScale(d));
yTicks
.selectAll("text")
.data(offsetTicks)
.enter()
.append("text")
.attr("class", "tick-value")
.attr("x", 0)
.attr("y", d => yScale(d))
.attr("dx", 0)
.attr("dy", chartDim.tickOffset)
.text(d => d.toFixed(pricePrecision));
}
function drawCandles({
priceTimeSeries,
candleChart,
containerWidth,
containerHeight,
candleDim,
xScale,
yScale
}) {
if (priceTimeSeries.length === 0) {
drawNullState({ candleChart, containerWidth, containerHeight });
} else {
candleChart
.selectAll("rect.candle")
.data(priceTimeSeries)
.enter()
.append("rect")
.attr("x", d => xScale(d.period))
.attr("y", d => yScale(d3.max([d.open, d.close])))
.attr("height", d =>
Math.max(Math.abs(yScale(d.open) - yScale(d.close)), 1)
)
.attr("width", candleDim.width)
.attr("class", d => (d.close > d.open ? "up-period" : "down-period")); // eslint-disable-line no-confusing-arrow
candleChart
.selectAll("line.stem")
.data(priceTimeSeries)
.enter()
.append("line")
.attr("class", "stem")
.attr("x1", d => xScale(d.period) + candleDim.width / 2)
.attr("x2", d => xScale(d.period) + candleDim.width / 2)
.attr("y1", d => yScale(d.high))
.attr("y2", d => yScale(d.low))
.attr("class", d => (d.close > d.open ? "up-period" : "down-period")); // eslint-disable-line no-confusing-arrow
}
}
function drawVolume({
priceTimeSeries,
candleChart,
containerHeight,
chartDim,
candleDim,
xScale,
containerWidth,
candleTicks
}) {
const yVolumeDomain = [0, ...map("volume")(priceTimeSeries)];
const yVolumeScale = d3
.scaleLinear()
.domain(d3.extent(yVolumeDomain))
.range([
containerHeight - chartDim.bottom,
chartDim.top + (containerHeight - chartDim.bottom) * 0.85
]);
candleChart
.selectAll("rect.volume")
.data(priceTimeSeries)
.enter()
.append("rect")
.attr("x", d => xScale(d.period))
.attr("y", d => yVolumeScale(d.volume))
.attr(
"height",
d => containerHeight - chartDim.bottom - yVolumeScale(d.volume)
)
.attr("width", d => candleDim.width)
.attr("class", "period-volume");
const maxVolume = maxBy(priceTimeSeries, v => v.volume);
if (!maxVolume || maxVolume.volume === 0) return;
const volumeTicks = [maxVolume, { volume: maxVolume.volume / 2 }];
const yTicks = candleTicks.append("g").attr("id", "depth_y_ticks");
yTicks
.selectAll("line")
.data(volumeTicks)
.enter()
.append("line")
.attr("class", "tick-line-volume")
.attr("x1", 0)
.attr("x2", containerWidth)
.attr("y1", d => yVolumeScale(d.volume))
.attr("y2", d => yVolumeScale(d.volume));
candleChart
.selectAll("text")
.data(volumeTicks)
.enter()
.append("text")
.attr("class", "tick-value-volume")
.attr("x", containerWidth - 110)
.attr("y", d => yVolumeScale(d.volume) - 4)
.text(d => {
if (createBigNumber(d.volume).gte(ONE)) {
return d.volume.toFixed(1) + ` ETH`;
}
return d.volume.toFixed(4) + ` ETH`;
});
}
function drawXAxisLabels({
candleChart,
containerHeight,
chartDim,
tickInterval,
xScale
}) {
candleChart
.append("g")
.attr("id", "candlestick-x-axis")
.attr("transform", `translate(0, ${containerHeight - chartDim.bottom})`)
.call(tickInterval(d3.axisBottom(xScale)))
.select("path")
.remove();
}
function drawCrosshairs({ candleTicks }) {
candleTicks.append("text").attr("id", "hovered_candlestick_price_label");
const crosshair = candleTicks
.append("g")
.attr("id", "candlestick_crosshairs")
.attr("class", "line")
.style("display", "none");
crosshair
.append("line")
.attr("id", "candlestick_crosshairY")
.attr("class", "crosshair");
}
function attachHoverClickHandlers({
candleChart,
candleDim,
chartDim,
containerHeight,
drawableWidth,
pricePrecision,
marketMax,
marketMin,
orderBookKeys,
priceTimeSeries,
updateHoveredPeriod,
updateHoveredPrice,
updateSelectedOrderProperties,
yScale,
xScale,
clearCrosshairs
}) {
candleChart
.append("rect")
.attr("class", "overlay")
.attr("width", drawableWidth)
.attr("height", containerHeight)
.on("mousemove", () =>
updateHoveredPrice(
yScale
.invert(d3.mouse(d3.select("#candlestick_chart").node())[1])
.toFixed(pricePrecision)
)
)
.on("mouseout", clearCrosshairs)
.on("click", () => {
const mouse = d3.mouse(d3.select("#candlestick_chart").node());
const orderPrice = yScale.invert(mouse[1]).toFixed(pricePrecision);
if (orderPrice > marketMin && orderPrice < marketMax) {
updateSelectedOrderProperties({
selectedNav: orderPrice > orderBookKeys.mid ? BUY : SELL,
orderPrice
});
}
});
candleChart
.selectAll("rect.hover")
.data(priceTimeSeries)
.enter()
.append("rect")
.attr("id", "testing")
.attr("x", d => xScale(d.period) - candleDim.gap * 0.5)
.attr("y", 0)
.attr("height", containerHeight - chartDim.bottom)
.attr("width", candleDim.width + candleDim.gap)
.attr("class", "period-hover")
.on("mouseover", d => updateHoveredPeriod(d))
.on("mousemove", () =>
updateHoveredPrice(
yScale
.invert(d3.mouse(d3.select("#candlestick_chart").node())[1])
.toFixed(pricePrecision)
)
)
.on("mouseout", clearCrosshairs);
candleChart.on("mouseout", clearCrosshairs);
}
function updateHoveredPriceCrosshair(
hoveredPrice,
yScale,
chartWidth,
pricePrecision
) {
if (hoveredPrice == null) {
d3.select("#candlestick_crosshairs").style("display", "none");
d3.select("#hovered_candlestick_price_label").text("");
} else {
const yPosition = yScale(hoveredPrice);
const clampedHoveredPrice = yScale.invert(yPosition);
d3.select("#candlestick_crosshairs").style("display", null);
d3.select("#candlestick_crosshairY")
.attr("x1", 0)
.attr("y1", yPosition)
.attr("x2", chartWidth)
.attr("y2", yPosition);
d3.select("#hovered_candlestick_price_label")
.attr("x", 0)
.attr("y", yScale(hoveredPrice) + 12)
.text(clampedHoveredPrice.toFixed(pricePrecision));
}
}
function drawNullState(options) {
const { containerWidth, containerHeight, candleChart } = options;
candleChart
.append("text")
.attr("class", Styles["MarketOutcomeCandlestick__null-message"])
.attr("x", containerWidth / 2)
.attr("y", containerHeight / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.text("No Completed Trades");
}
export default MarketOutcomeCandlestick;