@@ -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+ }
0 commit comments