dsh-token-chart 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +200 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/buckets.js +115 -0
- package/lib/chunk/echarts.entry.js +25 -0
- package/lib/client.echarts.js +58 -0
- package/lib/client.js +1743 -0
- package/lib/constants.js +22 -0
- package/lib/index.js +640 -0
- package/lib/paths.js +24 -0
- package/lib/prefs.js +54 -0
- package/lib/store.js +273 -0
- package/lib/token-chart.css +568 -0
- package/lib/types/buckets.d.ts +24 -0
- package/lib/types/chunk/echarts.entry.d.ts +11 -0
- package/lib/types/constants.d.ts +22 -0
- package/lib/types/index.d.ts +8 -0
- package/lib/types/paths.d.ts +3 -0
- package/lib/types/prefs.d.ts +8 -0
- package/lib/types/store.d.ts +72 -0
- package/lib/types/types.d.ts +230 -0
- package/lib/types/update.d.ts +24 -0
- package/lib/types.js +10 -0
- package/lib/update.js +110 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +110 -4
- package/src/buckets.ts +120 -0
- package/src/chunk/echarts.entry.ts +27 -0
- package/src/client/api.ts +126 -0
- package/src/client/charts.tsx +255 -0
- package/src/client/chrome.tsx +127 -0
- package/src/client/contract.ts +62 -0
- package/src/client/drawer.tsx +113 -0
- package/src/client/format.ts +36 -0
- package/src/client/globals.d.ts +21 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +164 -0
- package/src/client/models.ts +76 -0
- package/src/client/mount.tsx +312 -0
- package/src/client/panel.tsx +152 -0
- package/src/client/pill.ts +21 -0
- package/src/client/react.ts +12 -0
- package/src/client/series.tsx +140 -0
- package/src/client/settings.tsx +347 -0
- package/src/client/styles/components.less +191 -0
- package/src/client/styles/index.less +7 -0
- package/src/client/styles/tailwind.css +21 -0
- package/src/client/styles/tokens.less +25 -0
- package/src/client/table.tsx +153 -0
- package/src/client/ui.tsx +87 -0
- package/src/constants.ts +26 -0
- package/src/index.ts +673 -0
- package/src/paths.ts +28 -0
- package/src/prefs.ts +61 -0
- package/src/store.ts +295 -0
- package/src/types.ts +216 -0
- package/src/update.ts +106 -0
package/src/buckets.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage model: provider samples, bucket arithmetic, and the time helpers
|
|
3
|
+
* that turn them into chart-ready rows.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-token-chart/buckets
|
|
6
|
+
*/
|
|
7
|
+
import { DAY_MS } from './constants.ts'
|
|
8
|
+
import type { Buckets, Derived, SessionEvent, UsageSample } from './types.ts'
|
|
9
|
+
|
|
10
|
+
/** One zeroed bucket set. */
|
|
11
|
+
export function zeroBuckets(): Buckets {
|
|
12
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isCount(value: unknown): value is number {
|
|
16
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The last `usage` chunk embedded in one durable Assistant settlement stream.
|
|
21
|
+
* @param stream - compact records of the settlement.
|
|
22
|
+
* @returns the provider usage object, or undefined.
|
|
23
|
+
*/
|
|
24
|
+
export function lastStreamUsage(stream: readonly unknown[] | undefined): UsageSample | undefined {
|
|
25
|
+
if (!Array.isArray(stream)) return undefined
|
|
26
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
27
|
+
const record = stream[index]
|
|
28
|
+
if (record === null || typeof record !== 'object') continue
|
|
29
|
+
if (record.type !== 'chunk') continue
|
|
30
|
+
const chunk = record.chunk
|
|
31
|
+
if (chunk !== null && typeof chunk === 'object' && chunk.type === 'usage') return chunk.usage
|
|
32
|
+
}
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The usage one durable settlement reports, if any. */
|
|
37
|
+
export function usageOf(event: SessionEvent): UsageSample | undefined {
|
|
38
|
+
const data = event.data
|
|
39
|
+
if (data === null || typeof data !== 'object') return undefined
|
|
40
|
+
if (event.type === 'assistant/message' && data.usage !== undefined) return data.usage
|
|
41
|
+
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
|
|
42
|
+
return lastStreamUsage(data.stream)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Normalize one provider usage sample into the four billed buckets. */
|
|
46
|
+
export function bucketsFrom(usage: UsageSample | undefined): Buckets | undefined {
|
|
47
|
+
if (usage === null || typeof usage !== 'object') return undefined
|
|
48
|
+
const input = usage.inputTokens
|
|
49
|
+
const output = usage.outputTokens
|
|
50
|
+
if (!isCount(input) || !isCount(output)) return undefined
|
|
51
|
+
const cacheRead = usage.cacheReadTokens === undefined ? 0 : usage.cacheReadTokens
|
|
52
|
+
const cacheWrite = usage.cacheWriteTokens === undefined ? 0 : usage.cacheWriteTokens
|
|
53
|
+
if (!isCount(cacheRead) || !isCount(cacheWrite)) return undefined
|
|
54
|
+
return { input, output, cacheRead, cacheWrite }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function bucketsEqual(left: Buckets, right: Buckets): boolean {
|
|
58
|
+
return (
|
|
59
|
+
left.input === right.input &&
|
|
60
|
+
left.output === right.output &&
|
|
61
|
+
left.cacheRead === right.cacheRead &&
|
|
62
|
+
left.cacheWrite === right.cacheWrite
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function addInto(total: Buckets, buckets: Buckets): Buckets {
|
|
67
|
+
total.input += buckets.input
|
|
68
|
+
total.output += buckets.output
|
|
69
|
+
total.cacheRead += buckets.cacheRead
|
|
70
|
+
total.cacheWrite += buckets.cacheWrite
|
|
71
|
+
return total
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function subtract(total: Buckets, buckets: Buckets): Buckets {
|
|
75
|
+
total.input -= buckets.input
|
|
76
|
+
total.output -= buckets.output
|
|
77
|
+
total.cacheRead -= buckets.cacheRead
|
|
78
|
+
total.cacheWrite -= buckets.cacheWrite
|
|
79
|
+
return total
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Derived figures shared by every response. */
|
|
83
|
+
export function derived<T extends Record<string, unknown>>(buckets: Buckets, extra: T = {} as T): Derived & T {
|
|
84
|
+
const { input, output, cacheRead, cacheWrite } = buckets
|
|
85
|
+
const cache = cacheRead + cacheWrite
|
|
86
|
+
const billedInput = input + cacheRead + cacheWrite
|
|
87
|
+
const total = billedInput + output
|
|
88
|
+
return {
|
|
89
|
+
input,
|
|
90
|
+
output,
|
|
91
|
+
cacheRead,
|
|
92
|
+
cacheWrite,
|
|
93
|
+
cache,
|
|
94
|
+
total,
|
|
95
|
+
cacheHitRate: billedInput > 0 ? cacheRead / billedInput : 0,
|
|
96
|
+
...extra,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Local calendar-day key `YYYY-MM-DD`. */
|
|
101
|
+
export function dayKey(time: number): string {
|
|
102
|
+
const date = new Date(time)
|
|
103
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
104
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
105
|
+
return `${date.getFullYear()}-${month}-${day}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Epoch ms of local midnight, `offset` days back. */
|
|
109
|
+
export function startOfLocalDay(offset = 0): number {
|
|
110
|
+
const date = new Date()
|
|
111
|
+
date.setHours(0, 0, 0, 0)
|
|
112
|
+
return date.getTime() - offset * DAY_MS
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function timeLabel(time: number): string {
|
|
116
|
+
const date = new Date(time)
|
|
117
|
+
const hours = String(date.getHours()).padStart(2, '0')
|
|
118
|
+
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
119
|
+
return `${hours}:${minutes}`
|
|
120
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build entry for the package-local lazy chunk `client.echarts.js`.
|
|
3
|
+
*
|
|
4
|
+
* Only what the token-usage surfaces actually draw is pulled in: bar, line and
|
|
5
|
+
* pie series plus the grid/tooltip/legend/dataZoom components and the SVG
|
|
6
|
+
* renderer.
|
|
7
|
+
* Everything else ECharts ships (maps, 3D, GL, canvas renderer, the full
|
|
8
|
+
* component set) is tree-shaken away by the bundler.
|
|
9
|
+
*/
|
|
10
|
+
import * as echarts from 'echarts/core'
|
|
11
|
+
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
|
12
|
+
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
|
|
13
|
+
import { SVGRenderer } from 'echarts/renderers'
|
|
14
|
+
|
|
15
|
+
echarts.use([
|
|
16
|
+
BarChart,
|
|
17
|
+
LineChart,
|
|
18
|
+
PieChart,
|
|
19
|
+
GridComponent,
|
|
20
|
+
TooltipComponent,
|
|
21
|
+
LegendComponent,
|
|
22
|
+
// The x-axis range slider under the chart.
|
|
23
|
+
DataZoomComponent,
|
|
24
|
+
SVGRenderer,
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
export default echarts
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport and shared state: the preference store every seat reads,
|
|
3
|
+
* the JSON helpers, and the polling hooks the surfaces use.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-token-chart/client/api
|
|
6
|
+
*/
|
|
7
|
+
import type { Prefs } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
import type { EndpointState } from './models.ts'
|
|
10
|
+
|
|
11
|
+
import { React } from './react.ts'
|
|
12
|
+
|
|
13
|
+
import { API, DEFAULT_PREFS } from './contract.ts'
|
|
14
|
+
|
|
15
|
+
export const useSyncExternalStore =
|
|
16
|
+
typeof React.useSyncExternalStore === 'function'
|
|
17
|
+
? React.useSyncExternalStore
|
|
18
|
+
: (subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => unknown) => {
|
|
19
|
+
const [value, setValue] = React.useState(getSnapshot)
|
|
20
|
+
React.useEffect(() => subscribe(() => setValue(getSnapshot())), [subscribe, getSnapshot])
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Snapshot store every seat subscribes to. */
|
|
25
|
+
export interface PrefsStore {
|
|
26
|
+
get: () => Prefs
|
|
27
|
+
set: (next: Prefs) => void
|
|
28
|
+
subscribe: (listener: () => void) => () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const prefsStore: PrefsStore = (() => {
|
|
32
|
+
let value: Prefs = { ...DEFAULT_PREFS }
|
|
33
|
+
const listeners = new Set<() => void>()
|
|
34
|
+
return {
|
|
35
|
+
get: () => value,
|
|
36
|
+
set: (next: Prefs) => {
|
|
37
|
+
value = next
|
|
38
|
+
for (const listener of [...listeners]) (listener as () => void)()
|
|
39
|
+
},
|
|
40
|
+
subscribe: (listener: () => void) => {
|
|
41
|
+
listeners.add(listener)
|
|
42
|
+
return () => listeners.delete(listener)
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
})()
|
|
46
|
+
|
|
47
|
+
export function usePrefs(): Prefs {
|
|
48
|
+
return useSyncExternalStore(prefsStore.subscribe, prefsStore.get) as Prefs
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* --------------------------------------------------------- api client */
|
|
52
|
+
|
|
53
|
+
export async function apiGet(path: string): Promise<{ value: any }> {
|
|
54
|
+
const response = await fetch(`${API}${path}`, { headers: { accept: 'application/json' } })
|
|
55
|
+
const payload = await response.json().catch(() => null)
|
|
56
|
+
if (payload === null || payload.ok !== true) {
|
|
57
|
+
throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
|
|
58
|
+
}
|
|
59
|
+
return payload
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function apiPost(path: string, body: unknown): Promise<{ value: any }> {
|
|
63
|
+
const response = await fetch(`${API}${path}`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify(body ?? {}),
|
|
67
|
+
})
|
|
68
|
+
const payload = await response.json().catch(() => null)
|
|
69
|
+
if (payload === null || payload.ok !== true) {
|
|
70
|
+
throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
|
|
71
|
+
}
|
|
72
|
+
return payload
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Poll one API path: initial load, then a refresh whenever `tick` changes.
|
|
77
|
+
* @param path - API path including its query string.
|
|
78
|
+
* @param tick - refresh trigger.
|
|
79
|
+
*/
|
|
80
|
+
export function useEndpoint(path: string, tick: number): EndpointState {
|
|
81
|
+
const [state, setState] = React.useState<EndpointState>({ loading: true, value: undefined })
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
let live = true
|
|
84
|
+
setState((current) => ({ ...current, loading: true }))
|
|
85
|
+
apiGet(path)
|
|
86
|
+
.then((payload) => {
|
|
87
|
+
if (!live) return
|
|
88
|
+
setState({ loading: false, value: payload.value, error: undefined })
|
|
89
|
+
})
|
|
90
|
+
.catch((error) => {
|
|
91
|
+
if (!live) return
|
|
92
|
+
setState({
|
|
93
|
+
loading: false,
|
|
94
|
+
value: undefined,
|
|
95
|
+
error: String((error as Error)?.message ?? error),
|
|
96
|
+
} as EndpointState)
|
|
97
|
+
})
|
|
98
|
+
return () => {
|
|
99
|
+
live = false
|
|
100
|
+
}
|
|
101
|
+
}, [path, tick])
|
|
102
|
+
return state
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ------------------------------------------------------------- layout */
|
|
106
|
+
|
|
107
|
+
export function useElementWidth(): [React.RefObject<any>, number] {
|
|
108
|
+
const ref = React.useRef(null)
|
|
109
|
+
const [width, setWidth] = React.useState(0)
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
const node = ref.current as HTMLElement | null
|
|
112
|
+
if (node === null) return undefined
|
|
113
|
+
const update = (): void => setWidth((node as HTMLElement).clientWidth)
|
|
114
|
+
update()
|
|
115
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
116
|
+
window.addEventListener('resize', update)
|
|
117
|
+
return () => window.removeEventListener('resize', update)
|
|
118
|
+
}
|
|
119
|
+
const observer = new ResizeObserver(update)
|
|
120
|
+
observer.observe(node)
|
|
121
|
+
return () => observer.disconnect()
|
|
122
|
+
}, [])
|
|
123
|
+
return [ref, width]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ----------------------------------------------------------- charts */
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in SVG fallback charts, used when the ECharts chunk is
|
|
3
|
+
* unavailable (offline first paint, chunk failure, degraded mode).
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-token-chart/client/charts
|
|
6
|
+
*/
|
|
7
|
+
import type { ChartPoint, SeriesSpec, SliceSpec, Translate } from './models.ts'
|
|
8
|
+
|
|
9
|
+
import { useElementWidth } from './api.ts'
|
|
10
|
+
import { compactTokens, exactTokens, percentText } from './format.ts'
|
|
11
|
+
|
|
12
|
+
/** One bar/line chart: bars and lines share points and a metric axis. */
|
|
13
|
+
export function CartesianChart({
|
|
14
|
+
points,
|
|
15
|
+
series,
|
|
16
|
+
height = 210,
|
|
17
|
+
}: {
|
|
18
|
+
points: ChartPoint[]
|
|
19
|
+
series: SeriesSpec[]
|
|
20
|
+
height?: number
|
|
21
|
+
t: Translate
|
|
22
|
+
}) {
|
|
23
|
+
const [ref, width] = useElementWidth()
|
|
24
|
+
const box = Math.max(220, width || 320)
|
|
25
|
+
const hasRight = series.some((entry) => entry.axis === 'right')
|
|
26
|
+
const padding = { top: 12, right: hasRight ? 46 : 14, bottom: 30, left: 48 }
|
|
27
|
+
const innerWidth = Math.max(40, box - padding.left - padding.right)
|
|
28
|
+
const innerHeight = Math.max(40, height - padding.top - padding.bottom)
|
|
29
|
+
|
|
30
|
+
const leftMax = Math.max(
|
|
31
|
+
1,
|
|
32
|
+
...series
|
|
33
|
+
.filter((entry) => entry.axis !== 'right')
|
|
34
|
+
.flatMap((entry) => points.map((point) => Number(point.values[entry.key]) || 0)),
|
|
35
|
+
)
|
|
36
|
+
const rightMax = Math.max(
|
|
37
|
+
0.0001,
|
|
38
|
+
...series
|
|
39
|
+
.filter((entry) => entry.axis === 'right')
|
|
40
|
+
.flatMap((entry) => points.map((point) => Number(point.values[entry.key]) || 0)),
|
|
41
|
+
)
|
|
42
|
+
const scaleY = (value: number, axis: 'left' | 'right' | undefined): number => {
|
|
43
|
+
const max = axis === 'right' ? rightMax : leftMax
|
|
44
|
+
return padding.top + innerHeight - (Math.max(0, value) / max) * innerHeight
|
|
45
|
+
}
|
|
46
|
+
const step = innerWidth / Math.max(1, points.length)
|
|
47
|
+
const barSeries = series.filter((entry) => entry.type === 'bar')
|
|
48
|
+
const barWidth = Math.max(2, Math.min(26, (step * 0.72) / Math.max(1, barSeries.length)))
|
|
49
|
+
const gridLines = [0, 0.25, 0.5, 0.75, 1]
|
|
50
|
+
const labelEvery = Math.max(1, Math.ceil(points.length / Math.max(2, Math.floor(innerWidth / 56))))
|
|
51
|
+
|
|
52
|
+
const children: any[] = []
|
|
53
|
+
for (const ratio of gridLines) {
|
|
54
|
+
const y = padding.top + innerHeight * ratio
|
|
55
|
+
children.push(
|
|
56
|
+
<line
|
|
57
|
+
key={`grid-${ratio}`}
|
|
58
|
+
x1={padding.left}
|
|
59
|
+
x2={padding.left + innerWidth}
|
|
60
|
+
y1={y}
|
|
61
|
+
y2={y}
|
|
62
|
+
stroke="var(--dsw-alias-border-l1)"
|
|
63
|
+
strokeWidth={1}
|
|
64
|
+
strokeDasharray={ratio === 1 ? undefined : '3 4'}
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
children.push(
|
|
69
|
+
<text
|
|
70
|
+
key="axis-left-max"
|
|
71
|
+
x={padding.left - 8}
|
|
72
|
+
y={padding.top + 4}
|
|
73
|
+
textAnchor="end"
|
|
74
|
+
fontSize={10}
|
|
75
|
+
fill="var(--dsw-alias-label-secondary)"
|
|
76
|
+
>
|
|
77
|
+
{compactTokens(leftMax)}
|
|
78
|
+
</text>,
|
|
79
|
+
)
|
|
80
|
+
children.push(
|
|
81
|
+
<text
|
|
82
|
+
key="axis-left-zero"
|
|
83
|
+
x={padding.left - 8}
|
|
84
|
+
y={padding.top + innerHeight}
|
|
85
|
+
textAnchor="end"
|
|
86
|
+
fontSize={10}
|
|
87
|
+
fill="var(--dsw-alias-label-secondary)"
|
|
88
|
+
>
|
|
89
|
+
{'0'}
|
|
90
|
+
</text>,
|
|
91
|
+
)
|
|
92
|
+
if (hasRight) {
|
|
93
|
+
children.push(
|
|
94
|
+
<text
|
|
95
|
+
key="axis-right-max"
|
|
96
|
+
x={padding.left + innerWidth + 8}
|
|
97
|
+
y={padding.top + 4}
|
|
98
|
+
fontSize={10}
|
|
99
|
+
fill="var(--dsw-alias-label-secondary)"
|
|
100
|
+
>
|
|
101
|
+
{percentText(rightMax)}
|
|
102
|
+
</text>,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
points.forEach((point, index) => {
|
|
107
|
+
if (index % labelEvery !== 0) return
|
|
108
|
+
children.push(
|
|
109
|
+
<text
|
|
110
|
+
key={`label-${index}`}
|
|
111
|
+
x={padding.left + step * index + step / 2}
|
|
112
|
+
y={padding.top + innerHeight + 16}
|
|
113
|
+
textAnchor="middle"
|
|
114
|
+
fontSize={10}
|
|
115
|
+
fill="var(--dsw-alias-label-secondary)"
|
|
116
|
+
>
|
|
117
|
+
{String(point.label ?? '')}
|
|
118
|
+
</text>,
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
barSeries.forEach((entry, seriesIndex) => {
|
|
123
|
+
points.forEach((point, index) => {
|
|
124
|
+
const value = Math.max(0, Number(point.values[entry.key]) || 0)
|
|
125
|
+
if (value <= 0) return
|
|
126
|
+
const x = padding.left + step * index + step / 2 - (barWidth * barSeries.length) / 2 + barWidth * seriesIndex
|
|
127
|
+
const y = scaleY(value, entry.axis)
|
|
128
|
+
children.push(
|
|
129
|
+
<rect
|
|
130
|
+
key={`bar-${entry.key}-${index}`}
|
|
131
|
+
x={x}
|
|
132
|
+
y={y}
|
|
133
|
+
width={barWidth}
|
|
134
|
+
height={Math.max(1, padding.top + innerHeight - y)}
|
|
135
|
+
rx={Math.min(3, barWidth / 2)}
|
|
136
|
+
fill={entry.color}
|
|
137
|
+
>
|
|
138
|
+
<title>{`${entry.label}: ${exactTokens(value)}`}</title>
|
|
139
|
+
</rect>,
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
for (const entry of series.filter((item) => item.type === 'line')) {
|
|
145
|
+
const coordinates = points.map((point, index) => {
|
|
146
|
+
const value = Number(point.values[entry.key]) || 0
|
|
147
|
+
return [padding.left + step * index + step / 2, scaleY(value, entry.axis)]
|
|
148
|
+
})
|
|
149
|
+
children.push(
|
|
150
|
+
<polyline
|
|
151
|
+
key={`line-${entry.key}`}
|
|
152
|
+
points={coordinates.map((pair) => pair.join(',')).join(' ')}
|
|
153
|
+
fill="none"
|
|
154
|
+
stroke={entry.color}
|
|
155
|
+
strokeWidth={2}
|
|
156
|
+
strokeLinejoin="round"
|
|
157
|
+
strokeLinecap="round"
|
|
158
|
+
/>,
|
|
159
|
+
)
|
|
160
|
+
coordinates.forEach((pair, index) => {
|
|
161
|
+
children.push(
|
|
162
|
+
<circle key={`dot-${entry.key}-${index}`} cx={pair[0]} cy={pair[1]} r={2.6} fill={entry.color}>
|
|
163
|
+
<title>{`${entry.label}: ${exactTokens(points[index].values[entry.key])}`}</title>
|
|
164
|
+
</circle>,
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="dtc-chart" ref={ref}>
|
|
171
|
+
{
|
|
172
|
+
<svg width={box} height={height} role="img" aria-label={series.map((entry) => entry.label).join(', ')}>
|
|
173
|
+
{children}
|
|
174
|
+
</svg>
|
|
175
|
+
}
|
|
176
|
+
<Legend series={series} />
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Legend({ series }: { series: SeriesSpec[] }) {
|
|
182
|
+
return (
|
|
183
|
+
<div className="dtc-legend">
|
|
184
|
+
{series.map((entry) => (
|
|
185
|
+
<span key={entry.key} className="dtc-legend-item">
|
|
186
|
+
<span className="dtc-swatch" style={{ background: entry.color }} />
|
|
187
|
+
<span>{entry.label}</span>
|
|
188
|
+
</span>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Donut chart over prepared slices. */
|
|
195
|
+
export function PieChart({ slices, t }: { slices: SliceSpec[]; t: Translate }) {
|
|
196
|
+
const [ref, width] = useElementWidth()
|
|
197
|
+
const box = Math.max(180, Math.min(width || 240, 240))
|
|
198
|
+
const size = box
|
|
199
|
+
const radius = size / 2 - 6
|
|
200
|
+
const inner = radius * 0.58
|
|
201
|
+
const center = size / 2
|
|
202
|
+
const total = slices.reduce((sum, slice) => sum + (Number(slice.value) || 0), 0)
|
|
203
|
+
if (total <= 0) return <p className="dtc-empty">{t('panel.empty')}</p>
|
|
204
|
+
let angle = -Math.PI / 2
|
|
205
|
+
const paths = slices.map((slice, index) => {
|
|
206
|
+
const fraction = (Number(slice.value) || 0) / total
|
|
207
|
+
const sweep = fraction * Math.PI * 2
|
|
208
|
+
const start = angle
|
|
209
|
+
const end = angle + sweep
|
|
210
|
+
angle = end
|
|
211
|
+
const large = sweep > Math.PI ? 1 : 0
|
|
212
|
+
const point = (r: number, a: number): [number, number] => [center + r * Math.cos(a), center + r * Math.sin(a)]
|
|
213
|
+
const [x1, y1] = point(radius, start)
|
|
214
|
+
const [x2, y2] = point(radius, end)
|
|
215
|
+
const [x3, y3] = point(inner, end)
|
|
216
|
+
const [x4, y4] = point(inner, start)
|
|
217
|
+
const d =
|
|
218
|
+
slice.value <= 0
|
|
219
|
+
? ''
|
|
220
|
+
: `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${inner} ${inner} 0 ${large} 0 ${x4} ${y4} Z`
|
|
221
|
+
return (
|
|
222
|
+
<path
|
|
223
|
+
key={`slice-${slice.key}-${index}`}
|
|
224
|
+
d={d}
|
|
225
|
+
fill={slice.color}
|
|
226
|
+
fillOpacity={slice.opacity ?? 1}
|
|
227
|
+
stroke="var(--dsw-alias-bg-base)"
|
|
228
|
+
strokeWidth={1.5}
|
|
229
|
+
>
|
|
230
|
+
<title>{`${slice.label}: ${exactTokens(slice.value)} (${percentText(fraction)})`}</title>
|
|
231
|
+
</path>
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
return (
|
|
235
|
+
<div className="dtc-chart dtc-chart-pie" ref={ref}>
|
|
236
|
+
<svg width={size} height={size} role="img" aria-label={t('chart.pie')}>
|
|
237
|
+
{paths}
|
|
238
|
+
</svg>
|
|
239
|
+
{
|
|
240
|
+
<div className="dtc-legend dtc-legend-column">
|
|
241
|
+
{slices.map((slice, index) => (
|
|
242
|
+
<span key={`legend-${slice.key}-${index}`} className="dtc-legend-item">
|
|
243
|
+
<span className="dtc-swatch" style={{ background: slice.color, opacity: slice.opacity ?? 1 }} />
|
|
244
|
+
<span>{`${slice.label} · ${compactTokens(slice.value)} · ${percentText((Number(slice.value) || 0) / total)}`}</span>
|
|
245
|
+
</span>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Page sizes offered under a table, smallest first. */
|
|
254
|
+
export const PAGE_SIZES = [10, 20, 50]
|
|
255
|
+
export const DEFAULT_PAGE_SIZE = 20
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell chrome: the icon shared by the menu and the settings row, the
|
|
3
|
+
* session-header entry, the settings-nav marker and the style installer.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-token-chart/client/chrome
|
|
6
|
+
*/
|
|
7
|
+
import type { Translate } from './models.ts'
|
|
8
|
+
|
|
9
|
+
export function PanelIcon({ size = 18, active = false }: { size?: number; active?: boolean }) {
|
|
10
|
+
return (
|
|
11
|
+
<svg
|
|
12
|
+
width={size}
|
|
13
|
+
height={size}
|
|
14
|
+
viewBox="0 0 24 24"
|
|
15
|
+
fill="none"
|
|
16
|
+
aria-hidden={true}
|
|
17
|
+
style={{ opacity: active ? 1 : 0.9, display: 'block' }}
|
|
18
|
+
>
|
|
19
|
+
<rect x={3} y={12} width={4} height={9} rx={1} fill="currentColor" />
|
|
20
|
+
<rect x={9} y={8} width={4} height={13} rx={1} fill="currentColor" opacity={0.75} />
|
|
21
|
+
<rect x={15} y={4} width={4} height={17} rx={1} fill="currentColor" opacity={0.5} />
|
|
22
|
+
</svg>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The session-header usage entry; always present (see the settings copy). */
|
|
27
|
+
export function HeaderUsageButton({
|
|
28
|
+
sessionId,
|
|
29
|
+
open,
|
|
30
|
+
t,
|
|
31
|
+
}: {
|
|
32
|
+
sessionId: string
|
|
33
|
+
open: (sessionId: string) => boolean
|
|
34
|
+
t: Translate
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
className="dtc-header-button"
|
|
40
|
+
aria-label={t('usage.openDrawer')}
|
|
41
|
+
title={t('usage.title')}
|
|
42
|
+
onClick={() => open(sessionId)}
|
|
43
|
+
>
|
|
44
|
+
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" aria-hidden={true}>
|
|
45
|
+
<path d="M4 19V9M10 19V5M16 19v-7M22 19H2" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" />
|
|
46
|
+
</svg>
|
|
47
|
+
<span className="dtc-header-label">{t('panel.title')}</span>
|
|
48
|
+
</button>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when the clicked element is one of the product's usage pills. */
|
|
53
|
+
export function isUsagePill(target: any): boolean {
|
|
54
|
+
let node = target
|
|
55
|
+
for (let depth = 0; depth < 6 && node !== null && node !== undefined; depth += 1) {
|
|
56
|
+
if (node.nodeType === 1 && node.tagName === 'BUTTON') {
|
|
57
|
+
const text = node.textContent ?? ''
|
|
58
|
+
if (node.getAttribute('aria-haspopup') === 'dialog' && /\btok\b/i.test(text)) return true
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
node = node.parentNode
|
|
62
|
+
}
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Host route that serves the compiled stylesheet. */
|
|
67
|
+
const STYLE_PATH = '/token-chart/assets/token-chart.css'
|
|
68
|
+
/** Where the stylesheet link is remembered, so a re-mount does not duplicate it. */
|
|
69
|
+
const STYLE_LINK_MARKER = 'data-dtc-styles'
|
|
70
|
+
|
|
71
|
+
export const SETTINGS_NAV_MARKER = 'data-dtc-settings-nav'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Keep the marker on our own settings-nav row.
|
|
75
|
+
*
|
|
76
|
+
* The settings shell projects only `id`, `order` and `label` from a
|
|
77
|
+
* `settings.section` registration and picks its icon from a closed list of
|
|
78
|
+
* built-in ids, so there is no icon field to set. Until that contract grows
|
|
79
|
+
* one, identify our localized row after the dialog mounts and draw the same
|
|
80
|
+
* glyph the left-sidebar menu uses through CSS on the marker.
|
|
81
|
+
* @param label - locale-aware resolver for the section label.
|
|
82
|
+
* @returns disposer removing the observer and every marker it set.
|
|
83
|
+
*/
|
|
84
|
+
export function registerSettingsNavIcon(label: () => string): () => void {
|
|
85
|
+
let disposed = false
|
|
86
|
+
const sync = () => {
|
|
87
|
+
if (disposed) return
|
|
88
|
+
const current = label().trim()
|
|
89
|
+
for (const button of document.querySelectorAll('[role="dialog"] nav button')) {
|
|
90
|
+
if (current !== '' && (button.textContent ?? '').trim() === current) button.setAttribute(SETTINGS_NAV_MARKER, '')
|
|
91
|
+
else button.removeAttribute(SETTINGS_NAV_MARKER)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
sync()
|
|
95
|
+
const observer = new MutationObserver(sync)
|
|
96
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
97
|
+
return () => {
|
|
98
|
+
disposed = true
|
|
99
|
+
observer.disconnect()
|
|
100
|
+
for (const marked of document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`))
|
|
101
|
+
marked.removeAttribute(SETTINGS_NAV_MARKER)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Link the plugin's compiled stylesheet.
|
|
107
|
+
*
|
|
108
|
+
* The CSS is a real artifact served by the Host half and loaded as a stylesheet
|
|
109
|
+
* resource — nothing about styling rides inside the client bundle, and the
|
|
110
|
+
* loader removes the link again when this fiber is disposed.
|
|
111
|
+
*
|
|
112
|
+
* @returns disposer removing the link.
|
|
113
|
+
*/
|
|
114
|
+
export function installStyles(): () => void {
|
|
115
|
+
const existing = document.querySelector(`link[${STYLE_LINK_MARKER}]`)
|
|
116
|
+
if (existing !== null) return () => {}
|
|
117
|
+
|
|
118
|
+
const link = document.createElement('link')
|
|
119
|
+
link.rel = 'stylesheet'
|
|
120
|
+
link.href = STYLE_PATH
|
|
121
|
+
link.setAttribute(STYLE_LINK_MARKER, '')
|
|
122
|
+
document.head.appendChild(link)
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
link.remove()
|
|
126
|
+
}
|
|
127
|
+
}
|