Skip to content

Guides

Draw your own layers

Some things belong in your rendering rather than ours: a band in your brand colours, a rail of labels beside the plot, a heat map your analysts already have code for. This article covers how to put a layer over an embedded chart and keep it pinned to the price scale.

One number, and where it comes from

Everything here reduces to a single question: given a price, what is its y? The chart runs in another document, so the answer used to be unavailable from your page. It now arrives as a PriceScale object: the visible prices, the plot band measured in the frame's own pixels, and how price runs down that band.

CallReturnsWhen to reach for it
chart.priceToY(price)a number, synchronouslyThe one you will use. Pixels from the top of the iframe, which is the space an element sitting over the iframe already works in.
chart.getPriceScale()the whole PriceScaleFor the plot's horizontal extent (left, width) and for reading scale or inverted if your layer cares.
chart.on('priceScaleChanged', fn)an unsubscriberThe redraw trigger. Fires once as soon as the chart has laid out, then whenever the mapping moves.
The full list, including yToPrice and the two plot-relative spellings, is in Methods. The object is in Objects.

Why the mapping is pushed rather than asked for

There is no priceToY command, and that is a design decision rather than a gap. An overlay is repainted inside a requestAnimationFrame callback while the reader pans, and nothing asynchronous can be spent there. A per-point request would mean one message across the origin boundary for every level on every frame, each answered a frame or more later, so your layer would trail the candles it is pinned to and trail them further the more you drew.

So the frame sends the whole mapping whenever it moves, the loader keeps the last one, and your call is local arithmetic. In exchange you keep no state of your own: subscribe, and redraw when you are told to.

A band and a target that follow the chart

Open this and drag the chart. The shaded band and the dashed target are drawn by the pen into its own canvas, not by the chart, and they stay on their prices through every pan, zoom and resize.

An overlay pinned to the price scale
<script src="https://view.verticalprop.com/embed.js"></script>
<div id="wrap" style="position: relative">
  <div class="vp-chart"
     data-symbol="DEMO"
     data-interval="60"
     data-toolbar="1"
     data-theme="dark"
     data-datafeed="myFeed"
     style="height: 420px"></div>

  <!-- Over the placeholder, exactly. priceToY answers in the FRAME's
       coordinates, so a canvas sitting anywhere else needs an offset. -->
  <canvas id="overlay"></canvas>
</div>
<pre id="log"></pre>

<style>
body { margin: 0; background: #0b0f19; color: #e6e8eb;
       font: 13px/1.5 ui-monospace, monospace; }
#log { padding: 8px 12px; margin: 0; white-space: pre-wrap; }
#overlay { position: absolute; inset: 0; width: 100%; height: 100%;
           pointer-events: none; }
</style>

<script>
// A deterministic series, so the pen never depends on a venue and the
// same code always draws the same chart.
function bars(from, to, stepSec) {
  const out = [];
  let price = 100;
  for (let t = Math.ceil(from / stepSec) * stepSec; t <= to; t += stepSec) {
    price += Math.sin(t / 5000) * 0.6;
    const open = price;
    const close = price + Math.cos(t / 3000) * 0.8;
    out.push({
      time: t * 1000,                       // a bar's time is MILLISECONDS
      open, close,
      high: Math.max(open, close) + 0.5,
      low: Math.min(open, close) - 0.5,
      volume: 1000 + (t % 500),
    });
  }
  return out;
}

vpEmbed.datafeed('myFeed', {
  onReady: () => ({ supported_resolutions: ['60'] }),
  resolveSymbol: (name) => ({
    name, ticker: name, pricescale: 100, session: '24x7', timezone: 'Etc/UTC',
  }),
  // from and to are SECONDS; a bar's own time is MILLISECONDS.
  getBars: (symbol, resolution, from, to) => bars(from, to, 3600),
  subscribeBars: () => {},
  unsubscribeBars: () => {},
});

const log = (m) => { document.getElementById('log').textContent += m + '\n'; };
const chart = vpEmbed.chart(document.querySelector('.vp-chart'));
const canvas = document.getElementById('overlay');
const ctx = canvas.getContext('2d');

