);
- }
- });
-
- return () => {
- window.removeEventListener("message", handleMessage);
- };
- }, []);
-
- useEffect(() => {
- if (!payload) {
- setResolved(null);
- setLoadError(null);
- return undefined;
- }
-
- if (payload.data) {
- setResolved(payload);
- setLoadError(null);
- return undefined;
- }
-
- if (!payload.dataUrl) {
- setResolved(null);
- setLoadError("Missing data or dataUrl");
- return undefined;
- }
-
- const controller = new AbortController();
- const load = async () => {
- try {
- setLoadError(null);
- setResolved(null);
- const response = await fetch(payload.dataUrl as string, {
- signal: controller.signal,
- });
- if (!response.ok) {
- throw new Error(`Request failed with ${response.status}`);
- }
- const json = await response.json();
- const nextData = Array.isArray(json)
- ? json
- : Array.isArray(json?.data)
- ? json.data
- : [];
- const data = nextData.filter(isBarChartDatum);
- if (data.length === 0) {
- throw new Error("No valid data points");
+ const [payload, setPayload] = useState<
+ typeof BarChartWidgetPayload.Type | null
+ >(null);
+ const { app, isConnected, error } = useApp({
+ appInfo: { name: "bar-chart-widget", version: "0.1.0" },
+ capabilities: {},
+ onAppCreated: (app) => {
+ app.ontoolinput = (params) => {
+ const parsed = Option.getOrNull(
+ Schema.decodeUnknownOption(BarChartWidgetPayload)(params.arguments),
+ );
+ if (parsed) {
+ setPayload(parsed);
}
- setResolved(payload.props ? { data, props: payload.props } : { data });
- } catch (error) {
- if (!controller.signal.aborted) {
- setLoadError(
- error instanceof Error ? error.message : "Failed to load data",
- );
+ };
+ app.ontoolresult = (params) => {
+ const parsed = extractPayloadFromToolResult(params);
+ if (parsed) {
+ setPayload(parsed);
}
- }
- };
+ };
+ },
+ });
- void load();
- return () => controller.abort();
- }, [payload]);
+ useHostStyles(app, app?.getHostContext());
- if (!payload) {
- return Waiting for data…
;
+ if (error) {
+ return Error: {error.message}
;
}
- if (loadError) {
- return Failed to load data: {loadError}
;
+ if (!isConnected) {
+ return Connecting…
;
}
- if (!resolved?.data) {
- return Loading data…
;
+ if (!payload) {
+ return Waiting for data…
;
}
- const { data, props = {} } = resolved;
-
- return ;
+ return ;
};
diff --git a/packages/domain/package.json b/packages/domain/package.json
index f4d7fb9..ba6b2b8 100644
--- a/packages/domain/package.json
+++ b/packages/domain/package.json
@@ -7,6 +7,7 @@
".": "./src/Api.ts",
"./Api": "./src/Api.ts",
"./Chat": "./src/Chat.ts",
+ "./Chart": "./src/Chart.ts",
"./Rpc": "./src/Rpc.ts",
"./WebSocket": "./src/WebSocket.ts"
},
diff --git a/packages/domain/src/Chart.ts b/packages/domain/src/Chart.ts
new file mode 100644
index 0000000..7ba2b56
--- /dev/null
+++ b/packages/domain/src/Chart.ts
@@ -0,0 +1,141 @@
+import { Schema, Struct } from "effect";
+
+const ChannelString = Schema.String.annotate({ plotChannel: "field" });
+const ChannelNumber = Schema.Number.annotate({ plotChannel: "value" });
+const Channel = Schema.Union([ChannelString, ChannelNumber]);
+const IntervalValue = Schema.Union([
+ Schema.Number.annotate({ plotChannel: "interval" }),
+ Schema.String.annotate({ plotChannel: "interval" }),
+]);
+const AxisTickValue = Schema.Union([Schema.Number, Schema.String, Schema.Date]);
+const AxisTicks = Schema.Union([
+ Schema.Number,
+ Schema.String,
+ Schema.Array(AxisTickValue),
+]);
+
+export const BarDatumValue = Schema.Union([
+ Schema.String,
+ Schema.Number,
+ Schema.NumberFromString,
+ Schema.Boolean,
+ Schema.Date,
+ Schema.Null,
+]);
+
+export const BarDatum = Schema.Record(Schema.String, BarDatumValue).annotate({
+ description:
+ "Single bar data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'category' (string-ish) and 'value' (number-ish). If you use different keys, set marks.x and marks.y to those key names.",
+ examples: [
+ { category: "Q1", value: 120 },
+ { product: "Alpha", sales: 48, region: "EU" },
+ ],
+});
+
+export const BarDatumDefaults = Schema.Struct({
+ category: Schema.String,
+ value: Schema.Union([Schema.Number, Schema.NumberFromString]),
+}).mapFields(Struct.map(Schema.optionalKey));
+
+const NumberGreaterThanZero = Schema.Union([
+ Schema.Number,
+ Schema.NumberFromString,
+]).check(Schema.isGreaterThan(0));
+
+export const PlotLayoutProps = Schema.Struct({
+ title: Schema.String,
+ subtitle: Schema.String,
+ caption: Schema.String,
+
+ aspectRatio: NumberGreaterThanZero,
+ marginTop: NumberGreaterThanZero,
+ marginRight: NumberGreaterThanZero,
+ marginBottom: NumberGreaterThanZero,
+ marginLeft: NumberGreaterThanZero,
+ margin: NumberGreaterThanZero,
+ width: NumberGreaterThanZero,
+ height: NumberGreaterThanZero,
+
+ inset: NumberGreaterThanZero,
+ grid: Schema.Boolean,
+
+ color: Schema.Struct({
+ legend: Schema.Boolean,
+ }),
+}).mapFields(Struct.map(Schema.optionalKey));
+
+export const BarMarkProps = Schema.Struct({
+ x: Channel.annotate({
+ description:
+ "Primary position channel. For vertical bars, maps to the category/ordinal axis; for horizontal bars, maps to the value axis.",
+ }),
+ // x1: Channel,
+ // x2: Channel,
+ y: Channel.annotate({
+ description:
+ "Primary value channel. For vertical bars, maps to the value axis; for horizontal bars, maps to the category/ordinal axis.",
+ }),
+ // y1: Channel,
+ // y2: Channel,
+ z: Channel.annotate({
+ description:
+ "Series/channel for grouping within each stack. Use with fill for stacked bars when multiple series share the same x/y.",
+ }),
+ interval: IntervalValue,
+
+ inset: NumberGreaterThanZero,
+
+ fill: Channel.annotate({
+ description:
+ "Series/color channel. With only x and y available, setting fill (or z) makes Plot apply the stack transform, producing stacked bars. Vertical bars stack by x; horizontal bars stack by y.",
+ }),
+ // fillOpacity: Channel,
+ stroke: Channel.annotate({
+ description: "Stroke color channel. Maps to the outline color of the bars.",
+ }),
+ // strokeWidth: Channel,
+ // strokeOpacity: Channel,
+ // opacity: Channel,
+
+ tip: Schema.Boolean.annotate({
+ description: "If true, show a tooltip with all data fields on hover.",
+ }),
+}).mapFields(Struct.map(Schema.optionalKey));
+
+export const AxisMarkProps = Schema.Struct({
+ x: Channel,
+ y: Channel,
+
+ anchor: Schema.Literals(["top", "right", "bottom", "left"]),
+ ticks: AxisTicks,
+ // tickSpacing: Schema.Number,
+ // interval: IntervalValue,
+ // tickSize: Schema.Number,
+ tickFormat: Schema.Union([Schema.String, Schema.Unknown]),
+ tickRotate: Schema.Number,
+ label: Schema.Union([Schema.String, Schema.Null]),
+ labelAnchor: Schema.Literals(["top", "right", "bottom", "left", "center"]),
+ // labelOffset: Schema.Number,
+}).mapFields(Struct.map(Schema.optionalKey));
+
+export const BarChartWidgetPayload = Schema.Struct({
+ data: Schema.Array(BarDatum).annotate({
+ description:
+ "Array of bar data objects. Each object must expose the keys used in marks.x and marks.y.",
+ }),
+ direction: Schema.Literals(["horizontal", "vertical"]).annotate({
+ description:
+ "Orientation of bars. Vertical defaults to x=category and y=value; horizontal swaps them.",
+ }),
+ layout: PlotLayoutProps.annotate({
+ description:
+ "Plot layout options. All fields optional; omit to use defaults.",
+ }),
+ marks: BarMarkProps.annotate({
+ description:
+ "Mark channel mapping. Use marks.x and marks.y to select data keys. If omitted, defaults are x=category and y=value for vertical bars, swapped for horizontal.",
+ }),
+}).annotate({
+ description:
+ "Payload for render_bar_chart_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.",
+});