Skip to content

Commit a5e93ee

Browse files
committed
feat(appkit-ui): add onDataClick + selected props to charts (phases 1-2)
Adds two public chart props, inherited by every chart type via the factory: - onDataClick?(datum: ChartClickDatum): fire-and-forget click callback. base.tsx builds a memoized internal echarts onEvents={{ click }} only when the handler is set (no idle listener), mapping raw params via the pure mapToDatum. Pointer-only (canvas) — documented to require a keyboard-accessible equivalent. - selected?: string | string[]: controlled, name-based visual emphasis. base.tsx runs the pure applySelectionEmphasis transform over the built option so matching bar/pie-donut categories stay prominent and the rest dim; no-op when unset. ChartClickDatum is the only new public (barrel) symbol; mapToDatum, applySelectionEmphasis and SelectionEmphasisOptions are internal. echarts types stay out of the public API (datum.raw is unknown). Phases 1 and 2 are committed together so the producer helpers have their consumer (satisfies knip). Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 970ada0 commit a5e93ee

5 files changed

Lines changed: 323 additions & 2 deletions

File tree

packages/appkit-ui/src/react/charts/base.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import ReactEChartsCore from "echarts-for-react/esm/core";
2929
import { useCallback, useMemo, useRef } from "react";
3030
import { normalizeChartData, normalizeHeatmapData } from "./normalize";
3131
import {
32+
applySelectionEmphasis,
3233
buildCartesianOption,
3334
buildHeatmapOption,
3435
buildHorizontalBarOption,
@@ -38,11 +39,13 @@ import {
3839
} from "./options";
3940
import { useChartUITokens, useThemeColors } from "./theme";
4041
import type {
42+
ChartClickDatum,
4143
ChartColorPalette,
4244
ChartData,
4345
ChartType,
4446
Orientation,
4547
} from "./types";
48+
import { mapToDatum } from "./utils";
4649

4750
// ============================================================================
4851
// ECharts Registration (modular imports for tree-shaking)
@@ -168,6 +171,23 @@ export interface BaseChartProps {
168171
options?: Record<string, unknown>;
169172
/** Additional CSS classes */
170173
className?: string;
174+
/**
175+
* Fired when a data element (bar, slice, point) is clicked. Fire-and-forget:
176+
* the return value is ignored (async handlers are fine — the chart never awaits).
177+
* The handler receives a normalized {@link ChartClickDatum}.
178+
*
179+
* Pointer-only: charts render to <canvas>, so this does not fire for keyboard
180+
* users. Provide a keyboard-accessible equivalent (e.g. a table row action) for
181+
* the same action.
182+
*/
183+
onDataClick?: (datum: ChartClickDatum) => void;
184+
/**
185+
* Controlled selection by category name. Matching data element(s) render at full
186+
* prominence while the rest are dimmed. Drive it from your own state to reflect a
187+
* cross-filter or selection. Categorical charts (bar, pie/donut) show emphasis;
188+
* other chart types ignore it.
189+
*/
190+
selected?: string | string[];
171191
}
172192

173193
// ============================================================================
@@ -202,6 +222,8 @@ export function BaseChart({
202222
max,
203223
options: customOptions,
204224
className,
225+
onDataClick,
226+
selected,
205227
}: BaseChartProps) {
206228
// Determine the appropriate color palette based on chart type
207229
const resolvedPalette = colorPalette ?? getDefaultPalette(chartType);
@@ -329,8 +351,10 @@ export function BaseChart({
329351
});
330352
}
331353

332-
// Merge custom options
333-
return customOptions ? { ...opt, ...customOptions } : opt;
354+
// Merge custom options, then apply declarative selection emphasis. When
355+
// `selected` is undefined/empty, applySelectionEmphasis is a no-op.
356+
const merged = customOptions ? { ...opt, ...customOptions } : opt;
357+
return applySelectionEmphasis(merged, selected);
334358
}, [
335359
normalized,
336360
colors,
@@ -350,8 +374,23 @@ export function BaseChart({
350374
min,
351375
max,
352376
customOptions,
377+
selected,
353378
]);
354379

380+
// Build the ECharts event map only when a click handler is provided. Memoized
381+
// on `onDataClick` so the object identity is stable across renders —
382+
// echarts-for-react re-subscribes whenever `onEvents` identity changes, so an
383+
// unstable object would thrash listeners. When no handler is set, this is
384+
// `undefined` and no idle click listener is attached. `onEvents` is an internal
385+
// implementation detail and is intentionally not a public prop.
386+
const onEvents = useMemo(
387+
() =>
388+
onDataClick
389+
? { click: (params: unknown) => onDataClick(mapToDatum(params)) }
390+
: undefined,
391+
[onDataClick],
392+
);
393+
355394
if (!option) {
356395
return (
357396
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -370,6 +409,7 @@ export function BaseChart({
370409
opts={{ renderer: "canvas" }}
371410
notMerge={false}
372411
lazyUpdate={true}
412+
onEvents={onEvents}
373413
/>
374414
);
375415
}

packages/appkit-ui/src/react/charts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export type {
108108
BarChartSpecificProps,
109109
// Base props
110110
ChartBaseProps,
111+
ChartClickDatum,
111112
ChartColorPalette,
112113
ChartData,
113114
ChartType,

packages/appkit-ui/src/react/charts/options.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,181 @@ export function buildCartesianOption(
391391
})),
392392
};
393393
}
394+
395+
// ============================================================================
396+
// Selection Emphasis (declarative cross-filter highlighting)
397+
// ============================================================================
398+
399+
/**
400+
* Opacity applied to data elements that are NOT part of the current selection.
401+
* Kept local to the option builder since it only describes selection styling and
402+
* is not a themeable UI token.
403+
*/
404+
const DIMMED_OPACITY = 0.3;
405+
406+
/** Opacity applied to selected (emphasized) data elements. */
407+
const SELECTED_OPACITY = 1;
408+
409+
/** Options controlling {@link applySelectionEmphasis}. */
410+
interface SelectionEmphasisOptions {
411+
/** Opacity for dimmed (non-selected) elements. @default 0.3 */
412+
dimmedOpacity?: number;
413+
/** Opacity for emphasized (selected) elements. @default 1 */
414+
selectedOpacity?: number;
415+
}
416+
417+
/**
418+
* Normalizes the `selected` input into a lookup set of category names.
419+
* Returns `null` when there is nothing selected (undefined, or an empty
420+
* string/array), which callers treat as "no emphasis".
421+
*/
422+
function toSelectionSet(
423+
selected: string | string[] | undefined,
424+
): Set<string> | null {
425+
if (selected == null) return null;
426+
const names = Array.isArray(selected) ? selected : [selected];
427+
const set = new Set(names.map((name) => String(name)));
428+
return set.size > 0 ? set : null;
429+
}
430+
431+
/**
432+
* Finds the category-axis label array (`xAxis`/`yAxis` with `type: "category"`),
433+
* used to map a bar datum's position to its category name. Returns `null` when
434+
* no category axis is present (e.g. time-series or value axes).
435+
*/
436+
function categoryNamesFromAxes(
437+
option: Record<string, unknown>,
438+
): (string | number)[] | null {
439+
for (const axisKey of ["xAxis", "yAxis"] as const) {
440+
const axis = option[axisKey];
441+
if (axis !== null && typeof axis === "object" && !Array.isArray(axis)) {
442+
const a = axis as Record<string, unknown>;
443+
if (a.type === "category" && Array.isArray(a.data)) {
444+
return a.data as (string | number)[];
445+
}
446+
}
447+
}
448+
return null;
449+
}
450+
451+
/**
452+
* Returns a copy of a single data item with its `itemStyle.opacity` set.
453+
* Object data items (e.g. pie `{ name, value }`) are spread and their existing
454+
* `itemStyle` preserved; primitive data items (e.g. raw bar values) are wrapped
455+
* into `{ value, itemStyle }` — the equivalent ECharts data-item form. The
456+
* per-datum `itemStyle` merges over the series-level `itemStyle` in ECharts, so
457+
* styling such as bar `borderRadius` is retained.
458+
*/
459+
function withDatumOpacity(datum: unknown, opacity: number): unknown {
460+
if (datum !== null && typeof datum === "object" && !Array.isArray(datum)) {
461+
const d = datum as Record<string, unknown>;
462+
const prev =
463+
d.itemStyle !== null &&
464+
typeof d.itemStyle === "object" &&
465+
!Array.isArray(d.itemStyle)
466+
? (d.itemStyle as Record<string, unknown>)
467+
: {};
468+
return { ...d, itemStyle: { ...prev, opacity } };
469+
}
470+
return { value: datum as number | string, itemStyle: { opacity } };
471+
}
472+
473+
/**
474+
* Applies per-datum opacity to a single series based on the selection set.
475+
* Only categorical series carry a resolvable category name:
476+
* - `pie` — the name is read from each datum's `name` field.
477+
* - `bar` — the name is read from the category axis at the datum's index.
478+
* All other series types (line, area, scatter, radar, heatmap) are returned
479+
* unchanged, as is any series lacking a resolvable category name.
480+
*/
481+
function emphasizeSeries(
482+
series: unknown,
483+
selected: Set<string>,
484+
dimmedOpacity: number,
485+
selectedOpacity: number,
486+
categoryNames: (string | number)[] | null,
487+
): unknown {
488+
if (series === null || typeof series !== "object" || Array.isArray(series)) {
489+
return series;
490+
}
491+
const s = series as Record<string, unknown>;
492+
if (!Array.isArray(s.data)) return series;
493+
494+
let nameAt: (datum: unknown, index: number) => string | undefined;
495+
if (s.type === "pie") {
496+
nameAt = (datum) =>
497+
datum !== null && typeof datum === "object" && "name" in datum
498+
? String((datum as Record<string, unknown>).name)
499+
: undefined;
500+
} else if (s.type === "bar") {
501+
// Bar data items are raw values; the category name lives on the category axis.
502+
if (!categoryNames) return series;
503+
nameAt = (_datum, index) =>
504+
categoryNames[index] !== undefined
505+
? String(categoryNames[index])
506+
: undefined;
507+
} else {
508+
return series;
509+
}
510+
511+
const data = (s.data as unknown[]).map((datum, index) => {
512+
const name = nameAt(datum, index);
513+
if (name === undefined) return datum;
514+
const opacity = selected.has(name) ? selectedOpacity : dimmedOpacity;
515+
return withDatumOpacity(datum, opacity);
516+
});
517+
518+
return { ...s, data };
519+
}
520+
521+
/**
522+
* Pure, declarative selection-emphasis transform for a built ECharts `option`.
523+
*
524+
* Given one or more selected category names, returns a new `option` in which the
525+
* matching data element(s) render at full prominence while the rest are dimmed
526+
* via `itemStyle.opacity`. It is a **no-op** (returns the input unchanged) when
527+
* `selected` is `undefined` or empty.
528+
*
529+
* This function never touches an ECharts instance or calls `dispatchAction` — it
530+
* only shapes the option object, so it can be composed into the option-building
531+
* pipeline. It meaningfully affects the categorical chart types (`bar`, `pie`,
532+
* `donut`) where a data point maps to a category name; other chart types are
533+
* left untouched.
534+
*
535+
* @typeParam T - The option object type (typically `Record<string, unknown>`).
536+
* @param option - The ECharts option produced by one of the `build*Option` helpers.
537+
* @param selected - The selected category name(s); `undefined`/empty means no emphasis.
538+
* @param opts - Optional opacity overrides. See {@link SelectionEmphasisOptions}.
539+
* @returns A new option with emphasis applied, or the original `option` when there is no selection.
540+
*/
541+
export function applySelectionEmphasis<T>(
542+
option: T,
543+
selected: string | string[] | undefined,
544+
opts: SelectionEmphasisOptions = {},
545+
): T {
546+
const selectedSet = toSelectionSet(selected);
547+
// No selection → identity: no emphasis, no dimming.
548+
if (!selectedSet) return option;
549+
550+
if (option === null || typeof option !== "object" || Array.isArray(option)) {
551+
return option;
552+
}
553+
const opt = option as Record<string, unknown>;
554+
if (!Array.isArray(opt.series)) return option;
555+
556+
const dimmedOpacity = opts.dimmedOpacity ?? DIMMED_OPACITY;
557+
const selectedOpacity = opts.selectedOpacity ?? SELECTED_OPACITY;
558+
const categoryNames = categoryNamesFromAxes(opt);
559+
560+
const series = (opt.series as unknown[]).map((s) =>
561+
emphasizeSeries(
562+
s,
563+
selectedSet,
564+
dimmedOpacity,
565+
selectedOpacity,
566+
categoryNames,
567+
),
568+
);
569+
570+
return { ...opt, series } as T;
571+
}

