A sky pointer for Garmin watches. Pick an object (the Sun, the Moon, a planet, a bright star), hold the watch up with the back facing the sky, and the object is drawn where it really is behind the case. Move your arm until the marker settles in the middle of the screen and you are looking straight at it.
It is the idea behind a phone astronomy app held up with its camera, on a wrist, with no network, no phone, and no chart to orient yourself against first.
- How it works
- Screens
- Settings
- The maths
- Source map
- Cost and frame time
- Building
- Devices
- Accuracy
- Licence
The aim axis is the watch's Z axis, straight out through the back of the case, not the 12 o'clock edge. That makes the watch a viewfinder: you look at the screen, the sky is behind it, and the object is drawn at the place on the glass you would see it through if the watch were transparent.
Because it is a window, turning your wrist turns what you see through it. The picture is built in the watch's own frame and rolls with your hand. The written turn/tilt guidance underneath is measured against gravity instead, because it describes how to swing your arm, which has nothing to do with how the watch is rotated in your grip.
Two independent chains meet at the projection, and the whole thing runs once per frame at 10 Hz:
flowchart TD
subgraph SKY["Where the object is"]
direction TB
CLK["Clock, UTC"] --> JD["Julian Day<br/>64-bit"]
JD --> LST["Local sidereal time<br/>GMST plus longitude"]
JD --> SER["Series and elements<br/>Sun, Moon, planets"]
CAT["Star catalogue<br/>J2000"] --> RADEC
SER --> RADEC["Right ascension<br/>and declination"]
LST --> ALTAZ["Altitude and azimuth"]
RADEC --> ALTAZ
LAT["Latitude, from GPS"] --> ALTAZ
ALTAZ --> CORR["Parallax then refraction"]
CORR --> ENU["Unit vector<br/>east, north, up"]
end
subgraph WATCH["Where the watch is pointing"]
direction TB
ACC["Accelerometer"] --> GRAV["Gravity, normalised<br/>and negated"]
MAG["Magnetometer"] --> NORTH["Magnetic north<br/>vertical part removed"]
GRAV --> NORTH
GRAV --> FRAME["Device frame<br/>world axes in watch coordinates"]
NORTH --> FRAME
end
ENU --> OFF["Rotate into watch axes<br/>right, up, forward"]
FRAME --> OFF
OFF --> Q{"forward greater<br/>than 0.1?"}
Q -->|no| DROP["Behind the watch.<br/>Marker pinned to the rim,<br/>grid line broken"]
Q -->|yes| PROJ["Perspective divide"]
PROJ --> XY["Screen x, y"]
style SKY fill:#0d1117,stroke:#30363d
style WATCH fill:#0d1117,stroke:#30363d
Both sensor readings are smoothed as vectors before any of this, never as angles (see Smoothing).
flowchart LR
ROOT["Locate Sky Object"]
ROOT --> ALL["Show All"]
ROOT --> SUN["Sun"]
ROOT --> MOON["Moon"]
ROOT --> PLIST["Planets list"]
ROOT --> SLIST["Stars list"]
PLIST --> AIM
SLIST --> AIM
SUN --> AIM["Sky screen"]
MOON --> AIM
ALL --> AIM
AIM -->|hold up / menu| SET["Settings"]
SET -->|back| AIM
AIM -->|back| ROOT
The object name at the top, the marker where the object is, and a table below:
azimuth and altitude for the object (Obj) and for the aim (Aim), then how far
to Turn and Tilt. The two rows converge as you settle onto the object. If a
sensor axis is wired the wrong way they move apart as you close in, so the fault
shows at once.
Within On target. Below the horizon the altitude reads red: the object is
placed correctly, but it is underneath you.
When the object is off the edge of the view it pins to the rim with a chevron pointing further the way to move, inset far enough that the marker and its chevron both stay on the glass.
Above: both grids on at N at the north point.
This mode draws the whole catalogue as a plain sky map: the Sun, the Moon, the planets and the bright stars. Nothing is being aimed at, so there is no turn/tilt guidance, only a readout of where the watch is pointing.
Nothing is pinned to the rim in this mode, because with the whole sky on show the markers would pile up around the edge. An object that is not in front of the watch is not drawn.
With Crosshair on, a small cross marks the middle of the screen, and the object
nearest it, within the same On target, is named at the top with its
magnitude at the bottom. Star magnitudes are catalogue values; a planet's comes
from the same distances as its position (Schlyter's formulas, Saturn's rings
included), the Moon's from its phase, and the Sun's is
Objects are listed alphabetically in the Planets and Stars menus. The catalogue keeps its own order (stars by brightness, planets by distance out from the Sun), and a star id is its position in it, so only the menu presentation is sorted.
Aimed near the zenith with the horizon grid at
Hold the up/menu button on any sky screen. Every setting changes what is on the screen behind, so they are reachable from the screen they affect rather than only from the root menu. Selecting an item steps it to its next value in place. A short list is quicker to thumb through than a submenu, and the label cannot go stale behind the menu showing it.
| Setting | Values | Default |
|---|---|---|
| Horizon Grid | Off · 60 · 45 · 30 · 15 · 10 degrees | Off |
| Equatorial Grid | Off · 60 · 45 · 30 · 15 · 10 degrees | Off |
| Constellations | Off · On | Off |
| Sun Path | Off · On | Off |
| Moon Path | Off · On | Off |
| Crosshair | Off · On | Off |
| Update Location | One fix only · every 5 / 15 / 30 / 60 min | One fix only |
Watches that cannot draw the finest spacings in time, or hold them in memory, stop at 15 or 30 degrees.
Spacings that come to a whole number of hours say so. The sky turns
Everything starts off; the grids are there when you ask for them. The equatorial grid turns with the sky, as the stars on it do, and the horizon grid never moves.
The display is held awake while a sky screen is up. When burn-in protection refuses to keep it on, the panel gets a short rest before the app asks again.
Everything below runs on the watch from the clock and two sensors, with no almanac file and no network connection.
SkyMath.julianDay uses the standard Gregorian algorithm. For year
This one must be 64-bit. A Julian Day this century runs to about
Measured against the sky: an observation at 04:06 wants .toDouble() forces the running
total to 64 bits; the terms before it are exact whole numbers that a float still
holds safely.
Greenwich Mean Sidereal Time in degrees, with
Also 64-bit, and for the same reason:
The
Every object resolves to a geocentric right ascension
flowchart TD
OBJ["Object from the catalogue"] --> T{"type"}
T -->|star| S["Read J2000 RA and Dec<br/>straight from the table"]
T -->|sun| SU["Meeus abbreviated<br/>mean longitude, anomaly,<br/>equation of the centre"]
T -->|moon| MO["Meeus series<br/>largest terms only"]
T -->|planet| PL["Keplerian elements<br/>Newton solve for E"]
PL --> HG["Heliocentric to geocentric<br/>add the Sun vector"]
SU --> EQ["Ecliptic to equatorial<br/>rotate by the obliquity"]
MO --> EQ
HG --> EQ
EQ --> OUT["RA and Dec"]
S --> OUT
Bright naked-eye stars as literal J2000 RA/Dec plus visual magnitude. Proper motion and precession are ignored; both are far below what a wrist magnetometer can resolve.
Meeus, abbreviated. Mean longitude, mean anomaly, equation of the centre, then the apparent longitude corrected for aberration and nutation:
The Moon needs a series rather than a formula. It is built from four fundamental
arguments (mean elongation
Mercury through Saturn from approximate Keplerian elements, in the style of Paul
Schlyter's How to compute planetary positions. Each planet's elements (ascending
node
Kepler's equation has no closed form:
so it is solved by Newton iteration from a first guess, to
Then true anomaly and radius from the eccentric anomaly, rotated out of the orbital plane into heliocentric ecliptic coordinates, and the Sun's own geocentric vector added to shift the origin from the Sun to the Earth:
That last addition is the parallax of the whole Earth's orbit, and it makes Mars swing backwards through the sky a few weeks a year.
All three computed bodies come out in ecliptic coordinates and are rotated by the
obliquity
SkyMath.raDecToAltAz. With hour angle
Azimuth is measured from north, clockwise through east. The
Then to a world East-North-Up unit vector:
The equatorial grid, the constellation lines and the Sun and Moon paths skip those
two steps. SkyMath.equatorialToEnu writes the same rotation as one matrix,
without the arcsine, arccosine and quadrant test, and gives the same answer to
within floating-point noise:
Those overlays keep their points as fixed unit vectors (SkyMath.raDecToVector),
so each frame turns them with nine multiplications apiece.
Both act along the vertical circle, so azimuth is untouched.
Everything above answers for the centre of the Earth. You are 6378 km off that centre. For the Moon that matters:
where
That runs to about
Bennett's formula, giving arcminutes for an altitude
About
Two sensors, polled at 10 Hz rather than waited on. The push callbacks fire about once a second, which is far too slow and too stale to aim with while moving.
Exponential smoothing applied to the raw sensor vectors, not to angles derived from them:
Stellarium's SensorsMgr does the same, for the same reason: magnetometer noise is
what makes a sky view jitter, and it settles far better when averaged as vectors.
Stellarium runs
Heading is smoothed the short way round the circle, so
The sign comes from the hardware: this device reports the gravity direction rather
than specific force. Each such convention lives in a named constant with a check
you can run on the pointer screen. Aim the back at the horizon and the aim
elevation should read about
The system's compass heading is a flat reading and goes wrong as soon as you tilt the watch up at the sky, which is how this app is used. So the azimuth is worked out from the raw magnetometer instead.
Flatten both the field
With the vertical component of the field removed, what is left points north however the watch is tilted. Stellarium does the same, de-rotating the raw field by the device's own roll and pitch rather than trusting a system heading.
The system heading is still useful in one case: while the watch happens to be near level, it is both trustworthy and corrected to true north. The gap between it and the computed magnetic azimuth is therefore the local declination:
banked slowly, only while the watch is near level, and then applied at any tilt. Averaging stops once it has converged, so the reference stops creeping. It is worked out afresh each time a sky screen opens.
DeviceAim.deviceFrame returns the world's axes written in the watch's coordinates:
and is then swung round to true north by the banked declination
Without this rotation the whole drawn sky would sit turned by the local declination
against the numbers printed beneath it: a few degrees in most places and twenty in
parts of Canada, against an
viewOffset then takes a world direction
Forward is the component whose sign flips, because the aim is out through the back. All three are direction cosines, ready for a perspective divide.
A pinhole camera, with
so the screen spans
Anything with
Nothing is clipped and no band is reserved. The sky is laid down across the whole display and the text is drawn on top of it afterwards.
Five things can be drawn over the sky, each switched on separately: two grids that answer different questions, the constellation figures, and the paths of the Sun and the Moon.
| anchored to | moves when | |
|---|---|---|
Horizon (HorizonGrid) — circles of equal altitude, vertical circles between zenith and nadir |
the ground and the compass | you move the watch |
Equatorial (EquatorialGrid) — circles of equal declination, hour circles between the celestial poles |
the stars | you move the watch, and as time passes |
Constellations (Constellations) — stick figures joining their stars |
the stars | you move the watch, and as time passes |
Sun and Moon paths (SkyPaths) — where each will be, marked by date |
the stars | you move the watch, and as time passes |
flowchart LR
subgraph H["Horizon grid"]
direction LR
HA["az, alt"] --> HB["ENU vector"]
end
subgraph E["Equatorial grid"]
direction LR
EA["Horizon grid mesh<br/>east and north swapped"] --> EB["equatorialToEnu<br/>one matrix a frame"]
LATLST["latitude and<br/>sidereal time"] --> EB
EB --> EC["ENU vector"]
end
HB --> P["Rotate into watch axes,<br/>perspective divide"]
EC --> P
P --> SCR["Screen"]
The horizon grid takes no clock and no position:
The equatorial grid has no points of its own. A horizon-grid point with its east
and north parts swapped is the equatorial unit vector at the same two numbers, so
it draws the horizon grid's mesh through
Both count outwards from their zero line (the horizon for one, the celestial equator for the other) rather than up from the bottom, so that line is drawn at every spacing.
Cardinal letters are drawn even with the horizon grid off, since which way you are facing is the most directly useful thing on the screen.
Orion, Ursa Major, Ursa Minor, Cassiopeia, Cygnus, Crux and the twelve constellations of the zodiac, drawn as lines joining their stars, with a dot on each star. The watches with half the per-frame budget carry Leo and Scorpius but not the other ten zodiac figures: a frame with all of them on top of everything else is more than those watches allow.
The vertices live in Constellations rather than in SkyCatalog, because most of
them are not stars anyone would aim at. A figure needs its faint stars to read, and
adding them to the catalogue would bury the bright ones in the Stars menu and
scatter faint dots across Show All. They are line endpoints, not objects, so each
figure is held as runs of RA/Dec pairs, one per unbroken stroke.
One thing separates them from the equatorial grid they share a frame with. Like the
Sun and Moon paths, they apply refraction, because they have to stay under the stars
they join: the stars are placed through
apparentAltitude, so a belt drawn without it sits a couple of pixels below its own
three stars as the constellation rises.
The Sun's path through the next twelve months, with a dot on the first of each month, and the Moon's through the next four weeks, with a dot and the date on each day. Both come from the same series that place the bodies, with the same parallax and refraction, so the Sun and the Moon each sit on their own line. Each line has its own switch.
The dated positions are fixed on the sky, so they are worked out once a month for the Sun and once a day for the Moon, from the view timer a few days at a time, and placed for your time and position every few seconds.
Measured against gravity rather than the watch's own axes, so rolling your wrist
leaves it alone. aimBasis builds a frame from the aim direction (heading
and project puts the object direction
Tilt is measured off the aim's own horizontal plane, not straight off the aim axis. Both amount to the same thing near the object, but this stays well defined when it is far to one side, where "aim" and "up" both fall to zero and dividing one by the other is meaningless.
Aimed within a couple of degrees of straight up or down there is no sensible "turn
left" to give, since every direction is sideways from there, so the screen says
Straight up/down instead; the picture still holds.
ObjectArt. Stellarium wraps photographic surface maps onto spheres on the GPU.
None of that survives the trip down to a disc a few dozen pixels across, so what
is drawn is the handful of features still recognisable at that size.
The phase is computed from the same geometry Stellarium uses. With
What the drawing wants is the terminator ellipse's semi-axis as a fraction of the
disc radius,
running from
taken from the geometry rather than off the screen, which is why it stays right as your wrist rolls.
It is drawn as a dark disc, then the bright hemisphere, then the terminator ellipse in whichever colour that side ended up. That makes two convex half-ellipses rather than one lune, because a crescent is concave and how a device fills a concave polygon is not something to rely on.
flowchart LR
A["Dark disc<br/>full radius"] --> B["Bright hemisphere<br/>half-ellipse, semi-axis r"]
B --> C{"c greater than<br/>or equal to 0?"}
C -->|"yes, gibbous"| D["Half-ellipse, semi-axis -c·r<br/>painted light"]
C -->|"no, crescent"| E["Half-ellipse, semi-axis -c·r<br/>painted dark"]
- Sun — corona rings outside the disc, and a disc brightening towards the middle for limb darkening.
- Saturn — rings, drawn before the disc so the planet sits in front of them.
- Jupiter — two belts either side of the equator, which is as much as any small telescope shows.
-
Stars — magnitude-scaled radius
$r = \mathrm{clamp}\left(5 - \mathit{mag},, 2,, 7\right)$ , so brighter stars draw larger the way they look.
Rings and belts lie along the local horizontal, derived from where the zenith falls in the watch axes, so they roll with the sky rather than staying pinned to the screen.
| File | What it does |
|---|---|
PointerView.mc |
The sky screen: sensors, layout, both modes, drawing |
ObjectArt.mc |
Colour, size and face of each body; Moon phase |
DeviceAim.mc |
Orientation, tilt-compensated compass, projection, overlay lines |
Settings.mc |
Persisted choices, cached in memory |
SkyMath.mc |
Time, coordinate conversion, refraction, parallax |
HorizonGrid.mc |
Alt/az grid, the mesh both grids share, and cardinal letters |
EquatorialGrid.mc |
RA/Dec grid, drawn from the horizon grid's mesh |
Constellations.mc |
Stick figures, drawn from their own vertices |
SkyPaths.mc |
Sun and Moon calendar lines |
Planets.mc |
Keplerian planetary positions |
SolarLunar.mc |
Sun and Moon series, obliquity, parallax |
SkyCatalog.mc |
The object registry, the bright-star table, RA/Dec dispatch |
SettingsMenuDelegate.mc |
Cycling settings in place |
SkyMenus.mc |
Menu construction |
RootMenuDelegate.mc |
Root and object-list menu routing |
PointerDelegate.mc |
Back, and the menu button |
MiniverseApp.mc |
Entry point |
test/ |
Unit tests |
onUpdate runs at 10 Hz, and what dominates the frame is grid density and
catalogue size.
- Grids: the point count grows as the spacing shrinks. Both grids keep fixed points, built once per spacing from the view timer rather than in the draw, and each frame only rotates them. Watches with less memory or less time per frame stop at a coarser finest spacing.
- Show All works out where every object is every few seconds and holds the result, since the sky moves well under a pixel in that time. Only the projection is redone per frame, and the cache is dropped when your position changes.
- Position is event-driven, not per-frame. A cached fix is used immediately; if nothing usable arrives within a few seconds the GPS is driven actively.
Requires the Connect IQ SDK and a developer key.
monkeyc -f monkey.jungle -o bin/Miniverse.prg -y developer_key -d instinctcrossoveramoledRun it in the simulator with connectiq followed by monkeydo bin/Miniverse.prg instinctcrossoveramoled, or build for the store with -e -r. For the unit tests,
add -t to the build and /t to monkeydo.
Permissions: Positioning and Sensor.
Every product listed in manifest.xml, across round, rectangular and
semi-octagonal displays. Requires Connect IQ API level 3.4 or later.
The black-and-white watches (the Instinct 2 family, the Instinct 3 Solar, the Instinct E and the Descent G1) show dark shades, red, green and orange as black, so on those everything that is not black is drawn white. On the watches with a small round window in the top right corner, the object name and the grid labels are kept clear of it. On the smallest screens the readout steps down a size, and drops its column heads if it still will not fit, so the object stays clear of the table describing it, and two grid labels share a line.
Layout is derived from the display size and the real font
metrics rather than fixed pixel offsets, so the picture takes every row the text
does not need. On round displays, labels are pulled in to where the glass reaches
on each row.
A watch without a magnetometer shows No compass on this watch rather than waiting
for a reading that never comes.
Accuracy is limited by the magnetometer. A wrist compass resolves a few degrees at best, and every approximation here is chosen to sit comfortably underneath that:
| Source | Error |
|---|---|
| Sun position | |
| Moon position | |
| Planets | arcminutes |
| Stars (no proper motion or precession) | arcminutes |
| Refraction (Bennett) |
|
| Earth's flattening, ignored | |
| Wrist magnetometer | a few degrees |
GPL-3.0. See LICENSE.
Three parts of the approach follow Stellarium: smoothing raw vectors rather than angles, de-rotating the raw field instead of trusting a system heading, and taking the lunar phase from the elongation. No Stellarium code or assets are used. Solar and lunar series are from Jean Meeus, Astronomical Algorithms; the planetary elements follow Paul Schlyter's How to compute planetary positions. The zodiac line figures follow those published with d3-celestial (BSD 3-Clause).






