diff --git a/.github/extensions/flint-chart/.mcp.json b/.github/extensions/flint-chart/.mcp.json new file mode 100644 index 00000000..f74bb966 --- /dev/null +++ b/.github/extensions/flint-chart/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "flint": { + "command": "npx", + "args": ["--yes", "flint-chart-mcp"], + "cwd": "${PLUGIN_ROOT}" + } + } +} diff --git a/.github/extensions/flint-chart/.plugin/plugin.json b/.github/extensions/flint-chart/.plugin/plugin.json new file mode 100644 index 00000000..67eda8f9 --- /dev/null +++ b/.github/extensions/flint-chart/.plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "flint-chart", + "description": "Flint chart authoring MCP, skill, and native interactive chart canvas for Copilot CLI.", + "version": "0.2.2", + "author": { + "name": "Microsoft", + "url": "https://github.com/microsoft/flint-chart" + }, + "license": "MIT", + "homepage": "https://github.com/microsoft/flint-chart", + "repository": "https://github.com/microsoft/flint-chart", + "keywords": ["charts", "visualization", "flint", "mcp", "canvas"], + "skills": "./skills/", + "mcpServers": "./.mcp.json" +} diff --git a/.github/extensions/flint-chart/assets/flint-app.html b/.github/extensions/flint-chart/assets/flint-app.html new file mode 100644 index 00000000..68a8dec0 --- /dev/null +++ b/.github/extensions/flint-chart/assets/flint-app.html @@ -0,0 +1,110 @@ + + + + + + + Flint Chart + + + + +
+ + diff --git a/.github/extensions/flint-chart/extension.mjs b/.github/extensions/flint-chart/extension.mjs new file mode 100644 index 00000000..8daf49aa --- /dev/null +++ b/.github/extensions/flint-chart/extension.mjs @@ -0,0 +1,207 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const extensionDirectory = dirname(fileURLToPath(import.meta.url)); +const chartAppPath = join(extensionDirectory, "assets", "flint-app.html"); +const skillDirectory = join(extensionDirectory, "skills"); +const canvases = new Map(); + +function defaultInput() { + const services = ["Notifications", "Payments", "Checkout API", "Auth Service"]; + const severities = ["SEV1", "SEV2", "SEV3", "SEV4"]; + return { + data: { + values: services.flatMap((service, serviceIndex) => + severities.flatMap((severity, severityIndex) => + Array.from({ length: 6 }, (_, sampleIndex) => ({ + service, + severity, + timeToMitigate: 20 + (3 - severityIndex) * 35 + serviceIndex * 11 + sampleIndex * 8, + })), + ), + ), + }, + semantic_types: { + severity: "Ordinal", + service: "Nominal", + timeToMitigate: "Quantity", + }, + chart_spec: { + chartType: "Boxplot", + baseSize: { width: 560, height: 360 }, + encodings: { + x: { field: "severity" }, + y: { field: "timeToMitigate" }, + color: { field: "service" }, + }, + }, + }; +} + +function chartInput(input) { + const candidate = input?.chart_spec ? input : input?.input; + if ( + candidate?.chart_spec && + Array.isArray(candidate?.data?.values) + ) { + return candidate; + } + return defaultInput(); +} + +function scriptSafeJson(value) { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026"); +} + +function renderBridgeHtml(instance) { + return ` + + + + + Flint Chart Canvas + + + + + + +`; +} + +function messageText(params) { + const content = params?.content; + if (!Array.isArray(content)) return "Updated Flint chart from the chart canvas."; + return content + .filter((item) => item?.type === "text" && typeof item.text === "string") + .map((item) => item.text) + .join("\n") + || "Updated Flint chart from the chart canvas."; +} + +async function startCanvasServer(instanceId, input, session) { + const appHtml = await readFile(chartAppPath, "utf8"); + const instance = { input }; + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/") { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(renderBridgeHtml(instance)); + return; + } + if (request.method === "GET" && url.pathname === "/app") { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(appHtml); + return; + } + if (request.method === "POST" && url.pathname === "/message") { + const body = []; + for await (const chunk of request) body.push(chunk); + try { + const params = JSON.parse(Buffer.concat(body).toString("utf8")); + await session.send({ prompt: messageText(params) }); + response.writeHead(204).end(); + } catch (error) { + await session.log( + `Flint chart canvas could not send the edited chart: ${error instanceof Error ? error.message : String(error)}`, + { level: "error" }, + ); + response.writeHead(500).end(); + } + return; + } + response.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { instance, server, url: `http://127.0.0.1:${port}/` }; +} + +const session = await joinSession({ + mcpServers: { + flint: { + type: "local", + command: "npx", + args: ["--yes", "flint-chart-mcp"], + tools: ["*"], + }, + }, + skillDirectories: [skillDirectory], + canvases: [ + createCanvas({ + id: "flint-chart", + displayName: "Flint Chart Canvas", + description: "Interactive Flint chart workspace with a live preview and Vega-Lite output.", + inputSchema: { + type: "object", + additionalProperties: true, + }, + open: async (ctx) => { + const input = chartInput(ctx.input); + let entry = canvases.get(ctx.instanceId); + if (!entry) { + entry = await startCanvasServer(ctx.instanceId, input, session); + canvases.set(ctx.instanceId, entry); + } else { + entry.instance.input = input; + } + return { + title: "Flint Chart Canvas", + url: `${entry.url}?revision=${Date.now()}`, + }; + }, + onClose: async (ctx) => { + const entry = canvases.get(ctx.instanceId); + if (!entry) return; + canvases.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(resolve)); + }, + }), + ], +}); diff --git a/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md b/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md new file mode 100644 index 00000000..c63f24e1 --- /dev/null +++ b/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md @@ -0,0 +1,570 @@ +--- +name: flint-chart-author +description: "Use when: the user asks to make or render charts with flint-chart, visualize tabular data, generate a ChartAssemblyInput, validate/render through MCP, or add Flint to a JS/TS project. Author the semantic spec, transform data before Flint when needed, install/import Flint only when executable code is needed, and reserve backend-specific style tweaks for after compiling from Flint." +--- + +# flint-chart: authoring and using a chart spec + +## What you produce (and what you do NOT) + +Your output is the **spec**: the `chart_spec` and `semantic_types` of a +`ChartAssemblyInput`. You reference data columns **by name**. The host +passes the resulting input to `assembleVegaLite`, `assembleECharts`, +or `assembleChartjs` to get a backend spec. + +**You write the input spec, not the output spec.** And critically: + +- **DO** emit `chart_spec` (chart type, channel→field mapping, properties) + and `semantic_types` (field → semantic type). +- **Reference columns by name.** How `data` itself gets bound depends on + the situation — a URL, a host-side variable, or embedded rows (see "How + data gets bound"). Embedding is fine for small tables; just don't + re-serialize a *large* dataset by hand, since that risks truncation and + silent value corruption and wastes tokens. +- **Transform data before Flint.** If the requested chart needs aggregation, + filtering, joins, pivots, derived columns, or long/wide reshaping beyond + Flint's built-in static-series fold, use a coding, notebook, SQL, or data tool + first. Then author the Flint spec against the transformed table. +- **Style after Flint, only when needed.** Author structure in Flint. For a + presentation tweak Flint does not express (a reference line, annotation, or + shaded band), use the Vega-Lite escape hatch — see "Post-Flint style + customization". Never feed edited Vega-Lite JSON back to `render_chart`. + +## When the user wants more than a spec + +First decide which workflow the user is asking for: + +- **Spec authoring only:** return a `ChartAssemblyInput` or its + `semantic_types` + `chart_spec` pieces. Do not install packages or write + renderer code unless asked. +- **MCP chart output:** if Flint MCP tools are available, **default to + `create_chart_view`** whenever the user asks to see a chart — it opens an + interactive, live-rendered view with a customization panel, and it validates + the spec for you. Only fall back to `render_chart` (PNG/SVG) when the host has + no App UI support or the user explicitly wants a static image. Use + `validate_chart` to check a spec without rendering, `compile_chart` when the + user wants backend-native JSON, and `list_chart_types` when you need the + supported chart catalog. +- **Project integration, only when the user asks for code:** add Flint to an + app, notebook, script, or agentic product, install/import the library, and + call an assembler in code. Keep the same `ChartAssemblyInput` contract, then + let the host render the backend result. + +For MCP clients, the server can run with `npx`: + +```bash +npx -y flint-chart-mcp +``` + +For JavaScript or TypeScript projects, install Flint first and add only the +renderer peer dependencies needed by the backend you will render: + +```bash +npm install flint-chart +npm install vega vega-lite vega-embed # browser Vega-Lite rendering +npm install echarts # ECharts rendering +npm install chart.js # Chart.js rendering +``` + +Then compile with the requested backend: + +```ts +import { assembleChartjs, assembleECharts, assembleVegaLite } from 'flint-chart'; + +const vegaLiteSpec = assembleVegaLite(input); +const echartsOption = assembleECharts(input); +const chartjsConfig = assembleChartjs(input); +``` + +Python support is planned for a later release. Until the PyPI package is +published, use the npm package or MCP server for released workflows. + +```ts +interface ChartAssemblyInput { + // Bound by the HOST or by you, depending on the situation (see below). + data: { values: any[] } | { url: string }; + semantic_types?: Record; // field → semantic type ← you write this + chart_spec: { // ← you write this + chartType: string; // e.g. "Scatter Plot" + encodings: Record; // channel → { field, ... } (or array) + baseSize?: { width: number; height: number }; // target layout size, default 400×320 + canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch + chartProperties?: Record; // per-chart tuning (optional) + }; + options?: Record; // global layout options (rarely needed) +} +``` + +## How data gets bound + +Use the binding mode that matches the runtime. Do not mix them. + +1. **Direct MCP rendering: embed rows.** When calling `render_chart`, + `compile_chart`, or `validate_chart`, the tool arguments are JSON. If the + data is small or already transformed by another tool, pass it as + `data: { values: [...] }`. Do not pass runtime variable names in + MCP tool calls — the MCP server cannot see your local variables. +2. **Direct MCP rendering: reference a local file.** + The `flint-chart-mcp` server can load `data: { url: "..." }` from local + `.json`, `.csv`, or `.tsv` files. By default any local file the agent can + name is readable (relative paths resolve against the working directory); a + hardened deployment may reject local file references entirely via + `--disable-file-reference` (or `FLINT_MCP_DISABLE_FILE_REFERENCE`), in which + case pass rows inline with `data.values`. Remote URL + fetching is disabled. If the data must be transformed first, use a + coding/data tool to write a small prepared file, then reference that file. +3. **Generated application or notebook code: bind runtime variables.** If the + user asks you to add Flint to code, write normal data-loading code first and + pass a real runtime value, e.g. `data: { values: rows }`, to + `assembleVegaLite`, `assembleECharts`, or `assembleChartjs`. This variable + pattern is for generated code, not for MCP tool calls. + +For spec-only answers, return the `semantic_types` and `chart_spec` pieces and +state how the host should bind data. In the worked examples below, `data` is +shown as `{ values: [] }` to signal "host binds this" — focus on `chart_spec` +and `semantic_types`. + +## Data transformation before charting + +Flint is a chart compiler, not a data-wrangling layer. If the chart needs grouped +totals, time buckets, filters, joins, pivots, derived ratios, or a long-form +table, transform the data first with a host tool, then bind the prepared table +(see "How data gets bound"). Pick semantic types and channels for the transformed +columns, not for columns that no longer exist. + +**Sanity-read the values first — don't chart blind.** Inspect the actual data +with your data tool (distinct values per category column, min/max per measure), +not just the column names, and watch for: + +- **Embedded totals.** A category column may mix an aggregate level with its + parts (e.g. `all` alongside `cage-free`/`caged`, or a `Total` region). Charting + the total with its parts double-counts and flattens the parts — keep one or the + other on a stacked/grouped/colored channel, not both. +- **Units.** Check whether a rate is a fraction (0–1) or already a percent + (0–100) before tagging it `Percentage`; don't scale twice. +- **One real entity.** If your breakdown column has a single distinct value, the + per-group chart collapses to one mark — the intended breakdown is likely a + different column. + +## Post-Flint style customization + +Stay at the Flint level for structure (data, chart type, channels, transforms, +sizing, properties) — Flint specs stay portable and regenerate safely. Drop to +backend JSON only after a valid Flint chart exists, and only for a narrow +presentation change Flint does not expose (exact axis/legend/mark styling, +titles, annotations, reference lines, layout polish). Never use it to change the +data, chart type, field mappings, or transforms — fix those upstream. + +For a Vega-Lite-specific style tweak: + +1. Author and validate the Flint `ChartAssemblyInput`. +2. Render or inspect the Flint chart first, when possible. +3. Call `compile_chart` with `backend: "vegalite"`. +4. Make the smallest necessary style/presentation edit to the returned + Vega-Lite spec. +5. Render the edited spec in the host environment with a Vega-Lite renderer. + +This edited Vega-Lite spec is no longer a portable Flint spec. Do not send it to +`render_chart`; use `render_chart` only for Flint `ChartAssemblyInput`. + +## Step 1 — pick `chartType` + +Use one of the registered names **exactly**. Vega-Lite is the default and +broadest backend; the table below lists each Vega-Lite chart type, the +channels it accepts, and its tuning properties (see "Chart-level +properties"). Required channels are noted. + +| chartType | Channels | Notes / required | +|---|---|---| +| `"Scatter Plot"` | x, y, color, size, opacity, column, row | x + y required | +| `"Regression"` | x, y, size, color, column, row | scatter + fit line; props `regressionMethod`, `polyOrder` | +| `"Connected Scatter Plot"` | x, y, order, color, detail, column, row | x + y required; `order` = connection sequence (time/index), so the line traces a trajectory and may self-cross | +| `"Ranged Dot Plot"` | x, y, color | dumbbell of two x per category | +| `"Strip Plot"` | x, y, color, size, column, row | jittered points; props `stepWidth`, `pointSize`, `opacity` | +| `"Bar Chart"` | x, y, color, opacity, column, row | one discrete + one measure; prop `cornerRadius` | +| `"Grouped Bar Chart"` | x, y, group, column, row | `group` = the clustering category | +| `"Stacked Bar Chart"` | x, y, color, column, row | prop `stackMode` | +| `"Pyramid Chart"` | x, y, color | diverging horizontal bars | +| `"Lollipop Chart"` | x, y, color, column, row | prop `dotSize` | +| `"Waterfall Chart"` | x, y, color, column, row | `color` = Type column, values `start`/`delta`/`end` only; omit it for auto sign coloring; props `cornerRadius`, `totals` | +| `"Gantt Chart"` | y, x, x2, color, detail, column, row | x = start, x2 = end | +| `"Bullet Chart"` | y, x, goal, color, column, row | `goal` required (target) | +| `"Histogram"` | x, color, column, row | x = measure to bin; prop `binCount` | +| `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers` | +| `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | +| `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | +| `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | +| `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | +| `"Slope Chart"` | x, y, color, detail, column, row | two-period value change; straight segments + end points, one line per category | +| `"Area Chart"` | x, y, color, opacity, column, row | props `interpolate`, `opacity`, `stackMode` | +| `"Range Area Chart"` | x, y, y2, color, column, row | x + y + y2 required; translucent band from `y` (low) to `y2` (high), value axis fits the band (not zero) | +| `"Violin Plot"` | x, y, color, row | x (category) + y (measure) required; mirrored KDE density per category, prop `bandwidth`; **Vega-Lite only**; `column` is used internally for the per-category panels | +| `"Streamgraph"` | x, y, color, column, row | centre-stacked areas | +| `"Density Plot"` | x, color, column, row | prop `bandwidth` | +| `"Pie Chart"` | size, color, column, row | `size` = slice value (→ angle), `color` = category | +| `"Rose Chart"` | x, y, color, column, row | polar bars; props `alignment`, `innerRadius`, `padAngle` | +| `"Radar Chart"` | x, y, color, column, row | props `filled`, `fillOpacity`, `strokeWidth` | +| `"Candlestick Chart"` | x, open, high, low, close, column, row | OHLC all required | +| `"Bar Table"` | y, x, color, column, row | compact bars + value labels | +| `"KPI Card"` | metric, value, goal | big-number tile; prop `behindThreshold` | +| `"Map"` | longitude, latitude, color, size, opacity | bubble map; props `region`, `projection` | +| `"Choropleth"` | id, color, detail | `id` = geographic key | + +**Donut chart:** use `"Pie Chart"` with `chartProperties.innerRadius > 0`. + +**Choosing a bar chart (most common mix-up).** All three take one discrete +category on `x` (or `y`) plus one measure. They differ in how a **second** +category is shown — and each reads that second category from a **different +channel**: + +- `"Bar Chart"` — no second category. One bar per `x` value. A `color` + encoding just tints the bars (or stacks segments if multiple rows share an + `x`). It has **no `group` channel**, so a `group` encoding is silently + ignored. +- `"Stacked Bar Chart"` — second category on `color`, drawn as **stacked** + segments within each bar (totals matter). Tune with `stackMode` + (`stacked` / `normalize` / `layered`). +- `"Grouped Bar Chart"` — second category on the **`group`** channel, drawn as + **side-by-side (dodged)** bars within each `x` cluster (compare values + directly). Put the clustering category on `group`, *not* `color`. + +Rule of thumb: comparing parts-to-whole → Stacked; comparing values +side-by-side → Grouped (use `group`); single series → Bar. + +**Waterfall color is a special "Type" column, not a free category.** On a +`"Waterfall Chart"` the `color` channel is reserved for a *type* field whose +values are literally `start`, `delta`, and `end` — it drives which bars anchor +to zero, not an arbitrary grouping. Do **not** bind `color` to an +`Increase`/`Decrease` (or up/down, gain/loss) category: the up/down direction is +already derived from the **sign** of the `y` value and colored automatically +(green up / red down). For the common case, **omit `color` entirely** and let +Flint infer the start/delta/end and per-bar sign coloring. To force which bars +are anchored totals, use the `totals` property (`first`/`last`/`both`), not a +color field. Only bind `color` when you genuinely have a `start`/`delta`/`end` +type column. + +**Backend coverage.** Vega-Lite supports all of the above. Other backends +support a subset (verify if targeting a non-VL backend): + +- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, + `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, + `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. +- **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, + Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, Slope, + Connected Scatter. + +You do not need to call the library or inspect its source to author the +input — pick from this table. + +## Step 2 — map fields to channels + +Each channel maps to an **encoding object** `{ field, ... }` (or a bare +string shorthand, expanded to `{ field: "" }`): + +```json +"encodings": { + "x": { "field": "weight" }, + "y": "mpg", + "color": { "field": "origin" } +} +``` + +**Encoding object fields** (all optional except `field`): + +| Field | Values | Purpose | +|---|---|---| +| `field` | column name | Bind the channel to a data column | +| `type` | `quantitative`, `nominal`, `ordinal`, `temporal` | Override the inferred encoding type (rarely needed) | +| `aggregate` | `count`, `sum`, `average`, `mean` | Force an aggregation on a measure channel | +| `sortOrder` | `ascending`, `descending` | Sort direction for a discrete/sorted axis | +| `sortBy` | channel name (e.g. `"y"`) or field | Sort a category axis by another channel's measure | +| `scheme` | Vega scheme name (e.g. `viridis`, `redblue`) | Color scheme for the `color` channel | + +You usually don't need `type`, `aggregate`, or `sortOrder` — they're +inferred from the semantic type. Set them only with specific intent. + +**Multi-series (wide → long).** To plot several measure columns as series, +pass an **array** on `x` or `y` (only those two channels). The library +folds them into long form and synthesizes a series/legend field: + +```json +"encodings": { "x": { "field": "month" }, "y": ["sales", "profit"] } +``` + +All array fields must be quantitative, and you cannot also bind `color` +when using the array form (the fold owns the color/legend). This is the +**only** built-in reshape — there is no `transforms`/`fold` property. For any +other shape (long↔wide, an aggregate the encodings can't express, a derived +column, a pivot, a join), reshape the data first with a host tool — pandas/polars, +Arquero/`Array.map`/SQL, or a data/MCP tool — and pass the result as +`data.values`. If you have no way to transform, surface the gap to the developer +rather than inventing a transform property that does not exist. + +## Step 3 — annotate with semantic types + +**This is the most important step.** Semantic types drive all downstream +decisions — formatting, zero baseline, color scheme, scale direction, and +more. Pick the most specific type for each field. Full registered set: + +| Family | Semantic types | +|---|---| +| Temporal (point) | `DateTime`, `Date`, `Time`, `Timestamp` | +| Temporal (granule) | `Year`, `Quarter`, `Month`, `Week`, `Day`, `Hour`, `YearMonth`, `YearQuarter`, `YearWeek`, `Decade` | +| Temporal (span) | `Duration` | +| Measure (amount) | `Amount`, `Price`, `Quantity`, `Count`, `Number` | +| Measure (proportion) | `Percentage` | +| Measure (signed/diverging) | `Profit`, `PercentageChange`, `Sentiment`, `Correlation` | +| Measure (physical) | `Temperature` | +| Discrete / rank | `Rank`, `Score`, `ID` | +| Geographic (coord) | `Latitude`, `Longitude` | +| Geographic (place) | `Country`, `State`, `City`, `Region`, `Address`, `ZipCode` | +| Categorical | `Category`, `Name`, `Status`, `Boolean`, `Direction`, `Range` | +| Fallback | `Unknown` | + +What choosing well gets you (automatically): + +- `Price` / `Amount` → currency formatting, zero baseline, sequential color +- `Temperature` → diverging color scheme, no forced zero baseline +- `Correlation` → fixed `[-1, 1]` diverging domain +- `Rank` → reversed axis (1 on top), discrete color +- `Date` / `DateTime` → temporal axis with auto-granularity formatting +- `Percentage` → percent formatting, 0–100 domain awareness + +If you don't know, use `Quantity` for numbers, `Category` for strings, +`Date`/`DateTime` for date-shaped values. Do **not** invent type names. + +## Chart-level properties (`chartProperties`) + +`chartProperties` is an optional per-chart tuning map. Set a property only +when the user asks for that behavior — defaults are sensible. These are +**design choices**, not styling overrides (colors/fonts/ticks are still +derived). Values are clamped to the ranges shown. + +| Chart type | Property | Type / range (default) | Effect | +|---|---|---|---| +| Bar Chart | `cornerRadius` | 0–15 (0) | Round bar corners (px) | +| Bar / Area / Stacked Bar | `stackMode` | `stacked` \| `normalize` \| `layered` (unset) | Stacking behavior; `normalize` = 100% | +| Line / Area / Sparkline | `interpolate` | `linear` \| `monotone` \| `step` \| `step-before` \| `step-after` \| `basis` \| `cardinal` \| `catmull-rom` (`linear`) | Curve shape | +| Line / ECDF Plot | `showPoints` | boolean (false) | Draw point markers on the line | +| Sparkline | `baseline` | `mean` \| `zero` \| `median` \| `none` (`mean`) | Reference line per spark row | +| Sparkline | `trendWidth` | 80–600 (240) | Mini line-plot width (px) | +| Boxplot | `whiskerMethod` | `iqr` \| `minmax` (`iqr`) | Whisker rule (Tukey 1.5×IQR vs min–max) | +| Boxplot | `showOutliers` | boolean (true) | Show outlier points (Tukey only) | +| Area | `opacity` | 0.1–1 (0.7) | Fill opacity | +| Scatter | `opacity` | 0.1–1 (1) | Point opacity | +| Strip Plot | `stepWidth` | 10–100 (20) | Jitter spread | +| Strip Plot | `pointSize` | 0–150 (0=auto) | Point size | +| Strip Plot | `opacity` | 0–1 (0=auto) | Point opacity | +| Histogram | `binCount` | 5–50 (10) | Number of bins | +| Density Plot | `bandwidth` | 0.05–2 (0=auto) | Kernel bandwidth | +| Pie Chart | `innerRadius` | 0–100 (0) | Donut hole size (>0 → donut) | +| Rose Chart | `alignment` | `left` \| `center` (`left`) | Wedge alignment | +| Rose Chart | `innerRadius` | 0–100 (0) | Inner radius | +| Rose Chart | `padAngle` | 0–0.1 (0) | Gap between slices | +| Lollipop | `dotSize` | 20–300 (80) | Circle size (px) | +| Waterfall | `cornerRadius` | 0–8 (0) | Round bar corners | +| Waterfall | `totals` | `auto` \| `none` \| `first` \| `last` \| `both` (`auto`) | Which bars anchor to zero as totals (only when no Type column) | +| Regression | `regressionMethod` | `linear` \| `log` \| `exp` \| `pow` \| `quad` \| `poly` (`linear`) | Fit method | +| Regression | `polyOrder` | 1–5 (3) | Polynomial order (when `poly`) | +| Radar | `filled` | boolean (true) | Fill the polygon | +| Radar | `fillOpacity` | 0–0.5 (0.15) | Polygon fill opacity | +| Radar | `strokeWidth` | 0.5–4 (1.5) | Line width | +| KPI Card | `behindThreshold` | 0–1 (0.5) | Value/goal ratio cutoff for color | +| Map | `region` | `us` \| `world` \| `auto` (`auto`) | Geographic scope | +| Map | `projection` | `mercator` \| `equalEarth` \| `orthographic` \| `stereographic` \| `conic` \| `mollweide` | Map projection | + +**Cross-cutting properties** (apply to position/faceted charts when +relevant; set only to force non-default behavior): + +- `independentYAxis` (boolean) — faceted charts: give each panel its own + y-scale. +- `logScale_x` / `logScale_y` (boolean) — force a logarithmic axis. +- `includeZero_x` / `includeZero_y` (boolean) — force the axis to include 0. +- `xAxisType` / `yAxisType` (`temporal` | `nominal`) — force a temporal + field to render as discrete bands (or vice-versa). + +## Parameter overrides — when to reach for them + +Overrides exist, but prefer letting semantic types drive decisions. Reach +for an override only when the user's intent genuinely conflicts with the +default: + +- **Force an aggregation:** `encodings.y = { field: "sales", aggregate: "sum" }`. +- **Sort a category axis by its measure:** `encodings.x = { field: "name", sortBy: "y", sortOrder: "descending" }`. +- **Pick a color scheme:** `encodings.color = { field: "region", scheme: "tableau10" }`. +- **Override an inferred type:** `encodings.x = { field: "year", type: "ordinal" }` (e.g. treat a year as discrete bands). +- **Resize the chart:** Flint sizes from two numbers — `baseSize` (the *target* + it aims for, default 400×320) and `canvasSize` (a *hard ceiling* it may never + exceed). With dense data the chart stretches from base toward the ceiling. + - Want a comfortable size that may grow for dense data → set `chart_spec.baseSize = { width, height }`. + - Want a fixed slot it must fit inside → set `chart_spec.canvasSize = { width, height }` alone; the chart fills it and shrinks to fit, never overflowing. *What you ask for is what you get.* + - Both → aims for `baseSize`, grows toward `canvasSize`, never beyond. +- **Force log / zero baseline:** the `logScale_*` / `includeZero_*` chart + properties above. + +Global layout tuning lives in the top-level `options` object (e.g. +`addTooltips`, band padding, facet sizing). It is rarely needed for +authoring — omit it unless asked. + +## Worked examples + +In each example `data` is a placeholder — the host binds real rows or a +URL. You author only `chart_spec` and `semantic_types`. + +### Scatter plot + +User: "Plot car weight vs fuel economy, colored by origin." + +```json +{ + "data": { "values": [] }, + "semantic_types": { + "weight": "Quantity", + "mpg": "Quantity", + "origin": "Country" + }, + "chart_spec": { + "chartType": "Scatter Plot", + "encodings": { + "x": { "field": "weight" }, + "y": { "field": "mpg" }, + "color": { "field": "origin" } + }, + "baseSize": { "width": 400, "height": 300 } + } +} +``` + +### Revenue bar chart with facets, sorted by value + +User: "Show revenue by product line, biggest first, one panel per region." + +```json +{ + "data": { "values": [] }, + "semantic_types": { + "product_line": "Category", + "revenue": "Amount", + "region": "Region" + }, + "chart_spec": { + "chartType": "Bar Chart", + "encodings": { + "x": { "field": "product_line", "sortBy": "y", "sortOrder": "descending" }, + "y": { "field": "revenue" }, + "column": { "field": "region" } + } + } +} +``` + +### Time series, multiple series (wide → long via array) + +User: "Line chart of monthly sales and profit." + +```json +{ + "data": { "values": [] }, + "semantic_types": { + "month": "YearMonth", + "sales": "Amount", + "profit": "Profit" + }, + "chart_spec": { + "chartType": "Line Chart", + "encodings": { + "x": { "field": "month" }, + "y": ["sales", "profit"] + }, + "chartProperties": { "interpolate": "monotone", "showPoints": true } + } +} +``` + +### Donut chart (Pie + innerRadius), value on `size` + +User: "Show market share by vendor as a donut." + +Pie/donut maps the slice value to `size` (rendered as angle) and the +category to `color`. Data is already long (one row per vendor). + +```json +{ + "data": { "values": [] }, + "semantic_types": { + "vendor": "Category", + "share": "Percentage" + }, + "chart_spec": { + "chartType": "Pie Chart", + "encodings": { + "size": { "field": "share" }, + "color": { "field": "vendor" } + }, + "chartProperties": { "innerRadius": 60 } + } +} +``` + +### Bullet chart (KPI vs target) + +User: "Show each rep's sales against their quota." + +```json +{ + "data": { "values": [] }, + "semantic_types": { + "rep": "Name", + "sales": "Amount", + "quota": "Amount" + }, + "chart_spec": { + "chartType": "Bullet Chart", + "encodings": { + "y": { "field": "rep" }, + "x": { "field": "sales" }, + "goal": { "field": "quota" } + } + } +} +``` + +## What you should NOT do + +- **Don't re-emit the data.** Reference columns by name; let the host bind + `data` (url, variable, or small literal). Never paste large datasets. +- **Don't write backend specs directly** — write the `ChartAssemblyInput`, + then call the assembler. That's the whole point. +- **Don't invent transforms.** The only built-in reshape is the array form + on `x`/`y`. If the data shape is wrong for the chart, say so and ask the + host to reshape it. +- **Don't invent field names.** Reference only columns that exist, spelled + exactly. If the data is the wrong shape for the chart, reshape it upstream + rather than guessing column names that aren't there. +- **Don't set `type`/`aggregate`/`sortOrder`** unless intent conflicts + with the default. +- **Don't pass colors, font sizes, axis tick counts** — the compiler + derives these. Users fine-tune the *output* spec. +- **Don't invent semantic type names.** If none fit, use the family + default (`Quantity`, `Category`, `Date`). +- **Don't call the library to discover channels/types** — this document is + the authoring reference. + +## Validation checklist + +Before returning, verify: + +1. `chartType` is an exact registered name supported by the target backend. +2. Every `field` referenced in `encodings` is a real column name. +3. Every encoded field has an entry in `semantic_types` (specific type). +4. Required channels for the chart type are present (e.g. Bullet→`goal`, + Candlestick→`open/high/low/close`, Pie→`size`+`color`). +5. Any `chartProperties` keys are valid for that chart type and in range. +6. You did **not** inline large data or hand-tune derived styling. +7. The data carries no embedded total/subtotal level (e.g. an `all` / `total` + row) mixed with its components on a stacked, grouped, or colored channel. + +## Copilot CLI chart canvas + +When this plugin's native **Flint Chart Canvas** is available, open it with the complete `ChartAssemblyInput` after the input has been validated. Pass the exact inline `data.values`, `semantic_types`, and `chart_spec` that Flint will render. The canvas owns interactive chart customization and shows its Vega-Lite output; do not recreate that UI or replace it with a browser page. diff --git a/site/src/routes/DocSectionPage.tsx b/site/src/routes/DocSectionPage.tsx index 14a1f9cb..0ede440a 100644 --- a/site/src/routes/DocSectionPage.tsx +++ b/site/src/routes/DocSectionPage.tsx @@ -48,6 +48,16 @@ export function DocSectionPage({ section }: { section: DocSection }) { } }, [slug, section, firstSlug, navigate, lp]); + // Reset scroll to top when the active doc changes, unless a heading anchor + // is pending (handled by the next effect). + useEffect(() => { + const stored = sessionStorage.getItem(DOC_SCROLL_TO_KEY); + const hash = location.hash ? decodeURIComponent(location.hash.slice(1)) : ''; + if (!stored && !hash) { + mainRef.current?.scrollTo({ top: 0 }); + } + }, [activeSlug, location.hash]); + useEffect(() => { const stored = sessionStorage.getItem(DOC_SCROLL_TO_KEY); const hash = location.hash ? decodeURIComponent(location.hash.slice(1)) : '';