wiki-formant 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -753,9 +753,16 @@ monthly.
753
753
 
754
754
  // A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
755
755
  // range), once each.
756
- <TimeChart series={load} label="Price" format={formatPrice} range="30d" />
756
+ <TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
757
757
  ```
758
758
 
759
+ A price point may carry `open`, `high` and `low` (its `value` is the close)
760
+ and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
761
+ candles with `candles`; points that carry volume get a volume pane under the
762
+ price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
763
+ the canvas box, else its `color`. A week of candles opens on its first, closes
764
+ on its last, spans the highest high and lowest low, and sums its volume.
765
+
759
766
  `aggregate` is how a week or month is made from its days: `last` for a price,
760
767
  `mean` for a count, so the week in progress keeps its level instead of reading
761
768
  as a collapse. A single range draws no range buttons and shows every point.
package/dist/chart.d.ts CHANGED
@@ -1,7 +1,14 @@
1
- /** One value at a time in Unix seconds, UTC. */
1
+ /**
2
+ * One value at a time in Unix seconds, UTC. A price may carry the rest of its
3
+ * candle, `value` being the close, and the volume traded in it.
4
+ */
2
5
  export type ChartPoint = {
3
6
  time: number;
4
7
  value: number;
8
+ open?: number;
9
+ high?: number;
10
+ low?: number;
11
+ volume?: number;
5
12
  };
6
13
  export type ChartRange = '24h' | '7d' | '30d' | '90d' | '1y' | 'all';
7
14
  /** What a loader is asked for: hours for 24H, four hours for 7D, days for every longer range. */
@@ -19,8 +26,9 @@ export declare function chartSteps(span: number): {
19
26
  };
20
27
  /**
21
28
  * Points, oldest first, one per step. A price keeps the last value in each
22
- * step; a count takes the mean of its days, so the week in progress does not
23
- * read as a collapse.
29
+ * step, as a candle does its close: the first open, the highest high, the
30
+ * lowest low. A count takes the mean of its days, so the week in progress does
31
+ * not read as a collapse. Volume is summed.
24
32
  */
25
33
  export declare function bucketChart(points: readonly ChartPoint[], step: ChartStep, aggregate: 'last' | 'mean'): ChartPoint[];
26
34
  /** Compact from a thousand up, four significant figures below. */
@@ -40,13 +48,15 @@ export interface TimeChartProps {
40
48
  fromZero?: boolean;
41
49
  /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
42
50
  format?: (n: number) => string;
51
+ /** Open on candles, where the points carry them. A Line/Candles toggle shows either way. */
52
+ candles?: boolean;
43
53
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
44
54
  height?: number;
45
55
  className?: string;
46
56
  }
47
57
  /**
48
- * A time series with its range and step buttons. The buttons are
58
+ * A time series with its range, step and style buttons. The buttons are
49
59
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
50
60
  * and the box their look; the line and fill are the canvas box's `color`.
51
61
  */
52
- export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, height, className, }: TimeChartProps): import("react").JSX.Element;
62
+ export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, candles: initialCandles, height, className, }: TimeChartProps): import("react").JSX.Element;
package/dist/chart.js CHANGED
@@ -3,8 +3,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // chart.tsx — one time series as an area chart: the token, activity and visitor
4
4
  // charts on every site. Ranges run from 24 hours to all time, and a long range
5
5
  // steps by day, week or month, so three years do not draw as a thousand points.
6
- // `lightweight-charts` is loaded on mount, so the page paints without it. Every
7
- // colour is the canvas box's own `color`, and its axis `--chart-axis` if set.
6
+ // A price that carries its open, high and low can be drawn as candles, and one
7
+ // that carries volume gets a volume pane under it. `lightweight-charts` is
8
+ // loaded on mount, so the page paints without it. Every colour is the canvas
9
+ // box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
10
+ // `--chart-down`, each if set.
8
11
  import { useEffect, useMemo, useRef, useState } from 'react';
9
12
  const DAY = 86_400;
10
13
  const RANGE = {
@@ -41,23 +44,35 @@ function bucketStart(time, step) {
41
44
  }
42
45
  /**
43
46
  * Points, oldest first, one per step. A price keeps the last value in each
44
- * step; a count takes the mean of its days, so the week in progress does not
45
- * read as a collapse.
47
+ * step, as a candle does its close: the first open, the highest high, the
48
+ * lowest low. A count takes the mean of its days, so the week in progress does
49
+ * not read as a collapse. Volume is summed.
46
50
  */
47
51
  export function bucketChart(points, step, aggregate) {
48
52
  const out = [];
49
- for (const { time, value } of points) {
50
- const start = bucketStart(time, step);
53
+ let n = 0;
54
+ for (const p of points) {
55
+ const start = bucketStart(p.time, step);
51
56
  const last = out.at(-1);
52
- if (last?.time !== start)
53
- out.push({ time: start, value, n: 1 });
54
- else if (aggregate === 'last')
55
- last.value = value;
57
+ if (last?.time !== start) {
58
+ out.push({ ...p, time: start });
59
+ n = 1;
60
+ continue;
61
+ }
62
+ if (aggregate === 'last')
63
+ last.value = p.value;
56
64
  else
57
- last.value += (value - last.value) / ++last.n;
65
+ last.value += (p.value - last.value) / ++n;
66
+ if (last.high !== undefined && p.high !== undefined)
67
+ last.high = Math.max(last.high, p.high);
68
+ if (last.low !== undefined && p.low !== undefined)
69
+ last.low = Math.min(last.low, p.low);
70
+ if (p.volume !== undefined)
71
+ last.volume = (last.volume ?? 0) + p.volume;
58
72
  }
59
- return out.map(({ time, value }) => ({ time, value }));
73
+ return out;
60
74
  }
75
+ const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
61
76
  const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
62
77
  const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
63
78
  /** Compact from a thousand up, four significant figures below. */
@@ -81,11 +96,11 @@ const fromZeroScale = (base) => {
81
96
  return info;
82
97
  };
83
98
  /**
84
- * A time series with its range and step buttons. The buttons are
99
+ * A time series with its range, step and style buttons. The buttons are
85
100
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
86
101
  * and the box their look; the line and fill are the canvas box's `color`.
87
102
  */
88
- export function TimeChart({ series, label, ranges = typeof series === 'function' ? CHART_RANGES : DAILY_RANGES, range: initial = ranges.includes('30d') ? '30d' : ranges[0], aggregate = 'last', fromZero = false, format = formatChartValue, height, className, }) {
103
+ export function TimeChart({ series, label, ranges = typeof series === 'function' ? CHART_RANGES : DAILY_RANGES, range: initial = ranges.includes('30d') ? '30d' : ranges[0], aggregate = 'last', fromZero = false, format = formatChartValue, candles: initialCandles = false, height, className, }) {
89
104
  const box = useRef(null);
90
105
  const chart = useRef(null);
91
106
  const formatRef = useRef(format);
@@ -93,6 +108,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
93
108
  const [ready, setReady] = useState(false);
94
109
  const [range, setRange] = useState(initial);
95
110
  const [step, setStep] = useState(null);
111
+ const [candles, setCandles] = useState(initialCandles);
96
112
  const [loaded, setLoaded] = useState(null);
97
113
  const resolution = RANGE[range].resolution;
98
114
  useEffect(() => {
@@ -100,12 +116,15 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
100
116
  if (!el)
101
117
  return;
102
118
  let disposed = false;
103
- import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
119
+ import('lightweight-charts').then(({ createChart, AreaSeries, CandlestickSeries, HistogramSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
104
120
  if (disposed)
105
121
  return;
106
122
  const style = getComputedStyle(el);
107
123
  const axis = style.getPropertyValue('--chart-axis').trim();
108
124
  const ink = rgba(style.color);
125
+ const up = rgba(style.getPropertyValue('--chart-up').trim() || style.color);
126
+ const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
127
+ const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
109
128
  const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
110
129
  const api = createChart(el, {
111
130
  autoSize: true,
@@ -130,13 +149,30 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
130
149
  lineType: LineType.Curved,
131
150
  crosshairMarkerBackgroundColor: ink,
132
151
  crosshairMarkerBorderColor: ink,
133
- priceFormat: { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 },
152
+ priceFormat,
134
153
  ...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
135
154
  });
155
+ const candle = api.addSeries(CandlestickSeries, {
156
+ visible: false,
157
+ upColor: up,
158
+ downColor: down,
159
+ borderUpColor: up,
160
+ borderDownColor: down,
161
+ wickUpColor: up,
162
+ wickDownColor: down,
163
+ priceFormat,
164
+ });
136
165
  // The chart cannot be scrolled or zoomed, so every size it takes shows every point,
137
166
  // the first included: data drawn before autoSize has measured the box fits nothing.
138
167
  api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
139
- chart.current = { api, area };
168
+ // Volume takes a pane of its own under the price, a fifth of the height.
169
+ const addVolume = () => {
170
+ const volume = api.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceLineVisible: false, lastValueVisible: false }, 1);
171
+ api.panes()[0]?.setStretchFactor(4);
172
+ api.panes()[1]?.setStretchFactor(1);
173
+ return volume;
174
+ };
175
+ chart.current = { api, area, candle, volume: null, addVolume, up, down };
140
176
  setReady(true);
141
177
  });
142
178
  return () => {
@@ -175,20 +211,46 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
175
211
  const end = points.at(-1).time;
176
212
  const span = RANGE[range].span;
177
213
  const windowed = span === Infinity || ranges.length < 2 ? points : points.filter(p => p.time > Date.now() / 1000 - span);
214
+ const drawn = (shown, steps, step) => ({
215
+ shown,
216
+ steps,
217
+ step,
218
+ ohlc: aggregate === 'last' && shown.length > 0 && shown.every(isCandle),
219
+ volume: shown.some(p => p.volume !== undefined),
220
+ });
178
221
  if (resolution !== 'day' || !windowed.length)
179
- return { shown: windowed, steps: [], step: null };
222
+ return drawn(windowed, [], null);
180
223
  const { steps, fallback } = chartSteps(end - windowed[0].time);
181
224
  const chosen = step && steps.includes(step) ? step : fallback;
182
- return { shown: chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, step: chosen };
225
+ return drawn(chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, chosen);
183
226
  }, [points, range, ranges.length, resolution, step, aggregate]);
184
227
  // While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
185
228
  useEffect(() => {
186
- if (!ready || !chart.current || !source)
229
+ const c = chart.current;
230
+ if (!ready || !c || !source)
187
231
  return;
188
- chart.current.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
189
- chart.current.area.setData((view?.shown ?? []).map(p => ({ time: p.time, value: p.value })));
190
- chart.current.api.timeScale().fitContent();
191
- }, [ready, source, view]);
232
+ const shown = view?.shown ?? [];
233
+ const asCandles = candles && !!view?.ohlc;
234
+ const time = (p) => p.time;
235
+ c.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
236
+ c.area.applyOptions({ visible: !asCandles });
237
+ c.candle.applyOptions({ visible: asCandles });
238
+ c.area.setData(asCandles ? [] : shown.map(p => ({ time: time(p), value: p.value })));
239
+ c.candle.setData(asCandles ? shown.map(p => ({ time: time(p), open: p.open, high: p.high, low: p.low, close: p.value })) : []);
240
+ if (view?.volume && !c.volume)
241
+ c.volume = c.addVolume();
242
+ else if (!view?.volume && c.volume) {
243
+ c.api.removeSeries(c.volume);
244
+ c.volume = null;
245
+ if (c.api.panes().length > 1)
246
+ c.api.removePane(1);
247
+ }
248
+ c.volume?.setData(shown.map((p, i) => {
249
+ const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
250
+ return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
251
+ }));
252
+ c.api.timeScale().fitContent();
253
+ }, [ready, source, view, candles]);
192
254
  const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
193
- return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (ranges.length > 1 || (view && view.steps.length > 1)) && (_jsxs("div", { className: "chart-controls", children: [ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) }))] }))] }));
255
+ return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsxs("div", { role: "group", "aria-label": "Style", children: [_jsx("button", { type: "button", "aria-pressed": !candles, onClick: () => setCandles(false), children: "Line" }), _jsx("button", { type: "button", "aria-pressed": candles, onClick: () => setCandles(true), children: "Candles" })] }))] }))] }));
194
256
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",