Search code examples
d3.jsd3fc

Place several symbols on top of each candle on the canvas chart


Created a simplified example of what I need. There are 50 items in the series. Each item has a vertical list of five + symbols.

const data = [...Array(50).keys()];

const container = document.querySelector('d3fc-canvas');
const xScale = d3.scaleLinear().domain([0, data.length - 1]);
const yScale = d3.scaleLinear().domain(fc.extentLinear()(data));

const series = fc
  .seriesCanvasPoint()
  .xScale(xScale)
  .yScale(yScale)
  .crossValue((_, i) => i)
  .mainValue(d => d)
  .size((_, i) => 0)
  .decorate((context, datum) => {

    // Draw 5 symbols above current value

    for (let i = 0; i < 5; i++) {

      const y = yScale.invert(datum + i)

      context.textAlign = 'center';
      context.fillStyle = '#000';
      context.font = '25px Arial';
      context.fillText('+', 0, 0);
      context.translate(0, y);
    }
  });

d3.select(container)
  .on('draw', () => {
    series(data);
  })
  .on('measure', () => {
    const {
      width,
      height
    } = event.detail;
    xScale.range([0, width]);
    yScale.range([height, 0]);

    const ctx = container.querySelector('canvas').getContext('2d');
    series.context(ctx);
  });

container.requestRedraw();
body {
  color: #1b1e23;
  font-size: small;
  font-family: sans-serif;
  height: calc(100vh - 2em);
  margin: 1em;
  display: flex;
}

body>* {
  flex: auto;
}

.domain,
.gridline-y,
.gridline-x,
.annotation-line>line {
  stroke: currentColor;
  stroke-opacity: 0.1;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://unpkg.com/d3fc/build/d3fc.js"></script>
<d3fc-canvas use-device-pixel-ratio></d3fc-canvas>

Now, I'm trying to make it work with an actual financial chart and place these five + symbols vertically within each candle. It doesn't work because I don't know how to correctly convert Y value of each + to the coordinate system of the canvas.

const stream = fc.randomFinancial().stream();
const data = stream.take(20);
const scaleX = d3.scalePoint();
const scaleY = d3.scaleLinear();
const yExtent = fc.extentLinear().accessors([d => d.high, d => d.low]);
const xExtent = fc.extentLinear().accessors([d => d.date.getTime()]);
const gridlines = fc.annotationCanvasGridline();
const candlestick = fc.seriesCanvasCandlestick()
  .crossValue((o, i) => o.date.getTime())
  .lowValue((o, i) => o.low)
  .highValue((o, i) => o.high)
  .openValue((o, i) => o.open)
  .closeValue((o, i) => o.close);

const points = fc
  .seriesCanvasPoint()
  .crossValue((o, i) => o.date.getTime())
  .mainValue(d => d.close)
  .size((_, i) => 0)
  .decorate((context, datum) => {

    const arrows = getMarks(datum.open, datum.close);

    //context.translate(0, 0);

    // Draw 5 symbols above current value

    for (let i = 0; i < arrows.length; i++) {

      const posCurrent = scaleY.invert(arrows[i])

      //context.translate(0, 15);
      context.translate(0, posCurrent); // move to current arrow's position
      context.textAlign = 'center';
      context.fillStyle = '#000';
      context.font = '25px Arial';
      context.fillText('+', 0, 0);
      context.translate(0, -posCurrent); // reset and go back to start point
    }
  });

const multi = fc
  .seriesCanvasMulti()
  .series([gridlines, candlestick, points]);

const chart = fc
  .chartCartesian(scaleX, scaleY)
  .canvasPlotArea(multi);

function getMarks(open, close) {

  let price = close;

  const arrows = [];
  const gap = Math.abs(close - open) / 5;

  for (let i = 0; i < 5; i++) {
    arrows.push(close > open ? price -= gap : price += gap);
  }

  return arrows;
}

function renderChart() {

  data.push(stream.next());
  data.shift();

  scaleX.domain(data.map(o => o.date.getTime()));
  scaleY.domain(yExtent(data));

  chart
    .xDomain(scaleX.domain())
    .yDomain(scaleY.domain());

  d3.select('#chart')
    .datum(data)
    .call(chart);
}

renderChart();
setInterval(renderChart, 1000);
#chart {
  height: 500px;
}
<script src="https://unpkg.com/d3"></script>
<script src="https://unpkg.com/d3fc"></script>
<div id="chart"></div>

