Renko
A four-state renko chart: one brick per box of price on a zero-anchored grid, time compressed out of the axis, hollow-up and solid-down bricks, two-box reversals marked, a derived-or-given box size, and a refusal when the box is too small to draw.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-renko.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRenko" card in plain SVG with zod.
Recharts has no renko primitive and this is not a cartesian series at all — the
x axis is a brick ordinal, not a scale — so the walk, the grid and the label
fitting are small pure functions beside the schema. No new dependency.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
currency?: string; // ISO 4217, regex-checked
unit?: string; // suffix used when there is no currency
observation?: { one: string; many: string };
prices: { at: string; price: number }[] }.
- `at` is a LABEL and is never parsed as a date. Renko has no time axis, so
nothing downstream needs to know how long a gap between two labels was, and a
component that parsed them would start inventing a scale it does not have.
Labels may repeat: two prints inside one minute is a normal feed.
- Order is the only thing trusted about `prices`: walk them exactly as given,
never sort. A brick is a statement about what happened NEXT.
- superRefine: a ready chart needs at least two observations, because a brick is
a move and one price is not a move. Guard every access — sibling refinements
all run, so a ragged payload has to produce an issue rather than a TypeError
thrown out of safeParse.
- Props = z.infer of the schema plus boxSize (default: derived), maxBricks
(default 220, clamped 8-2000), height (default 260, clamped 160-520), onRetry,
onBrickSelect, className and the div's native props, forwardRef to the card.
- Export the maths so it is testable and so this prompt can describe it:
niceStep(), decimalsFor(), autoBoxSize(), buildRenkoSeries(), renkoWindow(),
columnMetrics(), and the RenkoBrick / RenkoSeries / RenkoResult types.
Behavior
- THE WALK is the component. A brick is worth exactly one box. Continuing in the
current direction needs one box past the last close; TURNING NEEDS TWO,
because a reversal brick starts from the far edge of the current brick rather
than from its close. That asymmetry is the whole reason renko filters noise.
For each observation, keep emitting bricks until none of the four conditions
hold — one observation can be worth many bricks.
- THE GRID IS ANCHORED AT ZERO. Every brick edge is an integer multiple of the
box, so levels stay integers, no floating-point error accumulates along the
walk, and two charts of the same instrument at the same box line up exactly.
The first level is floor(firstPrice / box) and the first brick can go either
way.
- TIME IS NOT AN AXIS, and the consequences are stated rather than hidden: the
observation that printed the most bricks at once and the longest stretch that
printed none come back with the series, and the card says both ("131 sessions
became 47 bricks; 2024-01-05 printed 2 of them at once, and the longest
stretch with no brick ran 9 sessions").
- BOX SIZE is the only parameter. When it is not given, derive it from TOTAL
VARIATION (the sum of every absolute step) divided by a target brick count,
snapped to the 1 / 2 / 2.5 / 5 x 10^n ladder — not from the high-to-low range,
because two decades of chop inside a 20-point band and a single 20-point ramp
have the same range and want boxes an order of magnitude apart. Total
variation over the box is also a strict upper bound on the brick count, so the
target is a ceiling the real count comes in under. Say on the card that the
box was derived; a box nobody chose is a box nobody should quote. A boxSize
that is not positive and finite is treated as ABSENT rather than clamped —
there is no defensible minimum spanning a 0.0001 pip and a 250-dollar bitcoin
brick.
- REFUSAL IS A FIRST-CLASS ANSWER. The walk carries a hard budget (5,000
bricks). Past it the chart stops and says which box would work, computed from
the same total variation: "a box of 0.25 would print more than 5,000 bricks;
the prices move 1,973 in total, so a box near 10 prints about 240". The
alternative is a locked tab followed by a wall nobody can read.
- NOTHING IS CLIPPED OR QUIETLY CAPPED. Over maxBricks, or when the card is too
narrow to give every brick a 3px column, the window is the LATEST bricks —
never a sample, because thinning the middle cuts the staircase that carries
the reading — and a note gives the numbers ("drawing the last 20 of 47; the 27
before them are off the left edge, because this card is only wide enough for
110 columns"). Every tally, reversal count and distance-to-next-brick still
counts the whole series.
- SIX BRANCHES OF ONE CARD, four of them the contract's: loading (a
deterministic skeleton staircase, aria-hidden, plus one sr-only role=status
line), empty (a valid contract with no prices), error (Try again only when
onRetry was passed), ready — plus "refused" (the box cannot be drawn) and
"no brick yet" (real prices that have not covered one box: not empty, not an
error, and it states how far price must move each way for the first brick).
Rows carrying NaN or Infinity are dropped and COUNTED under the chart: an
unreadable observation is not a flat one, and pretending it was would print
bricks nothing paid for.
- INTERACTION. One transparent hit rect over the whole wall finds the column
itself, converting through its own client box so it stays correct when the SVG
scales below its minimum width; a per-brick rect would be sub-pixel on a dense
chart, and a hollow brick does not hit-test its own middle. Click pins, and
the pin survives the pointer leaving. Keyboard: the wall is one
role="listbox" with tabIndex 0 and aria-activedescendant, so there is ONE tab
stop rather than one per brick. Tab lands on the NEWEST brick, which is what a
renko chart is read for. Left/Right step a brick, Down/Up jump to the next and
previous REVERSAL (the finding on this chart), Home/End to the ends,
Enter/Space pin, Escape releases. preventDefault fires only for keys that were
handled, so Tab still leaves the chart. A polite live region speaks only for
what nothing else announces: the pin, the release, and "no later reversal in
the drawn bricks".
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, nothing time-derived at render, no
Math.random anywhere — the same props always draw the same wall.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
the frame and the price grid, muted for the skeleton, muted-foreground for
axis text and the reversal ticks, ring for the focus outline, var(--chart-2)
for up bricks and var(--foreground) for down bricks.
- COLOUR NEVER CARRIES THE DIRECTION ALONE. Fill does: hollow = up, solid =
down, the same encoding as this library's candlestick, so it survives colour
blindness, greyscale print and a projector that eats the palette. Only
--chart-2 clears 3:1 against --card in both themes on the default palette,
which is why the up brick's outline is the only chart token here. A reversal
gets a tick under its column as well as its two-box offset. The legend is a
reminder, never the only route: every brick names its direction in its
accessible label, the readout line and the data table.
- GEOMETRY. The y scale is the brick grid itself: ticks are grid levels, one per
~44px, anchored on multiples of the step so a gridline keeps its price when
the window scrolls. Every brick is exactly one level tall. Columns come from
columnMetrics(): pitch = plotWidth / count, gap = 18% of the pitch clamped to
0.5-3px, and a brick never grows past ~1.8x its own height — a nine-brick
chart gets air rather than nine slabs. The left gutter is sized from the
series extremes, NOT from the visible ticks: the ticks depend on the window,
the window on the plot width and the plot width on the gutter, and that circle
has to be cut somewhere honest.
- The LAST OBSERVED PRICE is the one thing on this chart that is not on the
grid, so it is a dashed rule with a direct label, and the y domain is widened
to hold it — it can float up to a box away from the last close, and a rule
drawn outside its own frame would be a lie. The distance to the next brick and
to a reversal are in the summary line, because that is what a reader acts on.
- Only two end labels under the plot, elided with the full string in an SVG
<title>: with no time axis there is nothing to interpolate between them, and a
row of dates would imply there is.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence the focusable wall. Use role="group"
labelled by the heading and described by the summary line, which states the
finding in words. Every option is a DIRECT child of the listbox; a wrapping
<g> would sit between them as a generic and break the ownership the role
needs. Below the plot an sr-only WRAPPER DIV (never sr-only on the table
itself: CSS width is only a lower bound for a table box, so width:1px does not
hold one back and a narrow viewport picks up real horizontal scroll) holds
brick number, direction, open, close, turn and printed-at for every drawn
brick. The visible readout line is aria-hidden, because the focused wall
already announces the active brick and a live region would say it twice.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop.
Customization levers
- boxSize is the picture. Halve it and the wall roughly doubles, catching turns
earlier and paying in whipsaws; double it and the same six months become a
dozen bricks. Wire it to a control and keep the label ("box 25 · derived")
visible, because two renko charts are only comparable at the same box. An ATR
box is the usual next step: compute it upstream, pass the number, and the
chart neither knows nor cares.
- maxBricks + height set the density. 40-60 bricks at height 200 makes a
dashboard tile; 200 at height 420 makes a wall worth scanning. MIN_COLUMN
trades legibility against how much history fits; MAX_ASPECT decides how fat a
sparse chart's bricks get.
- Palette: re-point UP_INK / DOWN_INK to a house pair, but keep the hollow /
solid split or the chart stops working in greyscale. Keying the ink off
something else entirely (a regime flag, a strategy's position) is a two-line
change in the brick loop — the direction is still carried by the fill.
- Sub-blocks are independent: drop the legend, the readout or the compression
note for a tile; drop the reversal ticks and the turns still read from the
two-box offset. The sr-only table is not optional — it is the keyboard and
reader path to numbers the picture only implies.
- Interaction: onBrickSelect carries the whole brick, so wire it to a trade
list, a linked candlestick or a drill-down on the observation that printed it.
The hit rect is where a double-click, a drag-to-measure or a context menu goes
without touching the geometry.
- Extending the walk: an "unconfirmed" ghost brick for the box in progress, or a
wick-less high/low rule, both live in buildRenkoSeries() and reach the picture
through the same brick list. Whatever is added, keep the two rules that make
it a renko chart — one box per brick, two boxes to turn.Concepts
- Price-driven columns — the x axis is a brick ordinal, not a scale. A column exists because price covered a box, so a three-week range costs almost nothing and a gap costs seven columns. This is the trade renko makes: you give up “when” to get an evenly spaced picture of “how far”, and the card pays the debt back in words — the busiest observation and the longest quiet stretch are printed under the chart.
- Two boxes to turn — a continuation brick needs one box past the last close; a reversal needs two, because it starts from the far edge of the brick it is turning away from. That single asymmetry is the whole noise filter, it is why a whipsaw draws as an alternating comb of single bricks, and it is why halving the box more than doubles the turns.
- Zero-anchored grid — every brick edge is an integer multiple of the box, so the walk carries integer levels rather than accumulated floats, the price axis is the brick grid itself, and two charts of the same instrument at the same box line up edge for edge instead of drifting apart by a rounding error per brick.
- Derived box, quoted out loud — with no box given, one is derived from total variation over a target count and snapped to a readable ladder, and the card keeps saying which box it drew. Total variation, not range: chop and a ramp can share a range and want boxes an order of magnitude apart, and total variation over the box is also a hard upper bound on how many bricks can exist.
- Refusal over a frozen tab — a box far too small for the series is a misconfiguration, not a picture. The walk carries a budget, and past it the chart refuses and names a box that works rather than building thousands of rects nobody can read.
- Newest-first window — when more bricks exist than fit, the drawn set is the latest ones, never a sample: a renko chart is read from its right-hand end and thinning the middle would cut the staircase. What is off the left edge is counted, and every figure on the card still counts the whole series.
- The unfinished brick — the last observed price is the only mark not on the grid, drawn as a dashed rule with its distance to the next brick in both directions. A renko wall is always slightly behind the market, and saying by how much is what stops the last brick from being read as the current price.
Event Timeline
A four-state timeline of instants — one lane per track, a shape per kind, marks that crowd into one counted cluster instead of overlapping, brush-or-keyboard zoom into any window, and a now marker from an injected instant.
Word Tree
A four-state word tree that branches raw phrases out from a root term — ribbon width is that continuation's share, every word is a focusable button that re-roots the tree, and the breadcrumb walks back.