diff --git a/chartlets.js/src/lib/components/Plot.tsx b/chartlets.js/src/lib/components/Plot.tsx index 62d939b7..ef21832c 100644 --- a/chartlets.js/src/lib/components/Plot.tsx +++ b/chartlets.js/src/lib/components/Plot.tsx @@ -2,32 +2,24 @@ import { VegaLite } from "react-vega"; import { type PlotState } from "@/lib/types/state/component"; import { type ComponentChangeHandler } from "@/lib/types/state/event"; +import { useSignalListeners } from "@/lib/hooks"; export interface PlotProps extends Omit { onChange: ComponentChangeHandler; } export function Plot({ id, style, chart, onChange }: PlotProps) { + const signalListeners = useSignalListeners(chart, id, onChange); + if (!chart) { return
; } - const { datasets, ...spec } = chart; - const handleSignal = (_signalName: string, value: unknown) => { - if (id) { - return onChange({ - componentType: "Plot", - id: id, - property: "points", - value: value, - }); - } - }; + return ( ); diff --git a/chartlets.js/src/lib/components/Select.tsx b/chartlets.js/src/lib/components/Select.tsx index 24610abd..d238b64c 100644 --- a/chartlets.js/src/lib/components/Select.tsx +++ b/chartlets.js/src/lib/components/Select.tsx @@ -8,6 +8,7 @@ import { type SelectState, } from "@/lib/types/state/component"; import { type ComponentChangeHandler } from "@/lib/types/state/event"; +import { isString } from "@/lib/utils/isString"; export interface SelectProps extends Omit { onChange: ComponentChangeHandler; @@ -64,7 +65,7 @@ export function Select({ function normalizeSelectOption( option: SelectOption, ): [string | number, string] { - if (typeof option === "string") { + if (isString(option)) { return [option, option]; } else if (typeof option === "number") { return [option, option.toString()]; diff --git a/chartlets.js/src/lib/hooks.ts b/chartlets.js/src/lib/hooks.ts index f81e6c51..dfbf932e 100644 --- a/chartlets.js/src/lib/hooks.ts +++ b/chartlets.js/src/lib/hooks.ts @@ -1,7 +1,13 @@ import type { StoreState } from "@/lib/types/state/store"; import { store } from "@/lib/store"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import type { ContributionState } from "@/lib/types/state/contribution"; +import { + isTopLevelSelectionParameter, + type SignalHandler, +} from "@/lib/types/state/vega"; +import type { TopLevelSpec } from "vega-lite/src/spec"; +import { isString } from "@/lib/utils/isString"; import type { ComponentChangeEvent, ComponentChangeHandler, @@ -38,6 +44,104 @@ export function makeContributionsHook( }; } +export function useSignalListeners( + chart: TopLevelSpec | null, + id: string | undefined, + onChange: ComponentChangeHandler, +): { [key: string]: SignalHandler } { + /* + Here, we loop through all the params to create map of signals which will + be then used to create the map of signal-listeners because not all + params are event-listeners, and we need to identify them. Later in the + code, we then see which handlers do we have so that we can create + those listeners with the `name` specified in the event-listener object. + */ + const signals: { [key: string]: string } = useMemo(() => { + if (!chart) return {}; + const tempSignals: { [key: string]: string } = {}; + chart.params?.forEach((param) => { + if (isTopLevelSelectionParameter(param)) { + if ( + typeof param.select === "object" && + "on" in param.select && + param.select.on != null + ) { + const signalName = param.select.on; + /* + The signal name extracted from the `select.on` property can be + either a string or of Stream type (internal Vega + type). But since we create the selection events in + Altair (in the server) using `on="click"` etc., the event type + will be a string, but we need this type-guard to be sure. + If it is a Stream object, that case is not handled yet. + */ + if (isString(signalName)) { + tempSignals[signalName] = param.name; + } else { + console.warn( + `The signal ${param} is of Stream type` + + " (internal Vega-lite type) which is not handled yet.", + ); + } + } + } + }); + return tempSignals; + }, [chart]); + + const handleClickSignal = useCallback( + (signalName: string, signalValue: unknown) => { + if (id) { + return onChange({ + componentType: "Plot", + id: id, + property: signalName, + value: signalValue, + }); + } + }, + [id, onChange], + ); + + /* + Currently, we only have click events support, but if more are required, + they can be implemented and added in the map below. + */ + const signalHandlerMap: { [key: string]: SignalHandler } = useMemo( + () => ({ + click: handleClickSignal, + }), + [handleClickSignal], + ); + + /* + This function creates the map of signal listeners based on the `signals` + map computed above. + */ + const createSignalListeners = useCallback( + (signals: { [key: string]: string }) => { + const signalListeners: { [key: string]: SignalHandler } = {}; + Object.entries(signals).forEach(([event, signalName]) => { + if (signalHandlerMap[event]) { + signalListeners[signalName] = signalHandlerMap[event]; + } else { + console.warn( + "The signal " + event + " is not yet supported in chartlets.js", + ); + } + }); + + return signalListeners; + }, + [signalHandlerMap], + ); + + return useMemo( + () => createSignalListeners(signals), + [createSignalListeners, signals], + ); +} + /** * A hook that retrieves the contributions for the given contribution * point given by `contribPoint`. diff --git a/chartlets.js/src/lib/types/state/component.ts b/chartlets.js/src/lib/types/state/component.ts index 8b6fd8e4..9bc53829 100644 --- a/chartlets.js/src/lib/types/state/component.ts +++ b/chartlets.js/src/lib/types/state/component.ts @@ -1,6 +1,7 @@ import { type CSSProperties } from "react"; -import type { VisualizationSpec } from "react-vega"; import { isObject } from "@/lib/utils/isObject"; +import type { TopLevelSpec } from "vega-lite/src/spec"; +import { isString } from "@/lib/utils/isString"; export type ComponentType = | "Box" @@ -54,9 +55,7 @@ export interface CheckboxState extends ComponentState { export interface PlotState extends ComponentState { type: "Plot"; chart: - | (VisualizationSpec & { - datasets?: Record; // Add the datasets property - }) + | TopLevelSpec // This is the vega-lite specification type | null; } @@ -69,7 +68,7 @@ export interface TypographyState extends ContainerState { } export function isComponentState(object: unknown): object is ComponentState { - return isObject(object) && typeof object.type === "string"; + return isObject(object) && isString(object.type); } export function isContainerState(object: unknown): object is ContainerState { diff --git a/chartlets.js/src/lib/types/state/vega.ts b/chartlets.js/src/lib/types/state/vega.ts new file mode 100644 index 00000000..73f0cace --- /dev/null +++ b/chartlets.js/src/lib/types/state/vega.ts @@ -0,0 +1,17 @@ +import type { TopLevelParameter } from "vega-lite/src/spec/toplevel"; +import type { TopLevelSelectionParameter } from "vega-lite/src/selection"; + +export type SignalHandler = (signalName: string, value: unknown) => void; + +/* + There are two types of Parameters in Vega-lite. Variable and Selection parameters. We need to check if the provided + parameter in the chart from the server is a Selection parameter so that we can extract the selection event types + (point or interval) and further, the events from the `select.on` property (e.g. click, mouseover, keydown etc.) if + that property `on` exists. We need these events and their names to create the signal listeners and pass them to the + Vega-lite element for event-handling (signal-listeners). + */ +export function isTopLevelSelectionParameter( + param: TopLevelParameter, +): param is TopLevelSelectionParameter { + return "select" in param; +} diff --git a/chartlets.js/src/lib/utils/isString.ts b/chartlets.js/src/lib/utils/isString.ts new file mode 100644 index 00000000..9be9b393 --- /dev/null +++ b/chartlets.js/src/lib/utils/isString.ts @@ -0,0 +1,3 @@ +export function isString(signalName: unknown): signalName is string { + return typeof signalName === "string"; +} diff --git a/chartlets.js/src/lib/utils/objPath.ts b/chartlets.js/src/lib/utils/objPath.ts index c335f8a4..45156f9a 100644 --- a/chartlets.js/src/lib/utils/objPath.ts +++ b/chartlets.js/src/lib/utils/objPath.ts @@ -1,4 +1,5 @@ import { isObject } from "@/lib/utils/isObject"; +import { isString } from "@/lib/utils/isString"; export type ObjPath = (string | number)[]; export type ObjPathLike = ObjPath | string | number | undefined | null; @@ -83,7 +84,7 @@ export function normalizeObjPath(pathLike: ObjPathLike): ObjPath { } export function formatObjPath(objPath: ObjPathLike): string { - if (typeof objPath === "string") { + if (isString(objPath)) { return objPath; } else if (Array.isArray(objPath)) { return objPath diff --git a/chartlets.py/my_extension/my_panel_1.py b/chartlets.py/my_extension/my_panel_1.py index d50822e5..9a6af188 100644 --- a/chartlets.py/my_extension/my_panel_1.py +++ b/chartlets.py/my_extension/my_panel_1.py @@ -1,6 +1,10 @@ +import copy +from types import NoneType + import altair as alt +import pandas as pd -from chartlets import Component, Input, Output +from chartlets import Component, Input, Output, State from chartlets.components import Plot, Box, Select from chartlets.demo.contribs import Panel from chartlets.demo.context import Context @@ -60,7 +64,7 @@ def make_figure(ctx: Context, selected_dataset: int = 0) -> alt.Chart: # Create another parameter to handle the click events and send the data as # specified in the fields click_param = alt.selection_point( - on="click", name="onClick", fields=["x", variable_name] + on="click", name="points", fields=["x", variable_name] ) # Create a chart type using mark_* where * could be any kind of chart # supported by Vega. We can add properties and parameters as shown below. @@ -79,5 +83,45 @@ def make_figure(ctx: Context, selected_dataset: int = 0) -> alt.Chart: .properties(width=290, height=300, title="Vega charts") .add_params(corner_var, click_param) ) - return chart + + +@panel.callback( + Input("plot", property="points"), State("plot", "chart"), Output("plot", "chart") +) +def get_click_event_points(ctx: Context, points, plot) -> alt.Chart: + """ + This callback function shows how we can use the event handlers output + (property="points") which was defined in the `make_figure` callback + function as a `on='click'` handler. Here, we access the variables as + defined in the `fields` argument when creating the `click_param` parameter. + + Based on the click event, the user can access the point that was clicked. + The function below extracts the points and changes the color of the bar + that was clicked. + + """ + if points: + conditions = [] + for field, values in points.items(): + if field != "vlPoint": + for value in values: + field_type = plot["encoding"].get(field, {}).get("type", "") + if field_type == "nominal": + conditions.append(f"datum.{field} === '{value}'") + else: + conditions.append(f"datum.{field} === {value}") + conditions.append(f"datum.{field} === {repr(value)}") + + condition_expr = " && ".join(conditions) + + plot["encoding"]["color"] = { + "condition": { + "test": condition_expr, + # Highlight color when the condition is true + "value": "orange", + }, + "value": "steelblue", # Default color + } + + return alt.Chart.from_dict(plot) diff --git a/chartlets.py/my_extension/my_panel_2.py b/chartlets.py/my_extension/my_panel_2.py index 9188ab6c..6bee4c93 100644 --- a/chartlets.py/my_extension/my_panel_2.py +++ b/chartlets.py/my_extension/my_panel_2.py @@ -83,7 +83,13 @@ def make_figure( ) .properties(width=300, height=300, title="Vega charts using Shorthand syntax") .add_params(selector) - .interactive() + # .interactive() # Using interactive mode will lead to warning + # `WARN Scale bindings are currently only supported for scales with + # unbinned, continuous domains.` + # because it expects both x and y to be continuous scales, + # but we have x as Nominal which leads to this warning. + # This still works where we can only zoom in on the y axis but + # with a warning. ) return chart