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.
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 number | dropped, rather than reaching the path as L NaN NaN, which renders as nothing at all |
| Readings out of order | sorted 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 day | the 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 moved | drawn through the middle, rather than divided by a range of zero |
| A mark on the box's own edge | bars 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 saw | readings are spaced along the clock, so a fortnight of silence is not the same width as a busy Tuesday |
| Fewer than two readings | not 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-queryimport '@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
| Prop | Type | Description |
|---|---|---|
name | string | What is drawn, as a short noun. Names the image for a screen reader and titles a failure. |
series | Series | The reading. |
say | Blank & { low, high } | What it says where it cannot draw, and the two words under the box. |
format | (value: number) => string | The figure, written. Required. |
when | (at: number) => string | The moment, written. The indexer's UTC day by default. |
height | number | Taller where the drawing is what the screen is for. 140 by default. |
Split
| Prop | Type | Description |
|---|---|---|
name | string | What is divided, as a short noun. |
of | Slices | The reading. |
say | Blank | What it says where it cannot draw. |
format | (value: number) => string | A part's own figure, written. Required. |
percent | (of: number) => string | The share a part holds. figures().rate is one. |
Spark
| Prop | Type | Description |
|---|---|---|
of | readonly Point[] | The readings. Fewer than two draws nothing. |
name | string | What the line is of, for a screen reader. |
colour | `var(--${string})` | A token name. The text colour by default. |
Point and Slice
| Field | Type | Description |
|---|---|---|
Point.at | number | When the reading was taken, in seconds. |
Point.value | number | What was read. On a candle this is the close. |
Candle.open .high .low | number | The rest of the session. |
Slice.name | string | What this part is. |
Slice.value | number | How 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.