From c19a72b62079c1a89f22c56b394c61c64bc09410 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 04:59:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(charts):=20zero-dep=20chart=20family=20?= =?UTF-8?q?=E2=80=94=20$libs/plots=20engine=20+=20eidos=20components=20+?= =?UTF-8?q?=20demos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new data-visualisation family on the in-house, zero-dependency $libs/plots engine (scales · ticks · curves · shapes, all original). Compositional 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 --- src/libs/plots/README.md | 62 ++++ src/libs/plots/array.ts | 134 ++++++++ src/libs/plots/curve.ts | 96 ++++++ src/libs/plots/index.ts | 33 ++ src/libs/plots/path.ts | 20 ++ src/libs/plots/scale-band.ts | 110 +++++++ src/libs/plots/scale-linear.ts | 61 ++++ src/libs/plots/scale-time.ts | 220 +++++++++++++ src/libs/plots/shape-arc.ts | 119 +++++++ src/libs/plots/shape-line.ts | 43 +++ src/libs/plots/shape-stack.ts | 59 ++++ src/libs/plots/test/array.test.ts | 40 +++ src/libs/plots/test/scale.test.ts | 101 ++++++ src/libs/plots/test/shape.test.ts | 78 +++++ src/libs/plots/test/ticks.test.ts | 37 +++ src/libs/plots/ticks.ts | 84 +++++ src/libs/plots/types.ts | 32 ++ .../eidos/components/chart/IMPROVEMENTS.md | 186 +++++++++++ src/uix/eidos/components/chart/README.md | 123 +++++++ .../eidos/components/chart/area-chart.svelte | 54 ++++ .../eidos/components/chart/bar-chart.svelte | 49 +++ .../eidos/components/chart/bar-list.svelte | 83 +++++ .../eidos/components/chart/bar-segment.svelte | 90 ++++++ .../eidos/components/chart/chart-area.svelte | 31 ++ .../eidos/components/chart/chart-bar.svelte | 58 ++++ .../eidos/components/chart/chart-grid.svelte | 30 ++ .../components/chart/chart-legend.svelte | 13 + .../eidos/components/chart/chart-line.svelte | 32 ++ .../components/chart/chart-scatter.svelte | 32 ++ .../components/chart/chart-tooltip.svelte | 15 + .../components/chart/chart-x-axis.svelte | 39 +++ .../components/chart/chart-y-axis.svelte | 22 ++ src/uix/eidos/components/chart/chart.css | 283 +++++++++++++++++ src/uix/eidos/components/chart/chart.svelte | 300 ++++++++++++++++++ src/uix/eidos/components/chart/context.ts | 97 ++++++ src/uix/eidos/components/chart/index.ts | 73 +++++ .../eidos/components/chart/line-chart.svelte | 57 ++++ .../eidos/components/chart/pie-chart.svelte | 124 ++++++++ .../eidos/components/chart/radar-chart.svelte | 175 ++++++++++ .../components/chart/scatter-chart.svelte | 51 +++ .../eidos/components/chart/sparkline.svelte | 33 ++ src/uix/eidos/components/chart/types.ts | 223 +++++++++++++ src/uix/eidos/generated/base.css | 127 +++++--- src/uix/eidos/lib/recipes/base.ts | 186 +++++++---- src/uix/langs/components/chart.ts | 17 + src/uix/morfo/components/chart.ts | 145 +++++++++ src/uix/morfo/schema.ts | 6 + src/uix/morfo/types.ts | 6 + .../uix/components/area-chart/+page.svelte | 243 ++++++++++++++ .../uix/components/bar-chart/+page.svelte | 229 +++++++++++++ .../uix/components/bar-list/+page.svelte | 219 +++++++++++++ .../uix/components/bar-segment/+page.svelte | 188 +++++++++++ web/routes/uix/components/chart/+page.svelte | 272 ++++++++++++++++ .../uix/components/line-chart/+page.svelte | 289 +++++++++++++++++ .../uix/components/pie-chart/+page.svelte | 216 +++++++++++++ .../uix/components/radar-chart/+page.svelte | 217 +++++++++++++ .../uix/components/scatter-chart/+page.svelte | 229 +++++++++++++ .../uix/components/sparkline/+page.svelte | 216 +++++++++++++ 58 files changed, 6294 insertions(+), 113 deletions(-) create mode 100644 src/libs/plots/README.md create mode 100644 src/libs/plots/array.ts create mode 100644 src/libs/plots/curve.ts create mode 100644 src/libs/plots/index.ts create mode 100644 src/libs/plots/path.ts create mode 100644 src/libs/plots/scale-band.ts create mode 100644 src/libs/plots/scale-linear.ts create mode 100644 src/libs/plots/scale-time.ts create mode 100644 src/libs/plots/shape-arc.ts create mode 100644 src/libs/plots/shape-line.ts create mode 100644 src/libs/plots/shape-stack.ts create mode 100644 src/libs/plots/test/array.test.ts create mode 100644 src/libs/plots/test/scale.test.ts create mode 100644 src/libs/plots/test/shape.test.ts create mode 100644 src/libs/plots/test/ticks.test.ts create mode 100644 src/libs/plots/ticks.ts create mode 100644 src/libs/plots/types.ts create mode 100644 src/uix/eidos/components/chart/IMPROVEMENTS.md create mode 100644 src/uix/eidos/components/chart/README.md create mode 100644 src/uix/eidos/components/chart/area-chart.svelte create mode 100644 src/uix/eidos/components/chart/bar-chart.svelte create mode 100644 src/uix/eidos/components/chart/bar-list.svelte create mode 100644 src/uix/eidos/components/chart/bar-segment.svelte create mode 100644 src/uix/eidos/components/chart/chart-area.svelte create mode 100644 src/uix/eidos/components/chart/chart-bar.svelte create mode 100644 src/uix/eidos/components/chart/chart-grid.svelte create mode 100644 src/uix/eidos/components/chart/chart-legend.svelte create mode 100644 src/uix/eidos/components/chart/chart-line.svelte create mode 100644 src/uix/eidos/components/chart/chart-scatter.svelte create mode 100644 src/uix/eidos/components/chart/chart-tooltip.svelte create mode 100644 src/uix/eidos/components/chart/chart-x-axis.svelte create mode 100644 src/uix/eidos/components/chart/chart-y-axis.svelte create mode 100644 src/uix/eidos/components/chart/chart.css create mode 100644 src/uix/eidos/components/chart/chart.svelte create mode 100644 src/uix/eidos/components/chart/context.ts create mode 100644 src/uix/eidos/components/chart/index.ts create mode 100644 src/uix/eidos/components/chart/line-chart.svelte create mode 100644 src/uix/eidos/components/chart/pie-chart.svelte create mode 100644 src/uix/eidos/components/chart/radar-chart.svelte create mode 100644 src/uix/eidos/components/chart/scatter-chart.svelte create mode 100644 src/uix/eidos/components/chart/sparkline.svelte create mode 100644 src/uix/eidos/components/chart/types.ts create mode 100644 src/uix/langs/components/chart.ts create mode 100644 src/uix/morfo/components/chart.ts create mode 100644 web/routes/uix/components/area-chart/+page.svelte create mode 100644 web/routes/uix/components/bar-chart/+page.svelte create mode 100644 web/routes/uix/components/bar-list/+page.svelte create mode 100644 web/routes/uix/components/bar-segment/+page.svelte create mode 100644 web/routes/uix/components/chart/+page.svelte create mode 100644 web/routes/uix/components/line-chart/+page.svelte create mode 100644 web/routes/uix/components/pie-chart/+page.svelte create mode 100644 web/routes/uix/components/radar-chart/+page.svelte create mode 100644 web/routes/uix/components/scatter-chart/+page.svelte create mode 100644 web/routes/uix/components/sparkline/+page.svelte diff --git a/src/libs/plots/README.md b/src/libs/plots/README.md new file mode 100644 index 000000000..8c3757792 --- /dev/null +++ b/src/libs/plots/README.md @@ -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. diff --git a/src/libs/plots/array.ts b/src/libs/plots/array.ts new file mode 100644 index 000000000..749d7a06f --- /dev/null +++ b/src/libs/plots/array.ts @@ -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 | 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 | 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] | 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 { + 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(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, 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, 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(accessor: (datum: T) => number) { + function right(array: ArrayLike, 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, 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, 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 } +} diff --git a/src/libs/plots/curve.ts b/src/libs/plots/curve.ts new file mode 100644 index 000000000..1abacfe67 --- /dev/null +++ b/src/libs/plots/curve.ts @@ -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(n - 1) + const slope = new Array(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(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 +} diff --git a/src/libs/plots/index.ts b/src/libs/plots/index.ts new file mode 100644 index 000000000..e85808354 --- /dev/null +++ b/src/libs/plots/index.ts @@ -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' diff --git a/src/libs/plots/path.ts b/src/libs/plots/path.ts new file mode 100644 index 000000000..f9c822598 --- /dev/null +++ b/src/libs/plots/path.ts @@ -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)}` +} diff --git a/src/libs/plots/scale-band.ts b/src/libs/plots/scale-band.ts new file mode 100644 index 000000000..f2ed80381 --- /dev/null +++ b/src/libs/plots/scale-band.ts @@ -0,0 +1,110 @@ +/** + * Band and point scales: map a discrete list of categories to pixel positions. + * + * A **band** scale gives every category an equal-width slot (`bandwidth`) — the + * basis for bar charts. A **point** scale is the degenerate band with zero + * width: each category collapses to a single position, used to place markers in + * line and scatter charts with a categorical axis. + * + * Layout model: the range is divided into `n` equal *steps*. Each step holds + * one band plus an inner gap; the two ends carry an outer margin. Expressing + * every gap as a fraction of one step gives + * + * span = step · (n − paddingInner + 2 · paddingOuter) + * + * so `step = span / (n − paddingInner + 2·paddingOuter)` and + * `bandwidth = step · (1 − paddingInner)`. + */ + +export interface BandScale { + /** Left/top pixel edge of a category's band, or `undefined` if unknown. */ + (value: string): number | undefined + /** Width of each band in pixels. */ + readonly bandwidth: number + /** Distance between the start of consecutive bands (band + inner gap). */ + readonly step: number + readonly domain: readonly string[] + readonly range: readonly [number, number] +} + +export interface BandScaleConfig { + domain: readonly string[] + range: readonly [number, number] + /** Shorthand setting both inner and outer padding (fraction of a step). */ + padding?: number + /** Gap between adjacent bands, as a fraction of a step (0..1). */ + paddingInner?: number + /** Margin before the first and after the last band, as a fraction (0..1). */ + paddingOuter?: number + /** Where outer margin lands: 0 start, 1 end, 0.5 centred. Default 0.5. */ + align?: number +} + +export function scaleBand(config: BandScaleConfig): BandScale { + const domain = config.domain + const [r0, r1] = config.range + const paddingInner = config.paddingInner ?? config.padding ?? 0 + const paddingOuter = config.paddingOuter ?? config.padding ?? 0 + const align = config.align ?? 0.5 + const n = domain.length + + // Work in ascending pixel space, then reflect if the range descends. + const reverse = r1 < r0 + const lo = reverse ? r1 : r0 + const hi = reverse ? r0 : r1 + const span = hi - lo + + const step = n === 0 ? 0 : span / Math.max(1, n - paddingInner + paddingOuter * 2) + const bandwidth = step * (1 - paddingInner) + // `align` distributes the 2·paddingOuter·step of outer margin between ends. + const offset = lo + (span - step * (n - paddingInner)) * align + + const index = new Map() + for (let i = 0; i < n; i++) { + const ascending = offset + step * i + // Reflect within [lo, hi] so a descending range keeps bands in bounds. + index.set(domain[i], reverse ? hi - (ascending - lo) - bandwidth : ascending) + } + + const scale = (value: string): number | undefined => index.get(value) + return Object.assign(scale, { + bandwidth, + step, + domain: Object.freeze([...domain]) as readonly string[], + range: Object.freeze<[number, number]>([r0, r1]) + }) as BandScale +} + +export interface PointScale { + /** Pixel position of a category, or `undefined` if unknown. */ + (value: string): number | undefined + /** Distance between consecutive points. */ + readonly step: number + readonly domain: readonly string[] + readonly range: readonly [number, number] +} + +export interface PointScaleConfig { + domain: readonly string[] + range: readonly [number, number] + /** Outer margin before the first and after the last point (fraction). */ + padding?: number + align?: number +} + +/** A point scale is a band scale with `paddingInner = 1` (zero bandwidth). */ +export function scalePoint(config: PointScaleConfig): PointScale { + const band = scaleBand({ + domain: config.domain, + range: config.range, + paddingInner: 1, + paddingOuter: config.padding ?? 0, + align: config.align ?? 0.5 + }) + const scale = (value: string): number | undefined => band(value) + return Object.assign(scale, { + step: band.step, + domain: band.domain, + range: band.range + }) as PointScale +} diff --git a/src/libs/plots/scale-linear.ts b/src/libs/plots/scale-linear.ts new file mode 100644 index 000000000..a90251149 --- /dev/null +++ b/src/libs/plots/scale-linear.ts @@ -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 +} diff --git a/src/libs/plots/scale-time.ts b/src/libs/plots/scale-time.ts new file mode 100644 index 000000000..f4d8d2aa3 --- /dev/null +++ b/src/libs/plots/scale-time.ts @@ -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 +} diff --git a/src/libs/plots/shape-arc.ts b/src/libs/plots/shape-arc.ts new file mode 100644 index 000000000..81a8191f5 --- /dev/null +++ b/src/libs/plots/shape-arc.ts @@ -0,0 +1,119 @@ +/** + * Arc geometry for pie, donut and gauge charts, plus the `pie` layout that + * turns a list of values into slice angles. + * + * Angle convention: 0 points up (12 o'clock) and increases clockwise, matching + * how a pie chart reads. On screen (y grows downward) a point at angle `a` and + * radius `r` is `(cx + r·sin a, cy − r·cos a)`. + */ + +import { fmt } from './path' + +const TAU = Math.PI * 2 + +export interface ArcOptions { + innerRadius: number + outerRadius: number + /** Start angle in radians (0 = up, clockwise positive). */ + startAngle: number + endAngle: number + /** Centre, default `(0, 0)`. */ + cx?: number + cy?: number +} + +const pointOnArc = (cx: number, cy: number, r: number, a: number): string => + `${fmt(cx + r * Math.sin(a))},${fmt(cy - r * Math.cos(a))}` + +/** + * SVG path for an annular sector (or a pie slice when `innerRadius` is 0, or a + * full ring/disc when the sweep covers a full turn). + */ +export function arc(options: ArcOptions): string { + const r0 = Math.max(0, Math.min(options.innerRadius, options.outerRadius)) + const r1 = Math.max(options.innerRadius, options.outerRadius) + const cx = options.cx ?? 0 + const cy = options.cy ?? 0 + const a0 = Math.min(options.startAngle, options.endAngle) + const a1 = Math.max(options.startAngle, options.endAngle) + const sweep = a1 - a0 + + // Full turn: SVG can't draw a 360° arc in one command, so use two halves. + if (sweep >= TAU - 1e-9) { + const mid = a0 + Math.PI + const outer = + `M${pointOnArc(cx, cy, r1, a0)}` + + `A${fmt(r1)},${fmt(r1)},0,1,1,${pointOnArc(cx, cy, r1, mid)}` + + `A${fmt(r1)},${fmt(r1)},0,1,1,${pointOnArc(cx, cy, r1, a0)}` + if (r0 === 0) return `${outer}Z` + // Inner ring wound the opposite way carves out the hole (even-odd fill). + const inner = + `M${pointOnArc(cx, cy, r0, a0)}` + + `A${fmt(r0)},${fmt(r0)},0,1,0,${pointOnArc(cx, cy, r0, mid)}` + + `A${fmt(r0)},${fmt(r0)},0,1,0,${pointOnArc(cx, cy, r0, a0)}` + return `${outer}${inner}Z` + } + + const largeArc = sweep > Math.PI ? 1 : 0 + + if (r0 === 0) { + // Pie slice: centre → outer arc → centre. + return ( + `M${fmt(cx)},${fmt(cy)}` + + `L${pointOnArc(cx, cy, r1, a0)}` + + `A${fmt(r1)},${fmt(r1)},0,${largeArc},1,${pointOnArc(cx, cy, r1, a1)}` + + 'Z' + ) + } + + // Donut segment: outer arc (clockwise) then inner arc back (anticlockwise). + return ( + `M${pointOnArc(cx, cy, r1, a0)}` + + `A${fmt(r1)},${fmt(r1)},0,${largeArc},1,${pointOnArc(cx, cy, r1, a1)}` + + `L${pointOnArc(cx, cy, r0, a1)}` + + `A${fmt(r0)},${fmt(r0)},0,${largeArc},0,${pointOnArc(cx, cy, r0, a0)}` + + 'Z' + ) +} + +export interface PieOptions { + /** First slice's start angle. Default 0 (up). */ + startAngle?: number + /** Last slice's end angle. Default `2π` (full turn). */ + endAngle?: number + /** Gap between slices in radians. Default 0. */ + padAngle?: number +} + +export interface PieSlice { + /** Index into the input values. */ + index: number + /** The original value. */ + value: number + startAngle: number + endAngle: number + padAngle: number +} + +/** + * Lay out values as slice angles, in input order (no implicit sort, so slices + * line up with their data). Negative values are treated as 0. + */ +export function pie(values: readonly number[], options: PieOptions = {}): PieSlice[] { + const start = options.startAngle ?? 0 + const end = options.endAngle ?? TAU + const pad = options.padAngle ?? 0 + const total = values.reduce((acc, v) => acc + (v > 0 ? v : 0), 0) + // Reserve one gap per slice; the rest is shared out by value. + const usable = end - start - pad * values.length + + const slices: PieSlice[] = [] + let angle = start + for (let i = 0; i < values.length; i++) { + const value = values[i] > 0 ? values[i] : 0 + const span = total > 0 ? (value / total) * usable : 0 + slices.push({ index: i, value: values[i], startAngle: angle, endAngle: angle + span, padAngle: pad }) + angle += span + pad + } + return slices +} diff --git a/src/libs/plots/shape-line.ts b/src/libs/plots/shape-line.ts new file mode 100644 index 000000000..9e5c6e30a --- /dev/null +++ b/src/libs/plots/shape-line.ts @@ -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 `` 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` + ) +} diff --git a/src/libs/plots/shape-stack.ts b/src/libs/plots/shape-stack.ts new file mode 100644 index 000000000..fe75229ff --- /dev/null +++ b/src/libs/plots/shape-stack.ts @@ -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>( + 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 +} diff --git a/src/libs/plots/test/array.test.ts b/src/libs/plots/test/array.test.ts new file mode 100644 index 000000000..31e9c5076 --- /dev/null +++ b/src/libs/plots/test/array.test.ts @@ -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) + }) +}) diff --git a/src/libs/plots/test/scale.test.ts b/src/libs/plots/test/scale.test.ts new file mode 100644 index 000000000..7c856924b --- /dev/null +++ b/src/libs/plots/test/scale.test.ts @@ -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) + }) +}) diff --git a/src/libs/plots/test/shape.test.ts b/src/libs/plots/test/shape.test.ts new file mode 100644 index 000000000..e24c9919f --- /dev/null +++ b/src/libs/plots/test/shape.test.ts @@ -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) + }) +}) diff --git a/src/libs/plots/test/ticks.test.ts b/src/libs/plots/test/ticks.test.ts new file mode 100644 index 000000000..b6c2fc931 --- /dev/null +++ b/src/libs/plots/test/ticks.test.ts @@ -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) + }) +}) diff --git a/src/libs/plots/ticks.ts b/src/libs/plots/ticks.ts new file mode 100644 index 000000000..ec6a3b989 --- /dev/null +++ b/src/libs/plots/ticks.ts @@ -0,0 +1,84 @@ +/** + * Tick generation for continuous axes. + * + * The goal is "nice" numbers: an axis should be labelled at round values like + * 0, 20, 40, … rather than 0, 17.3, 34.6, …. A nice step is a value of the form + * `{1 | 2 | 5} × 10ⁿ` — the multiples humans read fluently. Given a desired + * tick count we compute the raw step (`range / count`) and snap it to the + * nearest nice value, measured on a logarithmic scale. + */ + +// Geometric midpoints between adjacent nice values: √(1·2), √(2·5), √(5·10). +// Snapping at these boundaries chooses the closest of {1, 2, 5, 10} in log +// space, which is where "closeness" of multiplicative steps actually lives. +const SNAP_2 = Math.SQRT2 // ≈ 1.414 +const SNAP_5 = Math.sqrt(10) // ≈ 3.162 +const SNAP_10 = Math.sqrt(50) // ≈ 7.071 + +/** The nice step (`{1|2|5} × 10ⁿ`) closest to a positive raw step. */ +function niceStep(raw: number): number { + if (!(raw > 0) || !Number.isFinite(raw)) return 0 + const power = Math.floor(Math.log10(raw)) + const base = Math.pow(10, power) + const fraction = raw / base // normalised into [1, 10) + const factor = fraction < SNAP_2 ? 1 : fraction < SNAP_5 ? 2 : fraction < SNAP_10 ? 5 : 10 + return factor * base +} + +/** The nice step that splits `[min, max]` into roughly `count` intervals. */ +export function tickStep(min: number, max: number, count: number): number { + if (count <= 0) return 0 + return niceStep(Math.abs(max - min) / count) +} + +/** + * Evenly spaced nice tick values spanning `[min, max]` inclusive. Returns the + * round multiples of the nice step that fall within the range, so the first and + * last tick can sit slightly inside the exact extent. Order follows the inputs + * (descending when `max < min`). + */ +export function ticks(min: number, max: number, count = 10): number[] { + if (min === max) return [min] + if (count <= 0 || !Number.isFinite(min) || !Number.isFinite(max)) return [] + const reverse = max < min + const lo = reverse ? max : min + const hi = reverse ? min : max + const step = niceStep((hi - lo) / count) + if (step === 0) return [] + + const result: number[] = [] + if (step < 1) { + // Sub-unit steps drift under repeated multiplication (0.1 × 3 ≠ 0.3), + // so iterate with an integer reciprocal and divide instead. + const inv = Math.round(1 / step) + const first = Math.ceil(lo * inv) + const last = Math.floor(hi * inv) + for (let i = first; i <= last; i++) result.push(i / inv) + } else { + const first = Math.ceil(lo / step) + const last = Math.floor(hi / step) + for (let i = first; i <= last; i++) result.push(i * step) + } + return reverse ? result.reverse() : result +} + +/** + * Round `[min, max]` outward to the nearest nice-step multiples, so an axis + * begins and ends on round numbers. Iterates because widening the range can + * change which step is nice; converges in a couple of passes. + */ +export function nice(min: number, max: number, count = 10): [number, number] { + if (min === max || !Number.isFinite(min) || !Number.isFinite(max)) return [min, max] + const reverse = max < min + let lo = reverse ? max : min + let hi = reverse ? min : max + let previous = 0 + for (let guard = 0; guard < 100; guard++) { + const step = niceStep((hi - lo) / count) + if (step === 0 || step === previous) break + lo = Math.floor(lo / step) * step + hi = Math.ceil(hi / step) * step + previous = step + } + return reverse ? [hi, lo] : [lo, hi] +} diff --git a/src/libs/plots/types.ts b/src/libs/plots/types.ts new file mode 100644 index 000000000..28a8e9abb --- /dev/null +++ b/src/libs/plots/types.ts @@ -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 diff --git a/src/uix/eidos/components/chart/IMPROVEMENTS.md b/src/uix/eidos/components/chart/IMPROVEMENTS.md new file mode 100644 index 000000000..d58090ab0 --- /dev/null +++ b/src/uix/eidos/components/chart/IMPROVEMENTS.md @@ -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 `` 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 (`` / ``) 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) → ``. 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` + (``) → drawing children. A **plugin-store** holds one reactive state object; + components are dumb readers via selector hooks (`useDrawingArea`, `useXScale`, + `useSeries`). The all-in-one `` 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.** `` +(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 `` across the plot + optional `` label. Trivial. +- Cost: low. + +**4. Secondary axis.** A right-hand Y axis bound to specific series +(``). +- 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 `` + + 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 `` 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 `` 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). diff --git a/src/uix/eidos/components/chart/README.md b/src/uix/eidos/components/chart/README.md new file mode 100644 index 000000000..49f960e0e --- /dev/null +++ b/src/uix/eidos/components/chart/README.md @@ -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 + + + +``` + +## Surface + +- **``** — the compositional frame. Measures its width, computes the + scales from `data` + `x`/`y`, and shares them with SVG children through + context: `` (alias ``, x+y lines), + ``, ``, ``, ``, ``, + ``, plus the config children `` (cursor-following + readout) and `` (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) — + `` (pie + donut via `donut={0..1}`), `` (spider), + `` (ranked rows), `` (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 ``.** 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 + `
` 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`). diff --git a/src/uix/eidos/components/chart/area-chart.svelte b/src/uix/eidos/components/chart/area-chart.svelte new file mode 100644 index 000000000..d019f1ea4 --- /dev/null +++ b/src/uix/eidos/components/chart/area-chart.svelte @@ -0,0 +1,54 @@ + + +
+ {#if title}
{title}
{/if} + {#if showLegend} +
+ {#each seriesList as key, i} + + + {key} + + {/each} +
+ {/if} + + {#if !noGrid}{/if} + + + {#each seriesList as key} + + + {/each} + +
diff --git a/src/uix/eidos/components/chart/bar-chart.svelte b/src/uix/eidos/components/chart/bar-chart.svelte new file mode 100644 index 000000000..3272c5703 --- /dev/null +++ b/src/uix/eidos/components/chart/bar-chart.svelte @@ -0,0 +1,49 @@ + + +
+ {#if title}
{title}
{/if} + {#if showLegend} +
+ {#each seriesList as key, i} + + + {key} + + {/each} +
+ {/if} + + {#if !noGrid}{/if} + + + + +
diff --git a/src/uix/eidos/components/chart/bar-list.svelte b/src/uix/eidos/components/chart/bar-list.svelte new file mode 100644 index 000000000..dabdb2b1b --- /dev/null +++ b/src/uix/eidos/components/chart/bar-list.svelte @@ -0,0 +1,83 @@ + + +
(active = null)}> + {#each items as item, i (item.label)} +
onPointerMove(e, i)} + > + {item.label} +
+
+
+ {fmt(item.value)} +
+ {/each} + {#if tooltip && active !== null} +
+ {items[active].label} + + + value + {fmt(items[active].value)} · {sharePct}% + +
+ {/if} +
diff --git a/src/uix/eidos/components/chart/bar-segment.svelte b/src/uix/eidos/components/chart/bar-segment.svelte new file mode 100644 index 000000000..75a8ee8a9 --- /dev/null +++ b/src/uix/eidos/components/chart/bar-segment.svelte @@ -0,0 +1,90 @@ + + +
+
(active = null)}> + {#each segments as seg, i (seg.label)} +
onPointerMove(e, i)} + >
+ {/each} +
+ {#if legend} +
+ {#each segments as seg (seg.label)} + + + {seg.label} + {fmt(seg.value)} · {Math.round(seg.pct)}% + + {/each} +
+ {/if} + {#if tooltip && active !== null} +
+ {segments[active].label} + + + value + + {fmt(segments[active].value)} · {Math.round(segments[active].pct)}% + + +
+ {/if} +
diff --git a/src/uix/eidos/components/chart/chart-area.svelte b/src/uix/eidos/components/chart/chart-area.svelte new file mode 100644 index 000000000..c50e4228a --- /dev/null +++ b/src/uix/eidos/components/chart/chart-area.svelte @@ -0,0 +1,31 @@ + + + + diff --git a/src/uix/eidos/components/chart/chart-bar.svelte b/src/uix/eidos/components/chart/chart-bar.svelte new file mode 100644 index 000000000..c5d05ad62 --- /dev/null +++ b/src/uix/eidos/components/chart/chart-bar.svelte @@ -0,0 +1,58 @@ + + + + + {#each bars as bar} + + {/each} + diff --git a/src/uix/eidos/components/chart/chart-grid.svelte b/src/uix/eidos/components/chart/chart-grid.svelte new file mode 100644 index 000000000..f479d7485 --- /dev/null +++ b/src/uix/eidos/components/chart/chart-grid.svelte @@ -0,0 +1,30 @@ + + + + {#each yLines as gy} + + {/each} + {#each xLines as gx} + + {/each} + diff --git a/src/uix/eidos/components/chart/chart-legend.svelte b/src/uix/eidos/components/chart/chart-legend.svelte new file mode 100644 index 000000000..57d4454c8 --- /dev/null +++ b/src/uix/eidos/components/chart/chart-legend.svelte @@ -0,0 +1,13 @@ + diff --git a/src/uix/eidos/components/chart/chart-line.svelte b/src/uix/eidos/components/chart/chart-line.svelte new file mode 100644 index 000000000..eca37144b --- /dev/null +++ b/src/uix/eidos/components/chart/chart-line.svelte @@ -0,0 +1,32 @@ + + + + diff --git a/src/uix/eidos/components/chart/chart-scatter.svelte b/src/uix/eidos/components/chart/chart-scatter.svelte new file mode 100644 index 000000000..b7bca1f9b --- /dev/null +++ b/src/uix/eidos/components/chart/chart-scatter.svelte @@ -0,0 +1,32 @@ + + + + + {#each points as p} + + {/each} + diff --git a/src/uix/eidos/components/chart/chart-tooltip.svelte b/src/uix/eidos/components/chart/chart-tooltip.svelte new file mode 100644 index 000000000..eb4cca32d --- /dev/null +++ b/src/uix/eidos/components/chart/chart-tooltip.svelte @@ -0,0 +1,15 @@ + diff --git a/src/uix/eidos/components/chart/chart-x-axis.svelte b/src/uix/eidos/components/chart/chart-x-axis.svelte new file mode 100644 index 000000000..bbf621dad --- /dev/null +++ b/src/uix/eidos/components/chart/chart-x-axis.svelte @@ -0,0 +1,39 @@ + + + + + {#each items as it} + + + {it.label} + + {/each} + diff --git a/src/uix/eidos/components/chart/chart-y-axis.svelte b/src/uix/eidos/components/chart/chart-y-axis.svelte new file mode 100644 index 000000000..2b8dbf10f --- /dev/null +++ b/src/uix/eidos/components/chart/chart-y-axis.svelte @@ -0,0 +1,22 @@ + + + + + {#each items as it} + + + + {it.label} + + + {/each} + diff --git a/src/uix/eidos/components/chart/chart.css b/src/uix/eidos/components/chart/chart.css new file mode 100644 index 000000000..ce119bc90 --- /dev/null +++ b/src/uix/eidos/components/chart/chart.css @@ -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
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; +} diff --git a/src/uix/eidos/components/chart/chart.svelte b/src/uix/eidos/components/chart/chart.svelte new file mode 100644 index 000000000..a625ec7cb --- /dev/null +++ b/src/uix/eidos/components/chart/chart.svelte @@ -0,0 +1,300 @@ + + +
+
+ {#if data.length === 0} +
{emptyText}
+ {:else if plot.width > 0 && plot.height > 0} + (activeIndex = null)} + > + + {@render children?.()} + {#if tooltipOn && activeIndex !== null} + + {#each series as key, i (key)} + {@const v = Number(data[activeIndex][key])} + {#if Number.isFinite(v)} + + {/if} + {/each} + {/if} + + + {#if tooltipOn && activeIndex !== null} +
+ {xLabel(data[activeIndex])} + {#each series as key, i (key)} + {@const v = Number(data[activeIndex][key])} + {#if Number.isFinite(v)} + + + {key} + {valueLabel(v, key)} + + {/if} + {/each} +
+ {/if} + {/if} +
+ {#if legendOn && series.length > 0} +
+ {#each series as key, i (key)} + + + {key} + + {/each} +
+ {/if} +
diff --git a/src/uix/eidos/components/chart/context.ts b/src/uix/eidos/components/chart/context.ts new file mode 100644 index 000000000..5516702a9 --- /dev/null +++ b/src/uix/eidos/components/chart/context.ts @@ -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 ``. */ +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[]; + 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): number; + /** Left edge x (pixels) for a bar at a datum. */ + bandStart(datum: Record): 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 ``). Returns a disposer. */ + enableTooltip(config?: ChartTooltipConfig): () => void; + /** Enable the HTML legend (called by ``). Returns a disposer. */ + enableLegend(): () => void; +} + +export function setChartContext(ctx: ChartContext): void { + setContext(CHART_KEY, ctx); +} + +export function getChartContext(): ChartContext { + const ctx = getContext(CHART_KEY); + if (!ctx) throw new Error('Chart parts must be used inside '); + return ctx; +} diff --git a/src/uix/eidos/components/chart/index.ts b/src/uix/eidos/components/chart/index.ts new file mode 100644 index 000000000..1d94bd87d --- /dev/null +++ b/src/uix/eidos/components/chart/index.ts @@ -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'; diff --git a/src/uix/eidos/components/chart/line-chart.svelte b/src/uix/eidos/components/chart/line-chart.svelte new file mode 100644 index 000000000..0c9a4c456 --- /dev/null +++ b/src/uix/eidos/components/chart/line-chart.svelte @@ -0,0 +1,57 @@ + + +
+ {#if title}
{title}
{/if} + {#if showLegend} +
+ {#each seriesList as key, i} + + + {key} + + {/each} +
+ {/if} + + {#if !noGrid}{/if} + + + {#each seriesList as key} + {#if area}{/if} + + {#if dots}{/if} + {/each} + +
diff --git a/src/uix/eidos/components/chart/pie-chart.svelte b/src/uix/eidos/components/chart/pie-chart.svelte new file mode 100644 index 000000000..ce73144fa --- /dev/null +++ b/src/uix/eidos/components/chart/pie-chart.svelte @@ -0,0 +1,124 @@ + + +
+ {#if title}
{title}
{/if} + (active = null)} + > + {#each slices as slice, i (slice.label)} + onPointerMove(e, i)} + /> + {/each} + + {#if legend} +
+ {#each slices as slice (slice.label)} + + + {slice.label} + + {/each} +
+ {/if} + {#if tooltip && active !== null} +
+ {slices[active].label} + + + value + {formatNumber(slices[active].value)} · {percent}% + +
+ {/if} +
diff --git a/src/uix/eidos/components/chart/radar-chart.svelte b/src/uix/eidos/components/chart/radar-chart.svelte new file mode 100644 index 000000000..5a41eeaf7 --- /dev/null +++ b/src/uix/eidos/components/chart/radar-chart.svelte @@ -0,0 +1,175 @@ + + +
+ {#if title}
{title}
{/if} + (active = null)} + > + {#each rings as ring}{/each} + {#each spokes as [x, y]} + + {/each} + {#each polygons as poly (poly.key)} + + {/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))} + + {/if} + {/each} + {/if} + {#each labels as lab} + + {lab.c} + + {/each} + + {#if legend && seriesKeys.length > 1} +
+ {#each seriesKeys as key, s (key)} + + + {key} + + {/each} +
+ {/if} + {#if tooltip && active !== null} +
+ {categories[active]} + {#each seriesKeys as key, s (key)} + {@const v = Number(data[active][key])} + {#if Number.isFinite(v)} + + + {key} + {fmt(v)} + + {/if} + {/each} +
+ {/if} +
diff --git a/src/uix/eidos/components/chart/scatter-chart.svelte b/src/uix/eidos/components/chart/scatter-chart.svelte new file mode 100644 index 000000000..cf6eb6dd8 --- /dev/null +++ b/src/uix/eidos/components/chart/scatter-chart.svelte @@ -0,0 +1,51 @@ + + +
+ {#if title}
{title}
{/if} + {#if showLegend} +
+ {#each seriesList as key, i} + + + {key} + + {/each} +
+ {/if} + + {#if !noGrid}{/if} + + + {#each seriesList as key} + + {/each} + +
diff --git a/src/uix/eidos/components/chart/sparkline.svelte b/src/uix/eidos/components/chart/sparkline.svelte new file mode 100644 index 000000000..f5ff6bd65 --- /dev/null +++ b/src/uix/eidos/components/chart/sparkline.svelte @@ -0,0 +1,33 @@ + + +{#if as === 'bars'} + + + +{:else} + + {#if area}{/if} + + +{/if} diff --git a/src/uix/eidos/components/chart/types.ts b/src/uix/eidos/components/chart/types.ts new file mode 100644 index 000000000..2fed72ea6 --- /dev/null +++ b/src/uix/eidos/components/chart/types.ts @@ -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[]; +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 { + /** 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 { + /** 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[]; + /** 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; +} diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 1641ec9bd..a85efa0a1 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -826,7 +826,6 @@ --breadcrumb-ellipsis-bg-hover: var(--color-surface-raised); --breadcrumb-ellipsis-color: var(--color-content-muted); --breadcrumb-ellipsis-color-hover: var(--color-content-primary); - --breadcrumb-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --breadcrumb-transition-duration: var(--duration-fast); --breadcrumb-transition-ease: var(--ease-default); --card-px-xs: var(--space-3); @@ -945,11 +944,6 @@ --checkbox-size-xl-indicator-stroke: 2px; --checkbox-radius: var(--radius-sm); --checkbox-border-width: var(--border-width); - --checkbox-focus-ring-offset: var(--focus-ring-offset); - --checkbox-focus-ring-width: var(--focus-ring-width); - --checkbox-focus-ring-color: var(--focus-ring-color); - --checkbox-focus-ring-surface: var(--color-surface-default); - --checkbox-focus-ring: 0 0 0 var(--checkbox-focus-ring-offset) var(--checkbox-focus-ring-surface), 0 0 0 calc(var(--checkbox-focus-ring-offset) + var(--checkbox-focus-ring-width)) var(--checkbox-focus-ring-color); --checkbox-transition-duration: var(--duration-fast); --checkbox-transition-ease: var(--ease-default); --checkbox-stroke-duration: var(--duration-fast); @@ -1075,7 +1069,6 @@ --field-control-bg-disabled: var(--color-surface-muted); --field-control-color: var(--color-content-primary); --field-control-placeholder-color: var(--color-content-muted); - --field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --field-affix-color: var(--color-content-secondary); --field-message-line-height: var(--leading-ui); --field-helper-color: var(--color-content-secondary); @@ -1278,7 +1271,6 @@ --spin-field-border-invalid: var(--color-risk-border); --spin-field-bg: var(--color-surface-default); --spin-field-color: var(--color-content-primary); - --spin-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --spin-field-control-radius: 0; --spin-field-control-border-width: var(--border-width); --spin-field-control-border: transparent; @@ -1296,7 +1288,6 @@ --spin-field-control-decrement-glyph: '\2212'; --spin-field-control-increment-glyph-stacked: '\25B2'; --spin-field-control-decrement-glyph-stacked: '\25BC'; - --spin-field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --spin-field-control-disabled-opacity: 0.48; --spin-field-transition-duration: var(--duration-fast); --spin-field-transition-ease: var(--ease-default); @@ -1403,11 +1394,10 @@ --date-field-border-invalid: var(--color-risk-border); --date-field-bg: var(--color-surface-default); --date-field-color: var(--color-content-primary); - --date-field-label-color: var(--color-content-secondary); + --date-field-label-color: var(--color-content-primary); --date-field-label-font-weight: var(--font-weight-regular); --date-field-placeholder-color: var(--color-content-muted); --date-field-literal-color: var(--color-content-muted); - --date-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --date-field-segment-height: calc(var(--_date-field-height) - var(--space-2)); --date-field-segment-radius: var(--radius-sm); --date-field-segment-color: var(--color-content-primary); @@ -1452,11 +1442,10 @@ --time-field-border-invalid: var(--color-risk-border); --time-field-bg: var(--color-surface-default); --time-field-color: var(--color-content-primary); - --time-field-label-color: var(--color-content-secondary); + --time-field-label-color: var(--color-content-primary); --time-field-label-font-weight: var(--font-weight-regular); --time-field-placeholder-color: var(--color-content-muted); --time-field-literal-color: var(--color-content-muted); - --time-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --time-field-segment-height: calc(var(--_time-field-height) - var(--space-2)); --time-field-segment-radius: var(--radius-sm); --time-field-segment-color: var(--color-content-primary); @@ -1501,11 +1490,10 @@ --color-field-border-invalid: var(--color-risk-border); --color-field-bg: var(--color-surface-default); --color-field-color: var(--color-content-primary); - --color-field-label-color: var(--color-content-secondary); + --color-field-label-color: var(--color-content-primary); --color-field-label-font-weight: var(--font-weight-regular); --color-field-placeholder-color: var(--color-content-muted); --color-field-literal-color: var(--color-content-muted); - --color-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --color-field-segment-height: calc(var(--_color-field-height) - var(--space-2)); --color-field-segment-radius: var(--radius-sm); --color-field-segment-color: var(--color-content-primary); @@ -1526,10 +1514,6 @@ --color-field-transition-duration: var(--duration-fast); --color-field-transition-ease: var(--ease-default); --color-field-disabled-opacity: var(--opacity-disabled); - --color-picker-content-gap: var(--space-3); - --color-picker-content-padding-sm: var(--space-3); - --color-picker-content-padding-md: var(--space-3-5); - --color-picker-content-padding-lg: var(--space-4); --color-picker-content-width-sm: 17rem; --color-picker-content-width-md: 19.5rem; --color-picker-content-width-lg: 22rem; @@ -1552,7 +1536,6 @@ --color-picker-trigger-swatch-size-lg: calc(var(--control-height-lg) - var(--space-2)); --color-picker-trigger-swatch-radius: var(--radius-full); --color-picker-trigger-swatch-border: var(--color-border-subtle); - --color-picker-trigger-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --color-picker-area-height: 11rem; --color-picker-area-radius: var(--radius-md); --color-picker-area-border-width: var(--border-width); @@ -1751,7 +1734,6 @@ --calendar-day-weekend-color: var(--color-content-secondary); --calendar-day-holiday-shadow: inset 0 -2px 0 var(--_calendar-accent-border); --calendar-day-unavailable-decoration: var(--color-risk-border); - --calendar-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --calendar-event-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-accent-border); --calendar-transition-duration: var(--duration-fast); --calendar-transition-ease: var(--ease-default); @@ -1798,7 +1780,6 @@ --pagination-selected-bg-hover: var(--color-primary-solid-hover); --pagination-selected-color: var(--color-content-on-solid); --pagination-ellipsis-color: var(--color-content-muted); - --pagination-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --pagination-transition-duration: var(--duration-fast); --pagination-transition-ease: var(--ease-default); --pagination-disabled-opacity: var(--opacity-disabled); @@ -1923,11 +1904,6 @@ --radio-group-indicator-bg-checked-disabled: color-mix( in srgb, var(--color-content-disabled) 40%, var(--color-surface-overlay) ); --radio-group-dot-color: var(--color-content-on-solid); --radio-group-dot-color-disabled: color-mix( in srgb, var(--color-content-on-solid) 82%, var(--color-surface-default) ); - --radio-group-focus-ring-offset: var(--focus-ring-offset); - --radio-group-focus-ring-width: var(--focus-ring-width); - --radio-group-focus-ring-color: var(--focus-ring-color); - --radio-group-focus-ring-surface: var(--color-surface-default); - --radio-group-focus-ring: 0 0 0 var(--radio-group-focus-ring-offset) var(--radio-group-focus-ring-surface), 0 0 0 calc(var(--radio-group-focus-ring-offset) + var(--radio-group-focus-ring-width)) var(--radio-group-focus-ring-color); --radio-group-transition-duration: var(--duration-fast); --radio-group-transition-ease: var(--ease-default); --radio-group-disabled-opacity: var(--opacity-disabled); @@ -1944,6 +1920,23 @@ --radio-group-label-font-weight: var(--font-weight-medium); --radio-group-label-line-height: var(--leading-ui); --radio-group-label-color: var(--color-content-primary); + --chart-line-width: var(--border-width-medium); + --chart-axis-width: var(--border-width-thin); + --chart-grid-width: var(--border-width-thin); + --chart-bar-radius: var(--radius-sm); + --chart-point-radius: 3.5px; + --chart-area-opacity: 0.15; + --chart-slice-stroke-width: var(--border-width-thin); + --chart-transition-duration: var(--duration-fast); + --chart-transition-ease: var(--ease-out); + --chart-tooltip-bg: var(--color-surface-overlay); + --chart-tooltip-border: var(--color-border-default); + --chart-tooltip-radius: var(--radius-md); + --chart-tooltip-shadow: var(--shadow-3); + --chart-tooltip-padding: var(--space-2); + --chart-tooltip-gap: var(--space-1-5); + --chart-crosshair-color: var(--color-border-strong); + --chart-crosshair-width: var(--border-width-thin); --qr-code-fg: #18181b; --qr-code-bg: #ffffff; --qr-code-size-xs: 96px; @@ -1991,7 +1984,6 @@ --radio-cards-icon-size-lg: var(--icon-size-md); --radio-cards-icon-size-xl: var(--icon-size-lg); --radio-cards-icon-color: var(--color-content-secondary); - --radio-cards-focus-ring: var(--radio-group-focus-ring); --radio-cards-transition-duration: var(--duration-fast); --radio-cards-transition-ease: var(--ease-default); --radio-cards-disabled-opacity: var(--opacity-disabled); @@ -2017,7 +2009,6 @@ --rating-group-item-color-partial: var(--color-fulfill-solid); --rating-group-item-hover-offset: -1px; --rating-group-focus-radius: var(--radius-md); - --rating-group-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --rating-group-transition-duration: var(--duration-fast); --rating-group-transition-ease: var(--ease-default); --rating-group-disabled-opacity: var(--opacity-disabled); @@ -2051,7 +2042,6 @@ --search-field-bg: var(--color-surface-default); --search-field-color: var(--color-content-primary); --search-field-placeholder-color: var(--color-content-muted); - --search-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --search-field-clear-hidden-opacity: 0; --search-field-icon-size-xs: var(--icon-size-xs); --search-field-icon-size-sm: var(--icon-size-sm); @@ -2102,11 +2092,9 @@ --password-field-bg: var(--color-surface-default); --password-field-color: var(--color-content-primary); --password-field-placeholder-color: var(--color-content-muted); - --password-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --password-field-trigger-radius: var(--radius-sm); --password-field-trigger-bg: transparent; --password-field-trigger-color: var(--color-content-secondary); - --password-field-trigger-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --password-field-icon-size-xs: var(--control-height-xs); --password-field-icon-size-sm: var(--control-height-xs); --password-field-icon-size-md: var(--control-height-sm); @@ -2173,7 +2161,6 @@ --select-trigger-indicator-color: var(--color-content-secondary); --select-trigger-indicator-size: var(--icon-size-sm); --select-placeholder-color: var(--color-content-muted); - --select-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --select-disabled-border: var(--color-border-subtle); --select-disabled-bg: var(--color-surface-raised); --select-disabled-color: var(--color-content-disabled); @@ -2277,7 +2264,6 @@ --toolbar-control-color: var(--color-content-secondary); --toolbar-control-color-hover: var(--color-content-primary); --toolbar-control-color-active: var(--color-primary-text); - --toolbar-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --toolbar-separator-bg: var(--color-border-subtle); --toolbar-separator-thickness: var(--border-width); --toolbar-separator-length: var(--control-height-md); @@ -2312,7 +2298,6 @@ --file-upload-dropzone-radius: var(--radius-lg); --file-upload-dropzone-bg: var(--color-surface-raised); --file-upload-dropzone-color: var(--color-content-secondary); - --file-upload-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --file-upload-button-gap: var(--space-1-5); --file-upload-button-px: var(--space-3); --file-upload-button-border-width: var(--border-width); @@ -2419,7 +2404,6 @@ --tag-group-item-shadow: var(--shadow-subtle); --tag-group-item-color: var(--color-content-secondary); --tag-group-item-color-hover: var(--color-content-primary); - --tag-group-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --tag-group-solid-fg: var(--color-content-on-solid); --tag-group-remove-size: var(--control-height-xs); --tag-group-remove-radius: var(--radius-full); @@ -2517,7 +2501,6 @@ --editable-control-border-hover: var(--color-border-default); --editable-control-bg: var(--color-surface-default); --editable-control-shadow: var(--shadow-subtle); - --editable-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --editable-placeholder-color: var(--color-content-muted); --editable-trigger-gap: var(--space-1); --editable-trigger-px: var(--space-2-5); @@ -2674,7 +2657,6 @@ --tags-input-control-border-hover: var(--color-border-strong); --tags-input-control-bg: var(--color-surface-default); --tags-input-control-shadow: var(--shadow-subtle); - --tags-input-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --tags-input-input-min-inline-size: 8ch; --tags-input-placeholder-color: var(--color-content-muted); --tags-input-item-radius: var(--radius-full); @@ -2721,6 +2703,68 @@ --tags-input-risk-border: var(--color-risk-border); --tags-input-threat-track: var(--color-threat-track); --tags-input-threat-border: var(--color-threat-border); + --metrics-gap-xs: var(--space-1); + --metrics-gap-sm: var(--space-1); + --metrics-gap-md: var(--space-1-5); + --metrics-gap-lg: var(--space-2); + --metrics-gap-xl: var(--space-2); + --metrics-gap-xxl: var(--space-3); + --metrics-icon-gap-xs: var(--space-2); + --metrics-icon-gap-sm: var(--space-2-5); + --metrics-icon-gap-md: var(--space-3); + --metrics-icon-gap-lg: var(--space-3-5); + --metrics-icon-gap-xl: var(--space-4); + --metrics-icon-gap-xxl: var(--space-5); + --metrics-icon-padding-xs: var(--space-1-5); + --metrics-icon-padding-sm: var(--space-2); + --metrics-icon-padding-md: var(--space-2); + --metrics-icon-padding-lg: var(--space-2-5); + --metrics-icon-padding-xl: var(--space-3); + --metrics-icon-padding-xxl: var(--space-4); + --metrics-icon-border-width: var(--border-width); + --metrics-icon-bg: var(--color-surface-muted); + --metrics-icon-color: var(--color-content-secondary); + --metrics-label-font-family: var(--font-ui); + --metrics-label-font-size-xs: var(--font-size-xxs); + --metrics-label-font-size-sm: var(--font-size-xs); + --metrics-label-font-size-md: var(--font-size-sm); + --metrics-label-font-size-lg: var(--font-size-md); + --metrics-label-font-size-xl: var(--font-size-lg); + --metrics-label-font-size-xxl: var(--font-size-xl); + --metrics-label-font-weight: var(--font-weight-medium); + --metrics-label-color: var(--color-content-secondary); + --metrics-label-line-height: var(--leading-ui); + --metrics-label-letter-spacing: var(--tracking-normal); + --metrics-value-font-family: var(--font-ui); + --metrics-value-font-size-xs: var(--font-size-xs); + --metrics-value-font-size-sm: var(--font-size-sm); + --metrics-value-font-size-md: var(--font-size-md); + --metrics-value-font-size-lg: var(--font-size-lg); + --metrics-value-font-size-xl: var(--font-size-xl); + --metrics-value-font-size-xxl: var(--font-size-xxl); + --metrics-value-font-weight: var(--font-weight-semibold); + --metrics-value-color: var(--color-content-primary); + --metrics-value-line-height: var(--leading-tight); + --metrics-value-letter-spacing: var(--tracking-tight); + --metrics-value-font-feature: var(--font-feature-tabular); + --metrics-description-font-family: var(--font-ui); + --metrics-description-font-size-xs: var(--font-size-xxs); + --metrics-description-font-size-sm: var(--font-size-xs); + --metrics-description-font-size-md: var(--font-size-sm); + --metrics-description-font-size-lg: var(--font-size-md); + --metrics-description-font-size-xl: var(--font-size-lg); + --metrics-description-font-size-xxl: var(--font-size-xl); + --metrics-description-color: var(--color-content-secondary); + --metrics-description-line-height: var(--leading-normal); + --metrics-chart-margin-block-start: var(--space-3); + --metrics-actions-gap: var(--space-3); + --metrics-actions-margin-block-start: var(--space-3); + --metrics-actions-padding-block-start: var(--space-3); + --metrics-actions-border-color: var(--color-border-subtle); + --metrics-flash-duration: var(--duration-slow); + --metrics-flash-ease: var(--ease-out); + --metrics-flash-radius: var(--radius-sm); + --metrics-flash-inset: var(--space-1); --stepper-gap-sm: var(--space-3); --stepper-gap-md: var(--space-4); --stepper-gap-lg: var(--space-5); @@ -2768,7 +2812,6 @@ --stepper-nav-bg: var(--color-surface-raised); --stepper-nav-color: var(--color-content-primary); --stepper-nav-hover-brightness: 1.04; - --stepper-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --stepper-transition-duration: var(--duration-fast); --stepper-transition-ease: var(--ease-default); --stepper-disabled-opacity: var(--opacity-disabled); @@ -2869,7 +2912,6 @@ --slider-thumb-shadow: var(--shadow-subtle); --slider-thumb-scale-active: 1.15; --slider-thumb-shadow-active: var(--shadow-3); - --slider-thumb-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow); --slider-tick-radius: var(--radius-full); --slider-tick-bg: var(--color-neutral-border); --slider-tick-bg-active: var(--color-primary-solid); @@ -2902,10 +2944,6 @@ --switch-thumb-radius: var(--radius-full); --switch-thumb-border-width: var(--border-width); --switch-thumb-shadow: 0 1px 2px rgb(17 14 10 / 0.12); - --switch-focus-ring-offset: var(--focus-ring-offset); - --switch-focus-ring-width: var(--focus-ring-width); - --switch-focus-ring-color: var(--focus-ring-color); - --switch-focus-ring-surface: var(--color-surface-default); --switch-transition-duration: var(--duration-fast); --switch-transition-ease: var(--ease-default); --switch-track-bg-off: var(--color-surface-overlay); @@ -3361,6 +3399,7 @@ --pin-input-cell-filled-border: var(--color-primary-border); --pin-input-cell-active-border: var(--color-primary-solid); --pin-input-cell-invalid-border: var(--color-threat-solid); + --scroll-area-auto-max-block: none; --scroll-area-bg: transparent; --scroll-area-radius: var(--radius-md); --scroll-area-scrollbar-padding: var(--space-0-5); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index d6929517e..14b136476 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -458,8 +458,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'ellipsis-bg-hover': 'var(--color-surface-raised)', 'ellipsis-color': 'var(--color-content-muted)', 'ellipsis-color-hover': 'var(--color-content-primary)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)' }, @@ -655,12 +653,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'size-xl-indicator-stroke': '2px', radius: 'var(--radius-sm)', 'border-width': 'var(--border-width)', - 'focus-ring-offset': 'var(--focus-ring-offset)', - 'focus-ring-width': 'var(--focus-ring-width)', - 'focus-ring-color': 'var(--focus-ring-color)', - 'focus-ring-surface': 'var(--color-surface-default)', - 'focus-ring': - '0 0 0 var(--checkbox-focus-ring-offset) var(--checkbox-focus-ring-surface), 0 0 0 calc(var(--checkbox-focus-ring-offset) + var(--checkbox-focus-ring-width)) var(--checkbox-focus-ring-color)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', // Checkmark stroke draw — synced with the box transition (--duration-fast). @@ -833,8 +825,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'control-bg-disabled': 'var(--color-surface-muted)', 'control-color': 'var(--color-content-primary)', 'control-placeholder-color': 'var(--color-content-muted)', - 'control-focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'affix-color': 'var(--color-content-secondary)', 'message-line-height': 'var(--leading-ui)', 'helper-color': 'var(--color-content-secondary)', @@ -1066,8 +1056,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'border-invalid': 'var(--color-risk-border)', bg: 'var(--color-surface-default)', color: 'var(--color-content-primary)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', // Steppers are square segments flush to the field edge (the field clips // their outer corners), separated from the input by a divider on the // inner side. @@ -1093,8 +1081,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'control-decrement-glyph': "'\\2212'", 'control-increment-glyph-stacked': "'\\25B2'", 'control-decrement-glyph-stacked': "'\\25BC'", - 'control-focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'control-disabled-opacity': '0.48', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', @@ -1231,12 +1217,12 @@ export const THEME_BASE_RECIPE_TOKENS = { 'border-invalid': 'var(--color-risk-border)', bg: 'var(--color-surface-default)', color: 'var(--color-content-primary)', - 'label-color': 'var(--color-content-secondary)', + // Field label = max contrast on its surface (content-primary), homogeneous + // across the whole field family — not a de-emphasized secondary. + 'label-color': 'var(--color-content-primary)', 'label-font-weight': 'var(--font-weight-regular)', 'placeholder-color': 'var(--color-content-muted)', 'literal-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'segment-height': 'calc(var(--_date-field-height) - var(--space-2))', 'segment-radius': 'var(--radius-sm)', 'segment-color': 'var(--color-content-primary)', @@ -1283,12 +1269,12 @@ export const THEME_BASE_RECIPE_TOKENS = { 'border-invalid': 'var(--color-risk-border)', bg: 'var(--color-surface-default)', color: 'var(--color-content-primary)', - 'label-color': 'var(--color-content-secondary)', + // Field label = max contrast on its surface (content-primary), homogeneous + // across the whole field family — not a de-emphasized secondary. + 'label-color': 'var(--color-content-primary)', 'label-font-weight': 'var(--font-weight-regular)', 'placeholder-color': 'var(--color-content-muted)', 'literal-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'segment-height': 'calc(var(--_time-field-height) - var(--space-2))', 'segment-radius': 'var(--radius-sm)', 'segment-color': 'var(--color-content-primary)', @@ -1335,12 +1321,12 @@ export const THEME_BASE_RECIPE_TOKENS = { 'border-invalid': 'var(--color-risk-border)', bg: 'var(--color-surface-default)', color: 'var(--color-content-primary)', - 'label-color': 'var(--color-content-secondary)', + // Field label = max contrast on its surface (content-primary), homogeneous + // across the whole field family — not a de-emphasized secondary. + 'label-color': 'var(--color-content-primary)', 'label-font-weight': 'var(--font-weight-regular)', 'placeholder-color': 'var(--color-content-muted)', 'literal-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'segment-height': 'calc(var(--_color-field-height) - var(--space-2))', 'segment-radius': 'var(--radius-sm)', 'segment-color': 'var(--color-content-primary)', @@ -1366,11 +1352,10 @@ export const THEME_BASE_RECIPE_TOKENS = { 'disabled-opacity': 'var(--opacity-disabled)' }, 'color-picker': { - // Popover content shell - 'content-gap': 'var(--space-3)', - 'content-padding-sm': 'var(--space-3)', - 'content-padding-md': 'var(--space-3-5)', - 'content-padding-lg': 'var(--space-4)', + // Popover content shell. The panel padding is the popover's canonical + // size-aware `--popover-padding-*` (shared by every floating panel) — the + // picker declares NO padding of its own. Inter-row gap is owned by + // [data-picker-body]; both kept the picker from being a padding outlier. 'content-width-sm': '17rem', 'content-width-md': '19.5rem', 'content-width-lg': '22rem', @@ -1395,8 +1380,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'trigger-swatch-size-lg': 'calc(var(--control-height-lg) - var(--space-2))', 'trigger-swatch-radius': 'var(--radius-full)', 'trigger-swatch-border': 'var(--color-border-subtle)', - 'trigger-focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', // 2D area 'area-height': '11rem', @@ -1719,8 +1702,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'day-weekend-color': 'var(--color-content-secondary)', 'day-holiday-shadow': 'inset 0 -2px 0 var(--_calendar-accent-border)', 'day-unavailable-decoration': 'var(--color-risk-border)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'event-shadow': '0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-accent-border)', 'transition-duration': 'var(--duration-fast)', @@ -1777,8 +1758,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'selected-bg-hover': 'var(--color-primary-solid-hover)', 'selected-color': 'var(--color-content-on-solid)', 'ellipsis-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': 'var(--opacity-disabled)' @@ -1922,12 +1901,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'dot-color': 'var(--color-content-on-solid)', 'dot-color-disabled': 'color-mix( in srgb, var(--color-content-on-solid) 82%, var(--color-surface-default) )', - 'focus-ring-offset': 'var(--focus-ring-offset)', - 'focus-ring-width': 'var(--focus-ring-width)', - 'focus-ring-color': 'var(--focus-ring-color)', - 'focus-ring-surface': 'var(--color-surface-default)', - 'focus-ring': - '0 0 0 var(--radio-group-focus-ring-offset) var(--radio-group-focus-ring-surface), 0 0 0 calc(var(--radio-group-focus-ring-offset) + var(--radio-group-focus-ring-width)) var(--radio-group-focus-ring-color)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': 'var(--opacity-disabled)', @@ -1970,6 +1943,29 @@ export const THEME_BASE_RECIPE_TOKENS = { // not theme tokens: a QR must stay dark-on-light to scan — inverting it in // dark mode would break scanners. Theming goes through the `color` / // `background` props (consumer owns the contrast). + chart: { + // Mark geometry sourced from canonical primitives. The two off-scale + // values (area fill tint, point radius) are NAMED here — never inlined + // as magic numbers in the wrapper. + 'line-width': 'var(--border-width-medium)', + 'axis-width': 'var(--border-width-thin)', + 'grid-width': 'var(--border-width-thin)', + 'bar-radius': 'var(--radius-sm)', + 'point-radius': '3.5px', + 'area-opacity': '0.15', + 'slice-stroke-width': 'var(--border-width-thin)', + 'transition-duration': 'var(--duration-fast)', + 'transition-ease': 'var(--ease-out)', + // Hover tooltip surface — matches the overlay / popover chrome. + 'tooltip-bg': 'var(--color-surface-overlay)', + 'tooltip-border': 'var(--color-border-default)', + 'tooltip-radius': 'var(--radius-md)', + 'tooltip-shadow': 'var(--shadow-3)', + 'tooltip-padding': 'var(--space-2)', + 'tooltip-gap': 'var(--space-1-5)', + 'crosshair-color': 'var(--color-border-strong)', + 'crosshair-width': 'var(--border-width-thin)' + }, 'qr-code': { fg: '#18181b', bg: '#ffffff', @@ -2037,7 +2033,6 @@ export const THEME_BASE_RECIPE_TOKENS = { // hosts) — a :root token referencing it is invalid-at-computed-value // and poisons down the tree. It's inlined in radio-cards.css instead, // applied on the card item where the palette IS in scope. - 'focus-ring': 'var(--radio-group-focus-ring)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': 'var(--opacity-disabled)' @@ -2065,8 +2060,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'item-color-partial': 'var(--color-fulfill-solid)', 'item-hover-offset': '-1px', 'focus-radius': 'var(--radius-md)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': 'var(--opacity-disabled)' @@ -2102,8 +2095,6 @@ export const THEME_BASE_RECIPE_TOKENS = { bg: 'var(--color-surface-default)', color: 'var(--color-content-primary)', 'placeholder-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'clear-hidden-opacity': '0', 'icon-size-xs': 'var(--icon-size-xs)', 'icon-size-sm': 'var(--icon-size-sm)', @@ -2156,13 +2147,9 @@ export const THEME_BASE_RECIPE_TOKENS = { bg: 'var(--color-surface-default)', color: 'var(--color-content-primary)', 'placeholder-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'trigger-radius': 'var(--radius-sm)', 'trigger-bg': 'transparent', 'trigger-color': 'var(--color-content-secondary)', - 'trigger-focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'icon-size-xs': 'var(--control-height-xs)', 'icon-size-sm': 'var(--control-height-xs)', 'icon-size-md': 'var(--control-height-sm)', @@ -2235,8 +2222,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'trigger-indicator-color': 'var(--color-content-secondary)', 'trigger-indicator-size': 'var(--icon-size-sm)', 'placeholder-color': 'var(--color-content-muted)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'disabled-border': 'var(--color-border-subtle)', 'disabled-bg': 'var(--color-surface-raised)', 'disabled-color': 'var(--color-content-disabled)', @@ -2395,8 +2380,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'control-color': 'var(--color-content-secondary)', 'control-color-hover': 'var(--color-content-primary)', 'control-color-active': 'var(--color-primary-text)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'separator-bg': 'var(--color-border-subtle)', 'separator-thickness': 'var(--border-width)', 'separator-length': 'var(--control-height-md)', @@ -2433,8 +2416,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'dropzone-radius': 'var(--radius-lg)', 'dropzone-bg': 'var(--color-surface-raised)', 'dropzone-color': 'var(--color-content-secondary)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'button-gap': 'var(--space-1-5)', 'button-px': 'var(--space-3)', 'button-border-width': 'var(--border-width)', @@ -2605,8 +2586,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'item-shadow': 'var(--shadow-subtle)', 'item-color': 'var(--color-content-secondary)', 'item-color-hover': 'var(--color-content-primary)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'solid-fg': 'var(--color-content-on-solid)', 'remove-size': 'var(--control-height-xs)', 'remove-radius': 'var(--radius-full)', @@ -2795,8 +2774,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'control-border-hover': 'var(--color-border-default)', 'control-bg': 'var(--color-surface-default)', 'control-shadow': 'var(--shadow-subtle)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'placeholder-color': 'var(--color-content-muted)', 'trigger-gap': 'var(--space-1)', 'trigger-px': 'var(--space-2-5)', @@ -3016,8 +2993,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'control-border-hover': 'var(--color-border-strong)', 'control-bg': 'var(--color-surface-default)', 'control-shadow': 'var(--shadow-subtle)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'input-min-inline-size': '8ch', 'placeholder-color': 'var(--color-content-muted)', 'item-radius': 'var(--radius-full)', @@ -3105,6 +3080,83 @@ export const THEME_BASE_RECIPE_TOKENS = { ] } }, + metrics: { + // Metrics is SURFACELESS — no per-colour palette here. The Delta's colour + // comes from the composed ; the featured icon and the live flash + // tint use the canonical `--color-*` tokens directly. + // ── Vertical rhythm between label / value / delta / description ── + 'gap-xs': 'var(--space-1)', + 'gap-sm': 'var(--space-1)', + 'gap-md': 'var(--space-1-5)', + 'gap-lg': 'var(--space-2)', + 'gap-xl': 'var(--space-2)', + 'gap-xxl': 'var(--space-3)', + // ── Featured icon (leading container; soft/solid/outline × colour from + // canonical tokens in CSS; padding + glyph size scale with the metric + // size; the shape drives the radius from canonical `--radius-*`). ── + 'icon-gap-xs': 'var(--space-2)', + 'icon-gap-sm': 'var(--space-2-5)', + 'icon-gap-md': 'var(--space-3)', + 'icon-gap-lg': 'var(--space-3-5)', + 'icon-gap-xl': 'var(--space-4)', + 'icon-gap-xxl': 'var(--space-5)', + 'icon-padding-xs': 'var(--space-1-5)', + 'icon-padding-sm': 'var(--space-2)', + 'icon-padding-md': 'var(--space-2)', + 'icon-padding-lg': 'var(--space-2-5)', + 'icon-padding-xl': 'var(--space-3)', + 'icon-padding-xxl': 'var(--space-4)', + 'icon-border-width': 'var(--border-width)', + 'icon-bg': 'var(--color-surface-muted)', + 'icon-color': 'var(--color-content-secondary)', + // ── Label ── + 'label-font-family': 'var(--font-ui)', + 'label-font-size-xs': 'var(--font-size-xxs)', + 'label-font-size-sm': 'var(--font-size-xs)', + 'label-font-size-md': 'var(--font-size-sm)', + 'label-font-size-lg': 'var(--font-size-md)', + 'label-font-size-xl': 'var(--font-size-lg)', + 'label-font-size-xxl': 'var(--font-size-xl)', + 'label-font-weight': 'var(--font-weight-medium)', + 'label-color': 'var(--color-content-secondary)', + 'label-line-height': 'var(--leading-ui)', + 'label-letter-spacing': 'var(--tracking-normal)', + // ── Value (headline figure, tabular numerals) ── + 'value-font-family': 'var(--font-ui)', + 'value-font-size-xs': 'var(--font-size-xs)', + 'value-font-size-sm': 'var(--font-size-sm)', + 'value-font-size-md': 'var(--font-size-md)', + 'value-font-size-lg': 'var(--font-size-lg)', + 'value-font-size-xl': 'var(--font-size-xl)', + 'value-font-size-xxl': 'var(--font-size-xxl)', + 'value-font-weight': 'var(--font-weight-semibold)', + 'value-color': 'var(--color-content-primary)', + 'value-line-height': 'var(--leading-tight)', + 'value-letter-spacing': 'var(--tracking-tight)', + 'value-font-feature': 'var(--font-feature-tabular)', + // ── Description ── + 'description-font-family': 'var(--font-ui)', + 'description-font-size-xs': 'var(--font-size-xxs)', + 'description-font-size-sm': 'var(--font-size-xs)', + 'description-font-size-md': 'var(--font-size-sm)', + 'description-font-size-lg': 'var(--font-size-md)', + 'description-font-size-xl': 'var(--font-size-lg)', + 'description-font-size-xxl': 'var(--font-size-xl)', + 'description-color': 'var(--color-content-secondary)', + 'description-line-height': 'var(--leading-normal)', + // ── Trend chart (sparkline) slot ── + 'chart-margin-block-start': 'var(--space-3)', + // ── Actions row (View report link / dropdown) ── + 'actions-gap': 'var(--space-3)', + 'actions-margin-block-start': 'var(--space-3)', + 'actions-padding-block-start': 'var(--space-3)', + 'actions-border-color': 'var(--color-border-subtle)', + // ── Live-update flash on the value (data-event-family='signal') ── + 'flash-duration': 'var(--duration-slow)', + 'flash-ease': 'var(--ease-out)', + 'flash-radius': 'var(--radius-sm)', + 'flash-inset': 'var(--space-1)' + }, stepper: { 'gap-sm': 'var(--space-3)', 'gap-md': 'var(--space-4)', @@ -3153,8 +3205,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'nav-bg': 'var(--color-surface-raised)', 'nav-color': 'var(--color-content-primary)', 'nav-hover-brightness': '1.04', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': 'var(--opacity-disabled)', @@ -3318,8 +3368,6 @@ export const THEME_BASE_RECIPE_TOKENS = { // because a ~20px thumb needs a larger multiplier to read as a lift. 'thumb-scale-active': '1.15', 'thumb-shadow-active': 'var(--shadow-3)', - 'thumb-focus-shadow': - '0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow)', 'tick-radius': 'var(--radius-full)', 'tick-bg': 'var(--color-neutral-border)', 'tick-bg-active': 'var(--color-primary-solid)', @@ -3354,10 +3402,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'thumb-radius': 'var(--radius-full)', 'thumb-border-width': 'var(--border-width)', 'thumb-shadow': '0 1px 2px rgb(17 14 10 / 0.12)', - 'focus-ring-offset': 'var(--focus-ring-offset)', - 'focus-ring-width': 'var(--focus-ring-width)', - 'focus-ring-color': 'var(--focus-ring-color)', - 'focus-ring-surface': 'var(--color-surface-default)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'track-bg-off': 'var(--color-surface-overlay)', @@ -4176,6 +4220,10 @@ export const THEME_BASE_RECIPE_TOKENS = { // ScrollArea — overlay scrollbars (macOS-style). // ───────────────────────────────────────────────────────────────────── 'scroll-area': { + // Auto-fit max height — the CONSUMER sets this (e.g. the command list caps + // to min(60vh, 24rem)); the recipe declares the `none` default so the token + // is part of the public contract instead of an undeclared consumed var. + 'auto-max-block': 'none', bg: 'transparent', radius: 'var(--radius-md)', 'scrollbar-padding': 'var(--space-0-5)', diff --git a/src/uix/langs/components/chart.ts b/src/uix/langs/components/chart.ts new file mode 100644 index 000000000..a8d30d1b3 --- /dev/null +++ b/src/uix/langs/components/chart.ts @@ -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; diff --git a/src/uix/morfo/components/chart.ts b/src/uix/morfo/components/chart.ts new file mode 100644 index 000000000..b930bd20f --- /dev/null +++ b/src/uix/morfo/components/chart.ts @@ -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 + * `` 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 ``. `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; diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 46d76cebc..2988e13e2 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -78,6 +78,12 @@ const elementSchema = union( literal('img'), literal('svg'), literal('path'), + literal('g'), + literal('rect'), + literal('circle'), + literal('line'), + literal('figure'), + literal('figcaption'), literal('label'), literal('form'), literal('fieldset'), diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index d15276a43..c52cec7e7 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -77,6 +77,12 @@ export type MorfoElement = | 'img' | 'svg' | 'path' + | 'g' + | 'rect' + | 'circle' + | 'line' + | 'figure' + | 'figcaption' | 'label' | 'form' | 'fieldset' diff --git a/web/routes/uix/components/area-chart/+page.svelte b/web/routes/uix/components/area-chart/+page.svelte new file mode 100644 index 000000000..eab1ecd2f --- /dev/null +++ b/web/routes/uix/components/area-chart/+page.svelte @@ -0,0 +1,243 @@ +
+
+
Charts · Area chart
+

Area chart

+

+ Filled area with a line edge per series, on the zero-dependency $libs/plots engine. + Smooth / straight / stepped curves; tunable fill opacity; single or stacked-by-series. +

+
+ parts{compiled.parts.order.length} + rendersvg · 0-dep + series{multi ? 2 : 1} + curve{curve} +
+
+ +
+
+ +
+
+ renders + {multi ? 2 : 1} series · {curve} · opacity {opacity} + height {height}px +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — every control changes the live chart above.

+
+ + + + + + + + +
+
+
+ eidos + AreaChart preset + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+
+ + + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+
+ + + + + + + + + + + + + +
PropTypeDefaultDescription
data{`readonly Record[]`}—Rows of data.
xstring—x position key.
y{`string | string[]`}—Series key(s).
xScale'linear' | 'time' | 'band' | 'point''linear'x mapping.
curve'monotone' | 'linear' | 'step''monotone'Point connection.
opacitynumber0.18Area fill opacity.
legend / noGrid / niceYbooleantrue / false / trueChrome toggles.
heightnumber260Plot height.
formatX / formatY{`(v: number) => string`}—Axis label formatters.
title / ariaLabelstring—Title / accessible name.
+ + + {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. The chart family shares one scope:['eidos'] contract.

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — styles from global theme tokens.

+
+ + + + + + + +
SurfaceToken
series / areavar(--color-{'{'}role{'}'}-solid) + fill-opacity
axis / gridvar(--color-border-default / -subtle)
labels / titlevar(--color-content-secondary / -primary)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
grid / axisaria-hidden"true"
+
+
+ {/if} + diff --git a/web/routes/uix/components/bar-chart/+page.svelte b/web/routes/uix/components/bar-chart/+page.svelte new file mode 100644 index 000000000..b10c9c1ad --- /dev/null +++ b/web/routes/uix/components/bar-chart/+page.svelte @@ -0,0 +1,229 @@ + + +
+
+
Charts · Bar chart
+

Bar chart

+

+ Categorical bars on a band scale, grouped side-by-side across series, on the zero-dependency + $libs/plots engine. Rounded corners; 0-anchored value axis; themed per series. +

+
+ parts{compiled.parts.order.length} + rendersvg · 0-dep + series{multi ? 2 : 1} + scaleband +
+
+ +
+
+ +
+
+ renders + {multi ? 2 : 1} series · radius {radius} + height {height}px +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — every control changes the live chart above.

+
+ + + + + + + +
+
+
+ eidos + BarChart preset + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+
+ + + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
data{`readonly Record[]`}—Rows of data.
xstring—Category key (band scale).
y{`string | string[]`}—Series key(s); multiple → grouped.
radiusnumber2Bar corner radius.
legend / noGrid / niceYbooleantrue / false / trueChrome toggles.
heightnumber260Plot height.
yDomain[number, number]0-anchoredOverride the y domain.
title / ariaLabelstring—Title / accessible name.
+
+

Bars require a band x scale; BarChart sets xScale="band" automatically.

+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. The chart family shares one scope:['eidos'] contract.

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — styles from global theme tokens.

+
+ + + + + + + +
SurfaceToken
barvar(--color-{'{'}role{'}'}-solid) per series
axis / gridvar(--color-border-default / -subtle)
labels / titlevar(--color-content-secondary / -primary)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
grid / axisaria-hidden"true"
+
+
+ {/if} +
diff --git a/web/routes/uix/components/bar-list/+page.svelte b/web/routes/uix/components/bar-list/+page.svelte new file mode 100644 index 000000000..538994198 --- /dev/null +++ b/web/routes/uix/components/bar-list/+page.svelte @@ -0,0 +1,219 @@ + + +
+
+
Charts · Bar list
+

Bar list

+

+ 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. +

+
+ renderhtml · 0-dep + familychart + items{limit ?? data.length} + sorted{sort ? 'yes' : 'no'} +
+
+ +
+
+ +
+
+ renders + {limit ?? data.length} rows · {sort ? 'sorted desc' : 'source order'} +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pass data as an array of {`{ label, value, color? }`} — one row each. Every control changes the live chart; the code below reflects the current props.

+
+ + + + +
+
+
+ eidos + BarList + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+

Default (sorted), source order, top-N, and explicit per-row intent colours.

+
+
Sorted
+
Source order
+
Top 3
+
Intent colours
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + +
PropTypeDefaultDescription
data{`{ label, value, color? }[]`}—Rows; per-item color is a ColorRole.
colorColorRole'primary'Default bar colour for items without their own.
sortbooleantrueSort descending by value.
limitnumber—Show only the top N rows.
tooltipbooleantrueShow the themed hover tooltip (label · value · % of total).
format{`(v: number) => string`}—Value formatter (defaults to the locale-aware number format).
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · chart family contract

+

Bar list is a pure HTML/CSS sub-surface of the chart family (scope:['eidos'], 0 events) — it doesn't use the SVG plotting parts below (provider / axes / marks); it emits its own data-chart-bar-list-* structure. The shared family contract:

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

Global theme tokens (no --chart-* recipe), so rows re-tint with the theme.

+
+ + + + + + + + +
SurfaceToken
barvar(--color-{'{'}role{'}'}-solid) — opaque, per row
trackvar(--color-surface-muted) · height var(--control-height-sm)
label / valuevar(--color-content-primary / -secondary)
radius / gapvar(--radius-sm) / var(--space-*)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
ElementRole / attributeNotes
rowlabel + value textBoth read by the screen reader; order follows the sorted ranking.
bardecorativeThe proportional fill is visual only; the value carries the meaning.
+

Put a self-describing string in each label (e.g. "/home — 4,200 views").

+
+
+ {/if} +
diff --git a/web/routes/uix/components/bar-segment/+page.svelte b/web/routes/uix/components/bar-segment/+page.svelte new file mode 100644 index 000000000..a9d829884 --- /dev/null +++ b/web/routes/uix/components/bar-segment/+page.svelte @@ -0,0 +1,188 @@ + + +
+
+
Charts · Bar segment
+

Bar segment

+

+ 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. +

+
+ renderhtml · 0-dep + familychart + segments{data.length} + whole100% +
+
+ +
+
+ +
+
+ renders + {data.length} segments · hover for label · value · % +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — every control changes the live chart above. Hover a segment for the tooltip.

+
+ + +
+
+
+ eidos + BarSegment + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+

Default, no legend, and explicit intent colours.

+
+
Traffic channels
+
No legend
+
Service health (intent colours)
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + +
PropTypeDefaultDescription
data{`{ label, value, color? }[]`}—Segments; color is a ColorRole.
legendbooleantrueShow the legend below the bar.
tooltipbooleantrueShow the themed hover tooltip.
format{`(v: number) => string`}—Value formatter (defaults to the locale-aware number format).
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · chart family contract

+

Bar segment is a pure HTML/CSS sub-surface of the chart family (scope:['eidos'], 0 events) — it emits its own data-chart-bar-segment-* structure, not the SVG plotting parts below. The shared family contract:

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

Global theme tokens; the tooltip reuses the chart's --chart-tooltip-* surface so it matches every other chart.

+
+ + + + + + + + +
SurfaceToken
segment fillvar(--color-{'{'}role{'}'}-solid) per segment
dim (non-hovered)opacity var(--opacity-scrim)
track height / radiusvar(--control-height-sm) / var(--radius-md)
tooltipvar(--chart-tooltip-bg / -border / -shadow)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
ElementRole / attributeNotes
segmentdecorativeWidth is visual; the legend carries label · value · %.
legendtextRead in order; the value + percentage are the accessible content.
+

The hover tooltip is a pointer affordance; the legend is the keyboard / screen-reader equivalent.

+
+
+ {/if} +
diff --git a/web/routes/uix/components/chart/+page.svelte b/web/routes/uix/components/chart/+page.svelte new file mode 100644 index 000000000..526c79a0a --- /dev/null +++ b/web/routes/uix/components/chart/+page.svelte @@ -0,0 +1,272 @@ + + +
+
+
Charts · Chart
+

Chart

+

+ The compositional chart frame and the chart family overview. <Chart> measures its + width, computes the scales from data + x/y via the + zero-dependency $libs/plots engine, and shares them with SVG children + (Grid / XAxis / YAxis / Line / + Area / Bar / Scatter). The ergonomic presets — + LineChart, AreaChart, BarChart, ScatterChart, + Sparkline, PieChart — compose these for you (one demo each in the + Charts group). +

+
+ parts{compiled.parts.order.length} + events0 + rendersvg · 0-dep + scopeeidos +
+
+ +
+
+ + + + + {#if area}{/if} + + {#if multi}{/if} + + + +
+
+ renders + compositional · {multi ? 2 : 1} series · {curve} + scope eidos · 0 events +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — the controls drive the compositional chart above; the gallery shows the presets.

+
+ + + +
+
+
+ eidos + compositional <Chart> + svelte +
+
{eidosSnippet}
+
+ +

The family

+

Each preset has its own demo page under Charts.

+
+ + + + + +
Sparkline
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference — <Chart> frame

+
+ + + + + + + + + + + +
PropTypeDefaultDescription
data{`readonly Record[]`}—Rows of data.
xstring—x position key.
y{`string | string[]`}—Series key(s) for the y domain.
xScale'linear' | 'time' | 'band' | 'point''linear'x mapping.
heightnumber260Plot height (width fills container).
margin / padding / niceY / yDomainvarious—Layout + domain tuning.
childrenSnippet—SVG parts: Grid / XAxis / YAxis / Line / Area / Bar / Scatter.
+
+

Parts (Chart.Line, Chart.Bar, …) take series, color (ColorRole) and per-mark options (curve, radius, opacity). See each preset's demo for the full surface.

+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. scope:['eidos'] — a display family, 0 events. One contract for the whole family.

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

sema · perceptual events

+

+ No events. A static chart is a derived render (data → scales → + SVG); scope: ['eidos'], like Avatar, Image and + QrCode. It emits no perceptual signal, so there is nothing for sema to project. + Hover tooltips (a future, opt-in interaction) will introduce contact events at + that point — see the component README · Gaps. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — the chart styles from global theme tokens directly, so series re-tint with the theme. chart.css is code-split (imported from chart.svelte).

+
+ + + + + + + + +
SurfaceToken
seriesvar(--color-{'{'}role{'}'}-solid) · primary · affirm · risk · …
axis / gridvar(--color-border-default / -subtle)
labels / titlevar(--color-content-secondary / -primary)
pie separatorvar(--color-surface-default)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
grid / axisaria-hidden"true" (decorative)
marks—summarised by the provider label
+

Always pass ariaLabel with a one-line summary of the chart. A keyboard-navigable data-table fallback is a planned P2 enhancement (README · Gaps).

+
+
+ {/if} +
diff --git a/web/routes/uix/components/line-chart/+page.svelte b/web/routes/uix/components/line-chart/+page.svelte new file mode 100644 index 000000000..d445526fc --- /dev/null +++ b/web/routes/uix/components/line-chart/+page.svelte @@ -0,0 +1,289 @@ + + +
+
+
Charts · Line chart
+

Line chart

+

+ Multi-series line over a continuous or categorical x, built on the in-house + zero-dependency $libs/plots engine. Smooth (monotone), straight or + stepped curves; optional area fill and point dots; themed per series from the palette. +

+
+ parts{compiled.parts.order.length} + rendersvg · 0-dep + series{multi ? 2 : 1} + curve{curve} +
+
+ +
+
+ +
+
+ renders + {multi ? 2 : 1} series · {curve}{area ? ' · area' : ''}{dots ? ' · dots' : ''} + height {height}px +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Pure eidos display — no soma, no sema. Every control + changes the live chart above. +

+
+ + + + + + + + + +
+
+
+ eidos + LineChart preset + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+

Curve styles, area fill and single vs multi-series — all themed from the palette.

+
+ + + + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + + + + + + + + +
PropTypeDefaultDescription
data{`readonly Record[]`}—Rows of data.
xstring—Key on each row for the x position.
y{`string | string[]`}—Series key(s).
xScale'linear' | 'time' | 'band' | 'point''linear'How x maps to pixels.
curve'monotone' | 'linear' | 'step''monotone'Point connection.
areabooleanfalseFill under each line.
dotsbooleanfalseDot at each point.
legendbooleantrueShow legend (multi-series).
noGridbooleanfalseHide the grid.
niceYbooleantrueRound the y domain to nice numbers.
heightnumber260Plot height (width fills the container).
yDomain[number, number]0-anchored extentOverride the y domain.
formatX / formatY{`(v: number) => string`}—Format axis labels.
titlestring—Title above the plot.
ariaLabelstring—Accessible name of the graphic.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. scope:['eidos'] — a display family, no soma/sema. The whole chart family shares this contract.

+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — the chart styles from global theme tokens directly, so series re-tint with the theme.

+
+ + + + + + + + + +
SurfaceToken
seriesvar(--color-{'{'}role{'}'}-solid) · primary · affirm · risk · …
axis line / tickvar(--color-border-default)
grid linevar(--color-border-subtle)
axis labelvar(--color-content-secondary)
titlevar(--color-content-primary)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
grid / axisaria-hidden"true" (decorative guides)
marks—summarised by the provider label
+

A keyboard-navigable data table fallback is a planned P2 enhancement (see component README · Gaps).

+
+
+ {/if} +
diff --git a/web/routes/uix/components/pie-chart/+page.svelte b/web/routes/uix/components/pie-chart/+page.svelte new file mode 100644 index 000000000..decc5cbf2 --- /dev/null +++ b/web/routes/uix/components/pie-chart/+page.svelte @@ -0,0 +1,216 @@ + + +
+
+
Charts · Pie chart
+

Pie chart

+

+ Part-to-whole slices (pie or donut) from the zero-dependency $libs/plots arc engine. + Slices follow input order, themed from the palette; tune the hole size and the gap between slices. +

+
+ parts{compiled.parts.order.length} + rendersvg · 0-dep + slices{data.length} + shape{donut > 0 ? 'donut' : 'pie'} +
+
+ +
+
+ +
+
+ renders + {donut > 0 ? 'donut' : 'pie'} · {data.length} slices · pad {padAngle} + size {size}px +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — every control changes the live chart above.

+
+ + + + + +
+
+
+ eidos + PieChart (standalone) + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+
+ + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + +
PropTypeDefaultDescription
data{`{ label, value, color? }[]`}—Slices, in render order.
sizenumber220Diameter in pixels.
donutnumber0Inner radius fraction (0 = pie, 0.6 = donut).
padAnglenumber0.02Gap between slices in radians.
legendbooleantrueShow the legend.
title / ariaLabelstring—Title / accessible name.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. Pie uses the provider + slice + legend parts of the shared scope:['eidos'] contract.

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — styles from global theme tokens.

+
+ + + + + + + +
SurfaceToken
slicevar(--color-{'{'}role{'}'}-solid) per slice
slice separatorvar(--color-surface-default)
legendvar(--color-content-secondary)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel ?? title
legend—visible label + swatch per slice
+
+
+ {/if} +
diff --git a/web/routes/uix/components/radar-chart/+page.svelte b/web/routes/uix/components/radar-chart/+page.svelte new file mode 100644 index 000000000..07e3aaf44 --- /dev/null +++ b/web/routes/uix/components/radar-chart/+page.svelte @@ -0,0 +1,217 @@ + + +
+
+
Charts · Radar
+

Radar chart

+

+ A radar / spider chart — categories around a circle, each series a polygon whose vertices are the + value at each axis (placed by a radial scaleLinear from the zero-dependency + $libs/plots engine). N-gon grid rings + spokes; hover a sector for the values. +

+
+ rendersvg · 0-dep + axes{data.length} + series{multi ? 2 : 1} + rings{levels} +
+
+ +
+
+ +
+
+ renders + {data.length} axes · {multi ? 2 : 1} series · {levels} rings +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pass data as rows of {`{ axis, ...series }`}, with axis + series naming the keys. Every control changes the live chart.

+
+ + + + + +
+
+
+ eidos + RadarChart + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+
+ + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
data{`Record[]`}—One row per axis/category.
axisstring—Key of the axis (category) label.
series{`string | string[]`}—Series key(s) — one polygon each.
maxnumbernicedRadial domain max.
levelsnumber4Concentric grid rings.
sizenumber300Diameter in pixels.
legend / tooltipbooleantrueChrome toggles.
format{`(v: number) => string`}—Tooltip value formatter (locale-aware default).
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · chart family contract

+

Radar is part of the chart family (scope:['eidos'], 0 events): the root is the provider SVG (role="img"); it adds radar-specific data-chart-radar-* surfaces. The shared contract:

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+
+ + + + + + + +
SurfaceToken
series polygonvar(--color-{'{'}role{'}'}-solid) + fill-opacity var(--chart-area-opacity)
rings / spokesvar(--color-border-subtle) · var(--chart-grid-width)
labelsvar(--color-content-secondary)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
rings / spokesdecorativevisual grid only
+

Pass ariaLabel summarising the comparison (e.g. "Vehicle ratings: current vs target").

+
+
+ {/if} +
diff --git a/web/routes/uix/components/scatter-chart/+page.svelte b/web/routes/uix/components/scatter-chart/+page.svelte new file mode 100644 index 000000000..e4a7fdb1b --- /dev/null +++ b/web/routes/uix/components/scatter-chart/+page.svelte @@ -0,0 +1,229 @@ + + +
+
+
Charts · Scatter chart
+

Scatter chart

+

+ Points over a continuous x/y, one circle per datum, on the zero-dependency $libs/plots + engine. Multiple series overlay with distinct palette colours; adjustable point radius. +

+
+ parts{compiled.parts.order.length} + rendersvg · 0-dep + series{multi ? 2 : 1} + scalelinear +
+
+ +
+
+ +
+
+ renders + {multi ? 2 : 1} series · r {radius} + height {height}px +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — every control changes the live chart above.

+
+ + + + + + +
+
+
+ eidos + ScatterChart preset + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+
+ + + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
data{`readonly Record[]`}—Rows of data.
xstring—x value key (numeric).
y{`string | string[]`}—Series key(s).
xScale'linear' | 'time' | 'band' | 'point''linear'x mapping.
radiusnumber3.5Point radius.
noGrid / niceYbooleanfalse / trueChrome toggles.
heightnumber260Plot height.
title / ariaLabelstring—Title / accessible name.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. The chart family shares one scope:['eidos'] contract.

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — styles from global theme tokens.

+
+ + + + + + + +
SurfaceToken
pointvar(--color-{'{'}role{'}'}-solid) per series
axis / gridvar(--color-border-default / -subtle)
labelsvar(--color-content-secondary)
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
grid / axisaria-hidden"true"
+
+
+ {/if} +
diff --git a/web/routes/uix/components/sparkline/+page.svelte b/web/routes/uix/components/sparkline/+page.svelte new file mode 100644 index 000000000..027478b15 --- /dev/null +++ b/web/routes/uix/components/sparkline/+page.svelte @@ -0,0 +1,216 @@ + + +
+
+
Charts · Sparkline
+

Sparkline

+

+ A compact, axis-less trend line for inline use in tables, cards and feeds — built on the same + zero-dependency $libs/plots engine. Optional area fill; themed by role. +

+
+ parts{compiled.parts.order.length} + rendersvg · 0-dep + chromenone + curve{curve} +
+
+ +
+
+ +
+
+ renders + {curve}{area ? ' · area' : ' · line'} · {color} + height {height}px +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — every control changes the live chart above.

+
+ + + + + +
+
+
+ eidos + Sparkline preset + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+

Inline trends, one per metric — the canonical sparkline use.

+
+
Sessions
+
Revenue
+
Errors · bars
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
data{`readonly Record[]`}—Rows of data.
xstring—x position key.
ystring—Single series key.
as'line' | 'bars''line'Render as a trend line or vertical bars.
curve'monotone' | 'linear' | 'step''monotone'Point connection.
areabooleantrueFill under the line.
colorColorRoleprimarySeries colour by role.
heightnumber40Compact height (width fills container).
+
+

No axes, grid or legend — a sparkline is intentionally chrome-less.

+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/chart.ts. Sparkline uses only the provider + line (+ area) parts of the shared contract.

+
+ + + + {#each partsList as part} + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

No --chart-* tokens — the line/area colour comes from the role token.

+
+ + + + + +
SurfaceToken
line / areavar(--color-{'{'}role{'}'}-solid) + fill-opacity
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + +
PartAttributeValue
provider (svg)role / aria-label"img" / ariaLabel
+

Pass ariaLabel with the trend summary (e.g. "Sessions, up 12% this week"); decorative sparklines may omit it.

+
+
+ {/if} +