Street light
NewA line-drawn street with an LED street light: change its height, tilt and optic and watch where the light lands.
street-light/street-light.tsx- Road 0%
- Pavements 0%
- Behind pole 0%
- Gardens 0%
- Houses 0%
- Sky 0%
- BUG
- –
- Road avg
- 0 lx
- Road peak
- 0 lx
- Bedroom window
- 0 lx
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Explaining a physical trade-off by letting the reader move it: how pole height, tilt and optic change where the light lands.
- A long-form article or report on lighting, light pollution or street design, where one interactive figure carries the argument.
- Teaching the vocabulary of a field (BUG rating, lux, isolux lines) next to the picture it describes, so every number has a place.
- A figure that should read as a drawing first: hairlines, captions and small tracked capitals, in a dark or a light scheme.
When not to use
- Real lighting design or compliance work. The model is a teaching one; use photometric software fed with the luminaire’s IES file.
- Reporting measured data. Use a chart or a table of the readings, not a simulation that looks like one.
- A dense dashboard tile. The figure needs about 600 px to read; a small stat card or a sparkline fits better.
- Pages without room for a canvas to repaint (long lists, many instances): one figure per page, or a static image for the rest.
You can add this component with the shadcn CLI or by hand:
Into components/street-light of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/street-light.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { StreetLight } from './street-light';export function LightingFigure() { return <StreetLight defaultOptic="wide" defaultTilt={10} defaultLuxMap autoplay={false} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A technical figure of a terraced street at night. Pick a narrow, wide or asymmetric optic, drag the pole’s height and the head’s tilt, and the light on the road, the gardens, the houses and the sky is computed as you go, with the BUG rating, road lux and the lux on a bedroom window. A lux map turns the light into false colour with isolux lines.
- The street is a small 3D model in metres, drawn in a parallel projection: every solid shows its top, its front and its right end, painted far to near on a canvas at the device’s pixel ratio, with 1 px hairlines over near-black faces.
- The light is a point source with a candela distribution per optic, all three at the same flux. The wide and asymmetric lenses turn with the head’s tilt; the narrow one is aimed back at the kerb and follows the tilt by a third, so its spot stays on the road by the pole. Every lit face gets a texture of illuminance, E = I · cos θ / d², 0.25 m a texel; the ground shares one texture, laid on each horizontal face through the plane’s affine map.
- Where the light goes is found by tracing a fixed grid of directions from the lens: each lands on the road, a pavement, the ground behind the pole, a garden, the houses or the sky, and the shares fill the stacked bar. The BUG rating reads the same directions: light behind the head, above the horizon, and at high angles.
- With the lux map on, the road, the near pavement and the facade take a colour on a logarithmic scale from 0.5 to 100 lx, with isolux lines (1, 2, 5, 10, 20, 50, 100, 200 lx) traced by marching squares over the same grids; the other faces keep a softer warm light, and the haze around the head stays. The two views cross-fade.
- A change of optic cross-fades the candela tables over a second, so the light, the bar and every number slide to their new values together; the picked LED module lifts out of its tray at the front of the plinth.
- Dragging on the figure sets the height (up and down) and the tilt (left and right). While on screen and untouched, a demo pointer switches optics, tilts the head, turns the lux map on and drags the height; any real input hands control to the reader.
- Painting runs only while something moves. Its colours follow the page’s color-scheme, or the colorScheme prop: the panel uses light-dark() tokens, and the canvas repaints when a probe in the stylesheet changes colour.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOptic | 'narrow' | 'wide' | 'asymmetric' | 'asymmetric' | The optic in the head when it mounts. |
defaultHeight | number | 8 | The pole’s height in metres, from 5 to 12. |
defaultTilt | number | 0 | The head’s tilt towards the houses, in degrees, from 0 to 25. |
defaultLuxMap | boolean | false | Starts with the lux map on. |
onChange | (settings: { optic, height, tilt, luxMap }) => void | — | Called with the settings whenever the reader or the demo changes them. |
autoplay | boolean | true | While on screen and untouched, a demo pointer plays with the controls. It never runs under reduced motion. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s. Without it, the component follows the page’s color-scheme. |
className | string | — | A class for the root, to size or place it. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useRef, useState, type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { OPTICS, WINDOW, ZONES, lanternAt, ratingText, report, type Lantern, type Mix, type Optic, type Rating,// Copyright (c) 2026 Bruno Delorence. MIT License./* * The light model of the street light: a point source with an intensity distribution, in metres * and lux. It is a teaching model, not a lighting design tool, but every number it gives comes * from the same physics: illuminance E = I(direction) · cos(incidence) / distance². * * - The world: x runs along the street, y across it (towards the houses), z up. The pole stands on * the near pavement; its arm reaches over the road, so "forward" for the lantern is +y. * - An optic is a relative intensity f(local direction), in the lantern's own frame (nadir is * straight down). Tilt turns that frame about the street axis, towards the houses. * - Every optic emits the same flux (LUMENS). Its candela table is f scaled so that the integral * of I over the sphere is that flux, so a tighter optic is brighter in its beam. * - Where the flux goes (road, pavements, behind the pole, gardens, houses, sky) is found by * tracing a fixed grid of directions, each weighted by the solid angle it stands for. * - The BUG rating (backlight, uplight, glare) is read from the same directions: the share of the * flux behind the lantern, above the horizon, and at high angles in front, each banded 0 to 3 * (4 for the worst glare). */export type Vec = { x: number; y: number; z: number };/** The three optics, in the order of their cards. */export const OPTICS = ['narrow', 'wide', 'asymmetric'] as const;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The diorama, as geometry: a street on a rounded plinth, in metres (x along the street, y across * it towards the houses, z up). It only describes shapes: faces with a fill and maybe light on * them, and hairlines. renderer.ts projects and paints them. * * - Every solid is drawn with its three faces the camera can see: the top, the front (facing -y) * and the right end (facing +x). Objects are painted far to near, so a nearer face hides what * is behind it; ground-level shapes go first, in their own layer. * - Faces marked `lit` receive the street light: horizontal ones share one illuminance texture of * the whole ground, vertical ones (the facade, the fronts of walls) get a texture of their own. */import { BANDS, HOUSES, POLE, ROAD_LEVEL, lanternAt, type Optic, type Vec } from './photometry';export type Tone = 'line' | 'faint' | 'bright' | 'none';export type Fill = | 'plinth' | 'side' | 'road' | 'pave' | 'kerb' | 'grass'// Copyright (c) 2026 Bruno Delorence. MIT License./* * Paints the diorama on a canvas at the device's pixel ratio: fills, light, hairlines, then the * glow of the lantern. Nothing here holds React state; street-light.tsx calls paint() with the * current lantern and blends whenever something moved. * * - The light is a texture of illuminance. The ground has one, 0.25 m a texel, laid on every * horizontal face through the affine map of the ground plane; each lit wall has its own. A texel * is either warm light (its strength eased from the lux) or a colour of the lux map, on a * logarithmic scale from 0.5 to 100 lx; the two cross-fade. * - Isolux lines (1, 2, 5, 10, 20, 50, 100, 200 lx) come from marching squares over the same * grids, faint in the warm view, white on the map. * - Every face is opaque and painted far to near, so the light on a face hides with it. * - The glow: a soft cone from the lens to the pool it lights (an ellipse from the moments of the * ground's illuminance), a halo on the lens, and a haze in the sky above the roofs, as strong * as the share of the flux that goes up or hits the houses. */import { BANDS, illuminance, type Lantern, type Vec } from './photometry';import { FLOOR, PAVE, PILLAR,// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the eye of the * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(/* Street light (street-light.tsx): a near-black technical figure, "Fig 1", over a panel of controls set in small, tracked monospace capitals. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets (data-color-scheme). The canvas cannot read tokens, so a hidden probe turns white or black with the scheme and the script repaints when its colour changes. Sizes are the figure's own at about 1080 px wide: 13 px labels tracked 0.1em, 14 to 15 px values. Below 600 px the sliders and the optic cards stack. */.root { /* The stage, and the panel's hairlines. */ --surface: #111; --line: #2a2a2a; --edge: #363636; --edge-strong: #d8d8d8; --track: #3d3d3d; /* Words: values, labels, and the dim ones. */ --ink: #f0f0f0; --muted: #777; --soft: #a8a8a8; /* Where the light goes: one colour per zone. */ --road: #e4c28b;- Height and tilt are native range inputs with their values spoken in metres and degrees; the optics are a radio group, and the lux map is a toggle button with aria-pressed.
- Keys 1, 2 and 3 pick an optic and L turns the lux map on or off, anywhere in the component.
- The figure is an image with a label that states the pole’s height, the tilt and the optic; the drag on it only repeats what the sliders do.
- The summary line (rating, road share, sky share, window lux) is read out politely once the settings rest, not at every step of a drag.
- Under reduced motion the lamp is on from the start, changes are instant and the demo never runs. The demo pointer is hidden from screen readers.