An ILI9341 panel is large enough for a useful live sensor graph, but a 240 × 320 RGB565 framebuffer costs 153,600 bytes:

240 * 320 * 2 bytes = 153,600 bytes

That is a poor default allocation on a small microcontroller when the graph itself may need only a few hundred samples. The display controller already contains its own graphics RAM. An ESP32-C3 can therefore treat the panel as the destination, keep only the application state it needs, and send changed pixels over SPI.

For a scrolling line graph, the useful state is not a second copy of the screen. It is a bounded history of sensor samples plus enough information to erase and redraw the part that changed.

Separate sample history from pixels

Suppose the plot is 200 pixels wide and shows one sample per horizontal position. The firmware only needs 200 measurements to reconstruct the trace.

A fixed-capacity ring buffer expresses that limit directly:

const WIDTH: usize = 200;

struct Samples {
    values: [i16; WIDTH],
    head: usize,
    len: usize,
}

impl Samples {
    const fn new() -> Self {
        Self {
            values: [0; WIDTH],
            head: 0,
            len: 0,
        }
    }

    fn push(&mut self, value: i16) {
        self.values[self.head] = value;
        self.head = (self.head + 1) % WIDTH;
        self.len = self.len.saturating_add(1).min(WIDTH);
    }

    fn get_oldest(&self, index: usize) -> Option<i16> {
        if index >= self.len {
            return None;
        }

        let start = if self.len == WIDTH { self.head } else { 0 };
        Some(self.values[(start + index) % WIDTH])
    }
}

The storage is fixed:

200 samples * 2 bytes = 400 bytes

There is no heap requirement in this structure, which makes it suitable for a #![no_std] firmware. More importantly, the memory cost follows the number and representation of samples rather than the display resolution.

If the sensor naturally produces f32, storing floats may be reasonable. If its useful range fits a calibrated integer representation, a fixed-point or integer sample can reduce storage and make the scaling rules explicit.

Map engineering values into a plot rectangle

The display coordinate system and the sensor’s units are different domains. Keep that conversion in one function.

For a plot with top coordinate y0, height h, and an input range min..=max, a linear mapping is:

normalized = (value - min) / (max - min)
y = y0 + (h - 1) - normalized * (h - 1)

The subtraction from the bottom is necessary because display Y coordinates normally increase downward.

Integer arithmetic avoids requiring floating-point state for a simple chart:

fn scale_y(value: i32, min: i32, max: i32, y0: i32, height: i32) -> i32 {
    let value = value.clamp(min, max);
    let span = max - min;
    let pixels = height - 1;

    y0 + pixels - ((value - min) * pixels / span)
}

The caller must ensure max > min. Clamping is also important: an out-of-range sensor reading should hit the graph boundary rather than generate a coordinate outside the plot.

For sensors whose range changes dramatically, automatic scaling is possible, but it changes the meaning of old pixels whenever the scale changes. A fixed range is often easier to read on an embedded dashboard because a given vertical position keeps the same physical meaning.

Full redraw spends SPI bandwidth on unchanged pixels

Clearing and repainting the entire 240 × 320 screen for every new sample is simple, but it sends far more data than a line graph needs.

Ignoring command overhead, a full RGB565 transfer is:

240 * 320 * 2 = 153,600 bytes per frame

At ten full redraws per second, pixel payload alone reaches:

1,536,000 bytes/s

The actual bus traffic also includes display commands and any other drawing. Practical frame rate depends on SPI clocking, driver overhead, transaction structure, and what else shares the bus.

A sensor sampled once per second does not need that workload. Even a faster plot usually changes only a narrow region or one new segment at a time.

Two incremental redraw strategies

There are two useful ways to make a scrolling chart without a framebuffer.

The first is to keep X positions fixed and redraw the entire plot rectangle from the ring buffer whenever a sample arrives. This still avoids a screen-sized framebuffer, but it redraws all graph pixels. It is straightforward and works well when the sample rate is low.

The second is a sweep display. The write cursor advances from left to right. Before drawing the newest segment, firmware clears only the next narrow column or small strip, then draws the new segment there. When the cursor reaches the right edge, it wraps to the left.

Conceptually:

old trace                 new sample
    |                          |
    v                          v
+--------------------+    +--------------------+
|      /\      /     |    |      /\      /     |
|  /\/  \_/\/       | -> |  /\/  \_/\/  \    |
|            ^       |    |             ^      |
+------------|-------+    +-------------|------+
             cursor                     cursor

