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