Is there a way to properly show these marks on top of the candle?

Cross-reference to Github for more details and examples.

https://github.com/d3fc/d3fc/issues/1635


Solution

  • Firstly, scale.invert is meant for when you have the pixel coordinate, but not the corresponding datum value. This is relevant when you are doing clicks on a screen, for example. In this case, it's not the right tool for the job. You already have access to datum, so you just need to call scaleY(datum.close).

    However, d3fc uses canvas.translate() to change the zero point to .mainvalue(). Therefore, you cannot simply use scaleY(datum.close) to get the y-coordinate of the close value. Instead of (x, y), each point you want to draw becomes (0, y(arrow) - y(d.close)). So you need to change the logic to reflect this.

    Needing to clean up after yourself with context.translate() is a recipe for bugs, It's almost always better to avoid such things.


    As a final note, I had some confusion about your getMarks function. If you want to draw 5 arrows evenly distributed over your bar, you need to have a gap of 1/4th of the bar height. Think of it like trees on a street. If the trees at 10 feet apart and the street is 100 feet long, you'll have 11 trees, not 10. Because of this, I drew 6 plus signs, not 5, because otherwise it looked kind of off balance.

    const stream = fc.randomFinancial().stream();
    const data = stream.take(20);
    const scaleX = d3.scalePoint();
    const scaleY = d3.scaleLinear();
    const yExtent = fc.extentLinear().accessors([d => d.high, d => d.low]);
    const xExtent = fc.extentLinear().accessors([d => d.date.getTime()]);
    const gridlines = fc.annotationCanvasGridline();
    const candlestick = fc.seriesCanvasCandlestick()
      .crossValue((o, i) => o.date.getTime())
      .lowValue((o, i) => o.low)
      .highValue((o, i) => o.high)
      .openValue((o, i) => o.open)
      .closeValue((o, i) => o.close);
    
    const points = fc
      .seriesCanvasPoint()
      .crossValue((o, i) => o.date.getTime())
      .mainValue(d => d.close)
      .size((_, i) => 0)
      .decorate((context, datum) => {
        const arrows = getMarks(datum.open, datum.close);
        const top = scaleY(datum.close);
    
        // We need a little bit of offset, because the "+" is 20 pixels high
        // and we want it in the middle of the point, not above it.
        const offset = 10;
    
        // Draw 5 symbols above current value
        for (let i = 0; i < arrows.length; i++) {
          context.textAlign = 'center';
          context.fillStyle = '#000';
          context.font = '25px Arial';
          context.fillText('+', 0, scaleY(arrows[i]) - top + offset);
        }
      });
    
    const multi = fc
      .seriesCanvasMulti()
      .series([gridlines, candlestick, points]);
    
    const chart = fc
      .chartCartesian(scaleX, scaleY)
      .canvasPlotArea(multi);
    
    function getMarks(open, close) {
    
      let price = Math.min(open, close);
    
      const arrows = [];
      const gap = Math.abs(close - open) / 5;
    
      for (let i = 0; i < 6; i++) {
        arrows.push(price + i * gap);
      }
    
      return arrows;
    }
    
    function renderChart() {
    
      data.push(stream.next());
      data.shift();
    
      scaleX.domain(data.map(o => o.date.getTime()));
      scaleY.domain(yExtent(data));
    
      chart
        .xDomain(scaleX.domain())
        .yDomain(scaleY.domain());
    
      d3.select('#chart')
        .datum(data)
        .call(chart);
    }
    
    renderChart();
    setInterval(renderChart, 1000);
    #chart {
      height: 500px;
    }
    <script src="https://unpkg.com/d3"></script>
    <script src="https://unpkg.com/d3fc"></script>
    <div id="chart"></div>