@blueprint-chart/mcp 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -15
- package/dist/cli.js +15 -2
- package/dist/dsl/capabilityMatrix.d.ts +22 -0
- package/dist/dsl/capabilityMatrix.js +37 -0
- package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
- package/dist/dsl/capabilityMatrix.test.js +49 -0
- package/dist/dsl/chartTypes.d.ts +16 -0
- package/dist/dsl/chartTypes.js +37 -0
- package/dist/dsl/chartTypes.test.d.ts +1 -0
- package/dist/dsl/chartTypes.test.js +32 -0
- package/dist/dsl/dataKey.d.ts +25 -0
- package/dist/dsl/dataKey.js +42 -0
- package/dist/dsl/dataKey.test.d.ts +1 -0
- package/dist/dsl/dataKey.test.js +35 -0
- package/dist/dsl/goalRanking.d.ts +7 -0
- package/dist/dsl/goalRanking.js +76 -0
- package/dist/dsl/goalRanking.test.d.ts +1 -0
- package/dist/dsl/goalRanking.test.js +83 -0
- package/dist/dsl/parseErrorHints.d.ts +12 -0
- package/dist/dsl/parseErrorHints.js +32 -0
- package/dist/dsl/parseErrorHints.test.d.ts +1 -0
- package/dist/dsl/parseErrorHints.test.js +26 -0
- package/dist/dsl/semanticWarnings.d.ts +7 -0
- package/dist/dsl/semanticWarnings.js +66 -0
- package/dist/dsl/semanticWarnings.test.d.ts +1 -0
- package/dist/dsl/semanticWarnings.test.js +32 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +66 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +34 -0
- package/dist/dsl/universalProperties.d.ts +30 -0
- package/dist/dsl/universalProperties.js +52 -0
- package/dist/dsl/universalProperties.test.d.ts +1 -0
- package/dist/dsl/universalProperties.test.js +26 -0
- package/dist/dsl/validate.d.ts +10 -0
- package/dist/dsl/validate.js +68 -0
- package/dist/dsl/validate.test.d.ts +1 -0
- package/dist/dsl/validate.test.js +73 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +1 -0
- package/dist/errors.test.js +21 -0
- package/dist/lib/zodToJsonSchema.d.ts +10 -5
- package/dist/lib/zodToJsonSchema.js +14 -6
- package/dist/links/buildUrls.d.ts +14 -0
- package/dist/links/buildUrls.js +20 -0
- package/dist/links/buildUrls.test.d.ts +1 -0
- package/dist/links/buildUrls.test.js +28 -0
- package/dist/links/editorConfig.d.ts +4 -0
- package/dist/links/editorConfig.js +15 -0
- package/dist/links/editorConfig.test.d.ts +1 -0
- package/dist/links/editorConfig.test.js +28 -0
- package/dist/links/encode.d.ts +11 -0
- package/dist/links/encode.js +19 -0
- package/dist/links/encode.test.d.ts +1 -0
- package/dist/links/encode.test.js +37 -0
- package/dist/parse.js +14 -6
- package/dist/parse.test.js +8 -0
- package/dist/prompts/authorChart.js +23 -18
- package/dist/prompts/authorChart.test.js +6 -0
- package/dist/render/diagnose.d.ts +19 -0
- package/dist/render/diagnose.js +100 -0
- package/dist/render/diagnose.test.d.ts +1 -0
- package/dist/render/diagnose.test.js +53 -0
- package/dist/render/frame.d.ts +10 -0
- package/dist/render/frame.js +10 -0
- package/dist/render/frame.test.d.ts +1 -0
- package/dist/render/frame.test.js +12 -0
- package/dist/render/jsdomEnv.d.ts +2 -1
- package/dist/render/jsdomEnv.js +14 -1
- package/dist/render/jsdomEnv.test.js +36 -2
- package/dist/render/renderSceneState.d.ts +5 -1
- package/dist/render/renderSceneState.js +4 -3
- package/dist/render/renderSceneState.test.js +13 -7
- package/dist/render/validatePipeline.d.ts +23 -0
- package/dist/render/validatePipeline.js +41 -0
- package/dist/render/validatePipeline.test.d.ts +1 -0
- package/dist/render/validatePipeline.test.js +34 -0
- package/dist/resources/docsReader.d.ts +4 -1
- package/dist/resources/docsReader.js +23 -6
- package/dist/resources/docsReader.test.js +27 -2
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/samples.d.ts +1 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +75 -5
- package/dist/server.test.js +105 -4
- package/dist/tools/describeChartType.d.ts +41 -0
- package/dist/tools/describeChartType.js +143 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +78 -0
- package/dist/tools/exportChart.d.ts +17 -0
- package/dist/tools/exportChart.js +31 -0
- package/dist/tools/exportChart.test.d.ts +1 -0
- package/dist/tools/exportChart.test.js +43 -0
- package/dist/tools/getExample.d.ts +20 -0
- package/dist/tools/getExample.js +55 -0
- package/dist/tools/getExample.test.d.ts +1 -0
- package/dist/tools/getExample.test.js +40 -0
- package/dist/tools/getGrammar.d.ts +17 -0
- package/dist/tools/getGrammar.js +38 -0
- package/dist/tools/getGrammar.test.d.ts +1 -0
- package/dist/tools/getGrammar.test.js +35 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +40 -7
- package/dist/tools/inspect.test.js +62 -13
- package/dist/tools/listChartTypes.d.ts +14 -0
- package/dist/tools/listChartTypes.js +42 -0
- package/dist/tools/listChartTypes.test.d.ts +1 -0
- package/dist/tools/listChartTypes.test.js +42 -0
- package/dist/tools/listPalettes.d.ts +13 -0
- package/dist/tools/listPalettes.js +12 -0
- package/dist/tools/listPalettes.test.d.ts +1 -0
- package/dist/tools/listPalettes.test.js +15 -0
- package/dist/tools/recommend.js +3 -1
- package/dist/tools/recommend.test.js +40 -0
- package/dist/tools/render.d.ts +14 -12
- package/dist/tools/render.js +96 -28
- package/dist/tools/render.test.js +137 -1
- package/dist/tools/searchExamples.d.ts +28 -0
- package/dist/tools/searchExamples.js +54 -0
- package/dist/tools/searchExamples.test.d.ts +1 -0
- package/dist/tools/searchExamples.test.js +32 -0
- package/dist/tools/validate.d.ts +9 -3
- package/dist/tools/validate.js +11 -1
- package/dist/tools/validate.test.js +33 -11
- package/dist/transports/http.d.ts +4 -2
- package/dist/transports/http.js +232 -23
- package/dist/transports/http.test.js +158 -22
- package/package.json +5 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +9 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
The MCP exposes Blueprint Chart's dataviz handbook, DSL grammar reference, chart-type docs, and canonical samples as MCP resources, plus
|
|
20
|
+
The MCP exposes Blueprint Chart's dataviz handbook, DSL grammar reference, chart-type docs, and canonical samples as MCP resources, plus eleven deterministic tools: `validate_dsl`, `inspect_dsl`, `recommend_chart_type`, `render`, `list_chart_types`, `describe_chart_type`, `get_example`, `get_grammar`, `export_chart`, `search_examples`, and `list_palettes`. Your LLM writes the `.bpc`; the MCP grounds it in real dataviz pedagogy and gives it a tight feedback loop.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
@@ -44,17 +44,29 @@ Add to `claude_desktop_config.json`:
|
|
|
44
44
|
## Use with Claude Code
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
claude mcp add blueprint-chart
|
|
47
|
+
claude mcp add blueprint-chart \
|
|
48
|
+
-e BLUEPRINT_CHART_EDITOR_URL=https://blueprintchart.com \
|
|
49
|
+
-e BLUEPRINT_CHART_DOCS_URL=https://docs.blueprintchart.com \
|
|
50
|
+
-- npx -y @blueprint-chart/mcp
|
|
48
51
|
```
|
|
49
52
|
|
|
50
53
|
## Tools
|
|
51
54
|
|
|
52
55
|
| Tool | Purpose |
|
|
53
56
|
| --- | --- |
|
|
54
|
-
| `validate_dsl` | Parse `.bpc`;
|
|
55
|
-
| `inspect_dsl` | Parse and summarize:
|
|
56
|
-
| `recommend_chart_type` | Rank chart types for a given column shape |
|
|
57
|
-
| `render` | Render to SVG (default) or
|
|
57
|
+
| `validate_dsl` | Parse `.bpc`; returns `{ valid, errors[], warnings[] }` — each error has `code`, `message`, `suggestion` |
|
|
58
|
+
| `inspect_dsl` | Parse and summarize: `chartType`, `scenes`, `seriesCount`, `rowCount`, `hasHighlights`, `hasColorizes`, etc. |
|
|
59
|
+
| `recommend_chart_type` | Rank chart types for a given column shape and row count |
|
|
60
|
+
| `render` | Render to SVG (default), PNG, or HTML; always returns structured frame metadata, with structured `errors[]` (each with `code` + `suggestion`) on failure. Pass `save: <path>` to write the output to disk (requires `MCP_ALLOW_FS_WRITE=1`) |
|
|
61
|
+
| `list_chart_types` | List all renderable chart types (tool equivalent of `bpc://handbook/choosing`) |
|
|
62
|
+
| `describe_chart_type` | Properties, when-to-use, when-NOT-to-use, and data-shape for one chart type (tool equivalent of `bpc://chart-types/{slug}`) |
|
|
63
|
+
| `get_example` | Fetch a canonical `.bpc` sample by chart type or sample name (tool equivalent of `bpc://samples/{id}`) |
|
|
64
|
+
| `search_examples` | Find canonical examples by topic keywords and/or chart type (returns pointers; fetch full DSL with `get_example`) |
|
|
65
|
+
| `get_grammar` | Full DSL syntax reference (tool equivalent of `bpc://grammar`) |
|
|
66
|
+
| `list_palettes` | List named colour palettes with hex colours for `colorPalette` |
|
|
67
|
+
| `export_chart` | Validate a `.bpc` and return shareable editor URLs — an editable `copyUrl` and a read-only `embedUrl` for iframes (requires `BLUEPRINT_CHART_EDITOR_URL`) |
|
|
68
|
+
|
|
69
|
+
The discovery tools (`list_chart_types`, `describe_chart_type`, `get_example`, `search_examples`, `get_grammar`, `list_palettes`) let clients without MCP resource support access the same reference material that the `bpc://` URIs expose.
|
|
58
70
|
|
|
59
71
|
## Resources
|
|
60
72
|
|
|
@@ -77,7 +89,7 @@ Once the MCP is connected, ask Claude to make a chart:
|
|
|
77
89
|
|
|
78
90
|
> **You:** Make a horizontal bar chart of English letter frequencies — top 10, highlight E.
|
|
79
91
|
>
|
|
80
|
-
> **Claude:** *(
|
|
92
|
+
> **Claude:** *(calls `list_chart_types`, `get_example({ chartType: "bar-horizontal" })`, writes the `.bpc`, calls `validate_dsl` to confirm it parses, calls `render` with `format: 'png'` and shows you the image and the source)*
|
|
81
93
|
>
|
|
82
94
|
> Here's the chart:
|
|
83
95
|
>
|
|
@@ -119,7 +131,7 @@ chart bar-vertical {
|
|
|
119
131
|
|
|
120
132
|
Full grammar at `bpc://grammar`; 17 canonical samples at `bpc://samples/<id>` (`letter-frequency`, `co2-emissions`, `quarterly-revenue`, `browser-market`, `temperature-anomaly`, `population-stacked-bar`, ...).
|
|
121
133
|
|
|
122
|
-
### `validate_dsl` — parse with
|
|
134
|
+
### `validate_dsl` — parse with structured diagnostics
|
|
123
135
|
|
|
124
136
|
Request:
|
|
125
137
|
|
|
@@ -130,15 +142,19 @@ Request:
|
|
|
130
142
|
}
|
|
131
143
|
```
|
|
132
144
|
|
|
133
|
-
Response
|
|
145
|
+
Response — `valid` is false; each entry in `errors[]` carries a `code`, human-readable `message`, and an actionable `suggestion`:
|
|
134
146
|
|
|
135
147
|
```json
|
|
136
148
|
{
|
|
137
|
-
"
|
|
138
|
-
"code": "E_PARSE",
|
|
149
|
+
"valid": false,
|
|
139
150
|
"errors": [
|
|
140
|
-
{
|
|
141
|
-
|
|
151
|
+
{
|
|
152
|
+
"code": "E_PARSE",
|
|
153
|
+
"message": "Expected \"\\\"\" but end of input found.",
|
|
154
|
+
"suggestion": "Close the string literal on line 2."
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
"warnings": []
|
|
142
158
|
}
|
|
143
159
|
```
|
|
144
160
|
|
|
@@ -195,7 +211,7 @@ Response:
|
|
|
195
211
|
}
|
|
196
212
|
```
|
|
197
213
|
|
|
198
|
-
### `render` — SVG (default) or
|
|
214
|
+
### `render` — SVG (default), PNG, or HTML
|
|
199
215
|
|
|
200
216
|
Request:
|
|
201
217
|
|
|
@@ -219,7 +235,7 @@ Response:
|
|
|
219
235
|
}
|
|
220
236
|
```
|
|
221
237
|
|
|
222
|
-
If rasterization fails (rare),
|
|
238
|
+
If rasterization fails (rare), `errors[]` is non-empty — each entry has a `code` (`"E_RENDER"`) and a `suggestion` — **and the response still includes** the SVG that was successfully produced, so partial success is preserved.
|
|
223
239
|
|
|
224
240
|
### Reading a resource
|
|
225
241
|
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ function parseList(value) {
|
|
|
15
15
|
function parseConfig(argv) {
|
|
16
16
|
const env = process.env;
|
|
17
17
|
const port = Number(env.PORT) || 4321;
|
|
18
|
-
const host = env.MCP_HOST || '127.0.0.1';
|
|
18
|
+
const host = env.MCP_HOST || (env.PORT ? '0.0.0.0' : '127.0.0.1');
|
|
19
19
|
const allowedOriginsList = parseList(env.MCP_ALLOWED_ORIGINS);
|
|
20
20
|
const allowedOrigins = allowedOriginsList
|
|
21
21
|
&& allowedOriginsList.length === 1 && allowedOriginsList[0] === '*'
|
|
@@ -38,6 +38,7 @@ function parseConfig(argv) {
|
|
|
38
38
|
? Number(env.MCP_RATE_LIMIT_PER_MINUTE)
|
|
39
39
|
: undefined,
|
|
40
40
|
silent: parseBool(env.MCP_SILENT),
|
|
41
|
+
rootRedirectUrl: env.MCP_ROOT_REDIRECT_URL || undefined,
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -76,6 +77,18 @@ Environment variables (HTTP mode):
|
|
|
76
77
|
MCP_MAX_CONCURRENT_REQUESTS Cap on concurrent POSTs (default 16).
|
|
77
78
|
MCP_RATE_LIMIT_PER_MINUTE Per-IP rate limit (default off; e.g. 60).
|
|
78
79
|
MCP_SILENT=1 Suppress JSON access logs to stderr.
|
|
80
|
+
MCP_ROOT_REDIRECT_URL If set, redirect GET / to this URL.
|
|
81
|
+
MCP_PUBLIC_URL Public base URL (no path, no trailing slash).
|
|
82
|
+
When set, advertised in serverInfo.icons so
|
|
83
|
+
MCP clients (claude.ai, etc.) can render the
|
|
84
|
+
favicon. Example: https://mcp.example.com
|
|
85
|
+
BLUEPRINT_CHART_EDITOR_URL Editor app base URL (no trailing slash), e.g.
|
|
86
|
+
https://blueprintchart.com. Required for the
|
|
87
|
+
export_chart tool to mint copy/embed links;
|
|
88
|
+
unset disables export (returns E_CONFIG).
|
|
89
|
+
BLUEPRINT_CHART_DOCS_URL Docs site base URL, e.g.
|
|
90
|
+
https://docs.blueprintchart.com. When set,
|
|
91
|
+
tools/resources include public docsUrl fields.
|
|
79
92
|
`);
|
|
80
93
|
process.exit(0);
|
|
81
94
|
}
|
|
@@ -105,7 +118,7 @@ const config = parseConfig(process.argv.slice(2));
|
|
|
105
118
|
if (config.http) {
|
|
106
119
|
startHttp(config.httpOpts)
|
|
107
120
|
.then((handle) => {
|
|
108
|
-
process.stderr.write(`MCP HTTP server listening at ${handle.url}
|
|
121
|
+
process.stderr.write(`MCP HTTP server listening at ${handle.url}\n`);
|
|
109
122
|
installSignalHandlers(handle.close);
|
|
110
123
|
})
|
|
111
124
|
.catch((err) => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-(chartType × key) capability metadata. `key` is a chart property OR a
|
|
3
|
+
* directive name (`highlight`, `colorize`, `annotation`, `transform`, `scene`).
|
|
4
|
+
*
|
|
5
|
+
* Phase 1: this matrix is MCP-local, seeded from the authoring usability test
|
|
6
|
+
* (test-results/mcp-authoring-report.md). Phase 2 promotes it into
|
|
7
|
+
* @blueprint-chart/lib as renderer-verified metadata; the MCP then consumes
|
|
8
|
+
* lib's matrix and deletes this file.
|
|
9
|
+
*
|
|
10
|
+
* Design: DEFAULT every cell to { applicable: true, implemented: true }
|
|
11
|
+
* ("supported") and list ONLY non-default cells in OVERRIDES. This keeps
|
|
12
|
+
* warnings high-precision (no false positives on the 34 bundled samples) and
|
|
13
|
+
* means a new chart type needs zero matrix edits to avoid spurious warnings.
|
|
14
|
+
*/
|
|
15
|
+
export interface CapabilityCell {
|
|
16
|
+
applicable: boolean;
|
|
17
|
+
implemented: boolean;
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
export type CapabilityStatus = 'supported' | 'not-implemented' | 'inapplicable';
|
|
21
|
+
export declare function statusOf(cell: CapabilityCell): CapabilityStatus;
|
|
22
|
+
export declare function lookupCapability(chartType: string, key: string): CapabilityCell;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const SUPPORTED = Object.freeze({ applicable: true, implemented: true });
|
|
2
|
+
export function statusOf(cell) {
|
|
3
|
+
if (!cell.applicable) {
|
|
4
|
+
return 'inapplicable';
|
|
5
|
+
}
|
|
6
|
+
if (!cell.implemented) {
|
|
7
|
+
return 'not-implemented';
|
|
8
|
+
}
|
|
9
|
+
return 'supported';
|
|
10
|
+
}
|
|
11
|
+
const OVERRIDES = {
|
|
12
|
+
donut: {
|
|
13
|
+
sort: { applicable: false, implemented: false, note: 'Slice order on a donut follows data order; use a `transform sort` on the data instead.' },
|
|
14
|
+
sortMode: { applicable: false, implemented: false, note: 'sortMode applies to grouped/stacked charts, not donut.' },
|
|
15
|
+
valueLabels: { applicable: false, implemented: false, note: 'Donut shows slice labels; use `displayAsPercentage` / `tooltips` instead of valueLabels.' },
|
|
16
|
+
},
|
|
17
|
+
pie: {
|
|
18
|
+
sort: { applicable: false, implemented: false, note: 'Slice order on a pie follows data order; use a `transform sort` on the data instead.' },
|
|
19
|
+
sortMode: { applicable: false, implemented: false, note: 'sortMode applies to grouped/stacked charts, not pie.' },
|
|
20
|
+
valueLabels: { applicable: false, implemented: false, note: 'Pie shows slice labels; use `tooltips` / `displayAsPercentage` instead of valueLabels.' },
|
|
21
|
+
},
|
|
22
|
+
// Single-series line/area are one path with no per-series/per-category marks.
|
|
23
|
+
// `highlight` (dim others) and `colorize` (recolour a target) both need a
|
|
24
|
+
// target to act on, so they are inapplicable here — they work on the
|
|
25
|
+
// multi-series variants (line-multi / area-stacked), which default supported.
|
|
26
|
+
line: {
|
|
27
|
+
highlight: { applicable: false, implemented: false, note: 'highlight dims other series/categories; a single-series line has nothing to dim. Use an `annotation` to call out a point, or `line-multi` for multiple series.' },
|
|
28
|
+
colorize: { applicable: false, implemented: false, note: 'colorize recolours a named series/category; a single-series line has no target. Use `colorPalette` / `colors`, or `line-multi` for multiple series.' },
|
|
29
|
+
},
|
|
30
|
+
area: {
|
|
31
|
+
highlight: { applicable: false, implemented: false, note: 'highlight dims other series/categories; a single-series area has nothing to dim. Use an `annotation` to call out a point, or `area-stacked` for multiple series.' },
|
|
32
|
+
colorize: { applicable: false, implemented: false, note: 'colorize recolours a named series/category; a single-series area has no target. Use `colorPalette` / `colors`, or `area-stacked` for multiple series.' },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
export function lookupCapability(chartType, key) {
|
|
36
|
+
return OVERRIDES[chartType]?.[key] ?? SUPPORTED;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { samples } from '@blueprint-chart/lib';
|
|
3
|
+
import { canonicalChartType } from './chartTypes';
|
|
4
|
+
import { lookupCapability, statusOf } from './capabilityMatrix';
|
|
5
|
+
describe('capabilityMatrix', () => {
|
|
6
|
+
it('defaults unknown cells to supported', () => {
|
|
7
|
+
expect(statusOf(lookupCapability('bar-vertical', 'title'))).toBe('supported');
|
|
8
|
+
expect(statusOf(lookupCapability('bar-vertical', 'some-unknown-key'))).toBe('supported');
|
|
9
|
+
});
|
|
10
|
+
it('marks sort on donut as inapplicable with a note', () => {
|
|
11
|
+
const cell = lookupCapability('donut', 'sort');
|
|
12
|
+
expect(statusOf(cell)).toBe('inapplicable');
|
|
13
|
+
expect(cell.note).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
it('marks colorize supported on donut/pie (W1c shipped per-slice colorize)', () => {
|
|
16
|
+
expect(statusOf(lookupCapability('donut', 'colorize'))).toBe('supported');
|
|
17
|
+
expect(statusOf(lookupCapability('pie', 'colorize'))).toBe('supported');
|
|
18
|
+
});
|
|
19
|
+
it('marks colorize inapplicable on single-series line/area, supported on multi-series (W1c)', () => {
|
|
20
|
+
expect(statusOf(lookupCapability('line', 'colorize'))).toBe('inapplicable');
|
|
21
|
+
expect(lookupCapability('line', 'colorize').note).toBeTruthy();
|
|
22
|
+
expect(statusOf(lookupCapability('area', 'colorize'))).toBe('inapplicable');
|
|
23
|
+
expect(statusOf(lookupCapability('line-multi', 'colorize'))).toBe('supported');
|
|
24
|
+
expect(statusOf(lookupCapability('bar-multi', 'colorize'))).toBe('supported');
|
|
25
|
+
});
|
|
26
|
+
it('marks highlight inapplicable on single-series line/area, supported on multi-series (W1b)', () => {
|
|
27
|
+
expect(statusOf(lookupCapability('line', 'highlight'))).toBe('inapplicable');
|
|
28
|
+
expect(lookupCapability('line', 'highlight').note).toBeTruthy();
|
|
29
|
+
expect(statusOf(lookupCapability('area', 'highlight'))).toBe('inapplicable');
|
|
30
|
+
// multi-series variants got highlight dimming in W1b → default supported
|
|
31
|
+
expect(statusOf(lookupCapability('line-multi', 'highlight'))).toBe('supported');
|
|
32
|
+
expect(statusOf(lookupCapability('area-stacked', 'highlight'))).toBe('supported');
|
|
33
|
+
expect(statusOf(lookupCapability('donut', 'highlight'))).toBe('supported');
|
|
34
|
+
});
|
|
35
|
+
it('does not flag any bundled sample property as inapplicable', () => {
|
|
36
|
+
const offenders = [];
|
|
37
|
+
for (const s of samples) {
|
|
38
|
+
const type = canonicalChartType(s.chartType) ?? s.chartType;
|
|
39
|
+
// matches bare-word "key =" property assignments (the only form bundled samples use)
|
|
40
|
+
const keys = Array.from(s.dsl.matchAll(/^\s*([A-Za-z][A-Za-z0-9]*)\s*=/gm)).map(m => m[1]);
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
if (statusOf(lookupCapability(type, key)) === 'inapplicable') {
|
|
43
|
+
offenders.push(`${s.id} (${type}): ${key}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
expect(offenders).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps alias chart-type names to canonical names. Lib registers aliases
|
|
3
|
+
* (`horizontal-bar` → `bar-horizontal`, `vertical-bar` → `bar-vertical`) at
|
|
4
|
+
* the registry level so both render identically; we surface that mapping here
|
|
5
|
+
* so validation and discovery tools can normalize input.
|
|
6
|
+
*
|
|
7
|
+
* Static rather than derived from the registry because the registry doesn't
|
|
8
|
+
* track alias→canonical pairs — it just registers each alias as a duplicate
|
|
9
|
+
* entry. Source of truth: `packages/lib/src/charts/registry.ts:445-446` and
|
|
10
|
+
* `packages/lib/src/enums.ts:24-25`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CHART_TYPE_ALIASES: Record<string, string>;
|
|
13
|
+
export declare function listCanonicalChartTypes(): string[];
|
|
14
|
+
export declare function canonicalChartType(name: string): string | undefined;
|
|
15
|
+
export declare function isKnownChartType(name: string): boolean;
|
|
16
|
+
export declare function aliasesFor(canonical: string): string[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { listCharts } from '@blueprint-chart/lib';
|
|
2
|
+
/**
|
|
3
|
+
* Maps alias chart-type names to canonical names. Lib registers aliases
|
|
4
|
+
* (`horizontal-bar` → `bar-horizontal`, `vertical-bar` → `bar-vertical`) at
|
|
5
|
+
* the registry level so both render identically; we surface that mapping here
|
|
6
|
+
* so validation and discovery tools can normalize input.
|
|
7
|
+
*
|
|
8
|
+
* Static rather than derived from the registry because the registry doesn't
|
|
9
|
+
* track alias→canonical pairs — it just registers each alias as a duplicate
|
|
10
|
+
* entry. Source of truth: `packages/lib/src/charts/registry.ts:445-446` and
|
|
11
|
+
* `packages/lib/src/enums.ts:24-25`.
|
|
12
|
+
*/
|
|
13
|
+
export const CHART_TYPE_ALIASES = {
|
|
14
|
+
'horizontal-bar': 'bar-horizontal',
|
|
15
|
+
'vertical-bar': 'bar-vertical',
|
|
16
|
+
};
|
|
17
|
+
const ALIAS_NAMES = new Set(Object.keys(CHART_TYPE_ALIASES));
|
|
18
|
+
export function listCanonicalChartTypes() {
|
|
19
|
+
return listCharts().filter(name => !ALIAS_NAMES.has(name));
|
|
20
|
+
}
|
|
21
|
+
export function canonicalChartType(name) {
|
|
22
|
+
if (CHART_TYPE_ALIASES[name]) {
|
|
23
|
+
return CHART_TYPE_ALIASES[name];
|
|
24
|
+
}
|
|
25
|
+
if (listCharts().includes(name)) {
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export function isKnownChartType(name) {
|
|
31
|
+
return canonicalChartType(name) !== undefined;
|
|
32
|
+
}
|
|
33
|
+
export function aliasesFor(canonical) {
|
|
34
|
+
return Object.entries(CHART_TYPE_ALIASES)
|
|
35
|
+
.filter(([, target]) => target === canonical)
|
|
36
|
+
.map(([alias]) => alias);
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CHART_TYPE_ALIASES, aliasesFor, canonicalChartType, isKnownChartType, listCanonicalChartTypes, } from './chartTypes';
|
|
3
|
+
describe('chartTypes', () => {
|
|
4
|
+
it('lists canonical chart types', () => {
|
|
5
|
+
const canon = listCanonicalChartTypes();
|
|
6
|
+
expect(canon).toContain('bar-vertical');
|
|
7
|
+
expect(canon).toContain('bar-horizontal');
|
|
8
|
+
expect(canon).not.toContain('horizontal-bar'); // alias excluded
|
|
9
|
+
});
|
|
10
|
+
it('maps aliases to canonical names', () => {
|
|
11
|
+
expect(CHART_TYPE_ALIASES['horizontal-bar']).toBe('bar-horizontal');
|
|
12
|
+
expect(CHART_TYPE_ALIASES['vertical-bar']).toBe('bar-vertical');
|
|
13
|
+
});
|
|
14
|
+
it('canonicalChartType returns canonical for alias or canonical', () => {
|
|
15
|
+
expect(canonicalChartType('horizontal-bar')).toBe('bar-horizontal');
|
|
16
|
+
expect(canonicalChartType('bar-horizontal')).toBe('bar-horizontal');
|
|
17
|
+
});
|
|
18
|
+
it('canonicalChartType returns undefined for unknown', () => {
|
|
19
|
+
expect(canonicalChartType('bar')).toBeUndefined();
|
|
20
|
+
expect(canonicalChartType('totally-fake')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
it('isKnownChartType accepts canonical and aliases', () => {
|
|
23
|
+
expect(isKnownChartType('bar-vertical')).toBe(true);
|
|
24
|
+
expect(isKnownChartType('horizontal-bar')).toBe(true);
|
|
25
|
+
expect(isKnownChartType('bar')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('aliasesFor returns the registered aliases for a canonical name', () => {
|
|
28
|
+
expect(aliasesFor('bar-horizontal')).toEqual(['horizontal-bar']);
|
|
29
|
+
expect(aliasesFor('bar-vertical')).toEqual(['vertical-bar']);
|
|
30
|
+
expect(aliasesFor('bar-stacked')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PropertyNode } from '@blueprint-chart/lib';
|
|
2
|
+
/**
|
|
3
|
+
* Heuristic for "data key is unquoted identifier-shaped." The lib's PropertyNode
|
|
4
|
+
* tagged quoted-string keys via an isQuoted boolean in older grammar versions,
|
|
5
|
+
* but the installed lib (v0.1.19) does not consistently expose it. We fall back
|
|
6
|
+
* to a regex match against the Identifier production from grammar.peggy.
|
|
7
|
+
*
|
|
8
|
+
* The Identifier production is: [a-zA-Z_#][a-zA-Z0-9_#-]*
|
|
9
|
+
*
|
|
10
|
+
* However, since parsed PropertyNode has no isQuoted flag, we cannot distinguish
|
|
11
|
+
* `"China" = 5` from `China = 5` purely from the AST. We tighten the heuristic
|
|
12
|
+
* by only flagging keys that start with a lowercase letter — real data labels in
|
|
13
|
+
* shipped samples always start with an uppercase letter, a digit, or `_`. A
|
|
14
|
+
* camelCase-starting key (e.g. `unquotedKey`) is a strong signal that the user
|
|
15
|
+
* typed a property key as if it were a chart-level option.
|
|
16
|
+
*/
|
|
17
|
+
export declare function looksLikeUnquotedKey(entry: PropertyNode): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Inverse of `looksLikeUnquotedKey`, with an additional exclusion of the
|
|
20
|
+
* `_series` pseudo-key (which is a multi-series header, not a row label).
|
|
21
|
+
*
|
|
22
|
+
* Quoted-label-shaped means: proper nouns, digit-leading, hyphen/underscore-
|
|
23
|
+
* leading — anything that does NOT start with a lowercase letter.
|
|
24
|
+
*/
|
|
25
|
+
export declare function looksLikeQuotedLabel(entry: PropertyNode): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic for "data key is unquoted identifier-shaped." The lib's PropertyNode
|
|
3
|
+
* tagged quoted-string keys via an isQuoted boolean in older grammar versions,
|
|
4
|
+
* but the installed lib (v0.1.19) does not consistently expose it. We fall back
|
|
5
|
+
* to a regex match against the Identifier production from grammar.peggy.
|
|
6
|
+
*
|
|
7
|
+
* The Identifier production is: [a-zA-Z_#][a-zA-Z0-9_#-]*
|
|
8
|
+
*
|
|
9
|
+
* However, since parsed PropertyNode has no isQuoted flag, we cannot distinguish
|
|
10
|
+
* `"China" = 5` from `China = 5` purely from the AST. We tighten the heuristic
|
|
11
|
+
* by only flagging keys that start with a lowercase letter — real data labels in
|
|
12
|
+
* shipped samples always start with an uppercase letter, a digit, or `_`. A
|
|
13
|
+
* camelCase-starting key (e.g. `unquotedKey`) is a strong signal that the user
|
|
14
|
+
* typed a property key as if it were a chart-level option.
|
|
15
|
+
*/
|
|
16
|
+
export function looksLikeUnquotedKey(entry) {
|
|
17
|
+
const k = entry.key;
|
|
18
|
+
const tagged = entry.isQuoted;
|
|
19
|
+
if (typeof tagged === 'boolean') {
|
|
20
|
+
return !tagged;
|
|
21
|
+
}
|
|
22
|
+
// Only flag identifiers that start with a lowercase letter — proper-noun labels
|
|
23
|
+
// and abbreviations used as data row labels always start with uppercase or `_`.
|
|
24
|
+
return /^[a-z][A-Za-z0-9_#-]*$/.test(k);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Inverse of `looksLikeUnquotedKey`, with an additional exclusion of the
|
|
28
|
+
* `_series` pseudo-key (which is a multi-series header, not a row label).
|
|
29
|
+
*
|
|
30
|
+
* Quoted-label-shaped means: proper nouns, digit-leading, hyphen/underscore-
|
|
31
|
+
* leading — anything that does NOT start with a lowercase letter.
|
|
32
|
+
*/
|
|
33
|
+
export function looksLikeQuotedLabel(entry) {
|
|
34
|
+
if (entry.key === '_series') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const tagged = entry.isQuoted;
|
|
38
|
+
if (typeof tagged === 'boolean') {
|
|
39
|
+
return tagged;
|
|
40
|
+
}
|
|
41
|
+
return !/^[a-z][A-Za-z0-9_#-]*$/.test(entry.key);
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { looksLikeUnquotedKey, looksLikeQuotedLabel } from './dataKey';
|
|
3
|
+
function node(key) {
|
|
4
|
+
return { key, value: 1 };
|
|
5
|
+
}
|
|
6
|
+
describe('dataKey helpers', () => {
|
|
7
|
+
it('looksLikeUnquotedKey flags camelCase identifiers', () => {
|
|
8
|
+
expect(looksLikeUnquotedKey(node('unquotedKey'))).toBe(true);
|
|
9
|
+
expect(looksLikeUnquotedKey(node('sort'))).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('looksLikeUnquotedKey passes proper-noun labels through', () => {
|
|
12
|
+
expect(looksLikeUnquotedKey(node('China'))).toBe(false);
|
|
13
|
+
expect(looksLikeUnquotedKey(node('United States'))).toBe(false);
|
|
14
|
+
expect(looksLikeUnquotedKey(node('_series'))).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
it('looksLikeQuotedLabel excludes _series pseudo-key', () => {
|
|
17
|
+
expect(looksLikeQuotedLabel(node('_series'))).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it('looksLikeQuotedLabel accepts proper-noun labels', () => {
|
|
20
|
+
expect(looksLikeQuotedLabel(node('China'))).toBe(true);
|
|
21
|
+
expect(looksLikeQuotedLabel(node('New York'))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('looksLikeQuotedLabel rejects camelCase keys', () => {
|
|
24
|
+
expect(looksLikeQuotedLabel(node('sort'))).toBe(false);
|
|
25
|
+
expect(looksLikeQuotedLabel(node('colorPalette'))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('respects tagged isQuoted field when present', () => {
|
|
28
|
+
const tagged = { key: 'someKey', value: 1, isQuoted: true };
|
|
29
|
+
expect(looksLikeUnquotedKey(tagged)).toBe(false);
|
|
30
|
+
expect(looksLikeQuotedLabel(tagged)).toBe(true);
|
|
31
|
+
const untagged = { key: 'SomeKey', value: 1, isQuoted: false };
|
|
32
|
+
expect(looksLikeUnquotedKey(untagged)).toBe(true);
|
|
33
|
+
expect(looksLikeQuotedLabel(untagged)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChartRecommendation } from '@blueprint-chart/lib';
|
|
2
|
+
/**
|
|
3
|
+
* Re-rank lib's recommendations using the goal string. Returns a new array;
|
|
4
|
+
* never mutates the input. `rowCount` (when provided) enables a pie-before-donut
|
|
5
|
+
* tiebreak for very small slice counts.
|
|
6
|
+
*/
|
|
7
|
+
export declare function applyGoalReranking(recs: ChartRecommendation[], goal: string | undefined, rowCount?: number): ChartRecommendation[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal keyword → chart types that goal favours, classified `strong` or `weak`.
|
|
3
|
+
*
|
|
4
|
+
* STRONG rules name a goal that implies a different chart *family* (part-to-whole,
|
|
5
|
+
* composition-over-time, range) and so may override lib's column-type "best".
|
|
6
|
+
* WEAK rules are narrative framings (trend, crossover, rank) that only break ties
|
|
7
|
+
* among non-best candidates — they must NOT demote lib's structurally-best pick.
|
|
8
|
+
* (Without this distinction the "overtakes" keyword wrongly boosted line-multi
|
|
9
|
+
* above the correct bar-multi for quarterly-revenue.)
|
|
10
|
+
*
|
|
11
|
+
* Used as a re-ranking layer ON TOP of lib's column-type/row-count ranking;
|
|
12
|
+
* lib's `recommendCharts` ignores the goal string entirely.
|
|
13
|
+
*/
|
|
14
|
+
const GOAL_RULES = [
|
|
15
|
+
{ pattern: /\b(share|part[- ]to[- ]whole|proportion|percentage of (the )?total|breakdown|make up)\b/i, types: ['pie', 'donut', 'bar-stacked', 'column-stacked'], strong: true },
|
|
16
|
+
{ pattern: /(?:\b(?:composition|mix|stacked).*(?:over time|by year|trend)|\b(?:over time|by year).*(?:composition|mix))\b/i, types: ['area-stacked', 'column-stacked'], strong: true },
|
|
17
|
+
{ pattern: /\b(over time|trend|grew|rose|fell|climbed|change over|year[- ]over[- ]year)\b/i, types: ['line', 'line-multi', 'area'], strong: false },
|
|
18
|
+
{ pattern: /\b(overtak\w*|overtook|crossover|surpass|cross over|catch[- ]up|diverg\w*)/i, types: ['line-multi', 'line'], strong: false },
|
|
19
|
+
{ pattern: /\b(rank|ranked|more than|compared|top \d+|most|largest|biggest)\b/i, types: ['bar-vertical', 'bar-horizontal'], strong: false },
|
|
20
|
+
{ pattern: /\b(range|high and low|high\/low|margin of error|confidence|interval|plus or minus)\b/i, types: ['bar-split'], strong: true },
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Re-rank lib's recommendations using the goal string. Returns a new array;
|
|
24
|
+
* never mutates the input. `rowCount` (when provided) enables a pie-before-donut
|
|
25
|
+
* tiebreak for very small slice counts.
|
|
26
|
+
*/
|
|
27
|
+
export function applyGoalReranking(recs, goal, rowCount) {
|
|
28
|
+
if (!goal || goal.trim() === '') {
|
|
29
|
+
return recs;
|
|
30
|
+
}
|
|
31
|
+
const strongBoost = new Set();
|
|
32
|
+
const weakBoost = new Set();
|
|
33
|
+
for (const rule of GOAL_RULES) {
|
|
34
|
+
if (rule.pattern.test(goal)) {
|
|
35
|
+
const target = rule.strong ? strongBoost : weakBoost;
|
|
36
|
+
for (const t of rule.types) {
|
|
37
|
+
target.add(t);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// No-match contract: if nothing matched, return unchanged WITHOUT running the
|
|
42
|
+
// tier sort — otherwise tier 1 (lib "best") would float ahead of a lib-higher
|
|
43
|
+
// "good" even when the author gave no usable goal.
|
|
44
|
+
if (strongBoost.size === 0 && weakBoost.size === 0) {
|
|
45
|
+
return recs;
|
|
46
|
+
}
|
|
47
|
+
// Tier: strong-boosted (0) → lib "best" (1) → weak-boosted (2) → rest (3).
|
|
48
|
+
const tierOf = (rec) => {
|
|
49
|
+
if (strongBoost.has(rec.chartType)) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
if (rec.fitness === 'best') {
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
if (weakBoost.has(rec.chartType)) {
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
return 3;
|
|
59
|
+
};
|
|
60
|
+
const sorted = recs
|
|
61
|
+
.map((rec, index) => ({ rec, index, tier: tierOf(rec) }))
|
|
62
|
+
// tier ascending; within a tier preserve lib's original order (stable)
|
|
63
|
+
.sort((a, b) => (a.tier === b.tier ? a.index - b.index : a.tier - b.tier))
|
|
64
|
+
.map(x => x.rec);
|
|
65
|
+
// Pie suits very small N; for ≤5 slices prefer pie over donut when both are
|
|
66
|
+
// present (and pie currently trails donut). Larger N keeps lib's donut order.
|
|
67
|
+
if (rowCount !== undefined && rowCount <= 5) {
|
|
68
|
+
const donutIdx = sorted.findIndex(r => r.chartType === 'donut');
|
|
69
|
+
const pieIdx = sorted.findIndex(r => r.chartType === 'pie');
|
|
70
|
+
if (donutIdx !== -1 && pieIdx !== -1 && pieIdx > donutIdx) {
|
|
71
|
+
const [pieRec] = sorted.splice(pieIdx, 1);
|
|
72
|
+
sorted.splice(donutIdx, 0, pieRec);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return sorted;
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyGoalReranking } from './goalRanking';
|
|
3
|
+
const recs = [
|
|
4
|
+
{ chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
|
|
5
|
+
{ chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
|
|
6
|
+
{ chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
|
|
7
|
+
{ chartType: 'area-stacked', label: 'Area stacked', fitness: 'alternative', reason: 'r' },
|
|
8
|
+
];
|
|
9
|
+
describe('applyGoalReranking', () => {
|
|
10
|
+
it('returns input unchanged when goal is undefined', () => {
|
|
11
|
+
expect(applyGoalReranking(recs, undefined)).toEqual(recs);
|
|
12
|
+
});
|
|
13
|
+
it('promotes part-to-whole types for a "share of total" goal', () => {
|
|
14
|
+
const out = applyGoalReranking(recs, 'show each region as a share of the total population');
|
|
15
|
+
expect(['pie', 'donut']).toContain(out[0].chartType);
|
|
16
|
+
});
|
|
17
|
+
it('promotes area-stacked for a "composition over time" goal', () => {
|
|
18
|
+
const out = applyGoalReranking(recs, 'composition of energy sources over time');
|
|
19
|
+
expect(out[0].chartType).toBe('area-stacked');
|
|
20
|
+
});
|
|
21
|
+
it('keeps original order when no keyword matches', () => {
|
|
22
|
+
const out = applyGoalReranking(recs, 'just some unrelated text');
|
|
23
|
+
expect(out.map(r => r.chartType)).toEqual(recs.map(r => r.chartType));
|
|
24
|
+
});
|
|
25
|
+
it('keeps lib "best" bar-multi ahead of a narrative-boosted line-multi (quarterly-revenue)', () => {
|
|
26
|
+
const r = [
|
|
27
|
+
{ chartType: 'line-multi', label: 'Multi-Line', fitness: 'good', reason: 'r' },
|
|
28
|
+
{ chartType: 'bar-multi', label: 'Grouped Bar', fitness: 'best', reason: 'r' },
|
|
29
|
+
];
|
|
30
|
+
const out = applyGoalReranking(r, 'software overtakes hardware as the top revenue driver', 6);
|
|
31
|
+
expect(out[0].chartType).toBe('bar-multi');
|
|
32
|
+
expect(out[1].chartType).toBe('line-multi');
|
|
33
|
+
});
|
|
34
|
+
it('promotes pie ahead of donut for a part-to-whole goal at very small N (world-population)', () => {
|
|
35
|
+
const r = [
|
|
36
|
+
{ chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
|
|
37
|
+
{ chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
|
|
38
|
+
{ chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
|
|
39
|
+
{ chartType: 'bar-horizontal', label: 'HBar', fitness: 'good', reason: 'r' },
|
|
40
|
+
];
|
|
41
|
+
const out = applyGoalReranking(r, 'Asia is nearly 60% of the world population; share of total', 5);
|
|
42
|
+
expect(out[0].chartType).toBe('pie');
|
|
43
|
+
expect(out.map(x => x.chartType)).toEqual(['pie', 'donut', 'bar-vertical', 'bar-horizontal']);
|
|
44
|
+
});
|
|
45
|
+
it('leaves lib "best" bar-vertical #1 for a ranked-comparison goal (co2-emissions)', () => {
|
|
46
|
+
const r = [
|
|
47
|
+
{ chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
|
|
48
|
+
{ chartType: 'bar-horizontal', label: 'HBar', fitness: 'good', reason: 'r' },
|
|
49
|
+
{ chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
|
|
50
|
+
{ chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
|
|
51
|
+
];
|
|
52
|
+
const out = applyGoalReranking(r, 'China emits more than the US and India combined; ranked', 6);
|
|
53
|
+
expect(out.map(x => x.chartType)).toEqual(['bar-vertical', 'bar-horizontal', 'donut', 'pie']);
|
|
54
|
+
});
|
|
55
|
+
it('promotes bar-split to #1 for a range / high-low goal (election-polls)', () => {
|
|
56
|
+
const r = [
|
|
57
|
+
{ chartType: 'bar-multi', label: 'Grouped Bar', fitness: 'best', reason: 'r' },
|
|
58
|
+
{ chartType: 'bar-split', label: 'Split Bar', fitness: 'good', reason: 'r' },
|
|
59
|
+
{ chartType: 'line-multi', label: 'Multi-Line', fitness: 'alternative', reason: 'r' },
|
|
60
|
+
];
|
|
61
|
+
const out = applyGoalReranking(r, 'each party has a high and low estimate; a polling range', 6);
|
|
62
|
+
expect(out[0].chartType).toBe('bar-split');
|
|
63
|
+
});
|
|
64
|
+
it('does NOT apply the pie tiebreak for larger N', () => {
|
|
65
|
+
const r = [
|
|
66
|
+
{ chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
|
|
67
|
+
{ chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
|
|
68
|
+
{ chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
|
|
69
|
+
];
|
|
70
|
+
const out = applyGoalReranking(r, 'share of total', 12);
|
|
71
|
+
const di = out.findIndex(x => x.chartType === 'donut');
|
|
72
|
+
const pi = out.findIndex(x => x.chartType === 'pie');
|
|
73
|
+
expect(di).toBeLessThan(pi);
|
|
74
|
+
});
|
|
75
|
+
it('returns input unchanged on no-match WITHOUT floating best ahead of a lib-higher good', () => {
|
|
76
|
+
const r = [
|
|
77
|
+
{ chartType: 'line-multi', label: 'Multi-Line', fitness: 'good', reason: 'r' },
|
|
78
|
+
{ chartType: 'bar-multi', label: 'Grouped Bar', fitness: 'best', reason: 'r' },
|
|
79
|
+
];
|
|
80
|
+
const out = applyGoalReranking(r, 'just some unrelated text', 6);
|
|
81
|
+
expect(out.map(x => x.chartType)).toEqual(['line-multi', 'bar-multi']);
|
|
82
|
+
});
|
|
83
|
+
});
|