diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c76de8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +Starting with version 0.2.1, all notable changes to `flint-chart` and +`flint-chart-mcp` are documented in this file. The two npm packages are +versioned and released together, so each release entry covers both packages. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.1] - 2026-07-13 + +### Added + +- Exported backend-neutral banded-axis detection from `flint-chart/core`, so + custom backends and host extensions can reuse Flint's axis semantics without + depending on Vega-Lite implementation files. + +### Changed + +- Expanded the standalone and MCP-bundled authoring skill with Waterfall Type + column and `totals` guidance. +- Updated generated backend references to list the accepted values for discrete + chart properties. + +### Fixed + +- Normalized discrete chart property values across all backends: display labels + are coerced to canonical values, invalid values fall back safely with a + warning, and nullable option metadata no longer causes failures. +- Made ECharts and Chart.js Rose Chart radii proportional to the square root of + values, so rendered wedge areas represent the data accurately. +- Treated only lowercase `start` and `end` Waterfall Type values as total + anchors in Vega-Lite; other values now remain floating deltas colored by sign. + +[Unreleased]: https://github.com/microsoft/flint-chart/compare/0.2.1...HEAD +[0.2.1]: https://github.com/microsoft/flint-chart/compare/c8e20b052ad9ddad29ba3ecfc825948c424e5ba5...0.2.1 \ No newline at end of file diff --git a/README.md b/README.md index a31f5c9..9d960b4 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,8 @@ The [project site](https://microsoft.github.io/flint-chart/) is the main entry point for examples, the live editor, and concept docs. For source-level references, start with the [API reference](docs/api-reference.md), the [Flint MCP project page](https://microsoft.github.io/flint-chart/#/mcp), or the -[Development guide](docs/DEVELOPMENT.md). +[Development guide](docs/DEVELOPMENT.md). See the [changelog](CHANGELOG.md) for +notable changes in each release. --- diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index 216d0d4..e9265dd 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -186,7 +186,7 @@ properties"). Required channels are noted. | `"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 | prop `cornerRadius` | +| `"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` | @@ -232,6 +232,18 @@ channel**: 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): @@ -352,6 +364,7 @@ derived). Values are clamped to the ranges shown. | 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 | diff --git a/docs/reference-chartjs.md b/docs/reference-chartjs.md index 2b06ac5..49344a4 100644 --- a/docs/reference-chartjs.md +++ b/docs/reference-chartjs.md @@ -119,7 +119,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | ### ![](chart-icon-slope.svg) Slope Chart @@ -133,9 +133,9 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | always | Mark opacity. | -| `stackMode` | choice | `Stacked (default)`, `Layered (overlap)` | — | always | Stacking strategy for overlapping series. | +| `stackMode` | choice | Stacked (default) _(default)_, `layered` (Layered (overlap)) | — | always | Stacking strategy for overlapping series. | ### ![](chart-icon-range-area.svg) Range Area Chart @@ -160,7 +160,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `innerRadius` | number | 0 – 60 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | ### ![](chart-icon-doughnut.svg) Doughnut Chart @@ -169,7 +169,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `innerRadius` | number | 20 – 80 (step 5) | `55` | always | Inner radius as a percentage of the outer radius. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | ## Polar @@ -179,7 +179,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `filled` | choice | `Filled (default)`, `Outline only` | — | always | Fill the enclosed radar area. | +| `filled` | choice | `true` (Filled (default)), `false` (Outline only) | — | always | Fill the enclosed radar area. | | `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.3` | always | Fill opacity for the area or region. | ### ![](chart-icon-rose.svg) Rose Chart @@ -188,5 +188,5 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `alignment` | choice | `Left (default)`, `Center` | — | always | Segment alignment for radial charts. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | +| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | Segment alignment for radial charts. | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | diff --git a/docs/reference-echarts.md b/docs/reference-echarts.md index 9e14e87..2498867 100644 --- a/docs/reference-echarts.md +++ b/docs/reference-echarts.md @@ -43,7 +43,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `regressionMethod` | choice | `Linear`, `Logarithmic`, `Exponential`, `Power`, `Quadratic`, `Polynomial` | `Linear` | always | Regression fit method. | +| `regressionMethod` | choice | `linear` (Linear), `log` (Logarithmic), `exp` (Exponential), `pow` (Power), `quad` (Quadratic), `poly` (Polynomial) | `linear` | always | Regression fit method. | | `polyOrder` | number | 2 – 10 (step 1) | `3` | always | Polynomial order for the regression fit. | ### ![](chart-icon-connected-scatter.svg) Connected Scatter Plot @@ -64,7 +64,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `whiskerMethod` | choice | `Tukey (1.5 × IQR)`, `Min–Max` | `Tukey (1.5 × IQR)` | always | Whiskers | +| `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers | | `showOutliers` | toggle | on / off | `true` | conditional | Outliers | ### ![](chart-icon-strip-plot.svg) Strip Plot @@ -95,7 +95,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `stackMode` | choice | `Stacked (default)`, `Normalize (100%)`, `Layered (overlap)` | — | conditional | Stacking strategy for overlapping series. | +| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. | ### ![](chart-icon-lollipop.svg) Lollipop Chart @@ -131,7 +131,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | | `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. | ### ![](chart-icon-bump.svg) Bump Chart @@ -152,9 +152,9 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.05) | `0.7` | always | Mark opacity. | -| `stackMode` | choice | `Stacked (default)`, `Normalize (100%)`, `Center`, `Layered (overlap)` | — | always | Stacking strategy for overlapping series. | +| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | always | Stacking strategy for overlapping series. | ### ![](chart-icon-streamgraph.svg) Streamgraph @@ -178,8 +178,8 @@ _No template-specific parameters._ |---|---|---|---|---|---| | `innerRadius` | number | 0 – 60 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | | `cornerRadius` | number | 0 – 10 (step 1) | `0` | always | Corner radius for supported marks. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | -| `labelType` | choice | `Name + %`, `Name`, `Value`, `Percent`, `None` | `Name + %` | always | Labels | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | +| `labelType` | choice | `categoryPercent` (Name + %), `category` (Name), `value` (Value), `percent` (Percent), `none` (None) | `categoryPercent` | always | Labels | ### ![](chart-icon-funnel.svg) Funnel Chart @@ -187,8 +187,8 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `sort` | choice | `Descending (default)`, `Ascending`, `Original order` | — | always | Sort order for ordered stages or categories. | -| `orient` | choice | `Vertical (default)`, `Horizontal` | — | always | Chart orientation. | +| `sort` | choice | `descending` (Descending (default)), `ascending` (Ascending), `none` (Original order) | — | always | Sort order for ordered stages or categories. | +| `orient` | choice | `vertical` (Vertical (default)), `horizontal` (Horizontal) | — | always | Chart orientation. | | `gap` | number | 0 – 20 (step 1) | `2` | always | Gap between segments. | ### ![](chart-icon-treemap.svg) Treemap @@ -197,7 +197,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `breadcrumb` | choice | `Show (default)`, `Hide` | — | always | Show or hide treemap breadcrumb navigation. | +| `breadcrumb` | choice | `true` (Show (default)), `false` (Hide) | — | always | Show or hide treemap breadcrumb navigation. | ### ![](chart-icon-sunburst.svg) Sunburst Chart @@ -206,7 +206,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `innerRadius` | number | 0 – 80 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | -| `labelRotate` | choice | `Radial (default)`, `Tangential`, `Horizontal` | — | always | Label orientation for sunburst sectors. | +| `labelRotate` | choice | `radial` (Radial (default)), `tangential` (Tangential), `0` (Horizontal) | — | always | Label orientation for sunburst sectors. | ### ![](chart-icon-tree.svg) Tree @@ -214,7 +214,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `orient` | choice | `Left → Right (default)`, `Top → Bottom` | — | always | Chart orientation. | +| `orient` | choice | `LR` (Left → Right (default)), `TB` (Top → Bottom) | — | always | Chart orientation. | ## Statistical @@ -287,8 +287,8 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `shape` | choice | `Polygon (default)`, `Circle` | — | always | Grid | -| `filled` | choice | `Filled (default)`, `Outline only` | — | always | Fill the enclosed radar area. | +| `shape` | choice | Polygon (default) _(default)_, `circle` (Circle) | — | always | Grid | +| `filled` | choice | `true` (Filled (default)), `false` (Outline only) | — | always | Fill the enclosed radar area. | | `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.3` | always | Fill opacity for the area or region. | ### ![](chart-icon-rose.svg) Rose Chart @@ -297,8 +297,8 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `alignment` | choice | `Left (default)`, `Center` | — | always | Segment alignment for radial charts. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | +| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | Segment alignment for radial charts. | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | ## Indicator @@ -310,7 +310,7 @@ _No template-specific parameters._ |---|---|---|---|---|---| | `min` | number | 0 – 1000 (step 10) | `0` | always | Min | | `max` | number | 0 – 10000 (step 100) | `100` | always | Max | -| `showProgress` | choice | `Show (default)`, `Hide` | — | always | Progress | +| `showProgress` | choice | `true` (Show (default)), `false` (Hide) | — | always | Progress | ## Flow @@ -320,7 +320,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `orient` | choice | `Horizontal (default)`, `Vertical` | — | always | Chart orientation. | +| `orient` | choice | `horizontal` (Horizontal (default)), `vertical` (Vertical) | — | always | Chart orientation. | | `nodeWidth` | number | 5 – 40 (step 5) | `20` | always | Node Width | | `nodeGap` | number | 2 – 30 (step 2) | `10` | always | Node Gap | @@ -330,4 +330,4 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `layout` | choice | `Circular (default)`, `Force-directed` | — | always | Layout | +| `layout` | choice | `circular` (Circular (default)), `force` (Force-directed) | — | always | Layout | diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index 3775551..2950015 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -48,7 +48,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `regressionMethod` | choice | `Linear`, `Logarithmic`, `Exponential`, `Power`, `Quadratic`, `Polynomial` | `Linear` | always | Regression fit method. | +| `regressionMethod` | choice | `linear` (Linear), `log` (Logarithmic), `exp` (Exponential), `pow` (Power), `quad` (Quadratic), `poly` (Polynomial) | `linear` | always | Regression fit method. | | `polyOrder` | number | 2 – 10 (step 1) | `3` | always | Polynomial order for the regression fit. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | @@ -104,8 +104,8 @@ The **Availability** column shows whether a parameter is `always` available or ` |---|---|---|---|---|---| | `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | Corner radius for supported marks. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | -| `xAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | -| `yAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | +| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | ### ![](chart-icon-column-grouped.svg) Grouped Bar Chart @@ -121,7 +121,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `stackMode` | choice | `Stacked (default)`, `Normalize (100%)`, `Center`, `Layered (overlap)` | — | conditional | Stacking strategy for overlapping series. | +| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | ### ![](chart-icon-lollipop.svg) Lollipop Chart @@ -132,8 +132,8 @@ The **Availability** column shows whether a parameter is `always` available or ` |---|---|---|---|---|---| | `dotSize` | number | 20 – 300 (step 10) | `80` | always | Size of the dot mark. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | -| `xAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | -| `yAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | +| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | ### ![](chart-icon-waterfall.svg) Waterfall Chart @@ -142,7 +142,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `cornerRadius` | number | 0 – 8 (step 1) | `0` | always | Corner radius for supported marks. | -| `totals` | choice | `Auto`, `None`, `First`, `Last`, `Both` | `Auto` | conditional | Totals | +| `totals` | choice | `auto` (Auto), `none` (None), `first` (First), `last` (Last), `both` (Both) | `auto` | conditional | Totals | | `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | @@ -214,7 +214,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `whiskerMethod` | choice | `Tukey (1.5 × IQR)`, `Min–Max` | `Tukey (1.5 × IQR)` | always | Whiskers | +| `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers | | `showOutliers` | toggle | on / off | `true` | conditional | Outliers | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | @@ -248,15 +248,15 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After`, `Basis (smooth)`, `Cardinal`, `Catmull-Rom` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | | `logScale_y` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the y-axis. | | `includeZero_x` | toggle | on / off | `false` | conditional | Anchor the x-axis at zero. | | `includeZero_y` | toggle | on / off | `false` | conditional | Anchor the y-axis at zero. | -| `xAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | -| `yAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | +| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | ### Sparkline @@ -264,8 +264,8 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After`, `Basis (smooth)`, `Cardinal`, `Catmull-Rom` | — | always | Line or area interpolation method. | -| `baseline` | choice | `Average`, `Zero`, `Median`, `None` | `Average` | always | Reference line | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | +| `baseline` | choice | `mean` (Average), `zero` (Zero), `median` (Median), `none` (None) | `mean` | always | Reference line | | `trendWidth` | number | 80 – 600 (step 10) | `240` | always | Sparkline width | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | @@ -303,12 +303,12 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After`, `Basis (smooth)`, `Cardinal`, `Catmull-Rom` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.1) | `0.7` | always | Mark opacity. | -| `stackMode` | choice | `Stacked (default)`, `Normalize (100%)`, `Center`, `Layered (overlap)` | — | conditional | Stacking strategy for overlapping series. | +| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | -| `xAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | -| `yAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | +| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | ### ![](chart-icon-streamgraph.svg) Streamgraph @@ -316,7 +316,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After`, `Basis (smooth)`, `Cardinal`, `Catmull-Rom` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | ### ![](chart-icon-range-area.svg) Range Area Chart @@ -325,7 +325,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | `Default (linear)`, `Linear`, `Monotone (smooth)`, `Step`, `Step Before`, `Step After`, `Basis (smooth)` | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.1) | `0.5` | always | Mark opacity. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | @@ -338,7 +338,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | ### ![](chart-icon-rose.svg) Rose Chart @@ -349,8 +349,8 @@ _No template-specific parameters._ |---|---|---|---|---|---| | `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | | `padAngle` | number | 0 – 0.1 (step 0.005) | `0` | always | Angular gap between radial segments. | -| `alignment` | choice | `Left (default)`, `Center` | — | always | Segment alignment for radial charts. | -| `sortSlices` | choice | `Data order`, `Largest first`, `Smallest first` | `Data order` | always | Sort slices | +| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | Segment alignment for radial charts. | +| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | ### ![](chart-icon-radar.svg) Radar Chart @@ -378,8 +378,8 @@ _No template-specific parameters._ |---|---|---|---|---|---| | `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | -| `xAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | -| `yAxisType` | choice | `Temporal`, `Discrete` | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | +| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | ### ![](chart-icon-bar-table.svg) Bar Table @@ -397,7 +397,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `layout` | choice | `Horizontal`, `Vertical`, `Grid` | `Grid` | always | Layout | +| `layout` | choice | `horizontal` (Horizontal), `vertical` (Vertical), `grid` (Grid) | `grid` | always | Layout | | `style` | toggle | on / off | `true` | always | Card style | | `behindThreshold` | number | 0 – 1 (step 0.05) | `0.5` | conditional | Behind threshold | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | @@ -411,9 +411,9 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `region` | choice | `Auto-detect`, `United States`, `World` | `Auto-detect` | always | Region | -| `projection` | choice | `Default`, `Mercator`, `Equal Earth`, `Orthographic (Globe)`, `Stereographic`, `Conic Equal Area`, `Conic Equidistant`, `Azimuthal Equidistant`, `Mollweide` | `Default` | conditional | Projection | -| `projectionCenter` | choice | `Default`, `World (Atlantic) [0, 0]`, `World (Pacific) [150, 0]`, `China [105, 35]`, `USA [-98, 39]`, `Europe [10, 50]`, `Japan [138, 36]`, `India [78, 22]`, `Brazil [-52, -14]`, `Australia [134, -25]`, `Russia [100, 60]`, `Africa [20, 0]`, `Middle East [45, 28]`, `Southeast Asia [115, 5]`, `South America [-60, -15]`, `North America [-100, 45]`, `UK [-2, 54]`, `Germany [10, 51]`, `France [2, 47]`, `Korea [128, 36]` | — | conditional | Center | +| `region` | choice | `auto` (Auto-detect), `us` (United States), `world` (World) | `auto` | always | Region | +| `projection` | choice | `default` (Default), `mercator` (Mercator), `equalEarth` (Equal Earth), `orthographic` (Orthographic (Globe)), `stereographic` (Stereographic), `conicEqualArea` (Conic Equal Area), `conicEquidistant` (Conic Equidistant), `azimuthalEquidistant` (Azimuthal Equidistant), `mollweide` (Mollweide) | `default` | conditional | Projection | +| `projectionCenter` | choice | Default _(default)_, `0,0` (World (Atlantic) [0, 0]), `150,0` (World (Pacific) [150, 0]), `105,35` (China [105, 35]), `-98,39` (USA [-98, 39]), `10,50` (Europe [10, 50]), `138,36` (Japan [138, 36]), `78,22` (India [78, 22]), `-52,-14` (Brazil [-52, -14]), `134,-25` (Australia [134, -25]), `100,60` (Russia [100, 60]), `20,0` (Africa [20, 0]), `45,28` (Middle East [45, 28]), `115,5` (Southeast Asia [115, 5]), `-60,-15` (South America [-60, -15]), `-100,45` (North America [-100, 45]), `-2,54` (UK [-2, 54]), `10,51` (Germany [10, 51]), `2,47` (France [2, 47]), `128,36` (Korea [128, 36]) | — | conditional | Center | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | | `logScale_y` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the y-axis. | | `includeZero_x` | toggle | on / off | `false` | conditional | Anchor the x-axis at zero. | @@ -425,4 +425,4 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `region` | choice | `Auto-detect`, `United States`, `World` | `Auto-detect` | always | Region | +| `region` | choice | `auto` (Auto-detect), `us` (United States), `world` (World) | `auto` | always | Region | diff --git a/package-lock.json b/package-lock.json index 8893879..70d640f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9511,7 +9511,7 @@ }, "packages/flint-js": { "name": "flint-chart", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@types/node": "^20.14.10", @@ -9552,7 +9552,7 @@ }, "packages/flint-mcp": { "name": "flint-chart-mcp", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.4", @@ -9561,7 +9561,7 @@ "@resvg/resvg-js": "^2.6.2", "chart.js": "^4.4.0", "echarts": "^6.0.0", - "flint-chart": "^0.2.0", + "flint-chart": "^0.2.1", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 7886eb2..c94aabc 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart", - "version": "0.2.0", + "version": "0.2.1", "description": "Semantic-level visualization library that compiles data + semantic types into chart specs for Vega-Lite, ECharts, and Chart.js.", "keywords": [ "visualization", diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index f97bbce..92aad55 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -43,6 +43,7 @@ import { computeLayout, computeChannelBudgets, deriveStretchCaps, resolveBaseSiz import { decideColorMaps } from '../core/color-decisions'; import { cjsApplyLayoutToSpec, cjsApplyTooltips } from './instantiate-spec'; import { normalizeStaticSeries } from '../core/static-series'; +import { normalizeChartProperties } from '../core/normalize-properties'; // --------------------------------------------------------------------------- // Public API @@ -71,7 +72,6 @@ export function assembleChartjs(input: ChartAssemblyInput): any { const sizeCeiling = input.chart_spec.canvasSize; const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); const canvasSize = baseSize; - const chartProperties = input.chart_spec.chartProperties; const options = input.options ?? {}; let chartTemplate = cjsGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { @@ -80,6 +80,14 @@ export function assembleChartjs(input: ChartAssemblyInput): any { const warnings: ChartWarning[] = []; + // Validate discrete property values against the template's options before + // they reach `instantiate` (map known labels → values, drop unknowns). + const normalizedProps = normalizeChartProperties( + chartTemplate.properties, input.chart_spec.chartProperties, + ); + const chartProperties = normalizedProps.chartProperties; + warnings.push(...normalizedProps.warnings); + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ diff --git a/packages/flint-js/src/chartjs/templates/bar.ts b/packages/flint-js/src/chartjs/templates/bar.ts index b00b51e..2ee4242 100644 --- a/packages/flint-js/src/chartjs/templates/bar.ts +++ b/packages/flint-js/src/chartjs/templates/bar.ts @@ -21,7 +21,7 @@ import { } from './utils'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, -} from '../../vegalite/templates/utils'; +} from '../../core/axis-detection'; import { makeCartesianPivot } from '../../core/pivot'; // --------------------------------------------------------------------------- diff --git a/packages/flint-js/src/chartjs/templates/combo.ts b/packages/flint-js/src/chartjs/templates/combo.ts index ee89719..11882fc 100644 --- a/packages/flint-js/src/chartjs/templates/combo.ts +++ b/packages/flint-js/src/chartjs/templates/combo.ts @@ -28,7 +28,7 @@ import { getSeriesBorderColor, getSeriesBackgroundColor, } from './utils'; -import { detectBandedAxisFromSemantics } from '../../vegalite/templates/utils'; +import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; /** A field is numeric if (nearly) all of its non-null values parse as numbers. */ function isNumericField(table: any[], field: string): boolean { diff --git a/packages/flint-js/src/chartjs/templates/rose.ts b/packages/flint-js/src/chartjs/templates/rose.ts index b3e9894..51b1766 100644 --- a/packages/flint-js/src/chartjs/templates/rose.ts +++ b/packages/flint-js/src/chartjs/templates/rose.ts @@ -95,6 +95,16 @@ export const cjsRoseChartDef: ChartTemplateDef = { // Alignment: 'center' puts wedge center at 12 o'clock, // 'left' puts wedge left edge at 12 o'clock. sortSlicesInPlace(labels, values, ctx.chartProperties?.sortSlices); + + // Area-truth: Chart.js `polarArea` maps the datum linearly to the wedge + // radius, so raw values make AREA ∝ value² (a 4:1 value reads as 16:1 + // area). Vega-Lite instead uses a sqrt radius scale. Chart.js' radial `r` + // scale has no sqrt type, so we plot sqrt(value) as the radius (making + // AREA ∝ value) and keep the true value for the tooltip/label. Guard + // against negatives (sqrt(NaN)). + const rawValues = values.slice(); + const radii = values.map(v => Math.sqrt(Math.max(0, v))); + const alignment = ctx.chartProperties?.alignment ?? 'left'; const n = categories.length; // Chart.js polarArea: startAngle 0 = 12 o'clock, CW. @@ -109,7 +119,7 @@ export const cjsRoseChartDef: ChartTemplateDef = { data: { labels, datasets: [{ - data: values, + data: radii, backgroundColor: bgColors, borderColor: borderColors, borderWidth: 1, @@ -122,12 +132,26 @@ export const cjsRoseChartDef: ChartTemplateDef = { scales: { r: { beginAtZero: true, - ticks: { display: true }, + // Radii are sqrt(value); the raw tick numbers would + // misrepresent the scale, so hide them (true values are + // surfaced in the tooltip). + ticks: { display: false }, }, }, plugins: { legend: { display: true, position: 'right' as const }, - tooltip: { enabled: true }, + tooltip: { + enabled: true, + callbacks: { + // Radius is sqrt-transformed; report the true value. + label: (item: any) => { + const raw = rawValues[item.dataIndex]; + const shown = raw != null ? raw : item.raw; + const name = item.label != null ? item.label : ''; + return `${name}: ${shown}`; + }, + }, + }, }, }, _width: Math.max(ctx.canvasSize.width, 350), diff --git a/packages/flint-js/src/core/axis-detection.ts b/packages/flint-js/src/core/axis-detection.ts new file mode 100644 index 0000000..573acb1 --- /dev/null +++ b/packages/flint-js/src/core/axis-detection.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ChannelSemantics } from './types'; + +type EncodingType = 'nominal' | 'ordinal' | 'quantitative' | 'temporal'; + +type BandedAxisResult = { + axis: 'x' | 'y'; + resolvedTypes?: Record; +}; + +const isDiscrete = (type: string | undefined): boolean => + type === 'nominal' || type === 'ordinal'; + +const getFieldCardinality = (field: string, table: any[]): number => + new Set(table.map((row: any) => row[field]).filter((value: any) => value != null)).size; + +/** Resolve a backend-neutral discrete encoding type for a field. */ +export function resolveDiscreteType( + currentType: string, + field: string | undefined, + table: any[], +): 'nominal' | 'ordinal' { + if (currentType === 'nominal') return 'nominal'; + if (currentType === 'ordinal') return 'ordinal'; + if (currentType === 'temporal') return 'ordinal'; + if (currentType === 'quantitative' && field && table.length > 0) { + return getFieldCardinality(field, table) <= 20 ? 'ordinal' : 'nominal'; + } + return 'nominal'; +} + +/** Choose the position axis that should use banded layout. */ +export function detectBandedAxisFromSemantics( + channelSemantics: Record, + table: any[], + options: { preferAxis?: 'x' | 'y' } = {}, +): BandedAxisResult | null { + const xType = channelSemantics.x?.type; + const yType = channelSemantics.y?.type; + + if (xType && isDiscrete(xType)) return { axis: 'x' }; + if (yType && isDiscrete(yType)) return { axis: 'y' }; + + if (xType && yType) { + if (xType === 'quantitative' && yType !== 'quantitative') { + return { axis: 'y' }; + } + if (yType === 'quantitative' && xType !== 'quantitative') { + return { axis: 'x' }; + } + return { axis: options.preferAxis || 'x' }; + } + + if (xType) { + const newType = resolveDiscreteType(xType, channelSemantics.x?.field, table); + return { axis: 'x', resolvedTypes: { x: newType } }; + } + if (yType) { + const newType = resolveDiscreteType(yType, channelSemantics.y?.field, table); + return { axis: 'y', resolvedTypes: { y: newType } }; + } + + return null; +} + +/** Choose a banded axis and force its encoding type to be discrete. */ +export function detectBandedAxisForceDiscrete( + channelSemantics: Record, + table: any[], + options: { preferAxis?: 'x' | 'y' } = {}, +): BandedAxisResult | null { + const result = detectBandedAxisFromSemantics(channelSemantics, table, options); + if (!result) return null; + + const axis = result.axis; + const semantics = channelSemantics[axis]; + if (!semantics) return result; + + if (!isDiscrete(semantics.type)) { + const newType = resolveDiscreteType(semantics.type, semantics.field, table); + return { + axis, + resolvedTypes: { ...result.resolvedTypes, [axis]: newType }, + }; + } + + return result; +} \ No newline at end of file diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 5759315..7c7310b 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -57,6 +57,12 @@ export { // Reusable encoding-action factories export { makeSortAction, type SortChoice } from './encoding-actions'; +// Backend-neutral position-axis detection for bar-like templates +export { + detectBandedAxisFromSemantics, + detectBandedAxisForceDiscrete, +} from './axis-detection'; + // Semantic type system export { SemanticTypes, diff --git a/packages/flint-js/src/core/normalize-properties.ts b/packages/flint-js/src/core/normalize-properties.ts new file mode 100644 index 0000000..dc5f7fa --- /dev/null +++ b/packages/flint-js/src/core/normalize-properties.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Normalize user-supplied `chart_spec.chartProperties` against a template's + * declared `properties`, so an invalid *value* never reaches the backend spec. + * + * The reference docs (see `scripts/gen-chart-reference.ts`) list the accepted + * `value` for each discrete property, but a caller may still pass a human label + * (e.g. `"Logarithmic"` instead of `"log"`) or an outright unknown value. Left + * unchecked, the template's `instantiate` copies that string verbatim into the + * backend transform (e.g. Vega-Lite's `regression` `method`), which the renderer + * silently rejects — producing a blank chart with no error. + * + * For every discrete property present in `chartProperties` this helper: + * - keeps the value untouched when it already matches an option `value`; + * - maps a known option `label` (case-insensitive) to its `value`, emitting an + * `info` warning so the caller learns the canonical value; + * - drops an unrecognized value (falling back to the property default) and + * emits a `warning`, keeping the "safe to pass" contract that a bad input + * degrades gracefully rather than rendering nothing. + * + * Non-discrete properties and keys not declared by the template (pivot state, + * axis overrides, encoding overrides, …) are passed through unchanged. + */ + +import type { ChartPropertyDef, ChartWarning } from './types'; + +export interface NormalizePropertiesResult { + chartProperties: Record | undefined; + warnings: ChartWarning[]; +} + +export function normalizeChartProperties( + properties: ChartPropertyDef[] | undefined, + chartProperties: Record | undefined, +): NormalizePropertiesResult { + const warnings: ChartWarning[] = []; + if (!properties || !chartProperties) { + return { chartProperties, warnings }; + } + + let result: Record | undefined; + const ensureCopy = (): Record => { + if (!result) result = { ...chartProperties }; + return result; + }; + + for (const def of properties) { + if (def.type !== 'discrete') continue; + if (!(def.key in chartProperties)) continue; + const value = chartProperties[def.key]; + if (value == null) continue; + + // Already a valid accepted value — nothing to do. + if (def.options.some((o) => o.value === value)) continue; + + // Known label (case-insensitive) → coerce to the canonical value. + const byLabel = + typeof value === 'string' + ? def.options.find( + (o) => + o.label != null && + o.label.toLowerCase() === value.trim().toLowerCase(), + ) + : undefined; + if (byLabel) { + ensureCopy()[def.key] = byLabel.value; + warnings.push({ + severity: 'info', + code: 'coerced-option-label', + message: `chartProperties.${def.key}: '${value}' is a display label; using the accepted value '${byLabel.value}' instead.`, + }); + continue; + } + + // Unrecognized value → drop it so the property falls back to its default, + // instead of emitting an invalid backend spec that renders blank. + const accepted = def.options + .map((o) => (o.value == null ? '(default)' : `'${o.value}'`)) + .join(', '); + const copy = ensureCopy(); + delete copy[def.key]; + warnings.push({ + severity: 'warning', + code: 'invalid-option-value', + message: `chartProperties.${def.key}: '${value}' is not a valid option (accepted: ${accepted}). Falling back to the default.`, + }); + } + + return { chartProperties: result ?? chartProperties, warnings }; +} diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index b96d8e8..8ab5bc7 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -74,6 +74,7 @@ import { inferVisCategory, computeZeroDecision } from '../core/semantic-types'; import { decideColorMaps } from '../core/color-decisions'; import { getPaletteForScheme } from './colormap'; import { normalizeStaticSeries } from '../core/static-series'; +import { normalizeChartProperties } from '../core/normalize-properties'; // --------------------------------------------------------------------------- // Public API @@ -103,7 +104,6 @@ export function assembleECharts(input: ChartAssemblyInput): any { const sizeCeiling = input.chart_spec.canvasSize; const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); const canvasSize = baseSize; - const chartProperties = input.chart_spec.chartProperties; const options = input.options ?? {}; let chartTemplate = ecGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { @@ -112,6 +112,14 @@ export function assembleECharts(input: ChartAssemblyInput): any { const warnings: ChartWarning[] = []; + // Validate discrete property values against the template's options before + // they reach `instantiate` (map known labels → values, drop unknowns). + const normalizedProps = normalizeChartProperties( + chartTemplate.properties, input.chart_spec.chartProperties, + ); + const chartProperties = normalizedProps.chartProperties; + warnings.push(...normalizedProps.warnings); + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ diff --git a/packages/flint-js/src/echarts/templates/bar.ts b/packages/flint-js/src/echarts/templates/bar.ts index b2d85d5..270fa20 100644 --- a/packages/flint-js/src/echarts/templates/bar.ts +++ b/packages/flint-js/src/echarts/templates/bar.ts @@ -20,7 +20,7 @@ import type { ColorDecision } from '../../core/color-decisions'; import { pickEChartsPalette } from '../colormap'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, -} from '../../vegalite/templates/utils'; +} from '../../core/axis-detection'; import { makeCartesianPivot } from '../../core/pivot'; // --------------------------------------------------------------------------- diff --git a/packages/flint-js/src/echarts/templates/boxplot.ts b/packages/flint-js/src/echarts/templates/boxplot.ts index c754076..e0ef8d2 100644 --- a/packages/flint-js/src/echarts/templates/boxplot.ts +++ b/packages/flint-js/src/echarts/templates/boxplot.ts @@ -13,7 +13,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { extractCategories, groupBy, getCategoryOrder } from './utils'; -import { detectBandedAxisForceDiscrete } from '../../vegalite/templates/utils'; +import { detectBandedAxisForceDiscrete } from '../../core/axis-detection'; const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; diff --git a/packages/flint-js/src/echarts/templates/lollipop.ts b/packages/flint-js/src/echarts/templates/lollipop.ts index e2660ba..bf6d29c 100644 --- a/packages/flint-js/src/echarts/templates/lollipop.ts +++ b/packages/flint-js/src/echarts/templates/lollipop.ts @@ -9,7 +9,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { extractCategories, groupBy, DEFAULT_COLORS, getCategoryOrder } from './utils'; import { detectAxes } from './utils'; -import { detectBandedAxisFromSemantics } from '../../vegalite/templates/utils'; +import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; /** Vega-Lite 风格:茎(rule)黑色、细线,圆点与 color 图例一致 */ const STEM_COLOR = '#000000'; diff --git a/packages/flint-js/src/echarts/templates/rose.ts b/packages/flint-js/src/echarts/templates/rose.ts index 011c521..dda8748 100644 --- a/packages/flint-js/src/echarts/templates/rose.ts +++ b/packages/flint-js/src/echarts/templates/rose.ts @@ -28,6 +28,17 @@ import { extractCategories, groupBy, computeCircumferencePressure } from './util */ export const EC_ROSE_LEGEND_BRIDGE_SERIES_NAME = '__dfRoseLegendBridge__'; +/** + * Map a raw measure to a wedge radius so that wedge AREA (not radius) is + * proportional to the value. ECharts' polar `radiusAxis` is linear (there is no + * `sqrt` axis type), so — unlike Vega-Lite which sets `radius.scale.type = 'sqrt'` + * — we transform the data itself. The true value is preserved on each data item + * (`_rawValue`) for the tooltip. Guards against negatives (sqrt(NaN)). + */ +function roseRadius(value: number): number { + return Math.sqrt(Math.max(0, value)); +} + export const ecRoseChartDef: ChartTemplateDef = { chart: 'Rose Chart', template: { mark: 'arc', encoding: {} }, @@ -67,8 +78,13 @@ export const ecRoseChartDef: ChartTemplateDef = { const legendData: string[] = []; if (colorField) { - // Stacked rose: one series per color group + // Stacked rose: one series per color group. ECharts stacks by SUMMING + // data values, so to make each segment's AREA proportional to its value + // we feed the incremental radius sqrt(cumsum) - sqrt(prevCumsum). The + // outer edge of each segment then sits at sqrt(total-so-far), mirroring + // Vega-Lite's stacked sqrt radius scale. const groups = groupBy(table, colorField); + const cumSum = categories.map(() => 0); for (const [name, rows] of groups) { legendData.push(name); @@ -81,12 +97,19 @@ export const ecRoseChartDef: ChartTemplateDef = { catAgg.set(cat, (catAgg.get(cat) ?? 0) + val); } - const values = categories.map(c => catAgg.get(c) ?? 0); + const data = categories.map((c, i) => { + const val = catAgg.get(c) ?? 0; + const prev = cumSum[i]; + const next = prev + val; + cumSum[i] = next; + // Incremental radius so the stacked outer edge lands at sqrt(next). + return { value: roseRadius(next) - roseRadius(prev), _rawValue: val }; + }); seriesArr.push({ type: 'bar', name, - data: values, + data, coordinateSystem: 'polar', stack: 'rose', emphasis: { focus: 'series' }, @@ -110,8 +133,9 @@ export const ecRoseChartDef: ChartTemplateDef = { seriesArr.push({ type: 'bar', data: categories.map((c, i) => ({ - value: values[i], + value: roseRadius(values[i]), name: String(c), + _rawValue: values[i], })), coordinateSystem: 'polar', emphasis: { focus: 'series' }, @@ -180,6 +204,19 @@ export const ecRoseChartDef: ChartTemplateDef = { const option: any = { tooltip: { trigger: 'item', + // Radii are sqrt-transformed for area-truth; show the true value + // (stashed on each data item as `_rawValue`) instead of the radius. + formatter: (params: any) => { + const raw = params?.data?._rawValue; + const shown = raw != null ? raw : params?.value; + const cat = params?.name != null && params.name !== '' ? String(params.name) : ''; + const series = params?.seriesName; + const head = series && series !== cat + ? (cat ? `${cat} · ${series}` : String(series)) + : cat; + const marker = params?.marker ?? ''; + return `${marker}${head}: ${shown}`; + }, }, angleAxis: { type: 'category', @@ -190,6 +227,9 @@ export const ecRoseChartDef: ChartTemplateDef = { // hide axis line for cleaner look axisLine: { show: false }, axisTick: { show: false }, + // Radii encode sqrt(value); showing the raw sqrt tick numbers would + // misrepresent the scale, so suppress them (values live in tooltips). + axisLabel: { show: false }, }, polar: { radius: polarRadius, diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index cecc007..309bc31 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -64,6 +64,7 @@ import { filterOverflow } from '../core/filter-overflow'; import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout'; import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; import { normalizeStaticSeries } from '../core/static-series'; +import { normalizeChartProperties } from '../core/normalize-properties'; // --------------------------------------------------------------------------- // Helpers @@ -114,7 +115,6 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { const sizeCeiling = input.chart_spec.canvasSize; const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); const canvasSize = baseSize; - const chartProperties = input.chart_spec.chartProperties; const options = input.options ?? {}; let chartTemplate = vlGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { @@ -123,6 +123,16 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { const warnings: ChartWarning[] = []; + // Validate discrete property *values* against the template's declared + // options before they reach `instantiate`. This maps a known display label + // to its accepted value and drops unrecognized values, so a bad input never + // produces an invalid backend spec (which would render blank). + const normalizedProps = normalizeChartProperties( + chartTemplate.properties, input.chart_spec.chartProperties, + ); + const chartProperties = normalizedProps.chartProperties; + warnings.push(...normalizedProps.warnings); + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index f3b9d5a..7f9c4cb 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -6,8 +6,10 @@ import { makeSortAction } from '../../core/encoding-actions'; import { makeCartesianPivot } from '../../core/pivot'; import { snapToBoundHeuristic } from '../../core/field-semantics'; import { - defaultBuildEncodings, setMarkProp, adjustBarMarks, adjustRectTiling, detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, +} from '../../core/axis-detection'; +import { + defaultBuildEncodings, setMarkProp, adjustBarMarks, adjustRectTiling, resolveAsDiscrete, } from './utils'; diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 9a6099f..ecc08ae 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -4,7 +4,8 @@ import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; import { makeSortAction } from '../../core/encoding-actions'; import { makeCartesianPivot } from '../../core/pivot'; -import { detectBandedAxisFromSemantics, setMarkProp } from './utils'; +import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; +import { setMarkProp } from './utils'; export const lollipopChartDef: ChartTemplateDef = { chart: "Lollipop Chart", diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 90b0872..863a246 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -2,9 +2,9 @@ // Licensed under the MIT License. import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { detectBandedAxisForceDiscrete } from '../../core/axis-detection'; import { defaultBuildEncodings, applyPointSizeScaling, setMarkProp, - detectBandedAxisForceDiscrete, } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index 1dce3a2..bfd714a 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -26,7 +26,8 @@ */ import { ChartTemplateDef } from '../../core/types'; -import { defaultBuildEncodings, resolveDiscreteType } from './utils'; +import { resolveDiscreteType } from '../../core/axis-detection'; +import { defaultBuildEncodings } from './utils'; const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; diff --git a/packages/flint-js/src/vegalite/templates/utils.ts b/packages/flint-js/src/vegalite/templates/utils.ts index 59afe4f..4f9a709 100644 --- a/packages/flint-js/src/vegalite/templates/utils.ts +++ b/packages/flint-js/src/vegalite/templates/utils.ts @@ -7,6 +7,7 @@ */ import type { ChannelSemantics, InstantiateContext } from '../../core/types'; +import { resolveDiscreteType } from '../../core/axis-detection'; // --------------------------------------------------------------------------- // Discrete-dimension helpers @@ -33,32 +34,6 @@ export function isEquallyStrided(field: string, table: any[]): boolean { return diffs.every(d => Math.abs(d - medianDiff) <= tolerance); } -/** - * Get the number of unique non-null values for a field in the data table. - */ -export function getFieldCardinality(field: string, table: any[]): number { - return new Set(table.map((r: any) => r[field]).filter((v: any) => v != null)).size; -} - -/** - * Determine the discrete type for a given encoding type. - * Returns the appropriate discrete type without mutating anything. - */ -export function resolveDiscreteType( - currentType: string, - field: string | undefined, - table: any[], -): 'nominal' | 'ordinal' { - if (currentType === 'nominal') return 'nominal'; - if (currentType === 'ordinal') return 'ordinal'; - if (currentType === 'temporal') return 'ordinal'; - if (currentType === 'quantitative' && field && table.length > 0) { - const cardinality = getFieldCardinality(field, table); - return cardinality <= 20 ? 'ordinal' : 'nominal'; - } - return 'nominal'; -} - /** * Convert a single encoding to a discrete VL type in-place. */ @@ -72,81 +47,6 @@ export function resolveAsDiscrete( return result; } -/** - * Detect which positional axis should be the banded/category axis, - * working from ChannelSemantics (v2 pipeline). - * - * Used by declareLayoutMode to set axisFlags and resolvedTypes. - * - * @returns axis: which axis is banded - * resolvedTypes: type overrides if conversion was needed - */ -export function detectBandedAxisFromSemantics( - channelSemantics: Record, - table: any[], - options: { preferAxis?: 'x' | 'y' } = {}, -): { axis: 'x' | 'y'; resolvedTypes?: Record } | null { - const xType = channelSemantics.x?.type; - const yType = channelSemantics.y?.type; - - // Already discrete? - if (xType && isDiscrete(xType)) return { axis: 'x' }; - if (yType && isDiscrete(yType)) return { axis: 'y' }; - - // Both continuous — don't convert, the banded flag handles sizing - if (xType && yType) { - if (xType === 'quantitative' && yType !== 'quantitative') { - return { axis: 'y' }; - } - if (yType === 'quantitative' && xType !== 'quantitative') { - return { axis: 'x' }; - } - return { axis: options.preferAxis || 'x' }; - } - - // Only one axis — convert to discrete - if (xType) { - const newType = resolveDiscreteType(xType, channelSemantics.x?.field, table); - return { axis: 'x', resolvedTypes: { x: newType } }; - } - if (yType) { - const newType = resolveDiscreteType(yType, channelSemantics.y?.field, table); - return { axis: 'y', resolvedTypes: { y: newType } }; - } - - return null; -} - -/** - * Detect which axis is banded, and also force discrete conversion - * when needed (grouped bar, boxplot must have a truly discrete axis). - * - * Returns resolvedTypes with the forced conversion. - */ -export function detectBandedAxisForceDiscrete( - channelSemantics: Record, - table: any[], - options: { preferAxis?: 'x' | 'y' } = {}, -): { axis: 'x' | 'y'; resolvedTypes?: Record } | null { - const result = detectBandedAxisFromSemantics(channelSemantics, table, options); - if (!result) return null; - - const axis = result.axis; - const cs = channelSemantics[axis]; - if (!cs) return result; - - // If the axis is NOT already discrete, force conversion - if (!isDiscrete(cs.type)) { - const newType = resolveDiscreteType(cs.type, cs.field, table); - return { - axis, - resolvedTypes: { ...result.resolvedTypes, [axis]: newType }, - }; - } - - return result; -} - /** * Default instantiate implementation for simple templates. * Maps each resolved encoding channel directly to spec.encoding[channel]. diff --git a/packages/flint-js/src/vegalite/templates/violin.ts b/packages/flint-js/src/vegalite/templates/violin.ts index 66cd3dc..6b5770a 100644 --- a/packages/flint-js/src/vegalite/templates/violin.ts +++ b/packages/flint-js/src/vegalite/templates/violin.ts @@ -32,7 +32,7 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; -import { detectBandedAxisForceDiscrete } from './utils'; +import { detectBandedAxisForceDiscrete } from '../../core/axis-detection'; const isDiscrete = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 5f328b8..cd4ea45 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -85,8 +85,14 @@ export const waterfallChartDef: ChartTemplateDef = { as: "__wf_prev_sum", }); + // A bar is a beige "total" only when its Type value is a recognized anchor + // keyword (start/end) — matching the ECharts/Chart.js templates, which key + // their color off `start`/`end` too. Any other value, including an + // agent-supplied increase/decrease category, is a floating delta colored by + // sign, so an unrecognized color vocabulary degrades to sign-based coloring + // instead of collapsing every bar to the single "total" hue. transforms.push({ - calculate: `datum['${typeField}'] !== 'delta' ? 'total' : datum['${yField}'] >= 0 ? 'increase' : 'decrease'`, + calculate: `(datum['${typeField}'] === 'start' || datum['${typeField}'] === 'end') ? 'total' : datum['${yField}'] >= 0 ? 'increase' : 'decrease'`, as: "__wf_color", }); diff --git a/packages/flint-js/tests/axis-detection.test.ts b/packages/flint-js/tests/axis-detection.test.ts new file mode 100644 index 0000000..45f0002 --- /dev/null +++ b/packages/flint-js/tests/axis-detection.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { + detectBandedAxisForceDiscrete, + detectBandedAxisFromSemantics, + type ChannelSemantics, +} from '../src/core'; + +const semantics = ( + field: string, + type: ChannelSemantics['type'], +): ChannelSemantics => ({ + field, + type, + semanticAnnotation: { semanticType: 'Unknown' }, +}); + +describe('detectBandedAxisFromSemantics', () => { + it('selects an already-discrete axis', () => { + const result = detectBandedAxisFromSemantics({ + x: semantics('category', 'nominal'), + y: semantics('value', 'quantitative'), + }, []); + + expect(result).toEqual({ axis: 'x' }); + }); + + it('honors preferAxis when both axes are continuous', () => { + const result = detectBandedAxisFromSemantics({ + x: semantics('first', 'quantitative'), + y: semantics('second', 'quantitative'), + }, [], { preferAxis: 'y' }); + + expect(result).toEqual({ axis: 'y' }); + }); + + it('returns a discrete type override for a single populated axis', () => { + const result = detectBandedAxisFromSemantics({ + x: semantics('date', 'temporal'), + }, [{ date: '2026-01-01' }]); + + expect(result).toEqual({ + axis: 'x', + resolvedTypes: { x: 'ordinal' }, + }); + }); +}); + +describe('detectBandedAxisForceDiscrete', () => { + it('forces the selected continuous axis to a discrete type', () => { + const result = detectBandedAxisForceDiscrete({ + x: semantics('first', 'quantitative'), + y: semantics('second', 'quantitative'), + }, [ + { first: 1, second: 10 }, + { first: 2, second: 20 }, + ], { preferAxis: 'y' }); + + expect(result).toEqual({ + axis: 'y', + resolvedTypes: { y: 'ordinal' }, + }); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/chart-properties-validation.test.ts b/packages/flint-js/tests/chart-properties-validation.test.ts new file mode 100644 index 0000000..d0443dd --- /dev/null +++ b/packages/flint-js/tests/chart-properties-validation.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; + +const BASE = { + data: { + values: [ + { flops: 1.2e20, loss: 2.81 }, + { flops: 7.0e20, loss: 2.42 }, + { flops: 2.8e21, loss: 2.34 }, + { flops: 5.4e21, loss: 2.11 }, + ], + }, + semantic_types: { flops: 'Quantity', loss: 'Quantity' }, +}; + +function assemble(chartProperties: Record): any { + return assembleVegaLite({ + ...BASE, + chart_spec: { + chartType: 'Regression', + encodings: { x: { field: 'flops' }, y: { field: 'loss' } }, + chartProperties, + }, + }); +} + +/** Pull the regression transform out of the assembled layered spec. */ +function regressionTransform(spec: any): any { + const layer = spec.layer.find((l: any) => + (l.transform ?? []).some((t: any) => 'regression' in t), + ); + return layer.transform.find((t: any) => 'regression' in t); +} + +describe('Regression — discrete property value validation', () => { + it('passes a valid accepted value through unchanged', () => { + const spec = assemble({ regressionMethod: 'log' }); + expect(regressionTransform(spec).method).toBe('log'); + expect(spec._warnings).toBeUndefined(); + }); + + it('coerces a known display label to its accepted value', () => { + const spec = assemble({ regressionMethod: 'Logarithmic' }); + // The invalid label must not reach the backend transform verbatim. + expect(regressionTransform(spec).method).toBe('log'); + const warn = (spec._warnings ?? []).find( + (w: any) => w.code === 'coerced-option-label', + ); + expect(warn).toBeDefined(); + expect(warn.severity).toBe('info'); + }); + + it('is case-insensitive when matching a label', () => { + const spec = assemble({ regressionMethod: 'EXPONENTIAL' }); + expect(regressionTransform(spec).method).toBe('exp'); + }); + + it('drops an unrecognized value and warns instead of emitting it', () => { + const spec = assemble({ regressionMethod: 'Nonsense' }); + // Falls back to the default (linear) → no `method` on the transform. + expect(regressionTransform(spec).method).toBeUndefined(); + const warn = (spec._warnings ?? []).find( + (w: any) => w.code === 'invalid-option-value', + ); + expect(warn).toBeDefined(); + expect(warn.severity).toBe('warning'); + }); + + it('leaves a valid default value without warnings', () => { + const spec = assemble({ regressionMethod: 'linear' }); + expect(regressionTransform(spec).method).toBeUndefined(); + expect(spec._warnings).toBeUndefined(); + }); +}); diff --git a/packages/flint-js/tests/rose-area-truth.test.ts b/packages/flint-js/tests/rose-area-truth.test.ts new file mode 100644 index 0000000..7774199 --- /dev/null +++ b/packages/flint-js/tests/rose-area-truth.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assembleECharts, assembleChartjs } from '../src'; +import { EC_ROSE_LEGEND_BRIDGE_SERIES_NAME } from '../src/echarts/templates/rose'; + +/** + * Rose Chart (Nightingale / Coxcomb) area-truth invariant across backends. + * + * A rose encodes each category's value as a wedge whose ANGLE is fixed and + * whose RADIUS varies. The perceptually honest encoding maps value to wedge + * AREA, which means radius must scale with sqrt(value). Vega-Lite achieves this + * with `radius.scale.type = 'sqrt'`. ECharts and Chart.js have no sqrt radial + * scale, so the compiler must emit sqrt(value) into the series data (keeping the + * true value for the tooltip). These tests pin that all three backends render a + * 4:1 value as a 2:1 radius (→ 4:1 area), not a 4:1 radius (→ 16:1 area). + */ + +const simpleInput = () => ({ + data: { values: [{ key: 'A', n: 4 }, { key: 'B', n: 1 }] }, + semantic_types: { key: 'Category', n: 'Count' }, + chart_spec: { + chartType: 'Rose Chart', + encodings: { x: { field: 'key' }, y: { field: 'n' } }, + }, +}); + +/** Read a data item's numeric radius value (items may be numbers or objects). */ +const radiusOf = (item: any): number => + typeof item === 'object' && item != null ? Number(item.value) : Number(item); + +describe('Vega-Lite Rose chart area-truth', () => { + it('uses a sqrt radius scale', () => { + const spec: any = assembleVegaLite(simpleInput()); + const enc = (spec.layer?.[0] ?? spec).encoding; + expect(enc.radius.scale.type).toBe('sqrt'); + }); +}); + +describe('ECharts Rose chart area-truth', () => { + it('emits sqrt(value) radii so area is proportional to value', () => { + const spec: any = assembleECharts(simpleInput()); + const polar = spec.series.find( + (s: any) => s.type === 'bar' && s.coordinateSystem === 'polar', + ); + const rA = radiusOf(polar.data[0]); + const rB = radiusOf(polar.data[1]); + // Radius ratio is sqrt(4/1) = 2, NOT the raw 4. + expect(rA / rB).toBeCloseTo(2, 6); + // Area ratio (radius²) matches the value ratio 4:1. + expect((rA * rA) / (rB * rB)).toBeCloseTo(4, 6); + }); + + it('keeps the true value on each data item for the tooltip', () => { + const spec: any = assembleECharts(simpleInput()); + const polar = spec.series.find( + (s: any) => s.type === 'bar' && s.coordinateSystem === 'polar', + ); + expect(polar.data[0]._rawValue).toBe(4); + expect(polar.data[1]._rawValue).toBe(1); + expect(typeof spec.tooltip.formatter).toBe('function'); + const shown = spec.tooltip.formatter({ data: polar.data[0], name: 'A', value: radiusOf(polar.data[0]) }); + expect(String(shown)).toContain('4'); + }); + + it('does not show misleading sqrt-space radial tick labels', () => { + const spec: any = assembleECharts(simpleInput()); + expect(spec.radiusAxis.axisLabel.show).toBe(false); + }); + + it('stacks segments so each segment area is proportional to its value', () => { + // D1: S1=9, S2=16 → total 25. Outer edge must sit at sqrt(25)=5: + // S1 segment radius sqrt(9)=3, S2 increment sqrt(25)-sqrt(9)=2. + const input = { + data: { + values: [ + { dir: 'D1', season: 'S1', n: 9 }, + { dir: 'D1', season: 'S2', n: 16 }, + { dir: 'D2', season: 'S1', n: 1 }, + { dir: 'D2', season: 'S2', n: 3 }, + ], + }, + semantic_types: { dir: 'Category', season: 'Category', n: 'Count' }, + chart_spec: { + chartType: 'Rose Chart', + encodings: { x: { field: 'dir' }, y: { field: 'n' }, color: { field: 'season' } }, + }, + }; + const spec: any = assembleECharts(input); + const stacks = spec.series.filter( + (s: any) => s.type === 'bar' && s.stack === 'rose', + ); + expect(stacks.length).toBe(2); + // Sum of stacked increments for D1 (index 0) = sqrt(total) = 5. + const outerD1 = stacks.reduce((acc: number, s: any) => acc + radiusOf(s.data[0]), 0); + expect(outerD1).toBeCloseTo(Math.sqrt(25), 6); + // Raw values preserved for the tooltip. + expect(stacks[0].data[0]._rawValue).toBe(9); + expect(stacks[1].data[0]._rawValue).toBe(16); + }); + + it('exports the legend bridge series name constant', () => { + expect(EC_ROSE_LEGEND_BRIDGE_SERIES_NAME).toBe('__dfRoseLegendBridge__'); + }); +}); + +describe('Chart.js Rose chart area-truth', () => { + it('emits sqrt(value) radii so area is proportional to value', () => { + const spec: any = assembleChartjs(simpleInput()); + const data = spec.data.datasets[0].data as number[]; + expect(data[0] / data[1]).toBeCloseTo(2, 6); + expect((data[0] * data[0]) / (data[1] * data[1])).toBeCloseTo(4, 6); + }); + + it('reports the true value in the tooltip and hides sqrt-space ticks', () => { + const spec: any = assembleChartjs(simpleInput()); + expect(spec.options.scales.r.ticks.display).toBe(false); + const label = spec.options.plugins.tooltip.callbacks.label; + expect(typeof label).toBe('function'); + expect(String(label({ dataIndex: 0, label: 'A', raw: spec.data.datasets[0].data[0] }))).toContain('4'); + }); +}); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index 216d0d4..e9265dd 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -186,7 +186,7 @@ properties"). Required channels are noted. | `"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 | prop `cornerRadius` | +| `"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` | @@ -232,6 +232,18 @@ channel**: 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): @@ -352,6 +364,7 @@ derived). Values are clamped to the ranges shown. | 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 | diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index 1eee78b..9f58d15 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart-mcp", - "version": "0.2.0", + "version": "0.2.1", "description": "Model Context Protocol server for Flint — compile, validate, and render semantic chart specs to Vega-Lite, ECharts, or Chart.js artifacts (PNG/SVG) in-process.", "keywords": [ "mcp", @@ -68,7 +68,7 @@ "@resvg/resvg-js": "^2.6.2", "chart.js": "^4.4.0", "echarts": "^6.0.0", - "flint-chart": "^0.2.0", + "flint-chart": "^0.2.1", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index 59be907..863eea2 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -183,7 +183,17 @@ function domain(p: ChartPropertyDef): string { return `${p.min} – ${p.max}${step}`; } case 'discrete': - return p.options.map((o) => `\`${o.label}\``).join(', '); + // Show the accepted `value` (what callers pass in + // `chartProperties`), with the human label as secondary text — the + // labels are not valid inputs. An `undefined` value means "omit the + // property to get this default", so render just its label. + return p.options + .map((o) => + o.value === undefined || o.value === null + ? `${o.label} _(default)_` + : `\`${o.value}\`${o.label ? ` (${o.label})` : ''}`, + ) + .join(', '); case 'binary': return 'on / off'; } @@ -197,8 +207,10 @@ function defaultValue(p: ChartPropertyDef): string { if (raw == null) return '—'; if (p.key === 'binCount' && raw === 0) return '`Auto`'; if (p.type === 'discrete') { + // Render the accepted `value`, not the display label, so the default + // shown matches what a caller passes in `chartProperties`. const match = p.options.find((o) => o.value === raw); - return match ? `\`${match.label}\`` : `\`${String(raw)}\``; + return match ? `\`${String(match.value)}\`` : `\`${String(raw)}\``; } return `\`${String(raw)}\``; } diff --git a/site/src/components/SiteShell.tsx b/site/src/components/SiteShell.tsx index fce7b54..ec3367b 100644 --- a/site/src/components/SiteShell.tsx +++ b/site/src/components/SiteShell.tsx @@ -1,8 +1,14 @@ import type { CSSProperties, ReactNode } from 'react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { CONTENT_MAX_WIDTH, GITHUB_REPO, siteTheme } from '../shared/theme'; +const GITHUB_REPO_API = 'https://api.github.com/repos/microsoft/flint-chart'; +const GITHUB_STARS_CACHE_KEY = 'flint-chart.github-stars'; +const GITHUB_STARS_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; + +let githubStarsRequest: Promise | undefined; + /** * Shared chrome: Vega-Lite-style top nav + page body + Microsoft disclosures. */ @@ -135,11 +141,31 @@ function BrandLink() { function GitHubLink() { const [hovered, setHovered] = useState(false); + const [starCount, setStarCount] = useState(readCachedGitHubStars); + const compactStarCount = starCount === null ? '' : formatCompactCount(starCount); + + useEffect(() => { + let active = true; + + void fetchGitHubStars().then((count) => { + if (count !== null) { + cacheGitHubStars(count); + if (active) setStarCount(count); + } + }); + + return () => { + active = false; + }; + }, []); + return ( setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ @@ -153,13 +179,92 @@ function GitHubLink() { > GitHub + ); } -function GitHubIcon() { +function fetchGitHubStars(): Promise { + githubStarsRequest ??= fetch(GITHUB_REPO_API, { + headers: { Accept: 'application/vnd.github+json' }, + }) + .then(async (response) => { + if (!response.ok) return null; + + const payload: unknown = await response.json(); + if ( + typeof payload === 'object' + && payload !== null + && 'stargazers_count' in payload + && typeof payload.stargazers_count === 'number' + ) { + return payload.stargazers_count; + } + return null; + }) + .catch(() => null); + + return githubStarsRequest; +} + +function readCachedGitHubStars(): number | null { + try { + const cached = JSON.parse(localStorage.getItem(GITHUB_STARS_CACHE_KEY) ?? 'null') as unknown; + if ( + typeof cached === 'object' + && cached !== null + && 'count' in cached + && 'fetchedAt' in cached + && typeof cached.count === 'number' + && typeof cached.fetchedAt === 'number' + && Date.now() - cached.fetchedAt < GITHUB_STARS_CACHE_MAX_AGE + ) { + return cached.count; + } + } catch { + // Storage may be unavailable in privacy-restricted browsing contexts. + } + return null; +} + +function cacheGitHubStars(count: number) { + try { + localStorage.setItem(GITHUB_STARS_CACHE_KEY, JSON.stringify({ count, fetchedAt: Date.now() })); + } catch { + // The live value still renders when storage is unavailable. + } +} + +function formatCompactCount(count: number): string { + if (count < 1_000) return String(count); + if (count < 1_000_000) return `${(count / 1_000).toFixed(count < 10_000 ? 1 : 0).replace('.0', '')}k`; + return `${(count / 1_000_000).toFixed(count < 10_000_000 ? 1 : 0).replace('.0', '')}m`; +} + +export function GitHubIcon({ size = 15 }: { size?: number } = {}) { return ( -
- + - + } variant="secondary" />
@@ -346,12 +346,14 @@ const SHOWCASE_EXAMPLES: ShowcaseExample[] = [ function HeroCTA({ label, + icon, to, href, variant, className, }: { label: string; + icon?: ReactNode; to?: string; href?: string; variant: 'primary' | 'secondary'; @@ -364,17 +366,20 @@ function HeroCTA({ onFocus: () => setActive(true), onBlur: () => setActive(false), }; + const ctaClassName = ['landing-hero-cta', className].filter(Boolean).join(' '); if (href) { return ( - + + {icon} {label} ); } return ( - + + {icon} {label} ); @@ -1029,14 +1034,12 @@ const leadTextColStyle: CSSProperties = { const leadButtonsColStyle: CSSProperties = { flex: '0 0 auto', - width: 190, + width: 210, display: 'flex', flexDirection: 'column', - borderLeft: `1px solid ${HAIRLINE}`, - paddingLeft: 28, + paddingTop: 8, }; -// Right-side quick actions kept flat to avoid competing with the hero copy. const actionBoxStyle: CSSProperties = { display: 'flex', flexDirection: 'column', @@ -1085,6 +1088,11 @@ const installLineLinkStyle: CSSProperties = { }; const landingInteractiveStyles = ` + .landing-hero-cta:focus-visible { + outline: 2px solid ${siteTheme.accent}; + outline-offset: 2px; + } + .landing-showcase-row { width: calc(100% + 96px); margin-left: -48px; @@ -1600,18 +1608,23 @@ const secondaryBtn: CSSProperties = { function heroCtaStyle(variant: 'primary' | 'secondary', active: boolean): CSSProperties { const base: CSSProperties = { - display: 'block', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, width: '100%', + minHeight: 44, boxSizing: 'border-box', textAlign: 'center', - padding: '10px 18px', + padding: '11px 18px', borderRadius: siteTheme.radius, textDecoration: 'none', fontSize: 14.5, - fontWeight: 500, + fontWeight: 600, lineHeight: 1.2, border: '1px solid transparent', - transition: 'background 0.12s ease, border-color 0.12s ease', + transform: active ? 'translateY(-1px)' : 'translateY(0)', + transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.12s ease', }; if (variant === 'primary') { return { @@ -1624,6 +1637,6 @@ function heroCtaStyle(variant: 'primary' | 'secondary', active: boolean): CSSPro ...base, color: siteTheme.text, background: active ? siteTheme.hover : PAPER, - borderColor: HAIRLINE, + borderColor: active ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.24)', }; }