From 35086c9116af4a5e9aa821903a5758a5ddcf0739 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 19:09:28 +0200 Subject: [PATCH 1/8] refactor: extract widget logic --- bun.lock | 17 +++++ packages/ui/README.md | 1 + packages/ui/package.json | 26 +++++++ packages/ui/src/index.ts | 9 +++ packages/ui/src/usePlotRenderer.tsx | 28 ++++++++ packages/ui/src/useWidgetPayload.tsx | 100 +++++++++++++++++++++++++++ packages/ui/src/useWidgetResize.tsx | 66 ++++++++++++++++++ packages/ui/src/widget.tsx | 48 +++++++++++++ packages/ui/tsconfig.json | 15 ++++ 9 files changed, 310 insertions(+) create mode 100644 packages/ui/README.md create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/usePlotRenderer.tsx create mode 100644 packages/ui/src/useWidgetPayload.tsx create mode 100644 packages/ui/src/useWidgetResize.tsx create mode 100644 packages/ui/src/widget.tsx create mode 100644 packages/ui/tsconfig.json diff --git a/bun.lock b/bun.lock index 5c54481..0afeaa4 100644 --- a/bun.lock +++ b/bun.lock @@ -150,6 +150,21 @@ "@types/node": "^25.5.0", }, }, + "packages/ui": { + "name": "@repo/ui", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "devDependencies": { + "@repo/config-typescript": "workspace:*", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + }, + }, "packages/widget-bar-chart": { "name": "@repo/widget-bar-chart", "version": "0.0.0", @@ -590,6 +605,8 @@ "@repo/presence": ["@repo/presence@workspace:packages/presence"], + "@repo/ui": ["@repo/ui@workspace:packages/ui"], + "@repo/widget-bar-chart": ["@repo/widget-bar-chart@workspace:packages/widget-bar-chart"], "@repo/widget-line-chart": ["@repo/widget-line-chart@workspace:packages/widget-line-chart"], diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..2d36f2e --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1 @@ +# @repo/ui \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..ae2db83 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "@repo/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc --noEmit", + "clean": "git clean -xdf .cache .turbo dist node_modules tsconfig.tsbuildinfo" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@repo/config-typescript": "workspace:*", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..559cd85 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,9 @@ +export { usePlotRenderer } from "./usePlotRenderer"; +export { useWidgetPayload } from "./useWidgetPayload"; +export { useWidgetResize } from "./useWidgetResize"; + +export { + mountWidgetApp, + WidgetFrame, + WidgetStatus, +} from "./widget"; diff --git a/packages/ui/src/usePlotRenderer.tsx b/packages/ui/src/usePlotRenderer.tsx new file mode 100644 index 0000000..0d0bba8 --- /dev/null +++ b/packages/ui/src/usePlotRenderer.tsx @@ -0,0 +1,28 @@ +import { type RefObject, useEffect } from "react"; + +type PlotRenderContext = { + width?: number; +}; +type UsePlotRendererOptions = { + containerRef: RefObject; + width?: number; + renderPlot: (context: PlotRenderContext) => HTMLElement; + dependencies: ReadonlyArray; +}; +export const usePlotRenderer = ({ + containerRef, + width, + renderPlot, + dependencies, +}: UsePlotRendererOptions) => { + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + + const plot = renderPlot(width ? { width } : {}); + containerRef.current.replaceChildren(plot); + + return () => plot.remove(); + }, [containerRef, width, renderPlot, ...dependencies]); +}; diff --git a/packages/ui/src/useWidgetPayload.tsx b/packages/ui/src/useWidgetPayload.tsx new file mode 100644 index 0000000..4a3142c --- /dev/null +++ b/packages/ui/src/useWidgetPayload.tsx @@ -0,0 +1,100 @@ +import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; +import { Option, Schema } from "effect"; +import { useRef, useState } from "react"; +import type { HostApp } from "./widget"; + +type ToolResultContentItem = NonNullable[number]; + +type ToolResultParams = Parameters< + NonNullable["app"]>["ontoolresult"]> +>[0]; + +type AppInfo = { + name: string; + version: string; +}; + +type UseWidgetPayloadOptions = { + appInfo: AppInfo; + capabilities?: Record; +}; + +type UseWidgetPayloadResult = { + app: HostApp | null; + error: Error | null; + isConnected: boolean; + payload: TPayload | null; +}; + +const decodeToolResult = >( + schema: TSchema, + params: ToolResultParams, +): Schema.Schema.Type | null => { + if (params.isError) { + return null; + } + + const structuredPayload = Schema.decodeUnknownOption(schema)( + params.structuredContent, + ); + if (Option.isSome(structuredPayload)) { + return structuredPayload.value as Schema.Schema.Type; + } + + const text = params.content?.find( + (item: ToolResultContentItem) => item.type === "text", + )?.text; + if (!text) { + return null; + } + + try { + return Schema.decodeUnknownSync(schema)( + JSON.parse(text), + ) as Schema.Schema.Type; + } catch { + return null; + } +}; + +export const useWidgetPayload = < + TSchema extends Schema.Decoder, +>( + schema: TSchema, + options: UseWidgetPayloadOptions, +): UseWidgetPayloadResult> => { + const [payload, setPayload] = useState | null>( + null, + ); + const lastPayloadRef = useRef(null); + + const { app, isConnected, error } = useApp({ + appInfo: options.appInfo, + capabilities: options.capabilities ?? {}, + onAppCreated: (nextApp: HostApp) => { + nextApp.ontoolresult = (params: ToolResultParams) => { + const nextPayload = decodeToolResult(schema, params); + if (!nextPayload) { + return; + } + + const serialized = JSON.stringify(nextPayload); + if (lastPayloadRef.current === serialized) { + return; + } + + lastPayloadRef.current = serialized; + setPayload(nextPayload); + }; + }, + }); + + useHostStyles(app, app?.getHostContext()); + + return { + app, + error, + isConnected, + payload, + }; +}; diff --git a/packages/ui/src/useWidgetResize.tsx b/packages/ui/src/useWidgetResize.tsx new file mode 100644 index 0000000..d5a1389 --- /dev/null +++ b/packages/ui/src/useWidgetResize.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +type SizeChangedSender = { + sendSizeChanged: (params: { width?: number; height?: number }) => unknown; +}; + +export const useWidgetResize = (app?: SizeChangedSender | null) => { + const frameRef = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(null); + const lastSizeRef = useRef<{ width: number; height: number } | null>(null); + const scheduledRef = useRef(false); + + useEffect(() => { + if (!frameRef.current) { + return undefined; + } + + const element = frameRef.current; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + const nextWidth = Math.floor(entry.contentRect.width); + if (Number.isFinite(nextWidth) && nextWidth > 0) { + setMeasuredWidth(nextWidth); + } + + if (!app) { + return; + } + + if (scheduledRef.current) { + return; + } + + scheduledRef.current = true; + requestAnimationFrame(() => { + scheduledRef.current = false; + + const width = Math.ceil(entry.contentRect.width); + const height = Math.ceil(entry.contentRect.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return; + } + + const lastSize = lastSizeRef.current; + if (lastSize?.width === width && lastSize?.height === height) { + return; + } + + lastSizeRef.current = { width, height }; + void app.sendSizeChanged({ width, height }); + }); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [app]); + + return { + frameRef, + measuredWidth, + }; +}; diff --git a/packages/ui/src/widget.tsx b/packages/ui/src/widget.tsx new file mode 100644 index 0000000..b91680a --- /dev/null +++ b/packages/ui/src/widget.tsx @@ -0,0 +1,48 @@ +import type { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { ReactElement, ReactNode, RefObject } from "react"; +import { createRoot } from "react-dom/client"; + +export type HostApp = NonNullable["app"]>; + +type WidgetFrameProps = { + children?: ReactNode; + frameRef?: RefObject; +}; + +type WidgetStatusProps = { + message: string; +}; + + + + + +export const mountWidgetApp = (element: ReactElement) => { + const rootElement = document.getElementById("root"); + + if (!rootElement) { + throw new Error("Widget root element not found"); + } + + createRoot(rootElement).render(element); +}; + +export const WidgetFrame = ({ children, frameRef }: WidgetFrameProps) => { + return ( +
+ {children} +
+ ); +}; + +export const WidgetStatus = ({ message }: WidgetStatusProps) => { + return
{message}
; +}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..27c182a --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["dist", "build", "node_modules"] +} From 9230cd486b86da28a33da853b2003775eb67fc4e Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 19:12:18 +0200 Subject: [PATCH 2/8] refactor: use extraction in bar chart --- bun.lock | 1 + packages/ui/src/index.ts | 1 + packages/ui/src/usePlotRenderer.tsx | 4 +- packages/ui/src/widget.tsx | 4 - packages/widget-bar-chart/package.json | 1 + packages/widget-bar-chart/src/app.tsx | 10 +- packages/widget-bar-chart/src/bar-chart.tsx | 211 +++++--------------- 7 files changed, 62 insertions(+), 170 deletions(-) diff --git a/bun.lock b/bun.lock index 0afeaa4..1064289 100644 --- a/bun.lock +++ b/bun.lock @@ -179,6 +179,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 559cd85..3b2d7e9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ export { useWidgetPayload } from "./useWidgetPayload"; export { useWidgetResize } from "./useWidgetResize"; export { + type HostApp, mountWidgetApp, WidgetFrame, WidgetStatus, diff --git a/packages/ui/src/usePlotRenderer.tsx b/packages/ui/src/usePlotRenderer.tsx index 0d0bba8..70ec014 100644 --- a/packages/ui/src/usePlotRenderer.tsx +++ b/packages/ui/src/usePlotRenderer.tsx @@ -3,12 +3,14 @@ import { type RefObject, useEffect } from "react"; type PlotRenderContext = { width?: number; }; + type UsePlotRendererOptions = { containerRef: RefObject; width?: number; - renderPlot: (context: PlotRenderContext) => HTMLElement; + renderPlot: (context: PlotRenderContext) => Element; dependencies: ReadonlyArray; }; + export const usePlotRenderer = ({ containerRef, width, diff --git a/packages/ui/src/widget.tsx b/packages/ui/src/widget.tsx index b91680a..ca2348a 100644 --- a/packages/ui/src/widget.tsx +++ b/packages/ui/src/widget.tsx @@ -13,10 +13,6 @@ type WidgetStatusProps = { message: string; }; - - - - export const mountWidgetApp = (element: ReactElement) => { const rootElement = document.getElementById("root"); diff --git a/packages/widget-bar-chart/package.json b/packages/widget-bar-chart/package.json index 93ee8ae..f49ac96 100644 --- a/packages/widget-bar-chart/package.json +++ b/packages/widget-bar-chart/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-bar-chart/src/app.tsx b/packages/widget-bar-chart/src/app.tsx index d4addf3..09b71a1 100644 --- a/packages/widget-bar-chart/src/app.tsx +++ b/packages/widget-bar-chart/src/app.tsx @@ -1,10 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { mountWidgetApp } from "@repo/ui"; import { WidgetApp } from "./bar-chart"; -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Widget root element not found"); -} - -createRoot(rootElement).render(); +mountWidgetApp(); diff --git a/packages/widget-bar-chart/src/bar-chart.tsx b/packages/widget-bar-chart/src/bar-chart.tsx index 89611f4..41c5b02 100644 --- a/packages/widget-bar-chart/src/bar-chart.tsx +++ b/packages/widget-bar-chart/src/bar-chart.tsx @@ -1,41 +1,14 @@ -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import * as Plot from "@observablehq/plot"; import { BarChartWidgetPayload } from "@repo/domain/Chart"; -import { Option, Schema } from "effect"; -import { useEffect, useRef, useState } from "react"; - -const extractPayloadFromToolResult = ( - params: Parameters< - NonNullable["app"]>["ontoolresult"]> - >[0], -) => { - if (params.isError) { - return null; - } - - const structured = params.structuredContent; - const structuredPayload = Schema.decodeUnknownOption(BarChartWidgetPayload)( - structured, - ); - if (Option.isSome(structuredPayload)) { - return structuredPayload.value; - } - - const text = params.content?.find((item) => item.type === "text")?.text; - if (text) { - try { - return Schema.decodeUnknownSync(BarChartWidgetPayload)(JSON.parse(text)); - } catch { - return null; - } - } - - return null; -}; - -type SizeChangedSender = { - sendSizeChanged: (params: { width?: number; height?: number }) => unknown; -}; +import { + type HostApp, + usePlotRenderer, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; +import { useRef } from "react"; const BarChart = ({ data, @@ -43,146 +16,70 @@ const BarChart = ({ layout, marks, app, -}: typeof BarChartWidgetPayload.Type & { app?: SizeChangedSender | null }) => { - const wrapperRef = useRef(null); +}: typeof BarChartWidgetPayload.Type & { app?: HostApp | null }) => { const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(null); - const lastSizeRef = useRef<{ width: number; height: number } | null>(null); - const scheduledRef = useRef(false); - - useEffect(() => { - if (!containerRef.current) { - return undefined; - } - - const layoutOptions = layout ?? {}; - const { width: layoutWidth, ...restLayoutOptions } = layoutOptions; - const width = layoutWidth ?? measuredWidth ?? undefined; - const isHorizontal = direction === "horizontal"; - const barOptions = { - ...marks, - x: marks?.x ?? (isHorizontal ? "value" : "category"), - y: marks?.y ?? (isHorizontal ? "category" : "value"), - }; - - const plot = Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - isHorizontal ? Plot.ruleX([0]) : Plot.ruleY([0]), - isHorizontal - ? Plot.barX(data, barOptions) - : Plot.barY(data, barOptions), - ].filter(Boolean), - }); - - containerRef.current.replaceChildren(plot); - return () => plot.remove(); - }, [data, layout, marks, measuredWidth, direction]); - - useEffect(() => { - if (!wrapperRef.current) { - return undefined; - } - - const element = wrapperRef.current; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const nextWidth = Math.floor(entry.contentRect.width); - if (Number.isFinite(nextWidth) && nextWidth > 0) { - setMeasuredWidth(nextWidth); - } - - if (!app) { - return; - } - - if (scheduledRef.current) { - return; - } + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + + usePlotRenderer({ + containerRef, + ...(width ? { width } : {}), + dependencies: [data, direction, layout, marks], + renderPlot: ({ width }: { width?: number }) => { + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const isHorizontal = direction === "horizontal"; + const barOptions = { + ...marks, + x: marks?.x ?? (isHorizontal ? "value" : "category"), + y: marks?.y ?? (isHorizontal ? "category" : "value"), + }; - scheduledRef.current = true; - requestAnimationFrame(() => { - scheduledRef.current = false; - const width = Math.ceil(entry.contentRect.width); - const height = Math.ceil(entry.contentRect.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return; - } - const lastSize = lastSizeRef.current; - if (lastSize?.width === width && lastSize?.height === height) { - return; - } - lastSizeRef.current = { width, height }; - void app.sendSizeChanged({ width, height }); + return Plot.plot({ + style: "--plot-background: var(--color-background-primary);", + ...restLayoutOptions, + ...(width ? { width } : {}), + color: { + ...(layoutOptions.color ? layoutOptions.color : {}), + scheme: "Category10", + }, + marks: [ + isHorizontal ? Plot.ruleX([0]) : Plot.ruleY([0]), + isHorizontal + ? Plot.barX(data, barOptions) + : Plot.barY(data, barOptions), + ].filter(Boolean), }); - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [app]); + }, + }); return ( -
+
-
+
); }; export const WidgetApp = () => { - const [payload, setPayload] = useState< - typeof BarChartWidgetPayload.Type | null - >(null); - const lastPayloadRef = useRef(null); - const updatePayload = (next: typeof BarChartWidgetPayload.Type) => { - const serialized = JSON.stringify(next); - if (lastPayloadRef.current === serialized) { - return; - } - lastPayloadRef.current = serialized; - setPayload(next); - }; - const { app, isConnected, error } = useApp({ - appInfo: { name: "widget-bar-chart", version: "0.1.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (params) => { - const parsed = extractPayloadFromToolResult(params); - if (parsed) { - updatePayload(parsed); - } - }; + const { app, isConnected, error, payload } = useWidgetPayload( + BarChartWidgetPayload, + { + appInfo: { name: "widget-bar-chart", version: "0.1.0" }, + capabilities: {}, }, - }); - - useHostStyles(app, app?.getHostContext()); + ); if (error) { - return
Error: {error.message}
; + return ; } if (!isConnected) { - return
Connecting…
; + return ; } if (!payload) { - return
Waiting for data…
; + return ; } return ; From 8d0590a6a7ea5777f7aadf86cf71b7b95a3f74cf Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 19:13:56 +0200 Subject: [PATCH 3/8] refactor: use extraction in line chart --- bun.lock | 1 + packages/widget-line-chart/package.json | 1 + packages/widget-line-chart/src/app.tsx | 10 +- packages/widget-line-chart/src/line-chart.tsx | 205 +++++------------- 4 files changed, 55 insertions(+), 162 deletions(-) diff --git a/bun.lock b/bun.lock index 1064289..4a0a8a9 100644 --- a/bun.lock +++ b/bun.lock @@ -204,6 +204,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-line-chart/package.json b/packages/widget-line-chart/package.json index 99b0bb7..eb0eabd 100644 --- a/packages/widget-line-chart/package.json +++ b/packages/widget-line-chart/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-line-chart/src/app.tsx b/packages/widget-line-chart/src/app.tsx index 04683d5..39341ec 100644 --- a/packages/widget-line-chart/src/app.tsx +++ b/packages/widget-line-chart/src/app.tsx @@ -1,10 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { mountWidgetApp } from "@repo/ui"; import { WidgetApp } from "./line-chart"; -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Widget root element not found"); -} - -createRoot(rootElement).render(); +mountWidgetApp(); diff --git a/packages/widget-line-chart/src/line-chart.tsx b/packages/widget-line-chart/src/line-chart.tsx index 19bddc3..00423bc 100644 --- a/packages/widget-line-chart/src/line-chart.tsx +++ b/packages/widget-line-chart/src/line-chart.tsx @@ -1,183 +1,80 @@ -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import * as Plot from "@observablehq/plot"; import { LineChartWidgetPayload } from "@repo/domain/Chart"; -import { Option, Schema } from "effect"; -import { useEffect, useRef, useState } from "react"; - -const extractPayloadFromToolResult = ( - params: Parameters< - NonNullable["app"]>["ontoolresult"]> - >[0], -) => { - if (params.isError) { - return null; - } - - const structured = params.structuredContent; - const structuredPayload = Schema.decodeUnknownOption(LineChartWidgetPayload)( - structured, - ); - if (Option.isSome(structuredPayload)) { - return structuredPayload.value; - } - - const text = params.content?.find((item) => item.type === "text")?.text; - if (text) { - try { - return Schema.decodeUnknownSync(LineChartWidgetPayload)(JSON.parse(text)); - } catch { - return null; - } - } - - return null; -}; - -type SizeChangedSender = { - sendSizeChanged: (params: { width?: number; height?: number }) => unknown; -}; +import { + type HostApp, + usePlotRenderer, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; +import { useRef } from "react"; const LineChart = ({ data, layout, marks, app, -}: typeof LineChartWidgetPayload.Type & { app?: SizeChangedSender | null }) => { - const wrapperRef = useRef(null); +}: typeof LineChartWidgetPayload.Type & { app?: HostApp | null }) => { const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(null); - const lastSizeRef = useRef<{ width: number; height: number } | null>(null); - const scheduledRef = useRef(false); - - useEffect(() => { - if (!containerRef.current) { - return undefined; - } - - const layoutOptions = layout ?? {}; - const { width: layoutWidth, ...restLayoutOptions } = layoutOptions; - const width = layoutWidth ?? measuredWidth ?? undefined; - - const plot = Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - Plot.ruleY([0]), - Plot.line(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], - }); - - containerRef.current.replaceChildren(plot); - return () => plot.remove(); - }, [data, layout, marks, measuredWidth]); - - useEffect(() => { - if (!wrapperRef.current) { - return undefined; - } - - const element = wrapperRef.current; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const nextWidth = Math.floor(entry.contentRect.width); - if (Number.isFinite(nextWidth) && nextWidth > 0) { - setMeasuredWidth(nextWidth); - } - - if (!app) { - return; - } - - if (scheduledRef.current) { - return; - } - - scheduledRef.current = true; - requestAnimationFrame(() => { - scheduledRef.current = false; - const width = Math.ceil(entry.contentRect.width); - const height = Math.ceil(entry.contentRect.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return; - } - const lastSize = lastSizeRef.current; - if (lastSize?.width === width && lastSize?.height === height) { - return; - } - lastSizeRef.current = { width, height }; - void app.sendSizeChanged({ width, height }); + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + + usePlotRenderer({ + containerRef, + ...(width ? { width } : {}), + dependencies: [data, layout, marks], + renderPlot: ({ width }: { width?: number }) => { + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + + return Plot.plot({ + style: "--plot-background: var(--color-background-primary);", + ...restLayoutOptions, + ...(width ? { width } : {}), + color: { + ...(layoutOptions.color ? layoutOptions.color : {}), + scheme: "Category10", + }, + marks: [ + Plot.ruleY([0]), + Plot.line(data, { + ...marks, + x: marks?.x ?? "x", + y: marks?.y ?? "y", + }), + ], }); - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [app]); + }, + }); return ( -
+
-
+
); }; export const WidgetApp = () => { - const [payload, setPayload] = useState< - typeof LineChartWidgetPayload.Type | null - >(null); - const lastPayloadRef = useRef(null); - const updatePayload = (next: typeof LineChartWidgetPayload.Type) => { - const serialized = JSON.stringify(next); - if (lastPayloadRef.current === serialized) { - return; - } - lastPayloadRef.current = serialized; - setPayload(next); - }; - const { app, isConnected, error } = useApp({ - appInfo: { name: "widget-line-chart", version: "0.1.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (params) => { - const parsed = extractPayloadFromToolResult(params); - if (parsed) { - updatePayload(parsed); - } - }; + const { app, isConnected, error, payload } = useWidgetPayload( + LineChartWidgetPayload, + { + appInfo: { name: "widget-line-chart", version: "0.1.0" }, + capabilities: {}, }, - }); - - useHostStyles(app, app?.getHostContext()); + ); if (error) { - return
Error: {error.message}
; + return ; } if (!isConnected) { - return
Connecting…
; + return ; } if (!payload) { - return
Waiting for data…
; + return ; } return ; From 811d2ddbbcd73513c6f608e2e7b70d5f358a2343 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 19:15:43 +0200 Subject: [PATCH 4/8] refactor: use extraction in scatterplot --- bun.lock | 1 + packages/widget-scatterplot/package.json | 1 + packages/widget-scatterplot/src/app.tsx | 10 +- .../widget-scatterplot/src/scatterplot.tsx | 209 +++++------------- 4 files changed, 56 insertions(+), 165 deletions(-) diff --git a/bun.lock b/bun.lock index 4a0a8a9..129e311 100644 --- a/bun.lock +++ b/bun.lock @@ -229,6 +229,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-scatterplot/package.json b/packages/widget-scatterplot/package.json index 0e87c9f..23f15af 100644 --- a/packages/widget-scatterplot/package.json +++ b/packages/widget-scatterplot/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-scatterplot/src/app.tsx b/packages/widget-scatterplot/src/app.tsx index a148c42..e588b71 100644 --- a/packages/widget-scatterplot/src/app.tsx +++ b/packages/widget-scatterplot/src/app.tsx @@ -1,10 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { mountWidgetApp } from "@repo/ui"; import { WidgetApp } from "./scatterplot"; -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Widget root element not found"); -} - -createRoot(rootElement).render(); +mountWidgetApp(); diff --git a/packages/widget-scatterplot/src/scatterplot.tsx b/packages/widget-scatterplot/src/scatterplot.tsx index 4afdd38..316d44f 100644 --- a/packages/widget-scatterplot/src/scatterplot.tsx +++ b/packages/widget-scatterplot/src/scatterplot.tsx @@ -1,43 +1,14 @@ -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import * as Plot from "@observablehq/plot"; import { ScatterplotWidgetPayload } from "@repo/domain/Chart"; -import { Option, Schema } from "effect"; -import { useEffect, useRef, useState } from "react"; - -const extractPayloadFromToolResult = ( - params: Parameters< - NonNullable["app"]>["ontoolresult"]> - >[0], -) => { - if (params.isError) { - return null; - } - - const structured = params.structuredContent; - const structuredPayload = Schema.decodeUnknownOption( - ScatterplotWidgetPayload, - )(structured); - if (Option.isSome(structuredPayload)) { - return structuredPayload.value; - } - - const text = params.content?.find((item) => item.type === "text")?.text; - if (text) { - try { - return Schema.decodeUnknownSync(ScatterplotWidgetPayload)( - JSON.parse(text), - ); - } catch { - return null; - } - } - - return null; -}; - -type SizeChangedSender = { - sendSizeChanged: (params: { width?: number; height?: number }) => unknown; -}; +import { + type HostApp, + usePlotRenderer, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; +import { useRef } from "react"; const Scatterplot = ({ data, @@ -45,144 +16,68 @@ const Scatterplot = ({ marks, app, }: typeof ScatterplotWidgetPayload.Type & { - app?: SizeChangedSender | null; + app?: HostApp | null; }) => { - const wrapperRef = useRef(null); const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(null); - const lastSizeRef = useRef<{ width: number; height: number } | null>(null); - const scheduledRef = useRef(false); - - useEffect(() => { - if (!containerRef.current) { - return undefined; - } - - const layoutOptions = layout ?? {}; - const { width: layoutWidth, ...restLayoutOptions } = layoutOptions; - const width = layoutWidth ?? measuredWidth ?? undefined; - - const plot = Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - Plot.ruleX([0]), - Plot.ruleY([0]), - Plot.dot(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], - }); - - containerRef.current.replaceChildren(plot); - return () => plot.remove(); - }, [data, layout, marks, measuredWidth]); - - useEffect(() => { - if (!wrapperRef.current) { - return undefined; - } - - const element = wrapperRef.current; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const nextWidth = Math.floor(entry.contentRect.width); - if (Number.isFinite(nextWidth) && nextWidth > 0) { - setMeasuredWidth(nextWidth); - } - - if (!app) { - return; - } - - if (scheduledRef.current) { - return; - } - - scheduledRef.current = true; - requestAnimationFrame(() => { - scheduledRef.current = false; - const width = Math.ceil(entry.contentRect.width); - const height = Math.ceil(entry.contentRect.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return; - } - const lastSize = lastSizeRef.current; - if (lastSize?.width === width && lastSize?.height === height) { - return; - } - lastSizeRef.current = { width, height }; - void app.sendSizeChanged({ width, height }); + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + + usePlotRenderer({ + containerRef, + ...(width ? { width } : {}), + dependencies: [data, layout, marks], + renderPlot: ({ width }: { width?: number }) => { + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + + return Plot.plot({ + style: "--plot-background: var(--color-background-primary);", + ...restLayoutOptions, + ...(width ? { width } : {}), + color: { + ...(layoutOptions.color ? layoutOptions.color : {}), + scheme: "Category10", + }, + marks: [ + Plot.ruleX([0]), + Plot.ruleY([0]), + Plot.dot(data, { + ...marks, + x: marks?.x ?? "x", + y: marks?.y ?? "y", + }), + ], }); - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [app]); + }, + }); return ( -
+
-
+
); }; export const WidgetApp = () => { - const [payload, setPayload] = useState< - typeof ScatterplotWidgetPayload.Type | null - >(null); - const lastPayloadRef = useRef(null); - const updatePayload = (next: typeof ScatterplotWidgetPayload.Type) => { - const serialized = JSON.stringify(next); - if (lastPayloadRef.current === serialized) { - return; - } - lastPayloadRef.current = serialized; - setPayload(next); - }; - const { app, isConnected, error } = useApp({ - appInfo: { name: "widget-scatterplot", version: "0.1.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (params) => { - const parsed = extractPayloadFromToolResult(params); - if (parsed) { - updatePayload(parsed); - } - }; + const { app, isConnected, error, payload } = useWidgetPayload( + ScatterplotWidgetPayload, + { + appInfo: { name: "widget-scatterplot", version: "0.1.0" }, + capabilities: {}, }, - }); - - useHostStyles(app, app?.getHostContext()); + ); if (error) { - return
Error: {error.message}
; + return ; } if (!isConnected) { - return
Connecting…
; + return ; } if (!payload) { - return
Waiting for data…
; + return ; } return ; From 6c6181436908312251c246214d8fa2c16107b1e5 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:19:06 +0200 Subject: [PATCH 5/8] refactor: break up chart domain schema --- packages/domain/src/Chart.ts | 513 +---------------------- packages/domain/src/Chart/BarChart.ts | 99 +++++ packages/domain/src/Chart/LineChart.ts | 94 +++++ packages/domain/src/Chart/Scatterplot.ts | 100 +++++ packages/domain/src/Chart/shared.ts | 207 +++++++++ 5 files changed, 511 insertions(+), 502 deletions(-) create mode 100644 packages/domain/src/Chart/BarChart.ts create mode 100644 packages/domain/src/Chart/LineChart.ts create mode 100644 packages/domain/src/Chart/Scatterplot.ts create mode 100644 packages/domain/src/Chart/shared.ts diff --git a/packages/domain/src/Chart.ts b/packages/domain/src/Chart.ts index 4ef4403..fae1a99 100644 --- a/packages/domain/src/Chart.ts +++ b/packages/domain/src/Chart.ts @@ -1,502 +1,11 @@ -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 RangeInterval = Schema.Union([ - Schema.Number, - Schema.Literals([ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "half", - "year", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ]), -]); - -const IntervalValue = RangeInterval.annotate({ plotChannel: "interval" }); -const AxisTickValue = Schema.Union([Schema.Number, Schema.String, Schema.Date]); -const AxisTicks = Schema.Union([ - Schema.Number, - Schema.String, - Schema.Array(AxisTickValue), -]); -const ScaleDomain = Schema.Array(AxisTickValue); -const ScaleType = Schema.Union([ - Schema.Literal("linear").annotate({ - description: - "Default for quantitative values (counts, measurements). Avoid for years if you want calendar ticks.", - }), - Schema.Literal("log").annotate({ - description: "Use for values spanning orders of magnitude (must be > 0).", - }), - Schema.Literal("symlog").annotate({ - description: "Use for wide-range values that cross zero.", - }), - Schema.Literal("pow").annotate({ - description: "Use for power transforms when you need a custom exponent.", - }), - Schema.Literal("sqrt").annotate({ - description: - "Use for square-root scaling (often for size/radius encodings).", - }), - Schema.Literal("utc").annotate({ - description: - "Use for dates or years so ticks follow the calendar in UTC (recommended).", - }), - Schema.Literal("time").annotate({ - description: - "Use for dates or years when you want local time instead of UTC.", - }), - Schema.Literal("point").annotate({ - description: "Use for ordinal categories shown as discrete points.", - }), - Schema.Literal("band").annotate({ - description: "Use for ordinal categories with width (bars, rects).", - }), - Schema.Literal("ordinal").annotate({ - description: "Use for ordered categories (strings, booleans).", - }), - Schema.Literal("categorical").annotate({ - description: "Use for unordered categories; mainly for color scales.", - }), - Schema.Literal("identity").annotate({ - description: "Use to bypass scaling and interpret values directly.", - }), -]); - -const LineCurve = Schema.Literals([ - "basis", - "basis-open", - "basis-closed", - "bump-x", - "bump-y", - "bundle", - "cardinal", - "cardinal-open", - "cardinal-closed", - "catmull-rom", - "catmull-rom-open", - "catmull-rom-closed", - "linear", - "linear-closed", - "monotone-x", - "monotone-y", - "natural", - "step", - "step-after", - "step-before", - "auto", -]); - -const LineMarker = Schema.Literals([ - "none", - "arrow", - "arrow-reverse", - "dot", - "circle", - "circle-stroke", - "tick", - "tick-x", - "tick-y", -]); - -const DotSymbol = Schema.Literals([ - "circle", - "cross", - "diamond", - "square", - "star", - "triangle", - "wye", - "plus", - "times", - "triangle2", - "asterisk", - "square2", - "diamond2", - "hexagon", -]); - -export const BarDatumValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.NumberFromString, - Schema.Boolean, - Schema.Date, - Schema.Null, -]); - -export const LineDatumValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.NumberFromString, - Schema.Boolean, - Schema.Date, - Schema.Null, -]); - -export const DotDatumValue = 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. For temporal axes, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", - 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)); - -export const LineDatum = Schema.Record(Schema.String, LineDatumValue).annotate({ - description: - "Single line data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (temporal/quantitative) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", - examples: [ - { x: "2024-01-01", y: 120 }, - { date: "2024-01-01", value: 48, series: "Alpha" }, - ], -}); - -export const LineDatumDefaults = Schema.Struct({ - x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), - y: Schema.Union([Schema.Number, Schema.NumberFromString]), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const DotDatum = Schema.Record(Schema.String, DotDatumValue).annotate({ - description: - "Single dot data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (quantitative/temporal) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", - examples: [ - { x: 10, y: 22 }, - { date: "2024-01-01", value: 48, category: "Alpha" }, - ], -}); - -export const DotDatumDefaults = Schema.Struct({ - x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), - y: 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, - - x: Schema.Struct({ - type: ScaleType, - domain: ScaleDomain, - ticks: AxisTicks, - tickFormat: Schema.Union([Schema.String, Schema.Null]), - // tickRotate: Schema.Number, - label: Schema.Union([Schema.String, Schema.Null]), - interval: IntervalValue, - // grid: Schema.Boolean, - }).mapFields(Struct.map(Schema.optionalKey)), - y: Schema.Struct({ - type: ScaleType, - domain: ScaleDomain, - ticks: AxisTicks, - tickFormat: Schema.Union([Schema.String, Schema.Null]), - // tickRotate: Schema.Number, - label: Schema.Union([Schema.String, Schema.Null]), - interval: IntervalValue, - // grid: Schema.Boolean, - }).mapFields(Struct.map(Schema.optionalKey)), - - color: Schema.Struct({ - legend: Schema.Boolean, - }), -}).mapFields(Struct.map(Schema.optionalKey)); - -PlotLayoutProps.annotate({ - description: - "Plot layout options. For year values (even if provided as whole numbers), set layout.x.type to 'utc' or 'time' and use a time tick format such as '%Y' with ticks or interval set to 'year'.", - examples: [ - { - x: { - type: "utc", - ticks: "year", - tickFormat: "%Y", - interval: "year", - }, - }, - ], -}); - -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 LineMarkProps = Schema.Struct({ - x: Channel.annotate({ - description: - "Horizontal position channel. For line charts, typically temporal or quantitative.", - }), - y: Channel.annotate({ - description: - "Vertical position channel. For line charts, typically quantitative.", - }), - z: Channel.annotate({ - description: - "Series/channel for grouping multiple lines. Typically a categorical value such as a series name.", - }), - - stroke: Channel.annotate({ - description: - "Stroke color channel. If specified, consider also setting z to avoid segmenting on color changes.", - }), - strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: - "Stroke width in pixels, or a channel name for per-point width.", - }), - strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: - "Stroke opacity (0-1), or a channel name for per-point opacity.", - }), - - curve: LineCurve.annotate({ - description: - "Curve interpolation name (e.g. linear, step, basis, catmull-rom).", - }), - marker: Schema.Union([Schema.Boolean, LineMarker]).annotate({ - description: - "Marker to draw at each point (true for default, or marker name).", - }), - sort: Schema.Union([ - Schema.String, - Schema.Struct({ - channel: Schema.String, - order: Schema.Literals(["ascending", "descending"]), - }), - ]).annotate({ - description: - "Sort option to order points or series. Use a channel name or { channel, order }.", - }), - - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", - }), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const DotMarkProps = Schema.Struct({ - x: Channel.annotate({ - description: - "Horizontal position channel. For scatterplots, typically quantitative or temporal.", - }), - y: Channel.annotate({ - description: - "Vertical position channel. For scatterplots, typically quantitative.", - }), - - r: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Dot radius in pixels, or a channel name for per-point size.", - }), - symbol: Schema.Union([Channel, DotSymbol]).annotate({ - description: - "Symbol name or channel. Use a categorical field to map different symbols.", - }), - rotate: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Rotation angle in degrees clockwise, or a channel name.", - }), - - fill: Channel.annotate({ - description: "Fill color channel. Maps to dot fill color.", - }), - fillOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Fill opacity (0-1), or a channel name.", - }), - stroke: Channel.annotate({ - description: "Stroke color channel. Maps to dot outline color.", - }), - strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Stroke width in pixels, or a channel name.", - }), - strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Stroke opacity (0-1), or a channel name.", - }), - opacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Overall opacity (0-1), or a channel name.", - }), - - sort: Schema.Union([ - Schema.String, - Schema.Null, - Schema.Struct({ - channel: Schema.String, - order: Schema.Literals(["ascending", "descending"]), - }), - ]).annotate({ - description: - "Sort option to order dots. Use a channel name, { channel, order }, or null for input order.", - }), - - 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, Schema.Null]), - 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 means x=category and y=value; horizontal means x=value and y=category.", - }) - .pipe(Schema.optionalKey), - layout: PlotLayoutProps.annotate({ - description: - "Plot layout options. All fields optional; omit to use defaults.", - }).pipe(Schema.optionalKey), - marks: BarMarkProps.annotate({ - description: - "Mark channel mapping. Use marks.x and marks.y to select data keys that match your data objects. For vertical bars, set x=category and y=value. For horizontal bars, set x=value and y=category.", - }), -}).annotate({ - description: - "Payload for render_bar_chart_widget. Align marks.x/marks.y with the data keys for the chosen orientation.", - examples: [ - { - data: [ - { category: "Q1", value: 120 }, - { category: "Q2", value: 98 }, - ], - direction: "vertical", - marks: { x: "category", y: "value" }, - }, - { - data: [ - { breed: "Golden Retriever", popularity: 9 }, - { breed: "Labrador Retriever", popularity: 8.5 }, - ], - direction: "horizontal", - marks: { x: "popularity", y: "breed" }, - }, - ], -}); - -export const LineChartWidgetPayload = Schema.Struct({ - data: Schema.Array(LineDatum).annotate({ - description: - "Array of line data objects. Each object must expose the keys used in marks.x and marks.y.", - }), - layout: PlotLayoutProps.annotate({ - description: - "Plot layout options. All fields optional; omit to use defaults.", - }).pipe(Schema.optionalKey), - marks: LineMarkProps.annotate({ - description: - "Line mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", - }).pipe(Schema.optionalKey), -}).annotate({ - description: - "Payload for render_line_chart_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", -}); - -export const ScatterplotWidgetPayload = Schema.Struct({ - data: Schema.Array(DotDatum).annotate({ - description: - "Array of dot data objects. Each object must expose the keys used in marks.x and marks.y.", - }), - layout: PlotLayoutProps.annotate({ - description: - "Plot layout options. All fields optional; omit to use defaults.", - }).pipe(Schema.optionalKey), - marks: DotMarkProps.annotate({ - description: - "Dot mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", - }), -}).annotate({ - description: - "Payload for render_scatterplot_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", -}); +export * from "./Chart/AreaChart"; +export * from "./Chart/BarChart"; +export * from "./Chart/BoxPlot"; +export * from "./Chart/BulletChart"; +export * from "./Chart/GeoChart"; +export * from "./Chart/Heatmap"; +export * from "./Chart/Histogram"; +export * from "./Chart/LineChart"; +export * from "./Chart/Scatterplot"; +export * from "./Chart/shared"; +export * from "./Chart/Treemap"; diff --git a/packages/domain/src/Chart/BarChart.ts b/packages/domain/src/Chart/BarChart.ts new file mode 100644 index 0000000..e39e444 --- /dev/null +++ b/packages/domain/src/Chart/BarChart.ts @@ -0,0 +1,99 @@ +import { Schema, Struct } from "effect"; +import { + Channel, + IntervalValue, + NumberGreaterThanZero, + PlotLayoutProps, +} from "./shared"; + +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. For temporal axes, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", + 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)); + +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.", + }), + y: Channel.annotate({ + description: + "Primary value channel. For vertical bars, maps to the value axis; for horizontal bars, maps to the category/ordinal axis.", + }), + 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.", + }), + stroke: Channel.annotate({ + description: "Stroke color channel. Maps to the outline color of the bars.", + }), + tip: Schema.Boolean.annotate({ + description: "If true, show a tooltip with all data fields on hover.", + }), +}).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 means x=category and y=value; horizontal means x=value and y=category.", + }) + .pipe(Schema.optionalKey), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: BarMarkProps.annotate({ + description: + "Mark channel mapping. Use marks.x and marks.y to select data keys that match your data objects. For vertical bars, set x=category and y=value. For horizontal bars, set x=value and y=category.", + }), +}).annotate({ + description: + "Payload for render_bar_chart_widget. Align marks.x/marks.y with the data keys for the chosen orientation.", + examples: [ + { + data: [ + { category: "Q1", value: 120 }, + { category: "Q2", value: 98 }, + ], + direction: "vertical", + marks: { x: "category", y: "value" }, + }, + { + data: [ + { breed: "Golden Retriever", popularity: 9 }, + { breed: "Labrador Retriever", popularity: 8.5 }, + ], + direction: "horizontal", + marks: { x: "popularity", y: "breed" }, + }, + ], +}); diff --git a/packages/domain/src/Chart/LineChart.ts b/packages/domain/src/Chart/LineChart.ts new file mode 100644 index 0000000..cf1163f --- /dev/null +++ b/packages/domain/src/Chart/LineChart.ts @@ -0,0 +1,94 @@ +import { Schema, Struct } from "effect"; +import { Channel, LineCurve, LineMarker, PlotLayoutProps } from "./shared"; + +export const LineDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const LineDatum = Schema.Record(Schema.String, LineDatumValue).annotate({ + description: + "Single line data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (temporal/quantitative) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", + examples: [ + { x: "2024-01-01", y: 120 }, + { date: "2024-01-01", value: 48, series: "Alpha" }, + ], +}); + +export const LineDatumDefaults = Schema.Struct({ + x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), + y: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const LineMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: + "Horizontal position channel. For line charts, typically temporal or quantitative.", + }), + y: Channel.annotate({ + description: + "Vertical position channel. For line charts, typically quantitative.", + }), + z: Channel.annotate({ + description: + "Series/channel for grouping multiple lines. Typically a categorical value such as a series name.", + }), + + stroke: Channel.annotate({ + description: + "Stroke color channel. If specified, consider also setting z to avoid segmenting on color changes.", + }), + strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: + "Stroke width in pixels, or a channel name for per-point width.", + }), + strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: + "Stroke opacity (0-1), or a channel name for per-point opacity.", + }), + + curve: LineCurve.annotate({ + description: + "Curve interpolation name (e.g. linear, step, basis, catmull-rom).", + }), + marker: Schema.Union([Schema.Boolean, LineMarker]).annotate({ + description: + "Marker to draw at each point (true for default, or marker name).", + }), + sort: Schema.Union([ + Schema.String, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), + ]).annotate({ + description: + "Sort option to order points or series. Use a channel name or { channel, order }.", + }), + + tip: Schema.Boolean.annotate({ + description: "If true, show a tooltip with all data fields on hover.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const LineChartWidgetPayload = Schema.Struct({ + data: Schema.Array(LineDatum).annotate({ + description: + "Array of line data objects. Each object must expose the keys used in marks.x and marks.y.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: LineMarkProps.annotate({ + description: + "Line mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_line_chart_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", +}); diff --git a/packages/domain/src/Chart/Scatterplot.ts b/packages/domain/src/Chart/Scatterplot.ts new file mode 100644 index 0000000..ac468c3 --- /dev/null +++ b/packages/domain/src/Chart/Scatterplot.ts @@ -0,0 +1,100 @@ +import { Schema, Struct } from "effect"; +import { Channel, DotSymbol, PlotLayoutProps } from "./shared"; + +export const DotDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const DotDatum = Schema.Record(Schema.String, DotDatumValue).annotate({ + description: + "Single dot data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (quantitative/temporal) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", + examples: [ + { x: 10, y: 22 }, + { date: "2024-01-01", value: 48, category: "Alpha" }, + ], +}); + +export const DotDatumDefaults = Schema.Struct({ + x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), + y: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const DotMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: + "Horizontal position channel. For scatterplots, typically quantitative or temporal.", + }), + y: Channel.annotate({ + description: + "Vertical position channel. For scatterplots, typically quantitative.", + }), + + r: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: "Dot radius in pixels, or a channel name for per-point size.", + }), + symbol: Schema.Union([Channel, DotSymbol]).annotate({ + description: + "Symbol name or channel. Use a categorical field to map different symbols.", + }), + rotate: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: "Rotation angle in degrees clockwise, or a channel name.", + }), + + fill: Channel.annotate({ + description: "Fill color channel. Maps to dot fill color.", + }), + fillOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: "Fill opacity (0-1), or a channel name.", + }), + stroke: Channel.annotate({ + description: "Stroke color channel. Maps to dot outline color.", + }), + strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: "Stroke width in pixels, or a channel name.", + }), + strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: "Stroke opacity (0-1), or a channel name.", + }), + opacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: "Overall opacity (0-1), or a channel name.", + }), + + sort: Schema.Union([ + Schema.String, + Schema.Null, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), + ]).annotate({ + description: + "Sort option to order dots. Use a channel name, { channel, order }, or null for input order.", + }), + + tip: Schema.Boolean.annotate({ + description: "If true, show a tooltip with all data fields on hover.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const ScatterplotWidgetPayload = Schema.Struct({ + data: Schema.Array(DotDatum).annotate({ + description: + "Array of dot data objects. Each object must expose the keys used in marks.x and marks.y.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: DotMarkProps.annotate({ + description: + "Dot mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", + }), +}).annotate({ + description: + "Payload for render_scatterplot_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", +}); diff --git a/packages/domain/src/Chart/shared.ts b/packages/domain/src/Chart/shared.ts new file mode 100644 index 0000000..50488d4 --- /dev/null +++ b/packages/domain/src/Chart/shared.ts @@ -0,0 +1,207 @@ +import { Schema, Struct } from "effect"; + +export const ChannelString = Schema.String.annotate({ plotChannel: "field" }); +export const ChannelNumber = Schema.Number.annotate({ plotChannel: "value" }); +export const Channel = Schema.Union([ChannelString, ChannelNumber]); +export const RangeInterval = Schema.Union([ + Schema.Number, + Schema.Literals([ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "half", + "year", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ]), +]); + +export const IntervalValue = RangeInterval.annotate({ + plotChannel: "interval", +}); +export const AxisTickValue = Schema.Union([ + Schema.Number, + Schema.String, + Schema.Date, +]); +export const AxisTicks = Schema.Union([ + Schema.Number, + Schema.String, + Schema.Array(AxisTickValue), +]); +export const ScaleDomain = Schema.Array(AxisTickValue); +export const ScaleType = Schema.Union([ + Schema.Literal("linear").annotate({ + description: + "Default for quantitative values (counts, measurements). Avoid for years if you want calendar ticks.", + }), + Schema.Literal("log").annotate({ + description: "Use for values spanning orders of magnitude (must be > 0).", + }), + Schema.Literal("symlog").annotate({ + description: "Use for wide-range values that cross zero.", + }), + Schema.Literal("pow").annotate({ + description: "Use for power transforms when you need a custom exponent.", + }), + Schema.Literal("sqrt").annotate({ + description: + "Use for square-root scaling (often for size/radius encodings).", + }), + Schema.Literal("utc").annotate({ + description: + "Use for dates or years so ticks follow the calendar in UTC (recommended).", + }), + Schema.Literal("time").annotate({ + description: + "Use for dates or years when you want local time instead of UTC.", + }), + Schema.Literal("point").annotate({ + description: "Use for ordinal categories shown as discrete points.", + }), + Schema.Literal("band").annotate({ + description: "Use for ordinal categories with width (bars, rects).", + }), + Schema.Literal("ordinal").annotate({ + description: "Use for ordered categories (strings, booleans).", + }), + Schema.Literal("categorical").annotate({ + description: "Use for unordered categories; mainly for color scales.", + }), + Schema.Literal("identity").annotate({ + description: "Use to bypass scaling and interpret values directly.", + }), +]); + +export const LineCurve = Schema.Literals([ + "basis", + "basis-open", + "basis-closed", + "bump-x", + "bump-y", + "bundle", + "cardinal", + "cardinal-open", + "cardinal-closed", + "catmull-rom", + "catmull-rom-open", + "catmull-rom-closed", + "linear", + "linear-closed", + "monotone-x", + "monotone-y", + "natural", + "step", + "step-after", + "step-before", + "auto", +]); + +export const LineMarker = Schema.Literals([ + "none", + "arrow", + "arrow-reverse", + "dot", + "circle", + "circle-stroke", + "tick", + "tick-x", + "tick-y", +]); + +export const DotSymbol = Schema.Literals([ + "circle", + "cross", + "diamond", + "square", + "star", + "triangle", + "wye", + "plus", + "times", + "triangle2", + "asterisk", + "square2", + "diamond2", + "hexagon", +]); + +export 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, + + x: Schema.Struct({ + type: ScaleType, + domain: ScaleDomain, + ticks: AxisTicks, + tickFormat: Schema.Union([Schema.String, Schema.Null]), + label: Schema.Union([Schema.String, Schema.Null]), + interval: IntervalValue, + }).mapFields(Struct.map(Schema.optionalKey)), + y: Schema.Struct({ + type: ScaleType, + domain: ScaleDomain, + ticks: AxisTicks, + tickFormat: Schema.Union([Schema.String, Schema.Null]), + label: Schema.Union([Schema.String, Schema.Null]), + interval: IntervalValue, + }).mapFields(Struct.map(Schema.optionalKey)), + + color: Schema.Struct({ + legend: Schema.Boolean, + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +PlotLayoutProps.annotate({ + description: + "Plot layout options. For year values (even if provided as whole numbers), set layout.x.type to 'utc' or 'time' and use a time tick format such as '%Y' with ticks or interval set to 'year'.", + examples: [ + { + x: { + type: "utc", + ticks: "year", + tickFormat: "%Y", + interval: "year", + }, + }, + ], +}); + +export const AxisMarkProps = Schema.Struct({ + x: Channel, + y: Channel, + + anchor: Schema.Literals(["top", "right", "bottom", "left"]), + ticks: AxisTicks, + tickFormat: Schema.Union([Schema.String, Schema.Unknown, Schema.Null]), + tickRotate: Schema.Number, + label: Schema.Union([Schema.String, Schema.Null]), + labelAnchor: Schema.Literals(["top", "right", "bottom", "left", "center"]), +}).mapFields(Struct.map(Schema.optionalKey)); From 4d93ffbacc8c9adc0817595e9ee3c83b953b8d55 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Mon, 6 Apr 2026 10:54:29 +0200 Subject: [PATCH 6/8] refactor: use the PlotFigure --- bun.lock | 1 + packages/ui/package.json | 1 + packages/ui/src/PlotFigure.tsx | 37 +++++++++++ packages/ui/src/index.ts | 1 + packages/widget-bar-chart/src/bar-chart.tsx | 61 ++++++++----------- packages/widget-line-chart/src/line-chart.tsx | 54 +++++++--------- .../widget-scatterplot/src/scatterplot.tsx | 56 +++++++---------- 7 files changed, 111 insertions(+), 100 deletions(-) create mode 100644 packages/ui/src/PlotFigure.tsx diff --git a/bun.lock b/bun.lock index 129e311..8be800f 100644 --- a/bun.lock +++ b/bun.lock @@ -155,6 +155,7 @@ "version": "0.0.0", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", "effect": "4.0.0-beta.43", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/packages/ui/package.json b/packages/ui/package.json index ae2db83..c2d19cd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", "effect": "4.0.0-beta.43", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/packages/ui/src/PlotFigure.tsx b/packages/ui/src/PlotFigure.tsx new file mode 100644 index 0000000..12ac964 --- /dev/null +++ b/packages/ui/src/PlotFigure.tsx @@ -0,0 +1,37 @@ +import * as Plot from "@observablehq/plot"; +import { type ComponentPropsWithoutRef, type RefObject, useRef } from "react"; + +import { usePlotRenderer } from "./usePlotRenderer"; + +type PlotOptions = Parameters[0]; + +export type PlotFigureProps = Omit< + ComponentPropsWithoutRef<"div">, + "children" +> & { + options: PlotOptions; + dependencies?: ReadonlyArray; + containerRef?: RefObject; +}; + +export const PlotFigure = ({ + options, + dependencies, + containerRef, + ...props +}: PlotFigureProps) => { + const internalRef = useRef(null); + const plotContainerRef = containerRef ?? internalRef; + + usePlotRenderer({ + containerRef: plotContainerRef, + dependencies: dependencies ?? [options], + renderPlot: () => + Plot.plot({ + style: "--plot-background: var(--color-background-primary);", + ...options, + }), + }); + + return
; +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3b2d7e9..e9ea3a3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ +export { PlotFigure, type PlotFigureProps } from "./PlotFigure"; export { usePlotRenderer } from "./usePlotRenderer"; export { useWidgetPayload } from "./useWidgetPayload"; export { useWidgetResize } from "./useWidgetResize"; diff --git a/packages/widget-bar-chart/src/bar-chart.tsx b/packages/widget-bar-chart/src/bar-chart.tsx index 41c5b02..77b1d25 100644 --- a/packages/widget-bar-chart/src/bar-chart.tsx +++ b/packages/widget-bar-chart/src/bar-chart.tsx @@ -2,13 +2,12 @@ import * as Plot from "@observablehq/plot"; import { BarChartWidgetPayload } from "@repo/domain/Chart"; import { type HostApp, - usePlotRenderer, + PlotFigure, useWidgetPayload, useWidgetResize, WidgetFrame, WidgetStatus, } from "@repo/ui"; -import { useRef } from "react"; const BarChart = ({ data, @@ -17,46 +16,38 @@ const BarChart = ({ marks, app, }: typeof BarChartWidgetPayload.Type & { app?: HostApp | null }) => { - const containerRef = useRef(null); const { frameRef, measuredWidth } = useWidgetResize(app); const width = layout?.width ?? measuredWidth ?? undefined; - usePlotRenderer({ - containerRef, - ...(width ? { width } : {}), - dependencies: [data, direction, layout, marks], - renderPlot: ({ width }: { width?: number }) => { - const layoutOptions = layout ?? {}; - const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; - const isHorizontal = direction === "horizontal"; - const barOptions = { - ...marks, - x: marks?.x ?? (isHorizontal ? "value" : "category"), - y: marks?.y ?? (isHorizontal ? "category" : "value"), - }; - - return Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - isHorizontal ? Plot.ruleX([0]) : Plot.ruleY([0]), - isHorizontal - ? Plot.barX(data, barOptions) - : Plot.barY(data, barOptions), - ].filter(Boolean), - }); - }, - }); + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const isHorizontal = direction === "horizontal"; + const barOptions = { + ...marks, + x: marks?.x ?? (isHorizontal ? "value" : "category"), + y: marks?.y ?? (isHorizontal ? "category" : "value"), + }; return ( -
+ ); }; diff --git a/packages/widget-line-chart/src/line-chart.tsx b/packages/widget-line-chart/src/line-chart.tsx index 00423bc..fdfa3a9 100644 --- a/packages/widget-line-chart/src/line-chart.tsx +++ b/packages/widget-line-chart/src/line-chart.tsx @@ -2,13 +2,12 @@ import * as Plot from "@observablehq/plot"; import { LineChartWidgetPayload } from "@repo/domain/Chart"; import { type HostApp, - usePlotRenderer, + PlotFigure, useWidgetPayload, useWidgetResize, WidgetFrame, WidgetStatus, } from "@repo/ui"; -import { useRef } from "react"; const LineChart = ({ data, @@ -16,42 +15,33 @@ const LineChart = ({ marks, app, }: typeof LineChartWidgetPayload.Type & { app?: HostApp | null }) => { - const containerRef = useRef(null); const { frameRef, measuredWidth } = useWidgetResize(app); const width = layout?.width ?? measuredWidth ?? undefined; - - usePlotRenderer({ - containerRef, - ...(width ? { width } : {}), - dependencies: [data, layout, marks], - renderPlot: ({ width }: { width?: number }) => { - const layoutOptions = layout ?? {}; - const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; - - return Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - Plot.ruleY([0]), - Plot.line(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], - }); - }, - }); + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; return ( -
+ ); }; diff --git a/packages/widget-scatterplot/src/scatterplot.tsx b/packages/widget-scatterplot/src/scatterplot.tsx index 316d44f..3f0ece1 100644 --- a/packages/widget-scatterplot/src/scatterplot.tsx +++ b/packages/widget-scatterplot/src/scatterplot.tsx @@ -2,13 +2,12 @@ import * as Plot from "@observablehq/plot"; import { ScatterplotWidgetPayload } from "@repo/domain/Chart"; import { type HostApp, - usePlotRenderer, + PlotFigure, useWidgetPayload, useWidgetResize, WidgetFrame, WidgetStatus, } from "@repo/ui"; -import { useRef } from "react"; const Scatterplot = ({ data, @@ -18,43 +17,34 @@ const Scatterplot = ({ }: typeof ScatterplotWidgetPayload.Type & { app?: HostApp | null; }) => { - const containerRef = useRef(null); const { frameRef, measuredWidth } = useWidgetResize(app); const width = layout?.width ?? measuredWidth ?? undefined; - - usePlotRenderer({ - containerRef, - ...(width ? { width } : {}), - dependencies: [data, layout, marks], - renderPlot: ({ width }: { width?: number }) => { - const layoutOptions = layout ?? {}; - const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; - - return Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - Plot.ruleX([0]), - Plot.ruleY([0]), - Plot.dot(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], - }); - }, - }); + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; return ( -
+ ); }; From 3169b8e1db22ed01a5b15e35b2d84800a6a23134 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Mon, 6 Apr 2026 11:16:32 +0200 Subject: [PATCH 7/8] docs: add widget-schema skill --- .agents/skills/widget-schema/SKILL.md | 306 ++++++++++++++++++++++++++ skills-lock.json | 10 + 2 files changed, 316 insertions(+) create mode 100644 .agents/skills/widget-schema/SKILL.md create mode 100644 skills-lock.json diff --git a/.agents/skills/widget-schema/SKILL.md b/.agents/skills/widget-schema/SKILL.md new file mode 100644 index 0000000..0a3adda --- /dev/null +++ b/.agents/skills/widget-schema/SKILL.md @@ -0,0 +1,306 @@ +--- +name: widget-schema +description: Design, review, or refactor LLM-facing schemas for Observable Plot widget charts. Use this whenever the user asks to create a chart payload schema, simplify chart props, align a domain schema with a widget implementation, remove styling controls from chart configuration, or separate chart structure from renderer and Plot implementation details. Especially relevant for work in `packages/domain/src/Chart/`, `packages/widget-*/src/`, and prompt or resource docs that teach chart schemas. +--- + +# Widget Schema + +Design schemas for chart widgets that an LLM can use reliably. + +The job of the schema is to describe chart structure and data mapping. The job of the widget is to translate that structure into Observable Plot marks and apply the product's visual design defaults. + +## Use This Skill When + +Use this skill when the user asks to: + +- create a new chart widget payload schema +- simplify or refactor an existing chart schema +- review whether chart props are too implementation-specific +- align a domain schema with an Observable Plot widget implementation +- remove styling knobs from LLM-facing chart configuration +- make multiple chart schemas more consistent with each other + +This skill is especially relevant in `packages/domain/src/Chart/` and `packages/widget-*/src/`. + +## Core Model + +Keep these responsibilities separate: + +- Domain schema: chart intent, data mapping, structural variants +- Widget renderer: Plot translation, styling defaults, visual polish, color, opacity, line widths, curve choices, tooltip behavior +- Guidance/examples: teach the LLM the structural API, not renderer internals + +If a prop exists only because the current widget implementation happens to use a specific Plot option, it probably does not belong in the schema. + +## Design Principles + +### 1. Prefer chart semantics over Plot internals + +Use names that reflect what the user means, not what Plot happens to call the channel. + +Prefer: + +- `series` +- `variant` +- `stack` +- `direction` + +Avoid exposing Plot-specific implementation props unless they are truly the user concept: + +- `z` +- `x1` / `x2` +- `y1` / `y2` +- `fill` as a grouping proxy +- `stroke` as a grouping proxy + +Example: + +- Better schema prop: `series: "industry"` +- Widget translation: `z: series`, `fill: series`, `stroke: series` + +### 2. Keep schemas structural + +Good schema fields usually answer these questions: + +- Which field is x? +- Which field is y? +- Is there a grouping dimension? +- Which structural variant of the chart is this? +- Is sorting or interval regularization needed? + +These usually belong in the schema: + +- `x`, `y` +- `series` +- `variant` +- `direction` +- `interval` +- `sort` +- chart-specific structural fields such as `value`, `target`, `size`, `facet` + +These usually do not belong in the schema: + +- `fillOpacity` +- `strokeWidth` +- `strokeOpacity` +- `opacity` +- `curve` +- `symbol` +- `marker` +- color schemes +- tooltip toggles + +If the widget can choose a sensible default once and apply it consistently, keep that choice in the widget. + +### 3. Keep one widget focused on one chart concept + +Do not overload one schema to support several subtly different Plot constructors unless they are genuinely the same user-facing chart. + +If the widget is an area chart, optimize for the common `areaY` case. If band areas become a distinct product concept, prefer a separate schema or widget rather than a broad, mixed abstraction. + +### 4. Make defaults do the work + +LLM-facing schemas should be easy to use with minimal props. + +Good patterns: + +- default `x` to `"x"` +- default `y` to `"y"` +- default `variant` to the most useful presentation +- derive grouping/color behavior from `series` + +The fewer decisions the LLM has to make, the more consistent the outputs will be. + +### 5. Examples are part of the API + +Examples teach the model how to use the schema. + +Examples should emphasize: + +- data shape +- field mapping +- grouping/series +- structural variants + +Examples should not teach: + +- opacity tweaking +- stroke-width tweaking +- Plot implementation details + +## Review Workflow + +When asked to review or refactor a chart schema, follow this order: + +1. Read the domain schema. +2. Read the widget implementation that consumes it. +3. Check the relevant Observable Plot docs for the intended constructor. +4. Identify which props are structural and which are renderer styling. +5. Propose a smaller schema using semantic names. +6. If asked to implement, update: + - the domain schema + - the widget renderer + - LLM guidance, examples, and docs that teach the schema +7. Verify with the repo-required checks. + +Do not stop at only changing TypeScript types if examples or prompt resources still teach the old API. + +## Schema Rules + +### Field naming + +Prefer a shared vocabulary across chart types where possible: + +- `x` +- `y` +- `series` +- `variant` +- `direction` +- `interval` +- `sort` + +This consistency matters because the LLM learns from repetition across widgets. + +### Structural variants + +Use a small literal enum when the user is choosing between meaningful chart presentations. + +Example: + +```ts +export const AreaVariant = Schema.Literals(["area", "area-line"]); +``` + +This is better than leaking renderer implementation such as `showLine`, because it names the presentation rather than the drawing instruction. + +### Grouping and color + +If the user concept is “series”, expose `series`. + +Do not make the LLM decide between `z`, `fill`, and `stroke` unless the product actually needs those as separate concepts. + +The widget can translate: + +```ts +const series = marks?.series; + +Plot.areaY(data, { + x, + y, + ...(series ? { z: series, fill: series } : {}), +}); + +Plot.lineY(data, { + x, + y, + ...(series ? { z: series, stroke: series } : {}), +}); +``` + +### Styling defaults + +Put visual defaults in the widget, not the schema. + +Examples: + +```ts +fillOpacity: 0.3; +strokeWidth: 2; +tip: true; +scheme: "Category10"; +``` + +These are good renderer defaults because they make charts look right without asking the LLM to art-direct them. + +## Observable Plot Guidance + +Choose the schema shape to match the Plot constructor you actually want to support. + +Examples: + +- If the widget is built around `Plot.areaY`, expose the `x` and `y` shorthand model. +- If the widget is built around `Plot.lineY`, optimize for `x`, `y`, and optional `series`. +- Avoid exposing `x1` / `x2` / `y1` / `y2` unless the widget is intentionally modeling the lower-level constructor. + +The schema should reflect the supported abstraction, not every option Plot makes available. + +## Implementation Expectations + +When implementing a refactor with this skill: + +- keep the schema minimal +- preserve useful structural capabilities +- move style and polish into the widget +- update examples to the new API +- update prompt resources or guidance docs that teach the schema +- run the required verification commands for the repo + +If repo-wide checks fail because of unrelated pre-existing issues, state that clearly and do not treat them as caused by the schema refactor. + +## Output Format + +If the user asks for a review first, return: + +1. Findings about the current schema +2. A proposed simpler schema +3. The reasoning behind the separation of concerns +4. Any migration notes for the widget or docs + +If the user asks for implementation, make the code changes directly and then summarize: + +1. What changed in the schema +2. What moved into the widget +3. Which docs/examples were updated +4. Verification results + +## Examples + +### Good + +```ts +marks: { + x: "date", + y: "value", + series: "industry", + variant: "area-line", +} +``` + +Why it is good: + +- expresses data mapping clearly +- exposes the user concept of series +- exposes a structural presentation choice +- leaves styling to the widget + +### Bad + +```ts +marks: { + x: "date", + y: "value", + z: "industry", + fill: "industry", + fillOpacity: 0.2, + strokeWidth: 1, + curve: "basis", + tip: true, +} +``` + +Why it is bad: + +- mixes schema and styling concerns +- leaks Plot implementation details +- forces the LLM to make design decisions the widget should own +- teaches a noisier API than the product needs + +## Success Criteria + +The skill has done its job when: + +- the schema is easy for an LLM to use correctly +- the widget still has enough information to render the intended chart type +- the product owns visual consistency in the renderer +- examples and docs reinforce the simplified API +- similar chart widgets start converging on the same vocabulary diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..9f67b5f --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "simple": { + "source": "roin-orca/skills", + "sourceType": "github", + "computedHash": "cfde9dde00c9b1f8934e0312440a5ae2ed76dbd81e49ba7433e0e63ea6551d13" + } + } +} From 7a1c916cc85fbe6d63b3f13d379daa4ab560ebf2 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Mon, 6 Apr 2026 11:29:40 +0200 Subject: [PATCH 8/8] refactor: use widget skill --- .../src/prompt/bar-chart-one-shot.ts | 7 +- packages/domain/src/Chart.ts | 7 -- packages/domain/src/Chart/BarChart.ts | 48 +++++++------- packages/domain/src/Chart/LineChart.ts | 60 +++++++---------- packages/domain/src/Chart/Scatterplot.ts | 66 ++++++++----------- packages/widget-bar-chart/src/bar-chart.tsx | 7 +- packages/widget-line-chart/src/line-chart.tsx | 20 +++--- .../widget-scatterplot/src/scatterplot.tsx | 23 ++++--- 8 files changed, 108 insertions(+), 130 deletions(-) diff --git a/apps/server-mcp/src/prompt/bar-chart-one-shot.ts b/apps/server-mcp/src/prompt/bar-chart-one-shot.ts index 7973a63..a38eb78 100644 --- a/apps/server-mcp/src/prompt/bar-chart-one-shot.ts +++ b/apps/server-mcp/src/prompt/bar-chart-one-shot.ts @@ -17,7 +17,7 @@ export const barChartOneShot = McpServer.prompt({ | - Vertical: x=category, y=value. | - Horizontal: x=value, y=category. |- Use layout for sizing, margins, title/subtitle/caption, and grid. - |- If you need groups/series, use marks.fill (or marks.z) with a series field. + |- If you need stacked bars, use marks.series with a categorical series field. | |Return only the JSON payload that matches BarChartWidgetPayload. |## Best practices (short) @@ -25,7 +25,7 @@ export const barChartOneShot = McpServer.prompt({ |- Include a clear title and a brief caption for context/source. |- Use direction: "horizontal" for long category labels. |- Keep margins generous when labels are rotated or long. - |- Use marks.fill only when multiple series are truly needed. + |- Use marks.series only when multiple series are truly needed. | |Example payload |{ @@ -46,8 +46,7 @@ export const barChartOneShot = McpServer.prompt({ | }, | "marks": { | "x": "month", - | "y": "sales", - | "tip": true + | "y": "sales" | } |} | diff --git a/packages/domain/src/Chart.ts b/packages/domain/src/Chart.ts index fae1a99..6503820 100644 --- a/packages/domain/src/Chart.ts +++ b/packages/domain/src/Chart.ts @@ -1,11 +1,4 @@ -export * from "./Chart/AreaChart"; export * from "./Chart/BarChart"; -export * from "./Chart/BoxPlot"; -export * from "./Chart/BulletChart"; -export * from "./Chart/GeoChart"; -export * from "./Chart/Heatmap"; -export * from "./Chart/Histogram"; export * from "./Chart/LineChart"; export * from "./Chart/Scatterplot"; export * from "./Chart/shared"; -export * from "./Chart/Treemap"; diff --git a/packages/domain/src/Chart/BarChart.ts b/packages/domain/src/Chart/BarChart.ts index e39e444..36c22c8 100644 --- a/packages/domain/src/Chart/BarChart.ts +++ b/packages/domain/src/Chart/BarChart.ts @@ -1,10 +1,5 @@ import { Schema, Struct } from "effect"; -import { - Channel, - IntervalValue, - NumberGreaterThanZero, - PlotLayoutProps, -} from "./shared"; +import { Channel, IntervalValue, PlotLayoutProps } from "./shared"; export const BarDatumValue = Schema.Union([ Schema.String, @@ -32,27 +27,26 @@ export const BarDatumDefaults = Schema.Struct({ 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.", + "Field for the category or value axis. Defaults to 'category' for vertical bars and 'value' for horizontal bars.", }), y: Channel.annotate({ description: - "Primary value channel. For vertical bars, maps to the value axis; for horizontal bars, maps to the category/ordinal axis.", + "Field for the value or category axis. Defaults to 'value' for vertical bars and 'category' for horizontal bars.", }), - z: Channel.annotate({ + series: Channel.annotate({ description: - "Series/channel for grouping within each stack. Use with fill for stacked bars when multiple series share the same x/y.", + "Optional categorical field for stacked bars or grouped color encoding.", }), interval: IntervalValue, - inset: NumberGreaterThanZero, - fill: Channel.annotate({ + sort: Schema.Union([ + Schema.String, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), + ]).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.", - }), - stroke: Channel.annotate({ - description: "Stroke color channel. Maps to the outline color of the bars.", - }), - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", + "Optional sort for category ordering. Use a channel name or { channel, order }.", }), }).mapFields(Struct.map(Schema.optionalKey)); @@ -73,11 +67,11 @@ export const BarChartWidgetPayload = Schema.Struct({ }).pipe(Schema.optionalKey), marks: BarMarkProps.annotate({ description: - "Mark channel mapping. Use marks.x and marks.y to select data keys that match your data objects. For vertical bars, set x=category and y=value. For horizontal bars, set x=value and y=category.", - }), + "Bar mark mapping. Use marks.x and marks.y to select data keys. Use marks.series for stacked bars. Defaults are based on direction if omitted.", + }).pipe(Schema.optionalKey), }).annotate({ description: - "Payload for render_bar_chart_widget. Align marks.x/marks.y with the data keys for the chosen orientation.", + "Payload for render_bar_chart_widget. Use for category comparison, ranking, and simple part-to-whole bars.", examples: [ { data: [ @@ -89,11 +83,13 @@ export const BarChartWidgetPayload = Schema.Struct({ }, { data: [ - { breed: "Golden Retriever", popularity: 9 }, - { breed: "Labrador Retriever", popularity: 8.5 }, + { quarter: "Q1", region: "EU", value: 40 }, + { quarter: "Q1", region: "NA", value: 80 }, + { quarter: "Q2", region: "EU", value: 30 }, + { quarter: "Q2", region: "NA", value: 68 }, ], - direction: "horizontal", - marks: { x: "popularity", y: "breed" }, + direction: "vertical", + marks: { x: "quarter", y: "value", series: "region" }, }, ], }); diff --git a/packages/domain/src/Chart/LineChart.ts b/packages/domain/src/Chart/LineChart.ts index cf1163f..9aef3cd 100644 --- a/packages/domain/src/Chart/LineChart.ts +++ b/packages/domain/src/Chart/LineChart.ts @@ -1,5 +1,5 @@ import { Schema, Struct } from "effect"; -import { Channel, LineCurve, LineMarker, PlotLayoutProps } from "./shared"; +import { Channel, PlotLayoutProps } from "./shared"; export const LineDatumValue = Schema.Union([ Schema.String, @@ -26,38 +26,14 @@ export const LineDatumDefaults = Schema.Struct({ export const LineMarkProps = Schema.Struct({ x: Channel.annotate({ - description: - "Horizontal position channel. For line charts, typically temporal or quantitative.", + description: "Horizontal position field. Defaults to 'x' when omitted.", }), y: Channel.annotate({ - description: - "Vertical position channel. For line charts, typically quantitative.", - }), - z: Channel.annotate({ - description: - "Series/channel for grouping multiple lines. Typically a categorical value such as a series name.", - }), - - stroke: Channel.annotate({ - description: - "Stroke color channel. If specified, consider also setting z to avoid segmenting on color changes.", - }), - strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: - "Stroke width in pixels, or a channel name for per-point width.", + description: "Vertical value field. Defaults to 'y' when omitted.", }), - strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + series: Channel.annotate({ description: - "Stroke opacity (0-1), or a channel name for per-point opacity.", - }), - - curve: LineCurve.annotate({ - description: - "Curve interpolation name (e.g. linear, step, basis, catmull-rom).", - }), - marker: Schema.Union([Schema.Boolean, LineMarker]).annotate({ - description: - "Marker to draw at each point (true for default, or marker name).", + "Optional categorical field for multiple series. The renderer uses this for grouping and color.", }), sort: Schema.Union([ Schema.String, @@ -69,10 +45,6 @@ export const LineMarkProps = Schema.Struct({ description: "Sort option to order points or series. Use a channel name or { channel, order }.", }), - - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", - }), }).mapFields(Struct.map(Schema.optionalKey)); export const LineChartWidgetPayload = Schema.Struct({ @@ -86,9 +58,27 @@ export const LineChartWidgetPayload = Schema.Struct({ }).pipe(Schema.optionalKey), marks: LineMarkProps.annotate({ description: - "Line mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", + "Line mark mapping. Use marks.x and marks.y to select data keys. Use marks.series for multiple lines. Defaults are x='x' and y='y' if omitted.", }).pipe(Schema.optionalKey), }).annotate({ description: - "Payload for render_line_chart_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", + "Payload for render_line_chart_widget. Use for trends and continuous comparisons across time or another ordered dimension.", + examples: [ + { + data: [ + { date: "2024-01-01", value: 120 }, + { date: "2024-02-01", value: 98 }, + ], + marks: { x: "date", y: "value" }, + }, + { + data: [ + { date: "2024-01-01", value: 120, series: "Alpha" }, + { date: "2024-02-01", value: 98, series: "Alpha" }, + { date: "2024-01-01", value: 80, series: "Beta" }, + { date: "2024-02-01", value: 110, series: "Beta" }, + ], + marks: { x: "date", y: "value", series: "series" }, + }, + ], }); diff --git a/packages/domain/src/Chart/Scatterplot.ts b/packages/domain/src/Chart/Scatterplot.ts index ac468c3..fa9cf75 100644 --- a/packages/domain/src/Chart/Scatterplot.ts +++ b/packages/domain/src/Chart/Scatterplot.ts @@ -1,5 +1,5 @@ import { Schema, Struct } from "effect"; -import { Channel, DotSymbol, PlotLayoutProps } from "./shared"; +import { Channel, PlotLayoutProps } from "./shared"; export const DotDatumValue = Schema.Union([ Schema.String, @@ -26,44 +26,18 @@ export const DotDatumDefaults = Schema.Struct({ export const DotMarkProps = Schema.Struct({ x: Channel.annotate({ - description: - "Horizontal position channel. For scatterplots, typically quantitative or temporal.", + description: "Horizontal position field. Defaults to 'x' when omitted.", }), y: Channel.annotate({ - description: - "Vertical position channel. For scatterplots, typically quantitative.", + description: "Vertical position field. Defaults to 'y' when omitted.", }), - - r: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Dot radius in pixels, or a channel name for per-point size.", + series: Channel.annotate({ + description: "Optional categorical field for grouped color encoding.", }), - symbol: Schema.Union([Channel, DotSymbol]).annotate({ + size: Schema.Union([Channel, Schema.NumberFromString]).annotate({ description: - "Symbol name or channel. Use a categorical field to map different symbols.", - }), - rotate: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Rotation angle in degrees clockwise, or a channel name.", - }), - - fill: Channel.annotate({ - description: "Fill color channel. Maps to dot fill color.", - }), - fillOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Fill opacity (0-1), or a channel name.", - }), - stroke: Channel.annotate({ - description: "Stroke color channel. Maps to dot outline color.", - }), - strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Stroke width in pixels, or a channel name.", + "Optional quantitative field for dot size, or a constant radius in pixels.", }), - strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Stroke opacity (0-1), or a channel name.", - }), - opacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Overall opacity (0-1), or a channel name.", - }), - sort: Schema.Union([ Schema.String, Schema.Null, @@ -75,10 +49,6 @@ export const DotMarkProps = Schema.Struct({ description: "Sort option to order dots. Use a channel name, { channel, order }, or null for input order.", }), - - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", - }), }).mapFields(Struct.map(Schema.optionalKey)); export const ScatterplotWidgetPayload = Schema.Struct({ @@ -92,9 +62,25 @@ export const ScatterplotWidgetPayload = Schema.Struct({ }).pipe(Schema.optionalKey), marks: DotMarkProps.annotate({ description: - "Dot mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", - }), + "Scatterplot mapping. Use marks.x and marks.y to select data keys. Use marks.series for grouped color and marks.size for bubble charts. Defaults are x='x' and y='y' if omitted.", + }).pipe(Schema.optionalKey), }).annotate({ description: - "Payload for render_scatterplot_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", + "Payload for render_scatterplot_widget. Use for correlation, clustering, and bubble-chart style comparisons.", + examples: [ + { + data: [ + { horsepower: 130, mpg: 24 }, + { horsepower: 165, mpg: 18 }, + ], + marks: { x: "mpg", y: "horsepower" }, + }, + { + data: [ + { x: 10, y: 22, category: "A", volume: 100 }, + { x: 15, y: 18, category: "B", volume: 160 }, + ], + marks: { x: "x", y: "y", series: "category", size: "volume" }, + }, + ], }); diff --git a/packages/widget-bar-chart/src/bar-chart.tsx b/packages/widget-bar-chart/src/bar-chart.tsx index 77b1d25..e2cc806 100644 --- a/packages/widget-bar-chart/src/bar-chart.tsx +++ b/packages/widget-bar-chart/src/bar-chart.tsx @@ -23,10 +23,15 @@ const BarChart = ({ const layoutOptions = layout ?? {}; const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; const isHorizontal = direction === "horizontal"; + const series = marks?.series; const barOptions = { - ...marks, x: marks?.x ?? (isHorizontal ? "value" : "category"), y: marks?.y ?? (isHorizontal ? "category" : "value"), + ...(series ? { z: series, fill: series } : {}), + ...(marks?.interval ? { interval: marks.interval } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + inset: 0.5, + tip: true, }; return ( diff --git a/packages/widget-line-chart/src/line-chart.tsx b/packages/widget-line-chart/src/line-chart.tsx index fdfa3a9..f9ad904 100644 --- a/packages/widget-line-chart/src/line-chart.tsx +++ b/packages/widget-line-chart/src/line-chart.tsx @@ -20,6 +20,17 @@ const LineChart = ({ const width = layout?.width ?? measuredWidth ?? undefined; const layoutOptions = layout ?? {}; const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const x = marks?.x ?? "x"; + const y = marks?.y ?? "y"; + const series = marks?.series; + const lineOptions = { + x, + y, + ...(series ? { z: series, stroke: series } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + strokeWidth: 2, + tip: true, + }; return ( @@ -31,14 +42,7 @@ const LineChart = ({ ...(layoutOptions.color ? layoutOptions.color : {}), scheme: "Category10", }, - marks: [ - Plot.ruleY([0]), - Plot.line(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], + marks: [Plot.ruleY([0]), Plot.line(data, lineOptions)], }} dependencies={[data, layout, marks]} /> diff --git a/packages/widget-scatterplot/src/scatterplot.tsx b/packages/widget-scatterplot/src/scatterplot.tsx index 3f0ece1..e7b5cf2 100644 --- a/packages/widget-scatterplot/src/scatterplot.tsx +++ b/packages/widget-scatterplot/src/scatterplot.tsx @@ -22,6 +22,19 @@ const Scatterplot = ({ const width = layout?.width ?? measuredWidth ?? undefined; const layoutOptions = layout ?? {}; const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const x = marks?.x ?? "x"; + const y = marks?.y ?? "y"; + const series = marks?.series; + const dotOptions = { + x, + y, + ...(series ? { fill: series, stroke: series } : {}), + ...(marks?.size ? { r: marks.size } : {}), + ...(marks?.sort !== undefined ? { sort: marks.sort } : {}), + strokeWidth: 1.5, + fillOpacity: 0.8, + tip: true, + }; return ( @@ -33,15 +46,7 @@ const Scatterplot = ({ ...(layoutOptions.color ? layoutOptions.color : {}), scheme: "Category10", }, - marks: [ - Plot.ruleX([0]), - Plot.ruleY([0]), - Plot.dot(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], + marks: [Plot.ruleX([0]), Plot.ruleY([0]), Plot.dot(data, dotOptions)], }} dependencies={[data, layout, marks]} />