@bicharts/chart-mcp 0.6.57 → 0.6.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-mcp",
3
- "version": "0.6.57",
3
+ "version": "0.6.58",
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). An account pays to author charts; the code it writes is yours and runs with no licence.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -43,8 +43,8 @@
43
43
  },
44
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.",
45
45
  "devDependencies": {
46
- "@bicharts/chart-host": "^0.6.56",
47
- "@bicharts/shape-core": "^0.6.56",
46
+ "@bicharts/chart-host": "^0.6.58",
47
+ "@bicharts/shape-core": "^0.6.58",
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",
49
49
  "@types/node": "^20.0.0",
50
50
  "@types/papaparse": "^5.3.14",
@@ -468,7 +468,9 @@ Add `@using MyCharts.Components` to `_Imports.razor`, then on a page:
468
468
 
469
469
  A Fabric App built from Rayfin's `dataapp` template is a React app over a semantic model. The golden
470
470
  path above applies unchanged; these are the places a data app differs, each one measured on a real
471
- build.
471
+ build. Everything here layers on the template's own AGENTS.md and skills - follow them for everything
472
+ this doesn't cover. The template's visuals skill asks before using another chart library: a request
473
+ for RegiaBI charts is that consent.
472
474
 
473
475
  **0. Once, right after scaffolding: `setup_fabric_app`.** It installs `@bicharts/chart-host` and `d3`, adds
474
476
  `src/lib/bic-chart-options.ts` (`useBicChartOptions()`: the app's Fabric theme - palette, foreground,
@@ -479,9 +481,14 @@ npm's latest, the release `rayfin init` installs, so run it before `rayfin init`
479
481
  (`rayfin init` installs the latest of only some packages, which leaves two copies of `rayfin-auth` on any other version).
480
482
  Its reply lists every `package.json` range it changed and names the Rayfin version installed; an app already on npm's
481
483
  latest is left alone. It fixes the template's own `scripts/validate-visual.spec.mjs`, which fails under ESM on a fresh
482
- scaffold (its reply says so), and adds
484
+ scaffold (its reply says so). It reads the theme through `src/lib/bic-css-theme.ts` - Microsoft's own `useCssTheme`
485
+ logic on `@microsoft/fabric-visuals-core` - and points the template's `src/main.tsx` at it, because importing the hook
486
+ from `@microsoft/fabric-visuals` bundles Vega into an app that doesn't chart with it (about half its download); the
487
+ reply names the one line it changed. It adds
483
488
  `.agents/skills/regiabi-charts/SKILL.md` beside the template's skills, for agents that read that folder. It never
484
- overwrites a file, and its reply ends with a worked example.
489
+ overwrites a file, and its reply ends with a worked example. Building notes on marks or saved scenarios? Once Rayfin's
490
+ data service is on (the notes recipe below, step a), run it again with `features: ["notes", "scenarios"]`: it writes
491
+ those files finished.
485
492
 
486
493
  **1. Pull each chart's rows at the chart's GRAIN, with a query written for that chart.** One row per
487
494
  country for a country map, one per route for a flow map, one per month for a trend. The template's
@@ -496,15 +503,15 @@ simply offered other charts. Save the result to a file and pass `csv_path`, or p
496
503
  `data`.
497
504
 
498
505
  **3. Rename the query's columns to plain names first, and keep the map.** Results come back as
499
- `DimCountry[CountryCode]` and `[Revenue per Capita]`. Generate on `CountryCode` and
500
- `RevenuePerCapita`, and apply the SAME rename to the live rows at run time - the chart reads columns by
506
+ `Geography[CountryIso3]` and `[Average Daily Rate]`. Generate on `CountryIso3` and
507
+ `AverageDailyRate`, and apply the SAME rename to the live rows at run time - the chart reads columns by
501
508
  name. Pass the model's metadata as you rename: `isMeasure`, `format` and `description` (from
502
509
  `INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`) go on each column in `data`, which is the context a
503
510
  Power BI visual gets for free. In `INFO.VIEW.COLUMNS()` a column's name is `[Name]`, not `[Column]`.
504
511
 
505
512
  **Steps 1-3 are one call: `query_semantic_model`.** Give it the chart's DAX (`dax`, or a `.dax` file) and a
506
513
  `name`; it runs the query through the project's own `fabric-app-data` CLI, renames the headers (dropping the
507
- table and any symbols: `[Return Rate %]` becomes `ReturnRate`), saves every row to `.bic/data/<name>.csv`,
514
+ table and any symbols: `[Occupancy %]` becomes `Occupancy`), saves every row to `.bic/data/<name>.csv`,
508
515
  reads each column's role, format and description from the model (`INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`),
509
516
  and profiles the result. Its reply ends with the arguments for the next two calls - `csv_path`, `measures`,
510
517
  `dimensions`, `formats`, `descriptions` - so pass them to `list_eligible_charts` and `generate_chart` as they
@@ -517,14 +524,19 @@ without naming:
517
524
 
518
525
  | the request sounds like | ask for |
519
526
  | --- | --- |
520
- | "compare A with B by country" | Bivariate World Choropleth |
521
- | "the shipping lanes / flows between countries" | Origin-Destination Flow Map |
522
- | "a projection I can steer with a growth rate, against our target" | What-if projection - the rate and horizon sliders are drawn inside the chart; there is no slider to build |
523
- | "how far apart the outcomes of several growth rates land" | What-if scenarios |
527
+ | two measures compared across countries or states | Bivariate World (or USA) Choropleth |
528
+ | movement, routes or flows between places | Origin-Destination Flow Map |
529
+ | a forecast the reader steers with an assumption (a rate, a price change), against a goal | What-if projection - its sliders are drawn inside the chart; there is no slider to build |
530
+ | how far apart the outcomes of several assumptions land | What-if scenarios |
524
531
 
525
532
  If the list doesn't offer what you expected, read its notes: it names a type refused on a count
526
533
  when your table is small, and it says when `name_filter` hid description matches.
527
534
 
535
+ A chart that draws its own rate control needs the history and the goal, not the model's what-if parameter:
536
+ leave the parameter's table (a `'Price Change'[Price Change]` column, say) out of its query. Grouping by it repeats
537
+ every period once per parameter value - 12 months by 17 values is 204 rows for 12 points - and the chart's own
538
+ slider sets the rate anyway.
539
+
528
540
  **5. Render live rows through the group, with the binding the generation returned.** The template
529
541
  queries at run time (`useSemanticModelQuery`), so the payload is rebuilt from live rows every time -
530
542
  never hand-built. **With `out_dir`, a D3 chart arrives with `chart.component.tsx` beside `chart.js`: a component that
@@ -534,17 +546,18 @@ headers onto the chart's plain names. Use it as it is:
534
546
 
535
547
  ```tsx
536
548
  // The component is named after the chart type ("Bivariate World Choropleth" -> BivariateWorldChoroplethChart).
537
- import { BivariateWorldChoroplethChart, rowsFromTable } from "@/charts/country-map/chart.component";
549
+ import { BivariateWorldChoroplethChart, rowsFromTable } from "@/charts/occupancy-map/chart.component";
538
550
 
539
551
  import { BicChartPanel } from "@/components/bic-chart-panel";
540
552
  import { useBicChartOptions } from "@/lib/bic-chart-options";
553
+ import QUERY from "@/queries/overview/occupancy-map.dax?raw"; // the template's rule: DAX lives in .dax files
541
554
 
542
555
  const result = useSemanticModelQuery({ connection: "<alias>", query: QUERY }); // the DAX it was generated on
543
556
  const options = useBicChartOptions();
557
+ const country = useBicFilter("CountryIso3", { label: "Country", display: "CountryName" }); // step 6
544
558
  <BicChartPanel result={result}>
545
559
  {(table, size) => ( // table = the live { columns, rows } result
546
- <BivariateWorldChoroplethChart table={table} {...size} options={options}
547
- onSelectRows={picked => onPick(picked[0]?.CountryCode ?? null)} />
560
+ <BivariateWorldChoroplethChart table={table} {...size} options={options} selects={country} />
548
561
  )}
549
562
  </BicChartPanel>
550
563
  ```
@@ -555,13 +568,13 @@ host by hand instead - `generate_chart` returns `binding`; pass all three:
555
568
  ```tsx
556
569
  import * as d3 from "d3";
557
570
  import { BicChart, BicChartGroup } from "@bicharts/chart-host/react";
558
- import code from "@/charts/country-map/chart.js?raw";
571
+ import code from "@/charts/occupancy-map/chart.js?raw";
559
572
  // Saved from the generate_chart result: columns = data.columns, binding = binding, geoKind = geo.kind.
560
- import { columns, binding, geoKind } from "@/charts/country-map/meta";
573
+ import { columns, binding, geoKind } from "@/charts/occupancy-map/meta";
561
574
 
562
575
  // The SAME rename the chart was generated on.
563
- const RENAME = { "DimCountry[CountryCode]": "CountryCode", "DimCountry[Country]": "Country",
564
- "[Revenue per Capita]": "RevenuePerCapita", "[Return Rate %]": "ReturnRate" };
576
+ const RENAME = { "Geography[CountryIso3]": "CountryIso3", "Geography[CountryName]": "CountryName",
577
+ "[Average Daily Rate]": "AverageDailyRate", "[Occupancy %]": "Occupancy" };
565
578
 
566
579
  function CountryMap({ table, onPick }) { // table = the live query result
567
580
  const rows = table.rows.map(r => Object.fromEntries(
@@ -571,7 +584,7 @@ function CountryMap({ table, onPick }) { // table = the live query resu
571
584
  geo={binding.geo} point={binding.point} destination={binding.destination}>
572
585
  <BicChart id="map" code={code} d3={d3} geoKind={geoKind}
573
586
  options={{ width, height }}
574
- onSelect={idxs => onPick(idxs.length ? rows[idxs[0]].CountryCode : null)} />
587
+ onSelect={idxs => onPick(idxs.length ? rows[idxs[0]].CountryIso3 : null)} />
575
588
  </BicChartGroup>
576
589
  );
577
590
  }
@@ -587,7 +600,9 @@ function CountryMap({ table, onPick }) { // table = the live query resu
587
600
  **Layout: a chart is never clipped or squashed.** Charts sit in cards, and a card is where they get cut off.
588
601
  - Render every chart inside `BicChartPanel`. It sizes the chart from its cell, never from the chart.
589
602
  - Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map about 560 x 300; a time series wants
590
- height). Below that the panel scrolls; it never squashes or clips the chart.
603
+ height). Below that the panel scrolls; it never squashes or clips the chart. That scroll is a safety net, not a layout:
604
+ inside a page that already scrolls it's a second scrollbar, which reads as broken - size every chart's cell at or above
605
+ its minimum so it never shows.
591
606
  - Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
592
607
  minimum.
593
608
  - 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
@@ -595,15 +610,60 @@ function CountryMap({ table, onPick }) { // table = the live query resu
595
610
  - A chart with controls drawn inside it (sliders, steppers, a reset) gets its card's full width. Put a side panel that
596
611
  goes with it - saved scenarios, notes, a form - below the chart, not beside it: beside it, the chart's cell drops
597
612
  under its minimum and the panel scrolls.
598
- - When you stack a panel below a chart, don't keep a fixed card height that can't hold both: let the card grow (a
599
- `min-h-*` in place of its `h-*`), or give the panel below `min-h-0 flex-1 overflow-auto` so it scrolls inside the card.
613
+ - When you stack a panel below a chart, don't keep a fixed card height that can't hold both. Prefer letting the card grow
614
+ (a `min-h-*` in place of its `h-*`); only where the card must stay fixed, give the panel below `min-h-0 flex-1
615
+ overflow-auto` so it scrolls inside the card. The chart's own cell keeps a DEFINITE height (as the template's
616
+ app-validation skill asks); only the card around it and the panel below may grow.
617
+ - One frame per chart: a RegiaBI chart sits in your card. The template's `VisualContainer` frames its `VegaVisual`
618
+ charts; don't put a second frame around one of ours.
600
619
  Give the chart's cell at least the chart's `minChartHeight` (`h-72` is 288px, under a 300 minimum).
601
620
 
602
- **6. Cross-filter across queries by KEY, not by row.** Charts fed by different queries don't share a
603
- row space, so one group can't span them. Put each query's charts in their own group, turn a click
604
- into the model's key (`CountryCode` above), keep it in app state, and filter the other queries with
605
- it. Anything saved against a mark - a note, a flag - is keyed the same way, never by `__rowIdx__`
606
- (see the row-index hazard above).
621
+ **6. Filter the page with `useBicFilter` - never write a select handler, a toggle or a Clear.** A page filter is
622
+ kept by the model's KEY (`CountryIso3`; a route's two ends), never by a row position, so it works across charts fed by
623
+ different queries and survives a re-query. Hand it to the chart that sets it with `selects`; the rest of the page
624
+ reads it:
625
+
626
+ ```tsx
627
+ import { useBicFilter, BicFilterChips } from "@bicharts/chart-host/react";
628
+ import { filterQuery, treatAs } from "@bicharts/chart-host/dax";
629
+ import FORECAST_DAX from "@/queries/overview/bookings-forecast.dax?raw";
630
+
631
+ // Once, in the page component, and passed down: each useBicFilter call is its own filter.
632
+ const country = useBicFilter("CountryIso3", { label: "Country", display: "CountryName" });
633
+ const route = useBicFilter(["OriginCode", "DestinationCode"],
634
+ { label: "Route", display: ["OriginName", "DestinationName"] });
635
+ const forecast = useSemanticModelQuery({ connection: "<alias>",
636
+ query: filterQuery(FORECAST_DAX, treatAs(country, "Geography[CountryIso3]")) });
637
+
638
+ <BicFilterChips /> {/* "Country: Japan ×" - what's filtered, and its clear */}
639
+ <BivariateWorldChoroplethChart table={countries} {...size} options={options} selects={country} />
640
+ <OriginDestinationFlowMapChart table={routes} {...size} options={options} selects={route}
641
+ filterBy={{ filter: country, columns: ["OriginCode", "DestinationCode"] }} />
642
+ ```
643
+
644
+ What the library does, so the page doesn't:
645
+ - A click sets the filter. The same click again, or a click on empty canvas, clears it. Ctrl-click adds a key.
646
+ - `country.clear()` (a button, a chip's ×) clears the filter AND the chart's marks.
647
+ - New rows keep a selection whose key is still drawn and clear one whose key is gone - reported, never silent.
648
+ - `country.value`, `country.values`, `country.keys`, `country.row` (the clicked row), `country.active` and
649
+ `country.text` ("Japan", or "Spain → Portugal" for a route) are what the page reads. For any OTHER key - a badge's,
650
+ a lane's end - `country.textOf(key, rows)` names it from a table that carries the display column.
651
+ - Another chart whose table carries the key: `filterBy={{ filter: country, columns: [...] }}` on its component - any
652
+ of several columns, for a route that starts or ends there. It's applied inside the component, so the chart redraws
653
+ only when the filter or its data changes. A table WITHOUT the key column can't be filtered that way (it warns): filter
654
+ its query instead.
655
+ - A query: `filterQuery(dax, treatAs(country, "Table[Column]"))` puts the filter into the `.dax` file's query the way
656
+ Microsoft's own data app skills do (CALCULATETABLE + TREATAS), escaped; with nothing selected the query is unchanged.
657
+ It adds a filter value and never changes the query's structure, so one `.dax` file serves both. A route filter maps
658
+ each end: `treatAs(route, "Routes[Origin]", "Routes[Destination]")`; `treatAs(route, "Geography[CountryIso3]")` takes
659
+ the route's FIRST column (its origin). Two filters on one column intersect: pass the one that should win.
660
+
661
+ A Microsoft `VegaVisual` or `DataGrid` on the same page joins the same filter:
662
+ `onInteraction={e => fromVegaInteraction(country, e)}` (`fromVegaInteraction` from `@bicharts/chart-host/react`; a page without React takes all of these from `@bicharts/chart-host/page`). It matches
663
+ the event's field NAMES to the filter's columns, so name the VegaVisual's column `CountryIso3` too. It's one way: a
664
+ page Clear clears the filter, but the VegaVisual keeps its own highlight until it's clicked - that chart owns it. If you take clicks yourself with `onSelectRows` instead, an
665
+ empty list means the selection was CLEARED: set your state from exactly what it gives you and never toggle it again.
666
+ Anything saved against a mark - a note, a flag - is keyed the same way, never by `__rowIdx__`.
607
667
 
608
668
  **Notes (or flags) on marks, end to end.** People leave a note on a country, a route, a product, and see
609
669
  it on that mark: the notes live in Rayfin's data service, and the badge is the chart's `annotations`.
@@ -639,138 +699,76 @@ Check with `npx tsc -b`; a bare `tsc --noEmit` reports TS6305 until the `rayfin/
639
699
  TS2345 names two copies of `rayfin-auth`, something installed only some Rayfin packages: run
640
700
  `setup_fabric_app` again.
641
701
 
642
- *c. The entity, keyed by the model's key - never by a row position.* `rayfin/data/MarkNote.ts`, then
643
- register it in `rayfin/data/schema.ts` (`export type AppSchema = { MarkNote: MarkNote };`):
644
-
645
- ```ts
646
- import { entity, authenticated, uuid, text, date } from '@microsoft/rayfin-core';
647
-
648
- @entity()
649
- @authenticated(['read', 'create'])
650
- export class MarkNote {
651
- @uuid() id!: string;
652
- @text({ max: 64 }) chart!: string; // which chart: "country-map", "lanes"
653
- @text({ max: 128 }) markKey!: string; // the mark's model key (CountryCode)
654
- @text({ max: 128, optional: true }) markKeyTo?: string; // a route's other end; empty for one mark
655
- @text({ max: 2000 }) body!: string;
656
- @text({ max: 320 }) author!: string;
657
- @date() createdAt!: Date;
658
- }
659
- ```
702
+ *c. The files: `setup_fabric_app` with `features: ["notes"]`.* With the data service on, it writes them finished and
703
+ never rewrites them:
704
+ - `rayfin/data/MarkNote.ts`, registered in `rayfin/data/schema.ts` - keyed by the mark's model key, never a row
705
+ position: `chart`, `markKey`, `markKeyTo` (a route's other end), `body`, `author`, `createdAt`. Every entity it
706
+ writes has an explicit permission decorator and a `max` on every `@text` (MSSQL gets `NVARCHAR(MAX)` without one,
707
+ which can break the deployed GraphQL schema).
708
+ - `src/lib/bic-notes.ts`: `useMarkNotes(author)` - every note (paged with `.executePaginated()`: one query returns at
709
+ most 100 and doesn't say there are more), an `error` when they can't load, and `add(chart, key, body)`, which saves
710
+ with `.create(` and reloads, and REJECTS when the save fails. The author is the signed-in user:
711
+ `useAuth().session?.user?.email`.
712
+ - `src/components/bic-notes-dialog.tsx`: `NotesDialog`, over the page through `createPortal`, never inside the
713
+ chart's card (a card has a fixed height and `overflow-hidden`, and clips it). The reader's text is cleared only once
714
+ the save succeeds; a failure is shown with the text kept.
715
+
716
+ *d. Badges on the marks: pass `annotations`, never measure the chart.* The host draws each badge on its key's mark
717
+ after every render and keeps it there through filters, re-queries and zooms; it never reads a row position. A route is
718
+ keyed by BOTH of its ends with `where` - no key column to add, no query to change and no chart to regenerate:
660
719
 
661
- Give every entity an explicit permission decorator (without one, any signed-in user gets every
662
- action), and every `@text` a `max` (without it MSSQL gets `NVARCHAR(MAX)` columns, which can break
663
- the deployed GraphQL schema). Use `.js` extensions on relative imports between entity files.
664
-
665
- *d. Read every note, write one.* `.execute()` returns one page (100 rows) and doesn't say there are
666
- more, so page through:
667
-
668
- ```ts
669
- // src/lib/mark-notes.ts
670
- import type { MarkAnnotation } from "@bicharts/chart-host";
671
- import { getRayfinClient } from "@/lib/rayfin-client";
672
- import type { MarkNote } from "../../rayfin/data/MarkNote";
673
-
674
- const FIELDS = ["id", "chart", "markKey", "markKeyTo", "body", "author", "createdAt"] as const;
675
-
676
- export async function loadMarkNotes(): Promise<MarkNote[]> { // newest first
677
- const all: MarkNote[] = [];
678
- let after: string | undefined;
679
- do {
680
- const q = getRayfinClient().data.MarkNote.select([...FIELDS]).orderBy({ createdAt: "desc" }).first(100);
681
- const page = await (after ? q.after(after) : q).executePaginated();
682
- all.push(...page.items);
683
- after = page.hasNextPage ? page.endCursor ?? undefined : undefined;
684
- } while (after);
685
- return all;
686
- }
720
+ ```tsx
721
+ import { noteBadges, notesFor, badgeKey } from "@bicharts/chart-host/react";
687
722
 
688
- export function addMarkNote(n: Pick<MarkNote, "chart" | "markKey" | "markKeyTo" | "body">, author: string) {
689
- return getRayfinClient().data.MarkNote.create({ ...n, author, createdAt: new Date() });
690
- }
691
- ```
723
+ import { useAuth } from "@/hooks/auth.context";
692
724
 
693
- In a hook, load inside the effect - `loadMarkNotes().then(all => { if (live) setNotes(all); })` with a
694
- `live` flag the cleanup clears; the template's lint rejects an effect that calls a state-setting
695
- function directly. The author is the signed-in user: `useAuth().session?.user?.email`.
696
-
697
- *e. Badges on the marks: pass `annotations`, never measure the chart.* The host draws each badge on
698
- its key's mark after every render and keeps it there through filters, re-queries and zooms; it never
699
- reads a row position. One key column is `column`/`value`. A route is keyed by BOTH of its ends with
700
- `where` - no key column to add, no query to change and no chart to regenerate:
701
-
702
- ```ts
703
- /** One badge per mark: a single key column, or a route's two ends. */
704
- export function noteBadges(notes: MarkNote[], chart: string, key: [string] | [string, string]): MarkAnnotation[] {
705
- const byMark = new Map<string, MarkNote[]>();
706
- for (const n of notes) {
707
- if (n.chart !== chart) continue;
708
- const id = n.markKeyTo ? `${n.markKey}|${n.markKeyTo}` : n.markKey;
709
- byMark.set(id, [...(byMark.get(id) ?? []), n]);
710
- }
711
- return [...byMark].map(([id, list]) => ({
712
- id, label: String(list.length), title: list[0].body,
713
- ...(key.length === 2
714
- ? { where: { [key[0]]: list[0].markKey, [key[1]]: list[0].markKeyTo ?? "" } }
715
- : { column: key[0], value: list[0].markKey }),
716
- }));
717
- }
718
- ```
725
+ const { notes, add } = useMarkNotes(useAuth().session?.user?.email);
726
+ <BivariateWorldChoroplethChart table={table} {...size} options={options} selects={country}
727
+ annotations={noteBadges(notes, { chart: "occupancy-map", columns: "CountryIso3" })}
728
+ onAnnotationClick={a => setOpen({ chart: "occupancy-map", columns: "CountryIso3", key: badgeKey(a) })} />
729
+ // a route by both ends: noteBadges(notes, { chart: "routes", columns: ["OriginCode", "DestinationCode"] })
730
+ // gives each route { where: { OriginCode: "ESP", DestinationCode: "PRT" }, label, title }
719
731
 
720
- ```tsx
721
- <BivariateWorldChoroplethChart table={table} {...size} options={options}
722
- annotations={noteBadges(notes, "country-map", ["CountryCode"])}
723
- onAnnotationClick={a => setOpenMark(a.id ?? null)} />
724
- // a route by both ends: annotations={noteBadges(notes, "lanes", ["OriginCountryCode", "DestinationCountryCode"])},
725
- // one badge per route, each { where: { OriginCountryCode: n.origin, DestinationCountryCode: n.dest }, label, title }
732
+ {open && <NotesDialog title={country.textOf(open.key, countryRows)} onClose={() => setOpen(null)}
733
+ notes={notesFor(notes, open.key, { chart: open.chart, columns: open.columns })}
734
+ onAdd={body => add(open.chart, open.key, body)} />}
735
+ // open = { chart, columns, key }: keep the chart's key columns with it - a route's notes need both ends.
726
736
  ```
727
737
 
728
- Don't read `data-row-idx` or bounding boxes to place anything: that breaks on the next redraw, and
729
- this does it for you.
738
+ Open a mark's notes from `onAnnotationClick` (`badgeKey(a)` is its key), and its first note from your own "Add a
739
+ note" action on the selected mark - `country.keys[0]` is its key, `country.text` its name. Don't read `data-row-idx`
740
+ or bounding boxes to place anything: that breaks on the next redraw, and this does it for you.
730
741
 
731
- *f. Open the notes over the page, never inside the chart's card.* Open them from `onAnnotationClick`
732
- for a mark that has notes, and for a mark's first note from your own "Add a note" action on the
733
- selected mark (`onSelectRows` gives you its key). Show them in a dialog, drawer or popover that
734
- overlays the page through a portal: a chart's card has a fixed height and `overflow-hidden`, so a
735
- panel inside it is clipped.
742
+ The files use the template's own tokens; restyle them as you like. The table and the permissions are created on
743
+ the next `npx rayfin up`, and every later `rayfin up` applies entity changes.
736
744
 
737
- ```tsx
738
- import { useState } from "react";
739
- import { createPortal } from "react-dom";
740
-
741
- export function NotesDialog({ title, notes, onAdd, onClose }: {
742
- title: string; notes: MarkNote[]; onAdd: (body: string) => void; onClose: () => void;
743
- }) {
744
- const [draft, setDraft] = useState("");
745
- return createPortal(
746
- <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000, display: "grid",
747
- placeItems: "center", background: "rgb(0 0 0 / 0.35)" }}>
748
- <div role="dialog" aria-modal="true" aria-label={title} onClick={e => e.stopPropagation()}
749
- style={{ width: "min(480px, 92vw)", maxHeight: "80vh", overflow: "auto", padding: 16,
750
- borderRadius: 8, background: "Canvas", color: "CanvasText" }}>
751
- <h2>{title}</h2>
752
- <ul>{notes.map(n => <li key={n.id}>{n.body} - {n.author}, {new Date(n.createdAt).toLocaleString()}</li>)}</ul>
753
- <textarea value={draft} onChange={e => setDraft(e.target.value)} maxLength={2000} />
754
- <button disabled={!draft.trim()} onClick={() => { onAdd(draft.trim()); setDraft(""); }}>Add note</button>
755
- <button onClick={onClose}>Close</button>
756
- </div>
757
- </div>,
758
- document.body);
759
- }
760
- ```
745
+ Don't stand up a backend to test notes; the deploy creates the tables. Finish with `npx tsc -b` and the tests, and say the notes go live on deploy.
761
746
 
762
- Style it with the app's own tokens. The table and the permissions are created on the next
763
- `npx rayfin up`, and every later `rayfin up` applies entity changes.
747
+ **7. A chart's own controls are page state: `useBicControls`.** A What-if chart's sliders (a rate, a horizon) are drawn
748
+ inside the chart - build no slider beside it. Give the chart `controls` and read them on the page:
764
749
 
765
- Don't stand up a backend to test notes; the deploy creates the tables. Finish with `npx tsc -b` and the tests, and say the notes go live on deploy.
750
+ ```tsx
751
+ import { useBicControls } from "@bicharts/chart-host/react";
752
+ import { ScenariosPanel } from "@/components/bic-scenarios-panel"; // setup_fabric_app features: ["scenarios"]
753
+
754
+ const scenario = useBicControls();
755
+ <WhatIfProjectionChart table={table} {...size} options={options} controls={scenario} />
756
+ <ScenariosPanel chart="bookings-forecast" controls={scenario} author={useAuth().session?.user?.email} />
757
+ ```
766
758
 
767
- **7. A reader's choice inside a chart is view state.** A what-if chart's rate and horizon are its
768
- own controls, reported through `viewState` on `<BicChart>` (`uiState` / `setUiState` in the
769
- integration contract). To save a named scenario, store that - don't rebuild the slider beside it.
759
+ `scenario.values` holds every slider's value from the first draw - no slider has to move before a scenario can be
760
+ saved - and `scenario.summary` is the chart's own label and readout for each ("price change per month +2.0% ·
761
+ horizon 12 months"), so a saved scenario shows the unit the chart shows. `scenario.set(saved)` puts one back; `scenario.reset()`
762
+ returns to the chart's defaults. Don't read `uiState.knobs` or the chart's source, and never save a default you made
763
+ up. `features: ["scenarios"]` writes the `SavedScenario` entity, `useSavedScenarios` and `ScenariosPanel` (save under a
764
+ name, list, apply, and which one is applied now - `applied`, the saved scenario whose values the chart shows; a failed
765
+ save keeps what was typed).
770
766
 
771
- **8. What stays the template's job:** sign-in, the query hooks, the app's database (Rayfin's data
772
- service - the notes recipe above), `VisualContainer` headers, deployment. The chart is a function you
773
- render inside them.
767
+ **8. What stays the template's job - this is additive, never a replacement:** sign-in, the query hooks
768
+ (`useSemanticModelQuery`; a page filter feeds it through `filterQuery`), the theme (`useCssTheme`, which
769
+ `bic-chart-options.ts` builds on), the app's database (Rayfin's data service), `VisualContainer` headers, the template's
770
+ own `VegaVisual` charts and `DataGrid` (which join a page filter through `fromVegaInteraction`), its skills, and
771
+ deployment. The chart is a function you render inside them.
774
772
 
775
773
  **9. Check every chart, then deploy.** Check every chart locally with this server's `check_chart` tool,
776
774
  at the size the panel will give it - by default it reads the chart's `minChartWidth` and `minChartHeight`
@@ -778,7 +776,11 @@ off its `BicChartPanel`, so pass no size unless you mean another one. A `WARNING
778
776
  label cut off at that size: deal with it before you call the chart done. The reply says whether more room fixes it.
779
777
  Never edit the generated chart code by hand: if something's wrong with a generated chart, regenerate it
780
778
  (`generate_chart` with a prompt describing the fix) or report it.
781
- You don't need to deploy to check a chart. Then `npx tsc -b` and the tests. Deploying (`npx rayfin up`), on Rayfin 1.36.1:
779
+ You don't need to deploy to check a chart. The template's `validate:visual` checks `VegaVisual` factories; a
780
+ RegiaBI chart's check is `check_chart` - don't list it there. And `check_chart` checks the chart, not the app: the
781
+ template's app-validation in the portal embed still applies. Then `npx tsc -b` and the tests. To test the page's behaviour,
782
+ `@bicharts/chart-host/testing` has a reader's gestures - `clickMark`, `clickEmpty`, `selectedRows` - for the checks
783
+ worth keeping: a second click on a mark clears its filter, and a page Clear clears the chart's marks. Deploying (`npx rayfin up`), on Rayfin 1.36.1:
782
784
  - Signed in? `npx rayfin login status` (a subcommand, not a `--status` flag); `npx rayfin login` if not.
783
785
  - Name the workspace: `npx rayfin up -w "<workspace>"`. Left out, it deploys to My Workspace.
784
786
  - The Fabric item is named by `id:` in `rayfin/rayfin.yml`, not `name:`. Two projects with the same