Skip to main content

Drawing with a canvas

Capabilities: overlay.draw.

A canvas is an overlay object you draw yourself: paths, curves, arcs, fills, strokes, gradients, text, any color. Use one when the chart objects on Drawing on the chart are not enough, which is how you build a custom instrument: a dial, a gauge, a scale, a rose.

You record commands into the canvas inside your draw function, and Lookout renders the recording. A canvas is one object in your scene with an id like any other: redraw it and only the difference crosses to the chart, leave it out of a call and it comes off the chart.

pub fn draw(c: *lk.Chart) void {
const at = inputs.boat.get();
var cv = c.canvas("ring", .{ .at = at, .anchor = .ownship, .space = .geo });

cv.strokeStyle(.{ .token = .warning });
cv.lineWidth(1.5);
cv.beginPath();
cv.arc(0, 0, lk.nm(1), 0, 360, false);
cv.stroke();

cv.done();
}

That is a one-mile guard ring riding own ship. done() seals the recording; a canvas you never seal is not posted.

The two spaces

Every canvas is anchored at a position (at, and .anchor = .ownship rides own ship's display position like any anchored object). The space says what your coordinates mean from there:

SpaceUnitsHolds its
.pointsscreen points, x east, y downsize on screen, at every zoom
.geometres east and north of the anchorsize on the ground, at every zoom

An instrument is .points: a dial that stays 64 points wide however far the mariner zooms out. A range ring or a sector is .geo: arc(0, 0, 1852, …) is one nautical mile on the water at any zoom. Stroke widths and text sizes are screen points in both spaces.

Both spaces are aligned to the chart, not to the display: x is east and y is south on the ground. When the mariner turns the view to course-up, your drawing turns with it. To hold part of it upright, see Keeping a readout level when the chart turns.

The commands

The recorder is the canvas model you already know:

GroupCalls
PathsbeginPath, moveTo, lineTo, quadTo, bezierTo, arc(cx, cy, r, from_deg, to_deg, ccw), closePath
Paintingfill(), stroke(), clip()
StylefillStyle, strokeStyle, lineWidth, lineCap, lineJoin
Textfont(size_pt, .regular/.bold), textAlign, fillText(text, x, y)
Transformtranslate, rotate(deg), scale, save, restore, screenAligned(on)

A style is a token, a free color, or a gradient:

cv.fillStyle(.{ .token = .ownship }); // the palette, per scheme
cv.fillStyle(.{ .rgba = .{ 0.97, 0.98, 1.0, 0.72 } }); // your own color
cv.fillStyle(.{ .radial = .{ .center = .{ 0, 0 }, .radius = 64, .stops = &.{
.{ .t = 0, .color = .{ .rgba = .{ 1, 1, 1, 0.7 } } },
.{ .t = 1, .color = .{ .token = .warning } },
} } }); // radial or .linear

Night is your job. A free RGBA color does not dim itself for the night palette. Use tokens where you can, because Lookout re-resolves them per scheme; where you use your own colors, choose ones that survive a dark wheelhouse.

A worked instrument

The shipped plugins/canvasdemo/ draws a wind dial at own ship: an open band with the chart showing through the middle, ticks every 10 and 30 degrees, bold cardinal letters, a pointer where the true wind blows from, and a readout on a plate below. The pointer is the part worth copying:

cv.save();
cv.rotate(twd);
cv.beginPath();
cv.moveTo(0, -(r0 + 2));
cv.lineTo(6, -(R - 4));
cv.lineTo(-6, -(R - 4));
cv.closePath();
cv.fill();
cv.restore();

Draw the shape pointing north in its own frame, rotate to the live bearing, and restore so the rotation ends with the pointer. The whole dial re-records every draw call, and Lookout sends only what changed: with a steady wind, nothing.

Keeping a readout level when the chart turns

Your coordinates are chart coordinates, so under course-up the whole canvas turns with the chart. That is what you want for a compass card: north stays north, the way a real rose behaves. It is not what you want for a number. A readout that turns with the chart ends up running up the side of the dial, and a number on its side is not a number.

Wrap the part that must stay upright in screenAligned:

cv.save();
cv.screenAligned(true);
cv.fillStyle(.{ .rgba = .{ 0.94, 0.96, 0.98, 0.88 } });
plate(&cv); // a rounded rectangle under the dial
cv.fill();
cv.fillStyle(.{ .rgba = .{ 0.12, 0.16, 0.22, 1 } });
cv.font(12, .regular);
cv.fillText(label, 0, R + 19);
cv.restore();

It is graphics state, like a fill style, so save and restore scope it and one canvas holds both kinds of content. It covers everything you record while it is on, not only text: the plate above stays a level rectangle, so the number sits on it at any view rotation. Anything you draw outside the scope keeps turning with the chart.

Two things to know:

  • It cancels your own rotate as well as the view's, for as long as it is on. The promise is level on the display, so a label at the tip of a rotated needle comes out upright without you undoing the needle's rotation.
  • The turn is applied about the point you are drawing from, which is the anchor until you translate away from it. Place the plate where you want it, then turn the alignment on.

Lookout re-tessellates a canvas that uses this when the view rotation changes, and only such a canvas. Your plugin is not called: this happens in the core, on the recording you already posted.

The limits

A canvas holds 2048 commands; the SDK drops the rest with one log line. A text run is cut at 256 bytes, a gradient at 8 stops.

The recording counts against your scene's 64 KiB like every other object. A canvas is not hit-testable: a pick payload needs a symbol.

Text on the chart reads from about 10 pt regular or 9 pt bold at 1x. Below that the stems fall under a pixel and the glyphs read as ghosts, worst over a busy chart.