@rikcodes/teamclaude 1.1.22-rik.1 → 1.1.22-rik.3
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/package.json +1 -1
- package/src/config.js +3 -0
- package/src/index.js +7 -0
- package/src/server.js +49 -9
- package/src/speedo.js +252 -0
- package/src/throughput.js +515 -0
- package/src/tui.js +247 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.22-rik.
|
|
3
|
+
"version": "1.1.22-rik.3",
|
|
4
4
|
"description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/config.js
CHANGED
|
@@ -107,6 +107,9 @@ export function createDefaultConfig() {
|
|
|
107
107
|
distributeSessions: false,
|
|
108
108
|
sessionTitles: { enabled: false, width: 18 },
|
|
109
109
|
quotaBarPercent: true,
|
|
110
|
+
// Tokens-per-second readouts in the TUI. Off unless asked for: with it off
|
|
111
|
+
// the proxy does no throughput work at all (see OutputTracker).
|
|
112
|
+
throughputMeter: false,
|
|
110
113
|
projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
|
|
111
114
|
eventLogging: 'hide',
|
|
112
115
|
defaultClientMode: 'mitm',
|
package/src/index.js
CHANGED
|
@@ -532,6 +532,9 @@ async function serverCommand() {
|
|
|
532
532
|
config.messageThreads = fleetThreads;
|
|
533
533
|
// Read by the TUI on every frame, so a hand edit lands on the next reload.
|
|
534
534
|
config.quotaBarPercent = diskConfig.quotaBarPercent !== false;
|
|
535
|
+
// Read by the server per request and by the TUI per frame, so the reload
|
|
536
|
+
// is the whole application: the next request is timed, or is not.
|
|
537
|
+
config.throughputMeter = diskConfig.throughputMeter === true;
|
|
535
538
|
// Read by `run`/`env` from disk, but the TUI settings screen shows it live.
|
|
536
539
|
config.defaultClientMode = diskConfig.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
|
|
537
540
|
// The fleet switch for spending Codex reset credits. The redeemer reads it
|
|
@@ -632,6 +635,7 @@ async function serverCommand() {
|
|
|
632
635
|
// the edit never reached disk and was silently undone by the next start.
|
|
633
636
|
if (config.eventLogging != null) diskConfig.eventLogging = config.eventLogging;
|
|
634
637
|
if (config.quotaBarPercent != null) diskConfig.quotaBarPercent = config.quotaBarPercent;
|
|
638
|
+
if (config.throughputMeter != null) diskConfig.throughputMeter = config.throughputMeter;
|
|
635
639
|
if (config.defaultClientMode != null) diskConfig.defaultClientMode = config.defaultClientMode;
|
|
636
640
|
if (config.autoRedeemResets != null) diskConfig.autoRedeemResets = config.autoRedeemResets;
|
|
637
641
|
if (config.blockedModels != null) diskConfig.blockedModels = config.blockedModels;
|
|
@@ -671,6 +675,9 @@ async function serverCommand() {
|
|
|
671
675
|
onRequestStart: (id, info) => tui.onRequestStart(id, info),
|
|
672
676
|
onRequestModel: (id, info) => tui.onRequestModel(id, info),
|
|
673
677
|
onRequestRouted: (id, info) => tui.onRequestRouted(id, info),
|
|
678
|
+
// Fired from inside the stream reader, and only with `throughputMeter` on,
|
|
679
|
+
// so it is bound once here rather than wrapped in one more call.
|
|
680
|
+
onRequestProgress: tui.onRequestProgress.bind(tui),
|
|
674
681
|
onRequestEnd: (id, info) => tui.onRequestEnd(id, info),
|
|
675
682
|
};
|
|
676
683
|
}
|
package/src/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { forwardRefusal, guardedLookup, FORBIDDEN_FORWARD } from './forward-targ
|
|
|
23
23
|
import { renderDashboardHtml, dashboardCsp } from './dashboard.js';
|
|
24
24
|
import { createUsageRecorder, resolveUsageDimensions, usageDimensionHeaderNames } from './client-usage.js';
|
|
25
25
|
import { responsesEventUsage, isResponsesBody, normalizeResponsesUsage } from './responses-usage.js';
|
|
26
|
+
import { OutputTracker } from './throughput.js';
|
|
26
27
|
import { classificationPath } from './classification-path.js';
|
|
27
28
|
import { serveManagementMcp } from './mcp-tools.js';
|
|
28
29
|
import { codexSpentWindows, isAccountWideCodexWindow } from './codex-quota.js';
|
|
@@ -1245,7 +1246,10 @@ export function clientSessionId(headers) {
|
|
|
1245
1246
|
* @param {Object} opts.accountManager
|
|
1246
1247
|
* @param {string} opts.upstream
|
|
1247
1248
|
* @param {string|null} [opts.logDir]
|
|
1248
|
-
* @param {Object} [opts.hooks] activity callbacks (onRequestStart, onRequestEnd, ...), all optional
|
|
1249
|
+
* @param {Object} [opts.hooks] activity callbacks (onRequestStart, onRequestEnd, ...), all optional.
|
|
1250
|
+
* onRequestProgress(reqId, { chars, at }) is called once per output event of
|
|
1251
|
+
* a streamed response, and only while `config.throughputMeter` is on: it runs
|
|
1252
|
+
* inside the stream reader, so it must stay O(1) and never render.
|
|
1249
1253
|
* @param {Object|null} [opts.sx]
|
|
1250
1254
|
* @param {number} [opts.holdMs]
|
|
1251
1255
|
* @param {Object} [opts.config] the live config object; read per request, never copied
|
|
@@ -1507,7 +1511,8 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
|
|
|
1507
1511
|
|
|
1508
1512
|
// stripOverage is sampled once here, at dispatch: retries and holds of
|
|
1509
1513
|
// this request keep it, and a reload applies to subsequent requests.
|
|
1510
|
-
const
|
|
1514
|
+
const output = hideActivity ? null : outputTrackerFor(config, hooks, reqId);
|
|
1515
|
+
const ctx = { account: null, status: null, tried: new Set(), reauthed: new Set(), model, advisorModel, streamRequested: parseRequestStream(body), fleetMessageThreads: config?.messageThreads === true, pinnedIndex, provider, holdBudgetMs: holdMs, pinKey, client, delivered: false, abandoned: false, onUsage: usageRecorder.onUsage, output, stripHeaders, stripOverage: shouldStripOverageHeaders(config), logLevel: resolveLogLevel(config), logMaxBodyBytes: resolveLogMaxBodyBytes(config) };
|
|
1511
1516
|
// Hold the session "in flight" across the WHOLE request (incl. retries and
|
|
1512
1517
|
// a multi-minute streaming completion) so it stays counted as active and
|
|
1513
1518
|
// never expires mid-request.
|
|
@@ -1563,7 +1568,9 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
|
|
|
1563
1568
|
// marked open would send the outer catch to call that same throwing hook
|
|
1564
1569
|
// a second time for one request.
|
|
1565
1570
|
openEntry = null;
|
|
1566
|
-
|
|
1571
|
+
// With the meter on, the end also carries the settled output count and
|
|
1572
|
+
// when the output started and stopped; off, the entry is what it was.
|
|
1573
|
+
if (!hideActivity) hooks.onRequestEnd?.(reqId, { method: req.method, path: req.url, account: ctx.account, status: ctx.status, model: ctx.model, sessionId, pinned: ctx.pinnedIndex != null, client, ...output?.summary() });
|
|
1567
1574
|
}
|
|
1568
1575
|
} catch (err) {
|
|
1569
1576
|
reportFailure('[TeamClaude] Unhandled error:', err);
|
|
@@ -1604,6 +1611,23 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
|
|
|
1604
1611
|
};
|
|
1605
1612
|
}
|
|
1606
1613
|
|
|
1614
|
+
/**
|
|
1615
|
+
* The throughput meter's tracker for one request, or null while the meter is off
|
|
1616
|
+
* (`throughputMeter`, opt-in). Sampled at dispatch off the live config, like
|
|
1617
|
+
* the telemetry mode, so the settings toggle lands on the next request. Off,
|
|
1618
|
+
* there is no tracker, and the stream reader's whole cost is testing for one.
|
|
1619
|
+
* A consumer with no progress hook (the headless activity log) gets none
|
|
1620
|
+
* either way: nothing would read it.
|
|
1621
|
+
*
|
|
1622
|
+
* @param {any} config the live config
|
|
1623
|
+
* @param {any} hooks
|
|
1624
|
+
* @param {number} reqId
|
|
1625
|
+
*/
|
|
1626
|
+
function outputTrackerFor(config, hooks, reqId) {
|
|
1627
|
+
const onProgress = hooks.onRequestProgress;
|
|
1628
|
+
return config?.throughputMeter === true && typeof onProgress === 'function' ? new OutputTracker(reqId, onProgress) : null;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1607
1631
|
/**
|
|
1608
1632
|
* Report a failure without depending on the console to survive it.
|
|
1609
1633
|
*
|
|
@@ -3110,6 +3134,10 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
3110
3134
|
}
|
|
3111
3135
|
let upstreamRes;
|
|
3112
3136
|
let admittedLoad = 0;
|
|
3137
|
+
// For the throughput meter: this attempt is being sent. A later attempt
|
|
3138
|
+
// overwrites it, so the time that survives is the answering attempt's, and
|
|
3139
|
+
// a buffered answer is timed from here rather than from the client's ask.
|
|
3140
|
+
ctx.output?.dispatched();
|
|
3113
3141
|
try {
|
|
3114
3142
|
upstreamRes = await upstreamFetch(upstreamUrl, {
|
|
3115
3143
|
method,
|
|
@@ -3669,7 +3697,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
3669
3697
|
const l = getLog();
|
|
3670
3698
|
const bw = l ? l.bodyWriter('RESPONSE BODY (streamed)', contentType) : null;
|
|
3671
3699
|
try {
|
|
3672
|
-
await streamResponse(upstreamBody, res, account.index, accountManager, bw, ctx.onUsage, ctx.pinKey, ctx.model);
|
|
3700
|
+
await streamResponse(upstreamBody, res, account.index, accountManager, bw, ctx.onUsage, ctx.pinKey, ctx.model, ctx.output);
|
|
3673
3701
|
// Reached only when the stream completed. A stream that dies upstream
|
|
3674
3702
|
// throws out of streamResponse, so it never marks itself delivered —
|
|
3675
3703
|
// which is the failure the token counters cannot see, since a stream
|
|
@@ -3684,7 +3712,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
3684
3712
|
l?.end();
|
|
3685
3713
|
} else {
|
|
3686
3714
|
const buf = Buffer.from(await upstreamRes.arrayBuffer());
|
|
3687
|
-
extractUsageFromBody(buf, account.index, accountManager, ctx.onUsage, ctx.pinKey, ctx.model);
|
|
3715
|
+
extractUsageFromBody(buf, account.index, accountManager, ctx.onUsage, ctx.pinKey, ctx.model, ctx.output);
|
|
3688
3716
|
const l = getLog();
|
|
3689
3717
|
if (l) { l.body('RESPONSE BODY', buf, contentType); l.end(); }
|
|
3690
3718
|
res.end(buf);
|
|
@@ -3855,8 +3883,9 @@ export function readWithIdleTimeout(reader, ms) {
|
|
|
3855
3883
|
|
|
3856
3884
|
/**
|
|
3857
3885
|
* Stream an SSE response to the client, parsing usage data along the way.
|
|
3886
|
+
* `output` is the throughput meter's tracker for this response, when it is on.
|
|
3858
3887
|
*/
|
|
3859
|
-
export async function streamResponse(webStream, res, accountIndex, accountManager, bodyWriter, onUsage = null, pinKey = null, model = null) {
|
|
3888
|
+
export async function streamResponse(webStream, res, accountIndex, accountManager, bodyWriter, onUsage = null, pinKey = null, model = null, output = null) {
|
|
3860
3889
|
const reader = webStream.getReader();
|
|
3861
3890
|
// A client that leaves while upstream is silent must not hold the pending
|
|
3862
3891
|
// read — and with it the upstream socket and its admission permit — until
|
|
@@ -3876,7 +3905,7 @@ export async function streamResponse(webStream, res, accountIndex, accountManage
|
|
|
3876
3905
|
// remembers that it did — the incremental counters would book the turn again
|
|
3877
3906
|
// if a second terminal event arrived. See parseSSEDataLine.
|
|
3878
3907
|
const responsesTurn = { settled: false };
|
|
3879
|
-
const usage = createSseLineScanner(line => parseSSEDataLine(line, accountIndex, accountManager, onUsage, merged, responsesTurn));
|
|
3908
|
+
const usage = createSseLineScanner(line => parseSSEDataLine(line, accountIndex, accountManager, onUsage, merged, responsesTurn, output));
|
|
3880
3909
|
|
|
3881
3910
|
try {
|
|
3882
3911
|
while (true) {
|
|
@@ -4032,8 +4061,9 @@ export function createSseLineScanner(onLine, maxChars = SSE_MAX_LINE_CHARS) {
|
|
|
4032
4061
|
* @param {((inputTokens: number, outputTokens: number) => void)|null} [onUsage]
|
|
4033
4062
|
* @param {Record<string, any>|null} [merged]
|
|
4034
4063
|
* @param {{settled: boolean}|null} [responsesTurn] this stream's "already booked" flag
|
|
4064
|
+
* @param {OutputTracker|null} [output] the throughput meter's tracker, when it is on
|
|
4035
4065
|
*/
|
|
4036
|
-
function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, merged = null, responsesTurn = null) {
|
|
4066
|
+
function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, merged = null, responsesTurn = null, output = null) {
|
|
4037
4067
|
if (!line.startsWith('data: ')) return;
|
|
4038
4068
|
|
|
4039
4069
|
try {
|
|
@@ -4046,6 +4076,7 @@ function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, me
|
|
|
4046
4076
|
accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
|
|
4047
4077
|
onUsage?.(0, data.usage.output_tokens || 0);
|
|
4048
4078
|
if (merged) Object.assign(merged, data.usage);
|
|
4079
|
+
output?.settle(data.usage.output_tokens);
|
|
4049
4080
|
} else if (!responsesTurn?.settled) {
|
|
4050
4081
|
// Both sides settle at once here, so unlike the Anthropic branches above
|
|
4051
4082
|
// this is a single incremental update rather than one per side — and it
|
|
@@ -4059,14 +4090,20 @@ function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, me
|
|
|
4059
4090
|
accountManager.updateUsage(accountIndex, usage.input_tokens, usage.output_tokens);
|
|
4060
4091
|
onUsage?.(usage.input_tokens, usage.output_tokens);
|
|
4061
4092
|
if (merged) Object.assign(merged, usage);
|
|
4093
|
+
output?.settle(usage.output_tokens);
|
|
4062
4094
|
}
|
|
4063
4095
|
}
|
|
4096
|
+
// The same parsed event, read for generated text. After the usage branches,
|
|
4097
|
+
// so a progress hook that throws costs only its own line: the catch below
|
|
4098
|
+
// swallows it, and the accounting for this line has already run.
|
|
4099
|
+
output?.event(data);
|
|
4064
4100
|
} catch {
|
|
4065
4101
|
// not valid JSON, skip
|
|
4066
4102
|
}
|
|
4067
4103
|
}
|
|
4068
4104
|
|
|
4069
|
-
|
|
4105
|
+
/** @param {OutputTracker|null} [output] the throughput meter's tracker, when it is on */
|
|
4106
|
+
function extractUsageFromBody(buffer, accountIndex, accountManager, onUsage = null, pinKey = null, model = null, output = null) {
|
|
4070
4107
|
try {
|
|
4071
4108
|
const json = JSON.parse(buffer.toString());
|
|
4072
4109
|
if (json.usage) {
|
|
@@ -4083,6 +4120,9 @@ function extractUsageFromBody(buffer, accountIndex, accountManager, onUsage = nu
|
|
|
4083
4120
|
accountManager.updateUsage(accountIndex, usage.input_tokens, usage.output_tokens);
|
|
4084
4121
|
onUsage?.(usage.input_tokens || 0, usage.output_tokens || 0);
|
|
4085
4122
|
accountManager.recordTokenUsage(accountIndex, pinKey, model, usage);
|
|
4123
|
+
// Buffered, so there is no generation interval to time: the tracker's
|
|
4124
|
+
// first and last token stay null and the reader times the whole request.
|
|
4125
|
+
output?.settle(usage.output_tokens);
|
|
4086
4126
|
}
|
|
4087
4127
|
} catch {
|
|
4088
4128
|
// not JSON or no usage
|
package/src/speedo.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The fleet speedo: a car-style dial drawn in Braille for the TUI.
|
|
2
|
+
//
|
|
3
|
+
// Braille because each cell is a 2×4 grid of dots, which is eight times the
|
|
4
|
+
// resolution of a character grid, and a terminal cell is about twice as tall as
|
|
5
|
+
// it is wide, so the dots come out close to square and a circle drawn in them
|
|
6
|
+
// comes out round. Every Braille character is one display column wide.
|
|
7
|
+
//
|
|
8
|
+
// The arc sweeps 240°: zero at the lower left, full scale at the lower right,
|
|
9
|
+
// half scale straight up. The needle pivots at the hub, and the reading sits
|
|
10
|
+
// under the hub with its unit below it. The needle is never drawn over the
|
|
11
|
+
// reading: it stops at the text's edge and picks up past it.
|
|
12
|
+
//
|
|
13
|
+
// The arc is lit in one colour up to the reading and dim past it. No green,
|
|
14
|
+
// yellow and red zones: the scale follows the fleet's own recent peak, so the
|
|
15
|
+
// top of it is "busier than lately", not a limit, and a zone would change
|
|
16
|
+
// colour the moment the scale stepped up under the same reading.
|
|
17
|
+
//
|
|
18
|
+
// Pure: a reading and a size in, lines out. Colour comes from the painter the
|
|
19
|
+
// caller hands over, so the dial is drawn with the same SGR helpers as the rest
|
|
20
|
+
// of the frame.
|
|
21
|
+
|
|
22
|
+
import { formatRate } from './throughput.js';
|
|
23
|
+
|
|
24
|
+
/** Rows the dial is drawn in at the least, and at the most. Six is the smallest
|
|
25
|
+
* that keeps an arc, a reading and its unit apart; past ten the dial only grows
|
|
26
|
+
* wider, and the top block has better uses for the columns. */
|
|
27
|
+
export const SPEEDO_MIN_H = 6;
|
|
28
|
+
export const SPEEDO_MAX_H = 10;
|
|
29
|
+
|
|
30
|
+
const DEG = Math.PI / 180;
|
|
31
|
+
const START = 210 * DEG; // where zero sits, counter-clockwise from 3 o'clock
|
|
32
|
+
const SWEEP = 240 * DEG; // clockwise from START to full scale
|
|
33
|
+
// Half the arc's stroke, in dots. A one-dot stroke leaves gaps where the circle
|
|
34
|
+
// crosses the dot grid at a shallow angle.
|
|
35
|
+
const HALF_STROKE = 0.75;
|
|
36
|
+
// Clear dots between the needle's tip and the inside of the arc.
|
|
37
|
+
const TIP_GAP = 3.5;
|
|
38
|
+
|
|
39
|
+
// Braille dot bits by [row][column] within a cell (Unicode's dots 1-8).
|
|
40
|
+
const DOT_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
|
|
41
|
+
const BRAILLE_BLANK = 0x2800;
|
|
42
|
+
|
|
43
|
+
const SGR = (/** @type {string} */ code) => (/** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m`;
|
|
44
|
+
/** @typedef {{ cyan: (s: string) => string, dim: (s: string) => string, bold: (s: string) => string }} Painter */
|
|
45
|
+
/** @type {Painter} */
|
|
46
|
+
const DEFAULT_PAINT = { cyan: SGR('36'), dim: SGR('2'), bold: SGR('1') };
|
|
47
|
+
|
|
48
|
+
/** @typedef {'blank'|'arc'|'arc-dim'|'needle'|'value'|'unit'|'label'} CellKind */
|
|
49
|
+
/** @typedef {{ ch: string, kind: CellKind }} Cell */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The radius, in dots, of the largest arc a dial `height` rows tall holds. The
|
|
53
|
+
* arc reaches R above the hub and R·sin 30° below it, and the bottom row is kept
|
|
54
|
+
* for the scale labels.
|
|
55
|
+
* @param {number} height
|
|
56
|
+
*/
|
|
57
|
+
function radiusFor(height) {
|
|
58
|
+
return Math.floor((4 * (height - 1) - 2.5) / 1.5);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The columns a dial `height` rows tall is drawn in: the arc's width, a column
|
|
63
|
+
* either side, rounded up to odd so the hub sits in the middle of a column and
|
|
64
|
+
* the needle stands straight at half scale.
|
|
65
|
+
* @param {number} height
|
|
66
|
+
*/
|
|
67
|
+
export function speedoWidth(height) {
|
|
68
|
+
const w = radiusFor(height) + 2;
|
|
69
|
+
return w % 2 ? w : w + 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Where `angle` falls along the sweep, 0 at zero and 1 at full scale, or null
|
|
73
|
+
* outside it (the gap at the bottom).
|
|
74
|
+
* @param {number} angle radians, counter-clockwise from 3 o'clock */
|
|
75
|
+
function sweepFraction(angle) {
|
|
76
|
+
let delta = (START - angle) % (2 * Math.PI);
|
|
77
|
+
if (delta < 0) delta += 2 * Math.PI;
|
|
78
|
+
const f = delta / SWEEP;
|
|
79
|
+
return f <= 1 + 1e-9 ? Math.min(1, f) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A scale end as it is labelled: `500`, `1k`, `20k`, `1M`. The scale is a
|
|
83
|
+
* 1-2-5 step, so it divides evenly and `1.0k` would only be noise.
|
|
84
|
+
* @param {number} n */
|
|
85
|
+
export function formatScale(n) {
|
|
86
|
+
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
|
87
|
+
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
|
|
88
|
+
return String(Math.round(n));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The dial as a grid of cells, before any colour: what each cell shows and what
|
|
94
|
+
* it belongs to. Exposed so the tests can find the needle without parsing SGR.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ rate: number, max: number, width: number, height: number }} opts
|
|
97
|
+
* @returns {Cell[][]} `height` rows of `width` cells
|
|
98
|
+
*/
|
|
99
|
+
export function speedoCells({ rate, max, width, height }) {
|
|
100
|
+
width = Math.max(0, Math.floor(width));
|
|
101
|
+
height = Math.max(0, Math.floor(height));
|
|
102
|
+
/** @type {Cell[][]} */
|
|
103
|
+
const cells = Array.from({ length: height }, () => Array.from({ length: width }, () => ({ ch: ' ', kind: /** @type {CellKind} */ ('blank') })));
|
|
104
|
+
if (!width || !height) return cells;
|
|
105
|
+
|
|
106
|
+
const frac = max > 0 && rate > 0 ? Math.min(1, rate / max) : 0;
|
|
107
|
+
const R = Math.min(radiusFor(height), width - 2);
|
|
108
|
+
const Wp = width * 2;
|
|
109
|
+
const Hp = height * 4;
|
|
110
|
+
// Dot coordinates: x to the right, y down, a dot's centre at +0.5. With an
|
|
111
|
+
// odd width, `width` dots across is the middle of the middle column.
|
|
112
|
+
const cx = width;
|
|
113
|
+
const cy = R + 1.5;
|
|
114
|
+
|
|
115
|
+
// 0 nothing, 1 arc, 2 needle.
|
|
116
|
+
const layer = new Uint8Array(Wp * Hp);
|
|
117
|
+
if (R >= 3) {
|
|
118
|
+
for (let py = 0; py < Hp; py++) {
|
|
119
|
+
for (let px = 0; px < Wp; px++) {
|
|
120
|
+
const dx = px + 0.5 - cx;
|
|
121
|
+
const dy = py + 0.5 - cy;
|
|
122
|
+
if (Math.abs(Math.hypot(dx, dy) - R) > HALF_STROKE) continue;
|
|
123
|
+
if (sweepFraction(Math.atan2(-dy, dx)) !== null) layer[py * Wp + px] = 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const angle = START - frac * SWEEP;
|
|
127
|
+
// Snapped, because the hub sits on the line between the middle column's two
|
|
128
|
+
// dots: cos(90°) comes out a hair either side of zero, and an upright
|
|
129
|
+
// needle would zigzag between them.
|
|
130
|
+
const snap = (/** @type {number} */ v) => (Math.abs(v) < 1e-9 ? 0 : v);
|
|
131
|
+
const ux = snap(Math.cos(angle));
|
|
132
|
+
const uy = snap(Math.sin(angle));
|
|
133
|
+
const len = R - TIP_GAP;
|
|
134
|
+
for (let t = 0; t <= len; t += 0.25) {
|
|
135
|
+
const px = Math.floor(cx + t * ux);
|
|
136
|
+
const py = Math.floor(cy - t * uy);
|
|
137
|
+
if (px >= 0 && px < Wp && py >= 0 && py < Hp) layer[py * Wp + px] = 2;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The reading under the hub, its unit under that, the scale ends on the
|
|
142
|
+
// bottom row beneath the arc's two ends. Rows are clamped so a short dial
|
|
143
|
+
// still keeps the reading.
|
|
144
|
+
const hubRow = Math.floor(cy / 4);
|
|
145
|
+
const valueRow = Math.max(0, Math.min(hubRow + 1, height - 2));
|
|
146
|
+
const unitRow = valueRow + 1;
|
|
147
|
+
const labelRow = height - 1;
|
|
148
|
+
const centre = width / 2;
|
|
149
|
+
const colFor = (/** @type {string} */ text) => Math.round(centre - text.length / 2);
|
|
150
|
+
|
|
151
|
+
/** @type {{ row: number, col: number, text: string, kind: CellKind }[]} */
|
|
152
|
+
const texts = [];
|
|
153
|
+
const value = formatRate(rate);
|
|
154
|
+
texts.push({ row: valueRow, col: colFor(value), text: value, kind: 'value' });
|
|
155
|
+
if (unitRow < height) texts.push({ row: unitRow, col: colFor('tok/s'), text: 'tok/s', kind: 'unit' });
|
|
156
|
+
|
|
157
|
+
// Nothing but text in the box around the reading: the needle stops at its
|
|
158
|
+
// edge, a column clear of the widest line.
|
|
159
|
+
const boxFrom = Math.min(...texts.map(t => t.col)) - 1;
|
|
160
|
+
const boxTo = Math.max(...texts.map(t => t.col + t.text.length)) + 1;
|
|
161
|
+
const inBox = (/** @type {number} */ r, /** @type {number} */ c) => r >= valueRow && r <= unitRow && c >= boxFrom && c < boxTo;
|
|
162
|
+
|
|
163
|
+
// The scale ends, both or neither: a lone `0` says nothing. Each must sit in
|
|
164
|
+
// the dial and clear of any text already on its row.
|
|
165
|
+
if (R >= 3 && labelRow > valueRow) {
|
|
166
|
+
const endX = R * Math.cos(30 * DEG);
|
|
167
|
+
const place = (/** @type {string} */ text, /** @type {number} */ dotX) => {
|
|
168
|
+
const col = Math.round(dotX / 2 - text.length / 2);
|
|
169
|
+
return { row: labelRow, col: Math.max(0, Math.min(width - text.length, col)), text, kind: /** @type {CellKind} */ ('label') };
|
|
170
|
+
};
|
|
171
|
+
const ends = [place('0', cx - endX), place(formatScale(max), cx + endX)];
|
|
172
|
+
const clear = (/** @type {{ row: number, col: number, text: string }} */ a) =>
|
|
173
|
+
a.col >= 0 && a.col + a.text.length <= width
|
|
174
|
+
&& texts.every(t => t.row !== a.row || a.col + a.text.length + 1 <= t.col || t.col + t.text.length + 1 <= a.col);
|
|
175
|
+
if (ends[0].col + ends[0].text.length + 1 <= ends[1].col && ends.every(clear)) texts.push(...ends);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (let r = 0; r < height; r++) {
|
|
179
|
+
for (let c = 0; c < width; c++) {
|
|
180
|
+
if (inBox(r, c)) continue;
|
|
181
|
+
let bits = 0;
|
|
182
|
+
let needle = false;
|
|
183
|
+
let fsum = 0;
|
|
184
|
+
let fn = 0;
|
|
185
|
+
for (let dy = 0; dy < 4; dy++) {
|
|
186
|
+
for (let dx = 0; dx < 2; dx++) {
|
|
187
|
+
const px = c * 2 + dx;
|
|
188
|
+
const py = r * 4 + dy;
|
|
189
|
+
const v = layer[py * Wp + px];
|
|
190
|
+
if (!v) continue;
|
|
191
|
+
bits |= DOT_BITS[dy][dx];
|
|
192
|
+
if (v === 2) needle = true;
|
|
193
|
+
else {
|
|
194
|
+
const f = sweepFraction(Math.atan2(-(py + 0.5 - cy), px + 0.5 - cx));
|
|
195
|
+
if (f !== null) { fsum += f; fn++; }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!bits) continue;
|
|
200
|
+
const ch = String.fromCodePoint(BRAILLE_BLANK + bits);
|
|
201
|
+
if (needle) { cells[r][c] = { ch, kind: 'needle' }; continue; }
|
|
202
|
+
const f = fn ? fsum / fn : 0;
|
|
203
|
+
cells[r][c] = { ch, kind: frac > 0 && f <= frac ? 'arc' : 'arc-dim' };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const t of texts) {
|
|
208
|
+
if (t.row < 0 || t.row >= height) continue;
|
|
209
|
+
for (let i = 0; i < t.text.length; i++) {
|
|
210
|
+
const c = t.col + i;
|
|
211
|
+
if (c >= 0 && c < width) cells[t.row][c] = { ch: t.text[i], kind: t.kind };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return cells;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The dial as `height` lines of exactly `width` display columns each.
|
|
219
|
+
*
|
|
220
|
+
* Colour runs are closed on every change of style, so no cell's colour can
|
|
221
|
+
* bleed into the next, or past the dial into whatever follows it on the line.
|
|
222
|
+
*
|
|
223
|
+
* @param {{ rate: number, max: number, width: number, height: number, paint?: Painter }} opts
|
|
224
|
+
* @returns {string[]}
|
|
225
|
+
*/
|
|
226
|
+
export function renderSpeedo({ rate, max, width, height, paint = DEFAULT_PAINT }) {
|
|
227
|
+
// Cyan is the dashboard's accent for what is live — the spinner, the active
|
|
228
|
+
// count — and the lit arc is exactly that.
|
|
229
|
+
/** @type {Record<CellKind, (s: string) => string>} */
|
|
230
|
+
const style = {
|
|
231
|
+
blank: (s) => s,
|
|
232
|
+
arc: (s) => paint.cyan(s),
|
|
233
|
+
'arc-dim': (s) => paint.dim(s),
|
|
234
|
+
needle: (s) => paint.bold(s),
|
|
235
|
+
value: (s) => paint.bold(s),
|
|
236
|
+
unit: (s) => paint.dim(s),
|
|
237
|
+
label: (s) => paint.dim(s),
|
|
238
|
+
};
|
|
239
|
+
return speedoCells({ rate, max, width, height }).map(row => {
|
|
240
|
+
let out = '';
|
|
241
|
+
let run = '';
|
|
242
|
+
/** @type {Cell|null} */
|
|
243
|
+
let head = null;
|
|
244
|
+
const flush = () => { if (head) out += style[head.kind](run); run = ''; };
|
|
245
|
+
for (const cell of row) {
|
|
246
|
+
if (!head || cell.kind !== head.kind) { flush(); head = cell; }
|
|
247
|
+
run += cell.ch;
|
|
248
|
+
}
|
|
249
|
+
flush();
|
|
250
|
+
return out;
|
|
251
|
+
});
|
|
252
|
+
}
|