응답이 나오는 속도(초당 출력 토큰)를 입력창 위에 속도계처럼 보여 준다

한국어 | English
Claude Code 함수 훅 플러그인(모드) 모음.
| 모드 | 하는 일 |
|---|---|
| state-compact | 긴 세션에서 자리를 비워도 캐시 재생성 비용을 크게 물지 않게 한다 |
| token-speedometer | 답이 출력되는 속도를 입력창 위에 레이싱 게임 계기판처럼 보여 준다 |
긴 세션을 열어 둔 채 자리를 비우면 프롬프트 캐시가 만료된다(1시간 또는 5분). 돌아와서 메시지를 하나 보내면 그동안 쌓인 컨텍스트 전체를 다시 캐시한다. 86만 토큰 세션이면 메시지 하나에 약 $6.9가 든다.
이 모드가 하는 일은 두 가지다.
Claude가 질문을 남기고 기다리거나 백그라운드 작업(서브에이전트 등)이 끝나기를 기다리는 동안 자리를 비우면, 캐시가 만료되기 직전에 모드가 대신 움직인다.
STATE.md)에 진행 상황과 다음 할 일을 적게 한다돌아왔을 때는 압축된 요약만 다시 캐시하면 되고, 세부 내용은 handoff 문서에 남아 있다.
캐시가 이미 만료된 세션에 메시지를 보내면, 컨텍스트가 10만 토큰 이상일 때 한 번 막고 비용을 알린다.
state-compact: 캐시가 만료됐습니다. 보내면 컨텍스트 약 861k 토큰을 다시 캐시합니다(약 $6.88).
그대로 보내려면 다시 보내고, 아니면 /compact나 새 세션을 쓰세요.
막힌 메시지는 입력창에 돌아온다. 같은 메시지를 다시 보내면 그대로 진행되고, /compact 같은 슬래시 명령은 막지 않는다.
/compact를 치거나 컨텍스트가 85%를 넘으면, 압축하기 전에 handoff 문서를 먼저 고치게 한다.취소를 누르면 예약이 풀린다. ◇ 압축 예정 11:27 취소
◆ 압축됨 · 자리 비움 11:27
○ 캐시 만료 (컨텍스트 861k) 18:16
✕ 압축 실패 · 이유 11:27
함수 훅 플러그인을 지원하는 Claude Code가 필요하다. 저장소를 받은 뒤 모드 폴더를 플러그인 폴더로 지정한다.
git clone https://github.com/iceberggymnast/claude-code-mods.git
터미널에서는 실행할 때 폴더를 넘긴다.
claude --plugin-dir <repo>/plugins/state-compact
데스크톱 앱처럼 실행 인자를 줄 수 없는 곳에서는 ~/.claude/settings.json의 env에 적는다. 여러 폴더는 경로 구분자(Windows ;, 그 밖 :)로 잇는다.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "<repo>/plugins/state-compact"
}
}
설치를 확인하려면 claude plugin validate <repo>/plugins/state-compact를 실행한다.
| 항목 | 기본값 | 설명 |
|---|---|---|
handoff_file | (비어 있음) | 압축 전에 반영할 파일. 저장소 루트 기준 이름. 비우면 반영 없이 압축만 한다 |
language | ko | 답 끝 표시, 경고 문구, Claude에게 보내는 지시의 언어. ko 또는 en |
/config의 플러그인 항목에서 바꾸거나, settings.json의 pluginConfigs에 적는다. --plugin-dir·CLAUDE_CODE_PLUGIN_DIRS로 불러온 경우 키는 state-compact 또는 state-compact@inline이다.
{
"pluginConfigs": {
"state-compact@inline": {
"options": {
"handoff_file": "STATE.md",
"language": "ko"
}
}
}
}
턴이 끝났을 때 아래를 모두 만족하면 예약한다.
| 캐시 TTL | 컨텍스트 | 압축 시점 |
|---|---|---|
| 1시간 | 20만 토큰 이상 | 마지막 요청 후 55분 |
| 5분 | 30만 토큰 이상 | 마지막 요청 후 4분 |
/compact에 준 요약 지시는 압축할 때 그대로 넘긴다◆ 압축 중…)은 마지막 답에만 뜬다만료 전 압축을 예약하지 않았거나 건너뛴 이유는 세션 기록에 남지 않는다. 그래서 턴이 끝날 때와 예약한 시각에 내린 판단을 세션 기록 파일(~/.claude/projects/<프로젝트>/<세션 id>.jsonl) 옆의 <세션 id>.state-compact.log에 한 줄씩 남긴다. TTL, 컨텍스트 토큰 수, 백그라운드 작업 수, Haiku 판정 결과와 걸린 시간, 건너뛴 이유가 들어가고 답 본문은 넣지 않는다. 25만 자를 넘으면 앞부분부터 지운다.
/compact 앞의 반영을 하지 않는다. 캐시 만료 표시는 마지막 응답 시각을 기준으로 붙인다. 꺼져 있던 동안 만료됐으면 그 세션에 다시 메시지를 보낼 때 그 전의 마지막 답에 붙인다(데스크톱 앱은 목록에서 세션을 열기만 해서는 모드를 실행하지 않는다). 이때 표시 시각은 실제 만료보다 응답에 걸린 시간만큼 늦다/compact를 하면 대화에 /compact 말풍선이 두 개 남는다. 첫 번째는 사용자가 입력한 것(문서 반영을 위해 멈춤)이고, 두 번째는 반영이 끝난 뒤 모드가 다시 실행한 것이다Windows 데스크톱 앱에서 수동 /compact → 문서 반영 → 압축 순서(요약 지시 인자 포함), 1시간 TTL 세션의 만료 전 압축(마지막 요청 55분 뒤 실행), 수동 /compact 때 반영한 답 끝의 ◆ 압축 중…→◆ 15:35 압축됨 · 수동 표시, 다른 세션을 보는 동안 TTL이 지난 세션의 ○ 18:16 캐시 만료 표시, 캐시가 만료된 86만 토큰 세션에서 첫 메시지를 막고 경고한 뒤 메시지를 입력창에 되돌리는 것을 확인했다. 아래는 아직 확인하지 못했다.
language를 en으로 바꿨을 때의 표시와 지시claude plugin marketplace add)로 설치했을 때 모드가 로드되는지답이 출력되는 속도(초당 토큰)를 입력창 위에 레이싱 게임 계기판처럼 보여 준다.
N, 턴이 끝나면 P(빨강)REQ 응답 대기 · THK 생각 중 · OUT 출력 중 · TOOL 도구 실행턴이 끝나면 숫자와 막대가 0으로 내려가고 P로 남는다. 설치는 설치와 같고, 폴더만 <repo>/plugins/token-speedometer로 준다. 설정 항목은 없다.
모델 비교 차트의 숫자와 견줄 수 있게 Artificial Analysis의 출력 속도와 같은 기준으로 잰다.
o200k_base 기준으로 센다. 화면에 나온 답(본문과 도구 입력)을 문자 종류별 비용으로 추정한다: 한글 0.90, 영문·숫자 0.25, 공백 0.10, 기호 0.61, 그 밖 1.0토큰/글자. Claude Code 세션 기록의 답 6,000개로 tiktoken과 맞춘 값이고, 따로 둔 6,000개에서 전체 오차는 -0.6%, 한글 비중별로 나눠도 ±4% 안이다Artificial Analysis는 답 조각의 처음 20%를 빼고 재지만 이 모드는 처음부터 잰다. 서빙 환경과 effort도 다르므로 차트의 숫자와 똑같이 나오지는 않는다.
isInteractive)에서만 도는데, 대화형 그림은 갱신할 때마다 깜박이고 크기가 줄어서 쓰지 않는다prefers-color-scheme으로 가른다Windows 데스크톱 앱(라이트 모드)에서 계기판 표시, 상태·기어 전환, 출력이 끝나면 바로 감속하는 것, 턴이 끝난 뒤 0으로 내려가 P로 남는 것, 창을 좁히면 비율대로 줄어드는 것을 확인했다. 아래는 아직 확인하지 못했다.
hooks/register.tsx 221 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { bar, hudSvg, PHASE_LABEL, seconds } from './hud'
4import type { Phase } from './hud'
5
6// 속도를 다시 재는 주기(데스크톱이 다시 그리는 한도인 초당 10회)와, 속도를 재는 구간
7const TICK_MS = 100
8const WINDOW_MS = 1000
9// 표시값이 매 주기 측정값 쪽으로 다가가는 비율. 숫자가 한 번에 뛰지 않고 차례로 올라가고 내려간다.
10const DISPLAY_EASE = 0.3
11// 턴이 끝나 0으로 내려가던 표시값이 이보다 작아지면 0으로 두고 타이머를 멈춘다
12const PARKED_TPS = 0.5
13// 그림을 다시 그리는 기준 해상도. 표시값이 이만큼도 안 바뀌었으면 다시 그리지 않는다.
14const REDRAW_TPS_STEP = 0.1
15
16// 속도는 Artificial Analysis 기준으로 잰다: 화면에 나온 답(본문과 도구 입력)을 OpenAI o200k_base 토큰으로 세고,
17// 답이 나오는 동안의 시간으로 나눈다. 생각 구간은 넣지 않는다.
18// o200k 토큰은 문자 종류별 글자당 비용으로 추정한다. Claude Code 세션 기록의 답 6,000개로 tiktoken과 맞춘 값이고,
19// 따로 둔 6,000개에서 전체 오차 -0.6%, 한글 비중별로 나눠도 ±4% 안이다.
20const O200K_PER_HANGUL = 0.8987
21const O200K_PER_ALNUM = 0.2533
22const O200K_PER_SPACE = 0.1001
23const O200K_PER_PUNCT = 0.6116
24const O200K_PER_OTHER = 1
25
26// 글자는 UTF-16 단위로 센다. 비용은 코드 포인트 단위로 맞췄으므로 이모지(2단위)는 두 배로 잡히고,
27// U+3000 같은 유니코드 공백은 기타로 센다. Claude Code의 답에는 드물어 오차에 거의 들어가지 않는다.
28function o200kTokens(text: string): number {
29 let n = 0
30 for (let i = 0; i < text.length; i++) {
31 const c = text.charCodeAt(i)
32 if ((c >= 0xac00 && c <= 0xd7a3) || (c >= 0x1100 && c <= 0x11ff) || (c >= 0x3130 && c <= 0x318f)) n += O200K_PER_HANGUL
33 else if ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122)) n += O200K_PER_ALNUM
34 else if (c === 32 || (c >= 9 && c <= 13)) n += O200K_PER_SPACE
35 else if (c >= 33 && c <= 126) n += O200K_PER_PUNCT
36 else n += O200K_PER_OTHER
37 }
38 return n
39}
40
41type Sample = { at: number; tokens: number }
42
43// 이번 턴의 측정값. 조각이 올 때는 토큰 추정치만 더하고, 속도는 타이머가 계산한다.
44// 턴이 끝나도 다음 턴이 시작될 때까지 남겨 P 표시에 쓴다.
45let hasTurn = false
46let isParked = false
47let timer: Timer | undefined
48let turnStartAt = 0
49let phase: Phase = 'request'
50let gear = '1'
51let tokens = 0
52let launchTaken = false
53let launchMs: number | undefined
54let answerTokens = 0
55let answerMs = 0
56let samples: Sample[] = []
57let liveTps = 0
58let maxTps = 0
59let closing: Promise<void> = Promise.resolve()
60
61// 마지막으로 그린 그림의 속도와 표시 내용
62let shownTps = 0
63let frameKey = ''
64
65// 끝난 응답들의 평균: 답의 토큰 / 첫 글자부터 끝까지의 시간
66function averageTps(): number | undefined {
67 return answerMs > 0 ? answerTokens / (answerMs / 1000) : undefined
68}
69
70// 표시 내용이 바뀌었을 때만 다시 그린다. 데스크톱은 초당 10번까지 다시 그린다.
71function redraw($: EngineInterface): void {
72 const avg = averageTps()
73 const key = [
74 isParked,
75 phase,
76 gear,
77 Math.round(liveTps / REDRAW_TPS_STEP),
78 Math.round(maxTps),
79 avg === undefined ? '' : Math.round(avg),
80 launchMs ?? '',
81 ].join('|')
82 if (key === frameKey) return
83 frameKey = key
84 shownTps = liveTps
85 $.ui.invalidate('ui.render')
86}
87
88async function tick($: EngineInterface): Promise<void> {
89 const now = await $.clock.now()
90 samples.push({ at: now, tokens })
91 // 구간 시작 직전의 표본 하나를 남긴다. 타이머가 몰려서 울려도 속도를 재는 구간이 WINDOW_MS보다 짧아지지 않는다.
92 while ((samples[1]?.at ?? now) <= now - WINDOW_MS) samples.shift()
93 const oldest = samples[0] ?? { at: now, tokens }
94 // 답이 나오는 중이 아니면 바로 0을 향한다. 구간 안에 남은 지난 출력 때문에 생각·도구 실행 중에 숫자가 오르지 않게 한다.
95 const isOutput = phase === 'output' && !isParked
96 const measured = !isOutput || now <= oldest.at ? 0 : (tokens - oldest.tokens) / ((now - oldest.at) / 1000)
97 liveTps += (measured - liveTps) * DISPLAY_EASE
98 maxTps = Math.max(maxTps, liveTps)
99 if (isParked && liveTps < PARKED_TPS) {
100 liveTps = 0
101 timer?.cancel()
102 timer = undefined
103 }
104 redraw($)
105}
106
107async function closeStep($: EngineInterface, answerAt: Promise<number>, stepTokens: number): Promise<void> {
108 const [start, end] = await Promise.all([answerAt, $.clock.now()])
109 if (end <= start) return
110 answerTokens += stepTokens
111 answerMs += end - start
112}
113
114export const register: Register = on => {
115 on('turn.start', async ($, e, next) => {
116 timer?.cancel()
117 turnStartAt = await $.clock.now()
118 hasTurn = true
119 isParked = false
120 phase = 'request'
121 gear = '1'
122 tokens = 0
123 launchTaken = false
124 launchMs = undefined
125 answerTokens = 0
126 answerMs = 0
127 samples = [{ at: turnStartAt, tokens: 0 }]
128 liveTps = 0
129 maxTps = 0
130 shownTps = 0
131 timer = $.clock.every(TICK_MS, () => void tick($))
132 redraw($)
133 return next(e)
134 })
135
136 // 메인 대화의 응답만 잰다. 서브에이전트의 응답이 섞이면 속도가 부풀어 오른다.
137 // 조각을 넘기는 길에서는 기다리지 않는다. 시각은 $.clock.now()를 걸어 두기만 하고 끝날 때 모아 읽는다.
138 on('turn.step', async function* ($, e, next) {
139 if (e.agentId !== undefined || !hasTurn || isParked) return yield* next(e)
140
141 phase = 'request'
142 gear = String(e.index + 1)
143 let answerAt: Promise<number> | undefined
144 let stepTokens = 0
145 try {
146 for await (const c of next(e)) {
147 if (!launchTaken) {
148 launchTaken = true
149 void $.clock.now().then(t => {
150 launchMs = t - turnStartAt
151 })
152 }
153 if (phase === 'request') phase = 'thinking'
154 const text = c.kind === 'text' ? c.text : c.kind === 'input' ? c.json : ''
155 if (text.length > 0) {
156 if (answerAt === undefined) {
157 answerAt = $.clock.now()
158 phase = 'output'
159 }
160 const n = o200kTokens(text)
161 tokens += n
162 stepTokens += n
163 }
164 yield c
165 }
166 } finally {
167 phase = 'tool'
168 if (answerAt !== undefined) closing = closeStep($, answerAt, stepTokens)
169 }
170 })
171
172 // 턴이 끝나면 P로 두고, 표시값은 타이머가 0까지 내린 뒤 멈춘다.
173 on('turn.complete', async ($, e, next) => {
174 if (e.agentId !== undefined || !hasTurn || isParked) return next(e)
175
176 await closing
177 isParked = true
178 redraw($)
179 return next(e)
180 })
181
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 if (e.props.hasSurvey || !hasTurn) return next(e)
184
185 const avg = averageTps()
186 const title = isParked ? '턴 종료' : PHASE_LABEL[phase]
187 const summary =
188 `${title} ${Math.round(shownTps)} tok/s · 평균 ${avg === undefined ? '—' : Math.round(avg)}` +
189 ` · 최고 ${Math.round(maxTps)} · 출발 ${seconds(launchMs)}`
190 const elements = $.ui.resolve(e)
191
192 // 터미널은 고정폭 글자로 막대를 그린다. 'Svg' in 검사는 타입을 좁히는 용도이고, 실행 중 표에는 터미널에도 그 키가 있다.
193 if (e.surface === 'terminal' || !('Svg' in elements)) {
194 const { Box, Text } = elements
195 return (
196 <Box borderStyle="round" borderDimColor paddingX={1}>
197 <Text dimColor={isParked}>{`${bar(shownTps)} ${summary}`}</Text>
198 </Box>
199 )
200 }
201
202 const { Box, Svg } = elements
203 const shownGear = isParked ? 'P' : phase === 'thinking' || phase === 'tool' ? 'N' : gear
204 const view = {
205 tps: shownTps,
206 avgTps: avg,
207 maxTps,
208 launchMs,
209 phase: isParked ? undefined : phase,
210 gear: shownGear,
211 }
212 // 그림 모드로 그린다. 대화형(isInteractive)은 그림 안의 애니메이션이 돌지만 데스크톱에서 크기가 줄고 갱신마다 깜박였다.
213 // 그림 모드에서는 애니메이션이 돌지 않는다(녹화로 확인: 막대가 6프레임마다 바뀜).
214 return (
215 <Box>
216 <Svg source={hudSvg(view)} alt={`토큰 속도계: ${summary}`} />
217 </Box>
218 )
219 })
220}
221hooks/hud.ts 174 lines1// 속도계 그림. 측정과 상관없는 순수 함수만 둔다.
2
3// 응답 사이: 모델 요청을 보내고 첫 조각을 기다림 · 첫 조각은 왔지만 보이는 출력이 아직 없음 · 출력이 나오는 중 · 도구 실행
4export type Phase = 'request' | 'thinking' | 'output' | 'tool'
5
6export const PHASE_LABEL: Record<Phase, string> = {
7 request: '응답 대기',
8 thinking: '생각 중',
9 output: '출력 중',
10 tool: '도구 실행',
11}
12const PHASE_LAMP: Record<Phase, string> = { request: 'REQ', thinking: 'THK', output: 'OUT', tool: 'TOOL' }
13const PHASES: readonly Phase[] = ['request', 'thinking', 'output', 'tool']
14
15// 막대가 끝까지 차는 속도와 빨간 구간이 시작되는 비율
16const GAUGE_MAX_TPS = 200
17const REDLINE_FRACTION = 0.8
18
19// 데스크톱 계기판. 배경 없이 그리고, 색은 앱의 밝은·어두운 모드에 따라 아래 두 벌 중 하나를 쓴다.
20// 흰색·검은색 요소의 글로우(gw)는 밝은 배경에서 번져 보여서 어두운 모드에서만 켠다. 초록·빨강은 늘 빛난다.
21const HUD_WIDTH = 720
22const HUD_HEIGHT = 34
23const BAR_SEGMENTS = 56
24// 속도가 0이어도 켜 두는 칸 수(공회전)
25const IDLE_SEGMENTS = 3
26// 칸을 기울이는 정도(칸 높이 대비 가로 밀림)
27const SEGMENT_SLANT = 0.36
28const HUD_STYLE =
29 '.d{fill:#151515}.dd{fill:#d0d0d0}.l{fill:#707070}.on{fill:#151515}.off{fill:#cfcfcf}' +
30 '.g0{stop-color:#b5b5b5}.g1{stop-color:#1a1a1a}.so{fill:#e6e6e6}.r{fill:#e8323c}.ro{fill:#f19ca1}' +
31 '.u{fill:#1a1a1a;stroke:#1a1a1a}.g{fill:#18a058}.gr{stroke:#18a058}.n{fill:#e8323c}.nr{stroke:#e8323c}' +
32 '@media (prefers-color-scheme: dark){' +
33 '.d{fill:#f5f5f5}.dd{fill:#3f4247}.l{fill:#9aa0a6}.on{fill:#f5f5f5}.off{fill:#3a3d42}' +
34 '.g0{stop-color:#5f6368}.g1{stop-color:#ffffff}.so{fill:#2a2d31}.r{fill:#ff3b3b}.ro{fill:#a3222b}' +
35 '.u{fill:#ffffff;stroke:#ffffff}.g{fill:#3ddc84}.gr{stroke:#3ddc84}.n{fill:#ff4d4d}.nr{stroke:#ff4d4d}' +
36 '.gw{filter:url(#glow)}}'
37
38// 터미널 막대
39const BAR_CELLS = 10
40const BAR_GLYPHS = '▁▂▃▄▅▆▇█'
41const EMPTY_GLYPH = '░'
42
43export type HudView = {
44 tps: number
45 // 끝난 응답들의 정확한 평균. 아직 끝난 응답이 없으면 없다.
46 avgTps: number | undefined
47 maxTps: number
48 launchMs: number | undefined
49 // 응답이 진행 중일 때의 상태. 턴이 끝나면(P) 없다.
50 phase: Phase | undefined
51 // 몇 번째 모델 요청인지. 생각 중·도구 실행이면 N, 턴이 끝나면 P(둘 다 빨간색)
52 gear: string
53}
54
55function fraction(tps: number): number {
56 return Math.min(Math.max(tps / GAUGE_MAX_TPS, 0), 1)
57}
58
59export function seconds(ms: number | undefined): string {
60 return ms === undefined ? '—' : `${(ms / 1000).toFixed(1)}s`
61}
62
63export function hudSvg(v: HudView): string {
64 const digits = String(Math.min(Math.round(v.tps), 999)).padStart(3, '0')
65 const lead = digits.length - digits.replace(/^0+(?=\d)/, '').length
66 const isNeutral = v.gear === 'P' || v.gear === 'N'
67
68 const barLeft = 232
69 const barRight = 562
70 const barTop = 8
71 const barHeight = 16
72 const gap = 2.2
73 const slant = barHeight * SEGMENT_SLANT
74 const pitch = (barRight - slant - barLeft + gap) / BAR_SEGMENTS
75 const segmentWidth = pitch - gap
76 const redFrom = Math.ceil(BAR_SEGMENTS * REDLINE_FRACTION)
77 // 켜진 칸 수(공회전 칸 수보다 적어지지 않는다)와 그 끝(마지막 켜진 칸의 아랫변 오른쪽 x)
78 const lit = Math.max(Math.round(fraction(v.tps) * BAR_SEGMENTS), IDLE_SEGMENTS)
79 const edge = barLeft + lit * pitch - gap
80 const segment = (i: number) => {
81 const x = barLeft + i * pitch
82 const b = barTop + barHeight
83 return `M${(x + slant).toFixed(1)} ${barTop}H${(x + slant + segmentWidth).toFixed(1)}L${(x + segmentWidth).toFixed(1)} ${b}H${x.toFixed(1)}Z`
84 }
85
86 let litNormal = ''
87 let litRed = ''
88 let offNormal = ''
89 let offRed = ''
90 for (let i = 0; i < BAR_SEGMENTS; i++) {
91 const d = segment(i)
92 if (i < lit) {
93 if (i >= redFrom) litRed += d
94 else litNormal += d
95 } else if (i >= redFrom) offRed += d
96 else offNormal += d
97 }
98
99 let s =
100 `<svg xmlns="http://www.w3.org/2000/svg" width="${HUD_WIDTH}" height="${HUD_HEIGHT}" viewBox="0 0 ${HUD_WIDTH} ${HUD_HEIGHT}"` +
101 ` font-family="'Segoe UI', 'Helvetica Neue', system-ui, sans-serif"><style>${HUD_STYLE}</style>` +
102 `<defs>` +
103 `<filter id="glow" x="-50%" y="-80%" width="200%" height="260%">` +
104 `<feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>` +
105 `<linearGradient id="fill" gradientUnits="userSpaceOnUse" x1="${barLeft}" y1="0" x2="${edge.toFixed(1)}" y2="0">` +
106 `<stop offset="0" class="g0"/><stop offset="1" class="g1"/></linearGradient>` +
107 `</defs>`
108
109 // 기어
110 s +=
111 `<g filter="url(#glow)">` +
112 `<circle cx="17" cy="17" r="12" fill="none" stroke-width="2" class="${isNeutral ? 'nr' : 'gr'}"/>` +
113 `<text x="17" y="21.5" text-anchor="middle" font-size="${v.gear.length > 1 ? 10 : 13}" font-weight="700" class="${isNeutral ? 'n' : 'g'}">${v.gear}</text>` +
114 `</g>`
115
116 // 큰 숫자. 칸마다 따로 그려 자릿수가 바뀌어도 위치가 흔들리지 않는다. 앞자리 0은 어둡게, 나머지는 빛나게.
117 let dim = ''
118 let bright = ''
119 for (let i = 0; i < digits.length; i++) {
120 const t = `<text x="${50 + i * 22}" y="30" text-anchor="middle">${digits[i]}</text>`
121 if (i < lead) dim += t
122 else bright += t
123 }
124 s +=
125 `<g font-size="36" font-weight="300" font-style="italic">` +
126 `<g class="dd">${dim}</g><g class="d gw">${bright}</g></g>`
127
128 // 단위와 상태 표시등
129 s += `<text x="116" y="12" font-size="9" letter-spacing="1" class="l">TOK/S</text>`
130 PHASES.forEach((p, i) => {
131 const isOn = p === v.phase
132 s += `<text x="${116 + i * 26}" y="27" font-size="8" font-weight="600" letter-spacing="0.5" class="${isOn ? 'on gw' : 'off'}">${PHASE_LAMP[p]}</text>`
133 })
134
135 // 기울인 분할 막대: 꺼진 칸, 켜진 칸(그라데이션과 글로우), 아래 진행선과 끝점
136 s +=
137 `<path d="${offNormal}" class="so"/>` +
138 `<path d="${offRed}" class="ro"/>` +
139 `<path d="${litNormal}" fill="url(#fill)" class="gw"/>` +
140 `<path d="${litRed}" class="r" filter="url(#glow)"/>` +
141 `<rect x="${barLeft}" y="28" width="${(edge - barLeft).toFixed(1)}" height="1.2" fill="url(#fill)"/>` +
142 `<circle cx="${edge.toFixed(1)}" cy="28.6" r="1.8" class="u gw"/>`
143
144 // 이번 턴의 최고 속도
145 if (v.maxTps > 0) {
146 const x = barLeft + slant + fraction(v.maxTps) * (barRight - slant - barLeft)
147 s += `<path d="M${(x - 3.5).toFixed(1)} 1L${(x + 3.5).toFixed(1)} 1L${x.toFixed(1)} 5.5Z" class="d"/>`
148 }
149
150 // 평균 · 최고 · 출발
151 const stats: [string, string][] = [
152 ['AVG', v.avgTps === undefined ? '—' : String(Math.round(v.avgTps))],
153 ['TOP', String(Math.round(v.maxTps))],
154 ['LAUNCH', seconds(v.launchMs)],
155 ]
156 stats.forEach(([label, value], i) => {
157 const x = 584 + i * 46
158 s +=
159 `<text x="${x}" y="11" font-size="8" letter-spacing="0.8" class="l">${label}</text>` +
160 `<text x="${x}" y="28" font-size="14" font-style="italic" class="d">${value}</text>`
161 })
162
163 return s + '</svg>'
164}
165
166export function bar(tps: number): string {
167 const filled = Math.round(fraction(tps) * BAR_CELLS)
168 let out = ''
169 for (let i = 0; i < BAR_CELLS; i++) {
170 out += i < filled ? BAR_GLYPHS[Math.floor((i * BAR_GLYPHS.length) / BAR_CELLS)] : EMPTY_GLYPH
171 }
172 return out
173}
174