@exegia/corpora-ui 2.0.0 → 3.0.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.
Files changed (55) hide show
  1. package/dist-lib/components/composed/chat/chart/chart-atom.d.ts +30 -0
  2. package/dist-lib/components/composed/chat/chart/chart-context.d.ts +2 -0
  3. package/dist-lib/components/composed/chat/chart/chart-legend.d.ts +4 -0
  4. package/dist-lib/components/composed/chat/chart/chart-plot.d.ts +3 -0
  5. package/dist-lib/components/composed/chat/chart/chart-root.d.ts +3 -0
  6. package/dist-lib/components/composed/chat/chart/chart-tooltip.d.ts +4 -0
  7. package/dist-lib/components/composed/chat/chart/constants.d.ts +3 -0
  8. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-area-chart.d.ts +93 -0
  9. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-bar-chart.d.ts +86 -0
  10. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-brush.d.ts +65 -0
  11. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-chart.d.ts +44 -0
  12. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-dot.d.ts +16 -0
  13. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-legend.d.ts +24 -0
  14. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-line-chart.d.ts +90 -0
  15. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-pie-chart.d.ts +73 -0
  16. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-tooltip.d.ts +29 -0
  17. package/dist-lib/components/composed/chat/chart/index.d.ts +6 -0
  18. package/dist-lib/components/composed/chat/chart/type.d.ts +80 -0
  19. package/dist-lib/components/composed/chat/chart/use-chart-state.d.ts +3 -0
  20. package/dist-lib/components/composed/chat/chart/use-chart.d.ts +6 -0
  21. package/dist-lib/components/composed/chat/chart/utils.d.ts +5 -0
  22. package/dist-lib/components/composed/chat/chart.d.ts +1 -44
  23. package/dist-lib/components/composed/chat/index.d.ts +1 -1
  24. package/dist-lib/components/stories/chart.story.d.ts +39 -2
  25. package/dist-lib/components/stories/insight-cards.story.d.ts +30 -1
  26. package/dist-lib/index.js +10020 -5549
  27. package/dist-lib/index.js.map +1 -1
  28. package/package.json +2 -1
  29. package/src/components/composed/chat/__tests__/chart.test.tsx +255 -1
  30. package/src/components/composed/chat/chart/chart-atom.ts +55 -0
  31. package/src/components/composed/chat/chart/chart-context.ts +9 -0
  32. package/src/components/composed/chat/chart/chart-legend.tsx +75 -0
  33. package/src/components/composed/chat/chart/chart-plot.tsx +233 -0
  34. package/src/components/composed/chat/chart/chart-root.tsx +229 -0
  35. package/src/components/composed/chat/chart/chart-tooltip.tsx +74 -0
  36. package/src/components/composed/chat/chart/constants.ts +8 -0
  37. package/src/components/composed/chat/chart/evilcharts/LICENSE +21 -0
  38. package/src/components/composed/chat/chart/evilcharts/README.md +8 -0
  39. package/src/components/composed/chat/chart/evilcharts/echarts-area-chart.tsx +2358 -0
  40. package/src/components/composed/chat/chart/evilcharts/echarts-bar-chart.tsx +2261 -0
  41. package/src/components/composed/chat/chart/evilcharts/echarts-brush.tsx +233 -0
  42. package/src/components/composed/chat/chart/evilcharts/echarts-chart.tsx +214 -0
  43. package/src/components/composed/chat/chart/evilcharts/echarts-dot.tsx +111 -0
  44. package/src/components/composed/chat/chart/evilcharts/echarts-legend.tsx +131 -0
  45. package/src/components/composed/chat/chart/evilcharts/echarts-line-chart.tsx +2112 -0
  46. package/src/components/composed/chat/chart/evilcharts/echarts-pie-chart.tsx +1235 -0
  47. package/src/components/composed/chat/chart/evilcharts/echarts-tooltip.tsx +125 -0
  48. package/src/components/composed/chat/chart/index.ts +13 -0
  49. package/src/components/composed/chat/chart/type.ts +88 -0
  50. package/src/components/composed/chat/chart/use-chart-state.ts +22 -0
  51. package/src/components/composed/chat/chart/use-chart.ts +70 -0
  52. package/src/components/composed/chat/chart/utils.ts +47 -0
  53. package/src/components/composed/chat/chart.tsx +2 -400
  54. package/src/components/composed/chat/index.ts +1 -7
  55. package/src/components/stories/chart.story.tsx +16 -1
