Commands, settings, SDK functions, anything in the docs.

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.

The weather-card example on a LEDABLE panel
weather-card/index.tsx
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

ElementPropsDraws
Rectx, y, width, heightA rectangle.
Ellipsex, y, width, heightThe ellipse inside that box.
Circlex, y, diameterA circle in the box at x, y; an Ellipse of equal sides.
PolygonpointsA closed shape through at least three [x, y] points.
Linefrom, to, colorA one-pixel line, both end points included.
Pixelx, y, colorOne pixel.
PixelMappixels, color, x, yOne colour at each [x, y] of pixels, offset by x and y.
Spriterows, palette, x, yA small bitmap written as text (below).
Textsee Text and fontsText in a bitmap font.
Groupx, y and moreMoves, turns, scales or clips its children (below).
Layeropacity, blendMode, maskDraws 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:

TSX
<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:

TSX
<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:

  • x and y move the children. Inside the group, coordinates start at the group's own top left, which is what lets a component like Sun draw at 0, 0.
  • scale enlarges by a whole factor: every pixel becomes a square of pixels.
  • flipX, flipY and rotate (90, 180 or 270 degrees) need the group's width and height, the box they flip or turn within.
  • clip is 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.