From 215f23a268421b30559858b0b51a8b00599f9c02 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:18:23 +0200 Subject: [PATCH 1/9] feat: add area chart --- .../src/widget/area-chart/area-chart.ts | 43 +++++++++ packages/domain/src/Chart/AreaChart.ts | 94 +++++++++++++++++++ packages/widget-area-chart/README.md | 8 ++ packages/widget-area-chart/index.html | 43 +++++++++ packages/widget-area-chart/package.json | 37 ++++++++ .../widget-area-chart/scripts/copy-asset.ts | 60 ++++++++++++ packages/widget-area-chart/src/app.tsx | 4 + packages/widget-area-chart/src/area-chart.tsx | 93 ++++++++++++++++++ packages/widget-area-chart/src/global.d.ts | 13 +++ packages/widget-area-chart/tsconfig.json | 12 +++ packages/widget-area-chart/vite.config.ts | 16 ++++ 11 files changed, 423 insertions(+) create mode 100644 apps/server-mcp/src/widget/area-chart/area-chart.ts create mode 100644 packages/domain/src/Chart/AreaChart.ts create mode 100644 packages/widget-area-chart/README.md create mode 100644 packages/widget-area-chart/index.html create mode 100644 packages/widget-area-chart/package.json create mode 100644 packages/widget-area-chart/scripts/copy-asset.ts create mode 100644 packages/widget-area-chart/src/app.tsx create mode 100644 packages/widget-area-chart/src/area-chart.tsx create mode 100644 packages/widget-area-chart/src/global.d.ts create mode 100644 packages/widget-area-chart/tsconfig.json create mode 100644 packages/widget-area-chart/vite.config.ts diff --git a/apps/server-mcp/src/widget/area-chart/area-chart.ts b/apps/server-mcp/src/widget/area-chart/area-chart.ts new file mode 100644 index 0000000..3f1aec2 --- /dev/null +++ b/apps/server-mcp/src/widget/area-chart/area-chart.ts @@ -0,0 +1,43 @@ +import { AreaChartWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const AreaChartWidgetResourceUri = "ui://area-chart"; + +const AreaChartWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "area-chart"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-area-chart.html") + : path.join(import.meta.dir, "widget/area-chart/widget-area-chart.html"); + return yield* fs.readFileString(htmlPath); +}); + +export const AreaChartWidgetResourceLayer = makeUiResource( + AreaChartWidgetResourceUri, + { + name: "Area Chart", + description: "Area chart widget UI", + html: AreaChartWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderAreaChartWidgetTool = makeUiRenderTool( + AreaChartWidgetResourceUri, + { + name: "render_area_chart_widget", + title: "Area Chart", + description: "Render the area chart widget UI", + parameters: AreaChartWidgetPayload, + success: AreaChartWidgetPayload, + }, +); + +export const renderAreaChartWidgetHandler = ( + payload: typeof AreaChartWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/AreaChart.ts b/packages/domain/src/Chart/AreaChart.ts new file mode 100644 index 0000000..f88d6b1 --- /dev/null +++ b/packages/domain/src/Chart/AreaChart.ts @@ -0,0 +1,94 @@ +import { Schema, Struct } from "effect"; +import { Channel, PlotLayoutProps, RangeInterval } from "./shared"; + +export const AreaDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const AreaDatum = Schema.Record(Schema.String, AreaDatumValue).annotate({ + description: + "Single area-chart 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.", + examples: [ + { x: "2024-01-01", y: 120 }, + { date: "2024-01-01", value: 48, series: "Alpha" }, + ], +}); + +export const AreaDatumDefaults = 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 AreaVariant = Schema.Literals(["area", "area-line"]); + +export const AreaMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: + "Horizontal position channel. For area charts, typically temporal or quantitative. Defaults to 'x' when omitted.", + }), + y: Channel.annotate({ + description: + "Vertical value channel for Plot.areaY and Plot.lineY. Defaults to 'y' when omitted.", + }), + series: Channel.annotate({ + description: + "Optional categorical field for multiple series. The renderer uses this for grouping, color, and stacked area layers.", + }), + interval: RangeInterval.annotate({ + description: + "Optional interval for regularizing sampled data, especially time series.", + }), + 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 }.", + }), + variant: AreaVariant.annotate({ + description: + "Whether to render only the filled area or the area with an outline line. Defaults to 'area-line'. When marks.series is set, the renderer suppresses the outline and shows stacked areas only.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const AreaChartWidgetPayload = Schema.Struct({ + data: Schema.Array(AreaDatum).annotate({ + description: + "Array of area-chart 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: AreaMarkProps.annotate({ + description: + "Area 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_area_chart_widget. Use for trends with filled context or stacked composition over time.", + examples: [ + { + data: [ + { date: "2024-01-01", value: 120 }, + { date: "2024-02-01", value: 98 }, + ], + marks: { x: "date", y: "value", variant: "area-line" }, + }, + { + data: [ + { date: "2024-01-01", unemployed: 120, industry: "Retail" }, + { date: "2024-02-01", unemployed: 98, industry: "Retail" }, + ], + marks: { x: "date", y: "unemployed", series: "industry" }, + }, + ], +}); diff --git a/packages/widget-area-chart/README.md b/packages/widget-area-chart/README.md new file mode 100644 index 0000000..0693677 --- /dev/null +++ b/packages/widget-area-chart/README.md @@ -0,0 +1,8 @@ +# Area Chart Widget + +Single-file MCP App widget that renders an area chart. + +## Scripts + +- `bun run build`: build the widget and copy the HTML asset +- `bun run dev`: watch and copy the HTML asset during local development diff --git a/packages/widget-area-chart/index.html b/packages/widget-area-chart/index.html new file mode 100644 index 0000000..12eefb3 --- /dev/null +++ b/packages/widget-area-chart/index.html @@ -0,0 +1,43 @@ + + + + + + Area Chart Widget + + + +
+ + + diff --git a/packages/widget-area-chart/package.json b/packages/widget-area-chart/package.json new file mode 100644 index 0000000..4145e70 --- /dev/null +++ b/packages/widget-area-chart/package.json @@ -0,0 +1,37 @@ +{ + "name": "@repo/widget-area-chart", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-area-chart/scripts/copy-asset.ts b/packages/widget-area-chart/scripts/copy-asset.ts new file mode 100644 index 0000000..456b98e --- /dev/null +++ b/packages/widget-area-chart/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/area-chart" + : "../../apps/server-mcp/dist/widget/area-chart", +); +const destinationPath = path.join(destinationDir, "widget-area-chart.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-area-chart/src/app.tsx b/packages/widget-area-chart/src/app.tsx new file mode 100644 index 0000000..d55ae8b --- /dev/null +++ b/packages/widget-area-chart/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./area-chart"; + +mountWidgetApp(); diff --git a/packages/widget-area-chart/src/area-chart.tsx b/packages/widget-area-chart/src/area-chart.tsx new file mode 100644 index 0000000..c83f2bd --- /dev/null +++ b/packages/widget-area-chart/src/area-chart.tsx @@ -0,0 +1,93 @@ +import * as Plot from "@observablehq/plot"; +import { AreaChartWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; + +const AreaChart = ({ + data, + layout, + marks, + app, +}: typeof AreaChartWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + 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 variant = marks?.variant ?? "area-line"; + const shouldShowLine = variant === "area-line"; + + const areaOptions = { + x, + y, + ...(series ? { z: series, fill: series } : {}), + fillOpacity: 0.3, + ...(marks?.interval ? { interval: marks.interval } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + tip: true, + }; + + const lineOptions = { + x, + y, + ...(series ? { z: series, stroke: series } : {}), + strokeWidth: 2, + ...(marks?.interval ? { interval: marks.interval } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + tip: true, + }; + + return ( + + + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + AreaChartWidgetPayload, + { + appInfo: { name: "widget-area-chart", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-area-chart/src/global.d.ts b/packages/widget-area-chart/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-area-chart/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-area-chart/tsconfig.json b/packages/widget-area-chart/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-area-chart/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-area-chart/vite.config.ts b/packages/widget-area-chart/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-area-chart/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From b3434849006fac0b9dea7a15b1493355c693d002 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:19:40 +0200 Subject: [PATCH 2/9] feat: add heatmap --- apps/server-mcp/src/widget/heatmap/heatmap.ts | 43 ++++++++++ packages/domain/src/Chart/Heatmap.ts | 78 ++++++++++++++++++ packages/widget-heatmap/README.md | 8 ++ packages/widget-heatmap/index.html | 43 ++++++++++ packages/widget-heatmap/package.json | 37 +++++++++ packages/widget-heatmap/scripts/copy-asset.ts | 60 ++++++++++++++ packages/widget-heatmap/src/app.tsx | 4 + packages/widget-heatmap/src/global.d.ts | 13 +++ packages/widget-heatmap/src/heatmap.tsx | 79 +++++++++++++++++++ packages/widget-heatmap/tsconfig.json | 12 +++ packages/widget-heatmap/vite.config.ts | 16 ++++ 11 files changed, 393 insertions(+) create mode 100644 apps/server-mcp/src/widget/heatmap/heatmap.ts create mode 100644 packages/domain/src/Chart/Heatmap.ts create mode 100644 packages/widget-heatmap/README.md create mode 100644 packages/widget-heatmap/index.html create mode 100644 packages/widget-heatmap/package.json create mode 100644 packages/widget-heatmap/scripts/copy-asset.ts create mode 100644 packages/widget-heatmap/src/app.tsx create mode 100644 packages/widget-heatmap/src/global.d.ts create mode 100644 packages/widget-heatmap/src/heatmap.tsx create mode 100644 packages/widget-heatmap/tsconfig.json create mode 100644 packages/widget-heatmap/vite.config.ts diff --git a/apps/server-mcp/src/widget/heatmap/heatmap.ts b/apps/server-mcp/src/widget/heatmap/heatmap.ts new file mode 100644 index 0000000..7b501a7 --- /dev/null +++ b/apps/server-mcp/src/widget/heatmap/heatmap.ts @@ -0,0 +1,43 @@ +import { HeatmapWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const HeatmapWidgetResourceUri = "ui://heatmap"; + +const HeatmapWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "heatmap"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-heatmap.html") + : path.join(import.meta.dir, "widget/heatmap/widget-heatmap.html"); + return yield* fs.readFileString(htmlPath); +}); + +export const HeatmapWidgetResourceLayer = makeUiResource( + HeatmapWidgetResourceUri, + { + name: "Heatmap", + description: "Heatmap widget UI", + html: HeatmapWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderHeatmapWidgetTool = makeUiRenderTool( + HeatmapWidgetResourceUri, + { + name: "render_heatmap_widget", + title: "Heatmap", + description: "Render the heatmap widget UI", + parameters: HeatmapWidgetPayload, + success: HeatmapWidgetPayload, + }, +); + +export const renderHeatmapWidgetHandler = ( + payload: typeof HeatmapWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/Heatmap.ts b/packages/domain/src/Chart/Heatmap.ts new file mode 100644 index 0000000..8c0744c --- /dev/null +++ b/packages/domain/src/Chart/Heatmap.ts @@ -0,0 +1,78 @@ +import { Schema, Struct } from "effect"; +import { ChannelString, PlotLayoutProps } from "./shared"; + +export const HeatmapDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const HeatmapDatum = Schema.Record( + Schema.String, + HeatmapDatumValue, +).annotate({ + description: + "Single heatmap cell data object. Keys are column names used by marks.x, marks.y, and marks.value. For matrix heatmaps, x and y should usually be ordinal categories while value should be quantitative.", + examples: [ + { x: "Jan", y: "North", value: 42 }, + { season: "S1", episode: "E1", rating: 8.7 }, + ], +}); + +export const HeatmapDatumDefaults = Schema.Struct({ + x: Schema.String, + y: Schema.String, + value: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const HeatmapMarkProps = Schema.Struct({ + x: ChannelString.annotate({ + description: + "Ordinal x-axis channel for the heatmap matrix, such as month, season, or category.", + }), + y: ChannelString.annotate({ + description: + "Ordinal y-axis channel for the heatmap matrix, such as region, episode, or category.", + }), + value: ChannelString.annotate({ + description: + "Quantitative value field used to color each cell, such as score, count, or correlation.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const HeatmapWidgetPayload = Schema.Struct({ + data: Schema.Array(HeatmapDatum).annotate({ + description: + "Array of matrix cell objects. Each object must expose the keys used in marks.x, marks.y, and marks.value.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: HeatmapMarkProps.annotate({ + description: + "Heatmap mapping. Use marks.x and marks.y for matrix axes and marks.value for the numeric cell value.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_heatmap_widget. Use for matrix-style heatmaps where each row represents a single cell value.", + examples: [ + { + data: [ + { month: "Jan", region: "North", sales: 42 }, + { month: "Jan", region: "South", sales: 31 }, + ], + marks: { x: "month", y: "region", value: "sales" }, + }, + { + data: [ + { season: "S1", episode: "E1", rating: 8.7 }, + { season: "S1", episode: "E2", rating: 7.9 }, + ], + marks: { x: "season", y: "episode", value: "rating" }, + }, + ], +}); diff --git a/packages/widget-heatmap/README.md b/packages/widget-heatmap/README.md new file mode 100644 index 0000000..e9e5bed --- /dev/null +++ b/packages/widget-heatmap/README.md @@ -0,0 +1,8 @@ +# Heatmap Widget + +Single-file MCP App widget that renders a matrix heatmap. + +## Scripts + +- `bun run build`: build the widget and copy the HTML asset +- `bun run dev`: watch and copy the HTML asset during local development diff --git a/packages/widget-heatmap/index.html b/packages/widget-heatmap/index.html new file mode 100644 index 0000000..9257ccd --- /dev/null +++ b/packages/widget-heatmap/index.html @@ -0,0 +1,43 @@ + + + + + + Heatmap Widget + + + +
+ + + diff --git a/packages/widget-heatmap/package.json b/packages/widget-heatmap/package.json new file mode 100644 index 0000000..cfd4524 --- /dev/null +++ b/packages/widget-heatmap/package.json @@ -0,0 +1,37 @@ +{ + "name": "@repo/widget-heatmap", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-heatmap/scripts/copy-asset.ts b/packages/widget-heatmap/scripts/copy-asset.ts new file mode 100644 index 0000000..5c7e3a2 --- /dev/null +++ b/packages/widget-heatmap/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/heatmap" + : "../../apps/server-mcp/dist/widget/heatmap", +); +const destinationPath = path.join(destinationDir, "widget-heatmap.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-heatmap/src/app.tsx b/packages/widget-heatmap/src/app.tsx new file mode 100644 index 0000000..af2827c --- /dev/null +++ b/packages/widget-heatmap/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./heatmap"; + +mountWidgetApp(); diff --git a/packages/widget-heatmap/src/global.d.ts b/packages/widget-heatmap/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-heatmap/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-heatmap/src/heatmap.tsx b/packages/widget-heatmap/src/heatmap.tsx new file mode 100644 index 0000000..ad4e5a6 --- /dev/null +++ b/packages/widget-heatmap/src/heatmap.tsx @@ -0,0 +1,79 @@ +import * as Plot from "@observablehq/plot"; +import { HeatmapWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; + +const Heatmap = ({ + data, + layout, + marks, + app, +}: typeof HeatmapWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + 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 value = marks?.value ?? "value"; + + const cellOptions = { + x, + y, + fill: value, + stroke: "white", + strokeOpacity: 0.5, + strokeWidth: 1, + inset: 0.5, + tip: true, + }; + + return ( + + + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + HeatmapWidgetPayload, + { + appInfo: { name: "widget-heatmap", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-heatmap/tsconfig.json b/packages/widget-heatmap/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-heatmap/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-heatmap/vite.config.ts b/packages/widget-heatmap/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-heatmap/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From 49dbbf2716a739968270d917c0ec947f5b879a62 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:20:06 +0200 Subject: [PATCH 3/9] feat: add bullet chart --- .../src/widget/bullet-chart/bullet-chart.ts | 46 ++++++++++ packages/domain/src/Chart/BulletChart.ts | 92 +++++++++++++++++++ packages/widget-bullet-chart/README.md | 3 + packages/widget-bullet-chart/index.html | 35 +++++++ packages/widget-bullet-chart/package.json | 37 ++++++++ .../widget-bullet-chart/scripts/copy-asset.ts | 60 ++++++++++++ packages/widget-bullet-chart/src/app.tsx | 4 + .../widget-bullet-chart/src/bullet-chart.tsx | 88 ++++++++++++++++++ packages/widget-bullet-chart/src/global.d.ts | 13 +++ packages/widget-bullet-chart/tsconfig.json | 12 +++ packages/widget-bullet-chart/vite.config.ts | 16 ++++ 11 files changed, 406 insertions(+) create mode 100644 apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts create mode 100644 packages/domain/src/Chart/BulletChart.ts create mode 100644 packages/widget-bullet-chart/README.md create mode 100644 packages/widget-bullet-chart/index.html create mode 100644 packages/widget-bullet-chart/package.json create mode 100644 packages/widget-bullet-chart/scripts/copy-asset.ts create mode 100644 packages/widget-bullet-chart/src/app.tsx create mode 100644 packages/widget-bullet-chart/src/bullet-chart.tsx create mode 100644 packages/widget-bullet-chart/src/global.d.ts create mode 100644 packages/widget-bullet-chart/tsconfig.json create mode 100644 packages/widget-bullet-chart/vite.config.ts diff --git a/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts b/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts new file mode 100644 index 0000000..ec8029b --- /dev/null +++ b/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts @@ -0,0 +1,46 @@ +import { BulletChartWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const BulletChartWidgetResourceUri = "ui://bullet-chart"; + +const BulletChartWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "bullet-chart"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-bullet-chart.html") + : path.join( + import.meta.dir, + "widget/bullet-chart/widget-bullet-chart.html", + ); + return yield* fs.readFileString(htmlPath); +}); + +export const BulletChartWidgetResourceLayer = makeUiResource( + BulletChartWidgetResourceUri, + { + name: "Bullet Chart", + description: "Bullet chart widget UI", + html: BulletChartWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderBulletChartWidgetTool = makeUiRenderTool( + BulletChartWidgetResourceUri, + { + name: "render_bullet_chart_widget", + title: "Bullet Chart", + description: "Render the bullet chart widget UI", + parameters: BulletChartWidgetPayload, + success: BulletChartWidgetPayload, + }, +); + +export const renderBulletChartWidgetHandler = ( + payload: typeof BulletChartWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/BulletChart.ts b/packages/domain/src/Chart/BulletChart.ts new file mode 100644 index 0000000..ae444d3 --- /dev/null +++ b/packages/domain/src/Chart/BulletChart.ts @@ -0,0 +1,92 @@ +import { Schema, Struct } from "effect"; +import { Channel, PlotLayoutProps } from "./shared"; + +export const BulletDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const BulletDatum = Schema.Record( + Schema.String, + BulletDatumValue, +).annotate({ + description: + "Single bullet-chart row. Each row should include a category label, a primary value, and a target value.", + examples: [ + { + label: "Revenue", + value: 72, + target: 80, + }, + ], +}); + +export const BulletDatumDefaults = Schema.Struct({ + label: Schema.String, + value: Schema.Union([Schema.Number, Schema.NumberFromString]), + target: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const BulletMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: + "Primary quantitative value field for the horizontal bar. Defaults to 'value' when omitted.", + }), + y: Channel.annotate({ + description: + "Categorical field for the bar rows. Defaults to 'label' when omitted.", + }), + target: Channel.annotate({ + description: + "Quantitative target field shown as a vertical rule marker. Defaults to 'target' when omitted.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const BulletChartWidgetPayload = Schema.Struct({ + data: Schema.Array(BulletDatum).annotate({ + description: + "Array of bullet chart rows. Each row must expose the fields used by marks.x, marks.y, and marks.target.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: BulletMarkProps.annotate({ + description: + "Bullet chart mapping. Use marks.x for the bar value, marks.y for the row label, and marks.target for the reference marker.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_bullet_chart_widget. Use for value-versus-target comparisons across one or more categories.", + examples: [ + { + data: [ + { + label: "Revenue", + value: 72, + target: 80, + }, + ], + marks: { + x: "value", + y: "label", + target: "target", + }, + }, + { + data: [ + { label: "Revenue", value: 72, target: 80 }, + { label: "Margin", value: 41, target: 50 }, + ], + marks: { + x: "value", + y: "label", + target: "target", + }, + }, + ], +}); diff --git a/packages/widget-bullet-chart/README.md b/packages/widget-bullet-chart/README.md new file mode 100644 index 0000000..32181de --- /dev/null +++ b/packages/widget-bullet-chart/README.md @@ -0,0 +1,3 @@ +# Bullet Chart Widget + +Single-file MCP App widget that renders horizontal bullet charts. diff --git a/packages/widget-bullet-chart/index.html b/packages/widget-bullet-chart/index.html new file mode 100644 index 0000000..2aabce6 --- /dev/null +++ b/packages/widget-bullet-chart/index.html @@ -0,0 +1,35 @@ + + + + + + Bullet Chart Widget + + + +
+ + + diff --git a/packages/widget-bullet-chart/package.json b/packages/widget-bullet-chart/package.json new file mode 100644 index 0000000..2cd8588 --- /dev/null +++ b/packages/widget-bullet-chart/package.json @@ -0,0 +1,37 @@ +{ + "name": "@repo/widget-bullet-chart", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-bullet-chart/scripts/copy-asset.ts b/packages/widget-bullet-chart/scripts/copy-asset.ts new file mode 100644 index 0000000..94f95f6 --- /dev/null +++ b/packages/widget-bullet-chart/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/bullet-chart" + : "../../apps/server-mcp/dist/widget/bullet-chart", +); +const destinationPath = path.join(destinationDir, "widget-bullet-chart.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-bullet-chart/src/app.tsx b/packages/widget-bullet-chart/src/app.tsx new file mode 100644 index 0000000..69a6920 --- /dev/null +++ b/packages/widget-bullet-chart/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./bullet-chart"; + +mountWidgetApp(); diff --git a/packages/widget-bullet-chart/src/bullet-chart.tsx b/packages/widget-bullet-chart/src/bullet-chart.tsx new file mode 100644 index 0000000..c040517 --- /dev/null +++ b/packages/widget-bullet-chart/src/bullet-chart.tsx @@ -0,0 +1,88 @@ +import * as Plot from "@observablehq/plot"; +import { BulletChartWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; + +const BulletChart = ({ + data, + layout, + marks, + app, +}: typeof BulletChartWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const x = marks?.x ?? "value"; + const y = marks?.y ?? "label"; + const target = marks?.target ?? "target"; + + return ( + + { + const row = d as Record; + return `${String(row[y] ?? "")}: ${String(row[x] ?? "")}`; + }, + }), + Plot.ruleX(data, { + x: target, + y, + inset: 6, + stroke: "currentColor", + strokeWidth: 2, + }), + ], + }} + dependencies={[data, layout, marks]} + /> + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + BulletChartWidgetPayload, + { + appInfo: { name: "widget-bullet-chart", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-bullet-chart/src/global.d.ts b/packages/widget-bullet-chart/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-bullet-chart/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-bullet-chart/tsconfig.json b/packages/widget-bullet-chart/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-bullet-chart/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-bullet-chart/vite.config.ts b/packages/widget-bullet-chart/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-bullet-chart/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From 1abcb8dce5c0e75a0c8bd7c0075a87a25eeacdc6 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:20:20 +0200 Subject: [PATCH 4/9] feat: add geo chart --- .../src/widget/geo-chart/geo-chart.ts | 43 ++++ packages/domain/src/Chart/GeoChart.ts | 193 ++++++++++++++++ packages/widget-geo-chart/README.md | 6 + packages/widget-geo-chart/index.html | 43 ++++ packages/widget-geo-chart/package.json | 41 ++++ .../widget-geo-chart/scripts/copy-asset.ts | 60 +++++ packages/widget-geo-chart/src/app.tsx | 4 + packages/widget-geo-chart/src/geo-chart.tsx | 212 ++++++++++++++++++ packages/widget-geo-chart/src/geo-data.ts | 56 +++++ packages/widget-geo-chart/src/global.d.ts | 13 ++ packages/widget-geo-chart/tsconfig.json | 12 + packages/widget-geo-chart/vite.config.ts | 16 ++ 12 files changed, 699 insertions(+) create mode 100644 apps/server-mcp/src/widget/geo-chart/geo-chart.ts create mode 100644 packages/domain/src/Chart/GeoChart.ts create mode 100644 packages/widget-geo-chart/README.md create mode 100644 packages/widget-geo-chart/index.html create mode 100644 packages/widget-geo-chart/package.json create mode 100644 packages/widget-geo-chart/scripts/copy-asset.ts create mode 100644 packages/widget-geo-chart/src/app.tsx create mode 100644 packages/widget-geo-chart/src/geo-chart.tsx create mode 100644 packages/widget-geo-chart/src/geo-data.ts create mode 100644 packages/widget-geo-chart/src/global.d.ts create mode 100644 packages/widget-geo-chart/tsconfig.json create mode 100644 packages/widget-geo-chart/vite.config.ts diff --git a/apps/server-mcp/src/widget/geo-chart/geo-chart.ts b/apps/server-mcp/src/widget/geo-chart/geo-chart.ts new file mode 100644 index 0000000..1dab2be --- /dev/null +++ b/apps/server-mcp/src/widget/geo-chart/geo-chart.ts @@ -0,0 +1,43 @@ +import { GeoChartWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const GeoChartWidgetResourceUri = "ui://geo-chart"; + +const GeoChartWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "geo-chart"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-geo-chart.html") + : path.join(import.meta.dir, "widget/geo-chart/widget-geo-chart.html"); + return yield* fs.readFileString(htmlPath); +}); + +export const GeoChartWidgetResourceLayer = makeUiResource( + GeoChartWidgetResourceUri, + { + name: "Geo Chart", + description: "Geo chart widget UI", + html: GeoChartWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderGeoChartWidgetTool = makeUiRenderTool( + GeoChartWidgetResourceUri, + { + name: "render_geo_chart_widget", + title: "Geo Chart", + description: "Render the geo chart widget UI", + parameters: GeoChartWidgetPayload, + success: GeoChartWidgetPayload, + }, +); + +export const renderGeoChartWidgetHandler = ( + payload: typeof GeoChartWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/GeoChart.ts b/packages/domain/src/Chart/GeoChart.ts new file mode 100644 index 0000000..4c28c86 --- /dev/null +++ b/packages/domain/src/Chart/GeoChart.ts @@ -0,0 +1,193 @@ +import { Schema, Struct } from "effect"; +import { + Channel, + ChannelString, + NumberGreaterThanZero, + PlotLayoutProps, +} from "./shared"; + +export const GeoDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const GeoDatum = Schema.Record(Schema.String, GeoDatumValue).annotate({ + description: + "Single geo-chart data object. Keys are column names used by point or choropleth mark mappings.", + examples: [ + { + city: "San Francisco", + latitude: 37.7749, + longitude: -122.4194, + population: 42, + }, + { country: "Canada", value: 63 }, + ], +}); + +export const GeoProjectionType = Schema.Literals([ + "equirectangular", + "orthographic", + "stereographic", + "mercator", + "equal-earth", + "azimuthal-equal-area", + "azimuthal-equidistant", + "conic-conformal", + "conic-equal-area", + "conic-equidistant", + "gnomonic", + "transverse-mercator", + "albers", + "albers-usa", + "identity", + "reflect-y", +]); + +export const GeoProjection = Schema.Union([ + GeoProjectionType, + Schema.Struct({ + type: GeoProjectionType, + rotate: Schema.Union([ + Schema.Tuple([Schema.Number, Schema.Number]), + Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]), + ]), + inset: NumberGreaterThanZero, + clip: Schema.Union([Schema.Boolean, Schema.Number, Schema.Null]), + }).mapFields(Struct.map(Schema.optionalKey)), +]); + +const GeoSort = Schema.Union([ + Schema.String, + Schema.Null, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), +]); + +export const GeoLandMark = Schema.Struct({ + _tag: Schema.Literal("land"), +}); + +export const GeoSphereMark = Schema.Struct({ + _tag: Schema.Literal("sphere"), +}); + +export const GeoGraticuleMark = Schema.Struct({ + _tag: Schema.Literal("graticule"), +}); + +export const GeoPointMark = Schema.Struct({ + _tag: Schema.Literal("point"), + longitude: Channel.annotate({ + description: "Longitude field or constant value. Defaults to 'longitude'.", + }), + latitude: Channel.annotate({ + description: "Latitude field or constant value. Defaults to 'latitude'.", + }), + size: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: + "Optional point size field or constant radius. Use this for bubble-map style points.", + }), + series: ChannelString.annotate({ + description: "Optional categorical field for grouped point color encoding.", + }), + title: ChannelString.annotate({ + description: "Optional field used for point titles or tooltips.", + }), + sort: GeoSort.annotate({ + description: + "Optional sort for point drawing order. Use null to preserve input order.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const GeoChoroplethMark = Schema.Struct({ + _tag: Schema.Literal("choropleth"), + dataKey: ChannelString.annotate({ + description: + "Data field used to join each row to a built-in world country feature. Defaults to 'id'.", + }), + featureKey: Schema.Literals(["id", "name"]).annotate({ + description: + "Which built-in country feature property to join against. Use 'name' for country names or 'id' for numeric ISO ids.", + }), + value: ChannelString.annotate({ + description: + "Numeric field used to color each matched country feature. Defaults to 'value'.", + }), + title: ChannelString.annotate({ + description: "Optional field used for choropleth titles or tooltips.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const GeoChartMark = Schema.Union([ + GeoLandMark, + GeoSphereMark, + GeoGraticuleMark, + GeoPointMark, + GeoChoroplethMark, +]); + +export const GeoChartWidgetPayload = Schema.Struct({ + data: Schema.Array(GeoDatum).annotate({ + description: + "Array of geo data objects. Point and choropleth marks consume this data; basemap marks ignore it.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + projection: GeoProjection.pipe(Schema.optionalKey), + marks: Schema.Array(GeoChartMark).annotate({ + description: + "Ordered geo mark list. Start with basemap marks such as sphere, land, or graticule, then add data-driven marks such as point or choropleth.", + }), +}).annotate({ + description: + "Payload for render_geo_chart_widget. Use for projected maps with optional basemap layers, geographic points, or country choropleths.", + examples: [ + { + data: [ + { + city: "San Francisco", + latitude: 37.7749, + longitude: -122.4194, + population: 808988, + }, + ], + projection: "equal-earth", + marks: [ + { _tag: "sphere" }, + { _tag: "land" }, + { + _tag: "point", + latitude: "latitude", + longitude: "longitude", + size: "population", + title: "city", + }, + ], + }, + { + data: [ + { country: "Canada", value: 42 }, + { country: "United States of America", value: 63 }, + ], + projection: "equal-earth", + marks: [ + { _tag: "sphere" }, + { + _tag: "choropleth", + featureKey: "name", + dataKey: "country", + value: "value", + }, + ], + }, + ], +}); diff --git a/packages/widget-geo-chart/README.md b/packages/widget-geo-chart/README.md new file mode 100644 index 0000000..f2228a9 --- /dev/null +++ b/packages/widget-geo-chart/README.md @@ -0,0 +1,6 @@ +# Geo Chart Widget + +Single-file MCP App widget that renders projection-based geo charts. + +Supported basemap marks are `sphere`, `land`, and `graticule`. Supported +data-driven marks are `point` and `choropleth`. diff --git a/packages/widget-geo-chart/index.html b/packages/widget-geo-chart/index.html new file mode 100644 index 0000000..90fc436 --- /dev/null +++ b/packages/widget-geo-chart/index.html @@ -0,0 +1,43 @@ + + + + + + Geo Chart Widget + + + +
+ + + diff --git a/packages/widget-geo-chart/package.json b/packages/widget-geo-chart/package.json new file mode 100644 index 0000000..949b249 --- /dev/null +++ b/packages/widget-geo-chart/package.json @@ -0,0 +1,41 @@ +{ + "name": "@repo/widget-geo-chart", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" + }, + "devDependencies": { + "@repo/config-typescript": "workspace:*", + "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", + "@types/d3": "^7.4.3", + "@types/geojson": "^7946.0.16", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/topojson-client": "^3.1.5", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-geo-chart/scripts/copy-asset.ts b/packages/widget-geo-chart/scripts/copy-asset.ts new file mode 100644 index 0000000..3b3e4a5 --- /dev/null +++ b/packages/widget-geo-chart/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/geo-chart" + : "../../apps/server-mcp/dist/widget/geo-chart", +); +const destinationPath = path.join(destinationDir, "widget-geo-chart.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-geo-chart/src/app.tsx b/packages/widget-geo-chart/src/app.tsx new file mode 100644 index 0000000..28cdec5 --- /dev/null +++ b/packages/widget-geo-chart/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./geo-chart"; + +mountWidgetApp(); diff --git a/packages/widget-geo-chart/src/geo-chart.tsx b/packages/widget-geo-chart/src/geo-chart.tsx new file mode 100644 index 0000000..584c4c2 --- /dev/null +++ b/packages/widget-geo-chart/src/geo-chart.tsx @@ -0,0 +1,212 @@ +import * as Plot from "@observablehq/plot"; +import { GeoChartWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; +import type { Feature, Geometry } from "geojson"; +import { countries, land } from "./geo-data"; + +const projectionDefaults = "equal-earth"; + +type GeoMark = (typeof GeoChartWidgetPayload.Type.marks)[number]; +type GeoRow = (typeof GeoChartWidgetPayload.Type.data)[number]; +type GeoRowRecord = Record; +type GeoFeatureWithProperties = Feature; + +const asRecord = (value: unknown): GeoRowRecord => + value && typeof value === "object" ? (value as GeoRowRecord) : {}; + +const getFieldValue = (row: GeoRowRecord, key: string) => row[key]; + +const toProjectionRotate = ( + rotate: readonly [number, number] | readonly [number, number, number], +): [number, number] | [number, number, number] => + rotate.length === 2 + ? [rotate[0], rotate[1]] + : [rotate[0], rotate[1], rotate[2]]; + +const getChannelValue = (row: GeoRowRecord, channel: unknown) => { + if (typeof channel === "number") { + return channel; + } + if (typeof channel === "string") { + return channel in row ? row[channel] : channel; + } + return undefined; +}; + +const plotProjection = + typeof projectionDefaults === "string" ? projectionDefaults : undefined; + +const GeoChart = ({ + data, + layout, + projection, + marks, + app, +}: typeof GeoChartWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const normalizedProjection = + typeof projection === "string" || projection == null + ? (projection ?? projectionDefaults) + : { + ...(projection.type ? { type: projection.type } : {}), + ...(projection.inset ? { inset: projection.inset } : {}), + ...(projection.clip !== undefined ? { clip: projection.clip } : {}), + ...(projection.rotate + ? { + rotate: toProjectionRotate(projection.rotate), + } + : {}), + }; + + const renderedMarks = marks + .map((mark: GeoMark) => { + switch (mark._tag) { + case "land": + return Plot.geo(land, { + fill: "#e5e7eb", + stroke: "#ffffff", + strokeWidth: 0.75, + }); + case "sphere": + return Plot.sphere({ + stroke: "#94a3b8", + strokeWidth: 1, + }); + case "graticule": + return Plot.graticule({ + strokeOpacity: 0.2, + }); + case "point": + return Plot.dot(data, { + x: mark.longitude ?? "longitude", + y: mark.latitude ?? "latitude", + ...(mark.size ? { r: mark.size } : { r: 4 }), + ...(mark.series ? { fill: mark.series, stroke: mark.series } : {}), + ...(mark.title ? { title: mark.title } : {}), + ...(mark.sort !== undefined ? { sort: mark.sort } : {}), + fillOpacity: 0.35, + strokeWidth: 1, + tip: true, + }); + case "choropleth": { + const keyField = mark.dataKey ?? "id"; + const featureField = mark.featureKey ?? "id"; + const rowsByKey = new Map(); + + for (const row of data) { + const key = getFieldValue(asRecord(row), keyField); + if (key != null) { + rowsByKey.set(String(key), row); + } + } + + const features: GeoFeatureWithProperties[] = countries.features.map( + (country) => { + const featureKey = + featureField === "name" + ? country.properties?.["name"] + : country.id; + const row = + featureKey == null + ? undefined + : rowsByKey.get(String(featureKey)); + + return { + ...country, + properties: { + ...asRecord(country.properties), + ...asRecord(row), + }, + }; + }, + ); + + return Plot.geo(features, { + fill: (d) => + getChannelValue(asRecord(d.properties), mark.value ?? "value"), + stroke: "#ffffff", + strokeWidth: 0.75, + ...(mark.title + ? { + title: (d) => + getChannelValue(asRecord(d.properties), mark.title), + } + : { + title: (d) => { + const properties = asRecord(d.properties); + const name = + typeof properties["name"] === "string" + ? properties["name"] + : String(d.id ?? "Unknown"); + const fillValue = getChannelValue(properties, mark.value); + return fillValue == null ? name : `${name}: ${fillValue}`; + }, + }), + tip: true, + }); + } + default: + return null; + } + }) + .filter(Boolean); + + const defaultColorScheme = marks.some( + (mark: GeoMark) => mark._tag === "choropleth", + ) + ? "Blues" + : "Category10"; + + return ( + + + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + GeoChartWidgetPayload, + { + appInfo: { name: "widget-geo-chart", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-geo-chart/src/geo-data.ts b/packages/widget-geo-chart/src/geo-data.ts new file mode 100644 index 0000000..3ce1828 --- /dev/null +++ b/packages/widget-geo-chart/src/geo-data.ts @@ -0,0 +1,56 @@ +import type { + Feature, + FeatureCollection, + GeoJsonProperties, + Geometry, +} from "geojson"; +import { feature } from "topojson-client"; +import countriesWorld from "world-atlas/countries-110m.json"; +import world from "world-atlas/land-110m.json"; + +type TopologyObject = Parameters[1]; +type TopologyInput = Parameters[0]; + +type TopologyWithObject = TopologyInput & { + objects: { + [K in Key]: TopologyObject; + }; +}; + +export type GeoFeature = Feature; +export type GeoFeatureCollection = FeatureCollection< + Geometry, + GeoJsonProperties +>; + +const asTopologyWithObject = ( + value: unknown, + key: Key, +): TopologyWithObject => { + const topology = value as TopologyWithObject; + if (!(key in topology.objects)) { + throw new Error(`Missing TopoJSON object: ${key}`); + } + return topology; +}; + +const asFeatureCollection = (value: ReturnType) => { + if ("features" in value) { + return value as GeoFeatureCollection; + } + + return { + type: "FeatureCollection", + features: [value as GeoFeature], + } satisfies GeoFeatureCollection; +}; + +const worldAtlas = asTopologyWithObject(world, "land"); +const worldAtlasCountries = asTopologyWithObject(countriesWorld, "countries"); + +export const land = asFeatureCollection( + feature(worldAtlas, worldAtlas.objects.land), +); +export const countries = asFeatureCollection( + feature(worldAtlasCountries, worldAtlasCountries.objects.countries), +); diff --git a/packages/widget-geo-chart/src/global.d.ts b/packages/widget-geo-chart/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-geo-chart/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-geo-chart/tsconfig.json b/packages/widget-geo-chart/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-geo-chart/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-geo-chart/vite.config.ts b/packages/widget-geo-chart/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-geo-chart/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From c0cf6e3012b703d3145727d28d654645efcf4fd1 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:20:38 +0200 Subject: [PATCH 5/9] feat: add histogram --- .../src/widget/histogram/histogram.ts | 43 ++++++++++ packages/domain/src/Chart/Histogram.ts | 80 +++++++++++++++++++ packages/widget-histogram/README.md | 8 ++ packages/widget-histogram/index.html | 43 ++++++++++ packages/widget-histogram/package.json | 37 +++++++++ .../widget-histogram/scripts/copy-asset.ts | 60 ++++++++++++++ packages/widget-histogram/src/app.tsx | 4 + packages/widget-histogram/src/global.d.ts | 13 +++ packages/widget-histogram/src/histogram.tsx | 77 ++++++++++++++++++ packages/widget-histogram/tsconfig.json | 12 +++ packages/widget-histogram/vite.config.ts | 16 ++++ 11 files changed, 393 insertions(+) create mode 100644 apps/server-mcp/src/widget/histogram/histogram.ts create mode 100644 packages/domain/src/Chart/Histogram.ts create mode 100644 packages/widget-histogram/README.md create mode 100644 packages/widget-histogram/index.html create mode 100644 packages/widget-histogram/package.json create mode 100644 packages/widget-histogram/scripts/copy-asset.ts create mode 100644 packages/widget-histogram/src/app.tsx create mode 100644 packages/widget-histogram/src/global.d.ts create mode 100644 packages/widget-histogram/src/histogram.tsx create mode 100644 packages/widget-histogram/tsconfig.json create mode 100644 packages/widget-histogram/vite.config.ts diff --git a/apps/server-mcp/src/widget/histogram/histogram.ts b/apps/server-mcp/src/widget/histogram/histogram.ts new file mode 100644 index 0000000..8f7c334 --- /dev/null +++ b/apps/server-mcp/src/widget/histogram/histogram.ts @@ -0,0 +1,43 @@ +import { HistogramWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const HistogramWidgetResourceUri = "ui://histogram"; + +const HistogramWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "histogram"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-histogram.html") + : path.join(import.meta.dir, "widget/histogram/widget-histogram.html"); + return yield* fs.readFileString(htmlPath); +}); + +export const HistogramWidgetResourceLayer = makeUiResource( + HistogramWidgetResourceUri, + { + name: "Histogram", + description: "Histogram widget UI", + html: HistogramWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderHistogramWidgetTool = makeUiRenderTool( + HistogramWidgetResourceUri, + { + name: "render_histogram_widget", + title: "Histogram", + description: "Render the histogram widget UI", + parameters: HistogramWidgetPayload, + success: HistogramWidgetPayload, + }, +); + +export const renderHistogramWidgetHandler = ( + payload: typeof HistogramWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/Histogram.ts b/packages/domain/src/Chart/Histogram.ts new file mode 100644 index 0000000..ac80275 --- /dev/null +++ b/packages/domain/src/Chart/Histogram.ts @@ -0,0 +1,80 @@ +import { Schema, Struct } from "effect"; +import { + ChannelString, + IntervalValue, + NumberGreaterThanZero, + PlotLayoutProps, +} from "./shared"; + +export const HistogramDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const HistogramDatum = Schema.Record( + Schema.String, + HistogramDatumValue, +).annotate({ + description: + "Single histogram data object. Provide raw observations rather than pre-binned counts. Keys are column names used by marks.x and optional grouping fields such as marks.series.", + examples: [{ value: 72 }, { weight: 81, sex: "female" }], +}); + +export const HistogramDatumDefaults = Schema.Struct({ + value: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const HistogramMarkProps = Schema.Struct({ + x: ChannelString.annotate({ + description: + "Source field to bin along the x-axis. This should reference a key in each data object, such as 'value' or 'weight'.", + }), + series: ChannelString.annotate({ + description: + "Optional categorical field for grouped histograms and color encoding.", + }), + interval: IntervalValue.annotate({ + description: + "Optional bin interval hint. Use calendar intervals such as 'month' for temporal histograms or a numeric step size for quantitative histograms.", + }), + thresholds: NumberGreaterThanZero.annotate({ + description: + "Optional number of bins to target when interval is not specified.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const HistogramWidgetPayload = Schema.Struct({ + data: Schema.Array(HistogramDatum).annotate({ + description: + "Array of raw observation objects. Each object must expose the key used in marks.x.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: HistogramMarkProps.annotate({ + description: + "Histogram mapping. Use marks.x to select the field to bin. Use marks.series for grouped histograms. Defaults should assume x='value' if omitted.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for a histogram widget. Provide raw observations and let the widget compute bin counts with Plot.binX.", + examples: [ + { + data: [{ value: 12 }, { value: 18 }, { value: 21 }, { value: 22 }], + marks: { x: "value" }, + }, + { + data: [ + { weight: 62, sex: "female" }, + { weight: 81, sex: "male" }, + ], + layout: { title: "Weight distribution by sex", grid: true }, + marks: { x: "weight", series: "sex", thresholds: 12 }, + }, + ], +}); diff --git a/packages/widget-histogram/README.md b/packages/widget-histogram/README.md new file mode 100644 index 0000000..c68bbca --- /dev/null +++ b/packages/widget-histogram/README.md @@ -0,0 +1,8 @@ +# Histogram Widget + +Single-file MCP App widget that renders a histogram. + +## Scripts + +- `bun run build`: build the widget and copy the HTML asset +- `bun run dev`: watch and copy the HTML asset during local development diff --git a/packages/widget-histogram/index.html b/packages/widget-histogram/index.html new file mode 100644 index 0000000..5ffeed5 --- /dev/null +++ b/packages/widget-histogram/index.html @@ -0,0 +1,43 @@ + + + + + + Histogram Widget + + + +
+ + + diff --git a/packages/widget-histogram/package.json b/packages/widget-histogram/package.json new file mode 100644 index 0000000..33eb6ab --- /dev/null +++ b/packages/widget-histogram/package.json @@ -0,0 +1,37 @@ +{ + "name": "@repo/widget-histogram", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-histogram/scripts/copy-asset.ts b/packages/widget-histogram/scripts/copy-asset.ts new file mode 100644 index 0000000..52ec262 --- /dev/null +++ b/packages/widget-histogram/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/histogram" + : "../../apps/server-mcp/dist/widget/histogram", +); +const destinationPath = path.join(destinationDir, "widget-histogram.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-histogram/src/app.tsx b/packages/widget-histogram/src/app.tsx new file mode 100644 index 0000000..c145a1b --- /dev/null +++ b/packages/widget-histogram/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./histogram"; + +mountWidgetApp(); diff --git a/packages/widget-histogram/src/global.d.ts b/packages/widget-histogram/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-histogram/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-histogram/src/histogram.tsx b/packages/widget-histogram/src/histogram.tsx new file mode 100644 index 0000000..f18804d --- /dev/null +++ b/packages/widget-histogram/src/histogram.tsx @@ -0,0 +1,77 @@ +import * as Plot from "@observablehq/plot"; +import { HistogramWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; + +const Histogram = ({ + data, + layout, + marks, + app, +}: typeof HistogramWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const series = marks?.series; + + const binOptions = { + x: marks?.x ?? "value", + ...(series ? { fill: series } : {}), + ...(marks?.interval ? { interval: marks.interval } : {}), + ...(marks?.thresholds ? { thresholds: marks.thresholds } : {}), + inset: 0.5, + tip: true, + }; + + return ( + + + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + HistogramWidgetPayload, + { + appInfo: { name: "widget-histogram", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-histogram/tsconfig.json b/packages/widget-histogram/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-histogram/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-histogram/vite.config.ts b/packages/widget-histogram/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-histogram/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From 98694edc106c14dfc5575d216598be00353c23ea Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:20:52 +0200 Subject: [PATCH 6/9] feat: add treemap --- apps/server-mcp/src/widget/treemap/treemap.ts | 43 +++++ packages/domain/src/Chart/Treemap.ts | 82 ++++++++ packages/widget-treemap/README.md | 8 + packages/widget-treemap/index.html | 43 +++++ packages/widget-treemap/package.json | 37 ++++ packages/widget-treemap/scripts/copy-asset.ts | 60 ++++++ packages/widget-treemap/src/app.tsx | 4 + packages/widget-treemap/src/global.d.ts | 13 ++ packages/widget-treemap/src/treemap.tsx | 182 ++++++++++++++++++ packages/widget-treemap/tsconfig.json | 12 ++ packages/widget-treemap/vite.config.ts | 16 ++ 11 files changed, 500 insertions(+) create mode 100644 apps/server-mcp/src/widget/treemap/treemap.ts create mode 100644 packages/domain/src/Chart/Treemap.ts create mode 100644 packages/widget-treemap/README.md create mode 100644 packages/widget-treemap/index.html create mode 100644 packages/widget-treemap/package.json create mode 100644 packages/widget-treemap/scripts/copy-asset.ts create mode 100644 packages/widget-treemap/src/app.tsx create mode 100644 packages/widget-treemap/src/global.d.ts create mode 100644 packages/widget-treemap/src/treemap.tsx create mode 100644 packages/widget-treemap/tsconfig.json create mode 100644 packages/widget-treemap/vite.config.ts diff --git a/apps/server-mcp/src/widget/treemap/treemap.ts b/apps/server-mcp/src/widget/treemap/treemap.ts new file mode 100644 index 0000000..7de05b7 --- /dev/null +++ b/apps/server-mcp/src/widget/treemap/treemap.ts @@ -0,0 +1,43 @@ +import { TreemapWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const TreemapWidgetResourceUri = "ui://treemap"; + +const TreemapWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "treemap"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-treemap.html") + : path.join(import.meta.dir, "widget/treemap/widget-treemap.html"); + return yield* fs.readFileString(htmlPath); +}); + +export const TreemapWidgetResourceLayer = makeUiResource( + TreemapWidgetResourceUri, + { + name: "Treemap", + description: "Treemap widget UI", + html: TreemapWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderTreemapWidgetTool = makeUiRenderTool( + TreemapWidgetResourceUri, + { + name: "render_treemap_widget", + title: "Treemap", + description: "Render the treemap widget UI", + parameters: TreemapWidgetPayload, + success: TreemapWidgetPayload, + }, +); + +export const renderTreemapWidgetHandler = ( + payload: typeof TreemapWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/Treemap.ts b/packages/domain/src/Chart/Treemap.ts new file mode 100644 index 0000000..818f11d --- /dev/null +++ b/packages/domain/src/Chart/Treemap.ts @@ -0,0 +1,82 @@ +import { Schema, Struct } from "effect"; +import { Channel, ChannelString, PlotLayoutProps } from "./shared"; + +export const TreemapDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const TreemapDatum = Schema.Record( + Schema.String, + TreemapDatumValue, +).annotate({ + description: + "Single treemap data object for a flat, single-level treemap. Keys are column names used by marks.label, marks.value, and optional marks.series. Each row represents one category rectangle.", + examples: [ + { category: "Retail", value: 120 }, + { name: "Alpha", amount: 48, portfolio: "Core" }, + ], +}); + +export const TreemapDatumDefaults = Schema.Struct({ + category: Schema.String, + value: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const TreemapMarkProps = Schema.Struct({ + label: ChannelString.annotate({ + description: + "Category label field used for treemap tiles and text labels. Defaults to 'category' when omitted.", + }), + value: Channel.annotate({ + description: + "Quantitative value field used to size treemap rectangles. Defaults to 'value' when omitted.", + }), + series: Channel.annotate({ + description: + "Optional categorical field used for grouping and color. When omitted, the renderer colors by the label field.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const TreemapWidgetPayload = Schema.Struct({ + data: Schema.Array(TreemapDatum).annotate({ + description: + "Array of flat treemap category objects. Each object must expose the keys used in marks.label and marks.value.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: TreemapMarkProps.annotate({ + description: + "Treemap field mapping. Use marks.label for category names, marks.value for numeric area sizing, and marks.series when color grouping should differ from the label.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_treemap_widget. Use for a flat part-to-whole comparison where each row represents one category and value.", + examples: [ + { + data: [ + { category: "Retail", value: 120 }, + { category: "Health", value: 80 }, + ], + marks: { label: "category", value: "value" }, + }, + { + data: [ + { company: "Alpha", portfolio: "Core", exposure: 120 }, + { company: "Beta", portfolio: "Core", exposure: 80 }, + { company: "Gamma", portfolio: "Venture", exposure: 45 }, + ], + marks: { + label: "company", + value: "exposure", + series: "portfolio", + }, + }, + ], +}); diff --git a/packages/widget-treemap/README.md b/packages/widget-treemap/README.md new file mode 100644 index 0000000..7fcb2b3 --- /dev/null +++ b/packages/widget-treemap/README.md @@ -0,0 +1,8 @@ +# Treemap Widget + +Single-file MCP App widget that renders a flat treemap. + +## Scripts + +- `bun run build`: build the widget and copy the HTML asset +- `bun run dev`: watch and copy the HTML asset during local development diff --git a/packages/widget-treemap/index.html b/packages/widget-treemap/index.html new file mode 100644 index 0000000..ec88b07 --- /dev/null +++ b/packages/widget-treemap/index.html @@ -0,0 +1,43 @@ + + + + + + Treemap Widget + + + +
+ + + diff --git a/packages/widget-treemap/package.json b/packages/widget-treemap/package.json new file mode 100644 index 0000000..03eaf38 --- /dev/null +++ b/packages/widget-treemap/package.json @@ -0,0 +1,37 @@ +{ + "name": "@repo/widget-treemap", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-treemap/scripts/copy-asset.ts b/packages/widget-treemap/scripts/copy-asset.ts new file mode 100644 index 0000000..ad4c7ed --- /dev/null +++ b/packages/widget-treemap/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/treemap" + : "../../apps/server-mcp/dist/widget/treemap", +); +const destinationPath = path.join(destinationDir, "widget-treemap.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-treemap/src/app.tsx b/packages/widget-treemap/src/app.tsx new file mode 100644 index 0000000..f3b3252 --- /dev/null +++ b/packages/widget-treemap/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./treemap"; + +mountWidgetApp(); diff --git a/packages/widget-treemap/src/global.d.ts b/packages/widget-treemap/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-treemap/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-treemap/src/treemap.tsx b/packages/widget-treemap/src/treemap.tsx new file mode 100644 index 0000000..de5426e --- /dev/null +++ b/packages/widget-treemap/src/treemap.tsx @@ -0,0 +1,182 @@ +import * as Plot from "@observablehq/plot"; +import { TreemapWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; +import { hierarchy, treemap } from "d3"; + +type TreemapNode = { + x0: number; + y0: number; + x1: number; + y1: number; + label: string; + value: number; + series: unknown; + title: string; +}; + +type TreemapItem = { + label: string; + value: number; + series: unknown; + title: string; +}; + +type TreemapHierarchyDatum = { + children?: Array; + label?: string; + value?: number; + series?: unknown; + title?: string; +}; + +const TreemapChart = ({ + data, + layout, + marks, + app, +}: typeof TreemapWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { + width: _layoutWidth, + height: layoutHeight, + ...restLayoutOptions + } = layoutOptions; + const plotWidth = width ?? 640; + const plotHeight = layoutHeight ?? 420; + const labelField = marks?.label ?? "category"; + const valueField = marks?.value ?? "value"; + const seriesField = marks?.series ?? labelField; + const tilePadding = 1; + + const items: Array = data + .map((row: (typeof data)[number]) => { + const label = row[labelField as keyof typeof row]; + const value = row[valueField as keyof typeof row]; + const series = row[seriesField as keyof typeof row]; + const labelText = label == null ? "" : String(label); + const numericValue = + typeof value === "number" ? value : Number(value == null ? NaN : value); + + const titleLines = [`${labelText}: ${numericValue}`]; + + if (marks?.series && series != null) { + titleLines.push(`${marks.series}: ${String(series)}`); + } + + return { + label: labelText, + value: numericValue, + series, + title: titleLines.join("\n"), + }; + }) + .filter((row: TreemapItem) => Number.isFinite(row.value) && row.value > 0); + + const root = hierarchy({ children: items }).sum( + (d) => d.value ?? 0, + ); + + const treemapRoot = treemap() + .size([plotWidth, plotHeight]) + .paddingInner(tilePadding)(root); + + const leaves = treemapRoot.leaves(); + const nodes: Array = leaves.map((leaf) => ({ + x0: leaf.x0, + y0: leaf.y0, + x1: leaf.x1, + y1: leaf.y1, + label: leaf.data.label ?? "", + value: leaf.data.value ?? 0, + series: leaf.data.series, + title: + leaf.data.title ?? `${leaf.data.label ?? ""}: ${leaf.data.value ?? 0}`, + })); + + const labelNodes = nodes.filter( + (node) => node.x1 - node.x0 >= 72 && node.y1 - node.y0 >= 28, + ); + + return ( + + d.series, + stroke: "white", + strokeOpacity: 0.9, + strokeWidth: 1, + inset: 1, + r: 6, + title: (d: TreemapNode) => d.title, + }), + labelNodes.length > 0 + ? Plot.text(labelNodes, { + x: (d: TreemapNode) => (d.x0 + d.x1) / 2, + y: (d: TreemapNode) => (d.y0 + d.y1) / 2, + text: (d: TreemapNode) => d.label, + fill: "white", + textAnchor: "middle", + lineAnchor: "middle", + fontSize: 12, + fontWeight: 600, + pointerEvents: "none", + }) + : null, + ].filter(Boolean), + }} + dependencies={[data, layout, marks]} + /> + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + TreemapWidgetPayload, + { + appInfo: { name: "widget-treemap", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-treemap/tsconfig.json b/packages/widget-treemap/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-treemap/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-treemap/vite.config.ts b/packages/widget-treemap/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-treemap/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From eca11eb6cc783975970076a90fb82b1a26ebeed8 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:21:14 +0200 Subject: [PATCH 7/9] feat: add box plot --- .../src/widget/box-plot/box-plot.ts | 43 ++++++++++ packages/domain/src/Chart/BoxPlot.ts | 83 +++++++++++++++++++ packages/widget-box-plot/README.md | 8 ++ packages/widget-box-plot/index.html | 43 ++++++++++ packages/widget-box-plot/package.json | 37 +++++++++ .../widget-box-plot/scripts/copy-asset.ts | 60 ++++++++++++++ packages/widget-box-plot/src/app.tsx | 4 + packages/widget-box-plot/src/box-plot.tsx | 83 +++++++++++++++++++ packages/widget-box-plot/src/global.d.ts | 13 +++ packages/widget-box-plot/tsconfig.json | 12 +++ packages/widget-box-plot/vite.config.ts | 16 ++++ 11 files changed, 402 insertions(+) create mode 100644 apps/server-mcp/src/widget/box-plot/box-plot.ts create mode 100644 packages/domain/src/Chart/BoxPlot.ts create mode 100644 packages/widget-box-plot/README.md create mode 100644 packages/widget-box-plot/index.html create mode 100644 packages/widget-box-plot/package.json create mode 100644 packages/widget-box-plot/scripts/copy-asset.ts create mode 100644 packages/widget-box-plot/src/app.tsx create mode 100644 packages/widget-box-plot/src/box-plot.tsx create mode 100644 packages/widget-box-plot/src/global.d.ts create mode 100644 packages/widget-box-plot/tsconfig.json create mode 100644 packages/widget-box-plot/vite.config.ts diff --git a/apps/server-mcp/src/widget/box-plot/box-plot.ts b/apps/server-mcp/src/widget/box-plot/box-plot.ts new file mode 100644 index 0000000..71b7147 --- /dev/null +++ b/apps/server-mcp/src/widget/box-plot/box-plot.ts @@ -0,0 +1,43 @@ +import { BoxPlotWidgetPayload } from "@repo/domain/Chart"; +import { Effect, FileSystem, Path } from "effect"; +import { makeUiRenderTool, makeUiResource } from "../../UiResource"; + +const BoxPlotWidgetResourceUri = "ui://box-plot"; + +const BoxPlotWidgetHtml = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceSuffix = path.join("widget", "box-plot"); + const isSourcePath = import.meta.dir.endsWith(sourceSuffix); + const htmlPath = isSourcePath + ? path.join(import.meta.dir, "widget-box-plot.html") + : path.join(import.meta.dir, "widget/box-plot/widget-box-plot.html"); + return yield* fs.readFileString(htmlPath); +}); + +export const BoxPlotWidgetResourceLayer = makeUiResource( + BoxPlotWidgetResourceUri, + { + name: "Box Plot", + description: "Box plot widget UI", + html: BoxPlotWidgetHtml, + meta: { + prefersBorder: false, + }, + }, +); + +export const RenderBoxPlotWidgetTool = makeUiRenderTool( + BoxPlotWidgetResourceUri, + { + name: "render_box_plot_widget", + title: "Box Plot", + description: "Render the box plot widget UI", + parameters: BoxPlotWidgetPayload, + success: BoxPlotWidgetPayload, + }, +); + +export const renderBoxPlotWidgetHandler = ( + payload: typeof BoxPlotWidgetPayload.Type, +) => Effect.succeed(payload); diff --git a/packages/domain/src/Chart/BoxPlot.ts b/packages/domain/src/Chart/BoxPlot.ts new file mode 100644 index 0000000..325396e --- /dev/null +++ b/packages/domain/src/Chart/BoxPlot.ts @@ -0,0 +1,83 @@ +import { Schema, Struct } from "effect"; +import { Channel, PlotLayoutProps } from "./shared"; + +export const BoxPlotDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const BoxPlotDatum = Schema.Record( + Schema.String, + BoxPlotDatumValue, +).annotate({ + description: + "Single box-plot data object. Provide raw observations rather than summary statistics. Keys are column names used by marks.group and marks.value.", + examples: [ + { category: "A", value: 12 }, + { group: "Control", score: 82 }, + ], +}); + +export const BoxPlotDatumDefaults = Schema.Struct({ + category: Schema.String, + value: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const BoxPlotMarkProps = Schema.Struct({ + group: Channel.annotate({ + description: + "Categorical grouping field for each distribution. Defaults to 'category' when omitted.", + }), + value: Channel.annotate({ + description: + "Quantitative field containing raw observations. Defaults to 'value' when omitted.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const BoxPlotWidgetPayload = Schema.Struct({ + data: Schema.Array(BoxPlotDatum).annotate({ + description: + "Array of raw observation objects. Each object must expose the keys used in marks.x and marks.y.", + }), + direction: Schema.Literals(["horizontal", "vertical"]) + .annotate({ + description: + "Orientation of the box plot. 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: BoxPlotMarkProps.annotate({ + description: + "Box-plot field mapping. Use marks.group for the categorical grouping dimension and marks.value for the raw observations. Defaults are group='category' and value='value' if omitted.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_box_plot_widget. Provide raw observations and let the widget compute quartiles, whiskers, and outliers.", + examples: [ + { + data: [ + { category: "A", value: 10 }, + { category: "A", value: 12 }, + { category: "B", value: 18 }, + ], + direction: "vertical", + marks: { group: "category", value: "value" }, + }, + { + data: [ + { team: "Alpha", score: 82 }, + { team: "Alpha", score: 79 }, + { team: "Beta", score: 88 }, + ], + direction: "horizontal", + marks: { group: "team", value: "score" }, + }, + ], +}); diff --git a/packages/widget-box-plot/README.md b/packages/widget-box-plot/README.md new file mode 100644 index 0000000..37f8319 --- /dev/null +++ b/packages/widget-box-plot/README.md @@ -0,0 +1,8 @@ +# Box Plot Widget + +Single-file MCP App widget that renders a box plot. + +## Scripts + +- `bun run build`: build the widget and copy the HTML asset +- `bun run dev`: watch and copy the HTML asset during local development diff --git a/packages/widget-box-plot/index.html b/packages/widget-box-plot/index.html new file mode 100644 index 0000000..d09ae7c --- /dev/null +++ b/packages/widget-box-plot/index.html @@ -0,0 +1,43 @@ + + + + + + Box Plot Widget + + + +
+ + + diff --git a/packages/widget-box-plot/package.json b/packages/widget-box-plot/package.json new file mode 100644 index 0000000..e0a935f --- /dev/null +++ b/packages/widget-box-plot/package.json @@ -0,0 +1,37 @@ +{ + "name": "@repo/widget-box-plot", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build && bun run build:asset", + "build:asset": "bun scripts/copy-asset.ts build", + "dev": "bun run dev:asset & vite build --watch", + "dev:asset": "bun scripts/copy-asset.ts dev --watch", + "dev:local": "vite --host --clearScreen false", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.json", + "clean": "git clean -xdf .cache dist node_modules" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2" + } +} diff --git a/packages/widget-box-plot/scripts/copy-asset.ts b/packages/widget-box-plot/scripts/copy-asset.ts new file mode 100644 index 0000000..0ce07d3 --- /dev/null +++ b/packages/widget-box-plot/scripts/copy-asset.ts @@ -0,0 +1,60 @@ +import { existsSync, watch } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const mode = process.argv[2]; +const shouldWatch = process.argv.includes("--watch"); + +if (mode !== "build" && mode !== "dev") { + console.error("Usage: bun scripts/copy-asset.ts [--watch]"); + process.exit(1); +} + +const sourcePath = path.resolve("dist/index.html"); +const destinationDir = path.resolve( + mode === "dev" + ? "../../apps/server-mcp/src/widget/box-plot" + : "../../apps/server-mcp/dist/widget/box-plot", +); +const destinationPath = path.join(destinationDir, "widget-box-plot.html"); + +const waitForSource = async () => { + if (existsSync(sourcePath)) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (existsSync(sourcePath)) { + clearInterval(interval); + resolve(); + } + }, 200); + }); +}; + +const copyOnce = async () => { + await mkdir(destinationDir, { recursive: true }); + await copyFile(sourcePath, destinationPath); +}; + +const copyAfterBuild = async () => { + await waitForSource(); + await copyOnce(); +}; + +if (shouldWatch) { + await copyAfterBuild(); + + watch( + path.dirname(sourcePath), + { persistent: true }, + async (_event, file) => { + if (file === path.basename(sourcePath)) { + await copyOnce(); + } + }, + ); +} else { + await copyAfterBuild(); +} diff --git a/packages/widget-box-plot/src/app.tsx b/packages/widget-box-plot/src/app.tsx new file mode 100644 index 0000000..a944c55 --- /dev/null +++ b/packages/widget-box-plot/src/app.tsx @@ -0,0 +1,4 @@ +import { mountWidgetApp } from "@repo/ui"; +import { WidgetApp } from "./box-plot"; + +mountWidgetApp(); diff --git a/packages/widget-box-plot/src/box-plot.tsx b/packages/widget-box-plot/src/box-plot.tsx new file mode 100644 index 0000000..f48ca34 --- /dev/null +++ b/packages/widget-box-plot/src/box-plot.tsx @@ -0,0 +1,83 @@ +import * as Plot from "@observablehq/plot"; +import { BoxPlotWidgetPayload } from "@repo/domain/Chart"; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; + +const BoxPlot = ({ + data, + direction, + layout, + marks, + app, +}: typeof BoxPlotWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const isHorizontal = direction === "horizontal"; + const groupField = marks?.group ?? "category"; + const valueField = marks?.value ?? "value"; + + const boxOptions = { + x: isHorizontal ? valueField : groupField, + y: isHorizontal ? groupField : valueField, + fill: groupField, + fillOpacity: 0.35, + stroke: "currentColor", + strokeWidth: 1.5, + r: 2.5, + tip: true, + }; + + return ( + + + + ); +}; + +export const WidgetApp = () => { + const { app, isConnected, error, payload } = useWidgetPayload( + BoxPlotWidgetPayload, + { + appInfo: { name: "widget-box-plot", version: "0.1.0" }, + capabilities: {}, + }, + ); + + if (error) { + return ; + } + + if (!isConnected) { + return ; + } + + if (!payload) { + return ; + } + + return ; +}; diff --git a/packages/widget-box-plot/src/global.d.ts b/packages/widget-box-plot/src/global.d.ts new file mode 100644 index 0000000..adb53c3 --- /dev/null +++ b/packages/widget-box-plot/src/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + openai?: { + callTool?: ( + name: string, + args: Record, + ) => Promise; + toolOutput?: unknown; + }; + } +} diff --git a/packages/widget-box-plot/tsconfig.json b/packages/widget-box-plot/tsconfig.json new file mode 100644 index 0000000..13d6081 --- /dev/null +++ b/packages/widget-box-plot/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "mcp-app.html"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-box-plot/vite.config.ts b/packages/widget-box-plot/vite.config.ts new file mode 100644 index 0000000..fc1b086 --- /dev/null +++ b/packages/widget-box-plot/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rolldownOptions: { + output: { + codeSplitting: false, + }, + }, + }, +}); From 6e572424605213f8ef1d962ad4dce6326510f82e Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 4 Apr 2026 23:26:12 +0200 Subject: [PATCH 8/9] refactor: wire new charts to mcp --- apps/server-mcp/src/index.ts | 57 ++ .../src/resource/data-visualization.md | 538 ++++++++++++------ bun.lock | 205 ++++++- package.json | 2 +- packages/domain/src/Chart.ts | 7 + turbo.json | 9 +- 6 files changed, 648 insertions(+), 170 deletions(-) diff --git a/apps/server-mcp/src/index.ts b/apps/server-mcp/src/index.ts index 939b756..ef5e0bb 100644 --- a/apps/server-mcp/src/index.ts +++ b/apps/server-mcp/src/index.ts @@ -6,11 +6,42 @@ import { DevTools } from "effect/unstable/devtools"; import { HttpRouter, HttpServer } from "effect/unstable/http"; import { barChartOneShot } from "./prompt/bar-chart-one-shot"; import { DataVisualizationResourceLayer } from "./resource/data-visualization"; +import { + AreaChartWidgetResourceLayer, + RenderAreaChartWidgetTool, + renderAreaChartWidgetHandler, +} from "./widget/area-chart/area-chart"; import { BarChartWidgetResourceLayer, RenderBarChartWidgetTool, renderBarChartWidgetHandler, } from "./widget/bar-chart/bar-chart"; +import { + BoxPlotWidgetResourceLayer, + RenderBoxPlotWidgetTool, + renderBoxPlotWidgetHandler, +} from "./widget/box-plot/box-plot"; +import { + BulletChartWidgetResourceLayer, + RenderBulletChartWidgetTool, + renderBulletChartWidgetHandler, +} from "./widget/bullet-chart/bullet-chart"; + +import { + GeoChartWidgetResourceLayer, + RenderGeoChartWidgetTool, + renderGeoChartWidgetHandler, +} from "./widget/geo-chart/geo-chart"; +import { + HeatmapWidgetResourceLayer, + RenderHeatmapWidgetTool, + renderHeatmapWidgetHandler, +} from "./widget/heatmap/heatmap"; +import { + HistogramWidgetResourceLayer, + RenderHistogramWidgetTool, + renderHistogramWidgetHandler, +} from "./widget/histogram/histogram"; import { LineChartWidgetResourceLayer, RenderLineChartWidgetTool, @@ -21,12 +52,24 @@ import { renderScatterplotWidgetHandler, ScatterplotWidgetResourceLayer, } from "./widget/scatterplot/scatterplot"; +import { + RenderTreemapWidgetTool, + renderTreemapWidgetHandler, + TreemapWidgetResourceLayer, +} from "./widget/treemap/treemap"; // Define Resources const ResourceLayer = Layer.mergeAll( + AreaChartWidgetResourceLayer, BarChartWidgetResourceLayer, + BoxPlotWidgetResourceLayer, + BulletChartWidgetResourceLayer, + GeoChartWidgetResourceLayer, + HeatmapWidgetResourceLayer, + HistogramWidgetResourceLayer, LineChartWidgetResourceLayer, ScatterplotWidgetResourceLayer, + TreemapWidgetResourceLayer, DataVisualizationResourceLayer, // You can add more resources here ); @@ -39,18 +82,32 @@ const PromptLayer = Layer.mergeAll( // Define Toolkit class AiTools extends Toolkit.make( + RenderAreaChartWidgetTool, RenderBarChartWidgetTool, + RenderBoxPlotWidgetTool, + RenderBulletChartWidgetTool, + RenderGeoChartWidgetTool, + RenderHeatmapWidgetTool, + RenderHistogramWidgetTool, RenderLineChartWidgetTool, RenderScatterplotWidgetTool, + RenderTreemapWidgetTool, // You can add more tools here ) {} const ToolLayer = McpServer.toolkit(AiTools).pipe( Layer.provide( AiTools.toLayer({ + render_area_chart_widget: renderAreaChartWidgetHandler, render_bar_chart_widget: renderBarChartWidgetHandler, + render_box_plot_widget: renderBoxPlotWidgetHandler, + render_bullet_chart_widget: renderBulletChartWidgetHandler, + render_geo_chart_widget: renderGeoChartWidgetHandler, + render_heatmap_widget: renderHeatmapWidgetHandler, + render_histogram_widget: renderHistogramWidgetHandler, render_line_chart_widget: renderLineChartWidgetHandler, render_scatterplot_widget: renderScatterplotWidgetHandler, + render_treemap_widget: renderTreemapWidgetHandler, // add implementation for more tools here }), ), diff --git a/apps/server-mcp/src/resource/data-visualization.md b/apps/server-mcp/src/resource/data-visualization.md index 4de3ca4..ac09c97 100644 --- a/apps/server-mcp/src/resource/data-visualization.md +++ b/apps/server-mcp/src/resource/data-visualization.md @@ -1,168 +1,372 @@ -# Data Visualization Practices for Widgets - -Practical guidance for choosing chart types and structuring payloads for the -widget tools: render_bar_chart_widget, render_line_chart_widget, and -render_scatterplot_widget. - -## Chart Selection Guide (Widget-Mapped) - -| What You Are Showing | Best Widget | Alternatives | -| ---------------------------- | ---------------------------------------------------- | ------------------------------ | -| Trend over time | Line chart widget | Stacked area (not implemented) | -| Comparison across categories | Bar chart widget | Lollipop (not implemented) | -| Ranking | Bar chart widget (horizontal) | Dot plot (not implemented) | -| Part-to-whole composition | Bar chart widget with marks.fill (stacked) | Treemap (not implemented) | -| Composition over time | Line chart widget (multiple series) | Stacked area (not implemented) | -| Distribution | Histogram (not implemented) | Box plot (not implemented) | -| Correlation (2 variables) | Scatterplot widget | Bubble chart (not implemented) | -| Correlation (many variables) | Heatmap (not implemented) | Pair plot (not implemented) | -| Geographic patterns | Map (not implemented) | Bubble map (not implemented) | -| Flow / process | Sankey (not implemented) | Funnel (not implemented) | -| Relationship network | Network graph (not implemented) | Chord (not implemented) | -| Performance vs. target | Bullet chart (not implemented) | Gauge (not implemented) | -| Multiple KPIs at once | Small multiples (not implemented as a single widget) | Dashboard of separate widgets | - -## When NOT to Use Certain Charts - -- Pie/Donut: avoid unless <6 categories and only rough proportions matter. - (Pie/Donut not implemented.) -- 3D charts: never. They distort perception. (Not implemented.) -- Dual-axis: avoid unless clearly labeled; can mislead. -- Stacked bars with many categories: hard to compare middle segments. - -## Widget Payload Patterns - -### Interactivity (Tooltips) - -Enable tooltips by setting `marks.tip` to `true` in the widget payload. The -tooltip will include all fields from the underlying data row. - -```json -{ - "data": [ - { "category": "Q1", "value": 120, "region": "EU" } - ], - "direction": "vertical", - "marks": { "x": "category", "y": "value", "tip": true } -} -``` - -### Line Chart (Time Series) - -Use for trends. For dates, prefer Date objects or epoch milliseconds and set -layout.x.type to "utc" with calendar ticks. - -```json -{ - "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" } - ], - "layout": { - "title": "Metric trend", - "subtitle": "By series", - "caption": "Source: Internal", - "grid": true, - "x": { "type": "utc", "ticks": "month", "tickFormat": "%b" }, - "y": { "label": "Value" } - }, - "marks": { - "x": "date", - "y": "value", - "z": "series", - "tip": true - } -} -``` - -### Bar Chart (Comparison or Ranking) - -Use direction "horizontal" when labels are long. Keep bars starting at zero. - -```json -{ - "data": [ - { "category": "Q1", "value": 120 }, - { "category": "Q2", "value": 98 }, - { "category": "Q3", "value": 140 }, - { "category": "Q4", "value": 110 } - ], - "direction": "vertical", - "layout": { - "title": "Quarterly sales", - "grid": true, - "y": { "label": "Sales" } - }, - "marks": { "x": "category", "y": "value", "tip": true } -} -``` - -Stacked bars (part-to-whole): set marks.fill (or marks.z) to a series key. - -```json -{ - "data": [ - { "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": "vertical", - "marks": { "x": "quarter", "y": "value", "fill": "region", "tip": true } -} -``` - -### Scatterplot (Correlation) - -Use for two quantitative variables. Add size or color via marks.r or marks.fill. - -```json -{ - "data": [ - { "x": 10, "y": 22, "group": "A" }, - { "x": 15, "y": 18, "group": "B" } - ], - "layout": { - "title": "Correlation", - "x": { "label": "Metric A" }, - "y": { "label": "Metric B" } - }, - "marks": { "x": "x", "y": "y", "fill": "group", "tip": true } -} -``` - -## Layout and Scale Guidance - -- Time series: set layout.x.type to "utc" (recommended) or "time". -- Bar charts: keep y-axis starting at zero to avoid exaggeration. -- Use layout.x.ticks or layout.x.interval for readable date ticks. -- Use layout.title/subtitle/caption to state the insight and context. -- Use layout.grid sparingly; let data be the focus. - -## Design Principles - -- Color encodes data, not decoration. Use a single accent to highlight. +# Data Visualization Selection Guide + +Use this guide to choose the right widget for a user's data and question. + +Pick the simplest chart that answers the user's question well. Prefer charts +with accurate comparison over charts that look more dramatic. + +This guide only covers widgets that exist in this server: + +- `render_line_chart_widget` +- `render_area_chart_widget` +- `render_bar_chart_widget` +- `render_histogram_widget` +- `render_box_plot_widget` +- `render_scatterplot_widget` +- `render_heatmap_widget` +- `render_treemap_widget` +- `render_geo_chart_widget` +- `render_bullet_chart_widget` + +## How To Choose + +Start from the user's analytic goal, not from a favorite chart type. + +1. If the user wants to show change over an ordered dimension, prefer a line chart. +2. If the user wants to compare categories, prefer a bar chart. +3. If the user wants to show a distribution, choose histogram or box plot. +4. If the user wants to show relationship between two quantitative variables, choose scatterplot. +5. If the user wants to show a matrix of values across two categorical axes, choose heatmap. +6. If the user wants part-to-whole and there is only one flat level of categories, consider treemap. +7. If the user wants value versus target, choose bullet chart. +8. If geography is essential to the message, choose geo chart. + +If two charts could work, prefer the one that supports more accurate reading. + +## Quick Selection Table + +| User intent | Best widget | Use when | Prefer instead of | +| ----------------------------------------------- | ------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------ | +| Trend over time or another ordered axis | Line chart | The main message is direction, slope, or comparison over sequence | Area chart when filled magnitude is not important | +| Magnitude over time or composition over time | Area chart | Filled area helps communicate accumulated volume or stacked composition | Line chart when only the topline matters | +| Compare or rank categories | Bar chart | Users need precise comparison across categories | Treemap when exact comparison matters | +| Distribution of one variable | Histogram | You have raw observations and want to show frequency by bin | Box plot when comparing grouped distributions | +| Distribution across groups | Box plot | You want quartiles, median, spread, and outliers by category | Histogram when shape of one distribution is the main message | +| Relationship between two quantitative variables | Scatterplot | You want correlation, clustering, or outliers | Heatmap when the data is already a matrix | +| Matrix comparison | Heatmap | Each row already represents one cell value across x and y categories | Scatterplot for raw point clouds | +| Flat part-to-whole composition | Treemap | You have one level of categories and area comparison is acceptable | Bar chart for more precise comparison | +| Geographic point or country pattern | Geo chart | Location itself matters to the story | Bar chart when geography is incidental | +| Actual value versus target | Bullet chart | You need to compare one or more values against explicit targets | Bar chart when there is no target | + +## Decision Rules + +### Line Chart + +Choose line charts for trends across time or another naturally ordered axis. + +Good fit: + +- month-by-month, day-by-day, year-over-year, or step-by-step change +- one or a few series with a shared ordered x-axis +- questions about rise, fall, crossover, or volatility + +Prefer line over area when: + +- the exact path matters more than accumulated magnitude +- several series need to be compared clearly + +Avoid line when: + +- x is just a set of unrelated categories +- the user is comparing totals across categories rather than change + +### Area Chart + +Choose area charts when filled magnitude adds meaning. + +Good fit: + +- traffic volume over time +- totals over time where the area communicates amount +- stacked composition over time with a small number of series + +Prefer area over line when: + +- the user cares about volume as well as direction +- stacked composition over time is the main message + +Avoid area when: + +- many series would overlap or stack into an unreadable chart +- exact comparison between middle stacked series matters + +### Bar Chart + +Choose bar charts for category comparison and ranking. + +Good fit: + +- comparing products, teams, regions, or time periods as discrete buckets +- ranking top or bottom performers +- comparing totals across categories + +Use horizontal bars when: + +- labels are long +- ranking is the main message + +Use stacked bars when: + +- the user wants part-to-whole within each category +- there are only a few series + +Avoid bar when: + +- the x-axis is continuous time and the story is trend +- there are too many stacked segments for reliable comparison + +### Histogram + +Choose histograms for the distribution of one quantitative or temporal variable. + +Good fit: + +- spread of salaries, ages, response times, or weights +- questions about skew, clusters, gaps, or rough shape +- raw observations that still need binning + +Prefer histogram over box plot when: + +- the shape of the distribution matters +- there is only one main variable to summarize + +Avoid histogram when: + +- the data is already summarized into quartiles +- the user mainly needs grouped comparison of medians and spread + +### Box Plot + +Choose box plots for comparing distributions across categories. + +Good fit: + +- score distribution by team +- response time distribution by service +- salary distribution by department + +Prefer box plot over histogram when: + +- there are several groups to compare side by side +- quartiles, median, spread, and outliers are the main message + +Avoid box plot when: + +- the user needs the full shape of the distribution +- there is no categorical grouping dimension + +Important: + +- box plots should be built from raw observations, not precomputed quartiles + +### Scatterplot + +Choose scatterplots for relationships between two quantitative variables. + +Good fit: + +- revenue versus margin +- horsepower versus fuel economy +- latency versus throughput +- cluster, correlation, and outlier analysis + +This widget also works for bubble-style charts when size adds a third variable. + +Prefer scatterplot over heatmap when: + +- the data consists of individual observations, not precomputed matrix cells + +Avoid scatterplot when: + +- both axes are categorical +- there are so many points that overplotting hides the pattern and a different summary is needed + +### Heatmap + +Choose heatmaps for a matrix of values across two categorical axes. + +Good fit: + +- month by region sales +- weekday by hour activity +- team by metric scorecards + +Use heatmap when: + +- each row already represents one cell value +- color is the main comparison channel + +Prefer heatmap over scatterplot when: + +- the data is already aggregated into a grid or matrix + +Avoid heatmap when: + +- the user needs precise positional reading more than matrix scanning +- the matrix is so large that labels and color differences become unreadable + +### Treemap + +Choose treemaps for flat part-to-whole composition. + +Good fit: + +- portfolio composition +- category contribution to a whole +- share of total across a modest number of categories + +Use treemap only when: + +- there is a single flat level of categories +- area comparison is acceptable + +Prefer bar chart over treemap when: + +- precise comparison matters more than compact part-to-whole presentation +- ranking is part of the message + +Avoid treemap when: + +- the data is hierarchical across multiple parent-child levels +- there are too many tiny categories to label or compare + +### Geo Chart + +Choose geo charts only when geography is essential. + +Good fit: + +- city locations with magnitude encoded as point size +- country-level choropleths +- any story where position on a map is meaningful + +This widget supports: + +- projected point maps +- country choropleths +- simple basemap layers such as sphere, land, and graticule + +Prefer geo chart over bar chart only when: + +- the user needs to reason about spatial location, regional clustering, or map context + +Avoid geo chart when: + +- geography is just decoration +- a ranked bar chart would make the comparison clearer + +Important: + +- choropleths are for country-level joins in this widget, not arbitrary custom polygons + +### Bullet Chart + +Choose bullet charts for value-versus-target comparison. + +Good fit: + +- actual versus target revenue by product line +- KPI versus goal by team +- one or more categories where each row has a current value and a target + +Prefer bullet chart over bar chart when: + +- the target is central to the question +- the user wants to compare actual performance against a benchmark + +Avoid bullet chart when: + +- there is no explicit target +- the user wants a single dashboard-style gauge display rather than precise comparison + +## Common Ambiguities + +### Line vs Area + +Choose line when the message is trend. + +Choose area when the message is trend plus magnitude, or composition over time. + +### Bar vs Treemap + +Choose bar when the user needs precise comparison or ranking. + +Choose treemap when the user wants compact flat part-to-whole composition and can tolerate area-based comparison. + +### Histogram vs Box Plot + +Choose histogram for one variable's distribution shape. + +Choose box plot for grouped distribution comparison. + +### Heatmap vs Scatterplot + +Choose heatmap for precomputed matrix cells. + +Choose scatterplot for raw observations positioned by two quantitative variables. + +### Bullet vs Bar + +Choose bullet when target comparison is part of the question. + +Choose bar when the user only wants category comparison. + +### Geo vs Non-Geo Charts + +Choose geo only when the map itself adds meaning. + +If the user is really comparing categories such as countries or regions by value, +and spatial position is not the point, prefer bar. + +## Good Practices + +- Prefer the most readable chart, not the most decorative one. +- Use direct comparison charts when users need exact judgment. +- Keep category counts manageable. - Sort categories by value unless a natural order exists. -- Keep labels short; switch to horizontal bars for long labels. -- Use direct labels for key points rather than cluttering everything. -- Maintain consistent scales across related charts. - -## Accessibility - -- Do not rely on color alone. Use direct labels or symbols. -- Ensure text is legible (>= 10pt labels, >= 12pt titles). -- Provide a caption and data context for screen readers. - -## Not Implemented (Widget Gaps) - -- Area, stacked area -- Histogram, box plot, violin plot -- Heatmap, treemap -- Choropleth or any map-based chart -- Sankey, funnel -- Network graph, chord diagram -- Bullet, gauge -- Pie, donut -- Small multiples as a single widget (use multiple widgets instead) +- Use horizontal bars for long labels. +- Use line or area only when the x-axis is meaningfully ordered. +- Use heatmaps and treemaps only when color or area comparison is acceptable. +- Use geo charts only when location matters. +- Use bullet charts when a target or benchmark is explicit. + +## Bad Practices + +- Do not choose pie or donut charts by default. +- Do not choose 3D charts. +- Do not choose dual-axis charts unless the user explicitly asks and the labeling can be made very clear. +- Do not choose a treemap when a bar chart would answer the question more accurately. +- Do not choose a geo chart just because the data contains place names. +- Do not choose a box plot from pre-aggregated quartiles; it expects raw observations. +- Do not choose a histogram from pre-binned counts; it expects raw observations. + +## Unsupported Chart Families + +These are not available as widgets here, so choose a supported alternative instead. + +- Pie and donut: usually replace with bar or treemap +- Gauge: usually replace with bullet chart +- Funnel: usually replace with bar chart if the stages are simple +- Network graph: usually replace with bar, scatterplot, or heatmap depending on the question +- Sankey: not available +- Chord diagram: not available +- Violin plot: not available +- Small multiples as one widget: use separate widgets instead + +## LLM Guidance + +When the human request is vague, infer the chart from the question they are asking. + +Examples of intent mapping: + +- "How has this changed over time?" -> line chart +- "Which category is biggest or smallest?" -> bar chart +- "What does the distribution look like?" -> histogram +- "How do these groups differ in spread?" -> box plot +- "Are these two measures related?" -> scatterplot +- "Show this matrix of scores" -> heatmap +- "How do these parts contribute to the whole?" -> treemap +- "Where are these values located geographically?" -> geo chart +- "How far are we from target?" -> bullet chart + +If the request is still ambiguous after that, ask one short clarifying question. diff --git a/bun.lock b/bun.lock index 8be800f..a8a428f 100644 --- a/bun.lock +++ b/bun.lock @@ -166,6 +166,31 @@ "@types/react-dom": "^19.2.3", }, }, + "packages/widget-area-chart": { + "name": "@repo/widget-area-chart", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, "packages/widget-bar-chart": { "name": "@repo/widget-bar-chart", "version": "0.0.0", @@ -191,6 +216,135 @@ "vite-plugin-singlefile": "^2.3.2", }, }, + "packages/widget-box-plot": { + "name": "@repo/widget-box-plot", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, + "packages/widget-bullet-chart": { + "name": "@repo/widget-bullet-chart", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, + "packages/widget-geo-chart": { + "name": "@repo/widget-geo-chart", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2", + }, + "devDependencies": { + "@repo/config-typescript": "workspace:*", + "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", + "@types/d3": "^7.4.3", + "@types/geojson": "^7946.0.16", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/topojson-client": "^3.1.5", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, + "packages/widget-heatmap": { + "name": "@repo/widget-heatmap", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, + "packages/widget-histogram": { + "name": "@repo/widget-histogram", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, "packages/widget-line-chart": { "name": "@repo/widget-line-chart", "version": "0.0.0", @@ -241,6 +395,31 @@ "vite-plugin-singlefile": "^2.3.2", }, }, + "packages/widget-treemap": { + "name": "@repo/widget-treemap", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "d3": "^7.9.0", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "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", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~6.0.2", + "vite": "^8.0.3", + "vite-plugin-singlefile": "^2.3.2", + }, + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -611,12 +790,26 @@ "@repo/ui": ["@repo/ui@workspace:packages/ui"], + "@repo/widget-area-chart": ["@repo/widget-area-chart@workspace:packages/widget-area-chart"], + "@repo/widget-bar-chart": ["@repo/widget-bar-chart@workspace:packages/widget-bar-chart"], + "@repo/widget-box-plot": ["@repo/widget-box-plot@workspace:packages/widget-box-plot"], + + "@repo/widget-bullet-chart": ["@repo/widget-bullet-chart@workspace:packages/widget-bullet-chart"], + + "@repo/widget-geo-chart": ["@repo/widget-geo-chart@workspace:packages/widget-geo-chart"], + + "@repo/widget-heatmap": ["@repo/widget-heatmap@workspace:packages/widget-heatmap"], + + "@repo/widget-histogram": ["@repo/widget-histogram@workspace:packages/widget-histogram"], + "@repo/widget-line-chart": ["@repo/widget-line-chart@workspace:packages/widget-line-chart"], "@repo/widget-scatterplot": ["@repo/widget-scatterplot@workspace:packages/widget-scatterplot"], + "@repo/widget-treemap": ["@repo/widget-treemap@workspace:packages/widget-treemap"], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], @@ -843,6 +1036,10 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/topojson-client": ["@types/topojson-client@3.1.5", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw=="], + + "@types/topojson-specification": ["@types/topojson-specification@1.0.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], @@ -959,7 +1156,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], @@ -1729,6 +1926,8 @@ "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], @@ -1811,6 +2010,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -1887,6 +2088,8 @@ "router/path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="], + "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index d4e1644..4677d95 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "turbo run build", "clean": "turbo run clean && git clean -xdf node_modules .cache .turbo dist tsconfig.tsbuildinfo playwright-report test-results", - "dev": "turbo run dev", + "dev": "turbo run dev --concurrency 15", "lint": "biome lint .", "format": "biome check --write .", "format:check": "biome check .", diff --git a/packages/domain/src/Chart.ts b/packages/domain/src/Chart.ts index 6503820..fae1a99 100644 --- a/packages/domain/src/Chart.ts +++ b/packages/domain/src/Chart.ts @@ -1,4 +1,11 @@ +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/turbo.json b/turbo.json index a529e95..8dd5686 100644 --- a/turbo.json +++ b/turbo.json @@ -9,9 +9,16 @@ }, "server-mcp#build": { "dependsOn": [ + "@repo/widget-area-chart#build", "@repo/widget-bar-chart#build", + "@repo/widget-box-plot#build", + "@repo/widget-bullet-chart#build", + "@repo/widget-geo-chart#build", + "@repo/widget-heatmap#build", + "@repo/widget-histogram#build", "@repo/widget-line-chart#build", - "@repo/widget-scatterplot#build" + "@repo/widget-scatterplot#build", + "@repo/widget-treemap#build" ] }, "dev": { From 0001b03cabf25e98f2ec138646ad2f37962654a8 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Mon, 6 Apr 2026 19:03:20 +0200 Subject: [PATCH 9/9] refactor: add public folder for widget and resources --- .gitignore | 2 +- apps/server-mcp/Dockerfile | 2 +- apps/server-mcp/package.json | 2 +- apps/server-mcp/public/data-visualization.md | 372 ++++++++++++++++++ apps/server-mcp/public/widgets/.gitkeep | 0 apps/server-mcp/scripts/copy-public.ts | 13 + apps/server-mcp/src/UiResource.ts | 16 +- .../src/resource/data-visualization.ts | 19 +- .../src/widget/area-chart/area-chart.ts | 22 +- .../src/widget/bar-chart/bar-chart.ts | 22 +- .../src/widget/box-plot/box-plot.ts | 22 +- .../src/widget/bullet-chart/bullet-chart.ts | 25 +- .../src/widget/geo-chart/geo-chart.ts | 22 +- apps/server-mcp/src/widget/heatmap/heatmap.ts | 22 +- .../src/widget/histogram/histogram.ts | 22 +- .../src/widget/line-chart/line-chart.ts | 22 +- .../src/widget/scatterplot/scatterplot.ts | 22 +- apps/server-mcp/src/widget/treemap/treemap.ts | 22 +- .../widget-area-chart/scripts/copy-asset.ts | 5 +- packages/widget-area-chart/src/area-chart.tsx | 18 +- .../widget-bar-chart/scripts/copy-asset.ts | 5 +- .../widget-box-plot/scripts/copy-asset.ts | 5 +- .../widget-bullet-chart/scripts/copy-asset.ts | 5 +- .../widget-geo-chart/scripts/copy-asset.ts | 5 +- packages/widget-heatmap/scripts/copy-asset.ts | 5 +- .../widget-histogram/scripts/copy-asset.ts | 5 +- .../widget-line-chart/scripts/copy-asset.ts | 5 +- .../widget-scatterplot/scripts/copy-asset.ts | 5 +- packages/widget-treemap/scripts/copy-asset.ts | 5 +- 29 files changed, 561 insertions(+), 156 deletions(-) create mode 100644 apps/server-mcp/public/data-visualization.md create mode 100644 apps/server-mcp/public/widgets/.gitkeep create mode 100644 apps/server-mcp/scripts/copy-public.ts diff --git a/.gitignore b/.gitignore index c401b5b..a3c31ff 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,4 @@ tsconfig.tsbuildinfo .reference # Server MCP widget -apps/server-mcp/src/widget/**/*.html +apps/server-mcp/public/**/*.html diff --git a/apps/server-mcp/Dockerfile b/apps/server-mcp/Dockerfile index 64a81a7..90b7996 100644 --- a/apps/server-mcp/Dockerfile +++ b/apps/server-mcp/Dockerfile @@ -30,7 +30,7 @@ RUN adduser --system --uid 1001 server-mcp USER server-mcp COPY --from=builder /app/apps/server-mcp/dist/index.js ./server-mcp/index.js -COPY --from=builder /app/apps/server-mcp/dist/widget ./server-mcp/widget +COPY --from=builder /app/apps/server-mcp/dist/public ./server-mcp/public COPY --from=builder /app/apps/server-mcp/package.json ./server-mcp/package.json EXPOSE 8000 diff --git a/apps/server-mcp/package.json b/apps/server-mcp/package.json index 8667d39..84382f8 100644 --- a/apps/server-mcp/package.json +++ b/apps/server-mcp/package.json @@ -4,7 +4,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "bun build src/index.ts --outdir=dist --target=bun --minify", + "build": "bun build src/index.ts --outdir=dist --target=bun --minify && bun scripts/copy-public.ts", "build:types": "tsc --emitDeclarationOnly", "dev": "bun --watch run src/index.ts", "inspector": "npx @mcpjam/inspector@latest", diff --git a/apps/server-mcp/public/data-visualization.md b/apps/server-mcp/public/data-visualization.md new file mode 100644 index 0000000..ac09c97 --- /dev/null +++ b/apps/server-mcp/public/data-visualization.md @@ -0,0 +1,372 @@ +# Data Visualization Selection Guide + +Use this guide to choose the right widget for a user's data and question. + +Pick the simplest chart that answers the user's question well. Prefer charts +with accurate comparison over charts that look more dramatic. + +This guide only covers widgets that exist in this server: + +- `render_line_chart_widget` +- `render_area_chart_widget` +- `render_bar_chart_widget` +- `render_histogram_widget` +- `render_box_plot_widget` +- `render_scatterplot_widget` +- `render_heatmap_widget` +- `render_treemap_widget` +- `render_geo_chart_widget` +- `render_bullet_chart_widget` + +## How To Choose + +Start from the user's analytic goal, not from a favorite chart type. + +1. If the user wants to show change over an ordered dimension, prefer a line chart. +2. If the user wants to compare categories, prefer a bar chart. +3. If the user wants to show a distribution, choose histogram or box plot. +4. If the user wants to show relationship between two quantitative variables, choose scatterplot. +5. If the user wants to show a matrix of values across two categorical axes, choose heatmap. +6. If the user wants part-to-whole and there is only one flat level of categories, consider treemap. +7. If the user wants value versus target, choose bullet chart. +8. If geography is essential to the message, choose geo chart. + +If two charts could work, prefer the one that supports more accurate reading. + +## Quick Selection Table + +| User intent | Best widget | Use when | Prefer instead of | +| ----------------------------------------------- | ------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------ | +| Trend over time or another ordered axis | Line chart | The main message is direction, slope, or comparison over sequence | Area chart when filled magnitude is not important | +| Magnitude over time or composition over time | Area chart | Filled area helps communicate accumulated volume or stacked composition | Line chart when only the topline matters | +| Compare or rank categories | Bar chart | Users need precise comparison across categories | Treemap when exact comparison matters | +| Distribution of one variable | Histogram | You have raw observations and want to show frequency by bin | Box plot when comparing grouped distributions | +| Distribution across groups | Box plot | You want quartiles, median, spread, and outliers by category | Histogram when shape of one distribution is the main message | +| Relationship between two quantitative variables | Scatterplot | You want correlation, clustering, or outliers | Heatmap when the data is already a matrix | +| Matrix comparison | Heatmap | Each row already represents one cell value across x and y categories | Scatterplot for raw point clouds | +| Flat part-to-whole composition | Treemap | You have one level of categories and area comparison is acceptable | Bar chart for more precise comparison | +| Geographic point or country pattern | Geo chart | Location itself matters to the story | Bar chart when geography is incidental | +| Actual value versus target | Bullet chart | You need to compare one or more values against explicit targets | Bar chart when there is no target | + +## Decision Rules + +### Line Chart + +Choose line charts for trends across time or another naturally ordered axis. + +Good fit: + +- month-by-month, day-by-day, year-over-year, or step-by-step change +- one or a few series with a shared ordered x-axis +- questions about rise, fall, crossover, or volatility + +Prefer line over area when: + +- the exact path matters more than accumulated magnitude +- several series need to be compared clearly + +Avoid line when: + +- x is just a set of unrelated categories +- the user is comparing totals across categories rather than change + +### Area Chart + +Choose area charts when filled magnitude adds meaning. + +Good fit: + +- traffic volume over time +- totals over time where the area communicates amount +- stacked composition over time with a small number of series + +Prefer area over line when: + +- the user cares about volume as well as direction +- stacked composition over time is the main message + +Avoid area when: + +- many series would overlap or stack into an unreadable chart +- exact comparison between middle stacked series matters + +### Bar Chart + +Choose bar charts for category comparison and ranking. + +Good fit: + +- comparing products, teams, regions, or time periods as discrete buckets +- ranking top or bottom performers +- comparing totals across categories + +Use horizontal bars when: + +- labels are long +- ranking is the main message + +Use stacked bars when: + +- the user wants part-to-whole within each category +- there are only a few series + +Avoid bar when: + +- the x-axis is continuous time and the story is trend +- there are too many stacked segments for reliable comparison + +### Histogram + +Choose histograms for the distribution of one quantitative or temporal variable. + +Good fit: + +- spread of salaries, ages, response times, or weights +- questions about skew, clusters, gaps, or rough shape +- raw observations that still need binning + +Prefer histogram over box plot when: + +- the shape of the distribution matters +- there is only one main variable to summarize + +Avoid histogram when: + +- the data is already summarized into quartiles +- the user mainly needs grouped comparison of medians and spread + +### Box Plot + +Choose box plots for comparing distributions across categories. + +Good fit: + +- score distribution by team +- response time distribution by service +- salary distribution by department + +Prefer box plot over histogram when: + +- there are several groups to compare side by side +- quartiles, median, spread, and outliers are the main message + +Avoid box plot when: + +- the user needs the full shape of the distribution +- there is no categorical grouping dimension + +Important: + +- box plots should be built from raw observations, not precomputed quartiles + +### Scatterplot + +Choose scatterplots for relationships between two quantitative variables. + +Good fit: + +- revenue versus margin +- horsepower versus fuel economy +- latency versus throughput +- cluster, correlation, and outlier analysis + +This widget also works for bubble-style charts when size adds a third variable. + +Prefer scatterplot over heatmap when: + +- the data consists of individual observations, not precomputed matrix cells + +Avoid scatterplot when: + +- both axes are categorical +- there are so many points that overplotting hides the pattern and a different summary is needed + +### Heatmap + +Choose heatmaps for a matrix of values across two categorical axes. + +Good fit: + +- month by region sales +- weekday by hour activity +- team by metric scorecards + +Use heatmap when: + +- each row already represents one cell value +- color is the main comparison channel + +Prefer heatmap over scatterplot when: + +- the data is already aggregated into a grid or matrix + +Avoid heatmap when: + +- the user needs precise positional reading more than matrix scanning +- the matrix is so large that labels and color differences become unreadable + +### Treemap + +Choose treemaps for flat part-to-whole composition. + +Good fit: + +- portfolio composition +- category contribution to a whole +- share of total across a modest number of categories + +Use treemap only when: + +- there is a single flat level of categories +- area comparison is acceptable + +Prefer bar chart over treemap when: + +- precise comparison matters more than compact part-to-whole presentation +- ranking is part of the message + +Avoid treemap when: + +- the data is hierarchical across multiple parent-child levels +- there are too many tiny categories to label or compare + +### Geo Chart + +Choose geo charts only when geography is essential. + +Good fit: + +- city locations with magnitude encoded as point size +- country-level choropleths +- any story where position on a map is meaningful + +This widget supports: + +- projected point maps +- country choropleths +- simple basemap layers such as sphere, land, and graticule + +Prefer geo chart over bar chart only when: + +- the user needs to reason about spatial location, regional clustering, or map context + +Avoid geo chart when: + +- geography is just decoration +- a ranked bar chart would make the comparison clearer + +Important: + +- choropleths are for country-level joins in this widget, not arbitrary custom polygons + +### Bullet Chart + +Choose bullet charts for value-versus-target comparison. + +Good fit: + +- actual versus target revenue by product line +- KPI versus goal by team +- one or more categories where each row has a current value and a target + +Prefer bullet chart over bar chart when: + +- the target is central to the question +- the user wants to compare actual performance against a benchmark + +Avoid bullet chart when: + +- there is no explicit target +- the user wants a single dashboard-style gauge display rather than precise comparison + +## Common Ambiguities + +### Line vs Area + +Choose line when the message is trend. + +Choose area when the message is trend plus magnitude, or composition over time. + +### Bar vs Treemap + +Choose bar when the user needs precise comparison or ranking. + +Choose treemap when the user wants compact flat part-to-whole composition and can tolerate area-based comparison. + +### Histogram vs Box Plot + +Choose histogram for one variable's distribution shape. + +Choose box plot for grouped distribution comparison. + +### Heatmap vs Scatterplot + +Choose heatmap for precomputed matrix cells. + +Choose scatterplot for raw observations positioned by two quantitative variables. + +### Bullet vs Bar + +Choose bullet when target comparison is part of the question. + +Choose bar when the user only wants category comparison. + +### Geo vs Non-Geo Charts + +Choose geo only when the map itself adds meaning. + +If the user is really comparing categories such as countries or regions by value, +and spatial position is not the point, prefer bar. + +## Good Practices + +- Prefer the most readable chart, not the most decorative one. +- Use direct comparison charts when users need exact judgment. +- Keep category counts manageable. +- Sort categories by value unless a natural order exists. +- Use horizontal bars for long labels. +- Use line or area only when the x-axis is meaningfully ordered. +- Use heatmaps and treemaps only when color or area comparison is acceptable. +- Use geo charts only when location matters. +- Use bullet charts when a target or benchmark is explicit. + +## Bad Practices + +- Do not choose pie or donut charts by default. +- Do not choose 3D charts. +- Do not choose dual-axis charts unless the user explicitly asks and the labeling can be made very clear. +- Do not choose a treemap when a bar chart would answer the question more accurately. +- Do not choose a geo chart just because the data contains place names. +- Do not choose a box plot from pre-aggregated quartiles; it expects raw observations. +- Do not choose a histogram from pre-binned counts; it expects raw observations. + +## Unsupported Chart Families + +These are not available as widgets here, so choose a supported alternative instead. + +- Pie and donut: usually replace with bar or treemap +- Gauge: usually replace with bullet chart +- Funnel: usually replace with bar chart if the stages are simple +- Network graph: usually replace with bar, scatterplot, or heatmap depending on the question +- Sankey: not available +- Chord diagram: not available +- Violin plot: not available +- Small multiples as one widget: use separate widgets instead + +## LLM Guidance + +When the human request is vague, infer the chart from the question they are asking. + +Examples of intent mapping: + +- "How has this changed over time?" -> line chart +- "Which category is biggest or smallest?" -> bar chart +- "What does the distribution look like?" -> histogram +- "How do these groups differ in spread?" -> box plot +- "Are these two measures related?" -> scatterplot +- "Show this matrix of scores" -> heatmap +- "How do these parts contribute to the whole?" -> treemap +- "Where are these values located geographically?" -> geo chart +- "How far are we from target?" -> bullet chart + +If the request is still ambiguous after that, ask one short clarifying question. diff --git a/apps/server-mcp/public/widgets/.gitkeep b/apps/server-mcp/public/widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/server-mcp/scripts/copy-public.ts b/apps/server-mcp/scripts/copy-public.ts new file mode 100644 index 0000000..2b16532 --- /dev/null +++ b/apps/server-mcp/scripts/copy-public.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import { cp, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const sourceDir = path.resolve("public"); +const destinationRoot = path.resolve("dist"); +const destinationDir = path.resolve("dist/public"); + +await mkdir(destinationRoot, { recursive: true }); +await cp(sourceDir, destinationDir, { + recursive: true, + force: true, +}); diff --git a/apps/server-mcp/src/UiResource.ts b/apps/server-mcp/src/UiResource.ts index 4d1d9d9..a073bce 100644 --- a/apps/server-mcp/src/UiResource.ts +++ b/apps/server-mcp/src/UiResource.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Schema } from "effect"; +import { Effect, FileSystem, Layer, Schema } from "effect"; import { McpServer, Tool } from "effect/unstable/ai"; const UiResourceMimeType = "text/html;profile=mcp-app"; @@ -21,6 +21,20 @@ type UiResourceSpec = { meta?: UiMeta; }; +export const readPublicAsset = (options: { + importMetaDir: string; + sourceSuffix: string; + sourcePath: string; + distPath: string; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const assetPath = options.importMetaDir.endsWith(options.sourceSuffix) + ? `${options.importMetaDir}/${options.sourcePath}` + : `${options.importMetaDir}/${options.distPath}`; + return yield* fs.readFileString(assetPath); + }); + export const makeUiResource = (uri: string, spec: UiResourceSpec) => Layer.unwrap( Effect.map(spec.html, (html) => diff --git a/apps/server-mcp/src/resource/data-visualization.ts b/apps/server-mcp/src/resource/data-visualization.ts index 7130c1a..c629285 100644 --- a/apps/server-mcp/src/resource/data-visualization.ts +++ b/apps/server-mcp/src/resource/data-visualization.ts @@ -1,24 +1,21 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect } from "effect"; import { McpServer } from "effect/unstable/ai"; +import { readPublicAsset } from "../UiResource"; const DataVisualizationResourceUri = "app://data-visualization"; const DataVisualizationMimeType = "text/markdown"; -const DataVisualizationContent = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("resource"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const mdPath = isSourcePath - ? path.join(import.meta.dir, "data-visualization.md") - : path.join(import.meta.dir, "resource/data-visualization.md"); - return yield* fs.readFileString(mdPath); +const DataVisualizationContent = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/resource", + sourcePath: "../../public/data-visualization.md", + distPath: "public/data-visualization.md", }); export const DataVisualizationResourceLayer = McpServer.resource({ uri: DataVisualizationResourceUri, name: "Data Visualization Practices", - description: "Widget-focused guidance for chart selection and payloads.", + description: "Widget-focused guidance for choosing the right chart.", mimeType: DataVisualizationMimeType, content: Effect.map(DataVisualizationContent, (text) => ({ contents: [ diff --git a/apps/server-mcp/src/widget/area-chart/area-chart.ts b/apps/server-mcp/src/widget/area-chart/area-chart.ts index 3f1aec2..992e563 100644 --- a/apps/server-mcp/src/widget/area-chart/area-chart.ts +++ b/apps/server-mcp/src/widget/area-chart/area-chart.ts @@ -1,18 +1,18 @@ import { AreaChartWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const AreaChartWidgetResourceUri = "ui://area-chart"; -const AreaChartWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "area-chart"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-area-chart.html") - : path.join(import.meta.dir, "widget/area-chart/widget-area-chart.html"); - return yield* fs.readFileString(htmlPath); +const AreaChartWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/area-chart", + sourcePath: "../../../public/widgets/widget-area-chart.html", + distPath: "public/widgets/widget-area-chart.html", }); export const AreaChartWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/bar-chart/bar-chart.ts b/apps/server-mcp/src/widget/bar-chart/bar-chart.ts index f5735fa..2d0963d 100644 --- a/apps/server-mcp/src/widget/bar-chart/bar-chart.ts +++ b/apps/server-mcp/src/widget/bar-chart/bar-chart.ts @@ -1,18 +1,18 @@ import { BarChartWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const BarChartWidgetResourceUri = "ui://bar-chart"; -const BarChartWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "bar-chart"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-bar-chart.html") - : path.join(import.meta.dir, "widget/bar-chart/widget-bar-chart.html"); - return yield* fs.readFileString(htmlPath); +const BarChartWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/bar-chart", + sourcePath: "../../../public/widgets/widget-bar-chart.html", + distPath: "public/widgets/widget-bar-chart.html", }); export const BarChartWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/box-plot/box-plot.ts b/apps/server-mcp/src/widget/box-plot/box-plot.ts index 71b7147..2d7da95 100644 --- a/apps/server-mcp/src/widget/box-plot/box-plot.ts +++ b/apps/server-mcp/src/widget/box-plot/box-plot.ts @@ -1,18 +1,18 @@ import { BoxPlotWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const BoxPlotWidgetResourceUri = "ui://box-plot"; -const BoxPlotWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "box-plot"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-box-plot.html") - : path.join(import.meta.dir, "widget/box-plot/widget-box-plot.html"); - return yield* fs.readFileString(htmlPath); +const BoxPlotWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/box-plot", + sourcePath: "../../../public/widgets/widget-box-plot.html", + distPath: "public/widgets/widget-box-plot.html", }); export const BoxPlotWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts b/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts index ec8029b..2bfae34 100644 --- a/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts +++ b/apps/server-mcp/src/widget/bullet-chart/bullet-chart.ts @@ -1,21 +1,18 @@ import { BulletChartWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const BulletChartWidgetResourceUri = "ui://bullet-chart"; -const BulletChartWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "bullet-chart"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-bullet-chart.html") - : path.join( - import.meta.dir, - "widget/bullet-chart/widget-bullet-chart.html", - ); - return yield* fs.readFileString(htmlPath); +const BulletChartWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/bullet-chart", + sourcePath: "../../../public/widgets/widget-bullet-chart.html", + distPath: "public/widgets/widget-bullet-chart.html", }); export const BulletChartWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/geo-chart/geo-chart.ts b/apps/server-mcp/src/widget/geo-chart/geo-chart.ts index 1dab2be..93014c0 100644 --- a/apps/server-mcp/src/widget/geo-chart/geo-chart.ts +++ b/apps/server-mcp/src/widget/geo-chart/geo-chart.ts @@ -1,18 +1,18 @@ import { GeoChartWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const GeoChartWidgetResourceUri = "ui://geo-chart"; -const GeoChartWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "geo-chart"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-geo-chart.html") - : path.join(import.meta.dir, "widget/geo-chart/widget-geo-chart.html"); - return yield* fs.readFileString(htmlPath); +const GeoChartWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/geo-chart", + sourcePath: "../../../public/widgets/widget-geo-chart.html", + distPath: "public/widgets/widget-geo-chart.html", }); export const GeoChartWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/heatmap/heatmap.ts b/apps/server-mcp/src/widget/heatmap/heatmap.ts index 7b501a7..9562cd0 100644 --- a/apps/server-mcp/src/widget/heatmap/heatmap.ts +++ b/apps/server-mcp/src/widget/heatmap/heatmap.ts @@ -1,18 +1,18 @@ import { HeatmapWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const HeatmapWidgetResourceUri = "ui://heatmap"; -const HeatmapWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "heatmap"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-heatmap.html") - : path.join(import.meta.dir, "widget/heatmap/widget-heatmap.html"); - return yield* fs.readFileString(htmlPath); +const HeatmapWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/heatmap", + sourcePath: "../../../public/widgets/widget-heatmap.html", + distPath: "public/widgets/widget-heatmap.html", }); export const HeatmapWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/histogram/histogram.ts b/apps/server-mcp/src/widget/histogram/histogram.ts index 8f7c334..8dcacca 100644 --- a/apps/server-mcp/src/widget/histogram/histogram.ts +++ b/apps/server-mcp/src/widget/histogram/histogram.ts @@ -1,18 +1,18 @@ import { HistogramWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const HistogramWidgetResourceUri = "ui://histogram"; -const HistogramWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "histogram"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-histogram.html") - : path.join(import.meta.dir, "widget/histogram/widget-histogram.html"); - return yield* fs.readFileString(htmlPath); +const HistogramWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/histogram", + sourcePath: "../../../public/widgets/widget-histogram.html", + distPath: "public/widgets/widget-histogram.html", }); export const HistogramWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/line-chart/line-chart.ts b/apps/server-mcp/src/widget/line-chart/line-chart.ts index ed4a402..13e862e 100644 --- a/apps/server-mcp/src/widget/line-chart/line-chart.ts +++ b/apps/server-mcp/src/widget/line-chart/line-chart.ts @@ -1,18 +1,18 @@ import { LineChartWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const LineChartWidgetResourceUri = "ui://line-chart"; -const LineChartWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "line-chart"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-line-chart.html") - : path.join(import.meta.dir, "widget/line-chart/widget-line-chart.html"); - return yield* fs.readFileString(htmlPath); +const LineChartWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/line-chart", + sourcePath: "../../../public/widgets/widget-line-chart.html", + distPath: "public/widgets/widget-line-chart.html", }); export const LineChartWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/scatterplot/scatterplot.ts b/apps/server-mcp/src/widget/scatterplot/scatterplot.ts index 94be9e7..26e7e9c 100644 --- a/apps/server-mcp/src/widget/scatterplot/scatterplot.ts +++ b/apps/server-mcp/src/widget/scatterplot/scatterplot.ts @@ -1,18 +1,18 @@ import { ScatterplotWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const ScatterplotWidgetResourceUri = "ui://scatterplot"; -const ScatterplotWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "scatterplot"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-scatterplot.html") - : path.join(import.meta.dir, "widget/scatterplot/widget-scatterplot.html"); - return yield* fs.readFileString(htmlPath); +const ScatterplotWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/scatterplot", + sourcePath: "../../../public/widgets/widget-scatterplot.html", + distPath: "public/widgets/widget-scatterplot.html", }); export const ScatterplotWidgetResourceLayer = makeUiResource( diff --git a/apps/server-mcp/src/widget/treemap/treemap.ts b/apps/server-mcp/src/widget/treemap/treemap.ts index 7de05b7..4c40b66 100644 --- a/apps/server-mcp/src/widget/treemap/treemap.ts +++ b/apps/server-mcp/src/widget/treemap/treemap.ts @@ -1,18 +1,18 @@ import { TreemapWidgetPayload } from "@repo/domain/Chart"; -import { Effect, FileSystem, Path } from "effect"; -import { makeUiRenderTool, makeUiResource } from "../../UiResource"; +import { Effect } from "effect"; +import { + makeUiRenderTool, + makeUiResource, + readPublicAsset, +} from "../../UiResource"; const TreemapWidgetResourceUri = "ui://treemap"; -const TreemapWidgetHtml = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceSuffix = path.join("widget", "treemap"); - const isSourcePath = import.meta.dir.endsWith(sourceSuffix); - const htmlPath = isSourcePath - ? path.join(import.meta.dir, "widget-treemap.html") - : path.join(import.meta.dir, "widget/treemap/widget-treemap.html"); - return yield* fs.readFileString(htmlPath); +const TreemapWidgetHtml = readPublicAsset({ + importMetaDir: import.meta.dir, + sourceSuffix: "src/widget/treemap", + sourcePath: "../../../public/widgets/widget-treemap.html", + distPath: "public/widgets/widget-treemap.html", }); export const TreemapWidgetResourceLayer = makeUiResource( diff --git a/packages/widget-area-chart/scripts/copy-asset.ts b/packages/widget-area-chart/scripts/copy-asset.ts index 456b98e..d96513c 100644 --- a/packages/widget-area-chart/scripts/copy-asset.ts +++ b/packages/widget-area-chart/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/area-chart" - : "../../apps/server-mcp/dist/widget/area-chart", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-area-chart.html"); diff --git a/packages/widget-area-chart/src/area-chart.tsx b/packages/widget-area-chart/src/area-chart.tsx index c83f2bd..0d029b5 100644 --- a/packages/widget-area-chart/src/area-chart.tsx +++ b/packages/widget-area-chart/src/area-chart.tsx @@ -24,16 +24,20 @@ const AreaChart = ({ const y = marks?.y ?? "y"; const series = marks?.series; const variant = marks?.variant ?? "area-line"; - const shouldShowLine = variant === "area-line"; + const shouldShowLine = variant === "area-line" && !series; + + const sharedMarkOptions = { + ...(marks?.interval ? { interval: marks.interval } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + tip: true, + }; const areaOptions = { x, y, ...(series ? { z: series, fill: series } : {}), fillOpacity: 0.3, - ...(marks?.interval ? { interval: marks.interval } : {}), - ...(marks?.sort ? { sort: marks.sort } : {}), - tip: true, + ...sharedMarkOptions, }; const lineOptions = { @@ -41,9 +45,7 @@ const AreaChart = ({ y, ...(series ? { z: series, stroke: series } : {}), strokeWidth: 2, - ...(marks?.interval ? { interval: marks.interval } : {}), - ...(marks?.sort ? { sort: marks.sort } : {}), - tip: true, + ...sharedMarkOptions, }; return ( @@ -59,7 +61,7 @@ const AreaChart = ({ marks: [ Plot.ruleY([0]), Plot.areaY(data, areaOptions), - shouldShowLine ? Plot.lineY(data, lineOptions) : null, + shouldShowLine ? Plot.line(data, lineOptions) : null, ].filter(Boolean), }} dependencies={[data, layout, marks]} diff --git a/packages/widget-bar-chart/scripts/copy-asset.ts b/packages/widget-bar-chart/scripts/copy-asset.ts index 937cadb..d9e11a7 100644 --- a/packages/widget-bar-chart/scripts/copy-asset.ts +++ b/packages/widget-bar-chart/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/bar-chart" - : "../../apps/server-mcp/dist/widget/bar-chart", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-bar-chart.html"); diff --git a/packages/widget-box-plot/scripts/copy-asset.ts b/packages/widget-box-plot/scripts/copy-asset.ts index 0ce07d3..6ceb4e5 100644 --- a/packages/widget-box-plot/scripts/copy-asset.ts +++ b/packages/widget-box-plot/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/box-plot" - : "../../apps/server-mcp/dist/widget/box-plot", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-box-plot.html"); diff --git a/packages/widget-bullet-chart/scripts/copy-asset.ts b/packages/widget-bullet-chart/scripts/copy-asset.ts index 94f95f6..918b063 100644 --- a/packages/widget-bullet-chart/scripts/copy-asset.ts +++ b/packages/widget-bullet-chart/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/bullet-chart" - : "../../apps/server-mcp/dist/widget/bullet-chart", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-bullet-chart.html"); diff --git a/packages/widget-geo-chart/scripts/copy-asset.ts b/packages/widget-geo-chart/scripts/copy-asset.ts index 3b3e4a5..2910e30 100644 --- a/packages/widget-geo-chart/scripts/copy-asset.ts +++ b/packages/widget-geo-chart/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/geo-chart" - : "../../apps/server-mcp/dist/widget/geo-chart", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-geo-chart.html"); diff --git a/packages/widget-heatmap/scripts/copy-asset.ts b/packages/widget-heatmap/scripts/copy-asset.ts index 5c7e3a2..fc08ff4 100644 --- a/packages/widget-heatmap/scripts/copy-asset.ts +++ b/packages/widget-heatmap/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/heatmap" - : "../../apps/server-mcp/dist/widget/heatmap", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-heatmap.html"); diff --git a/packages/widget-histogram/scripts/copy-asset.ts b/packages/widget-histogram/scripts/copy-asset.ts index 52ec262..cb16c9a 100644 --- a/packages/widget-histogram/scripts/copy-asset.ts +++ b/packages/widget-histogram/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/histogram" - : "../../apps/server-mcp/dist/widget/histogram", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-histogram.html"); diff --git a/packages/widget-line-chart/scripts/copy-asset.ts b/packages/widget-line-chart/scripts/copy-asset.ts index 522830e..a147e8b 100644 --- a/packages/widget-line-chart/scripts/copy-asset.ts +++ b/packages/widget-line-chart/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/line-chart" - : "../../apps/server-mcp/dist/widget/line-chart", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-line-chart.html"); diff --git a/packages/widget-scatterplot/scripts/copy-asset.ts b/packages/widget-scatterplot/scripts/copy-asset.ts index c2c6799..1f6c2e8 100644 --- a/packages/widget-scatterplot/scripts/copy-asset.ts +++ b/packages/widget-scatterplot/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/scatterplot" - : "../../apps/server-mcp/dist/widget/scatterplot", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-scatterplot.html"); diff --git a/packages/widget-treemap/scripts/copy-asset.ts b/packages/widget-treemap/scripts/copy-asset.ts index ad4c7ed..4456208 100644 --- a/packages/widget-treemap/scripts/copy-asset.ts +++ b/packages/widget-treemap/scripts/copy-asset.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { existsSync, watch } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -13,8 +14,8 @@ if (mode !== "build" && mode !== "dev") { const sourcePath = path.resolve("dist/index.html"); const destinationDir = path.resolve( mode === "dev" - ? "../../apps/server-mcp/src/widget/treemap" - : "../../apps/server-mcp/dist/widget/treemap", + ? "../../apps/server-mcp/public/widgets" + : "../../apps/server-mcp/dist/public/widgets", ); const destinationPath = path.join(destinationDir, "widget-treemap.html");