wiki-formant 0.35.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 +39 -6
- package/dist/analytics.d.ts +1 -1
- package/dist/analytics.js +2 -1
- package/dist/base.css +23 -3
- package/dist/chart.d.ts +62 -0
- package/dist/chart.js +256 -0
- package/dist/stats.d.ts +3 -3
- package/dist/stats.js +14 -10
- package/package.json +6 -1
- package/dist/stats-chart.d.ts +0 -4
- package/dist/stats-chart.js +0 -71
package/README.md
CHANGED
|
@@ -724,7 +724,7 @@ export const POST = gate.route;
|
|
|
724
724
|
// src/app/stats/page.tsx
|
|
725
725
|
if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
|
|
726
726
|
return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
|
|
727
|
-
const days = statsDays((await searchParams).days); // 1, 7, 30, 90 or
|
|
727
|
+
const days = statsDays((await searchParams).days); // 1, 7, 30, 90, 365 or Infinity (?days=all), else 30
|
|
728
728
|
return <Stats digest={await digest(sql, { days })} days={days} />;
|
|
729
729
|
```
|
|
730
730
|
|
|
@@ -732,14 +732,47 @@ return <Stats digest={await digest(sql, { days })} days={days} />;
|
|
|
732
732
|
own subpath, because it imports the optional peer `@simplewebauthn/browser`.
|
|
733
733
|
`Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
|
|
734
734
|
headline figures against the window before, visitors per day, every non-empty
|
|
735
|
-
ranked list, and `?days=` links between windows
|
|
736
|
-
is server-rendered. The chart is
|
|
737
|
-
axis
|
|
738
|
-
|
|
735
|
+
ranked list, and `?days=` links between windows, all time among them. Everything
|
|
736
|
+
but the daily chart is server-rendered. The chart is `TimeChart` with a count
|
|
737
|
+
axis from zero, so `lightweight-charts` is an optional peer that a stats page
|
|
738
|
+
needs installed.
|
|
739
|
+
|
|
740
|
+
## Charts
|
|
741
|
+
|
|
742
|
+
`TimeChart` (`wiki-formant/chart`) is every time-series chart on the four sites:
|
|
743
|
+
token prices, ledger activity, a caper's price, visitors per day. One area
|
|
744
|
+
series in the box's own `color`, its axis in `--chart-axis` if set, with range
|
|
745
|
+
buttons — 24H, 7D, 30D, 90D, 1Y, All — and, on a range long enough, a day, week
|
|
746
|
+
or month step. A step is offered once it makes four points, and the default is
|
|
747
|
+
the finest that makes 200 or fewer, so a year opens weekly and five years
|
|
748
|
+
monthly.
|
|
749
|
+
|
|
750
|
+
```tsx
|
|
751
|
+
// Daily points: the chart slices and steps them itself; 30D up.
|
|
752
|
+
<TimeChart series={days} label="Transactions" aggregate="mean" fromZero />
|
|
753
|
+
|
|
754
|
+
// A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
|
|
755
|
+
// range), once each.
|
|
756
|
+
<TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
|
|
757
|
+
```
|
|
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
|
+
|
|
766
|
+
`aggregate` is how a week or month is made from its days: `last` for a price,
|
|
767
|
+
`mean` for a count, so the week in progress keeps its level instead of reading
|
|
768
|
+
as a collapse. A single range draws no range buttons and shows every point.
|
|
769
|
+
`bucketChart` and `chartSteps` are exported for a chart drawn elsewhere.
|
|
770
|
+
`base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
|
|
771
|
+
canvas's top-left corner; the site's stylesheet gives them and the box a look.
|
|
739
772
|
|
|
740
773
|
## Base stylesheet
|
|
741
774
|
|
|
742
|
-
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`,
|
|
775
|
+
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
|
|
743
776
|
|
|
744
777
|
```css
|
|
745
778
|
@import "wiki-formant/base.css" layer(components);
|
package/dist/analytics.d.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface Gap {
|
|
|
67
67
|
export declare const SEARCH_GAPS: Gap;
|
|
68
68
|
export interface DigestOptions {
|
|
69
69
|
site?: string;
|
|
70
|
-
/** How far back, in whole days from now. */
|
|
70
|
+
/** How far back, in whole days from now; `Infinity` for all time. */
|
|
71
71
|
days: number;
|
|
72
72
|
/** The site's own X handle: `follow_clicks` counts clicks on links to it. */
|
|
73
73
|
handle?: string;
|
package/dist/analytics.js
CHANGED
|
@@ -196,7 +196,8 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
|
|
|
196
196
|
*/
|
|
197
197
|
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
|
|
198
198
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
199
|
-
|
|
199
|
+
// All time is a century: further back than any row, and still an int to Postgres.
|
|
200
|
+
const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
|
|
200
201
|
const $ = (v) => `$${values.push(v)}`;
|
|
201
202
|
const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
|
|
202
203
|
/** A ranked list from the views, plus the same breakdown from `ViewDay`. */
|
package/dist/base.css
CHANGED
|
@@ -170,6 +170,29 @@ pre:hover > .code-copy-btn,
|
|
|
170
170
|
}
|
|
171
171
|
.revision-diff { margin: 0.25em 0 0; }
|
|
172
172
|
|
|
173
|
+
/* ---- Chart ----------------------------------------------------------------
|
|
174
|
+
`TimeChart`: the canvas, a loading or empty state over it, and the range and
|
|
175
|
+
step buttons pinned to its top-left corner. A site gives the buttons and the
|
|
176
|
+
box their look; pressed is all this marks. */
|
|
177
|
+
.chart { position: relative; margin: 0; }
|
|
178
|
+
/* Its own stacking context: the library's canvases stack themselves, and the buttons go over them. */
|
|
179
|
+
.chart-canvas { position: relative; z-index: 0; height: var(--chart-height, 15rem); }
|
|
180
|
+
/* The chart lays itself out in a <table>; a site's table styles are not for it. */
|
|
181
|
+
.chart-canvas :is(table, tr, td) { background: none; border: 0; border-radius: 0; }
|
|
182
|
+
.chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
|
|
183
|
+
.chart-controls {
|
|
184
|
+
position: absolute;
|
|
185
|
+
top: 0.5rem;
|
|
186
|
+
left: 0.5rem;
|
|
187
|
+
z-index: 1;
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-wrap: wrap;
|
|
190
|
+
gap: 0.5rem;
|
|
191
|
+
}
|
|
192
|
+
.chart-controls > div { display: flex; gap: 2px; }
|
|
193
|
+
.chart-controls button { font: inherit; color: inherit; background: none; cursor: pointer; opacity: 0.6; }
|
|
194
|
+
.chart-controls button[aria-pressed='true'] { opacity: 1; }
|
|
195
|
+
|
|
173
196
|
/* ---- Stats ----------------------------------------------------------------
|
|
174
197
|
`--share` is a row's value over the largest in its list, 0 to 1. Bars take
|
|
175
198
|
`currentColor` so they show before a stylesheet colours them. */
|
|
@@ -183,9 +206,6 @@ pre:hover > .code-copy-btn,
|
|
|
183
206
|
margin: 0;
|
|
184
207
|
}
|
|
185
208
|
.stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
186
|
-
.stats-chart { height: 15rem; }
|
|
187
|
-
/* The chart lays itself out in a <table>; a site's table styles are not for it. */
|
|
188
|
-
.stats-chart :is(table, tr, td) { background: none; border: 0; border-radius: 0; }
|
|
189
209
|
.stats-lists {
|
|
190
210
|
display: grid;
|
|
191
211
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
package/dist/chart.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
*/
|
|
5
|
+
export type ChartPoint = {
|
|
6
|
+
time: number;
|
|
7
|
+
value: number;
|
|
8
|
+
open?: number;
|
|
9
|
+
high?: number;
|
|
10
|
+
low?: number;
|
|
11
|
+
volume?: number;
|
|
12
|
+
};
|
|
13
|
+
export type ChartRange = '24h' | '7d' | '30d' | '90d' | '1y' | 'all';
|
|
14
|
+
/** What a loader is asked for: hours for 24H, four hours for 7D, days for every longer range. */
|
|
15
|
+
export type ChartResolution = 'hour' | '4h' | 'day';
|
|
16
|
+
/** How a range of days is drawn: each day, or a point per week or month. */
|
|
17
|
+
export type ChartStep = 'day' | 'week' | 'month';
|
|
18
|
+
/** Points by resolution, from `from` (Unix seconds, 0 for all time), oldest first. */
|
|
19
|
+
export type ChartLoader = (resolution: ChartResolution, from: number) => Promise<readonly ChartPoint[]>;
|
|
20
|
+
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
21
|
+
export declare const CHART_RANGES: readonly ChartRange[];
|
|
22
|
+
/** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
|
|
23
|
+
export declare function chartSteps(span: number): {
|
|
24
|
+
steps: ChartStep[];
|
|
25
|
+
fallback: ChartStep;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Points, oldest first, one per step. A price keeps the last value in each
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
export declare function bucketChart(points: readonly ChartPoint[], step: ChartStep, aggregate: 'last' | 'mean'): ChartPoint[];
|
|
34
|
+
/** Compact from a thousand up, four significant figures below. */
|
|
35
|
+
export declare const formatChartValue: (n: number) => string;
|
|
36
|
+
export interface TimeChartProps {
|
|
37
|
+
/** Daily points, oldest first, or a loader for every resolution the ranges need. */
|
|
38
|
+
series: readonly ChartPoint[] | ChartLoader;
|
|
39
|
+
/** Read by assistive technology, as the chart's name. */
|
|
40
|
+
label: string;
|
|
41
|
+
/** The ranges offered. A single one draws no buttons and shows every point. */
|
|
42
|
+
ranges?: readonly ChartRange[];
|
|
43
|
+
/** The range shown first. */
|
|
44
|
+
range?: ChartRange;
|
|
45
|
+
/** A week or month of prices keeps its last; of counts, the mean of its days. */
|
|
46
|
+
aggregate?: 'last' | 'mean';
|
|
47
|
+
/** The value axis starts at zero, so a quiet week does not read as a collapse. */
|
|
48
|
+
fromZero?: boolean;
|
|
49
|
+
/** The value axis and crosshair label. Read on every draw, so it need not be stable. */
|
|
50
|
+
format?: (n: number) => string;
|
|
51
|
+
/** Open on candles, where the points carry them. A Line/Candles toggle shows either way. */
|
|
52
|
+
candles?: boolean;
|
|
53
|
+
/** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
|
|
54
|
+
height?: number;
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A time series with its range, step and style buttons. The buttons are
|
|
59
|
+
* `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
|
|
60
|
+
* and the box their look; the line and fill are the canvas box's `color`.
|
|
61
|
+
*/
|
|
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
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// chart.tsx — one time series as an area chart: the token, activity and visitor
|
|
4
|
+
// charts on every site. Ranges run from 24 hours to all time, and a long range
|
|
5
|
+
// steps by day, week or month, so three years do not draw as a thousand points.
|
|
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.
|
|
11
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
const DAY = 86_400;
|
|
13
|
+
const RANGE = {
|
|
14
|
+
'24h': { label: '24H', span: DAY, resolution: 'hour' },
|
|
15
|
+
'7d': { label: '7D', span: 7 * DAY, resolution: '4h' },
|
|
16
|
+
'30d': { label: '30D', span: 30 * DAY, resolution: 'day' },
|
|
17
|
+
'90d': { label: '90D', span: 90 * DAY, resolution: 'day' },
|
|
18
|
+
'1y': { label: '1Y', span: 365 * DAY, resolution: 'day' },
|
|
19
|
+
all: { label: 'All', span: Infinity, resolution: 'day' },
|
|
20
|
+
};
|
|
21
|
+
const STEP = {
|
|
22
|
+
day: { label: 'D', span: DAY },
|
|
23
|
+
week: { label: 'W', span: 7 * DAY },
|
|
24
|
+
month: { label: 'M', span: 30.44 * DAY },
|
|
25
|
+
};
|
|
26
|
+
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
27
|
+
export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
|
|
28
|
+
const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
|
|
29
|
+
/** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
|
|
30
|
+
export function chartSteps(span) {
|
|
31
|
+
const steps = ['day', 'week', 'month'].filter(s => s === 'day' || span / STEP[s].span >= 4);
|
|
32
|
+
return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
|
|
33
|
+
}
|
|
34
|
+
/** The UTC day, Monday or first of the month a time falls in. */
|
|
35
|
+
function bucketStart(time, step) {
|
|
36
|
+
const day = Math.floor(time / DAY);
|
|
37
|
+
if (step === 'day')
|
|
38
|
+
return day * DAY;
|
|
39
|
+
// 1 January 1970 was a Thursday, three days after a Monday.
|
|
40
|
+
if (step === 'week')
|
|
41
|
+
return (day - ((day + 3) % 7)) * DAY;
|
|
42
|
+
const d = new Date(time * 1000);
|
|
43
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Points, oldest first, one per step. A price keeps the last value in each
|
|
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.
|
|
50
|
+
*/
|
|
51
|
+
export function bucketChart(points, step, aggregate) {
|
|
52
|
+
const out = [];
|
|
53
|
+
let n = 0;
|
|
54
|
+
for (const p of points) {
|
|
55
|
+
const start = bucketStart(p.time, step);
|
|
56
|
+
const last = out.at(-1);
|
|
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;
|
|
64
|
+
else
|
|
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;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
|
|
76
|
+
const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
|
|
77
|
+
const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
|
|
78
|
+
/** Compact from a thousand up, four significant figures below. */
|
|
79
|
+
export const formatChartValue = (n) => (Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n));
|
|
80
|
+
/**
|
|
81
|
+
* A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
|
|
82
|
+
* a pixel and read back, because a site's colour may be oklch() or a named one.
|
|
83
|
+
*/
|
|
84
|
+
function rgba(colour, alpha = 1) {
|
|
85
|
+
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
86
|
+
if (!ctx)
|
|
87
|
+
return colour;
|
|
88
|
+
ctx.fillStyle = colour;
|
|
89
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
90
|
+
return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
|
|
91
|
+
}
|
|
92
|
+
const fromZeroScale = (base) => {
|
|
93
|
+
const info = base();
|
|
94
|
+
if (info?.priceRange)
|
|
95
|
+
info.priceRange.minValue = 0;
|
|
96
|
+
return info;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* A time series with its range, step and style buttons. The buttons are
|
|
100
|
+
* `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
|
|
101
|
+
* and the box their look; the line and fill are the canvas box's `color`.
|
|
102
|
+
*/
|
|
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, }) {
|
|
104
|
+
const box = useRef(null);
|
|
105
|
+
const chart = useRef(null);
|
|
106
|
+
const formatRef = useRef(format);
|
|
107
|
+
formatRef.current = format;
|
|
108
|
+
const [ready, setReady] = useState(false);
|
|
109
|
+
const [range, setRange] = useState(initial);
|
|
110
|
+
const [step, setStep] = useState(null);
|
|
111
|
+
const [candles, setCandles] = useState(initialCandles);
|
|
112
|
+
const [loaded, setLoaded] = useState(null);
|
|
113
|
+
const resolution = RANGE[range].resolution;
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const el = box.current;
|
|
116
|
+
if (!el)
|
|
117
|
+
return;
|
|
118
|
+
let disposed = false;
|
|
119
|
+
import('lightweight-charts').then(({ createChart, AreaSeries, CandlestickSeries, HistogramSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
|
|
120
|
+
if (disposed)
|
|
121
|
+
return;
|
|
122
|
+
const style = getComputedStyle(el);
|
|
123
|
+
const axis = style.getPropertyValue('--chart-axis').trim();
|
|
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 };
|
|
128
|
+
const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
|
|
129
|
+
const api = createChart(el, {
|
|
130
|
+
autoSize: true,
|
|
131
|
+
layout: {
|
|
132
|
+
background: { type: ColorType.Solid, color: 'transparent' },
|
|
133
|
+
textColor: axis ? rgba(axis) : rgba(style.color, 0.7),
|
|
134
|
+
fontFamily: style.fontFamily,
|
|
135
|
+
fontSize: parseFloat(style.fontSize) || 11,
|
|
136
|
+
},
|
|
137
|
+
grid: { vertLines: { color: rgba(axis || style.color, 0.1) }, horzLines: { color: rgba(axis || style.color, 0.1) } },
|
|
138
|
+
crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
|
|
139
|
+
rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
|
|
140
|
+
timeScale: { borderVisible: false, secondsVisible: false },
|
|
141
|
+
handleScroll: false,
|
|
142
|
+
handleScale: false,
|
|
143
|
+
});
|
|
144
|
+
const area = api.addSeries(AreaSeries, {
|
|
145
|
+
lineColor: ink,
|
|
146
|
+
topColor: rgba(style.color, 0.4),
|
|
147
|
+
bottomColor: rgba(style.color, 0.02),
|
|
148
|
+
lineWidth: 2,
|
|
149
|
+
lineType: LineType.Curved,
|
|
150
|
+
crosshairMarkerBackgroundColor: ink,
|
|
151
|
+
crosshairMarkerBorderColor: ink,
|
|
152
|
+
priceFormat,
|
|
153
|
+
...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
|
|
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
|
+
});
|
|
165
|
+
// The chart cannot be scrolled or zoomed, so every size it takes shows every point,
|
|
166
|
+
// the first included: data drawn before autoSize has measured the box fits nothing.
|
|
167
|
+
api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
|
|
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 };
|
|
176
|
+
setReady(true);
|
|
177
|
+
});
|
|
178
|
+
return () => {
|
|
179
|
+
disposed = true;
|
|
180
|
+
chart.current?.api.remove();
|
|
181
|
+
chart.current = null;
|
|
182
|
+
setReady(false);
|
|
183
|
+
};
|
|
184
|
+
}, [fromZero]);
|
|
185
|
+
// One request per resolution for the life of the loader: every range from 30D up reads the same days.
|
|
186
|
+
const cache = useRef(new Map());
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
cache.current.clear();
|
|
189
|
+
}, [series]);
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (typeof series !== 'function')
|
|
192
|
+
return;
|
|
193
|
+
let current = true;
|
|
194
|
+
let request = cache.current.get(resolution);
|
|
195
|
+
if (!request) {
|
|
196
|
+
const now = Math.floor(Date.now() / 1000);
|
|
197
|
+
request = series(resolution, resolution === 'day' ? 0 : now - RANGE[range].span);
|
|
198
|
+
cache.current.set(resolution, request);
|
|
199
|
+
request.catch(() => cache.current.delete(resolution));
|
|
200
|
+
}
|
|
201
|
+
request.then(points => current && setLoaded({ resolution, points }), (e) => current && setLoaded({ resolution, error: e instanceof Error ? e.message : 'The chart could not load' }));
|
|
202
|
+
return () => {
|
|
203
|
+
current = false;
|
|
204
|
+
};
|
|
205
|
+
}, [series, resolution, range]);
|
|
206
|
+
const source = useMemo(() => (typeof series === 'function' ? (loaded?.resolution === resolution ? loaded : null) : { resolution: 'day', points: series }), [series, loaded, resolution]);
|
|
207
|
+
const points = source && 'points' in source ? source.points : null;
|
|
208
|
+
const view = useMemo(() => {
|
|
209
|
+
if (!points?.length)
|
|
210
|
+
return null;
|
|
211
|
+
const end = points.at(-1).time;
|
|
212
|
+
const span = RANGE[range].span;
|
|
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
|
+
});
|
|
221
|
+
if (resolution !== 'day' || !windowed.length)
|
|
222
|
+
return drawn(windowed, [], null);
|
|
223
|
+
const { steps, fallback } = chartSteps(end - windowed[0].time);
|
|
224
|
+
const chosen = step && steps.includes(step) ? step : fallback;
|
|
225
|
+
return drawn(chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, chosen);
|
|
226
|
+
}, [points, range, ranges.length, resolution, step, aggregate]);
|
|
227
|
+
// While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const c = chart.current;
|
|
230
|
+
if (!ready || !c || !source)
|
|
231
|
+
return;
|
|
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]);
|
|
254
|
+
const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
|
|
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" })] }))] }))] }));
|
|
256
|
+
}
|
package/dist/stats.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ import type { Digest } from './analytics.js';
|
|
|
2
2
|
export interface StatsProps {
|
|
3
3
|
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
4
4
|
digest: Digest;
|
|
5
|
-
/** The window the digest covers, in days. */
|
|
5
|
+
/** The window the digest covers, in days; `Infinity` for all time. */
|
|
6
6
|
days: number;
|
|
7
7
|
/** The windows offered as `?days=` links on the same page. */
|
|
8
8
|
ranges?: readonly number[];
|
|
9
9
|
}
|
|
10
|
-
/** The windows the stats page offers, in days. */
|
|
11
|
-
export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365];
|
|
10
|
+
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
11
|
+
export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
|
|
12
12
|
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
13
13
|
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
14
14
|
/**
|
package/dist/stats.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
/** The windows the stats page offers, in days. */
|
|
4
|
-
export const STATS_RANGES = [1, 7, 30, 90, 365];
|
|
2
|
+
import { TimeChart } from './chart.js';
|
|
3
|
+
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
4
|
+
export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
5
|
+
/** A window as its `?days=` value. */
|
|
6
|
+
const daysParam = (n) => (Number.isFinite(n) ? String(n) : 'all');
|
|
5
7
|
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
6
|
-
export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n =>
|
|
8
|
+
export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
|
|
7
9
|
const count = (n) => n.toLocaleString('en');
|
|
8
10
|
const regions = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
9
11
|
const country = (code) => {
|
|
@@ -36,11 +38,13 @@ function StatsList({ title, rows, label = v => v }) {
|
|
|
36
38
|
*/
|
|
37
39
|
export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
38
40
|
const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
|
|
39
|
-
// A rolling window of n days touches n + 1 UTC dates, the first of them in
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// A rolling window of n days touches n + 1 UTC dates, the first of them in
|
|
42
|
+
// part. All time starts at the first day anything was counted.
|
|
43
|
+
const today = Math.floor(Date.now() / 86_400_000);
|
|
44
|
+
const first = Number.isFinite(days) ? today - days : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / 86_400_000));
|
|
45
|
+
const series = Array.from({ length: today - first + 1 }, (_, i) => {
|
|
46
|
+
const time = (first + i) * 86_400;
|
|
47
|
+
return { time, value: byDay.get(new Date(time * 1000).toISOString().slice(0, 10)) ?? 0 };
|
|
44
48
|
});
|
|
45
49
|
const figures = [
|
|
46
50
|
['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
|
|
@@ -52,5 +56,5 @@ export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
|
52
56
|
if (d.follow_clicks)
|
|
53
57
|
figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
|
|
54
58
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
55
|
-
return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx(
|
|
59
|
+
return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${daysParam(n)}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : Number.isFinite(n) ? `${n} days` : 'All time' }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx(TimeChart, { series: series, label: "Visitors per day", ranges: ['all'], aggregate: "mean", fromZero: true, className: "stats-chart" }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
56
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "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",
|
|
@@ -177,6 +177,11 @@
|
|
|
177
177
|
"import": "./dist/stats.js",
|
|
178
178
|
"default": "./dist/stats.js"
|
|
179
179
|
},
|
|
180
|
+
"./chart": {
|
|
181
|
+
"types": "./dist/chart.d.ts",
|
|
182
|
+
"import": "./dist/chart.js",
|
|
183
|
+
"default": "./dist/chart.js"
|
|
184
|
+
},
|
|
180
185
|
"./dom": {
|
|
181
186
|
"types": "./dist/dom.d.ts",
|
|
182
187
|
"import": "./dist/dom.js",
|
package/dist/stats-chart.d.ts
DELETED
package/dist/stats-chart.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
// stats-chart.tsx — visitors per day for `Stats`, with a date axis and a count
|
|
4
|
-
// axis. `lightweight-charts` is loaded on mount, so the rest of the page paints
|
|
5
|
-
// without it. Every colour is the box's own `color`, like the bars beside it.
|
|
6
|
-
import { useEffect, useRef } from 'react';
|
|
7
|
-
/**
|
|
8
|
-
* A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
|
|
9
|
-
* a pixel and read back, because a site's colour may be oklch() or a named one.
|
|
10
|
-
*/
|
|
11
|
-
function rgba(colour, alpha = 1) {
|
|
12
|
-
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
13
|
-
if (!ctx)
|
|
14
|
-
return colour;
|
|
15
|
-
ctx.fillStyle = colour;
|
|
16
|
-
ctx.fillRect(0, 0, 1, 1);
|
|
17
|
-
return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
|
|
18
|
-
}
|
|
19
|
-
/** Counts start at zero, so a quiet week does not read as a collapse. */
|
|
20
|
-
const fromZero = (base) => {
|
|
21
|
-
const info = base();
|
|
22
|
-
if (info?.priceRange)
|
|
23
|
-
info.priceRange.minValue = 0;
|
|
24
|
-
return info;
|
|
25
|
-
};
|
|
26
|
-
/** One `[YYYY-MM-DD, visitors]` per UTC day, oldest first. */
|
|
27
|
-
export function StatsChart({ series }) {
|
|
28
|
-
const ref = useRef(null);
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
const box = ref.current;
|
|
31
|
-
if (!box)
|
|
32
|
-
return;
|
|
33
|
-
let chart;
|
|
34
|
-
let disposed = false;
|
|
35
|
-
import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle }) => {
|
|
36
|
-
if (disposed)
|
|
37
|
-
return;
|
|
38
|
-
const { color, fontFamily } = getComputedStyle(box);
|
|
39
|
-
const ink = rgba(color);
|
|
40
|
-
const guide = { color: rgba(color, 0.4), width: 1, style: LineStyle.Dotted };
|
|
41
|
-
chart = createChart(box, {
|
|
42
|
-
autoSize: true,
|
|
43
|
-
layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: rgba(color, 0.7), fontFamily },
|
|
44
|
-
grid: { vertLines: { visible: false }, horzLines: { color: rgba(color, 0.1) } },
|
|
45
|
-
crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
|
|
46
|
-
rightPriceScale: { borderVisible: false },
|
|
47
|
-
timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
|
|
48
|
-
handleScroll: false,
|
|
49
|
-
handleScale: false,
|
|
50
|
-
});
|
|
51
|
-
chart
|
|
52
|
-
.addSeries(AreaSeries, {
|
|
53
|
-
lineColor: ink,
|
|
54
|
-
topColor: rgba(color, 0.4),
|
|
55
|
-
bottomColor: rgba(color, 0.02),
|
|
56
|
-
lineWidth: 2,
|
|
57
|
-
crosshairMarkerBackgroundColor: ink,
|
|
58
|
-
priceLineVisible: false,
|
|
59
|
-
priceFormat: { type: 'price', precision: 0, minMove: 1 },
|
|
60
|
-
autoscaleInfoProvider: fromZero,
|
|
61
|
-
})
|
|
62
|
-
.setData(series.map(([time, value]) => ({ time, value })));
|
|
63
|
-
chart.timeScale().fitContent();
|
|
64
|
-
});
|
|
65
|
-
return () => {
|
|
66
|
-
disposed = true;
|
|
67
|
-
chart?.remove();
|
|
68
|
-
};
|
|
69
|
-
}, [series]);
|
|
70
|
-
return _jsx("div", { ref: ref, className: "stats-chart", role: "img", "aria-label": "Visitors per day" });
|
|
71
|
-
}
|