wiki-formant 0.91.1 → 0.94.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 +13 -7
- package/dist/analytics.d.ts +2 -2
- package/dist/analytics.js +7 -2
- package/dist/base.css +50 -3
- package/dist/block-editor.d.ts +7 -1
- package/dist/block-editor.js +46 -9
- package/dist/chart.d.ts +9 -5
- package/dist/chart.js +45 -20
- package/dist/dom.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -859,7 +859,7 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
|
|
|
859
859
|
|
|
860
860
|
## Block editor
|
|
861
861
|
|
|
862
|
-
`wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once, and so is the chrome: each block is a card with a toolbar (its name, then add below, move, duplicate and delete, shown while the block is in hand and always on a touch screen), and every add menu is two tight columns of names, each type's `hint` its tooltip, drawn by `base.css` on the `--ui-*` tokens. The fields' look arrives as a kit.
|
|
862
|
+
`wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once, and so is the chrome: each block is a card with a toolbar (its name, then add below, move, duplicate and delete, shown while the block is in hand and always on a touch screen), and every add menu is two tight columns of names, each type's `hint` its tooltip, drawn by `base.css` on the `--ui-*` tokens. An option's `group` lists it under that heading, groups in the order they first appear; a menu of more than eight opens on a box that finds a type by name, Enter taking the first found (on a touch screen it waits to be tapped, so the keyboard does not cover the sheet). The fields' look arrives as a kit.
|
|
863
863
|
|
|
864
864
|
```tsx
|
|
865
865
|
const KIT: BlockEditorKit = {
|
|
@@ -957,7 +957,10 @@ An agent is counted as a visitor is, one address and user agent a day, from its
|
|
|
957
957
|
`AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
|
|
958
958
|
among the visitors, and `visitors_incl_agents` counts the two at once.
|
|
959
959
|
`ownAgents` leaves the site's own out of both. A visit is a run of one
|
|
960
|
-
visitor's views with no gap over 30 minutes
|
|
960
|
+
visitor's views with no gap over 30 minutes, and a bounce is a visit of one view
|
|
961
|
+
with no event of the reader's inside it: a search that keeps the path, a click
|
|
962
|
+
out or a share makes the visit count. A browser under automation
|
|
963
|
+
(`navigator.webdriver`) sends no beacon at all. A multi-tenant app passes a `site`
|
|
961
964
|
key to every call and a `site` resolver to `collect`.
|
|
962
965
|
|
|
963
966
|
Instrumenting the agent lane and not the human one is the easy mistake: it
|
|
@@ -1081,7 +1084,8 @@ which no total makes sense of. A stepped range starts on a whole week or month,
|
|
|
1081
1084
|
so its first point is not a few days of one; the week in progress is its total
|
|
1082
1085
|
so far. `fromZero` starts a count's axis at zero and stops it there, with
|
|
1083
1086
|
no negative ticks under it. A single range draws no range buttons and shows every point.
|
|
1084
|
-
|
|
1087
|
+
No step is finer than the points' own spacing, so weekly readings offer W and M
|
|
1088
|
+
and draw on the days they were taken. `chartSteps` is exported for a chart drawn elsewhere.
|
|
1085
1089
|
|
|
1086
1090
|
`format` writes the axis and the crosshair label. An axis tick also gets the
|
|
1087
1091
|
spacing between ticks as a second argument, so a formatter can write every tick
|
|
@@ -1090,13 +1094,15 @@ decimal places that takes, and `formatChartValue`, the default, uses it.
|
|
|
1090
1094
|
|
|
1091
1095
|
`markers` are events, `{ time, label }`, each drawn as an arrow on the last
|
|
1092
1096
|
point at or before it, so one mid-week lands on its week and two on one point
|
|
1093
|
-
share it.
|
|
1094
|
-
|
|
1095
|
-
|
|
1097
|
+
share it. No words go on the plot: the label is a tooltip (`.chart-tip`) while
|
|
1098
|
+
the crosshair is on the point, one line an event, so it can be the event's full
|
|
1099
|
+
name. `step` opens on a step where the range can make it, and `onView` hears the
|
|
1100
|
+
range, and the step the reader picked (null while the chart picks its own), once
|
|
1101
|
+
the points are in and on every change, for a page that keeps them in its URL:
|
|
1096
1102
|
|
|
1097
1103
|
```tsx
|
|
1098
1104
|
<TimeChart series={days} label="Transactions" aggregate="sum" fromZero
|
|
1099
|
-
markers={[{ time: haltAt, label: '
|
|
1105
|
+
markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
|
|
1100
1106
|
```
|
|
1101
1107
|
`base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
|
|
1102
1108
|
canvas's top-left corner, rules a line between groups and dims a disabled step;
|
package/dist/analytics.d.ts
CHANGED
|
@@ -89,7 +89,7 @@ export interface DigestDay {
|
|
|
89
89
|
visitors: number;
|
|
90
90
|
pageviews: number;
|
|
91
91
|
visits: number;
|
|
92
|
-
/** Visits that saw one page. */
|
|
92
|
+
/** Visits that saw one page and did nothing else: a search, a click out or any reader event makes it a visit. */
|
|
93
93
|
bounces: number;
|
|
94
94
|
/** Seconds from each visit's first view to its last, summed. */
|
|
95
95
|
seconds: number;
|
|
@@ -101,7 +101,7 @@ export interface DigestDay {
|
|
|
101
101
|
export interface Digest {
|
|
102
102
|
visitors: number;
|
|
103
103
|
pageviews: number;
|
|
104
|
-
/** Percent of visits that
|
|
104
|
+
/** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
|
|
105
105
|
bounce_rate: number | null;
|
|
106
106
|
/** Mean seconds from a visit's first view to its last; null with no visits. */
|
|
107
107
|
visit_duration: number | null;
|
package/dist/analytics.js
CHANGED
|
@@ -223,6 +223,10 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
223
223
|
), e${s} AS (
|
|
224
224
|
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
|
|
225
225
|
${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
|
|
226
|
+
), bounced${s} AS (
|
|
227
|
+
SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
|
|
228
|
+
SELECT 1 FROM e${s} x WHERE x.visitor = b.visitor AND x.name NOT IN ('AI Bot Visit', 'MCP Call')
|
|
229
|
+
AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
|
|
226
230
|
), ag${s} AS (
|
|
227
231
|
SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
|
|
228
232
|
WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
|
|
@@ -230,7 +234,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
230
234
|
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
|
|
231
235
|
), sessions${s} AS (
|
|
232
236
|
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
233
|
-
SELECT count(*)
|
|
237
|
+
SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
|
|
234
238
|
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
|
|
235
239
|
) t(bounces, visits, seconds)
|
|
236
240
|
), archived${s} AS (
|
|
@@ -262,7 +266,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
262
266
|
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
263
267
|
sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
|
|
264
268
|
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
265
|
-
UNION ALL SELECT ${day('at')}, 0, 0, count(*),
|
|
269
|
+
UNION ALL SELECT ${day('at')}, 0, 0, count(*), 0, coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
|
|
270
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), 0, 0, 0, 0 FROM bounced GROUP BY 1
|
|
266
271
|
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
|
|
267
272
|
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM ag) u GROUP BY 1
|
|
268
273
|
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
package/dist/base.css
CHANGED
|
@@ -614,6 +614,20 @@ pre:hover > .code-copy-btn,
|
|
|
614
614
|
cut the axis to half its labels. */
|
|
615
615
|
.chart-canvas table { table-layout: auto; }
|
|
616
616
|
.chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
|
|
617
|
+
/* An event's name, over the mark the crosshair is on, in the colours of the axis labels. */
|
|
618
|
+
.chart-tip {
|
|
619
|
+
position: absolute;
|
|
620
|
+
z-index: 2;
|
|
621
|
+
max-width: 50%;
|
|
622
|
+
padding: 0.25rem 0.5rem;
|
|
623
|
+
font-size: 0.6875rem;
|
|
624
|
+
line-height: 1.4;
|
|
625
|
+
white-space: pre-line;
|
|
626
|
+
color: var(--ui-on-accent);
|
|
627
|
+
background: var(--ui-accent);
|
|
628
|
+
border-radius: calc(var(--ui-radius) / 2);
|
|
629
|
+
pointer-events: none;
|
|
630
|
+
}
|
|
617
631
|
.chart-controls {
|
|
618
632
|
position: absolute;
|
|
619
633
|
top: 0.5rem;
|
|
@@ -722,7 +736,8 @@ pre:hover > .code-copy-btn,
|
|
|
722
736
|
/* ---- the block editor (BlockEditor, BlockToolbar, AddBlockMenu) -------------
|
|
723
737
|
Every wiki's editor wears the same chrome: a card per block, its name and its
|
|
724
738
|
controls in a toolbar above its fields, and an add menu of two tight columns,
|
|
725
|
-
names only, with what each type is for as its tooltip
|
|
739
|
+
names only, with what each type is for as its tooltip, under their groups'
|
|
740
|
+
headings, and a box to find one by name at the top of a long menu. The four had drawn it
|
|
726
741
|
four ways, one a 16-row list a screen tall. A block's controls show while it
|
|
727
742
|
is in hand (hovered, focused, picked, or with its menu open), and always on a
|
|
728
743
|
screen with no hover. The fields inside are the site's kit. */
|
|
@@ -800,9 +815,12 @@ pre:hover > .code-copy-btn,
|
|
|
800
815
|
position: absolute;
|
|
801
816
|
top: calc(100% + 0.25rem);
|
|
802
817
|
z-index: 50;
|
|
803
|
-
display:
|
|
804
|
-
|
|
818
|
+
display: flex;
|
|
819
|
+
flex-direction: column;
|
|
805
820
|
width: min(24rem, calc(100vw - 2rem));
|
|
821
|
+
max-height: min(28rem, calc(100dvh - 2rem));
|
|
822
|
+
overflow-y: auto;
|
|
823
|
+
overscroll-behavior: contain;
|
|
806
824
|
padding: 0.25rem;
|
|
807
825
|
background: var(--ui-surface);
|
|
808
826
|
border: 1px solid var(--ui-border);
|
|
@@ -818,6 +836,35 @@ pre:hover > .code-copy-btn,
|
|
|
818
836
|
/* As specific as the two placements above, and after them. */
|
|
819
837
|
.block-menu:is(:not([data-below]), [data-below]) > .block-menu__panel { position: fixed; inset: auto 1rem 1rem; width: auto; translate: none; }
|
|
820
838
|
}
|
|
839
|
+
.block-menu__group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
840
|
+
.block-menu__group + .block-menu__group { margin-top: 0.25rem; }
|
|
841
|
+
.block-menu__heading {
|
|
842
|
+
grid-column: 1 / -1;
|
|
843
|
+
padding: 0.375rem 0.5rem 0.125rem;
|
|
844
|
+
font-size: var(--ui-small);
|
|
845
|
+
font-weight: 500;
|
|
846
|
+
letter-spacing: 0.02em;
|
|
847
|
+
text-transform: uppercase;
|
|
848
|
+
color: var(--ui-muted);
|
|
849
|
+
}
|
|
850
|
+
/* The box stays in view as a long menu scrolls under it. */
|
|
851
|
+
.block-menu__find {
|
|
852
|
+
position: sticky;
|
|
853
|
+
top: -0.25rem;
|
|
854
|
+
z-index: 1;
|
|
855
|
+
margin: -0.25rem -0.25rem 0.25rem;
|
|
856
|
+
padding: 0.5rem 0.75rem;
|
|
857
|
+
border: 0;
|
|
858
|
+
border-bottom: 1px solid var(--ui-border);
|
|
859
|
+
border-radius: var(--ui-radius) var(--ui-radius) 0 0;
|
|
860
|
+
background: var(--ui-surface);
|
|
861
|
+
font: inherit;
|
|
862
|
+
font-size: var(--ui-small);
|
|
863
|
+
color: inherit;
|
|
864
|
+
outline: none;
|
|
865
|
+
}
|
|
866
|
+
.block-menu__find:focus-visible { border-bottom-color: var(--ui-accent); }
|
|
867
|
+
.block-menu__none { padding: 0.375rem 0.5rem; font-size: var(--ui-small); color: var(--ui-muted); }
|
|
821
868
|
.block-menu__item {
|
|
822
869
|
display: flex;
|
|
823
870
|
align-items: center;
|
package/dist/block-editor.d.ts
CHANGED
|
@@ -117,6 +117,8 @@ export interface BlockOption {
|
|
|
117
117
|
icon?: ReactNode;
|
|
118
118
|
/** What it is for: the item's tooltip. */
|
|
119
119
|
hint?: string;
|
|
120
|
+
/** The heading it is listed under; groups keep the order they first appear in. */
|
|
121
|
+
group?: string;
|
|
120
122
|
}
|
|
121
123
|
export interface AddBlockMenuProps {
|
|
122
124
|
options: readonly BlockOption[];
|
|
@@ -127,7 +129,11 @@ export interface AddBlockMenuProps {
|
|
|
127
129
|
/** The foot button's words. */
|
|
128
130
|
label?: string;
|
|
129
131
|
}
|
|
130
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* An add button and its menu, which closes on a pick, a click outside it or
|
|
134
|
+
* Escape. A long menu opens on a box that finds a type by name, Enter taking
|
|
135
|
+
* the first found. Nothing to offer, no button.
|
|
136
|
+
*/
|
|
131
137
|
export declare function AddBlockMenu({ options, onPick, icon, below, label }: AddBlockMenuProps): import("react").JSX.Element | null;
|
|
132
138
|
export interface BlockToolbarProps extends Pick<BlockActionsProps, 'index' | 'total' | 'ops'> {
|
|
133
139
|
label: string;
|
package/dist/block-editor.js
CHANGED
|
@@ -22,7 +22,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
22
22
|
// what lets a column or an infobox hold a list without being handed the whole
|
|
23
23
|
// configuration. Keep both stable — a module constant or a `useMemo` — or every
|
|
24
24
|
// card re-renders on every keystroke.
|
|
25
|
-
import { createContext, memo, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
25
|
+
import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
26
26
|
import { BlockActions, useBlockOperations, useClickOutside } from './react.js';
|
|
27
27
|
import { CODE_LANGS, DEFAULT_LANG, EVENT_DATE, eventBegun, eventEnded, eventWhen, LIST_SOURCES, today, } from './blocks.js';
|
|
28
28
|
import { BANNER_VARIANTS } from './text.js';
|
|
@@ -93,13 +93,40 @@ const BlockCard = memo(function BlockCard({ block, index, total, selected, neste
|
|
|
93
93
|
});
|
|
94
94
|
/** The least gap, in px, between an open menu and the window's side. */
|
|
95
95
|
const MENU_MARGIN = 8;
|
|
96
|
-
/**
|
|
96
|
+
/** A menu longer than this opens with a box to find a type by name. */
|
|
97
|
+
const FIND_OVER = 8;
|
|
98
|
+
/** Options under their headings, in the order each group first appears. */
|
|
99
|
+
function grouped(options) {
|
|
100
|
+
const groups = new Map();
|
|
101
|
+
for (const o of options) {
|
|
102
|
+
const key = o.group ?? '';
|
|
103
|
+
groups.set(key, [...(groups.get(key) ?? []), o]);
|
|
104
|
+
}
|
|
105
|
+
return [...groups];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* An add button and its menu, which closes on a pick, a click outside it or
|
|
109
|
+
* Escape. A long menu opens on a box that finds a type by name, Enter taking
|
|
110
|
+
* the first found. Nothing to offer, no button.
|
|
111
|
+
*/
|
|
97
112
|
export function AddBlockMenu({ options, onPick, icon, below = false, label = 'Add block' }) {
|
|
98
113
|
const [open, setOpen] = useState(false);
|
|
99
|
-
const
|
|
114
|
+
const [query, setQuery] = useState('');
|
|
115
|
+
const close = useCallback(() => {
|
|
116
|
+
setOpen(false);
|
|
117
|
+
setQuery('');
|
|
118
|
+
}, []);
|
|
100
119
|
const ref = useClickOutside(close);
|
|
101
120
|
const trigger = useRef(null);
|
|
102
121
|
const panel = useRef(null);
|
|
122
|
+
const find = useRef(null);
|
|
123
|
+
// A touch screen is left without its keyboard until the box is tapped, so
|
|
124
|
+
// the sheet is not half hidden behind it.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const el = find.current;
|
|
127
|
+
if (open && el && !el.ownerDocument.defaultView?.matchMedia?.('(hover: none)').matches)
|
|
128
|
+
el.focus();
|
|
129
|
+
}, [open]);
|
|
103
130
|
// Hung off a button deep in a column, the panel can run past the window's
|
|
104
131
|
// edge: it moves back in by what it overhangs. A phone's sheet is fixed and
|
|
105
132
|
// already fits.
|
|
@@ -119,18 +146,28 @@ export function AddBlockMenu({ options, onPick, icon, below = false, label = 'Ad
|
|
|
119
146
|
}, [open]);
|
|
120
147
|
if (options.length === 0)
|
|
121
148
|
return null;
|
|
149
|
+
const words = query.trim().toLowerCase();
|
|
150
|
+
const found = words ? options.filter(o => o.label.toLowerCase().includes(words)) : options;
|
|
151
|
+
const pick = (type) => {
|
|
152
|
+
close();
|
|
153
|
+
onPick(type);
|
|
154
|
+
};
|
|
122
155
|
return (_jsxs("span", { ref: ref, className: "block-menu", "data-below": below || undefined, onKeyDown: e => {
|
|
123
156
|
if (e.key !== 'Escape' || !open)
|
|
124
157
|
return;
|
|
125
158
|
e.stopPropagation();
|
|
126
159
|
close();
|
|
127
160
|
trigger.current?.focus();
|
|
128
|
-
}, children: [_jsxs("button", { ref: trigger, type: "button", className: below ? 'block-toolbar__button' : 'block-add', title: below ? 'Add a block below' : undefined, "aria-label": below ? 'Add a block below' : undefined, "aria-haspopup": "menu", "aria-expanded": open, onClick: () =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
161
|
+
}, children: [_jsxs("button", { ref: trigger, type: "button", className: below ? 'block-toolbar__button' : 'block-add', title: below ? 'Add a block below' : undefined, "aria-label": below ? 'Add a block below' : undefined, "aria-haspopup": "menu", "aria-expanded": open, onClick: () => (open ? close() : setOpen(true)), children: [icon, !below && label] }), open && (_jsxs("span", { ref: panel, className: "block-menu__panel", role: "menu", children: [options.length > FIND_OVER && (_jsx("input", { ref: find, type: "search", className: "block-menu__find", placeholder: "Find a block", "aria-label": "Find a block", enterKeyHint: "go", value: query, onChange: e => setQuery(e.target.value), onKeyDown: e => {
|
|
162
|
+
if (e.key !== 'Enter' || !found[0])
|
|
163
|
+
return;
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
pick(found[0].type);
|
|
166
|
+
} })), found.length === 0 && _jsx("span", { className: "block-menu__none", children: "No block by that name." }), grouped(found).map(([group, items]) => (_jsxs("span", { className: "block-menu__group", role: "group", "aria-label": group || undefined, children: [group && (_jsx("span", { className: "block-menu__heading", "aria-hidden": true, children: group })), items.map(o => (_jsxs("button", { type: "button", role: "menuitem", className: "block-menu__item", title: o.hint, onClick: e => {
|
|
167
|
+
// Inside a card, a pick is not also a click on the card.
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
pick(o.type);
|
|
170
|
+
}, children: [o.icon, _jsx("span", { children: o.label })] }, o.type)))] }, group)))] }))] }));
|
|
134
171
|
}
|
|
135
172
|
/** A block's name and its controls: add below, move up and down, duplicate, delete. */
|
|
136
173
|
export function BlockToolbar({ label, icon, index, total, ops, icons, options, onAdd, children }) {
|
package/dist/chart.d.ts
CHANGED
|
@@ -18,12 +18,12 @@ export type ChartResolution = 'hour' | '4h' | 'day';
|
|
|
18
18
|
export type ChartStep = 'day' | 'week' | 'month';
|
|
19
19
|
/** Points by resolution, from `from` (Unix seconds, 0 for all time), oldest first. */
|
|
20
20
|
export type ChartLoader = (resolution: ChartResolution, from: number) => Promise<readonly ChartPoint[]>;
|
|
21
|
-
/** Something that happened at a time, in Unix seconds,
|
|
21
|
+
/** Something that happened at a time, in Unix seconds, marked on the point that holds it; `label` is its tooltip. */
|
|
22
22
|
export type ChartMarker = {
|
|
23
23
|
time: number;
|
|
24
24
|
label: string;
|
|
25
25
|
};
|
|
26
|
-
/** The range and step a reader has picked, for a page that keeps them in its URL. */
|
|
26
|
+
/** The range and step a reader has picked, for a page that keeps them in its URL; `step` is null while the chart picks its own. */
|
|
27
27
|
export type ChartView = {
|
|
28
28
|
range: ChartRange;
|
|
29
29
|
step: ChartStep | null;
|
|
@@ -32,8 +32,12 @@ export type ChartView = {
|
|
|
32
32
|
export type ChartAggregate = 'last' | 'sum' | 'mean';
|
|
33
33
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
34
34
|
export declare const CHART_RANGES: readonly ChartRange[];
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A step is offered once it makes four points, and the default is the finest that
|
|
37
|
+
* makes 200 or fewer. None is finer than the points' own `spacing`: weekly readings
|
|
38
|
+
* have no day.
|
|
39
|
+
*/
|
|
40
|
+
export declare function chartSteps(span: number, spacing?: number): {
|
|
37
41
|
steps: ChartStep[];
|
|
38
42
|
fallback: ChartStep;
|
|
39
43
|
};
|
|
@@ -71,7 +75,7 @@ export interface TimeChartProps {
|
|
|
71
75
|
* window links, which load another digest rather than slice this one.
|
|
72
76
|
*/
|
|
73
77
|
controls?: ReactNode;
|
|
74
|
-
/** Events
|
|
78
|
+
/** Events marked on the points that hold them, each named in a tooltip. */
|
|
75
79
|
markers?: readonly ChartMarker[];
|
|
76
80
|
/** The step shown first, where the range can make it. */
|
|
77
81
|
step?: ChartStep;
|
package/dist/chart.js
CHANGED
|
@@ -8,8 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// loaded on mount, so the page paints without it. Every colour is the canvas
|
|
9
9
|
// box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
|
|
10
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. Events are
|
|
12
|
-
// on the point they fall in, so a spike carries the
|
|
11
|
+
// since a curve between two closes draws prices nobody paid. Events are marks
|
|
12
|
+
// on the point they fall in, named in a tooltip over it, so a spike carries the
|
|
13
|
+
// reason for it without words across the plot.
|
|
13
14
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
14
15
|
const DAY = 86_400;
|
|
15
16
|
const RANGE = {
|
|
@@ -29,11 +30,22 @@ const STEPS = ['day', 'week', 'month'];
|
|
|
29
30
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
30
31
|
export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
|
|
31
32
|
const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* A step is offered once it makes four points, and the default is the finest that
|
|
35
|
+
* makes 200 or fewer. None is finer than the points' own `spacing`: weekly readings
|
|
36
|
+
* have no day.
|
|
37
|
+
*/
|
|
38
|
+
export function chartSteps(span, spacing = DAY) {
|
|
39
|
+
const fine = STEPS.filter(s => STEP[s].span >= spacing * 0.9);
|
|
40
|
+
const finest = fine[0] ?? 'month';
|
|
41
|
+
const steps = STEPS.filter(s => s === finest || (fine.includes(s) && span / STEP[s].span >= 4));
|
|
35
42
|
return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
|
|
36
43
|
}
|
|
44
|
+
/** The points' own spacing: the median gap, so a missing day or a skipped week does not change it. */
|
|
45
|
+
function spacing(points) {
|
|
46
|
+
const gaps = points.slice(1).map((p, i) => p.time - points[i].time).sort((a, b) => a - b);
|
|
47
|
+
return gaps[Math.floor(gaps.length / 2)] ?? DAY;
|
|
48
|
+
}
|
|
37
49
|
/** The UTC day, Monday or first of the month a time falls in. */
|
|
38
50
|
function bucketStart(time, step) {
|
|
39
51
|
const day = Math.floor(time / DAY);
|
|
@@ -81,7 +93,7 @@ export function bucketChart(points, step, aggregate) {
|
|
|
81
93
|
}
|
|
82
94
|
/**
|
|
83
95
|
* Markers on the points drawn: each on the last point at or before it, so one
|
|
84
|
-
* mid-week sits on its week. Markers on one point share it,
|
|
96
|
+
* mid-week sits on its week. Markers on one point share it, a label a line; one
|
|
85
97
|
* before the first point or after the last point's step is left off.
|
|
86
98
|
*
|
|
87
99
|
* @internal
|
|
@@ -102,7 +114,7 @@ export function placeMarkers(markers, shown, step) {
|
|
|
102
114
|
const time = shown[i].time;
|
|
103
115
|
placed.set(time, [...(placed.get(time) ?? []), m.label]);
|
|
104
116
|
}
|
|
105
|
-
return [...placed].map(([time, labels]) => ({ time, label: labels.join('
|
|
117
|
+
return [...placed].map(([time, labels]) => ({ time, label: labels.join('\n') }));
|
|
106
118
|
}
|
|
107
119
|
const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
|
|
108
120
|
/** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
|
|
@@ -163,6 +175,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
163
175
|
const [step, setStep] = useState(initialStep ?? null);
|
|
164
176
|
const [candles, setCandles] = useState(initialCandles);
|
|
165
177
|
const [loaded, setLoaded] = useState(null);
|
|
178
|
+
// The marker under the crosshair, at the cursor, in the box's pixels.
|
|
179
|
+
const [tip, setTip] = useState(null);
|
|
180
|
+
const placed = useRef(new Map());
|
|
166
181
|
const resolution = RANGE[range].resolution;
|
|
167
182
|
useEffect(() => {
|
|
168
183
|
const el = box.current;
|
|
@@ -227,6 +242,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
227
242
|
wickDownColor: down,
|
|
228
243
|
priceFormat,
|
|
229
244
|
});
|
|
245
|
+
api.subscribeCrosshairMove(({ time, point }) => {
|
|
246
|
+
const label = time === undefined || !point ? undefined : placed.current.get(time);
|
|
247
|
+
setTip(label ? { x: point.x, y: point.y, label } : null);
|
|
248
|
+
});
|
|
230
249
|
// The chart cannot be scrolled or zoomed, so every size it takes shows every point,
|
|
231
250
|
// the first included: data drawn before autoSize has measured the box fits nothing.
|
|
232
251
|
api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
|
|
@@ -298,9 +317,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
298
317
|
});
|
|
299
318
|
if (resolution !== 'day' || !windowed.length)
|
|
300
319
|
return drawn(windowed, [], null);
|
|
301
|
-
const { steps, fallback } = chartSteps(end - windowed[0].time);
|
|
320
|
+
const { steps, fallback } = chartSteps(end - windowed[0].time, spacing(points));
|
|
302
321
|
const chosen = step && steps.includes(step) ? step : fallback;
|
|
303
|
-
|
|
322
|
+
// The points' own step draws them as they are: a Sunday's reading stays on its Sunday.
|
|
323
|
+
if (chosen === steps[0])
|
|
304
324
|
return drawn(windowed, steps, chosen);
|
|
305
325
|
// A stepped range starts on a whole week or month, so its first total is not a few days of one.
|
|
306
326
|
const from = bucketStart(windowed[0].time, chosen);
|
|
@@ -336,24 +356,29 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
336
356
|
const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
|
|
337
357
|
return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
|
|
338
358
|
}));
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
shape: 'arrowDown',
|
|
343
|
-
color: c.ink,
|
|
344
|
-
text: m.label,
|
|
345
|
-
}));
|
|
359
|
+
const marks = placeMarkers(markers ?? [], shown, view?.step ?? null);
|
|
360
|
+
placed.current = new Map(marks.map(m => [m.time, m.label]));
|
|
361
|
+
const drawn = marks.map(m => ({ time: time(m), position: 'aboveBar', shape: 'arrowDown', color: c.ink }));
|
|
346
362
|
c.areaMarkers.setMarkers(asCandles ? [] : drawn);
|
|
347
363
|
c.candleMarkers.setMarkers(asCandles ? drawn : []);
|
|
348
364
|
c.api.timeScale().fitContent();
|
|
349
365
|
}, [ready, source, view, candles, markers]);
|
|
350
|
-
|
|
366
|
+
// Reported once there are points to step, so a step read from a URL is not cleared while they load.
|
|
367
|
+
const shown = !!view;
|
|
368
|
+
const picked = step && step === view?.step ? step : null;
|
|
351
369
|
useEffect(() => {
|
|
352
|
-
|
|
353
|
-
|
|
370
|
+
if (shown)
|
|
371
|
+
onViewRef.current?.({ range, step: picked });
|
|
372
|
+
}, [shown, range, picked]);
|
|
354
373
|
// The steps always show where a range is drawn by day, so the buttons do not jump about; one
|
|
355
374
|
// the range cannot make (any, under 24H and 7D) is disabled.
|
|
356
375
|
const daily = ranges.some(r => RANGE[r].resolution === 'day');
|
|
357
376
|
const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
|
|
358
|
-
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 }),
|
|
377
|
+
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 }), tip && box.current && (
|
|
378
|
+
// Over the cursor, or under it near the top; growing away from the nearer side.
|
|
379
|
+
_jsx("span", { className: "chart-tip", role: "tooltip", style: {
|
|
380
|
+
top: box.current.offsetTop + tip.y,
|
|
381
|
+
...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
|
|
382
|
+
translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
|
|
383
|
+
}, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, 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))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
|
|
359
384
|
}
|
package/dist/dom.js
CHANGED
|
@@ -330,7 +330,10 @@ export function scrollTables(html) {
|
|
|
330
330
|
// ---- analytics beacon -------------------------------------------------------
|
|
331
331
|
let beaconEndpoint = '/api/view';
|
|
332
332
|
let beaconStarted = false;
|
|
333
|
-
|
|
333
|
+
// A browser under automation (Puppeteer, Playwright, Selenium) sets
|
|
334
|
+
// `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
|
|
335
|
+
// pattern cannot tell from a reader's, so it sends nothing at all.
|
|
336
|
+
const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
|
|
334
337
|
/**
|
|
335
338
|
* Report each page view to the site's own `endpoint`, whose route hands it to
|
|
336
339
|
* `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.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",
|