Skip to main contentSkip to navigation

A series over time, the same series at the width of a cell, and a whole with its parts — each drawn from a reading, so a read that failed is not a flat line at zero.

Three drawings, made out of SVG and arithmetic. Chart is a series over time, Spark is that series at the width of a table cell, and Split is a whole and how it divides.

No charting library sits behind them. A path is an array turned into a string, and every package this could import brings a second layout engine, a second colour system and a second opinion about what a missing reading is.

Loading...

The reading

Chart takes a Series and Split takes Slices. Both are the three constructors the rest of this directory uses:

type Series =
  | { at: 'reading' }
  | { at: 'read'; shape: 'line' | 'bars'; points: readonly Point[] }
  | { at: 'read'; shape: 'candle'; points: readonly Candle[] }
  | { at: 'failed'; why: string }

type Slices =
  | { at: 'reading' }
  | { at: 'failed'; why: string }
  | { at: 'read'; of: readonly Slice[]; whole?: number }

A read that failed and a read that came back with nothing are one value in any client that keeps an array and a flag. Drawn as a flat line at zero, the first of them is a claim that the price did not move.

Both hold their room, and it is worth saying how much. A box that collapses to the height of one sentence carries everything under it up the page, so a screen settles at a different depth on the read that worked and the read that did not.

Chart holds its whole box in all four states — the drawing's height and the row of figures under it — because the height is a number the caller states and every state can fill it. A read that failed says so standing in that room rather than in place of it.

Split holds its track and no more. The legend under the track is one row per part in the caller's own words, so three parts and six are different heights by construction, and a reservation claiming otherwise would be a number nothing could keep. What is held is the bar: in every state there is a track, so an unanswered read reads as a division that is missing rather than one that is absent.

Three shapes, and why they are not one

A line carries its value in POSITION, a bar in LENGTH, and a candle in the two ends of a body. That is why shape picks between drawings rather than styling one:

  • A bar is measured from zero, because a bar's length is its value. Measured from anywhere else it is a length that means nothing.
  • A line is measured from what was read, because its height is a comparison with the readings beside it. Forced down to zero, a day spent between 0.000109 and 0.000111 flattens into a grey rule.
  • A candle spans its own high and low, which fall outside its ends. Scaled to the closes alone, its wicks leave the box.

The range the box covers is written under it rather than drawn as an axis. Axes would be four more figures claiming a precision an indexer has not earned.

The shape rides on the answer rather than on the component, because a caller does not choose a drawing — it says what it read.

What the arithmetic protects against

A reading that is not a numberdropped, rather than reaching the path as L NaN NaN, which renders as nothing at all
Readings out of ordersorted by time, on a copy — a line through rows in the order they were returned is a shape nothing happened in
An indexer's impossible daythe vertical scale is chosen for the middle 98% of the readings; every reading is still drawn, and one outside the box is clamped to its edge
A series that never moveddrawn through the middle, rather than divided by a range of zero
A mark on the box's own edgebars and candles are placed across a box inset by half a mark, so the newest session is a whole body rather than a clipped half
Days nobody sawreadings are spaced along the clock, so a fortnight of silence is not the same width as a busy Tuesday
Fewer than two readingsnot drawn at all — one close is a dot on an axis it alone decided

Reading it without a mouse

The drawn chart is one tab stop with a cursor on it. Left and right step through the readings, Home and End reach the ends, and the date and figure are announced on every step. It does not wrap: a series is time, and arriving at the newest reading to land on the oldest is a jump that reads as a series looping.

A stop per reading would put a year of days in the tab order of a page with a button under it.

Split, and the stated whole

whole is what the parts are parts OF, where that is more than they come to. A quorum of a million with 402,000 behind it is a bar two fifths full; the same 402,000 with no whole stated is a bar entirely one colour. Unsaid, the parts are the whole.

A stated whole smaller than its parts is not a whole, so the larger of the two wins and a quorum already passed draws a full bar rather than one overflowing its own track. Every part at zero draws no bar at all, because a bar of no length is a picture a reader takes for a reading.

One bar rather than a ring: a reader compares parts of a ring by angle, which is the measurement people read worst, and along a bar the same parts are lengths on a shared baseline. It is also the shape that survives the 300-pixel column a vote result actually stands in.

Colour

Slice.paint and Spark's colour take a token name, typed as `var(--${string})`. A literal cannot retune between one ground and the other, so a bar that reads on black is invisible on white — the type is what makes that unwritable rather than a rule somebody has to remember.

Parts that carry no meaning of their own take --chart-1 through --chart-5, in order. That ramp is the one place this token set allows hue for its own sake, because on a categorical drawing distinguishable hues ARE the encoding and parts told apart only by lightness are unreadable to most people at a glance. What the five resolve to is the surface's decision, so a Lux surface and a white-label one draw the same component in their own ramps.

