@dbx-tools/ui-mastra 0.1.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/README.md +198 -0
- package/index.ts +32 -0
- package/package.json +62 -0
- package/src/react/bubbles.tsx +428 -0
- package/src/react/chat-view.tsx +574 -0
- package/src/react/data-grid.tsx +342 -0
- package/src/react/embed-slots.tsx +258 -0
- package/src/react/export-menu.tsx +71 -0
- package/src/react/feedback-controls.tsx +139 -0
- package/src/react/index.ts +42 -0
- package/src/react/markdown.tsx +387 -0
- package/src/react/mastra-chat.tsx +1376 -0
- package/src/react/suggestion-pills.tsx +46 -0
- package/src/react/suggestions.ts +109 -0
- package/src/react/thread-sidebar.tsx +315 -0
- package/src/react/tool-pill.tsx +684 -0
- package/src/react/types.ts +295 -0
- package/src/styles.css +22 -0
- package/src/support/chart-option.ts +130 -0
- package/src/support/export.ts +485 -0
- package/src/support/mastra-client.ts +883 -0
- package/src/support/mastra-stream.ts +66 -0
- package/src/support/shiki-plugin.ts +134 -0
- package/src/support/thread-sessions.ts +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { GenieWriterEvent } from "@dbx-tools/shared-mastra";
|
|
2
|
+
import type { UIMessage } from "ai";
|
|
3
|
+
import type { ExportFormat } from "../support/export";
|
|
4
|
+
|
|
5
|
+
export type { ExportFormat } from "../support/export";
|
|
6
|
+
|
|
7
|
+
// Public types for the chat UI: the controlled `ChatView` props plus
|
|
8
|
+
// the supporting tool-event / approval shapes a host transport feeds
|
|
9
|
+
// it. Kept dependency-free of the components so both the presentational
|
|
10
|
+
// layer and the `useMastraChat` driver can share them.
|
|
11
|
+
|
|
12
|
+
export type ChatStatus = "submitted" | "streaming" | "ready" | "error";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lifecycle of a single tool invocation surfaced inline in the assistant
|
|
16
|
+
* bubble. `running` while we wait for the backend, `done` on `tool-result`,
|
|
17
|
+
* `error` on `tool-error`. `progress` is an in-order log of mid-flight
|
|
18
|
+
* events the tool itself pushed through Mastra's `ctx.writer`.
|
|
19
|
+
*/
|
|
20
|
+
export type ToolEvent = {
|
|
21
|
+
id: string;
|
|
22
|
+
toolName: string;
|
|
23
|
+
status: "running" | "done" | "error";
|
|
24
|
+
progress?: ToolProgress[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalised progress event shape. Aliases {@link GenieWriterEvent}
|
|
29
|
+
* from `@dbx-tools/appkit-mastra-shared` so the Genie agent
|
|
30
|
+
* (server) and the chat UI (client) stay in lock-step on the
|
|
31
|
+
* unified flat `{type, ...}` events the `tool-output` chunks
|
|
32
|
+
* carry. New variants should be added there.
|
|
33
|
+
*/
|
|
34
|
+
export type ToolProgress = GenieWriterEvent;
|
|
35
|
+
|
|
36
|
+
/** Subset of a Model Serving endpoint surfaced in the model picker. */
|
|
37
|
+
export type ChatModelOption = { name: string };
|
|
38
|
+
|
|
39
|
+
/** Thumbs reaction a user can leave on an assistant turn. */
|
|
40
|
+
export type FeedbackValue = "up" | "down";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One feedback action from the user: a thumbs `value`, a freeform
|
|
44
|
+
* `comment`, or both. Emitted by {@link ChatViewProps.onFeedback}.
|
|
45
|
+
*/
|
|
46
|
+
export type FeedbackSubmission = {
|
|
47
|
+
value?: FeedbackValue;
|
|
48
|
+
comment?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Feedback state for a single assistant message. `traceId` is the
|
|
53
|
+
* MLflow trace the feedback attaches to (captured from the stream
|
|
54
|
+
* response); its presence is what makes a bubble eligible for feedback
|
|
55
|
+
* controls. `value` mirrors the last thumbs the user chose so the
|
|
56
|
+
* active thumb stays highlighted.
|
|
57
|
+
*/
|
|
58
|
+
export type MessageFeedback = {
|
|
59
|
+
traceId: string;
|
|
60
|
+
value?: FeedbackValue;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One conversation thread surfaced in the {@link ChatViewProps}
|
|
65
|
+
* sidebar. A trimmed view of the wire `MastraThread`: only what the
|
|
66
|
+
* list needs to render and reference a conversation.
|
|
67
|
+
*/
|
|
68
|
+
export type ThreadSummary = {
|
|
69
|
+
/** Thread id. Passed back out via {@link ChatViewProps.onSelectThread}. */
|
|
70
|
+
id: string;
|
|
71
|
+
/** Human-readable title; falls back to a placeholder when absent (new threads). */
|
|
72
|
+
title?: string;
|
|
73
|
+
/** ISO-8601 last-activity timestamp, used to render a relative "time ago" hint. */
|
|
74
|
+
updatedAt?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type ChatViewProps = {
|
|
78
|
+
messages: UIMessage[];
|
|
79
|
+
status: ChatStatus;
|
|
80
|
+
/**
|
|
81
|
+
* The error from the last failed turn, surfaced as a destructive
|
|
82
|
+
* Alert when `status` is `"error"`. When omitted, the Alert shows a
|
|
83
|
+
* generic message. `useMastraChat` populates it on the drop-in path;
|
|
84
|
+
* a host driving `ChatView` itself supplies its own.
|
|
85
|
+
*/
|
|
86
|
+
error?: Error | null;
|
|
87
|
+
sendMessage: (message: { text: string }) => void;
|
|
88
|
+
regenerate?: () => void;
|
|
89
|
+
/**
|
|
90
|
+
* Abort the in-flight response. When provided and the chat is running
|
|
91
|
+
* (`status` is `"submitted"` or `"streaming"`), the composer swaps the
|
|
92
|
+
* Send button for a Stop button that calls this. The handler should
|
|
93
|
+
* cancel the active generation and return the chat to `"ready"`. Omit
|
|
94
|
+
* to hide the Stop affordance (the Send button just disables while a
|
|
95
|
+
* response streams).
|
|
96
|
+
*/
|
|
97
|
+
onStop?: () => void;
|
|
98
|
+
/** Extra classes merged onto the root layout container. */
|
|
99
|
+
className?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Starter questions shown as one-tap buttons on the empty state.
|
|
102
|
+
* Defaults to none - nothing renders when omitted or empty, so the
|
|
103
|
+
* component carries no built-in example prompts. The drop-in
|
|
104
|
+
* `MastraChat` fills this from the agent's Genie space sample
|
|
105
|
+
* questions when the caller doesn't pass an explicit list.
|
|
106
|
+
*/
|
|
107
|
+
suggestions?: string[];
|
|
108
|
+
toolEventsByMessage?: Record<string, ToolEvent[]>;
|
|
109
|
+
/** Available model endpoints. Pass an empty array (or omit) to hide the picker. */
|
|
110
|
+
models?: ChatModelOption[];
|
|
111
|
+
/** Currently selected model name; empty string means "use server default". */
|
|
112
|
+
model?: string;
|
|
113
|
+
onModelChange?: (model: string) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Optional infinite-scroll-up handler. Fired when the user scrolls
|
|
116
|
+
* within `TOP_LOAD_MORE_THRESHOLD_PX` of the top of the
|
|
117
|
+
* transcript. The parent is expected to fetch the next older page
|
|
118
|
+
* and prepend it to `messages`; the view preserves the visual
|
|
119
|
+
* scroll position across the prepend so the reveal feels like
|
|
120
|
+
* paging up through history rather than a layout jump.
|
|
121
|
+
*/
|
|
122
|
+
onLoadMore?: () => void;
|
|
123
|
+
/** True while a {@link ChatViewProps.onLoadMore} fetch is in flight. */
|
|
124
|
+
isLoadingMore?: boolean;
|
|
125
|
+
/** True when more history is still available (drives the trigger). */
|
|
126
|
+
hasMore?: boolean;
|
|
127
|
+
/** True while the *initial* history page is loading. */
|
|
128
|
+
isLoadingHistory?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Resolve an approval-gated tool call. Fired when the user clicks
|
|
131
|
+
* Approve or Deny on the inline approval card. The handler must
|
|
132
|
+
* resume the suspended Mastra workflow on its own: with
|
|
133
|
+
* `mastraClient.getAgent(...).stream()`, call
|
|
134
|
+
* `agent.approveToolCall({ runId, toolCallId })` /
|
|
135
|
+
* `agent.declineToolCall({ runId, toolCallId })` to get a fresh
|
|
136
|
+
* stream Response and pipe it through the same chunk handler
|
|
137
|
+
* (this is exactly what `useMastraChat` does).
|
|
138
|
+
*
|
|
139
|
+
* It requires the `runId` Mastra emitted with the approval
|
|
140
|
+
* chunk - the field is always populated when the card was rendered
|
|
141
|
+
* from a live `data-tool-call-approval` part or an out-of-band
|
|
142
|
+
* `pendingApprovalsByMessage` entry. It will be missing only for
|
|
143
|
+
* approvals reconstructed from history (where the original runId
|
|
144
|
+
* is lost), in which case the handler should surface a "this
|
|
145
|
+
* approval is stale, please re-ask the model" message rather than
|
|
146
|
+
* trying to resume a workflow that no longer exists.
|
|
147
|
+
*/
|
|
148
|
+
onResolveToolApproval?: (args: ApprovalDecision) => void | Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* Out-of-band approval requests keyed by assistant message id, for
|
|
151
|
+
* transports that don't surface approvals as `UIMessage` parts.
|
|
152
|
+
* The `/stream` page populates this from Mastra's
|
|
153
|
+
* `tool-call-approval` chunk so the same `ToolApprovalCard`
|
|
154
|
+
* UI works without injecting synthetic data parts. Each entry is
|
|
155
|
+
* merged with any approvals already discovered in `message.parts`.
|
|
156
|
+
*/
|
|
157
|
+
pendingApprovalsByMessage?: Record<string, PendingApproval[]>;
|
|
158
|
+
/**
|
|
159
|
+
* Wipe the current chat thread. When provided, the header renders
|
|
160
|
+
* a "Clear" button that calls this and shows a confirmation
|
|
161
|
+
* prompt first. The handler is responsible for both the
|
|
162
|
+
* server-side delete (typically `mastraClient.clearHistory()`) and
|
|
163
|
+
* resetting client-side transcript / tool-event state so the
|
|
164
|
+
* blank slate sticks across the next render. Omit to hide the
|
|
165
|
+
* button entirely (read-only embeds, history-less agents).
|
|
166
|
+
*/
|
|
167
|
+
onClear?: () => void | Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* The caller's conversation threads, newest first. When provided
|
|
170
|
+
* together with {@link onSelectThread}, the view renders a
|
|
171
|
+
* collapsible sidebar listing them so the user can switch between
|
|
172
|
+
* conversations. Omit (or pass without `onSelectThread`) to render
|
|
173
|
+
* the classic single-thread chat with no sidebar.
|
|
174
|
+
*/
|
|
175
|
+
threads?: ThreadSummary[];
|
|
176
|
+
/** Id of the currently-active thread, highlighted in the sidebar. */
|
|
177
|
+
activeThreadId?: string;
|
|
178
|
+
/**
|
|
179
|
+
* Thread ids with an in-flight generation (`submitted` or `streaming`).
|
|
180
|
+
* Drives a per-row streaming indicator in the sidebar while a turn
|
|
181
|
+
* continues after the user switches away.
|
|
182
|
+
*/
|
|
183
|
+
streamingThreadIds?: string[];
|
|
184
|
+
/** True while the initial thread list is loading (drives a sidebar spinner). */
|
|
185
|
+
isLoadingThreads?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Switch the conversation to `threadId`. The handler reloads that
|
|
188
|
+
* thread's history and points subsequent turns at it. Providing this
|
|
189
|
+
* (with {@link threads}) is what turns the sidebar on.
|
|
190
|
+
*/
|
|
191
|
+
onSelectThread?: (threadId: string) => void;
|
|
192
|
+
/**
|
|
193
|
+
* Start a fresh conversation. The handler mints a new thread id,
|
|
194
|
+
* clears the transcript, and points subsequent turns at it. When
|
|
195
|
+
* provided, the sidebar shows a "New chat" affordance.
|
|
196
|
+
*/
|
|
197
|
+
onNewThread?: () => void;
|
|
198
|
+
/**
|
|
199
|
+
* Delete a conversation by id. The handler removes it server-side and
|
|
200
|
+
* refreshes the list; deleting the active thread starts a new one.
|
|
201
|
+
* When provided, each sidebar row shows a delete affordance.
|
|
202
|
+
*/
|
|
203
|
+
onDeleteThread?: (threadId: string) => void;
|
|
204
|
+
/**
|
|
205
|
+
* Rename a conversation by id. The handler persists the new title
|
|
206
|
+
* server-side and updates the list (optimistically, so the new name
|
|
207
|
+
* shows immediately). When provided, each sidebar row shows a rename
|
|
208
|
+
* affordance that swaps the title into an inline text field.
|
|
209
|
+
*/
|
|
210
|
+
onRenameThread?: (threadId: string, title: string) => void;
|
|
211
|
+
/**
|
|
212
|
+
* Controlled open/closed state for the conversation sidebar. When
|
|
213
|
+
* omitted the view manages its own (session-only) open state; pass
|
|
214
|
+
* this together with {@link onToggleSidebar} to control and persist
|
|
215
|
+
* the show/hide choice from the host (the driver does this so the
|
|
216
|
+
* choice survives reloads). The header toggle is shown whenever the
|
|
217
|
+
* sidebar is enabled, regardless of who owns the state.
|
|
218
|
+
*/
|
|
219
|
+
sidebarOpen?: boolean;
|
|
220
|
+
/**
|
|
221
|
+
* Toggle the conversation sidebar's visibility. Provided alongside
|
|
222
|
+
* {@link sidebarOpen} for controlled mode; when omitted the view
|
|
223
|
+
* flips its own internal open state.
|
|
224
|
+
*/
|
|
225
|
+
onToggleSidebar?: () => void;
|
|
226
|
+
/**
|
|
227
|
+
* Export the whole conversation in the chosen {@link ExportFormat}
|
|
228
|
+
* (PDF via the browser print dialog, or a Markdown download). When
|
|
229
|
+
* provided, the header shows an Export menu. Charts and data tables are
|
|
230
|
+
* inlined into the export. Omit to hide conversation-level export.
|
|
231
|
+
*/
|
|
232
|
+
onExportConversation?: (format: ExportFormat) => void | Promise<void>;
|
|
233
|
+
/**
|
|
234
|
+
* Export a single message in the chosen {@link ExportFormat}. When
|
|
235
|
+
* provided, each assistant bubble shows a per-message export menu.
|
|
236
|
+
*/
|
|
237
|
+
onExportMessage?: (message: UIMessage, format: ExportFormat) => void | Promise<void>;
|
|
238
|
+
/**
|
|
239
|
+
* Feedback state keyed by assistant message id. Only messages with an
|
|
240
|
+
* entry (i.e. a captured MLflow trace id) show feedback controls, so
|
|
241
|
+
* this doubles as the "is feedback available for this message" gate.
|
|
242
|
+
* `useMastraChat` populates it from each streamed turn's trace-id
|
|
243
|
+
* response header when MLflow logging is enabled.
|
|
244
|
+
*/
|
|
245
|
+
feedbackByMessage?: Record<string, MessageFeedback>;
|
|
246
|
+
/**
|
|
247
|
+
* Submit thumbs / comment feedback for an assistant message. When
|
|
248
|
+
* provided (and the message has a {@link feedbackByMessage} entry),
|
|
249
|
+
* the bubble shows thumbs up/down plus a comment affordance. The
|
|
250
|
+
* handler logs the feedback to MLflow via the plugin's feedback route.
|
|
251
|
+
*/
|
|
252
|
+
onFeedback?: (
|
|
253
|
+
message: UIMessage,
|
|
254
|
+
submission: FeedbackSubmission,
|
|
255
|
+
) => void | Promise<void>;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Payload {@link ChatViewProps.onResolveToolApproval} receives. */
|
|
259
|
+
export type ApprovalDecision =
|
|
260
|
+
| {
|
|
261
|
+
approved: true;
|
|
262
|
+
toolName: string;
|
|
263
|
+
toolCallId: string;
|
|
264
|
+
/**
|
|
265
|
+
* Mastra run id from the approval chunk. Required to resume
|
|
266
|
+
* the suspended workflow; absent only when the card was
|
|
267
|
+
* reconstructed from history (no live runId available).
|
|
268
|
+
*/
|
|
269
|
+
runId?: string;
|
|
270
|
+
input: unknown;
|
|
271
|
+
}
|
|
272
|
+
| {
|
|
273
|
+
approved: false;
|
|
274
|
+
toolName: string;
|
|
275
|
+
toolCallId: string;
|
|
276
|
+
runId?: string;
|
|
277
|
+
input: unknown;
|
|
278
|
+
reason: string;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* One approval-gated tool call paused mid-turn. `runId` is the
|
|
283
|
+
* Mastra workflow id needed to resume; it's always present when the
|
|
284
|
+
* card was constructed from a live source (the stream's
|
|
285
|
+
* `data-tool-call-approval` part or `pendingApprovalsByMessage`),
|
|
286
|
+
* and absent only for approvals reconstructed from a history load
|
|
287
|
+
* where the original runId is lost.
|
|
288
|
+
*/
|
|
289
|
+
export type PendingApproval = {
|
|
290
|
+
toolName: string;
|
|
291
|
+
toolCallId: string;
|
|
292
|
+
/** Mastra run id from the approval chunk. */
|
|
293
|
+
runId?: string;
|
|
294
|
+
input: unknown;
|
|
295
|
+
};
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @dbx-tools/ui-mastra stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Import once from the host app's Tailwind entry, after Tailwind and
|
|
5
|
+
* your AppKit-UI theme:
|
|
6
|
+
*
|
|
7
|
+
* @import "tailwindcss";
|
|
8
|
+
* @import "@databricks/appkit-ui/styles.css";
|
|
9
|
+
* @import "@dbx-tools/ui-mastra/styles.css";
|
|
10
|
+
*
|
|
11
|
+
* This file deliberately defines NO core design tokens (`--background`,
|
|
12
|
+
* `--primary`, `--success`, ...). The chat UI styles exclusively with
|
|
13
|
+
* AppKit semantic tokens, so it inherits whatever theme the host app
|
|
14
|
+
* provides and restyles to match the surrounding brand for free.
|
|
15
|
+
*
|
|
16
|
+
* Pulls shared Streamdown / shiki styling from `@dbx-tools/ui-appkit` and
|
|
17
|
+
* registers this package's React components with Tailwind via `@source`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
@import "@dbx-tools/ui-appkit/styles.css";
|
|
21
|
+
|
|
22
|
+
@source "./react";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation normalizer for planner-produced Echarts specs.
|
|
3
|
+
*
|
|
4
|
+
* The chart planner (`@dbx-tools/appkit-mastra`) emits a JSON-safe
|
|
5
|
+
* `EChartsOption` that travels through the cache and over the wire, so
|
|
6
|
+
* it can't carry function-valued formatters or make width-dependent
|
|
7
|
+
* layout choices. This module patches those presentation concerns back
|
|
8
|
+
* in at render time - identically for the live inline chart
|
|
9
|
+
* (`embed-slots`) and the print/PDF export (`export.ts`) - so both read
|
|
10
|
+
* the same way:
|
|
11
|
+
*
|
|
12
|
+
* - large value-axis ticks render compact (`800M`, not `800,000,000`);
|
|
13
|
+
* - value/category axis names sit in conventional positions (rotated
|
|
14
|
+
* on the left for `y`, centered below for `x`) instead of floating at
|
|
15
|
+
* the axis ends where they collide with the centered title;
|
|
16
|
+
* - category labels stay legible (shown, rotated, de-overlapped) rather
|
|
17
|
+
* than silently decimated when many bars share a narrow canvas;
|
|
18
|
+
* - the title and grid leave room for one another.
|
|
19
|
+
*
|
|
20
|
+
* It only fills gaps: any field the spec already sets (an explicit
|
|
21
|
+
* `axisLabel.formatter`, `nameLocation`, etc.) is preserved.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Compact SI formatter shared across every value-axis tick. */
|
|
25
|
+
const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
|
|
26
|
+
notation: "compact",
|
|
27
|
+
maximumFractionDigits: 1,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a value-axis tick compactly: `1200 -> "1.2K"`,
|
|
32
|
+
* `800000000 -> "800M"`. Values below 1000 (and non-finite ones) render
|
|
33
|
+
* verbatim so small-scale axes and category-like values are untouched.
|
|
34
|
+
*/
|
|
35
|
+
function compactAxisLabel(value: number): string {
|
|
36
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value);
|
|
37
|
+
return Math.abs(value) < 1000 ? String(value) : COMPACT_NUMBER.format(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A permissive record view of an option node we patch field-by-field. */
|
|
41
|
+
type Obj = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
const isObj = (v: unknown): v is Obj =>
|
|
44
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
45
|
+
|
|
46
|
+
/** True when `title` (object or array) carries any non-empty `text`. */
|
|
47
|
+
function hasTitleText(title: unknown): boolean {
|
|
48
|
+
const entries = Array.isArray(title) ? title : [title];
|
|
49
|
+
return entries.some(
|
|
50
|
+
(t) => isObj(t) && typeof t.text === "string" && t.text.trim().length > 0,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pin a title to the top-center so it clears the plot / axis names. */
|
|
55
|
+
function normalizeTitle(title: unknown): unknown {
|
|
56
|
+
if (Array.isArray(title)) return title.map(normalizeTitle);
|
|
57
|
+
if (!isObj(title)) return title;
|
|
58
|
+
return { left: "center", top: 8, ...title };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensure the grid leaves room for a top title and for rotated category
|
|
63
|
+
* labels + a centered x-axis name below. `containLabel` keeps the tick
|
|
64
|
+
* labels themselves inside the box; the explicit margins reserve space
|
|
65
|
+
* for the title (top) and the axis name (bottom) which `containLabel`
|
|
66
|
+
* does not account for.
|
|
67
|
+
*/
|
|
68
|
+
function normalizeGrid(grid: unknown, opts: { hasTitle: boolean }): unknown {
|
|
69
|
+
const base = isObj(grid) ? grid : {};
|
|
70
|
+
return {
|
|
71
|
+
left: 12,
|
|
72
|
+
right: 24,
|
|
73
|
+
bottom: 24,
|
|
74
|
+
...base,
|
|
75
|
+
top: base.top ?? (opts.hasTitle ? 64 : 32),
|
|
76
|
+
containLabel: base.containLabel ?? true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Patch a single axis node in place-safe fashion (`x` or `y`). */
|
|
81
|
+
function normalizeAxis(axis: Obj, pos: "x" | "y"): Obj {
|
|
82
|
+
const next: Obj = { ...axis };
|
|
83
|
+
const existingLabel = isObj(next.axisLabel) ? next.axisLabel : {};
|
|
84
|
+
|
|
85
|
+
if (next.type === "value") {
|
|
86
|
+
// Compact big-number ticks unless the spec pinned its own formatter.
|
|
87
|
+
next.axisLabel = { formatter: compactAxisLabel, ...existingLabel };
|
|
88
|
+
} else if (next.type === "category") {
|
|
89
|
+
// Show every category, rotated and de-overlapped, rather than
|
|
90
|
+
// letting Echarts drop labels on a crowded axis.
|
|
91
|
+
next.axisLabel = { interval: 0, rotate: 30, hideOverlap: true, ...existingLabel };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Move a set axis name to a conventional spot so it never collides
|
|
95
|
+
// with the centered title (y) or floats past the last tick (x).
|
|
96
|
+
if (typeof next.name === "string" && next.name.trim().length > 0) {
|
|
97
|
+
next.nameLocation = next.nameLocation ?? "middle";
|
|
98
|
+
if (pos === "y") {
|
|
99
|
+
next.nameRotate = next.nameRotate ?? 90;
|
|
100
|
+
next.nameGap = next.nameGap ?? 56;
|
|
101
|
+
} else {
|
|
102
|
+
next.nameGap = next.nameGap ?? 56;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Apply {@link normalizeAxis} across an axis field (object or array). */
|
|
109
|
+
function normalizeAxisField(axis: unknown, pos: "x" | "y"): unknown {
|
|
110
|
+
if (Array.isArray(axis))
|
|
111
|
+
return axis.map((a) => (isObj(a) ? normalizeAxis(a, pos) : a));
|
|
112
|
+
return isObj(axis) ? normalizeAxis(axis, pos) : axis;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Return a render-ready copy of a planner `EChartsOption`: compact
|
|
117
|
+
* value ticks, conventionally-placed axis names, legible category
|
|
118
|
+
* labels, and title/grid spacing. Pure and shallow-cloning - the input
|
|
119
|
+
* (which may be a shared/cached object) is never mutated. Non-object
|
|
120
|
+
* input is returned untouched.
|
|
121
|
+
*/
|
|
122
|
+
export function normalizeChartOption<T>(option: T): T {
|
|
123
|
+
if (!isObj(option)) return option;
|
|
124
|
+
const next: Obj = { ...option };
|
|
125
|
+
next.title = normalizeTitle(next.title);
|
|
126
|
+
next.grid = normalizeGrid(next.grid, { hasTitle: hasTitleText(next.title) });
|
|
127
|
+
next.xAxis = normalizeAxisField(next.xAxis, "x");
|
|
128
|
+
next.yAxis = normalizeAxisField(next.yAxis, "y");
|
|
129
|
+
return next as T;
|
|
130
|
+
}
|