wiki-formant 0.36.0 → 0.39.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 +20 -5
- package/dist/analytics.d.ts +18 -2
- package/dist/analytics.js +16 -11
- package/dist/base.css +15 -2
- package/dist/chart.d.ts +15 -5
- package/dist/chart.js +99 -28
- package/dist/stats-figures.d.ts +10 -0
- package/dist/stats-figures.js +58 -0
- package/dist/stats.d.ts +3 -3
- package/dist/stats.js +5 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -731,9 +731,14 @@ return <Stats digest={await digest(sql, { days })} days={days} />;
|
|
|
731
731
|
`PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
|
|
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
|
-
headline figures against the window before,
|
|
735
|
-
ranked list, and `?days=` links between windows, all time among them.
|
|
736
|
-
|
|
734
|
+
headline figures against the window before, one of them per day, every non-empty
|
|
735
|
+
ranked list, and `?days=` links between windows, all time among them. Each
|
|
736
|
+
figure is a button that charts it: visitors, page views, bounce rate, visit
|
|
737
|
+
length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
|
|
738
|
+
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
739
|
+
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
740
|
+
of those charts rather than drawn as zero. Everything but the figures and the
|
|
741
|
+
chart is server-rendered. The chart is `TimeChart` with a count
|
|
737
742
|
axis from zero, so `lightweight-charts` is an optional peer that a stats page
|
|
738
743
|
needs installed.
|
|
739
744
|
|
|
@@ -753,8 +758,18 @@ monthly.
|
|
|
753
758
|
|
|
754
759
|
// A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
|
|
755
760
|
// range), once each.
|
|
756
|
-
<TimeChart series={load} label="Price" format={formatPrice} range="30d" />
|
|
757
|
-
```
|
|
761
|
+
<TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
A price point may carry `open`, `high` and `low` (its `value` is the close)
|
|
765
|
+
and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
|
|
766
|
+
candles with `candles`; points that carry volume get a volume pane under the
|
|
767
|
+
price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
|
|
768
|
+
the canvas box, else its `color`. A week of candles opens on its first, closes
|
|
769
|
+
on its last, spans the highest high and lowest low, and sums its volume. A point
|
|
770
|
+
with a volume of zero is a stretch nothing traded in: its candle and bar leave
|
|
771
|
+
the slot empty, except the last, which is the price now. A price's line is
|
|
772
|
+
straight between closes and a count's is curved.
|
|
758
773
|
|
|
759
774
|
`aggregate` is how a week or month is made from its days: `last` for a price,
|
|
760
775
|
`mean` for a count, so the week in progress keeps its level instead of reading
|
package/dist/analytics.d.ts
CHANGED
|
@@ -81,6 +81,20 @@ export interface DigestOptions {
|
|
|
81
81
|
ownAgents?: string[];
|
|
82
82
|
}
|
|
83
83
|
type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
|
|
84
|
+
/** One UTC day of the figures a digest totals; a visit falls on the day it began. */
|
|
85
|
+
export interface DigestDay {
|
|
86
|
+
date: string;
|
|
87
|
+
visitors: number;
|
|
88
|
+
pageviews: number;
|
|
89
|
+
visits: number;
|
|
90
|
+
/** Visits that saw one page. */
|
|
91
|
+
bounces: number;
|
|
92
|
+
/** Seconds from each visit's first view to its last, summed. */
|
|
93
|
+
seconds: number;
|
|
94
|
+
visitors_incl_agents: number;
|
|
95
|
+
/** Clicks on links to the site's X handle; zero without a handle. */
|
|
96
|
+
follow_clicks: number;
|
|
97
|
+
}
|
|
84
98
|
export interface Digest {
|
|
85
99
|
visitors: number;
|
|
86
100
|
pageviews: number;
|
|
@@ -100,7 +114,8 @@ export interface Digest {
|
|
|
100
114
|
sibling_referrals: Ranked<'source', 'visitors'>;
|
|
101
115
|
countries: Ranked<'country', 'visitors'>;
|
|
102
116
|
devices: Ranked<'device', 'visitors'>;
|
|
103
|
-
|
|
117
|
+
/** Every day in the window with anything in it, oldest first. */
|
|
118
|
+
by_day: DigestDay[];
|
|
104
119
|
follow_clicks: {
|
|
105
120
|
events: number;
|
|
106
121
|
visitors: number;
|
|
@@ -112,7 +127,8 @@ export interface Digest {
|
|
|
112
127
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
113
128
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
114
129
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
115
|
-
* page, source, country, device and per-day figures; events have no
|
|
130
|
+
* page, visit, source, country, device and per-day figures; events have no
|
|
131
|
+
* history, so an archived day's visitors are its visitors with agents too.
|
|
116
132
|
*/
|
|
117
133
|
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
|
|
118
134
|
/**
|
package/dist/analytics.js
CHANGED
|
@@ -192,7 +192,8 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
|
|
|
192
192
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
193
193
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
194
194
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
195
|
-
* page, source, country, device and per-day figures; events have no
|
|
195
|
+
* page, visit, source, country, device and per-day figures; events have no
|
|
196
|
+
* history, so an archived day's visitors are its visitors with agents too.
|
|
196
197
|
*/
|
|
197
198
|
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
|
|
198
199
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
@@ -202,6 +203,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
202
203
|
const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
|
|
203
204
|
/** A ranked list from the views, plus the same breakdown from `ViewDay`. */
|
|
204
205
|
const merged = (label, column, live) => `SELECT k AS ${label}, sum(n) AS visitors FROM (${live} UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a."${column}")) t(k, n) GROUP BY 1`;
|
|
206
|
+
/** Clicks on links to the site's own X account, among `e`. */
|
|
207
|
+
const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
|
|
208
|
+
const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
|
|
205
209
|
const gapSelects = gaps.map(g => {
|
|
206
210
|
const prop = $(g.prop);
|
|
207
211
|
return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
|
|
@@ -216,11 +220,11 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
216
220
|
), numbered AS (
|
|
217
221
|
SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
|
|
218
222
|
), visits AS (
|
|
219
|
-
SELECT visitor, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
|
|
223
|
+
SELECT visitor, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
|
|
220
224
|
(array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
|
|
221
225
|
FROM numbered GROUP BY visitor, visit
|
|
222
226
|
), e AS (
|
|
223
|
-
SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
|
|
227
|
+
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
|
|
224
228
|
), a AS (
|
|
225
229
|
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
|
|
226
230
|
), sessions AS (
|
|
@@ -252,14 +256,15 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
252
256
|
'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
|
|
253
257
|
'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
|
|
254
258
|
'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
|
|
255
|
-
'by_day', ${list(`SELECT date, sum(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
260
|
+
sum(seconds) AS seconds, sum(agents) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
|
|
261
|
+
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
262
|
+
UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0 FROM visits GROUP BY 1
|
|
263
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM e) u GROUP BY 1
|
|
264
|
+
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
265
|
+
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, visitors, 0 FROM a
|
|
266
|
+
) t(date, visitors, pageviews, visits, bounces, seconds, agents, follows) GROUP BY 1`, undefined, '1')},
|
|
267
|
+
'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
|
|
263
268
|
'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
|
|
264
269
|
WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
|
|
265
270
|
AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
|
package/dist/base.css
CHANGED
|
@@ -199,13 +199,26 @@ pre:hover > .code-copy-btn,
|
|
|
199
199
|
.stats { display: grid; gap: 2rem; }
|
|
200
200
|
.stats-ranges { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
201
201
|
.stats-ranges a[aria-current] { text-decoration: underline; text-underline-offset: 0.3em; }
|
|
202
|
+
/* Each figure is a button that charts it; pressed is the one drawn. */
|
|
202
203
|
.stats-figures {
|
|
203
204
|
display: grid;
|
|
204
205
|
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
|
205
206
|
gap: 1rem;
|
|
206
|
-
margin: 0;
|
|
207
207
|
}
|
|
208
|
-
.stats-figures
|
|
208
|
+
.stats-figures button {
|
|
209
|
+
display: grid;
|
|
210
|
+
gap: 0.25em;
|
|
211
|
+
padding: 0;
|
|
212
|
+
font: inherit;
|
|
213
|
+
color: inherit;
|
|
214
|
+
text-align: start;
|
|
215
|
+
background: none;
|
|
216
|
+
border: 0;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
opacity: 0.6;
|
|
219
|
+
}
|
|
220
|
+
.stats-figures button[aria-pressed='true'] { opacity: 1; }
|
|
221
|
+
.stats-figures strong { font-variant-numeric: tabular-nums; }
|
|
209
222
|
.stats-lists {
|
|
210
223
|
display: grid;
|
|
211
224
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
package/dist/chart.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
/**
|
|
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
|
|
23
|
-
*
|
|
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
|
|
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,12 @@ 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
|
-
//
|
|
7
|
-
//
|
|
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. A count's line is curved; a price's is straight,
|
|
11
|
+
// since a curve between two closes draws prices nobody paid.
|
|
8
12
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
13
|
const DAY = 86_400;
|
|
10
14
|
const RANGE = {
|
|
@@ -41,23 +45,35 @@ function bucketStart(time, step) {
|
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
47
|
* Points, oldest first, one per step. A price keeps the last value in each
|
|
44
|
-
* step
|
|
45
|
-
*
|
|
48
|
+
* step, as a candle does its close: the first open, the highest high, the
|
|
49
|
+
* lowest low. A count takes the mean of its days, so the week in progress does
|
|
50
|
+
* not read as a collapse. Volume is summed.
|
|
46
51
|
*/
|
|
47
52
|
export function bucketChart(points, step, aggregate) {
|
|
48
53
|
const out = [];
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
let n = 0;
|
|
55
|
+
for (const p of points) {
|
|
56
|
+
const start = bucketStart(p.time, step);
|
|
51
57
|
const last = out.at(-1);
|
|
52
|
-
if (last?.time !== start)
|
|
53
|
-
out.push({ time: start
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
if (last?.time !== start) {
|
|
59
|
+
out.push({ ...p, time: start });
|
|
60
|
+
n = 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (aggregate === 'last')
|
|
64
|
+
last.value = p.value;
|
|
56
65
|
else
|
|
57
|
-
last.value += (value - last.value) / ++
|
|
66
|
+
last.value += (p.value - last.value) / ++n;
|
|
67
|
+
if (last.high !== undefined && p.high !== undefined)
|
|
68
|
+
last.high = Math.max(last.high, p.high);
|
|
69
|
+
if (last.low !== undefined && p.low !== undefined)
|
|
70
|
+
last.low = Math.min(last.low, p.low);
|
|
71
|
+
if (p.volume !== undefined)
|
|
72
|
+
last.volume = (last.volume ?? 0) + p.volume;
|
|
58
73
|
}
|
|
59
|
-
return out
|
|
74
|
+
return out;
|
|
60
75
|
}
|
|
76
|
+
const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
|
|
61
77
|
const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
|
|
62
78
|
const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
|
|
63
79
|
/** Compact from a thousand up, four significant figures below. */
|
|
@@ -81,11 +97,11 @@ const fromZeroScale = (base) => {
|
|
|
81
97
|
return info;
|
|
82
98
|
};
|
|
83
99
|
/**
|
|
84
|
-
* A time series with its range and
|
|
100
|
+
* A time series with its range, step and style buttons. The buttons are
|
|
85
101
|
* `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
|
|
86
102
|
* and the box their look; the line and fill are the canvas box's `color`.
|
|
87
103
|
*/
|
|
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, }) {
|
|
104
|
+
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
105
|
const box = useRef(null);
|
|
90
106
|
const chart = useRef(null);
|
|
91
107
|
const formatRef = useRef(format);
|
|
@@ -93,6 +109,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
93
109
|
const [ready, setReady] = useState(false);
|
|
94
110
|
const [range, setRange] = useState(initial);
|
|
95
111
|
const [step, setStep] = useState(null);
|
|
112
|
+
const [candles, setCandles] = useState(initialCandles);
|
|
96
113
|
const [loaded, setLoaded] = useState(null);
|
|
97
114
|
const resolution = RANGE[range].resolution;
|
|
98
115
|
useEffect(() => {
|
|
@@ -100,13 +117,17 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
100
117
|
if (!el)
|
|
101
118
|
return;
|
|
102
119
|
let disposed = false;
|
|
103
|
-
import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
|
|
120
|
+
import('lightweight-charts').then(({ createChart, AreaSeries, CandlestickSeries, HistogramSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
|
|
104
121
|
if (disposed)
|
|
105
122
|
return;
|
|
106
123
|
const style = getComputedStyle(el);
|
|
107
124
|
const axis = style.getPropertyValue('--chart-axis').trim();
|
|
108
125
|
const ink = rgba(style.color);
|
|
126
|
+
const up = rgba(style.getPropertyValue('--chart-up').trim() || style.color);
|
|
127
|
+
const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
|
|
128
|
+
const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
|
|
109
129
|
const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
|
|
130
|
+
const grid = rgba(axis || style.color, 0.1);
|
|
110
131
|
const api = createChart(el, {
|
|
111
132
|
autoSize: true,
|
|
112
133
|
layout: {
|
|
@@ -114,8 +135,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
114
135
|
textColor: axis ? rgba(axis) : rgba(style.color, 0.7),
|
|
115
136
|
fontFamily: style.fontFamily,
|
|
116
137
|
fontSize: parseFloat(style.fontSize) || 11,
|
|
138
|
+
// The volume pane's border is a grid line, not the library's light-theme rule.
|
|
139
|
+
panes: { separatorColor: grid, separatorHoverColor: 'transparent', enableResize: false },
|
|
117
140
|
},
|
|
118
|
-
grid: { vertLines: { color:
|
|
141
|
+
grid: { vertLines: { color: grid }, horzLines: { color: grid } },
|
|
119
142
|
crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
|
|
120
143
|
rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
|
|
121
144
|
timeScale: { borderVisible: false, secondsVisible: false },
|
|
@@ -127,16 +150,33 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
127
150
|
topColor: rgba(style.color, 0.4),
|
|
128
151
|
bottomColor: rgba(style.color, 0.02),
|
|
129
152
|
lineWidth: 2,
|
|
130
|
-
lineType: LineType.Curved,
|
|
153
|
+
lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
|
|
131
154
|
crosshairMarkerBackgroundColor: ink,
|
|
132
155
|
crosshairMarkerBorderColor: ink,
|
|
133
|
-
priceFormat
|
|
156
|
+
priceFormat,
|
|
134
157
|
...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
|
|
135
158
|
});
|
|
159
|
+
const candle = api.addSeries(CandlestickSeries, {
|
|
160
|
+
visible: false,
|
|
161
|
+
upColor: up,
|
|
162
|
+
downColor: down,
|
|
163
|
+
borderUpColor: up,
|
|
164
|
+
borderDownColor: down,
|
|
165
|
+
wickUpColor: up,
|
|
166
|
+
wickDownColor: down,
|
|
167
|
+
priceFormat,
|
|
168
|
+
});
|
|
136
169
|
// The chart cannot be scrolled or zoomed, so every size it takes shows every point,
|
|
137
170
|
// the first included: data drawn before autoSize has measured the box fits nothing.
|
|
138
171
|
api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
|
|
139
|
-
|
|
172
|
+
// Volume takes a pane of its own under the price, a fifth of the height.
|
|
173
|
+
const addVolume = () => {
|
|
174
|
+
const volume = api.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceLineVisible: false, lastValueVisible: false }, 1);
|
|
175
|
+
api.panes()[0]?.setStretchFactor(4);
|
|
176
|
+
api.panes()[1]?.setStretchFactor(1);
|
|
177
|
+
return volume;
|
|
178
|
+
};
|
|
179
|
+
chart.current = { api, area, candle, volume: null, addVolume, up, down };
|
|
140
180
|
setReady(true);
|
|
141
181
|
});
|
|
142
182
|
return () => {
|
|
@@ -145,7 +185,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
145
185
|
chart.current = null;
|
|
146
186
|
setReady(false);
|
|
147
187
|
};
|
|
148
|
-
}, [fromZero]);
|
|
188
|
+
}, [fromZero, aggregate]);
|
|
149
189
|
// One request per resolution for the life of the loader: every range from 30D up reads the same days.
|
|
150
190
|
const cache = useRef(new Map());
|
|
151
191
|
useEffect(() => {
|
|
@@ -175,20 +215,51 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
175
215
|
const end = points.at(-1).time;
|
|
176
216
|
const span = RANGE[range].span;
|
|
177
217
|
const windowed = span === Infinity || ranges.length < 2 ? points : points.filter(p => p.time > Date.now() / 1000 - span);
|
|
218
|
+
const drawn = (shown, steps, step) => ({
|
|
219
|
+
shown,
|
|
220
|
+
steps,
|
|
221
|
+
step,
|
|
222
|
+
ohlc: aggregate === 'last' && shown.length > 0 && shown.every(isCandle),
|
|
223
|
+
volume: shown.some(p => p.volume !== undefined),
|
|
224
|
+
});
|
|
178
225
|
if (resolution !== 'day' || !windowed.length)
|
|
179
|
-
return
|
|
226
|
+
return drawn(windowed, [], null);
|
|
180
227
|
const { steps, fallback } = chartSteps(end - windowed[0].time);
|
|
181
228
|
const chosen = step && steps.includes(step) ? step : fallback;
|
|
182
|
-
return
|
|
229
|
+
return drawn(chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, chosen);
|
|
183
230
|
}, [points, range, ranges.length, resolution, step, aggregate]);
|
|
184
231
|
// While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
|
|
185
232
|
useEffect(() => {
|
|
186
|
-
|
|
233
|
+
const c = chart.current;
|
|
234
|
+
if (!ready || !c || !source)
|
|
187
235
|
return;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
236
|
+
const shown = view?.shown ?? [];
|
|
237
|
+
const asCandles = candles && !!view?.ohlc;
|
|
238
|
+
const time = (p) => p.time;
|
|
239
|
+
// A point with no volume is a stretch nothing traded in: its candle and its bar leave the slot
|
|
240
|
+
// empty rather than draw a flat dash. The last one always draws, as the price now.
|
|
241
|
+
const idle = (p, i) => p.volume === 0 && i < shown.length - 1;
|
|
242
|
+
c.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
|
|
243
|
+
c.area.applyOptions({ visible: !asCandles });
|
|
244
|
+
c.candle.applyOptions({ visible: asCandles });
|
|
245
|
+
c.area.setData(asCandles ? [] : shown.map(p => ({ time: time(p), value: p.value })));
|
|
246
|
+
c.candle.setData(asCandles ? shown.map((p, i) => (idle(p, i) ? { time: time(p) } : { time: time(p), open: p.open, high: p.high, low: p.low, close: p.value })) : []);
|
|
247
|
+
if (view?.volume && !c.volume)
|
|
248
|
+
c.volume = c.addVolume();
|
|
249
|
+
else if (!view?.volume && c.volume) {
|
|
250
|
+
c.api.removeSeries(c.volume);
|
|
251
|
+
c.volume = null;
|
|
252
|
+
if (c.api.panes().length > 1)
|
|
253
|
+
c.api.removePane(1);
|
|
254
|
+
}
|
|
255
|
+
c.volume?.setData(shown.map((p, i) => {
|
|
256
|
+
if (!p.volume)
|
|
257
|
+
return { time: time(p) };
|
|
258
|
+
const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
|
|
259
|
+
return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
|
|
260
|
+
}));
|
|
261
|
+
c.api.timeScale().fitContent();
|
|
262
|
+
}, [ready, source, view, candles]);
|
|
192
263
|
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))) }))] }))] }));
|
|
264
|
+
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
265
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Digest } from './analytics.js';
|
|
2
|
+
/**
|
|
3
|
+
* The figures as `.stats-figures button[aria-pressed]` and the pressed one per
|
|
4
|
+
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
5
|
+
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function StatsFigures({ digest, first }: {
|
|
8
|
+
digest: Digest;
|
|
9
|
+
first: number;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// stats-figures.tsx — the stats page's headline figures and the chart under
|
|
4
|
+
// them. Each figure is a button that charts that figure per day, so the chart
|
|
5
|
+
// is not only visitors. Its own module because it holds state; `Stats` stays a
|
|
6
|
+
// server component and hands it the digest, which is plain JSON.
|
|
7
|
+
import { useMemo, useState } from 'react';
|
|
8
|
+
import { TimeChart } from './chart.js';
|
|
9
|
+
const count = (n) => n.toLocaleString('en');
|
|
10
|
+
const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
|
|
11
|
+
const percent = (n) => `${Math.round(n)}%`;
|
|
12
|
+
/** Unsigned at zero, so no change never reads as growth. */
|
|
13
|
+
const change = (now, before) => {
|
|
14
|
+
if (!before)
|
|
15
|
+
return null;
|
|
16
|
+
const pct = Math.round((100 * (now - before)) / before);
|
|
17
|
+
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
18
|
+
};
|
|
19
|
+
const figuresOf = (d) => [
|
|
20
|
+
{ label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, d.previous_visitors), day: r => r.visitors },
|
|
21
|
+
{ label: 'Page views', value: count(d.pageviews), day: r => r.pageviews },
|
|
22
|
+
{
|
|
23
|
+
label: 'Bounce rate',
|
|
24
|
+
value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
|
|
25
|
+
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
26
|
+
format: percent,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: 'Visit length',
|
|
30
|
+
value: d.visit_duration == null ? '–' : duration(d.visit_duration),
|
|
31
|
+
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
32
|
+
format: duration,
|
|
33
|
+
},
|
|
34
|
+
{ label: 'With agents', value: count(d.visitors_incl_agents), day: r => r.visitors_incl_agents },
|
|
35
|
+
...(d.follow_clicks ? [{ label: 'Clicks to X', value: count(d.follow_clicks.events), day: (r) => r.follow_clicks }] : []),
|
|
36
|
+
];
|
|
37
|
+
const DAY = 86_400;
|
|
38
|
+
const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
|
|
39
|
+
/**
|
|
40
|
+
* The figures as `.stats-figures button[aria-pressed]` and the pressed one per
|
|
41
|
+
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
42
|
+
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function StatsFigures({ digest, first }) {
|
|
45
|
+
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
46
|
+
const [chosen, choose] = useState(0);
|
|
47
|
+
const figure = figures[chosen] ?? figures[0];
|
|
48
|
+
const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
|
|
49
|
+
const series = useMemo(() => {
|
|
50
|
+
const today = Math.floor(Date.now() / 1000 / DAY);
|
|
51
|
+
return Array.from({ length: today - first + 1 }, (_, i) => {
|
|
52
|
+
const time = (first + i) * DAY;
|
|
53
|
+
const value = figure.day(byDay.get(new Date(time * 1000).toISOString().slice(0, 10)) ?? EMPTY);
|
|
54
|
+
return value == null ? null : { time, value };
|
|
55
|
+
}).filter(p => p !== null);
|
|
56
|
+
}, [byDay, first, figure]);
|
|
57
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && _jsx("small", { children: ` ${f.delta}` })] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: `${figure.label} per day`, ranges: ['all'], aggregate: "mean", fromZero: true, format: figure.format, className: "stats-chart" })] }));
|
|
58
|
+
}
|
package/dist/stats.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
|
|
|
13
13
|
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
14
14
|
/**
|
|
15
15
|
* A site's own analytics on one page: the headline figures against the window
|
|
16
|
-
* before,
|
|
17
|
-
* with nothing in them are left out. The chart and the
|
|
18
|
-
* `currentColor` until the importing stylesheet gives them a colour.
|
|
16
|
+
* before, whichever of them is chosen per day, and every ranked list the
|
|
17
|
+
* digest carries. Lists with nothing in them are left out. The chart and the
|
|
18
|
+
* bars are `currentColor` until the importing stylesheet gives them a colour.
|
|
19
19
|
*/
|
|
20
20
|
export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
|
package/dist/stats.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { StatsFigures } from './stats-figures.js';
|
|
3
3
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
4
4
|
export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
5
5
|
/** A window as its `?days=` value. */
|
|
@@ -16,14 +16,6 @@ const country = (code) => {
|
|
|
16
16
|
return code;
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
|
-
const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
|
|
20
|
-
/** Unsigned at zero, so no change never reads as growth. */
|
|
21
|
-
const change = (now, before) => {
|
|
22
|
-
if (!before)
|
|
23
|
-
return null;
|
|
24
|
-
const pct = Math.round((100 * (now - before)) / before);
|
|
25
|
-
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
26
|
-
};
|
|
27
19
|
function StatsList({ title, rows, label = v => v }) {
|
|
28
20
|
if (!rows.length)
|
|
29
21
|
return null;
|
|
@@ -32,29 +24,15 @@ function StatsList({ title, rows, label = v => v }) {
|
|
|
32
24
|
}
|
|
33
25
|
/**
|
|
34
26
|
* A site's own analytics on one page: the headline figures against the window
|
|
35
|
-
* before,
|
|
36
|
-
* with nothing in them are left out. The chart and the
|
|
37
|
-
* `currentColor` until the importing stylesheet gives them a colour.
|
|
27
|
+
* before, whichever of them is chosen per day, and every ranked list the
|
|
28
|
+
* digest carries. Lists with nothing in them are left out. The chart and the
|
|
29
|
+
* bars are `currentColor` until the importing stylesheet gives them a colour.
|
|
38
30
|
*/
|
|
39
31
|
export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
40
|
-
const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
|
|
41
32
|
// A rolling window of n days touches n + 1 UTC dates, the first of them in
|
|
42
33
|
// part. All time starts at the first day anything was counted.
|
|
43
34
|
const today = Math.floor(Date.now() / 86_400_000);
|
|
44
35
|
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 };
|
|
48
|
-
});
|
|
49
|
-
const figures = [
|
|
50
|
-
['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
|
|
51
|
-
['Page views', count(d.pageviews), null],
|
|
52
|
-
['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
|
|
53
|
-
['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
|
|
54
|
-
['With agents', count(d.visitors_incl_agents), null],
|
|
55
|
-
];
|
|
56
|
-
if (d.follow_clicks)
|
|
57
|
-
figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
|
|
58
36
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
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(
|
|
37
|
+
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(StatsFigures, { digest: d, first: first }), _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') })] })] }));
|
|
60
38
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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",
|