diff --git a/chartlets.js/CHANGES.md b/chartlets.js/CHANGES.md index c576eb8d..26762744 100644 --- a/chartlets.js/CHANGES.md +++ b/chartlets.js/CHANGES.md @@ -1,3 +1,7 @@ +## Version 0.0.29 (from 2024/11/26) + +* Resolved warnings that appeared when using Vega charts. + ## Version 0.0.28 (from 2024/11/26) * Updated docs. diff --git a/chartlets.js/package.json b/chartlets.js/package.json index 2ede7f72..036f5e0b 100644 --- a/chartlets.js/package.json +++ b/chartlets.js/package.json @@ -1,6 +1,6 @@ { "name": "chartlets", - "version": "0.0.28", + "version": "0.0.29", "description": "An experimental library for integrating interactive charts into existing JavaScript applications.", "type": "module", "files": [ diff --git a/chartlets.js/src/lib/components/Plot.tsx b/chartlets.js/src/lib/components/Plot.tsx index c4cd31b1..6a121cf1 100644 --- a/chartlets.js/src/lib/components/Plot.tsx +++ b/chartlets.js/src/lib/components/Plot.tsx @@ -1,40 +1,30 @@ -import { VegaLite, type VisualizationSpec } from "react-vega"; +import { VegaLite } from "react-vega"; import { type ComponentState } from "@/lib/types/state/component"; import type { ComponentProps } from "@/lib/component/Component"; +import { useSignalListeners } from "@/lib/hooks"; +import type { TopLevelSpec } from "vega-lite"; interface PlotState extends ComponentState { chart?: - | (VisualizationSpec & { - datasets?: Record; // Add the datasets property - }) - | null - | undefined; + | TopLevelSpec // This is the vega-lite specification type + | null; } interface PlotProps extends ComponentProps, PlotState {} export function Plot({ type, id, style, chart, onChange }: PlotProps) { + const signalListeners = useSignalListeners(chart, type, id, onChange); + if (!chart) { return
; } - const { datasets, ...spec } = chart; - const handleSignal = (_signalName: string, value: unknown) => { - if (id) { - return onChange({ - componentType: type, - 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 1aed5c62..963f4c7e 100644 --- a/chartlets.js/src/lib/components/Select.tsx +++ b/chartlets.js/src/lib/components/Select.tsx @@ -5,6 +5,7 @@ import MuiSelect, { type SelectChangeEvent } from "@mui/material/Select"; import { type ComponentState } from "@/lib/types/state/component"; import type { ComponentProps } from "@/lib/component/Component"; +import { isString } from "@/lib/utils/isString"; export type SelectOption = | string @@ -70,7 +71,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..e76b014b 100644 --- a/chartlets.js/src/lib/hooks.ts +++ b/chartlets.js/src/lib/hooks.ts @@ -1,12 +1,16 @@ 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 { type SignalHandler } from "@/lib/types/state/vega"; +import type { TopLevelSpec } from "vega-lite"; import type { ComponentChangeEvent, ComponentChangeHandler, } from "@/lib/types/state/event"; import { handleComponentChange } from "@/lib/actions/handleComponentChange"; +import { isString } from "@/lib/utils/isString"; +import { isObject } from "@/lib/utils/isObject"; const selectConfiguration = (state: StoreState) => state.configuration; @@ -38,6 +42,102 @@ export function makeContributionsHook( }; } +export function useSignalListeners( + chart: TopLevelSpec | null | undefined, + type: string, + id: string | undefined, + onChange: ComponentChangeHandler, +): { [key: string]: SignalHandler } { + /* + This is a partial representation of the parameter type from + SelectionParameter type from the `vega-lite` module. Since we are + only interested in extracting the handlers, the following + properties are required. + */ + type SelectionParameter = { name: string; select: { on: string } }; + /* + Here, we 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 {}; + if (!chart.params) return {}; + return chart.params + .filter( + (param): param is SelectionParameter => + isObject(param) && + param !== null && + "name" in param && + "select" in param && + isObject(param.select) && + param.select?.on != null && + isString(param.select.on), + ) + .reduce( + (acc, param) => { + acc[param.select.on] = param.name; + return acc; + }, + {} as { [key: string]: string }, + ); + }, [chart]); + + const handleClickSignal = useCallback( + (signalName: string, signalValue: unknown) => { + if (id) { + return onChange({ + componentType: type, + id: id, + property: signalName, + value: signalValue, + }); + } + }, + [id, onChange, type], + ); + + /* + 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 efe21b82..ff616d43 100644 --- a/chartlets.js/src/lib/types/state/component.ts +++ b/chartlets.js/src/lib/types/state/component.ts @@ -1,5 +1,6 @@ import { type CSSProperties } from "react"; import { isObject } from "@/lib/utils/isObject"; +import { isString } from "@/lib/utils/isString"; export type ComponentType = | "Box" @@ -37,7 +38,7 @@ export interface ContainerState extends ComponentState { } 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..4821ec97 --- /dev/null +++ b/chartlets.js/src/lib/types/state/vega.ts @@ -0,0 +1 @@ +export type SignalHandler = (signalName: string, value: unknown) => void; 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/CHANGES.md b/chartlets.py/CHANGES.md index 6c24faf0..1331295e 100644 --- a/chartlets.py/CHANGES.md +++ b/chartlets.py/CHANGES.md @@ -1,7 +1,9 @@ -## Version 0.0.x (in development) +## Version 0.0.29 (from 2024/11/26) -* Fixed a bug that prevent using annotations of type `dict` or `dict[str, T]`. +* Fixed a bug that prevents using annotations of type `dict` or `dict[str, T]`. in callback functions. +* Introduced a callback function in `my_panel.py` to handle click events. + Demonstrates how to dynamically change the color of a clicked bar. ## Version 0.0.28 (from 2024/11/26) diff --git a/chartlets.py/chartlets/version.py b/chartlets.py/chartlets/version.py index 7dc68830..be67c33b 100644 --- a/chartlets.py/chartlets/version.py +++ b/chartlets.py/chartlets/version.py @@ -1 +1 @@ -version = "0.0.28" +version = "0.0.29" diff --git a/chartlets.py/my_extension/my_panel_1.py b/chartlets.py/my_extension/my_panel_1.py index d50822e5..4a1dde4f 100644 --- a/chartlets.py/my_extension/my_panel_1.py +++ b/chartlets.py/my_extension/my_panel_1.py @@ -1,6 +1,11 @@ +import copy +from types import NoneType +from typing import Any + 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 +65,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 +84,46 @@ 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.encoding"), + Output("plot", "chart.encoding.color"), +) +def get_click_event_points( + ctx: Context, points: dict[str, Any], encoding: dict[str, Any] +) -> dict[str, Any]: + """ + 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": + field_type = encoding.get(field, {}).get("type", "") + for value in values: + if field_type == "nominal": + conditions.append(f"datum.{field} === '{value}'") + else: + conditions.append(f"datum.{field} === {value}") + + condition_expr = " && ".join(conditions) + + return { + "condition": { + "test": condition_expr, + # Highlight color when the condition is true + "value": "orange", + }, + "value": "steelblue", # Default color + } diff --git a/chartlets.py/my_extension/my_panel_2.py b/chartlets.py/my_extension/my_panel_2.py index f19f91d3..b0220cb4 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