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:
| Space | Units | Holds its |
|---|---|---|
.points | screen points, x east, y down | size on screen, at every zoom |
.geo | metres east and north of the anchor | size 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:
| Group | Calls |
|---|---|
| Paths | beginPath, moveTo, lineTo, quadTo, bezierTo, arc(cx, cy, r, from_deg, to_deg, ccw), closePath |
| Painting | fill(), stroke(), clip() |
| Style | fillStyle, strokeStyle, lineWidth, lineCap, lineJoin |
| Text | font(size_pt, .regular/.bold), textAlign, fillText(text, x, y) |
| Transform | translate, 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
rotateas 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
translateaway 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.