Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartRenkoData, ChartRenkoPoint } from "./chart-renko.contract"

export type RenkoDirection = "up" | "down"

/** what a pinned brick hands back to the consumer */
export interface RenkoSelection {
  /** position in the whole series, 0-based */
  index: number
  direction: RenkoDirection

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-renko.json

Prompt

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.

On This Page