One map, fourteen projections. Same coastline, same drawing call. Scroll to zoom, click to recentre, and cross the date line or a pole without the map tearing.
Alpha. Lifted from a shipping plugin, tests and all. The API may still move before 0.1.
Draw a coastline onto a canvas, through whatever projection you already have. Hand it Natural Earth GeoJSON as published and it draws every island as is, with no decoder and no simplification in between.
import { geojsonRings, limn } from 'coast-wright'
const coastline = await (await fetch('./ne_50m_coastline.geojson')).json()
limn(ctx, geojsonRings(coastline), ([lon, lat]) => /* → [px, py] */, {
color: '#8ab',
centralMeridian: 0,
})No dependencies. No projection of its own. A couple of hundred lines.
You are drawing a map on a canvas, in a browser or a Signal K plugin, and you already have a projection or want to choose one. You want the land where it is, islands included, and no stray line slashed across the Pacific.
Both look fine in a screenshot. Both fail on a boat.
The seam. Where a coastline crosses the date line, two neighbouring
points are a few miles apart on the water and 360 degrees apart in the
numbers. Join them and you get a line clean across the map. The same happens
wherever your map's edge falls, which is why limn wants centralMeridian: the
longitude your projection measures from. A guard that only knows about the
date line looks right until somebody centres the map on their own boat. A
globe has a seam of a different shape, the far side, so visible lets your
projection refuse a point before it is drawn.
The pole. Most map libraries bring their own Web Mercator, and Mercator
cannot show a pole. limn brings nothing. It takes your
project function and asks no questions, so an azimuthal chart over the pole, a band
around a vessel and a plain equirectangular grid are all the same call.
Every line and polygon ring in a GeoJSON document, as [lon, lat] pairs.
Takes a FeatureCollection, a Feature or a bare geometry; points are
skipped. This is what you hand to limn.
The result is cached per document, since a map redraws on every resize and a full-detail coastline is tens of thousands of points. Treat it as read-only. Anything that is not GeoJSON throws rather than drawing something wrong.
Strokes rings onto a canvas 2D context. project([lon, lat]) returns
[px, py], d3-geo's shape, so a d3 projection is a valid argument as it
stands. On a globe or an azimuthal chart, where a point lands on screen
depends on both its coordinates, so it receives the pair at once.
Deprecated: the pair of callbacks
x(lon, lat)andy(lat, lon)inproject's place still works, and warns once. The same goes for every function below that takesproject.
rings may also be an async iterable of rings, for a source that hands them
over piece by piece. Each ring is drawn as it arrives, exactly as an array's
would be, and limn returns a promise that resolves when the iterable ends;
an array or other sync iterable is still drawn before limn returns, and
nothing is returned. Draw nothing else on that context until the promise
settles: the rings share its saved state and, with oneStroke, its path.
| Option | Default | |
|---|---|---|
color |
context's own | Stroke style. |
alpha |
0.45 |
Coastline under data should stay under it. |
width |
1 |
Line width in pixels. |
lineCap |
context's own | How a stroke ends where the pen lifts: 'butt', 'round' or 'square', the canvas's own lineCap. |
oneStroke |
false |
A translucent stroke lays down once where rings meet or overlap, instead of darkening at every join. |
centralMeridian |
0 |
The longitude your projection measures from. Wrong, and the seam guard guards the wrong place. |
visible |
— | (lon, lat) => bool. Refused points lift the pen: a globe's far side, an azimuthal chart's antipode. |
Every polygon in a GeoJSON document, holes kept, cached like
geojsonRings. Lines and points are skipped. This is what you hand to fill
and trace.
Fills land. A polygon that crosses the seam is filled as two shapes meeting at the map's edge, never as one smeared across it. On a globe, land is cut at the horizon and closed along it, so only the near side is filled.
polygons may also be an async iterable of GeoJSON polygons or features,
for a source that hands land over as it is read or fetched. Given one, fill
and trace return a promise. Draw nothing else on that context until the
promise settles.
| Option | Default | |
|---|---|---|
color |
context's own | Fill style. |
alpha |
1 |
Opacity. |
rule |
'evenodd' |
Canvas fill rule. Even-odd keeps holes whichever way their rings run; 'nonzero' if your data promises winding. |
centralMeridian |
0 |
As for limn. |
visible |
— | As for limn: refused land is cut away along the horizon, never projected. |
The same path as fill, left for you to fill or clip. Clip to it to keep a
stroke on the land:
ctx.save();
trace(ctx, geojsonPolygons(land), project, { centralMeridian });
ctx.clip('evenodd');
limn(ctx, geojsonRings(coastline), project, { oneStroke: true, lineCap: 'round', centralMeridian });
ctx.restore();The visible that nearly every centred projection wants: admits the points
strictly within angle degrees of arc of the centre. 90 is an orthographic
globe's near side; just under 180 (say 179) is an azimuthal chart with its
antipode refused.
limn(ctx, rings, project, { centralMeridian: -60, visible: withinCap([-60, 45], 90) });The line a ship sails holding one compass course: bearing in degrees
clockwise from north, length in degrees of arc (one degree is 60 nautical
miles), as up to steps + 1 points for limn to draw. A portolan chart's
lattice is many of these. It is straight only on Mercator, and it stops at 85°
latitude rather than spiral into the pole.
A period chart's rivers wiggle, its lakes are lozenges and its seas are hatched. These work in pixels, after projection, so a wiggle is one size at every zoom.
runs(rings, project, { centralMeridian, visible }): pixel runs, the pen lifted wherelimnlifts it.pen(ctx, runs, { close })adds pixel runs to the current path, to stroke or fill.simplify(run, tolerance)drops bends smaller thantolerancepx, before a wave goes on.meander(run, { amplitude, wavelength, phase }): the run wiggling, still ending where it did.boundingBox(run)→{ cx, cy, angle, length, width }, a shape's box along its long axis.lozenge(box, steps = 24): a closed pointed oval on that box.hatch(ctx, polygons, project, { angle, gap, amplitude, dash, … })strokes parallel lines, clipped.
Letters names onto the chart. A coast's name is set along the coast, on the
land, its letters standing on the shore so that it reads from the sea. A
point's name sits level above the point, or runs inland from the shore it
stands on when given shore. Where two names would overlap, the later one is
dropped, so sort the list by importance first. An island too small to carry its
name along its coast gets it level above, as a point would.
letter(
ctx,
[
{ text: 'Sicilia', at: sicilyRing }, // a stretch of coast or a whole island ring
{ text: 'Palermo', at: [13.36, 38.12] }, // a point
{ text: 'Malta', at: { type: 'Point', coordinates: [14.4, 35.9] } },
],
project,
{ fonts: ['IM Fell English', 'Georgia', 'serif'], size: 13, color: '#6b2a1a', centralMeridian },
);Each name is { text, at, land, fonts, size }. at is [lon, lat],
[[lon, lat], …], or a GeoJSON Point, LineString or MultiLineString geometry.
land is which side of the line, walked in order, the land lies: 'left' by
default, as Natural Earth winds its coastlines, or 'right'. fonts and
size override the options for that one name.
coast-wright loads no fonts. fonts is a list of family names, tried in
order as a CSS font stack, so a face that has not loaded yet draws in the next
one. Load your own first, with document.fonts.load('13px "IM Fell English"')
or a FontFace, then draw.
| Option | Default | |
|---|---|---|
fonts |
['serif'] |
One family or several (a list, or one CSS stack string), first choice first. |
size |
12 |
Font size in pixels. |
style, weight |
— | As in a CSS font: 'italic', '600'. |
color |
context's own | Fill style. |
alpha |
1 |
Opacity. |
gap |
0.3 × size |
Pixels between the coast and the letters. |
padding |
2 |
Pixels kept clear round each name when testing overlap. |
upright |
true |
Turn upside-down names round. false keeps every name reading from the sea. |
centralMeridian |
0 |
As for limn: a coast crossing the seam is lettered on its longer side. |
visible |
— | As for limn: a name whose place is refused is not drawn. |
Returns the names it drew, each { text, font, x, y, angle, baseline, box }.
place(ctx, names, project, options) does the layout alone, without drawing.
The portolano is a compact coastline format from an earlier design. New work draws GeoJSON instead (why); the decoder stays exported until its retirement is decided, so existing callers keep working:
rings(portolano)→ every ring, flattened across polygons, cached per document.polygons(portolano)→[[outer, …holes], …], holes kept. Fill even-odd; winding order is not promised.decodeRing(encoded, precision)→ one ring; takeprecisionfrom the document, profiles differ.
rings and polygons refuse a document whose format or coordinate order
they do not recognise.
coast-wright bundles no data: you pass the GeoJSON in.
Natural Earth is public domain and publishes its coastline as GeoJSON at three scales: about 140 KB, 1.6 MB and 10 MB for 110m, 50m and 10m, a third of that gzipped. Pick the scale by timing parse and draw on your own target, then pin the version and check the file's hash at build.
https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_50m_coastline.geojson
Natural Earth asks for no credit. If you give one: Made with Natural Earth.
MIT.
