@bicharts/chart-mcp 0.6.57 → 0.6.59
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 +70 -70
- package/dist/index.mjs +70 -61
- package/package.json +3 -3
- package/skills/bic-charts/SKILL.md +159 -147
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.59",
|
|
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.
|
|
47
|
-
"@bicharts/shape-core": "^0.6.
|
|
46
|
+
"@bicharts/chart-host": "^0.6.59",
|
|
47
|
+
"@bicharts/shape-core": "^0.6.59",
|
|
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)
|
|
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
|
-
`
|
|
500
|
-
`
|
|
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: `[
|
|
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
|
-
|
|
|
521
|
-
|
|
|
522
|
-
|
|
|
523
|
-
|
|
|
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/
|
|
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/
|
|
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/
|
|
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 = { "
|
|
564
|
-
"[
|
|
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]].
|
|
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,70 @@ 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
|
|
599
|
-
`min-h-*` in place of its `h-*`),
|
|
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.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
it
|
|
606
|
-
|
|
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__`.
|
|
667
|
+
|
|
668
|
+
More the page gets from the same filters, each one line:
|
|
669
|
+
- A link that opens the page as it was: `useBicUrlState()` in the page component (after its `useBicFilter` /
|
|
670
|
+
`useBicControls` calls) keeps every filter and every chart's controls in `?view=`. `useBicView()` gives `save()` /
|
|
671
|
+
`restore(view)` for a saved view of your own (a user's favourite), and `token()` for a link.
|
|
672
|
+
- Linked hover: `useBicFilter("CountryIso3", { hover: true })` - hovering a mark lights the same key's marks on every
|
|
673
|
+
chart bound to that filter (a glow, never a selection).
|
|
674
|
+
- A filter that narrows a query to nothing: `<BicChartPanel ... onClearFilters={country.clear}>` says so with a Clear,
|
|
675
|
+
instead of an empty chart. A chart's own empty message: `emptyText` on its component.
|
|
676
|
+
- While building: `<BicDevtools />` shows every filter, its keys, why it last changed, and each chart's controls.
|
|
607
677
|
|
|
608
678
|
**Notes (or flags) on marks, end to end.** People leave a note on a country, a route, a product, and see
|
|
609
679
|
it on that mark: the notes live in Rayfin's data service, and the badge is the chart's `annotations`.
|
|
@@ -639,138 +709,76 @@ Check with `npx tsc -b`; a bare `tsc --noEmit` reports TS6305 until the `rayfin/
|
|
|
639
709
|
TS2345 names two copies of `rayfin-auth`, something installed only some Rayfin packages: run
|
|
640
710
|
`setup_fabric_app` again.
|
|
641
711
|
|
|
642
|
-
*c. The
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
```
|
|
712
|
+
*c. The files: `setup_fabric_app` with `features: ["notes"]`.* With the data service on, it writes them finished and
|
|
713
|
+
never rewrites them:
|
|
714
|
+
- `rayfin/data/MarkNote.ts`, registered in `rayfin/data/schema.ts` - keyed by the mark's model key, never a row
|
|
715
|
+
position: `chart`, `markKey`, `markKeyTo` (a route's other end), `body`, `author`, `createdAt`. Every entity it
|
|
716
|
+
writes has an explicit permission decorator and a `max` on every `@text` (MSSQL gets `NVARCHAR(MAX)` without one,
|
|
717
|
+
which can break the deployed GraphQL schema).
|
|
718
|
+
- `src/lib/bic-notes.ts`: `useMarkNotes(author)` - every note (paged with `.executePaginated()`: one query returns at
|
|
719
|
+
most 100 and doesn't say there are more), an `error` when they can't load, and `add(chart, key, body)`, which saves
|
|
720
|
+
with `.create(` and reloads, and REJECTS when the save fails. The author is the signed-in user:
|
|
721
|
+
`useAuth().session?.user?.email`.
|
|
722
|
+
- `src/components/bic-notes-dialog.tsx`: `NotesDialog`, over the page through `createPortal`, never inside the
|
|
723
|
+
chart's card (a card has a fixed height and `overflow-hidden`, and clips it). The reader's text is cleared only once
|
|
724
|
+
the save succeeds; a failure is shown with the text kept.
|
|
725
|
+
|
|
726
|
+
*d. Badges on the marks: pass `annotations`, never measure the chart.* The host draws each badge on its key's mark
|
|
727
|
+
after every render and keeps it there through filters, re-queries and zooms; it never reads a row position. A route is
|
|
728
|
+
keyed by BOTH of its ends with `where` - no key column to add, no query to change and no chart to regenerate:
|
|
660
729
|
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
}
|
|
730
|
+
```tsx
|
|
731
|
+
import { noteBadges, notesFor, badgeKey } from "@bicharts/chart-host/react";
|
|
687
732
|
|
|
688
|
-
|
|
689
|
-
return getRayfinClient().data.MarkNote.create({ ...n, author, createdAt: new Date() });
|
|
690
|
-
}
|
|
691
|
-
```
|
|
733
|
+
import { useAuth } from "@/hooks/auth.context";
|
|
692
734
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
```
|
|
735
|
+
const { notes, add } = useMarkNotes(useAuth().session?.user?.email);
|
|
736
|
+
<BivariateWorldChoroplethChart table={table} {...size} options={options} selects={country}
|
|
737
|
+
annotations={noteBadges(notes, { chart: "occupancy-map", columns: "CountryIso3" })}
|
|
738
|
+
onAnnotationClick={a => setOpen({ chart: "occupancy-map", columns: "CountryIso3", key: badgeKey(a) })} />
|
|
739
|
+
// a route by both ends: noteBadges(notes, { chart: "routes", columns: ["OriginCode", "DestinationCode"] })
|
|
740
|
+
// gives each route { where: { OriginCode: "ESP", DestinationCode: "PRT" }, label, title }
|
|
719
741
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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 }
|
|
742
|
+
{open && <NotesDialog title={country.textOf(open.key, countryRows)} onClose={() => setOpen(null)}
|
|
743
|
+
notes={notesFor(notes, open.key, { chart: open.chart, columns: open.columns })}
|
|
744
|
+
onAdd={body => add(open.chart, open.key, body)} />}
|
|
745
|
+
// open = { chart, columns, key }: keep the chart's key columns with it - a route's notes need both ends.
|
|
726
746
|
```
|
|
727
747
|
|
|
728
|
-
|
|
729
|
-
|
|
748
|
+
Open a mark's notes from `onAnnotationClick` (`badgeKey(a)` is its key), and its first note from your own "Add a
|
|
749
|
+
note" action on the selected mark - `country.keys[0]` is its key, `country.text` its name. Don't read `data-row-idx`
|
|
750
|
+
or bounding boxes to place anything: that breaks on the next redraw, and this does it for you.
|
|
730
751
|
|
|
731
|
-
|
|
732
|
-
|
|
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.
|
|
752
|
+
The files use the template's own tokens; restyle them as you like. The table and the permissions are created on
|
|
753
|
+
the next `npx rayfin up`, and every later `rayfin up` applies entity changes.
|
|
736
754
|
|
|
737
|
-
|
|
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
|
-
```
|
|
755
|
+
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
756
|
|
|
762
|
-
|
|
763
|
-
|
|
757
|
+
**7. A chart's own controls are page state: `useBicControls`.** A What-if chart's sliders (a rate, a horizon) are drawn
|
|
758
|
+
inside the chart - build no slider beside it. Give the chart `controls` and read them on the page:
|
|
764
759
|
|
|
765
|
-
|
|
760
|
+
```tsx
|
|
761
|
+
import { useBicControls } from "@bicharts/chart-host/react";
|
|
762
|
+
import { ScenariosPanel } from "@/components/bic-scenarios-panel"; // setup_fabric_app features: ["scenarios"]
|
|
763
|
+
|
|
764
|
+
const scenario = useBicControls();
|
|
765
|
+
<WhatIfProjectionChart table={table} {...size} options={options} controls={scenario} />
|
|
766
|
+
<ScenariosPanel chart="bookings-forecast" controls={scenario} author={useAuth().session?.user?.email} />
|
|
767
|
+
```
|
|
766
768
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
769
|
+
`scenario.values` holds every slider's value from the first draw - no slider has to move before a scenario can be
|
|
770
|
+
saved - and `scenario.summary` is the chart's own label and readout for each ("price change per month +2.0% ·
|
|
771
|
+
horizon 12 months"), so a saved scenario shows the unit the chart shows. `scenario.set(saved)` puts one back; `scenario.reset()`
|
|
772
|
+
returns to the chart's defaults. Don't read `uiState.knobs` or the chart's source, and never save a default you made
|
|
773
|
+
up. `features: ["scenarios"]` writes the `SavedScenario` entity, `useSavedScenarios` and `ScenariosPanel` (save under a
|
|
774
|
+
name, list, apply, and which one is applied now - `applied`, the saved scenario whose values the chart shows; a failed
|
|
775
|
+
save keeps what was typed).
|
|
770
776
|
|
|
771
|
-
**8. What stays the template's job:** sign-in, the query hooks
|
|
772
|
-
|
|
773
|
-
|
|
777
|
+
**8. What stays the template's job - this is additive, never a replacement:** sign-in, the query hooks
|
|
778
|
+
(`useSemanticModelQuery`; a page filter feeds it through `filterQuery`), the theme (`useCssTheme`, which
|
|
779
|
+
`bic-chart-options.ts` builds on), the app's database (Rayfin's data service), `VisualContainer` headers, the template's
|
|
780
|
+
own `VegaVisual` charts and `DataGrid` (which join a page filter through `fromVegaInteraction`), its skills, and
|
|
781
|
+
deployment. The chart is a function you render inside them.
|
|
774
782
|
|
|
775
783
|
**9. Check every chart, then deploy.** Check every chart locally with this server's `check_chart` tool,
|
|
776
784
|
at the size the panel will give it - by default it reads the chart's `minChartWidth` and `minChartHeight`
|
|
@@ -778,7 +786,11 @@ off its `BicChartPanel`, so pass no size unless you mean another one. A `WARNING
|
|
|
778
786
|
label cut off at that size: deal with it before you call the chart done. The reply says whether more room fixes it.
|
|
779
787
|
Never edit the generated chart code by hand: if something's wrong with a generated chart, regenerate it
|
|
780
788
|
(`generate_chart` with a prompt describing the fix) or report it.
|
|
781
|
-
You don't need to deploy to check a chart.
|
|
789
|
+
You don't need to deploy to check a chart. The template's `validate:visual` checks `VegaVisual` factories; a
|
|
790
|
+
RegiaBI chart's check is `check_chart` - don't list it there. And `check_chart` checks the chart, not the app: the
|
|
791
|
+
template's app-validation in the portal embed still applies. Then `npx tsc -b` and the tests. To test the page's behaviour,
|
|
792
|
+
`@bicharts/chart-host/testing` has a reader's gestures - `clickMark`, `clickEmpty`, `selectedRows` - for the checks
|
|
793
|
+
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
794
|
- Signed in? `npx rayfin login status` (a subcommand, not a `--status` flag); `npx rayfin login` if not.
|
|
783
795
|
- Name the workspace: `npx rayfin up -w "<workspace>"`. Left out, it deploys to My Workspace.
|
|
784
796
|
- The Fabric item is named by `id:` in `rayfin/rayfin.yml`, not `name:`. Two projects with the same
|