packages/appkit-ui/src/react/charts/types.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,56 @@ export interface ChartBaseProps {
8989

9090
/** Additional ECharts options to merge */
9191
options?: Record<string, unknown>;
92+
93+
/**
94+
* Fired when a data element (bar, slice, point) is clicked. Fire-and-forget:
95+
* the return value is ignored (async handlers are fine — the chart never awaits).
96+
* The handler receives a normalized {@link ChartClickDatum}.
97+
*
98+
* Pointer-only: charts render to <canvas>, so this does not fire for keyboard
99+
* users. Provide a keyboard-accessible equivalent (e.g. a table row action) for
100+
* the same action.
101+
*/
102+
onDataClick?: (datum: ChartClickDatum) => void;
103+
104+
/**
105+
* Controlled selection by category name. Matching data element(s) render at full
106+
* prominence while the rest are dimmed. Drive it from your own state to reflect a
107+
* cross-filter or selection. Categorical charts (bar, pie/donut) show emphasis;
108+
* other chart types ignore it.
109+
*/
110+
selected?: string | string[];
111+
}
112+
113+
// ============================================================================
114+
// Interaction / Click Events
115+
// ============================================================================
116+
117+
/**
118+
* A normalized description of a clicked chart element.
119+
*
120+
* This is the public, ECharts-free shape emitted by chart click handlers. It is
121+
* the single boundary that keeps ECharts event types out of appkit-ui's public
122+
* API — consumers should read the strongly-typed fields below and reach for
123+
* {@link ChartClickDatum.raw} only when they knowingly opt into unsupported
124+
* internals.
125+
*
126+
* In the common cross-filter case, {@link ChartClickDatum.name} carries the
127+
* dimension value of the clicked element.
128+
*/
129+
export interface ChartClickDatum {
130+
/** Category label of the clicked element — the dimension value in the common cross-filter case. */
131+
name: string;
132+
/** The datum's value. */
133+
value: number | string | null;
134+
/** Series label, when present. */
135+
seriesName?: string;
136+
/** Index of the datum within its series. */
137+
dataIndex: number;
138+
/** Which series was clicked. */
139+
seriesIndex: number;
140+
/** Untouched ECharts event params. Typed `unknown` — cast at your own risk; not part of the supported surface. */
141+
raw: unknown;
92142
}
93143

94144
// ============================================================================

0 commit comments

Comments
 (0)