Parts whose meaning IS the hue — a vote for and a vote against — say so with paint.good and paint.bad, which are the only two hues the brand spends outside the achromatic ladder.

Figures and words

Neither drawing formats a number and neither reaches for a catalog.

format is required rather than defaulted, because a number is not a string until a locale has been applied to it and a component that picks one has pinned every reader to it. figures(reading).precise is the usual answer; count and brief are there too. Split's percent takes figures(reading).rate — a share is not a movement and takes no sign.

say is the three sentences a drawing says where it has nothing to draw, plus the two words under the box:

interface Blank {
  reading: ReactNode
  failed: ReactNode
  nothing: ReactNode
}

nothing is required for the reason a table's empty is: only the caller knows whether no series means the indexer has ingested none or this asset has never traded, and those are different facts about a venue.

when is the only one with a default, and the default is locale-free: the indexer's own UTC day, because a date shifted into the reader's zone names the day before for half the world. A caller whose readings are the reader's own days hands in figures(reading).day.

Installation

npm install @luxfi/ui @hanzo/gui @hanzo/ui @hanzo/design @tanstack/react-query
import '@luxfi/ui/tokens.css'
import '@hanzo/design/styles.css'
import { AppProvider } from '@luxfi/ui/provider'
import { Chart, Spark, Split, figures } from '@luxfi/ui/read'

AppProvider goes once at the root of the app and mounts the gui engine and a query client. The two sheets are where the names resolve: colour from @luxfi/ui/tokens.css, spacing, elevation and measure from @hanzo/design.

Usage

const en = figures({ locale: 'en-US', currency: 'USD' })

const say = {
  reading: t('reading'),
  failed: t('notRead'),
  nothing: t('venue.noSeries'),
  low: t('low'),
  high: t('high'),
}

const q = useQuery({ queryKey: ['closes', token], queryFn: readCloses })

const series: Series = q.isPending
  ? { at: 'reading' }
  : q.isError
    ? { at: 'failed', why: String(q.error) }
    : { at: 'read', shape: 'line', points: q.data }

<Chart name={t('closingPrice')} series={series} say={say} format={en.precise} />

The caller is what turns a query into a reading, and that mapping is the only place the distinction can be lost.

<Split
  name={t('votesCast')}
  of={{ at: 'read', of: [
    { name: t('for'), value: forVotes, paint: paint.good },
    { name: t('against'), value: againstVotes, paint: paint.bad },
    { name: t('abstain'), value: abstainVotes },
  ] }}
  say={say}
  format={en.count}
  percent={en.rate}
/>
<Spark of={closes} name={t('sparkline', { token })} />

Props

Chart

PropTypeDescription
namestringWhat is drawn, as a short noun. Names the image for a screen reader and titles a failure.
seriesSeriesThe reading.
sayBlank & { low, high }What it says where it cannot draw, and the two words under the box.
format(value: number) => stringThe figure, written. Required.
when(at: number) => stringThe moment, written. The indexer's UTC day by default.
heightnumberTaller where the drawing is what the screen is for. 140 by default.

Split

PropTypeDescription
namestringWhat is divided, as a short noun.
ofSlicesThe reading.
sayBlankWhat it says where it cannot draw.
format(value: number) => stringA part's own figure, written. Required.
percent(of: number) => stringThe share a part holds. figures().rate is one.

Spark

PropTypeDescription
ofreadonly Point[]The readings. Fewer than two draws nothing.
namestringWhat the line is of, for a screen reader.
colour`var(--${string})`A token name. The text colour by default.

Point and Slice

FieldTypeDescription
Point.atnumberWhen the reading was taken, in seconds.
Point.valuenumberWhat was read. On a candle this is the close.
Candle.open .high .lownumberThe rest of the session.
Slice.namestringWhat this part is.
Slice.valuenumberHow much of the whole it holds.
Slice.paint`var(--${string})`What it is drawn in. The ramp in order by default.

What it does not do

It does not read anything, and it does not decide what a reading is: the caller maps a query onto Series or Slices, and a caller that reports a failure as { at: 'read', shape: 'line', points: [] } writes exactly the fabrication the type exists to prevent, which nothing in the component can catch.

It draws no axis, no grid and no legend beyond the names Split writes under its own bar. It does not poll, refresh, zoom, pan or carry a period selector — the control that picks a span belongs to the screen, and pressing it hands down a new Series. It says nothing about when a reading was taken either: a series an hour stale is drawn exactly like a live one.

Spark draws a line and nothing else. A movement line is read down a column against the rows either side of it, and forty of them carrying their own figures and readouts would be forty charts on one screen.