// Levels of your own: a support band and a target. Prices, not pixels.
const BAND = { low: 99, high: 101.5 };
const TARGET = 103;

function draw() {
  const scale = chart.getPriceScale();
  // Null until the chart has laid out. Draw nothing rather than guessing:
  // the next event carries real numbers.
  if (!scale) return;

  // Match the canvas to the element it covers, in device pixels.
  const dpr = window.devicePixelRatio || 1;
  canvas.width = canvas.clientWidth * dpr;
  canvas.height = canvas.clientHeight * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);

  // priceToY is synchronous and costs nothing, so it is safe to call it as
  // often as the drawing needs.
  const top = chart.priceToY(BAND.high);
  const bottom = chart.priceToY(BAND.low);

  // scale.left and scale.width are the PLOT, not the iframe: the price
  // gutter and the time axis are outside them.
  ctx.fillStyle = 'rgba(38, 166, 154, 0.15)';
  ctx.fillRect(scale.left, top, scale.width, bottom - top);

  const y = chart.priceToY(TARGET);
  ctx.strokeStyle = '#f7931a';
  ctx.setLineDash([4, 4]);
  ctx.beginPath();
  ctx.moveTo(scale.left, y);
  ctx.lineTo(scale.left + scale.width, y);
  ctx.stroke();
  ctx.setLineDash([]);

  ctx.fillStyle = '#f7931a';
  ctx.font = '11px ui-monospace, monospace';
  ctx.fillText('target ' + TARGET, scale.left + 6, y - 5);
}

// The only subscription this needs. It fires once as soon as the chart has
// laid out, then whenever the range moves, the frame is resized, or the
// reader switches to a log or inverted scale.
chart.on('priceScaleChanged', (scale) => {
  draw();
  log(scale.scale + (scale.inverted ? ' inverted' : '') +
      '  ' + scale.from.toFixed(2) + ' to ' + scale.to.toFixed(2) +
      '  band ' + Math.round(scale.top) + '..' + Math.round(scale.top + scale.height));
});

log('pan or zoom the chart. The band and the target follow it.');
</script>

The two mistakes worth naming

Both draw a layer that looks deliberate and is wrong by a constant, which is the hardest kind of wrong to notice.

MistakeWhat you seeThe fix
Measuring against the iframe rather than the plotEvery level sits high, by more on an embed with a toolbar and by more again once your reader adds an indicator pane.top is not zero. The plot band starts below the toolbar, and height excludes the time axis and any pane underneath.
Interpolating between the visible bounds yourselfCorrect near the middle of the plot and increasingly wrong towards the edges, but only once the reader switches to a logarithmic scale.Use priceToY. It carries the chart's own arithmetic, including the logarithmic and inverted cases, which the reader can turn on from the chart's settings without your page hearing about it.
The plot is also narrower than the frame: the price gutter is outside it. left and width are the horizontal extent to draw across, which is why the pen above uses them rather than the canvas's own width.

Before the chart has laid out

All five synchronous methods answer null until the first priceScaleChanged arrives, which is a frame or two after mount. Return early on a null rather than falling back to an estimate: an overlay drawn from a guessed mapping is worse than one that is not there yet, and the event that makes it correct is already on its way.

The shape of every redraw
function draw() {
  const scale = chart.getPriceScale();
  if (!scale) return;                 // not laid out yet
  // ...
}

chart.on('priceScaleChanged', draw);  // fires once at mount, then on every move

A page that wants the mapping once rather than following it can await chart.getVisiblePriceRange() instead, which resolves to the same object, or to null for the same reason.

What this costs your reader

One small message per animation frame at most, and only when the mapping has actually changed. Panning a chart whose scale is locked sends nothing. That bound is the frame's, not yours: you cannot subscribe your way into a message storm, and you do not need to throttle the handler.

If your layer is a level, an entry, a stop or a target, consider whether it belongs in the chart instead. Orders and positions draws those natively, with drag handles and a price label on the scale, and needs no overlay at all. This article is for the things we do not draw.