Replicating the iOS Lock Screen Temperature Gauge
I have always liked the iOS lock screen temperature gauge, as it allows you to see the current, minimum and maximum temperature at a glance, including a visual to show you how close you are to the extremes. It's a continuation of Apple's skeuomorphism, given it resembles a real thermostat but is also quite stylised.
This is an HTML canvas version of this element, drawn using circles and some basic trigonometry (see how it scales to full screen). I optimised for simplicity to allow me to port it over to a drawing library for MicroPython to draw it with a Raspberry Pi Pico. More on that later.
Library Functions
For reasons I'll explain later, I need some basic library functions to manage the canvas. This first method getMappedRangeValueClamped
allows you to map one range onto another, which is useful for animation. In the case of this gauge, I want to map the temperature onto an angle around the gauge.
function getMappedRangeValueClamped(srcRange, dstRange, value) {
value = Math.max(Math.min(value, srcRange[1]), srcRange[0]);
let ratio = (value - srcRange[0]) / (srcRange[1] - srcRange[0]);
return dstRange[0] + ratio * (dstRange[1] - dstRange[0]);
}
And of course degrees to radians..
function degreesToRadians(degrees) {
return degrees * (Math.PI / 180);
}
And a "get me the point at this angle on a circle" method:
function pointOnCircle(x, y, radius, angle) {
return [
x + radius * Math.cos(angle),
y + radius * Math.sin(angle)
];
}
And finally two drawing methods starting with "draw a circle":
function circle(x, y, radius) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fill();
}
And then "draw a polygon":
function polygon(points) {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i][0], points[i][1]);
}
ctx.closePath();
ctx.fill();
}
Shape Breakdown
The first thing to draw is the gauge itself. We’re going to cheat and draw two circles, one slightly smaller one on top of a larger one. This gives the illusion that there’s a gauge.
const centre = [size[0] / 2 + position[0], size[1] / 2 + position[1]];
const guageRadius = size[1] * 0.45;
const guageThickness = size[1] * 0.05;
ctx.fillStyle = guageColor;
circle(centre[0], centre[1], guageRadius + guageThickness);
ctx.fillStyle = backgroundColor;
circle(centre[0], centre[1], guageRadius - guageThickness);
Next we’ll mask the bottom of the gauge using a triangle so it is no longer a complete circle.
const extentX = [centre[0] - size[1] * 0.5, centre[0] + size[1] * 0.5];
ctx.fillStyle = backgroundColor;
polygon([
[extentX[0], position[1] + size[1]],
[extentX[0], position[1] + size[1] * 0.8],
[centre[0], centre[1]],
[extentX[1], position[1] + size[1] * 0.8],
[extentX[1], position[1] + size[1]]
]);
Let’s smooth the ends of the guage by drawing two circles, the same thickness as the gauge.
const degreesOffset = 65;
const gaugeMinMaxRadians = [degreesToRadians(90 + degreesOffset), degreesToRadians(90 + 360 - degreesOffset)];
ctx.fillStyle = guageColor;
const roundedCapStart = pointOnCircle(centre[0], centre[1], guageRadius, gaugeMinMaxRadians[0] - 0.1);
circle(roundedCapStart[0], roundedCapStart[1], guageThickness);
const roundedCapEnd = pointOnCircle(centre[0], centre[1], guageRadius, gaugeMinMaxRadians[1] + 0.1);
circle(roundedCapEnd[0], roundedCapEnd[1], guageThickness);
Now we’ll draw the notch on the gauge to demonstrate the current temperature. This requires some trigonometry to calculate the point at which the notch should be rendered. Then we’ll use the same trick as the gauge; one circle for the border, one for the notch.
const radians = getMappedRangeValueClamped(
[minimumTemperature, maximumTemperature],
gaugeMinMaxRadians,
currentTemperature
);
const notchPoint = pointOnCircle(centre[0], centre[1], guageRadius, radians);
And finally, we can draw the text labels.
ctx.font = `600 ${size[1] * 0.4}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${Math.round(currentTemperature)}`, centre[0], centre[1]);
ctx.font = `600 ${size[1] * 0.175}px sans-serif`;
ctx.fillText(`${Math.round(minimumTemperature)}`, extentX[0] + size[1] * 0.3, position[1] + size[1] * 0.85);
ctx.fillText(`${Math.round(maximumTemperature)}`, extentX[1] - size[1] * 0.3, position[1] + size[1] * 0.85);
As mentioned above, this technique optimises for ease of implementation and not performance. It’s highly inefficient because pixels get drawn multiple times, where if we were smarter we’d figure out up front what color a given pixel would be and color it once, however that is beyond the scope of this article.
PicoGraphics on MicroPython
I’m using a library called PicoGraphics to render simple 2D primitives to the screen. It just so happens that it can render circles and polygons, just like the methods we defined in the HTML canvas version. Here's the full logic ported to MicroPython: gauge.py.

This is a preview using a PicoGraphics emulator in pygame on Windows so it doesn't look exactly like this on the device I'm deploying it to, but it gives you an idea of the final result. On the device the temperature gauge alone draws in about 4ms, which isn't as fast as it could be, but this isn't a problem given how infrequently the temperature updates.
🏷️ gauge temperature draw iframe screen circles library circle given canvas drawing micropython ll notch picographics
Please click here to load comments.