Spa mini-sectors
Spa-Francorchamps in square dots, cut into 24 mini-sectors, with the fastest run from Eau Rouge to Les Combes lit.
Made with Claude Opus 5.5
Unofficial fan tribute, not affiliated with or endorsed by Formula One Group. Formula 1 and its characters are trademarks of their owners. Non-commercial; contact [email protected] for takedown.
- Technique
- text characters, instrument displays
- Shape
- 16:9 (cropped for other screens)
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
Notes
Formula 1 live timing splits each lap into three sectors and those into mini-sectors, and lights up the fastest time through each. The centreline comes from the TUMFTM racetrack database, which took it from OpenStreetMap. The lap and its sector times are made up, though they add up.
Sources
- TUMFTM, racetrack-database.
- OpenStreetMap contributors, OpenStreetMap.
- Circuit de Spa-Francorchamps.
Source code
wallpapers/racing-spa-minisectors/design.py, 134 lines
"""Spa-Francorchamps as a dot-matrix live-timing map: 24 mini-sectors in three sectors, Eau Rouge to Les Combes lit as the fastest run."""
import bisect
import math
import numpy as np
from numpy.typing import NDArray
from walldye import ACCENT, UI, UI_ALT, UI_HI, Canvas, Paint, design
from walldye.geom import Polyline
from walldye.pixel import glyphs, grid_runs
PITCH, DOT = 8, 4 # dot lattice pitch (the braille pitch of 8x16 glyphs at px=2) and dot size
# Sector ends in centreline metres (S1 to the entry of Les Combes, S2 just past Stavelot), and
# the mini-sectors in each.
SECTORS = ((2370, 8), (5050, 9), (7000, 7))
FAST = (3, 4, 5, 6, 7) # Eau Rouge and Raidillon, then the Kemmel straight up to the S1 split
BOX = (1240, 540, 1150, 720) # where the track is fitted: centre x, centre y, width, height
# (text, centreline metres, dx, dy): a label at that track point plus an offset in px
LABELS = (("LA SOURCE", 385, -104, -6), ("EAU ROUGE", 1080, -112, -20), ("POUHON", 3950, -90, 30))
# A completed lap; S1 is the best sector, marked with ACCENT as a timing screen marks a best.
TIMING = (
"SPA 7.004 KM",
"─" * 16,
"",
"S1 31.412",
"S2 46.905",
"S3 28.337",
"",
"", # the mini-sector blocks
"",
"LAP 1:46.654",
)
X0, Y0, LH = 180, 300, 32 # timing column origin and line height (8x16 glyphs at px=2)
TRACK, BEST, SPLIT = 1, 2, 3 # dot roles, indices into the dot palette
def starts() -> list[tuple[float, int]]:
"""(centreline metres, sector index) at the start of every mini-sector, in lap order."""
out, lo = [], 0
for si, (hi, n) in enumerate(SECTORS):
out += [(lo + (hi - lo) * j / n, si) for j in range(n)]
lo = hi
return out
STARTS = starts()
def fit(spa: list[list[float]]) -> NDArray[np.float64]:
"""The centreline in broadcast orientation (north to the left: La Source bottom-left,
Kemmel climbing to the right), fitted into BOX, in dot units."""
pts = -np.asarray(spa, dtype=np.float64)[:, ::-1] # (east, north) to screen (-north, -east)
lo, hi = pts.min(axis=0), pts.max(axis=0)
cx, cy, bw, bh = BOX
k = min(bw / (hi[0] - lo[0]), bh / (hi[1] - lo[1])) / PITCH
return (pts - (lo + hi) / 2) * k + np.array([cx, cy]) / PITCH
def raster(line: Polyline) -> list[tuple[tuple[int, int], float]]:
"""Ordered 8-connected, one-dot-thin lattice cells along a closed line, each with the arc
length where the line enters it."""
ds = np.arange(int(line.length * 4)) / 4
xy: list[list[float]] = np.rint(line.at(ds)).tolist()
seq: list[tuple[tuple[int, int], float]] = []
for (x, y), d in zip(xy, ds.tolist(), strict=True):
c = (int(x), int(y))
if not seq or seq[-1][0] != c:
seq.append((c, d))
changed = True
while changed: # drop corner cells whose neighbours already touch diagonally
changed = False
out: list[tuple[tuple[int, int], float]] = []
for k, cell in enumerate(seq):
a = out[-1][0] if out else seq[-1][0]
b = seq[(k + 1) % len(seq)][0]
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) <= 1 and a != b:
changed = True
continue
out.append(cell)
seq = out
return seq
def tint(col: int, row: int, ch: str) -> Paint:
"""Timing column colours: labels quiet, times bright, the best sector lit."""
if row == 1:
return UI
if row in (3, 4, 5, 9) and col < 5:
return UI_ALT
return ACCENT if row == 3 else UI_HI
@design()
def draw(s: Canvas) -> None:
# TUMFTM racetrack-database Spa.csv centreline, x east / y north in metres, simplified at
# 1.5 m; it starts on the start/finish line.
spa: list[list[float]] = s.data("spa.json")
# Unsmoothed: smoothing would shift the start/finish vertex and the metre scale.
line = Polyline(fit(spa), closed=True)
per_m = line.length / SECTORS[-1][0] # dot units per centreline metre
bounds = [m for m, _ in STARTS]
cells = raster(line)
dots = np.zeros((s.h // PITCH, s.w // PITCH), dtype=np.int64)
for (x, y), d in cells:
mini = bisect.bisect_right(bounds, d / per_m) - 1
dots[y, x] = BEST if mini in FAST else TRACK
for m, (metres, si) in enumerate(STARTS):
bd = metres * per_m
for (x, y), d in cells:
if min(abs(d - bd), line.length - abs(d - bd)) < 1.6:
dots[y, x] = 0 # a gap in the dots at every mini-sector boundary
if m == 0 or STARTS[m - 1][1] != si: # start/finish and sector splits get a tick
p, n = line.at(bd), line.tangent(bd).perp()
# snapped to one of 8 directions, so the tick is a straight row of dots
a = math.radians(45 * round(math.degrees(math.atan2(n.y, n.x)) / 45))
step = (round(math.cos(a)), round(math.sin(a)))
w = 3 if m == 0 else 2
for k in range(-w, w + 1):
dots[round(p.y) + k * step[1], round(p.x) + k * step[0]] = SPLIT
# Dots sit on every other cell of a DOT grid, so no two ever merge into one run.
grid = np.zeros((s.h // DOT, s.w // DOT), dtype=np.int64)
grid[::2, ::2] = dots
grid_runs(s, grid, (None, UI_ALT, ACCENT, UI_HI), DOT, (DOT / 2, DOT / 2))
for text, metres, dx, dy in LABELS:
p = line.at(metres * per_m) * PITCH
glyphs(s, text, UI, at=(round(p.x + dx), round(p.y + dy)), font="8x16", px=1)
glyphs(s, TIMING, tint, at=(X0, Y0), font="8x16", px=2)
with s.buckets((UI_HI, ACCENT), "fill") as blocks: # one block per mini-sector
for m, (_, si) in enumerate(STARTS):
blocks[int(m in FAST)].rect(X0 + m * 10 + si * 8, Y0 + 7 * LH + 8, 6, 16)"""Spa-Francorchamps as a dot-matrix live-timing map: 24 mini-sectors in three sectors, Eau Rouge to Les Combes lit as the fastest run."""
import bisect
import math
import numpy as np
from numpy.typing import NDArray
from walldye import ACCENT, UI, UI_ALT, UI_HI, Canvas, Paint, design
from walldye.geom import Polyline
from walldye.pixel import glyphs, grid_runs
PITCH, DOT = 8, 4 # dot lattice pitch (the braille pitch of 8x16 glyphs at px=2) and dot size
# Sector ends in centreline metres (S1 to the entry of Les Combes, S2 just past Stavelot), and
# the mini-sectors in each.
SECTORS = ((2370, 8), (5050, 9), (7000, 7))
FAST = (3, 4, 5, 6, 7) # Eau Rouge and Raidillon, then the Kemmel straight up to the S1 split
BOX = (1240, 540, 1150, 720) # where the track is fitted: centre x, centre y, width, height
# (text, centreline metres, dx, dy): a label at that track point plus an offset in px
LABELS = (("LA SOURCE", 385, -104, -6), ("EAU ROUGE", 1080, -112, -20), ("POUHON", 3950, -90, 30))
# A completed lap; S1 is the best sector, marked with ACCENT as a timing screen marks a best.
TIMING = (
"SPA 7.004 KM",
"─" * 16,
"",
"S1 31.412",
"S2 46.905",
"S3 28.337",
"",
"", # the mini-sector blocks
"",
"LAP 1:46.654",
)
X0, Y0, LH = 180, 300, 32 # timing column origin and line height (8x16 glyphs at px=2)
TRACK, BEST, SPLIT = 1, 2, 3 # dot roles, indices into the dot palette
def starts() -> list[tuple[float, int]]:
"""(centreline metres, sector index) at the start of every mini-sector, in lap order."""
out, lo = [], 0
for si, (hi, n) in enumerate(SECTORS):
out += [(lo + (hi - lo) * j / n, si) for j in range(n)]
lo = hi
return out
STARTS = starts()
def fit(spa: list[list[float]]) -> NDArray[np.float64]:
"""The centreline in broadcast orientation (north to the left: La Source bottom-left,
Kemmel climbing to the right), fitted into BOX, in dot units."""
pts = -np.asarray(spa, dtype=np.float64)[:, ::-1] # (east, north) to screen (-north, -east)
lo, hi = pts.min(axis=0), pts.max(axis=0)
cx, cy, bw, bh = BOX
k = min(bw / (hi[0] - lo[0]), bh / (hi[1] - lo[1])) / PITCH
return (pts - (lo + hi) / 2) * k + np.array([cx, cy]) / PITCH
def raster(line: Polyline) -> list[tuple[tuple[int, int], float]]:
"""Ordered 8-connected, one-dot-thin lattice cells along a closed line, each with the arc
length where the line enters it."""
ds = np.arange(int(line.length * 4)) / 4
xy: list[list[float]] = np.rint(line.at(ds)).tolist()
seq: list[tuple[tuple[int, int], float]] = []
for (x, y), d in zip(xy, ds.tolist(), strict=True):
c = (int(x), int(y))
if not seq or seq[-1][0] != c:
seq.append((c, d))
changed = True
while changed: # drop corner cells whose neighbours already touch diagonally
changed = False
out: list[tuple[tuple[int, int], float]] = []
for k, cell in enumerate(seq):
a = out[-1][0] if out else seq[-1][0]
b = seq[(k + 1) % len(seq)][0]
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) <= 1 and a != b:
changed = True
continue
out.append(cell)
seq = out
return seq
def tint(col: int, row: int, ch: str) -> Paint:
"""Timing column colours: labels quiet, times bright, the best sector lit."""
if row == 1:
return UI
if row in (3, 4, 5, 9) and col < 5:
return UI_ALT
return ACCENT if row == 3 else UI_HI
@design()
def draw(s: Canvas) -> None:
# TUMFTM racetrack-database Spa.csv centreline, x east / y north in metres, simplified at
# 1.5 m; it starts on the start/finish line.
spa: list[list[float]] = s.data("spa.json")
# Unsmoothed: smoothing would shift the start/finish vertex and the metre scale.
line = Polyline(fit(spa), closed=True)
per_m = line.length / SECTORS[-1][0] # dot units per centreline metre
bounds = [m for m, _ in STARTS]
cells = raster(line)
dots = np.zeros((s.h // PITCH, s.w // PITCH), dtype=np.int64)
for (x, y), d in cells:
mini = bisect.bisect_right(bounds, d / per_m) - 1
dots[y, x] = BEST if mini in FAST else TRACK
for m, (metres, si) in enumerate(STARTS):
bd = metres * per_m
for (x, y), d in cells:
if min(abs(d - bd), line.length - abs(d - bd)) < 1.6:
dots[y, x] = 0 # a gap in the dots at every mini-sector boundary
if m == 0 or STARTS[m - 1][1] != si: # start/finish and sector splits get a tick
p, n = line.at(bd), line.tangent(bd).perp()
# snapped to one of 8 directions, so the tick is a straight row of dots
a = math.radians(45 * round(math.degrees(math.atan2(n.y, n.x)) / 45))
step = (round(math.cos(a)), round(math.sin(a)))
w = 3 if m == 0 else 2
for k in range(-w, w + 1):
dots[round(p.y) + k * step[1], round(p.x) + k * step[0]] = SPLIT
# Dots sit on every other cell of a DOT grid, so no two ever merge into one run.
grid = np.zeros((s.h // DOT, s.w // DOT), dtype=np.int64)
grid[::2, ::2] = dots
grid_runs(s, grid, (None, UI_ALT, ACCENT, UI_HI), DOT, (DOT / 2, DOT / 2))
for text, metres, dx, dy in LABELS:
p = line.at(metres * per_m) * PITCH
glyphs(s, text, UI, at=(round(p.x + dx), round(p.y + dy)), font="8x16", px=1)
glyphs(s, TIMING, tint, at=(X0, Y0), font="8x16", px=2)
with s.buckets((UI_HI, ACCENT), "fill") as blocks: # one block per mini-sector
for m, (_, si) in enumerate(STARTS):
blocks[int(m in FAST)].rect(X0 + m * 10 + si * 8, Y0 + 7 * LH + 8, 6, 16)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render racing-spa-minisectors --theme fireproof -o racing-spa-minisectors-fireproof-16x9.svg