Build apps
Drawing
Describe a frame in JSX and get exactly the pixels you wrote.
On this page
You draw with @ledable/sdk/graphics. A frame is a JSX tree with <Frame> at its root;
renderFrame turns the tree into pixels, and render returns the frames it made.

import { defineApp } from "@ledable/sdk/app";
import {
Circle,
Ellipse,
Frame,
Group,
Layer,
Line,
LinearGradient,
RadialGradient,
Rect,
Stop,
Text,
renderFrame,
type BitmapFont,
} from "@ledable/sdk/graphics";
import { PIXOLLETTA, TINY, loadFonts } from "@ledable/sdk/fonts";
// Made-up data: this example is about drawing, not forecasting.
const NOW = 21;
const HOURLY = [14, 15, 17, 19, 21, 22, 21, 19];
const COLDEST = 10;
const WARMEST = 25;
const FONT_REFS = { label: TINY, figure: PIXOLLETTA } as const;
type Fonts = { readonly [K in keyof typeof FONT_REFS]: BitmapFont };
function Sun({ x, y }: { readonly x: number; readonly y: number }) {
return (
<Group x={x} y={y}>
<Line from={[6, 0]} to={[6, 12]} color="#f6cd6d" />
<Line from={[0, 6]} to={[12, 6]} color="#f6cd6d" />
<Line from={[2, 2]} to={[10, 10]} color="#f6cd6d" />
<Line from={[10, 2]} to={[2, 10]} color="#f6cd6d" />
<Circle
x={2}
y={2}
diameter={9}
fill={
<RadialGradient center={[0.5, 0.5]} radius={0.5}>
<Stop offset={0} color="#fff6c2" />
<Stop offset={1} color="#f39c12" />
</RadialGradient>
}
/>
</Group>
);
}
function Cloud({ x, y }: { readonly x: number; readonly y: number }) {
// One layer, so the puffs fade as one shape: with opacity on each
// puff, the places where they overlap would come out brighter.
return (
<Layer opacity={0.8}>
<Group x={x} y={y}>
<Ellipse x={0} y={3} width={9} height={6} fill="#dfe6ee" />
<Ellipse x={4} y={0} width={9} height={8} fill="#dfe6ee" />
<Rect x={2} y={5} width={12} height={4} fill="#dfe6ee" />
</Group>
</Layer>
);
}
function Chart(props: {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}) {
const pitch = Math.floor(props.width / HOURLY.length);
// In canvas space the colour depends on how high a pixel sits on the
// panel, so a taller bar reaches warmer colours.
const heat = (
<LinearGradient from={[0, 0.65]} to={[0, 1]} space="canvas">
<Stop offset={0} color="#ff5a36" />
<Stop offset={1} color="#2f7bff" />
</LinearGradient>
);
return (
<Group x={props.x} y={props.y}>
{HOURLY.map((degrees, hour) => {
const share = (degrees - COLDEST) / (WARMEST - COLDEST);
const height = Math.max(1, Math.round(share * props.height));
return (
<Rect
key={hour}
x={hour * pitch}
y={props.height - height}
width={pitch - 1}
height={height}
fill={heat}
/>
);
})}
</Group>
);
}
function card(fonts: Fonts, width: number, height: number) {
return renderFrame(
<Frame width={width} height={height} background="#000000">
<Sun x={2} y={1} />
<Cloud x={8} y={6} />
<Text
x={width - 2}
y={2}
anchor="ra"
text={`${NOW}°`}
font={fonts.figure}
color="#ffffff"
/>
<Text
x={width - 2}
y={13}
anchor="ra"
text="CLOUDY"
font={fonts.label}
color="#8a96a3"
/>
<Chart x={0} y={height - 11} width={width} height={10} />
<Line
from={[0, height - 1]}
to={[width - 1, height - 1]}
color="#3a4450"
/>
</Frame>,
);
}
export default defineApp({
settings: () => ({}),
async render(ctx) {
const fonts = await loadFonts(ctx.assets, FONT_REFS);
// Draw for the panel that asks, not for an assumed size.
const [width, height] = ctx.resolution;
return { image: [card(fonts, width, height)] };
},
async preview(ctx) {
const fonts = await loadFonts(ctx.assets, FONT_REFS);
return { image: [card(fonts, 64, 32)] };
},
});Frames
renderFrame(<Frame width={…} height={…}>…</Frame>) returns a framebuffer: its width, its
height, and data, the pixels as RGBA bytes, row by row from the top left. A render returns a
list of them as image; one frame is a still picture, several are an animation (see
Animation).
renderFrame draws the tree once and keeps nothing. There is no DOM, no CSS, no state, no hooks
and no lifecycle: a frame is a pure function of the values you pass in, and the next frame is a new
tree. JSX here is not React. Every element is a function you import (<Rect>, never <rect>), and
your own components are plain functions that take props and return an element, like Sun and
Chart above. Children can be elements, arrays of elements, and null, undefined or booleans,
which draw nothing, so {cond ? <Rect … /> : null} works. Text or numbers as children throw: text
is drawn with <Text>.
<Frame> takes width, height and background, which is opaque black (#000000) unless you
give another colour. Elements draw in the order they are written, each over the ones before it.
The panel and its coordinates
The display is a panel of 64×32 pixels. Coordinates start at the top-left pixel, x grows to the
right and y grows down, and every coordinate and size is a whole number of pixels: a fraction
throws. <Rect x={2} y={1} width={4} height={2}> covers exactly 8 pixels, from column 2 to 5 and
row 1 to 2. Whatever lies outside the frame is cut off.
In render, read the panel's size from ctx.resolution and make your frames exactly that size,
as the example does: the display refuses an image of any other size and shows its loading
indicator instead. preview has no ctx.resolution; it draws for the 64×32 panel.
Elements
| Element | Props | Draws |
|---|---|---|
Rect | x, y, width, height | A rectangle. |
Ellipse | x, y, width, height | The ellipse inside that box. |
Circle | x, y, diameter | A circle in the box at x, y; an Ellipse of equal sides. |
Polygon | points | A closed shape through at least three [x, y] points. |
Line | from, to, color | A one-pixel line, both end points included. |
Pixel | x, y, color | One pixel. |
PixelMap | pixels, color, x, y | One colour at each [x, y] of pixels, offset by x and y. |
Sprite | rows, palette, x, y | A small bitmap written as text (below). |
Text | see Text and fonts | Text in a bitmap font. |
Group | x, y and more | Moves, turns, scales or clips its children (below). |
Layer | opacity, blendMode, mask | Draws its children apart, then blends them in (below). |
Rect, Ellipse, Circle and Polygon take a fill, a stroke or both; one of them is
required. strokeWidth is 1 unless you set it. A stroke grows inward from the shape's edge and
never reaches outside the box you gave, so a stroked and an unstroked rectangle of the same size
cover the same pixels.
Every element that draws also takes opacity, from 0 to 1, and blendMode: normal (the
default), multiply, screen or add.
A Sprite is a quick way to put a small hand-made bitmap in code. Each character of rows is
one pixel, and palette gives each character a colour, or null for a transparent pixel:
<Sprite
x={28}
y={12}
palette={{ ".": null, R: "#ff3b30", W: "#ffffff" }}
rows={[".RR.RR.", "RWRRRRR", "RRRRRRR", ".RRRRR.", "..RRR..", "...R..."]}
/>Larger artwork belongs in an image file; see Images and assets.
Colours
A colour is a string in one of four forms: #RGB, #RGBA, #RRGGBB or #RRGGBBAA. Names such
as red and functions such as rgb() throw. The alpha part blends the colour over what is
already drawn there.
On the panel, black is a pixel that is off, and dark colours are dim. brightenColor(color)
raises a colour that is darker than a threshold toward it and keeps its hue, which helps a colour
a person picked stay readable as text. parseColor turns a colour string into its r, g,
b and a numbers. A colour setting gives you numbers rather than a string; the
Quickstart example turns them into #RRGGBB.
Gradients
Instead of a colour, fill and stroke take a gradient element:
<Rect
x={0}
y={0}
width={64}
height={8}
fill={
<LinearGradient from={[0, 0]} to={[1, 0]}>
<Stop offset={0} color="#2f7bff" />
<Stop offset={1} color="#ff5a36" />
</LinearGradient>
}
/>LinearGradient runs from from to to; RadialGradient runs outward from center to
radius. Points and the radius are fractions: with space="bounds", the default, [0, 0] is the
top left of the shape and [1, 1] its bottom right; with space="canvas" they are fractions of
the whole frame, so shapes that share the gradient continue each other, as the bars of the example
do. A gradient has between 2 and 16 Stops, each with an offset from 0 to 1 and a color.
Every pixel takes the colour at its centre.
Groups
Group changes where and how its children land:
xandymove the children. Inside the group, coordinates start at the group's own top left, which is what lets a component likeSundraw at 0, 0.scaleenlarges by a whole factor: every pixel becomes a square of pixels.flipX,flipYandrotate(90, 180 or 270 degrees) need the group'swidthandheight, the box they flip or turn within.clipis a rectangle, in the group's own coordinates, outside which its children are not drawn.
Within one group, the flip applies first, then the turn, the scale and the move. A child's own group applies before its parent's.
Layers
A Layer draws its children on a transparent sheet of their own and then blends the sheet into
the frame with its opacity and blendMode. Fading a layer fades its children as one picture: in
the example, the puffs of the cloud overlap, and an opacity on each puff would make the overlaps
brighter. mask takes an element; where that element is transparent the layer does not show, and
where it is opaque the layer shows fully.
Exactly the pixels you wrote
The renderer works on whole pixels and never smooths an edge:
- A pixel is either covered by a shape or not; there is no anti-aliasing.
- A filled ellipse covers every pixel whose centre lies inside it or on its edge.
- A polygon covers every pixel whose centre lies inside it, plus every pixel on its edges, so thin spikes and corners stay lit. Where a polygon crosses itself, inside follows the even-odd rule: an area enclosed twice is a hole.
- Lines run from one end point to the other with no gaps, both ends included.
- Each colour is stored with 8 bits per channel, after blending.
The same tree always gives the same pixels, on your machine and on LEDABLE's servers. The renderer is part of your app's bundle, so a version you published keeps drawing exactly as it did when you uploaded it.
Limits
A tree holds at most 20,000 elements and nests at most 128 deep. A frame is at most 256 pixels on a side, a polygon has at most 1,024 points, a stroke is at most 32 pixels wide, and a group scales by at most 64. Going past a limit, like any other invalid prop, throws an error that names the element and the prop.
Combining frames
A framebuffer is plain data, so you can also draw one onto another. compositeFramebuffer(target, source, x, y) blends source over target at that position; a frame you render with a
transparent background, such as #00000000, can be laid over a picture this way.
cloneFramebuffer copies a frame you want to draw over more than once. This is how you draw
images, which Images and assets covers.