Net-new data-visualisation family on the in-house, zero-dependency $libs/plots engine (scales · ticks · curves · shapes, all original). Compositional <Chart> frame (context-shared scales, responsive, cursor-following tooltip) + primitives Grid/XAxis/YAxis/Line/Area/Bar/Scatter/Tooltip/Legend + presets Line/Area/Bar/ Scatter/Sparkline (line + bars) + standalone Pie/Radar/BarList/BarSegment, each with a themed hover tooltip. Themed via --chart-* recipe tokens + global colour tokens; locale via eidos.format/langs; morfo `chart` (scope: eidos, role=img). Point scales span edge-to-edge. Per-chart demos under web/routes/uix/components. Roadmap + MUI X Charts comparison in components/chart/IMPROVEMENTS.md. Note: recipes/base.ts + generated/base.css are shared files; this commit unavoidably carries the (inert) metrics recipe block that co-lives there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>active-uix
parent
a0da99aa15
commit
c19a72b620
@ -0,0 +1,62 @@
|
||||
# plots — charting math engine
|
||||
|
||||
Pure, **dependency-free** primitives for building charts: scales (data →
|
||||
pixels), shape generators (points → SVG paths), tick generation and small
|
||||
statistics helpers. No DOM, no framework, no state — just functions. The UIX
|
||||
chart components consume this; the engine knows nothing about them.
|
||||
|
||||
Like the other `libs/` modules it depends on **nobody**: every algorithm here
|
||||
(linear/band/time scales, 1-2-5 "nice" ticks, monotone cubic interpolation,
|
||||
arc geometry) is implemented from the underlying mathematics, not ported from
|
||||
any external library.
|
||||
|
||||
## Why a lib (and not an art)
|
||||
|
||||
The math is pure functions with no runtime state and no CSS emission — the exact
|
||||
contract of `libs/`. Reactivity belongs in the consuming soma layer (a provider
|
||||
recomputes a fresh scale when its inputs change), mirroring how `chronos/engine`
|
||||
keeps pure layout modules separate from its reactive `state.svelte.ts`.
|
||||
|
||||
## Surface
|
||||
|
||||
| Module | Exports | Purpose |
|
||||
| --------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `scale-linear` | `scaleLinear` | Continuous data → pixels (value axes) |
|
||||
| `scale-band` | `scaleBand`, `scalePoint` | Categories → bands (bars) / points (line·scatter) |
|
||||
| `scale-time` | `scaleTime` | Epoch-ms → pixels with calendar-aware ticks |
|
||||
| `ticks` | `ticks`, `tickStep`, `nice` | "Nice" round tick values for an axis |
|
||||
| `array` | `extent`, `min`, `max`, `sum`, `range`, `bisect*`, `bisector` | Stats + nearest-datum lookup (hover) |
|
||||
| `shape-line` | `line`, `area` | Polyline / filled-band SVG paths |
|
||||
| `shape-arc` | `arc`, `pie` | Pie / donut / gauge geometry + slice layout |
|
||||
| `shape-stack` | `stack` | Stacked baselines (stacked bar / area) |
|
||||
| `curve` | `curveLinear`, `curveMonotoneX`, `curveStep`, `curve(name)` | Point-connection strategies |
|
||||
| `path` | `fmt`, `coord` | Coordinate formatting for custom shapes |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Pixel space.** Scales map data → pixels; shape generators take points that
|
||||
are *already* in pixels. This decouples shapes from scales (compositional).
|
||||
- **Immutable scales.** `scaleLinear(config)` returns a callable whose
|
||||
`domain`/`range` are frozen and whose `.nice()` returns a *new* scale — derive
|
||||
a fresh scale on input change, never mutate in place (suits Svelte runes).
|
||||
- **Angles** (arc/pie): `0` points up (12 o'clock), increasing **clockwise**.
|
||||
- **Time** works on epoch milliseconds; a `DateValue` consumer converts at its
|
||||
edge so this module carries no date dependency.
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import { line, scaleLinear } from '$libs/plots'
|
||||
|
||||
const x = scaleLinear({ domain: [0, 5], range: [0, 300] })
|
||||
const y = scaleLinear({ domain: [0, 100], range: [150, 0] }) // y axis is inverted
|
||||
const data = [10, 40, 30, 80, 60, 90]
|
||||
|
||||
const d = line(data.map((value, i) => ({ x: x(i), y: y(value) })))
|
||||
// → "M0,135L60,90L120,105L180,30L240,60L300,15"
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
`npx vitest run src/libs/plots` — 36 cases across `array` / `ticks` / `scale` /
|
||||
`shape`, asserting the math against known values.
|
||||
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Array and statistics helpers for the plots engine. Values in, scalars or
|
||||
* small arrays out. NaN and nullish entries are skipped so a gap in the data
|
||||
* never poisons an extent or a sum.
|
||||
*/
|
||||
|
||||
/** Smallest finite value, or `undefined` when there are none. */
|
||||
export function min(values: Iterable<number>): number | undefined {
|
||||
let result: number | undefined
|
||||
for (const value of values) {
|
||||
if (value == null || Number.isNaN(value)) continue
|
||||
if (result === undefined || value < result) result = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Largest finite value, or `undefined` when there are none. */
|
||||
export function max(values: Iterable<number>): number | undefined {
|
||||
let result: number | undefined
|
||||
for (const value of values) {
|
||||
if (value == null || Number.isNaN(value)) continue
|
||||
if (result === undefined || value > result) result = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Minimum and maximum in a single pass; `undefined` when no finite values. */
|
||||
export function extent(values: Iterable<number>): [number, number] | undefined {
|
||||
let lo: number | undefined
|
||||
let hi: number | undefined
|
||||
for (const value of values) {
|
||||
if (value == null || Number.isNaN(value)) continue
|
||||
if (lo === undefined) {
|
||||
lo = value
|
||||
hi = value
|
||||
} else {
|
||||
if (value < lo) lo = value
|
||||
if (value > (hi as number)) hi = value
|
||||
}
|
||||
}
|
||||
return lo === undefined ? undefined : [lo, hi as number]
|
||||
}
|
||||
|
||||
/** Sum of the finite values (empty / all-NaN sums to 0). */
|
||||
export function sum(values: Iterable<number>): number {
|
||||
let total = 0
|
||||
for (const value of values) {
|
||||
if (value == null || Number.isNaN(value)) continue
|
||||
total += value
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric sequence `[start, start + step, …)` stopping before `stop`. Called
|
||||
* with one argument it counts `[0, n)`, matching the half-open convention of
|
||||
* array indices.
|
||||
*/
|
||||
export function range(start: number, stop?: number, step = 1): number[] {
|
||||
let from = start
|
||||
let to = stop
|
||||
if (to === undefined) {
|
||||
to = start
|
||||
from = 0
|
||||
}
|
||||
const count = step === 0 ? 0 : Math.max(0, Math.ceil((to - from) / step))
|
||||
const result = new Array<number>(count)
|
||||
for (let i = 0; i < count; i++) result[i] = from + i * step
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Index where `x` would insert into the ascending `array` to keep it sorted,
|
||||
* placing `x` AFTER equal entries (the upper bound).
|
||||
*/
|
||||
export function bisectRight(array: ArrayLike<number>, x: number, lo = 0, hi = array.length): number {
|
||||
let low = lo
|
||||
let high = hi
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1
|
||||
if (x < array[mid]) high = mid
|
||||
else low = mid + 1
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
/** As {@link bisectRight}, but placing `x` BEFORE equal entries (lower bound). */
|
||||
export function bisectLeft(array: ArrayLike<number>, x: number, lo = 0, hi = array.length): number {
|
||||
let low = lo
|
||||
let high = hi
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1
|
||||
if (array[mid] < x) low = mid + 1
|
||||
else high = mid
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
/**
|
||||
* A bisector over arrays of arbitrary `T`, comparing by `accessor`. `center`
|
||||
* returns the index of the entry nearest `x` — the lookup a chart needs to map
|
||||
* a cursor position to the closest datum.
|
||||
*/
|
||||
export function bisector<T>(accessor: (datum: T) => number) {
|
||||
function right(array: ArrayLike<T>, x: number, lo = 0, hi = array.length): number {
|
||||
let low = lo
|
||||
let high = hi
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1
|
||||
if (x < accessor(array[mid])) high = mid
|
||||
else low = mid + 1
|
||||
}
|
||||
return low
|
||||
}
|
||||
function left(array: ArrayLike<T>, x: number, lo = 0, hi = array.length): number {
|
||||
let low = lo
|
||||
let high = hi
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1
|
||||
if (accessor(array[mid]) < x) low = mid + 1
|
||||
else high = mid
|
||||
}
|
||||
return low
|
||||
}
|
||||
function center(array: ArrayLike<T>, x: number, lo = 0, hi = array.length): number {
|
||||
if (array.length === 0) return -1
|
||||
const i = right(array, x, lo, hi)
|
||||
if (i <= lo) return lo
|
||||
if (i >= hi) return hi - 1
|
||||
// Pick whichever neighbour is closer in the accessor's units.
|
||||
return x - accessor(array[i - 1]) <= accessor(array[i]) - x ? i - 1 : i
|
||||
}
|
||||
return { left, right, center }
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Curves: strategies for connecting a sequence of points into SVG commands.
|
||||
*
|
||||
* Each curve follows the {@link Curve} contract — it emits the commands linking
|
||||
* `points[1..n-1]` assuming the pen already rests at `points[0]`. That lets the
|
||||
* line and area generators reuse one curve for both edges of a shape.
|
||||
*/
|
||||
|
||||
import type { Curve, Point } from './types'
|
||||
import { coord } from './path'
|
||||
|
||||
/** Straight segments between consecutive points. */
|
||||
export const curveLinear: Curve = (points) => {
|
||||
let d = ''
|
||||
for (let i = 1; i < points.length; i++) d += `L${coord(points[i].x, points[i].y)}`
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* Step curve: holds each value, then jumps to the next at the midpoint between
|
||||
* the two x positions — the classic staircase used for discrete/stepwise data.
|
||||
*/
|
||||
export const curveStep: Curve = (points) => {
|
||||
let d = ''
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1]
|
||||
const b = points[i]
|
||||
const midX = (a.x + b.x) / 2
|
||||
d += `L${coord(midX, a.y)}L${coord(midX, b.y)}L${coord(b.x, b.y)}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotone cubic curve: a smooth line that never overshoots the data. We fit a
|
||||
* cubic Hermite spline whose tangent at each point is chosen so the
|
||||
* interpolation stays monotone between samples — so a run of increasing values
|
||||
* produces no spurious dip, and a local max stays the max.
|
||||
*
|
||||
* The tangent `m[i]` is the weighted harmonic mean of the neighbouring secant
|
||||
* slopes, forced to 0 wherever the slope changes sign (a local extremum). Each
|
||||
* segment then becomes a Bézier whose control points sit one third of the way
|
||||
* along, lifted by the endpoint tangents.
|
||||
*
|
||||
* Requires x to be strictly increasing (which a sorted series guarantees).
|
||||
*/
|
||||
export const curveMonotoneX: Curve = (points) => {
|
||||
const n = points.length
|
||||
if (n < 2) return ''
|
||||
if (n === 2) return `L${coord(points[1].x, points[1].y)}`
|
||||
|
||||
const dx = new Array<number>(n - 1)
|
||||
const slope = new Array<number>(n - 1)
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const h = points[i + 1].x - points[i].x
|
||||
dx[i] = h
|
||||
slope[i] = h === 0 ? 0 : (points[i + 1].y - points[i].y) / h
|
||||
}
|
||||
|
||||
const tangent = new Array<number>(n)
|
||||
tangent[0] = slope[0]
|
||||
tangent[n - 1] = slope[n - 2]
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (slope[i - 1] * slope[i] <= 0) {
|
||||
// Sign change (or a flat) ⇒ this point is a local extremum: flat tangent.
|
||||
tangent[i] = 0
|
||||
} else {
|
||||
const w1 = 2 * dx[i] + dx[i - 1]
|
||||
const w2 = dx[i] + 2 * dx[i - 1]
|
||||
tangent[i] = (w1 + w2) / (w1 / slope[i - 1] + w2 / slope[i])
|
||||
}
|
||||
}
|
||||
|
||||
let d = ''
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const p0 = points[i]
|
||||
const p1 = points[i + 1]
|
||||
const h = dx[i]
|
||||
const c1x = p0.x + h / 3
|
||||
const c1y = p0.y + (tangent[i] * h) / 3
|
||||
const c2x = p1.x - h / 3
|
||||
const c2y = p1.y - (tangent[i + 1] * h) / 3
|
||||
d += `C${coord(c1x, c1y)},${coord(c2x, c2y)},${coord(p1.x, p1.y)}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const CURVES = { linear: curveLinear, step: curveStep, monotone: curveMonotoneX } as const
|
||||
|
||||
/** Named curve presets, for selecting a curve by string (e.g. from a prop). */
|
||||
export type CurveName = keyof typeof CURVES
|
||||
|
||||
/** Resolve a curve by name; defaults to linear for an unknown name. */
|
||||
export function curve(name: CurveName): Curve {
|
||||
return CURVES[name] ?? curveLinear
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* # plots — charting math engine
|
||||
*
|
||||
* Pure, dependency-free primitives for building charts: scales (data → pixels),
|
||||
* shape generators (points → SVG paths), tick generation and small statistics
|
||||
* helpers. No DOM, no framework, no state — just functions. The UIX chart
|
||||
* components consume this; the engine knows nothing about them.
|
||||
*
|
||||
* Import the whole surface from `$libs/plots`, or a single module when you only
|
||||
* need one slice (e.g. `$libs/plots/scale-linear`).
|
||||
*/
|
||||
|
||||
export type { AreaPoint, Curve, Point } from './types'
|
||||
|
||||
export { bisectLeft, bisectRight, bisector, extent, max, min, range, sum } from './array'
|
||||
export { nice, tickStep, ticks } from './ticks'
|
||||
|
||||
export { scaleLinear } from './scale-linear'
|
||||
export type { LinearScale, LinearScaleConfig } from './scale-linear'
|
||||
export { scaleBand, scalePoint } from './scale-band'
|
||||
export type { BandScale, BandScaleConfig, PointScale, PointScaleConfig } from './scale-band'
|
||||
export { scaleTime } from './scale-time'
|
||||
export type { TimeScale, TimeScaleConfig } from './scale-time'
|
||||
|
||||
export { curve, curveLinear, curveMonotoneX, curveStep } from './curve'
|
||||
export type { CurveName } from './curve'
|
||||
export { coord, fmt } from './path'
|
||||
export { area, line } from './shape-line'
|
||||
export type { AreaOptions, LineOptions } from './shape-line'
|
||||
export { arc, pie } from './shape-arc'
|
||||
export type { ArcOptions, PieOptions, PieSlice } from './shape-arc'
|
||||
export { stack } from './shape-stack'
|
||||
export type { StackOptions, StackPoint, StackSeries } from './shape-stack'
|
||||
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Path-building utilities. SVG accepts comma- or space-separated numbers, so
|
||||
* every shape concatenates formatted coordinates directly — no DOM, no
|
||||
* `CanvasPathMethods`, just strings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Round a coordinate to 3 decimals and collapse `-0` to `0`. Three decimals is
|
||||
* sub-pixel precise for any realistic chart and keeps emitted paths compact and
|
||||
* deterministic (stable test output).
|
||||
*/
|
||||
export function fmt(value: number): string {
|
||||
const rounded = Math.round(value * 1000) / 1000
|
||||
return Object.is(rounded, -0) ? '0' : String(rounded)
|
||||
}
|
||||
|
||||
/** Format a single point as the SVG coordinate pair `"x,y"`. */
|
||||
export function coord(x: number, y: number): string {
|
||||
return `${fmt(x)},${fmt(y)}`
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Linear scale: a straight-line map from a numeric data `domain` to a pixel
|
||||
* `range`. This is the workhorse behind value axes.
|
||||
*
|
||||
* The scale is **immutable** — `scaleLinear(config)` returns a callable whose
|
||||
* `domain`/`range` are frozen and whose `nice()` returns a *new* scale. That
|
||||
* suits a reactive (recompute-on-change) consumer better than the mutable
|
||||
* getter/setter style: derive a fresh scale when inputs change, never patch one
|
||||
* in place.
|
||||
*/
|
||||
|
||||
import { nice as niceDomain, ticks as generateTicks } from './ticks'
|
||||
|
||||
export interface LinearScale {
|
||||
/** Map a data value to its pixel position. */
|
||||
(value: number): number
|
||||
/** Map a pixel position back to a data value. */
|
||||
invert(pixel: number): number
|
||||
/** Nice tick values across the domain (about `count` of them). */
|
||||
ticks(count?: number): number[]
|
||||
/** A new scale whose domain is rounded outward to nice numbers. */
|
||||
nice(count?: number): LinearScale
|
||||
readonly domain: readonly [number, number]
|
||||
readonly range: readonly [number, number]
|
||||
}
|
||||
|
||||
export interface LinearScaleConfig {
|
||||
/** `[min, max]` of the data. */
|
||||
domain: readonly [number, number]
|
||||
/** `[start, end]` in pixels; may be descending (e.g. `[height, 0]` for y). */
|
||||
range: readonly [number, number]
|
||||
/** Clamp outputs to the range (and inverts to the domain). Default `false`. */
|
||||
clamp?: boolean
|
||||
}
|
||||
|
||||
const clamp01 = (t: number): number => (t < 0 ? 0 : t > 1 ? 1 : t)
|
||||
|
||||
export function scaleLinear(config: LinearScaleConfig): LinearScale {
|
||||
const [d0, d1] = config.domain
|
||||
const [r0, r1] = config.range
|
||||
const clamp = config.clamp ?? false
|
||||
const domainSpan = d1 - d0
|
||||
const rangeSpan = r1 - r0
|
||||
|
||||
const scale = (value: number): number => {
|
||||
const t = domainSpan === 0 ? 0 : (value - d0) / domainSpan
|
||||
return r0 + (clamp ? clamp01(t) : t) * rangeSpan
|
||||
}
|
||||
|
||||
return Object.assign(scale, {
|
||||
invert: (pixel: number): number => {
|
||||
const t = rangeSpan === 0 ? 0 : (pixel - r0) / rangeSpan
|
||||
return d0 + (clamp ? clamp01(t) : t) * domainSpan
|
||||
},
|
||||
ticks: (count = 10): number[] => generateTicks(d0, d1, count),
|
||||
nice: (count = 10): LinearScale =>
|
||||
scaleLinear({ ...config, domain: niceDomain(d0, d1, count) }),
|
||||
domain: Object.freeze<[number, number]>([d0, d1]),
|
||||
range: Object.freeze<[number, number]>([r0, r1])
|
||||
}) as LinearScale
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Time scale: a linear map over **epoch milliseconds** whose ticks land on
|
||||
* human time boundaries (every 15 min, every hour, every month, …) instead of
|
||||
* arbitrary millisecond counts.
|
||||
*
|
||||
* The engine stays date-model-agnostic: the domain is plain numbers (ms since
|
||||
* the epoch) and ticks come back as numbers. A chart that works in the
|
||||
* framework's `DateValue` converts to/from ms at its edge — keeping this module
|
||||
* pure and free of any calendar dependency. Tick boundaries are computed with
|
||||
* the platform `Date` in local time.
|
||||
*/
|
||||
|
||||
import { bisector } from './array'
|
||||
import { tickStep, ticks as numericTicks } from './ticks'
|
||||
|
||||
export interface TimeScale {
|
||||
/** Map an epoch-ms instant to its pixel position. */
|
||||
(ms: number): number
|
||||
/** Map a pixel position back to epoch ms. */
|
||||
invert(pixel: number): number
|
||||
/** Tick instants (epoch ms) on nice time boundaries. */
|
||||
ticks(count?: number): number[]
|
||||
/** A new scale whose domain is rounded outward to a nice time boundary. */
|
||||
nice(count?: number): TimeScale
|
||||
readonly domain: readonly [number, number]
|
||||
readonly range: readonly [number, number]
|
||||
}
|
||||
|
||||
export interface TimeScaleConfig {
|
||||
/** `[start, end]` as epoch milliseconds. */
|
||||
domain: readonly [number, number]
|
||||
/** `[start, end]` in pixels. */
|
||||
range: readonly [number, number]
|
||||
clamp?: boolean
|
||||
}
|
||||
|
||||
type TimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
const WEEK = 7 * DAY
|
||||
const MONTH = 30 * DAY // nominal length, only used to pick an interval
|
||||
const YEAR = 365 * DAY
|
||||
|
||||
interface TimeInterval {
|
||||
unit: TimeUnit
|
||||
step: number
|
||||
/** Nominal length in ms — the sort key for choosing the closest interval. */
|
||||
ms: number
|
||||
}
|
||||
|
||||
// Candidate intervals in ascending duration. Steps are the ones that read
|
||||
// cleanly on an axis (… 15s, 30s, 1m, 5m … 6h, 12h, 1d, 1 week, 1 month …).
|
||||
const INTERVALS: readonly TimeInterval[] = [
|
||||
{ unit: 'second', step: 1, ms: SECOND },
|
||||
{ unit: 'second', step: 5, ms: 5 * SECOND },
|
||||
{ unit: 'second', step: 15, ms: 15 * SECOND },
|
||||
{ unit: 'second', step: 30, ms: 30 * SECOND },
|
||||
{ unit: 'minute', step: 1, ms: MINUTE },
|
||||
{ unit: 'minute', step: 5, ms: 5 * MINUTE },
|
||||
{ unit: 'minute', step: 15, ms: 15 * MINUTE },
|
||||
{ unit: 'minute', step: 30, ms: 30 * MINUTE },
|
||||
{ unit: 'hour', step: 1, ms: HOUR },
|
||||
{ unit: 'hour', step: 3, ms: 3 * HOUR },
|
||||
{ unit: 'hour', step: 6, ms: 6 * HOUR },
|
||||
{ unit: 'hour', step: 12, ms: 12 * HOUR },
|
||||
{ unit: 'day', step: 1, ms: DAY },
|
||||
{ unit: 'day', step: 2, ms: 2 * DAY },
|
||||
{ unit: 'week', step: 1, ms: WEEK },
|
||||
{ unit: 'month', step: 1, ms: MONTH },
|
||||
{ unit: 'month', step: 3, ms: 3 * MONTH },
|
||||
{ unit: 'year', step: 1, ms: YEAR }
|
||||
]
|
||||
|
||||
const intervalBisect = bisector((interval: TimeInterval) => interval.ms)
|
||||
|
||||
/** Snap a date down to the start of its `unit`/`step` slot (local time). */
|
||||
function floorTime(date: Date, unit: TimeUnit, step: number): void {
|
||||
switch (unit) {
|
||||
case 'second':
|
||||
date.setMilliseconds(0)
|
||||
date.setSeconds(Math.floor(date.getSeconds() / step) * step)
|
||||
break
|
||||
case 'minute':
|
||||
date.setSeconds(0, 0)
|
||||
date.setMinutes(Math.floor(date.getMinutes() / step) * step)
|
||||
break
|
||||
case 'hour':
|
||||
date.setMinutes(0, 0, 0)
|
||||
date.setHours(Math.floor(date.getHours() / step) * step)
|
||||
break
|
||||
case 'day':
|
||||
date.setHours(0, 0, 0, 0)
|
||||
break
|
||||
case 'week':
|
||||
date.setHours(0, 0, 0, 0)
|
||||
date.setDate(date.getDate() - date.getDay()) // back to Sunday
|
||||
break
|
||||
case 'month':
|
||||
date.setHours(0, 0, 0, 0)
|
||||
date.setDate(1)
|
||||
date.setMonth(Math.floor(date.getMonth() / step) * step)
|
||||
break
|
||||
case 'year':
|
||||
date.setHours(0, 0, 0, 0)
|
||||
date.setMonth(0, 1)
|
||||
date.setFullYear(Math.floor(date.getFullYear() / step) * step)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** Advance a date by one `unit`/`step` slot (local time, calendar-aware). */
|
||||
function offsetTime(date: Date, unit: TimeUnit, step: number): void {
|
||||
switch (unit) {
|
||||
case 'second':
|
||||
date.setSeconds(date.getSeconds() + step)
|
||||
break
|
||||
case 'minute':
|
||||
date.setMinutes(date.getMinutes() + step)
|
||||
break
|
||||
case 'hour':
|
||||
date.setHours(date.getHours() + step)
|
||||
break
|
||||
case 'day':
|
||||
date.setDate(date.getDate() + step)
|
||||
break
|
||||
case 'week':
|
||||
date.setDate(date.getDate() + step * 7)
|
||||
break
|
||||
case 'month':
|
||||
date.setMonth(date.getMonth() + step)
|
||||
break
|
||||
case 'year':
|
||||
date.setFullYear(date.getFullYear() + step)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interval whose nominal length is closest to `target` ms. Returns `null`
|
||||
* below one second, where the caller falls back to plain numeric ticks. For
|
||||
* multi-year targets the year step is widened to a nice number.
|
||||
*/
|
||||
function chooseInterval(target: number, lo: number, hi: number, count: number): TimeInterval | null {
|
||||
if (!(target > 0)) return null
|
||||
const i = intervalBisect.right(INTERVALS, target)
|
||||
if (i === 0) return null // sub-second
|
||||
if (i >= INTERVALS.length) {
|
||||
const years = Math.max(1, tickStep(new Date(lo).getFullYear(), new Date(hi).getFullYear(), count))
|
||||
return { unit: 'year', step: years, ms: years * YEAR }
|
||||
}
|
||||
const lower = INTERVALS[i - 1]
|
||||
const upper = INTERVALS[i]
|
||||
// Closest on a ratio (log) scale, matching how durations compare.
|
||||
return target / lower.ms < upper.ms / target ? lower : upper
|
||||
}
|
||||
|
||||
const clamp01 = (t: number): number => (t < 0 ? 0 : t > 1 ? 1 : t)
|
||||
|
||||
export function scaleTime(config: TimeScaleConfig): TimeScale {
|
||||
const [d0, d1] = config.domain
|
||||
const [r0, r1] = config.range
|
||||
const clamp = config.clamp ?? false
|
||||
const domainSpan = d1 - d0
|
||||
const rangeSpan = r1 - r0
|
||||
|
||||
const scale = (ms: number): number => {
|
||||
const t = domainSpan === 0 ? 0 : (ms - d0) / domainSpan
|
||||
return r0 + (clamp ? clamp01(t) : t) * rangeSpan
|
||||
}
|
||||
|
||||
return Object.assign(scale, {
|
||||
invert: (pixel: number): number => {
|
||||
const t = rangeSpan === 0 ? 0 : (pixel - r0) / rangeSpan
|
||||
return d0 + (clamp ? clamp01(t) : t) * domainSpan
|
||||
},
|
||||
|
||||
ticks: (count = 10): number[] => {
|
||||
const lo = Math.min(d0, d1)
|
||||
const hi = Math.max(d0, d1)
|
||||
if (lo === hi) return [lo]
|
||||
const interval = chooseInterval((hi - lo) / count, lo, hi, count)
|
||||
if (!interval) return numericTicks(lo, hi, count) // sub-second
|
||||
|
||||
const cursor = new Date(lo)
|
||||
floorTime(cursor, interval.unit, interval.step)
|
||||
if (cursor.getTime() < lo) offsetTime(cursor, interval.unit, interval.step)
|
||||
|
||||
const result: number[] = []
|
||||
for (let guard = 0; cursor.getTime() <= hi && guard < 100000; guard++) {
|
||||
result.push(cursor.getTime())
|
||||
offsetTime(cursor, interval.unit, interval.step)
|
||||
}
|
||||
return d0 <= d1 ? result : result.reverse()
|
||||
},
|
||||
|
||||
nice: (count = 10): TimeScale => {
|
||||
const lo = Math.min(d0, d1)
|
||||
const hi = Math.max(d0, d1)
|
||||
const interval = chooseInterval((hi - lo) / count, lo, hi, count)
|
||||
if (!interval) return scaleTime(config)
|
||||
|
||||
const start = new Date(lo)
|
||||
floorTime(start, interval.unit, interval.step)
|
||||
const end = new Date(hi)
|
||||
floorTime(end, interval.unit, interval.step)
|
||||
if (end.getTime() < hi) offsetTime(end, interval.unit, interval.step)
|
||||
|
||||
const niceLo = start.getTime()
|
||||
const niceHi = end.getTime()
|
||||
const domain: [number, number] = d0 <= d1 ? [niceLo, niceHi] : [niceHi, niceLo]
|
||||
return scaleTime({ ...config, domain })
|
||||
},
|
||||
|
||||
domain: Object.freeze<[number, number]>([d0, d1]),
|
||||
range: Object.freeze<[number, number]>([r0, r1])
|
||||
}) as TimeScale
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Line and area path generators. Inputs are already in pixel space (the caller
|
||||
* maps data through scales first), so these are pure string builders.
|
||||
*/
|
||||
|
||||
import type { AreaPoint, Curve, Point } from './types'
|
||||
import { coord } from './path'
|
||||
import { curveLinear } from './curve'
|
||||
|
||||
export interface LineOptions {
|
||||
/** How to connect points. Default: straight segments. */
|
||||
curve?: Curve
|
||||
}
|
||||
|
||||
/**
|
||||
* An open polyline through `points`. Returns an empty string for no points (a
|
||||
* valid, invisible `d`), so binding it to a `<path>` is always safe.
|
||||
*/
|
||||
export function line(points: readonly Point[], options: LineOptions = {}): string {
|
||||
if (points.length === 0) return ''
|
||||
const link = options.curve ?? curveLinear
|
||||
return `M${coord(points[0].x, points[0].y)}${link(points)}`
|
||||
}
|
||||
|
||||
export interface AreaOptions {
|
||||
curve?: Curve
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled band between the `y0` (baseline) and `y1` (value) edges at each `x`.
|
||||
* The outline runs along the top edge left→right, then back along the baseline
|
||||
* right→left, and closes — so the same curve shapes both edges.
|
||||
*/
|
||||
export function area(points: readonly AreaPoint[], options: AreaOptions = {}): string {
|
||||
if (points.length === 0) return ''
|
||||
const link = options.curve ?? curveLinear
|
||||
const tops: Point[] = points.map((p) => ({ x: p.x, y: p.y1 }))
|
||||
const baseline: Point[] = points.map((p) => ({ x: p.x, y: p.y0 })).reverse()
|
||||
return (
|
||||
`M${coord(tops[0].x, tops[0].y)}${link(tops)}` +
|
||||
`L${coord(baseline[0].x, baseline[0].y)}${link(baseline)}Z`
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Stack layout: turn parallel series into stacked `[y0, y1]` baselines, the
|
||||
* basis for stacked bar and area charts. Output is in **data units** — the
|
||||
* caller maps `y0`/`y1` through a scale.
|
||||
*/
|
||||
|
||||
export interface StackPoint {
|
||||
/** Index of the row in the input data. */
|
||||
index: number
|
||||
/** Lower edge (cumulative total below this series) in data units. */
|
||||
y0: number
|
||||
/** Upper edge (`y0` + this series' value) in data units. */
|
||||
y1: number
|
||||
/** This series' raw value at the row. */
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface StackSeries {
|
||||
/** The data key this series stacks. */
|
||||
key: string
|
||||
/** Index of the key in the input `keys`. */
|
||||
index: number
|
||||
points: StackPoint[]
|
||||
}
|
||||
|
||||
export interface StackOptions {
|
||||
/**
|
||||
* `'none'` stacks cumulatively from 0 (default). `'expand'` normalises each
|
||||
* row to sum to 1 — the basis for 100%-stacked charts.
|
||||
*/
|
||||
offset?: 'none' | 'expand'
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack `keys` across `data` rows. For each row the keys accumulate in order:
|
||||
* the first key sits on 0, each later key on top of the previous total.
|
||||
*/
|
||||
export function stack<T extends Record<string, number>>(
|
||||
data: readonly T[],
|
||||
keys: readonly string[],
|
||||
options: StackOptions = {}
|
||||
): StackSeries[] {
|
||||
const expand = options.offset === 'expand'
|
||||
const series: StackSeries[] = keys.map((key, index) => ({ key, index, points: [] }))
|
||||
|
||||
for (let row = 0; row < data.length; row++) {
|
||||
const total = expand
|
||||
? keys.reduce((acc, key) => acc + (Number(data[row][key]) || 0), 0)
|
||||
: 0
|
||||
let baseline = 0
|
||||
for (let k = 0; k < keys.length; k++) {
|
||||
const raw = Number(data[row][keys[k]]) || 0
|
||||
const value = expand && total > 0 ? raw / total : raw
|
||||
series[k].points.push({ index: row, y0: baseline, y1: baseline + value, value: raw })
|
||||
baseline += value
|
||||
}
|
||||
}
|
||||
return series
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bisectLeft, bisectRight, bisector, extent, max, min, range, sum } from '../array'
|
||||
|
||||
describe('array helpers', () => {
|
||||
it('min/max/extent skip NaN and nullish', () => {
|
||||
const values = [3, NaN, 1, 4, 1, 5]
|
||||
expect(min(values)).toBe(1)
|
||||
expect(max(values)).toBe(5)
|
||||
expect(extent(values)).toEqual([1, 5])
|
||||
})
|
||||
|
||||
it('extent/min of empty is undefined', () => {
|
||||
expect(extent([])).toBeUndefined()
|
||||
expect(min([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sum ignores NaN', () => {
|
||||
expect(sum([1, 2, NaN, 3])).toBe(6)
|
||||
})
|
||||
|
||||
it('range is half-open', () => {
|
||||
expect(range(3)).toEqual([0, 1, 2])
|
||||
expect(range(1, 4)).toEqual([1, 2, 3])
|
||||
expect(range(0, 1, 0.25)).toEqual([0, 0.25, 0.5, 0.75])
|
||||
})
|
||||
|
||||
it('bisectLeft/Right bracket equal entries', () => {
|
||||
const sorted = [1, 2, 2, 3]
|
||||
expect(bisectLeft(sorted, 2)).toBe(1)
|
||||
expect(bisectRight(sorted, 2)).toBe(3)
|
||||
})
|
||||
|
||||
it('bisector.center finds the nearest datum', () => {
|
||||
const data = [{ t: 0 }, { t: 10 }, { t: 20 }]
|
||||
const by = bisector((d: { t: number }) => d.t)
|
||||
expect(by.center(data, 3)).toBe(0)
|
||||
expect(by.center(data, 7)).toBe(1)
|
||||
expect(by.center(data, 16)).toBe(2)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { scaleBand, scalePoint } from '../scale-band'
|
||||
import { scaleLinear } from '../scale-linear'
|
||||
import { scaleTime } from '../scale-time'
|
||||
|
||||
describe('scaleLinear', () => {
|
||||
it('maps the domain onto the range', () => {
|
||||
const s = scaleLinear({ domain: [0, 100], range: [0, 200] })
|
||||
expect(s(0)).toBe(0)
|
||||
expect(s(50)).toBe(100)
|
||||
expect(s(100)).toBe(200)
|
||||
})
|
||||
|
||||
it('inverts pixels back to data', () => {
|
||||
const s = scaleLinear({ domain: [0, 100], range: [0, 200] })
|
||||
expect(s.invert(100)).toBe(50)
|
||||
})
|
||||
|
||||
it('supports a descending range (y axis)', () => {
|
||||
const s = scaleLinear({ domain: [0, 10], range: [100, 0] })
|
||||
expect(s(0)).toBe(100)
|
||||
expect(s(5)).toBe(50)
|
||||
expect(s(10)).toBe(0)
|
||||
})
|
||||
|
||||
it('clamps when asked', () => {
|
||||
const s = scaleLinear({ domain: [0, 10], range: [0, 100], clamp: true })
|
||||
expect(s(-5)).toBe(0)
|
||||
expect(s(15)).toBe(100)
|
||||
})
|
||||
|
||||
it('exposes ticks and a niced domain', () => {
|
||||
const s = scaleLinear({ domain: [0, 95], range: [0, 100] })
|
||||
expect(s.ticks(5)).toEqual([0, 20, 40, 60, 80])
|
||||
expect(s.nice(5).domain).toEqual([0, 100])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scaleBand', () => {
|
||||
it('splits the range into equal bands', () => {
|
||||
const s = scaleBand({ domain: ['a', 'b', 'c', 'd'], range: [0, 100] })
|
||||
expect(s.step).toBe(25)
|
||||
expect(s.bandwidth).toBe(25)
|
||||
expect(s('a')).toBe(0)
|
||||
expect(s('b')).toBe(25)
|
||||
expect(s('d')).toBe(75)
|
||||
expect(s('missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('inner padding shrinks the bandwidth', () => {
|
||||
const s = scaleBand({ domain: ['a', 'b'], range: [0, 100], paddingInner: 0.5 })
|
||||
expect(s.step).toBeCloseTo(66.6667, 3)
|
||||
expect(s.bandwidth).toBeCloseTo(33.3333, 3)
|
||||
expect(s('a')).toBeCloseTo(0, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scalePoint', () => {
|
||||
it('places zero-width points evenly', () => {
|
||||
const s = scalePoint({ domain: ['a', 'b', 'c'], range: [0, 100] })
|
||||
expect(s.step).toBe(50)
|
||||
expect(s('a')).toBe(0)
|
||||
expect(s('b')).toBe(50)
|
||||
expect(s('c')).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scaleTime', () => {
|
||||
const day = 86_400_000
|
||||
const t0 = Date.UTC(2026, 5, 1) // fixed instant; June avoids DST edges
|
||||
|
||||
it('maps and inverts over epoch ms', () => {
|
||||
const s = scaleTime({ domain: [t0, t0 + day], range: [0, 100] })
|
||||
expect(s(t0)).toBe(0)
|
||||
expect(s(t0 + day)).toBe(100)
|
||||
expect(s.invert(50)).toBe(t0 + day / 2)
|
||||
})
|
||||
|
||||
it('emits sorted ticks inside the range', () => {
|
||||
const s = scaleTime({ domain: [t0, t0 + day], range: [0, 100] })
|
||||
const t = s.ticks(8)
|
||||
expect(t.length).toBeGreaterThan(2)
|
||||
for (let i = 1; i < t.length; i++) expect(t[i]).toBeGreaterThan(t[i - 1])
|
||||
expect(t[0]).toBeGreaterThanOrEqual(t0)
|
||||
expect(t[t.length - 1]).toBeLessThanOrEqual(t0 + day)
|
||||
})
|
||||
|
||||
it('keeps multi-year tick counts sane', () => {
|
||||
const s = scaleTime({ domain: [Date.UTC(2010, 0, 1), Date.UTC(2026, 0, 1)], range: [0, 100] })
|
||||
const t = s.ticks(6)
|
||||
expect(t.length).toBeGreaterThan(1)
|
||||
expect(t.length).toBeLessThan(30)
|
||||
for (let i = 1; i < t.length; i++) expect(t[i]).toBeGreaterThan(t[i - 1])
|
||||
})
|
||||
|
||||
it('nice widens the domain to a boundary', () => {
|
||||
const s = scaleTime({ domain: [t0 + 1000, t0 + day - 1000], range: [0, 100] }).nice(8)
|
||||
expect(s.domain[0]).toBeLessThanOrEqual(t0 + 1000)
|
||||
expect(s.domain[1]).toBeGreaterThanOrEqual(t0 + day - 1000)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { curveStep } from '../curve'
|
||||
import { arc, pie } from '../shape-arc'
|
||||
import { area, line } from '../shape-line'
|
||||
import { stack } from '../shape-stack'
|
||||
|
||||
describe('line / area', () => {
|
||||
it('line emits a move then line-tos', () => {
|
||||
expect(line([{ x: 0, y: 0 }, { x: 10, y: 20 }, { x: 20, y: 5 }])).toBe('M0,0L10,20L20,5')
|
||||
})
|
||||
|
||||
it('an empty line is the empty path', () => {
|
||||
expect(line([])).toBe('')
|
||||
})
|
||||
|
||||
it('area traces the value edge then the baseline back', () => {
|
||||
const d = area([
|
||||
{ x: 0, y0: 100, y1: 50 },
|
||||
{ x: 10, y0: 100, y1: 20 }
|
||||
])
|
||||
expect(d).toBe('M0,50L10,20L10,100L0,100Z')
|
||||
})
|
||||
|
||||
it('step curve jumps at the midpoint', () => {
|
||||
expect(line([{ x: 0, y: 0 }, { x: 10, y: 10 }], { curve: curveStep })).toBe('M0,0L5,0L5,10L10,10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('arc / pie', () => {
|
||||
it('lays out slices in input order over a full turn', () => {
|
||||
const slices = pie([1, 1, 2])
|
||||
expect(slices.length).toBe(3)
|
||||
expect(slices[0].startAngle).toBe(0)
|
||||
expect(slices[2].endAngle).toBeCloseTo(Math.PI * 2, 6)
|
||||
// The value-2 slice spans half the circle.
|
||||
expect(slices[2].endAngle - slices[2].startAngle).toBeCloseTo(Math.PI, 6)
|
||||
})
|
||||
|
||||
it('reserves a gap per slice when padded', () => {
|
||||
const slices = pie([1, 1], { padAngle: 0.1 })
|
||||
expect(slices[0].endAngle - slices[0].startAngle).toBeCloseTo((Math.PI * 2 - 0.2) / 2, 6)
|
||||
})
|
||||
|
||||
it('draws a pie slice from the centre', () => {
|
||||
const d = arc({ innerRadius: 0, outerRadius: 100, startAngle: 0, endAngle: Math.PI / 2 })
|
||||
expect(d).toBe('M0,0L0,-100A100,100,0,0,1,100,0Z')
|
||||
})
|
||||
|
||||
it('draws a donut segment with two arcs', () => {
|
||||
const d = arc({ innerRadius: 50, outerRadius: 100, startAngle: 0, endAngle: Math.PI / 2 })
|
||||
expect(d.split('A').length).toBe(3)
|
||||
})
|
||||
|
||||
it('draws a full ring with two arcs per edge', () => {
|
||||
const d = arc({ innerRadius: 50, outerRadius: 100, startAngle: 0, endAngle: Math.PI * 2 })
|
||||
expect(d.split('A').length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stack', () => {
|
||||
it('accumulates keys per row', () => {
|
||||
const data = [
|
||||
{ a: 1, b: 2 },
|
||||
{ a: 3, b: 1 }
|
||||
]
|
||||
const [sa, sb] = stack(data, ['a', 'b'])
|
||||
expect(sa.points[0]).toMatchObject({ y0: 0, y1: 1 })
|
||||
expect(sb.points[0]).toMatchObject({ y0: 1, y1: 3 })
|
||||
expect(sb.points[1]).toMatchObject({ y0: 3, y1: 4 })
|
||||
})
|
||||
|
||||
it('expand normalises each row to 1', () => {
|
||||
const data = [{ a: 1, b: 3 }]
|
||||
const [sa, sb] = stack(data, ['a', 'b'], { offset: 'expand' })
|
||||
expect(sa.points[0].y1).toBeCloseTo(0.25, 6)
|
||||
expect(sb.points[0].y1).toBeCloseTo(1, 6)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nice, tickStep, ticks } from '../ticks'
|
||||
|
||||
describe('ticks', () => {
|
||||
it('produces round multiples across a range', () => {
|
||||
expect(ticks(0, 100, 5)).toEqual([0, 20, 40, 60, 80, 100])
|
||||
expect(ticks(0, 10, 10)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
|
||||
it('handles sub-unit steps without float drift', () => {
|
||||
expect(ticks(0, 1, 10)).toEqual([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1])
|
||||
})
|
||||
|
||||
it('stays within the exact extent', () => {
|
||||
const t = ticks(2, 97, 5)
|
||||
expect(t[0]).toBeGreaterThanOrEqual(2)
|
||||
expect(t[t.length - 1]).toBeLessThanOrEqual(97)
|
||||
})
|
||||
|
||||
it('mirrors order when the range descends', () => {
|
||||
expect(ticks(100, 0, 5)).toEqual([100, 80, 60, 40, 20, 0])
|
||||
})
|
||||
|
||||
it('returns a single tick for a zero-width range', () => {
|
||||
expect(ticks(5, 5, 10)).toEqual([5])
|
||||
})
|
||||
|
||||
it('nice rounds the domain outward', () => {
|
||||
expect(nice(0.1, 9.9, 10)).toEqual([0, 10])
|
||||
expect(nice(2, 97, 5)).toEqual([0, 100])
|
||||
})
|
||||
|
||||
it('tickStep returns the nice step', () => {
|
||||
expect(tickStep(0, 100, 5)).toBe(20)
|
||||
expect(tickStep(0, 1, 10)).toBeCloseTo(0.1, 10)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared geometric primitives for the plots engine. Pure data types with no
|
||||
* runtime behaviour — the scale and shape modules build on these.
|
||||
*
|
||||
* All coordinates are in **pixel space**: scales map data values to pixels,
|
||||
* and shape generators consume the already-mapped points. Keeping shapes
|
||||
* pixel-only decouples them from scales (the compositional model).
|
||||
*/
|
||||
|
||||
/** A 2D point in pixel space. */
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertical span at a single `x`, used by area and stacked shapes: `y0` is
|
||||
* the baseline edge and `y1` the value edge (both in pixel space).
|
||||
*/
|
||||
export interface AreaPoint {
|
||||
x: number
|
||||
y0: number
|
||||
y1: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A curve links a sequence of points into SVG path commands. It returns the
|
||||
* commands that connect `points[1..n-1]` assuming the pen already sits at
|
||||
* `points[0]` (the caller emits the leading `M`). This contract lets `line`
|
||||
* and `area` reuse the same curve for their top and bottom edges.
|
||||
*/
|
||||
export type Curve = (points: readonly Point[]) => string
|
||||
@ -0,0 +1,186 @@
|
||||
# Chart — Improvement Roadmap (vs MUI X Charts)
|
||||
|
||||
> Hand-off doc to continue in a fresh session. It captures the analysis of
|
||||
> **MUI X Charts** (`@mui/x-charts`, `packages/x-charts/src`, structure fetched
|
||||
> from GitHub 2026-06-24) and a prioritized set of improvements that fit OUR
|
||||
> model: compositional, **zero-dependency**, eidos-themed, Svelte-5-native.
|
||||
> Read this + [`README.md`](./README.md) + [`$libs/plots/README.md`](../../../../libs/plots/README.md)
|
||||
> before starting.
|
||||
|
||||
---
|
||||
|
||||
## 0. Where we are (snapshot 2026-06-24)
|
||||
|
||||
**Math** — `$libs/plots` (zero-dep, original): `scaleLinear/Band/Point/Time`,
|
||||
`ticks` (1-2-5 nice), `curve` (linear/step/monotoneX), `shape-line`/`area`,
|
||||
`shape-arc` (arc/pie), **`shape-stack`**, `bisector`, `path`. 36 tests.
|
||||
|
||||
**Components** — `chart/`:
|
||||
- Compositional `<Chart>` frame: context-shared scales, responsive via
|
||||
`bind:clientWidth`, cursor-following tooltip (`bisector.center`). Primitives:
|
||||
`Chart.Grid`/`CartesianGrid`, `Chart.XAxis`/`YAxis`, `Chart.Line`/`Area`/`Bar`/`Scatter`,
|
||||
`Chart.Tooltip`, `Chart.Legend`.
|
||||
- Presets: `LineChart`, `AreaChart`, `BarChart`, `ScatterChart`, `Sparkline`
|
||||
(line **and** `as="bars"`).
|
||||
- Standalone: `PieChart` (pie/donut), `BarList`, `BarSegment`, `RadarChart` —
|
||||
each with its own themed hover tooltip.
|
||||
- `ChartContext` (context.ts): `data, x, series, xScale, yScale, plot, bandwidth,
|
||||
baseline, px(), bandStart(), categoryX(), py(), color(), formatNumber(),
|
||||
formatTime(), enableTooltip(), enableLegend()`.
|
||||
- Themed by `--chart-*` recipe tokens (`lib/recipes/base.ts` → `generated/base.css`)
|
||||
+ global color tokens. Locale via `eidos.format`/`eidos.langs`. morfo `chart`
|
||||
`scope:['eidos']`, `role="img"`. Point scale spans **edge-to-edge** (line/area
|
||||
touch the axes).
|
||||
|
||||
**GOTCHAs (don't relearn the hard way):**
|
||||
- Config children (`<Chart.Tooltip>` / `<Chart.Legend>`) must be used
|
||||
**statically** — a reactive `{#if}` toggle does NOT dispose their frame state
|
||||
on unmount (empty-output Svelte components don't fire `$effect` teardown /
|
||||
`onDestroy` on conditional unmount). For a reactive toggle, use the frame-level
|
||||
`tooltip` **prop** instead (presets do).
|
||||
- The frame is `data-chart-frame` (flex column) → `data-chart-plot` (fixed
|
||||
height, tooltip anchor) → `<svg data-chart>`. Legend renders below the plot.
|
||||
- Verify via `preview_eval` DOM measurements; `preview_screenshot` drifts (the
|
||||
shared browser jumps to `/`); the preview `serverId` rotates per session.
|
||||
|
||||
---
|
||||
|
||||
## 1. How MUI X Charts is built (reference)
|
||||
|
||||
Confirmed first-hand from `packages/x-charts/src`:
|
||||
|
||||
- **Three tiers + headless core.** `ChartsDataProvider` (state) → `ChartsSurface`
|
||||
(`<svg>`) → drawing children. A **plugin-store** holds one reactive state object;
|
||||
components are dumb readers via selector hooks (`useDrawingArea`, `useXScale`,
|
||||
`useSeries`). The all-in-one `<LineChart>` is just that child tree frozen.
|
||||
- **Series = data-driven array** discriminated by `type` (`line|bar|scatter|pie|radar`),
|
||||
`data` inline or `dataKey`+`dataset`, axis binding by `xAxisId`/`yAxisId`. Mixed
|
||||
charts = heterogeneous `series` array.
|
||||
- **Axes = arrays** (`xAxis`/`yAxis`) → multiple/secondary axes first-class;
|
||||
`scaleType` ∈ linear·band·point·log·symlog·pow·sqrt·time·utc. Scales = **vendored d3**.
|
||||
- ~25 composable sub-components (`ChartsGrid`, `ChartsTooltip`, `ChartsLegend`,
|
||||
`ChartsAxisHighlight`, `ChartsReferenceLine`, `ChartsClipPath`, `ChartsText`…)
|
||||
+ 14 feature-plugins (`useChartHighlight`, `useChartTooltip`, `useChartClosestPoint`,
|
||||
`useChartKeyboardNavigation`, `useChartBrush`, `useChartCartesianAxis`(zoom), …).
|
||||
- Community features: highlighting/fade, axis-vs-item tooltip, **stacking**,
|
||||
interactive legend, reference lines, **animation** (react-spring), localization,
|
||||
a11y (per-item focus + aria). Pro: zoom/pan, export, multi-axes, WebGL.
|
||||
|
||||
**Verdict vs ours:** philosophically aligned (composition) and we're MORE
|
||||
compositional (each mark declares its series; MUI is still data-driven) and
|
||||
zero-dep (their d3 vs our `$libs/plots`). We're behind on **interaction +
|
||||
a few primitives**, not architecture. Do NOT adopt their plugin-store (React-y,
|
||||
heavy) — our Svelte context (`setChartContext`) is the right-sized equivalent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Improvements — prioritized
|
||||
|
||||
### Tier 1 — high value, fit our model now
|
||||
|
||||
**1. Stacking (bars + areas).** _The math already exists_ (`shape-stack` in
|
||||
`$libs/plots`); it's just not wired into the marks.
|
||||
- Where: `chart-bar.svelte`, `chart-area.svelte`, frame `chart.svelte` (y-domain
|
||||
must sum stacked series), `types.ts` (`stack?: string` group key on the mark/preset).
|
||||
- Approach: group series by `stack` key; compute cumulative baselines per datum
|
||||
via `shape-stack`; each segment draws from its lower to upper offset instead of
|
||||
from `baseline`. The frame's y-domain extent must use stacked totals.
|
||||
- Cost: low-moderate. Highest value/effort ratio — **do this first.**
|
||||
|
||||
**2. Highlight / fade.** Hover a series (or legend item) → fade the others.
|
||||
- Where: `chart.svelte` (track a `highlightedSeries` state in context),
|
||||
`chart-line/area/bar/scatter.svelte` (read it, set `data-dim`), `chart.css`
|
||||
(`[data-chart-*][data-dim='true'] { opacity: var(--opacity-scrim) }`).
|
||||
- Approach: pure eidos — a `data-dim` attr + CSS, no JS painting. Add
|
||||
`ctx.highlight(seriesKey | null)` and an `onpointerenter`/`leave` on each mark
|
||||
(and on legend items). Respects the "eidos owns the visual" doctrine.
|
||||
- Cost: low. Big perceived-quality win.
|
||||
|
||||
**3. Reference lines.** `<Chart.ReferenceLine y={target} label="Goal" />`
|
||||
(and `x={…}`).
|
||||
- Where: new `chart-reference-line.svelte` (reads `ctx.py`/`ctx.px`), `index.ts`
|
||||
(`Chart.ReferenceLine`), `chart.css` + a `--chart-reference-*` token, `types.ts`.
|
||||
- Approach: a dashed `<line>` across the plot + optional `<text>` label. Trivial.
|
||||
- Cost: low.
|
||||
|
||||
**4. Secondary axis.** A right-hand Y axis bound to specific series
|
||||
(`<Chart.YAxis side="right" series={['pct']} />`).
|
||||
- Where: `chart.svelte` (model the axis as an object with an id + its own scale;
|
||||
compute a second y-scale over the bound series), `chart-y-axis.svelte` (`side`),
|
||||
the marks (pick which y-scale by series→axis binding), `context.ts` (expose
|
||||
`pyFor(seriesKey)` or a per-axis scale lookup).
|
||||
- Approach: this is the first step toward MUI-style "axes as objects with ids".
|
||||
Keep it minimal: one optional secondary y-scale, opt-in. Don't build the full
|
||||
array model.
|
||||
- Cost: moderate. Common dashboard need (revenue vs %).
|
||||
|
||||
**5. Item tooltip (vs axis tooltip).** Today the tooltip is axis-mode (all series
|
||||
at the nearest x). Add an item-mode (hover a specific bar/point → just that mark).
|
||||
- Where: `chart.svelte` (tooltip already exists — add a `mode: 'axis' | 'item'`),
|
||||
`chart-bar/scatter.svelte` (per-mark pointer target sets the active item).
|
||||
- Approach: bars/scatter benefit most from item-mode; line/area keep axis-mode.
|
||||
- Cost: moderate.
|
||||
|
||||
**6. Interactive legend.** Click a legend item to hide/show its series.
|
||||
- Where: the preset legends + `Chart.Legend` (frame), `chart.svelte` (a
|
||||
`hiddenSeries` set in context; marks skip hidden series).
|
||||
- Approach: toggle on click, `data-hidden` on the legend swatch, marks read the set.
|
||||
- Cost: low-moderate.
|
||||
|
||||
### Tier 2 — next wave
|
||||
|
||||
7. **Enter animation via `$motion`** — line draw (stroke-dashoffset), bars grow
|
||||
(scaleY from baseline), arc sweep, with reduced-motion fallback. Animation is a
|
||||
channel of the event/render, not a parallel engine — use `Presence` + `$motion`
|
||||
per the motion doctrine. Mind the `prefers-reduced-motion` tracker in `$adom`.
|
||||
8. **Log / symlog scale** in `$libs/plots` (`scale-log.ts`) + `xScale`/`yScale`
|
||||
accepting `'log'`. Useful for wide-range data.
|
||||
9. **Scatter nearest-point** — O(n) nearest-by-distance (NOT MUI's Flatbush; we're
|
||||
"basic"). Replaces nearest-x for scatter tooltips.
|
||||
10. **Gradient fills** (area fades to transparent via an `<linearGradient>` +
|
||||
token), **clip-path** to the plot rect (prevents mark overflow), **rotated X
|
||||
labels** (long categories) + **axis titles**.
|
||||
|
||||
### Tier 3 — out of "basic" scope (note + defer)
|
||||
|
||||
Zoom/pan, brush/range-select, keyboard data navigation, WebGL/progressive
|
||||
rendering, new chart types (a simple **Gauge** could be worth it; Heatmap/Funnel/
|
||||
Sankey/Candlestick are not). Per-item a11y + a hidden `<table>` data fallback is
|
||||
the one accessibility gap worth a dedicated pass (MUI marks items focusable +
|
||||
describable; we only give `role="img"` + `ariaLabel`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Do NOT adopt (and why)
|
||||
|
||||
- **The plugin-store engine** — React-specific + heavy; our `setChartContext`
|
||||
(Svelte context) is the correct, lighter equivalent.
|
||||
- **`series` array as the primary API** — the user prefers composition
|
||||
(see memory `feedback_compositional_not_data_driven`); ours is already more
|
||||
compositional. At most offer it as an alternative for mixed charts (already covered).
|
||||
- **Vendored d3** — zero-dependency mandate; `$libs/plots` is the original
|
||||
equivalent. Never add d3.
|
||||
- **WebGL / zoom / brush** — beyond a "basic" library.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture reference (for the implementer)
|
||||
|
||||
- **Context**: marks call `getChartContext()` and read pre-scaled geometry
|
||||
(`ctx.px(d)`, `ctx.py(v)`, `ctx.plot`, `ctx.series`, `ctx.color(i)`). The frame
|
||||
(`chart.svelte`) owns the scales + pointer tracking; marks are dumb readers.
|
||||
- **Add a primitive mark**: new `chart-{x}.svelte` reading the context →
|
||||
register in `index.ts` namespace (`Chart.{X}`) → add `Chart{X}Props` to `types.ts`.
|
||||
- **Add a recipe token**: edit the `chart` block in `lib/recipes/base.ts`, then
|
||||
regenerate with `npm run generate:eidos-css` (keeps `generated/base.css` in sync;
|
||||
the `recipe-css-contract` test enforces declared↔consumed parity).
|
||||
- **Standalone charts** (pie/radar/bar-list/bar-segment) own their SVG/HTML +
|
||||
tooltip; they don't use the `<Chart>` frame (angular / non-cartesian geometry).
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- `npm run check` → **0 chart errors** (59 pre-existing, unrelated).
|
||||
- Eidos contract tests **pass for chart** (recipe-css-contract, component-api,
|
||||
component-visual-attrs, lint). The ~9 eidos-suite failures are pre-existing from
|
||||
OTHER tracks (`palabras`, `spin-field`, `drawer`, `icon`) — not charts.
|
||||
- Run a dev server and verify via `preview_eval` DOM measurements (not screenshots).
|
||||
@ -0,0 +1,123 @@
|
||||
# Chart
|
||||
|
||||
Data-visualisation family built on the in-house, **zero-dependency** plotting
|
||||
engine [`$libs/plots`](../../../../libs/plots/README.md) (scales · shapes ·
|
||||
ticks · curves). SVG, themeable via design tokens, composable primitives plus
|
||||
ergonomic presets.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { LineChart } from '$uix/eidos/components/chart';
|
||||
</script>
|
||||
|
||||
<LineChart data={revenue} x="month" xScale="point" y={['revenue', 'expenses']} />
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
- **`<Chart>`** — the compositional frame. Measures its width, computes the
|
||||
scales from `data` + `x`/`y`, and shares them with SVG children through
|
||||
context: `<Chart.Grid>` (alias `<Chart.CartesianGrid>`, x+y lines),
|
||||
`<Chart.XAxis>`, `<Chart.YAxis>`, `<Chart.Line>`, `<Chart.Area>`, `<Chart.Bar>`,
|
||||
`<Chart.Scatter>`, plus the config children `<Chart.Tooltip>` (cursor-following
|
||||
readout) and `<Chart.Legend>` (HTML legend below the plot). Point scales span
|
||||
**edge-to-edge** so the line/area touch the y-axis and the right edge.
|
||||
- **Presets** (named exports) — `LineChart`, `AreaChart`, `BarChart`,
|
||||
`ScatterChart`, `Sparkline` (line **or** `as="bars"`). They compose the frame +
|
||||
axes + grid + marks + the HTML chrome (title + legend), with the hover tooltip
|
||||
on by default (`tooltip` prop).
|
||||
- **Standalone** (named exports, own SVG/HTML — non-cartesian geometry) —
|
||||
`<PieChart>` (pie + donut via `donut={0..1}`), `<RadarChart>` (spider),
|
||||
`<BarList>` (ranked rows), `<BarSegment>` (single stacked bar). Each owns its
|
||||
own themed hover tooltip.
|
||||
|
||||
All live in this one family directory — new chart types are added here as named
|
||||
exports, never as separate top-level component directories. The improvement
|
||||
roadmap (incl. the MUI X Charts comparison) lives in
|
||||
[`IMPROVEMENTS.md`](./IMPROVEMENTS.md).
|
||||
|
||||
## Baseline
|
||||
|
||||
**No Air baseline** — charts did not exist in the legacy `air/` layer. The
|
||||
family is net-new, built on the in-house `$libs/plots` engine. The design
|
||||
references are LayerCake / Pancake (compositional, Svelte) for the API shape and
|
||||
d3-scale / d3-shape (algorithms, reimplemented) for the math — see
|
||||
[`$libs/plots`](../../../../libs/plots/README.md).
|
||||
|
||||
## Passive justification (F-1.5)
|
||||
|
||||
`scope: ['eidos']`, **0 events**. A chart is a *derived render* of its data
|
||||
(`data` → scales → SVG), like `Avatar`, `Image` and `QrCode`. It has no
|
||||
interactive state machine and emits no perceptual signal, so there is no soma
|
||||
behaviour and no sema projection. The opt-in hover **tooltip** is handled
|
||||
entirely inside the eidos frame (local pointer tracking → `bisector.center` →
|
||||
crosshair + readout); it stays a passive display because it neither mutates
|
||||
component state nor projects a sema signal — no morfo event is involved.
|
||||
|
||||
## A2.3 exception — data marks
|
||||
|
||||
The mark parts (`line`, `area`, `bar`, `point`, `slice`) declare no `data-*`
|
||||
beyond their part marker (`data-chart-{kebab}`). Their visual treatment is the
|
||||
per-series colour, set inline from the theme palette
|
||||
(`var(--color-{role}-solid)`) — there is no enum state to project as a
|
||||
`data-*`. The axis carries `data-chart-axis="x|y"`.
|
||||
|
||||
## Subset (G-1.1)
|
||||
|
||||
Series colours cycle the canonical role palette via `seriesColor(index)`:
|
||||
`primary · affirm · risk · secondary · fulfill · threat · loss · neutral`. A
|
||||
mark's `color` prop accepts any `ColorRole`. Charts do not take an `intent`
|
||||
prop — the palette is categorical (series identity), not evaluative.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Capability | Chart (UIX) | Chart.js | Recharts | unovis | LayerCake |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Zero runtime deps | ✅ (own `$libs/plots`) | ❌ | ❌ (d3) | ❌ (d3) | ⚠️ (BYO d3) |
|
||||
| Render | SVG | Canvas | SVG | SVG/Canvas/HTML | BYO |
|
||||
| Compositional API | ✅ + presets | ❌ (config) | ✅ | ⚠️ | ✅ |
|
||||
| Themed by tokens | ✅ (`--color-*`) | ⚠️ | ⚠️ | ⚠️ | ❌ |
|
||||
| Svelte 5 runes-native | ✅ | ❌ | ❌ (React) | ⚠️ wrappers | ✅ |
|
||||
|
||||
Line/Bar/Area/Scatter/Pie/Sparkline cover the dashboard baseline. Heavy types
|
||||
(candlestick, sankey, treemap, geo, 3D) are out of scope — see Gaps.
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **One family, one morfo.** `chart` is a single eidos component (like a
|
||||
layout family). The presets + pie share `chartMorfo` (provider + grid + axis
|
||||
+ line/area/bar/point/slice + legend + title, all optional). New chart types
|
||||
reuse the contract instead of inventing one each.
|
||||
- **Provider = the `<svg role="img">`.** The svg is the labelled graphic
|
||||
(`aria-label`); the responsive wrapper is the eidos-only `data-chart-frame`
|
||||
shell. Matches the `QrCode` precedent.
|
||||
- **Pixel-space marks.** Marks consume already-scaled points from the context,
|
||||
so shapes stay decoupled from scales (the engine's compositional model).
|
||||
- **`--chart-*` recipe for geometry, global tokens for colour.** Series colours
|
||||
come from `--color-{role}-solid` and chrome from `--color-content-*` /
|
||||
`--color-border-*` (categorical, theme-driven). Geometric constants (stroke
|
||||
widths, point/bar radius, area opacity, tooltip surface, transition timing)
|
||||
live in a `chart` recipe block (`lib/recipes/base.ts` → `--chart-*`), each
|
||||
sourced from a canonical primitive — no magic numbers in `chart.css`.
|
||||
- **SVG primitives in `MorfoElement`.** Charts are the first SVG-heavy
|
||||
component; `g · rect · circle · line · figure · figcaption` were added to the
|
||||
element union + schema (additive).
|
||||
|
||||
## Gaps
|
||||
|
||||
| Item | Disposition |
|
||||
| --- | --- |
|
||||
| Hover tooltip + crosshair (`bisector.center`) | ✅ **done** (cursor-following, opt-in `tooltip` prop) |
|
||||
| Stacked bar / area (`shape-stack` ready in `$libs/plots`) | Tier 1 — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
|
||||
| Highlight/fade · reference lines · secondary axis · interactive legend | Tier 1 — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
|
||||
| Enter animation (`$motion`) · log scale · gradients | Tier 2 — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
|
||||
| Keyboard data navigation + `<table>` data fallback | Tier 3 a11y — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
|
||||
| Canvas/WebGL · zoom/pan · candlestick / sankey / treemap / geo / 3D | descartar — out of scope (SVG, "basic") |
|
||||
|
||||
## Theming
|
||||
|
||||
Series: `var(--color-{role}-solid)`. Axis lines: `--color-border-default`;
|
||||
grid: `--color-border-subtle`; labels: `--color-content-secondary`; title:
|
||||
`--color-content-primary`. Pie slice separators: `--color-surface-default`.
|
||||
Geometry + tooltip surface: the `--chart-*` recipe tokens (`lib/recipes/base.ts`).
|
||||
`chart.css` is code-split (imported from `chart.svelte`).
|
||||
@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
/** Ergonomic area chart — filled areas with a line edge per series. */
|
||||
import Chart from './chart.svelte';
|
||||
import Grid from './chart-grid.svelte';
|
||||
import XAxis from './chart-x-axis.svelte';
|
||||
import YAxis from './chart-y-axis.svelte';
|
||||
import Line from './chart-line.svelte';
|
||||
import Area from './chart-area.svelte';
|
||||
import { seriesColor } from './context';
|
||||
import type { AreaChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
title,
|
||||
legend = true,
|
||||
noGrid = false,
|
||||
xTicks,
|
||||
yTicks,
|
||||
formatX,
|
||||
formatY,
|
||||
curve = 'monotone',
|
||||
opacity,
|
||||
tooltip = true,
|
||||
...frame
|
||||
}: AreaChartProps = $props();
|
||||
|
||||
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
|
||||
const showLegend = $derived(legend && seriesList.length > 1);
|
||||
</script>
|
||||
|
||||
<figure data-chart-figure>
|
||||
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
|
||||
{#if showLegend}
|
||||
<div data-chart-legend>
|
||||
{#each seriesList as key, i}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
|
||||
{key}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Chart {data} {x} {y} {tooltip} {...frame}>
|
||||
{#if !noGrid}<Grid ticks={yTicks} />{/if}
|
||||
<YAxis ticks={yTicks} format={formatY} />
|
||||
<XAxis ticks={xTicks} format={formatX} />
|
||||
{#each seriesList as key}
|
||||
<Area series={key} {curve} {opacity} />
|
||||
<Line series={key} {curve} />
|
||||
{/each}
|
||||
</Chart>
|
||||
</figure>
|
||||
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
/** Ergonomic bar chart — categorical x, grouped bars across series. */
|
||||
import Chart from './chart.svelte';
|
||||
import Grid from './chart-grid.svelte';
|
||||
import XAxis from './chart-x-axis.svelte';
|
||||
import YAxis from './chart-y-axis.svelte';
|
||||
import Bar from './chart-bar.svelte';
|
||||
import { seriesColor } from './context';
|
||||
import type { BarChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
title,
|
||||
legend = true,
|
||||
noGrid = false,
|
||||
xTicks,
|
||||
yTicks,
|
||||
formatX,
|
||||
formatY,
|
||||
radius,
|
||||
tooltip = true,
|
||||
...frame
|
||||
}: BarChartProps = $props();
|
||||
|
||||
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
|
||||
const showLegend = $derived(legend && seriesList.length > 1);
|
||||
</script>
|
||||
|
||||
<figure data-chart-figure>
|
||||
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
|
||||
{#if showLegend}
|
||||
<div data-chart-legend>
|
||||
{#each seriesList as key, i}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
|
||||
{key}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Chart {data} {x} {y} {tooltip} {...frame} xScale="band">
|
||||
{#if !noGrid}<Grid ticks={yTicks} />{/if}
|
||||
<YAxis ticks={yTicks} format={formatY} />
|
||||
<XAxis ticks={xTicks} format={formatX} />
|
||||
<Bar {radius} />
|
||||
</Chart>
|
||||
</figure>
|
||||
@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<BarList>` — a ranked list of horizontal bars: label · proportional bar ·
|
||||
* value, with a themed hover tooltip (label · value · % of total). Chakra
|
||||
* bar-list style. Pure HTML/CSS (no SVG, no scales): each bar's width is its
|
||||
* value relative to the max. Solid, opaque per-row colour on a neutral track;
|
||||
* the label sits to the left so the bar stays fully saturated.
|
||||
*/
|
||||
import './chart.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { seriesColor } from './context';
|
||||
import type { BarListProps } from './types';
|
||||
|
||||
let { data, color = 'primary', sort = true, limit, tooltip = true, format }: BarListProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
function fmt(v: number): string {
|
||||
if (format) return format(v);
|
||||
try {
|
||||
return eidos.format?.numbers?.format?.(v) ?? String(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
const total = $derived(data.reduce((acc, d) => acc + (d.value > 0 ? d.value : 0), 0));
|
||||
const items = $derived.by(() => {
|
||||
let arr = data.map((d) => ({ label: d.label, value: d.value, fill: seriesColor(d.color ?? color) }));
|
||||
if (sort) arr = [...arr].sort((a, b) => b.value - a.value);
|
||||
if (limit !== undefined) arr = arr.slice(0, limit);
|
||||
const max = Math.max(1, ...arr.map((d) => (d.value > 0 ? d.value : 0)));
|
||||
return arr.map((d) => ({ ...d, pct: Math.max(0, Math.min(100, (d.value / max) * 100)) }));
|
||||
});
|
||||
|
||||
// ── Themed hover tooltip ────────────────────────────────────────────────
|
||||
let active = $state<number | null>(null);
|
||||
let pointerX = $state(0);
|
||||
let pointerY = $state(0);
|
||||
let containerWidth = $state(0);
|
||||
function onPointerMove(event: PointerEvent, i: number): void {
|
||||
if (!tooltip) return;
|
||||
active = i;
|
||||
const box = (event.currentTarget as Element).closest('[data-chart-bar-list]') as Element;
|
||||
const rect = box.getBoundingClientRect();
|
||||
containerWidth = rect.width;
|
||||
pointerX = event.clientX - rect.left;
|
||||
pointerY = event.clientY - rect.top;
|
||||
}
|
||||
const flip = $derived(containerWidth > 0 && pointerX > containerWidth * 0.6);
|
||||
const sharePct = $derived(
|
||||
active === null || total <= 0 ? 0 : Math.round((items[active].value / total) * 100)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-chart-bar-list onpointerleave={() => (active = null)}>
|
||||
{#each items as item, i (item.label)}
|
||||
<div
|
||||
data-chart-bar-list-item
|
||||
data-active={active === i ? 'true' : 'false'}
|
||||
onpointermove={(e) => onPointerMove(e, i)}
|
||||
>
|
||||
<span data-chart-bar-list-label>{item.label}</span>
|
||||
<div data-chart-bar-list-track>
|
||||
<div data-chart-bar-list-bar style="inline-size: {item.pct}%; background: {item.fill};"></div>
|
||||
</div>
|
||||
<span data-chart-bar-list-value>{fmt(item.value)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if tooltip && active !== null}
|
||||
<div
|
||||
data-chart-tooltip
|
||||
data-flip={flip ? 'true' : 'false'}
|
||||
style="left: {pointerX}px; top: {pointerY}px;"
|
||||
>
|
||||
<span data-chart-tooltip-x>{items[active].label}</span>
|
||||
<span data-chart-tooltip-row>
|
||||
<span data-chart-tooltip-swatch style="background: {items[active].fill};"></span>
|
||||
value
|
||||
<span data-chart-tooltip-value>{fmt(items[active].value)} · {sharePct}%</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<BarSegment>` — a single horizontal bar split into proportional segments
|
||||
* (parts of a whole), with an optional legend and a themed hover tooltip.
|
||||
* Chakra bar-segment style. Pure HTML/CSS: each segment's width is its share
|
||||
* of the total.
|
||||
*/
|
||||
import './chart.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { seriesColor } from './context';
|
||||
import type { BarSegmentProps } from './types';
|
||||
|
||||
let { data, legend = true, tooltip = true, format }: BarSegmentProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
function fmt(v: number): string {
|
||||
if (format) return format(v);
|
||||
try {
|
||||
return eidos.format?.numbers?.format?.(v) ?? String(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
const total = $derived(data.reduce((acc, d) => acc + (d.value > 0 ? d.value : 0), 0));
|
||||
const segments = $derived.by(() =>
|
||||
data.map((d, i) => ({
|
||||
label: d.label,
|
||||
value: d.value,
|
||||
fill: seriesColor(d.color ?? i),
|
||||
pct: total > 0 ? ((d.value > 0 ? d.value : 0) / total) * 100 : 0
|
||||
}))
|
||||
);
|
||||
|
||||
// ── Themed hover tooltip ────────────────────────────────────────────────
|
||||
let active = $state<number | null>(null);
|
||||
let pointerX = $state(0);
|
||||
let pointerY = $state(0);
|
||||
let containerWidth = $state(0);
|
||||
function onPointerMove(event: PointerEvent, i: number): void {
|
||||
if (!tooltip) return;
|
||||
active = i;
|
||||
const box = (event.currentTarget as Element).closest('[data-chart-bar-segment]') as Element;
|
||||
const rect = box.getBoundingClientRect();
|
||||
containerWidth = rect.width;
|
||||
pointerX = event.clientX - rect.left;
|
||||
pointerY = event.clientY - rect.top;
|
||||
}
|
||||
const flip = $derived(containerWidth > 0 && pointerX > containerWidth * 0.6);
|
||||
</script>
|
||||
|
||||
<div data-chart-bar-segment>
|
||||
<div data-chart-bar-segment-track onpointerleave={() => (active = null)}>
|
||||
{#each segments as seg, i (seg.label)}
|
||||
<div
|
||||
data-chart-bar-segment-fill
|
||||
data-dim={active !== null && active !== i ? 'true' : 'false'}
|
||||
style="inline-size: {seg.pct}%; background: {seg.fill};"
|
||||
onpointermove={(e) => onPointerMove(e, i)}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if legend}
|
||||
<div data-chart-bar-segment-legend>
|
||||
{#each segments as seg (seg.label)}
|
||||
<span data-chart-bar-segment-legend-item>
|
||||
<span data-chart-swatch style="background: {seg.fill};"></span>
|
||||
{seg.label}
|
||||
<span data-chart-bar-segment-legend-value>{fmt(seg.value)} · {Math.round(seg.pct)}%</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if tooltip && active !== null}
|
||||
<div
|
||||
data-chart-tooltip
|
||||
data-flip={flip ? 'true' : 'false'}
|
||||
style="left: {pointerX}px; top: {pointerY}px;"
|
||||
>
|
||||
<span data-chart-tooltip-x>{segments[active].label}</span>
|
||||
<span data-chart-tooltip-row>
|
||||
<span data-chart-tooltip-swatch style="background: {segments[active].fill};"></span>
|
||||
value
|
||||
<span data-chart-tooltip-value>
|
||||
{fmt(segments[active].value)} · {Math.round(segments[active].pct)}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
/** A filled area mark between the baseline and each series value. */
|
||||
import { area as areaPath, curve as resolveCurve } from '$libs/plots';
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartAreaProps } from './types';
|
||||
|
||||
let { series, color, curve = 'linear', opacity }: ChartAreaProps = $props();
|
||||
const ctx = getChartContext();
|
||||
|
||||
const key = $derived(series ?? ctx.series[0]);
|
||||
const index = $derived(key ? Math.max(0, ctx.series.indexOf(key)) : 0);
|
||||
const fill = $derived(ctx.color(color ?? index));
|
||||
|
||||
const d = $derived.by(() => {
|
||||
if (!key) return '';
|
||||
const baseY = ctx.py(ctx.baseline);
|
||||
const points = ctx.data
|
||||
.map((row) => ({ x: ctx.px(row), y1: ctx.py(Number(row[key])), y0: baseY }))
|
||||
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y1));
|
||||
return areaPath(points, { curve: resolveCurve(curve) });
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- fill-opacity comes from --chart-area-opacity (chart.css); `opacity` overrides. -->
|
||||
<path
|
||||
data-chart-area
|
||||
d={d}
|
||||
fill={fill}
|
||||
stroke="none"
|
||||
style={opacity !== undefined ? `fill-opacity: ${opacity}` : undefined}
|
||||
/>
|
||||
@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Bar mark for a band x scale. With `series` it fills the band; without it,
|
||||
* every declared series renders grouped side-by-side within each band.
|
||||
*/
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartBarProps } from './types';
|
||||
|
||||
let { series, color, radius }: ChartBarProps = $props();
|
||||
const ctx = getChartContext();
|
||||
|
||||
const keys = $derived(series ? [series] : [...ctx.series]);
|
||||
|
||||
interface Bar {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
const bars = $derived.by<Bar[]>(() => {
|
||||
const out: Bar[] = [];
|
||||
const baseY = ctx.py(ctx.baseline);
|
||||
const slots = Math.max(1, keys.length);
|
||||
const slot = ctx.bandwidth / slots;
|
||||
const w = slot * 0.86;
|
||||
const inset = (slot - w) / 2;
|
||||
for (const row of ctx.data) {
|
||||
const start = ctx.bandStart(row);
|
||||
keys.forEach((key, j) => {
|
||||
const value = Number(row[key]);
|
||||
if (Number.isNaN(value)) return;
|
||||
const yValue = ctx.py(value);
|
||||
const top = Math.min(yValue, baseY);
|
||||
const h = Math.abs(yValue - baseY);
|
||||
const roleIndex = series ? Math.max(0, ctx.series.indexOf(key)) : j;
|
||||
out.push({ x: start + j * slot + inset, y: top, w, h, fill: ctx.color(color ?? roleIndex) });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Corner radius comes from --chart-bar-radius (chart.css); `radius` overrides. -->
|
||||
<g data-chart-bars>
|
||||
{#each bars as bar}
|
||||
<rect
|
||||
data-chart-bar
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.w}
|
||||
height={bar.h}
|
||||
fill={bar.fill}
|
||||
style={radius !== undefined ? `rx: ${radius}px` : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Cartesian grid lines — horizontal (y) by default, vertical (x) optional.
|
||||
* Exposed as both `Chart.Grid` and `Chart.CartesianGrid`.
|
||||
*/
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartGridProps } from './types';
|
||||
|
||||
let { ticks = 5, x = false, y = true }: ChartGridProps = $props();
|
||||
const ctx = getChartContext();
|
||||
|
||||
const yLines = $derived(y ? ctx.yScale.ticks(ticks).map((t) => ctx.yScale(t)) : []);
|
||||
const xLines = $derived.by(() => {
|
||||
if (!x) return [];
|
||||
if (ctx.xKind === 'band' || ctx.xKind === 'point') {
|
||||
return (ctx.xScale.domain as readonly string[]).map((category) => ctx.categoryX(category));
|
||||
}
|
||||
const scale = ctx.xScale as { ticks(count: number): number[] };
|
||||
return scale.ticks(ticks).map((v) => (ctx.xScale as (value: number) => number)(v));
|
||||
});
|
||||
</script>
|
||||
|
||||
<g data-chart-grid>
|
||||
{#each yLines as gy}
|
||||
<line data-chart-grid-line x1={0} x2={ctx.plot.width} y1={gy} y2={gy} />
|
||||
{/each}
|
||||
{#each xLines as gx}
|
||||
<line data-chart-grid-line x1={gx} x2={gx} y1={0} y2={ctx.plot.height} />
|
||||
{/each}
|
||||
</g>
|
||||
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<Chart.Legend>` — a config child (renders nothing). It enables the frame's
|
||||
* HTML legend, which lists the declared series with their themed swatches.
|
||||
* Mirrors `<Chart.Tooltip>`: the frame owns the markup so the legend can sit
|
||||
* below the fixed-height plot area. Enable on mount, dispose on unmount.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getChartContext } from './context';
|
||||
|
||||
const ctx = getChartContext();
|
||||
onDestroy(ctx.enableLegend());
|
||||
</script>
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
/** A line mark. Maps each row's series value through the shared scales. */
|
||||
import { curve as resolveCurve, line as linePath } from '$libs/plots';
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartLineProps } from './types';
|
||||
|
||||
let { series, color, curve = 'linear', width }: ChartLineProps = $props();
|
||||
const ctx = getChartContext();
|
||||
|
||||
const key = $derived(series ?? ctx.series[0]);
|
||||
const index = $derived(key ? Math.max(0, ctx.series.indexOf(key)) : 0);
|
||||
const stroke = $derived(ctx.color(color ?? index));
|
||||
|
||||
const d = $derived.by(() => {
|
||||
if (!key) return '';
|
||||
const points = ctx.data
|
||||
.map((row) => ({ x: ctx.px(row), y: ctx.py(Number(row[key])) }))
|
||||
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
||||
return linePath(points, { curve: resolveCurve(curve) });
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- stroke-width comes from --chart-line-width (chart.css); `width` overrides. -->
|
||||
<path
|
||||
data-chart-line
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
style={width !== undefined ? `stroke-width: ${width}px` : undefined}
|
||||
/>
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
/** A scatter mark — one circle per row at the series value. */
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartScatterProps } from './types';
|
||||
|
||||
let { series, color, radius }: ChartScatterProps = $props();
|
||||
const ctx = getChartContext();
|
||||
|
||||
const key = $derived(series ?? ctx.series[0]);
|
||||
const index = $derived(key ? Math.max(0, ctx.series.indexOf(key)) : 0);
|
||||
const fill = $derived(ctx.color(color ?? index));
|
||||
|
||||
const points = $derived.by(() => {
|
||||
if (!key) return [];
|
||||
return ctx.data
|
||||
.map((row) => ({ cx: ctx.px(row), cy: ctx.py(Number(row[key])) }))
|
||||
.filter((p) => Number.isFinite(p.cx) && Number.isFinite(p.cy));
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Radius comes from --chart-point-radius (chart.css); `radius` overrides. -->
|
||||
<g data-chart-scatter>
|
||||
{#each points as p}
|
||||
<circle
|
||||
data-chart-point
|
||||
cx={p.cx}
|
||||
cy={p.cy}
|
||||
fill={fill}
|
||||
style={radius !== undefined ? `r: ${radius}px` : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<Chart.Tooltip>` — enables the hover tooltip on the parent chart. A config
|
||||
* component (renders nothing itself): on mount it registers with the frame,
|
||||
* which owns the pointer tracking, crosshair + value readout; it disposes on
|
||||
* unmount. Mirrors the Recharts / Chakra `<Tooltip>` composition element.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartTooltipProps } from './types';
|
||||
|
||||
let { format }: ChartTooltipProps = $props();
|
||||
const ctx = getChartContext();
|
||||
onDestroy(ctx.enableTooltip({ format }));
|
||||
</script>
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
/** The x axis: a baseline, tick marks and labels (categories or values). */
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartAxisProps } from './types';
|
||||
|
||||
let { ticks = 6, format }: ChartAxisProps = $props();
|
||||
const ctx = getChartContext();
|
||||
const fmt = $derived(
|
||||
format ?? ((v: number) => (ctx.xKind === 'time' ? ctx.formatTime(v) : ctx.formatNumber(v)))
|
||||
);
|
||||
|
||||
interface Tick {
|
||||
pos: number;
|
||||
label: string;
|
||||
}
|
||||
const items = $derived.by<Tick[]>(() => {
|
||||
if (ctx.xKind === 'band' || ctx.xKind === 'point') {
|
||||
return (ctx.xScale.domain as readonly string[]).map((cat) => ({
|
||||
pos: ctx.categoryX(cat),
|
||||
label: cat
|
||||
}));
|
||||
}
|
||||
const scale = ctx.xScale as { ticks(count: number): number[] };
|
||||
return scale.ticks(ticks).map((v) => ({
|
||||
pos: (ctx.xScale as (value: number) => number)(v),
|
||||
label: fmt(v)
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<g data-chart-axis="x" transform="translate(0, {ctx.plot.height})">
|
||||
<line data-chart-axis-line x1={0} y1={0} x2={ctx.plot.width} y2={0} />
|
||||
{#each items as it}
|
||||
<g transform="translate({it.pos}, 0)">
|
||||
<line data-chart-axis-tick x1={0} x2={0} y1={0} y2={4} />
|
||||
<text data-chart-axis-label y={16} text-anchor="middle">{it.label}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/** The y axis: a vertical line, tick marks and value labels. */
|
||||
import { getChartContext } from './context';
|
||||
import type { ChartAxisProps } from './types';
|
||||
|
||||
let { ticks = 5, format }: ChartAxisProps = $props();
|
||||
const ctx = getChartContext();
|
||||
const fmt = $derived(format ?? ((v: number) => ctx.formatNumber(v)));
|
||||
const items = $derived(ctx.yScale.ticks(ticks).map((t) => ({ y: ctx.yScale(t), label: fmt(t) })));
|
||||
</script>
|
||||
|
||||
<g data-chart-axis="y">
|
||||
<line data-chart-axis-line x1={0} y1={0} x2={0} y2={ctx.plot.height} />
|
||||
{#each items as it}
|
||||
<g transform="translate(0, {it.y})">
|
||||
<line data-chart-axis-tick x1={-4} x2={0} y1={0} y2={0} />
|
||||
<text data-chart-axis-label x={-8} y={0} text-anchor="end" dominant-baseline="middle">
|
||||
{it.label}
|
||||
</text>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Chart recipe — styling for the SVG frame, axes, grid, marks and the hover
|
||||
* tooltip, plus the HTML chrome (title + legend) the presets compose. Every
|
||||
* geometric constant (stroke widths, radii, opacities, transition timing,
|
||||
* tooltip surface) comes from a `--chart-*` recipe token sourced from canonical
|
||||
* primitives — see `lib/recipes/base.ts`. Series fills/strokes are themed per
|
||||
* series inline (`var(--color-{role}-solid)`); colours for chrome come from the
|
||||
* global content/border/surface tokens.
|
||||
*/
|
||||
|
||||
[data-chart-frame] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
inline-size: 100%;
|
||||
}
|
||||
[data-chart-plot] {
|
||||
position: relative; /* anchor for the absolutely-positioned hover tooltip */
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/* The plotting surface (morfo Provider — role="img"). */
|
||||
[data-chart] {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
overflow: visible;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Localised empty state when there is no data. */
|
||||
[data-chart-empty] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
block-size: 100%;
|
||||
color: var(--color-content-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ── Axes ─────────────────────────────────────────────────────────────── */
|
||||
[data-chart-axis-line],
|
||||
[data-chart-axis-tick] {
|
||||
stroke: var(--color-border-default);
|
||||
stroke-width: var(--chart-axis-width);
|
||||
}
|
||||
|
||||
[data-chart-axis-label] {
|
||||
fill: var(--color-content-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* ── Grid ─────────────────────────────────────────────────────────────── */
|
||||
[data-chart-grid-line] {
|
||||
stroke: var(--color-border-subtle);
|
||||
stroke-width: var(--chart-grid-width);
|
||||
}
|
||||
|
||||
/* ── Marks ────────────────────────────────────────────────────────────── */
|
||||
[data-chart-line] {
|
||||
stroke-width: var(--chart-line-width);
|
||||
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
[data-chart-area] {
|
||||
fill-opacity: var(--chart-area-opacity);
|
||||
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
[data-chart-bar] {
|
||||
rx: var(--chart-bar-radius);
|
||||
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
[data-chart-point] {
|
||||
r: var(--chart-point-radius);
|
||||
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
|
||||
/* Pie / donut slices, separated by a hairline of the page surface. */
|
||||
[data-chart-slice] {
|
||||
stroke: var(--color-surface-default);
|
||||
stroke-width: var(--chart-slice-stroke-width);
|
||||
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
|
||||
/* ── Hover crosshair + active markers ─────────────────────────────────── */
|
||||
[data-chart-crosshair] {
|
||||
stroke: var(--chart-crosshair-color);
|
||||
stroke-width: var(--chart-crosshair-width);
|
||||
stroke-dasharray: 3 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-chart-active-dot] {
|
||||
r: var(--chart-point-radius);
|
||||
stroke: var(--color-surface-default);
|
||||
stroke-width: var(--chart-axis-width);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Hover tooltip ────────────────────────────────────────────────────── */
|
||||
[data-chart-tooltip] {
|
||||
position: absolute;
|
||||
z-index: var(--z-index-tooltip);
|
||||
pointer-events: none;
|
||||
/* Anchored at the active datum's x (inline `left`) + cursor y (`top`);
|
||||
offset to the side and vertically centred. Flips left near the edge. */
|
||||
transform: translate(var(--space-3), -50%);
|
||||
/* Glide between data points + flip side smoothly (the abrupt jump otherwise). */
|
||||
transition:
|
||||
left var(--chart-transition-duration) var(--chart-transition-ease),
|
||||
transform var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--chart-tooltip-gap);
|
||||
padding: var(--chart-tooltip-padding);
|
||||
background: var(--chart-tooltip-bg);
|
||||
border: var(--chart-axis-width) solid var(--chart-tooltip-border);
|
||||
border-radius: var(--chart-tooltip-radius);
|
||||
box-shadow: var(--chart-tooltip-shadow);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-content-primary);
|
||||
min-inline-size: max-content;
|
||||
}
|
||||
[data-chart-tooltip][data-flip='true'] {
|
||||
transform: translate(calc(-100% - var(--space-3)), -50%);
|
||||
}
|
||||
[data-chart-tooltip-x] {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
[data-chart-tooltip-row] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--chart-tooltip-gap);
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
[data-chart-tooltip-swatch] {
|
||||
inline-size: 0.6em;
|
||||
block-size: 0.6em;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: none;
|
||||
}
|
||||
[data-chart-tooltip-value] {
|
||||
margin-inline-start: var(--space-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
|
||||
/* ── Figure chrome (presets) ──────────────────────────────────────────── */
|
||||
[data-chart-figure] {
|
||||
/* Reset the browser's default <figure> margin (1em 40px) so the chart fills
|
||||
and aligns with its container instead of sitting inset / off-centre. */
|
||||
margin: 0;
|
||||
position: relative; /* anchor for the pie's hover tooltip */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
[data-chart-title] {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
|
||||
[data-chart-legend] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1-5) var(--space-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-chart-legend-item] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
|
||||
[data-chart-swatch] {
|
||||
inline-size: 0.75em;
|
||||
block-size: 0.75em;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Bar list (ranked horizontal bars) ────────────────────────────────── */
|
||||
/* ONE grid for the whole list so the label / track / value columns line up
|
||||
across every row — the track (the grey "100%" rail) is then identical-width
|
||||
on every row, so the filled bars are directly comparable. */
|
||||
[data-chart-bar-list] {
|
||||
position: relative; /* anchor for the hover tooltip */
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
inline-size: 100%;
|
||||
}
|
||||
[data-chart-bar-list-item] {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
}
|
||||
[data-chart-bar-list-label] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-content-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-chart-bar-list-track] {
|
||||
block-size: var(--control-height-sm, var(--space-7));
|
||||
background: var(--color-surface-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-chart-bar-list-bar] {
|
||||
block-size: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: inline-size var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
[data-chart-bar-list-value] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-content-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* ── Bar segment (single stacked bar) ─────────────────────────────────── */
|
||||
[data-chart-bar-segment] {
|
||||
position: relative; /* anchor for the hover tooltip */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
inline-size: 100%;
|
||||
}
|
||||
[data-chart-bar-segment-track] {
|
||||
display: flex;
|
||||
gap: var(--border-width-medium);
|
||||
block-size: var(--control-height-sm, var(--space-7));
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-chart-bar-segment-fill] {
|
||||
block-size: 100%;
|
||||
min-inline-size: var(--border-width-thick);
|
||||
transition:
|
||||
inline-size var(--chart-transition-duration) var(--chart-transition-ease),
|
||||
opacity var(--chart-transition-duration) var(--chart-transition-ease);
|
||||
}
|
||||
[data-chart-bar-segment-fill][data-dim='true'] {
|
||||
opacity: var(--opacity-scrim);
|
||||
}
|
||||
[data-chart-bar-segment-legend] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
[data-chart-bar-segment-legend-item] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
[data-chart-bar-segment-legend-value] {
|
||||
color: var(--color-content-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Radar / spider chart ─────────────────────────────────────────────── */
|
||||
[data-chart-radar-ring],
|
||||
[data-chart-radar-spoke] {
|
||||
fill: none;
|
||||
stroke: var(--color-border-subtle);
|
||||
stroke-width: var(--chart-grid-width);
|
||||
}
|
||||
[data-chart-radar-area] {
|
||||
fill-opacity: var(--chart-area-opacity);
|
||||
stroke-width: var(--chart-line-width);
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Chart>` — compositional chart frame. Measures its own width,
|
||||
* computes the x/y scales from the data via `$libs/plots`, and shares them
|
||||
* with its SVG children (grid, axes, marks) through context. It also owns the
|
||||
* hover tooltip (enabled by `<Chart.Tooltip>` or the `tooltip` prop): pointer
|
||||
* tracking, nearest-datum lookup (`bisector.center`), crosshair + active dots
|
||||
* (SVG) and the value readout (HTML), all formatted through the locale-aware
|
||||
* format engine. Titles/legends are composed around it (see presets).
|
||||
*/
|
||||
import './chart.css';
|
||||
import { bisector, extent, nice, scaleBand, scaleLinear, scalePoint, scaleTime } from '$libs/plots';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import {
|
||||
seriesColor,
|
||||
setChartContext,
|
||||
type ChartContext,
|
||||
type ChartTooltipConfig,
|
||||
type ChartXScale
|
||||
} from './context';
|
||||
import type { ChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
xScale: xKind = 'linear',
|
||||
xDomain,
|
||||
yDomain,
|
||||
height = 260,
|
||||
width: fixedWidth,
|
||||
margin,
|
||||
niceY = true,
|
||||
padding = 0.2,
|
||||
tooltip = false,
|
||||
ariaLabel,
|
||||
children
|
||||
}: ChartProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
|
||||
// Locale-aware default formatters (react to prefs.locale via the format
|
||||
// engine). Optional-chained + guarded so a chart still renders if format
|
||||
// was not wired into this ActiveEidos.
|
||||
function formatNumber(value: number): string {
|
||||
try {
|
||||
return eidos.format?.numbers?.format?.(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
function formatTime(ms: number): string {
|
||||
try {
|
||||
return (
|
||||
eidos.format?.dates?.formatDate?.(new Date(ms), { month: 'short', day: 'numeric' }) ??
|
||||
String(ms)
|
||||
);
|
||||
} catch {
|
||||
return String(ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Localised empty state (reacts to prefs.language), guarded for langs-less runtimes.
|
||||
const emptyText = $derived.by(() => {
|
||||
try {
|
||||
return eidos.langs.ts('#?components.chart.empty|No data');
|
||||
} catch {
|
||||
return 'No data';
|
||||
}
|
||||
});
|
||||
|
||||
let measured = $state(0);
|
||||
const width = $derived(fixedWidth ?? measured);
|
||||
|
||||
const margins = $derived({
|
||||
top: margin?.top ?? 12,
|
||||
right: margin?.right ?? 16,
|
||||
bottom: margin?.bottom ?? 28,
|
||||
left: margin?.left ?? 44
|
||||
});
|
||||
|
||||
const series = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
|
||||
const isBandLike = $derived(xKind === 'band' || xKind === 'point');
|
||||
|
||||
const plot = $derived({
|
||||
left: margins.left,
|
||||
top: margins.top,
|
||||
width: Math.max(0, width - margins.left - margins.right),
|
||||
height: Math.max(0, height - margins.top - margins.bottom)
|
||||
});
|
||||
|
||||
// y domain across every declared series, anchored at 0 unless overridden.
|
||||
const yValues = $derived.by(() => {
|
||||
const out: number[] = [];
|
||||
for (const row of data) {
|
||||
for (const key of series) {
|
||||
const v = Number(row[key]);
|
||||
if (!Number.isNaN(v)) out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const yDom = $derived.by((): [number, number] => {
|
||||
if (yDomain) return [yDomain[0], yDomain[1]];
|
||||
const e = extent(yValues) ?? [0, 1];
|
||||
const lo = Math.min(0, e[0]);
|
||||
const hi = Math.max(0, e[1]);
|
||||
const span: [number, number] = lo === hi ? [lo, lo + 1] : [lo, hi];
|
||||
return niceY ? nice(span[0], span[1], 5) : span;
|
||||
});
|
||||
const yScale = $derived(scaleLinear({ domain: yDom, range: [plot.height, 0] }));
|
||||
const baseValue = $derived(yDom[0] <= 0 && yDom[1] >= 0 ? 0 : yDom[0]);
|
||||
|
||||
const xScale = $derived.by<ChartXScale>(() => {
|
||||
if (isBandLike) {
|
||||
const domain = data.map((d) => String(d[x]));
|
||||
return xKind === 'point'
|
||||
? // point scale (line / area): span edge-to-edge so the line touches the
|
||||
// y-axis and the right edge — the canonical look. `padding` is band-only.
|
||||
scalePoint({ domain, range: [0, plot.width], padding: 0 })
|
||||
: scaleBand({ domain, range: [0, plot.width], padding });
|
||||
}
|
||||
const nums = data.map((d) => Number(d[x]));
|
||||
const dom = xDomain ? [xDomain[0], xDomain[1]] : (extent(nums) ?? [0, 1]);
|
||||
return xKind === 'time'
|
||||
? scaleTime({ domain: [dom[0], dom[1]], range: [0, plot.width] })
|
||||
: scaleLinear({ domain: [dom[0], dom[1]], range: [0, plot.width] });
|
||||
});
|
||||
|
||||
const bandwidth = $derived(
|
||||
'bandwidth' in xScale ? (xScale as { bandwidth: number }).bandwidth : 0
|
||||
);
|
||||
|
||||
function categoryX(category: string): number {
|
||||
const pos = (xScale as (v: string) => number | undefined)(category) ?? 0;
|
||||
return pos + (xKind === 'band' ? bandwidth / 2 : 0);
|
||||
}
|
||||
function pointX(datum: Record<string, unknown>): number {
|
||||
if (isBandLike) return categoryX(String(datum[x]));
|
||||
return (xScale as (v: number) => number)(Number(datum[x]));
|
||||
}
|
||||
|
||||
// ── Hover tooltip ───────────────────────────────────────────────────────
|
||||
let tooltipConfig = $state<ChartTooltipConfig | null>(null);
|
||||
const tooltipOn = $derived(tooltip || tooltipConfig !== null);
|
||||
let legendToken = $state<object | null>(null);
|
||||
const legendOn = $derived(legendToken !== null);
|
||||
|
||||
let activeIndex = $state<number | null>(null);
|
||||
let pointerX = $state(0);
|
||||
let pointerY = $state(0);
|
||||
|
||||
const pxByIndex = $derived(data.map((d) => pointX(d)));
|
||||
const xBisect = bisector((n: number) => n);
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (!tooltipOn || data.length === 0 || plot.width <= 0) return;
|
||||
const rect = (event.currentTarget as Element).getBoundingClientRect();
|
||||
const localX = event.clientX - rect.left - plot.left;
|
||||
activeIndex = xBisect.center(pxByIndex, localX);
|
||||
pointerX = event.clientX - rect.left;
|
||||
pointerY = event.clientY - rect.top;
|
||||
}
|
||||
|
||||
const anchorX = $derived(activeIndex === null ? 0 : plot.left + pxByIndex[activeIndex]);
|
||||
const tooltipTop = $derived(Math.max(0, Math.min(pointerY, height)));
|
||||
const tooltipFlip = $derived(width > 0 && anchorX > width * 0.6);
|
||||
|
||||
function xLabel(row: Record<string, unknown>): string {
|
||||
if (isBandLike) return String(row[x]);
|
||||
const value = Number(row[x]);
|
||||
return xKind === 'time' ? formatTime(value) : formatNumber(value);
|
||||
}
|
||||
function valueLabel(value: number, key: string): string {
|
||||
return tooltipConfig?.format?.(value, key) ?? formatNumber(value);
|
||||
}
|
||||
|
||||
const ctx: ChartContext = {
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get x() {
|
||||
return x;
|
||||
},
|
||||
get series() {
|
||||
return series;
|
||||
},
|
||||
get xKind() {
|
||||
return xKind;
|
||||
},
|
||||
get xScale() {
|
||||
return xScale;
|
||||
},
|
||||
get yScale() {
|
||||
return yScale;
|
||||
},
|
||||
get plot() {
|
||||
return plot;
|
||||
},
|
||||
get bandwidth() {
|
||||
return bandwidth;
|
||||
},
|
||||
get baseline() {
|
||||
return baseValue;
|
||||
},
|
||||
px: (d) => pointX(d),
|
||||
bandStart: (d) => pointX(d) - (xKind === 'band' ? bandwidth / 2 : 0),
|
||||
categoryX,
|
||||
py: (v) => yScale(v),
|
||||
color: (i) => seriesColor(i),
|
||||
formatNumber,
|
||||
formatTime,
|
||||
enableTooltip: (config = {}) => {
|
||||
tooltipConfig = config;
|
||||
return () => {
|
||||
if (tooltipConfig === config) tooltipConfig = null;
|
||||
};
|
||||
},
|
||||
enableLegend: () => {
|
||||
const token = {};
|
||||
legendToken = token;
|
||||
return () => {
|
||||
if (legendToken === token) legendToken = null;
|
||||
};
|
||||
}
|
||||
};
|
||||
setChartContext(ctx);
|
||||
</script>
|
||||
|
||||
<div data-chart-frame bind:clientWidth={measured}>
|
||||
<div data-chart-plot style="block-size: {height}px">
|
||||
{#if data.length === 0}
|
||||
<div data-chart-empty>{emptyText}</div>
|
||||
{:else if plot.width > 0 && plot.height > 0}
|
||||
<svg
|
||||
data-chart
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
onpointermove={onPointerMove}
|
||||
onpointerleave={() => (activeIndex = null)}
|
||||
>
|
||||
<g transform="translate({plot.left}, {plot.top})">
|
||||
{@render children?.()}
|
||||
{#if tooltipOn && activeIndex !== null}
|
||||
<line
|
||||
data-chart-crosshair
|
||||
x1={pxByIndex[activeIndex]}
|
||||
x2={pxByIndex[activeIndex]}
|
||||
y1={0}
|
||||
y2={plot.height}
|
||||
/>
|
||||
{#each series as key, i (key)}
|
||||
{@const v = Number(data[activeIndex][key])}
|
||||
{#if Number.isFinite(v)}
|
||||
<circle
|
||||
data-chart-active-dot
|
||||
cx={pxByIndex[activeIndex]}
|
||||
cy={yScale(v)}
|
||||
fill={seriesColor(i)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</g>
|
||||
</svg>
|
||||
{#if tooltipOn && activeIndex !== null}
|
||||
<div
|
||||
data-chart-tooltip
|
||||
data-flip={tooltipFlip ? 'true' : 'false'}
|
||||
style="left: {anchorX}px; top: {tooltipTop}px;"
|
||||
>
|
||||
<span data-chart-tooltip-x>{xLabel(data[activeIndex])}</span>
|
||||
{#each series as key, i (key)}
|
||||
{@const v = Number(data[activeIndex][key])}
|
||||
{#if Number.isFinite(v)}
|
||||
<span data-chart-tooltip-row>
|
||||
<span data-chart-tooltip-swatch style="background: {seriesColor(i)}"></span>
|
||||
{key}
|
||||
<span data-chart-tooltip-value>{valueLabel(v, key)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if legendOn && series.length > 0}
|
||||
<div data-chart-legend>
|
||||
{#each series as key, i (key)}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {seriesColor(i)};"></span>
|
||||
{key}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,97 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { BandScale, LinearScale, PointScale, TimeScale } from '$libs/plots';
|
||||
import type { ColorRole } from '$uix/eidos/lib/types';
|
||||
import type { ChartXScaleKind } from './types';
|
||||
|
||||
const CHART_KEY = Symbol('chart');
|
||||
|
||||
export type ChartXScale = LinearScale | TimeScale | BandScale | PointScale;
|
||||
|
||||
/** Ordered role palette for multi-series colour assignment. */
|
||||
export const SERIES_ROLES: readonly ColorRole[] = [
|
||||
'primary',
|
||||
'affirm',
|
||||
'risk',
|
||||
'secondary',
|
||||
'fulfill',
|
||||
'threat',
|
||||
'loss',
|
||||
'neutral'
|
||||
];
|
||||
|
||||
/** Themed solid colour for a series by index (cycles) or explicit role. */
|
||||
export function seriesColor(indexOrRole: number | ColorRole): string {
|
||||
const role =
|
||||
typeof indexOrRole === 'number'
|
||||
? SERIES_ROLES[((indexOrRole % SERIES_ROLES.length) + SERIES_ROLES.length) % SERIES_ROLES.length]
|
||||
: indexOrRole;
|
||||
return `var(--color-${role}-solid)`;
|
||||
}
|
||||
|
||||
/** Themed soft surface for a category fill (`var(--color-{role}-surface)`). */
|
||||
export function seriesSurface(indexOrRole: number | ColorRole): string {
|
||||
const role =
|
||||
typeof indexOrRole === 'number'
|
||||
? SERIES_ROLES[((indexOrRole % SERIES_ROLES.length) + SERIES_ROLES.length) % SERIES_ROLES.length]
|
||||
: indexOrRole;
|
||||
return `var(--color-${role}-surface)`;
|
||||
}
|
||||
|
||||
export interface ChartPlot {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Tooltip configuration registered by `<Chart.Tooltip>`. */
|
||||
export interface ChartTooltipConfig {
|
||||
/** Format a series value for the tooltip. Defaults to the locale-aware number format. */
|
||||
format?: (value: number, seriesKey: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared frame state. The root computes scales + geometry and exposes them
|
||||
* through getters so child marks stay reactive to data / size changes.
|
||||
*/
|
||||
export interface ChartContext {
|
||||
readonly data: readonly Record<string, unknown>[];
|
||||
readonly x: string;
|
||||
readonly series: readonly string[];
|
||||
readonly xKind: ChartXScaleKind;
|
||||
readonly xScale: ChartXScale;
|
||||
readonly yScale: LinearScale;
|
||||
readonly plot: ChartPlot;
|
||||
/** Band width in pixels (0 for non-band scales). */
|
||||
readonly bandwidth: number;
|
||||
/** Baseline value for area/bar fills — 0 when the domain crosses it, else the domain floor. */
|
||||
readonly baseline: number;
|
||||
/** Centre x (pixels) for a datum — band centre or the scaled value. */
|
||||
px(datum: Record<string, unknown>): number;
|
||||
/** Left edge x (pixels) for a bar at a datum. */
|
||||
bandStart(datum: Record<string, unknown>): number;
|
||||
/** Centre x (pixels) for a category label. */
|
||||
categoryX(category: string): number;
|
||||
/** y pixel for a value. */
|
||||
py(value: number): number;
|
||||
/** Themed series colour by index or role. */
|
||||
color(indexOrRole: number | ColorRole): string;
|
||||
/** Locale-aware default number formatter (axes + tooltip). */
|
||||
formatNumber(value: number): string;
|
||||
/** Locale-aware default formatter for an epoch-ms instant (time axis + tooltip). */
|
||||
formatTime(ms: number): string;
|
||||
/** Enable the hover tooltip (called by `<Chart.Tooltip>`). Returns a disposer. */
|
||||
enableTooltip(config?: ChartTooltipConfig): () => void;
|
||||
/** Enable the HTML legend (called by `<Chart.Legend>`). Returns a disposer. */
|
||||
enableLegend(): () => void;
|
||||
}
|
||||
|
||||
export function setChartContext(ctx: ChartContext): void {
|
||||
setContext(CHART_KEY, ctx);
|
||||
}
|
||||
|
||||
export function getChartContext(): ChartContext {
|
||||
const ctx = getContext<ChartContext | undefined>(CHART_KEY);
|
||||
if (!ctx) throw new Error('Chart parts must be used inside <Chart>');
|
||||
return ctx;
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import ChartComponent from './chart.svelte';
|
||||
import Grid from './chart-grid.svelte';
|
||||
import XAxis from './chart-x-axis.svelte';
|
||||
import YAxis from './chart-y-axis.svelte';
|
||||
import Line from './chart-line.svelte';
|
||||
import Area from './chart-area.svelte';
|
||||
import Bar from './chart-bar.svelte';
|
||||
import Scatter from './chart-scatter.svelte';
|
||||
import Tooltip from './chart-tooltip.svelte';
|
||||
import Legend from './chart-legend.svelte';
|
||||
|
||||
type ChartNamespace = typeof ChartComponent & {
|
||||
Grid: typeof Grid;
|
||||
CartesianGrid: typeof Grid;
|
||||
XAxis: typeof XAxis;
|
||||
YAxis: typeof YAxis;
|
||||
Line: typeof Line;
|
||||
Area: typeof Area;
|
||||
Bar: typeof Bar;
|
||||
Scatter: typeof Scatter;
|
||||
Tooltip: typeof Tooltip;
|
||||
Legend: typeof Legend;
|
||||
};
|
||||
|
||||
const Chart = ChartComponent as ChartNamespace;
|
||||
Chart.Grid = Grid;
|
||||
Chart.XAxis = XAxis;
|
||||
Chart.YAxis = YAxis;
|
||||
Chart.Line = Line;
|
||||
Chart.Area = Area;
|
||||
Chart.Bar = Bar;
|
||||
Chart.Scatter = Scatter;
|
||||
Chart.CartesianGrid = Grid;
|
||||
Chart.Tooltip = Tooltip;
|
||||
Chart.Legend = Legend;
|
||||
|
||||
export { Chart };
|
||||
export default Chart;
|
||||
|
||||
export { default as LineChart } from './line-chart.svelte';
|
||||
export { default as AreaChart } from './area-chart.svelte';
|
||||
export { default as BarChart } from './bar-chart.svelte';
|
||||
export { default as ScatterChart } from './scatter-chart.svelte';
|
||||
export { default as Sparkline } from './sparkline.svelte';
|
||||
export { default as PieChart } from './pie-chart.svelte';
|
||||
export { default as BarList } from './bar-list.svelte';
|
||||
export { default as BarSegment } from './bar-segment.svelte';
|
||||
export { default as RadarChart } from './radar-chart.svelte';
|
||||
|
||||
export { SERIES_ROLES, seriesColor } from './context';
|
||||
export type { ChartContext } from './context';
|
||||
export type {
|
||||
AreaChartProps,
|
||||
BarChartProps,
|
||||
BarListProps,
|
||||
BarSegmentProps,
|
||||
ChartAreaProps,
|
||||
ChartCategory,
|
||||
ChartAxisProps,
|
||||
ChartBarProps,
|
||||
ChartGridProps,
|
||||
ChartLineProps,
|
||||
ChartProps,
|
||||
ChartScatterProps,
|
||||
ChartTooltipProps,
|
||||
ChartXScaleKind,
|
||||
LineChartProps,
|
||||
PieChartProps,
|
||||
PieDatum,
|
||||
RadarChartProps,
|
||||
ScatterChartProps,
|
||||
SparklineProps
|
||||
} from './types';
|
||||
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
/** Ergonomic line chart — composes the frame, axes, grid and line marks. */
|
||||
import Chart from './chart.svelte';
|
||||
import Grid from './chart-grid.svelte';
|
||||
import XAxis from './chart-x-axis.svelte';
|
||||
import YAxis from './chart-y-axis.svelte';
|
||||
import Line from './chart-line.svelte';
|
||||
import Area from './chart-area.svelte';
|
||||
import Scatter from './chart-scatter.svelte';
|
||||
import { seriesColor } from './context';
|
||||
import type { LineChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
title,
|
||||
legend = true,
|
||||
noGrid = false,
|
||||
xTicks,
|
||||
yTicks,
|
||||
formatX,
|
||||
formatY,
|
||||
curve = 'monotone',
|
||||
area = false,
|
||||
dots = false,
|
||||
tooltip = true,
|
||||
...frame
|
||||
}: LineChartProps = $props();
|
||||
|
||||
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
|
||||
const showLegend = $derived(legend && seriesList.length > 1);
|
||||
</script>
|
||||
|
||||
<figure data-chart-figure>
|
||||
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
|
||||
{#if showLegend}
|
||||
<div data-chart-legend>
|
||||
{#each seriesList as key, i}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
|
||||
{key}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Chart {data} {x} {y} {tooltip} {...frame}>
|
||||
{#if !noGrid}<Grid ticks={yTicks} />{/if}
|
||||
<YAxis ticks={yTicks} format={formatY} />
|
||||
<XAxis ticks={xTicks} format={formatX} />
|
||||
{#each seriesList as key}
|
||||
{#if area}<Area series={key} {curve} />{/if}
|
||||
<Line series={key} {curve} />
|
||||
{#if dots}<Scatter series={key} radius={3} />{/if}
|
||||
{/each}
|
||||
</Chart>
|
||||
</figure>
|
||||
@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Standalone pie / donut chart. Angular geometry doesn't fit the x/y frame,
|
||||
* so this owns its own SVG and consumes `arc` + `pie` from the engine. It
|
||||
* also owns its own hover tooltip (per slice → label · value · percentage),
|
||||
* since there's no Chart frame to delegate to.
|
||||
*/
|
||||
import './chart.css';
|
||||
import { arc, pie } from '$libs/plots';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { seriesColor } from './context';
|
||||
import type { PieChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
size = 220,
|
||||
donut = 0,
|
||||
padAngle = 0.02,
|
||||
legend = true,
|
||||
tooltip = true,
|
||||
title,
|
||||
ariaLabel
|
||||
}: PieChartProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
// Locale-aware number formatting (reacts to prefs.locale), guarded.
|
||||
const formatNumber = (v: number): string => {
|
||||
try {
|
||||
return eidos.format?.numbers?.format?.(v) ?? String(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
const radius = $derived(size / 2);
|
||||
const inner = $derived(radius * Math.min(0.95, Math.max(0, donut)));
|
||||
const total = $derived(data.reduce((acc, d) => acc + (d.value > 0 ? d.value : 0), 0));
|
||||
|
||||
const slices = $derived.by(() =>
|
||||
pie(
|
||||
data.map((d) => d.value),
|
||||
{ padAngle }
|
||||
).map((slice, i) => ({
|
||||
d: arc({
|
||||
innerRadius: inner,
|
||||
outerRadius: radius,
|
||||
startAngle: slice.startAngle,
|
||||
endAngle: slice.endAngle,
|
||||
cx: radius,
|
||||
cy: radius
|
||||
}),
|
||||
fill: seriesColor(data[i].color ?? i),
|
||||
label: data[i].label,
|
||||
value: data[i].value
|
||||
}))
|
||||
);
|
||||
|
||||
// ── Hover tooltip ───────────────────────────────────────────────────────
|
||||
let active = $state<number | null>(null);
|
||||
let pointerX = $state(0);
|
||||
let pointerY = $state(0);
|
||||
|
||||
function onPointerMove(event: PointerEvent, i: number): void {
|
||||
if (!tooltip) return;
|
||||
active = i;
|
||||
const figure = (event.currentTarget as Element).closest('[data-chart-figure]');
|
||||
const rect = (figure ?? (event.currentTarget as Element)).getBoundingClientRect();
|
||||
pointerX = event.clientX - rect.left;
|
||||
pointerY = event.clientY - rect.top;
|
||||
}
|
||||
|
||||
const percent = $derived(
|
||||
active === null || total <= 0 ? 0 : Math.round((slices[active].value / total) * 100)
|
||||
);
|
||||
const tooltipFlip = $derived(pointerX > size * 0.6);
|
||||
</script>
|
||||
|
||||
<figure data-chart-figure>
|
||||
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
|
||||
<svg
|
||||
data-chart
|
||||
role="img"
|
||||
aria-label={ariaLabel ?? title}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 {size} {size}"
|
||||
style="inline-size: {size}px; flex: none; align-self: center;"
|
||||
onpointerleave={() => (active = null)}
|
||||
>
|
||||
{#each slices as slice, i (slice.label)}
|
||||
<path
|
||||
data-chart-slice
|
||||
d={slice.d}
|
||||
fill={slice.fill}
|
||||
opacity={active === null || active === i ? 1 : 0.45}
|
||||
onpointermove={(e) => onPointerMove(e, i)}
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if legend}
|
||||
<div data-chart-legend>
|
||||
{#each slices as slice (slice.label)}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {slice.fill}"></span>
|
||||
{slice.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if tooltip && active !== null}
|
||||
<div
|
||||
data-chart-tooltip
|
||||
data-flip={tooltipFlip ? 'true' : 'false'}
|
||||
style="left: {pointerX}px; top: {pointerY}px;"
|
||||
>
|
||||
<span data-chart-tooltip-x>{slices[active].label}</span>
|
||||
<span data-chart-tooltip-row>
|
||||
<span data-chart-tooltip-swatch style="background: {slices[active].fill}"></span>
|
||||
value
|
||||
<span data-chart-tooltip-value>{formatNumber(slices[active].value)} · {percent}%</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<RadarChart>` — a radar / spider chart. Categories sit around a circle;
|
||||
* each series is a polygon whose vertices are the value at each category,
|
||||
* placed by a radial `scaleLinear` from the engine. N-gon grid rings + spokes,
|
||||
* category labels, legend and a hover tooltip (nearest category by cursor
|
||||
* angle). Standalone (angular geometry doesn't fit the x/y frame).
|
||||
*/
|
||||
import './chart.css';
|
||||
import { extent, nice, scaleLinear } from '$libs/plots';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { seriesColor } from './context';
|
||||
import type { RadarChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
axis,
|
||||
series,
|
||||
max,
|
||||
size = 300,
|
||||
levels = 4,
|
||||
legend = true,
|
||||
tooltip = true,
|
||||
format,
|
||||
title,
|
||||
ariaLabel
|
||||
}: RadarChartProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
function fmt(v: number): string {
|
||||
if (format) return format(v);
|
||||
try {
|
||||
return eidos.format?.numbers?.format?.(v) ?? String(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
const seriesKeys = $derived(Array.isArray(series) ? [...series] : [series as string]);
|
||||
const categories = $derived(data.map((d) => String(d[axis])));
|
||||
const center = $derived(size / 2);
|
||||
const radius = $derived(center - size * 0.16);
|
||||
|
||||
const domainMax = $derived.by(() => {
|
||||
if (max !== undefined) return max;
|
||||
const vals: number[] = [];
|
||||
for (const row of data) for (const k of seriesKeys) {
|
||||
const v = Number(row[k]);
|
||||
if (!Number.isNaN(v)) vals.push(v);
|
||||
}
|
||||
const e = extent(vals) ?? [0, 1];
|
||||
return nice(0, Math.max(1, e[1]), levels)[1];
|
||||
});
|
||||
const rScale = $derived(scaleLinear({ domain: [0, domainMax], range: [0, radius] }));
|
||||
|
||||
const angleFor = (i: number): number =>
|
||||
-Math.PI / 2 + (i * 2 * Math.PI) / Math.max(1, categories.length);
|
||||
function vertex(i: number, r: number): [number, number] {
|
||||
const a = angleFor(i);
|
||||
return [center + r * Math.cos(a), center + r * Math.sin(a)];
|
||||
}
|
||||
const ptsFrom = (mapper: (i: number) => [number, number]): string =>
|
||||
categories
|
||||
.map((_, i) => {
|
||||
const [x, y] = mapper(i);
|
||||
return `${Math.round(x * 100) / 100},${Math.round(y * 100) / 100}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
const rings = $derived(
|
||||
Array.from({ length: levels }, (_, l) => ptsFrom((i) => vertex(i, (radius * (l + 1)) / levels)))
|
||||
);
|
||||
const spokes = $derived(categories.map((_, i) => vertex(i, radius)));
|
||||
const polygons = $derived(
|
||||
seriesKeys.map((key, s) => ({
|
||||
key,
|
||||
fill: seriesColor(s),
|
||||
points: ptsFrom((i) => vertex(i, rScale(Number(data[i][key]) || 0)))
|
||||
}))
|
||||
);
|
||||
const labels = $derived(
|
||||
categories.map((c, i) => {
|
||||
const [x, y] = vertex(i, radius + size * 0.06);
|
||||
const cos = Math.cos(angleFor(i));
|
||||
return { c, x, y, anchor: cos > 0.3 ? 'start' : cos < -0.3 ? 'end' : 'middle' };
|
||||
})
|
||||
);
|
||||
|
||||
// ── Hover tooltip — nearest category by cursor angle ────────────────────
|
||||
let active = $state<number | null>(null);
|
||||
let pointerX = $state(0);
|
||||
let pointerY = $state(0);
|
||||
let figureWidth = $state(0);
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (!tooltip || categories.length === 0) return;
|
||||
const svgRect = (event.currentTarget as Element).getBoundingClientRect();
|
||||
const figure = (event.currentTarget as Element).closest('[data-chart-figure]') as Element;
|
||||
const figRect = figure.getBoundingClientRect();
|
||||
figureWidth = figRect.width;
|
||||
pointerX = event.clientX - figRect.left;
|
||||
pointerY = event.clientY - figRect.top;
|
||||
const a = Math.atan2(event.clientY - svgRect.top - center, event.clientX - svgRect.left - center);
|
||||
const step = (2 * Math.PI) / categories.length;
|
||||
let i = Math.round((a + Math.PI / 2) / step) % categories.length;
|
||||
if (i < 0) i += categories.length;
|
||||
active = i;
|
||||
}
|
||||
const flip = $derived(figureWidth > 0 && pointerX > figureWidth * 0.6);
|
||||
</script>
|
||||
|
||||
<figure data-chart-figure>
|
||||
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
|
||||
<svg
|
||||
data-chart
|
||||
role="img"
|
||||
aria-label={ariaLabel ?? title ?? 'Radar chart'}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 {size} {size}"
|
||||
style="inline-size: {size}px; flex: none; align-self: center;"
|
||||
onpointermove={onPointerMove}
|
||||
onpointerleave={() => (active = null)}
|
||||
>
|
||||
{#each rings as ring}<polygon data-chart-radar-ring points={ring} />{/each}
|
||||
{#each spokes as [x, y]}
|
||||
<line data-chart-radar-spoke x1={center} y1={center} x2={x} y2={y} />
|
||||
{/each}
|
||||
{#each polygons as poly (poly.key)}
|
||||
<polygon data-chart-radar-area points={poly.points} fill={poly.fill} stroke={poly.fill} />
|
||||
{/each}
|
||||
{#if active !== null}
|
||||
{#each seriesKeys as key, s (key)}
|
||||
{@const v = Number(data[active][key])}
|
||||
{#if Number.isFinite(v)}
|
||||
{@const [cx, cy] = vertex(active, rScale(v))}
|
||||
<circle data-chart-active-dot {cx} {cy} fill={seriesColor(s)} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{#each labels as lab}
|
||||
<text data-chart-axis-label x={lab.x} y={lab.y} text-anchor={lab.anchor} dominant-baseline="middle">
|
||||
{lab.c}
|
||||
</text>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if legend && seriesKeys.length > 1}
|
||||
<div data-chart-legend>
|
||||
{#each seriesKeys as key, s (key)}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {seriesColor(s)};"></span>
|
||||
{key}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if tooltip && active !== null}
|
||||
<div
|
||||
data-chart-tooltip
|
||||
data-flip={flip ? 'true' : 'false'}
|
||||
style="left: {pointerX}px; top: {pointerY}px;"
|
||||
>
|
||||
<span data-chart-tooltip-x>{categories[active]}</span>
|
||||
{#each seriesKeys as key, s (key)}
|
||||
{@const v = Number(data[active][key])}
|
||||
{#if Number.isFinite(v)}
|
||||
<span data-chart-tooltip-row>
|
||||
<span data-chart-tooltip-swatch style="background: {seriesColor(s)};"></span>
|
||||
{key}
|
||||
<span data-chart-tooltip-value>{fmt(v)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
/** Ergonomic scatter chart — points per series over a continuous x. */
|
||||
import Chart from './chart.svelte';
|
||||
import Grid from './chart-grid.svelte';
|
||||
import XAxis from './chart-x-axis.svelte';
|
||||
import YAxis from './chart-y-axis.svelte';
|
||||
import Scatter from './chart-scatter.svelte';
|
||||
import { seriesColor } from './context';
|
||||
import type { ScatterChartProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
title,
|
||||
legend = true,
|
||||
noGrid = false,
|
||||
xTicks,
|
||||
yTicks,
|
||||
formatX,
|
||||
formatY,
|
||||
radius,
|
||||
tooltip = true,
|
||||
...frame
|
||||
}: ScatterChartProps = $props();
|
||||
|
||||
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
|
||||
const showLegend = $derived(legend && seriesList.length > 1);
|
||||
</script>
|
||||
|
||||
<figure data-chart-figure>
|
||||
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
|
||||
{#if showLegend}
|
||||
<div data-chart-legend>
|
||||
{#each seriesList as key, i}
|
||||
<span data-chart-legend-item>
|
||||
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
|
||||
{key}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Chart {data} {x} {y} {tooltip} {...frame}>
|
||||
{#if !noGrid}<Grid ticks={yTicks} />{/if}
|
||||
<YAxis ticks={yTicks} format={formatY} />
|
||||
<XAxis ticks={xTicks} format={formatX} />
|
||||
{#each seriesList as key}
|
||||
<Scatter series={key} {radius} />
|
||||
{/each}
|
||||
</Chart>
|
||||
</figure>
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
/** A compact, axis-less trend — line or bars — for inline use in tables, cards, feeds. */
|
||||
import Chart from './chart.svelte';
|
||||
import Area from './chart-area.svelte';
|
||||
import Line from './chart-line.svelte';
|
||||
import Bar from './chart-bar.svelte';
|
||||
import type { SparklineProps } from './types';
|
||||
|
||||
let {
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
as = 'line',
|
||||
curve = 'monotone',
|
||||
area = true,
|
||||
color,
|
||||
height = 40,
|
||||
...frame
|
||||
}: SparklineProps = $props();
|
||||
|
||||
const margin = { top: 3, right: 3, bottom: 3, left: 3 };
|
||||
</script>
|
||||
|
||||
{#if as === 'bars'}
|
||||
<Chart {data} {x} {y} {height} {...frame} xScale="band" padding={0.25} {margin}>
|
||||
<Bar {color} />
|
||||
</Chart>
|
||||
{:else}
|
||||
<Chart {data} {x} {y} {height} {...frame} {margin}>
|
||||
{#if area}<Area {curve} {color} />{/if}
|
||||
<Line {curve} {color} />
|
||||
</Chart>
|
||||
{/if}
|
||||
@ -0,0 +1,223 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { CurveName } from '$libs/plots';
|
||||
import type { ColorRole } from '$uix/eidos/lib/types';
|
||||
|
||||
export type ChartData = readonly Record<string, unknown>[];
|
||||
export type ChartXScaleKind = 'linear' | 'time' | 'band' | 'point';
|
||||
|
||||
export interface ChartMargin {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export interface ChartProps {
|
||||
/** Rows of data; each mark reads its series key off these rows. */
|
||||
data: ChartData;
|
||||
/** Key on each row for the x position. */
|
||||
x: string;
|
||||
/** Series key(s) used to compute the y domain. */
|
||||
y?: string | readonly string[];
|
||||
/** Map x continuously (`linear`/`time`) or categorically (`band`/`point`). Default `linear`. */
|
||||
xScale?: ChartXScaleKind;
|
||||
/** Override the x domain (continuous scales only). */
|
||||
xDomain?: readonly [number, number];
|
||||
/** Override the y domain. Defaults to a 0-anchored, niced extent. */
|
||||
yDomain?: readonly [number, number];
|
||||
/** Plot height in pixels (width fills the container). Default 260. */
|
||||
height?: number;
|
||||
/** Fixed width in pixels. Omit to measure the container. */
|
||||
width?: number;
|
||||
/** Inner margins reserving room for axes. */
|
||||
margin?: ChartMargin;
|
||||
/** Round the y domain to nice numbers. Default true. */
|
||||
niceY?: boolean;
|
||||
/** Band (bar) padding (0..1). Default 0.2. Point scales span edge-to-edge. */
|
||||
padding?: number;
|
||||
/** Enable the hover tooltip (crosshair + value readout). Default false; presets default true. */
|
||||
tooltip?: boolean;
|
||||
/** Accessible label for the chart image. */
|
||||
ariaLabel?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
export interface ChartSeriesProps {
|
||||
/** Series key. Defaults to the first declared series. */
|
||||
series?: string;
|
||||
/** Override colour by role; defaults to the series' palette slot. */
|
||||
color?: ColorRole;
|
||||
}
|
||||
|
||||
export interface ChartLineProps extends ChartSeriesProps {
|
||||
curve?: CurveName;
|
||||
/** Stroke width in pixels. Default 2. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface ChartAreaProps extends ChartSeriesProps {
|
||||
curve?: CurveName;
|
||||
/** Fill opacity. Default 0.15. */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface ChartBarProps extends ChartSeriesProps {
|
||||
/** Corner radius in pixels. Default 2. */
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export interface ChartScatterProps extends ChartSeriesProps {
|
||||
/** Point radius in pixels. Default 3.5. */
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export interface ChartAxisProps {
|
||||
/** Approximate tick count for continuous axes. */
|
||||
ticks?: number;
|
||||
/** Format a tick value to a label. */
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
export interface ChartGridProps {
|
||||
/** Approximate grid-line count. Default 5. */
|
||||
ticks?: number;
|
||||
/** Draw vertical grid lines (at x ticks / categories). Default false. */
|
||||
x?: boolean;
|
||||
/** Draw horizontal grid lines (at y ticks). Default true. */
|
||||
y?: boolean;
|
||||
}
|
||||
|
||||
export interface ChartTooltipProps {
|
||||
/** Format a series value for the tooltip. Defaults to the locale-aware number format. */
|
||||
format?: (value: number, seriesKey: string) => string;
|
||||
}
|
||||
|
||||
// ── Ergonomic presets ──────────────────────────────────────────────────────
|
||||
|
||||
interface PresetBase extends Omit<ChartProps, 'children'> {
|
||||
/** Optional title rendered above the plot. */
|
||||
title?: string;
|
||||
/** Show a legend when there is more than one series. Default true. */
|
||||
legend?: boolean;
|
||||
/** Approximate x tick count. */
|
||||
xTicks?: number;
|
||||
/** Approximate y tick count. */
|
||||
yTicks?: number;
|
||||
/** Format x axis labels. */
|
||||
formatX?: (value: number) => string;
|
||||
/** Format y axis labels. */
|
||||
formatY?: (value: number) => string;
|
||||
/** Hide the grid. Default false. */
|
||||
noGrid?: boolean;
|
||||
/** Show the hover tooltip. Default true. */
|
||||
tooltip?: boolean;
|
||||
}
|
||||
|
||||
export interface LineChartProps extends PresetBase {
|
||||
curve?: CurveName;
|
||||
/** Also fill the area under each line. Default false. */
|
||||
area?: boolean;
|
||||
/** Show a dot at each point. Default false. */
|
||||
dots?: boolean;
|
||||
}
|
||||
|
||||
export interface AreaChartProps extends PresetBase {
|
||||
curve?: CurveName;
|
||||
/** Area fill opacity. Default 0.18. */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends PresetBase {
|
||||
/** Bar corner radius. Default 2. */
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export interface ScatterChartProps extends PresetBase {
|
||||
/** Point radius. Default 3.5. */
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export interface SparklineProps extends Omit<ChartProps, 'children' | 'y'> {
|
||||
/** Single series key. */
|
||||
y: string;
|
||||
/** Render as a line (default) or vertical bars. */
|
||||
as?: 'line' | 'bars';
|
||||
curve?: CurveName;
|
||||
/** Fill the area under the line (line mode only). Default true. */
|
||||
area?: boolean;
|
||||
color?: ColorRole;
|
||||
}
|
||||
|
||||
export interface ChartCategory {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: ColorRole;
|
||||
}
|
||||
|
||||
/** Alias kept for the pie API; same shape as ChartCategory. */
|
||||
export type PieDatum = ChartCategory;
|
||||
|
||||
export interface RadarChartProps {
|
||||
/** Rows; one per axis/category. */
|
||||
data: readonly Record<string, unknown>[];
|
||||
/** Key for the axis (category) label on each row. */
|
||||
axis: string;
|
||||
/** Series key(s) — one polygon each. */
|
||||
series: string | readonly string[];
|
||||
/** Domain max. Default: niced max across all series. */
|
||||
max?: number;
|
||||
/** Diameter in pixels. Default 300. */
|
||||
size?: number;
|
||||
/** Number of concentric grid rings. Default 4. */
|
||||
levels?: number;
|
||||
/** Show the legend (multi-series). Default true. */
|
||||
legend?: boolean;
|
||||
/** Show the themed hover tooltip. Default true. */
|
||||
tooltip?: boolean;
|
||||
/** Format values in the tooltip. Defaults to the locale-aware number format. */
|
||||
format?: (value: number) => string;
|
||||
/** Optional title above the chart. */
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface BarListProps {
|
||||
data: readonly ChartCategory[];
|
||||
/** Default bar colour (role) for items without their own `color`. Default 'primary'. */
|
||||
color?: ColorRole;
|
||||
/** Sort descending by value. Default true. */
|
||||
sort?: boolean;
|
||||
/** Show only the top N items. Default: all. */
|
||||
limit?: number;
|
||||
/** Show the themed hover tooltip (label · value · % of total). Default true. */
|
||||
tooltip?: boolean;
|
||||
/** Format the value. Defaults to the locale-aware number format. */
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
export interface BarSegmentProps {
|
||||
data: readonly ChartCategory[];
|
||||
/** Show the legend below the bar. Default true. */
|
||||
legend?: boolean;
|
||||
/** Show the themed hover tooltip (label · value · %). Default true. */
|
||||
tooltip?: boolean;
|
||||
/** Format values in the legend + tooltip. Defaults to the locale-aware number format. */
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
export interface PieChartProps {
|
||||
data: readonly PieDatum[];
|
||||
/** Diameter in pixels. Default 220. */
|
||||
size?: number;
|
||||
/** Inner radius as a fraction of the outer (0 = pie, 0.6 = donut). Default 0. */
|
||||
donut?: number;
|
||||
/** Gap between slices in radians. Default 0.02. */
|
||||
padAngle?: number;
|
||||
/** Show the legend. Default true. */
|
||||
legend?: boolean;
|
||||
/** Show the hover tooltip (label · value · percentage). Default true. */
|
||||
tooltip?: boolean;
|
||||
/** Optional title above the chart. */
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the chart family. Merged under `components.chart.*` by
|
||||
* `ActiveUix` (via the `componentLangs` barrel). Referenced by absolute
|
||||
* idlangref: `'#?components.chart.{key}|fallback'`.
|
||||
*/
|
||||
export const chartLangs = {
|
||||
label: {
|
||||
es: 'Gráfico',
|
||||
en: 'Chart'
|
||||
},
|
||||
empty: {
|
||||
es: 'Sin datos',
|
||||
en: 'No data'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,145 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Chart — data-visualisation family rendered from the own zero-dependency
|
||||
* plotting engine (`$libs/plots`). Eidos-native display primitive (like Avatar
|
||||
* / QrCode): `data` → scales → SVG. No soma — there is no interactive state,
|
||||
* just a derived render; no sema — a static chart emits no perceptual events.
|
||||
*
|
||||
* `scope: ['eidos']`, 0-event surface. One morfo declares the shared contract
|
||||
* for the whole family: the line / area / bar / scatter presets and the
|
||||
* standalone pie all compose these parts. The Provider is the root
|
||||
* `<svg role="img">` whose `aria-label` carries the meaning; the marks
|
||||
* (line / area / bar / point / slice) are decorative paths the label
|
||||
* summarises; grid + axes are structural guides.
|
||||
*
|
||||
* Parts are optional because each chart type renders a subset — a LineChart
|
||||
* has line (+ area) marks, a BarChart has bars, the pie has slices. Only the
|
||||
* Provider is always present.
|
||||
*/
|
||||
export const chartMorfo = {
|
||||
name: 'Chart',
|
||||
kebab: 'chart',
|
||||
scope: ['eidos'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/#img',
|
||||
texts: {
|
||||
label: '#?components.chart.label|Chart'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
// The plotting surface — the root `<svg>`. `role="img"` + `aria-label`
|
||||
// make the chart a single labelled graphic; the marks inside are
|
||||
// decorative (the label is the text alternative).
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'svg',
|
||||
role: 'img',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }]
|
||||
},
|
||||
{
|
||||
// Horizontal grid lines at the value-axis ticks. Decorative.
|
||||
name: 'Grid',
|
||||
kebab: 'grid',
|
||||
archetype: 'separator',
|
||||
kind: 'public',
|
||||
defaultElement: 'g',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
// A value / category axis with ticks + labels. `data-chart-axis`
|
||||
// carries which axis it is. Decorative (labels are visual guides).
|
||||
name: 'Axis',
|
||||
kebab: 'axis',
|
||||
archetype: 'separator',
|
||||
kind: 'public',
|
||||
defaultElement: 'g',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-chart-axis', values: ['x', 'y'] }],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
// A line mark — one polyline per series.
|
||||
name: 'Line',
|
||||
kebab: 'line',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'path',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// A filled area mark between the baseline and a series.
|
||||
name: 'Area',
|
||||
kebab: 'area',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'path',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// A bar mark — one rect per datum (grouped across series).
|
||||
name: 'Bar',
|
||||
kebab: 'bar',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'rect',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// A scatter point — one circle per datum.
|
||||
name: 'Point',
|
||||
kebab: 'point',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'circle',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// A pie / donut slice.
|
||||
name: 'Slice',
|
||||
kebab: 'slice',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'path',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// Series legend (HTML chrome composed around the plot).
|
||||
name: 'Legend',
|
||||
kebab: 'legend',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// Optional chart title.
|
||||
name: 'Title',
|
||||
kebab: 'title',
|
||||
archetype: 'title',
|
||||
kind: 'public',
|
||||
defaultElement: 'figcaption',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,243 @@
|
||||
<script lang="ts">
|
||||
import { AreaChart } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
import type { CurveName } from '$libs/plots';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const data = months.map((month, i) => ({
|
||||
month,
|
||||
revenue: [42, 48, 55, 51, 67, 72, 69, 78, 74, 83, 88, 95][i],
|
||||
expenses: [30, 33, 37, 35, 44, 46, 45, 50, 48, 53, 55, 58][i]
|
||||
}));
|
||||
|
||||
let curve = $state<CurveName>('monotone');
|
||||
let opacity = $state(0.18);
|
||||
let multi = $state(false);
|
||||
let legend = $state(true);
|
||||
let grid = $state(true);
|
||||
let niceY = $state(true);
|
||||
let height = $state(260);
|
||||
let showTooltip = $state(true);
|
||||
|
||||
const curves: CurveName[] = ['monotone', 'linear', 'step'];
|
||||
const opacities = [0.1, 0.18, 0.3];
|
||||
const heights = [200, 260, 320];
|
||||
const seriesKeys = $derived(multi ? ['revenue', 'expenses'] : 'revenue');
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const money = (v: number) => `${v}k`;
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { AreaChart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // rows keyed by x (a month), one column per series',
|
||||
' const data = [',
|
||||
" { month: 'Jan', revenue: 42, expenses: 30 },",
|
||||
" { month: 'Feb', revenue: 48, expenses: 33 }",
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<AreaChart',
|
||||
' {data}',
|
||||
' x="month"',
|
||||
' xScale="point"',
|
||||
multi ? " y={['revenue', 'expenses']}" : ' y="revenue"',
|
||||
curve !== 'monotone' && ` curve="${curve}"`,
|
||||
opacity !== 0.18 && ` opacity={${opacity}}`,
|
||||
!legend && ' legend={false}',
|
||||
!grid && ' noGrid',
|
||||
!niceY && ' niceY={false}',
|
||||
height !== 260 && ` height={${height}}`,
|
||||
' formatY={(v) => `${v}k`}',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Area chart</div>
|
||||
<h1 data-uix-page-title>Area chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
Filled area with a line edge per series, on the zero-dependency <code>$libs/plots</code> engine.
|
||||
Smooth / straight / stepped curves; tunable fill opacity; single or stacked-by-series.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>curve</span>{curve}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area>
|
||||
<AreaChart {data} x="month" xScale="point" y={seriesKeys} {curve} {opacity} {legend} {height} {niceY} tooltip={showTooltip} noGrid={!grid} formatY={money} ariaLabel="Monthly revenue area" />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · {curve} · opacity {opacity}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>curve</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each curves as c}<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>fill opacity</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each opacities as o}<button data-uix-chip data-active={opacity === o} onclick={() => (opacity = o)}>{o}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>height</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>series</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'revenue + expenses' : 'revenue only'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>legend</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>grid</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>niceY</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>AreaChart preset</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
|
||||
<AreaChart {data} x="month" xScale="point" y="revenue" title="Default" formatY={money} height={200} />
|
||||
<AreaChart {data} x="month" xScale="point" y="revenue" curve="step" opacity={0.3} title="Stepped · denser" formatY={money} height={200} />
|
||||
<AreaChart {data} x="month" xScale="point" y={['revenue', 'expenses']} title="Two series" formatY={money} height={200} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
|
||||
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x position key.</td></tr>
|
||||
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s).</td></tr>
|
||||
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>x mapping.</td></tr>
|
||||
<tr><td class="name">curve</td><td class="type">'monotone' | 'linear' | 'step'</td><td class="default">'monotone'</td><td>Point connection.</td></tr>
|
||||
<tr><td class="name">opacity</td><td class="type">number</td><td class="default">0.18</td><td>Area fill opacity.</td></tr>
|
||||
<tr><td class="name">legend / noGrid / niceY</td><td class="type">boolean</td><td class="default">true / false / true</td><td>Chrome toggles.</td></tr>
|
||||
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height.</td></tr>
|
||||
<tr><td class="name">formatX / formatY</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Axis label formatters.</td></tr>
|
||||
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. The chart family shares one <code>scope:['eidos']</code> contract.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">series / area</td><td class="type">var(--color-{'{'}role{'}'}-solid) + fill-opacity</td></tr>
|
||||
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
|
||||
<tr><td class="name">labels / title</td><td class="type">var(--color-content-secondary / -primary)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { BarChart } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const data = [
|
||||
{ quarter: 'Q1', productA: 45, productB: 30 },
|
||||
{ quarter: 'Q2', productA: 52, productB: 41 },
|
||||
{ quarter: 'Q3', productA: 48, productB: 55 },
|
||||
{ quarter: 'Q4', productA: 67, productB: 49 }
|
||||
];
|
||||
|
||||
let multi = $state(true);
|
||||
let radius = $state(2);
|
||||
let legend = $state(true);
|
||||
let grid = $state(true);
|
||||
let niceY = $state(true);
|
||||
let height = $state(260);
|
||||
let showTooltip = $state(true);
|
||||
|
||||
const radii = [0, 2, 6];
|
||||
const heights = [200, 260, 320];
|
||||
const seriesKeys = $derived(multi ? ['productA', 'productB'] : 'productA');
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { BarChart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // rows keyed by x (a quarter), one column per series',
|
||||
' const data = [',
|
||||
" { quarter: 'Q1', productA: 45, productB: 30 },",
|
||||
" { quarter: 'Q2', productA: 52, productB: 41 }",
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<BarChart',
|
||||
' {data}',
|
||||
' x="quarter"',
|
||||
multi ? " y={['productA', 'productB']}" : ' y="productA"',
|
||||
radius !== 2 && ` radius={${radius}}`,
|
||||
!legend && ' legend={false}',
|
||||
!grid && ' noGrid',
|
||||
!niceY && ' niceY={false}',
|
||||
height !== 260 && ` height={${height}}`,
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Bar chart</div>
|
||||
<h1 data-uix-page-title>Bar chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
Categorical bars on a band scale, grouped side-by-side across series, on the zero-dependency
|
||||
<code>$libs/plots</code> engine. Rounded corners; 0-anchored value axis; themed per series.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>scale</span>band</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area>
|
||||
<BarChart {data} x="quarter" y={seriesKeys} {radius} {legend} {height} {niceY} tooltip={showTooltip} noGrid={!grid} ariaLabel="Quarterly sales by product" />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · radius {radius}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>corner radius</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each radii as r}<button data-uix-chip data-active={radius === r} onclick={() => (radius = r)}>{r}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>height</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>series</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'grouped (A + B)' : 'productA only'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>legend</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>grid</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>niceY</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>BarChart preset</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
|
||||
<BarChart {data} x="quarter" y="productA" title="Single series" height={200} />
|
||||
<BarChart {data} x="quarter" y="productA" radius={6} title="Rounded" height={200} />
|
||||
<BarChart {data} x="quarter" y={['productA', 'productB']} title="Grouped" height={200} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
|
||||
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>Category key (band scale).</td></tr>
|
||||
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s); multiple → grouped.</td></tr>
|
||||
<tr><td class="name">radius</td><td class="type">number</td><td class="default">2</td><td>Bar corner radius.</td></tr>
|
||||
<tr><td class="name">legend / noGrid / niceY</td><td class="type">boolean</td><td class="default">true / false / true</td><td>Chrome toggles.</td></tr>
|
||||
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height.</td></tr>
|
||||
<tr><td class="name">yDomain</td><td class="type">[number, number]</td><td class="default">0-anchored</td><td>Override the y domain.</td></tr>
|
||||
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>Bars require a band x scale; <code>BarChart</code> sets <code>xScale="band"</code> automatically.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. The chart family shares one <code>scope:['eidos']</code> contract.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">bar</td><td class="type">var(--color-{'{'}role{'}'}-solid) per series</td></tr>
|
||||
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
|
||||
<tr><td class="name">labels / title</td><td class="type">var(--color-content-secondary / -primary)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { BarList, type ChartCategory } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
import type { ColorRole } from '$uix/eidos/lib/types';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
// Deliberately NOT in value order, so the `sort` prop has a visible effect.
|
||||
const data: ChartCategory[] = [
|
||||
{ label: '/docs', value: 2400 },
|
||||
{ label: '/home', value: 4200 },
|
||||
{ label: '/about', value: 900 },
|
||||
{ label: '/blog', value: 1800 },
|
||||
{ label: '/pricing', value: 3100 }
|
||||
];
|
||||
const colored: ChartCategory[] = [
|
||||
{ label: 'Passed', value: 86, color: 'affirm' },
|
||||
{ label: 'Flaky', value: 9, color: 'risk' },
|
||||
{ label: 'Failed', value: 5, color: 'threat' }
|
||||
];
|
||||
|
||||
let barColor = $state<ColorRole>('primary');
|
||||
let showTooltip = $state(true);
|
||||
let sort = $state(true);
|
||||
let limited = $state(false);
|
||||
const limit = $derived(limited ? 3 : undefined);
|
||||
|
||||
const colorRoles: ColorRole[] = ['primary', 'secondary', 'affirm', 'fulfill', 'risk', 'threat'];
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { BarList } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // each row is { label, value, color? } — `color` (a ColorRole) is optional, per row',
|
||||
' const data = [',
|
||||
" { label: '/docs', value: 2400 },",
|
||||
" { label: '/home', value: 4200 },",
|
||||
" { label: '/about', value: 900 },",
|
||||
" { label: '/blog', value: 1800 },",
|
||||
" { label: '/pricing', value: 3100 }",
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<BarList',
|
||||
' {data}',
|
||||
` color="${barColor}"`,
|
||||
!sort && ' sort={false}',
|
||||
limited && ' limit={3}',
|
||||
!showTooltip && ' tooltip={false}',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Bar list</div>
|
||||
<h1 data-uix-page-title>Bar list</h1>
|
||||
<p data-uix-page-lede>
|
||||
A ranked list of horizontal bars — label over a proportional bar, value at the end. Pure HTML/CSS
|
||||
(no SVG, no scales): each bar's width is its value relative to the max. Themed per row from the
|
||||
palette; values formatted through the locale-aware engine. Chakra bar-list style.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>html · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>family</span>chart</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>items</span>{limit ?? data.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>sorted</span>{sort ? 'yes' : 'no'}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area style="max-inline-size: 460px;">
|
||||
<BarList {data} color={barColor} {sort} {limit} tooltip={showTooltip} />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{limit ?? data.length} rows</span> · {sort ? 'sorted desc' : 'source order'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pass <code>data</code> as an array of <code>{`{ label, value, color? }`}</code> — one row each. Every control changes the live chart; the code below reflects the current props.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>color</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each colorRoles as r}<button data-uix-chip data-active={barColor === r} onclick={() => (barColor = r)}>{r}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>sort (desc)</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={sort} /><span data-uix-switch-label>{sort ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>limit to 3</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={limited} /><span data-uix-switch-label>{limited ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>BarList</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<p data-uix-section-desc>Default (sorted), source order, top-N, and explicit per-row intent colours.</p>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Sorted</div><BarList {data} /></div>
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Source order</div><BarList {data} sort={false} /></div>
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Top 3</div><BarList {data} limit={3} /></div>
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Intent colours</div><BarList data={colored} sort={false} /></div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`{ label, value, color? }[]`}</td><td class="default empty">—</td><td>Rows; per-item <code>color</code> is a ColorRole.</td></tr>
|
||||
<tr><td class="name">color</td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Default bar colour for items without their own.</td></tr>
|
||||
<tr><td class="name">sort</td><td class="type">boolean</td><td class="default">true</td><td>Sort descending by value.</td></tr>
|
||||
<tr><td class="name">limit</td><td class="type">number</td><td class="default empty">—</td><td>Show only the top N rows.</td></tr>
|
||||
<tr><td class="name">tooltip</td><td class="type">boolean</td><td class="default">true</td><td>Show the themed hover tooltip (label · value · % of total).</td></tr>
|
||||
<tr><td class="name">format</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Value formatter (defaults to the locale-aware number format).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · chart family contract</h2>
|
||||
<p data-uix-section-desc>Bar list is a pure HTML/CSS sub-surface of the <code>chart</code> family (<code>scope:['eidos']</code>, 0 events) — it doesn't use the SVG plotting parts below (provider / axes / marks); it emits its own <code>data-chart-bar-list-*</code> structure. The shared family contract:</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>Global theme tokens (no <code>--chart-*</code> recipe), so rows re-tint with the theme.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">bar</td><td class="type">var(--color-{'{'}role{'}'}-solid) — opaque, per row</td></tr>
|
||||
<tr><td class="name">track</td><td class="type">var(--color-surface-muted) · height var(--control-height-sm)</td></tr>
|
||||
<tr><td class="name">label / value</td><td class="type">var(--color-content-primary / -secondary)</td></tr>
|
||||
<tr><td class="name">radius / gap</td><td class="type">var(--radius-sm) / var(--space-*)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Element</th><th>Role / attribute</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">row</td><td class="type">label + value text</td><td>Both read by the screen reader; order follows the sorted ranking.</td></tr>
|
||||
<tr><td class="name">bar</td><td class="type">decorative</td><td>The proportional fill is visual only; the value carries the meaning.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p data-uix-section-desc style="margin-top: 0.75rem;">Put a self-describing string in each <code>label</code> (e.g. "/home — 4,200 views").</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { BarSegment, type ChartCategory } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const data: ChartCategory[] = [
|
||||
{ label: 'Direct', value: 38 },
|
||||
{ label: 'Organic', value: 27 },
|
||||
{ label: 'Referral', value: 18 },
|
||||
{ label: 'Social', value: 12 },
|
||||
{ label: 'Email', value: 5 }
|
||||
];
|
||||
const status: ChartCategory[] = [
|
||||
{ label: 'Healthy', value: 72, color: 'affirm' },
|
||||
{ label: 'Degraded', value: 21, color: 'risk' },
|
||||
{ label: 'Down', value: 7, color: 'threat' }
|
||||
];
|
||||
|
||||
let legend = $state(true);
|
||||
let showTooltip = $state(true);
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { BarSegment } from '$uix/eidos/components/chart';",
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<BarSegment',
|
||||
' data={channels}',
|
||||
!legend && ' legend={false}',
|
||||
!showTooltip && ' tooltip={false}',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Bar segment</div>
|
||||
<h1 data-uix-page-title>Bar segment</h1>
|
||||
<p data-uix-page-lede>
|
||||
A single horizontal bar split into proportional segments (parts of a whole), with an optional
|
||||
legend and a themed hover tooltip. Pure HTML/CSS, themed per segment; values formatted through the
|
||||
locale-aware engine. Chakra bar-segment style.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>html · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>family</span>chart</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>segments</span>{data.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>whole</span>100%</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area style="max-inline-size: 520px;">
|
||||
<BarSegment {data} {legend} tooltip={showTooltip} />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{data.length} segments</span> · hover for label · value · %</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above. Hover a segment for the tooltip.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>legend</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>BarSegment</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<p data-uix-section-desc>Default, no legend, and explicit intent colours.</p>
|
||||
<div data-uix-stage-area style="display: flex; flex-direction: column; gap: 1.5rem; max-inline-size: 520px;">
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Traffic channels</div><BarSegment {data} /></div>
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">No legend</div><BarSegment {data} legend={false} /></div>
|
||||
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Service health (intent colours)</div><BarSegment data={status} /></div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`{ label, value, color? }[]`}</td><td class="default empty">—</td><td>Segments; <code>color</code> is a ColorRole.</td></tr>
|
||||
<tr><td class="name">legend</td><td class="type">boolean</td><td class="default">true</td><td>Show the legend below the bar.</td></tr>
|
||||
<tr><td class="name">tooltip</td><td class="type">boolean</td><td class="default">true</td><td>Show the themed hover tooltip.</td></tr>
|
||||
<tr><td class="name">format</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Value formatter (defaults to the locale-aware number format).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · chart family contract</h2>
|
||||
<p data-uix-section-desc>Bar segment is a pure HTML/CSS sub-surface of the <code>chart</code> family (<code>scope:['eidos']</code>, 0 events) — it emits its own <code>data-chart-bar-segment-*</code> structure, not the SVG plotting parts below. The shared family contract:</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>Global theme tokens; the tooltip reuses the chart's <code>--chart-tooltip-*</code> surface so it matches every other chart.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">segment fill</td><td class="type">var(--color-{'{'}role{'}'}-solid) per segment</td></tr>
|
||||
<tr><td class="name">dim (non-hovered)</td><td class="type">opacity var(--opacity-scrim)</td></tr>
|
||||
<tr><td class="name">track height / radius</td><td class="type">var(--control-height-sm) / var(--radius-md)</td></tr>
|
||||
<tr><td class="name">tooltip</td><td class="type">var(--chart-tooltip-bg / -border / -shadow)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Element</th><th>Role / attribute</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">segment</td><td class="type">decorative</td><td>Width is visual; the legend carries label · value · %.</td></tr>
|
||||
<tr><td class="name">legend</td><td class="type">text</td><td>Read in order; the value + percentage are the accessible content.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p data-uix-section-desc style="margin-top: 0.75rem;">The hover tooltip is a pointer affordance; the legend is the keyboard / screen-reader equivalent.</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Chart,
|
||||
LineChart,
|
||||
AreaChart,
|
||||
BarChart,
|
||||
ScatterChart,
|
||||
Sparkline,
|
||||
PieChart,
|
||||
type PieDatum
|
||||
} from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
import type { CurveName } from '$libs/plots';
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const revenue = months.map((month, i) => ({
|
||||
month,
|
||||
revenue: [42, 48, 55, 51, 67, 72, 69, 78, 74, 83, 88, 95][i],
|
||||
expenses: [30, 33, 37, 35, 44, 46, 45, 50, 48, 53, 55, 58][i]
|
||||
}));
|
||||
const sales = [
|
||||
{ quarter: 'Q1', productA: 45, productB: 30 },
|
||||
{ quarter: 'Q2', productA: 52, productB: 41 },
|
||||
{ quarter: 'Q3', productA: 48, productB: 55 },
|
||||
{ quarter: 'Q4', productA: 67, productB: 49 }
|
||||
];
|
||||
const scatter = [5, 12, 18, 25, 31, 38, 44, 52, 60, 68].map((x, i) => ({
|
||||
x,
|
||||
y: [12, 19, 15, 28, 24, 35, 31, 42, 38, 49][i]
|
||||
}));
|
||||
const traffic: PieDatum[] = [
|
||||
{ label: 'Direct', value: 38 },
|
||||
{ label: 'Organic', value: 27 },
|
||||
{ label: 'Referral', value: 18 },
|
||||
{ label: 'Social', value: 12 },
|
||||
{ label: 'Email', value: 5 }
|
||||
];
|
||||
const spark = [4, 6, 5, 8, 7, 9, 8, 11, 10, 13, 12, 15, 14, 17].map((v, i) => ({ i, v }));
|
||||
|
||||
let curve = $state<CurveName>('monotone');
|
||||
let area = $state(true);
|
||||
let multi = $state(true);
|
||||
|
||||
const curves: CurveName[] = ['monotone', 'linear', 'step'];
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const money = (v: number) => `${v}k`;
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Chart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // rows keyed by x (a month), one column per series',
|
||||
' const data = [',
|
||||
" { month: 'Jan', revenue: 42, expenses: 30 },",
|
||||
" { month: 'Feb', revenue: 48, expenses: 33 }",
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<Chart',
|
||||
' {data}',
|
||||
' x="month"',
|
||||
' xScale="point"',
|
||||
multi ? " y={['revenue', 'expenses']}" : ' y="revenue"',
|
||||
'>',
|
||||
' <Chart.Grid />',
|
||||
' <Chart.YAxis format={(v) => `${v}k`} />',
|
||||
' <Chart.XAxis />',
|
||||
area && ' <Chart.Area series="revenue" />',
|
||||
' <Chart.Line series="revenue" ' + `curve="${curve}"` + ' />',
|
||||
multi && ` <Chart.Line series="expenses" curve="${curve}" />`,
|
||||
' <Chart.Tooltip />',
|
||||
' <Chart.Legend />',
|
||||
'</Chart>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Chart</div>
|
||||
<h1 data-uix-page-title>Chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
The compositional chart frame and the chart family overview. <code><Chart></code> measures its
|
||||
width, computes the scales from <code>data</code> + <code>x</code>/<code>y</code> via the
|
||||
zero-dependency <code>$libs/plots</code> engine, and shares them with SVG children
|
||||
(<code>Grid</code> / <code>XAxis</code> / <code>YAxis</code> / <code>Line</code> /
|
||||
<code>Area</code> / <code>Bar</code> / <code>Scatter</code>). The ergonomic presets —
|
||||
<code>LineChart</code>, <code>AreaChart</code>, <code>BarChart</code>, <code>ScatterChart</code>,
|
||||
<code>Sparkline</code>, <code>PieChart</code> — compose these for you (one demo each in the
|
||||
Charts group).
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>events</span>0</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>scope</span>eidos</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area>
|
||||
<Chart data={revenue} x="month" xScale="point" y={multi ? ['revenue', 'expenses'] : 'revenue'} height={260} ariaLabel="Revenue overview">
|
||||
<Chart.Grid />
|
||||
<Chart.YAxis format={money} />
|
||||
<Chart.XAxis />
|
||||
{#if area}<Chart.Area series="revenue" {curve} />{/if}
|
||||
<Chart.Line series="revenue" {curve} />
|
||||
{#if multi}<Chart.Line series="expenses" {curve} />{/if}
|
||||
<Chart.Tooltip />
|
||||
<Chart.Legend />
|
||||
</Chart>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>compositional</span> · {multi ? 2 : 1} series · {curve}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>scope</span> eidos · 0 events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>0</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — the controls drive the compositional chart above; the gallery shows the presets.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>curve</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each curves as c}<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>area fill</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={area} /><span data-uix-switch-label>{area ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>series</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'revenue + expenses' : 'revenue only'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>compositional <Chart></span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
|
||||
<h2 data-uix-section-title style="margin-top: 1.5rem;">The family</h2>
|
||||
<p data-uix-section-desc>Each preset has its own demo page under Charts.</p>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
|
||||
<LineChart data={revenue} x="month" xScale="point" y={['revenue', 'expenses']} title="LineChart" formatY={money} height={190} />
|
||||
<AreaChart data={revenue} x="month" xScale="point" y="revenue" title="AreaChart" formatY={money} height={190} />
|
||||
<BarChart data={sales} x="quarter" y={['productA', 'productB']} title="BarChart" height={190} />
|
||||
<ScatterChart data={scatter} x="x" y="y" xScale="linear" title="ScatterChart" height={190} />
|
||||
<PieChart data={traffic} size={170} donut={0.55} title="PieChart" ariaLabel="Traffic" />
|
||||
<div><div style="font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; color: var(--color-content-primary);">Sparkline</div><Sparkline data={spark} x="i" y="v" /></div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference — <Chart> frame</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
|
||||
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x position key.</td></tr>
|
||||
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s) for the y domain.</td></tr>
|
||||
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>x mapping.</td></tr>
|
||||
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height (width fills container).</td></tr>
|
||||
<tr><td class="name">margin / padding / niceY / yDomain</td><td class="type">various</td><td class="default empty">—</td><td>Layout + domain tuning.</td></tr>
|
||||
<tr><td class="name">children</td><td class="type">Snippet</td><td class="default empty">—</td><td>SVG parts: Grid / XAxis / YAxis / Line / Area / Bar / Scatter.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>Parts (<code>Chart.Line</code>, <code>Chart.Bar</code>, …) take <code>series</code>, <code>color</code> (ColorRole) and per-mark options (<code>curve</code>, <code>radius</code>, <code>opacity</code>). See each preset's demo for the full surface.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. <code>scope:['eidos']</code> — a display family, 0 events. One contract for the whole family.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> · perceptual events</h2>
|
||||
<p data-uix-section-desc>
|
||||
<strong>No events.</strong> A static chart is a derived render (<code>data</code> → scales →
|
||||
SVG); <code>scope: ['eidos']</code>, like <code>Avatar</code>, <code>Image</code> and
|
||||
<code>QrCode</code>. It emits no perceptual signal, so there is nothing for sema to project.
|
||||
Hover tooltips (a future, opt-in interaction) will introduce <code>contact</code> events at
|
||||
that point — see the component README · Gaps.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — the chart styles from global theme tokens directly, so series re-tint with the theme. <code>chart.css</code> is code-split (imported from <code>chart.svelte</code>).</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">series</td><td class="type">var(--color-{'{'}role{'}'}-solid) · primary · affirm · risk · …</td></tr>
|
||||
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
|
||||
<tr><td class="name">labels / title</td><td class="type">var(--color-content-secondary / -primary)</td></tr>
|
||||
<tr><td class="name">pie separator</td><td class="type">var(--color-surface-default)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true" (decorative)</td></tr>
|
||||
<tr><td class="name">marks</td><td>—</td><td class="type">summarised by the provider label</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p data-uix-section-desc style="margin-top: 0.75rem;">Always pass <code>ariaLabel</code> with a one-line summary of the chart. A keyboard-navigable data-table fallback is a planned P2 enhancement (README · Gaps).</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { LineChart, type LineChartProps } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
import type { CurveName } from '$libs/plots';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const data = months.map((month, i) => ({
|
||||
month,
|
||||
revenue: [42, 48, 55, 51, 67, 72, 69, 78, 74, 83, 88, 95][i],
|
||||
expenses: [30, 33, 37, 35, 44, 46, 45, 50, 48, 53, 55, 58][i]
|
||||
}));
|
||||
|
||||
// ── Controls — one per public prop ──────────────────────────────────────
|
||||
let curve = $state<CurveName>('monotone');
|
||||
let area = $state(false);
|
||||
let dots = $state(false);
|
||||
let multi = $state(true);
|
||||
let legend = $state(true);
|
||||
let grid = $state(true);
|
||||
let niceY = $state(true);
|
||||
let height = $state(260);
|
||||
let showTooltip = $state(true);
|
||||
|
||||
const curves: CurveName[] = ['monotone', 'linear', 'step'];
|
||||
const heights = [200, 260, 320];
|
||||
|
||||
const seriesKeys = $derived<LineChartProps['y']>(multi ? ['revenue', 'expenses'] : 'revenue');
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { LineChart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // rows keyed by x (a month), one column per series',
|
||||
' const data = [',
|
||||
" { month: 'Jan', revenue: 42, expenses: 30 },",
|
||||
" { month: 'Feb', revenue: 48, expenses: 33 }",
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<LineChart',
|
||||
' {data}',
|
||||
' x="month"',
|
||||
' xScale="point"',
|
||||
multi ? " y={['revenue', 'expenses']}" : ' y="revenue"',
|
||||
curve !== 'monotone' && ` curve="${curve}"`,
|
||||
area && ' area',
|
||||
dots && ' dots',
|
||||
!legend && ' legend={false}',
|
||||
!grid && ' noGrid',
|
||||
!niceY && ' niceY={false}',
|
||||
height !== 260 && ` height={${height}}`,
|
||||
' formatY={(v) => `${v}k`}',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
|
||||
const money = (v: number) => `${v}k`;
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Line chart</div>
|
||||
<h1 data-uix-page-title>Line chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
Multi-series line over a continuous or categorical x, built on the in-house
|
||||
<strong>zero-dependency</strong> <code>$libs/plots</code> engine. Smooth (monotone), straight or
|
||||
stepped curves; optional area fill and point dots; themed per series from the palette.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>curve</span>{curve}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area>
|
||||
<LineChart
|
||||
{data}
|
||||
x="month"
|
||||
xScale="point"
|
||||
y={seriesKeys}
|
||||
{curve}
|
||||
{area}
|
||||
{dots}
|
||||
{legend}
|
||||
{height}
|
||||
{niceY}
|
||||
tooltip={showTooltip}
|
||||
noGrid={!grid}
|
||||
formatY={money}
|
||||
ariaLabel="Monthly revenue versus expenses"
|
||||
/>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · {curve}{area ? ' · area' : ''}{dots ? ' · dots' : ''}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
Pure <span data-uix-layer-badge="eidos">eidos</span> display — no soma, no sema. Every control
|
||||
changes the live chart above.
|
||||
</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>curve</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each curves as c}
|
||||
<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>height</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each heights as h}
|
||||
<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>series</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'revenue + expenses' : 'revenue only'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>area fill</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={area} /><span data-uix-switch-label>{area ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>dots</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={dots} /><span data-uix-switch-label>{dots ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>legend</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>grid</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>niceY</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>LineChart preset</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<p data-uix-section-desc>Curve styles, area fill and single vs multi-series — all themed from the palette.</p>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
|
||||
<LineChart {data} x="month" xScale="point" y="revenue" title="Monotone" formatY={money} height={200} />
|
||||
<LineChart {data} x="month" xScale="point" y="revenue" curve="step" title="Stepped" formatY={money} height={200} />
|
||||
<LineChart {data} x="month" xScale="point" y="revenue" area title="Area fill" formatY={money} height={200} />
|
||||
<LineChart {data} x="month" xScale="point" y={['revenue', 'expenses']} dots title="Two series · dots" formatY={money} height={200} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
|
||||
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>Key on each row for the x position.</td></tr>
|
||||
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s).</td></tr>
|
||||
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>How x maps to pixels.</td></tr>
|
||||
<tr><td class="name">curve</td><td class="type">'monotone' | 'linear' | 'step'</td><td class="default">'monotone'</td><td>Point connection.</td></tr>
|
||||
<tr><td class="name">area</td><td class="type">boolean</td><td class="default">false</td><td>Fill under each line.</td></tr>
|
||||
<tr><td class="name">dots</td><td class="type">boolean</td><td class="default">false</td><td>Dot at each point.</td></tr>
|
||||
<tr><td class="name">legend</td><td class="type">boolean</td><td class="default">true</td><td>Show legend (multi-series).</td></tr>
|
||||
<tr><td class="name">noGrid</td><td class="type">boolean</td><td class="default">false</td><td>Hide the grid.</td></tr>
|
||||
<tr><td class="name">niceY</td><td class="type">boolean</td><td class="default">true</td><td>Round the y domain to nice numbers.</td></tr>
|
||||
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height (width fills the container).</td></tr>
|
||||
<tr><td class="name">yDomain</td><td class="type">[number, number]</td><td class="default">0-anchored extent</td><td>Override the y domain.</td></tr>
|
||||
<tr><td class="name">formatX / formatY</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Format axis labels.</td></tr>
|
||||
<tr><td class="name">title</td><td class="type">string</td><td class="default empty">—</td><td>Title above the plot.</td></tr>
|
||||
<tr><td class="name">ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name of the graphic.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. <code>scope:['eidos']</code> — a display family, no soma/sema. The whole chart family shares this contract.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.role ?? '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — the chart styles from global theme tokens directly, so series re-tint with the theme.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">series</td><td class="type">var(--color-{'{'}role{'}'}-solid) · primary · affirm · risk · …</td></tr>
|
||||
<tr><td class="name">axis line / tick</td><td class="type">var(--color-border-default)</td></tr>
|
||||
<tr><td class="name">grid line</td><td class="type">var(--color-border-subtle)</td></tr>
|
||||
<tr><td class="name">axis label</td><td class="type">var(--color-content-secondary)</td></tr>
|
||||
<tr><td class="name">title</td><td class="type">var(--color-content-primary)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true" (decorative guides)</td></tr>
|
||||
<tr><td class="name">marks</td><td>—</td><td class="type">summarised by the provider label</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p data-uix-section-desc style="margin-top: 0.75rem;">A keyboard-navigable data table fallback is a planned P2 enhancement (see component README · Gaps).</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { PieChart, type PieDatum } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const data: PieDatum[] = [
|
||||
{ label: 'Direct', value: 38 },
|
||||
{ label: 'Organic', value: 27 },
|
||||
{ label: 'Referral', value: 18 },
|
||||
{ label: 'Social', value: 12 },
|
||||
{ label: 'Email', value: 5 }
|
||||
];
|
||||
|
||||
let donut = $state(0);
|
||||
let padAngle = $state(0.02);
|
||||
let size = $state(220);
|
||||
let legend = $state(true);
|
||||
let showTooltip = $state(true);
|
||||
|
||||
const donuts = [0, 0.4, 0.6];
|
||||
const pads = [0, 0.02, 0.05];
|
||||
const sizes = [180, 220, 260];
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { PieChart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // slices: { label, value, color? }',
|
||||
' const data = [',
|
||||
" { label: 'Direct', value: 38 },",
|
||||
" { label: 'Organic', value: 27 }",
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<PieChart',
|
||||
' {data}',
|
||||
donut !== 0 && ` donut={${donut}}`,
|
||||
padAngle !== 0.02 && ` padAngle={${padAngle}}`,
|
||||
size !== 220 && ` size={${size}}`,
|
||||
!legend && ' legend={false}',
|
||||
' ariaLabel="Traffic sources"',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Pie chart</div>
|
||||
<h1 data-uix-page-title>Pie chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
Part-to-whole slices (pie or donut) from the zero-dependency <code>$libs/plots</code> arc engine.
|
||||
Slices follow input order, themed from the palette; tune the hole size and the gap between slices.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>slices</span>{data.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>shape</span>{donut > 0 ? 'donut' : 'pie'}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area style="justify-content: center;">
|
||||
<PieChart {data} {donut} {padAngle} {size} {legend} tooltip={showTooltip} ariaLabel="Traffic sources" />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{donut > 0 ? 'donut' : 'pie'}</span> · {data.length} slices · pad {padAngle}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>size</span> {size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>donut (inner radius)</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each donuts as d}<button data-uix-chip data-active={donut === d} onclick={() => (donut = d)}>{d === 0 ? 'pie' : d}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>pad angle</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each pads as p}<button data-uix-chip data-active={padAngle === p} onclick={() => (padAngle = p)}>{p}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each sizes as s}<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>legend</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>PieChart (standalone)</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<div data-uix-stage-area style="display: flex; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<PieChart {data} size={180} title="Pie" ariaLabel="Traffic sources pie" />
|
||||
<PieChart {data} size={180} donut={0.6} title="Donut" ariaLabel="Traffic sources donut" />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`{ label, value, color? }[]`}</td><td class="default empty">—</td><td>Slices, in render order.</td></tr>
|
||||
<tr><td class="name">size</td><td class="type">number</td><td class="default">220</td><td>Diameter in pixels.</td></tr>
|
||||
<tr><td class="name">donut</td><td class="type">number</td><td class="default">0</td><td>Inner radius fraction (0 = pie, 0.6 = donut).</td></tr>
|
||||
<tr><td class="name">padAngle</td><td class="type">number</td><td class="default">0.02</td><td>Gap between slices in radians.</td></tr>
|
||||
<tr><td class="name">legend</td><td class="type">boolean</td><td class="default">true</td><td>Show the legend.</td></tr>
|
||||
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. Pie uses the provider + slice + legend parts of the shared <code>scope:['eidos']</code> contract.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">slice</td><td class="type">var(--color-{'{'}role{'}'}-solid) per slice</td></tr>
|
||||
<tr><td class="name">slice separator</td><td class="type">var(--color-surface-default)</td></tr>
|
||||
<tr><td class="name">legend</td><td class="type">var(--color-content-secondary)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel ?? title</td></tr>
|
||||
<tr><td class="name">legend</td><td>—</td><td class="type">visible label + swatch per slice</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
import { RadarChart } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const data = [
|
||||
{ axis: 'Speed', current: 80, target: 90 },
|
||||
{ axis: 'Reliability', current: 70, target: 85 },
|
||||
{ axis: 'Comfort', current: 60, target: 78 },
|
||||
{ axis: 'Safety', current: 90, target: 95 },
|
||||
{ axis: 'Economy', current: 65, target: 72 },
|
||||
{ axis: 'Design', current: 75, target: 82 }
|
||||
];
|
||||
|
||||
let multi = $state(true);
|
||||
let levels = $state(4);
|
||||
let legend = $state(true);
|
||||
let showTooltip = $state(true);
|
||||
let size = $state(300);
|
||||
|
||||
const levelChoices = [3, 4, 5];
|
||||
const sizes = [260, 300, 340];
|
||||
const seriesKeys = $derived(multi ? ['current', 'target'] : 'current');
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { RadarChart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // one row per axis (category) + one column per series',
|
||||
' const data = [',
|
||||
" { axis: 'Speed', current: 80, target: 90 },",
|
||||
" { axis: 'Safety', current: 90, target: 95 }",
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<RadarChart',
|
||||
' {data}',
|
||||
' axis="axis"',
|
||||
multi ? " series={['current', 'target']}" : ' series="current"',
|
||||
levels !== 4 && ` levels={${levels}}`,
|
||||
!legend && ' legend={false}',
|
||||
!showTooltip && ' tooltip={false}',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Radar</div>
|
||||
<h1 data-uix-page-title>Radar chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
A radar / spider chart — categories around a circle, each series a polygon whose vertices are the
|
||||
value at each axis (placed by a radial <code>scaleLinear</code> from the zero-dependency
|
||||
<code>$libs/plots</code> engine). N-gon grid rings + spokes; hover a sector for the values.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>axes</span>{data.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>rings</span>{levels}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area style="justify-content: center;">
|
||||
<RadarChart {data} axis="axis" series={seriesKeys} {levels} {legend} {size} tooltip={showTooltip} ariaLabel="Vehicle ratings" />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{data.length} axes</span> · {multi ? 2 : 1} series · {levels} rings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pass <code>data</code> as rows of <code>{`{ axis, ...series }`}</code>, with <code>axis</code> + <code>series</code> naming the keys. Every control changes the live chart.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>rings (levels)</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each levelChoices as l}<button data-uix-chip data-active={levels === l} onclick={() => (levels = l)}>{l}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each sizes as s}<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>series</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'current + target' : 'current only'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>legend</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>RadarChart</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<div data-uix-stage-area style="display: flex; gap: 1.5rem; flex-wrap: wrap; justify-content: center;">
|
||||
<RadarChart {data} axis="axis" series="current" size={240} levels={3} ariaLabel="Single series" />
|
||||
<RadarChart {data} axis="axis" series={['current', 'target']} size={240} ariaLabel="Two series" />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`Record<string, unknown>[]`}</td><td class="default empty">—</td><td>One row per axis/category.</td></tr>
|
||||
<tr><td class="name">axis</td><td class="type">string</td><td class="default empty">—</td><td>Key of the axis (category) label.</td></tr>
|
||||
<tr><td class="name">series</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s) — one polygon each.</td></tr>
|
||||
<tr><td class="name">max</td><td class="type">number</td><td class="default">niced</td><td>Radial domain max.</td></tr>
|
||||
<tr><td class="name">levels</td><td class="type">number</td><td class="default">4</td><td>Concentric grid rings.</td></tr>
|
||||
<tr><td class="name">size</td><td class="type">number</td><td class="default">300</td><td>Diameter in pixels.</td></tr>
|
||||
<tr><td class="name">legend / tooltip</td><td class="type">boolean</td><td class="default">true</td><td>Chrome toggles.</td></tr>
|
||||
<tr><td class="name">format</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Tooltip value formatter (locale-aware default).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · chart family contract</h2>
|
||||
<p data-uix-section-desc>Radar is part of the <code>chart</code> family (<code>scope:['eidos']</code>, 0 events): the root is the <code>provider</code> SVG (<code>role="img"</code>); it adds radar-specific <code>data-chart-radar-*</code> surfaces. The shared contract:</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">series polygon</td><td class="type">var(--color-{'{'}role{'}'}-solid) + fill-opacity var(--chart-area-opacity)</td></tr>
|
||||
<tr><td class="name">rings / spokes</td><td class="type">var(--color-border-subtle) · var(--chart-grid-width)</td></tr>
|
||||
<tr><td class="name">labels</td><td class="type">var(--color-content-secondary)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
<tr><td class="name">rings / spokes</td><td>decorative</td><td class="type">visual grid only</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p data-uix-section-desc style="margin-top: 0.75rem;">Pass <code>ariaLabel</code> summarising the comparison (e.g. "Vehicle ratings: current vs target").</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { ScatterChart } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const data = [
|
||||
{ x: 5, alpha: 12, beta: 22 },
|
||||
{ x: 12, alpha: 19, beta: 18 },
|
||||
{ x: 18, alpha: 15, beta: 30 },
|
||||
{ x: 25, alpha: 28, beta: 24 },
|
||||
{ x: 31, alpha: 24, beta: 36 },
|
||||
{ x: 38, alpha: 35, beta: 29 },
|
||||
{ x: 44, alpha: 31, beta: 44 },
|
||||
{ x: 52, alpha: 42, beta: 38 },
|
||||
{ x: 60, alpha: 38, beta: 51 },
|
||||
{ x: 68, alpha: 49, beta: 46 }
|
||||
];
|
||||
|
||||
let radius = $state(3.5);
|
||||
let multi = $state(false);
|
||||
let grid = $state(true);
|
||||
let niceY = $state(true);
|
||||
let height = $state(260);
|
||||
let showTooltip = $state(true);
|
||||
|
||||
const radii = [2.5, 3.5, 5];
|
||||
const heights = [200, 260, 320];
|
||||
const seriesKeys = $derived(multi ? ['alpha', 'beta'] : 'alpha');
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { ScatterChart } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // rows: a numeric x + one or more y series',
|
||||
' const data = [',
|
||||
' { x: 5, alpha: 12, beta: 22 },',
|
||||
' { x: 12, alpha: 19, beta: 18 }',
|
||||
' // …',
|
||||
' ];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<ScatterChart',
|
||||
' {data}',
|
||||
' x="x"',
|
||||
multi ? " y={['alpha', 'beta']}" : ' y="alpha"',
|
||||
' xScale="linear"',
|
||||
radius !== 3.5 && ` radius={${radius}}`,
|
||||
!grid && ' noGrid',
|
||||
!niceY && ' niceY={false}',
|
||||
height !== 260 && ` height={${height}}`,
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Scatter chart</div>
|
||||
<h1 data-uix-page-title>Scatter chart</h1>
|
||||
<p data-uix-page-lede>
|
||||
Points over a continuous x/y, one circle per datum, on the zero-dependency <code>$libs/plots</code>
|
||||
engine. Multiple series overlay with distinct palette colours; adjustable point radius.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>scale</span>linear</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area>
|
||||
<ScatterChart {data} x="x" y={seriesKeys} xScale="linear" {radius} {height} {niceY} tooltip={showTooltip} noGrid={!grid} ariaLabel="Scatter plot" />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · r {radius}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>point radius</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each radii as r}<button data-uix-chip data-active={radius === r} onclick={() => (radius = r)}>{r}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>height</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>series</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'alpha + beta' : 'alpha only'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>grid</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>niceY</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>tooltip</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>ScatterChart preset</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
|
||||
<ScatterChart {data} x="x" y="alpha" xScale="linear" title="Single series" height={200} />
|
||||
<ScatterChart {data} x="x" y="alpha" xScale="linear" radius={5} title="Bigger points" height={200} />
|
||||
<ScatterChart {data} x="x" y={['alpha', 'beta']} xScale="linear" title="Two series" height={200} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
|
||||
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x value key (numeric).</td></tr>
|
||||
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s).</td></tr>
|
||||
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>x mapping.</td></tr>
|
||||
<tr><td class="name">radius</td><td class="type">number</td><td class="default">3.5</td><td>Point radius.</td></tr>
|
||||
<tr><td class="name">noGrid / niceY</td><td class="type">boolean</td><td class="default">false / true</td><td>Chrome toggles.</td></tr>
|
||||
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height.</td></tr>
|
||||
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. The chart family shares one <code>scope:['eidos']</code> contract.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">point</td><td class="type">var(--color-{'{'}role{'}'}-solid) per series</td></tr>
|
||||
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
|
||||
<tr><td class="name">labels</td><td class="type">var(--color-content-secondary)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { Sparkline } from '$uix/eidos/components/chart';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { chartMorfo } from '@/uix/morfo/components/chart';
|
||||
import type { CurveName } from '$libs/plots';
|
||||
import type { ColorRole } from '$uix/eidos/lib/types';
|
||||
|
||||
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const data = [4, 6, 5, 8, 7, 9, 8, 11, 10, 13, 12, 15, 14, 17].map((v, i) => ({ i, v }));
|
||||
|
||||
let mode = $state<'line' | 'bars'>('line');
|
||||
let curve = $state<CurveName>('monotone');
|
||||
let area = $state(true);
|
||||
let color = $state<ColorRole>('primary');
|
||||
let height = $state(40);
|
||||
|
||||
const modes = ['line', 'bars'] as const;
|
||||
const curves: CurveName[] = ['monotone', 'linear', 'step'];
|
||||
const colorRoles: ColorRole[] = ['primary', 'affirm', 'risk', 'secondary', 'threat'];
|
||||
const heights = [32, 40, 64];
|
||||
|
||||
const compiled = compileMorfo(chartMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Sparkline } from '$uix/eidos/components/chart';",
|
||||
'',
|
||||
' // just { x, y } points',
|
||||
' const data = [{ i: 0, v: 4 }, { i: 1, v: 6 }, { i: 2, v: 5 }];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<Sparkline',
|
||||
' {data}',
|
||||
' x="i"',
|
||||
' y="v"',
|
||||
mode === 'bars' && ' as="bars"',
|
||||
curve !== 'monotone' && ` curve="${curve}"`,
|
||||
!area && ' area={false}',
|
||||
color !== 'primary' && ` color="${color}"`,
|
||||
height !== 40 && ` height={${height}}`,
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Charts · Sparkline</div>
|
||||
<h1 data-uix-page-title>Sparkline</h1>
|
||||
<p data-uix-page-lede>
|
||||
A compact, axis-less trend line for inline use in tables, cards and feeds — built on the same
|
||||
zero-dependency <code>$libs/plots</code> engine. Optional area fill; themed by role.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>chrome</span>none</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>curve</span>{curve}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area style="max-inline-size: 240px;">
|
||||
<Sparkline {data} x="i" y="v" as={mode} {curve} {area} {color} {height} />
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>renders</span>
|
||||
<span><span data-uix-stage-trace-event>{curve}</span>{area ? ' · area' : ' · line'} · {color}</span>
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>as</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each modes as m}<button data-uix-chip data-active={mode === m} onclick={() => (mode = m)}>{m}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>curve</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each curves as c}<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>color</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each colorRoles as r}<button data-uix-chip data-active={color === r} onclick={() => (color = r)}>{r}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>height</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>area fill</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={area} /><span data-uix-switch-label>{area ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>Sparkline preset</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'examples'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Examples</h2>
|
||||
<p data-uix-section-desc>Inline trends, one per metric — the canonical sparkline use.</p>
|
||||
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; max-inline-size: 640px;">
|
||||
<div><div style="font-size: 0.75rem; color: var(--color-content-secondary); margin-bottom: 0.25rem;">Sessions</div><Sparkline {data} x="i" y="v" /></div>
|
||||
<div><div style="font-size: 0.75rem; color: var(--color-content-secondary); margin-bottom: 0.25rem;">Revenue</div><Sparkline {data} x="i" y="v" color="affirm" /></div>
|
||||
<div><div style="font-size: 0.75rem; color: var(--color-content-secondary); margin-bottom: 0.25rem;">Errors · bars</div><Sparkline {data} x="i" y="v" as="bars" color="threat" /></div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
|
||||
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x position key.</td></tr>
|
||||
<tr><td class="name">y</td><td class="type">string</td><td class="default empty">—</td><td>Single series key.</td></tr>
|
||||
<tr><td class="name">as</td><td class="type">'line' | 'bars'</td><td class="default">'line'</td><td>Render as a trend line or vertical bars.</td></tr>
|
||||
<tr><td class="name">curve</td><td class="type">'monotone' | 'linear' | 'step'</td><td class="default">'monotone'</td><td>Point connection.</td></tr>
|
||||
<tr><td class="name">area</td><td class="type">boolean</td><td class="default">true</td><td>Fill under the line.</td></tr>
|
||||
<tr><td class="name">color</td><td class="type">ColorRole</td><td class="default empty">primary</td><td>Series colour by role.</td></tr>
|
||||
<tr><td class="name">height</td><td class="type">number</td><td class="default">40</td><td>Compact height (width fills container).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>No axes, grid or legend — a sparkline is intentionally chrome-less.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/chart.ts</code>. Sparkline uses only the provider + line (+ area) parts of the shared contract.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr><td class="name">{part.kebab}</td><td><code data-uix-part-marker>[{part.marker}]</code></td><td class="type"><{part.defaultElement}></td><td class="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>No <code>--chart-*</code> tokens — the line/area colour comes from the role token.</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">line / area</td><td class="type">var(--color-{'{'}role{'}'}-solid) + fill-opacity</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p data-uix-section-desc style="margin-top: 0.75rem;">Pass <code>ariaLabel</code> with the trend summary (e.g. "Sessions, up 12% this week"); decorative sparklines may omit it.</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue