@bicharts/chart-mcp 0.6.51 → 0.6.54
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/dist/check-chart.mjs +305 -0
- package/dist/index.mjs +88 -61
- package/package.json +13 -9
- package/skills/bic-charts/CHART-SHAPES.md +5 -4
- package/skills/bic-charts/SKILL.md +219 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.54",
|
|
4
4
|
"description": "MCP (Model Context Protocol) stdio server for BIC AI charts: profile a dataset locally, then generate chart code that has passed the BIC backend's render gates. Works with any MCP-capable client (Claude Code, Claude Desktop, Cursor, Copilot Studio). Requires a BIC trial or paid account.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -41,17 +41,21 @@
|
|
|
41
41
|
"prestart": "node build.mjs",
|
|
42
42
|
"start": "node dist/index.mjs"
|
|
43
43
|
},
|
|
44
|
-
"dependencies":
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
46
|
-
"papaparse": "^5.4.1",
|
|
47
|
-
"zod": "^3.23.0"
|
|
48
|
-
},
|
|
44
|
+
"// dependencies": "NONE, deliberately. dist/ bundles every module the server uses, so a runtime dependency installs nothing it needs - it only makes npx fetch that package (and its tree) before the first start. Everything the bundle carries is a devDependency; scripts/verify-package.mjs fails the release if a dependency appears or a shipped file imports anything but Node's built-ins.",
|
|
49
45
|
"devDependencies": {
|
|
50
|
-
"@bicharts/chart-host": "^0.6.
|
|
51
|
-
"@bicharts/shape-core": "^0.6.
|
|
46
|
+
"@bicharts/chart-host": "^0.6.52",
|
|
47
|
+
"@bicharts/shape-core": "^0.6.52",
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
52
49
|
"@types/node": "^20.0.0",
|
|
53
50
|
"@types/papaparse": "^5.3.14",
|
|
51
|
+
"d3": "^7.9.0",
|
|
52
|
+
"d3-hexbin": "^0.2.2",
|
|
53
|
+
"d3-sankey": "^0.12.3",
|
|
54
|
+
"d3-voronoi-treemap": "^1.1.2",
|
|
54
55
|
"esbuild": "^0.28.1",
|
|
55
|
-
"
|
|
56
|
+
"happy-dom": "^20.14.5",
|
|
57
|
+
"papaparse": "^5.4.1",
|
|
58
|
+
"typescript": "^5.4.0",
|
|
59
|
+
"zod": "^3.23.0"
|
|
56
60
|
}
|
|
57
61
|
}
|
|
@@ -59,7 +59,7 @@ on the same picture, not a looser one.
|
|
|
59
59
|
| Flippable multi-card | 1 continuous | <=12 categories, needs 180x120 px | D3 | Best when more headline numbers compete for one tile than fit at once: several measures side by side, or one or two measures paged through a small category, with the details on hover |
|
|
60
60
|
| Funnel chart | 1 continuous | <=12 categories, needs 460x340 px | D3, PLOTLY | Best for conversion/attrition pipelines: signup funnels, sales pipeline stages, checkout flow drop-off |
|
|
61
61
|
| Gantt chart | 1 categorical, 2 temporal | <=60 categories, needs 420x260 px | D3 | Best for project and phase schedules: tasks with a start and an end date, optionally grouped by phase, workstream or owner |
|
|
62
|
-
| Gauge | 1 continuous, a bounded scale | needs 140x140 px | D3 |
|
|
62
|
+
| Gauge | 1 continuous, a bounded scale | needs 140x140 px | D3 | A value against a ceiling the data states - a percentage, a rate, or a measure that has a target - overall or one dial per category; a constant value is a valid reading |
|
|
63
63
|
| Grouped bar chart | 2 categorical, 1 continuous | needs 700x480 px | D3, Matplotlib, Plotly, Seaborn | Requires one categorical, one subcategory, and one numeric variable |
|
|
64
64
|
| Heatmap | 2 categorical, 1 continuous | <=50 categories, needs 460x340 px | D3, Matplotlib, Plotly, Seaborn | Requires two categorical or ordinal fields for the rows and columns, and one measure for the cell colour |
|
|
65
65
|
| Hexbin plot | 2 continuous | needs 280x280 px | D3, Matplotlib, Plotly, Seaborn | Requires two continuous variables |
|
|
@@ -71,7 +71,7 @@ on the same picture, not a looser one.
|
|
|
71
71
|
| KDE plot (1D & 2D) | 1 continuous | needs 400x300 px | D3, Matplotlib, Plotly, Seaborn, Statsmodels | Requires one continuous variable (or two for 2D KDE) |
|
|
72
72
|
| Lag plot (scatter of value vs lag) | 1 continuous, 20+ time points | needs 280x280 px | D3, Matplotlib, Plotly, Seaborn, Statsmodels | Requires a datetime series with one continuous variable |
|
|
73
73
|
| Line chart | 1 continuous | needs 400x300 px | D3, Matplotlib, Plotly, Seaborn | One or more continuous series over an ordered/temporal axis |
|
|
74
|
-
| Linear gauge | 1 continuous, a bounded scale | needs 180x50 px | D3 |
|
|
74
|
+
| Linear gauge | 1 continuous, a bounded scale | needs 180x50 px | D3 | A value against a ceiling the data states, overall or one track per category, in a short wide tile - percent complete, utilisation, progress to a target; a constant value is a valid reading |
|
|
75
75
|
| Lollipop chart | 1 categorical, 1 continuous | <=50 categories, needs 600x420 px | D3, PLOTLY | Best as a cleaner bar replacement for ranking many categories by a single measure |
|
|
76
76
|
| Marimekko / Mosaic plot | 2 categorical, 1 continuous | needs 320x280 px | D3 | Best for 3-10 outer × 3-10 inner categories with a positive measure |
|
|
77
77
|
| Mermaid diagram | 1 categorical | <=150 categories, needs 360x240 px | D3 | A source and a target column (a process, a hand-off chain, a dependency list), two to four nested categories (an org or product hierarchy), or a date beside an event name (milestones, releases, history) - up to a few dozen boxes |
|
|
@@ -87,12 +87,13 @@ on the same picture, not a looser one.
|
|
|
87
87
|
| Pareto chart | 1 categorical | <=30 categories, needs 320x280 px | D3, MATPLOTLIB, PLOTLY | Best for finding the vital few: defect/complaint/incident counts by cause, revenue concentration by product or customer (the 80/20 view) |
|
|
88
88
|
| Pie chart | 1 categorical | <=10 categories, needs 280x280 px | D3, Matplotlib, Plotly | Requires one categorical variable with proportions |
|
|
89
89
|
| Polar plot | 1 continuous | needs 299x283 px | D3, Matplotlib, Plotly | Requires angle and radius (two continuous variables) |
|
|
90
|
-
| Progress ring | 1 continuous, a bounded scale | needs 160x160 px | D3 |
|
|
90
|
+
| Progress ring | 1 continuous, a bounded scale | needs 160x160 px | D3 | A value against a ceiling the data states, overall or one ring per category, in a small or square tile - percent complete, attainment, utilisation; a constant value is a valid reading |
|
|
91
91
|
| Radar (spider) chart | 1 continuous | <=8 axes, needs 700x480 px | D3, Matplotlib, Plotly | Requires multiple continuous variables across categories |
|
|
92
92
|
| Range-compare time series | 1 continuous, 15+ time points | needs 480x320 px | D3 | Best for a single measure tracked over many dates when the story is a chosen window: drag the two handles to total a period and compare its endpoints, like revenue across a campaign or sensor readings around an incident |
|
|
93
93
|
| Regression plot (line with CI) | 2 continuous | needs 520x380 px | D3, Matplotlib, Plotly, Seaborn, Statsmodels | Requires two continuous variables - a predictor and a response |
|
|
94
94
|
| Ridgeline plots | 1 categorical, 1 continuous | <=25 categories, needs 280x280 px | D3, Matplotlib, Plotly, Seaborn | Best with many observations per category |
|
|
95
95
|
| Rose (Coxcomb) chart | 1 categorical | <=24 categories, needs 300x300 px | D3 | Best for cyclical or directional categories: wind direction, hour of day, month of year, compass sectors - anywhere the categories wrap around rather than running left to right |
|
|
96
|
+
| Rotating carousel | 1 continuous | <=12 categories, needs 320x200 px | D3 | Best when each category value deserves its own small chart and the tile has room for only one at a time; the reader turns it, or it turns itself |
|
|
96
97
|
| Rug plot | 1 continuous | needs 360x280 px | D3, Matplotlib, Plotly, Seaborn | Requires one continuous variable |
|
|
97
98
|
| Sankey alluvial flow | 2 categorical, 1 continuous | <=10 categories, <=80 edges, needs 420x280 px | D3, Matplotlib, Plotly | Requires at least two categorical variables |
|
|
98
99
|
| Scatter plot | 2 continuous | needs 460x340 px | D3, Matplotlib, Plotly, Seaborn | Requires two continuous variables |
|
|
@@ -108,7 +109,7 @@ on the same picture, not a looser one.
|
|
|
108
109
|
| Sunburst chart | 2 categorical, 1 continuous | needs 400x300 px | D3, Matplotlib, Plotly | Requires hierarchical categorical data |
|
|
109
110
|
| Tabular with embedded | 1 categorical, 1 continuous | <=40 categories, needs 420x200 px | D3 | Best for a small set of items compared across several attributes at once (a forecast/roster/scorecard table) |
|
|
110
111
|
| Ternary plot | 3 continuous | needs 320x320 px | D3 | Three measures that add up to the same total on every row - product mix, budget allocation, vote share, channel split |
|
|
111
|
-
| Thermometer | 1 continuous, a bounded scale | needs 90x220 px | D3 |
|
|
112
|
+
| Thermometer | 1 continuous, a bounded scale | needs 90x220 px | D3 | A value against a ceiling the data states, overall or one tube per category, in a tall narrow tile - a sidebar rail or a column of KPIs down a page edge; a constant value is a valid reading |
|
|
112
113
|
| Time series plot | 1 continuous | needs 460x340 px | D3, Plotly | Requires a datetime axis with at least one continuous variable |
|
|
113
114
|
| Trail | 2 continuous | needs 360x220 px | D3 | Best when a series has both a level and a weight: price with traded volume, sentiment with mention count, a metric with the sample size behind it |
|
|
114
115
|
| Treemap | 1 categorical, 1 continuous | needs 240x220 px | D3, Matplotlib, Plotly | Requires hierarchical categorical data with values |
|
|
@@ -460,6 +460,16 @@ A Fabric App built from Rayfin's `dataapp` template is a React app over a semant
|
|
|
460
460
|
path above applies unchanged; these are the places a data app differs, each one measured on a real
|
|
461
461
|
build.
|
|
462
462
|
|
|
463
|
+
**0. Once, right after scaffolding: `setup_fabric_app`.** It installs `@bicharts/chart-host` and `d3`, adds
|
|
464
|
+
`src/lib/bic-chart-options.ts` (`useBicChartOptions()`: the app's Fabric theme - palette, foreground,
|
|
465
|
+
background - as chart options, so charts follow light and dark) and `src/components/bic-chart-panel.tsx`
|
|
466
|
+
(`<BicChartPanel result={useSemanticModelQuery(...)}>`: a box that measures itself and shows the query's
|
|
467
|
+
loading, error and empty states), and ignores `.bic/`. It also puts every `@microsoft/rayfin-*` package on one version
|
|
468
|
+
(the template installs one too old to deploy, and `rayfin init` later upgrades only some of them - its reply lists every
|
|
469
|
+
`package.json` range it changed; an app already on one deployable version is left alone), and adds
|
|
470
|
+
`.agents/skills/regiabi-charts/SKILL.md` beside the template's skills, for agents that read that folder. It never
|
|
471
|
+
overwrites a file, and its reply ends with a worked example.
|
|
472
|
+
|
|
463
473
|
**1. Pull each chart's rows at the chart's GRAIN, with a query written for that chart.** One row per
|
|
464
474
|
country for a country map, one per route for a flow map, one per month for a trend. The template's
|
|
465
475
|
writing-phase command is `npx fabric-app-data query <alias> --query '<DAX>'`. Don't pull a fact
|
|
@@ -479,6 +489,15 @@ name. Pass the model's metadata as you rename: `isMeasure`, `format` and `descri
|
|
|
479
489
|
`INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`) go on each column in `data`, which is the context a
|
|
480
490
|
Power BI visual gets for free.
|
|
481
491
|
|
|
492
|
+
**Steps 1-3 are one call: `query_semantic_model`.** Give it the chart's DAX (`dax`, or a `.dax` file) and a
|
|
493
|
+
`name`; it runs the query through the project's own `fabric-app-data` CLI, renames the headers (dropping the
|
|
494
|
+
table and any symbols: `[Return Rate %]` becomes `ReturnRate`), saves every row to `.bic/data/<name>.csv`,
|
|
495
|
+
reads each column's role, format and description from the model (`INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`),
|
|
496
|
+
and profiles the result. Its reply ends with the arguments for the next two calls - `csv_path`, `measures`,
|
|
497
|
+
`dimensions`, `formats`, `descriptions` - so pass them to `list_eligible_charts` and `generate_chart` as they
|
|
498
|
+
are. The CLI returns at most 1,000 rows; the tool says so when it cut a result, and the fix is a query at the
|
|
499
|
+
chart's grain.
|
|
500
|
+
|
|
482
501
|
**4. Ask before choosing, then choose what answers the question.** `list_eligible_charts` on that
|
|
483
502
|
data. The catalogue goes past the template's own charts, and these are the ones people describe
|
|
484
503
|
without naming:
|
|
@@ -495,7 +514,30 @@ when your table is small, and it says when `name_filter` hid description matches
|
|
|
495
514
|
|
|
496
515
|
**5. Render live rows through the group, with the binding the generation returned.** The template
|
|
497
516
|
queries at run time (`useSemanticModelQuery`), so the payload is rebuilt from live rows every time -
|
|
498
|
-
never hand-built. `
|
|
517
|
+
never hand-built. **With `out_dir`, a D3 chart arrives with `chart.component.tsx` beside `chart.js`: a component that
|
|
518
|
+
already does all of this** - the columns, the binding (`geo`, `point`, `destination`), the geometry kind, the
|
|
519
|
+
d3 plugins the chart calls, and `rowsFromTable`, which maps a DAX result's `Table[Column]` and `[Measure]`
|
|
520
|
+
headers onto the chart's plain names. Use it as it is:
|
|
521
|
+
|
|
522
|
+
```tsx
|
|
523
|
+
// The component is named after the chart type ("Bivariate World Choropleth" -> BivariateWorldChoroplethChart).
|
|
524
|
+
import { BivariateWorldChoroplethChart, rowsFromTable } from "@/charts/country-map/chart.component";
|
|
525
|
+
|
|
526
|
+
import { BicChartPanel } from "@/components/bic-chart-panel";
|
|
527
|
+
import { useBicChartOptions } from "@/lib/bic-chart-options";
|
|
528
|
+
|
|
529
|
+
const result = useSemanticModelQuery({ connection: "<alias>", query: QUERY }); // the DAX it was generated on
|
|
530
|
+
const options = useBicChartOptions();
|
|
531
|
+
<BicChartPanel result={result}>
|
|
532
|
+
{(table, size) => ( // table = the live { columns, rows } result
|
|
533
|
+
<BivariateWorldChoroplethChart table={table} {...size} options={options}
|
|
534
|
+
onSelectRows={picked => onPick(picked[0]?.CountryCode ?? null)} />
|
|
535
|
+
)}
|
|
536
|
+
</BicChartPanel>
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
It is rewritten with the chart on every generation, so keep app logic outside it. What it does, if you
|
|
540
|
+
host by hand instead - `generate_chart` returns `binding`; pass all three:
|
|
499
541
|
|
|
500
542
|
```tsx
|
|
501
543
|
import * as d3 from "d3";
|
|
@@ -529,18 +571,192 @@ function CountryMap({ table, onPick }) { // table = the live query resu
|
|
|
529
571
|
- **`<BicChartGroup destination>` needs `@bicharts/chart-host` 0.6.50 or later.** Before it, a flow
|
|
530
572
|
map's far end is not built.
|
|
531
573
|
|
|
574
|
+
**Layout: a chart is never clipped or squashed.** Charts sit in cards, and a card is where they get cut off.
|
|
575
|
+
- Render every chart inside `BicChartPanel`. It sizes the chart from its cell, never from the chart.
|
|
576
|
+
- Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map about 560 x 300; a time series wants
|
|
577
|
+
height). Below that the panel scrolls; it never squashes or clips the chart.
|
|
578
|
+
- Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
|
|
579
|
+
minimum.
|
|
580
|
+
- Put `min-h-0` on every flex child between a card and its chart (`min-w-0` in a row). A flex item's default
|
|
581
|
+
`min-height: auto` lets it grow past its card, and the card's `overflow-hidden` slices off the rest.
|
|
582
|
+
|
|
532
583
|
**6. Cross-filter across queries by KEY, not by row.** Charts fed by different queries don't share a
|
|
533
584
|
row space, so one group can't span them. Put each query's charts in their own group, turn a click
|
|
534
585
|
into the model's key (`CountryCode` above), keep it in app state, and filter the other queries with
|
|
535
586
|
it. Anything saved against a mark - a note, a flag - is keyed the same way, never by `__rowIdx__`
|
|
536
587
|
(see the row-index hazard above).
|
|
537
588
|
|
|
589
|
+
**Notes (or flags) on marks, end to end.** People leave a note on a country, a route, a product, and see
|
|
590
|
+
it on that mark: the notes live in Rayfin's data service, and the badge is the chart's `annotations`.
|
|
591
|
+
Every Rayfin fact here was checked on Rayfin 1.36.1, a preview - where your installed version differs,
|
|
592
|
+
its own docs (`node_modules/@microsoft/rayfin-guide/assets/docs/data/`) win.
|
|
593
|
+
|
|
594
|
+
*a. Turn the data service on.* The data app template ships it off. Run `setup_fabric_app` first -
|
|
595
|
+
`setup_fabric_app` puts every `@microsoft/rayfin-*` package on one version - then:
|
|
596
|
+
|
|
597
|
+
```bash
|
|
598
|
+
npx rayfin init --services auth,data --auth-methods fabric --dialect mssql --overwrite --project-name <id from rayfin/rayfin.yml> .
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Each flag is needed: without `--overwrite` it prints "Initialization cancelled" and exits 0 with no
|
|
602
|
+
reason; without `--project-name` (pass the `id:` already in `rayfin/rayfin.yml`, so nothing is renamed)
|
|
603
|
+
it stops with "--project-name is required in non-interactive mode". It rewrites `rayfin/rayfin.yml`
|
|
604
|
+
and `.gitignore`, so `git diff` both and put back what's yours (on 1.36.1 it dropped the template's
|
|
605
|
+
`src/fabric.generated.ts` line from `.gitignore`). It writes `rayfin/tsconfig.json` - keep its
|
|
606
|
+
`outDir`, `.temp/compiled`, which is where the deploy reads the entities from - and no entities.
|
|
607
|
+
|
|
608
|
+
*b. Make it type-check.* Three edits, each needed on the data app template:
|
|
609
|
+
- `rayfin/tsconfig.json`, in `compilerOptions`: `"tsBuildInfoFile": ".temp/tsconfig.tsbuildinfo"` and
|
|
610
|
+
`"allowImportingTsExtensions": false` (it inherits both from the root: TS6377 and TS5096 otherwise).
|
|
611
|
+
- the root `tsconfig.json`: `"references": [{ "path": "./rayfin" }]`.
|
|
612
|
+
- type the client with your schema: `RayfinClient<AppSchema>` in `src/lib/rayfin-client.ts`
|
|
613
|
+
(`import type { AppSchema } from "../../rayfin/data/schema";`) and in the constructor of
|
|
614
|
+
`src/services/rayfin-auth.service.ts` (TS2345 otherwise).
|
|
615
|
+
|
|
616
|
+
Check with `npx tsc -b`; a bare `tsc --noEmit` reports TS6305 until the `rayfin/` project is built. If a
|
|
617
|
+
TS2345 names two copies of `rayfin-auth`, something installed only some Rayfin packages: run
|
|
618
|
+
`setup_fabric_app` again.
|
|
619
|
+
|
|
620
|
+
*c. The entity, keyed by the model's key - never by a row position.* `rayfin/data/MarkNote.ts`, then
|
|
621
|
+
register it in `rayfin/data/schema.ts` (`export type AppSchema = { MarkNote: MarkNote };`):
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
import { entity, authenticated, uuid, text, date } from '@microsoft/rayfin-core';
|
|
625
|
+
|
|
626
|
+
@entity()
|
|
627
|
+
@authenticated(['read', 'create'])
|
|
628
|
+
export class MarkNote {
|
|
629
|
+
@uuid() id!: string;
|
|
630
|
+
@text({ max: 64 }) chart!: string; // which chart: "country-map", "lanes"
|
|
631
|
+
@text({ max: 128 }) markKey!: string; // the mark's model key (CountryCode)
|
|
632
|
+
@text({ max: 128, optional: true }) markKeyTo?: string; // a route's other end; empty for one mark
|
|
633
|
+
@text({ max: 2000 }) body!: string;
|
|
634
|
+
@text({ max: 320 }) author!: string;
|
|
635
|
+
@date() createdAt!: Date;
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
Give every entity an explicit permission decorator (without one, any signed-in user gets every
|
|
640
|
+
action), and every `@text` a `max` (without it MSSQL gets `NVARCHAR(MAX)` columns, which can break
|
|
641
|
+
the deployed GraphQL schema). Use `.js` extensions on relative imports between entity files.
|
|
642
|
+
|
|
643
|
+
*d. Read every note, write one.* `.execute()` returns one page (100 rows) and doesn't say there are
|
|
644
|
+
more, so page through:
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
// src/lib/mark-notes.ts
|
|
648
|
+
import type { MarkAnnotation } from "@bicharts/chart-host";
|
|
649
|
+
import { getRayfinClient } from "@/lib/rayfin-client";
|
|
650
|
+
import type { MarkNote } from "../../rayfin/data/MarkNote";
|
|
651
|
+
|
|
652
|
+
const FIELDS = ["id", "chart", "markKey", "markKeyTo", "body", "author", "createdAt"] as const;
|
|
653
|
+
|
|
654
|
+
export async function loadMarkNotes(): Promise<MarkNote[]> { // newest first
|
|
655
|
+
const all: MarkNote[] = [];
|
|
656
|
+
let after: string | undefined;
|
|
657
|
+
do {
|
|
658
|
+
const q = getRayfinClient().data.MarkNote.select([...FIELDS]).orderBy({ createdAt: "desc" }).first(100);
|
|
659
|
+
const page = await (after ? q.after(after) : q).executePaginated();
|
|
660
|
+
all.push(...page.items);
|
|
661
|
+
after = page.hasNextPage ? page.endCursor ?? undefined : undefined;
|
|
662
|
+
} while (after);
|
|
663
|
+
return all;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function addMarkNote(n: Pick<MarkNote, "chart" | "markKey" | "markKeyTo" | "body">, author: string) {
|
|
667
|
+
return getRayfinClient().data.MarkNote.create({ ...n, author, createdAt: new Date() });
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
In a hook, load inside the effect - `loadMarkNotes().then(all => { if (live) setNotes(all); })` with a
|
|
672
|
+
`live` flag the cleanup clears; the template's lint rejects an effect that calls a state-setting
|
|
673
|
+
function directly. The author is the signed-in user: `useAuth().session?.user?.email`.
|
|
674
|
+
|
|
675
|
+
*e. Badges on the marks: pass `annotations`, never measure the chart.* The host draws each badge on
|
|
676
|
+
its key's mark after every render and keeps it there through filters, re-queries and zooms; it never
|
|
677
|
+
reads a row position. One key column is `column`/`value`. A route is keyed by BOTH of its ends with
|
|
678
|
+
`where` - no key column to add, no query to change and no chart to regenerate:
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
/** One badge per mark: a single key column, or a route's two ends. */
|
|
682
|
+
export function noteBadges(notes: MarkNote[], chart: string, key: [string] | [string, string]): MarkAnnotation[] {
|
|
683
|
+
const byMark = new Map<string, MarkNote[]>();
|
|
684
|
+
for (const n of notes) {
|
|
685
|
+
if (n.chart !== chart) continue;
|
|
686
|
+
const id = n.markKeyTo ? `${n.markKey}|${n.markKeyTo}` : n.markKey;
|
|
687
|
+
byMark.set(id, [...(byMark.get(id) ?? []), n]);
|
|
688
|
+
}
|
|
689
|
+
return [...byMark].map(([id, list]) => ({
|
|
690
|
+
id, label: String(list.length), title: list[0].body,
|
|
691
|
+
...(key.length === 2
|
|
692
|
+
? { where: { [key[0]]: list[0].markKey, [key[1]]: list[0].markKeyTo ?? "" } }
|
|
693
|
+
: { column: key[0], value: list[0].markKey }),
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
```tsx
|
|
699
|
+
<BivariateWorldChoroplethChart table={table} {...size} options={options}
|
|
700
|
+
annotations={noteBadges(notes, "country-map", ["CountryCode"])}
|
|
701
|
+
onAnnotationClick={a => setOpenMark(a.id ?? null)} />
|
|
702
|
+
// a route by both ends: annotations={noteBadges(notes, "lanes", ["OriginCountryCode", "DestinationCountryCode"])},
|
|
703
|
+
// one badge per route, each { where: { OriginCountryCode: n.origin, DestinationCountryCode: n.dest }, label, title }
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Don't read `data-row-idx` or bounding boxes to place anything: that breaks on the next redraw, and
|
|
707
|
+
this does it for you.
|
|
708
|
+
|
|
709
|
+
*f. Open the notes over the page, never inside the chart's card.* Open them from `onAnnotationClick`
|
|
710
|
+
for a mark that has notes, and for a mark's first note from your own "Add a note" action on the
|
|
711
|
+
selected mark (`onSelectRows` gives you its key). Show them in a dialog, drawer or popover that
|
|
712
|
+
overlays the page through a portal: a chart's card has a fixed height and `overflow-hidden`, so a
|
|
713
|
+
panel inside it is clipped.
|
|
714
|
+
|
|
715
|
+
```tsx
|
|
716
|
+
import { useState } from "react";
|
|
717
|
+
import { createPortal } from "react-dom";
|
|
718
|
+
|
|
719
|
+
export function NotesDialog({ title, notes, onAdd, onClose }: {
|
|
720
|
+
title: string; notes: MarkNote[]; onAdd: (body: string) => void; onClose: () => void;
|
|
721
|
+
}) {
|
|
722
|
+
const [draft, setDraft] = useState("");
|
|
723
|
+
return createPortal(
|
|
724
|
+
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000, display: "grid",
|
|
725
|
+
placeItems: "center", background: "rgb(0 0 0 / 0.35)" }}>
|
|
726
|
+
<div role="dialog" aria-modal="true" aria-label={title} onClick={e => e.stopPropagation()}
|
|
727
|
+
style={{ width: "min(480px, 92vw)", maxHeight: "80vh", overflow: "auto", padding: 16,
|
|
728
|
+
borderRadius: 8, background: "Canvas", color: "CanvasText" }}>
|
|
729
|
+
<h2>{title}</h2>
|
|
730
|
+
<ul>{notes.map(n => <li key={n.id}>{n.body} - {n.author}, {new Date(n.createdAt).toLocaleString()}</li>)}</ul>
|
|
731
|
+
<textarea value={draft} onChange={e => setDraft(e.target.value)} maxLength={2000} />
|
|
732
|
+
<button disabled={!draft.trim()} onClick={() => { onAdd(draft.trim()); setDraft(""); }}>Add note</button>
|
|
733
|
+
<button onClick={onClose}>Close</button>
|
|
734
|
+
</div>
|
|
735
|
+
</div>,
|
|
736
|
+
document.body);
|
|
737
|
+
}
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Style it with the app's own tokens. The table and the permissions are created on the next
|
|
741
|
+
`npx rayfin up`, and every later `rayfin up` applies entity changes.
|
|
742
|
+
|
|
538
743
|
**7. A reader's choice inside a chart is view state.** A what-if chart's rate and horizon are its
|
|
539
744
|
own controls, reported through `viewState` on `<BicChart>` (`uiState` / `setUiState` in the
|
|
540
745
|
integration contract). To save a named scenario, store that - don't rebuild the slider beside it.
|
|
541
746
|
|
|
542
|
-
**8. What stays the template's job:** sign-in, the query hooks, the app's database
|
|
543
|
-
|
|
747
|
+
**8. What stays the template's job:** sign-in, the query hooks, the app's database (Rayfin's data
|
|
748
|
+
service - the notes recipe above), `VisualContainer` headers, deployment. The chart is a function you
|
|
749
|
+
render inside them.
|
|
750
|
+
|
|
751
|
+
**9. Check every chart, then deploy.** Check every chart locally with this server's `check_chart` tool,
|
|
752
|
+
at the size the layout gives it - you don't need to deploy to check a chart. Then `npx tsc -b` and the
|
|
753
|
+
tests. Deploying (`npx rayfin up`), on Rayfin 1.36.1:
|
|
754
|
+
- Name the workspace: `npx rayfin up -w "<workspace>"`. Left out, it deploys to My Workspace.
|
|
755
|
+
- The Fabric item is named by `id:` in `rayfin/rayfin.yml`, not `name:`. Two projects with the same
|
|
756
|
+
`id:` in one workspace deploy into one item: give each its own `id:`, or deploy with
|
|
757
|
+
`--item-name <unique-name>`. Pass `--yes` to reuse an existing item only when you know it's yours.
|
|
758
|
+
- Deleted the app outside Rayfin? `rayfin/.deployments.json` still names the deleted item, so the next
|
|
759
|
+
`rayfin up` fails with 404 Not Found. Move that file aside and deploy again.
|
|
544
760
|
|
|
545
761
|
## 4. Things that will otherwise cost you time
|
|
546
762
|
|