@@ -0,0 +1,233 @@
1
+ import { withAlpha, type ResolvedColors } from "./echarts-chart";
2
+ import type { DataZoomComponentOption } from "echarts/components";
3
+ import type { FC } from "react";
4
+ import * as echarts from "echarts/core";
5
+
6
+ type EChartsInstance = ReturnType<typeof echarts.init>;
7
+
8
+ const BRUSH_BORDER_OPACITY = 1; // brush frame, × border alpha (evil-brush uses the full token)
9
+
10
+ export { BRUSH_BORDER_OPACITY };
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Brush marker — the declarative `<Chart.Brush/>` child. Rendering nothing, its
14
+ // PRESENCE turns the brush on (replacing the old showBrush prop) and its props
15
+ // carry the brush's height, handle-label formatter, and range callback. Shared
16
+ // so every cartesian chart attaches the SAME component to its root.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ export interface BrushProps {
20
+ height?: number; // brush preview strip height in px (default 56)
21
+ formatLabel?: (value: string, index: number) => string; // formats the range-handle labels
22
+ onChange?: (range: { startIndex: number; endIndex: number }) => void; // fires as the range moves
23
+ }
24
+
25
+ /** Declares the zoom brush below the chart. Presence renders it; renders nothing itself. */
26
+ export const Brush: FC<BrushProps> = () => null;
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Brush overlays — the evil-brush look: a rounded border around the SELECTED
30
+ // range, dimmed unselected sides, centered grip-dot handle pills, and range
31
+ // label pills below the frame. None of that is a dataZoom capability. They are
32
+ // raw zrender elements updated imperatively — routing them through setOption
33
+ // re-renders the dataZoom component mid-drag, resetting its drag anchor (the
34
+ // handle progressively lags the pointer).
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ export type BrushRange = { start: number; end: number };
38
+ export type BrushGeometry = { bottom: number; height: number };
39
+
40
+ export type BrushOverlayParams = {
41
+ range: BrushRange;
42
+ geom: BrushGeometry;
43
+ size: { width: number; height: number };
44
+ tokens: ResolvedColors["tokens"];
45
+ labels: { start: string; end: string } | null;
46
+ showLabels: boolean;
47
+ hover: { left: boolean; right: boolean };
48
+ };
49
+
50
+ type ZrRect = InstanceType<typeof echarts.graphic.Rect>;
51
+ type ZrCircle = InstanceType<typeof echarts.graphic.Circle>;
52
+ type ZrText = InstanceType<typeof echarts.graphic.Text>;
53
+
54
+ export type BrushOverlayElements = {
55
+ dimLeft: ZrRect;
56
+ dimRight: ZrRect;
57
+ frame: ZrRect;
58
+ pillLeft: ZrRect;
59
+ pillRight: ZrRect;
60
+ grips: ZrCircle[]; // 3 left + 3 right
61
+ labelStart: ZrText;
62
+ labelEnd: ZrText;
63
+ };
64
+
65
+ export function syncBrushOverlay(
66
+ chart: EChartsInstance,
67
+ store: { brushOverlay: BrushOverlayElements | null },
68
+ params: BrushOverlayParams | null,
69
+ ) {
70
+ const zr = chart.getZr();
71
+ if (!zr) return;
72
+
73
+ if (!params) {
74
+ if (store.brushOverlay) {
75
+ const { grips, ...rest } = store.brushOverlay;
76
+ [...Object.values(rest), ...grips].forEach((el) => zr.remove(el));
77
+ store.brushOverlay = null;
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (!store.brushOverlay) {
83
+ const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} });
84
+ const els: BrushOverlayElements = {
85
+ dimLeft: rect(100),
86
+ dimRight: rect(100),
87
+ frame: rect(101),
88
+ pillLeft: rect(102),
89
+ pillRight: rect(102),
90
+ grips: Array.from(
91
+ { length: 6 },
92
+ () => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} }),
93
+ ),
94
+ labelStart: new echarts.graphic.Text({ silent: true, z: 104 }),
95
+ labelEnd: new echarts.graphic.Text({ silent: true, z: 104 }),
96
+ };
97
+ const { grips, ...rest } = els;
98
+ [...Object.values(rest), ...grips].forEach((el) => zr.add(el));
99
+ store.brushOverlay = els;
100
+ }
101
+
102
+ const els = store.brushOverlay;
103
+ const { range, geom, size, tokens, labels, showLabels, hover } = params;
104
+
105
+ const trackLeft = 8;
106
+ const trackRight = Math.max(size.width - 8, trackLeft);
107
+ const trackWidth = trackRight - trackLeft;
108
+ const top = size.height - geom.bottom - geom.height;
109
+ const centerY = top + geom.height / 2;
110
+ const selectionLeft = trackLeft + (trackWidth * range.start) / 100;
111
+ const selectionRight = trackLeft + (trackWidth * range.end) / 100;
112
+
113
+ const dimFill = withAlpha(tokens.background, 0.7);
114
+ els.dimLeft.setShape({
115
+ x: trackLeft,
116
+ y: top,
117
+ width: Math.max(selectionLeft - trackLeft, 0),
118
+ height: geom.height,
119
+ });
120
+ els.dimLeft.setStyle({ fill: dimFill });
121
+ els.dimRight.setShape({
122
+ x: selectionRight,
123
+ y: top,
124
+ width: Math.max(trackRight - selectionRight, 0),
125
+ height: geom.height,
126
+ });
127
+ els.dimRight.setStyle({ fill: dimFill });
128
+
129
+ els.frame.setShape({
130
+ x: selectionLeft,
131
+ y: top,
132
+ width: Math.max(selectionRight - selectionLeft, 0),
133
+ height: geom.height,
134
+ r: 6,
135
+ });
136
+ els.frame.setStyle({
137
+ fill: "none",
138
+ stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY),
139
+ lineWidth: 1,
140
+ });
141
+
142
+ // Handle pills: evil-brush's 6×16 grip pill, centered on the selection edge,
143
+ // brightening to foreground on hover/drag.
144
+ const pill = (el: ZrRect, x: number, hovered: boolean) => {
145
+ el.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 });
146
+ el.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground });
147
+ };
148
+ pill(els.pillLeft, selectionLeft, hover.left);
149
+ pill(els.pillRight, selectionRight, hover.right);
150
+
151
+ const gripFill = withAlpha(tokens.background, 0.7);
152
+ [-4, 0, 4].forEach((offset, i) => {
153
+ els.grips[i].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 });
154
+ els.grips[i].setStyle({ fill: gripFill });
155
+ els.grips[i + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 });
156
+ els.grips[i + 3].setStyle({ fill: gripFill });
157
+ });
158
+
159
+ // Range label pills straddle the frame's bottom line — an overlay, so they
160
+ // occupy no layout space; half the pill sits above the line, half below. Each
161
+ // pill grows INWARD from its handle with a small inset, like the Recharts
162
+ // labels, instead of hanging past the frame edge.
163
+ const label = (el: ZrText, text: string, x: number, align: "left" | "right") => {
164
+ el.setStyle({
165
+ text,
166
+ x: align === "left" ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2),
167
+ y: top + geom.height,
168
+ align,
169
+ verticalAlign: "middle",
170
+ fill: tokens.background,
171
+ backgroundColor: tokens.foreground,
172
+ padding: [2, 5],
173
+ borderRadius: 4,
174
+ font: "500 9px system-ui, sans-serif",
175
+ });
176
+ el.attr("invisible", !showLabels || !text);
177
+ };
178
+ label(els.labelStart, labels?.start ?? "", selectionLeft, "left");
179
+ label(els.labelEnd, labels?.end ?? "", selectionRight, "right");
180
+ }
181
+
182
+ // ─────────────────────────────────────────────────────────────────────────────
183
+ // dataZoom slider — the transparent drag layer laid over the mini chart. Fully
184
+ // chart-agnostic: the visible frame/handles/labels are the graphic overlays
185
+ // above; this provides interaction only. Both zoom entries target only the MAIN
186
+ // x-axis (index 0), so the mini chart never filters itself. The per-chart
187
+ // mini-series (which differ per chart type) are built by the chart, not here.
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+
190
+ export function buildBrushDataZoom(params: {
191
+ brushBottom: number;
192
+ brushHeight: number;
193
+ brushRange: BrushRange;
194
+ fillerColor: string;
195
+ }): DataZoomComponentOption[] {
196
+ const { brushBottom, brushHeight, brushRange, fillerColor } = params;
197
+
198
+ return [
199
+ {
200
+ type: "slider",
201
+ show: true,
202
+ xAxisIndex: [0],
203
+ left: 8,
204
+ right: 8,
205
+ bottom: brushBottom,
206
+ height: brushHeight,
207
+ // Carry the live range through every rebuild — a notMerge push
208
+ // without start/end would reset the zoom to the full extent.
209
+ start: brushRange.start,
210
+ end: brushRange.end,
211
+ brushSelect: false,
212
+ // Range labels are overlay pills below the frame (see
213
+ // syncBrushOverlay) — the native detail text renders INSIDE the
214
+ // track, which is not the evil-brush look.
215
+ showDetail: false,
216
+ backgroundColor: "transparent",
217
+ // The visible frame is the graphic overlay riding the selection —
218
+ // the component's own static border stays hidden.
219
+ borderColor: "transparent",
220
+ fillerColor,
221
+ dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } },
222
+ selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } },
223
+ // Interaction only — the visible pills are graphic overlays (see
224
+ // syncBrushOverlay). Kept generous for an easy grab target.
225
+ handleIcon: "path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z",
226
+ handleSize: "35%",
227
+ handleStyle: { opacity: 0 },
228
+ moveHandleSize: 0,
229
+ emphasis: { handleStyle: { opacity: 0 } },
230
+ },
231
+ { type: "inside", xAxisIndex: [0] },
232
+ ];
233
+ }
@@ -0,0 +1,214 @@
1
+ import { CanvasRenderer, SVGRenderer } from "echarts/renderers";
2
+ import type { ComponentType, ReactNode } from "react";
3
+ import * as echarts from "echarts/core";
4
+
5
+ export const ECHARTS_RENDERERS = {
6
+ canvas: "canvas",
7
+ svg: "svg",
8
+ } as const;
9
+
10
+ export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS];
11
+
12
+ export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas;
13
+
14
+ // Renderer registration is shared by every modular ECharts chart. Individual
15
+ // chart modules only register the series and components they use.
16
+ echarts.use([CanvasRenderer, SVGRenderer]);
17
+
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ // Theme keys + config — replicated from the repo's <ChartStyle> so the ECharts
20
+ // charts stay self-contained (no recharts ui imports). Shared by every ECharts
21
+ // chart and the tooltip/legend/brush ui modules.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ // Theme selectors mirror the repo's <ChartStyle>: light is the bare root, dark is `.dark`.
25
+ export const THEMES = { light: "", dark: ".dark" } as const;
26
+ export type ThemeKey = keyof typeof THEMES;
27
+ export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[];
28
+
29
+ // Require at least one theme key — identical constraint to the repo's ChartConfig.
30
+ export type AtLeastOneThemeColor =
31
+ | { light: string[]; dark?: string[] }
32
+ | { light?: string[]; dark: string[] };
33
+
34
+ export type ChartConfig = Record<
35
+ string,
36
+ {
37
+ label?: ReactNode;
38
+ icon?: ComponentType;
39
+ colors?: AtLeastOneThemeColor;
40
+ }
41
+ >;
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // Color plumbing — replicated from the repo's <ChartStyle> so the charts stay
45
+ // self-contained (no ./recharts imports).
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ // Max slots a key needs = longest color array across themes (min 1). Both themes
49
+ // always emit the same number of `--color-{key}-{n}` vars.
50
+ export function getColorsCount(item: ChartConfig[string]): number {
51
+ if (!item.colors) return 1;
52
+ const counts = THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0);
53
+ return Math.max(...counts, 1);
54
+ }
55
+
56
+ // Distribute colors evenly across slots; extra slots go to the LAST color(s).
57
+ // 2 colors / 4 slots → [c0, c0, c1, c1]; 3 colors / 4 slots → [c0, c1, c2, c2].
58
+ export function distributeColors(colors: string[], maxCount: number): string[] {
59
+ const available = colors.length;
60
+ if (available >= maxCount) return colors.slice(0, maxCount);
61
+
62
+ const result: string[] = [];
63
+ const baseSlots = Math.floor(maxCount / available);
64
+ const extraSlots = maxCount % available;
65
+
66
+ for (let i = 0; i < available; i++) {
67
+ const isExtra = i >= available - extraSlots;
68
+ const slots = baseSlots + (isExtra ? 1 : 0);
69
+ for (let j = 0; j < slots; j++) result.push(colors[i]);
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ // Emits the same CSS <ChartStyle> would: `--color-{key}-{n}` scoped to
76
+ // `[data-chart={id}]` (light) and `.dark [data-chart={id}]` (dark).
77
+ export function buildChartCss(id: string, config: ChartConfig): string {
78
+ const colorConfig = Object.entries(config).filter(([, item]) => item.colors);
79
+ if (!colorConfig.length) return "";
80
+
81
+ const varsFor = (theme: ThemeKey) =>
82
+ colorConfig
83
+ .flatMap(([key, item]) => {
84
+ const authored = item.colors?.[theme];
85
+ if (!authored || authored.length === 0) return [];
86
+ return distributeColors(authored, getColorsCount(item)).map(
87
+ (color, index) => ` --color-${key.replace(/[^a-zA-Z0-9_-]/g, character => "\\" + character.codePointAt(0)!.toString(16) + " ")}-${index}: ${color};`,
88
+ );
89
+ })
90
+ .join("\n");
91
+
92
+ return Object.entries(THEMES)
93
+ .map(([theme, prefix]) => `${prefix} [data-chart=${id}] {\n${varsFor(theme as ThemeKey)}\n}`)
94
+ .join("\n");
95
+ }
96
+
97
+ // A single reusable 1×1 canvas normalizes ANY CSS color (hex, named, oklch, …)
98
+ // to a concrete rgba string by painting it and reading the pixel back.
99
+ let normalizerCtx: CanvasRenderingContext2D | null = null;
100
+ export function normalizeColor(value: string): string {
101
+ const raw = value.trim();
102
+ if (!raw || typeof document === "undefined") return raw;
103
+
104
+ if (!normalizerCtx) {
105
+ const canvas = document.createElement("canvas");
106
+ canvas.width = 1;
107
+ canvas.height = 1;
108
+ normalizerCtx = canvas.getContext("2d", { willReadFrequently: true });
109
+ }
110
+ if (!normalizerCtx) return raw;
111
+
112
+ normalizerCtx.clearRect(0, 0, 1, 1);
113
+ normalizerCtx.fillStyle = "#000";
114
+ normalizerCtx.fillStyle = raw; // invalid values leave the sentinel in place
115
+ normalizerCtx.fillRect(0, 0, 1, 1);
116
+ const [r, g, b, a] = normalizerCtx.getImageData(0, 0, 1, 1).data;
117
+ return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
118
+ }
119
+
120
+ // Scales the alpha of a normalized `rgba(r, g, b, a)` string. Multiplying (not
121
+ // replacing) keeps translucent theme tokens honest: a border that is 10%-white
122
+ // at `withAlpha(border, 0.5)` lands at 5%, matching Tailwind's `border/50`.
123
+ export function withAlpha(color: string, alpha: number): string {
124
+ const match = color.match(/rgba?\(([^)]+)\)/);
125
+ if (!match) return color;
126
+ const [r, g, b, a] = match[1].split(",").map((p) => p.trim());
127
+ const base = a === undefined ? 1 : Number.parseFloat(a) || 0;
128
+ return `rgba(${r}, ${g}, ${b}, ${(base * alpha).toFixed(3)})`;
129
+ }
130
+
131
+ export type ResolvedColors = {
132
+ series: Record<string, string[]>; // normalized `--color-{key}-{n}` slots per key
133
+ tokens: {
134
+ mutedForeground: string;
135
+ border: string;
136
+ foreground: string;
137
+ background: string;
138
+ };
139
+ };
140
+
141
+ // Reads the injected CSS vars + theme tokens from the live DOM. Series slots come
142
+ // from `getComputedStyle` on the container; tokens are read off a throwaway probe
143
+ // carrying the matching Tailwind class (robust to the var naming a theme uses).
144
+ export function resolveColors(
145
+ container: HTMLElement,
146
+ config: ChartConfig,
147
+ seriesKeys: string[],
148
+ ): ResolvedColors {
149
+ const computed = getComputedStyle(container);
150
+ const series: Record<string, string[]> = {};
151
+
152
+ for (const key of seriesKeys) {
153
+ const count = getColorsCount(config[key] ?? {});
154
+ const slots: string[] = [];
155
+ for (let n = 0; n < count; n++) {
156
+ const raw = computed.getPropertyValue(`--color-${key}-${n}`).trim();
157
+ slots.push(raw ? normalizeColor(raw) : "rgba(120, 120, 120, 1)");
158
+ }
159
+ series[key] = slots;
160
+ }
161
+
162
+ const probe = document.createElement("span");
163
+ probe.style.cssText = "position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;";
164
+ container.appendChild(probe);
165
+ const readToken = (className: string) => {
166
+ probe.className = className;
167
+ return normalizeColor(getComputedStyle(probe).color);
168
+ };
169
+ const tokens = {
170
+ mutedForeground: readToken("text-muted-foreground"),
171
+ border: readToken("text-border"),
172
+ foreground: readToken("text-foreground"),
173
+ background: readToken("text-background"),
174
+ };
175
+ container.removeChild(probe);
176
+
177
+ return { series, tokens };
178
+ }
179
+
180
+ // Horizontal multi-stop color for a series — a solid string when there is only
181
+ // one color, else an evenly-distributed left→right LinearGradient. Reused for the
182
+ // stroke, symbol fills, and as the base tint for the area fill.
183
+ export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient {
184
+ if (slots.length <= 1) return slots[0] ?? "rgba(120, 120, 120, 1)";
185
+ const stops = slots.map((color, i) => ({ offset: i / (slots.length - 1), color }));
186
+ return new echarts.graphic.LinearGradient(0, 0, 1, 0, stops);
187
+ }
188
+
189
+ // Solid var / gradient of vars for a series indicator — mirrors getIndicatorColorStyle.
190
+ // Used by BOTH the tooltip rows and the legend indicators.
191
+ export function indicatorBackground(key: string, colorsCount: number): string {
192
+ if (colorsCount <= 1) return `var(--color-${key}-0)`;
193
+ const stops = Array.from({ length: colorsCount }, (_, i) => {
194
+ const offset = (i / (colorsCount - 1)) * 100;
195
+ return `var(--color-${key}-${i}) ${offset}%`;
196
+ }).join(", ");
197
+ return `linear-gradient(to right, ${stops})`;
198
+ }
199
+
200
+ // Composites a translucent color over an opaque base into a FLAT color. The
201
+ // tick dots need this: a translucent stroke double-paints where its round caps
202
+ // overlap the line body, which reads as two stacked colors.
203
+ export function flattenColor(color: string, base: string): string {
204
+ const parse = (value: string) =>
205
+ value
206
+ .match(/rgba?\(([^)]+)\)/)?.[1]
207
+ .split(",")
208
+ .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1];
209
+ const [r, g, b, a = 1] = parse(color);
210
+ const [baseR, baseG, baseB] = parse(base);
211
+ const mix = (channel: number, baseChannel: number) =>
212
+ Math.round(channel * a + baseChannel * (1 - a));
213
+ return `rgb(${mix(r, baseR)}, ${mix(g, baseG)}, ${mix(b, baseB)})`;
214
+ }
@@ -0,0 +1,111 @@
1
+ import type * as echarts from "echarts/core";
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Dots — map the resting/active variants onto ECharts symbols. Shared by every
5
+ // ECharts chart that draws point markers.
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ export type DotVariant = "none" | "default" | "border" | "colored-border" | "ping";
9
+
10
+ // Dot marker paint — structurally assignable to BOTH the series-level and the
11
+ // per-datum itemStyle (the per-datum variant forbids callback color paints, so
12
+ // the broader LineSeriesOption["itemStyle"] cannot be reused for it).
13
+ export type DotItemStyleOption = {
14
+ color?: string | echarts.graphic.LinearGradient;
15
+ borderColor?: string | echarts.graphic.LinearGradient;
16
+ borderWidth?: number;
17
+ opacity?: number;
18
+ };
19
+
20
+ export type DotStyle = { size: number; itemStyle: DotItemStyleOption };
21
+
22
+ // Re-color a solid paint at a given alpha (hex #rgb/#rrggbb or rgb/rgba in, rgba
23
+ // out; named colors pass through). Used for the translucent "ping" halo ring.
24
+ function withAlpha(color: string, alpha: number): string {
25
+ if (color.startsWith("#")) {
26
+ let hex = color.slice(1);
27
+ if (hex.length === 3)
28
+ hex = hex
29
+ .split("")
30
+ .map((c) => c + c)
31
+ .join("");
32
+ const n = parseInt(hex, 16);
33
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
34
+ }
35
+ const m = color.match(/rgba?\(([^)]+)\)/);
36
+ if (m) {
37
+ const [r, g, b] = m[1].split(",").map((s) => parseFloat(s));
38
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
39
+ }
40
+ return color;
41
+ }
42
+
43
+ export function dotItemStyle(
44
+ variant: DotVariant,
45
+ paint: string | echarts.graphic.LinearGradient,
46
+ background: string,
47
+ ): DotItemStyleOption {
48
+ switch (variant) {
49
+ case "border":
50
+ // Series-colored core with a thick background halo (Recharts r6 / sw5).
51
+ return { color: paint, borderColor: background, borderWidth: 2 };
52
+ case "colored-border":
53
+ // Background-filled core with a thin colored ring (Recharts r3 / sw1).
54
+ return { color: background, borderColor: paint, borderWidth: 1 };
55
+ case "ping": {
56
+ // Solid core wrapped in a wide, translucent same-color ring — a static
57
+ // "ping". The ring is the symbol's BORDER (a stroke ~1.25× the core
58
+ // radius), which fills out to a soft halo disc; a genuinely large symbol
59
+ // would silently fail to render on a category-axis line, so we stroke a
60
+ // small one instead.
61
+ const halo = typeof paint === "string" ? withAlpha(paint, 0.28) : paint;
62
+ return { color: paint, borderColor: halo, borderWidth: 10 };
63
+ }
64
+ case "default":
65
+ return { color: paint, borderWidth: 0 };
66
+ default:
67
+ return {};
68
+ }
69
+ }
70
+
71
+ // Sizes mirror the Recharts markers: default r3, border r6 (mostly halo), and
72
+ // colored-border r3+ring. Flattening these to one size makes the hover ring read
73
+ // LARGER than a haloed resting dot — the opposite of the Recharts twin.
74
+ export const DOT_SIZES: Record<DotVariant, number> = {
75
+ none: 0,
76
+ default: 6,
77
+ border: 8,
78
+ "colored-border": 6,
79
+ ping: 8,
80
+ };
81
+
82
+ export function dotStyle(
83
+ variant: DotVariant,
84
+ paint: string | echarts.graphic.LinearGradient,
85
+ background: string,
86
+ ): DotStyle {
87
+ return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) };
88
+ }
89
+
90
+ // The color the horizontal series gradient shows at position t ∈ [0, 1]. ECharts
91
+ // paints a gradient itemStyle relative to each symbol's own bounding box — a full
92
+ // rainbow inside every dot — while the Recharts dots clip a chart-wide gradient,
93
+ // so each takes the gradient's color at its x-position. Sampling reproduces that.
94
+ export function sampleGradient(slots: string[], t: number): string {
95
+ if (slots.length <= 1) return slots[0] ?? "rgba(120, 120, 120, 1)";
96
+
97
+ const parse = (color: string) =>
98
+ color
99
+ .match(/rgba?\(([^)]+)\)/)?.[1]
100
+ .split(",")
101
+ .map(Number) ?? [120, 120, 120, 1];
102
+
103
+ const position = t * (slots.length - 1);
104
+ const index = Math.min(Math.floor(position), slots.length - 2);
105
+ const fraction = position - index;
106
+ const [r1, g1, b1, a1 = 1] = parse(slots[index]);
107
+ const [r2, g2, b2, a2 = 1] = parse(slots[index + 1]);
108
+ const lerp = (from: number, to: number) => from + (to - from) * fraction;
109
+
110
+ return `rgba(${Math.round(lerp(r1, r2))}, ${Math.round(lerp(g1, g2))}, ${Math.round(lerp(b1, b2))}, ${lerp(a1, a2).toFixed(3)})`;
111
+ }