@oh-my-pi/pi-tui 18.8.8 → 18.8.9
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/CHANGELOG.md +25 -23
- package/dist/types/charts/chart-plan.d.ts +11 -5
- package/dist/types/charts/chart-svg.d.ts +11 -7
- package/dist/types/charts/table-data.d.ts +6 -1
- package/dist/types/kitty-graphics.d.ts +2 -2
- package/dist/types/multiplexers/cmux.d.ts +16 -0
- package/dist/types/multiplexers/herdr.d.ts +26 -0
- package/dist/types/multiplexers/notify.d.ts +13 -0
- package/dist/types/multiplexers/orca.d.ts +12 -0
- package/dist/types/multiplexers/screen.d.ts +9 -0
- package/dist/types/multiplexers/tmux.d.ts +29 -0
- package/dist/types/multiplexers/types.d.ts +100 -0
- package/dist/types/multiplexers/wmux.d.ts +12 -0
- package/dist/types/multiplexers/zellij.d.ts +12 -0
- package/dist/types/status-line/types.d.ts +2 -2
- package/dist/types/terminal-capabilities.d.ts +24 -32
- package/dist/types/terminal-multiplexer.d.ts +127 -14
- package/dist/types/terminal.d.ts +5 -4
- package/dist/types/tmux.d.ts +1 -4
- package/package.json +9 -9
- package/src/charts/chart-plan.ts +152 -12
- package/src/charts/chart-svg.ts +479 -126
- package/src/charts/table-data.ts +43 -8
- package/src/chat/svg-figure.ts +45 -8
- package/src/kitty-graphics.ts +4 -4
- package/src/multiplexers/cmux.ts +29 -0
- package/src/multiplexers/herdr.ts +64 -0
- package/src/multiplexers/notify.ts +24 -0
- package/src/multiplexers/orca.ts +16 -0
- package/src/multiplexers/screen.ts +15 -0
- package/src/multiplexers/tmux.ts +40 -0
- package/src/multiplexers/types.ts +110 -0
- package/src/multiplexers/wmux.ts +16 -0
- package/src/multiplexers/zellij.ts +24 -0
- package/src/status-line/component.ts +12 -8
- package/src/status-line/segments.ts +7 -11
- package/src/status-line/types.ts +2 -2
- package/src/terminal-capabilities.ts +65 -181
- package/src/terminal-multiplexer.ts +112 -33
- package/src/terminal.ts +34 -28
- package/src/theme/session-color.ts +1 -7
- package/src/theme/theme.ts +7 -4
- package/src/tmux.ts +4 -11
- package/src/tui.ts +18 -12
|
@@ -1,20 +1,133 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function isInsideHerdr(env?: NodeJS.ProcessEnv): boolean;
|
|
3
|
-
/** Terminal multiplexers omp recognizes as owning the screen grid. */
|
|
4
|
-
export type TerminalMultiplexer = "herdr" | "tmux" | "screen" | "zellij" | "cmux" | "wmux";
|
|
1
|
+
import type { TerminalMultiplexerModule, TerminalMultiplexerNotificationRequest } from "./multiplexers/types.js";
|
|
5
2
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Every multiplexer omp recognizes, in classification order within each
|
|
4
|
+
* precedence tier. Adding a multiplexer means adding its module here.
|
|
5
|
+
*/
|
|
6
|
+
declare const registry: readonly [{
|
|
7
|
+
readonly id: "herdr";
|
|
8
|
+
readonly precedence: "session";
|
|
9
|
+
readonly sessionEnvKeys: readonly ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID"];
|
|
10
|
+
readonly ownsScreenGrid: true;
|
|
11
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
12
|
+
readonly notifier: {
|
|
13
|
+
readonly tier: "pane";
|
|
14
|
+
readonly send: ({ message, env }: TerminalMultiplexerNotificationRequest) => boolean;
|
|
15
|
+
};
|
|
16
|
+
readonly hyperlinks: "render";
|
|
17
|
+
readonly honorsSynchronizedOutput: true;
|
|
18
|
+
readonly imagesRequireOverride: true;
|
|
19
|
+
}, {
|
|
20
|
+
readonly id: "tmux";
|
|
21
|
+
readonly precedence: "session";
|
|
22
|
+
readonly sessionEnvKeys: readonly ["TMUX"];
|
|
23
|
+
readonly termPrefix: "tmux";
|
|
24
|
+
readonly ownsScreenGrid: true;
|
|
25
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
26
|
+
readonly notifier: {
|
|
27
|
+
readonly tier: "inBand";
|
|
28
|
+
readonly send: ({ sequence, write }: TerminalMultiplexerNotificationRequest) => boolean;
|
|
29
|
+
};
|
|
30
|
+
readonly hyperlinks: {
|
|
31
|
+
readonly forward: {
|
|
32
|
+
readonly termProgram: "tmux";
|
|
33
|
+
readonly major: 3;
|
|
34
|
+
readonly minor: 4;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
readonly backgroundColorCache: {
|
|
38
|
+
readonly passthrough: typeof import("./tmux.js").wrapTmuxPassthrough;
|
|
39
|
+
readonly settleMs: 100;
|
|
40
|
+
};
|
|
41
|
+
readonly expandsRepPadding: true;
|
|
42
|
+
readonly expiresSynchronizedOutput: true;
|
|
43
|
+
readonly altRestoreEndsSynchronizedOutput: true;
|
|
44
|
+
readonly growsBeforeSigwinch: true;
|
|
45
|
+
}, {
|
|
46
|
+
readonly id: "screen";
|
|
47
|
+
readonly precedence: "session";
|
|
48
|
+
readonly sessionEnvKeys: readonly ["STY"];
|
|
49
|
+
readonly termPrefix: "screen";
|
|
50
|
+
readonly ownsScreenGrid: true;
|
|
51
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
52
|
+
readonly hyperlinks: "drop";
|
|
53
|
+
}, {
|
|
54
|
+
readonly id: "zellij";
|
|
55
|
+
readonly precedence: "session";
|
|
56
|
+
readonly sessionEnvKeys: readonly ["ZELLIJ"];
|
|
57
|
+
readonly ownsScreenGrid: true;
|
|
58
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
59
|
+
readonly notifier: {
|
|
60
|
+
readonly tier: "inBand";
|
|
61
|
+
readonly send: ({ sequence, write }: TerminalMultiplexerNotificationRequest) => boolean;
|
|
62
|
+
};
|
|
63
|
+
readonly backgroundQueryBrokenOn: readonly ["darwin"];
|
|
64
|
+
}, {
|
|
65
|
+
readonly id: "cmux";
|
|
66
|
+
readonly precedence: "session";
|
|
67
|
+
readonly sessionEnvKeys: readonly ["CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_REMOTE_TRANSPORT"];
|
|
68
|
+
readonly ownsScreenGrid: true;
|
|
69
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
70
|
+
readonly notifier: {
|
|
71
|
+
readonly tier: "surface";
|
|
72
|
+
readonly send: ({ message, env }: TerminalMultiplexerNotificationRequest) => boolean;
|
|
73
|
+
};
|
|
74
|
+
}, {
|
|
75
|
+
readonly id: "wmux";
|
|
76
|
+
readonly precedence: "session";
|
|
77
|
+
readonly sessionEnvKeys: readonly ["WMUX", "WMUX_SURFACE_ID"];
|
|
78
|
+
readonly ownsScreenGrid: true;
|
|
79
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
80
|
+
}, {
|
|
81
|
+
readonly id: "orca";
|
|
82
|
+
readonly precedence: "outerApp";
|
|
83
|
+
readonly sessionEnvKeys: readonly ["ORCA_PANE_KEY", "ORCA_WORKTREE_ID"];
|
|
84
|
+
readonly ownsScreenGrid: false;
|
|
85
|
+
readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
|
|
86
|
+
}];
|
|
87
|
+
/** Terminal multiplexers omp recognizes. */
|
|
88
|
+
export type TerminalMultiplexer = (typeof registry)[number]["id"];
|
|
89
|
+
/** Every environment variable multiplexer classification reads, including the TERM fallback. */
|
|
90
|
+
export declare const TERMINAL_MULTIPLEXER_ENV_KEYS: readonly string[];
|
|
91
|
+
/**
|
|
92
|
+
* Whether an explicit session marker identifies the current provider.
|
|
93
|
+
*
|
|
94
|
+
* TERM is intentionally excluded: it is a classification fallback, not proof
|
|
95
|
+
* that a particular multiplexer session owns the current grid.
|
|
96
|
+
*/
|
|
97
|
+
export declare function hasTerminalMultiplexerSession(multiplexer: TerminalMultiplexer, env?: NodeJS.ProcessEnv): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Every multiplexer whose explicit session markers are present, in registry
|
|
100
|
+
* order. Nested sessions all appear; TERM is not consulted.
|
|
101
|
+
*/
|
|
102
|
+
export declare function terminalMultiplexerSessions(env?: NodeJS.ProcessEnv): TerminalMultiplexerModule<TerminalMultiplexer>[];
|
|
103
|
+
/**
|
|
104
|
+
* The multiplexer TERM names, whether or not its session markers survived.
|
|
105
|
+
* This is classification's TERM fallback; session markers are not consulted.
|
|
106
|
+
*/
|
|
107
|
+
export declare function terminalMultiplexerForTerm(env?: NodeJS.ProcessEnv): TerminalMultiplexerModule<TerminalMultiplexer> | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Registry entry for the multiplexer hosting the current process, or
|
|
110
|
+
* `undefined` for a direct terminal. Same precedence as
|
|
111
|
+
* {@link classifyTerminalMultiplexer}.
|
|
112
|
+
*/
|
|
113
|
+
export declare function classifyTerminalMultiplexerModule(env?: NodeJS.ProcessEnv): TerminalMultiplexerModule<TerminalMultiplexer> | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Classify which terminal multiplexer hosts the current process, or `null` for
|
|
116
|
+
* a direct terminal. Single source of truth for both the render-path gate
|
|
8
117
|
* (`isInsideTerminalMultiplexer`) and the debug snapshot label.
|
|
9
118
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* overrides and can be set outside a CMUX/WMUX terminal. wmux is a Windows
|
|
15
|
-
* multiplexer (Electron + xterm.js) modeled on cmux/herdr that repaints its
|
|
16
|
-
* pane in place and exports WMUX=1 plus a native WMUX_SURFACE_ID.
|
|
119
|
+
* Session markers are authoritative. TERM can also survive when those markers
|
|
120
|
+
* are stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh), so a
|
|
121
|
+
* `tmux`/`screen` TERM prefix comes next. Outer applications such as Orca rank
|
|
122
|
+
* last so a multiplexer running inside them wins.
|
|
17
123
|
*/
|
|
18
124
|
export declare function classifyTerminalMultiplexer(env?: NodeJS.ProcessEnv): TerminalMultiplexer | null;
|
|
19
|
-
/** True when
|
|
125
|
+
/** True when the classified multiplexer owns the current screen grid. */
|
|
20
126
|
export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Offer a notification to every active session's notifier, innermost tier
|
|
129
|
+
* first. Returns whether one delivered it; otherwise the terminal fallback
|
|
130
|
+
* applies unchanged.
|
|
131
|
+
*/
|
|
132
|
+
export declare function routeTerminalMultiplexerNotification(request: TerminalMultiplexerNotificationRequest): boolean;
|
|
133
|
+
export {};
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -298,10 +298,11 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
298
298
|
onAppearanceReport(callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void): () => void;
|
|
299
299
|
/**
|
|
300
300
|
* Re-query the terminal background through the startup DA1-sentinel FIFO,
|
|
301
|
-
* pending/queued gating, parsing, dedup, and appearance callbacks. Inside
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
301
|
+
* pending/queued gating, parsing, dedup, and appearance callbacks. Inside a
|
|
302
|
+
* multiplexer that answers OSC 11 from a cache (tmux), only this explicit
|
|
303
|
+
* path first passes an OSC 11 query to the outer terminal, waits briefly for
|
|
304
|
+
* the multiplexer to consume the response into its cache, then reads that
|
|
305
|
+
* cache with a direct query. The outer query deliberately has
|
|
305
306
|
* no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key
|
|
306
307
|
* sequence and leak the remaining bytes into the editor. Startup and Mode 2031
|
|
307
308
|
* probes remain direct. Suppressed while inactive, headless, or after teardown.
|
package/dist/types/tmux.d.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
|
|
3
|
-
/** Wrap a control sequence in tmux's DCS passthrough envelope. */
|
|
4
|
-
export declare function wrapTmuxPassthrough(payload: string): string;
|
|
1
|
+
export { wrapTmuxPassthrough } from "./multiplexers/tmux.js";
|
|
5
2
|
/** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
|
|
6
3
|
export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
|
|
7
4
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.8.
|
|
4
|
+
"version": "18.8.9",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.8.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.8.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.8.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.8.
|
|
44
|
+
"@oh-my-pi/omptype": "18.8.9",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.8.9",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.8.9",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.8.9",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.8.9",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.8.9",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.8.9",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.8.9"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/charts/chart-plan.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* out of the scale, and units never share an axis.
|
|
14
14
|
*/
|
|
15
15
|
import {
|
|
16
|
+
type Cell,
|
|
16
17
|
type Dimension,
|
|
17
18
|
isMonotonic,
|
|
18
19
|
isNumber,
|
|
@@ -32,6 +33,8 @@ import {
|
|
|
32
33
|
* - `share`: parts of a whole (percentages summing to 100).
|
|
33
34
|
* - `diverging`: signed changes around zero.
|
|
34
35
|
* - `scatter`: two continuous measures per item.
|
|
36
|
+
* - `change`: metrics in different units against a baseline column (before/after), as factors.
|
|
37
|
+
* - `progress`: scores out of a total (`12/12`), as filled tracks.
|
|
35
38
|
*/
|
|
36
39
|
export type ChartKind =
|
|
37
40
|
| "bar"
|
|
@@ -42,14 +45,16 @@ export type ChartKind =
|
|
|
42
45
|
| "multiples"
|
|
43
46
|
| "share"
|
|
44
47
|
| "diverging"
|
|
45
|
-
| "scatter"
|
|
48
|
+
| "scatter"
|
|
49
|
+
| "change"
|
|
50
|
+
| "progress";
|
|
46
51
|
|
|
47
52
|
/** What to draw from a table: the kind, the column naming each category, and the columns plotted. */
|
|
48
53
|
export interface ChartPlan {
|
|
49
54
|
readonly kind: ChartKind;
|
|
50
55
|
/** Column naming each category (or holding the x values of a line); rows are numbered without one. */
|
|
51
56
|
readonly label: number | undefined;
|
|
52
|
-
/** Plotted columns, in order. */
|
|
57
|
+
/** Plotted columns, in order; a `change` measures the rest against the first (or its `a → b` cells). */
|
|
53
58
|
readonly series: readonly number[];
|
|
54
59
|
/** Each data row is its own series (a metric with its own unit) and the plotted columns are the categories. */
|
|
55
60
|
readonly transpose: boolean;
|
|
@@ -94,6 +99,8 @@ export interface ChartSpec {
|
|
|
94
99
|
readonly caption?: string;
|
|
95
100
|
/** Name of the category axis (the label column's header). */
|
|
96
101
|
readonly axis: string;
|
|
102
|
+
/** What a `change` measures against (its baseline column); its points are factors of it. */
|
|
103
|
+
readonly baseline?: string;
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
const BEFORE =
|
|
@@ -102,6 +109,8 @@ const AFTER = /\b(?:after|new|patched|optimi[sz]ed|fixed|warm|now|to|v2|with|pro
|
|
|
102
109
|
const DELTA = /(?:Δ|delta|change|diff|improvement|speedup|reduction|savings|saved|gain|regression|% chg)/i;
|
|
103
110
|
/** Aggregate columns that restate the others; dropped when enough same-unit series remain. */
|
|
104
111
|
const AGGREGATE = /^(?:total|sum|cumulative|cum\.?|running total|avg|average|mean)\b/i;
|
|
112
|
+
/** The arrow of an `a → b` header or cell, with the spaces around it. */
|
|
113
|
+
const ARROW = /\s*(?:→|->|⇒)\s*/;
|
|
105
114
|
/** Row labels that name an ordered step (`Run 3`, `Day 2`, `N=1000`, `Q3`). */
|
|
106
115
|
const SEQUENCE_LABEL =
|
|
107
116
|
/^(?:run|day|week|month|iter(?:ation)?|round|step|phase|epoch|v|version|n\s*=|wave|attempt|pass|batch|q[1-4]|#)\s*\d/i;
|
|
@@ -110,11 +119,18 @@ const MONTH = /^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
|
|
|
110
119
|
const MAX_PANELS = 6;
|
|
111
120
|
/** Most lines one line chart keeps apart. */
|
|
112
121
|
const MAX_LINES = 6;
|
|
122
|
+
/** Most series a `progress` pick draws as tracks per category; more read as bars or a heatmap. */
|
|
123
|
+
const MAX_TRACKS = 3;
|
|
124
|
+
/** Smallest factor (either way) that makes a `change` chart say more than its table. */
|
|
125
|
+
const NOTABLE_FACTOR = 1.5;
|
|
113
126
|
|
|
114
127
|
/** The local best guess at a chart for `table`, or `undefined` when no chart fits. */
|
|
115
128
|
export function planChart(table: TableAnalysis): ChartPlan | undefined {
|
|
116
129
|
const { rows } = table;
|
|
117
130
|
let label = table.label;
|
|
131
|
+
const change = changeColumns(table);
|
|
132
|
+
if (change)
|
|
133
|
+
return { kind: "change", label: label?.index, series: change.map(column => column.index), transpose: false };
|
|
118
134
|
let measures = dropAggregates(table.measures);
|
|
119
135
|
if (measures.length === 0 || rows.length < 2) return undefined;
|
|
120
136
|
|
|
@@ -136,9 +152,11 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
|
|
|
136
152
|
transpose,
|
|
137
153
|
});
|
|
138
154
|
|
|
155
|
+
// `a → b` cells: one axis when the rows share a unit, factors of change when each row has its own.
|
|
156
|
+
if (measures.length === 1 && measures[0]!.arrowShare >= 0.6)
|
|
157
|
+
return plan(measures[0]!.mixed ? "change" : "paired", measures);
|
|
139
158
|
const mixed = measures.filter(column => column.mixed);
|
|
140
159
|
if (mixed.length > 0 && mixed.length * 2 >= measures.length) return plan("multiples", measures, true);
|
|
141
|
-
if (measures.length === 1 && measures[0]!.arrowShare >= 0.6) return plan("paired", measures);
|
|
142
160
|
|
|
143
161
|
const dims = new Set(measures.map(column => column.dim));
|
|
144
162
|
if (dims.size === 1 && measures.length >= 2 && measures.length <= 4) {
|
|
@@ -151,6 +169,8 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
|
|
|
151
169
|
const lead = measures[0]!.dim;
|
|
152
170
|
return plan("line", measures.filter(column => column.dim === lead).slice(0, MAX_LINES));
|
|
153
171
|
}
|
|
172
|
+
// Several score columns compare like any same-unit measures (as rates); a lone one fills tracks.
|
|
173
|
+
if (measures.length === 1 && measures[0]!.scores) return plan("progress", measures);
|
|
154
174
|
if (measures.length <= 2) {
|
|
155
175
|
const delta = measures.find(
|
|
156
176
|
column =>
|
|
@@ -159,7 +179,7 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
|
|
|
159
179
|
);
|
|
160
180
|
if (delta) return plan("diverging", [delta]);
|
|
161
181
|
}
|
|
162
|
-
const share = measures.find(column => column.dim === "percent");
|
|
182
|
+
const share = measures.find(column => column.dim === "percent" && !column.scores);
|
|
163
183
|
if (share && rows.length <= 10 && (measures.length === 1 || (measures.length === 2 && dims.size === 2))) {
|
|
164
184
|
const values = columnValues(share, rows);
|
|
165
185
|
const sum = values.reduce((total, value) => total + value, 0);
|
|
@@ -183,6 +203,7 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
|
|
|
183
203
|
const label = plan.label === undefined ? undefined : table.columns[plan.label];
|
|
184
204
|
const columns = plan.series.map(index => table.columns[index]).filter(column => column !== undefined);
|
|
185
205
|
if (columns.length === 0) return undefined;
|
|
206
|
+
if (plan.kind === "change" && !plan.transpose) return changeChart(table, label, columns);
|
|
186
207
|
const rowName = (row: number, at: number) => (label ? label.cells[row]!.text : "") || `#${at + 1}`;
|
|
187
208
|
|
|
188
209
|
let categories: string[];
|
|
@@ -193,11 +214,11 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
|
|
|
193
214
|
} else if (plan.kind === "paired" && columns.length === 1) {
|
|
194
215
|
// `a → b` cells: the column splits into its from and to values.
|
|
195
216
|
const column = columns[0]!;
|
|
196
|
-
const [from, to] = column.header
|
|
197
|
-
const before = columnSeries(column, table.rows, from
|
|
217
|
+
const [from, to] = arrowNames(column.header);
|
|
218
|
+
const before = columnSeries(column, table.rows, from);
|
|
198
219
|
const after: ChartSeries = {
|
|
199
220
|
...before,
|
|
200
|
-
name:
|
|
221
|
+
name: to,
|
|
201
222
|
points: table.rows.map(row => {
|
|
202
223
|
const cell = column.cells[row];
|
|
203
224
|
return isNumber(cell) && cell.to !== undefined
|
|
@@ -247,15 +268,27 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
|
|
|
247
268
|
|
|
248
269
|
/**
|
|
249
270
|
* Whether a chart of `spec` reads faster than its table: at least four
|
|
250
|
-
* categories, and either nine plotted values or a
|
|
251
|
-
* On a model-judged sample of assistant tables this
|
|
252
|
-
* tables a chart clearly helped while rejecting most two-
|
|
271
|
+
* categories (or small-multiple panels), and either nine plotted values or a
|
|
272
|
+
* 3× spread within a series. On a model-judged sample of assistant tables this
|
|
273
|
+
* gate kept 85% of the tables a chart clearly helped while rejecting most two-
|
|
274
|
+
* and three-row ones. A `change` needs three metrics and one moving by
|
|
275
|
+
* {@link NOTABLE_FACTOR} either way: its factors are what the table lacks.
|
|
253
276
|
*/
|
|
254
277
|
export function worthCharting(spec: ChartSpec): boolean {
|
|
255
|
-
|
|
278
|
+
const { kind, categories, series } = spec;
|
|
279
|
+
// Factors share no unit with the table's figures: three metrics are enough once one moves noticeably.
|
|
280
|
+
if (kind === "change")
|
|
281
|
+
return (
|
|
282
|
+
categories.length >= 3 &&
|
|
283
|
+
series.some(entry =>
|
|
284
|
+
entry.points.some(point => point && Math.abs(Math.log(point.value)) >= Math.log(NOTABLE_FACTOR)),
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
// Small multiples compare within each panel, so four metrics count like four categories.
|
|
288
|
+
if ((kind === "multiples" ? Math.max(categories.length, series.length) : categories.length) < 4) return false;
|
|
256
289
|
let points = 0;
|
|
257
290
|
let spread = 1;
|
|
258
|
-
for (const entry of
|
|
291
|
+
for (const entry of series) {
|
|
259
292
|
const positive: number[] = [];
|
|
260
293
|
for (const point of entry.points) {
|
|
261
294
|
if (!point) continue;
|
|
@@ -277,10 +310,117 @@ function fitKind(kind: ChartKind, series: readonly ChartSeries[], hasX: boolean)
|
|
|
277
310
|
if (kind === "scatter" && hasX) return kind;
|
|
278
311
|
if (new Set(series.map(entry => `${entry.dim}:${entry.unit}`)).size > 1) return "multiples";
|
|
279
312
|
if (kind === "share" || kind === "diverging" || kind === "line" || kind === "heatmap") return kind;
|
|
313
|
+
if (
|
|
314
|
+
kind === "progress" &&
|
|
315
|
+
series.length <= MAX_TRACKS &&
|
|
316
|
+
series.every(
|
|
317
|
+
entry =>
|
|
318
|
+
entry.dim === "percent" && entry.points.every(point => !point || (point.value >= 0 && point.value <= 100)),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
return kind;
|
|
280
322
|
const count = series.length;
|
|
281
323
|
return count === 1 ? "bar" : count === 2 ? "paired" : count <= 4 ? "grouped" : "heatmap";
|
|
282
324
|
}
|
|
283
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Baseline and compared columns of a before/after table a shared axis cannot
|
|
328
|
+
* hold: rows in different units, or prose cells (`none seen`) keeping its
|
|
329
|
+
* columns from reading as measures. The baseline is the `before` column; the
|
|
330
|
+
* compared ones are the `after` column and any measure beside it pairing with
|
|
331
|
+
* the baseline in two or more rows. Deltas and columns holding their own
|
|
332
|
+
* `a → b` transitions take no part.
|
|
333
|
+
*/
|
|
334
|
+
function changeColumns(table: TableAnalysis): TableColumn[] | undefined {
|
|
335
|
+
const { label } = table;
|
|
336
|
+
if (label?.role !== "label") return undefined;
|
|
337
|
+
const values = table.columns.filter(
|
|
338
|
+
column =>
|
|
339
|
+
column !== label &&
|
|
340
|
+
(column.role === "measure" || column.role === "label") &&
|
|
341
|
+
!DELTA.test(column.header) &&
|
|
342
|
+
!ARROW.test(column.header) &&
|
|
343
|
+
column.arrowShare < 0.6,
|
|
344
|
+
);
|
|
345
|
+
const baseline = values.find(column => BEFORE.test(column.header));
|
|
346
|
+
if (!baseline) return undefined;
|
|
347
|
+
const rows = [...table.rows, ...table.totals];
|
|
348
|
+
const compared = values.filter(
|
|
349
|
+
column =>
|
|
350
|
+
column !== baseline &&
|
|
351
|
+
(column.role === "measure" || AFTER.test(column.header)) &&
|
|
352
|
+
rows.filter(row => changePoint(baseline.cells[row], column.cells[row])).length >= 2,
|
|
353
|
+
);
|
|
354
|
+
if (!compared.some(column => AFTER.test(column.header))) return undefined;
|
|
355
|
+
const columns = [baseline, ...compared];
|
|
356
|
+
// One unit throughout: a shared axis holds the values themselves (`paired`, `grouped`).
|
|
357
|
+
if (columns.every(column => column.role === "measure" && !column.mixed)) return undefined;
|
|
358
|
+
return columns;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A `change` over `columns`: per row, each compared column as a factor of the
|
|
363
|
+
* baseline's value, or the `a → b` cells of a lone column as factors of their
|
|
364
|
+
* start. Total rows stay (a factor never skews the scale); rows with no pair
|
|
365
|
+
* are named in the caption instead.
|
|
366
|
+
*/
|
|
367
|
+
function changeChart(
|
|
368
|
+
table: TableAnalysis,
|
|
369
|
+
label: TableColumn | undefined,
|
|
370
|
+
columns: readonly TableColumn[],
|
|
371
|
+
): ChartSpec | undefined {
|
|
372
|
+
const [baseline, ...compared] = columns;
|
|
373
|
+
if (!baseline) return undefined;
|
|
374
|
+
const [from, to] = arrowNames(baseline.header);
|
|
375
|
+
const pairs = compared.length
|
|
376
|
+
? compared.map(column => ({
|
|
377
|
+
name: column.header,
|
|
378
|
+
at: (row: number) => changePoint(baseline.cells[row], column.cells[row]),
|
|
379
|
+
}))
|
|
380
|
+
: [{ name: to, at: (row: number) => arrowChange(baseline.cells[row]) }];
|
|
381
|
+
const rows = [...table.rows, ...table.totals].sort((a, b) => a - b);
|
|
382
|
+
const name = (row: number) => (label ? label.cells[row]!.text : "") || `#${row + 1}`;
|
|
383
|
+
const kept = rows.filter(row => pairs.some(pair => pair.at(row)));
|
|
384
|
+
if (kept.length < 2) return undefined;
|
|
385
|
+
const skipped = rows.filter(row => !kept.includes(row)).map(name);
|
|
386
|
+
return {
|
|
387
|
+
kind: "change",
|
|
388
|
+
categories: kept.map(name),
|
|
389
|
+
series: pairs.map(pair => ({
|
|
390
|
+
name: pair.name,
|
|
391
|
+
dim: "ratio",
|
|
392
|
+
unit: "",
|
|
393
|
+
points: kept.map(row => pair.at(row) ?? null),
|
|
394
|
+
notes: kept.map(() => undefined),
|
|
395
|
+
})),
|
|
396
|
+
caption: skipped.length ? `Not plotted: ${skipped.join(" · ")}` : undefined,
|
|
397
|
+
axis: label?.header ?? "",
|
|
398
|
+
baseline: compared.length ? baseline.header : from,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `to` as a factor of `from` (`59.4 s → 1.23 s` is 0.0207), when both are numbers of one dimension. */
|
|
403
|
+
function changePoint(from: Cell | undefined, to: Cell | undefined): ChartPoint | undefined {
|
|
404
|
+
if (!isNumber(from) || !isNumber(to) || from.dim !== to.dim || from.value <= 0 || to.value < 0) return undefined;
|
|
405
|
+
return {
|
|
406
|
+
value: to.value / from.value,
|
|
407
|
+
text: `${from.figure} → ${to.figure}`,
|
|
408
|
+
emphasis: from.emphasis || to.emphasis,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** An `a → b` cell as the factor `b ÷ a`. */
|
|
413
|
+
function arrowChange(cell: Cell | undefined): ChartPoint | undefined {
|
|
414
|
+
if (!isNumber(cell) || cell.to === undefined || cell.value <= 0 || cell.to < 0) return undefined;
|
|
415
|
+
return { value: cell.to / cell.value, text: `${cell.figure} → ${cell.toFigure ?? ""}`, emphasis: cell.emphasis };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The from and to names in an `a → b` header (`out tokens r5 → r6`), else `before` and `after`. */
|
|
419
|
+
function arrowNames(header: string): [string, string] {
|
|
420
|
+
const [from, to] = header.split(ARROW);
|
|
421
|
+
return from && to ? [from, to] : ["before", "after"];
|
|
422
|
+
}
|
|
423
|
+
|
|
284
424
|
/** Same-unit aggregate columns (`Total`, `avg`) restate the series beside them. */
|
|
285
425
|
function dropAggregates(measures: readonly TableColumn[]): readonly TableColumn[] {
|
|
286
426
|
return measures.filter(
|