This design has bounded drawing work per sample. It does not visually shift every old point left, so it behaves more like an oscilloscope sweep than a continuously translated chart.

A true left-scrolling graph requires either redrawing the plot from stored samples, using controller features carefully where applicable, or maintaining pixel data elsewhere. The ring buffer solves sample history; it does not make existing display pixels move by itself.

Erasing a line requires knowing what was there

Incremental drawing creates a subtle problem: drawing the new line is easy, but old pixels remain unless they are explicitly removed.

Clearing a narrow strip before reuse is often simpler than trying to redraw the exact old line in the background color. A strip can include the full plot height:

plot height = 160 px
strip width = 2 px
RGB565 payload = 160 * 2 * 2 = 640 bytes

That is dramatically smaller than a full-screen transfer.

There is a tradeoff. If grid lines or labels cross that strip, clearing it also removes them. The firmware must redraw the static decorations inside the cleared region, or keep labels outside the changing plot area.

This leads to a useful layout boundary:

+--------------------------+
| title / numeric reading  |  updated separately
+--------------------------+
|                          |
|      plot rectangle      |  incremental redraw
|                          |
+--------------------------+
| fixed labels             |  rarely redrawn
+--------------------------+

Treating each region according to its update frequency keeps display traffic predictable.

embedded-graphics fits the drawing layer

In Rust, embedded-graphics provides geometry, colors, text, and drawing primitives through a DrawTarget. A compatible ILI9341 driver can expose the panel through that abstraction, while the ESP HAL provides the SPI and GPIO implementation underneath.

The layering is roughly:

application graph state
        |
embedded-graphics primitives
        |
ILI9341 display driver
        |
SPI + GPIO traits / HAL
        |
ESP32-C3

That separation matters. The ring buffer and scaling code do not need to know which SPI peripheral is in use. Likewise, the display driver does not need to know whether a line represents temperature, voltage, or acceleration.

Crate APIs and constructors can change between releases, so firmware should pin compatible versions and follow the selected driver’s current API rather than copying initialization code written for another release.

Keep acquisition timing independent from display timing

A display update can take much longer than reading a small I²C sensor register. Coupling the two operations in one timing-critical path can introduce sample jitter.

A better boundary is:

sensor acquisition -> sample queue/state -> display update

The acquisition side records measurements at the required cadence. The display side consumes the latest state at a rate the screen can sustain.

This distinction becomes more important when Wi-Fi is added. Network activity, sensor acquisition, and display transfers have different latency characteristics. The graph should not silently redefine the sampling interval merely because an SPI transaction took longer than expected.

With an async embedded executor such as Embassy, these responsibilities can be separate tasks where the supported ESP32-C3 stack provides the required drivers. A synchronous firmware can use the same architectural boundary with timers and a cooperative main loop.

SPI ownership is part of the design

An ILI9341 module may share an SPI bus with another peripheral such as a microSD card. Sharing MOSI, MISO, and SCLK does not mean both devices can be driven simultaneously. Each device needs its own chip-select behavior, and access to the bus must be serialized.

The graph renderer should therefore avoid holding the SPI bus longer than necessary. Large full-screen redraws increase the time before another device can use the bus. Narrow updates reduce both display traffic and bus occupancy.

This is also why display performance cannot be reduced to the configured SPI frequency. Transaction setup, command/data transitions, chip-select handling, driver batching, and competing devices all affect useful throughput.

Memory should be budgeted by ownership

A framebuffer is not the only consumer of RAM. Sensor buffers, network packets, protocol state, task stacks, strings, and driver allocations all compete for the same finite memory.

For the graph itself, the budget can stay small and visible:

sample history      400 bytes   (200 * i16)
graph metadata       tens of bytes
temporary geometry   small stack values
framebuffer           0 bytes

Exact totals depend on Rust layout and the surrounding program, but the important property is structural: memory grows with bounded application state, not with every pixel on the panel.

That makes the design useful beyond one display controller. The same principle applies whenever a display can accept drawing commands directly and the application does not need random access to a complete off-screen image.

The useful boundary is the plot, not the screen

A live sensor graph on ESP32-C3 does not require treating the ILI9341 like a desktop monitor. The panel already stores the pixels that are visible. Firmware needs to retain the information required to produce the next update.

A bounded ring buffer preserves measurement history. A dedicated scaling function gives sensor values stable pixel semantics. Incremental redraw limits SPI traffic, while separating acquisition from rendering prevents display latency from becoming sensor timing.

The result is not a general-purpose graphics pipeline. It is a display path shaped around one embedded workload, which is exactly why it can remain small and predictable.