From 8af44217fed7687d4e674fc5274606b52eaf0b4e Mon Sep 17 00:00:00 2001 From: xrendan Date: Thu, 11 Jun 2026 17:24:20 -0600 Subject: [PATCH 01/13] Add charts2 Storybook samples --- .storybook/main.ts | 1 + packages/charts2/.gitignore | 3 + packages/charts2/README.md | 129 + packages/charts2/build.ts | 65 + packages/charts2/package.json | 89 + packages/charts2/samples/README.md | 10 + .../samples/discrete-bar-population.json | 8 + .../samples/line-federal-departments.json | 7 + .../samples/line-provincial-budgets.json | 9 + .../samples/stacked-area-government-debt.json | 9 + .../samples/stacked-bar-government-debt.json | 9 + ...d-discrete-bar-provincial-composition.json | 10 + .../charts2/scripts/extract-font-metrics.ts | 139 + packages/charts2/src/cli/errors.ts | 44 + packages/charts2/src/cli/index.ts | 102 + packages/charts2/src/cli/loadInputs.test.ts | 146 + packages/charts2/src/cli/loadInputs.ts | 234 + packages/charts2/src/cli/render.test.ts | 210 + packages/charts2/src/cli/render.ts | 432 ++ packages/charts2/src/cli/validate.test.ts | 118 + packages/charts2/src/cli/validate.ts | 145 + .../core/color/categoricalAssigner.test.ts | 151 + .../src/core/color/categoricalAssigner.ts | 112 + packages/charts2/src/core/color/index.ts | 1 + .../charts2/src/core/data/dataset.test.ts | 129 + packages/charts2/src/core/data/dataset.ts | 117 + .../charts2/src/core/data/derived.test.ts | 200 + packages/charts2/src/core/data/derived.ts | 142 + packages/charts2/src/core/data/index.ts | 10 + .../charts2/src/core/data/manifest.test.ts | 175 + packages/charts2/src/core/data/manifest.ts | 240 + packages/charts2/src/core/data/parse.test.ts | 123 + packages/charts2/src/core/data/parse.ts | 177 + packages/charts2/src/core/data/time.test.ts | 126 + packages/charts2/src/core/data/time.ts | 154 + .../charts2/src/core/data/tolerance.test.ts | 92 + packages/charts2/src/core/data/tolerance.ts | 57 + .../charts2/src/core/data/validate.test.ts | 154 + packages/charts2/src/core/data/validate.ts | 172 + packages/charts2/src/core/definition/index.ts | 9 + .../src/core/definition/migrate.test.ts | 64 + .../charts2/src/core/definition/migrate.ts | 85 + .../src/core/definition/resolve.test.ts | 199 + .../charts2/src/core/definition/resolve.ts | 161 + .../src/core/definition/schema.test.ts | 215 + .../charts2/src/core/definition/schema.ts | 359 ++ .../src/core/definition/serialize.test.ts | 134 + .../charts2/src/core/definition/serialize.ts | 84 + .../src/core/definition/urlState.test.ts | 243 + .../charts2/src/core/definition/urlState.ts | 192 + packages/charts2/src/core/format/index.ts | 6 + .../charts2/src/core/format/locales.test.ts | 43 + packages/charts2/src/core/format/locales.ts | 74 + .../charts2/src/core/format/number.test.ts | 184 + packages/charts2/src/core/format/number.ts | 240 + .../src/core/format/timeLabels.test.ts | 111 + .../charts2/src/core/format/timeLabels.ts | 127 + packages/charts2/src/core/index.ts | 14 + .../__snapshots__/layoutChart.test.ts.snap | 66 + packages/charts2/src/core/layout/axis.test.ts | 225 + packages/charts2/src/core/layout/axis.ts | 497 ++ .../core/layout/charts/discreteBar.test.ts | 152 + .../src/core/layout/charts/discreteBar.ts | 297 + .../src/core/layout/charts/line.test.ts | 143 + .../charts2/src/core/layout/charts/line.ts | 323 + .../charts2/src/core/layout/charts/shared.ts | 268 + .../core/layout/charts/stackedArea.test.ts | 131 + .../src/core/layout/charts/stackedArea.ts | 363 ++ .../src/core/layout/charts/stackedBar.ts | 211 + .../layout/charts/stackedDiscreteBar.test.ts | 172 + .../core/layout/charts/stackedDiscreteBar.ts | 385 ++ .../src/core/layout/chooseType.test.ts | 36 + .../charts2/src/core/layout/chooseType.ts | 53 + .../charts2/src/core/layout/chrome.test.ts | 144 + packages/charts2/src/core/layout/chrome.ts | 336 + packages/charts2/src/core/layout/context.ts | 136 + .../charts2/src/core/layout/declutter.test.ts | 68 + packages/charts2/src/core/layout/declutter.ts | 171 + packages/charts2/src/core/layout/index.ts | 19 + .../src/core/layout/layoutChart.test.ts | 293 + .../charts2/src/core/layout/layoutChart.ts | 263 + packages/charts2/src/core/layout/legend.ts | 97 + packages/charts2/src/core/layout/scales.ts | 176 + .../charts2/src/core/layout/series.test.ts | 155 + packages/charts2/src/core/layout/series.ts | 217 + .../charts2/src/core/layout/stacking.test.ts | 116 + packages/charts2/src/core/layout/stacking.ts | 118 + packages/charts2/src/core/scene/nodes.ts | 186 + packages/charts2/src/core/text/bounds.test.ts | 73 + packages/charts2/src/core/text/bounds.ts | 240 + .../src/core/text/createMeasurer.test.ts | 96 + .../charts2/src/core/text/createMeasurer.ts | 63 + packages/charts2/src/core/text/index.ts | 9 + .../charts2/src/core/text/inkWidth.test.ts | 82 + packages/charts2/src/core/text/measurer.ts | 61 + .../charts2/src/core/text/metricsTables.ts | 34 + .../charts2/src/core/text/truncate.test.ts | 34 + packages/charts2/src/core/text/truncate.ts | 24 + packages/charts2/src/core/text/wrap.test.ts | 94 + packages/charts2/src/core/text/wrap.ts | 115 + packages/charts2/src/core/theme/index.ts | 3 + packages/charts2/src/core/theme/logos.ts | 8 + packages/charts2/src/core/theme/registry.ts | 39 + .../charts2/src/core/theme/themes.test.ts | 82 + packages/charts2/src/core/theme/themes.ts | 130 + packages/charts2/src/core/theme/types.ts | 62 + packages/charts2/src/core/types.ts | 315 + .../discrete-bar--default--1200x600.svg | 2 + .../discrete-bar--default--300x160.svg | 2 + .../discrete-bar--default--850x600.svg | 2 + .../discrete-bar--negatives--850x600.svg | 2 + .../discrete-bar--sort-name--850x600.svg | 2 + .../__golden__/line--default--1200x600.svg | 2 + .../__golden__/line--default--300x160.svg | 2 + .../__golden__/line--default--850x600.svg | 2 + .../corpus/__golden__/line--fr--850x600.svg | 2 + .../line--many-entities--1200x600.svg | 2 + .../line--missing-data--850x600.svg | 2 + .../__golden__/line--relative--850x600.svg | 2 + .../__golden__/line--single-time--850x600.svg | 2 + .../stacked-area--default--1200x600.svg | 2 + .../stacked-area--default--300x160.svg | 2 + .../stacked-area--default--850x600.svg | 2 + .../__golden__/stacked-area--fr--850x600.svg | 2 + .../stacked-area--relative--850x600.svg | 2 + .../stacked-bar--default--1200x600.svg | 2 + .../stacked-bar--default--300x160.svg | 2 + .../stacked-bar--default--850x600.svg | 2 + .../stacked-bar--relative--850x600.svg | 2 + ...tacked-discrete-bar--default--1200x600.svg | 2 + ...stacked-discrete-bar--default--300x160.svg | 2 + ...stacked-discrete-bar--default--850x600.svg | 2 + ...ed-discrete-bar--missing-data--850x600.svg | 2 + ...tacked-discrete-bar--relative--850x600.svg | 2 + packages/charts2/src/corpus/bless.ts | 37 + packages/charts2/src/corpus/corpus.test.ts | 86 + packages/charts2/src/corpus/corpus.ts | 319 + .../src/fixtures/federal-departments.ts | 151 + .../charts2/src/fixtures/government-debt.ts | 92 + packages/charts2/src/fixtures/index.test.ts | 149 + packages/charts2/src/fixtures/index.ts | 64 + packages/charts2/src/fixtures/pathological.ts | 71 + .../src/fixtures/population-snapshot.ts | 61 + .../src/fixtures/provincial-budgets.ts | 95 + packages/charts2/src/fixtures/types.ts | 7 + .../fonts/metrics/financier-text-regular.json | 5223 ++++++++++++++++ .../founders-grotesk-mono-regular.json | 207 + .../src/fonts/metrics/soehne-kraftig.json | 5551 +++++++++++++++++ packages/charts2/src/index.ts | 2 + packages/charts2/src/react/Chart.test.tsx | 139 + packages/charts2/src/react/Chart.tsx | 212 + packages/charts2/src/react/SceneSVG.test.tsx | 271 + packages/charts2/src/react/SceneSVG.tsx | 384 ++ .../src/react/chrome/DataTable.test.tsx | 182 + .../charts2/src/react/chrome/DataTable.tsx | 362 ++ .../src/react/chrome/EntitySelector.test.tsx | 153 + .../src/react/chrome/EntitySelector.tsx | 259 + .../src/react/chrome/SettingsMenu.test.tsx | 74 + .../charts2/src/react/chrome/SettingsMenu.tsx | 108 + .../charts2/src/react/chrome/Tabs.test.tsx | 60 + packages/charts2/src/react/chrome/Tabs.tsx | 80 + .../src/react/chrome/Timeline.test.tsx | 353 ++ .../charts2/src/react/chrome/Timeline.tsx | 367 ++ .../charts2/src/react/chrome/Tooltip.test.tsx | 119 + packages/charts2/src/react/chrome/Tooltip.tsx | 136 + .../src/react/chrome/fuzzySearch.test.ts | 69 + .../charts2/src/react/chrome/fuzzySearch.ts | 89 + packages/charts2/src/react/chrome/index.ts | 11 + packages/charts2/src/react/index.ts | 15 + .../react/interaction/emphasisReducer.test.ts | 135 + .../src/react/interaction/emphasisReducer.ts | 60 + .../src/react/interaction/useUrlState.test.ts | 115 + .../src/react/interaction/useUrlState.ts | 71 + packages/charts2/src/react/styles/charts.scss | 635 ++ packages/charts2/src/samples.test.ts | 43 + .../charts2/src/stories/Chrome.stories.tsx | 227 + .../src/stories/DiscreteBar.stories.tsx | 91 + packages/charts2/src/stories/Line.stories.tsx | 89 + .../src/stories/StackedArea.stories.tsx | 72 + .../src/stories/StackedBar.stories.tsx | 58 + .../stories/StackedDiscreteBar.stories.tsx | 73 + packages/charts2/src/stories/helpers.tsx | 30 + packages/charts2/src/stories/scss.d.ts | 3 + packages/charts2/tsconfig.build.json | 24 + packages/charts2/tsconfig.json | 19 + packages/charts2/vitest.config.ts | 9 + 186 files changed, 31542 insertions(+) create mode 100644 packages/charts2/.gitignore create mode 100644 packages/charts2/README.md create mode 100644 packages/charts2/build.ts create mode 100644 packages/charts2/package.json create mode 100644 packages/charts2/samples/README.md create mode 100644 packages/charts2/samples/discrete-bar-population.json create mode 100644 packages/charts2/samples/line-federal-departments.json create mode 100644 packages/charts2/samples/line-provincial-budgets.json create mode 100644 packages/charts2/samples/stacked-area-government-debt.json create mode 100644 packages/charts2/samples/stacked-bar-government-debt.json create mode 100644 packages/charts2/samples/stacked-discrete-bar-provincial-composition.json create mode 100644 packages/charts2/scripts/extract-font-metrics.ts create mode 100644 packages/charts2/src/cli/errors.ts create mode 100644 packages/charts2/src/cli/index.ts create mode 100644 packages/charts2/src/cli/loadInputs.test.ts create mode 100644 packages/charts2/src/cli/loadInputs.ts create mode 100644 packages/charts2/src/cli/render.test.ts create mode 100644 packages/charts2/src/cli/render.ts create mode 100644 packages/charts2/src/cli/validate.test.ts create mode 100644 packages/charts2/src/cli/validate.ts create mode 100644 packages/charts2/src/core/color/categoricalAssigner.test.ts create mode 100644 packages/charts2/src/core/color/categoricalAssigner.ts create mode 100644 packages/charts2/src/core/color/index.ts create mode 100644 packages/charts2/src/core/data/dataset.test.ts create mode 100644 packages/charts2/src/core/data/dataset.ts create mode 100644 packages/charts2/src/core/data/derived.test.ts create mode 100644 packages/charts2/src/core/data/derived.ts create mode 100644 packages/charts2/src/core/data/index.ts create mode 100644 packages/charts2/src/core/data/manifest.test.ts create mode 100644 packages/charts2/src/core/data/manifest.ts create mode 100644 packages/charts2/src/core/data/parse.test.ts create mode 100644 packages/charts2/src/core/data/parse.ts create mode 100644 packages/charts2/src/core/data/time.test.ts create mode 100644 packages/charts2/src/core/data/time.ts create mode 100644 packages/charts2/src/core/data/tolerance.test.ts create mode 100644 packages/charts2/src/core/data/tolerance.ts create mode 100644 packages/charts2/src/core/data/validate.test.ts create mode 100644 packages/charts2/src/core/data/validate.ts create mode 100644 packages/charts2/src/core/definition/index.ts create mode 100644 packages/charts2/src/core/definition/migrate.test.ts create mode 100644 packages/charts2/src/core/definition/migrate.ts create mode 100644 packages/charts2/src/core/definition/resolve.test.ts create mode 100644 packages/charts2/src/core/definition/resolve.ts create mode 100644 packages/charts2/src/core/definition/schema.test.ts create mode 100644 packages/charts2/src/core/definition/schema.ts create mode 100644 packages/charts2/src/core/definition/serialize.test.ts create mode 100644 packages/charts2/src/core/definition/serialize.ts create mode 100644 packages/charts2/src/core/definition/urlState.test.ts create mode 100644 packages/charts2/src/core/definition/urlState.ts create mode 100644 packages/charts2/src/core/format/index.ts create mode 100644 packages/charts2/src/core/format/locales.test.ts create mode 100644 packages/charts2/src/core/format/locales.ts create mode 100644 packages/charts2/src/core/format/number.test.ts create mode 100644 packages/charts2/src/core/format/number.ts create mode 100644 packages/charts2/src/core/format/timeLabels.test.ts create mode 100644 packages/charts2/src/core/format/timeLabels.ts create mode 100644 packages/charts2/src/core/index.ts create mode 100644 packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap create mode 100644 packages/charts2/src/core/layout/axis.test.ts create mode 100644 packages/charts2/src/core/layout/axis.ts create mode 100644 packages/charts2/src/core/layout/charts/discreteBar.test.ts create mode 100644 packages/charts2/src/core/layout/charts/discreteBar.ts create mode 100644 packages/charts2/src/core/layout/charts/line.test.ts create mode 100644 packages/charts2/src/core/layout/charts/line.ts create mode 100644 packages/charts2/src/core/layout/charts/shared.ts create mode 100644 packages/charts2/src/core/layout/charts/stackedArea.test.ts create mode 100644 packages/charts2/src/core/layout/charts/stackedArea.ts create mode 100644 packages/charts2/src/core/layout/charts/stackedBar.ts create mode 100644 packages/charts2/src/core/layout/charts/stackedDiscreteBar.test.ts create mode 100644 packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts create mode 100644 packages/charts2/src/core/layout/chooseType.test.ts create mode 100644 packages/charts2/src/core/layout/chooseType.ts create mode 100644 packages/charts2/src/core/layout/chrome.test.ts create mode 100644 packages/charts2/src/core/layout/chrome.ts create mode 100644 packages/charts2/src/core/layout/context.ts create mode 100644 packages/charts2/src/core/layout/declutter.test.ts create mode 100644 packages/charts2/src/core/layout/declutter.ts create mode 100644 packages/charts2/src/core/layout/index.ts create mode 100644 packages/charts2/src/core/layout/layoutChart.test.ts create mode 100644 packages/charts2/src/core/layout/layoutChart.ts create mode 100644 packages/charts2/src/core/layout/legend.ts create mode 100644 packages/charts2/src/core/layout/scales.ts create mode 100644 packages/charts2/src/core/layout/series.test.ts create mode 100644 packages/charts2/src/core/layout/series.ts create mode 100644 packages/charts2/src/core/layout/stacking.test.ts create mode 100644 packages/charts2/src/core/layout/stacking.ts create mode 100644 packages/charts2/src/core/scene/nodes.ts create mode 100644 packages/charts2/src/core/text/bounds.test.ts create mode 100644 packages/charts2/src/core/text/bounds.ts create mode 100644 packages/charts2/src/core/text/createMeasurer.test.ts create mode 100644 packages/charts2/src/core/text/createMeasurer.ts create mode 100644 packages/charts2/src/core/text/index.ts create mode 100644 packages/charts2/src/core/text/inkWidth.test.ts create mode 100644 packages/charts2/src/core/text/measurer.ts create mode 100644 packages/charts2/src/core/text/metricsTables.ts create mode 100644 packages/charts2/src/core/text/truncate.test.ts create mode 100644 packages/charts2/src/core/text/truncate.ts create mode 100644 packages/charts2/src/core/text/wrap.test.ts create mode 100644 packages/charts2/src/core/text/wrap.ts create mode 100644 packages/charts2/src/core/theme/index.ts create mode 100644 packages/charts2/src/core/theme/logos.ts create mode 100644 packages/charts2/src/core/theme/registry.ts create mode 100644 packages/charts2/src/core/theme/themes.test.ts create mode 100644 packages/charts2/src/core/theme/themes.ts create mode 100644 packages/charts2/src/core/theme/types.ts create mode 100644 packages/charts2/src/core/types.ts create mode 100644 packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--fr--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--relative--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg create mode 100644 packages/charts2/src/corpus/bless.ts create mode 100644 packages/charts2/src/corpus/corpus.test.ts create mode 100644 packages/charts2/src/corpus/corpus.ts create mode 100644 packages/charts2/src/fixtures/federal-departments.ts create mode 100644 packages/charts2/src/fixtures/government-debt.ts create mode 100644 packages/charts2/src/fixtures/index.test.ts create mode 100644 packages/charts2/src/fixtures/index.ts create mode 100644 packages/charts2/src/fixtures/pathological.ts create mode 100644 packages/charts2/src/fixtures/population-snapshot.ts create mode 100644 packages/charts2/src/fixtures/provincial-budgets.ts create mode 100644 packages/charts2/src/fixtures/types.ts create mode 100644 packages/charts2/src/fonts/metrics/financier-text-regular.json create mode 100644 packages/charts2/src/fonts/metrics/founders-grotesk-mono-regular.json create mode 100644 packages/charts2/src/fonts/metrics/soehne-kraftig.json create mode 100644 packages/charts2/src/index.ts create mode 100644 packages/charts2/src/react/Chart.test.tsx create mode 100644 packages/charts2/src/react/Chart.tsx create mode 100644 packages/charts2/src/react/SceneSVG.test.tsx create mode 100644 packages/charts2/src/react/SceneSVG.tsx create mode 100644 packages/charts2/src/react/chrome/DataTable.test.tsx create mode 100644 packages/charts2/src/react/chrome/DataTable.tsx create mode 100644 packages/charts2/src/react/chrome/EntitySelector.test.tsx create mode 100644 packages/charts2/src/react/chrome/EntitySelector.tsx create mode 100644 packages/charts2/src/react/chrome/SettingsMenu.test.tsx create mode 100644 packages/charts2/src/react/chrome/SettingsMenu.tsx create mode 100644 packages/charts2/src/react/chrome/Tabs.test.tsx create mode 100644 packages/charts2/src/react/chrome/Tabs.tsx create mode 100644 packages/charts2/src/react/chrome/Timeline.test.tsx create mode 100644 packages/charts2/src/react/chrome/Timeline.tsx create mode 100644 packages/charts2/src/react/chrome/Tooltip.test.tsx create mode 100644 packages/charts2/src/react/chrome/Tooltip.tsx create mode 100644 packages/charts2/src/react/chrome/fuzzySearch.test.ts create mode 100644 packages/charts2/src/react/chrome/fuzzySearch.ts create mode 100644 packages/charts2/src/react/chrome/index.ts create mode 100644 packages/charts2/src/react/index.ts create mode 100644 packages/charts2/src/react/interaction/emphasisReducer.test.ts create mode 100644 packages/charts2/src/react/interaction/emphasisReducer.ts create mode 100644 packages/charts2/src/react/interaction/useUrlState.test.ts create mode 100644 packages/charts2/src/react/interaction/useUrlState.ts create mode 100644 packages/charts2/src/react/styles/charts.scss create mode 100644 packages/charts2/src/samples.test.ts create mode 100644 packages/charts2/src/stories/Chrome.stories.tsx create mode 100644 packages/charts2/src/stories/DiscreteBar.stories.tsx create mode 100644 packages/charts2/src/stories/Line.stories.tsx create mode 100644 packages/charts2/src/stories/StackedArea.stories.tsx create mode 100644 packages/charts2/src/stories/StackedBar.stories.tsx create mode 100644 packages/charts2/src/stories/StackedDiscreteBar.stories.tsx create mode 100644 packages/charts2/src/stories/helpers.tsx create mode 100644 packages/charts2/src/stories/scss.d.ts create mode 100644 packages/charts2/tsconfig.build.json create mode 100644 packages/charts2/tsconfig.json create mode 100644 packages/charts2/vitest.config.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index c1005359caa..12927c1493e 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,6 +5,7 @@ const config: StorybookConfig = { stories: [ "./docs/**/*.mdx", "../packages/charts/src/**/*.stories.@(ts|tsx)", + "../packages/charts2/src/**/*.stories.@(ts|tsx)", "../packages/colours/src/**/*.stories.@(ts|tsx)", "../packages/components/src/**/*.stories.@(ts|tsx)", ], diff --git a/packages/charts2/.gitignore b/packages/charts2/.gitignore new file mode 100644 index 00000000000..2f4d1d044a7 --- /dev/null +++ b/packages/charts2/.gitignore @@ -0,0 +1,3 @@ +.fonts-cache/ +dist/ +node_modules/ diff --git a/packages/charts2/README.md b/packages/charts2/README.md new file mode 100644 index 00000000000..260391ad0e6 --- /dev/null +++ b/packages/charts2/README.md @@ -0,0 +1,129 @@ +# @buildcanada/charts2 + +Build Canada charts v2: a pure-function layout core with a single React SVG renderer, deterministic headless rendering, and a CLI. Specs: `bcds/specs/` (architecture: `specs/28-architecture.md`). + +## Layout + +- `src/core/` — DOM-free, React-free: data layer, formatting, themes, text metrics, layout → `ChartScene` +- `src/react/` — `SceneSVG` (the only renderer) + `Chart` + interactive chrome (tooltip, timeline, entity selector, tabs, settings, data table) +- `src/cli/` — `bcds-charts render|validate` (same render path via `renderToStaticMarkup`) +- `src/fixtures/` — committed fixture datasets (spec 26 §2), loadable by name in tests/stories/CLI +- `samples/` — CLI-ready chart definitions, tested against bundled fixtures +- `src/corpus/` — golden SVG corpus + bless script (spec 26 §1.3) +- `src/stories/` — Storybook stories under `Charts2/` (run Storybook from the repo root) + +## Quick start + +```tsx +import { buildDataset, parseCsv, parseDefinition, parseManifest } from "@buildcanada/charts2/core" +import { Chart, Tooltip } from "@buildcanada/charts2" +import "@buildcanada/charts2/styles.css" + +// 1. Dataset: manifest + CSV → Dataset (in-repo code can shortcut with +// loadFixtureDataset("provincial-budgets") from src/fixtures). +const { manifest } = parseManifest(rawManifestJson) +const { rows } = parseCsv(csvText, manifest!) +const { dataset } = buildDataset(manifest!, rows) + +// 2. Definition: title + data + y is a publishable chart (spec 02 §2); +// everything else is progressive refinement with documented defaults. +const { definition, diagnostics } = parseDefinition({ + title: "Provincial budget spending", + data: "provincial-budgets", + y: ["total_spending"], + selectedEntities: ["Ontario", "Quebec", "British Columbia"], +}) +if (definition === null) throw new Error(diagnostics.map((d) => d.message).join("; ")) + +// 3. Render. +export function Demo() { + return ( + ( + + )} + /> + ) +} +``` + +Headless (no React state, no DOM): `layoutChart({ definition, dataset, size })` → `ChartScene` → `renderToStaticMarkup()` — exactly what the CLI does. + +## CLI + +`bcds-charts` ships as the package bin (`dist/cli/index.js`; run `bun run build` once before the workspace bin works). From source: `bun run --cwd ../.. charts2 …` or `bun src/cli/index.ts …`. + +### `bcds-charts render ` + +Render one chart definition to SVG/PNG. The SVG string is a pure function of definition + dataset + flags (spec 24 §3) — same inputs, same bytes. On any error diagnostic, nothing is written. + +| Flag | Meaning | +|---|---| +| `--out ` | Output path; `-` writes SVG to stdout (default `.`) | +| `--format svg\|png` | Repeatable or comma-separated, e.g. `--format svg,png` (default `svg`) | +| `--width ` / `--height ` | Size (default 850×600); aspect clamped to [0.5, 2] with a warning | +| `--preset ` | `social` (1200×628) \| `square` (1080×1080) \| `thumbnail` (300×160, minimal chrome) \| `slide` (1920×1080) | +| `--scale ` | PNG raster scale (default 2) | +| `--theme ` | Theme name (default from definition) | +| `--locale en\|fr` | Locale override (default from definition) | +| `--state ` | URL-style view state, e.g. `"tab=line&time=2014-15..2024-25&entities=ON~QC"` | +| `--transparent` | No background fill | +| `--no-chrome` | Plot only (no header/footer) | +| `--fonts ` | TTF directory for PNG rasterization (default: the package `.fonts-cache`) | + +The definition's `data` field may reference a dataset directory (`manifest.json` + `data.csv`), a `{manifest, rows}` JSON file, or a bundled fixture name (`provincial-budgets`, `federal-departments`, `population-snapshot`, `government-debt`, `pathological`). + +Sample definitions live in `samples/` and can be rendered directly: + +```bash +bun src/cli/index.ts render samples/line-provincial-budgets.json --out chart.svg +bun src/cli/index.ts render samples/stacked-area-government-debt.json --preset social +``` + +### `bcds-charts validate ` + +Report ALL problems at once (spec 01 §8). Accepts a definition JSON, a dataset directory, a single `manifest.json`, a `{manifest, rows}` JSON file, or a fixture name. Diagnostics print to stderr one per line; a summary line goes to stdout. + +### Exit codes + +- `0` — success +- `1` — validation/render errors +- `2` — bad usage (unknown flag/preset/format, missing arguments) + +## Golden SVG corpus + +`src/corpus/corpus.ts` defines 27 named cases (`----x`) — every chart type × representative states (default, relative, single-time collapse, missing data, French, thumbnail chrome) × 3 sizes (300×160 / 850×600 / 1200×600). `src/corpus/corpus.test.ts` re-renders each case through the CLI pipeline and asserts byte-for-byte equality with the committed reference in `src/corpus/__golden__/`, plus cross-cutting invariants (spec 26 §3): no `NaN`/`Infinity`/exponent coordinates, XML well-formedness, and same-inputs → same-bytes. + +After an **intentional** rendering change: + +```bash +bun run corpus:bless # rewrites src/corpus/__golden__/*.svg +git diff src/corpus/__golden__ # review every diff; commit in the same PR +``` + +## Develop + +```bash +bun install +bun run extract-font-metrics # regenerates metrics JSON + .fonts-cache TTFs +bun run test +bun run build # required once before the workspace bin works +bun run --cwd ../.. charts2 render # CLI from source +bun run --cwd ../.. storybook # stories under "Charts2/" (repo-root Storybook) +``` + +Brand font binaries are never committed here or published — only metrics JSON. See `specs/28-architecture.md` §3. + +## Deferred (later phases) + +Implemented today: line, discrete-bar, stacked-area, stacked-bar, stacked-discrete-bar; themes; en/fr locales; URL state; interactive chrome; CLI render/validate. Per the phased plan, **not yet implemented**: + +- Faceting — `facet: entity|metric` parses but small multiples do not lay out yet (spec 09) +- Comparison lines — `comparisonLines` parses but does not render (spec 02) +- Further chart types: maps (spec 20), scatter (spec 18), slope (spec 12), dumbbell (spec 17), marimekko (spec 19) +- Motion/video rendering — `animate`, golden frames (spec 25) +- Explorer — control sweeps over a chart family (spec 23) diff --git a/packages/charts2/build.ts b/packages/charts2/build.ts new file mode 100644 index 00000000000..2eaf2c6ba97 --- /dev/null +++ b/packages/charts2/build.ts @@ -0,0 +1,65 @@ +import { mkdir, cp, rm, chmod, readFile, writeFile } from "node:fs/promises" +import { spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { join, dirname } from "node:path" +import { Glob } from "bun" + +const runCommand = (command: string, args: string[]): Promise => { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: "inherit" }) + proc.on("close", (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} exited with code ${code}`)) + }) + }) +} + +// SCSS and committed font-metrics JSON ship alongside the compiled JS. +// Brand font binaries (woff2) are intentionally NOT copied: the published +// package must not redistribute licensed fonts (see specs/28-architecture.md). +const copyAssets = async () => { + const srcDir = "src" + const distDir = "dist" + for (const pattern of ["**/*.scss", "fonts/metrics/*.json"]) { + const glob = new Glob(pattern) + for await (const file of glob.scan(srcDir)) { + const destPath = join(distDir, file) + await mkdir(dirname(destPath), { recursive: true }) + await cp(join(srcDir, file), destPath) + } + } +} + +const makeBinExecutable = async () => { + const binPath = "dist/cli/index.js" + if (!existsSync(binPath)) return + const content = await readFile(binPath, "utf8") + if (!content.startsWith("#!")) { + await writeFile(binPath, `#!/usr/bin/env node\n${content}`) + } + await chmod(binPath, 0o755) +} + +const build = async () => { + console.log("Cleaning dist directory...") + if (existsSync("dist")) { + await rm("dist", { recursive: true }) + } + await mkdir("dist", { recursive: true }) + + console.log("Copying assets to dist...") + await copyAssets() + + console.log("Compiling TypeScript...") + await runCommand("npx", ["tsc", "--project", "tsconfig.build.json"]) + + console.log("Making CLI bin executable...") + await makeBinExecutable() + + console.log("Build complete!") +} + +build().catch((err) => { + console.error("Build failed:", err) + process.exit(1) +}) diff --git a/packages/charts2/package.json b/packages/charts2/package.json new file mode 100644 index 00000000000..ebfbdbb2019 --- /dev/null +++ b/packages/charts2/package.json @@ -0,0 +1,89 @@ +{ + "name": "@buildcanada/charts2", + "version": "0.1.0", + "description": "Build Canada charts v2: pure layout core with a single React SVG renderer, deterministic headless rendering, and a CLI.", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./styles.css": "./dist/react/styles/charts.scss", + "./styles/*": "./dist/react/styles/*" + }, + "bin": { + "bcds-charts": "./dist/cli/index.js" + }, + "files": [ + "dist", + "samples" + ], + "repository": { + "type": "git", + "url": "https://github.com/BuildCanada/bcds.git", + "directory": "packages/charts2" + }, + "bugs": { + "url": "https://github.com/BuildCanada/bcds/issues" + }, + "homepage": "https://github.com/BuildCanada/bcds#readme", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "bun run build.ts", + "prepublishOnly": "bun run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "corpus:bless": "bun src/corpus/bless.ts", + "extract-font-metrics": "bun run scripts/extract-font-metrics.ts" + }, + "keywords": [ + "charts", + "data-visualization", + "svg", + "react", + "build-canada" + ], + "author": "Build Canada", + "license": "MIT", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "@buildcanada/colours": "^0.3.3", + "@resvg/resvg-js": "^2.6.2", + "citty": "^0.1.6", + "d3-array": "^3.2.4", + "d3-dsv": "^3.0.1", + "d3-format": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "zod": "^4.3.5" + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@types/d3-array": "^3.2.2", + "@types/d3-dsv": "^3.0.7", + "@types/d3-format": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/node": "^22.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "fontkit": "^2.0.4", + "happy-dom": "^20.1.0", + "typescript": "~5.9.2", + "vitest": "^4.0.15", + "wawoff2": "^2.0.1" + } +} diff --git a/packages/charts2/samples/README.md b/packages/charts2/samples/README.md new file mode 100644 index 00000000000..7f5cc428bba --- /dev/null +++ b/packages/charts2/samples/README.md @@ -0,0 +1,10 @@ +# Charts2 samples + +These definition files are ready to use with the CLI from the package root: + +```bash +bun src/cli/index.ts render samples/line-provincial-budgets.json --out chart.svg +bun src/cli/index.ts validate samples/stacked-area-government-debt.json +``` + +Each `samples/*.json` file is covered by `src/samples.test.ts`. diff --git a/packages/charts2/samples/discrete-bar-population.json b/packages/charts2/samples/discrete-bar-population.json new file mode 100644 index 00000000000..cc2aa126952 --- /dev/null +++ b/packages/charts2/samples/discrete-bar-population.json @@ -0,0 +1,8 @@ +{ + "slug": "discrete-bar-population", + "title": "Population by province and territory", + "data": "population-snapshot", + "y": ["population"], + "types": ["discrete-bar"], + "sourceText": "Statistics Canada" +} diff --git a/packages/charts2/samples/line-federal-departments.json b/packages/charts2/samples/line-federal-departments.json new file mode 100644 index 00000000000..5b499ac8873 --- /dev/null +++ b/packages/charts2/samples/line-federal-departments.json @@ -0,0 +1,7 @@ +{ + "slug": "line-federal-departments", + "title": "Federal departmental spending", + "data": "federal-departments", + "y": ["spending"], + "sourceText": "Public Accounts of Canada" +} diff --git a/packages/charts2/samples/line-provincial-budgets.json b/packages/charts2/samples/line-provincial-budgets.json new file mode 100644 index 00000000000..c036b2b38b0 --- /dev/null +++ b/packages/charts2/samples/line-provincial-budgets.json @@ -0,0 +1,9 @@ +{ + "slug": "line-provincial-budgets", + "title": "Provincial budget spending", + "subtitle": "Total budgetary expenditure, public accounts basis", + "data": "provincial-budgets", + "y": ["total_spending"], + "selectedEntities": ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + "sourceText": "Provincial public accounts" +} diff --git a/packages/charts2/samples/stacked-area-government-debt.json b/packages/charts2/samples/stacked-area-government-debt.json new file mode 100644 index 00000000000..03f59c001c6 --- /dev/null +++ b/packages/charts2/samples/stacked-area-government-debt.json @@ -0,0 +1,9 @@ +{ + "slug": "stacked-area-government-debt", + "title": "Government debt as a share of GDP", + "subtitle": "Federal, provincial, and municipal debt divided by nominal GDP", + "data": "government-debt", + "y": ["federal_debt", "provincial_debt", "municipal_debt"], + "types": ["stacked-area"], + "sourceText": "Fiscal reference tables" +} diff --git a/packages/charts2/samples/stacked-bar-government-debt.json b/packages/charts2/samples/stacked-bar-government-debt.json new file mode 100644 index 00000000000..af9a23a2b69 --- /dev/null +++ b/packages/charts2/samples/stacked-bar-government-debt.json @@ -0,0 +1,9 @@ +{ + "slug": "stacked-bar-government-debt", + "title": "Government debt as a share of GDP", + "subtitle": "Federal, provincial, and municipal debt divided by nominal GDP", + "data": "government-debt", + "y": ["federal_debt", "provincial_debt", "municipal_debt"], + "types": ["stacked-bar"], + "sourceText": "Fiscal reference tables" +} diff --git a/packages/charts2/samples/stacked-discrete-bar-provincial-composition.json b/packages/charts2/samples/stacked-discrete-bar-provincial-composition.json new file mode 100644 index 00000000000..8e6cf528c4b --- /dev/null +++ b/packages/charts2/samples/stacked-discrete-bar-provincial-composition.json @@ -0,0 +1,10 @@ +{ + "slug": "stacked-discrete-bar-provincial-composition", + "title": "Provincial spending composition", + "subtitle": "Program spending and debt charges by province", + "data": "provincial-budgets", + "y": ["program_spending", "debt_charges"], + "types": ["stacked-discrete-bar"], + "selectedEntities": ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + "sourceText": "Provincial public accounts" +} diff --git a/packages/charts2/scripts/extract-font-metrics.ts b/packages/charts2/scripts/extract-font-metrics.ts new file mode 100644 index 00000000000..ff74d04ba6b --- /dev/null +++ b/packages/charts2/scripts/extract-font-metrics.ts @@ -0,0 +1,139 @@ +/** + * Extract deterministic font metrics from the brand WOFF2s into committed + * JSON tables (src/fonts/metrics/*.json). Run with: + * + * bun run scripts/extract-font-metrics.ts + * + * The WOFF2 binaries themselves are NEVER copied into this package or its + * published artifact (Klim font license) — only these numeric tables ship. + * + * Kerning comes from GPOS via fontkit's layout() per glyph pair (these fonts + * have no legacy `kern` table). Ligatures are irrelevant to the table because + * SVG text renders with liga disabled (see core/text and SceneSVG). + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { join, resolve } from "node:path" +// eslint-disable-next-line import-x/no-extraneous-dependencies -- devDependencies; build-time only +import * as fontkit from "fontkit" +// @ts-expect-error wawoff2 ships no types +import { decompress } from "wawoff2" + +const FONTS_DIR = resolve(import.meta.dir, "../../components/src/assets/fonts") +const OUT_DIR = resolve(import.meta.dir, "../src/fonts/metrics") +/** + * Decompressed TTFs for rasterization (resvg's fontdb cannot read WOFF2). + * Gitignored and regenerable — licensed font binaries are never committed + * here nor published. + */ +const TTF_CACHE_DIR = resolve(import.meta.dir, "../.fonts-cache") + +/** role/metricsId → source woff2 */ +const FONTS: Record = { + "soehne-kraftig": join(FONTS_DIR, "soehne-kraftig.woff2"), + "financier-text-regular": join(FONTS_DIR, "financier-text-regular.woff2"), + "founders-grotesk-mono-regular": join(FONTS_DIR, "founders-grotesk-mono-regular.woff2"), +} + +// Fixed charset: printable ASCII, Latin-1 letters incl. French accents, +// typographic punctuation, NBSP + narrow NBSP, minus, dashes, currency. +const buildCharset = (): string[] => { + const chars: string[] = [] + for (let cp = 0x20; cp <= 0x7e; cp++) chars.push(String.fromCodePoint(cp)) + for (let cp = 0xa0; cp <= 0xff; cp++) chars.push(String.fromCodePoint(cp)) + chars.push( + "–", // en dash + "—", // em dash + "‘", "’", "“", "”", // smart quotes + "…", // ellipsis + "−", // true minus + " ", // narrow NBSP (fr number groups) + "€", // euro + "‰", // per mille + ) + return [...new Set(chars)] +} + +interface FontMetricsTableJson { + familyName: string + unitsPerEm: number + ascent: number + descent: number + capHeight: number + advances: Record + kerning: Record + defaultAdvance: number +} + +const extract = (path: string): FontMetricsTableJson => { + const font = fontkit.openSync(path) as fontkit.Font + const charset = buildCharset() + + const advances: Record = {} + for (const ch of charset) { + if (!font.hasGlyphForCodePoint(ch.codePointAt(0)!)) continue + // layout() applies GSUB/GPOS; single chars give the shaped advance. + const run = font.layout(ch, { liga: false, calt: false }) + const width = run.positions.reduce((sum, p) => sum + p.xAdvance, 0) + advances[String(ch.codePointAt(0))] = width + } + + // Pair kerning: layout the pair and subtract the bare advances. + const kerning: Record = {} + for (const a of charset) { + const aCp = a.codePointAt(0)! + const aAdv = advances[String(aCp)] + if (aAdv === undefined) continue + for (const b of charset) { + const bCp = b.codePointAt(0)! + const bAdv = advances[String(bCp)] + if (bAdv === undefined) continue + const run = font.layout(a + b, { liga: false, calt: false }) + if (run.positions.length !== 2) continue // shaped to ligature/other — skip pair + const pairWidth = run.positions[0].xAdvance + run.positions[1].xAdvance + const adjustment = pairWidth - (aAdv + bAdv) + if (adjustment !== 0) kerning[`${aCp},${bCp}`] = adjustment + } + } + + // Fallback advance for unknown glyphs: width of "0" (tabular-ish), else average. + const zeroAdv = advances[String("0".codePointAt(0))] + const all = Object.values(advances) + const defaultAdvance = zeroAdv ?? Math.round(all.reduce((s, w) => s + w, 0) / all.length) + + return { + familyName: font.familyName, + unitsPerEm: font.unitsPerEm, + ascent: font.ascent, + descent: font.descent, + capHeight: font.capHeight, + advances, + kerning, + defaultAdvance, + } +} + +const main = async () => { + await mkdir(OUT_DIR, { recursive: true }) + await mkdir(TTF_CACHE_DIR, { recursive: true }) + for (const [id, path] of Object.entries(FONTS)) { + const table = extract(path) + const out = join(OUT_DIR, `${id}.json`) + // Stable key order for clean diffs. + await writeFile(out, JSON.stringify(table, null, 2) + "\n") + + const ttf = await decompress(await readFile(path)) + const ttfPath = join(TTF_CACHE_DIR, `${id}.ttf`) + await writeFile(ttfPath, Buffer.from(ttf)) + + console.log( + `${id}: ${Object.keys(table.advances).length} glyphs, ` + + `${Object.keys(table.kerning).length} kern pairs → ${out}; ttf → ${ttfPath}`, + ) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/charts2/src/cli/errors.ts b/packages/charts2/src/cli/errors.ts new file mode 100644 index 00000000000..509cf9d4ec2 --- /dev/null +++ b/packages/charts2/src/cli/errors.ts @@ -0,0 +1,44 @@ +/** + * CLI error types and diagnostic printing. + * + * Exit-code contract (spec 24): 0 success, 1 validation/render errors, + * 2 bad usage. Command handlers throw CliUsageError (→ 2) or CliFailure + * (→ 1); src/cli/index.ts maps them to process exit codes. + */ + +import type { Diagnostic } from "../core/types.ts" + +/** Bad flags/arguments — exit code 2. */ +export class CliUsageError extends Error {} + +/** + * Validation/render failure — exit code 1. An empty message means the + * details were already printed (as diagnostics) and only the exit code + * remains to be set. + */ +export class CliFailure extends Error { + constructor(message = "") { + super(message) + } +} + +/** One Diagnostic as one line: `severity code message (k=v, k=v)`. */ +export function formatDiagnostic(diagnostic: Diagnostic): string { + const entries = Object.entries(diagnostic.context ?? {}) + const context = entries.length > 0 ? ` (${entries.map(([key, value]) => `${key}=${value}`).join(", ")})` : "" + return `${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}${context}` +} + +export function printDiagnostics(diagnostics: readonly Diagnostic[]): void { + for (const diagnostic of diagnostics) { + process.stderr.write(`${formatDiagnostic(diagnostic)}\n`) + } +} + +export function countErrors(diagnostics: readonly Diagnostic[]): number { + return diagnostics.filter((diagnostic) => diagnostic.severity === "error").length +} + +export function hasErrors(diagnostics: readonly Diagnostic[]): boolean { + return countErrors(diagnostics) > 0 +} diff --git a/packages/charts2/src/cli/index.ts b/packages/charts2/src/cli/index.ts new file mode 100644 index 00000000000..b251fa5df7c --- /dev/null +++ b/packages/charts2/src/cli/index.ts @@ -0,0 +1,102 @@ +/** + * bcds-charts — CLI entry point (spec 24, spec 28 §5). + * + * Subcommands: render (definition → SVG/PNG), validate (all errors at once). + * Exit codes: 0 success, 1 validation/render errors, 2 bad usage. + * + * No shebang in this source file — build.ts prepends `#!/usr/bin/env node` + * to dist/cli/index.js and marks it executable. + */ + +import { defineCommand, runCommand, showUsage, type CommandDef } from "citty" +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import { CliFailure, CliUsageError } from "./errors.ts" +import { renderCommand } from "./render.ts" +import { validateCommand } from "./validate.ts" + +function packageVersion(): string { + try { + const path = join(dirname(fileURLToPath(import.meta.url)), "../../package.json") + const parsed = JSON.parse(readFileSync(path, "utf8")) as { version?: string } + return parsed.version ?? "0.0.0" + } catch { + return "0.0.0" + } +} + +const subCommands: Record = { + render: renderCommand as CommandDef, + validate: validateCommand as CommandDef, +} + +const main = defineCommand({ + meta: { + name: "bcds-charts", + version: packageVersion(), + description: "Render and validate Build Canada chart definitions (spec 24)", + }, + subCommands, +}) + +/** + * citty's mri-style parser never consumes a bare "-" as a flag value, so + * `--out -` (stdout) would parse as an empty --out plus a stray positional. + * Normalize it to the equivalent `--out=-` form before parsing. + */ +export function normalizeRawArgs(rawArgs: readonly string[]): string[] { + const out: string[] = [] + for (let i = 0; i < rawArgs.length; i++) { + if (rawArgs[i] === "--out" && rawArgs[i + 1] === "-") { + out.push("--out=-") + i++ + } else { + out.push(rawArgs[i]) + } + } + return out +} + +async function run(argv: string[]): Promise { + const rawArgs = normalizeRawArgs(argv) + const subName = rawArgs.find((arg) => !arg.startsWith("-")) + + try { + if (rawArgs.includes("--help") || rawArgs.includes("-h")) { + const sub = subName !== undefined ? subCommands[subName] : undefined + if (sub !== undefined) await showUsage(sub, main) + else await showUsage(main) + process.exit(0) + } + if (rawArgs.length === 1 && rawArgs[0] === "--version") { + process.stdout.write(`${packageVersion()}\n`) + process.exit(0) + } + await runCommand(main, { rawArgs }) + process.exit(0) + } catch (error) { + if (error instanceof CliUsageError) { + process.stderr.write(`${error.message}\n`) + process.exit(2) + } + if (error instanceof CliFailure) { + if (error.message !== "") process.stderr.write(`${error.message}\n`) + process.exit(1) + } + // citty's CLIError: unknown subcommand, missing required argument, … + if (error instanceof Error && error.name === "CLIError") { + process.stderr.write(`${error.message}\n`) + const sub = subName !== undefined ? subCommands[subName] : undefined + if (sub !== undefined) await showUsage(sub, main) + else await showUsage(main) + process.exit(2) + } + const message = error instanceof Error ? (error.stack ?? error.message) : String(error) + process.stderr.write(`${message}\n`) + process.exit(1) + } +} + +await run(process.argv.slice(2)) diff --git a/packages/charts2/src/cli/loadInputs.test.ts b/packages/charts2/src/cli/loadInputs.test.ts new file mode 100644 index 00000000000..af93c3290c9 --- /dev/null +++ b/packages/charts2/src/cli/loadInputs.test.ts @@ -0,0 +1,146 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +import { viewStateToParams } from "../core/index.ts" +import { loadDataset, parseState } from "./loadInputs.ts" + +// --------------------------------------------------------------------------- +// Fixtures on disk +// --------------------------------------------------------------------------- + +const tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "bcds-cli-test-")) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop() as string, { recursive: true, force: true }) + } +}) + +function rawManifest(name: string): Record { + return { + name, + timeGrain: "year", + entity: { label: "thing", labelPlural: "things" }, + columns: { value: { name: "Value", type: "numeric" } }, + sources: [{ name: "Test data" }], + } +} + +const CSV = "entity,time,value\nA,2020,1\nA,2021,2\n" + +function writeDatasetDir(base: string, name: string, manifestName: string): void { + const dir = join(base, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "manifest.json"), JSON.stringify(rawManifest(manifestName))) + writeFileSync(join(dir, "data.csv"), CSV) +} + +function writeDatasetJson(base: string, name: string, manifestName: string): void { + const rows = [ + { entity: "A", time: 2020, value: 1 }, + { entity: "A", time: 2021, value: 2 }, + ] + writeFileSync(join(base, name), JSON.stringify({ manifest: rawManifest(manifestName), rows })) +} + +// --------------------------------------------------------------------------- +// parseState +// --------------------------------------------------------------------------- + +describe("parseState", () => { + it("decodes a URL-style state string", () => { + const { state, diagnostics } = parseState("tab=line&time=2014-15..2024-25&entities=ON~QC", "fiscal-year") + expect(diagnostics).toEqual([]) + expect(state.tab).toBe("line") + expect(state.time).toEqual({ start: 2014, end: 2024 }) + expect(state.entities).toEqual(["ON", "QC"]) + }) + + it("round-trips through viewStateToParams", () => { + const original = "tab=line&time=2014-15..2024-25&entities=ON~QC" + const first = parseState(original, "fiscal-year") + const encoded = viewStateToParams(first.state, "fiscal-year").toString() + const second = parseState(encoded, "fiscal-year") + expect(second.state).toEqual(first.state) + expect(second.diagnostics).toEqual([]) + }) + + it("tolerates a leading question mark and reports bad values as warnings", () => { + const { state, diagnostics } = parseState("?tab=nope&entities=ON", "year") + expect(state.tab).toBeUndefined() + expect(state.entities).toEqual(["ON"]) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ severity: "warning", code: "invalid-url-param" }) + }) +}) + +// --------------------------------------------------------------------------- +// loadDataset resolution order: dir → JSON file → bundled fixture +// --------------------------------------------------------------------------- + +describe("loadDataset", () => { + it("loads a dataset directory (manifest.json + data.csv)", () => { + const base = makeTmpDir() + writeDatasetDir(base, "mydata", "from-dir") + const result = loadDataset("mydata", [base]) + expect(result.manifest?.name).toBe("from-dir") + expect(result.dataset?.entities).toEqual(["A"]) + expect(result.diagnostics).toEqual([]) + }) + + it("loads a single JSON file {manifest, rows}", () => { + const base = makeTmpDir() + writeDatasetJson(base, "mydata.json", "from-file") + const result = loadDataset("mydata.json", [base]) + expect(result.manifest?.name).toBe("from-file") + expect(result.dataset?.times).toEqual([2020, 2021]) + }) + + it("prefers the definition directory over later search directories", () => { + const defDir = makeTmpDir() + const cwdDir = makeTmpDir() + writeDatasetDir(defDir, "mydata", "from-def-dir") + writeDatasetJson(cwdDir, "mydata", "from-cwd-file") + const result = loadDataset("mydata", [defDir, cwdDir]) + expect(result.manifest?.name).toBe("from-def-dir") + }) + + it("skips a directory without manifest.json and falls through", () => { + const defDir = makeTmpDir() + const cwdDir = makeTmpDir() + mkdirSync(join(defDir, "mydata")) // no manifest.json inside + writeDatasetJson(cwdDir, "mydata", "from-cwd-file") + const result = loadDataset("mydata", [defDir, cwdDir]) + expect(result.manifest?.name).toBe("from-cwd-file") + }) + + it("a local directory shadows a bundled fixture of the same name", () => { + const base = makeTmpDir() + writeDatasetDir(base, "provincial-budgets", "local-pb") + const result = loadDataset("provincial-budgets", [base]) + expect(result.manifest?.name).toBe("local-pb") + }) + + it("falls back to bundled fixtures by name", () => { + const base = makeTmpDir() + const result = loadDataset("provincial-budgets", [base]) + expect(result.manifest?.name).toBe("provincial-budgets") + expect(result.dataset?.entities).toContain("Ontario") + }) + + it("reports an error when nothing matches", () => { + const base = makeTmpDir() + const result = loadDataset("does-not-exist", [base]) + expect(result.dataset).toBeNull() + expect(result.diagnostics).toHaveLength(1) + expect(result.diagnostics[0]).toMatchObject({ severity: "error", code: "dataset-not-found" }) + }) +}) diff --git a/packages/charts2/src/cli/loadInputs.ts b/packages/charts2/src/cli/loadInputs.ts new file mode 100644 index 00000000000..e5367a2d934 --- /dev/null +++ b/packages/charts2/src/cli/loadInputs.ts @@ -0,0 +1,234 @@ +/** + * CLI input loading: definition files, dataset references, URL-style state. + * + * The CLI is the ONLY part of the package allowed to touch the filesystem + * (spec 28 §1); every parse step here delegates to pure src/core functions. + * + * A definition's `data` field resolves in order (spec 24): + * (a) a directory containing manifest.json + data.csv, + * (b) a single JSON file { manifest, rows }, + * (c) a bundled fixture name (provincial-budgets, government-debt, …), + * relative to the definition file's directory first, then the cwd. + */ + +import { existsSync, readFileSync, statSync } from "node:fs" +import { join, resolve } from "node:path" + +import type { ParsedRows } from "../core/data/parse.ts" +import { + buildDataset, + paramsToViewState, + parseCsv, + parseDefinition, + parseJsonRows, + parseManifest, + validateDataset, +} from "../core/index.ts" +import type { ChartDefinition, Dataset, Diagnostic, Manifest, TimeGrain, ViewState } from "../core/types.ts" +import { fixtureNames, fixtures, type FixtureName } from "../fixtures/index.ts" + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function ioDiagnostic(code: string, message: string, path: string): Diagnostic { + return { severity: "error", code, message, context: { path } } +} + +interface ReadJsonResult { + raw: unknown + diagnostics: Diagnostic[] +} + +/** Read + JSON.parse a file; failures become error Diagnostics, never throws. */ +export function readJsonFile(path: string, what: string): ReadJsonResult { + let text: string + try { + text = readFileSync(path, "utf8") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + raw: undefined, + diagnostics: [ioDiagnostic(`${what}-unreadable`, `Could not read ${what} file: ${message}`, path)], + } + } + try { + return { raw: JSON.parse(text), diagnostics: [] } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + raw: undefined, + diagnostics: [ioDiagnostic(`${what}-invalid-json`, `${what} file is not valid JSON: ${message}`, path)], + } + } +} + +/** + * validateDataset and buildDataset intentionally overlap (duplicate rows, + * bad times, non-numeric cells are reported by both so each is usable + * standalone). The CLI runs both, so drop repeats: two diagnostics with the + * same severity, code, and context describe the same problem. + */ +export function dedupeDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { + const seen = new Set() + const out: Diagnostic[] = [] + for (const diagnostic of diagnostics) { + const key = `${diagnostic.severity}|${diagnostic.code}|${JSON.stringify(diagnostic.context ?? diagnostic.message)}` + if (seen.has(key)) continue + seen.add(key) + out.push(diagnostic) + } + return out +} + +// --------------------------------------------------------------------------- +// loadDefinition +// --------------------------------------------------------------------------- + +export interface LoadDefinitionResult { + /** null when the file is unreadable/invalid (see diagnostics). */ + definition: ChartDefinition | null + diagnostics: Diagnostic[] +} + +/** Definition JSON file → migrate → parse, collecting all diagnostics. */ +export function loadDefinition(path: string): LoadDefinitionResult { + const { raw, diagnostics } = readJsonFile(path, "definition") + if (diagnostics.length > 0) return { definition: null, diagnostics } + // parseDefinition runs migrateDefinition internally and reports both. + return parseDefinition(raw) +} + +// --------------------------------------------------------------------------- +// loadDataset +// --------------------------------------------------------------------------- + +export interface LoadDatasetResult { + /** null when the dataset could not be built at all (see diagnostics). */ + dataset: Dataset | null + manifest: Manifest | null + /** Manifest parse + row parse + validateDataset + buildDataset, deduped. */ + diagnostics: Diagnostic[] +} + +/** The full diagnostic set for one raw manifest + row source. */ +function buildFromRaw(manifestRaw: unknown, rowsOf: (manifest: Manifest) => ParsedRows): LoadDatasetResult { + const { manifest, diagnostics: manifestDiagnostics } = parseManifest(manifestRaw) + if (manifest === null) { + return { dataset: null, manifest: null, diagnostics: manifestDiagnostics } + } + const parsed = rowsOf(manifest) + const validation = validateDataset(manifest, parsed.rows) + const built = buildDataset(manifest, parsed.rows) + return { + dataset: built.dataset, + manifest, + diagnostics: dedupeDiagnostics([ + ...manifestDiagnostics, + ...parsed.diagnostics, + ...validation, + ...built.diagnostics, + ]), + } +} + +/** Dataset form (a): a directory containing manifest.json + data.csv. */ +export function loadDatasetDir(dir: string): LoadDatasetResult { + const manifestPath = join(dir, "manifest.json") + const csvPath = join(dir, "data.csv") + const { raw, diagnostics } = readJsonFile(manifestPath, "manifest") + if (diagnostics.length > 0) return { dataset: null, manifest: null, diagnostics } + let csv: string + try { + csv = readFileSync(csvPath, "utf8") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + dataset: null, + manifest: null, + diagnostics: [ioDiagnostic("dataset-unreadable", `Could not read dataset CSV: ${message}`, csvPath)], + } + } + return buildFromRaw(raw, (manifest) => parseCsv(csv, manifest)) +} + +/** Dataset form (b): a single JSON file { manifest, rows }. */ +export function loadDatasetJsonFile(path: string): LoadDatasetResult { + const { raw, diagnostics } = readJsonFile(path, "dataset") + if (diagnostics.length > 0) return { dataset: null, manifest: null, diagnostics } + if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("manifest" in raw) || !("rows" in raw)) { + return { + dataset: null, + manifest: null, + diagnostics: [ + ioDiagnostic( + "dataset-invalid", + 'Dataset JSON file must be an object with "manifest" and "rows" fields', + path, + ), + ], + } + } + const shaped = raw as { manifest: unknown; rows: unknown } + return buildFromRaw(shaped.manifest, (manifest) => parseJsonRows(shaped.rows, manifest)) +} + +/** Dataset form (c): a bundled fixture name. */ +export function loadFixtureByName(name: FixtureName): LoadDatasetResult { + const fixture = fixtures[name] + return buildFromRaw(fixture.manifest, (manifest) => parseCsv(fixture.csv, manifest)) +} + +export function isFixtureName(dataRef: string): dataRef is FixtureName { + return Object.hasOwn(fixtures, dataRef) +} + +/** + * Resolve a definition's `data` reference: directory → JSON file → bundled + * fixture, trying each search directory in order (definition dir, then cwd). + */ +export function loadDataset( + dataRef: string, + searchDirs: readonly string[] = [process.cwd()], +): LoadDatasetResult { + for (const dir of searchDirs) { + const candidate = resolve(dir, dataRef) + const stat = statSync(candidate, { throwIfNoEntry: false }) + if (stat === undefined) continue + if (stat.isDirectory()) { + if (existsSync(join(candidate, "manifest.json"))) return loadDatasetDir(candidate) + continue + } + if (stat.isFile()) return loadDatasetJsonFile(candidate) + } + if (isFixtureName(dataRef)) return loadFixtureByName(dataRef) + return { + dataset: null, + manifest: null, + diagnostics: [ + { + severity: "error", + code: "dataset-not-found", + message: + `Dataset "${dataRef}" was not found as a directory (manifest.json + data.csv), ` + + `a JSON file ({manifest, rows}), or a bundled fixture (${fixtureNames.join(", ")})`, + context: { data: dataRef }, + }, + ], + } +} + +// --------------------------------------------------------------------------- +// parseState +// --------------------------------------------------------------------------- + +export interface ParseStateResult { + state: ViewState + diagnostics: Diagnostic[] +} + +/** URL-style state string ("tab=line&time=2014-15..2024-25&entities=ON~QC") → ViewState. */ +export function parseState(stateString: string, grain: TimeGrain): ParseStateResult { + const query = stateString.startsWith("?") ? stateString.slice(1) : stateString + return paramsToViewState(new URLSearchParams(query), grain) +} diff --git a/packages/charts2/src/cli/render.test.ts b/packages/charts2/src/cli/render.test.ts new file mode 100644 index 00000000000..ce81429d79c --- /dev/null +++ b/packages/charts2/src/cli/render.test.ts @@ -0,0 +1,210 @@ +import { spawnSync } from "node:child_process" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { afterEach, describe, expect, it } from "vitest" + +import { CliUsageError } from "./errors.ts" +import { + DEFAULT_HEIGHT, + DEFAULT_WIDTH, + XML_DECLARATION, + defaultFontsDir, + outputPathFor, + parseFormats, + rasterize, + renderDefinitionToSvg, + resolveRenderGeometry, +} from "./render.ts" + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..") + +const tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "bcds-cli-render-")) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop() as string, { recursive: true, force: true }) + } +}) + +function writeDefinition(dir: string, definition: Record): string { + const path = join(dir, "definition.json") + writeFileSync(path, JSON.stringify(definition)) + return path +} + +// --------------------------------------------------------------------------- +// Geometry: presets + aspect clamping (spec 24 test expectations) +// --------------------------------------------------------------------------- + +describe("resolveRenderGeometry", () => { + const table = [ + { flags: {}, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, chrome: "full", clamped: false }, + { flags: { preset: "social" }, width: 1200, height: 628, chrome: "full", clamped: false }, + { flags: { preset: "square" }, width: 1080, height: 1080, chrome: "full", clamped: false }, + { flags: { preset: "thumbnail" }, width: 300, height: 160, chrome: "thumbnail", clamped: false }, + { flags: { preset: "slide" }, width: 1920, height: 1080, chrome: "full", clamped: false }, + // explicit width/height override the preset size + { flags: { preset: "social", width: 600, height: 600 }, width: 600, height: 600, chrome: "full", clamped: false }, + // aspect > 2 → height raised + { flags: { width: 2000, height: 400 }, width: 2000, height: 1000, chrome: "full", clamped: true }, + // aspect < 0.5 → height lowered + { flags: { width: 400, height: 1000 }, width: 400, height: 800, chrome: "full", clamped: true }, + // --no-chrome wins over the preset chrome + { flags: { preset: "thumbnail", noChrome: true }, width: 300, height: 160, chrome: "none", clamped: false }, + ] as const + + it.each(table)("resolves %j", ({ flags, width, height, chrome, clamped }) => { + const geometry = resolveRenderGeometry({ ...flags }) + expect(geometry.width).toBe(width) + expect(geometry.height).toBe(height) + expect(geometry.chrome).toBe(chrome) + if (clamped) { + expect(geometry.diagnostics).toHaveLength(1) + expect(geometry.diagnostics[0]).toMatchObject({ severity: "warning", code: "aspect-clamped" }) + } else { + expect(geometry.diagnostics).toEqual([]) + } + }) + + it("rejects unknown presets as a usage error", () => { + expect(() => resolveRenderGeometry({ preset: "billboard" })).toThrow(CliUsageError) + }) +}) + +// --------------------------------------------------------------------------- +// Flag helpers +// --------------------------------------------------------------------------- + +describe("parseFormats", () => { + it("defaults to svg", () => { + expect(parseFormats(undefined)).toEqual(["svg"]) + }) + + it("accepts repeated flags and comma lists, deduped", () => { + expect(parseFormats(["svg", "png"])).toEqual(["svg", "png"]) + expect(parseFormats("svg,png,svg")).toEqual(["svg", "png"]) + }) + + it("rejects unknown formats as a usage error", () => { + expect(() => parseFormats("gif")).toThrow(CliUsageError) + }) +}) + +describe("outputPathFor", () => { + it("defaults to .", () => { + expect(outputPathFor(undefined, "my-chart", "svg", 1)).toBe("my-chart.svg") + }) + + it("uses an explicit --out verbatim for a single format", () => { + expect(outputPathFor("out/chart.svg", "my-chart", "svg", 1)).toBe("out/chart.svg") + }) + + it("swaps the extension per format when several formats are requested", () => { + expect(outputPathFor("out/chart.svg", "my-chart", "png", 2)).toBe("out/chart.png") + expect(outputPathFor("out/chart", "my-chart", "png", 2)).toBe("out/chart.png") + }) +}) + +// --------------------------------------------------------------------------- +// SVG pipeline +// --------------------------------------------------------------------------- + +describe("renderDefinitionToSvg", () => { + it("is byte-deterministic on the government-debt fixture", () => { + const dir = makeTmpDir() + const path = writeDefinition(dir, { + title: "Government debt", + data: "government-debt", + y: ["federal_debt", "provincial_debt"], + }) + const first = renderDefinitionToSvg({ definitionPath: path }) + const second = renderDefinitionToSvg({ definitionPath: path }) + expect(first.svg).not.toBeNull() + expect(first.svg).toContain(" d.severity === "error")).toEqual([]) + }) + + it("--transparent renders the backdrop with fill=\"transparent\"", () => { + const dir = makeTmpDir() + const path = writeDefinition(dir, { + title: "Test", + data: "provincial-budgets", + y: ["total_spending"], + }) + const result = renderDefinitionToSvg({ definitionPath: path, transparent: true }) + expect(result.svg).toContain('fill="transparent"') + }) + + it("returns null svg and error diagnostics for a broken definition", () => { + const dir = makeTmpDir() + const path = writeDefinition(dir, { title: "No y or data" }) + const result = renderDefinitionToSvg({ definitionPath: path }) + expect(result.svg).toBeNull() + expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// PNG smoke (skips with a clear message when the TTF cache is absent) +// --------------------------------------------------------------------------- + +describe("rasterize", () => { + it("renders a thumbnail PNG of provincial-budgets", (ctx) => { + if (!existsSync(defaultFontsDir())) { + console.warn( + "skipping PNG smoke test: .fonts-cache missing — run `bun run extract-font-metrics` in packages/charts2", + ) + ctx.skip() + return + } + const dir = makeTmpDir() + const path = writeDefinition(dir, { + title: "Provincial budgets", + data: "provincial-budgets", + y: ["total_spending"], + }) + const result = renderDefinitionToSvg({ definitionPath: path, preset: "thumbnail" }) + expect(result.svg).not.toBeNull() + const png = rasterize(result.svg as string, { + fontsDir: defaultFontsDir(), + width: result.width, + scale: 2, + }) + expect(png.length).toBeGreaterThan(0) + expect([...png.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]) + }) +}) + +// --------------------------------------------------------------------------- +// End-to-end spawn smoke test (the ONE spawned-process test) +// --------------------------------------------------------------------------- + +describe("bcds-charts render (spawned)", () => { + it("renders a definition file to SVG with exit code 0", () => { + const dir = makeTmpDir() + const definitionPath = writeDefinition(dir, { + title: "Spawn smoke", + data: "provincial-budgets", + y: ["total_spending"], + }) + const outPath = join(dir, "out.svg") + const proc = spawnSync("bun", ["src/cli/index.ts", "render", definitionPath, "--out", outPath], { + cwd: packageRoot, + encoding: "utf8", + }) + expect(proc.error).toBeUndefined() + expect(proc.status, proc.stderr).toBe(0) + expect(existsSync(outPath)).toBe(true) + expect(readFileSync(outPath, "utf8")).toContain(") → XML declaration → file + * or stdout; PNG via @resvg/resvg-js (loadSystemFonts: false, explicit TTF + * files from --fonts or the monorepo .fonts-cache). + * + * Determinism (spec 24 §3): the SVG string is a pure function of the + * definition + dataset + flags — flags are inputs. `--transparent` clones + * the scene with background "transparent" before SceneSVG renders it; the + * background rect then carries fill="transparent" (valid SVG/CSS, verified + * fully transparent under resvg). Error diagnostics → nothing is written. + */ + +import { Resvg } from "@resvg/resvg-js" +import { defineCommand } from "citty" +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs" +import { basename, dirname, extname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { createElement } from "react" +import { renderToStaticMarkup } from "react-dom/server" + +import { getTheme, layoutChart, resolveDefinitionTimes, type ChromeMode, type Theme } from "../core/index.ts" +import type { Diagnostic, Locale, ViewState } from "../core/types.ts" +import { SceneSVG } from "../react/SceneSVG.tsx" +import { CliFailure, CliUsageError, countErrors, hasErrors, printDiagnostics } from "./errors.ts" +import { loadDataset, loadDefinition, parseState } from "./loadInputs.ts" + +// --------------------------------------------------------------------------- +// Geometry: presets + aspect clamping (spec 24 §2) +// --------------------------------------------------------------------------- + +export interface RenderPreset { + width: number + height: number + chrome: ChromeMode +} + +export const PRESETS: Record = { + social: { width: 1200, height: 628, chrome: "full" }, + square: { width: 1080, height: 1080, chrome: "full" }, + thumbnail: { width: 300, height: 160, chrome: "thumbnail" }, + slide: { width: 1920, height: 1080, chrome: "full" }, +} + +export const DEFAULT_WIDTH = 850 +export const DEFAULT_HEIGHT = 600 +export const MIN_ASPECT = 0.5 +export const MAX_ASPECT = 2 + +export interface GeometryFlags { + preset?: string + width?: number + height?: number + noChrome?: boolean +} + +export interface RenderGeometry { + width: number + height: number + chrome: ChromeMode + diagnostics: Diagnostic[] +} + +/** + * Preset sets size + chrome; explicit --width/--height override the size; + * --no-chrome overrides the chrome. Aspect (width ÷ height) is clamped to + * [0.5, 2] by adjusting the height, with a warning. + */ +export function resolveRenderGeometry(flags: GeometryFlags): RenderGeometry { + const diagnostics: Diagnostic[] = [] + let width = DEFAULT_WIDTH + let height = DEFAULT_HEIGHT + let chrome: ChromeMode = "full" + + if (flags.preset !== undefined) { + const preset = PRESETS[flags.preset] + if (preset === undefined) { + throw new CliUsageError( + `Unknown preset "${flags.preset}" (expected one of: ${Object.keys(PRESETS).join(", ")})`, + ) + } + width = preset.width + height = preset.height + chrome = preset.chrome + } + if (flags.width !== undefined) width = flags.width + if (flags.height !== undefined) height = flags.height + + const aspect = width / height + if (aspect > MAX_ASPECT) { + const clamped = Math.round(width / MAX_ASPECT) + diagnostics.push({ + severity: "warning", + code: "aspect-clamped", + message: `Aspect ratio ${width}:${height} exceeds ${MAX_ASPECT}; height raised to ${clamped}`, + context: { width, height, clampedHeight: clamped }, + }) + height = clamped + } else if (aspect < MIN_ASPECT) { + const clamped = Math.round(width / MIN_ASPECT) + diagnostics.push({ + severity: "warning", + code: "aspect-clamped", + message: `Aspect ratio ${width}:${height} is below ${MIN_ASPECT}; height lowered to ${clamped}`, + context: { width, height, clampedHeight: clamped }, + }) + height = clamped + } + + if (flags.noChrome === true) chrome = "none" + return { width, height, chrome, diagnostics } +} + +// --------------------------------------------------------------------------- +// The render pipeline (also the unit under test — no process state) +// --------------------------------------------------------------------------- + +export const XML_DECLARATION = '' + +export interface RenderSvgOptions extends GeometryFlags { + definitionPath: string + /** URL-style view state overrides. */ + state?: string + themeName?: string + locale?: Locale + transparent?: boolean +} + +export interface RenderSvgResult { + /** null when any error diagnostic occurred — nothing should be written. */ + svg: string | null + slug: string + width: number + height: number + diagnostics: Diagnostic[] +} + +/** Definition file path → deterministic SVG string (spec 24 §3). */ +export function renderDefinitionToSvg(options: RenderSvgOptions): RenderSvgResult { + const geometry = resolveRenderGeometry(options) + const diagnostics: Diagnostic[] = [...geometry.diagnostics] + const fallbackSlug = basename(options.definitionPath).replace(/\.[^.]*$/, "") + const failed = (slug: string): RenderSvgResult => ({ + svg: null, + slug, + width: geometry.width, + height: geometry.height, + diagnostics, + }) + + const loaded = loadDefinition(options.definitionPath) + diagnostics.push(...loaded.diagnostics) + if (loaded.definition === null) return failed(fallbackSlug) + + let definition = loaded.definition + const slug = definition.slug ?? fallbackSlug + if (options.locale !== undefined) definition = { ...definition, locale: options.locale } + + const data = loadDataset(definition.data, [resolve(dirname(options.definitionPath)), process.cwd()]) + diagnostics.push(...data.diagnostics) + if (data.dataset === null || hasErrors(diagnostics)) return failed(slug) + + const grain = data.dataset.manifest.timeGrain + const resolvedTimes = resolveDefinitionTimes(definition, grain) + diagnostics.push(...resolvedTimes.diagnostics) + definition = resolvedTimes.definition + + let view: ViewState | undefined + if (options.state !== undefined) { + const parsedState = parseState(options.state, grain) + diagnostics.push(...parsedState.diagnostics) + view = parsedState.state + } + + let theme: Theme | undefined + if (options.themeName !== undefined) { + const lookup = getTheme(options.themeName) + if (lookup.warning !== undefined) { + diagnostics.push({ severity: "warning", code: "unknown-theme", message: lookup.warning }) + } + theme = lookup.theme + } + + let scene = layoutChart({ + definition, + dataset: data.dataset, + view, + theme, + size: { width: geometry.width, height: geometry.height }, + chrome: geometry.chrome, + }) + diagnostics.push(...scene.diagnostics) + if (hasErrors(diagnostics)) return failed(slug) + + if (options.transparent === true) scene = { ...scene, background: "transparent" } + + const markup = renderToStaticMarkup(createElement(SceneSVG, { scene, idPrefix: slug })) + return { + svg: `${XML_DECLARATION}\n${markup}`, + slug, + width: geometry.width, + height: geometry.height, + diagnostics, + } +} + +// --------------------------------------------------------------------------- +// PNG rasterization (spec 28 §3: explicit font files, never system fonts) +// --------------------------------------------------------------------------- + +/** ../../.fonts-cache relative to this file = the package root cache (src and dist). */ +export function defaultFontsDir(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "../../.fonts-cache") +} + +export function listFontFiles(fontsDir: string): string[] { + return readdirSync(fontsDir) + .filter((file) => file.toLowerCase().endsWith(".ttf")) + .sort() + .map((file) => join(fontsDir, file)) +} + +export interface RasterizeOptions { + fontsDir: string + /** Logical pixel width; the PNG is width × scale physical pixels. */ + width: number + scale: number +} + +export function rasterize(svg: string, options: RasterizeOptions): Buffer { + if (!existsSync(options.fontsDir)) { + throw new CliFailure( + `Fonts directory not found: ${options.fontsDir}\n` + + "PNG rasterization needs TTF copies of the brand fonts. Run `bun run extract-font-metrics` " + + "in packages/charts2 to regenerate the .fonts-cache, or pass --fonts pointing at " + + "licensed TTF copies.", + ) + } + const fontFiles = listFontFiles(options.fontsDir) + if (fontFiles.length === 0) { + throw new CliFailure( + `No .ttf files found in ${options.fontsDir}. Run \`bun run extract-font-metrics\` in ` + + "packages/charts2, or pass --fonts containing TTF copies of the brand fonts.", + ) + } + const resvg = new Resvg(svg, { + font: { loadSystemFonts: false, fontFiles }, + fitTo: { mode: "width", value: options.width * options.scale }, + }) + return resvg.render().asPng() +} + +// --------------------------------------------------------------------------- +// Flag parsing +// --------------------------------------------------------------------------- + +export type OutputFormat = "svg" | "png" + +/** --format svg --format png and --format svg,png both work; deduped, ordered. */ +export function parseFormats(value: string | string[] | undefined): OutputFormat[] { + if (value === undefined) return ["svg"] + const tokens = (Array.isArray(value) ? value : [value]) + .flatMap((entry) => entry.split(",")) + .map((token) => token.trim().toLowerCase()) + .filter((token) => token !== "") + if (tokens.length === 0) return ["svg"] + const formats: OutputFormat[] = [] + for (const token of tokens) { + if (token !== "svg" && token !== "png") { + throw new CliUsageError(`Unknown format "${token}" (expected svg or png)`) + } + if (!formats.includes(token)) formats.push(token) + } + return formats +} + +function parsePositiveInt(value: string | undefined, flag: string): number | undefined { + if (value === undefined) return undefined + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliUsageError(`--${flag} must be a positive integer, got "${value}"`) + } + return parsed +} + +function parseLocale(value: string | undefined): Locale | undefined { + if (value === undefined) return undefined + if (value !== "en" && value !== "fr") { + throw new CliUsageError(`--locale must be "en" or "fr", got "${value}"`) + } + return value +} + +/** + * Output path per format: default `.`; an explicit --out is + * used verbatim for a single format, and gets its svg/png extension swapped + * per format when several formats are requested. + */ +export function outputPathFor( + out: string | undefined, + slug: string, + format: OutputFormat, + formatCount: number, +): string { + if (out === undefined) return `${slug}.${format}` + if (formatCount === 1) return out + const extension = extname(out).toLowerCase() + const base = extension === ".svg" || extension === ".png" ? out.slice(0, -extension.length) : out + return `${base}.${format}` +} + +// --------------------------------------------------------------------------- +// The command +// --------------------------------------------------------------------------- + +interface RenderArgs { + definition: string + out?: string + format?: string | string[] + width?: string + height?: string + preset?: string + scale?: string + theme?: string + locale?: string + state?: string + transparent: boolean + chrome: boolean + fonts?: string +} + +export function runRender(args: RenderArgs): void { + if (args.out === "") throw new CliUsageError("--out requires a value") + const formats = parseFormats(args.format) + const scale = parsePositiveInt(typeof args.scale === "string" ? args.scale : undefined, "scale") ?? 2 + + const result = renderDefinitionToSvg({ + definitionPath: args.definition, + preset: args.preset, + width: parsePositiveInt(args.width, "width"), + height: parsePositiveInt(args.height, "height"), + noChrome: args.chrome === false, + state: args.state, + themeName: args.theme, + locale: parseLocale(args.locale), + transparent: args.transparent, + }) + + printDiagnostics(result.diagnostics) + if (result.svg === null) { + throw new CliFailure(`render failed: ${countErrors(result.diagnostics)} error(s)`) + } + + if (args.out === "-") { + if (formats.length !== 1 || formats[0] !== "svg") { + throw new CliUsageError('--out "-" (stdout) is only supported for the svg format') + } + process.stdout.write(`${result.svg}\n`) + return + } + + for (const format of formats) { + const path = outputPathFor(args.out, result.slug, format, formats.length) + const content = + format === "svg" + ? `${result.svg}\n` + : rasterize(result.svg, { + fontsDir: args.fonts ?? defaultFontsDir(), + width: result.width, + scale, + }) + try { + mkdirSync(dirname(resolve(path)), { recursive: true }) + writeFileSync(path, content) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new CliFailure(`could not write ${path}: ${message}`) + } + process.stderr.write(`wrote ${path}\n`) + } +} + +export const renderCommand = defineCommand({ + meta: { + name: "render", + description: "Render a chart definition to SVG/PNG", + }, + args: { + definition: { + type: "positional", + description: "Path to a chart definition JSON file", + required: true, + }, + out: { + type: "string", + description: 'Output path; "-" writes SVG to stdout (default .)', + }, + format: { + type: "string", + description: "svg | png; repeatable or comma-separated (default svg)", + }, + width: { type: "string", description: `Width in px (default ${DEFAULT_WIDTH})` }, + height: { type: "string", description: `Height in px (default ${DEFAULT_HEIGHT})` }, + preset: { + type: "string", + description: + "social (1200×628) | square (1080×1080) | thumbnail (300×160, minimal chrome) | slide (1920×1080)", + }, + scale: { type: "string", description: "PNG raster scale (default 2)" }, + theme: { type: "string", description: "Theme name (default from definition)" }, + locale: { type: "string", description: "en | fr (default from definition)" }, + state: { + type: "string", + description: 'URL-style view state, e.g. "tab=line&time=2014-15..2024-25&entities=ON~QC"', + }, + transparent: { type: "boolean", description: "No background fill", default: false }, + chrome: { + type: "boolean", + description: "Pass --no-chrome to render the plot only (no header/footer)", + default: true, + }, + fonts: { + type: "string", + description: "Directory of TTF files for PNG rasterization (default: the package .fonts-cache)", + }, + }, + run({ args }) { + runRender(args as unknown as RenderArgs) + }, +}) diff --git a/packages/charts2/src/cli/validate.test.ts b/packages/charts2/src/cli/validate.test.ts new file mode 100644 index 00000000000..cef13af0554 --- /dev/null +++ b/packages/charts2/src/cli/validate.test.ts @@ -0,0 +1,118 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +import { validateInput } from "./validate.ts" + +const tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "bcds-cli-validate-")) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop() as string, { recursive: true, force: true }) + } +}) + +function errorCodes(result: ReturnType): string[] { + return result.diagnostics.filter((d) => d.severity === "error").map((d) => d.code) +} + +function warningCodes(result: ReturnType): string[] { + return result.diagnostics.filter((d) => d.severity === "warning").map((d) => d.code) +} + +describe("validateInput", () => { + it("reports every expected error on the pathological fixture", () => { + const result = validateInput("pathological") + expect(result.kind).toBe("fixture") + expect(errorCodes(result)).toContain("duplicate-row") + expect(errorCodes(result)).toContain("non-numeric-cell") + expect(warningCodes(result)).toContain("zero-denominator") + expect(result.errors).toBeGreaterThanOrEqual(2) + }) + + it("flows pathological dataset errors through a definition that references it", () => { + const dir = makeTmpDir() + const path = join(dir, "definition.json") + writeFileSync(path, JSON.stringify({ title: "Bad data", data: "pathological", y: ["spending"] })) + const result = validateInput(path) + expect(result.kind).toBe("definition") + expect(errorCodes(result)).toContain("duplicate-row") + expect(errorCodes(result)).toContain("non-numeric-cell") + expect(result.errors).toBeGreaterThan(0) + }) + + it("passes a clean definition referencing a bundled fixture", () => { + const dir = makeTmpDir() + const path = join(dir, "definition.json") + writeFileSync(path, JSON.stringify({ title: "Good", data: "provincial-budgets", y: ["total_spending"] })) + const result = validateInput(path) + expect(result.errors).toBe(0) + expect(result.diagnostics).toEqual([]) + }) + + it("runs resolveBindings: a y column missing from the manifest is an error", () => { + const dir = makeTmpDir() + const path = join(dir, "definition.json") + writeFileSync(path, JSON.stringify({ title: "Bad y", data: "provincial-budgets", y: ["nonexistent"] })) + const result = validateInput(path) + expect(errorCodes(result)).toContain("unknown-y-column") + }) + + it("runs resolveDefinitionTimes: a malformed time bound is an error", () => { + const dir = makeTmpDir() + const path = join(dir, "definition.json") + writeFileSync( + path, + JSON.stringify({ + title: "Bad time", + data: "provincial-budgets", + y: ["total_spending"], + time: ["not-a-time", "latest"], + }), + ) + const result = validateInput(path) + expect(errorCodes(result)).toContain("bad-time-bound") + }) + + it("validates a dataset directory and reports duplicate rows", () => { + const dir = makeTmpDir() + const datasetDir = join(dir, "data") + mkdirSync(datasetDir) + writeFileSync( + join(datasetDir, "manifest.json"), + JSON.stringify({ + name: "dupes", + timeGrain: "year", + entity: { label: "thing", labelPlural: "things" }, + columns: { value: { name: "Value", type: "numeric" } }, + sources: [{ name: "Test" }], + }), + ) + writeFileSync(join(datasetDir, "data.csv"), "entity,time,value\nA,2020,1\nA,2020,2\n") + const result = validateInput(datasetDir) + expect(result.kind).toBe("dataset-dir") + expect(errorCodes(result)).toContain("duplicate-row") + }) + + it("validates a standalone manifest.json", () => { + const dir = makeTmpDir() + const path = join(dir, "manifest.json") + writeFileSync(path, JSON.stringify({ name: "incomplete" })) + const result = validateInput(path) + expect(result.kind).toBe("manifest") + expect(result.errors).toBeGreaterThan(0) + }) + + it("reports missing inputs", () => { + const result = validateInput("definitely-not-a-real-input") + expect(result.kind).toBeNull() + expect(errorCodes(result)).toEqual(["input-not-found"]) + }) +}) diff --git a/packages/charts2/src/cli/validate.ts b/packages/charts2/src/cli/validate.ts new file mode 100644 index 00000000000..ea259c3d8f5 --- /dev/null +++ b/packages/charts2/src/cli/validate.ts @@ -0,0 +1,145 @@ +/** + * bcds-charts validate — report ALL problems at once (spec 01 §8, spec 24). + * + * Accepts a chart definition file, a dataset directory (manifest.json + + * data.csv), a single manifest.json, a {manifest, rows} JSON file, or a + * bundled fixture name. The input kind is detected, the full diagnostic set + * runs (manifest parse, dataset validate, definition parse + resolveBindings + * + resolveDefinitionTimes when both sides are available), every Diagnostic + * prints to stderr one per line, and a summary line goes to stdout. + * Exit code 1 when any error, 0 otherwise. + */ + +import { defineCommand } from "citty" +import { existsSync, statSync } from "node:fs" +import { basename, dirname, join, resolve } from "node:path" + +import { parseDefinition, parseManifest, resolveBindings, resolveDefinitionTimes } from "../core/index.ts" +import type { Diagnostic } from "../core/types.ts" +import { CliFailure, countErrors, printDiagnostics } from "./errors.ts" +import { + isFixtureName, + loadDataset, + loadDatasetDir, + loadDatasetJsonFile, + loadFixtureByName, + readJsonFile, +} from "./loadInputs.ts" + +export type InputKind = "definition" | "dataset-dir" | "dataset-file" | "manifest" | "fixture" + +export interface ValidateResult { + kind: InputKind | null + diagnostics: Diagnostic[] + errors: number + warnings: number +} + +function summarize(kind: InputKind | null, diagnostics: Diagnostic[]): ValidateResult { + const errors = countErrors(diagnostics) + return { kind, diagnostics, errors, warnings: diagnostics.length - errors } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** Full validation of a definition: parse + dataset + bindings + times. */ +function validateDefinition(raw: unknown, baseDir: string): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + const parsed = parseDefinition(raw) + diagnostics.push(...parsed.diagnostics) + if (parsed.definition === null) return diagnostics + + const data = loadDataset(parsed.definition.data, [baseDir, process.cwd()]) + diagnostics.push(...data.diagnostics) + if (data.manifest === null) return diagnostics + + diagnostics.push(...resolveBindings(parsed.definition, data.manifest).diagnostics) + diagnostics.push(...resolveDefinitionTimes(parsed.definition, data.manifest.timeGrain).diagnostics) + return diagnostics +} + +/** Detect the input kind and run its full diagnostic set. Never throws. */ +export function validateInput(input: string): ValidateResult { + const path = resolve(input) + const stat = statSync(path, { throwIfNoEntry: false }) + + if (stat?.isDirectory()) { + if (!existsSync(join(path, "manifest.json"))) { + return summarize("dataset-dir", [ + { + severity: "error", + code: "manifest-missing", + message: `Directory has no manifest.json: ${path}`, + context: { path }, + }, + ]) + } + return summarize("dataset-dir", loadDatasetDir(path).diagnostics) + } + + if (stat?.isFile()) { + if (basename(path) === "manifest.json") { + // Validate the dataset around it when data.csv is also present. + if (existsSync(join(dirname(path), "data.csv"))) { + return summarize("manifest", loadDatasetDir(dirname(path)).diagnostics) + } + const { raw, diagnostics } = readJsonFile(path, "manifest") + if (diagnostics.length > 0) return summarize("manifest", diagnostics) + return summarize("manifest", parseManifest(raw).diagnostics) + } + + const { raw, diagnostics } = readJsonFile(path, "input") + if (diagnostics.length > 0) return summarize(null, diagnostics) + + if (isPlainObject(raw) && "manifest" in raw && "rows" in raw) { + return summarize("dataset-file", loadDatasetJsonFile(path).diagnostics) + } + if (isPlainObject(raw) && !("y" in raw) && !("data" in raw) && "columns" in raw && "timeGrain" in raw) { + return summarize("manifest", parseManifest(raw).diagnostics) + } + return summarize("definition", validateDefinition(raw, dirname(path))) + } + + if (isFixtureName(input)) { + return summarize("fixture", loadFixtureByName(input).diagnostics) + } + + return summarize(null, [ + { + severity: "error", + code: "input-not-found", + message: `Input "${input}" is not a file, directory, or bundled fixture name`, + context: { input }, + }, + ]) +} + +interface ValidateArgs { + input: string +} + +export function runValidate(args: ValidateArgs): void { + const result = validateInput(args.input) + printDiagnostics(result.diagnostics) + process.stdout.write(`${result.errors} errors, ${result.warnings} warnings\n`) + if (result.errors > 0) throw new CliFailure() +} + +export const validateCommand = defineCommand({ + meta: { + name: "validate", + description: "Validate a chart definition, dataset directory, or manifest — all errors at once", + }, + args: { + input: { + type: "positional", + description: "Definition JSON, dataset directory, manifest.json, {manifest, rows} JSON, or fixture name", + required: true, + }, + }, + run({ args }) { + runValidate(args as unknown as ValidateArgs) + }, +}) diff --git a/packages/charts2/src/core/color/categoricalAssigner.test.ts b/packages/charts2/src/core/color/categoricalAssigner.test.ts new file mode 100644 index 00000000000..647ba605324 --- /dev/null +++ b/packages/charts2/src/core/color/categoricalAssigner.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest" + +import { + assignColours, + createColourState, + FALLBACK_COLOUR, +} from "./categoricalAssigner.ts" + +const palette = ["#p0", "#p1", "#p2", "#p3", "#p4"] as const + +describe("createColourState", () => { + it("keeps the palette by reference and starts with no assignments", () => { + const state = createColourState(palette) + expect(state.palette).toBe(palette) + expect(state.assigned).toEqual({}) + }) + + it("is a plain serializable object that survives a JSON round-trip", () => { + const state = createColourState(palette) + assignColours(state, ["A", "B"]) + const revived = JSON.parse(JSON.stringify(state)) + expect(assignColours(revived, ["A", "B", "C"])).toEqual( + new Map([ + ["A", "#p0"], + ["B", "#p1"], + ["C", "#p2"], + ]), + ) + }) +}) + +describe("assignColours", () => { + it("assigns palette colours in palette order for fresh series", () => { + const state = createColourState(palette) + expect(assignColours(state, ["A", "B", "C"])).toEqual( + new Map([ + ["A", "#p0"], + ["B", "#p1"], + ["C", "#p2"], + ]), + ) + }) + + it("is deterministic: same inputs produce the same map twice", () => { + const run = () => { + const state = createColourState(palette) + const fixed = new Map([["B", "#fixed"]]) + return assignColours(state, ["A", "B", "C", "D"], fixed) + } + expect(run()).toEqual(run()) + }) + + it("returns identical colours when called again on the same state", () => { + const state = createColourState(palette) + const first = assignColours(state, ["A", "B", "C"]) + const second = assignColours(state, ["A", "B", "C"]) + expect(second).toEqual(first) + }) + + it("persists colours when series are removed and added (A,B,C → -B → +D,E)", () => { + const state = createColourState(palette) + const first = assignColours(state, ["A", "B", "C"]) + + // remove B, add D and E + const second = assignColours(state, ["A", "C", "D", "E"]) + + // surviving series never change colour + expect(second.get("A")).toBe(first.get("A")) + expect(second.get("C")).toBe(first.get("C")) + + // B keeps its reservation within the session: D and E take the + // next unused palette colours, not B's + expect(second.get("D")).toBe("#p3") + expect(second.get("E")).toBe("#p4") + + // re-adding B returns its original colour + const third = assignColours(state, ["A", "B", "C", "D", "E"]) + expect(third.get("B")).toBe(first.get("B")) + }) + + it("does not reshuffle colours when series are reordered", () => { + const state = createColourState(palette) + const first = assignColours(state, ["A", "B", "C"]) + const reordered = assignColours(state, ["C", "A", "B"]) + for (const key of ["A", "B", "C"]) { + expect(reordered.get(key)).toBe(first.get(key)) + } + }) + + it("gives fixed assignments precedence and skips their palette colours", () => { + const state = createColourState(palette) + const fixed = new Map([["B", "#p2"]]) + const result = assignColours(state, ["A", "B", "C"], fixed) + expect(result.get("B")).toBe("#p2") + // auto assignment skips #p2 even though B comes after A + expect(result.get("A")).toBe("#p0") + expect(result.get("C")).toBe("#p1") + }) + + it("claims fixed palette colours even before the fixed series is reached", () => { + const state = createColourState(palette) + const fixed = new Map([["Z", "#p0"]]) + // Z is not even in seriesKeys, but its colour is claimed + const result = assignColours(state, ["A"], fixed) + expect(result.get("A")).toBe("#p1") + }) + + it("lets fixed override a previously cached colour", () => { + const state = createColourState(palette) + expect(assignColours(state, ["A"]).get("A")).toBe("#p0") + const fixed = new Map([["A", "#brand"]]) + expect(assignColours(state, ["A"], fixed).get("A")).toBe("#brand") + // the override is cached: it sticks even without the fixed map + expect(assignColours(state, ["A"]).get("A")).toBe("#brand") + }) + + it("supports off-palette fixed colours without disturbing palette order", () => { + const state = createColourState(palette) + const fixed = new Map([["B", "#off-palette"]]) + const result = assignColours(state, ["A", "B", "C"], fixed) + expect(result.get("A")).toBe("#p0") + expect(result.get("B")).toBe("#off-palette") + expect(result.get("C")).toBe("#p1") + }) + + it("repeats colours least-used-first when the palette is exhausted", () => { + const small = ["#p0", "#p1", "#p2"] + const state = createColourState(small) + const result = assignColours(state, ["A", "B", "C", "D", "E", "F", "G"]) + expect([...result.values()]).toEqual([ + "#p0", + "#p1", + "#p2", + "#p0", + "#p1", + "#p2", + "#p0", + ]) + }) + + it("falls back to black on an empty palette", () => { + const state = createColourState([]) + expect(assignColours(state, ["A"]).get("A")).toBe(FALLBACK_COLOUR) + }) + + it("returns the map in seriesKeys order", () => { + const state = createColourState(palette) + const result = assignColours(state, ["C", "A", "B"]) + expect([...result.keys()]).toEqual(["C", "A", "B"]) + }) +}) diff --git a/packages/charts2/src/core/color/categoricalAssigner.ts b/packages/charts2/src/core/color/categoricalAssigner.ts new file mode 100644 index 00000000000..fb18439ef62 --- /dev/null +++ b/packages/charts2/src/core/color/categoricalAssigner.ts @@ -0,0 +1,112 @@ +/** + * Categorical colour assignment (spec 04 §2). + * + * Pure-function port of owid-grapher's CategoricalColorAssigner + * (color/CategoricalColorAssigner.ts) and getLeastUsedColor + * (color/ColorUtils.ts), de-MobX'd and de-classed. + * + * Assignment rules: + * 1. Fixed assignments (per-chart entityColours / column colour / entity + * registry colour, pre-resolved by the caller into one map) win and + * claim their colours up front — auto-assignment skips colours claimed + * anywhere in the fixed map, even for series later in the list. + * 2. A series already in the state keeps its colour (persistence). + * 3. Otherwise the series takes the least-used palette colour, ties broken + * by palette order — so fresh assignment walks the palette in order, + * skipping claimed colours, and repeats least-used-first once the + * palette is exhausted. + */ + +import type { HexColour, SeriesKey } from "../types.ts" + +/** Returned when the palette is empty (mirrors OWID's "#000" fallback). */ +export const FALLBACK_COLOUR: HexColour = "#000000" + +/** + * Session memory for colour assignment. A plain serializable object — no + * classes, no Maps — so it can round-trip through JSON (e.g. across render + * modes or worker boundaries). + */ +export interface ColourState { + /** Ordered categorical palette (theme.palette.categorical, by reference). */ + palette: readonly HexColour[] + /** + * Every series ever assigned in this session, with its colour. + * + * Removed series deliberately KEEP their reservation (this matches + * OWID's autoColorMapCache): spec 04 guarantees surviving series never + * change colour within a session, and a removed-then-re-added series + * gets its original colour back. There is intentionally no + * releaseColour — freeing a removed series' colour would hand it to the + * next new series and break that guarantee. + */ + assigned: Record +} + +export function createColourState(palette: readonly HexColour[]): ColourState { + return { palette, assigned: {} } +} + +/** + * Port of OWID getLeastUsedColor: the first unused palette colour in + * palette order, else the least-used one (ties broken by palette order). + */ +function leastUsedColour( + palette: readonly HexColour[], + usedColours: readonly HexColour[], +): HexColour { + if (palette.length === 0) return FALLBACK_COLOUR + const counts = new Map() + for (const colour of usedColours) { + counts.set(colour, (counts.get(colour) ?? 0) + 1) + } + let best = palette[0] + let bestCount = Number.POSITIVE_INFINITY + for (const colour of palette) { + const count = counts.get(colour) ?? 0 + if (count === 0) return colour + if (count < bestCount) { + best = colour + bestCount = count + } + } + return best +} + +/** + * Colours currently claimed: the session memory overlaid with this call's + * fixed assignments (fixed wins per key — mirrors OWID merging + * autoColorMapCache then colorMap). Fixed entries whose series are not in + * this call's seriesKeys still claim their colours. + */ +function usedColours( + state: ColourState, + fixed: ReadonlyMap, +): HexColour[] { + const merged: Record = { ...state.assigned } + for (const [key, colour] of fixed) merged[key] = colour + return Object.values(merged) +} + +/** + * Assign a colour to every series key, in order. MUTATES state.assigned — + * the state is the session memory that makes assignment persistent across + * calls. Returns a fresh Map in seriesKeys order. + */ +export function assignColours( + state: ColourState, + seriesKeys: readonly SeriesKey[], + fixed: ReadonlyMap = new Map(), +): Map { + const result = new Map() + for (const key of seriesKeys) { + let colour = fixed.get(key) + if (colour === undefined) colour = state.assigned[key] + if (colour === undefined) { + colour = leastUsedColour(state.palette, usedColours(state, fixed)) + } + state.assigned[key] = colour + result.set(key, colour) + } + return result +} diff --git a/packages/charts2/src/core/color/index.ts b/packages/charts2/src/core/color/index.ts new file mode 100644 index 00000000000..93b1d90e597 --- /dev/null +++ b/packages/charts2/src/core/color/index.ts @@ -0,0 +1 @@ +export * from "./categoricalAssigner.ts" diff --git a/packages/charts2/src/core/data/dataset.test.ts b/packages/charts2/src/core/data/dataset.test.ts new file mode 100644 index 00000000000..a808e3414c2 --- /dev/null +++ b/packages/charts2/src/core/data/dataset.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest" + +import { federalDepartments } from "../../fixtures/federal-departments.ts" +import { buildDataset, buildEntityResolver } from "./dataset.ts" +import { parseManifest } from "./manifest.ts" +import { parseCsv } from "./parse.ts" + +function load(rawManifest: Record, csv: string) { + const { manifest, diagnostics } = parseManifest(rawManifest) + if (manifest === null) throw new Error(diagnostics.map((d) => d.message).join("; ")) + const parsed = parseCsv(csv, manifest) + return { manifest, ...buildDataset(manifest, parsed.rows) } +} + +const simpleManifest = { + name: "test", + timeGrain: "year", + columns: { spending: {} }, +} + +describe("buildDataset", () => { + it("orders entities by first appearance", () => { + const { dataset } = load( + simpleManifest, + "entity,time,spending\nQuebec,2021,1\nOntario,2020,2\nQuebec,2020,3\nAlberta,2021,4\n", + ) + expect(dataset.entities).toEqual(["Quebec", "Ontario", "Alberta"]) + }) + + it("collects sorted unique time ordinals", () => { + const { dataset } = load( + simpleManifest, + "entity,time,spending\nQuebec,2021,1\nOntario,2019,2\nQuebec,2019,3\nQuebec,2023,4\n", + ) + expect(dataset.times).toEqual([2019, 2021, 2023]) + }) + + it("aligns column values with rowIndexOf", () => { + const { dataset } = load(simpleManifest, "entity,time,spending\nQuebec,2021,1.5\nOntario,2020,\n") + const spending = dataset.columns.get("spending")! + const quebecRow = dataset.rowIndexOf("Quebec", 2021) + const ontarioRow = dataset.rowIndexOf("Ontario", 2020) + expect(spending.values[quebecRow]).toBe(1.5) + expect(spending.values[ontarioRow]).toBeNull() + }) + + it("returns -1 for unknown (entity, time) lookups", () => { + const { dataset } = load(simpleManifest, "entity,time,spending\nQuebec,2021,1\n") + expect(dataset.rowIndexOf("Quebec", 2020)).toBe(-1) + expect(dataset.rowIndexOf("Ontario", 2021)).toBe(-1) + }) + + it("flags duplicate (entity, time) rows and keeps the first", () => { + const { dataset, diagnostics } = load( + simpleManifest, + "entity,time,spending\nQuebec,2021,1\nQuebec,2021,2\n", + ) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ + severity: "error", + code: "duplicate-row", + context: { row: 2, firstRow: 1, entity: "Quebec", time: 2021 }, + }) + const spending = dataset.columns.get("spending")! + expect(spending.values[dataset.rowIndexOf("Quebec", 2021)]).toBe(1) + }) + + it("flags unparseable times and skips those rows", () => { + const { dataset, diagnostics } = load( + simpleManifest, + "entity,time,spending\nQuebec,2021,1\nOntario,not-a-year,2\n", + ) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ severity: "error", code: "bad-time", context: { row: 2 } }) + expect(dataset.entities).toEqual(["Quebec"]) + }) + + it("builds none-grain datasets with empty times and null-time row lookup", () => { + const { dataset } = load( + { name: "snapshot", timeGrain: "none", columns: { population: {} } }, + "entity,population\nOntario,100\nQuebec,200\n", + ) + expect(dataset.times).toEqual([]) + expect(dataset.rowIndexOf("Quebec", null)).toBeGreaterThanOrEqual(0) + expect(dataset.columns.get("population")!.values[dataset.rowIndexOf("Quebec", null)]).toBe(200) + }) + + it("treats undeclared CSV columns as absent from the dataset", () => { + const { dataset } = load(simpleManifest, "entity,time,spending,mystery\nQuebec,2021,1,9\n") + expect(dataset.columns.has("mystery")).toBe(false) + }) +}) + +describe("entity alias resolution", () => { + it("resolves aliases and French names to the canonical entity", () => { + const resolve = buildEntityResolver({ + name: "test", + timeGrain: "year", + fiscalYearStartMonth: 4, + entity: { label: "province", labelPlural: "provinces" }, + columns: {}, + entities: [{ name: "Quebec", nameFr: "Québec", aliases: ["QC", "Province of Quebec"] }], + sources: [], + }) + expect(resolve("Quebec")).toBe("Quebec") + expect(resolve("Québec")).toBe("Quebec") + expect(resolve("QC")).toBe("Quebec") + expect(resolve("Province of Quebec")).toBe("Quebec") + expect(resolve("Ontario")).toBe("Ontario") // unknown names pass through + }) + + it("merges aliased rows into one entity series (federal-departments fixture)", () => { + const { manifest } = parseManifest(federalDepartments.manifest) + const parsed = parseCsv(federalDepartments.csv, manifest!) + const { dataset, diagnostics } = buildDataset(manifest!, parsed.rows) + + expect(diagnostics).toEqual([]) + expect(dataset.entities).toHaveLength(15) + expect(dataset.entities).not.toContain("Industry Canada") + expect(dataset.entities).not.toContain("DFAIT") + + // "Industry Canada" 2019-20/2020-21 rows landed under the canonical name + const ised = "Innovation, Science and Economic Development Canada" + const spending = dataset.columns.get("spending")! + expect(spending.values[dataset.rowIndexOf(ised, 2019)]).toBe(50) + expect(spending.values[dataset.rowIndexOf(ised, 2023)]).toBe(54) + expect(spending.values[dataset.rowIndexOf("Global Affairs Canada", 2019)]).toBe(60) + }) +}) diff --git a/packages/charts2/src/core/data/dataset.ts b/packages/charts2/src/core/data/dataset.ts new file mode 100644 index 00000000000..30d7434027d --- /dev/null +++ b/packages/charts2/src/core/data/dataset.ts @@ -0,0 +1,117 @@ +/** + * Dataset assembly: manifest + raw rows → Dataset. Spec 01 §2, §5. + * + * - Entity order is order of first appearance (after alias resolution). + * - times are the sorted unique ordinals present in the data. + * - Column values are row-aligned arrays; rowIndexOf(entity, time) finds + * the row via an internal Map. + * - Duplicate (entity, time) rows are error Diagnostics; the first row wins. + */ + +import type { CellValue, ColumnData, Dataset, Diagnostic, Manifest, TimeOrdinal } from "../types.ts" +import type { RawRow } from "./parse.ts" +import { compareTimes, parseTime } from "./time.ts" + +/** + * Build an entity-name resolver from the manifest's optional entities list: + * canonical names, French names, and aliases all resolve to the canonical + * name; unknown names pass through unchanged (validateDataset warns). + */ +export function buildEntityResolver(manifest: Manifest): (name: string) => string { + const lookup = new Map() + for (const entity of manifest.entities ?? []) { + lookup.set(entity.name, entity.name) + if (entity.nameFr !== undefined) lookup.set(entity.nameFr, entity.name) + for (const alias of entity.aliases ?? []) lookup.set(alias, entity.name) + } + return (name: string) => lookup.get(name) ?? name +} + +function rowKey(entity: string, time: TimeOrdinal | null): string { + return `${entity} ${time}` +} + +export interface BuildDatasetResult { + dataset: Dataset + diagnostics: Diagnostic[] +} + +export function buildDataset(manifest: Manifest, rows: readonly RawRow[]): BuildDatasetResult { + const diagnostics: Diagnostic[] = [] + const resolveEntity = buildEntityResolver(manifest) + const hasTime = manifest.timeGrain !== "none" + + interface KeptRow { + entity: string + time: TimeOrdinal | null + cells: RawRow + } + + const kept: KeptRow[] = [] + const entities: string[] = [] + const seenEntities = new Set() + const timeSet = new Set() + const indexByKey = new Map() + const firstRowByKey = new Map() + + for (let i = 0; i < rows.length; i++) { + const rowNumber = i + 1 + const cells = rows[i] + const entity = resolveEntity(typeof cells.entity === "string" ? cells.entity : String(cells.entity ?? "")) + + let time: TimeOrdinal | null = null + if (hasTime) { + const rawTime = cells.time + time = typeof rawTime === "string" || typeof rawTime === "number" ? parseTime(rawTime, manifest.timeGrain) : null + if (time === null) { + diagnostics.push({ + severity: "error", + code: "bad-time", + message: `Row ${rowNumber}: time "${cells.time}" does not parse under grain "${manifest.timeGrain}"`, + context: { row: rowNumber, value: String(cells.time), grain: manifest.timeGrain }, + }) + continue + } + } + + const key = rowKey(entity, time) + const firstRow = firstRowByKey.get(key) + if (firstRow !== undefined) { + diagnostics.push({ + severity: "error", + code: "duplicate-row", + message: `Row ${rowNumber} duplicates (${entity}${time !== null ? `, ${time}` : ""}) first seen at row ${firstRow}; the first row wins`, + context: { row: rowNumber, firstRow, entity, ...(time !== null ? { time } : {}) }, + }) + continue + } + + firstRowByKey.set(key, rowNumber) + indexByKey.set(key, kept.length) + kept.push({ entity, time, cells }) + + if (!seenEntities.has(entity)) { + seenEntities.add(entity) + entities.push(entity) + } + if (time !== null) timeSet.add(time) + } + + const times = [...timeSet].sort(compareTimes) + + const columns = new Map() + for (const [slug, meta] of Object.entries(manifest.columns)) { + const values: CellValue[] = kept.map((row) => row.cells[slug] ?? null) + columns.set(slug, { slug, meta, values }) + } + + const dataset: Dataset = { + manifest, + entities, + times, + rowIndexOf: (entity: string, time: TimeOrdinal | null) => indexByKey.get(rowKey(entity, time)) ?? -1, + columns, + } + + return { dataset, diagnostics } +} diff --git a/packages/charts2/src/core/data/derived.test.ts b/packages/charts2/src/core/data/derived.test.ts new file mode 100644 index 00000000000..1b8732e0d3c --- /dev/null +++ b/packages/charts2/src/core/data/derived.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest" + +import type { Dataset } from "../types.ts" +import { buildDataset } from "./dataset.ts" +import { resolveValue } from "./derived.ts" +import { parseManifest } from "./manifest.ts" +import { parseCsv } from "./parse.ts" + +function makeDataset(rawManifest: Record, csv: string): Dataset { + const { manifest, diagnostics } = parseManifest(rawManifest) + if (manifest === null) throw new Error(diagnostics.map((d) => d.message).join("; ")) + const parsed = parseCsv(csv, manifest) + return buildDataset(manifest, parsed.rows).dataset +} + +const debtManifest = { + name: "debt", + timeGrain: "year", + columns: { + debt: { denominator: "gdp", derivedUnit: "% of GDP", displayFactor: 100 }, + gdp: {}, + plain: {}, + }, +} + +const debtCsv = `entity,time,debt,gdp,plain +Canada,2019,1100,2200,7 +Canada,2020,1200,2000,8 +Canada,2021,1224,,9 +Canada,2022,,2500,10 +` + +describe("resolveValue without a denominator", () => { + const dataset = makeDataset(debtManifest, debtCsv) + + it("returns the raw value times displayFactor (default 1)", () => { + expect(resolveValue(dataset, "plain", "Canada", 2019)).toEqual({ + status: "value", + value: 7, + time: 2019, + sourceTime: 2019, + projected: false, + interpolated: false, + }) + }) + + it("applies displayFactor to plain columns", () => { + expect(resolveValue(dataset, "plain", "Canada", 2019, { displayFactor: 1000 })).toMatchObject({ + status: "value", + value: 7000, + }) + }) + + it("is missing (no-data), never zero, for an absent cell", () => { + expect(resolveValue(dataset, "debt", "Canada", 2022)).toEqual({ status: "missing", reason: "no-data" }) + }) + + it("is missing (no-data) for an unknown entity or column", () => { + expect(resolveValue(dataset, "plain", "Atlantis", 2019)).toEqual({ status: "missing", reason: "no-data" }) + expect(resolveValue(dataset, "nope", "Canada", 2019)).toEqual({ status: "missing", reason: "no-data" }) + }) +}) + +describe("resolveValue with a denominator (spec 01 §7)", () => { + const dataset = makeDataset(debtManifest, debtCsv) + + it("divides per (entity, time) and applies displayFactor AFTER division", () => { + const resolved = resolveValue(dataset, "debt", "Canada", 2019) + expect(resolved).toEqual({ + status: "value", + value: 50, // 1100 / 2200 × 100 + time: 2019, + sourceTime: 2019, + projected: false, + interpolated: false, + raw: { numerator: 1100, denominator: 2200 }, + }) + }) + + it("attaches the unscaled numerator and denominator for auditability", () => { + const resolved = resolveValue(dataset, "debt", "Canada", 2020) + expect(resolved).toMatchObject({ value: 60, raw: { numerator: 1200, denominator: 2000 } }) + }) + + it("is missing (zero-denominator) when the denominator is missing after tolerance", () => { + expect(resolveValue(dataset, "debt", "Canada", 2021)).toEqual({ + status: "missing", + reason: "zero-denominator", + }) + }) + + it("is missing (zero-denominator) when the denominator is zero — never Infinity", () => { + const zeroDataset = makeDataset(debtManifest, "entity,time,debt,gdp,plain\nCanada,2019,1100,0,7\n") + expect(resolveValue(zeroDataset, "debt", "Canada", 2019)).toEqual({ + status: "missing", + reason: "zero-denominator", + }) + }) + + it("is missing (no-data) when the numerator is missing, even if the denominator is too", () => { + const bothMissing = makeDataset(debtManifest, "entity,time,debt,gdp,plain\nCanada,2019,,,7\n") + expect(resolveValue(bothMissing, "debt", "Canada", 2019)).toEqual({ status: "missing", reason: "no-data" }) + }) + + it("resolves the denominator with the DENOMINATOR column's own tolerance", () => { + const tolerant = makeDataset( + { + name: "debt", + timeGrain: "year", + columns: { + debt: { denominator: "gdp", displayFactor: 100 }, + gdp: { tolerance: 1 }, + }, + }, + "entity,time,debt,gdp\nCanada,2019,1100,2200\nCanada,2020,1200,\n", + ) + // gdp missing at 2020 borrows 2019's 2200 via its own tolerance + expect(resolveValue(tolerant, "debt", "Canada", 2020)).toMatchObject({ + status: "value", + value: (1200 / 2200) * 100, + raw: { numerator: 1200, denominator: 2200 }, + }) + }) +}) + +describe("resolveValue tolerance and overrides", () => { + const tolerantManifest = { + name: "test", + timeGrain: "year", + columns: { spending: { tolerance: 2, toleranceDirection: "backwards" } }, + } + const csv = "entity,time,spending\nCanada,2019,10\nCanada,2020,\nCanada,2021,\n" + + it("borrows within the column's tolerance and reports the sourceTime", () => { + const dataset = makeDataset(tolerantManifest, csv) + expect(resolveValue(dataset, "spending", "Canada", 2021)).toEqual({ + status: "value", + value: 10, + time: 2021, + sourceTime: 2019, + projected: false, + interpolated: false, + }) + }) + + it("per-binding overrides replace the column's tolerance", () => { + const dataset = makeDataset(tolerantManifest, csv) + expect(resolveValue(dataset, "spending", "Canada", 2021, { tolerance: 0 })).toEqual({ + status: "missing", + reason: "no-data", + }) + }) +}) + +describe("resolveValue projection flags", () => { + it("marks every value of a projection: true column", () => { + const dataset = makeDataset( + { name: "test", timeGrain: "year", columns: { forecast: { projection: true } } }, + "entity,time,forecast\nCanada,2019,1\n", + ) + expect(resolveValue(dataset, "forecast", "Canada", 2019)).toMatchObject({ projected: true }) + }) + + it("marks values at/after projectionFrom, judged on the sourceTime", () => { + const dataset = makeDataset( + { name: "test", timeGrain: "year", columns: { spending: { projectionFrom: 2021, tolerance: 1 } } }, + "entity,time,spending\nCanada,2020,10\nCanada,2021,11\nCanada,2022,\n", + ) + expect(resolveValue(dataset, "spending", "Canada", 2020)).toMatchObject({ projected: false }) + expect(resolveValue(dataset, "spending", "Canada", 2021)).toMatchObject({ projected: true }) + // 2022 borrows from 2021 (sourceTime 2021 >= projectionFrom 2021) → projected + expect(resolveValue(dataset, "spending", "Canada", 2022)).toMatchObject({ + projected: true, + sourceTime: 2021, + }) + }) +}) + +describe("resolveValue on a none-grain dataset", () => { + const dataset = makeDataset( + { name: "snapshot", timeGrain: "none", columns: { population: {} } }, + "entity,population\nOntario,15608000\nQuebec,\n", + ) + + it("resolves cells with a null time", () => { + expect(resolveValue(dataset, "population", "Ontario", null)).toMatchObject({ + status: "value", + value: 15608000, + time: 0, + sourceTime: 0, + }) + }) + + it("missing cells stay missing", () => { + expect(resolveValue(dataset, "population", "Quebec", null)).toEqual({ + status: "missing", + reason: "no-data", + }) + }) +}) diff --git a/packages/charts2/src/core/data/derived.ts b/packages/charts2/src/core/data/derived.ts new file mode 100644 index 00000000000..49109596607 --- /dev/null +++ b/packages/charts2/src/core/data/derived.ts @@ -0,0 +1,142 @@ +/** + * Derived value resolution — THE single data-access path. Spec 01 §7. + * + * Every chart, tooltip, table cell and CSV download obtains cell values + * through resolveValue, so "missing ≠ zero", tolerance borrowing, + * denominator division, displayFactor and projection flagging behave + * identically on every surface. + * + * Pipeline per (column, entity, time): + * 1. Resolve the numerator with the column's own tolerance. + * 2. If the column declares a denominator, resolve it per (entity, time) + * with the DENOMINATOR column's tolerance; missing-after-tolerance or + * zero ⇒ missing ("zero-denominator"), never Infinity or 0. + * 3. value = numerator [/ denominator] × displayFactor + * (displayFactor applies AFTER division — e.g. ×1,000 per-capita rates). + * 4. Denominator-derived cells carry raw {numerator, denominator} for + * auditability (tooltip detail line, table download). + */ + +import type { CellValue, ColumnData, ColumnMeta, Dataset, ResolvedValue, TimeOrdinal } from "../types.ts" +import { resolveWithTolerance } from "./tolerance.ts" + +const MISSING_NO_DATA: ResolvedValue = { status: "missing", reason: "no-data" } +const MISSING_ZERO_DENOMINATOR: ResolvedValue = { status: "missing", reason: "zero-denominator" } + +interface ResolvedCell { + value: CellValue + sourceTime: TimeOrdinal +} + +/** + * Resolve one column's cell for an entity at a time, applying tolerance. + * For "none"-grain datasets (time === null) the lookup is direct and the + * reported sourceTime is 0. + */ +function resolveCell( + dataset: Dataset, + column: ColumnData, + entity: string, + time: TimeOrdinal | null, + tolerance: number, + direction: ColumnMeta["toleranceDirection"], +): ResolvedCell | null { + if (time === null) { + const row = dataset.rowIndexOf(entity, null) + if (row < 0) return null + const value = column.values[row] + if (value === null || value === undefined) return null + return { value, sourceTime: 0 } + } + + // Fast path: exact hit needs no series scan. + const row = dataset.rowIndexOf(entity, time) + if (row >= 0) { + const value = column.values[row] + if (value !== null && value !== undefined) return { value, sourceTime: time } + } + if (tolerance <= 0) return null + + // Build the entity's series over the dataset's sorted times and borrow. + const times: TimeOrdinal[] = [] + const values: CellValue[] = [] + for (const t of dataset.times) { + const r = dataset.rowIndexOf(entity, t) + if (r < 0) continue + times.push(t) + values.push(column.values[r]) + } + return resolveWithTolerance(times, values, time, tolerance, direction) +} + +/** Merge per-binding overrides into column metadata, ignoring undefined entries. */ +function mergeMeta(meta: ColumnMeta, overrides?: Partial): ColumnMeta { + if (overrides === undefined) return meta + const merged: ColumnMeta = { ...meta } + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) { + ;(merged as unknown as Record)[key] = value + } + } + return merged +} + +/** + * Resolve the display value of `columnSlug` for `entity` at `time` + * (null for "none"-grain datasets). `overrides` are per-binding column + * metadata overrides from the chart definition (spec 02). + */ +export function resolveValue( + dataset: Dataset, + columnSlug: string, + entity: string, + time: TimeOrdinal | null, + overrides?: Partial, +): ResolvedValue { + const column = dataset.columns.get(columnSlug) + if (column === undefined) return MISSING_NO_DATA + + const meta = mergeMeta(column.meta, overrides) + + const numerator = resolveCell(dataset, column, entity, time, meta.tolerance, meta.toleranceDirection) + if (numerator === null || typeof numerator.value !== "number") return MISSING_NO_DATA + + let value = numerator.value + let raw: { numerator: number; denominator: number } | undefined + + if (meta.denominator !== undefined) { + const denominatorColumn = dataset.columns.get(meta.denominator) + const denominator = + denominatorColumn === undefined + ? null + : resolveCell( + dataset, + denominatorColumn, + entity, + time, + denominatorColumn.meta.tolerance, + denominatorColumn.meta.toleranceDirection, + ) + if (denominator === null || typeof denominator.value !== "number" || denominator.value === 0) { + return MISSING_ZERO_DENOMINATOR + } + raw = { numerator: numerator.value, denominator: denominator.value } + value = numerator.value / denominator.value + } + + value *= meta.displayFactor + + const requestedTime = time ?? 0 + const projected = + meta.projection || (meta.projectionFrom !== undefined && numerator.sourceTime >= meta.projectionFrom) + + return { + status: "value", + value, + time: requestedTime, + sourceTime: numerator.sourceTime, + projected, + interpolated: false, + ...(raw !== undefined ? { raw } : {}), + } +} diff --git a/packages/charts2/src/core/data/index.ts b/packages/charts2/src/core/data/index.ts new file mode 100644 index 00000000000..6039f651bf7 --- /dev/null +++ b/packages/charts2/src/core/data/index.ts @@ -0,0 +1,10 @@ +// Data layer (M1): parsing, validation, time grains, tolerance, derived values. +// Spec 01 (data format), spec 08 §4 (tolerance). Pure functions, no I/O. + +export * from "./dataset.ts" +export * from "./derived.ts" +export * from "./manifest.ts" +export * from "./parse.ts" +export * from "./time.ts" +export * from "./tolerance.ts" +export * from "./validate.ts" diff --git a/packages/charts2/src/core/data/manifest.test.ts b/packages/charts2/src/core/data/manifest.test.ts new file mode 100644 index 00000000000..4bc5d1d5787 --- /dev/null +++ b/packages/charts2/src/core/data/manifest.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest" + +import { parseManifest } from "./manifest.ts" + +const minimal = { + name: "test", + timeGrain: "year", + columns: { total_spending: {} }, +} + +describe("parseManifest defaults", () => { + it("applies column defaults: displayFactor 1, tolerance 0, direction both, projection false", () => { + const { manifest, diagnostics } = parseManifest(minimal) + expect(diagnostics).toEqual([]) + const column = manifest!.columns.total_spending + expect(column.displayFactor).toBe(1) + expect(column.tolerance).toBe(0) + expect(column.toleranceDirection).toBe("both") + expect(column.projection).toBe(false) + expect(column.type).toBe("numeric") + }) + + it("defaults the column name to the slug, title-cased", () => { + const { manifest } = parseManifest(minimal) + expect(manifest!.columns.total_spending.name).toBe("Total Spending") + }) + + it("defaults fiscalYearStartMonth to 4 (April)", () => { + const { manifest } = parseManifest(minimal) + expect(manifest!.fiscalYearStartMonth).toBe(4) + }) + + it("defaults sources to an empty list", () => { + const { manifest } = parseManifest(minimal) + expect(manifest!.sources).toEqual([]) + }) + + it("derives labelPlural from label when missing", () => { + const { manifest } = parseManifest({ ...minimal, entity: { label: "province" } }) + expect(manifest!.entity.labelPlural).toBe("provinces") + const withY = parseManifest({ ...minimal, entity: { label: "category" } }) + expect(withY.manifest!.entity.labelPlural).toBe("categories") + }) + + it("defaults the entity labels entirely when entity is absent", () => { + const { manifest } = parseManifest(minimal) + expect(manifest!.entity.label).toBe("entity") + expect(manifest!.entity.labelPlural).toBe("entities") + }) + + it("keeps an explicit labelPlural", () => { + const { manifest } = parseManifest({ + ...minimal, + entity: { label: "province or territory", labelPlural: "provinces and territories" }, + }) + expect(manifest!.entity.labelPlural).toBe("provinces and territories") + }) +}) + +describe("parseManifest unknown fields", () => { + it("warns on unknown top-level fields without rejecting the manifest", () => { + const { manifest, diagnostics } = parseManifest({ ...minimal, futureFeature: true }) + expect(manifest).not.toBeNull() + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ + severity: "warning", + code: "unknown-manifest-field", + context: { field: "futureFeature" }, + }) + }) + + it("warns on unknown column fields with the column slug in context", () => { + const { manifest, diagnostics } = parseManifest({ + ...minimal, + columns: { total_spending: { sparkles: "yes" } }, + }) + expect(manifest).not.toBeNull() + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ + severity: "warning", + code: "unknown-manifest-field", + context: { column: "total_spending", field: "sparkles" }, + }) + }) +}) + +describe("parseManifest errors", () => { + it("rejects non-object input", () => { + const { manifest, diagnostics } = parseManifest("not a manifest") + expect(manifest).toBeNull() + expect(diagnostics[0].severity).toBe("error") + }) + + it("rejects an invalid timeGrain with the path in the message", () => { + const { manifest, diagnostics } = parseManifest({ ...minimal, timeGrain: "weekly" }) + expect(manifest).toBeNull() + expect(diagnostics.some((d) => d.severity === "error" && d.message.includes("timeGrain"))).toBe(true) + }) + + it("rejects a missing name", () => { + const { manifest } = parseManifest({ timeGrain: "year", columns: {} }) + expect(manifest).toBeNull() + }) + + it("rejects fiscalYearStartMonth outside 1-12", () => { + const { manifest } = parseManifest({ ...minimal, fiscalYearStartMonth: 13 }) + expect(manifest).toBeNull() + }) +}) + +describe("parseManifest projectionFrom", () => { + it("parses string projectionFrom values under the manifest's grain", () => { + const { manifest, diagnostics } = parseManifest({ + name: "test", + timeGrain: "fiscal-year", + columns: { spending: { projectionFrom: "2024-25" } }, + }) + expect(diagnostics).toEqual([]) + expect(manifest!.columns.spending.projectionFrom).toBe(2024) + }) + + it("accepts numeric projectionFrom values as ordinals", () => { + const { manifest } = parseManifest({ + ...minimal, + columns: { spending: { projectionFrom: 2025 } }, + }) + expect(manifest!.columns.spending.projectionFrom).toBe(2025) + }) + + it("errors when a string projectionFrom does not parse under the grain", () => { + const { manifest, diagnostics } = parseManifest({ + ...minimal, + columns: { spending: { projectionFrom: "2024-Q3" } }, + }) + expect(manifest).not.toBeNull() + expect(manifest!.columns.spending.projectionFrom).toBeUndefined() + expect(diagnostics.some((d) => d.severity === "error" && d.code === "manifest-invalid")).toBe(true) + }) +}) + +describe("parseManifest denominators", () => { + it("warns when a denominator references an undeclared column", () => { + const { diagnostics } = parseManifest({ + ...minimal, + columns: { spending: { denominator: "gdp" } }, + }) + expect(diagnostics.some((d) => d.code === "unknown-denominator" && d.severity === "warning")).toBe(true) + }) + + it("accepts a denominator that references a declared column", () => { + const { diagnostics } = parseManifest({ + ...minimal, + columns: { spending: { denominator: "gdp" }, gdp: {} }, + }) + expect(diagnostics).toEqual([]) + }) +}) + +describe("parseManifest miscellaneous normalization", () => { + it("drops explicit null colours (JSON manifests use null for 'theme-assigned')", () => { + const { manifest } = parseManifest({ + ...minimal, + columns: { spending: { colour: null } }, + }) + expect(manifest!.columns.spending.colour).toBeUndefined() + }) + + it("passes entities metadata through", () => { + const { manifest } = parseManifest({ + ...minimal, + entities: [{ name: "Quebec", nameFr: "Québec", aliases: ["QC"], group: "Central" }], + }) + expect(manifest!.entities).toEqual([{ name: "Quebec", nameFr: "Québec", aliases: ["QC"], group: "Central" }]) + }) +}) diff --git a/packages/charts2/src/core/data/manifest.ts b/packages/charts2/src/core/data/manifest.ts new file mode 100644 index 00000000000..55cc0e2f836 --- /dev/null +++ b/packages/charts2/src/core/data/manifest.ts @@ -0,0 +1,240 @@ +/** + * Manifest parsing: raw JSON → Manifest with defaults applied. Spec 01 §4–5. + * + * Unknown fields are warnings, never errors (forward compatibility); + * structural problems (bad grain, missing name, …) are errors and yield + * a null manifest. + */ + +import { z } from "zod" + +import type { ColumnMeta, Diagnostic, Manifest } from "../types.ts" +import { parseTime } from "./time.ts" + +// --------------------------------------------------------------------------- +// Schemas (zod v4) — unknown keys are stripped here and warned about below. +// --------------------------------------------------------------------------- + +const timeGrainSchema = z.enum(["year", "fiscal-year", "quarter", "month", "date", "none"]) + +const columnTypeSchema = z.enum(["numeric", "integer", "percentage", "currency", "categorical", "ordinal"]) + +const toleranceDirectionSchema = z.enum(["both", "backwards", "forwards"]) + +const columnSchema = z.object({ + name: z.string().optional(), + type: columnTypeSchema.default("numeric"), + unit: z.string().optional(), + shortUnit: z.string().optional(), + currency: z.string().optional(), + displayFactor: z.number().default(1), + decimals: z.number().int().min(0).optional(), + tolerance: z.number().int().min(0).default(0), + toleranceDirection: toleranceDirectionSchema.default("both"), + projection: z.boolean().default(false), + projectionFrom: z.union([z.number(), z.string()]).optional(), + denominator: z.string().optional(), + derivedUnit: z.string().optional(), + derivedShortUnit: z.string().optional(), + colour: z.string().nullish(), + order: z.array(z.string()).optional(), + description: z.string().optional(), + source: z.number().int().optional(), +}) + +const entityLabelSchema = z.object({ + label: z.string(), + labelPlural: z.string().optional(), + kind: z.string().optional(), +}) + +const entityMetaSchema = z.object({ + name: z.string(), + code: z.string().optional(), + nameFr: z.string().optional(), + aliases: z.array(z.string()).optional(), + group: z.string().optional(), + colour: z.string().optional(), +}) + +const sourceMetaSchema = z.object({ + name: z.string(), + url: z.string().optional(), + publisher: z.string().optional(), + retrieved: z.string().optional(), + citation: z.string().optional(), + license: z.string().optional(), +}) + +const manifestSchema = z.object({ + name: z.string(), + title: z.string().optional(), + timeGrain: timeGrainSchema, + fiscalYearStartMonth: z.number().int().min(1).max(12).default(4), + entity: entityLabelSchema.default({ label: "entity" }), + columns: z.record(z.string(), columnSchema), + dimensions: z.array(z.string()).optional(), + entities: z.array(entityMetaSchema).optional(), + sources: z.array(sourceMetaSchema).default([]), +}) + +const KNOWN_MANIFEST_KEYS = new Set(Object.keys(manifestSchema.shape)) +const KNOWN_COLUMN_KEYS = new Set(Object.keys(columnSchema.shape)) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Naive English plural for derived entity labels: "province" → "provinces", "entity" → "entities". */ +function pluralize(label: string): string { + if (/[^aeiou]y$/.test(label)) return `${label.slice(0, -1)}ies` + if (/(s|x|z|ch|sh)$/.test(label)) return `${label}es` + return `${label}s` +} + +/** "total_spending" → "Total Spending" (spec 01: name defaults to the slug, title-cased). */ +function titleCaseSlug(slug: string): string { + return slug + .split(/[_\s-]+/) + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function unknownFieldWarnings(raw: Record): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + for (const key of Object.keys(raw)) { + if (!KNOWN_MANIFEST_KEYS.has(key)) { + diagnostics.push({ + severity: "warning", + code: "unknown-manifest-field", + message: `Unknown manifest field "${key}" was ignored`, + context: { field: key }, + }) + } + } + const columns = raw.columns + if (isPlainObject(columns)) { + for (const [slug, column] of Object.entries(columns)) { + if (!isPlainObject(column)) continue + for (const key of Object.keys(column)) { + if (!KNOWN_COLUMN_KEYS.has(key)) { + diagnostics.push({ + severity: "warning", + code: "unknown-manifest-field", + message: `Unknown field "${key}" on column "${slug}" was ignored`, + context: { column: slug, field: key }, + }) + } + } + } + } + return diagnostics +} + +// --------------------------------------------------------------------------- +// parseManifest +// --------------------------------------------------------------------------- + +export interface ParseManifestResult { + /** null when the manifest has structural errors (see diagnostics). */ + manifest: Manifest | null + diagnostics: Diagnostic[] +} + +export function parseManifest(raw: unknown): ParseManifestResult { + const diagnostics: Diagnostic[] = [] + + if (!isPlainObject(raw)) { + return { + manifest: null, + diagnostics: [ + { + severity: "error", + code: "manifest-invalid", + message: "Manifest must be a JSON object", + }, + ], + } + } + + diagnostics.push(...unknownFieldWarnings(raw)) + + const result = manifestSchema.safeParse(raw) + if (!result.success) { + for (const issue of result.error.issues) { + diagnostics.push({ + severity: "error", + code: "manifest-invalid", + message: issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message, + context: { path: issue.path.join(".") }, + }) + } + return { manifest: null, diagnostics } + } + + const parsed = result.data + + const columns: Record = {} + for (const [slug, column] of Object.entries(parsed.columns)) { + const { projectionFrom, colour, name, ...rest } = column + + let projectionFromOrdinal: number | undefined + if (typeof projectionFrom === "number") { + projectionFromOrdinal = projectionFrom + } else if (typeof projectionFrom === "string") { + const ordinal = parseTime(projectionFrom, parsed.timeGrain) + if (ordinal === null) { + diagnostics.push({ + severity: "error", + code: "manifest-invalid", + message: `columns.${slug}.projectionFrom: "${projectionFrom}" does not parse under grain "${parsed.timeGrain}"`, + context: { column: slug, value: projectionFrom }, + }) + } else { + projectionFromOrdinal = ordinal + } + } + + columns[slug] = { + ...rest, + name: name ?? titleCaseSlug(slug), + ...(colour !== null && colour !== undefined ? { colour } : {}), + ...(projectionFromOrdinal !== undefined ? { projectionFrom: projectionFromOrdinal } : {}), + } + } + + // Denominators must reference declared columns (spec 01 §7). + for (const [slug, column] of Object.entries(columns)) { + if (column.denominator !== undefined && !(column.denominator in columns)) { + diagnostics.push({ + severity: "warning", + code: "unknown-denominator", + message: `Column "${slug}" declares denominator "${column.denominator}" which is not a declared column; derived values will be missing`, + context: { column: slug, denominator: column.denominator }, + }) + } + } + + const manifest: Manifest = { + name: parsed.name, + ...(parsed.title !== undefined ? { title: parsed.title } : {}), + timeGrain: parsed.timeGrain, + fiscalYearStartMonth: parsed.fiscalYearStartMonth, + entity: { + label: parsed.entity.label, + labelPlural: parsed.entity.labelPlural ?? pluralize(parsed.entity.label), + ...(parsed.entity.kind !== undefined ? { kind: parsed.entity.kind } : {}), + }, + columns, + ...(parsed.dimensions !== undefined ? { dimensions: parsed.dimensions } : {}), + ...(parsed.entities !== undefined ? { entities: parsed.entities } : {}), + sources: parsed.sources, + } + + return { manifest, diagnostics } +} diff --git a/packages/charts2/src/core/data/parse.test.ts b/packages/charts2/src/core/data/parse.test.ts new file mode 100644 index 00000000000..357e8b62c15 --- /dev/null +++ b/packages/charts2/src/core/data/parse.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest" + +import type { Manifest } from "../types.ts" +import { parseManifest } from "./manifest.ts" +import { parseCsv, parseJsonRows } from "./parse.ts" + +function manifest(): Manifest { + const { manifest } = parseManifest({ + name: "test", + timeGrain: "year", + columns: { spending: {}, category: { type: "categorical" } }, + }) + return manifest! +} + +describe("parseCsv", () => { + it("parses a simple table with manifest-driven typing", () => { + const { rows, columns, diagnostics } = parseCsv( + "entity,time,spending,category\nOntario,2021,189.1,Health\n", + manifest(), + ) + expect(diagnostics).toEqual([]) + expect(columns).toEqual(["entity", "time", "spending", "category"]) + expect(rows).toEqual([{ entity: "Ontario", time: "2021", spending: 189.1, category: "Health" }]) + }) + + it("strips a UTF-8 byte-order mark", () => { + const { rows, diagnostics } = parseCsv("\uFEFFentity,time,spending,category\nOntario,2021,1,A\n", manifest()) + expect(diagnostics).toEqual([]) + expect(rows[0].entity).toBe("Ontario") + }) + + it("treats empty cells as null, never zero", () => { + const { rows } = parseCsv("entity,time,spending,category\nOntario,2021,,\n", manifest()) + expect(rows[0].spending).toBeNull() + expect(rows[0].spending).not.toBe(0) + expect(rows[0].category).toBeNull() + }) + + it("flags non-numeric cells in numeric columns with their row number", () => { + const { rows, diagnostics } = parseCsv( + "entity,time,spending,category\nOntario,2021,1.5,A\nQuebec,2021,n/a,B\n", + manifest(), + ) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ + severity: "error", + code: "non-numeric-cell", + context: { column: "spending", value: "n/a", row: 2 }, + }) + // the offending raw string is kept so validate can report it too + expect(rows[1].spending).toBe("n/a") + }) + + it("rejects numbers with thousands separators", () => { + const { diagnostics } = parseCsv("entity,time,spending,category\nOntario,2021,\"12,000\",A\n", manifest()) + expect(diagnostics.some((d) => d.code === "non-numeric-cell")).toBe(true) + }) + + it("rejects ragged rows and skips them", () => { + const { rows, diagnostics } = parseCsv( + "entity,time,spending,category\nOntario,2021,1\nQuebec,2021,2,B\n", + manifest(), + ) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ severity: "error", code: "ragged-row", context: { row: 1 } }) + expect(rows).toHaveLength(1) + expect(rows[0].entity).toBe("Quebec") + }) + + it("handles quoted fields containing commas", () => { + const { rows, diagnostics } = parseCsv( + 'entity,time,spending,category\n"Innovation, Science and Economic Development Canada",2021,5,A\n', + manifest(), + ) + expect(diagnostics).toEqual([]) + expect(rows[0].entity).toBe("Innovation, Science and Economic Development Canada") + }) + + it("errors on an empty file", () => { + const { diagnostics } = parseCsv("", manifest()) + expect(diagnostics[0].code).toBe("empty-table") + }) +}) + +describe("parseJsonRows", () => { + it("normalizes objects into the same row shape as parseCsv", () => { + const { rows, columns, diagnostics } = parseJsonRows( + [{ entity: "Ontario", time: 2021, spending: 189.1, category: "Health" }], + manifest(), + ) + expect(diagnostics).toEqual([]) + expect(columns).toEqual(["entity", "time", "spending", "category"]) + expect(rows).toEqual([{ entity: "Ontario", time: 2021, spending: 189.1, category: "Health" }]) + }) + + it("treats null, undefined and empty-string cells as null", () => { + const { rows } = parseJsonRows( + [{ entity: "Ontario", time: 2021, spending: null, category: "" }], + manifest(), + ) + expect(rows[0].spending).toBeNull() + expect(rows[0].category).toBeNull() + }) + + it("strictly parses numeric strings in numeric columns", () => { + const { rows, diagnostics } = parseJsonRows( + [ + { entity: "Ontario", time: 2021, spending: "1.5" }, + { entity: "Quebec", time: 2021, spending: "n/a" }, + ], + manifest(), + ) + expect(rows[0].spending).toBe(1.5) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]).toMatchObject({ code: "non-numeric-cell", context: { row: 2 } }) + }) + + it("rejects non-array input", () => { + const { diagnostics } = parseJsonRows({ entity: "Ontario" }, manifest()) + expect(diagnostics[0].code).toBe("invalid-rows") + }) +}) diff --git a/packages/charts2/src/core/data/parse.ts b/packages/charts2/src/core/data/parse.ts new file mode 100644 index 00000000000..899fb0d60d2 --- /dev/null +++ b/packages/charts2/src/core/data/parse.ts @@ -0,0 +1,177 @@ +/** + * Raw table parsing: CSV / JSON rows → typed raw rows. Spec 01 §2. + * + * Invariants: + * - An empty cell is null, NEVER 0 ("missing ≠ zero" starts here). + * - Numeric columns parse strictly: a non-numeric, non-empty cell is an + * error Diagnostic with its row number; the offending raw string is kept + * in the row so validateDataset can also report it. + * - Ragged CSV rows are rejected (skipped with an error Diagnostic). + * + * Typing comes from the manifest (d3-dsv does no inference of its own): + * declared numeric columns become numbers; entity, time, dimensions and + * categorical columns stay strings. Time stays in its raw string/number + * form — ordinal conversion happens in buildDataset via core/data/time. + */ + +import { csvParseRows } from "d3-dsv" + +import type { CellValue, ColumnType, Diagnostic, Manifest } from "../types.ts" + +/** One raw table row, keyed by column name. Missing/empty cells are null. */ +export type RawRow = Record + +export interface ParsedRows { + rows: RawRow[] + /** Column names in table order (CSV header / first-appearance for JSON). */ + columns: string[] + diagnostics: Diagnostic[] +} + +const NUMERIC_TYPES: ReadonlySet = new Set(["numeric", "integer", "percentage", "currency"]) + +/** True when the manifest declares this column as a numeric type. */ +export function isNumericColumn(manifest: Manifest, column: string): boolean { + const meta = manifest.columns[column] + return meta !== undefined && NUMERIC_TYPES.has(meta.type) +} + +/** + * Strictly parse one numeric cell. Empty → null. A parse failure returns + * the trimmed raw string (callers emit the Diagnostic). + */ +function parseNumericCell(trimmed: string): number | string | null { + if (trimmed === "") return null + const value = Number(trimmed) + return Number.isFinite(value) ? value : trimmed +} + +function nonNumericDiagnostic(column: string, value: string, row: number): Diagnostic { + return { + severity: "error", + code: "non-numeric-cell", + message: `Column "${column}" expects numbers but row ${row} contains "${value}"`, + context: { column, value, row }, + } +} + +/** + * Parse CSV text into raw rows. Row numbers in diagnostics are 1-based + * data-row positions (the header row is not counted). + */ +export function parseCsv(text: string, manifest: Manifest): ParsedRows { + const diagnostics: Diagnostic[] = [] + + // Strip a UTF-8 byte-order mark if present. + const stripped = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text + const grid = csvParseRows(stripped) + + if (grid.length === 0) { + return { + rows: [], + columns: [], + diagnostics: [{ severity: "error", code: "empty-table", message: "CSV contains no header row" }], + } + } + + const header = grid[0].map((name) => name.trim()) + const rows: RawRow[] = [] + + for (let i = 1; i < grid.length; i++) { + const line = grid[i] + const rowNumber = i // 1-based data-row position + if (line.length !== header.length) { + diagnostics.push({ + severity: "error", + code: "ragged-row", + message: `Row ${rowNumber} has ${line.length} cells but the header declares ${header.length}`, + context: { row: rowNumber, cells: line.length, expected: header.length }, + }) + continue + } + + const row: RawRow = {} + for (let j = 0; j < header.length; j++) { + const column = header[j] + const trimmed = line[j].trim() + if (column === "entity") { + row[column] = trimmed + } else if (isNumericColumn(manifest, column)) { + const value = parseNumericCell(trimmed) + if (typeof value === "string") { + diagnostics.push(nonNumericDiagnostic(column, value, rowNumber)) + } + row[column] = value + } else { + // time, dimensions, categorical, and undeclared columns stay strings + row[column] = trimmed === "" ? null : trimmed + } + } + rows.push(row) + } + + return { rows, columns: header, diagnostics } +} + +/** + * Normalize JSON rows (array of objects) into the same shape parseCsv + * produces. null/undefined/"" cells become null; numeric columns accept + * numbers or strictly-parsed numeric strings. + */ +export function parseJsonRows(raw: unknown, manifest: Manifest): ParsedRows { + const diagnostics: Diagnostic[] = [] + + if (!Array.isArray(raw)) { + return { + rows: [], + columns: [], + diagnostics: [{ severity: "error", code: "invalid-rows", message: "JSON rows must be an array of objects" }], + } + } + + const columns: string[] = [] + const seen = new Set() + const rows: RawRow[] = [] + + for (let i = 0; i < raw.length; i++) { + const input = raw[i] + const rowNumber = i + 1 + if (typeof input !== "object" || input === null || Array.isArray(input)) { + diagnostics.push({ + severity: "error", + code: "invalid-rows", + message: `Row ${rowNumber} is not an object`, + context: { row: rowNumber }, + }) + continue + } + + const row: RawRow = {} + for (const [column, value] of Object.entries(input as Record)) { + if (!seen.has(column)) { + seen.add(column) + columns.push(column) + } + if (value === null || value === undefined || value === "") { + row[column] = null + } else if (isNumericColumn(manifest, column)) { + if (typeof value === "number" && Number.isFinite(value)) { + row[column] = value + } else { + const parsed = parseNumericCell(String(value).trim()) + if (typeof parsed === "string") { + diagnostics.push(nonNumericDiagnostic(column, parsed, rowNumber)) + } + row[column] = parsed + } + } else if (typeof value === "number") { + row[column] = value + } else { + row[column] = String(value) + } + } + rows.push(row) + } + + return { rows, columns, diagnostics } +} diff --git a/packages/charts2/src/core/data/time.test.ts b/packages/charts2/src/core/data/time.test.ts new file mode 100644 index 00000000000..1c3c94ac485 --- /dev/null +++ b/packages/charts2/src/core/data/time.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest" + +import { compareTimes, formatTimeOrdinalRaw, parseTime, snapToAvailable } from "./time.ts" + +describe("parseTime / formatTimeOrdinalRaw round-trips", () => { + it("parses year grain from strings and numbers", () => { + expect(parseTime("2024", "year")).toBe(2024) + expect(parseTime(2024, "year")).toBe(2024) + expect(parseTime("1867", "year")).toBe(1867) + expect(formatTimeOrdinalRaw(2024, "year")).toBe("2024") + }) + + it("rejects non-integer year values", () => { + expect(parseTime("2024.5", "year")).toBeNull() + expect(parseTime(2024.5, "year")).toBeNull() + expect(parseTime("twenty", "year")).toBeNull() + }) + + it("parses fiscal years to their start year", () => { + expect(parseTime("2024-25", "fiscal-year")).toBe(2024) + expect(parseTime("2019-20", "fiscal-year")).toBe(2019) + expect(formatTimeOrdinalRaw(2024, "fiscal-year")).toBe("2024-25") + }) + + it("handles the century boundary in fiscal years", () => { + expect(parseTime("1999-00", "fiscal-year")).toBe(1999) + expect(formatTimeOrdinalRaw(1999, "fiscal-year")).toBe("1999-00") + expect(parseTime("2099-00", "fiscal-year")).toBe(2099) + }) + + it("rejects fiscal years whose YY suffix is not start + 1", () => { + expect(parseTime("2024-26", "fiscal-year")).toBeNull() + expect(parseTime("2024-24", "fiscal-year")).toBeNull() + expect(parseTime("2024-2025", "fiscal-year")).toBeNull() + }) + + it("encodes quarters as year*4 + (q-1)", () => { + expect(parseTime("2024-Q3", "quarter")).toBe(2024 * 4 + 2) + expect(parseTime("2024-Q1", "quarter")).toBe(2024 * 4) + expect(formatTimeOrdinalRaw(2024 * 4 + 2, "quarter")).toBe("2024-Q3") + expect(parseTime("2024-Q5", "quarter")).toBeNull() + }) + + it("encodes months as year*12 + (m-1)", () => { + expect(parseTime("2024-07", "month")).toBe(2024 * 12 + 6) + expect(parseTime("2024-01", "month")).toBe(2024 * 12) + expect(parseTime("2024-12", "month")).toBe(2024 * 12 + 11) + expect(formatTimeOrdinalRaw(2024 * 12 + 6, "month")).toBe("2024-07") + expect(parseTime("2024-13", "month")).toBeNull() + expect(parseTime("2024-00", "month")).toBeNull() + }) + + it("encodes dates as days since 1970-01-01 UTC", () => { + expect(parseTime("1970-01-01", "date")).toBe(0) + expect(parseTime("1970-01-02", "date")).toBe(1) + expect(parseTime("1969-12-31", "date")).toBe(-1) + expect(parseTime("2024-07-01", "date")).toBe(19905) + expect(formatTimeOrdinalRaw(19905, "date")).toBe("2024-07-01") + expect(formatTimeOrdinalRaw(0, "date")).toBe("1970-01-01") + }) + + it("rejects calendar-invalid dates instead of letting Date overflow them", () => { + expect(parseTime("2024-02-30", "date")).toBeNull() + expect(parseTime("2023-02-29", "date")).toBeNull() + expect(parseTime("2024-02-29", "date")).toBe(parseTime("2024-02-28", "date")! + 1) + }) + + it("round-trips every grain through format → parse", () => { + const cases: Array<["year" | "fiscal-year" | "quarter" | "month" | "date", number]> = [ + ["year", 2024], + ["fiscal-year", 2019], + ["quarter", 2024 * 4 + 3], + ["month", 2024 * 12], + ["date", 19905], + ] + for (const [grain, ordinal] of cases) { + expect(parseTime(formatTimeOrdinalRaw(ordinal, grain), grain)).toBe(ordinal) + } + }) + + it("never parses times under the none grain", () => { + expect(parseTime("2024", "none")).toBeNull() + expect(parseTime(2024, "none")).toBeNull() + expect(formatTimeOrdinalRaw(2024, "none")).toBe("") + }) + + it("returns null for null/undefined raw values", () => { + expect(parseTime(null, "year")).toBeNull() + expect(parseTime(undefined, "year")).toBeNull() + }) +}) + +describe("snapToAvailable", () => { + const times = [2019, 2021, 2024] + + it("returns exact matches unchanged", () => { + expect(snapToAvailable(2021, times)).toBe(2021) + }) + + it("snaps to the nearest available time", () => { + expect(snapToAvailable(2018, times)).toBe(2019) + expect(snapToAvailable(2023, times)).toBe(2024) + expect(snapToAvailable(2025, times)).toBe(2024) + }) + + it("clamps to the extremes", () => { + expect(snapToAvailable(1900, times)).toBe(2019) + expect(snapToAvailable(3000, times)).toBe(2024) + }) + + it("resolves equidistant ties to the earlier time", () => { + expect(snapToAvailable(2020, times)).toBe(2019) + expect(snapToAvailable(2020, [2018, 2022])).toBe(2018) + }) + + it("returns null when no times are available", () => { + expect(snapToAvailable(2020, [])).toBeNull() + }) +}) + +describe("compareTimes", () => { + it("orders ordinals ascending", () => { + expect([2024, 2019, 2021].sort(compareTimes)).toEqual([2019, 2021, 2024]) + expect(compareTimes(5, 5)).toBe(0) + }) +}) diff --git a/packages/charts2/src/core/data/time.ts b/packages/charts2/src/core/data/time.ts new file mode 100644 index 00000000000..8702c85dd46 --- /dev/null +++ b/packages/charts2/src/core/data/time.ts @@ -0,0 +1,154 @@ +/** + * Time grain parsing and canonical (raw) formatting. Spec 01 §3, spec 08. + * + * Times are integer ordinals, uniform per grain (see core/types.ts): + * year → the year (2024) + * fiscal-year → the start year (2024 for "2024-25") + * quarter → year * 4 + (q - 1) + * month → year * 12 + (m - 1) + * date → days since 1970-01-01 (UTC, no timezone math) + * none → ordinals never occur + * + * Only canonical *raw* string forms live here; display formatting (en-dashes, + * locale month names, …) lives in core/format (M2), not in this module. + * + * Determinism: no Intl, no Date.now, no timezone-dependent Date parsing. + * Date strings are decomposed into components and combined via Date.UTC. + */ + +import type { TimeGrain, TimeOrdinal } from "../types.ts" + +const MS_PER_DAY = 86_400_000 + +const YEAR_RE = /^-?\d{1,6}$/ +const FISCAL_YEAR_RE = /^(\d{4})-(\d{2})$/ +const QUARTER_RE = /^(\d{4})-Q([1-4])$/ +const MONTH_RE = /^(\d{4})-(\d{2})$/ +const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/ + +function pad2(n: number): string { + return String(n).padStart(2, "0") +} + +/** + * Parse a raw time cell (the canonical encoding for the grain) into an + * integer ordinal. Returns null when the value does not parse under the + * declared grain — callers turn that into a Diagnostic with row context. + */ +export function parseTime(raw: string | number | null | undefined, grain: TimeGrain): TimeOrdinal | null { + if (raw === null || raw === undefined) return null + if (grain === "none") return null + + if (typeof raw === "number") { + // Only the year grain accepts bare numbers (CSV always yields strings; + // JSON year datasets naturally carry numeric times). + if (grain === "year" && Number.isInteger(raw)) return raw + return null + } + + const text = raw.trim() + switch (grain) { + case "year": { + if (!YEAR_RE.test(text)) return null + return parseInt(text, 10) + } + case "fiscal-year": { + const m = FISCAL_YEAR_RE.exec(text) + if (m === null) return null + const start = parseInt(m[1], 10) + // The YY suffix must be the start year + 1 (e.g. "2024-25", "1999-00"). + if (m[2] !== pad2((start + 1) % 100)) return null + return start + } + case "quarter": { + const m = QUARTER_RE.exec(text) + if (m === null) return null + return parseInt(m[1], 10) * 4 + (parseInt(m[2], 10) - 1) + } + case "month": { + const m = MONTH_RE.exec(text) + if (m === null) return null + const month = parseInt(m[2], 10) + if (month < 1 || month > 12) return null + return parseInt(m[1], 10) * 12 + (month - 1) + } + case "date": { + const m = DATE_RE.exec(text) + if (m === null) return null + const year = parseInt(m[1], 10) + const month = parseInt(m[2], 10) + const day = parseInt(m[3], 10) + // Date.UTC of explicit components — never timezone-dependent parsing. + const ms = Date.UTC(year, month - 1, day) + const check = new Date(ms) + // Reject overflowed components (e.g. "2024-02-30" → March 1). + if ( + check.getUTCFullYear() !== year || + check.getUTCMonth() !== month - 1 || + check.getUTCDate() !== day + ) { + return null + } + return ms / MS_PER_DAY + } + } +} + +/** + * Format an ordinal back into the grain's canonical raw string — the exact + * inverse of parseTime. This is the encoding for CSV round-trips and URLs; + * human display strings are produced by core/format (M2). + */ +export function formatTimeOrdinalRaw(ordinal: TimeOrdinal, grain: TimeGrain): string { + switch (grain) { + case "year": + return String(ordinal) + case "fiscal-year": + return `${ordinal}-${pad2((ordinal + 1) % 100)}` + case "quarter": { + const year = Math.floor(ordinal / 4) + return `${year}-Q${ordinal - year * 4 + 1}` + } + case "month": { + const year = Math.floor(ordinal / 12) + return `${year}-${pad2(ordinal - year * 12 + 1)}` + } + case "date": { + const d = new Date(ordinal * MS_PER_DAY) + return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}` + } + case "none": + return "" + } +} + +/** + * Snap an arbitrary ordinal to the nearest available time (spec 08 §1: + * selection always snaps to times present in the data). `times` must be + * sorted ascending. Ties go to the earlier time for determinism. + * Returns null when no times are available. + */ +export function snapToAvailable(ordinal: TimeOrdinal, times: readonly TimeOrdinal[]): TimeOrdinal | null { + if (times.length === 0) return null + + // Binary search: first index with times[i] >= ordinal. + let lo = 0 + let hi = times.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (times[mid] < ordinal) lo = mid + 1 + else hi = mid + } + + if (lo === 0) return times[0] + if (lo === times.length) return times[times.length - 1] + const before = times[lo - 1] + const after = times[lo] + // <= : equal distance resolves to the earlier time. + return ordinal - before <= after - ordinal ? before : after +} + +/** Total order over time ordinals (ascending). */ +export function compareTimes(a: TimeOrdinal, b: TimeOrdinal): number { + return a - b +} diff --git a/packages/charts2/src/core/data/tolerance.test.ts b/packages/charts2/src/core/data/tolerance.test.ts new file mode 100644 index 00000000000..88b86515b6e --- /dev/null +++ b/packages/charts2/src/core/data/tolerance.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" + +import { resolveWithTolerance } from "./tolerance.ts" + +describe("resolveWithTolerance", () => { + const times = [2018, 2019, 2021, 2022] + const values = [10, null, 30, 40] + + it("returns the exact value when present", () => { + expect(resolveWithTolerance(times, values, 2018, 0, "both")).toEqual({ value: 10, sourceTime: 2018 }) + }) + + it("returns null when the target is missing and tolerance is 0", () => { + expect(resolveWithTolerance(times, values, 2019, 0, "both")).toBeNull() + expect(resolveWithTolerance(times, values, 2020, 0, "both")).toBeNull() + }) + + it("borrows the nearest non-null value within tolerance", () => { + // 2019 is null; 2018 (distance 1) beats 2021 (distance 2) + expect(resolveWithTolerance(times, values, 2019, 2, "both")).toEqual({ value: 10, sourceTime: 2018 }) + }) + + it("skips null cells even when they are nearest", () => { + // target 2020: 2019 and 2021 both distance 1, but 2019 is null + expect(resolveWithTolerance(times, values, 2020, 1, "both")).toEqual({ value: 30, sourceTime: 2021 }) + }) + + it("resolves equidistant ties to the earlier time", () => { + expect(resolveWithTolerance([2019, 2021], [10, 20], 2020, 1, "both")).toEqual({ + value: 10, + sourceTime: 2019, + }) + }) + + it("respects direction: backwards borrows only from earlier times", () => { + expect(resolveWithTolerance([2019, 2021], [10, 20], 2020, 1, "backwards")).toEqual({ + value: 10, + sourceTime: 2019, + }) + // nothing earlier in range → null even though 2021 is within tolerance + expect(resolveWithTolerance([2021], [20], 2020, 1, "backwards")).toBeNull() + }) + + it("respects direction: forwards borrows only from later times", () => { + expect(resolveWithTolerance([2019, 2021], [10, 20], 2020, 1, "forwards")).toEqual({ + value: 20, + sourceTime: 2021, + }) + expect(resolveWithTolerance([2019], [10], 2020, 1, "forwards")).toBeNull() + }) + + it("direction includes the target time itself", () => { + expect(resolveWithTolerance([2020], [15], 2020, 1, "backwards")).toEqual({ value: 15, sourceTime: 2020 }) + expect(resolveWithTolerance([2020], [15], 2020, 1, "forwards")).toEqual({ value: 15, sourceTime: 2020 }) + }) + + it("borrows at the data extents but never beyond tolerance", () => { + // target past the last time: borrowing existing values is allowed… + expect(resolveWithTolerance(times, values, 2024, 2, "both")).toEqual({ value: 40, sourceTime: 2022 }) + // …but never further than the tolerance allows (no extrapolation) + expect(resolveWithTolerance(times, values, 2025, 2, "both")).toBeNull() + }) + + it("runs a (gap pattern × tolerance × direction) table", () => { + const t = [1, 2, 3, 4, 5] + const v = [100, null, null, null, 500] + const cases: Array<[number, number, "both" | "backwards" | "forwards", unknown]> = [ + [3, 0, "both", null], + [3, 1, "both", null], + [3, 2, "both", { value: 100, sourceTime: 1 }], // tie 1 vs 5 → earlier + [3, 2, "forwards", { value: 500, sourceTime: 5 }], + [3, 2, "backwards", { value: 100, sourceTime: 1 }], + [2, 1, "both", { value: 100, sourceTime: 1 }], + [4, 1, "both", { value: 500, sourceTime: 5 }], + [4, 1, "backwards", null], + ] + for (const [target, tolerance, direction, expected] of cases) { + expect(resolveWithTolerance(t, v, target, tolerance, direction), `target ${target} ±${tolerance} ${direction}`).toEqual(expected) + } + }) + + it("borrows categorical (string) values too", () => { + expect(resolveWithTolerance([2019], ["Health"], 2020, 1, "both")).toEqual({ + value: "Health", + sourceTime: 2019, + }) + }) + + it("returns null for an empty series", () => { + expect(resolveWithTolerance([], [], 2020, 5, "both")).toBeNull() + }) +}) diff --git a/packages/charts2/src/core/data/tolerance.ts b/packages/charts2/src/core/data/tolerance.ts new file mode 100644 index 00000000000..0a382b6f627 --- /dev/null +++ b/packages/charts2/src/core/data/tolerance.ts @@ -0,0 +1,57 @@ +/** + * Tolerance matching (borrowed values). Spec 08 §4, spec 01 column metadata. + * + * A missing value at the target time may be filled from the nearest time + * within ±tolerance (direction configurable). Borrowing never invents + * values — only existing cells are returned, and the sourceTime is always + * reported so borrowed values can be marked everywhere downstream. + * Ties (equidistant earlier/later candidates) go to the earlier time for + * determinism. + */ + +import type { CellValue, TimeOrdinal, ToleranceDirection } from "../types.ts" + +export interface ToleranceMatch { + value: CellValue + /** The time the value actually came from (≠ target ⇒ borrowed). */ + sourceTime: TimeOrdinal +} + +/** + * Resolve one entity's column value at targetTime, borrowing from the + * nearest non-null neighbour within tolerance. `times` and `values` are + * parallel arrays describing that entity's series (times sorted ascending). + * + * Direction is in time: "backwards" borrows only from earlier times + * (sourceTime <= target), "forwards" only from later ones. + */ +export function resolveWithTolerance( + times: readonly TimeOrdinal[], + values: readonly CellValue[], + targetTime: TimeOrdinal, + tolerance: number, + direction: ToleranceDirection, +): ToleranceMatch | null { + let best: ToleranceMatch | null = null + let bestDistance = Infinity + + for (let i = 0; i < times.length; i++) { + const value = values[i] + if (value === null || value === undefined) continue + + const delta = times[i] - targetTime + if (direction === "backwards" && delta > 0) continue + if (direction === "forwards" && delta < 0) continue + + const distance = Math.abs(delta) + if (distance > tolerance) continue + + // Strict < keeps the earlier candidate on ties (times scan ascending). + if (distance < bestDistance) { + bestDistance = distance + best = { value, sourceTime: times[i] } + } + } + + return best +} diff --git a/packages/charts2/src/core/data/validate.test.ts b/packages/charts2/src/core/data/validate.test.ts new file mode 100644 index 00000000000..f6c541743f1 --- /dev/null +++ b/packages/charts2/src/core/data/validate.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest" + +import { federalDepartments } from "../../fixtures/federal-departments.ts" +import { pathological } from "../../fixtures/pathological.ts" +import type { Diagnostic } from "../types.ts" +import { parseManifest } from "./manifest.ts" +import { parseCsv } from "./parse.ts" +import { validateDataset } from "./validate.ts" + +function validateFixture(rawManifest: Record, csv: string): Diagnostic[] { + const { manifest, diagnostics } = parseManifest(rawManifest) + if (manifest === null) throw new Error(diagnostics.map((d) => d.message).join("; ")) + const parsed = parseCsv(csv, manifest) + return validateDataset(manifest, parsed.rows) +} + +describe("validateDataset on the pathological fixture", () => { + const diagnostics = validateFixture(pathological.manifest, pathological.csv) + + it("flags the duplicate (Québec, 2021) row with both row references", () => { + const duplicate = diagnostics.find((d) => d.code === "duplicate-row") + expect(duplicate).toMatchObject({ + severity: "error", + context: { row: 3, firstRow: 2, entity: "Québec", time: 2021 }, + }) + }) + + it("flags the non-numeric 'n/a' cell with its row and column", () => { + const nonNumeric = diagnostics.find((d) => d.code === "non-numeric-cell") + expect(nonNumeric).toMatchObject({ + severity: "error", + context: { column: "spending", value: "n/a", row: 6 }, + }) + }) + + it("flags the zero denominator cell", () => { + const zero = diagnostics.find((d) => d.code === "zero-denominator") + expect(zero).toMatchObject({ + severity: "warning", + context: { column: "population", row: 4 }, + }) + }) + + it("reports all problems at once (no early exit)", () => { + const codes = diagnostics.map((d) => d.code) + expect(codes).toContain("duplicate-row") + expect(codes).toContain("non-numeric-cell") + expect(codes).toContain("zero-denominator") + }) +}) + +describe("validateDataset column checks", () => { + const manifest = { + name: "test", + timeGrain: "year", + columns: { spending: {}, ghost: {} }, + } + const csv = "entity,time,spending,mystery\nQuebec,2021,1,9\n" + + it("errors on declared-but-absent columns", () => { + const diagnostics = validateFixture(manifest, csv) + const missing = diagnostics.find((d) => d.code === "missing-column") + expect(missing).toMatchObject({ severity: "error", context: { column: "ghost" } }) + }) + + it("warns on undeclared-but-present columns", () => { + const diagnostics = validateFixture(manifest, csv) + const undeclared = diagnostics.find((d) => d.code === "undeclared-column") + expect(undeclared).toMatchObject({ severity: "warning", context: { column: "mystery" } }) + }) + + it("does not warn about entity, time, or declared dimension columns", () => { + const diagnostics = validateFixture( + { name: "test", timeGrain: "year", columns: { spending: {} }, dimensions: ["category"] }, + "entity,time,category,spending\nQuebec,2021,Health,1\n", + ) + expect(diagnostics).toEqual([]) + }) + + it("errors when the time column is absent under a time grain", () => { + const diagnostics = validateFixture( + { name: "test", timeGrain: "year", columns: { spending: {} } }, + "entity,spending\nQuebec,1\n", + ) + expect(diagnostics.some((d) => d.code === "missing-column" && d.context?.column === "time")).toBe(true) + }) + + it("warns when a time column is present under grain none", () => { + const diagnostics = validateFixture( + { name: "test", timeGrain: "none", columns: { spending: {} } }, + "entity,time,spending\nQuebec,2021,1\n", + ) + expect(diagnostics.some((d) => d.code === "unexpected-time-column" && d.severity === "warning")).toBe(true) + }) +}) + +describe("validateDataset time checks", () => { + it("flags every row whose time fails to parse under the declared grain", () => { + const diagnostics = validateFixture( + { name: "test", timeGrain: "fiscal-year", columns: { spending: {} } }, + "entity,time,spending\nQuebec,2021-22,1\nQuebec,2021,2\nQuebec,2021-23,3\n", + ) + const badTimes = diagnostics.filter((d) => d.code === "bad-time") + expect(badTimes).toHaveLength(2) + expect(badTimes[0].context).toMatchObject({ row: 2, value: "2021" }) + expect(badTimes[1].context).toMatchObject({ row: 3, value: "2021-23" }) + }) + + it("detects duplicates on none-grain datasets by entity alone", () => { + const diagnostics = validateFixture( + { name: "test", timeGrain: "none", columns: { spending: {} } }, + "entity,spending\nQuebec,1\nQuebec,2\n", + ) + expect(diagnostics.some((d) => d.code === "duplicate-row" && d.context?.row === 2)).toBe(true) + }) +}) + +describe("validateDataset entity resolution", () => { + it("resolves aliases before duplicate detection", () => { + const diagnostics = validateFixture( + { + name: "test", + timeGrain: "year", + columns: { spending: {} }, + entities: [{ name: "Quebec", aliases: ["QC"] }], + }, + "entity,time,spending\nQuebec,2021,1\nQC,2021,2\n", + ) + expect(diagnostics.some((d) => d.code === "duplicate-row")).toBe(true) + }) + + it("warns once, listing all unknown entities, when the manifest declares an entities list", () => { + const { manifest } = parseManifest(federalDepartments.manifest) + const parsed = parseCsv( + federalDepartments.csv + "Ministry of Silly Walks,2019-20,1\nDepartment of Mystery,2019-20,2\n", + manifest!, + ) + const diagnostics = validateDataset(manifest!, parsed.rows) + const unknown = diagnostics.filter((d) => d.code === "unknown-entities") + expect(unknown).toHaveLength(1) + expect(unknown[0]).toMatchObject({ + severity: "warning", + context: { entities: "Ministry of Silly Walks, Department of Mystery", count: 2 }, + }) + }) + + it("does not warn about entities when the manifest has no entities list", () => { + const diagnostics = validateFixture( + { name: "test", timeGrain: "year", columns: { spending: {} } }, + "entity,time,spending\nAnybody,2021,1\n", + ) + expect(diagnostics).toEqual([]) + }) +}) diff --git a/packages/charts2/src/core/data/validate.ts b/packages/charts2/src/core/data/validate.ts new file mode 100644 index 00000000000..155848b3b3e --- /dev/null +++ b/packages/charts2/src/core/data/validate.ts @@ -0,0 +1,172 @@ +/** + * Dataset validation. Spec 01 §8: report ALL problems at once, with row + * references, instead of silently coercing. Also powers `charts validate` + * in the CLI (spec 24). + * + * Errors: duplicate (entity, time) rows, unparseable times, declared + * columns absent from the table, non-numeric cells in numeric columns. + * Warnings: undeclared columns present, unknown entities (when the + * manifest declares an entities list), zero denominator cells. + * + * Row numbers are 1-based data-row positions (header not counted), + * matching core/data/parse diagnostics. + */ + +import type { Diagnostic, Manifest, TimeOrdinal } from "../types.ts" +import { buildEntityResolver } from "./dataset.ts" +import { isNumericColumn, type RawRow } from "./parse.ts" +import { parseTime } from "./time.ts" + +export function validateDataset(manifest: Manifest, rows: readonly RawRow[]): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + const hasTime = manifest.timeGrain !== "none" + + // -- Column presence ---------------------------------------------------- + const present: string[] = [] + const presentSet = new Set() + for (const row of rows) { + for (const column of Object.keys(row)) { + if (!presentSet.has(column)) { + presentSet.add(column) + present.push(column) + } + } + } + + const special = new Set(["entity", "time", ...(manifest.dimensions ?? [])]) + + if (rows.length > 0 && !presentSet.has("entity")) { + diagnostics.push({ + severity: "error", + code: "missing-column", + message: 'Required column "entity" is absent from the table', + context: { column: "entity" }, + }) + } + if (hasTime && rows.length > 0 && !presentSet.has("time")) { + diagnostics.push({ + severity: "error", + code: "missing-column", + message: `Time grain is "${manifest.timeGrain}" but the table has no "time" column`, + context: { column: "time", grain: manifest.timeGrain }, + }) + } + if (!hasTime && presentSet.has("time")) { + diagnostics.push({ + severity: "warning", + code: "unexpected-time-column", + message: 'Time grain is "none" but the table has a "time" column; it will be ignored', + context: { column: "time" }, + }) + } + + for (const slug of Object.keys(manifest.columns)) { + if (!presentSet.has(slug)) { + diagnostics.push({ + severity: "error", + code: "missing-column", + message: `Declared column "${slug}" is absent from the table`, + context: { column: slug }, + }) + } + } + for (const column of present) { + if (!special.has(column) && !(column in manifest.columns)) { + diagnostics.push({ + severity: "warning", + code: "undeclared-column", + message: `Column "${column}" is present in the table but not declared in the manifest`, + context: { column }, + }) + } + } + + // Denominator column slugs declared in the manifest (for zero checks). + const denominatorSlugs = new Set() + for (const meta of Object.values(manifest.columns)) { + if (meta.denominator !== undefined) denominatorSlugs.add(meta.denominator) + } + + // -- Per-row checks ----------------------------------------------------- + const resolveEntity = buildEntityResolver(manifest) + const knownEntities = + manifest.entities !== undefined ? new Set(manifest.entities.map((entity) => entity.name)) : null + const unknownEntities: string[] = [] + const unknownEntitySet = new Set() + const firstRowByKey = new Map() + + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + const rowNumber = i + 1 + + const rawEntity = typeof row.entity === "string" ? row.entity : String(row.entity ?? "") + const entity = resolveEntity(rawEntity) + if (knownEntities !== null && !knownEntities.has(entity) && !unknownEntitySet.has(rawEntity)) { + unknownEntitySet.add(rawEntity) + unknownEntities.push(rawEntity) + } + + let time: TimeOrdinal | null = null + let timeOk = !hasTime + if (hasTime && presentSet.has("time")) { + const rawTime = row.time + time = typeof rawTime === "string" || typeof rawTime === "number" ? parseTime(rawTime, manifest.timeGrain) : null + if (time === null) { + diagnostics.push({ + severity: "error", + code: "bad-time", + message: `Row ${rowNumber}: time "${row.time}" does not parse under grain "${manifest.timeGrain}"`, + context: { row: rowNumber, value: String(row.time), grain: manifest.timeGrain }, + }) + } else { + timeOk = true + } + } + + // Duplicates — only among rows whose key is well-formed. + if (timeOk) { + const key = `${entity} ${time}` + const firstRow = firstRowByKey.get(key) + if (firstRow !== undefined) { + diagnostics.push({ + severity: "error", + code: "duplicate-row", + message: `Row ${rowNumber} duplicates (${entity}${time !== null ? `, ${time}` : ""}) first seen at row ${firstRow}`, + context: { row: rowNumber, firstRow, entity, ...(time !== null ? { time } : {}) }, + }) + } else { + firstRowByKey.set(key, rowNumber) + } + } + + for (const [column, value] of Object.entries(row)) { + if (isNumericColumn(manifest, column) && typeof value === "string") { + diagnostics.push({ + severity: "error", + code: "non-numeric-cell", + message: `Column "${column}" expects numbers but row ${rowNumber} contains "${value}"`, + context: { column, value, row: rowNumber }, + }) + } + if (denominatorSlugs.has(column) && value === 0) { + diagnostics.push({ + severity: "warning", + code: "zero-denominator", + message: `Denominator column "${column}" is zero at row ${rowNumber}; dependent derived cells will be missing`, + context: { column, row: rowNumber }, + }) + } + } + } + + if (unknownEntities.length > 0) { + diagnostics.push({ + severity: "warning", + code: "unknown-entities", + message: `Entities not in the manifest's entities list: ${unknownEntities.join(", ")}`, + context: { entities: unknownEntities.join(", "), count: unknownEntities.length }, + }) + } + + return diagnostics +} diff --git a/packages/charts2/src/core/definition/index.ts b/packages/charts2/src/core/definition/index.ts new file mode 100644 index 00000000000..8935d22bea6 --- /dev/null +++ b/packages/charts2/src/core/definition/index.ts @@ -0,0 +1,9 @@ +// Chart definition layer (M5): schema parsing with defaults, default-omitting +// serialization, binding/selection resolution, URL view-state codec, and +// schema versioning. Spec 02. Pure functions, no I/O. + +export * from "./migrate.ts" +export * from "./resolve.ts" +export * from "./schema.ts" +export * from "./serialize.ts" +export * from "./urlState.ts" diff --git a/packages/charts2/src/core/definition/migrate.test.ts b/packages/charts2/src/core/definition/migrate.test.ts new file mode 100644 index 00000000000..f3cc77567ec --- /dev/null +++ b/packages/charts2/src/core/definition/migrate.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest" + +import { CURRENT_SCHEMA_VERSION, definitionMigrations, migrateDefinition } from "./migrate.ts" + +describe("migrateDefinition scaffold (spec 02 §4)", () => { + it("treats a missing schemaVersion as version 1", () => { + const { raw, diagnostics } = migrateDefinition({ title: "T", data: "d", y: ["a"] }) + expect(diagnostics).toEqual([]) + expect(raw).toEqual({ title: "T", data: "d", y: ["a"], schemaVersion: CURRENT_SCHEMA_VERSION }) + }) + + it("passes a current-version definition through unchanged", () => { + const input = { schemaVersion: CURRENT_SCHEMA_VERSION, title: "T" } + const { raw, diagnostics } = migrateDefinition(input) + expect(diagnostics).toEqual([]) + expect(raw).toEqual(input) + }) + + it("does not mutate the input object", () => { + const input = { title: "T" } + migrateDefinition(input) + expect(input).toEqual({ title: "T" }) + }) + + it("rejects an unknown future schema version with an error", () => { + const { raw, diagnostics } = migrateDefinition({ schemaVersion: 99, title: "T" }) + expect(raw).toBeNull() + expect(diagnostics).toEqual([ + expect.objectContaining({ + severity: "error", + code: "unknown-schema-version", + context: { schemaVersion: 99, current: CURRENT_SCHEMA_VERSION }, + }), + ]) + }) + + it("rejects a non-object definition", () => { + const { raw, diagnostics } = migrateDefinition(["not", "an", "object"]) + expect(raw).toBeNull() + expect(diagnostics[0]).toMatchObject({ severity: "error", code: "definition-invalid" }) + }) + + it("rejects a non-integer schemaVersion", () => { + for (const bad of [1.5, "1", 0, -1]) { + const { raw, diagnostics } = migrateDefinition({ schemaVersion: bad, title: "T" }) + expect(raw).toBeNull() + expect(diagnostics[0].severity).toBe("error") + } + }) + + it("treats an explicit null schemaVersion like a missing one", () => { + const { raw, diagnostics } = migrateDefinition({ schemaVersion: null, title: "T" }) + expect(diagnostics).toEqual([]) + expect(raw!.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) + }) + + it("has no registered migrations while v1 is current", () => { + // When this fails, CURRENT_SCHEMA_VERSION was bumped: add the + // corresponding migration to definitionMigrations and a fixture + // definition at the old version proving identical output. + expect(CURRENT_SCHEMA_VERSION).toBe(1) + expect(definitionMigrations).toEqual([]) + }) +}) diff --git a/packages/charts2/src/core/definition/migrate.ts b/packages/charts2/src/core/definition/migrate.ts new file mode 100644 index 00000000000..63a3533daf0 --- /dev/null +++ b/packages/charts2/src/core/definition/migrate.ts @@ -0,0 +1,85 @@ +/** + * Definition versioning & migration. Spec 02 §4. + * + * Definitions carry a `schemaVersion`; loading an older version migrates it + * forward deterministically before schema parsing. A missing schemaVersion + * is read as 1. Versions newer than CURRENT_SCHEMA_VERSION are an error — + * we never guess at fields from the future. + */ + +import type { Diagnostic } from "../types.ts" + +export const CURRENT_SCHEMA_VERSION = 1 + +export interface DefinitionMigration { + /** The version this migration upgrades FROM (it produces `from + 1`). */ + from: number + description: string + apply: (raw: Record) => Record +} + +/** + * Ordered migration scaffold. v1 is current, so the list is empty; when a + * v2 schema lands, add `{ from: 1, description, apply }` here and bump + * CURRENT_SCHEMA_VERSION. Each migration is a pure raw → raw function so + * fixture definitions at historical versions replay identically. + */ +export const definitionMigrations: readonly DefinitionMigration[] = [] + +export interface MigrateDefinitionResult { + /** null when migration is impossible (see diagnostics). */ + raw: Record | null + diagnostics: Diagnostic[] +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function migrationError(code: string, message: string, context?: Record): MigrateDefinitionResult { + return { + raw: null, + diagnostics: [{ severity: "error", code, message, ...(context !== undefined ? { context } : {}) }], + } +} + +/** + * Bring a raw definition object up to CURRENT_SCHEMA_VERSION by replaying + * the migrations from its declared version. Missing schemaVersion ⇒ 1. + */ +export function migrateDefinition(raw: unknown): MigrateDefinitionResult { + if (!isPlainObject(raw)) { + return migrationError("definition-invalid", "Chart definition must be a JSON object") + } + + const declared = raw.schemaVersion ?? 1 + if (typeof declared !== "number" || !Number.isInteger(declared) || declared < 1) { + return migrationError( + "definition-invalid", + `schemaVersion must be a positive integer, got ${JSON.stringify(declared)}`, + ) + } + if (declared > CURRENT_SCHEMA_VERSION) { + return migrationError( + "unknown-schema-version", + `Definition declares schemaVersion ${declared} but this build only understands up to ${CURRENT_SCHEMA_VERSION}`, + { schemaVersion: declared, current: CURRENT_SCHEMA_VERSION }, + ) + } + + let current: Record = { ...raw } + for (let version = declared; version < CURRENT_SCHEMA_VERSION; version++) { + const migration = definitionMigrations.find((candidate) => candidate.from === version) + if (migration === undefined) { + return migrationError( + "missing-migration", + `No migration registered from schemaVersion ${version}`, + { schemaVersion: version }, + ) + } + current = migration.apply(current) + } + + current.schemaVersion = CURRENT_SCHEMA_VERSION + return { raw: current, diagnostics: [] } +} diff --git a/packages/charts2/src/core/definition/resolve.test.ts b/packages/charts2/src/core/definition/resolve.test.ts new file mode 100644 index 00000000000..cee0902dc3b --- /dev/null +++ b/packages/charts2/src/core/definition/resolve.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset } from "../../fixtures/index.ts" +import type { ChartDefinition } from "../types.ts" +import { resolveBindings, resolveSelection } from "./resolve.ts" +import { parseDefinition } from "./schema.ts" + +const debt = loadFixtureDataset("government-debt") +const departments = loadFixtureDataset("federal-departments") + +/** Build a valid definition through the real parser so defaults apply. */ +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "T", data: "fixture", y: ["spending"], ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +describe("resolveBindings override merge", () => { + it("returns manifest column meta untouched when there are no bindings", () => { + const definition = definitionFor({ y: ["federal_debt"] }) + const { columns, diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([]) + expect(columns.federal_debt).toEqual(debt.manifest.columns.federal_debt) + expect(columns.gdp).toEqual(debt.manifest.columns.gdp) + }) + + it("applies per-binding overrides on top of manifest meta", () => { + const definition = definitionFor({ + y: ["federal_debt"], + bindings: { federal_debt: { name: "Net federal debt", decimals: 0 } }, + }) + const { columns } = resolveBindings(definition, debt.manifest) + expect(columns.federal_debt.name).toBe("Net federal debt") + expect(columns.federal_debt.decimals).toBe(0) + // Everything not overridden comes from the manifest. + expect(columns.federal_debt.unit).toBe("billion CAD") + expect(columns.federal_debt.denominator).toBe("gdp") + expect(columns.federal_debt.displayFactor).toBe(100) + }) + + it("ignores undefined entries in an override", () => { + const definition = definitionFor({ y: ["federal_debt"] }) + definition.bindings = { federal_debt: { name: undefined, decimals: 0 } } + const { columns } = resolveBindings(definition, debt.manifest) + expect(columns.federal_debt.name).toBe("Federal debt") + expect(columns.federal_debt.decimals).toBe(0) + }) + + it("does not mutate the manifest's column meta", () => { + const definition = definitionFor({ + y: ["federal_debt"], + bindings: { federal_debt: { name: "Overridden" } }, + }) + resolveBindings(definition, debt.manifest) + expect(debt.manifest.columns.federal_debt.name).toBe("Federal debt") + }) + + it("warns when a binding references an unknown column", () => { + const definition = definitionFor({ + y: ["federal_debt"], + bindings: { nonexistent: { name: "Ghost" } }, + }) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "warning", code: "unknown-binding-column" }), + ]) + }) + + it("errors when a y slug is not in the manifest", () => { + const definition = definitionFor({ y: ["federal_debt", "nonexistent"] }) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "error", code: "unknown-y-column" }), + ]) + }) +}) + +describe("resolveBindings stacking denominators (spec 01 §7)", () => { + const stackedY = { y: ["federal_debt", "provincial_debt", "municipal_debt"], types: ["stacked-area", "line"] } + + it("accepts stacked y columns sharing one denominator", () => { + const definition = definitionFor(stackedY) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([]) + }) + + it("errors when a binding override gives stacked columns different denominators", () => { + const definition = definitionFor({ + ...stackedY, + bindings: { provincial_debt: { denominator: "federal_debt" } }, + }) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "error", code: "mixed-denominators" }), + ]) + }) + + it("errors when one stacked column has a denominator and another has none", () => { + const definition = definitionFor({ + y: ["federal_debt", "gdp"], + types: ["stacked-bar"], + }) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "error", code: "mixed-denominators" }), + ]) + }) + + it("allows different denominators when no stacked type is offered", () => { + const definition = definitionFor({ + ...stackedY, + types: ["line", "discrete-bar"], + bindings: { provincial_debt: { denominator: "federal_debt" } }, + }) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([]) + }) + + it("never flags a single stacked y column", () => { + const definition = definitionFor({ y: ["federal_debt"], types: ["stacked-area"] }) + const { diagnostics } = resolveBindings(definition, debt.manifest) + expect(diagnostics).toEqual([]) + }) +}) + +describe("resolveSelection default top-N (spec 02 §1)", () => { + it("selects the top 8 entities by latest y[0] value, descending", () => { + const definition = definitionFor({}) + const { entities, diagnostics } = resolveSelection(definition, departments.dataset) + expect(diagnostics).toEqual([]) + // Department i spends i*10 + 4 in 2023-24, so the top 8 are the last 8. + expect(entities).toEqual([ + "Crown-Indigenous Relations and Northern Affairs Canada", + "Natural Resources Canada", + "Veterans Affairs Canada", + "Fisheries and Oceans Canada", + "Canada Revenue Agency", + "Agriculture and Agri-Food Canada", + "Environment and Climate Change Canada", + "Transport Canada", + ]) + }) + + it("returns every entity when fewer than 8 are available", () => { + const definition = definitionFor({ y: ["federal_debt"] }) + const { entities } = resolveSelection(definition, debt.dataset) + expect(entities).toEqual(["Canada"]) + }) + + it("restricts the default selection to includedEntities", () => { + const definition = definitionFor({ + includedEntities: ["National Defence", "Health Canada", "Transport Canada"], + }) + const { entities } = resolveSelection(definition, departments.dataset) + expect(entities).toEqual(["Transport Canada", "Health Canada", "National Defence"]) + }) + + it("removes excludedEntities before ranking", () => { + const definition = definitionFor({ + excludedEntities: ["Crown-Indigenous Relations and Northern Affairs Canada"], + }) + const { entities } = resolveSelection(definition, departments.dataset) + expect(entities[0]).toBe("Natural Resources Canada") + expect(entities).toHaveLength(8) + expect(entities[7]).toBe("Public Safety Canada") + }) +}) + +describe("resolveSelection explicit selection", () => { + it("intersects selectedEntities with the available set, preserving author order", () => { + const definition = definitionFor({ + selectedEntities: ["Health Canada", "National Defence"], + }) + const { entities, diagnostics } = resolveSelection(definition, departments.dataset) + expect(diagnostics).toEqual([]) + expect(entities).toEqual(["Health Canada", "National Defence"]) + }) + + it("warns about selected entities missing from the dataset", () => { + const definition = definitionFor({ + selectedEntities: ["Health Canada", "Ministry of Silly Walks"], + }) + const { entities, diagnostics } = resolveSelection(definition, departments.dataset) + expect(entities).toEqual(["Health Canada"]) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "warning", code: "unavailable-selected-entity" }), + ]) + }) + + it("drops selected entities excluded by excludedEntities", () => { + const definition = definitionFor({ + selectedEntities: ["Health Canada", "National Defence"], + excludedEntities: ["National Defence"], + }) + const { entities, diagnostics } = resolveSelection(definition, departments.dataset) + expect(entities).toEqual(["Health Canada"]) + expect(diagnostics).toHaveLength(1) + }) +}) diff --git a/packages/charts2/src/core/definition/resolve.ts b/packages/charts2/src/core/definition/resolve.ts new file mode 100644 index 00000000000..c6d2b6cd20f --- /dev/null +++ b/packages/charts2/src/core/definition/resolve.ts @@ -0,0 +1,161 @@ +/** + * Binding and selection resolution: definition × dataset → effective column + * metadata and the initial entity selection. Spec 02 §1. + * + * - resolveBindings merges manifest column meta with the definition's + * per-binding overrides (undefined entries ignored) and enforces the + * chart-level stacking rule from spec 01 §7: y columns stacked together + * must share a denominator. + * - resolveSelection computes the initial entities: the author's + * selectedEntities intersected with the choosable set, else the top 8 + * by latest y[0] value (spec 02 default). + */ + +import { resolveValue } from "../data/derived.ts" +import type { ChartDefinition, ColumnMeta, Dataset, Diagnostic, Manifest } from "../types.ts" + +/** Spec 02 §1: default initial selection is the "top N (≈8)" entities. */ +export const DEFAULT_SELECTION_SIZE = 8 + +// --------------------------------------------------------------------------- +// resolveBindings +// --------------------------------------------------------------------------- + +export interface ResolveBindingsResult { + /** Manifest column meta with per-binding overrides applied, by slug. */ + columns: Record + diagnostics: Diagnostic[] +} + +/** Merge per-binding overrides into column metadata, ignoring undefined entries. */ +function mergeColumnMeta(meta: ColumnMeta, overrides: Partial | undefined): ColumnMeta { + const merged: ColumnMeta = { ...meta } + if (overrides !== undefined) { + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) { + ;(merged as unknown as Record)[key] = value + } + } + } + return merged +} + +export function resolveBindings(definition: ChartDefinition, manifest: Manifest): ResolveBindingsResult { + const diagnostics: Diagnostic[] = [] + + const columns: Record = {} + for (const [slug, meta] of Object.entries(manifest.columns)) { + columns[slug] = mergeColumnMeta(meta, definition.bindings?.[slug]) + } + + for (const slug of Object.keys(definition.bindings ?? {})) { + if (!(slug in manifest.columns)) { + diagnostics.push({ + severity: "warning", + code: "unknown-binding-column", + message: `Binding override for "${slug}" references a column not in dataset "${manifest.name}"`, + context: { column: slug, dataset: manifest.name }, + }) + } + } + + for (const slug of definition.y) { + if (!(slug in manifest.columns)) { + diagnostics.push({ + severity: "error", + code: "unknown-y-column", + message: `y references column "${slug}" which is not in dataset "${manifest.name}"`, + context: { column: slug, dataset: manifest.name }, + }) + } + } + + // Spec 01 §7: stacking columns with different denominators is a + // validation error — component ÷ D only sums coherently to total ÷ D + // when every stacked column shares the same D. + const stackingCapable = definition.types.some((type) => type.startsWith("stacked-")) + if (stackingCapable) { + const stackedColumns = definition.y.filter((slug) => slug in columns) + const denominators = new Set(stackedColumns.map((slug) => columns[slug].denominator ?? "")) + if (stackedColumns.length > 1 && denominators.size > 1) { + diagnostics.push({ + severity: "error", + code: "mixed-denominators", + message: + `Stacked y columns must share one denominator; got ` + + stackedColumns.map((slug) => `${slug}: ${columns[slug].denominator ?? "(none)"}`).join(", "), + context: { columns: stackedColumns.join(", ") }, + }) + } + } + + return { columns, diagnostics } +} + +// --------------------------------------------------------------------------- +// resolveSelection +// --------------------------------------------------------------------------- + +export interface ResolveSelectionResult { + /** Initial entity selection, in selection order. */ + entities: string[] + diagnostics: Diagnostic[] +} + +/** The latest resolved y[0] value for an entity, or null when it has none. */ +function latestValue(dataset: Dataset, slug: string, entity: string, overrides: Partial | undefined): number | null { + if (dataset.manifest.timeGrain === "none" || dataset.times.length === 0) { + const resolved = resolveValue(dataset, slug, entity, null, overrides) + return resolved.status === "value" ? resolved.value : null + } + for (let i = dataset.times.length - 1; i >= 0; i--) { + const resolved = resolveValue(dataset, slug, entity, dataset.times[i], overrides) + if (resolved.status === "value") return resolved.value + } + return null +} + +export function resolveSelection(definition: ChartDefinition, dataset: Dataset): ResolveSelectionResult { + const diagnostics: Diagnostic[] = [] + + const included = definition.includedEntities !== undefined ? new Set(definition.includedEntities) : null + const excluded = new Set(definition.excludedEntities ?? []) + const available = dataset.entities.filter( + (entity) => (included === null || included.has(entity)) && !excluded.has(entity), + ) + + if (definition.selectedEntities !== undefined) { + const availableSet = new Set(available) + const entities: string[] = [] + for (const name of definition.selectedEntities) { + if (availableSet.has(name)) { + entities.push(name) + } else { + diagnostics.push({ + severity: "warning", + code: "unavailable-selected-entity", + message: `Selected entity "${name}" is not available in dataset "${dataset.manifest.name}"`, + context: { entity: name, dataset: dataset.manifest.name }, + }) + } + } + return { entities, diagnostics } + } + + // Default: top N by latest y[0] value, descending; ties keep the + // dataset's canonical entity order for determinism. Entities without + // any resolvable y[0] value never enter the default selection. + const slug = definition.y[0] + const overrides = definition.bindings?.[slug] + const ranked: { entity: string; value: number; index: number }[] = [] + available.forEach((entity, index) => { + const value = latestValue(dataset, slug, entity, overrides) + if (value !== null) ranked.push({ entity, value, index }) + }) + ranked.sort((a, b) => b.value - a.value || a.index - b.index) + + return { + entities: ranked.slice(0, DEFAULT_SELECTION_SIZE).map((entry) => entry.entity), + diagnostics, + } +} diff --git a/packages/charts2/src/core/definition/schema.test.ts b/packages/charts2/src/core/definition/schema.test.ts new file mode 100644 index 00000000000..e320cac0b89 --- /dev/null +++ b/packages/charts2/src/core/definition/schema.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest" + +import { parseDefinition, resolveDefinitionTimes } from "./schema.ts" + +const minimal = { + title: "Provincial spending", + data: "provincial-budgets", + y: ["spending"], +} + +describe("parseDefinition defaults (spec 02 §2)", () => { + it("parses a minimal definition without diagnostics", () => { + const { definition, diagnostics } = parseDefinition(minimal) + expect(diagnostics).toEqual([]) + expect(definition).not.toBeNull() + expect(definition!.title).toBe("Provincial spending") + expect(definition!.data).toBe("provincial-budgets") + expect(definition!.y).toEqual(["spending"]) + }) + + it("defaults schemaVersion to the current version (1)", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.schemaVersion).toBe(1) + }) + + it("defaults types to the line + discrete-bar pair", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.types).toEqual(["line", "discrete-bar"]) + }) + + it("defaults every title annotation to enabled", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.titleAnnotations).toEqual({ entity: true, time: true, changePrefix: true }) + }) + + it("fills the missing keys of a partial titleAnnotations object", () => { + const { definition } = parseDefinition({ ...minimal, titleAnnotations: { entity: false } }) + expect(definition!.titleAnnotations).toEqual({ entity: false, time: true, changePrefix: true }) + }) + + it("defaults selectionMode multi, stackMode absolute, facet none, missingData auto", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.selectionMode).toBe("multi") + expect(definition!.stackMode).toBe("absolute") + expect(definition!.facet).toBe("none") + expect(definition!.missingData).toBe("auto") + }) + + it("defaults hideTimeline and every hide* toggle to false", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.hideTimeline).toBe(false) + expect(definition!.hideLegend).toBe(false) + expect(definition!.hideSeriesLabels).toBe(false) + expect(definition!.hideRelativeToggle).toBe(false) + expect(definition!.hideTotalLabel).toBe(false) + }) + + it("leaves optional fields undefined rather than inventing values", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.time).toBeUndefined() + expect(definition!.selectedEntities).toBeUndefined() + expect(definition!.sort).toBeUndefined() + expect(definition!.defaultTab).toBeUndefined() + }) +}) + +describe("parseDefinition required fields", () => { + it("rejects a definition without a title", () => { + const { definition, diagnostics } = parseDefinition({ data: "d", y: ["a"] }) + expect(definition).toBeNull() + expect(diagnostics.some((d) => d.severity === "error" && d.message.includes("title"))).toBe(true) + }) + + it("rejects a definition without a data reference", () => { + const { definition, diagnostics } = parseDefinition({ title: "T", y: ["a"] }) + expect(definition).toBeNull() + expect(diagnostics.some((d) => d.severity === "error" && d.message.includes("data"))).toBe(true) + }) + + it("rejects a definition without y columns", () => { + const { definition, diagnostics } = parseDefinition({ title: "T", data: "d" }) + expect(definition).toBeNull() + expect(diagnostics.some((d) => d.severity === "error" && d.message.includes("y"))).toBe(true) + }) + + it("rejects an empty y array", () => { + const { definition, diagnostics } = parseDefinition({ title: "T", data: "d", y: [] }) + expect(definition).toBeNull() + expect(diagnostics.some((d) => d.severity === "error")).toBe(true) + }) + + it("rejects a non-object definition", () => { + const { definition, diagnostics } = parseDefinition("not a definition") + expect(definition).toBeNull() + expect(diagnostics[0].severity).toBe("error") + }) +}) + +describe("parseDefinition unknown fields (spec 02 §4)", () => { + it("warns about unknown top-level fields instead of silently ignoring them", () => { + const { definition, diagnostics } = parseDefinition({ ...minimal, x: "gdp" }) + expect(definition).not.toBeNull() + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "warning", code: "unknown-definition-field", context: { field: "x" } }), + ]) + }) + + it("warns about unknown fields inside binding overrides", () => { + const { definition, diagnostics } = parseDefinition({ + ...minimal, + bindings: { spending: { wat: 1 } }, + }) + expect(definition).not.toBeNull() + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "warning", code: "unknown-definition-field" }), + ]) + expect(diagnostics[0].message).toContain("spending") + }) + + it("warns about unknown fields inside axis configs", () => { + const { diagnostics } = parseDefinition({ ...minimal, yAxis: { min: 0, gridlines: "dotted" } }) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "warning", code: "unknown-definition-field" }), + ]) + }) +}) + +describe("parseDefinition time forms (spec 02 §1)", () => { + it("expands a single numeric time to a collapsed selection", () => { + const { definition } = parseDefinition({ ...minimal, time: 2024 }) + expect(definition!.time).toEqual({ start: 2024, end: 2024 }) + }) + + it('expands "latest" to a collapsed selection at latest', () => { + const { definition } = parseDefinition({ ...minimal, time: "latest" }) + expect(definition!.time).toEqual({ start: "latest", end: "latest" }) + }) + + it("accepts a [start, end] pair", () => { + const { definition } = parseDefinition({ ...minimal, time: ["earliest", 2024] }) + expect(definition!.time).toEqual({ start: "earliest", end: 2024 }) + }) + + it("accepts a {start, end} object", () => { + const { definition } = parseDefinition({ ...minimal, time: { start: 2010, end: "latest" } }) + expect(definition!.time).toEqual({ start: 2010, end: "latest" }) + }) + + it("carries grain-encoded strings verbatim until the grain is known", () => { + const { definition, diagnostics } = parseDefinition({ ...minimal, time: ["2014-15", "2024-25"] }) + expect(diagnostics).toEqual([]) + expect(definition!.time).toEqual({ start: "2014-15", end: "2024-25" }) + }) +}) + +describe("resolveDefinitionTimes", () => { + it("resolves fiscal-year strings to start-year ordinals", () => { + const { definition } = parseDefinition({ ...minimal, time: ["2014-15", "2024-25"] }) + const resolved = resolveDefinitionTimes(definition!, "fiscal-year") + expect(resolved.diagnostics).toEqual([]) + expect(resolved.definition.time).toEqual({ start: 2014, end: 2024 }) + }) + + it("leaves numbers and earliest/latest keywords untouched", () => { + const { definition } = parseDefinition({ ...minimal, time: ["earliest", 2020], timelineRange: [2000, "latest"] }) + const resolved = resolveDefinitionTimes(definition!, "year") + expect(resolved.diagnostics).toEqual([]) + expect(resolved.definition.time).toEqual({ start: "earliest", end: 2020 }) + expect(resolved.definition.timelineRange).toEqual({ start: 2000, end: "latest" }) + }) + + it("resolves timelineRange strings under the grain too", () => { + const { definition } = parseDefinition({ ...minimal, timelineRange: ["2019-20", "2023-24"] }) + const resolved = resolveDefinitionTimes(definition!, "fiscal-year") + expect(resolved.definition.timelineRange).toEqual({ start: 2019, end: 2023 }) + }) + + it("drops a selection with an invalid bound and reports an error", () => { + const { definition } = parseDefinition({ ...minimal, time: ["banana", "2024-25"] }) + const resolved = resolveDefinitionTimes(definition!, "fiscal-year") + expect(resolved.definition.time).toBeUndefined() + expect(resolved.diagnostics).toEqual([ + expect.objectContaining({ severity: "error", code: "bad-time-bound" }), + ]) + }) + + it("rejects strings that do not match the dataset's grain", () => { + // "2014-15" is a fiscal-year encoding; under the year grain it is invalid. + const { definition } = parseDefinition({ ...minimal, time: "2014-15" }) + const resolved = resolveDefinitionTimes(definition!, "year") + expect(resolved.definition.time).toBeUndefined() + expect(resolved.diagnostics[0].code).toBe("bad-time-bound") + }) + + it("does not mutate the input definition", () => { + const { definition } = parseDefinition({ ...minimal, time: "2014-15" }) + resolveDefinitionTimes(definition!, "fiscal-year") + expect(definition!.time).toEqual({ start: "2014-15", end: "2014-15" }) + }) +}) + +describe("parseDefinition versioning", () => { + it("rejects definitions from a future schema version", () => { + const { definition, diagnostics } = parseDefinition({ ...minimal, schemaVersion: 99 }) + expect(definition).toBeNull() + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "error", code: "unknown-schema-version" }), + ]) + }) + + it("treats a missing schemaVersion as version 1", () => { + const { definition } = parseDefinition(minimal) + expect(definition!.schemaVersion).toBe(1) + }) +}) diff --git a/packages/charts2/src/core/definition/schema.ts b/packages/charts2/src/core/definition/schema.ts new file mode 100644 index 00000000000..0dd1b887aea --- /dev/null +++ b/packages/charts2/src/core/definition/schema.ts @@ -0,0 +1,359 @@ +/** + * Chart definition parsing: raw JSON → ChartDefinition with defaults + * applied. Spec 02 §1–2. + * + * Defaults philosophy (spec 02 §2): a minimal definition — title, data, y — + * must produce a publishable chart; every other field is progressive + * refinement with a documented default. Unknown fields are warnings, never + * silently dropped (spec 02 §4); structural problems (missing title, empty + * y, malformed time) are errors and yield a null definition. + * + * Time bounds accept ordinals, "earliest"/"latest", or grain-encoded raw + * strings ("2024-25"). The grain is a dataset property the schema cannot + * know, so string bounds are carried verbatim until the manifest is loaded; + * resolveDefinitionTimes(definition, grain) then converts them to ordinals. + */ + +import { z } from "zod" + +import { parseTime } from "../data/time.ts" +import type { ChartDefinition, ChartType, Diagnostic, TimeBound, TimeGrain, TimeSelection } from "../types.ts" +import { CURRENT_SCHEMA_VERSION, migrateDefinition } from "./migrate.ts" + +/** Spec 02 §1: a definition supports the line + discrete-bar pair by default. */ +export const DEFAULT_CHART_TYPES: readonly ChartType[] = ["line", "discrete-bar"] + +// --------------------------------------------------------------------------- +// Schemas (zod v4) — unknown keys are stripped here and warned about below. +// --------------------------------------------------------------------------- + +const chartTypeSchema = z.enum(["line", "discrete-bar", "stacked-area", "stacked-bar", "stacked-discrete-bar"]) + +const tabSchema = z.union([chartTypeSchema, z.literal("table")]) + +const scaleTypeSchema = z.enum(["linear", "log"]) + +const columnTypeSchema = z.enum(["numeric", "integer", "percentage", "currency", "categorical", "ordinal"]) + +const toleranceDirectionSchema = z.enum(["both", "backwards", "forwards"]) + +const axisConfigSchema = z.object({ + min: z.union([z.number(), z.literal("auto")]).optional(), + max: z.union([z.number(), z.literal("auto")]).optional(), + scale: scaleTypeSchema.optional(), + canToggleScale: z.boolean().optional(), + label: z.string().optional(), + hideGridlines: z.boolean().optional(), + hideTickLabels: z.boolean().optional(), +}) + +const sortConfigSchema = z.object({ + by: z.enum(["total", "name", "column", "change", "custom"]), + order: z.enum(["asc", "desc"]), + column: z.string().optional(), +}) + +const titleAnnotationsSchema = z + .object({ + entity: z.boolean().default(true), + time: z.boolean().default(true), + changePrefix: z.boolean().default(true), + }) + .default({ entity: true, time: true, changePrefix: true }) + +/** Per-binding column metadata overrides: Partial. Spec 02 §1, spec 01 §7. */ +const bindingOverrideSchema = z.object({ + name: z.string().optional(), + type: columnTypeSchema.optional(), + unit: z.string().optional(), + shortUnit: z.string().optional(), + currency: z.string().optional(), + displayFactor: z.number().optional(), + decimals: z.number().int().min(0).optional(), + tolerance: z.number().int().min(0).optional(), + toleranceDirection: toleranceDirectionSchema.optional(), + projection: z.boolean().optional(), + projectionFrom: z.number().optional(), + denominator: z.string().optional(), + derivedUnit: z.string().optional(), + derivedShortUnit: z.string().optional(), + colour: z.string().optional(), + order: z.array(z.string()).optional(), + description: z.string().optional(), + source: z.number().int().optional(), +}) + +const comparisonLineSchema = z.object({ + y: z.number().optional(), + x: z.number().optional(), + label: z.string().optional(), +}) + +/** Raw time bound: ordinal, "earliest"/"latest", or grain-encoded string. */ +const timeBoundRawSchema = z.union([z.number(), z.string()]) + +/** Spec 02 §1: `time` accepts a single value, [start, end], or {start, end}. */ +const timeSelectionRawSchema = z.union([ + timeBoundRawSchema, + z.tuple([timeBoundRawSchema, timeBoundRawSchema]), + z.object({ start: timeBoundRawSchema, end: timeBoundRawSchema }), +]) + +const definitionSchema = z.object({ + schemaVersion: z.number().int().default(CURRENT_SCHEMA_VERSION), + slug: z.string().optional(), + title: z.string(), + subtitle: z.string().optional(), + note: z.string().optional(), + sourceText: z.string().optional(), + titleAnnotations: titleAnnotationsSchema, + + data: z.string(), + y: z.array(z.string()).min(1), + filter: z.record(z.string(), z.string()).optional(), + bindings: z.record(z.string(), bindingOverrideSchema).optional(), + + types: z.array(chartTypeSchema).min(1).default([...DEFAULT_CHART_TYPES]), + defaultTab: tabSchema.optional(), + + selectedEntities: z.array(z.string()).optional(), + includedEntities: z.array(z.string()).optional(), + excludedEntities: z.array(z.string()).optional(), + entityColours: z.record(z.string(), z.string()).optional(), + selectionMode: z.enum(["multi", "single", "fixed"]).default("multi"), + focusedSeries: z.array(z.string()).optional(), + + time: timeSelectionRawSchema.optional(), + timelineRange: timeSelectionRawSchema.optional(), + hideTimeline: z.boolean().default(false), + + xAxis: axisConfigSchema.optional(), + yAxis: axisConfigSchema.optional(), + stackMode: z.enum(["absolute", "relative"]).default("absolute"), + sort: sortConfigSchema.optional(), + facet: z.enum(["none", "entity", "metric"]).default("none"), + missingData: z.enum(["auto", "hide", "show"]).default("auto"), + comparisonLines: z.array(comparisonLineSchema).optional(), + seriesStrategy: z.enum(["entity", "metric"]).optional(), + + hideLegend: z.boolean().default(false), + hideSeriesLabels: z.boolean().default(false), + hideRelativeToggle: z.boolean().default(false), + hideTotalLabel: z.boolean().default(false), + + theme: z.string().optional(), + locale: z.enum(["en", "fr"]).optional(), +}) + +const KNOWN_DEFINITION_KEYS = new Set(Object.keys(definitionSchema.shape)) +const KNOWN_TITLE_ANNOTATION_KEYS = new Set(["entity", "time", "changePrefix"]) +const KNOWN_AXIS_KEYS = new Set(Object.keys(axisConfigSchema.shape)) +const KNOWN_SORT_KEYS = new Set(Object.keys(sortConfigSchema.shape)) +const KNOWN_BINDING_KEYS = new Set(Object.keys(bindingOverrideSchema.shape)) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function warnUnknownKeys( + raw: Record, + known: ReadonlySet, + where: string | null, + diagnostics: Diagnostic[], +): void { + for (const key of Object.keys(raw)) { + if (!known.has(key)) { + diagnostics.push({ + severity: "warning", + code: "unknown-definition-field", + message: + where === null + ? `Unknown definition field "${key}" was ignored` + : `Unknown field "${key}" on ${where} was ignored`, + context: where === null ? { field: key } : { field: key, where }, + }) + } + } +} + +/** Spec 02 §4: unknown fields are reported, not ignored silently. */ +function unknownFieldWarnings(raw: Record): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + warnUnknownKeys(raw, KNOWN_DEFINITION_KEYS, null, diagnostics) + if (isPlainObject(raw.titleAnnotations)) { + warnUnknownKeys(raw.titleAnnotations, KNOWN_TITLE_ANNOTATION_KEYS, "titleAnnotations", diagnostics) + } + for (const axis of ["xAxis", "yAxis"]) { + const value = raw[axis] + if (isPlainObject(value)) warnUnknownKeys(value, KNOWN_AXIS_KEYS, axis, diagnostics) + } + if (isPlainObject(raw.sort)) { + warnUnknownKeys(raw.sort, KNOWN_SORT_KEYS, "sort", diagnostics) + } + if (isPlainObject(raw.bindings)) { + for (const [slug, override] of Object.entries(raw.bindings)) { + if (isPlainObject(override)) { + warnUnknownKeys(override, KNOWN_BINDING_KEYS, `binding "${slug}"`, diagnostics) + } + } + } + return diagnostics +} + +type RawTimeBound = number | string +type RawTimeSelection = RawTimeBound | [RawTimeBound, RawTimeBound] | { start: RawTimeBound; end: RawTimeBound } + +/** + * Grain-encoded strings ("2024-25") cannot be resolved without the dataset + * manifest, so they are carried verbatim inside the TimeBound slot until + * resolveDefinitionTimes converts them. Numbers and the "earliest"/"latest" + * keywords are already canonical. + */ +function toTimeBound(value: RawTimeBound): TimeBound { + return value as TimeBound +} + +function toTimeSelection(raw: RawTimeSelection): TimeSelection { + if (typeof raw === "number" || typeof raw === "string") { + return { start: toTimeBound(raw), end: toTimeBound(raw) } + } + if (Array.isArray(raw)) { + return { start: toTimeBound(raw[0]), end: toTimeBound(raw[1]) } + } + return { start: toTimeBound(raw.start), end: toTimeBound(raw.end) } +} + +// --------------------------------------------------------------------------- +// parseDefinition +// --------------------------------------------------------------------------- + +export interface ParseDefinitionResult { + /** null when the definition has structural errors (see diagnostics). */ + definition: ChartDefinition | null + diagnostics: Diagnostic[] +} + +export function parseDefinition(raw: unknown): ParseDefinitionResult { + const diagnostics: Diagnostic[] = [] + + const migrated = migrateDefinition(raw) + diagnostics.push(...migrated.diagnostics) + if (migrated.raw === null) return { definition: null, diagnostics } + + diagnostics.push(...unknownFieldWarnings(migrated.raw)) + + const result = definitionSchema.safeParse(migrated.raw) + if (!result.success) { + for (const issue of result.error.issues) { + diagnostics.push({ + severity: "error", + code: "definition-invalid", + message: issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message, + context: { path: issue.path.join(".") }, + }) + } + return { definition: null, diagnostics } + } + + const parsed = result.data + + const definition: ChartDefinition = { + schemaVersion: parsed.schemaVersion, + title: parsed.title, + titleAnnotations: parsed.titleAnnotations, + data: parsed.data, + y: parsed.y, + types: parsed.types, + selectionMode: parsed.selectionMode, + hideTimeline: parsed.hideTimeline, + stackMode: parsed.stackMode, + facet: parsed.facet, + missingData: parsed.missingData, + hideLegend: parsed.hideLegend, + hideSeriesLabels: parsed.hideSeriesLabels, + hideRelativeToggle: parsed.hideRelativeToggle, + hideTotalLabel: parsed.hideTotalLabel, + ...(parsed.slug !== undefined ? { slug: parsed.slug } : {}), + ...(parsed.subtitle !== undefined ? { subtitle: parsed.subtitle } : {}), + ...(parsed.note !== undefined ? { note: parsed.note } : {}), + ...(parsed.sourceText !== undefined ? { sourceText: parsed.sourceText } : {}), + ...(parsed.filter !== undefined ? { filter: parsed.filter } : {}), + ...(parsed.bindings !== undefined ? { bindings: parsed.bindings } : {}), + ...(parsed.defaultTab !== undefined ? { defaultTab: parsed.defaultTab } : {}), + ...(parsed.selectedEntities !== undefined ? { selectedEntities: parsed.selectedEntities } : {}), + ...(parsed.includedEntities !== undefined ? { includedEntities: parsed.includedEntities } : {}), + ...(parsed.excludedEntities !== undefined ? { excludedEntities: parsed.excludedEntities } : {}), + ...(parsed.entityColours !== undefined ? { entityColours: parsed.entityColours } : {}), + ...(parsed.focusedSeries !== undefined ? { focusedSeries: parsed.focusedSeries } : {}), + ...(parsed.time !== undefined ? { time: toTimeSelection(parsed.time) } : {}), + ...(parsed.timelineRange !== undefined ? { timelineRange: toTimeSelection(parsed.timelineRange) } : {}), + ...(parsed.xAxis !== undefined ? { xAxis: parsed.xAxis } : {}), + ...(parsed.yAxis !== undefined ? { yAxis: parsed.yAxis } : {}), + ...(parsed.sort !== undefined ? { sort: parsed.sort } : {}), + ...(parsed.comparisonLines !== undefined ? { comparisonLines: parsed.comparisonLines } : {}), + ...(parsed.seriesStrategy !== undefined ? { seriesStrategy: parsed.seriesStrategy } : {}), + ...(parsed.theme !== undefined ? { theme: parsed.theme } : {}), + ...(parsed.locale !== undefined ? { locale: parsed.locale } : {}), + } + + return { definition, diagnostics } +} + +// --------------------------------------------------------------------------- +// resolveDefinitionTimes +// --------------------------------------------------------------------------- + +export interface ResolveDefinitionTimesResult { + definition: ChartDefinition + diagnostics: Diagnostic[] +} + +function resolveTimeBound( + bound: number | string, + grain: TimeGrain, + field: string, + diagnostics: Diagnostic[], +): TimeBound | null { + if (typeof bound === "number" || bound === "earliest" || bound === "latest") return bound + const ordinal = parseTime(bound, grain) + if (ordinal === null) { + diagnostics.push({ + severity: "error", + code: "bad-time-bound", + message: `Time bound "${bound}" in "${field}" does not parse under grain "${grain}"`, + context: { field, value: bound, grain }, + }) + return null + } + return ordinal +} + +/** + * Convert any grain-encoded string time bounds ("2024-25", "2024-Q1", …) + * left by parseDefinition into ordinals, now that the dataset's grain is + * known. A selection containing an invalid bound is dropped with an error + * Diagnostic; numbers and "earliest"/"latest" pass through untouched. + * Never mutates the input definition. + */ +export function resolveDefinitionTimes(definition: ChartDefinition, grain: TimeGrain): ResolveDefinitionTimesResult { + const diagnostics: Diagnostic[] = [] + const resolved: ChartDefinition = { ...definition } + + for (const field of ["time", "timelineRange"] as const) { + const selection = definition[field] + if (selection === undefined) continue + const start = resolveTimeBound(selection.start, grain, field, diagnostics) + const end = resolveTimeBound(selection.end, grain, field, diagnostics) + if (start === null || end === null) { + delete resolved[field] + } else { + resolved[field] = { start, end } + } + } + + return { definition: resolved, diagnostics } +} diff --git a/packages/charts2/src/core/definition/serialize.test.ts b/packages/charts2/src/core/definition/serialize.test.ts new file mode 100644 index 00000000000..7c408a72f69 --- /dev/null +++ b/packages/charts2/src/core/definition/serialize.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest" + +import { parseDefinition } from "./schema.ts" +import { serializeDefinition } from "./serialize.ts" + +const minimal = { + title: "Provincial spending", + data: "provincial-budgets", + y: ["spending"], +} + +/** Parse a raw definition that is known to be valid in these tests. */ +function parse(raw: unknown) { + const { definition } = parseDefinition(raw) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +const fullyPopulated = { + schemaVersion: 1, + slug: "provincial-spending", + title: "Provincial spending", + subtitle: "In billions of dollars", + note: "Excludes territories", + sourceText: "Public Accounts", + titleAnnotations: { entity: false, time: true, changePrefix: false }, + data: "provincial-budgets", + y: ["spending", "revenue"], + filter: { sector: "health" }, + bindings: { spending: { name: "Total spending", decimals: 0, denominator: "gdp" } }, + types: ["stacked-area", "line"], + defaultTab: "table", + selectedEntities: ["Alberta", "Ontario"], + includedEntities: ["Alberta", "Ontario", "Québec"], + excludedEntities: ["Canada"], + entityColours: { Alberta: "blue" }, + selectionMode: "single", + focusedSeries: ["Alberta"], + time: [2010, "latest"], + timelineRange: ["earliest", "latest"], + hideTimeline: true, + xAxis: { label: "Fiscal year" }, + yAxis: { min: 0, max: "auto", scale: "log", canToggleScale: true, hideGridlines: true }, + stackMode: "relative", + sort: { by: "column", order: "desc", column: "spending" }, + facet: "entity", + missingData: "hide", + comparisonLines: [{ y: 100, label: "Target" }, { x: 2020 }], + seriesStrategy: "entity", + hideLegend: true, + hideSeriesLabels: true, + hideRelativeToggle: true, + hideTotalLabel: true, + theme: "dark", + locale: "fr", +} + +describe("serializeDefinition omits defaults (spec 02 test expectation)", () => { + it("serializes a minimal definition to exactly title, data, y", () => { + const serialized = serializeDefinition(parse(minimal)) + expect(Object.keys(serialized).sort()).toEqual(["data", "title", "y"]) + expect(serialized).toEqual(minimal) + }) + + it("omits schemaVersion when it is 1 (missing reads as 1)", () => { + const serialized = serializeDefinition(parse({ ...minimal, schemaVersion: 1 })) + expect("schemaVersion" in serialized).toBe(false) + }) + + it("omits types when they equal the default pair, keeps them otherwise", () => { + expect("types" in serializeDefinition(parse({ ...minimal, types: ["line", "discrete-bar"] }))).toBe(false) + expect(serializeDefinition(parse({ ...minimal, types: ["line"] })).types).toEqual(["line"]) + }) + + it("writes only the suppressed title annotations", () => { + const serialized = serializeDefinition(parse({ ...minimal, titleAnnotations: { time: false } })) + expect(serialized.titleAnnotations).toEqual({ time: false }) + const allOn = serializeDefinition(parse({ ...minimal, titleAnnotations: { entity: true } })) + expect("titleAnnotations" in allOn).toBe(false) + }) + + it("omits explicitly-written default enum values", () => { + const serialized = serializeDefinition( + parse({ ...minimal, selectionMode: "multi", stackMode: "absolute", facet: "none", missingData: "auto" }), + ) + expect(Object.keys(serialized).sort()).toEqual(["data", "title", "y"]) + }) + + it("omits hide* toggles written as false", () => { + const serialized = serializeDefinition(parse({ ...minimal, hideTimeline: false, hideLegend: false })) + expect(Object.keys(serialized).sort()).toEqual(["data", "title", "y"]) + }) + + it("collapses an equal-bound time selection to a single value", () => { + const serialized = serializeDefinition(parse({ ...minimal, time: 2020 })) + expect(serialized.time).toBe(2020) + }) + + it("serializes a range as [start, end]", () => { + const serialized = serializeDefinition(parse({ ...minimal, time: ["earliest", 2024] })) + expect(serialized.time).toEqual(["earliest", 2024]) + }) +}) + +describe("serializeDefinition round-trips (spec 02 test expectation)", () => { + it("round-trips a minimal definition", () => { + const definition = parse(minimal) + const reparsed = parseDefinition(serializeDefinition(definition)) + expect(reparsed.diagnostics).toEqual([]) + expect(reparsed.definition).toEqual(definition) + }) + + it("round-trips a fully-populated definition", () => { + const definition = parse(fullyPopulated) + const serialized = serializeDefinition(definition) + const reparsed = parseDefinition(serialized) + expect(reparsed.diagnostics).toEqual([]) + expect(reparsed.definition).toEqual(definition) + }) + + it("round-trips unresolved grain-encoded time strings", () => { + const definition = parse({ ...minimal, time: ["2014-15", "2024-25"] }) + const serialized = serializeDefinition(definition) + expect(serialized.time).toEqual(["2014-15", "2024-25"]) + expect(parseDefinition(serialized).definition).toEqual(definition) + }) + + it("serializes to a plain JSON-safe object", () => { + const definition = parse(fullyPopulated) + const serialized = serializeDefinition(definition) + const viaJson = JSON.parse(JSON.stringify(serialized)) + expect(parseDefinition(viaJson).definition).toEqual(definition) + }) +}) diff --git a/packages/charts2/src/core/definition/serialize.ts b/packages/charts2/src/core/definition/serialize.ts new file mode 100644 index 00000000000..596fa324020 --- /dev/null +++ b/packages/charts2/src/core/definition/serialize.ts @@ -0,0 +1,84 @@ +/** + * Definition serialization: ChartDefinition → plain JSON-safe object, + * omitting every field equal to its documented default. Spec 02 test + * expectation: "serializing a definition omits defaulted fields", and + * parseDefinition(serializeDefinition(d)) round-trips to an identical + * definition. + */ + +import type { ChartDefinition, TimeSelection } from "../types.ts" +import { DEFAULT_CHART_TYPES } from "./schema.ts" + +function arraysEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]) +} + +/** {start, end} → single value when collapsed, [start, end] otherwise. */ +function serializeTimeSelection(selection: TimeSelection): unknown { + if (selection.start === selection.end) return selection.start + return [selection.start, selection.end] +} + +export function serializeDefinition(definition: ChartDefinition): Record { + const out: Record = {} + + // A missing schemaVersion is read as 1 (spec 02 §4), so 1 is the omitted default. + if (definition.schemaVersion !== 1) out.schemaVersion = definition.schemaVersion + + if (definition.slug !== undefined) out.slug = definition.slug + out.title = definition.title + if (definition.subtitle !== undefined) out.subtitle = definition.subtitle + if (definition.note !== undefined) out.note = definition.note + if (definition.sourceText !== undefined) out.sourceText = definition.sourceText + + // Each annotation defaults to true; only suppressed ones are written. + const annotations: Record = {} + if (!definition.titleAnnotations.entity) annotations.entity = false + if (!definition.titleAnnotations.time) annotations.time = false + if (!definition.titleAnnotations.changePrefix) annotations.changePrefix = false + if (Object.keys(annotations).length > 0) out.titleAnnotations = annotations + + out.data = definition.data + out.y = [...definition.y] + if (definition.filter !== undefined) out.filter = { ...definition.filter } + if (definition.bindings !== undefined) { + out.bindings = Object.fromEntries( + Object.entries(definition.bindings).map(([slug, override]) => [slug, { ...override }]), + ) + } + + if (!arraysEqual(definition.types, DEFAULT_CHART_TYPES)) out.types = [...definition.types] + if (definition.defaultTab !== undefined) out.defaultTab = definition.defaultTab + + if (definition.selectedEntities !== undefined) out.selectedEntities = [...definition.selectedEntities] + if (definition.includedEntities !== undefined) out.includedEntities = [...definition.includedEntities] + if (definition.excludedEntities !== undefined) out.excludedEntities = [...definition.excludedEntities] + if (definition.entityColours !== undefined) out.entityColours = { ...definition.entityColours } + if (definition.selectionMode !== "multi") out.selectionMode = definition.selectionMode + if (definition.focusedSeries !== undefined) out.focusedSeries = [...definition.focusedSeries] + + if (definition.time !== undefined) out.time = serializeTimeSelection(definition.time) + if (definition.timelineRange !== undefined) out.timelineRange = serializeTimeSelection(definition.timelineRange) + if (definition.hideTimeline) out.hideTimeline = true + + if (definition.xAxis !== undefined) out.xAxis = { ...definition.xAxis } + if (definition.yAxis !== undefined) out.yAxis = { ...definition.yAxis } + if (definition.stackMode !== "absolute") out.stackMode = definition.stackMode + if (definition.sort !== undefined) out.sort = { ...definition.sort } + if (definition.facet !== "none") out.facet = definition.facet + if (definition.missingData !== "auto") out.missingData = definition.missingData + if (definition.comparisonLines !== undefined) { + out.comparisonLines = definition.comparisonLines.map((line) => ({ ...line })) + } + if (definition.seriesStrategy !== undefined) out.seriesStrategy = definition.seriesStrategy + + if (definition.hideLegend) out.hideLegend = true + if (definition.hideSeriesLabels) out.hideSeriesLabels = true + if (definition.hideRelativeToggle) out.hideRelativeToggle = true + if (definition.hideTotalLabel) out.hideTotalLabel = true + + if (definition.theme !== undefined) out.theme = definition.theme + if (definition.locale !== undefined) out.locale = definition.locale + + return out +} diff --git a/packages/charts2/src/core/definition/urlState.test.ts b/packages/charts2/src/core/definition/urlState.test.ts new file mode 100644 index 00000000000..035c52efd9b --- /dev/null +++ b/packages/charts2/src/core/definition/urlState.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest" + +import type { TimeGrain, ViewState } from "../types.ts" +import { paramsToViewState, viewStateToParams } from "./urlState.ts" + +function decode(query: string, grain: TimeGrain = "year") { + return paramsToViewState(new URLSearchParams(query), grain) +} + +describe("viewStateToParams encoding", () => { + const encodeCases: { name: string; state: ViewState; grain: TimeGrain; expected: string }[] = [ + { name: "tab", state: { tab: "discrete-bar" }, grain: "year", expected: "tab=discrete-bar" }, + { name: "table tab", state: { tab: "table" }, grain: "year", expected: "tab=table" }, + { + name: "fiscal-year time range", + state: { time: { start: 2014, end: 2024 } }, + grain: "fiscal-year", + expected: "time=2014-15..2024-25", + }, + { + name: "year time range", + state: { time: { start: 2010, end: 2024 } }, + grain: "year", + expected: "time=2010..2024", + }, + { + name: "earliest..year", + state: { time: { start: "earliest", end: 2020 } }, + grain: "year", + expected: "time=earliest..2020", + }, + { + name: "collapsed latest", + state: { time: { start: "latest", end: "latest" } }, + grain: "fiscal-year", + expected: "time=latest", + }, + { + name: "collapsed single year", + state: { time: { start: 2020, end: 2020 } }, + grain: "year", + expected: "time=2020", + }, + { + name: "quarter range", + state: { time: { start: 2024 * 4, end: 2024 * 4 + 3 } }, + grain: "quarter", + expected: "time=2024-Q1..2024-Q4", + }, + { name: "yScale", state: { yScale: "log" }, grain: "year", expected: "yScale=log" }, + { name: "stackMode", state: { stackMode: "relative" }, grain: "year", expected: "stackMode=relative" }, + { name: "facet", state: { facet: "entity" }, grain: "year", expected: "facet=entity" }, + { + name: "tableSort", + state: { tableSort: { column: "spending", order: "asc" } }, + grain: "year", + expected: "tableSort=spending%3Aasc", + }, + { name: "tableScope", state: { tableScope: "all" }, grain: "year", expected: "tableScope=all" }, + ] + + for (const { name, state, grain, expected } of encodeCases) { + it(`encodes ${name} as ${expected}`, () => { + expect(viewStateToParams(state, grain).toString()).toBe(expected) + }) + } + + it("encodes entity lists with ~ joins and per-name URL encoding", () => { + const params = viewStateToParams({ entities: ["Île-du-Prince-Édouard", "Ontario"] }, "year") + expect(params.get("entities")).toBe("%C3%8Ele-du-Prince-%C3%89douard~Ontario") + }) + + it("writes nothing for an empty state", () => { + expect(viewStateToParams({}, "year").toString()).toBe("") + }) + + it("omits undefined fields entirely", () => { + const params = viewStateToParams({ tab: "line" }, "year") + expect([...params.keys()]).toEqual(["tab"]) + }) +}) + +describe("paramsToViewState decoding", () => { + const decodeCases: { name: string; query: string; grain: TimeGrain; expected: ViewState }[] = [ + { name: "tab", query: "tab=stacked-area", grain: "year", expected: { tab: "stacked-area" } }, + { + name: "fiscal-year range", + query: "time=2014-15..2024-25", + grain: "fiscal-year", + expected: { time: { start: 2014, end: 2024 } }, + }, + { + name: "year range", + query: "time=2010..2024", + grain: "year", + expected: { time: { start: 2010, end: 2024 } }, + }, + { + name: "earliest..year", + query: "time=earliest..2020", + grain: "year", + expected: { time: { start: "earliest", end: 2020 } }, + }, + { + name: "bare latest", + query: "time=latest", + grain: "fiscal-year", + expected: { time: { start: "latest", end: "latest" } }, + }, + { + name: "single year", + query: "time=2020", + grain: "year", + expected: { time: { start: 2020, end: 2020 } }, + }, + { + name: "month range", + query: "time=2024-01..2024-06", + grain: "month", + expected: { time: { start: 2024 * 12, end: 2024 * 12 + 5 } }, + }, + { + name: "accented entities", + query: "entities=%C3%8Ele-du-Prince-%C3%89douard~Ontario", + grain: "year", + expected: { entities: ["Île-du-Prince-Édouard", "Ontario"] }, + }, + { name: "focus", query: "focus=Alberta", grain: "year", expected: { focus: ["Alberta"] } }, + { name: "yScale", query: "yScale=linear", grain: "year", expected: { yScale: "linear" } }, + { name: "stackMode", query: "stackMode=absolute", grain: "year", expected: { stackMode: "absolute" } }, + { name: "facet", query: "facet=metric", grain: "year", expected: { facet: "metric" } }, + { + name: "tableSort", + query: "tableSort=spending:desc", + grain: "year", + expected: { tableSort: { column: "spending", order: "desc" } }, + }, + { name: "tableScope", query: "tableScope=selected", grain: "year", expected: { tableScope: "selected" } }, + ] + + for (const { name, query, grain, expected } of decodeCases) { + it(`decodes ${name} from "${query}"`, () => { + const { state, diagnostics } = decode(query, grain) + expect(diagnostics).toEqual([]) + expect(state).toEqual(expected) + }) + } + + it("decodes an empty entities param as an empty selection", () => { + const { state } = decode("entities=") + expect(state.entities).toEqual([]) + }) + + it("ignores parameter names it does not own, without diagnostics", () => { + const { state, diagnostics } = decode("utm_source=newsletter&page=2") + expect(state).toEqual({}) + expect(diagnostics).toEqual([]) + }) +}) + +describe("paramsToViewState never throws on bad input", () => { + const invalidCases: { name: string; query: string; grain: TimeGrain }[] = [ + { name: "unknown tab", query: "tab=pie", grain: "year" }, + { name: "unparseable time", query: "time=banana", grain: "year" }, + { name: "half-bad time range", query: "time=2010..banana", grain: "year" }, + { name: "triple-dotted time", query: "time=2010..2020..2024", grain: "year" }, + { name: "fiscal string under year grain", query: "time=2014-15", grain: "year" }, + { name: "unknown yScale", query: "yScale=cubic", grain: "year" }, + { name: "unknown stackMode", query: "stackMode=normalized", grain: "year" }, + { name: "unknown facet", query: "facet=both", grain: "year" }, + { name: "tableSort with dot separator", query: "tableSort=spending.asc", grain: "year" }, + { name: "tableSort with unknown order", query: "tableSort=spending:up", grain: "year" }, + { name: "tableSort without column", query: "tableSort=:asc", grain: "year" }, + { name: "unknown tableScope", query: "tableScope=everything", grain: "year" }, + { name: "malformed percent-encoding", query: "entities=%E0%A4%A", grain: "year" }, + ] + + for (const { name, query, grain } of invalidCases) { + it(`drops the field and warns for ${name}`, () => { + const { state, diagnostics } = decode(query, grain) + expect(state).toEqual({}) + expect(diagnostics).toEqual([ + expect.objectContaining({ severity: "warning", code: "invalid-url-param" }), + ]) + }) + } + + it("keeps valid params while dropping invalid ones", () => { + const { state, diagnostics } = decode("tab=line&yScale=cubic&time=2010..2020") + expect(state).toEqual({ tab: "line", time: { start: 2010, end: 2020 } }) + expect(diagnostics).toHaveLength(1) + }) +}) + +describe("URL codec round-trip property (spec 02 §3)", () => { + const states: { grain: TimeGrain; state: ViewState }[] = [ + { grain: "year", state: {} }, + { grain: "year", state: { tab: "line" } }, + { grain: "year", state: { tab: "table", tableScope: "all" } }, + { grain: "year", state: { time: { start: 2010, end: 2024 } } }, + { grain: "year", state: { time: { start: "earliest", end: "latest" } } }, + { grain: "year", state: { time: { start: "earliest", end: 2020 } } }, + { grain: "year", state: { time: { start: 2020, end: 2020 } } }, + { grain: "fiscal-year", state: { time: { start: 2014, end: 2024 } } }, + { grain: "fiscal-year", state: { time: { start: "latest", end: "latest" } } }, + { grain: "fiscal-year", state: { time: { start: 1999, end: 2000 } } }, + { grain: "quarter", state: { time: { start: 2023 * 4 + 2, end: 2024 * 4 + 1 } } }, + { grain: "month", state: { time: { start: 2024 * 12, end: 2024 * 12 + 11 } } }, + { grain: "date", state: { time: { start: 19723, end: 20088 } } }, + { grain: "year", state: { entities: [] } }, + { grain: "year", state: { entities: ["Île-du-Prince-Édouard", "Terre-Neuve-et-Labrador"] } }, + { grain: "year", state: { entities: ["Innovation, Science and Economic Development Canada"] } }, + { grain: "year", state: { entities: ["A~B", "C & D", "50% rule"] } }, + { grain: "year", state: { focus: ["Alberta – Spending", "Québec – Spending"] } }, + { grain: "year", state: { yScale: "log", stackMode: "relative", facet: "entity" } }, + { grain: "year", state: { tableSort: { column: "total_spending", order: "asc" } } }, + { grain: "year", state: { tableSort: { column: "debt", order: "desc" }, tableScope: "selected" } }, + { + grain: "fiscal-year", + state: { + tab: "stacked-discrete-bar", + time: { start: 2019, end: "latest" }, + entities: ["Île-du-Prince-Édouard", "Ontario"], + focus: ["Ontario"], + yScale: "linear", + stackMode: "relative", + facet: "metric", + tableSort: { column: "spending", order: "desc" }, + tableScope: "all", + }, + }, + ] + + it("round-trips every hand-built state losslessly through a serialized URL", () => { + for (const { grain, state } of states) { + const params = viewStateToParams(state, grain) + // Go through the string form: that is what actually lives in URLs. + const reparsed = paramsToViewState(new URLSearchParams(params.toString()), grain) + expect(reparsed.diagnostics).toEqual([]) + expect(reparsed.state).toEqual(state) + } + }) +}) diff --git a/packages/charts2/src/core/definition/urlState.ts b/packages/charts2/src/core/definition/urlState.ts new file mode 100644 index 00000000000..438fc685f61 --- /dev/null +++ b/packages/charts2/src/core/definition/urlState.ts @@ -0,0 +1,192 @@ +/** + * ViewState ↔ URLSearchParams codec. Spec 02 §3. + * + * User state layers over the definition and round-trips through the URL so + * any explored view is shareable. Parameter semantics follow owid-grapher's + * (`time=2010..2024`, `time=latest`), adapted to our time encodings + * (fiscal years: `time=2014-15..2024-25`). + * + * Params: tab, time, entities, focus, yScale, stackMode, facet, tableSort, + * tableScope. Encodings: + * - time: one canonical raw string, or "start..end"; bounds are + * formatTimeOrdinalRaw under the dataset grain or "earliest"/"latest" + * - entities/focus: "~"-joined, each name URL-encoded individually + * (literal "~" in a name is escaped as %7E before joining) + * - tableSort: "column:asc" | "column:desc" (":" never appears in the + * order token, so the last ":" splits unambiguously) + * + * Decoding never throws: unknown values produce a warning Diagnostic and + * the field is dropped. Unrecognized parameter NAMES are ignored silently — + * chart params share the page URL with the host application's own params. + */ + +import { formatTimeOrdinalRaw, parseTime } from "../data/time.ts" +import type { Diagnostic, SortOrder, Tab, TimeBound, TimeGrain, TimeSelection, ViewState } from "../types.ts" + +const TABS = new Set(["line", "discrete-bar", "stacked-area", "stacked-bar", "stacked-discrete-bar", "table"]) +const SCALES = new Set(["linear", "log"]) +const STACK_MODES = new Set(["absolute", "relative"]) +const FACETS = new Set(["none", "entity", "metric"]) +const TABLE_SCOPES = new Set(["selected", "all"]) +const SORT_ORDERS = new Set(["asc", "desc"]) + +// --------------------------------------------------------------------------- +// Encoding +// --------------------------------------------------------------------------- + +function encodeTimeBound(bound: TimeBound, grain: TimeGrain): string { + if (typeof bound === "number") return formatTimeOrdinalRaw(bound, grain) + return bound +} + +function encodeTimeSelection(selection: TimeSelection, grain: TimeGrain): string { + const start = encodeTimeBound(selection.start, grain) + const end = encodeTimeBound(selection.end, grain) + return start === end ? start : `${start}..${end}` +} + +function encodeNameList(names: readonly string[]): string { + // encodeURIComponent leaves "~" alone (it is unreserved), so escape it + // by hand — it is our join separator. + return names.map((name) => encodeURIComponent(name).replaceAll("~", "%7E")).join("~") +} + +/** Encode a view state as URL query parameters; only defined fields are written. */ +export function viewStateToParams(state: ViewState, grain: TimeGrain): URLSearchParams { + const params = new URLSearchParams() + if (state.tab !== undefined) params.set("tab", state.tab) + if (state.time !== undefined) params.set("time", encodeTimeSelection(state.time, grain)) + if (state.entities !== undefined) params.set("entities", encodeNameList(state.entities)) + if (state.focus !== undefined) params.set("focus", encodeNameList(state.focus)) + if (state.yScale !== undefined) params.set("yScale", state.yScale) + if (state.stackMode !== undefined) params.set("stackMode", state.stackMode) + if (state.facet !== undefined) params.set("facet", state.facet) + if (state.tableSort !== undefined) params.set("tableSort", `${state.tableSort.column}:${state.tableSort.order}`) + if (state.tableScope !== undefined) params.set("tableScope", state.tableScope) + return params +} + +// --------------------------------------------------------------------------- +// Decoding +// --------------------------------------------------------------------------- + +export interface ParamsToViewStateResult { + state: ViewState + diagnostics: Diagnostic[] +} + +function invalidParam(param: string, value: string): Diagnostic { + return { + severity: "warning", + code: "invalid-url-param", + message: `URL parameter "${param}" has unrecognized value "${value}" and was ignored`, + context: { param, value }, + } +} + +function decodeTimeBound(text: string, grain: TimeGrain): TimeBound | null { + if (text === "earliest" || text === "latest") return text + return parseTime(text, grain) +} + +function decodeTimeSelection(text: string, grain: TimeGrain): TimeSelection | null { + const parts = text.split("..") + if (parts.length === 1) { + const bound = decodeTimeBound(parts[0], grain) + if (bound === null) return null + return { start: bound, end: bound } + } + if (parts.length === 2) { + const start = decodeTimeBound(parts[0], grain) + const end = decodeTimeBound(parts[1], grain) + if (start === null || end === null) return null + return { start, end } + } + return null +} + +function decodeNameList(value: string): string[] | null { + if (value === "") return [] + try { + return value.split("~").map((part) => decodeURIComponent(part)) + } catch { + // Malformed percent-encoding — never throw, report instead. + return null + } +} + +/** + * Decode URL query parameters back into a view state. Unknown values yield + * a warning Diagnostic and the field is dropped — decoding never throws. + */ +export function paramsToViewState(params: URLSearchParams, grain: TimeGrain): ParamsToViewStateResult { + const diagnostics: Diagnostic[] = [] + const state: ViewState = {} + + const tab = params.get("tab") + if (tab !== null) { + if (TABS.has(tab)) state.tab = tab as Tab + else diagnostics.push(invalidParam("tab", tab)) + } + + const time = params.get("time") + if (time !== null) { + const selection = decodeTimeSelection(time, grain) + if (selection !== null) state.time = selection + else diagnostics.push(invalidParam("time", time)) + } + + const entities = params.get("entities") + if (entities !== null) { + const names = decodeNameList(entities) + if (names !== null) state.entities = names + else diagnostics.push(invalidParam("entities", entities)) + } + + const focus = params.get("focus") + if (focus !== null) { + const names = decodeNameList(focus) + if (names !== null) state.focus = names + else diagnostics.push(invalidParam("focus", focus)) + } + + const yScale = params.get("yScale") + if (yScale !== null) { + if (SCALES.has(yScale)) state.yScale = yScale as ViewState["yScale"] + else diagnostics.push(invalidParam("yScale", yScale)) + } + + const stackMode = params.get("stackMode") + if (stackMode !== null) { + if (STACK_MODES.has(stackMode)) state.stackMode = stackMode as ViewState["stackMode"] + else diagnostics.push(invalidParam("stackMode", stackMode)) + } + + const facet = params.get("facet") + if (facet !== null) { + if (FACETS.has(facet)) state.facet = facet as ViewState["facet"] + else diagnostics.push(invalidParam("facet", facet)) + } + + const tableSort = params.get("tableSort") + if (tableSort !== null) { + // The order token never contains ":", so the LAST ":" is the split + // point even if a column slug were to contain one. + const separator = tableSort.lastIndexOf(":") + const column = separator > 0 ? tableSort.slice(0, separator) : "" + const order = separator > 0 ? tableSort.slice(separator + 1) : "" + if (column !== "" && SORT_ORDERS.has(order)) { + state.tableSort = { column, order: order as SortOrder } + } else { + diagnostics.push(invalidParam("tableSort", tableSort)) + } + } + + const tableScope = params.get("tableScope") + if (tableScope !== null) { + if (TABLE_SCOPES.has(tableScope)) state.tableScope = tableScope as ViewState["tableScope"] + else diagnostics.push(invalidParam("tableScope", tableScope)) + } + + return { state, diagnostics } +} diff --git a/packages/charts2/src/core/format/index.ts b/packages/charts2/src/core/format/index.ts new file mode 100644 index 00000000000..e0ec5670892 --- /dev/null +++ b/packages/charts2/src/core/format/index.ts @@ -0,0 +1,6 @@ +// M2: formatting service (spec 03 §4–5). One service for every surface so a +// value never formats differently between axis, tooltip, label, and table. + +export * from "./locales.ts" +export * from "./number.ts" +export * from "./timeLabels.ts" diff --git a/packages/charts2/src/core/format/locales.test.ts b/packages/charts2/src/core/format/locales.test.ts new file mode 100644 index 00000000000..92f5a0f08fb --- /dev/null +++ b/packages/charts2/src/core/format/locales.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { enCA, frCA, longScaleWord, shortScaleSuffixes } from "./locales.ts" + +describe("static d3-format locales", () => { + it("en-CA groups thousands with commas and a leading dollar symbol", () => { + expect(enCA.format("$,.0f")(1234567)).toBe("$1,234,567") + }) + + it("fr-CA groups thousands with narrow NBSP and a decimal comma", () => { + expect(frCA.format(",.1f")(1234567.8)).toBe("1\u202f234\u202f567,8") + }) + + it("fr-CA places the currency symbol after the number behind an NBSP", () => { + expect(frCA.format("$,.0f")(1234)).toBe("1\u202f234\u00a0$") + }) +}) + +describe("longScaleWord pluralization", () => { + it("English scale words never pluralize after a numeral", () => { + expect(longScaleWord(9, "en", 24.1)).toBe("billion") + }) + + it("French scale words stay singular below two", () => { + expect(longScaleWord(9, "fr", 1.9)).toBe("milliard") + }) + + it("French scale words pluralize from two upward", () => { + expect(longScaleWord(9, "fr", 2)).toBe("milliards") + }) + + it("French pluralization considers magnitude, not sign", () => { + expect(longScaleWord(6, "fr", -3)).toBe("millions") + }) + + it("French 1e12 is the long-scale billion", () => { + expect(longScaleWord(12, "fr", 1)).toBe("billion") + }) + + it("French SI-style tick suffixes use G for milliard", () => { + expect(shortScaleSuffixes.fr[9]).toBe("G") + expect(shortScaleSuffixes.en[9]).toBe("B") + }) +}) diff --git a/packages/charts2/src/core/format/locales.ts b/packages/charts2/src/core/format/locales.ts new file mode 100644 index 00000000000..3e1bfc8309d --- /dev/null +++ b/packages/charts2/src/core/format/locales.ts @@ -0,0 +1,74 @@ +/** + * Static d3-format locale definitions (spec 28 §2 rule 3). + * + * NO Intl anywhere in core: number formatting routes through these frozen + * d3-format locale objects so output is byte-identical across runtimes. + * ICU version drift is a determinism bug. + */ + +import { formatLocale } from "d3-format" +import type { FormatLocaleObject } from "d3-format" +import type { Locale } from "../types.ts" + +// Typographic characters used throughout formatting. Exported so tests and +// callers can reference them by name rather than invisible literals. + +/** No-break space (U+00A0): binds units to numbers in French ("24 $", "42 %"). */ +export const NBSP = "\u00a0" +/** Narrow no-break space (U+202F): French thousands grouping ("10 000"). */ +export const NARROW_NBSP = "\u202f" +/** True minus sign (U+2212), never a hyphen. */ +export const MINUS_SIGN = "\u2212" + +export const enCA: FormatLocaleObject = formatLocale({ + decimal: ".", + thousands: ",", + grouping: [3], + currency: ["$", ""], +}) + +export const frCA: FormatLocaleObject = formatLocale({ + decimal: ",", + thousands: NARROW_NBSP, + grouping: [3], + currency: ["", `${NBSP}$`], +}) + +export function localeFormatter(locale: Locale): FormatLocaleObject { + return locale === "fr" ? frCA : enCA +} + +// --------------------------------------------------------------------------- +// Abbreviation word forms +// --------------------------------------------------------------------------- + +/** Power-of-ten exponent of an abbreviation tier. */ +export type ScaleExponent = 3 | 6 | 9 | 12 + +type ScaleTable = Record + +/** Long (spelled-out) scale words, singular form. French long scale: 1e12 = "billion". */ +export const longScaleWords: Record = { + en: { 3: "thousand", 6: "million", 9: "billion", 12: "trillion" }, + fr: { 3: "millier", 6: "million", 9: "milliard", 12: "billion" }, +} + +/** Short tick suffixes. French uses SI-style letters (G for milliard). */ +export const shortScaleSuffixes: Record = { + en: { 3: "k", 6: "M", 9: "B", 12: "T" }, + fr: { 3: "k", 6: "M", 9: "G", 12: "T" }, +} + +/** + * Pluralized long-scale word for a formatted mantissa. + * + * French nouns pluralize from 2 upward ("1,9 million" but "2 millions"); + * English is invariant after a numeral ("24 billion", never "24 billions"). + */ +export function longScaleWord(exponent: ScaleExponent, locale: Locale, mantissa: number): string { + const word = longScaleWords[locale][exponent] + if (locale === "fr" && Math.abs(mantissa) >= 2) { + return `${word}s` + } + return word +} diff --git a/packages/charts2/src/core/format/number.test.ts b/packages/charts2/src/core/format/number.test.ts new file mode 100644 index 00000000000..79a5b6ccdee --- /dev/null +++ b/packages/charts2/src/core/format/number.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest" +import type { FormatMeta, FormatValueOptions, Verbosity } from "./number.ts" +import type { Locale } from "../types.ts" +import { formatChange, formatValue } from "./number.ts" + +const numeric: FormatMeta = { type: "numeric" } +const integer: FormatMeta = { type: "integer" } +const percentage: FormatMeta = { type: "percentage" } +const cad: FormatMeta = { type: "currency", currency: "CAD" } +const debtToGdp: FormatMeta = { + type: "currency", + currency: "CAD", + denominator: "gdp", + derivedUnit: "% of GDP", + derivedShortUnit: "%", +} + +interface Case { + description: string + value: number + meta: FormatMeta + locale: Locale + verbosity: Verbosity + showSign?: boolean + expected: string +} + +function run(cases: Case[]): void { + for (const c of cases) { + it(c.description, () => { + const opts: FormatValueOptions = { locale: c.locale, verbosity: c.verbosity, showSign: c.showSign } + expect(formatValue(c.value, c.meta, opts)).toBe(c.expected) + }) + } +} + +describe("formatValue: abbreviation thresholds (en ticks)", () => { + run([ + { description: "999 stays unabbreviated below the 1e3 threshold", value: 999, meta: numeric, locale: "en", verbosity: "tick", expected: "999" }, + { description: "exactly 1e3 abbreviates to 1k", value: 1_000, meta: numeric, locale: "en", verbosity: "tick", expected: "1k" }, + { description: "1,200 abbreviates to 1.2k", value: 1_200, meta: numeric, locale: "en", verbosity: "tick", expected: "1.2k" }, + { description: "999,999 rounds up across the tier boundary to 1M, never 1,000k", value: 999_999, meta: numeric, locale: "en", verbosity: "tick", expected: "1M" }, + { description: "exactly 1e6 abbreviates to 1M", value: 1_000_000, meta: numeric, locale: "en", verbosity: "tick", expected: "1M" }, + { description: "1.2 million abbreviates to 1.2M", value: 1_200_000, meta: numeric, locale: "en", verbosity: "tick", expected: "1.2M" }, + { description: "exactly 1e9 abbreviates to 1B", value: 1e9, meta: numeric, locale: "en", verbosity: "tick", expected: "1B" }, + { description: "exactly 1e12 abbreviates to 1T", value: 1e12, meta: numeric, locale: "en", verbosity: "tick", expected: "1T" }, + { description: "2.4 quadrillion stays in the trillion tier with grouping", value: 2.4e15, meta: numeric, locale: "en", verbosity: "tick", expected: "2,400T" }, + { description: "mantissas keep three significant figures (12.84M not 13M)", value: 12_837_000, meta: numeric, locale: "en", verbosity: "tick", expected: "12.8M" }, + ]) +}) + +describe("formatValue: long verbosity spells out scale words", () => { + run([ + { description: "24.1 billion spells out in English tooltips", value: 24.13e9, meta: numeric, locale: "en", verbosity: "long", expected: "24.1 billion" }, + { description: "long form does not abbreviate below one million", value: 999_999, meta: numeric, locale: "en", verbosity: "long", expected: "999,999" }, + { description: "1.2 million spells out", value: 1_200_000, meta: numeric, locale: "en", verbosity: "long", expected: "1.2 million" }, + { description: "1.5 trillion spells out", value: 1.5e12, meta: numeric, locale: "en", verbosity: "long", expected: "1.5 trillion" }, + { description: "fr: 1,2 milliard stays singular below two (NBSP joins the word)", value: 1.2e9, meta: numeric, locale: "fr", verbosity: "long", expected: "1,2\u00a0milliard" }, + { description: "fr: 24,1 milliards pluralizes at two and above", value: 24.1e9, meta: numeric, locale: "fr", verbosity: "long", expected: "24,1\u00a0milliards" }, + { description: "fr: 3 billions is the French long-scale word for 1e12", value: 3e12, meta: numeric, locale: "fr", verbosity: "long", expected: "3\u00a0billions" }, + { description: "fr: unabbreviated values group thousands with narrow NBSP", value: 10_000, meta: numeric, locale: "fr", verbosity: "long", expected: "10\u202f000" }, + ]) +}) + +describe("formatValue: French ticks use SI-style suffixes", () => { + run([ + { description: "fr tick: 1,2 M with NBSP before the suffix", value: 1_200_000, meta: numeric, locale: "fr", verbosity: "tick", expected: "1,2\u00a0M" }, + { description: "fr tick: G for milliard", value: 24e9, meta: numeric, locale: "fr", verbosity: "tick", expected: "24\u00a0G" }, + { description: "fr tick: T for the French billion (1e12)", value: 2e12, meta: numeric, locale: "fr", verbosity: "tick", expected: "2\u00a0T" }, + { description: "fr tick: decimal comma in the mantissa", value: 1_250, meta: numeric, locale: "fr", verbosity: "tick", expected: "1,25\u00a0k" }, + ]) +}) + +describe("formatValue: signs", () => { + run([ + { description: "negative values carry the true minus sign, never a hyphen", value: -1_200_000, meta: numeric, locale: "en", verbosity: "tick", expected: "\u22121.2M" }, + { description: "negative currency keeps the minus outside the symbol", value: -24e9, meta: cad, locale: "en", verbosity: "tick", expected: "\u2212$24B" }, + { description: "fr negative uses the true minus sign too", value: -1.2e9, meta: numeric, locale: "fr", verbosity: "long", expected: "\u22121,2\u00a0milliard" }, + { description: "showSign forces an explicit plus on percentages", value: 3.2, meta: percentage, locale: "en", verbosity: "label", showSign: true, expected: "+3.2%" }, + { description: "showSign leaves zero unsigned", value: 0, meta: numeric, locale: "en", verbosity: "tick", showSign: true, expected: "0" }, + { description: "a negative that rounds to zero drops the minus sign", value: -0.4, meta: { type: "numeric", decimals: 0 }, locale: "en", verbosity: "tick", expected: "0" }, + { description: "zero formats as a bare 0", value: 0, meta: numeric, locale: "en", verbosity: "long", expected: "0" }, + ]) +}) + +describe("formatValue: currency", () => { + run([ + { description: "en tick: symbol leads and B abbreviates", value: 24e9, meta: cad, locale: "en", verbosity: "tick", expected: "$24B" }, + { description: "en tick: $24.1B keeps mantissa significance", value: 24.13e9, meta: cad, locale: "en", verbosity: "tick", expected: "$24.1B" }, + { description: "en long: $24.1 billion spells out behind the symbol", value: 24.13e9, meta: cad, locale: "en", verbosity: "long", expected: "$24.1 billion" }, + { description: "en unabbreviated currency keeps grouping", value: 950, meta: cad, locale: "en", verbosity: "tick", expected: "$950" }, + { description: "missing currency code defaults to the CAD dollar sign", value: 5_000, meta: { type: "currency" }, locale: "en", verbosity: "tick", expected: "$5k" }, + { description: "USD renders the disambiguated US$ symbol", value: 5_000, meta: { type: "currency", currency: "USD" }, locale: "en", verbosity: "tick", expected: "US$5k" }, + { description: "fr tick: 24 G$ puts the symbol after the SI suffix", value: 24e9, meta: cad, locale: "fr", verbosity: "tick", expected: "24\u00a0G$" }, + { description: "fr unabbreviated: symbol trails behind an NBSP", value: 950, meta: cad, locale: "fr", verbosity: "tick", expected: "950\u00a0$" }, + { description: "fr long: 24,1 milliards $ trails the symbol", value: 24.13e9, meta: cad, locale: "fr", verbosity: "long", expected: "24,1\u00a0milliards\u00a0$" }, + ]) +}) + +describe("formatValue: percentages", () => { + run([ + { description: "percentage appends % with no space in English", value: 42, meta: percentage, locale: "en", verbosity: "tick", expected: "42%" }, + { description: "fr percentage binds % with an NBSP", value: 42, meta: percentage, locale: "fr", verbosity: "tick", expected: "42\u00a0%" }, + { description: "percentages never abbreviate, even past 1e3", value: 1_500, meta: percentage, locale: "en", verbosity: "tick", expected: "1,500%" }, + { description: "negative percentage takes the true minus", value: -3.2, meta: percentage, locale: "en", verbosity: "label", expected: "\u22123.2%" }, + { description: "fr percentage uses the decimal comma", value: 3.2, meta: percentage, locale: "fr", verbosity: "label", showSign: true, expected: "+3,2\u00a0%" }, + ]) +}) + +describe("formatValue: decimals and smart defaults", () => { + run([ + { description: "explicit decimals are honoured exactly, untrimmed", value: 24, meta: { type: "numeric", decimals: 1 }, locale: "en", verbosity: "label", expected: "24.0" }, + { description: "explicit decimals: 0 rounds to whole numbers", value: 3.7, meta: { type: "numeric", decimals: 0 }, locale: "en", verbosity: "label", expected: "4" }, + { description: "explicit decimals apply to abbreviated mantissas too", value: 24e9, meta: { type: "currency", currency: "CAD", decimals: 1 }, locale: "en", verbosity: "tick", expected: "$24.0B" }, + { description: "smart default trims to at most two decimals at unit scale", value: 3.14159, meta: numeric, locale: "en", verbosity: "label", expected: "3.14" }, + { description: "smart default keeps grouping below the long-form threshold", value: 1_234.5, meta: numeric, locale: "en", verbosity: "long", expected: "1,234.5" }, + { description: "very small values keep their significance", value: 0.0004, meta: numeric, locale: "en", verbosity: "label", expected: "0.0004" }, + { description: "very small values round to two significant figures", value: 0.000456, meta: numeric, locale: "en", verbosity: "label", expected: "0.00046" }, + { description: "integer columns never show decimals", value: 1_234.6, meta: integer, locale: "en", verbosity: "long", expected: "1,235" }, + { description: "integer columns still abbreviate on ticks", value: 1_200_000, meta: integer, locale: "en", verbosity: "tick", expected: "1.2M" }, + ]) +}) + +describe("formatValue: units", () => { + run([ + { description: "short unit attaches to ticks with a space", value: 1_200_000, meta: { type: "numeric", shortUnit: "t" }, locale: "en", verbosity: "tick", expected: "1.2M t" }, + { description: "long unit appears at long verbosity", value: 24.1e9, meta: { type: "numeric", unit: "tonnes", shortUnit: "t" }, locale: "en", verbosity: "long", expected: "24.1 billion tonnes" }, + { description: "ticks fall back to nothing when no short unit exists", value: 12, meta: { type: "numeric", unit: "tonnes" }, locale: "en", verbosity: "tick", expected: "12" }, + { description: "long verbosity falls back to the short unit", value: 12, meta: { type: "numeric", shortUnit: "t" }, locale: "en", verbosity: "long", expected: "12 t" }, + { description: "fr units bind with an NBSP", value: 1_200_000, meta: { type: "numeric", shortUnit: "t" }, locale: "fr", verbosity: "tick", expected: "1,2\u00a0M\u00a0t" }, + ]) +}) + +describe("formatValue: derived columns (denominator)", () => { + run([ + { description: "derived short unit replaces the currency symbol on ticks", value: 42.5, meta: debtToGdp, locale: "en", verbosity: "tick", expected: "42.5%" }, + { description: "derived long unit spells the ratio out", value: 42.5, meta: debtToGdp, locale: "en", verbosity: "long", expected: "42.5% of GDP" }, + { description: "fr derived unit binds with an NBSP", value: 42.5, meta: { ...debtToGdp, derivedUnit: "% du PIB" }, locale: "fr", verbosity: "long", expected: "42,5\u00a0% du PIB" }, + { description: "a denominator without derived units leaves base behavior intact", value: 42.5, meta: { type: "currency", currency: "CAD", denominator: "population" }, locale: "en", verbosity: "tick", expected: "$42.5" }, + ]) +}) + +describe("formatChange", () => { + it("labels percentage-point changes with pp and the relative change with %", () => { + const { absolute, relative } = formatChange(50, 53.2, percentage, { locale: "en" }) + expect(absolute).toBe("+3.2 pp") + expect(relative).toBe("+6.4%") + }) + + it("formats a negative percentage-point change with the true minus", () => { + const { absolute, relative } = formatChange(40, 30, percentage, { locale: "en" }) + expect(absolute).toBe("\u221210 pp") + expect(relative).toBe("\u221225%") + }) + + it("fr: binds pp and % with NBSP and uses the decimal comma", () => { + const { absolute, relative } = formatChange(50, 53.2, percentage, { locale: "fr" }) + expect(absolute).toBe("+3,2\u00a0pp") + expect(relative).toBe("+6,4\u00a0%") + }) + + it("formats currency changes in the column's own unit", () => { + const { absolute, relative } = formatChange(100, 80, cad, { locale: "en" }) + expect(absolute).toBe("\u2212$20") + expect(relative).toBe("\u221220%") + }) + + it("abbreviates large absolute changes at the requested verbosity", () => { + const { absolute } = formatChange(10e9, 34.1e9, cad, { locale: "en", verbosity: "tick" }) + expect(absolute).toBe("+$24.1B") + }) + + it("returns null relative change when the start value is zero", () => { + const { absolute, relative } = formatChange(0, 5, numeric, { locale: "en" }) + expect(absolute).toBe("+5") + expect(relative).toBeNull() + }) + + it("measures relative change against the magnitude of a negative start", () => { + const { absolute, relative } = formatChange(-10, -5, numeric, { locale: "en" }) + expect(absolute).toBe("+5") + expect(relative).toBe("+50%") + }) +}) diff --git a/packages/charts2/src/core/format/number.ts b/packages/charts2/src/core/format/number.ts new file mode 100644 index 00000000000..2078cd21878 --- /dev/null +++ b/packages/charts2/src/core/format/number.ts @@ -0,0 +1,240 @@ +/** + * THE number-formatting service (spec 03 §4). + * + * A single entry point used by every surface — axis ticks, tooltips, data + * labels, tables, CSV headers — so a value never formats differently between + * surfaces. No Intl (spec 28 §2 rule 3): everything routes through the static + * d3-format locales in ./locales.ts. + */ + +import type { ColumnMeta, Locale } from "../types.ts" +import type { ScaleExponent } from "./locales.ts" +import { MINUS_SIGN, NBSP, localeFormatter, longScaleWord, shortScaleSuffixes } from "./locales.ts" + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * How much room the surface has: + * - "tick" — axis ticks: short abbreviation ("$24B", fr "24 G$") + * - "label" — data labels: same short abbreviation as ticks + * - "long" — tooltips/tables: spelled out ("$24.1 billion", fr "24,1 milliards $") + */ +export type Verbosity = "tick" | "label" | "long" + +/** The display-relevant slice of ColumnMeta that formatting consumes. */ +export type FormatMeta = Pick< + ColumnMeta, + | "type" + | "unit" + | "shortUnit" + | "currency" + | "decimals" + | "denominator" + | "derivedUnit" + | "derivedShortUnit" +> + +export interface FormatValueOptions { + locale: Locale + verbosity: Verbosity + /** Force an explicit "+" on positive values (relative-change displays). */ + showSign?: boolean +} + +export interface FormatChangeOptions { + locale: Locale + /** Defaults to "label". */ + verbosity?: Verbosity +} + +export interface ChangeStrings { + /** Signed absolute change; "pp" units when the column is a percentage. */ + absolute: string + /** Signed relative change as a percentage; null when start is 0 (undefined ratio). */ + relative: string | null +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/** Symbol for an ISO currency code. Default and "CAD" render the bare "$". */ +function currencySymbol(code: string | undefined): string { + switch (code) { + case undefined: + case "CAD": + return "$" + case "USD": + return "US$" + case "EUR": + return "€" + case "GBP": + return "£" + default: + return code + } +} + +/** Abbreviation tier for a magnitude, given the verbosity's entry threshold. */ +function tierExponent(abs: number, threshold: number): ScaleExponent | 0 { + if (abs < threshold) return 0 + if (abs >= 1e12) return 12 + if (abs >= 1e9) return 9 + if (abs >= 1e6) return 6 + if (abs >= 1e3) return 3 + return 0 +} + +/** + * d3-format specifier for the digits. + * + * Explicit `meta.decimals` are honoured exactly (fixed, untrimmed) so a + * column formats consistently everywhere. Smart defaults otherwise: + * - abbreviated mantissas: 3 significant figures, trimmed ("24.1", "1.2") + * - |v| ≥ 1: up to 2 decimals, trimmed ("1,234.5") + * - 0 < |v| < 1: 2 significant figures, trimmed — small values keep their + * significance ("0.0004"), never collapse to "0" + */ +function digitsSpecifier(abs: number, abbreviated: boolean, meta: FormatMeta): string { + if (meta.decimals !== undefined) return `,.${meta.decimals}f` + if (abbreviated) return ",.3~r" + if (meta.type === "integer") return ",.0f" + if (abs > 0 && abs < 1) return ".2~r" + return ",.2~f" +} + +/** True when the formatted digits are all zeros (guards against "−0"). */ +function isZeroString(digits: string): boolean { + return !/[1-9]/.test(digits) +} + +interface FormattedNumber { + /** "" | "+" | true minus. */ + sign: string + /** Locale-grouped digits of the (abbreviated) magnitude. */ + digits: string + /** Short suffix or long word, with its joining space; "" when unabbreviated. */ + scale: string +} + +function formatNumberParts(value: number, meta: FormatMeta, opts: FormatValueOptions, abbreviate: boolean): FormattedNumber { + const { locale, verbosity, showSign = false } = opts + const format = localeFormatter(locale) + const abs = Math.abs(value) + + const threshold = verbosity === "long" ? 1e6 : 1e3 + let exponent = abbreviate ? tierExponent(abs, threshold) : 0 + + // Rounding can push a mantissa past 1000 ("999,950" → "1,000k"): bump tiers. + if (exponent > 0 && exponent < 12 && abs / 10 ** exponent >= 999.5) { + exponent += 3 + } + + const mantissa = abs / 10 ** exponent + const digits = format.format(digitsSpecifier(abs, exponent > 0, meta))(mantissa) + + const sign = isZeroString(digits) ? "" : value < 0 ? MINUS_SIGN : showSign && value > 0 ? "+" : "" + + let scale = "" + if (exponent > 0) { + scale = + verbosity === "long" + ? (locale === "fr" ? NBSP : " ") + longScaleWord(exponent as ScaleExponent, locale, mantissa) + : locale === "fr" + ? NBSP + shortScaleSuffixes.fr[exponent as ScaleExponent] + : shortScaleSuffixes.en[exponent as ScaleExponent] + } + + return { sign, digits, scale } +} + +// --------------------------------------------------------------------------- +// formatValue +// --------------------------------------------------------------------------- + +/** + * Format a display value for a column (spec 03 §4). + * + * - tick/label verbosity abbreviates from 1e3 with short suffixes + * (en "k/M/B/T", fr SI-style "k/M/G/T"); long verbosity spells out scale + * words from 1e6 ("24.1 billion", fr "24,1 milliards"). + * - percentage columns never abbreviate and append "%" (fr: NBSP + "%"). + * - currency uses the column's currency symbol, placed per locale + * (en "$24B", fr "24 G$"). + * - denominator-derived columns use derivedUnit/derivedShortUnit and drop + * the underlying type's symbol ("42.5% of GDP"). + * - negatives carry the true minus sign (U+2212); showSign forces "+". + */ +export function formatValue(value: number, meta: FormatMeta, opts: FormatValueOptions): string { + if (!Number.isFinite(value)) return "" + + const { locale, verbosity } = opts + const isDerived = Boolean(meta.denominator && (meta.derivedUnit || meta.derivedShortUnit)) + const isPercent = !isDerived && meta.type === "percentage" + const isCurrency = !isDerived && meta.type === "currency" + + const { sign, digits, scale } = formatNumberParts(value, meta, opts, !isPercent) + + if (isCurrency) { + const symbol = currencySymbol(meta.currency) + if (locale === "fr") { + // Symbol trails in French: "1 234 $", "24 G$", "24,1 milliards $". + const joiner = verbosity !== "long" && scale !== "" ? "" : NBSP + return sign + digits + scale + joiner + symbol + } + return sign + symbol + digits + scale + } + + let unit: string | undefined + if (isDerived) { + unit = verbosity === "long" ? (meta.derivedUnit ?? meta.derivedShortUnit) : (meta.derivedShortUnit ?? meta.derivedUnit) + } else if (isPercent) { + unit = "%" + } else { + unit = verbosity === "long" ? (meta.unit ?? meta.shortUnit) : meta.shortUnit + } + + if (unit) { + if (locale === "fr") return sign + digits + scale + NBSP + unit + // Percent-style units bind directly to the number in English ("42%", "42.5% of GDP"). + const joiner = unit.startsWith("%") ? "" : " " + return sign + digits + scale + joiner + unit + } + + return sign + digits + scale +} + +// --------------------------------------------------------------------------- +// formatChange +// --------------------------------------------------------------------------- + +/** + * Format the change between two display values as signed strings. + * + * - `absolute` is end − start in the column's own unit; for percentage + * columns the change is in percentage points and labelled "pp". + * - `relative` is (end − start) / |start| as a signed percentage, or null + * when start is 0 (the ratio is undefined; never rendered as 0). + */ +export function formatChange(start: number, end: number, meta: FormatMeta, opts: FormatChangeOptions): ChangeStrings { + const valueOpts: FormatValueOptions = { + locale: opts.locale, + verbosity: opts.verbosity ?? "label", + showSign: true, + } + const diff = end - start + + const absolute = + meta.type === "percentage" + ? formatValue(diff, { type: "numeric", unit: "pp", shortUnit: "pp", decimals: meta.decimals }, valueOpts) + : formatValue(diff, meta, valueOpts) + + const relative = + start === 0 || !Number.isFinite(start) || !Number.isFinite(end) + ? null + : formatValue((diff / Math.abs(start)) * 100, { type: "percentage" }, valueOpts) + + return { absolute, relative } +} diff --git a/packages/charts2/src/core/format/timeLabels.test.ts b/packages/charts2/src/core/format/timeLabels.test.ts new file mode 100644 index 00000000000..81fec107896 --- /dev/null +++ b/packages/charts2/src/core/format/timeLabels.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest" +import type { Locale, TimeGrain, TimeOrdinal } from "../types.ts" +import { formatTime, formatTimeRange } from "./timeLabels.ts" + +// Ordinal encodings under test (types.ts): +// quarter → year * 4 + (q - 1) Q3 2024 = 8098 +// month → year * 12 + (m - 1) July 2024 = 24294 +// date → days since 1970-01-01 2024-07-01 = 19905 + +interface TimeCase { + description: string + ordinal: TimeOrdinal + grain: TimeGrain + locale: Locale + expected: string +} + +function run(cases: TimeCase[]): void { + for (const c of cases) { + it(c.description, () => { + expect(formatTime(c.ordinal, c.grain, c.locale)).toBe(c.expected) + }) + } +} + +describe("formatTime: year", () => { + run([ + { description: "years render bare", ordinal: 2024, grain: "year", locale: "en", expected: "2024" }, + { description: "years are locale-invariant", ordinal: 2024, grain: "year", locale: "fr", expected: "2024" }, + ]) +}) + +describe("formatTime: fiscal year", () => { + run([ + { description: "fiscal years join start and end with an en dash", ordinal: 2024, grain: "fiscal-year", locale: "en", expected: "2024\u201325" }, + { description: "fiscal years are locale-invariant", ordinal: 2024, grain: "fiscal-year", locale: "fr", expected: "2024\u201325" }, + { description: "the century boundary wraps to a zero-padded 00", ordinal: 2099, grain: "fiscal-year", locale: "en", expected: "2099\u201300" }, + { description: "single-digit end years are zero-padded", ordinal: 2008, grain: "fiscal-year", locale: "en", expected: "2008\u201309" }, + ]) +}) + +describe("formatTime: quarter", () => { + run([ + { description: "quarters render Q-number then year in English", ordinal: 2024 * 4 + 2, grain: "quarter", locale: "en", expected: "Q3 2024" }, + { description: "French quarters use the T prefix (trimestre)", ordinal: 2024 * 4 + 2, grain: "quarter", locale: "fr", expected: "T3 2024" }, + { description: "the first quarter of a year round-trips", ordinal: 2020 * 4, grain: "quarter", locale: "en", expected: "Q1 2020" }, + { description: "the fourth quarter of a year round-trips", ordinal: 1999 * 4 + 3, grain: "quarter", locale: "en", expected: "Q4 1999" }, + ]) +}) + +describe("formatTime: month", () => { + run([ + { description: "months spell the English month name before the year", ordinal: 2024 * 12 + 6, grain: "month", locale: "en", expected: "July 2024" }, + { description: "French month names are lowercase", ordinal: 2024 * 12 + 6, grain: "month", locale: "fr", expected: "juillet 2024" }, + { description: "January round-trips", ordinal: 2020 * 12, grain: "month", locale: "en", expected: "January 2020" }, + { description: "December round-trips", ordinal: 2020 * 12 + 11, grain: "month", locale: "fr", expected: "décembre 2020" }, + { description: "accented French month names come from the static table", ordinal: 2024 * 12 + 1, grain: "month", locale: "fr", expected: "février 2024" }, + ]) +}) + +describe("formatTime: date", () => { + run([ + { description: "English dates render month day, year", ordinal: 19905, grain: "date", locale: "en", expected: "July 1, 2024" }, + { description: "French dates render day month year", ordinal: 19905, grain: "date", locale: "fr", expected: "1 juillet 2024" }, + { description: "the epoch itself is January 1, 1970", ordinal: 0, grain: "date", locale: "en", expected: "January 1, 1970" }, + { description: "negative ordinals reach back before the epoch", ordinal: -1, grain: "date", locale: "en", expected: "December 31, 1969" }, + { description: "leap day resolves correctly", ordinal: 19782, grain: "date", locale: "en", expected: "February 29, 2024" }, + ]) +}) + +describe("formatTime: none", () => { + run([{ description: "grain none has no time labels", ordinal: 0, grain: "none", locale: "en", expected: "" }]) +}) + +describe("formatTimeRange", () => { + it("joins calendar years with an en dash", () => { + expect(formatTimeRange(2010, 2024, "year", "en")).toBe("2010\u20132024") + }) + + it("year ranges are locale-invariant", () => { + expect(formatTimeRange(2010, 2024, "year", "fr")).toBe("2010\u20132024") + }) + + it("spells the connective between fiscal years in English", () => { + expect(formatTimeRange(2014, 2024, "fiscal-year", "en")).toBe("2014\u201315 to 2024\u201325") + }) + + it("wraps French fiscal ranges in de … à", () => { + expect(formatTimeRange(2014, 2024, "fiscal-year", "fr")).toBe("de 2014\u201315 à 2024\u201325") + }) + + it("spells the connective between quarters", () => { + expect(formatTimeRange(2020 * 4, 2024 * 4 + 2, "quarter", "en")).toBe("Q1 2020 to Q3 2024") + }) + + it("wraps French quarter ranges in de … à", () => { + expect(formatTimeRange(2020 * 4, 2024 * 4 + 2, "quarter", "fr")).toBe("de T1 2020 à T3 2024") + }) + + it("spells the connective between months", () => { + expect(formatTimeRange(2020 * 12, 2024 * 12 + 6, "month", "en")).toBe("January 2020 to July 2024") + }) + + it("wraps French date ranges in de … à", () => { + expect(formatTimeRange(0, 19905, "date", "fr")).toBe("de 1 janvier 1970 à 1 juillet 2024") + }) + + it("collapses equal endpoints to a single label", () => { + expect(formatTimeRange(2024, 2024, "fiscal-year", "en")).toBe("2024\u201325") + }) +}) diff --git a/packages/charts2/src/core/format/timeLabels.ts b/packages/charts2/src/core/format/timeLabels.ts new file mode 100644 index 00000000000..d08657a4950 --- /dev/null +++ b/packages/charts2/src/core/format/timeLabels.ts @@ -0,0 +1,127 @@ +/** + * Time label formatting (spec 03 §5, spec 08). + * + * Times are integer ordinals (see TimeOrdinal in ../types.ts); display + * strings derive purely from (ordinal, grain, locale). No Intl and no Date — + * month names come from static tables and calendar math is pure integer + * arithmetic, so labels are byte-identical across runtimes. + */ + +import type { Locale, TimeGrain, TimeOrdinal } from "../types.ts" + +/** En dash (U+2013): fiscal years ("2024–25") and ranges ("2010–2024"). */ +export const EN_DASH = "\u2013" + +const MONTHS_EN = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] as const + +const MONTHS_FR = [ + "janvier", + "février", + "mars", + "avril", + "mai", + "juin", + "juillet", + "août", + "septembre", + "octobre", + "novembre", + "décembre", +] as const + +interface CivilDate { + year: number + /** 1–12 */ + month: number + /** 1–31 */ + day: number +} + +/** + * Days since 1970-01-01 → civil date (proleptic Gregorian, UTC, no timezone + * math). Howard Hinnant's civil_from_days algorithm. + */ +function civilFromDays(days: number): CivilDate { + const z = days + 719468 + const era = Math.floor(z / 146097) + const doe = z - era * 146097 + const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365) + const y = yoe + era * 400 + const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)) + const mp = Math.floor((5 * doy + 2) / 153) + const day = doy - Math.floor((153 * mp + 2) / 5) + 1 + const month = mp < 10 ? mp + 3 : mp - 9 + return { year: month <= 2 ? y + 1 : y, month, day } +} + +/** "2024–25" with an en dash; century boundaries wrap to "2099–00". */ +function formatFiscalYear(startYear: number): string { + const endYear = String((startYear + 1) % 100).padStart(2, "0") + return `${startYear}${EN_DASH}${endYear}` +} + +/** + * Format a single time ordinal per its grain (spec 03 §5): + * - year: "2024" + * - fiscal-year: "2024–25" (ordinal is the start year) + * - quarter: "Q3 2024" (fr "T3 2024") + * - month: "July 2024" (fr "juillet 2024") + * - date: "July 1, 2024" (fr "1 juillet 2024") + * - none: "" (ordinals never occur for this grain) + */ +export function formatTime(ordinal: TimeOrdinal, grain: TimeGrain, locale: Locale): string { + switch (grain) { + case "year": + return String(ordinal) + case "fiscal-year": + return formatFiscalYear(ordinal) + case "quarter": { + const year = Math.floor(ordinal / 4) + const quarter = ordinal - year * 4 + 1 + const prefix = locale === "fr" ? "T" : "Q" + return `${prefix}${quarter} ${year}` + } + case "month": { + const year = Math.floor(ordinal / 12) + const month = ordinal - year * 12 + const name = locale === "fr" ? MONTHS_FR[month] : MONTHS_EN[month] + return `${name} ${year}` + } + case "date": { + const { year, month, day } = civilFromDays(ordinal) + if (locale === "fr") return `${day} ${MONTHS_FR[month - 1]} ${year}` + return `${MONTHS_EN[month - 1]} ${day}, ${year}` + } + case "none": + return "" + } +} + +/** + * Format a time range (spec 03 §5): + * - equal endpoints collapse to a single label + * - calendar years join with an en dash: "2010–2024" + * - every other grain spells the connective so labels that contain spaces + * or dashes stay readable: "2014–15 to 2024–25", fr "de 2014–15 à 2024–25" + */ +export function formatTimeRange(start: TimeOrdinal, end: TimeOrdinal, grain: TimeGrain, locale: Locale): string { + if (start === end || grain === "none") return formatTime(start, grain, locale) + const startLabel = formatTime(start, grain, locale) + const endLabel = formatTime(end, grain, locale) + if (grain === "year") return `${startLabel}${EN_DASH}${endLabel}` + if (locale === "fr") return `de ${startLabel} à ${endLabel}` + return `${startLabel} to ${endLabel}` +} diff --git a/packages/charts2/src/core/index.ts b/packages/charts2/src/core/index.ts new file mode 100644 index 00000000000..d044faee08d --- /dev/null +++ b/packages/charts2/src/core/index.ts @@ -0,0 +1,14 @@ +// Frozen contracts (M0) +export * from "./types.ts" +export * from "./scene/nodes.ts" +export * from "./text/measurer.ts" +export * from "./theme/types.ts" + +// Implementation modules +export * from "./data/index.ts" // M1: parsing, dataset, tolerance, resolveValue, validation +export * from "./format/index.ts" // M2: number + time formatting +export * from "./theme/index.ts" // M3: themes, registry +export * from "./color/index.ts" // M3: categorical colour assignment +export * from "./text/index.ts" // M4: measurer impl, wrap, truncate, Bounds +export * from "./definition/index.ts" // M5: definition schema, bindings, URL state +export * from "./layout/index.ts" // M6: layoutChart and per-chart layouts diff --git a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap new file mode 100644 index 00000000000..e72acbf8b0a --- /dev/null +++ b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap @@ -0,0 +1,66 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`layoutChart matrix: every fixture × applicable type × three sizes > scene JSON is snapshot-stable across runs 1`] = ` +{ + "federal-departments discrete-bar @ default 850x600": "f1683ff2", + "federal-departments discrete-bar @ thumbnail 300x160": "fb04f9ee", + "federal-departments discrete-bar @ wide 1200x600": "b7651001", + "federal-departments line @ default 850x600": "5d1f4018", + "federal-departments line @ thumbnail 300x160": "8942dc13", + "federal-departments line @ wide 1200x600": "183e392d", + "federal-departments stacked-area @ default 850x600": "5796d8f0", + "federal-departments stacked-area @ thumbnail 300x160": "93b08596", + "federal-departments stacked-area @ wide 1200x600": "116cd37a", + "federal-departments stacked-bar @ default 850x600": "2fa67ce2", + "federal-departments stacked-bar @ thumbnail 300x160": "ed5d9683", + "federal-departments stacked-bar @ wide 1200x600": "6565ed86", + "government-debt discrete-bar @ default 850x600": "5703773c", + "government-debt discrete-bar @ thumbnail 300x160": "c061d3b6", + "government-debt discrete-bar @ wide 1200x600": "0b750ee6", + "government-debt line @ default 850x600": "755a0701", + "government-debt line @ thumbnail 300x160": "8a09389a", + "government-debt line @ wide 1200x600": "c6655c6b", + "government-debt stacked-area @ default 850x600": "7bd0c1b8", + "government-debt stacked-area @ thumbnail 300x160": "e4f8c0fb", + "government-debt stacked-area @ wide 1200x600": "9c981175", + "government-debt stacked-bar @ default 850x600": "6dc04bbb", + "government-debt stacked-bar @ thumbnail 300x160": "fe95ff1d", + "government-debt stacked-bar @ wide 1200x600": "a164f460", + "government-debt stacked-discrete-bar @ default 850x600": "8b095dc4", + "government-debt stacked-discrete-bar @ thumbnail 300x160": "c5273f3d", + "government-debt stacked-discrete-bar @ wide 1200x600": "72020187", + "pathological discrete-bar @ default 850x600": "b8a52ba3", + "pathological discrete-bar @ thumbnail 300x160": "a8aa113c", + "pathological discrete-bar @ wide 1200x600": "e0e21bc7", + "pathological huge line @ default 850x600": "8d39739d", + "pathological huge line @ thumbnail 300x160": "df215a65", + "pathological huge line @ wide 1200x600": "90ef87b4", + "pathological line @ default 850x600": "0870edc5", + "pathological line @ thumbnail 300x160": "50d611ca", + "pathological line @ wide 1200x600": "8691aec8", + "pathological stacked-bar @ default 850x600": "fc018b0b", + "pathological stacked-bar @ thumbnail 300x160": "0e1dd920", + "pathological stacked-bar @ wide 1200x600": "5a3928ca", + "population-snapshot discrete-bar @ default 850x600": "1495c2f3", + "population-snapshot discrete-bar @ thumbnail 300x160": "a2d8c044", + "population-snapshot discrete-bar @ wide 1200x600": "131d37e0", + "population-snapshot stacked-discrete-bar @ default 850x600": "8f70c28b", + "population-snapshot stacked-discrete-bar @ thumbnail 300x160": "5bd76470", + "population-snapshot stacked-discrete-bar @ wide 1200x600": "6ca6b40b", + "provincial-budgets discrete-bar @ default 850x600": "75a00f49", + "provincial-budgets discrete-bar @ thumbnail 300x160": "3482f6dd", + "provincial-budgets discrete-bar @ wide 1200x600": "051a9351", + "provincial-budgets line @ default 850x600": "172142b6", + "provincial-budgets line @ thumbnail 300x160": "b3ce9182", + "provincial-budgets line @ wide 1200x600": "0d341b16", + "provincial-budgets stacked-area @ default 850x600": "226a027c", + "provincial-budgets stacked-area @ thumbnail 300x160": "23c804f3", + "provincial-budgets stacked-area @ wide 1200x600": "3480ea01", + "provincial-budgets stacked-bar @ default 850x600": "0f450b2e", + "provincial-budgets stacked-bar @ thumbnail 300x160": "0000697c", + "provincial-budgets stacked-bar @ wide 1200x600": "b3ed0821", + "provincial-budgets stacked-discrete-bar @ default 850x600": "2c6015e4", + "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "26a8494a", + "provincial-budgets stacked-discrete-bar @ wide 1200x600": "6d6a92a2", +} +`; diff --git a/packages/charts2/src/core/layout/axis.test.ts b/packages/charts2/src/core/layout/axis.test.ts new file mode 100644 index 00000000000..834e9b57d21 --- /dev/null +++ b/packages/charts2/src/core/layout/axis.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest" + +import { defaultMeasurer } from "../text/createMeasurer.ts" +import { + horizontalValueAxisNodes, + linearTicks, + logTicks, + prepareValueAxis, + timeAxisNodes, + verticalValueAxisNodes, +} from "./axis.ts" +import { computeValueDomain, createValueScale, niceLinearDomain, targetTickCount } from "./scales.ts" +import { buildCanadaTheme } from "../theme/themes.ts" + +describe("targetTickCount", () => { + it("adapts to pixel length and clamps to 2–6", () => { + expect(targetTickCount(500, 12)).toBe(6) + expect(targetTickCount(100, 12)).toBe(5) + expect(targetTickCount(10, 12)).toBe(2) + }) +}) + +describe("niceLinearDomain", () => { + it("extends to a round tick when the data edge exceeds the last tick by more than 25% of a step", () => { + const { domain, ticks } = niceLinearDomain(0, 97, 6) + expect(domain).toEqual([0, 100]) + expect(ticks).toContain(100) + for (const tick of ticks) expect(tick % 10).toBe(0) + }) + + it("keeps the data edge when the overshoot is within 25% of a step", () => { + const { domain, ticks } = niceLinearDomain(0, 81, 6) + expect(domain).toEqual([0, 81]) + expect(ticks[ticks.length - 1]).toBe(80) + }) + + it("extends below the first tick symmetrically", () => { + const { domain } = niceLinearDomain(-97, 0, 6) + expect(domain[0]).toBe(-100) + }) + + it("degenerates gracefully for a single value", () => { + expect(niceLinearDomain(5, 5, 6)).toEqual({ domain: [5, 5], ticks: [5] }) + }) +}) + +describe("linearTicks", () => { + it("produces round in-domain ticks and marks zero solid", () => { + const { domain, ticks } = linearTicks([0, 97], 6) + for (const tick of ticks) { + expect(tick.value).toBeGreaterThanOrEqual(domain[0]) + expect(tick.value).toBeLessThanOrEqual(domain[1]) + } + const zero = ticks.find((t) => t.value === 0) + expect(zero?.solid).toBe(true) + }) +}) + +describe("logTicks", () => { + it("prioritizes powers of ten over 2×/5× over in-between values", () => { + const ticks = logTicks([1, 1000], 6) + const p1 = ticks.filter((t) => t.priority === 1).map((t) => t.value) + expect(p1).toEqual(expect.arrayContaining([1, 10, 100, 1000])) + for (const tick of ticks) expect(tick.value).toBeGreaterThan(0) + }) +}) + +describe("computeValueDomain", () => { + it("always includes zero for bar marks", () => { + const { min, max } = computeValueDomain({ values: [40, 80], markType: "bar", scaleType: "linear" }) + expect(min).toBe(0) + expect(max).toBe(80) + }) + + it("includes zero for lines by default but releases it with min auto", () => { + const anchored = computeValueDomain({ values: [40, 80], markType: "line", scaleType: "linear" }) + expect(anchored.min).toBe(0) + const released = computeValueDomain({ + values: [40, 80], + markType: "line", + scaleType: "linear", + config: { min: "auto" }, + }) + expect(released.min).toBe(40) + }) + + it("honours manual min/max", () => { + const { min, max } = computeValueDomain({ + values: [40, 80], + markType: "line", + scaleType: "linear", + config: { min: 10, max: 200 }, + }) + expect(min).toBe(10) + expect(max).toBe(200) + }) + + it("excludes non-positive values on log scales with a counted diagnostic", () => { + const result = computeValueDomain({ values: [-5, 0, 10, 100], markType: "line", scaleType: "log" }) + expect(result.min).toBe(10) + expect(result.excludedCount).toBe(2) + expect(result.diagnostics[0]?.code).toBe("log-excluded-values") + expect(result.diagnostics[0]?.context?.count).toBe(2) + }) +}) + +describe("prepareValueAxis", () => { + const base = { + markType: "line" as const, + pixelLength: 400, + font: { family: "body" as const, sizePx: 12, weight: 400 as const }, + meta: { type: "numeric" as const }, + locale: "en" as const, + measurer: defaultMeasurer, + } + + it("formats and measures every labelled tick", () => { + const spec = prepareValueAxis({ ...base, values: [0, 50, 97], scaleType: "linear" }) + for (const tick of spec.ticks) { + expect(tick.label).not.toBe("") + expect(tick.metrics.width).toBeGreaterThan(0) + } + expect(spec.maxLabelWidth).toBeGreaterThan(0) + }) + + it("reports log exclusions through the spec diagnostics", () => { + const spec = prepareValueAxis({ ...base, values: [-1, 0, 5, 500], scaleType: "log" }) + expect(spec.excludedCount).toBe(2) + expect(spec.diagnostics.some((d) => d.code === "log-excluded-values")).toBe(true) + for (const tick of spec.ticks) expect(tick.value).toBeGreaterThan(0) + }) + + it("pins the max for relative stacked mode", () => { + const spec = prepareValueAxis({ + ...base, + values: [0, 40, 60], + scaleType: "linear", + markType: "bar", + pinnedMax: 100, + }) + expect(spec.domain[1]).toBe(100) + }) +}) + +describe("axis nodes", () => { + const font = { family: "body" as const, sizePx: 12, weight: 400 as const } + const plotArea = { x: 40, y: 20, width: 300, height: 180 } + + it("renders y-axis spacing gridlines as dashed except the solid zero line", () => { + const spec = prepareValueAxis({ + markType: "line", + scaleType: "linear", + values: [0, 100], + pixelLength: 180, + font, + meta: { type: "numeric" }, + locale: "en", + measurer: defaultMeasurer, + }) + const scale = createValueScale("linear", spec.domain, [200, 20]) + const nodes = verticalValueAxisNodes(spec, scale, plotArea, 20, { theme: buildCanadaTheme, font }) + const gridRules = nodes.filter((node) => node.kind === "rule" && node.role === "grid") + expect(gridRules.some((node) => node.kind === "rule" && node.style.dash?.join(",") === "4,4")).toBe(true) + const zero = gridRules.find((node) => node.key === "axis/y/grid/0") + expect(zero?.kind === "rule" ? zero.style.dash : undefined).toBeUndefined() + }) + + it("renders vertical value-axis gridlines as dashed without duplicate bottom tick marks", () => { + const spec = prepareValueAxis({ + markType: "bar", + scaleType: "linear", + values: [0, 100], + pixelLength: 300, + font, + meta: { type: "numeric" }, + locale: "en", + measurer: defaultMeasurer, + }) + const scale = createValueScale("linear", spec.domain, [40, 340]) + const nodes = horizontalValueAxisNodes(spec, scale, plotArea, plotArea, { theme: buildCanadaTheme, font }) + const gridRules = nodes.filter((node) => node.kind === "rule" && node.role === "grid") + expect(gridRules.some((node) => node.kind === "rule" && node.style.dash?.join(",") === "4,4")).toBe(true) + const zero = gridRules.find((node) => node.key === "axis/x/grid/0") + expect(zero?.kind === "rule" ? zero.style.dash : undefined).toBeUndefined() + expect(nodes.some((node) => node.kind === "rule" && node.key.startsWith("axis/x/tick-mark/"))).toBe(false) + expect(nodes.some((node) => node.kind === "text" && node.key.startsWith("axis/x/tick/"))).toBe(true) + }) + + it("renders x-axis tick marks when vertical gridlines are hidden", () => { + const spec = prepareValueAxis({ + markType: "bar", + scaleType: "linear", + values: [0, 100], + pixelLength: 300, + font, + meta: { type: "numeric" }, + locale: "en", + measurer: defaultMeasurer, + }) + const scale = createValueScale("linear", spec.domain, [40, 340]) + const nodes = horizontalValueAxisNodes(spec, scale, plotArea, plotArea, { + theme: buildCanadaTheme, + font, + hideGridlines: true, + }) + expect(nodes.some((node) => node.kind === "rule" && node.key.startsWith("axis/x/tick-mark/"))).toBe(true) + expect(nodes.some((node) => node.kind === "text" && node.key.startsWith("axis/x/tick/"))).toBe(true) + }) + + it("renders time-axis tick marks that descend to visible tick labels", () => { + const nodes = timeAxisNodes({ + times: [2020, 2021, 2022], + place: (time) => 40 + (time - 2020) * 150, + plotArea, + clampBounds: plotArea, + grain: "year", + locale: "en", + theme: buildCanadaTheme, + measurer: defaultMeasurer, + font, + }) + expect(nodes.some((node) => node.kind === "rule" && node.key.startsWith("axis/x/tick-mark/"))).toBe(true) + expect(nodes.some((node) => node.kind === "text" && node.key.startsWith("axis/x/tick/"))).toBe(true) + }) +}) diff --git a/packages/charts2/src/core/layout/axis.ts b/packages/charts2/src/core/layout/axis.ts new file mode 100644 index 00000000000..7160335779b --- /dev/null +++ b/packages/charts2/src/core/layout/axis.ts @@ -0,0 +1,497 @@ +/** + * Axis tick generation and axis scene nodes (spec 03 §3). + * + * Tick generation is ported from owid-grapher axis/Axis.ts: + * - linear: d3 ticks with nice-domain extension (scales.ts niceLinearDomain), + * value-0 ticks marked solid; + * - log: the priority-1/2/3 heuristic over d3 log ticks, converting + * "in-between" values to unlabelled faint gridlines (log-paper look) or + * dropping low priorities when the axis would be overwhelmed; + * - collision filtering: tick labels are measured and overlapping labels are + * hidden by priority (labels thin, never rotate); + * - first/last labels are clip-protected by re-anchoring at the range edges. + */ + +import { scaleLog } from "d3-scale" + +import type { Rect, SceneNode } from "../scene/nodes.ts" +import type { FontSpec, TextMeasurer, TextMetrics } from "../text/measurer.ts" +import type { Theme } from "../theme/types.ts" +import { formatTime } from "../format/timeLabels.ts" +import { formatValue, type FormatMeta } from "../format/number.ts" +import type { AxisConfig, Diagnostic, Locale, ScaleType, TimeGrain, TimeOrdinal } from "../types.ts" +import { + computeValueDomain, + createValueScale, + niceLinearDomain, + targetTickCount, + type MarkType, + type ValueScale, +} from "./scales.ts" + +export const TICK_PADDING = 8 +export const PLOT_TOP_PAD = 6 + +export interface Tickmark { + value: number + priority: number + gridLineOnly?: boolean + faint?: boolean + solid?: boolean +} + +// --------------------------------------------------------------------------- +// Tick generation +// --------------------------------------------------------------------------- + +export function linearTicks(domain: [number, number], targetCount: number): { domain: [number, number]; ticks: Tickmark[] } { + const { domain: nice, ticks } = niceLinearDomain(domain[0], domain[1], targetCount) + return { + domain: nice, + ticks: ticks.map((value) => ({ value, priority: 2, ...(value === 0 ? { solid: true } : {}) })), + } +} + +export function logTicks(domain: [number, number], targetCount: number): Tickmark[] { + const maxLabelledTicks = Math.round(targetCount * 1.25) + const maxTicks = Math.round(targetCount * 3) + if (domain[0] === domain[1]) return [{ value: domain[0], priority: 1 }] + + const scale = scaleLog().domain(domain).range([0, 1]) + const candidates = scale.ticks(maxLabelledTicks) + let ticks: Tickmark[] = candidates.map((value) => { + if (Math.fround(Math.log10(value)) % 1 === 0) return { value, priority: 1 } + if (Math.fround(Math.log10(value * 2)) % 1 === 0) return { value, priority: 2 } + if (Math.fround(Math.log10(value / 2)) % 1 === 0) return { value, priority: 2 } + return { value, priority: 3 } + }) + + if (ticks.length > maxLabelledTicks) { + if (ticks.length <= maxTicks) { + const labelled = ticks.filter((t) => t.priority < 3) + if (labelled.length >= 2) { + ticks = ticks.map((t) => (t.priority === 3 ? { ...t, faint: true, gridLineOnly: true } : t)) + } + } else { + for (let priority = 3; priority > 1; priority--) { + if (ticks.length > maxLabelledTicks) ticks = ticks.filter((t) => t.priority < priority) + } + } + } + return ticks +} + +// --------------------------------------------------------------------------- +// Prepared value axis: domain + formatted/measured ticks +// --------------------------------------------------------------------------- + +export interface PreparedTick extends Tickmark { + label: string + metrics: TextMetrics +} + +export interface ValueAxisSpec { + domain: [number, number] + ticks: PreparedTick[] + maxLabelWidth: number + /** Pixel height consumed by one row of tick labels. */ + labelHeight: number + excludedCount: number + diagnostics: Diagnostic[] +} + +export interface PrepareValueAxisInput { + values: readonly number[] + markType: MarkType + scaleType: ScaleType + config?: AxisConfig + /** Estimated pixel length of the axis (drives the target tick count). */ + pixelLength: number + font: FontSpec + meta: FormatMeta + locale: Locale + measurer: TextMeasurer + showSign?: boolean + /** Pin the domain max (relative stacked mode pins 100). */ + pinnedMax?: number +} + +export function prepareValueAxis(input: PrepareValueAxisInput): ValueAxisSpec { + const { values, markType, scaleType, config, pixelLength, font, meta, locale, measurer, showSign = false } = input + const domainResult = computeValueDomain({ values, markType, scaleType, config }) + let domain: [number, number] = [domainResult.min, domainResult.max] + if (input.pinnedMax !== undefined) domain = [Math.min(domain[0], 0), input.pinnedMax] + + const target = targetTickCount(pixelLength, font.sizePx) + let ticks: Tickmark[] + if (scaleType === "log") { + if (domain[0] <= 0) domain = [Math.max(domain[0], 1e-9), Math.max(domain[1], 1)] + ticks = logTicks(domain, target) + } else { + const result = linearTicks(domain, target) + domain = result.domain + ticks = result.ticks + } + + const prepared: PreparedTick[] = ticks.map((tick) => { + const label = tick.gridLineOnly ? "" : formatValue(tick.value, meta, { locale, verbosity: "tick", showSign }) + return { ...tick, label, metrics: measurer.measure(label, font) } + }) + + return { + domain, + ticks: prepared, + maxLabelWidth: Math.max(0, ...prepared.filter((t) => !t.gridLineOnly).map((t) => t.metrics.width)), + labelHeight: font.sizePx * 1.2, + excludedCount: domainResult.excludedCount, + diagnostics: domainResult.diagnostics, + } +} + +// --------------------------------------------------------------------------- +// Node builders +// --------------------------------------------------------------------------- + +interface AxisNodeStyleArgs { + theme: Theme + font: FontSpec + hideGridlines?: boolean + hideTickLabels?: boolean +} + +function gridStrokeFor(theme: Theme, tick: Tickmark): { stroke: string; opacity: number } { + if (tick.solid) return { stroke: theme.chrome.axisLine, opacity: 1 } + if (tick.faint) return { stroke: theme.chrome.gridline, opacity: 0.45 } + return { stroke: theme.chrome.gridline, opacity: 1 } +} + +function yGridStyle(theme: Theme, tick: Tickmark) { + const { stroke, opacity } = gridStrokeFor(theme, tick) + return { + stroke, + strokeWidth: 1, + opacity, + ...(tick.solid === true ? {} : { dash: [4, 4] }), + } +} + +function xGridStyle(theme: Theme, tick: Tickmark) { + const { stroke, opacity } = gridStrokeFor(theme, tick) + return { + stroke, + strokeWidth: 1, + opacity, + ...(tick.solid === true ? {} : { dash: [4, 4] }), + } +} + +/** + * Left value axis: horizontal gridlines across the plot, right-anchored tick + * labels in the margin. Labels are clamped so the topmost never clips above + * `clampTop`. + */ +export function verticalValueAxisNodes( + spec: ValueAxisSpec, + scale: ValueScale, + plotArea: Rect, + clampTop: number, + style: AxisNodeStyleArgs, +): SceneNode[] { + const nodes: SceneNode[] = [] + const { theme, font } = style + for (const tick of spec.ticks) { + const y = scale.place(tick.value) + if (!Number.isFinite(y)) continue + if (style.hideGridlines !== true || tick.solid === true) { + nodes.push({ + key: `axis/y/grid/${tick.value}`, + role: "grid", + kind: "rule", + from: { x: plotArea.x, y }, + to: { x: plotArea.x + plotArea.width, y }, + style: yGridStyle(theme, tick), + }) + } + if (tick.gridLineOnly === true || style.hideTickLabels === true || tick.label === "") continue + const metrics = tick.metrics + let baseline = y + (metrics.ascent - metrics.descent) / 2 + baseline = Math.max(baseline, clampTop + metrics.ascent) + nodes.push({ + key: `axis/y/tick/${tick.value}`, + role: "axis", + kind: "text", + position: { x: plotArea.x - TICK_PADDING, y: baseline }, + text: tick.label, + font, + anchor: "end", + colour: theme.chrome.tickLabel, + measured: metrics, + }) + } + return nodes +} + +/** + * Bottom value axis (horizontal-bar charts): vertical gridlines, centred tick + * labels below the plot, end labels re-anchored to avoid clipping outside + * `clampBounds`. + */ +export function horizontalValueAxisNodes( + spec: ValueAxisSpec, + scale: ValueScale, + plotArea: Rect, + clampBounds: Rect, + style: AxisNodeStyleArgs, +): SceneNode[] { + const nodes: SceneNode[] = [] + const { theme, font } = style + const placements: { x: number; anchor: "start" | "middle" | "end"; tick: PreparedTick; hasGridline: boolean }[] = [] + + for (const tick of spec.ticks) { + const x = scale.place(tick.value) + if (!Number.isFinite(x)) continue + const hasGridline = style.hideGridlines !== true || tick.solid === true + if (hasGridline) { + nodes.push({ + key: `axis/x/grid/${tick.value}`, + role: "grid", + kind: "rule", + from: { x, y: plotArea.y }, + to: { x, y: plotArea.y + plotArea.height }, + style: xGridStyle(theme, tick), + }) + } + if (tick.gridLineOnly === true || style.hideTickLabels === true || tick.label === "") continue + let anchor: "start" | "middle" | "end" = "middle" + let labelX = x + const half = tick.metrics.width / 2 + if (x - half < clampBounds.x) { + anchor = "start" + labelX = Math.max(x - half, clampBounds.x) + } else if (x + half > clampBounds.x + clampBounds.width) { + anchor = "end" + labelX = Math.min(x + half, clampBounds.x + clampBounds.width) + } + placements.push({ x: labelX, anchor, tick, hasGridline }) + } + + // Collision filter: hide overlapping labels (priority order, ties left-first). + const visible = filterOverlappingX(placements.map((p) => ({ ...p, width: p.tick.metrics.width, priority: p.tick.priority }))) + const ascent = visible.length > 0 ? visible[0].tick.metrics.ascent : 0 + const baseline = plotArea.y + plotArea.height + PLOT_TOP_PAD + ascent + for (const p of visible) { + if (!p.hasGridline) { + nodes.push({ + key: `axis/x/tick-mark/${p.tick.value}`, + role: "axis", + kind: "rule", + from: { x: p.x, y: plotArea.y + plotArea.height }, + to: { x: p.x, y: baseline - p.tick.metrics.ascent - 2 }, + style: { stroke: theme.chrome.axisLine, strokeWidth: 1 }, + }) + } + nodes.push({ + key: `axis/x/tick/${p.tick.value}`, + role: "axis", + kind: "text", + position: { x: p.x, y: baseline }, + text: p.tick.label, + font, + anchor: p.anchor, + colour: theme.chrome.tickLabel, + measured: p.tick.metrics, + }) + } + return nodes +} + +interface XPlacement { + x: number + width: number + anchor: "start" | "middle" | "end" + priority: number +} + +function extentOf(p: XPlacement): [number, number] { + if (p.anchor === "start") return [p.x, p.x + p.width] + if (p.anchor === "end") return [p.x - p.width, p.x] + return [p.x - p.width / 2, p.x + p.width / 2] +} + +/** Hide overlapping horizontal labels by priority, with 3px breathing room. */ +function filterOverlappingX(placements: T[]): T[] { + const byPriority = [...placements].sort((a, b) => a.priority - b.priority || extentOf(a)[0] - extentOf(b)[0]) + const kept: T[] = [] + for (const candidate of byPriority) { + const [left, right] = extentOf(candidate) + const collides = kept.some((other) => { + const [oLeft, oRight] = extentOf(other) + return left - 3 < oRight && right + 3 > oLeft + }) + if (!collides) kept.push(candidate) + } + return kept.sort((a, b) => extentOf(a)[0] - extentOf(b)[0]) +} + +// --------------------------------------------------------------------------- +// Time axis (spec 03 §3): ticks on natural boundaries, thinned not rotated +// --------------------------------------------------------------------------- + +export interface TimeAxisInput { + times: readonly TimeOrdinal[] + place: (time: TimeOrdinal) => number + plotArea: Rect + clampBounds: Rect + grain: TimeGrain + locale: Locale + theme: Theme + measurer: TextMeasurer + font: FontSpec +} + +/** Bottom time axis labels. No vertical gridlines (spec 03 §3). */ +export function timeAxisNodes(input: TimeAxisInput): SceneNode[] { + const { times, place, plotArea, clampBounds, grain, locale, theme, measurer, font } = input + if (times.length === 0) return [] + + const labels = times.map((time) => { + const text = formatTime(time, grain, locale) + return { time, text, metrics: measurer.measure(text, font) } + }) + + // Thinning: keep first and last (clip-protected), thin the middle so the + // widest label plus breathing room fits between picks. + const maxWidth = Math.max(...labels.map((l) => l.metrics.width)) + const capacity = Math.max(2, Math.floor(plotArea.width / (maxWidth + 16))) + const step = Math.max(1, Math.ceil(times.length / capacity)) + const pickedIndices: number[] = [] + for (let i = 0; i < times.length; i += step) pickedIndices.push(i) + const lastIndex = times.length - 1 + if (pickedIndices[pickedIndices.length - 1] !== lastIndex) { + if (lastIndex - pickedIndices[pickedIndices.length - 1] < step / 2 && pickedIndices.length > 1) { + pickedIndices[pickedIndices.length - 1] = lastIndex + } else { + pickedIndices.push(lastIndex) + } + } + + const placements: (XPlacement & { label: (typeof labels)[number] })[] = pickedIndices.map((index, order) => { + const label = labels[index] + const x = place(label.time) + const half = label.metrics.width / 2 + let anchor: "start" | "middle" | "end" = "middle" + let labelX = x + if (x - half < clampBounds.x) { + anchor = "start" + labelX = Math.max(x - half, clampBounds.x) + } else if (x + half > clampBounds.x + clampBounds.width) { + anchor = "end" + labelX = Math.min(x + half, clampBounds.x + clampBounds.width) + } + // First/last get top priority so middle labels thin out first. + const priority = order === 0 || index === lastIndex ? 1 : 2 + return { x: labelX, width: label.metrics.width, anchor, priority, label } + }) + + const visible = filterOverlappingX(placements) + const ascent = visible.length > 0 ? visible[0].label.metrics.ascent : 0 + const baseline = plotArea.y + plotArea.height + PLOT_TOP_PAD + ascent + return visible.flatMap((p) => [ + { + key: `axis/x/tick-mark/${p.label.time}`, + role: "axis" as const, + kind: "rule" as const, + from: { x: p.x, y: plotArea.y + plotArea.height }, + to: { x: p.x, y: baseline - p.label.metrics.ascent - 2 }, + style: { stroke: theme.chrome.axisLine, strokeWidth: 1 }, + }, + { + key: `axis/x/tick/${p.label.time}`, + role: "axis" as const, + kind: "text" as const, + position: { x: p.x, y: baseline }, + text: p.label.text, + font, + anchor: p.anchor, + colour: theme.chrome.tickLabel, + measured: p.label.metrics, + }, + ]) +} + +// --------------------------------------------------------------------------- +// Composite: vertical value axis + plot area solver +// --------------------------------------------------------------------------- + +export interface VerticalAxesInput { + area: Rect + values: readonly number[] + markType: MarkType + scaleType: ScaleType + config?: AxisConfig + meta: FormatMeta + locale: Locale + theme: Theme + measurer: TextMeasurer + font: FontSpec + /** Width reserved at the right edge (series end labels). */ + rightReserve: number + showSign?: boolean + pinnedMax?: number +} + +export interface VerticalAxesResult { + plotArea: Rect + yScale: ValueScale + spec: ValueAxisSpec + nodes: SceneNode[] + /** Height of the bottom strip reserved for the time axis labels. */ + xAxisHeight: number + diagnostics: Diagnostic[] +} + +/** + * Solve the y-axis ↔ plot-area dependency for charts with a left value axis + * and a bottom time axis: ticks are generated once from a provisional pixel + * length, the axis margin is sized from those measured labels, and the same + * ticks are then placed with the final scale (deterministic single pass). + */ +export function layoutVerticalAxes(input: VerticalAxesInput): VerticalAxesResult { + const { area, font, theme } = input + const sample = input.measurer.measure("0", font) + const xAxisHeight = sample.ascent + sample.descent + PLOT_TOP_PAD + 2 + const provisionalLength = Math.max(10, area.height - xAxisHeight - PLOT_TOP_PAD) + + const spec = prepareValueAxis({ + values: input.values, + markType: input.markType, + scaleType: input.scaleType, + config: input.config, + pixelLength: provisionalLength, + font, + meta: input.meta, + locale: input.locale, + measurer: input.measurer, + showSign: input.showSign, + pinnedMax: input.pinnedMax, + }) + + const hideTickLabels = input.config?.hideTickLabels === true + const yAxisWidth = hideTickLabels ? 0 : spec.maxLabelWidth + TICK_PADDING + const plotArea: Rect = { + x: area.x + yAxisWidth, + y: area.y + PLOT_TOP_PAD, + width: Math.max(10, area.width - yAxisWidth - input.rightReserve), + height: Math.max(10, area.height - PLOT_TOP_PAD - xAxisHeight), + } + + const yScale = createValueScale(input.scaleType, spec.domain, [plotArea.y + plotArea.height, plotArea.y]) + const nodes = verticalValueAxisNodes(spec, yScale, plotArea, area.y, { + theme, + font, + hideGridlines: input.config?.hideGridlines, + hideTickLabels, + }) + + return { plotArea, yScale, spec, nodes, xAxisHeight, diagnostics: spec.diagnostics } +} diff --git a/packages/charts2/src/core/layout/charts/discreteBar.test.ts b/packages/charts2/src/core/layout/charts/discreteBar.test.ts new file mode 100644 index 00000000000..3c748063481 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/discreteBar.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutDiscreteBar } from "./discreteBar.ts" +import type { ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("discrete bar sorting (spec 13)", () => { + const raw = { y: ["total_spending"], selectedEntities: ["Nova Scotia", "Ontario", "Quebec", "British Columbia", "Alberta"] } + + it("defaults to value descending", () => { + const layer = layoutDiscreteBar(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.series.map((s) => s.label)).toEqual([ + "Ontario", + "Quebec", + "British Columbia", + "Alberta", + "Nova Scotia", + ]) + }) + + it("sorts by value ascending when asked", () => { + const layer = layoutDiscreteBar( + ctxFor("provincial-budgets", { ...raw, sort: { by: "total", order: "asc" } }), + AREA, + OPTS, + ) + expect(layer.series.map((s) => s.label)).toEqual([ + "Nova Scotia", + "Alberta", + "British Columbia", + "Quebec", + "Ontario", + ]) + }) + + it("sorts by name", () => { + const layer = layoutDiscreteBar( + ctxFor("provincial-budgets", { ...raw, sort: { by: "name", order: "asc" } }), + AREA, + OPTS, + ) + expect(layer.series.map((s) => s.label)).toEqual([ + "Alberta", + "British Columbia", + "Nova Scotia", + "Ontario", + "Quebec", + ]) + }) + + it("keeps the selection order for custom sort", () => { + const layer = layoutDiscreteBar( + ctxFor("provincial-budgets", { ...raw, sort: { by: "custom", order: "asc" } }), + AREA, + OPTS, + ) + expect(layer.series.map((s) => s.label)).toEqual([ + "Nova Scotia", + "Ontario", + "Quebec", + "British Columbia", + "Alberta", + ]) + }) +}) + +describe("discrete bar tolerance suffix (spec 13)", () => { + it("appends 'in ‹time›' exactly when the value time differs from the target", () => { + const ctx = ctxFor("provincial-budgets", { y: ["debt_charges"], time: "2024-25" }) + const layer = layoutDiscreteBar(ctx, AREA, OPTS) + const quebec = layer.nodes.find((n) => n.key === "value/Quebec") + const ontario = layer.nodes.find((n) => n.key === "value/Ontario") + expect(quebec?.kind).toBe("text") + if (quebec?.kind !== "text" || ontario?.kind !== "text") return + expect(quebec.text).toContain("in 2023–24") // borrowed via tolerance 2 + expect(ontario.text).not.toContain("in ") // exact hit, no suffix + }) +}) + +describe("discrete bar negatives (spec 13)", () => { + it("extends bars left of the zero baseline and mirrors the value labels", () => { + const ctx = ctxFor("pathological", { y: ["negatives"], time: 2021 }) + const layer = layoutDiscreteBar(ctx, AREA, OPTS) + const bars = layer.nodes.filter((n) => n.kind === "rect" && n.key.endsWith("/bar")) + expect(bars.length).toBe(3) + // All values are negative, so every bar's right edge is the shared zero line. + const rightEdges = bars.map((n) => (n.kind === "rect" ? n.rect.x + n.rect.width : 0)) + for (const edge of rightEdges) expect(edge).toBeCloseTo(rightEdges[0], 5) + const valueLabels = layer.nodes.filter((n) => n.kind === "text" && n.key.startsWith("value/")) + for (const label of valueLabels) { + if (label.kind === "text") expect(label.anchor).toBe("end") + } + }) +}) + +describe("discrete bar without a time dimension", () => { + it("lays out grain-none datasets at a null target time", () => { + const ctx = ctxFor("population-snapshot", { y: ["population"], types: ["discrete-bar"] }) + const layer = layoutDiscreteBar(ctx, AREA, OPTS) + expect(layer.series.length).toBeGreaterThan(0) + expect(layer.series[0].points[0].time).toBe(null) + // No tolerance suffix and no title annotation without time. + const target = layer.hover.targets[0] + if (target.kind === "series") expect(target.tooltip.titleAnnotation).toBeUndefined() + }) +}) + +describe("discrete bar relative mode", () => { + it("shows shares of the visible total using absolute weights", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario", "Quebec"], + stackMode: "relative", + }) + const layer = layoutDiscreteBar(ctx, AREA, OPTS) + const total = layer.series.reduce((sum, s) => sum + s.points[0].value, 0) + expect(total).toBeCloseTo(100) + }) +}) + +describe("discrete bar hover", () => { + it("emits one series target per bar with an emphasized single row", () => { + const ctx = ctxFor("provincial-budgets", { y: ["total_spending"], selectedEntities: ["Ontario", "Quebec"] }) + const layer = layoutDiscreteBar(ctx, AREA, OPTS) + expect(layer.hover.targets.length).toBe(2) + const target = layer.hover.targets[0] + expect(target.kind).toBe("series") + if (target.kind !== "series") return + expect(target.tooltip.rows.length).toBe(1) + expect(target.tooltip.rows[0].emphasized).toBe(true) + }) +}) diff --git a/packages/charts2/src/core/layout/charts/discreteBar.ts b/packages/charts2/src/core/layout/charts/discreteBar.ts new file mode 100644 index 00000000000..bd725720b44 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/discreteBar.ts @@ -0,0 +1,297 @@ +/** + * Discrete bar chart layout (spec 13). + * + * Horizontal bars from a zero baseline at a single target time. Default sort + * is value descending; toleranced values append "in ‹time›" to the value + * label; negative bars extend left with mirrored labels; bar height + * compresses to a floor when rows are many. + */ + +import { resolveValue } from "../../data/derived.ts" +import { formatTime } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { SortConfig } from "../../types.ts" +import { horizontalValueAxisNodes, prepareValueAxis, PLOT_TOP_PAD } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { bandPositions, createValueScale } from "../scales.ts" +import { buildSeriesModels } from "../series.ts" +import { + buildFooters, + centeredBaseline, + collectFooterFlags, + compareStrings, + emptyLayer, + labelValueText, + legendItemsFor, + metricSubtitle, + noteFooterFlags, + noticeFor, + pointByTime, + seriesLabelFont, + strings, + textNode, + tickFont, + tooltipValueText, + valueLabelFont, + metaFor, + RELATIVE_META, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +export const DEFAULT_BAR_SORT: SortConfig = { by: "total", order: "desc" } + +const BAR_HEIGHT_FLOOR = 3 +const BAR_HEIGHT_MAX = 36 + +interface Bar { + series: SeriesModel + point: SeriesPoint + value: number + labelText: string + valueText: string +} + +function changeOver(ctx: LayoutContext, slug: string, entity: string): number { + if (ctx.window === null) return 0 + const overrides = ctx.definition.bindings?.[slug] + const start = resolveValue(ctx.dataset, slug, entity, ctx.window.start, overrides) + const end = resolveValue(ctx.dataset, slug, entity, ctx.window.end, overrides) + if (start.status !== "value" || end.status !== "value") return 0 + return end.value - start.value +} + +function sortBars(ctx: LayoutContext, bars: Bar[]): Bar[] { + const sort = ctx.definition.sort ?? DEFAULT_BAR_SORT + const direction = sort.order === "asc" ? 1 : -1 + const sorted = [...bars] + switch (sort.by) { + case "name": + sorted.sort((a, b) => direction * compareStrings(a.series.label, b.series.label)) + break + case "column": { + const slug = sort.column ?? ctx.definition.y[0] + const target = ctx.window?.end ?? null + const keyOf = (bar: Bar): number => { + const entity = bar.series.entity + if (entity === undefined) return bar.value + const resolved = resolveValue(ctx.dataset, slug, entity, target, ctx.definition.bindings?.[slug]) + return resolved.status === "value" ? resolved.value : Number.NEGATIVE_INFINITY + } + sorted.sort((a, b) => direction * (keyOf(a) - keyOf(b))) + break + } + case "change": + sorted.sort((a, b) => { + const ca = a.series.entity !== undefined ? changeOver(ctx, a.series.column ?? ctx.definition.y[0], a.series.entity) : 0 + const cb = b.series.entity !== undefined ? changeOver(ctx, b.series.column ?? ctx.definition.y[0], b.series.entity) : 0 + return direction * (ca - cb) + }) + break + case "custom": + break + case "total": + default: + sorted.sort((a, b) => direction * (a.value - b.value)) + break + } + return sorted +} + +export function layoutDiscreteBar(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const relative = ctx.stackMode === "relative" + const target = ctx.window?.end ?? null + + const builtResult = buildSeriesModels(ctx, "discrete-bar") + const diagnostics = [...builtResult.diagnostics] + + // One bar per series, from its point at the target time (resolveValue + // already applied tolerance when building the series). + const labelFont = seriesLabelFont(scale) + const valueFont = valueLabelFont(scale) + let bars: Bar[] = [] + for (const series of builtResult.series) { + const point = pointByTime(series).get(target) + if (point === undefined) continue + bars.push({ series, point, value: point.value, labelText: series.label, valueText: "" }) + } + if (bars.length === 0) return emptyLayer(area, diagnostics) + + // Relative mode: share of the visible total (absolute weights). + if (relative) { + const total = bars.reduce((sum, bar) => sum + Math.abs(bar.value), 0) + for (const bar of bars) bar.value = total > 0 ? (bar.value / total) * 100 : 0 + } + + bars = sortBars(ctx, bars) + + // Value label text, with the tolerance suffix when borrowed (spec 13). + const t = strings(locale) + for (const bar of bars) { + const slug = bar.series.column ?? ctx.definition.y[0] + let text = labelValueText(ctx, slug, bar.value, relative) + if (target !== null && bar.point.sourceTime !== undefined && bar.point.sourceTime !== target) { + text += t.inTime(formatTime(bar.point.sourceTime, grain, locale)) + } + bar.valueText = text + } + + // --- Geometry -------------------------------------------------------------- + const labelMaxWidth = Math.min( + Math.max(...bars.map((bar) => measurer.measure(bar.labelText, labelFont).width)), + area.width * 0.3, + ) + const labelColWidth = labelMaxWidth + 6 + let posReserve = 0 + let negReserve = 0 + for (const bar of bars) { + const width = measurer.measure(bar.valueText, valueFont).width + 6 + if (bar.value < 0) negReserve = Math.max(negReserve, width) + else posReserve = Math.max(posReserve, width) + } + + const axisFont = tickFont(scale) + const sample = measurer.measure("0", axisFont) + const axisHeight = sample.ascent + sample.descent + PLOT_TOP_PAD + 2 + + const plotArea: Rect = { + x: area.x + labelColWidth + negReserve, + y: area.y + 4, + width: Math.max(10, area.width - labelColWidth - negReserve - posReserve), + height: Math.max(10, area.height - 4 - axisHeight), + } + + const spec = prepareValueAxis({ + values: [0, ...bars.map((bar) => bar.value)], + markType: "bar", + scaleType: "linear", + config: ctx.definition.xAxis, + pixelLength: plotArea.width, + font: axisFont, + meta: relative ? RELATIVE_META : metaFor(ctx, ctx.definition.y[0]), + locale, + measurer, + showSign: relative, + }) + diagnostics.push(...spec.diagnostics) + const xScale = createValueScale("linear", spec.domain, [plotArea.x, plotArea.x + plotArea.width]) + + const nodes: SceneNode[] = horizontalValueAxisNodes(spec, xScale, plotArea, area, { + theme, + font: axisFont, + hideGridlines: ctx.definition.xAxis?.hideGridlines, + hideTickLabels: ctx.definition.xAxis?.hideTickLabels, + }) + + // --- Bars, labels, hover ----------------------------------------------------- + const rows = bandPositions(bars.length, [plotArea.y, plotArea.y + plotArea.height], 1) + const rowHeight = rows.length > 0 ? rows[0].width : plotArea.height + const barHeight = Math.min(Math.max(rowHeight * 0.7, BAR_HEIGHT_FLOOR), BAR_HEIGHT_MAX) + const zeroX = xScale.place(0) + const subtitle = metricSubtitle(ctx, ctx.definition.y[0]) + const targets: HitTarget[] = [] + const outSeries: SeriesModel[] = [] + + bars.forEach((bar, index) => { + const row = rows[index] + const barTop = row.center - barHeight / 2 + const endX = xScale.place(bar.value) + const rect: Rect = { + x: Math.min(zeroX, endX), + y: barTop, + width: Math.abs(endX - zeroX), + height: barHeight, + } + nodes.push({ + key: `series/${bar.series.key}/bar`, + seriesKey: bar.series.key, + role: "mark", + kind: "rect", + rect, + style: { + fill: bar.series.colour, + ...(bar.point.projected === true ? { patternId: "projection", opacity: 0.85 } : {}), + }, + }) + + // Row label (left of the bar area). + const rowLabel = truncateWithEllipsis(bar.labelText, labelFont, Math.max(10, labelMaxWidth), measurer) + const rowLabelMetrics = measurer.measure(rowLabel, labelFont) + nodes.push( + textNode({ + key: `label/${bar.series.key}`, + role: "label", + text: rowLabel, + font: labelFont, + anchor: "end", + x: area.x + labelColWidth - 6, + baselineY: centeredBaseline(row.center, rowLabelMetrics), + colour: theme.chrome.tickLabel, + measurer, + seriesKey: bar.series.key, + }), + ) + + // Value label at the bar end, mirrored for negatives. + const valueMetrics = measurer.measure(bar.valueText, valueFont) + const negative = bar.value < 0 + nodes.push( + textNode({ + key: `value/${bar.series.key}`, + role: "label", + text: bar.valueText, + font: valueFont, + anchor: negative ? "end" : "start", + x: negative ? endX - 4 : endX + 4, + baselineY: centeredBaseline(row.center, valueMetrics), + colour: theme.chrome.tickLabel, + measurer, + seriesKey: bar.series.key, + }), + ) + + // Hover: the whole row is the hit area. + const flags = collectFooterFlags() + noteFooterFlags(flags, bar.point, target) + const slug = bar.series.column ?? ctx.definition.y[0] + const notice = noticeFor(bar.point, target) + targets.push({ + kind: "series", + seriesKey: bar.series.key, + shape: { x: plotArea.x, y: row.start, width: plotArea.width, height: row.width }, + tooltip: { + title: bar.series.label, + ...(target !== null ? { titleAnnotation: formatTime(target, grain, locale) } : {}), + ...(subtitle !== undefined ? { subtitle } : {}), + rows: [ + { + seriesKey: bar.series.key, + label: bar.series.label, + swatch: bar.series.colour, + valueText: tooltipValueText(ctx, slug, bar.value, relative), + emphasized: true, + ...(notice !== undefined ? { notice } : {}), + }, + ], + footers: buildFooters(flags, grain, locale), + }, + }) + + outSeries.push({ ...bar.series, points: [{ ...bar.point, value: bar.value }] }) + }) + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets }, + legendItems: legendItemsFor(outSeries), + greyedLegendKeys: [], + needsLegendFallback: false, + empty: false, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/charts/line.test.ts b/packages/charts2/src/core/layout/charts/line.test.ts new file mode 100644 index 00000000000..309a8df3950 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/line.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutLineChart } from "./line.ts" +import type { ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("line chart gaps (spec 11)", () => { + it("renders an interior-gap series as two segments in one line node", () => { + const ctx = ctxFor("provincial-budgets", { y: ["program_spending"], selectedEntities: ["Nova Scotia"] }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const line = layer.nodes.find((n) => n.key === "series/Nova Scotia/line") + expect(line?.kind).toBe("line") + if (line?.kind !== "line") return + expect(line.segments.length).toBe(2) + expect(line.segments[0].length).toBe(3) // 2019-20 .. 2021-22 + expect(line.segments[1].length).toBe(2) // 2023-24 .. 2024-25 + }) + + it("renders a gapless series as a single segment", () => { + const ctx = ctxFor("provincial-budgets", { y: ["total_spending"], selectedEntities: ["Ontario"] }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const line = layer.nodes.find((n) => n.key === "series/Ontario/line") + expect(line?.kind).toBe("line") + if (line?.kind !== "line") return + expect(line.segments.length).toBe(1) + expect(line.segments[0].length).toBe(6) + }) +}) + +describe("line chart hover model (spec 06/11)", () => { + it("creates one time target per time with rows sorted by value descending", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Nova Scotia", "Ontario", "Alberta"], + }) + const layer = layoutLineChart(ctx, AREA, OPTS) + expect(layer.hover.targets.length).toBe(6) + expect(layer.hover.timeGuide).toBeDefined() + const first = layer.hover.targets[0] + expect(first.kind).toBe("time") + if (first.kind !== "time") return + expect(first.tooltip.title).toBe("2019–20") + expect(first.tooltip.rows.map((r) => r.label)).toEqual(["Ontario", "Alberta", "Nova Scotia"]) + }) + + it("reports missing values as 'No data' rows, never zero", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["program_spending"], + selectedEntities: ["Ontario", "Nova Scotia"], + }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const target = layer.hover.targets.find((t) => t.kind === "time" && t.time === 2022) + expect(target).toBeDefined() + const row = target?.tooltip.rows.find((r) => r.seriesKey === "Nova Scotia") + expect(row?.notice).toBe("missing") + expect(row?.valueText).toBe("No data") + }) + + it("adds a tolerance footer for borrowed values", () => { + const ctx = ctxFor("provincial-budgets", { y: ["debt_charges"], selectedEntities: ["Quebec"] }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const target = layer.hover.targets.find((t) => t.kind === "time" && t.time === 2024) + expect(target?.tooltip.footers.some((f) => f.text === "Data from 2023–24")).toBe(true) + }) +}) + +describe("line chart end labels", () => { + it("places non-overlapping end labels for many colliding series", () => { + const ctx = ctxFor("federal-departments", { y: ["spending"] }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const labels = layer.nodes.filter((n) => n.kind === "text" && n.key.startsWith("label/")) + expect(labels.length).toBeGreaterThanOrEqual(2) + const boxes = labels + .map((n) => (n.kind === "text" ? { top: n.position.y - n.measured.ascent, bottom: n.position.y + n.measured.descent } : null)) + .filter((b) => b !== null) + .sort((a, b) => a.top - b.top) + for (let i = 1; i < boxes.length; i++) { + expect(boxes[i].top + 0.001).toBeGreaterThanOrEqual(boxes[i - 1].bottom - 1) + } + }) + + it("skips end labels and offers a legend when hideSeriesLabels is set", () => { + const ctx = ctxFor("provincial-budgets", { y: ["total_spending"], hideSeriesLabels: true }) + const layer = layoutLineChart(ctx, AREA, OPTS) + expect(layer.nodes.some((n) => n.key.startsWith("label/"))).toBe(false) + expect(layer.needsLegendFallback).toBe(true) + expect(layer.legendItems.length).toBeGreaterThan(0) + }) +}) + +describe("line chart relative mode (spec 11)", () => { + it("rebases values and formats the axis as signed percentages", () => { + const ctx = ctxFor("government-debt", { y: ["federal_debt"], stackMode: "relative" }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const values = layer.series[0].points.map((p) => p.value) + expect(values[0]).toBeCloseTo(0) + expect(values[1]).toBeCloseTo(20) + const positiveTick = layer.nodes.find( + (n) => n.kind === "text" && n.role === "axis" && n.text.startsWith("+"), + ) + expect(positiveTick).toBeDefined() + }) +}) + +describe("line chart projections", () => { + it("renders projected runs as a separate dashed node sharing the transition point", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario"], + bindings: { total_spending: { projectionFrom: 2023 } }, + }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const solid = layer.nodes.find((n) => n.key === "series/Ontario/line") + const projected = layer.nodes.find((n) => n.key === "series/Ontario/line/projected") + expect(solid?.kind).toBe("line") + expect(projected?.kind).toBe("line") + if (solid?.kind !== "line" || projected?.kind !== "line") return + expect(projected.style.dash).toBeDefined() + // Transition point shared: last solid vertex === first projected vertex. + const lastSolid = solid.segments[0][solid.segments[0].length - 1] + expect(projected.segments[0][0]).toEqual(lastSolid) + }) +}) diff --git a/packages/charts2/src/core/layout/charts/line.ts b/packages/charts2/src/core/layout/charts/line.ts new file mode 100644 index 00000000000..c312bcac651 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/line.ts @@ -0,0 +1,323 @@ +/** + * Line chart layout (spec 11). + * + * - Gap-broken polylines: a line node's segments array encodes data gaps + * (no implicit interpolation). + * - Projection runs render dashed/lighter, sharing the transition point with + * the solid run (transition marked with a point). + * - Markers appear when sparse and always for isolated/single points. + * - Series labels at line ends, decluttered; legend fallback when they + * cannot fit or when hideSeriesLabels is set. + * - Hover: one target per time with a multi-series tooltip sorted by value + * descending, plus a vertical time guide. + */ + +import { formatTime } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint, TooltipRow, Vec2 } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { TimeOrdinal } from "../../types.ts" +import { layoutVerticalAxes, timeAxisNodes } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { declutterLabels, type LabelCandidate } from "../declutter.ts" +import { createValueScale } from "../scales.ts" +import { buildSeriesModels, toRelativeLineSeries } from "../series.ts" +import { + buildFooters, + centeredBaseline, + collectFooterFlags, + emptyLayer, + legendItemsFor, + metricSubtitle, + missingRow, + noteFooterFlags, + noticeFor, + pointByTime, + seriesLabelFont, + textNode, + tickFont, + tooltipValueText, + metaFor, + RELATIVE_META, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +const LABEL_GAP = 4 +const MARKER_SPACING_THRESHOLD = 30 + +interface Run { + projected: boolean + points: SeriesPoint[] +} + +/** Split a series into gap-free runs, then by the projected flag (the + * transition point is shared between the solid and projected run). */ +function buildRuns(series: SeriesModel, times: readonly TimeOrdinal[], logScale: boolean): Run[] { + const byTime = pointByTime(series) + const gapRuns: SeriesPoint[][] = [] + let current: SeriesPoint[] = [] + for (const time of times) { + const point = byTime.get(time) + if (point !== undefined && (!logScale || point.value > 0)) { + current.push(point) + } else if (current.length > 0) { + gapRuns.push(current) + current = [] + } + } + if (current.length > 0) gapRuns.push(current) + + const runs: Run[] = [] + for (const run of gapRuns) { + let cur: Run = { projected: run[0].projected === true, points: [run[0]] } + for (let i = 1; i < run.length; i++) { + const projected = run[i].projected === true + if (projected === cur.projected) { + cur.points.push(run[i]) + } else { + runs.push(cur) + cur = { projected, points: [run[i - 1], run[i]] } + } + } + runs.push(cur) + } + return runs +} + +export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const relative = ctx.stackMode === "relative" + + const builtResult = buildSeriesModels(ctx, "line") + const diagnostics = [...builtResult.diagnostics] + let series = builtResult.series + if (relative) { + const transformed = toRelativeLineSeries(series) + series = transformed.series + diagnostics.push(...transformed.diagnostics) + } + series = series.filter((s) => s.points.length > 0) + if (series.length === 0 || ctx.times.length === 0) return emptyLayer(area, diagnostics) + + const logScale = ctx.scaleType === "log" && !relative + + // --- Right margin for end-of-line labels -------------------------------- + const labelFont = seriesLabelFont(scale) + const showLabels = !ctx.definition.hideSeriesLabels && !opts.legendReserved + const labelMaxWidth = Math.max(30, area.width * 0.25) + let rightReserve = 0 + const labelTexts = new Map() + if (showLabels) { + for (const s of series) { + const text = truncateWithEllipsis(s.label, labelFont, labelMaxWidth, measurer) + labelTexts.set(s.key, text) + rightReserve = Math.max(rightReserve, measurer.measure(text, labelFont).width) + } + rightReserve += LABEL_GAP + 4 + } + + // --- Axes ---------------------------------------------------------------- + const slug = ctx.definition.y[0] + const values = series.flatMap((s) => s.points.map((p) => p.value)) + const axes = layoutVerticalAxes({ + area, + values, + markType: "line", + scaleType: logScale ? "log" : "linear", + config: ctx.definition.yAxis, + meta: relative ? RELATIVE_META : metaFor(ctx, slug), + locale, + theme, + measurer, + font: tickFont(scale), + rightReserve, + showSign: relative, + }) + diagnostics.push(...axes.diagnostics) + const { plotArea, yScale } = axes + + const window = ctx.window ?? { start: ctx.times[0], end: ctx.times[ctx.times.length - 1] } + const xScale = createValueScale("linear", [window.start, window.end], [plotArea.x, plotArea.x + plotArea.width]) + + const nodes: SceneNode[] = [...axes.nodes] + nodes.push( + ...timeAxisNodes({ + times: ctx.times, + place: (t) => xScale.place(t), + plotArea, + clampBounds: area, + grain, + locale, + theme, + measurer, + font: tickFont(scale), + }), + ) + + // --- Marks ---------------------------------------------------------------- + const singleSeries = series.length === 1 + const strokeWidth = singleSeries ? 2.5 : 2 + const spacing = plotArea.width / Math.max(1, ctx.times.length - 1) + const sparse = spacing > MARKER_SPACING_THRESHOLD + + for (const s of series) { + const runs = buildRuns(s, ctx.times, logScale) + const toVec = (p: SeriesPoint): Vec2 => ({ x: xScale.place(p.time ?? window.start), y: yScale.place(p.value) }) + + const solidSegments = runs.filter((r) => !r.projected && r.points.length > 1).map((r) => r.points.map(toVec)) + const projectedSegments = runs.filter((r) => r.projected && r.points.length > 1).map((r) => r.points.map(toVec)) + + if (solidSegments.length > 0) { + nodes.push({ + key: `series/${s.key}/line`, + seriesKey: s.key, + role: "mark", + kind: "line", + segments: solidSegments, + style: { stroke: s.colour, strokeWidth, lineCap: "round" }, + }) + } + if (projectedSegments.length > 0) { + nodes.push({ + key: `series/${s.key}/line/projected`, + seriesKey: s.key, + role: "mark", + kind: "line", + segments: projectedSegments, + style: { stroke: s.colour, strokeWidth: Math.max(1, strokeWidth - 0.75), dash: [5, 3], opacity: 0.9, lineCap: "round" }, + }) + // Mark the projection transition point. + for (let i = 1; i < runs.length; i++) { + if (runs[i].projected && !runs[i - 1].projected) { + const boundary = runs[i].points[0] + nodes.push({ + key: `series/${s.key}/projection-start/${boundary.time}`, + seriesKey: s.key, + role: "mark", + kind: "point", + center: toVec(boundary), + radius: strokeWidth + 0.5, + style: { fill: s.colour }, + }) + } + } + } + + // Markers: sparse charts, single-point series, and isolated run points. + const drawable = runs.flatMap((r) => r.points) + const showAll = sparse || drawable.length === 1 + for (const run of runs) { + const isolated = run.points.length === 1 + if (!showAll && !isolated) continue + for (const point of run.points) { + nodes.push({ + key: `series/${s.key}/marker/${point.time}`, + seriesKey: s.key, + role: "mark", + kind: "point", + center: toVec(point), + radius: singleSeries ? 3.5 : 2.5, + style: { fill: s.colour }, + }) + } + } + } + + // --- End-of-line labels ---------------------------------------------------- + let needsLegendFallback = false + if (showLabels) { + const candidates: LabelCandidate[] = [] + for (const s of series) { + const drawable = s.points.filter((p) => !logScale || p.value > 0) + if (drawable.length === 0) continue + const last = drawable[drawable.length - 1] + const text = labelTexts.get(s.key) ?? s.label + const metrics = measurer.measure(text, labelFont) + candidates.push({ + seriesKey: s.key, + text, + targetY: yScale.place(last.value), + priority: last.value, + width: metrics.width, + height: metrics.ascent + metrics.descent, + }) + } + const { placed, dropped } = declutterLabels(candidates, plotArea.y, plotArea.y + plotArea.height) + for (const label of placed) { + const metrics = measurer.measure(label.text, labelFont) + const colour = series.find((s) => s.key === label.seriesKey)?.colour ?? theme.chrome.tickLabel + nodes.push( + textNode({ + key: `label/${label.seriesKey}`, + role: "label", + text: label.text, + font: labelFont, + anchor: "start", + x: plotArea.x + plotArea.width + LABEL_GAP, + baselineY: label.y + metrics.ascent, + colour, + measurer, + seriesKey: label.seriesKey, + }), + ) + } + if (dropped.length > 0) needsLegendFallback = true + } else if (ctx.definition.hideSeriesLabels && !opts.legendReserved) { + needsLegendFallback = true + } + + // --- Hover ------------------------------------------------------------------- + const targets: HitTarget[] = [] + const subtitle = builtResult.strategy === "entity" ? metricSubtitle(ctx, slug) : undefined + for (const time of ctx.times) { + const flags = collectFooterFlags() + const present: { row: TooltipRow; value: number }[] = [] + const missing: TooltipRow[] = [] + for (const s of series) { + const point = pointByTime(s).get(time) + if (point === undefined || (logScale && point.value <= 0)) { + missing.push(missingRow(s.key, s.label, s.colour, locale)) + continue + } + noteFooterFlags(flags, point, time) + present.push({ + value: point.value, + row: { + seriesKey: s.key, + label: s.label, + swatch: s.colour, + valueText: relative + ? tooltipValueText(ctx, s.column ?? slug, point.value, true) + : tooltipValueText(ctx, s.column ?? slug, point.value, false), + emphasized: false, + ...(noticeFor(point, time) !== undefined ? { notice: noticeFor(point, time) } : {}), + }, + }) + } + present.sort((a, b) => b.value - a.value) + targets.push({ + kind: "time", + time, + x: xScale.place(time), + tooltip: { + title: formatTime(time, grain, locale), + ...(subtitle !== undefined ? { subtitle } : {}), + rows: [...present.map((p) => p.row), ...missing], + footers: buildFooters(flags, grain, locale), + }, + }) + } + + return { + plotArea, + nodes, + series, + hover: { targets, timeGuide: { y0: plotArea.y, y1: plotArea.y + plotArea.height } }, + legendItems: legendItemsFor(series), + greyedLegendKeys: [], + needsLegendFallback, + empty: false, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/charts/shared.ts b/packages/charts2/src/core/layout/charts/shared.ts new file mode 100644 index 00000000000..721d0b70bf6 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/shared.ts @@ -0,0 +1,268 @@ +/** + * Shared chart-layer plumbing: the ChartLayer contract every chart module + * returns, the chart font ramp, localized strings, and tooltip helpers. + */ + +import type { FormatMeta } from "../../format/number.ts" +import { formatValue } from "../../format/number.ts" +import { formatTime } from "../../format/timeLabels.ts" +import type { + HoverModel, + LegendItem, + Rect, + SceneNode, + SeriesModel, + SeriesPoint, + TooltipModel, + TooltipRow, +} from "../../scene/nodes.ts" +import { round2 } from "../../scene/nodes.ts" +import type { FontSpec, TextMeasurer } from "../../text/measurer.ts" +import type { ColumnMeta, Diagnostic, Locale, SeriesKey, TimeGrain, TimeOrdinal } from "../../types.ts" +import type { LayoutContext } from "../context.ts" + +// --------------------------------------------------------------------------- +// ChartLayer — what every chart module hands back to layoutChart +// --------------------------------------------------------------------------- + +export interface ChartLayer { + plotArea: Rect + nodes: SceneNode[] + series: SeriesModel[] + hover: HoverModel + /** Legend items, in series order, for when layoutChart shows a legend. */ + legendItems: LegendItem[] + /** Keys rendered greyed in the legend (zero-throughout stacked bands). */ + greyedLegendKeys: SeriesKey[] + /** Direct labelling failed: request a legend when none was reserved. */ + needsLegendFallback: boolean + /** No drawable data — layoutChart renders the no-data panel instead. */ + empty: boolean + diagnostics: Diagnostic[] +} + +export function emptyLayer(plotArea: Rect, diagnostics: Diagnostic[]): ChartLayer { + return { + plotArea, + nodes: [], + series: [], + hover: { targets: [] }, + legendItems: [], + greyedLegendKeys: [], + needsLegendFallback: false, + empty: true, + diagnostics, + } +} + +export interface ChartLayerOptions { + /** A legend is (or will be) shown above the chart. */ + legendReserved: boolean + /** Thumbnail chrome: minimal labelling. */ + thumbnail: boolean + /** Breakpoint font scale derived from the scene width (spec 10 §6). */ + fontScale: number +} + +// --------------------------------------------------------------------------- +// Fonts (theme base size × breakpoint scale) +// --------------------------------------------------------------------------- + +/** Spec 10 §6: named breakpoints gate the font scale. */ +export function fontScaleFor(width: number): number { + if (width < 400) return 0.85 + if (width < 700) return 0.95 + return 1 +} + +function font(family: FontSpec["family"], sizePx: number, weight: FontSpec["weight"]): FontSpec { + return { family, sizePx: round2(sizePx), weight } +} + +export const titleFont = (scale: number): FontSpec => font("heading", 20 * scale, 700) +export const subtitleFont = (scale: number): FontSpec => font("body", 13 * scale, 400) +export const tickFont = (scale: number): FontSpec => font("body", 12 * scale, 400) +export const seriesLabelFont = (scale: number): FontSpec => font("body", 12 * scale, 400) +export const valueLabelFont = (scale: number): FontSpec => font("body", 12 * scale, 400) +export const legendFont = (scale: number): FontSpec => font("body", 12 * scale, 400) +export const footerFont = (scale: number): FontSpec => font("body", 11 * scale, 400) +export const noDataFont = (scale: number): FontSpec => font("body", 14 * scale, 400) + +// --------------------------------------------------------------------------- +// Localized strings +// --------------------------------------------------------------------------- + +export interface ChartStrings { + noData: string + noDataPanel: string + projected: string + interpolated: string + total: string + source: string + dataFrom: (time: string) => string + inTime: (time: string) => string +} + +const STRINGS: Record = { + en: { + noData: "No data", + noDataPanel: "No data for the current selection", + projected: "Projected data", + interpolated: "Includes interpolated values", + total: "Total", + source: "Source", + dataFrom: (time: string) => `Data from ${time}`, + inTime: (time: string) => ` in ${time}`, + }, + fr: { + noData: "Aucune donnée", + noDataPanel: "Aucune donnée pour la sélection actuelle", + projected: "Données projetées", + interpolated: "Comprend des valeurs interpolées", + total: "Total", + source: "Source", + dataFrom: (time: string) => `Données de ${time}`, + inTime: (time: string) => ` en ${time}`, + }, +} + +export function strings(locale: Locale): ChartStrings { + return STRINGS[locale] +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +export const RELATIVE_META: FormatMeta = { type: "percentage" } + +export function metaFor(ctx: LayoutContext, slug: string): ColumnMeta | FormatMeta { + return ctx.columns[slug] ?? { type: "numeric" } +} + +export function tooltipValueText(ctx: LayoutContext, slug: string, value: number, relative: boolean): string { + if (relative) return formatValue(value, RELATIVE_META, { locale: ctx.locale, verbosity: "long", showSign: true }) + return formatValue(value, metaFor(ctx, slug), { locale: ctx.locale, verbosity: "long" }) +} + +export function labelValueText(ctx: LayoutContext, slug: string, value: number, relative: boolean): string { + if (relative) return formatValue(value, RELATIVE_META, { locale: ctx.locale, verbosity: "label", showSign: true }) + return formatValue(value, metaFor(ctx, slug), { locale: ctx.locale, verbosity: "label" }) +} + +/** Tooltip subtitle (spec 06 §1): metric name + unit when not obvious. */ +export function metricSubtitle(ctx: LayoutContext, slug: string): string | undefined { + const meta = ctx.columns[slug] + if (meta === undefined) return undefined + const unit = meta.denominator !== undefined ? (meta.derivedUnit ?? meta.derivedShortUnit) : meta.unit + return unit !== undefined && unit !== "" ? `${meta.name} (${unit})` : meta.name +} + +// --------------------------------------------------------------------------- +// Tooltip assembly +// --------------------------------------------------------------------------- + +export interface FooterFlags { + /** Distinct borrowed source times (sourceTime ≠ requested time). */ + borrowedTimes: TimeOrdinal[] + projected: boolean + interpolated: boolean +} + +export function collectFooterFlags(): FooterFlags { + return { borrowedTimes: [], projected: false, interpolated: false } +} + +export function noteFooterFlags(flags: FooterFlags, point: SeriesPoint | undefined, time: TimeOrdinal | null): void { + if (point === undefined) return + if (point.projected === true) flags.projected = true + if (point.interpolated === true) flags.interpolated = true + if ( + time !== null && + point.sourceTime !== undefined && + point.sourceTime !== time && + !flags.borrowedTimes.includes(point.sourceTime) + ) { + flags.borrowedTimes.push(point.sourceTime) + } +} + +export function buildFooters(flags: FooterFlags, grain: TimeGrain, locale: Locale): TooltipModel["footers"] { + const footers: TooltipModel["footers"] = [] + const t = strings(locale) + for (const time of [...flags.borrowedTimes].sort((a, b) => a - b)) { + footers.push({ icon: "notice", text: t.dataFrom(formatTime(time, grain, locale)) }) + } + if (flags.interpolated) footers.push({ icon: "notice", text: t.interpolated }) + if (flags.projected) footers.push({ icon: "projection", text: t.projected }) + return footers +} + +export function missingRow(seriesKey: SeriesKey, label: string, swatch: string, locale: Locale): TooltipRow { + return { + seriesKey, + label, + swatch, + valueText: strings(locale).noData, + emphasized: false, + notice: "missing", + } +} + +export function noticeFor(point: SeriesPoint, time: TimeOrdinal | null): TooltipRow["notice"] { + if (point.projected === true) return "projected" + if (time !== null && point.sourceTime !== undefined && point.sourceTime !== time) return "toleranced" + return undefined +} + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +export function pointByTime(series: SeriesModel): Map { + return new Map(series.points.map((point) => [point.time, point])) +} + +export function legendItemsFor(series: readonly SeriesModel[]): LegendItem[] { + return series.map((s) => ({ seriesKey: s.key, label: s.label, swatch: s.colour })) +} + +/** Baseline so the text's vertical center sits on `centerY`. */ +export function centeredBaseline(centerY: number, metrics: { ascent: number; descent: number }): number { + return centerY + (metrics.ascent - metrics.descent) / 2 +} + +export interface TextNodeArgs { + key: string + role: "mark" | "axis" | "grid" | "label" | "annotation" | "chrome" + text: string + font: FontSpec + anchor: "start" | "middle" | "end" + x: number + baselineY: number + colour: string + measurer: TextMeasurer + seriesKey?: SeriesKey + opacity?: number +} + +export function textNode(args: TextNodeArgs): SceneNode { + return { + key: args.key, + role: args.role, + kind: "text", + position: { x: args.x, y: args.baselineY }, + text: args.text, + font: args.font, + anchor: args.anchor, + colour: args.colour, + measured: args.measurer.measure(args.text, args.font), + ...(args.seriesKey !== undefined ? { seriesKey: args.seriesKey } : {}), + ...(args.opacity !== undefined ? { opacity: args.opacity } : {}), + } +} + +/** Deterministic string-comparison (no Intl, no locale-dependent collation). */ +export function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} diff --git a/packages/charts2/src/core/layout/charts/stackedArea.test.ts b/packages/charts2/src/core/layout/charts/stackedArea.test.ts new file mode 100644 index 00000000000..9ecaa17d269 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/stackedArea.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import type { ChartLayerOptions } from "./shared.ts" +import { layoutStackedArea } from "./stackedArea.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +const DEBT_Y = ["federal_debt", "provincial_debt", "municipal_debt"] + +describe("stacked area offsets vs the hand-computed government-debt fixture", () => { + it("stacks per-time offsets as cumulative sums in definition order", () => { + const ctx = ctxFor("government-debt", { y: DEBT_Y, types: ["stacked-area"] }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + expect(layer.series.map((s) => s.key)).toEqual(DEBT_Y) + const at2019 = layer.series.map((s) => s.points.find((p) => p.time === 2019)) + // federal 50, provincial 30, municipal 5 (% of GDP) + expect(at2019[0]?.value).toBeCloseTo(50) + expect(at2019[0]?.valueOffset).toBeCloseTo(0) + expect(at2019[1]?.value).toBeCloseTo(30) + expect(at2019[1]?.valueOffset).toBeCloseTo(50) + expect(at2019[2]?.value).toBeCloseTo(5) + expect(at2019[2]?.valueOffset).toBeCloseTo(80) + }) +}) + +describe("stacked area interpolation (spec 14)", () => { + it("linearly interpolates interior gaps and flags the points", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["program_spending"], + selectedEntities: ["Ontario", "Nova Scotia"], + types: ["stacked-area"], + }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + const ns = layer.series.find((s) => s.key === "Nova Scotia") + const interpolated = ns?.points.find((p) => p.time === 2022) + expect(interpolated).toBeDefined() + expect(interpolated?.interpolated).toBe(true) + expect(interpolated?.value).toBeCloseTo((12.4 + 14.7) / 2) + }) + + it("flags interpolated spans in the tooltip footers", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["program_spending"], + selectedEntities: ["Ontario", "Nova Scotia"], + types: ["stacked-area"], + }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + const target = layer.hover.targets.find((t) => t.kind === "time" && t.time === 2022) + expect(target?.tooltip.footers.some((f) => f.text.includes("interpolated"))).toBe(true) + }) +}) + +describe("stacked area relative mode (spec 14)", () => { + it("shares sum to ~100 at every time and the axis pins to 100", () => { + const ctx = ctxFor("government-debt", { y: DEBT_Y, types: ["stacked-area"], stackMode: "relative" }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + for (const time of ctx.times) { + const sum = layer.series.reduce((acc, s) => acc + (s.points.find((p) => p.time === time)?.value ?? 0), 0) + expect(sum).toBeCloseTo(100, 6) + } + const topOffsets = layer.series[2].points.map((p) => p.value + (p.valueOffset ?? 0)) + for (const top of topOffsets) expect(top).toBeCloseTo(100, 6) + }) + + it("tooltip shows both share and absolute value", () => { + const ctx = ctxFor("government-debt", { y: DEBT_Y, types: ["stacked-area"], stackMode: "relative" }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + const target = layer.hover.targets[0] + if (target.kind !== "time") return + expect(target.tooltip.rows[0].valueText).toMatch(/%.*\(/) + expect(target.tooltip.totalRow).toBeUndefined() + }) +}) + +describe("stacked area zero-throughout series (spec 14)", () => { + it("drops the series from the stack but keeps it greyed in the legend", () => { + const ctx = ctxFor("government-debt", { + y: ["federal_debt", "municipal_debt"], + types: ["stacked-area"], + bindings: { municipal_debt: { displayFactor: 0 } }, + }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + expect(layer.greyedLegendKeys).toEqual(["municipal_debt"]) + expect(layer.series.map((s) => s.key)).toEqual(["federal_debt"]) + const greyedItem = layer.legendItems.find((item) => item.seriesKey === "municipal_debt") + expect(greyedItem?.swatch).toBe(buildCanadaTheme.palette.noData) + expect(layer.nodes.some((n) => n.key === "series/municipal_debt/band")).toBe(false) + }) +}) + +describe("stacked area negative validation (spec 14)", () => { + it("rejects negative inputs with an error diagnostic and renders nothing", () => { + const ctx = ctxFor("pathological", { y: ["negatives"], types: ["stacked-area"] }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + expect(layer.empty).toBe(true) + expect(layer.nodes).toEqual([]) + expect(layer.diagnostics.some((d) => d.severity === "error" && d.code === "negative-values-in-stacked-area")).toBe( + true, + ) + }) +}) + +describe("stacked area tooltip totals", () => { + it("includes a Total row in absolute mode and stack-ordered rows", () => { + const ctx = ctxFor("government-debt", { y: DEBT_Y, types: ["stacked-area"] }) + const layer = layoutStackedArea(ctx, AREA, OPTS) + const target = layer.hover.targets.find((t) => t.kind === "time" && t.time === 2019) + expect(target?.tooltip.rows.map((r) => r.seriesKey)).toEqual(DEBT_Y) + expect(target?.tooltip.totalRow).toBeDefined() + expect(target?.tooltip.totalRow?.valueText).toContain("85") + }) +}) diff --git a/packages/charts2/src/core/layout/charts/stackedArea.ts b/packages/charts2/src/core/layout/charts/stackedArea.ts new file mode 100644 index 00000000000..ea6050b2492 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/stackedArea.ts @@ -0,0 +1,363 @@ +/** + * Stacked area chart layout (spec 14). + * + * - Negative values are a validation error: an error Diagnostic is emitted + * and nothing renders for that configuration (authors are directed to + * stacked bar or line). + * - Interior gaps are linearly interpolated (flagged + tooltipped); + * leading/trailing missing times contribute nothing (the band starts at + * its first time, the stack below stays continuous). + * - Series that are zero throughout the window are dropped from the stack + * but kept in the legend, greyed. + * - Stack order is definition order, bottom-up; relative mode pins 0–100%. + */ + +import { formatValue } from "../../format/number.ts" +import { formatTime } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint, TooltipRow, Vec2 } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { SeriesKey, TimeOrdinal } from "../../types.ts" +import { layoutVerticalAxes, timeAxisNodes } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { declutterLabels, type LabelCandidate } from "../declutter.ts" +import { createValueScale } from "../scales.ts" +import { buildSeriesModels, toShareOfTotalSeries } from "../series.ts" +import { stackSeries, withMissingValuesAsZeroes, type StackedSeries } from "../stacking.ts" +import { + buildFooters, + collectFooterFlags, + emptyLayer, + legendItemsFor, + missingRow, + noteFooterFlags, + pointByTime, + seriesLabelFont, + strings, + textNode, + tickFont, + tooltipValueText, + metaFor, + RELATIVE_META, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +const LABEL_GAP = 4 +export const BAND_OPACITY = 0.75 + +/** Linearly interpolate interior gaps; leading/trailing gaps stay missing. */ +function interpolateInteriorGaps(series: SeriesModel, times: readonly TimeOrdinal[]): SeriesModel { + const byTime = pointByTime(series) + const presentTimes = times.filter((t) => byTime.has(t)) + if (presentTimes.length < 2) return series + const first = presentTimes[0] + const last = presentTimes[presentTimes.length - 1] + + const points: SeriesPoint[] = [] + let prevIndex = -1 + for (let i = 0; i < times.length; i++) { + const time = times[i] + if (time < first || time > last) continue + const point = byTime.get(time) + if (point !== undefined) { + points.push(point) + prevIndex = i + continue + } + // Interior gap: find neighbours and interpolate on the ordinal axis. + const prevTime = times[prevIndex] + const prevPoint = byTime.get(prevTime) + let nextTime: TimeOrdinal | undefined + for (let j = i + 1; j < times.length; j++) { + if (byTime.has(times[j])) { + nextTime = times[j] + break + } + } + const nextPoint = nextTime !== undefined ? byTime.get(nextTime) : undefined + if (prevPoint === undefined || nextPoint === undefined || nextTime === undefined) continue + const ratio = (time - prevTime) / (nextTime - prevTime) + points.push({ + time, + value: prevPoint.value + (nextPoint.value - prevPoint.value) * ratio, + sourceTime: time, + interpolated: true, + }) + } + return { ...series, points } +} + +export function layoutStackedArea(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const relative = ctx.stackMode === "relative" + + const builtResult = buildSeriesModels(ctx, "stacked-area") + const diagnostics = [...builtResult.diagnostics] + if (builtResult.series.length === 0 || ctx.times.length === 0) return emptyLayer(area, diagnostics) + + // Spec 14: negative values are a validation error — render nothing. + const negatives = builtResult.series.flatMap((s) => s.points.filter((p) => p.value < 0)) + if (negatives.length > 0) { + diagnostics.push({ + severity: "error", + code: "negative-values-in-stacked-area", + message: + "Stacked area charts require non-negative values; use a stacked bar chart (which supports negatives) or a line chart", + context: { count: negatives.length }, + }) + return emptyLayer(area, diagnostics) + } + + // Interpolate interior gaps, keep absolute values for tooltips. + const interpolated = builtResult.series.map((s) => interpolateInteriorGaps(s, ctx.times)) + + // Zero-throughout series leave the stack but stay in the legend, greyed. + const greyedLegendKeys: SeriesKey[] = [] + const active: SeriesModel[] = [] + for (const series of interpolated) { + if (series.points.every((p) => p.value === 0)) { + greyedLegendKeys.push(series.key) + } else { + active.push(series) + } + } + if (active.length === 0) return emptyLayer(area, diagnostics) + + const absoluteByKey = new Map(active.map((s) => [s.key, pointByTime(s)])) + const display = relative ? toShareOfTotalSeries(active) : active + + // Stack in definition order, bottom-up. + const stackedInput: StackedSeries[] = display.map((series) => ({ + seriesKey: series.key, + points: series.points + .filter((p) => p.time !== null) + .map((p) => ({ + position: p.time as number, + time: p.time as number, + value: p.value, + valueOffset: 0, + ...(p.interpolated === true ? { interpolated: true } : {}), + })), + })) + const stacked = stackSeries(withMissingValuesAsZeroes(stackedInput)) + + // --- Axes ------------------------------------------------------------------- + const labelFont = seriesLabelFont(scale) + const showLabels = !ctx.definition.hideSeriesLabels && !opts.legendReserved + const labelMaxWidth = Math.max(30, area.width * 0.25) + let rightReserve = 0 + const labelTexts = new Map() + if (showLabels) { + for (const series of display) { + const text = truncateWithEllipsis(series.label, labelFont, labelMaxWidth, measurer) + labelTexts.set(series.key, text) + rightReserve = Math.max(rightReserve, measurer.measure(text, labelFont).width) + } + rightReserve += LABEL_GAP + 4 + } + + const slug = ctx.definition.y[0] + const stackTops = stacked.flatMap((s) => s.points.map((p) => p.value + p.valueOffset)) + const axes = layoutVerticalAxes({ + area, + values: [0, ...stackTops], + markType: "bar", + scaleType: "linear", + config: ctx.definition.yAxis, + meta: relative ? RELATIVE_META : metaFor(ctx, slug), + locale, + theme, + measurer, + font: tickFont(scale), + rightReserve, + ...(relative ? { pinnedMax: 100 } : {}), + }) + diagnostics.push(...axes.diagnostics) + const { plotArea, yScale } = axes + + const window = ctx.window ?? { start: ctx.times[0], end: ctx.times[ctx.times.length - 1] } + const xScale = createValueScale("linear", [window.start, window.end], [plotArea.x, plotArea.x + plotArea.width]) + const placeX = (position: number): number => xScale.place(position) + + const nodes: SceneNode[] = [...axes.nodes] + nodes.push( + ...timeAxisNodes({ + times: ctx.times, + place: placeX, + plotArea, + clampBounds: area, + grain, + locale, + theme, + measurer, + font: tickFont(scale), + }), + ) + + // --- Bands ------------------------------------------------------------------- + const baselineY = yScale.place(0) + const singleTime = stacked.length > 0 && stacked[0].points.length === 1 + const upperOf = (series: StackedSeries): Vec2[] => { + if (singleTime) { + const point = series.points[0] + const y = yScale.place(point.value + point.valueOffset) + return [ + { x: plotArea.x, y }, + { x: plotArea.x + plotArea.width, y }, + ] + } + return series.points.map((p) => ({ x: placeX(p.position), y: yScale.place(p.value + p.valueOffset) })) + } + + const colourByKey = new Map(display.map((s) => [s.key, s.colour])) + let prevUpper: Vec2[] | null = null + for (const series of stacked) { + const upper = upperOf(series) + const lower = prevUpper ?? upper.map((p) => ({ x: p.x, y: baselineY })) + const colour = colourByKey.get(series.seriesKey) ?? theme.palette.noData + nodes.push({ + key: `series/${series.seriesKey}/band`, + seriesKey: series.seriesKey, + role: "mark", + kind: "area", + upper, + lower, + style: { fill: colour, opacity: BAND_OPACITY }, + }) + nodes.push({ + key: `series/${series.seriesKey}/edge`, + seriesKey: series.seriesKey, + role: "mark", + kind: "line", + segments: [upper], + style: { stroke: colour, strokeWidth: 1 }, + }) + prevUpper = upper + } + + // --- Band labels at right midpoints -------------------------------------------- + let needsLegendFallback = greyedLegendKeys.length > 0 && !opts.legendReserved && !ctx.definition.hideLegend + if (showLabels) { + const candidates: LabelCandidate[] = [] + for (const series of stacked) { + const last = series.points[series.points.length - 1] + if (last === undefined || last.value === 0) continue + const top = yScale.place(last.value + last.valueOffset) + const bottom = yScale.place(last.valueOffset) + const text = labelTexts.get(series.seriesKey) ?? series.seriesKey + const metrics = measurer.measure(text, labelFont) + candidates.push({ + seriesKey: series.seriesKey, + text, + targetY: (top + bottom) / 2, + priority: last.value, + width: metrics.width, + height: metrics.ascent + metrics.descent, + }) + } + const { placed, dropped } = declutterLabels(candidates, plotArea.y, plotArea.y + plotArea.height) + for (const label of placed) { + const metrics = measurer.measure(label.text, labelFont) + nodes.push( + textNode({ + key: `label/${label.seriesKey}`, + role: "label", + text: label.text, + font: labelFont, + anchor: "start", + x: plotArea.x + plotArea.width + LABEL_GAP, + baselineY: label.y + metrics.ascent, + colour: colourByKey.get(label.seriesKey) ?? theme.chrome.tickLabel, + measurer, + seriesKey: label.seriesKey, + }), + ) + } + if (dropped.length > 0) needsLegendFallback = true + } else if (ctx.definition.hideSeriesLabels && !opts.legendReserved) { + needsLegendFallback = true + } + + // --- Hover --------------------------------------------------------------------- + const targets: HitTarget[] = [] + const t = strings(locale) + const displayByKey = new Map(display.map((s) => [s.key, s])) + for (const time of ctx.times) { + const flags = collectFooterFlags() + const rows: TooltipRow[] = [] + let total = 0 + let presentCount = 0 + for (const series of stacked) { + const stackPoint = series.points.find((p) => p.position === time) + const model = displayByKey.get(series.seriesKey) + const absPoint = absoluteByKey.get(series.seriesKey)?.get(time) + if (stackPoint === undefined || stackPoint.missing === true || model === undefined || absPoint === undefined) { + rows.push(missingRow(series.seriesKey, model?.label ?? series.seriesKey, colourByKey.get(series.seriesKey) ?? theme.palette.noData, locale)) + continue + } + noteFooterFlags(flags, absPoint, time) + if (stackPoint.interpolated === true) flags.interpolated = true + total += absPoint.value + presentCount += 1 + const absText = tooltipValueText(ctx, model.column ?? slug, absPoint.value, false) + const valueText = relative + ? `${formatValue(stackPoint.value, RELATIVE_META, { locale, verbosity: "long" })} (${absText})` + : absText + rows.push({ + seriesKey: series.seriesKey, + label: model.label, + swatch: model.colour, + valueText, + emphasized: false, + }) + } + targets.push({ + kind: "time", + time, + x: placeX(time), + tooltip: { + title: formatTime(time, grain, locale), + rows, + ...(presentCount >= 2 && !relative + ? { + totalRow: { + seriesKey: "total", + label: t.total, + swatch: theme.chrome.axisLine, + valueText: tooltipValueText(ctx, slug, total, false), + emphasized: true, + }, + } + : {}), + footers: buildFooters(flags, grain, locale), + }, + }) + } + + // SeriesModel output carries the stacked offsets (layer-2 contract). + const offsetByKey = new Map(stacked.map((s) => [s.seriesKey, new Map(s.points.map((p) => [p.position, p.valueOffset]))])) + const outSeries = display.map((series) => ({ + ...series, + points: series.points.map((p) => ({ + ...p, + valueOffset: p.time !== null ? (offsetByKey.get(series.key)?.get(p.time) ?? 0) : 0, + })), + })) + + const greyedItems = interpolated + .filter((s) => greyedLegendKeys.includes(s.key)) + .map((s) => ({ seriesKey: s.key, label: s.label, swatch: theme.palette.noData })) + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets, timeGuide: { y0: plotArea.y, y1: plotArea.y + plotArea.height } }, + legendItems: [...legendItemsFor(display), ...greyedItems], + greyedLegendKeys, + needsLegendFallback, + empty: false, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/charts/stackedBar.ts b/packages/charts2/src/core/layout/charts/stackedBar.ts new file mode 100644 index 00000000000..5a819ba8f81 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/stackedBar.ts @@ -0,0 +1,211 @@ +/** + * Stacked bar chart layout (spec 15). + * + * Vertical columns at discrete time positions; positives stack upward and + * negatives stack downward independently (stackSeriesInBothDirections — a + * negative segment never offsets a positive one). NO interpolation: a + * missing value contributes nothing to its column and the tooltip reports a + * "No data" row. Relative mode is the share of each column's absolute total. + */ + +import { formatValue } from "../../format/number.ts" +import { formatTime } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, TooltipRow } from "../../scene/nodes.ts" +import type { TimeOrdinal } from "../../types.ts" +import { layoutVerticalAxes, timeAxisNodes } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { bandPositions, createValueScale } from "../scales.ts" +import { buildSeriesModels, toShareOfTotalSeries } from "../series.ts" +import { stackSeriesInBothDirections, type StackedSeries } from "../stacking.ts" +import { + buildFooters, + collectFooterFlags, + emptyLayer, + legendItemsFor, + missingRow, + noteFooterFlags, + pointByTime, + strings, + tickFont, + tooltipValueText, + metaFor, + RELATIVE_META, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +export function layoutStackedBar(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const relative = ctx.stackMode === "relative" + + const builtResult = buildSeriesModels(ctx, "stacked-bar") + const diagnostics = [...builtResult.diagnostics] + if (builtResult.series.length === 0 || ctx.times.length === 0) return emptyLayer(area, diagnostics) + + const absoluteByKey = new Map(builtResult.series.map((s) => [s.key, pointByTime(s)])) + const display = relative ? toShareOfTotalSeries(builtResult.series) : builtResult.series + + // Every column position is seeded for every series; missing values are + // zero-filled with a missing flag (no interpolation, spec 15). + const stackedInput: StackedSeries[] = display.map((series) => { + const byTime = pointByTime(series) + return { + seriesKey: series.key, + points: ctx.times.map((time) => { + const point = byTime.get(time) + return { + position: time, + time, + value: point?.value ?? 0, + valueOffset: 0, + missing: point === undefined, + } + }), + } + }) + const stacked = stackSeriesInBothDirections(stackedInput) + + // --- Axes ---------------------------------------------------------------- + const slug = ctx.definition.y[0] + const extents = stacked.flatMap((s) => s.points.map((p) => p.value + p.valueOffset)) + const axes = layoutVerticalAxes({ + area, + values: [0, ...extents], + markType: "bar", + scaleType: "linear", + config: ctx.definition.yAxis, + meta: relative ? RELATIVE_META : metaFor(ctx, slug), + locale, + theme, + measurer, + font: tickFont(scale), + rightReserve: 0, + showSign: relative, + }) + diagnostics.push(...axes.diagnostics) + const { plotArea, yScale } = axes + + const bands = bandPositions(ctx.times.length, [plotArea.x, plotArea.x + plotArea.width], 0.7) + const bandByTime = new Map() + ctx.times.forEach((time, index) => bandByTime.set(time, bands[index])) + const placeX = (time: TimeOrdinal): number => bandByTime.get(time)?.center ?? plotArea.x + + const nodes: SceneNode[] = [...axes.nodes] + nodes.push( + ...timeAxisNodes({ + times: ctx.times, + place: placeX, + plotArea, + clampBounds: area, + grain, + locale, + theme, + measurer, + font: tickFont(scale), + }), + ) + + // --- Column segments --------------------------------------------------------- + const colourByKey = new Map(display.map((s) => [s.key, s.colour])) + for (const series of stacked) { + const colour = colourByKey.get(series.seriesKey) ?? theme.palette.noData + for (const point of series.points) { + if (point.missing === true || point.value === 0) continue + const band = bandByTime.get(point.position as TimeOrdinal) + if (band === undefined) continue + const y1 = yScale.place(point.valueOffset) + const y2 = yScale.place(point.value + point.valueOffset) + nodes.push({ + key: `series/${series.seriesKey}/bar/${point.position}`, + seriesKey: series.seriesKey, + role: "mark", + kind: "rect", + rect: { + x: band.start, + y: Math.min(y1, y2), + width: band.width, + height: Math.abs(y2 - y1), + }, + style: { fill: colour }, + }) + } + } + + // --- Hover ---------------------------------------------------------------------- + const t = strings(locale) + const displayByKey = new Map(display.map((s) => [s.key, s])) + const targets: HitTarget[] = [] + for (const time of ctx.times) { + const flags = collectFooterFlags() + const rows: TooltipRow[] = [] + let total = 0 + let presentCount = 0 + for (const series of stacked) { + const model = displayByKey.get(series.seriesKey) + if (model === undefined) continue + const absPoint = absoluteByKey.get(series.seriesKey)?.get(time) + const stackPoint = series.points.find((p) => p.position === time) + if (absPoint === undefined || stackPoint === undefined || stackPoint.missing === true) { + rows.push(missingRow(series.seriesKey, model.label, model.colour, locale)) + continue + } + noteFooterFlags(flags, absPoint, time) + total += absPoint.value + presentCount += 1 + const absText = tooltipValueText(ctx, model.column ?? slug, absPoint.value, false) + rows.push({ + seriesKey: series.seriesKey, + label: model.label, + swatch: model.colour, + valueText: relative + ? `${formatValue(stackPoint.value, RELATIVE_META, { locale, verbosity: "long" })} (${absText})` + : absText, + emphasized: false, + }) + } + targets.push({ + kind: "time", + time, + x: placeX(time), + tooltip: { + title: formatTime(time, grain, locale), + rows, + ...(presentCount >= 2 && !relative + ? { + totalRow: { + seriesKey: "total", + label: t.total, + swatch: theme.chrome.axisLine, + valueText: tooltipValueText(ctx, slug, total, false), + emphasized: true, + }, + } + : {}), + footers: buildFooters(flags, grain, locale), + }, + }) + } + + // SeriesModel output carries the stacked offsets for real points only. + const offsetByKey = new Map(stacked.map((s) => [s.seriesKey, new Map(s.points.map((p) => [p.position, p.valueOffset]))])) + const outSeries: SeriesModel[] = display.map((series) => ({ + ...series, + points: series.points.map((p) => ({ + ...p, + valueOffset: p.time !== null ? (offsetByKey.get(series.key)?.get(p.time) ?? 0) : 0, + })), + })) + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets, timeGuide: { y0: plotArea.y, y1: plotArea.y + plotArea.height } }, + legendItems: legendItemsFor(display), + greyedLegendKeys: [], + needsLegendFallback: false, + empty: false, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/charts/stackedDiscreteBar.test.ts b/packages/charts2/src/core/layout/charts/stackedDiscreteBar.test.ts new file mode 100644 index 00000000000..310281b3a5c --- /dev/null +++ b/packages/charts2/src/core/layout/charts/stackedDiscreteBar.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import type { ChartLayerOptions } from "./shared.ts" +import { layoutStackedBar } from "./stackedBar.ts" +import { layoutStackedDiscreteBar } from "./stackedDiscreteBar.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: true, thumbnail: false, fontScale: 1 } + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("stacked discrete bar negative offsets (OWID contract, spec 16)", () => { + it("gives a negative segment valueOffset 0 when it is the first negative", () => { + const ctx = ctxFor("pathological", { + y: ["spending", "negatives"], + selectedEntities: ["Québec"], + time: 2021, + types: ["stacked-discrete-bar"], + }) + const layer = layoutStackedDiscreteBar(ctx, AREA, OPTS) + const spending = layer.series.find((s) => s.key === "spending") + const negatives = layer.series.find((s) => s.key === "negatives") + expect(spending?.points[0].value).toBeCloseTo(2) // 110 ÷ 55 per person + expect(spending?.points[0].valueOffset).toBe(0) + expect(negatives?.points[0].value).toBeCloseTo(-6) + expect(negatives?.points[0].valueOffset).toBe(0) // negatives offset independently + }) +}) + +describe("stacked discrete bar partial entities (spec 16)", () => { + it("renders partial stacks with the missing segments flagged in the tooltip", () => { + const ctx = ctxFor("pathological", { + y: ["spending", "negatives"], + selectedEntities: ["Québec"], + time: 2023, // population 0 → spending per person missing + types: ["stacked-discrete-bar"], + }) + const layer = layoutStackedDiscreteBar(ctx, AREA, OPTS) + expect(layer.nodes.some((n) => n.key === "series/spending/bar/Québec")).toBe(false) + expect(layer.nodes.some((n) => n.key === "series/negatives/bar/Québec")).toBe(true) + const target = layer.hover.targets[0] + if (target.kind !== "series") return + const spendingRow = target.tooltip.rows.find((r) => r.seriesKey === "spending") + expect(spendingRow?.notice).toBe("missing") + expect(spendingRow?.valueText).toBe("No data") + }) + + it("excludes entities missing all metrics", () => { + const ctx = ctxFor("pathological", { + y: ["spending", "negatives"], + selectedEntities: ["Québec", "Î.-P.-É."], + time: 2022, // Québec has no 2022 row at all + types: ["stacked-discrete-bar"], + }) + const layer = layoutStackedDiscreteBar(ctx, AREA, OPTS) + expect(layer.nodes.some((n) => n.key === "label/Québec")).toBe(false) + expect(layer.nodes.some((n) => n.key === "label/Î.-P.-É.")).toBe(true) + }) +}) + +describe("stacked discrete bar relative mode (spec 16)", () => { + it("normalizes each bar by its absolute total and hides the total label", () => { + const ctx = ctxFor("government-debt", { + y: ["federal_debt", "provincial_debt", "municipal_debt"], + types: ["stacked-discrete-bar"], + stackMode: "relative", + }) + const layer = layoutStackedDiscreteBar(ctx, AREA, OPTS) + const sum = layer.series.reduce((acc, s) => acc + (s.points[0]?.value ?? 0), 0) + expect(sum).toBeCloseTo(100, 6) + expect(layer.nodes.some((n) => n.key.endsWith("/total"))).toBe(false) + }) +}) + +describe("stacked discrete bar sorting and totals", () => { + const raw = { + y: ["program_spending", "debt_charges"], + selectedEntities: ["Nova Scotia", "Ontario", "Quebec"], + time: "2023-24", + types: ["stacked-discrete-bar"], + } + + it("sorts entities by net total descending by default", () => { + const layer = layoutStackedDiscreteBar(ctxFor("provincial-budgets", raw), AREA, OPTS) + const labels = layer.nodes.filter((n) => n.kind === "text" && n.key.startsWith("label/")) + // Bands are laid out top-to-bottom in sorted order. + const sorted = labels + .map((n) => (n.kind === "text" ? { text: n.text, y: n.position.y } : null)) + .filter((v) => v !== null) + .sort((a, b) => a.y - b.y) + .map((v) => v.text) + expect(sorted).toEqual(["Ontario", "Quebec", "Nova Scotia"]) + }) + + it("shows total labels unless hideTotalLabel", () => { + const withTotals = layoutStackedDiscreteBar(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(withTotals.nodes.some((n) => n.key === "value/Ontario/total")).toBe(true) + const without = layoutStackedDiscreteBar( + ctxFor("provincial-budgets", { ...raw, hideTotalLabel: true }), + AREA, + OPTS, + ) + expect(without.nodes.some((n) => n.key.endsWith("/total"))).toBe(false) + }) + + it("provides a metric legend in metric order", () => { + const layer = layoutStackedDiscreteBar(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.legendItems.map((item) => item.seriesKey)).toEqual(["program_spending", "debt_charges"]) + expect(layer.legendItems.map((item) => item.label)).toEqual(["Program spending", "Debt charges"]) + }) +}) + +describe("stacked bar both-direction stacking (spec 15)", () => { + it("stacks mixed-sign series without negatives offsetting positives", () => { + const ctx = ctxFor("pathological", { + y: ["negatives"], + selectedEntities: ["Québec", "Î.-P.-É."], + types: ["stacked-bar"], + }) + const layer = layoutStackedBar(ctx, AREA, OPTS) + // Both series are negative: the second stacks below the first. + const quebec = layer.series.find((s) => s.key === "Québec") + const ipe = layer.series.find((s) => s.key === "Î.-P.-É.") + const at2020q = quebec?.points.find((p) => p.time === 2020) + const at2020i = ipe?.points.find((p) => p.time === 2020) + expect(at2020q?.valueOffset).toBe(0) + expect(at2020i?.valueOffset).toBeCloseTo(-5) + }) + + it("reports missing column values as 'No data' tooltip rows", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["program_spending"], + selectedEntities: ["Ontario", "Nova Scotia"], + types: ["stacked-bar"], + }) + const layer = layoutStackedBar(ctx, AREA, OPTS) + const target = layer.hover.targets.find((t) => t.kind === "time" && t.time === 2022) + const row = target?.tooltip.rows.find((r) => r.seriesKey === "Nova Scotia") + expect(row?.notice).toBe("missing") + // And no rect is drawn for the missing contribution. + expect(layer.nodes.some((n) => n.key === "series/Nova Scotia/bar/2022")).toBe(false) + }) + + it("relative mode sums each column to 100 using absolute weights", () => { + const ctx = ctxFor("government-debt", { + y: ["federal_debt", "provincial_debt", "municipal_debt"], + types: ["stacked-bar"], + stackMode: "relative", + }) + const layer = layoutStackedBar(ctx, AREA, OPTS) + for (const time of ctx.times) { + const sum = layer.series.reduce((acc, s) => acc + (s.points.find((p) => p.time === time)?.value ?? 0), 0) + expect(sum).toBeCloseTo(100, 6) + } + }) +}) diff --git a/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts b/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts new file mode 100644 index 00000000000..6a75e72ce87 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts @@ -0,0 +1,385 @@ +/** + * Stacked discrete bar chart layout (spec 16). + * + * One horizontal stacked bar per entity at a single target time; segments in + * metric order (series strategy is always metric). Entities missing ALL + * metrics are excluded; partial entities render partial stacks with the + * gaps flagged in the tooltip. Negative segments extend left of zero with + * offsets independent of the positives (OWID contract: the first negative + * segment has valueOffset 0). Relative mode normalizes each bar by its + * absolute total; the total label hides. + */ + +import { assignColours, createColourState } from "../../color/categoricalAssigner.ts" +import { resolveValue } from "../../data/derived.ts" +import { formatValue } from "../../format/number.ts" +import { formatTime } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint, TooltipRow } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { Diagnostic, ResolvedValue, SortConfig } from "../../types.ts" +import { horizontalValueAxisNodes, prepareValueAxis, PLOT_TOP_PAD } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { bandPositions, createValueScale } from "../scales.ts" +import { stackSeriesInBothDirections, type StackedSeries } from "../stacking.ts" +import { + buildFooters, + centeredBaseline, + collectFooterFlags, + compareStrings, + emptyLayer, + missingRow, + noteFooterFlags, + noticeFor, + seriesLabelFont, + strings, + textNode, + tickFont, + tooltipValueText, + valueLabelFont, + metaFor, + RELATIVE_META, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +const BAR_HEIGHT_FLOOR = 3 +const BAR_HEIGHT_MAX = 36 +const DEFAULT_SORT: SortConfig = { by: "total", order: "desc" } + +interface Cell { + slug: string + /** Absolute (pre-relative) resolved point; undefined when missing. */ + point?: SeriesPoint + /** Display value (share in relative mode); 0 when missing. */ + value: number + missing: boolean +} + +interface EntityBar { + entity: string + cells: Cell[] + /** Net display total. */ + total: number + /** Net absolute total. */ + absTotal: number + partial: boolean +} + +function toPoint(resolved: ResolvedValue, time: number | null): SeriesPoint | undefined { + if (resolved.status !== "value" || !Number.isFinite(resolved.value)) return undefined + return { + time, + value: resolved.value, + sourceTime: resolved.sourceTime, + ...(resolved.projected ? { projected: true } : {}), + ...(resolved.interpolated ? { interpolated: true } : {}), + } +} + +export function layoutStackedDiscreteBar(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const relative = ctx.stackMode === "relative" + const target = ctx.window?.end ?? null + + const diagnostics: Diagnostic[] = [] + const slugs = ctx.definition.y.filter((slug) => ctx.dataset.columns.has(slug)) + if (slugs.length === 0) return emptyLayer(area, diagnostics) + + // Metric → colour (column colour fixed, rest from the palette). + const fixed = new Map() + for (const slug of slugs) { + const colour = ctx.columns[slug]?.colour + if (colour !== undefined) fixed.set(slug, colour) + } + const colours = assignColours(createColourState(ctx.theme.palette.categorical), slugs, fixed) + const labelOf = (slug: string): string => ctx.columns[slug]?.name ?? slug + + // --- Resolve every (entity × metric) cell ----------------------------------- + let bars: EntityBar[] = [] + for (const entity of ctx.entities) { + const cells: Cell[] = slugs.map((slug) => { + const resolved = resolveValue(ctx.dataset, slug, entity, target, ctx.definition.bindings?.[slug]) + const point = toPoint(resolved, target) + return { slug, value: point?.value ?? 0, missing: point === undefined, ...(point !== undefined ? { point } : {}) } + }) + const presentCells = cells.filter((cell) => !cell.missing) + if (presentCells.length === 0) continue // missing ALL metrics → excluded + if (ctx.definition.missingData === "hide" && presentCells.length < cells.length) { + diagnostics.push({ + severity: "warning", + code: "entity-hidden-missing-data", + message: `Entity "${entity}" hidden: it is missing some metrics at the target time (missingData: hide)`, + context: { entity }, + }) + continue + } + const absTotal = presentCells.reduce((sum, cell) => sum + cell.value, 0) + bars.push({ entity, cells, total: absTotal, absTotal, partial: presentCells.length < cells.length }) + } + if (bars.length === 0) return emptyLayer(area, diagnostics) + + // Relative mode: each bar normalizes by its absolute total (spec 16). + if (relative) { + for (const bar of bars) { + const absSum = bar.cells.reduce((sum, cell) => sum + Math.abs(cell.value), 0) + for (const cell of bar.cells) cell.value = absSum > 0 ? (cell.value / absSum) * 100 : 0 + bar.total = bar.cells.reduce((sum, cell) => sum + (cell.missing ? 0 : cell.value), 0) + } + } + + // --- Sort --------------------------------------------------------------------- + const sort = ctx.definition.sort ?? DEFAULT_SORT + const direction = sort.order === "asc" ? 1 : -1 + switch (sort.by) { + case "name": + bars.sort((a, b) => direction * compareStrings(a.entity, b.entity)) + break + case "column": { + const slug = sort.column ?? slugs[0] + const valueOf = (bar: EntityBar): number => { + const cell = bar.cells.find((c) => c.slug === slug) + return cell !== undefined && !cell.missing ? cell.value : Number.NEGATIVE_INFINITY + } + bars.sort((a, b) => direction * (valueOf(a) - valueOf(b))) + break + } + case "custom": + break + case "total": + case "change": + default: + bars.sort((a, b) => direction * (a.total - b.total)) + break + } + + // --- Stack offsets (OWID both-directions contract) ---------------------------- + const stackedInput: StackedSeries[] = slugs.map((slug) => ({ + seriesKey: slug, + points: bars.map((bar, index) => { + const cell = bar.cells.find((c) => c.slug === slug) + return { + position: index, + time: target ?? 0, + value: cell !== undefined && !cell.missing ? cell.value : 0, + valueOffset: 0, + missing: cell === undefined || cell.missing, + } + }), + })) + const stacked = stackSeriesInBothDirections(stackedInput) + + // --- Geometry -------------------------------------------------------------------- + const labelFont = seriesLabelFont(scale) + const valueFont = valueLabelFont(scale) + const labelMaxWidth = Math.min( + Math.max(...bars.map((bar) => measurer.measure(bar.entity, labelFont).width)), + area.width * 0.3, + ) + const labelColWidth = labelMaxWidth + 6 + + const showTotals = !ctx.definition.hideTotalLabel && !relative + const totalTexts = new Map() + let rightReserve = 0 + if (showTotals) { + for (const bar of bars) { + const text = formatValue(bar.total, metaFor(ctx, slugs[0]), { locale, verbosity: "label" }) + totalTexts.set(bar.entity, text) + rightReserve = Math.max(rightReserve, measurer.measure(text, valueFont).width + 6) + } + } + + const axisFont = tickFont(scale) + const sample = measurer.measure("0", axisFont) + const axisHeight = sample.ascent + sample.descent + PLOT_TOP_PAD + 2 + + const plotArea: Rect = { + x: area.x + labelColWidth, + y: area.y + 4, + width: Math.max(10, area.width - labelColWidth - rightReserve), + height: Math.max(10, area.height - 4 - axisHeight), + } + + const extents = stacked.flatMap((s) => s.points.map((p) => p.value + p.valueOffset)) + const spec = prepareValueAxis({ + values: [0, ...extents], + markType: "bar", + scaleType: "linear", + config: ctx.definition.xAxis, + pixelLength: plotArea.width, + font: axisFont, + meta: relative ? RELATIVE_META : metaFor(ctx, slugs[0]), + locale, + measurer, + showSign: relative, + }) + diagnostics.push(...spec.diagnostics) + const xScale = createValueScale("linear", spec.domain, [plotArea.x, plotArea.x + plotArea.width]) + + const nodes: SceneNode[] = horizontalValueAxisNodes(spec, xScale, plotArea, area, { + theme, + font: axisFont, + hideGridlines: ctx.definition.xAxis?.hideGridlines, + hideTickLabels: ctx.definition.xAxis?.hideTickLabels, + }) + + // --- Rows ---------------------------------------------------------------------------- + const rows = bandPositions(bars.length, [plotArea.y, plotArea.y + plotArea.height], 1) + const barHeight = Math.min(Math.max((rows[0]?.width ?? plotArea.height) * 0.7, BAR_HEIGHT_FLOOR), BAR_HEIGHT_MAX) + const targets: HitTarget[] = [] + const t = strings(locale) + + const tooltipRowsFor = (bar: EntityBar, emphasizedSlug: string): TooltipRow[] => + bar.cells.map((cell) => { + if (cell.missing || cell.point === undefined) { + return missingRow(cell.slug, labelOf(cell.slug), colours.get(cell.slug) ?? theme.palette.noData, locale) + } + const absText = tooltipValueText(ctx, cell.slug, cell.point.value, false) + const notice = noticeFor(cell.point, target) + return { + seriesKey: cell.slug, + label: labelOf(cell.slug), + swatch: colours.get(cell.slug) ?? theme.palette.noData, + valueText: relative + ? `${formatValue(cell.value, RELATIVE_META, { locale, verbosity: "long" })} (${absText})` + : absText, + emphasized: cell.slug === emphasizedSlug, + ...(notice !== undefined ? { notice } : {}), + } + }) + + bars.forEach((bar, barIndex) => { + const row = rows[barIndex] + const barTop = row.center - barHeight / 2 + + // Entity label. + const rowLabel = truncateWithEllipsis(bar.entity, labelFont, Math.max(10, labelMaxWidth), measurer) + const rowLabelMetrics = measurer.measure(rowLabel, labelFont) + nodes.push( + textNode({ + key: `label/${bar.entity}`, + role: "label", + text: rowLabel, + font: labelFont, + anchor: "end", + x: area.x + labelColWidth - 6, + baselineY: centeredBaseline(row.center, rowLabelMetrics), + colour: theme.chrome.tickLabel, + measurer, + }), + ) + + // Segments in metric order. + let positiveExtent = 0 + for (const series of stacked) { + const point = series.points[barIndex] + if (point.missing === true || point.value === 0) continue + const x1 = xScale.place(point.valueOffset) + const x2 = xScale.place(point.value + point.valueOffset) + if (point.value > 0) positiveExtent = Math.max(positiveExtent, point.value + point.valueOffset) + const cell = bar.cells.find((c) => c.slug === series.seriesKey) + const segmentRect: Rect = { + x: Math.min(x1, x2), + y: barTop, + width: Math.max(Math.abs(x2 - x1), 0.5), + height: barHeight, + } + nodes.push({ + key: `series/${series.seriesKey}/bar/${bar.entity}`, + seriesKey: series.seriesKey, + role: "mark", + kind: "rect", + rect: segmentRect, + style: { + fill: colours.get(series.seriesKey) ?? theme.palette.noData, + ...(cell?.point?.projected === true ? { patternId: "projection", opacity: 0.85 } : {}), + }, + }) + + // Hover: one target per segment. + const flags = collectFooterFlags() + for (const c of bar.cells) noteFooterFlags(flags, c.point, target) + targets.push({ + kind: "series", + seriesKey: series.seriesKey, + shape: segmentRect, + tooltip: { + title: bar.entity, + ...(target !== null ? { titleAnnotation: formatTime(target, grain, locale) } : {}), + rows: tooltipRowsFor(bar, series.seriesKey), + ...(bar.cells.filter((c) => !c.missing).length >= 2 && !relative + ? { + totalRow: { + seriesKey: "total", + label: t.total, + swatch: theme.chrome.axisLine, + valueText: tooltipValueText(ctx, slugs[0], bar.total, false), + emphasized: true, + }, + } + : {}), + footers: buildFooters(flags, grain, locale), + }, + }) + } + + // Total label beyond the positive extent (spec 16 mixed-sign rule). + if (showTotals) { + const text = totalTexts.get(bar.entity) ?? "" + const metrics = measurer.measure(text, valueFont) + nodes.push( + textNode({ + key: `value/${bar.entity}/total`, + role: "label", + text, + font: valueFont, + anchor: "start", + x: xScale.place(Math.max(positiveExtent, 0)) + 4, + baselineY: centeredBaseline(row.center, metrics), + colour: theme.chrome.tickLabel, + measurer, + }), + ) + } + }) + + // --- Series models (one per metric, points in sorted-entity order) ----------- + const outSeries: SeriesModel[] = stacked.map((series) => { + const colour = colours.get(series.seriesKey) ?? theme.palette.noData + return { + key: series.seriesKey, + label: labelOf(series.seriesKey), + colour, + column: series.seriesKey, + points: series.points + .filter((p) => p.missing !== true) + .map((p) => { + const bar = bars[series.points.indexOf(p)] + const cell = bar?.cells.find((c) => c.slug === series.seriesKey) + return { + time: target, + value: p.value, + valueOffset: p.valueOffset, + ...(cell?.point?.sourceTime !== undefined ? { sourceTime: cell.point.sourceTime } : {}), + ...(cell?.point?.projected === true ? { projected: true } : {}), + } + }), + } + }) + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets }, + legendItems: slugs.map((slug) => ({ + seriesKey: slug, + label: labelOf(slug), + swatch: colours.get(slug) ?? theme.palette.noData, + })), + greyedLegendKeys: [], + needsLegendFallback: false, + empty: false, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/chooseType.test.ts b/packages/charts2/src/core/layout/chooseType.test.ts new file mode 100644 index 00000000000..9080dc24128 --- /dev/null +++ b/packages/charts2/src/core/layout/chooseType.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" + +import { activeChartType } from "./chooseType.ts" + +describe("activeChartType", () => { + it("uses the first type for an expanded window", () => { + expect(activeChartType(["line", "discrete-bar"], undefined, false)).toBe("line") + }) + + it("flips line to discrete-bar exactly when the window collapses", () => { + expect(activeChartType(["line", "discrete-bar"], undefined, true)).toBe("discrete-bar") + }) + + it("flips back to line when the window expands again", () => { + expect(activeChartType(["line", "discrete-bar"], { tab: "discrete-bar" }, false)).toBe("line") + }) + + it("keeps a lone line type even when collapsed", () => { + expect(activeChartType(["line"], undefined, true)).toBe("line") + }) + + it("pairs stacked-area with stacked-discrete-bar", () => { + expect(activeChartType(["stacked-area", "stacked-discrete-bar"], undefined, true)).toBe("stacked-discrete-bar") + expect(activeChartType(["stacked-area", "stacked-discrete-bar"], { tab: "stacked-discrete-bar" }, false)).toBe( + "stacked-area", + ) + }) + + it("honours the reader's tab when it needs no collapse", () => { + expect(activeChartType(["line", "stacked-area"], { tab: "stacked-area" }, false)).toBe("stacked-area") + }) + + it("honours defaultTab when no tab is in the view", () => { + expect(activeChartType(["line", "stacked-area"], undefined, false, "stacked-area")).toBe("stacked-area") + }) +}) diff --git a/packages/charts2/src/core/layout/chooseType.ts b/packages/charts2/src/core/layout/chooseType.ts new file mode 100644 index 00000000000..4b3973dadb6 --- /dev/null +++ b/packages/charts2/src/core/layout/chooseType.ts @@ -0,0 +1,53 @@ +/** + * Active chart type resolution (spec 08 §1). + * + * A multi-type definition behaves like OWID's [LineChart, DiscreteBar] pair: + * the range form when the window spans time, the single-time form when the + * window collapses to start === end — and back when it expands. The same + * collapse pairs stacked-area with stacked-discrete-bar. + */ + +import type { ChartType, Tab, ViewState } from "../types.ts" + +/** range form → single-time form when the window collapses. */ +const COLLAPSE_PAIR: Partial> = { + line: "discrete-bar", + "stacked-area": "stacked-discrete-bar", +} + +/** single-time form → range form when the window expands. */ +const EXPAND_PAIR: Partial> = { + "discrete-bar": "line", + "stacked-discrete-bar": "stacked-area", +} + +function isChartType(tab: Tab | undefined): tab is ChartType { + return tab !== undefined && tab !== "table" +} + +/** + * Pick the chart type to lay out, honouring the reader's tab, the author's + * defaultTab, and the line↔bar collapse at start === end. + */ +export function activeChartType( + types: readonly ChartType[], + view: ViewState | undefined, + collapsed: boolean, + defaultTab?: Tab, +): ChartType { + let requested: ChartType = + isChartType(view?.tab) && types.includes(view.tab) + ? view.tab + : isChartType(defaultTab) && types.includes(defaultTab) + ? defaultTab + : types[0] + + if (collapsed) { + const partner = COLLAPSE_PAIR[requested] + if (partner !== undefined && types.includes(partner)) requested = partner + } else { + const partner = EXPAND_PAIR[requested] + if (partner !== undefined && types.includes(partner)) requested = partner + } + return requested +} diff --git a/packages/charts2/src/core/layout/chrome.test.ts b/packages/charts2/src/core/layout/chrome.test.ts new file mode 100644 index 00000000000..343e81896ca --- /dev/null +++ b/packages/charts2/src/core/layout/chrome.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest" + +import { parseDefinition } from "../definition/schema.ts" +import type { ChartDefinition } from "../types.ts" +import { loadFixtureDataset } from "../../fixtures/index.ts" +import { defaultMeasurer } from "../text/createMeasurer.ts" +import { BUILD_CANADA_SQUARE_LOGO_DATA_URI, CANADA_SPENDS_LOGO_DATA_URI } from "../theme/logos.ts" +import { buildCanadaTheme, canadaSpendsTheme } from "../theme/themes.ts" +import { chartTitleText, layoutChrome } from "./chrome.ts" + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Government debt", data: "government-debt", y: ["federal_debt"], ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +const WINDOW = { start: 2019, end: 2023 } + +describe("title auto-annotations (spec 02 §1, spec 10 §2)", () => { + it("appends the entity when a single entity is shown and not already in the title", () => { + const title = chartTitleText({ + definition: definitionFor({}), + entities: ["Canada"], + window: WINDOW, + grain: "fiscal-year", + locale: "en", + relative: false, + }) + expect(title).toBe("Government debt, Canada, 2019–20 to 2023–24") + }) + + it("skips the entity annotation when it is already in the title or multiple entities show", () => { + const inTitle = chartTitleText({ + definition: definitionFor({ title: "Canada's government debt" }), + entities: ["Canada"], + window: WINDOW, + grain: "fiscal-year", + locale: "en", + relative: false, + }) + expect(inTitle).toBe("Canada's government debt, 2019–20 to 2023–24") + const multi = chartTitleText({ + definition: definitionFor({}), + entities: ["Ontario", "Quebec"], + window: WINDOW, + grain: "fiscal-year", + locale: "en", + relative: false, + }) + expect(multi).toBe("Government debt, 2019–20 to 2023–24") + }) + + it("collapses the time annotation for a single-time window", () => { + const title = chartTitleText({ + definition: definitionFor({}), + entities: ["Ontario", "Quebec"], + window: { start: 2023, end: 2023 }, + grain: "fiscal-year", + locale: "en", + relative: false, + }) + expect(title).toBe("Government debt, 2023–24") + }) + + it("prefixes 'Change in' in relative mode", () => { + const title = chartTitleText({ + definition: definitionFor({}), + entities: ["Canada"], + window: WINDOW, + grain: "fiscal-year", + locale: "en", + relative: true, + }) + expect(title).toBe("Change in Government debt, Canada, 2019–20 to 2023–24") + }) + + it("each annotation is independently suppressible", () => { + const definition = definitionFor({ + titleAnnotations: { entity: false, time: false, changePrefix: false }, + }) + const title = chartTitleText({ + definition, + entities: ["Canada"], + window: WINDOW, + grain: "fiscal-year", + locale: "en", + relative: true, + }) + expect(title).toBe("Government debt") + }) + + it("omits the time annotation for grain none", () => { + const title = chartTitleText({ + definition: definitionFor({}), + entities: ["Ontario", "Quebec"], + window: null, + grain: "none", + locale: "en", + relative: false, + }) + expect(title).toBe("Government debt") + }) +}) + +describe("chrome logo", () => { + const governmentDebt = loadFixtureDataset("government-debt").dataset + const base = { + definition: definitionFor({ subtitle: "Federal, provincial, and municipal debt" }), + manifest: governmentDebt.manifest, + locale: "en" as const, + measurer: defaultMeasurer, + size: { width: 850, height: 600 }, + mode: "full" as const, + fontScale: 1, + window: WINDOW, + grain: "year" as const, + entities: ["Canada"], + relative: false, + } + + it("always places the Build Canada square logo in the top-right chrome", () => { + const layout = layoutChrome({ ...base, theme: buildCanadaTheme }) + const logo = layout.nodes.find((node) => node.key === "chrome/logo/build-canada-square") + expect(logo).toMatchObject({ + kind: "image", + role: "chrome", + rect: { x: 786.4, y: 16, width: 47.6, height: 47.6 }, + href: BUILD_CANADA_SQUARE_LOGO_DATA_URI, + }) + expect(layout.contentArea.y).toBe(71.6) + }) + + it("uses the Canada Spends logo for the Canada Spends theme", () => { + const layout = layoutChrome({ ...base, theme: canadaSpendsTheme }) + const logo = layout.nodes.find((node) => node.key === "chrome/logo/canada-spends") + expect(logo).toMatchObject({ + kind: "image", + role: "chrome", + rect: { x: 679.0315789473684, y: 16, width: 154.96842105263158, height: 47.6 }, + href: CANADA_SPENDS_LOGO_DATA_URI, + }) + expect(layout.contentArea.y).toBe(71.6) + }) +}) diff --git a/packages/charts2/src/core/layout/chrome.ts b/packages/charts2/src/core/layout/chrome.ts new file mode 100644 index 00000000000..f3078d8637c --- /dev/null +++ b/packages/charts2/src/core/layout/chrome.ts @@ -0,0 +1,336 @@ +/** + * Frame chrome geometry (spec 10 §1–2): header (title + subtitle) and footer + * (source, note, attribution) text nodes, plus the content rectangle left + * for legend + plot. Interactive chrome components (tabs, controls, + * timeline) are M9's — this module lays out static text geometry only. + * + * Title auto-annotations per spec 02 §1: appended entity name (single entity + * not already in the title), appended time reflecting the current window, + * and a "Change in" prefix in relative mode — each suppressible via + * titleAnnotations. + */ + +import { formatTimeRange } from "../format/timeLabels.ts" +import type { Rect, SceneNode } from "../scene/nodes.ts" +import type { TextMeasurer } from "../text/measurer.ts" +import { shrinkToFit } from "../text/wrap.ts" +import { wrapText, LINE_HEIGHT } from "../text/wrap.ts" +import { truncateWithEllipsis } from "../text/truncate.ts" +import { + BUILD_CANADA_SQUARE_LOGO_ASPECT_RATIO, + BUILD_CANADA_SQUARE_LOGO_DATA_URI, + CANADA_SPENDS_LOGO_ASPECT_RATIO, + CANADA_SPENDS_LOGO_DATA_URI, +} from "../theme/logos.ts" +import type { Theme } from "../theme/types.ts" +import type { ChartDefinition, Locale, Manifest, TimeGrain } from "../types.ts" +import type { TimeWindow } from "./context.ts" +import { footerFont, strings, subtitleFont, titleFont } from "./charts/shared.ts" + +export type ChromeMode = "full" | "thumbnail" | "none" + +const TITLE_MAX_LINES = 2 +const TITLE_MIN_SIZE = 12 +const HEADER_GAP = 8 +const FOOTER_GAP = 4 +const FOOTER_TOP_GAP = 10 +const LOGO_GAP = 12 +const LOGO_LAYOUT_ITERATIONS = 5 + +// --------------------------------------------------------------------------- +// Title annotation (spec 02 §1) — exported for the spec 10 title tests +// --------------------------------------------------------------------------- + +export interface TitleTextInput { + definition: ChartDefinition + entities: readonly string[] + window: TimeWindow | null + grain: TimeGrain + locale: Locale + relative: boolean +} + +export function chartTitleText(input: TitleTextInput): string { + const { definition, entities, window, grain, locale, relative } = input + const annotations = definition.titleAnnotations + let title = definition.title + + if (relative && annotations.changePrefix) { + title = locale === "fr" ? `Évolution : ${title}` : `Change in ${title}` + } + if ( + annotations.entity && + entities.length === 1 && + !title.toLowerCase().includes(entities[0].toLowerCase()) + ) { + title = `${title}, ${entities[0]}` + } + if (annotations.time && window !== null && grain !== "none") { + title = `${title}, ${formatTimeRange(window.start, window.end, grain, locale)}` + } + return title +} + +// --------------------------------------------------------------------------- +// Frame layout +// --------------------------------------------------------------------------- + +export interface ChromeInput { + definition: ChartDefinition + manifest: Manifest + theme: Theme + locale: Locale + measurer: TextMeasurer + size: { width: number; height: number } + mode: ChromeMode + fontScale: number + window: TimeWindow | null + grain: TimeGrain + entities: readonly string[] + relative: boolean +} + +export interface ChromeLayout { + /** Area between header and footer where legend + plot live. */ + contentArea: Rect + nodes: SceneNode[] + titleText: string +} + +function sourceLineText(definition: ChartDefinition, manifest: Manifest, locale: Locale): string { + const text = + definition.sourceText ?? + manifest.sources + .map((source) => source.name) + .filter((name) => name !== "") + .join("; ") + return text === "" ? "" : `${strings(locale).source}: ${text}` +} + +export function layoutChrome(input: ChromeInput): ChromeLayout { + const { definition, manifest, theme, locale, measurer, size, mode, fontScale } = input + const padding = theme.chrome.padding + const innerX = padding.left + const innerWidth = Math.max(10, size.width - padding.left - padding.right) + const nodes: SceneNode[] = [] + + const titleText = chartTitleText({ + definition, + entities: input.entities, + window: input.window, + grain: input.grain, + locale, + relative: input.relative, + }) + + let cursorY = padding.top + + // --- Header --------------------------------------------------------------- + if (mode !== "none") { + const header = solveHeaderLayout({ + definition, + titleText, + mode, + fontScale, + innerWidth, + theme, + measurer, + }) + const { logoHeight, logoWidth, title, subtitle } = header + const logoX = innerX + innerWidth - logoWidth + nodes.push(brandLogoNode(theme, logoX, cursorY, logoWidth, logoHeight)) + + title.lines.forEach((line, index) => { + const metrics = measurer.measure(line, title.font) + nodes.push({ + key: `chrome/title/line-${index}`, + role: "chrome", + kind: "text", + position: { x: innerX, y: cursorY + index * LINE_HEIGHT * title.font.sizePx + metrics.ascent }, + text: line, + font: title.font, + anchor: "start", + colour: theme.chrome.title, + measured: metrics, + }) + }) + + if (subtitle !== undefined) { + subtitle.lines.forEach((line, index) => { + const metrics = measurer.measure(line, subtitle.font) + nodes.push({ + key: `chrome/subtitle/line-${index}`, + role: "chrome", + kind: "text", + position: { + x: innerX, + y: cursorY + subtitle.offsetY + index * LINE_HEIGHT * subtitle.font.sizePx + metrics.ascent, + }, + text: line, + font: subtitle.font, + anchor: "start", + colour: theme.chrome.subtitle, + measured: metrics, + }) + }) + } + cursorY += header.height + HEADER_GAP + } + + // --- Footer (bottom-up) ------------------------------------------------------ + const font = footerFont(fontScale) + const lineHeight = LINE_HEIGHT * font.sizePx + interface FooterLine { + key: string + text: string + anchor: "start" | "end" + } + const footerLines: FooterLine[] = [] + if (mode === "full") { + if (definition.note !== undefined && definition.note !== "") { + footerLines.push({ key: "chrome/note", text: definition.note, anchor: "start" }) + } + const source = sourceLineText(definition, manifest, locale) + if (source !== "") footerLines.push({ key: "chrome/source", text: source, anchor: "start" }) + } + if (mode !== "none" && theme.attribution.text !== "") { + footerLines.push({ key: "chrome/attribution", text: theme.attribution.text, anchor: "end" }) + } + + let footerTop = size.height - padding.bottom + if (footerLines.length > 0) { + footerTop -= footerLines.length * lineHeight + (footerLines.length - 1) * FOOTER_GAP + FOOTER_TOP_GAP + let lineY = footerTop + FOOTER_TOP_GAP + for (const line of footerLines) { + const text = truncateWithEllipsis(line.text, font, innerWidth, measurer) + const metrics = measurer.measure(text, font) + nodes.push({ + key: line.key, + role: "chrome", + kind: "text", + position: { + x: line.anchor === "end" ? innerX + innerWidth : innerX, + y: lineY + metrics.ascent, + }, + text, + font, + anchor: line.anchor, + colour: theme.chrome.tickLabel, + measured: metrics, + }) + lineY += lineHeight + FOOTER_GAP + } + } + + const contentArea: Rect = { + x: innerX, + y: cursorY, + width: innerWidth, + height: Math.max(10, footerTop - cursorY), + } + + return { contentArea, nodes, titleText } +} + +interface HeaderLayoutInput { + definition: ChartDefinition + titleText: string + mode: ChromeMode + fontScale: number + innerWidth: number + theme: Theme + measurer: TextMeasurer +} + +interface HeaderTextLayout { + logoHeight: number + logoWidth: number + height: number + title: { + lines: string[] + font: ReturnType + height: number + } + subtitle?: { + lines: string[] + font: ReturnType + height: number + offsetY: number + } +} + +function solveHeaderLayout(input: HeaderLayoutInput): HeaderTextLayout { + const aspectRatio = logoAspectRatio(input.theme) + let logoHeight = LINE_HEIGHT * titleFont(input.fontScale).sizePx + let layout = measureHeaderText(input, logoHeight * aspectRatio) + + for (let i = 0; i < LOGO_LAYOUT_ITERATIONS; i++) { + logoHeight = layout.height + const next = measureHeaderText(input, logoHeight * aspectRatio) + if (Math.abs(next.height - layout.height) < 0.01) { + layout = next + break + } + layout = next + } + + logoHeight = layout.height + return { + ...layout, + logoHeight, + logoWidth: logoHeight * aspectRatio, + } +} + +function measureHeaderText(input: HeaderLayoutInput, logoWidth: number): Omit { + const textWidth = Math.max(10, input.innerWidth - logoWidth - LOGO_GAP) + const title = shrinkToFit( + input.titleText, + titleFont(input.fontScale), + textWidth, + TITLE_MAX_LINES, + input.measurer, + TITLE_MIN_SIZE, + ) + const titleHeight = title.lines.length * LINE_HEIGHT * title.font.sizePx + + if (input.mode !== "full" || input.definition.subtitle === undefined || input.definition.subtitle === "") { + return { + height: titleHeight, + title: { lines: title.lines, font: title.font, height: titleHeight }, + } + } + + const subtitleFontSpec = subtitleFont(input.fontScale) + const subtitle = wrapText(input.definition.subtitle, subtitleFontSpec, textWidth, input.measurer) + return { + height: titleHeight + HEADER_GAP + subtitle.height, + title: { lines: title.lines, font: title.font, height: titleHeight }, + subtitle: { + lines: subtitle.lines, + font: subtitleFontSpec, + height: subtitle.height, + offsetY: titleHeight + HEADER_GAP, + }, + } +} + +function logoAspectRatio(theme: Theme): number { + return theme.branding.logo === "canada-spends" + ? CANADA_SPENDS_LOGO_ASPECT_RATIO + : BUILD_CANADA_SQUARE_LOGO_ASPECT_RATIO +} + +function brandLogoNode(theme: Theme, x: number, y: number, width: number, height: number): SceneNode { + return { + key: `chrome/logo/${theme.branding.logo}`, + role: "chrome", + kind: "image", + href: + theme.branding.logo === "canada-spends" + ? CANADA_SPENDS_LOGO_DATA_URI + : BUILD_CANADA_SQUARE_LOGO_DATA_URI, + rect: { x, y, width, height }, + preserveAspectRatio: "xMidYMid meet", + } +} diff --git a/packages/charts2/src/core/layout/context.ts b/packages/charts2/src/core/layout/context.ts new file mode 100644 index 00000000000..41c67b9d5c2 --- /dev/null +++ b/packages/charts2/src/core/layout/context.ts @@ -0,0 +1,136 @@ +/** + * LayoutContext — the resolved inputs every layout stage consumes. + * + * buildContext merges the reader's ViewState over the definition's defaults + * (view.time ?? definition.time, view.entities ?? resolved selection, …), + * resolves grain-encoded time bounds, merges column bindings, and snaps the + * time window to times present in the data (spec 08 §1). Pure: no I/O, no + * mutation of inputs. + */ + +import { snapToAvailable } from "../data/time.ts" +import { resolveBindings, resolveSelection } from "../definition/resolve.ts" +import { resolveDefinitionTimes } from "../definition/schema.ts" +import type { TextMeasurer } from "../text/measurer.ts" +import type { Theme } from "../theme/types.ts" +import type { + ChartDefinition, + ColumnMeta, + Dataset, + Diagnostic, + Locale, + ScaleType, + StackMode, + TimeBound, + TimeGrain, + TimeOrdinal, + ViewState, +} from "../types.ts" + +export interface TimeWindow { + start: TimeOrdinal + end: TimeOrdinal +} + +export interface LayoutContext { + /** Definition with grain-encoded time bounds resolved to ordinals. */ + definition: ChartDefinition + dataset: Dataset + view: ViewState + theme: Theme + locale: Locale + measurer: TextMeasurer + /** Manifest column meta with per-binding overrides applied, by slug. */ + columns: Record + /** Effective entity selection (view.entities over the resolved default). */ + entities: string[] + /** Dataset times filtered to the selected window (empty for grain "none"). */ + times: TimeOrdinal[] + /** Snapped selection window; null for grain "none" or an empty dataset. */ + window: TimeWindow | null + grain: TimeGrain + /** Effective stack/relative mode (view over definition). */ + stackMode: StackMode + /** Effective y scale (view over definition yAxis config). */ + scaleType: ScaleType + /** True when the window is a single time (drives line↔bar collapse). */ + collapsed: boolean + diagnostics: Diagnostic[] +} + +export interface BuildContextArgs { + definition: ChartDefinition + dataset: Dataset + view?: ViewState + theme: Theme + measurer: TextMeasurer +} + +function resolveBound(bound: TimeBound, times: readonly TimeOrdinal[]): TimeOrdinal | null { + if (times.length === 0) return null + if (bound === "earliest") return times[0] + if (bound === "latest") return times[times.length - 1] + if (typeof bound === "number") return snapToAvailable(bound, times) + return null +} + +export function buildContext({ definition, dataset, view = {}, theme, measurer }: BuildContextArgs): LayoutContext { + const diagnostics: Diagnostic[] = [] + const grain = dataset.manifest.timeGrain + + const resolvedTimes = resolveDefinitionTimes(definition, grain) + diagnostics.push(...resolvedTimes.diagnostics) + const def = resolvedTimes.definition + + const bindings = resolveBindings(def, dataset.manifest) + diagnostics.push(...bindings.diagnostics) + + let entities: string[] + if (view.entities !== undefined) { + const known = new Set(dataset.entities) + entities = view.entities.filter((name) => known.has(name)) + } else { + const selection = resolveSelection(def, dataset) + diagnostics.push(...selection.diagnostics) + entities = selection.entities + } + + let window: TimeWindow | null = null + let times: TimeOrdinal[] = [] + if (grain !== "none" && dataset.times.length > 0) { + const selection = view.time ?? def.time ?? { start: "earliest" as const, end: "latest" as const } + let start = resolveBound(selection.start, dataset.times) + let end = resolveBound(selection.end, dataset.times) + if (start !== null && end !== null) { + if (start > end) { + const swap = start + start = end + end = swap + } + window = { start, end } + const lo = start + const hi = end + times = dataset.times.filter((t) => t >= lo && t <= hi) + } + } + + const collapsed = grain === "none" || window === null || window.start === window.end + + return { + definition: def, + dataset, + view, + theme, + locale: def.locale ?? theme.localeDefault, + measurer, + columns: bindings.columns, + entities, + times, + window, + grain, + stackMode: view.stackMode ?? def.stackMode, + scaleType: view.yScale ?? def.yAxis?.scale ?? "linear", + collapsed, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/declutter.test.ts b/packages/charts2/src/core/layout/declutter.test.ts new file mode 100644 index 00000000000..e3a2137cfc4 --- /dev/null +++ b/packages/charts2/src/core/layout/declutter.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" + +import { declutterLabels, LABEL_SPACING, type LabelCandidate } from "./declutter.ts" + +function candidate(key: string, targetY: number, priority: number, height = 14, width = 60): LabelCandidate { + return { seriesKey: key, text: key, targetY, priority, width, height } +} + +function assertNoOverlap(placed: { y: number; height: number }[]): void { + const sorted = [...placed].sort((a, b) => a.y - b.y) + for (let i = 1; i < sorted.length; i++) { + expect(sorted[i].y + 0.001).toBeGreaterThanOrEqual(sorted[i - 1].y + sorted[i - 1].height) + } +} + +describe("declutterLabels", () => { + it("keeps non-colliding labels at their target positions", () => { + const { placed, dropped } = declutterLabels( + [candidate("a", 50, 1), candidate("b", 200, 2), candidate("c", 350, 3)], + 0, + 400, + ) + expect(dropped).toEqual([]) + expect(placed.map((p) => p.y)).toEqual([43, 193, 343]) + }) + + it("nudges eight colliding labels apart with no overlaps, all inside the range", () => { + const candidates = Array.from({ length: 8 }, (_, i) => candidate(`s${i}`, 100 + i * 0.5, i + 1)) + const { placed, dropped } = declutterLabels(candidates, 0, 400) + expect(dropped).toEqual([]) + expect(placed.length).toBe(8) + assertNoOverlap(placed) + for (const label of placed) { + expect(label.y).toBeGreaterThanOrEqual(0) + expect(label.y + label.height).toBeLessThanOrEqual(400) + } + }) + + it("drops the lowest-priority (smallest final value) labels when space runs out", () => { + // Range fits only 3 labels of height 14 (+ spacing). + const available = 3 * 14 + 2 * LABEL_SPACING + const candidates = [ + candidate("small", 10, 1), + candidate("mid", 20, 5), + candidate("big", 30, 10), + candidate("bigger", 40, 20), + candidate("tiny", 50, 0.5), + ] + const { placed, dropped } = declutterLabels(candidates, 0, available) + expect(placed.map((p) => p.seriesKey).sort()).toEqual(["big", "bigger", "mid"]) + expect(dropped).toEqual(["small", "tiny"]) + assertNoOverlap(placed) + }) + + it("is deterministic", () => { + const candidates = Array.from({ length: 10 }, (_, i) => candidate(`s${i}`, 120, 10 - i)) + const first = declutterLabels(candidates, 0, 300) + const second = declutterLabels(candidates, 0, 300) + expect(second).toEqual(first) + }) + + it("clamps single labels into the range", () => { + const { placed } = declutterLabels([candidate("edge", 0, 1)], 0, 100) + expect(placed[0].y).toBe(0) + const bottom = declutterLabels([candidate("edge", 100, 1)], 0, 100) + expect(bottom.placed[0].y + bottom.placed[0].height).toBeLessThanOrEqual(100) + }) +}) diff --git a/packages/charts2/src/core/layout/declutter.ts b/packages/charts2/src/core/layout/declutter.ts new file mode 100644 index 00000000000..f396d5872bc --- /dev/null +++ b/packages/charts2/src/core/layout/declutter.ts @@ -0,0 +1,171 @@ +/** + * End-of-line series label decluttering — port of owid-grapher + * verticalLabels/VerticalLabelsState.ts placement (candidate at the series' + * final y, vertical group-merge nudging) and a simplified filter pass + * (VerticalLabelsFilterAlgorithms): when the labels can't all fit in the + * available height, the lowest-priority candidates (smallest final value) + * are dropped — the caller falls back to a legend. + * + * Deterministic: ties resolve by input order; no randomness. + */ + +import type { SeriesKey } from "../types.ts" + +export const LABEL_SPACING = 4 + +export interface LabelCandidate { + seriesKey: SeriesKey + text: string + /** Ideal vertical center (the series' final point y). */ + targetY: number + /** Importance: larger keeps the label longer (the series' final value). */ + priority: number + width: number + height: number +} + +export interface PlacedLabel { + seriesKey: SeriesKey + text: string + /** Top of the label box after collision resolution. */ + y: number + targetY: number + width: number + height: number +} + +export interface DeclutterResult { + placed: PlacedLabel[] + /** Series whose labels did not fit — legend fallback signal. */ + dropped: SeriesKey[] +} + +interface Group { + labels: PlacedLabel[] +} + +function groupTop(group: Group): number { + return group.labels[0].y +} + +function groupBottom(group: Group): number { + const last = group.labels[group.labels.length - 1] + return last.y + last.height +} + +function stackGroup(group: Group, y: number): void { + let currentY = y + for (const label of group.labels) { + label.y = currentY + currentY += label.height + LABEL_SPACING + } +} + +function totalHeight(labels: readonly { height: number }[]): number { + if (labels.length === 0) return 0 + return labels.reduce((sum, l) => sum + l.height, 0) + (labels.length - 1) * LABEL_SPACING +} + +/** + * Place labels in [y0, y1] without overlaps. + * + * 1. Filter: drop lowest-priority candidates until the total stacked height + * fits the available space. + * 2. Place: start each label centred on its targetY (clamped into range), + * then iteratively merge overlapping neighbour groups, positioning each + * merged group at the size-weighted compromise of its members and + * re-stacking with even spacing (OWID's group-merge loop). + */ +export function declutterLabels(candidates: readonly LabelCandidate[], y0: number, y1: number): DeclutterResult { + const available = Math.max(0, y1 - y0) + + // --- Filter pass: keep highest-priority labels that fit ----------------- + const indexed = candidates.map((candidate, index) => ({ candidate, index })) + const byPriority = [...indexed].sort( + (a, b) => b.candidate.priority - a.candidate.priority || a.index - b.index, + ) + const keep: typeof indexed = [] + const dropped: SeriesKey[] = [] + let usedHeight = 0 + for (const entry of byPriority) { + const padding = keep.length === 0 ? 0 : LABEL_SPACING + const next = usedHeight + padding + entry.candidate.height + if (next <= available) { + keep.push(entry) + usedHeight = next + } else { + dropped.push(entry.candidate.seriesKey) + } + } + // Report drops in input order for determinism. + dropped.sort( + (a, b) => + indexed.findIndex((e) => e.candidate.seriesKey === a) - + indexed.findIndex((e) => e.candidate.seriesKey === b), + ) + + // --- Placement pass ----------------------------------------------------- + const sorted = [...keep].sort((a, b) => a.candidate.targetY - b.candidate.targetY || a.index - b.index) + const groups: Group[] = sorted.map(({ candidate }) => { + const clampedY = Math.min(Math.max(candidate.targetY - candidate.height / 2, y0), Math.max(y0, y1 - candidate.height)) + return { + labels: [ + { + seriesKey: candidate.seriesKey, + text: candidate.text, + y: clampedY, + targetY: candidate.targetY, + width: candidate.width, + height: candidate.height, + }, + ], + } + }) + + let hasOverlap = true + while (hasOverlap && groups.length > 1) { + hasOverlap = false + for (let i = 0; i < groups.length - 1; i++) { + const top = groups[i] + const bottom = groups[i + 1] + if (groupBottom(top) + LABEL_SPACING > groupTop(bottom)) { + const overlapHeight = groupBottom(top) - groupTop(bottom) + LABEL_SPACING + const topHeight = groupBottom(top) - groupTop(top) + const bottomHeight = groupBottom(bottom) - groupTop(bottom) + const newHeight = topHeight + LABEL_SPACING + bottomHeight + const targetY = + groupTop(top) - + overlapHeight * (bottom.labels.length / (top.labels.length + bottom.labels.length)) + const overflowTop = Math.max(y0 - targetY, 0) + const overflowBottom = Math.max(targetY + newHeight - y1, 0) + const newY = targetY + overflowTop - overflowBottom + const merged: Group = { labels: [...top.labels, ...bottom.labels] } + stackGroup(merged, newY) + groups.splice(i, 2, merged) + hasOverlap = true + break + } + } + } + + const placed = groups.flatMap((group) => group.labels) + // Final safety clamp: when the kept labels exactly fill the range the + // merge maths can leave sub-pixel overflow at the edges. + if (placed.length > 0) { + const height = totalHeight(placed) + if (height <= y1 - y0) { + const first = placed[0] + if (first.y < y0) { + const shift = y0 - first.y + for (const label of placed) label.y += shift + } + const last = placed[placed.length - 1] + const overflow = last.y + last.height - y1 + if (overflow > 0) { + for (const label of placed) label.y -= overflow + } + } + } + + return { placed, dropped } +} diff --git a/packages/charts2/src/core/layout/index.ts b/packages/charts2/src/core/layout/index.ts new file mode 100644 index 00000000000..9c5e8f9e427 --- /dev/null +++ b/packages/charts2/src/core/layout/index.ts @@ -0,0 +1,19 @@ +// Layout core (M6): (definition, dataset, view, theme, size, measurer) → +// ChartScene. Specs 03, 05, 08, 10–16, 26. + +export * from "./axis.ts" +export * from "./charts/discreteBar.ts" +export * from "./charts/line.ts" +export * from "./charts/shared.ts" +export * from "./charts/stackedArea.ts" +export * from "./charts/stackedBar.ts" +export * from "./charts/stackedDiscreteBar.ts" +export * from "./chooseType.ts" +export * from "./chrome.ts" +export * from "./context.ts" +export * from "./declutter.ts" +export * from "./layoutChart.ts" +export * from "./legend.ts" +export * from "./scales.ts" +export * from "./series.ts" +export * from "./stacking.ts" diff --git a/packages/charts2/src/core/layout/layoutChart.test.ts b/packages/charts2/src/core/layout/layoutChart.test.ts new file mode 100644 index 00000000000..6f9757c1ae1 --- /dev/null +++ b/packages/charts2/src/core/layout/layoutChart.test.ts @@ -0,0 +1,293 @@ +/** + * layoutChart integration matrix (spec 26): every fixture × every applicable + * chart type × three sizes must produce a scene with no NaN coordinates, + * every text node inside the scene bounds, unique stable keys, and + * deterministic, snapshot-stable JSON. + */ + +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../fixtures/index.ts" +import { parseDefinition } from "../definition/schema.ts" +import type { ChartScene, SceneNode } from "../scene/nodes.ts" +import { defaultMeasurer } from "../text/createMeasurer.ts" +import { buildCanadaTheme } from "../theme/themes.ts" +import type { ChartDefinition, ChartType, ViewState } from "../types.ts" +import type { ChromeMode } from "./chrome.ts" +import { layoutChart } from "./layoutChart.ts" + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function sceneFor( + fixture: FixtureName, + raw: Record, + size: { width: number; height: number }, + chrome: ChromeMode = "full", + view: ViewState = {}, +): ChartScene { + const { dataset } = loadFixtureDataset(fixture) + return layoutChart({ + definition: definitionFor(raw), + dataset, + view, + theme: buildCanadaTheme, + size, + measurer: defaultMeasurer, + chrome, + }) +} + +// --------------------------------------------------------------------------- +// Invariant helpers +// --------------------------------------------------------------------------- + +function assertNoNaNDeep(value: unknown, path: string): void { + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error(`Non-finite number at ${path}: ${value}`) + return + } + if (Array.isArray(value)) { + value.forEach((entry, index) => assertNoNaNDeep(entry, `${path}[${index}]`)) + return + } + if (typeof value === "object" && value !== null) { + for (const [key, entry] of Object.entries(value)) assertNoNaNDeep(entry, `${path}.${key}`) + } +} + +function collectNodes(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => (node.kind === "group" ? [node, ...collectNodes(node.children)] : [node])) +} + +function assertTextInBounds(scene: ChartScene): void { + const tolerance = 1 + for (const node of collectNodes(scene.nodes)) { + if (node.kind !== "text") continue + const { width } = node.measured + const left = node.anchor === "start" ? node.position.x : node.anchor === "end" ? node.position.x - width : node.position.x - width / 2 + const right = left + width + const top = node.position.y - node.measured.ascent + const bottom = node.position.y + node.measured.descent + expect(left, `${node.key} left`).toBeGreaterThanOrEqual(-tolerance) + expect(right, `${node.key} right`).toBeLessThanOrEqual(scene.width + tolerance) + expect(top, `${node.key} top`).toBeGreaterThanOrEqual(-tolerance) + expect(bottom, `${node.key} bottom`).toBeLessThanOrEqual(scene.height + tolerance) + } +} + +function assertUniqueKeys(scene: ChartScene): void { + const keys = collectNodes(scene.nodes).map((node) => node.key) + const seen = new Set() + for (const key of keys) { + expect(seen.has(key), `duplicate node key: ${key}`).toBe(false) + seen.add(key) + } +} + +/** FNV-1a over the JSON serialization — small, snapshot-stable fingerprints. */ +function fingerprint(scene: ChartScene): string { + const text = JSON.stringify(scene) + let hash = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash.toString(16).padStart(8, "0") +} + +// --------------------------------------------------------------------------- +// The matrix +// --------------------------------------------------------------------------- + +interface MatrixCase { + name: string + fixture: FixtureName + raw: Record +} + +const DEBT_Y = ["federal_debt", "provincial_debt", "municipal_debt"] + +const CASES: MatrixCase[] = [ + { name: "provincial-budgets line", fixture: "provincial-budgets", raw: { y: ["total_spending"], types: ["line"] } }, + { + name: "provincial-budgets discrete-bar", + fixture: "provincial-budgets", + raw: { y: ["total_spending"], types: ["discrete-bar"] }, + }, + { + name: "provincial-budgets stacked-area", + fixture: "provincial-budgets", + raw: { y: ["program_spending", "debt_charges"], selectedEntities: ["Ontario"], types: ["stacked-area"] }, + }, + { + name: "provincial-budgets stacked-bar", + fixture: "provincial-budgets", + raw: { y: ["program_spending", "debt_charges"], selectedEntities: ["Ontario"], types: ["stacked-bar"] }, + }, + { + name: "provincial-budgets stacked-discrete-bar", + fixture: "provincial-budgets", + raw: { y: ["program_spending", "debt_charges"], types: ["stacked-discrete-bar"] }, + }, + { name: "federal-departments line", fixture: "federal-departments", raw: { y: ["spending"], types: ["line"] } }, + { + name: "federal-departments discrete-bar", + fixture: "federal-departments", + raw: { y: ["spending"], types: ["discrete-bar"] }, + }, + { + name: "federal-departments stacked-area", + fixture: "federal-departments", + raw: { y: ["spending"], types: ["stacked-area"], selectedEntities: ["National Defence", "Health Canada"] }, + }, + { + name: "federal-departments stacked-bar", + fixture: "federal-departments", + raw: { y: ["spending"], types: ["stacked-bar"], selectedEntities: ["National Defence", "Health Canada"] }, + }, + { + name: "population-snapshot discrete-bar", + fixture: "population-snapshot", + raw: { y: ["population"], types: ["discrete-bar"] }, + }, + { + name: "population-snapshot stacked-discrete-bar", + fixture: "population-snapshot", + raw: { y: ["population", "median_age"], types: ["stacked-discrete-bar"] }, + }, + { name: "government-debt line", fixture: "government-debt", raw: { y: DEBT_Y, types: ["line"] } }, + { name: "government-debt discrete-bar", fixture: "government-debt", raw: { y: DEBT_Y, types: ["discrete-bar"] } }, + { name: "government-debt stacked-area", fixture: "government-debt", raw: { y: DEBT_Y, types: ["stacked-area"] } }, + { name: "government-debt stacked-bar", fixture: "government-debt", raw: { y: DEBT_Y, types: ["stacked-bar"] } }, + { + name: "government-debt stacked-discrete-bar", + fixture: "government-debt", + raw: { y: DEBT_Y, types: ["stacked-discrete-bar"] }, + }, + { name: "pathological line", fixture: "pathological", raw: { y: ["spending"], types: ["line"] } }, + { name: "pathological discrete-bar", fixture: "pathological", raw: { y: ["negatives"], types: ["discrete-bar"] } }, + { name: "pathological stacked-bar", fixture: "pathological", raw: { y: ["negatives"], types: ["stacked-bar"] } }, + { name: "pathological huge line", fixture: "pathological", raw: { y: ["huge"], types: ["line"] } }, +] + +const SIZES: { label: string; width: number; height: number; chrome: ChromeMode }[] = [ + { label: "thumbnail 300x160", width: 300, height: 160, chrome: "thumbnail" }, + { label: "default 850x600", width: 850, height: 600, chrome: "full" }, + { label: "wide 1200x600", width: 1200, height: 600, chrome: "full" }, +] + +describe("layoutChart matrix: every fixture × applicable type × three sizes", () => { + const fingerprints: Record = {} + + for (const testCase of CASES) { + for (const size of SIZES) { + it(`${testCase.name} @ ${size.label} is finite, in-bounds, unique-keyed, deterministic`, () => { + const scene = sceneFor(testCase.fixture, testCase.raw, size, size.chrome) + assertNoNaNDeep(scene, "scene") + assertTextInBounds(scene) + assertUniqueKeys(scene) + expect(scene.width).toBe(size.width) + expect(scene.plotArea.width).toBeGreaterThan(0) + expect(scene.plotArea.height).toBeGreaterThan(0) + + const again = sceneFor(testCase.fixture, testCase.raw, size, size.chrome) + expect(again).toEqual(scene) + + fingerprints[`${testCase.name} @ ${size.label}`] = fingerprint(scene) + }) + } + } + + it("scene JSON is snapshot-stable across runs", () => { + expect(fingerprints).toMatchSnapshot() + }) +}) + +describe("layoutChart behaviours", () => { + it("renders the no-data panel for an empty selection instead of throwing", () => { + const scene = sceneFor("provincial-budgets", { y: ["total_spending"], selectedEntities: [] }, { width: 850, height: 600 }) + const message = scene.nodes.find((n) => n.key === "chrome/no-data") + expect(message?.kind).toBe("text") + if (message?.kind === "text") expect(message.text).toBe("No data for the current selection") + expect(scene.series).toEqual([]) + expect(scene.hover.targets).toEqual([]) + }) + + it("renders the no-data panel for a stacked area with negative inputs, carrying the error diagnostic", () => { + const scene = sceneFor("pathological", { y: ["negatives"], types: ["stacked-area"] }, { width: 850, height: 600 }) + expect(scene.diagnostics.some((d) => d.code === "negative-values-in-stacked-area")).toBe(true) + expect(scene.nodes.some((n) => n.key === "chrome/no-data")).toBe(true) + }) + + it("collapses line to discrete-bar at start === end and restores on expand", () => { + const raw = { y: ["total_spending"], types: ["line", "discrete-bar"] } + const collapsed = sceneFor("provincial-budgets", raw, { width: 850, height: 600 }, "full", { + time: { start: 2023, end: 2023 }, + }) + expect(collapsed.nodes.some((n) => n.key.endsWith("/bar"))).toBe(true) + expect(collapsed.nodes.some((n) => n.key.endsWith("/line"))).toBe(false) + const expanded = sceneFor("provincial-budgets", raw, { width: 850, height: 600 }, "full", { + time: { start: 2019, end: 2023 }, + }) + expect(expanded.nodes.some((n) => n.key.endsWith("/line"))).toBe(true) + expect(expanded.nodes.some((n) => n.key.endsWith("/bar"))).toBe(false) + }) + + it("shows a legend for stacked bars over time and stacked discrete bars", () => { + const stackedBar = sceneFor( + "government-debt", + { y: DEBT_Y, types: ["stacked-bar"] }, + { width: 850, height: 600 }, + ) + expect(stackedBar.legend?.map((item) => item.seriesKey)).toEqual(DEBT_Y) + expect(stackedBar.nodes.some((n) => n.key === "legend/federal_debt/swatch")).toBe(true) + const line = sceneFor("government-debt", { y: DEBT_Y, types: ["line"] }, { width: 850, height: 600 }) + expect(line.legend).toBeUndefined() + }) + + it("falls back to a legend when hideSeriesLabels suppresses direct labels", () => { + const scene = sceneFor( + "government-debt", + { y: DEBT_Y, types: ["line"], hideSeriesLabels: true }, + { width: 850, height: 600 }, + ) + expect(scene.legend).toBeDefined() + expect(scene.nodes.some((n) => n.key.startsWith("legend/"))).toBe(true) + }) + + it("thumbnail chrome renders title + plot + attribution only", () => { + const scene = sceneFor("government-debt", { y: DEBT_Y, types: ["line"] }, { width: 300, height: 160 }, "thumbnail") + expect(scene.nodes.some((n) => n.key.startsWith("chrome/title"))).toBe(true) + expect(scene.nodes.some((n) => n.key === "chrome/attribution")).toBe(true) + expect(scene.nodes.some((n) => n.key === "chrome/source")).toBe(false) + expect(scene.nodes.some((n) => n.key.startsWith("chrome/subtitle"))).toBe(false) + }) + + it("chrome none renders the plot only", () => { + const scene = sceneFor("government-debt", { y: DEBT_Y, types: ["line"] }, { width: 850, height: 600 }, "none") + expect(scene.nodes.some((n) => n.role === "chrome")).toBe(false) + expect(scene.nodes.some((n) => n.role === "mark")).toBe(true) + }) + + it("aggregates diagnostics from every stage", () => { + const scene = sceneFor( + "pathological", + { y: ["spending"], types: ["line"], yAxis: { scale: "log" }, selectedEntities: ["Québec"] }, + { width: 850, height: 600 }, + ) + assertNoNaNDeep(scene, "scene") + }) + + it("precomputes tooltip models on hover targets (M9 consumes them as data)", () => { + const scene = sceneFor("government-debt", { y: DEBT_Y, types: ["line"] }, { width: 850, height: 600 }) + expect(scene.hover.targets.length).toBe(5) + const target = scene.hover.targets[0] + if (target.kind !== "time") return + expect(target.tooltip.rows.length).toBe(3) + expect(target.tooltip.rows[0].valueText).toContain("%") + }) +}) diff --git a/packages/charts2/src/core/layout/layoutChart.ts b/packages/charts2/src/core/layout/layoutChart.ts new file mode 100644 index 00000000000..689c1832a87 --- /dev/null +++ b/packages/charts2/src/core/layout/layoutChart.ts @@ -0,0 +1,263 @@ +/** + * layoutChart — THE layout entry point (spec 28 architecture). + * + * (definition, dataset, view, theme, size, measurer) → ChartScene. + * Orchestrates: buildContext → chooseType → chrome → legend → axes + chart + * layout → assembly. Every coordinate passes through round2; node keys are + * stable (derived from region/series/value, never bare array indices over + * data); diagnostics aggregate from every stage. An empty selection or a + * window with no data yields a "No data for the current selection" scene + * (spec 07 §4) — never a throw. + */ + +import type { ChartScene, HitTarget, HoverModel, Rect, SceneNode, Vec2 } from "../scene/nodes.ts" +import { round2 } from "../scene/nodes.ts" +import { defaultMeasurer } from "../text/createMeasurer.ts" +import type { TextMeasurer } from "../text/measurer.ts" +import { getTheme } from "../theme/registry.ts" +import type { Theme } from "../theme/types.ts" +import { truncateWithEllipsis } from "../text/truncate.ts" +import type { ChartDefinition, ChartType, Dataset, Diagnostic, ViewState } from "../types.ts" +import { layoutDiscreteBar } from "./charts/discreteBar.ts" +import { layoutLineChart } from "./charts/line.ts" +import { layoutStackedArea } from "./charts/stackedArea.ts" +import { layoutStackedBar } from "./charts/stackedBar.ts" +import { layoutStackedDiscreteBar } from "./charts/stackedDiscreteBar.ts" +import { + centeredBaseline, + fontScaleFor, + legendFont, + noDataFont, + strings, + textNode, + type ChartLayer, + type ChartLayerOptions, +} from "./charts/shared.ts" +import { activeChartType } from "./chooseType.ts" +import { layoutChrome, type ChromeMode } from "./chrome.ts" +import { buildContext, type LayoutContext } from "./context.ts" +import { layoutLegend, type LegendLayout } from "./legend.ts" + +export interface LayoutChartOptions { + definition: ChartDefinition + dataset: Dataset + view?: ViewState + /** Defaults to the registry lookup of definition.theme. */ + theme?: Theme + size: { width: number; height: number } + /** Defaults to the committed brand metrics measurer. */ + measurer?: TextMeasurer + chrome?: ChromeMode +} + +type ChartLayoutFn = (ctx: LayoutContext, area: Rect, opts: ChartLayerOptions) => ChartLayer + +const CHART_LAYOUTS: Record = { + line: layoutLineChart, + "discrete-bar": layoutDiscreteBar, + "stacked-area": layoutStackedArea, + "stacked-bar": layoutStackedBar, + "stacked-discrete-bar": layoutStackedDiscreteBar, +} + +/** Spec 05 §1: when a legend is planned before the chart is laid out. */ +function legendPlanned(chartType: ChartType, definition: ChartDefinition, mode: ChromeMode): boolean { + if (definition.hideLegend || mode === "thumbnail" || mode === "none") return false + if (chartType === "stacked-bar" || chartType === "stacked-discrete-bar") return true + if (definition.hideSeriesLabels && (chartType === "line" || chartType === "stacked-area")) return true + return false +} + +export function layoutChart(options: LayoutChartOptions): ChartScene { + const { definition, dataset, view, size } = options + const theme = options.theme ?? getTheme(definition.theme).theme + const measurer = options.measurer ?? defaultMeasurer + const mode: ChromeMode = options.chrome ?? "full" + const fontScale = fontScaleFor(size.width) + + const ctx = buildContext({ definition, dataset, view, theme, measurer }) + const diagnostics: Diagnostic[] = [...ctx.diagnostics] + if (options.theme === undefined) { + const lookup = getTheme(definition.theme) + if (lookup.warning !== undefined) { + diagnostics.push({ severity: "warning", code: "unknown-theme", message: lookup.warning }) + } + } + + const chartType = activeChartType(ctx.definition.types, view, ctx.collapsed, ctx.definition.defaultTab) + + const chrome = layoutChrome({ + definition: ctx.definition, + manifest: dataset.manifest, + theme, + locale: ctx.locale, + measurer, + size, + mode, + fontScale, + window: ctx.window, + grain: ctx.grain, + entities: ctx.entities, + relative: ctx.stackMode === "relative", + }) + + // --- No data before chart layout: empty selection / empty window ---------- + const noTimes = ctx.grain !== "none" && ctx.times.length === 0 + if (ctx.entities.length === 0 || noTimes) { + return noDataScene(ctx, size, theme, chrome.nodes, chrome.contentArea, fontScale, diagnostics) + } + + // --- Chart layout, with the legend two-pass ------------------------------- + const run = CHART_LAYOUTS[chartType] + let wantLegend = legendPlanned(chartType, ctx.definition, mode) + const baseOpts: ChartLayerOptions = { legendReserved: wantLegend, thumbnail: mode === "thumbnail", fontScale } + let layer = run(ctx, chrome.contentArea, baseOpts) + + if (!wantLegend && layer.needsLegendFallback && !ctx.definition.hideLegend && mode === "full") { + wantLegend = true + layer = run(ctx, chrome.contentArea, { ...baseOpts, legendReserved: true }) + } + + let legendLayout: LegendLayout | null = null + if (wantLegend && !layer.empty && layer.legendItems.length > 0) { + legendLayout = layoutLegend({ + items: layer.legendItems, + x: chrome.contentArea.x, + y: chrome.contentArea.y, + width: chrome.contentArea.width, + theme, + measurer, + font: legendFont(fontScale), + greyedKeys: layer.greyedLegendKeys, + }) + const chartArea: Rect = { + x: chrome.contentArea.x, + y: chrome.contentArea.y + legendLayout.height, + width: chrome.contentArea.width, + height: Math.max(10, chrome.contentArea.height - legendLayout.height), + } + layer = run(ctx, chartArea, { ...baseOpts, legendReserved: true }) + } + + diagnostics.push(...layer.diagnostics) + + if (layer.empty) { + return noDataScene(ctx, size, theme, chrome.nodes, chrome.contentArea, fontScale, diagnostics) + } + + const nodes: SceneNode[] = [ + ...chrome.nodes, + ...(legendLayout !== null ? legendLayout.nodes : []), + ...layer.nodes, + ] + + return { + width: size.width, + height: size.height, + background: theme.chrome.background, + plotArea: roundRect(layer.plotArea), + nodes: nodes.map(roundNode), + series: layer.series, + ...(legendLayout !== null ? { legend: legendLayout.items } : {}), + hover: roundHover(layer.hover), + diagnostics, + } +} + +// --------------------------------------------------------------------------- +// No-data scene (spec 07 §4) +// --------------------------------------------------------------------------- + +function noDataScene( + ctx: LayoutContext, + size: { width: number; height: number }, + theme: Theme, + chromeNodes: SceneNode[], + contentArea: Rect, + fontScale: number, + diagnostics: Diagnostic[], +): ChartScene { + const font = noDataFont(fontScale) + const message = truncateWithEllipsis(strings(ctx.locale).noDataPanel, font, contentArea.width, ctx.measurer) + const metrics = ctx.measurer.measure(message, font) + const node = textNode({ + key: "chrome/no-data", + role: "annotation", + text: message, + font, + anchor: "middle", + x: contentArea.x + contentArea.width / 2, + baselineY: centeredBaseline(contentArea.y + contentArea.height / 2, metrics), + colour: theme.chrome.subtitle, + measurer: ctx.measurer, + }) + return { + width: size.width, + height: size.height, + background: theme.chrome.background, + plotArea: roundRect(contentArea), + nodes: [...chromeNodes, node].map(roundNode), + series: [], + hover: { targets: [] }, + diagnostics, + } +} + +// --------------------------------------------------------------------------- +// Deterministic rounding (spec 24 §3): every coordinate through round2 +// --------------------------------------------------------------------------- + +function roundVec(v: Vec2): Vec2 { + return { x: round2(v.x), y: round2(v.y) } +} + +function roundRect(rect: Rect): Rect { + return { x: round2(rect.x), y: round2(rect.y), width: round2(rect.width), height: round2(rect.height) } +} + +function roundNode(node: SceneNode): SceneNode { + switch (node.kind) { + case "group": + return { + ...node, + children: node.children.map(roundNode), + ...(node.clip !== undefined ? { clip: roundRect(node.clip) } : {}), + } + case "line": + return { ...node, segments: node.segments.map((segment) => segment.map(roundVec)) } + case "area": + return { ...node, upper: node.upper.map(roundVec), lower: node.lower.map(roundVec) } + case "image": + return { ...node, rect: roundRect(node.rect) } + case "rect": + return { ...node, rect: roundRect(node.rect) } + case "point": + return { ...node, center: roundVec(node.center), radius: round2(node.radius) } + case "rule": + return { ...node, from: roundVec(node.from), to: roundVec(node.to) } + case "text": + return { + ...node, + position: roundVec(node.position), + measured: { + width: round2(node.measured.width), + ascent: round2(node.measured.ascent), + descent: round2(node.measured.descent), + }, + } + } +} + +function roundHover(hover: HoverModel): HoverModel { + const targets: HitTarget[] = hover.targets.map((target) => + target.kind === "time" + ? { ...target, x: round2(target.x) } + : { ...target, shape: roundRect(target.shape) }, + ) + return { + targets, + ...(hover.timeGuide !== undefined + ? { timeGuide: { y0: round2(hover.timeGuide.y0), y1: round2(hover.timeGuide.y1) } } + : {}), + } +} diff --git a/packages/charts2/src/core/layout/legend.ts b/packages/charts2/src/core/layout/legend.ts new file mode 100644 index 00000000000..2a252d2fbd4 --- /dev/null +++ b/packages/charts2/src/core/layout/legend.ts @@ -0,0 +1,97 @@ +/** + * Categorical legend layout (spec 05). + * + * Horizontal rows of swatch + label above the plot, wrapping as needed; + * order matches series order (which matches stacking/sort order). Greyed + * items (zero-throughout stacked bands) render with the noData swatch and + * dimmed labels. Legend nodes carry seriesKey so hover emphasis works + * through the same model as the marks. + */ + +import type { LegendItem, SceneNode } from "../scene/nodes.ts" +import type { FontSpec, TextMeasurer } from "../text/measurer.ts" +import { truncateWithEllipsis } from "../text/truncate.ts" +import type { Theme } from "../theme/types.ts" +import type { SeriesKey } from "../types.ts" + +const SWATCH_SIZE = 10 +const SWATCH_GAP = 6 +const ITEM_GAP = 20 +const ROW_GAP = 6 +const BOTTOM_GAP = 8 + +export interface LegendLayoutInput { + items: readonly LegendItem[] + x: number + y: number + width: number + theme: Theme + measurer: TextMeasurer + font: FontSpec + greyedKeys?: readonly SeriesKey[] +} + +export interface LegendLayout { + nodes: SceneNode[] + items: LegendItem[] + /** Total height consumed, including the gap below the legend. */ + height: number +} + +export function layoutLegend(input: LegendLayoutInput): LegendLayout { + const { items, x, y, width, theme, measurer, font, greyedKeys = [] } = input + if (items.length === 0) return { nodes: [], items: [], height: 0 } + + const greyed = new Set(greyedKeys) + const rowHeight = Math.max(font.sizePx * 1.2, SWATCH_SIZE + 2) + const nodes: SceneNode[] = [] + + let cursorX = x + let cursorY = y + for (const item of items) { + const maxLabelWidth = Math.max(20, width - SWATCH_SIZE - SWATCH_GAP) + const label = truncateWithEllipsis(item.label, font, maxLabelWidth, measurer) + const metrics = measurer.measure(label, font) + const itemWidth = SWATCH_SIZE + SWATCH_GAP + metrics.width + + if (cursorX + itemWidth > x + width && cursorX > x) { + cursorX = x + cursorY += rowHeight + ROW_GAP + } + + const centerY = cursorY + rowHeight / 2 + const isGreyed = greyed.has(item.seriesKey) + nodes.push({ + key: `legend/${item.seriesKey}/swatch`, + seriesKey: item.seriesKey, + role: "label", + kind: "rect", + rect: { x: cursorX, y: centerY - SWATCH_SIZE / 2, width: SWATCH_SIZE, height: SWATCH_SIZE }, + style: { fill: isGreyed ? theme.palette.noData : item.swatch }, + }) + nodes.push({ + key: `legend/${item.seriesKey}/label`, + seriesKey: item.seriesKey, + role: "label", + kind: "text", + position: { + x: cursorX + SWATCH_SIZE + SWATCH_GAP, + y: centerY + (metrics.ascent - metrics.descent) / 2, + }, + text: label, + font, + anchor: "start", + colour: theme.chrome.tickLabel, + measured: metrics, + ...(isGreyed ? { opacity: 0.6 } : {}), + }) + + cursorX += itemWidth + ITEM_GAP + } + + return { + nodes, + items: [...items], + height: cursorY + rowHeight + BOTTOM_GAP - y, + } +} diff --git a/packages/charts2/src/core/layout/scales.ts b/packages/charts2/src/core/layout/scales.ts new file mode 100644 index 00000000000..975e65793c4 --- /dev/null +++ b/packages/charts2/src/core/layout/scales.ts @@ -0,0 +1,176 @@ +/** + * Scales and domains (spec 03 §1–2). + * + * Domain rules: bar-like marks always include zero; line marks include zero + * unless released with min: "auto"; manual min/max override; nice extension + * never exceeds ~25% of a tick step beyond the data (ported from + * owid-grapher axis/Axis.ts makeScaleNice). Log scales exclude non-positive + * values and report how many were excluded. + */ + +import { scaleLinear, scaleLog } from "d3-scale" + +import type { AxisConfig, Diagnostic, ScaleType } from "../types.ts" + +export type MarkType = "bar" | "line" + +/** Spec 03 §3: target tick count adapts to pixel length, roughly 6 at default size. */ +export function targetTickCount(pixelLength: number, fontSizePx: number): number { + const raw = Math.round(pixelLength / (fontSizePx * 1.8)) + return Math.min(6, Math.max(2, raw)) +} + +// --------------------------------------------------------------------------- +// Nice domains (port of AbstractAxis.makeScaleNice) +// --------------------------------------------------------------------------- + +export interface NiceDomainResult { + domain: [number, number] + ticks: number[] +} + +/** + * Extend a linear domain to round tick values. The extension never exceeds + * 25% of one tick step beyond the data (otherwise the data edge is kept and + * the outermost tick sits inside the domain). + */ +export function niceLinearDomain(min: number, max: number, targetTicks: number): NiceDomainResult { + if (min === max) return { domain: [min, max], ticks: [min] } + + const scale = scaleLinear().domain([min, max]) + let ticks = scale.ticks(targetTicks) + + if (ticks.length < 2) { + const nice = scale.nice(targetTicks) + const domain = nice.domain() as [number, number] + return { domain, ticks: nice.ticks(targetTicks) } + } + + const step = ticks[1] - ticks[0] + const first = ticks[0] + const last = ticks[ticks.length - 1] + + let lo = min + let hi = max + if (max > last + 0.25 * step) { + hi = last + step + ticks = [...ticks, hi] + } + if (min < first - 0.25 * step) { + lo = first - step + ticks = [lo, ...ticks] + } + return { domain: [lo, hi], ticks } +} + +// --------------------------------------------------------------------------- +// Value domains (spec 03 §2) +// --------------------------------------------------------------------------- + +export interface DomainInput { + values: readonly number[] + markType: MarkType + scaleType: ScaleType + config?: AxisConfig +} + +export interface DomainResult { + min: number + max: number + /** Count of values excluded by a log scale (0 on linear). */ + excludedCount: number + diagnostics: Diagnostic[] +} + +export function computeValueDomain({ values, markType, scaleType, config }: DomainInput): DomainResult { + const diagnostics: Diagnostic[] = [] + let usable = values.filter((v) => Number.isFinite(v)) + let excludedCount = 0 + + if (scaleType === "log") { + const positive = usable.filter((v) => v > 0) + excludedCount = usable.length - positive.length + if (excludedCount > 0) { + diagnostics.push({ + severity: "warning", + code: "log-excluded-values", + message: `${excludedCount} non-positive value${excludedCount === 1 ? "" : "s"} excluded from the log scale`, + context: { count: excludedCount }, + }) + } + usable = positive + } + + let min = usable.length > 0 ? Math.min(...usable) : scaleType === "log" ? 1 : 0 + let max = usable.length > 0 ? Math.max(...usable) : scaleType === "log" ? 10 : 1 + + if (scaleType !== "log") { + const releaseZero = markType === "line" && config?.min === "auto" + if (!releaseZero) { + min = Math.min(min, 0) + max = Math.max(max, 0) + } + } + + if (typeof config?.min === "number") min = config.min + if (typeof config?.max === "number") max = config.max + if (min > max) { + const swap = min + min = max + max = swap + } + + return { min, max, excludedCount, diagnostics } +} + +// --------------------------------------------------------------------------- +// Placement +// --------------------------------------------------------------------------- + +/** Single-value domain alignment (spec 03 §2): no degenerate axes. */ +export type SingleValueAlign = "start" | "middle" | "end" + +export interface ValueScale { + type: ScaleType + domain: [number, number] + range: [number, number] + place: (value: number) => number +} + +export function createValueScale( + type: ScaleType, + domain: [number, number], + range: [number, number], + align: SingleValueAlign = "middle", +): ValueScale { + if (domain[0] === domain[1]) { + const position = align === "start" ? range[0] : align === "end" ? range[1] : (range[0] + range[1]) / 2 + return { type, domain, range, place: () => position } + } + const scale = (type === "log" ? scaleLog() : scaleLinear()).domain(domain).range(range) + return { type, domain, range, place: (v: number) => scale(v) } +} + +// --------------------------------------------------------------------------- +// Band positions for categorical rows/columns +// --------------------------------------------------------------------------- + +export interface Band { + start: number + center: number + width: number +} + +/** Equal-width slots across a range; bands occupy innerRatio of each slot. */ +export function bandPositions(count: number, range: [number, number], innerRatio = 0.7): Band[] { + if (count <= 0) return [] + const span = range[1] - range[0] + const slot = span / count + const width = Math.abs(slot) * innerRatio + const bands: Band[] = [] + for (let i = 0; i < count; i++) { + const center = range[0] + (i + 0.5) * slot + bands.push({ start: center - width / 2, center, width }) + } + return bands +} diff --git a/packages/charts2/src/core/layout/series.test.ts b/packages/charts2/src/core/layout/series.test.ts new file mode 100644 index 00000000000..a494fd732b8 --- /dev/null +++ b/packages/charts2/src/core/layout/series.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../fixtures/index.ts" +import { parseDefinition } from "../definition/schema.ts" +import { defaultMeasurer } from "../text/createMeasurer.ts" +import { buildCanadaTheme } from "../theme/themes.ts" +import type { ChartDefinition, ViewState } from "../types.ts" +import { buildContext, type LayoutContext } from "./context.ts" +import { buildSeriesModels, toRelativeLineSeries, toShareOfTotalSeries } from "./series.ts" + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("series strategy truth table (spec 11)", () => { + it("one metric → each entity is a series", () => { + const ctx = ctxFor("provincial-budgets", { y: ["total_spending"], selectedEntities: ["Ontario", "Quebec"] }) + const { series, strategy } = buildSeriesModels(ctx, "line") + expect(strategy).toBe("entity") + expect(series.map((s) => s.key)).toEqual(["Ontario", "Quebec"]) + }) + + it("multiple metrics, one entity → each metric is a series", () => { + const ctx = ctxFor("government-debt", { y: ["federal_debt", "provincial_debt", "municipal_debt"] }) + const { series, strategy } = buildSeriesModels(ctx, "line") + expect(strategy).toBe("metric") + expect(series.map((s) => s.key)).toEqual(["federal_debt", "provincial_debt", "municipal_debt"]) + expect(series.map((s) => s.label)).toEqual(["Federal debt", "Provincial debt", "Municipal debt"]) + }) + + it("multiple metrics × multiple entities → 'Entity – Metric' series", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending", "program_spending"], + selectedEntities: ["Ontario", "Quebec"], + }) + const { series } = buildSeriesModels(ctx, "line") + expect(series.map((s) => s.key)).toEqual([ + "Ontario – total_spending", + "Ontario – program_spending", + "Quebec – total_spending", + "Quebec – program_spending", + ]) + expect(series[0].label).toBe("Ontario – Total spending") + }) + + it("definition.seriesStrategy overrides the heuristic", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending", "program_spending"], + selectedEntities: ["Ontario", "Quebec"], + seriesStrategy: "entity", + }) + const { series, strategy } = buildSeriesModels(ctx, "line") + expect(strategy).toBe("entity") + expect(series.map((s) => s.key)).toEqual(["Ontario", "Quebec"]) + }) + + it("stacked-discrete-bar always uses metric series", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario"], + seriesStrategy: "entity", + }) + const { strategy } = buildSeriesModels(ctx, "stacked-discrete-bar") + expect(strategy).toBe("metric") + }) +}) + +describe("buildSeriesModels data handling", () => { + it("reads every value through resolveValue and carries sourceTime for borrowed cells", () => { + const ctx = ctxFor("provincial-budgets", { y: ["debt_charges"], selectedEntities: ["Quebec"] }) + const { series } = buildSeriesModels(ctx, "line") + const borrowed = series[0].points.find((p) => p.time === 2024) + expect(borrowed).toBeDefined() + expect(borrowed?.sourceTime).toBe(2023) // tolerance 2 borrows from 2023-24 + expect(borrowed?.value).toBeCloseTo(9.3) + }) + + it("missing values never appear as zero points", () => { + const ctx = ctxFor("provincial-budgets", { y: ["program_spending"], selectedEntities: ["Nova Scotia"] }) + const { series } = buildSeriesModels(ctx, "line") + expect(series[0].points.some((p) => p.time === 2022)).toBe(false) + expect(series[0].points.length).toBe(5) + }) + + it("missingData hide drops gapped series with a warning", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["program_spending"], + selectedEntities: ["Ontario", "Nova Scotia"], + missingData: "hide", + }) + const { series, diagnostics } = buildSeriesModels(ctx, "line") + expect(series.map((s) => s.key)).toEqual(["Ontario"]) + expect(diagnostics.some((d) => d.code === "series-hidden-missing-data")).toBe(true) + }) + + it("applies fixed entity colours ahead of palette assignment", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario", "Quebec"], + entityColours: { Ontario: "#123456" }, + }) + const { series } = buildSeriesModels(ctx, "line") + expect(series[0].colour).toBe("#123456") + expect(series[1].colour).not.toBe("#123456") + }) + + it("supports datasets without a time dimension", () => { + const ctx = ctxFor("population-snapshot", { y: ["population"], selectedEntities: ["Ontario", "Yukon"] }) + const { series } = buildSeriesModels(ctx, "discrete-bar") + expect(series.length).toBe(2) + expect(series[0].points[0].time).toBe(null) + expect(series[0].points[0].value).toBe(15608000) + }) +}) + +describe("relative transforms", () => { + it("line relative mode rebases to cumulative % change since the window start", () => { + const ctx = ctxFor("government-debt", { y: ["federal_debt"] }) + const { series } = buildSeriesModels(ctx, "line") + const { series: relative } = toRelativeLineSeries(series) + // federal % of GDP: 50, 60, 51, 50, 48 → 0, +20, +2, 0, −4 + const values = relative[0].points.map((p) => p.value) + expect(values[0]).toBeCloseTo(0) + expect(values[1]).toBeCloseTo(20) + expect(values[2]).toBeCloseTo(2) + expect(values[3]).toBeCloseTo(0) + expect(values[4]).toBeCloseTo(-4) + }) + + it("hides zero-base series in relative mode instead of dividing by zero", () => { + const series = [ + { key: "a", label: "a", colour: "#000", points: [{ time: 1, value: 0 }, { time: 2, value: 5 }] }, + ] + const { series: out, diagnostics } = toRelativeLineSeries(series) + expect(out).toEqual([]) + expect(diagnostics[0]?.code).toBe("relative-zero-base") + }) + + it("share-of-total uses absolute weights and preserves sign", () => { + const series = [ + { key: "a", label: "a", colour: "#000", points: [{ time: 1, value: 30 }] }, + { key: "b", label: "b", colour: "#000", points: [{ time: 1, value: -10 }] }, + ] + const out = toShareOfTotalSeries(series) + expect(out[0].points[0].value).toBeCloseTo(75) + expect(out[1].points[0].value).toBeCloseTo(-25) + }) +}) diff --git a/packages/charts2/src/core/layout/series.ts b/packages/charts2/src/core/layout/series.ts new file mode 100644 index 00000000000..5cdc43fecc3 --- /dev/null +++ b/packages/charts2/src/core/layout/series.ts @@ -0,0 +1,217 @@ +/** + * Series construction (spec 11 "Data consumed", spec 26 §1.2). + * + * Series strategy truth table: + * - one metric → each entity is a series + * - >1 metrics → each metric is a series (per selected entity); with + * multiple entities × multiple metrics, series are "Entity – Metric" + * - definition.seriesStrategy overrides; stacked-discrete-bar is always + * metric series (spec 16). + * + * EVERY value is read through resolveValue — missing is never zero, and + * sourceTime/projected travel onto SeriesPoint for downstream marking. + */ + +import { resolveValue } from "../data/derived.ts" +import type { SeriesModel, SeriesPoint } from "../scene/nodes.ts" +import { assignColours, createColourState } from "../color/categoricalAssigner.ts" +import type { + ChartType, + Diagnostic, + HexColour, + SeriesKey, + SeriesStrategy, + TimeOrdinal, +} from "../types.ts" +import type { LayoutContext } from "./context.ts" + +/** "Entity – Metric" separator (en dash, spaced). */ +export const SERIES_KEY_SEPARATOR = " – " + +export function resolveSeriesStrategy(ctx: LayoutContext, chartType: ChartType): SeriesStrategy { + if (chartType === "stacked-discrete-bar") return "metric" + if (ctx.definition.seriesStrategy !== undefined) return ctx.definition.seriesStrategy + return ctx.definition.y.length > 1 ? "metric" : "entity" +} + +export interface BuildSeriesResult { + series: SeriesModel[] + strategy: SeriesStrategy + diagnostics: Diagnostic[] +} + +interface SeriesDef { + key: SeriesKey + label: string + entity?: string + column: string +} + +function seriesDefsFor(ctx: LayoutContext, strategy: SeriesStrategy): SeriesDef[] { + const slugs = ctx.definition.y.filter((slug) => ctx.dataset.columns.has(slug)) + if (slugs.length === 0) return [] + + if (strategy === "entity") { + const slug = slugs[0] + return ctx.entities.map((entity) => ({ key: entity, label: entity, entity, column: slug })) + } + + // Metric series: per selected entity × metric. Single entity keeps bare + // metric keys; multiple entities produce "Entity – Metric". + const defs: SeriesDef[] = [] + const multiEntity = ctx.entities.length > 1 + for (const entity of ctx.entities) { + for (const slug of slugs) { + const name = ctx.columns[slug]?.name ?? slug + defs.push({ + key: multiEntity ? `${entity}${SERIES_KEY_SEPARATOR}${slug}` : slug, + label: multiEntity ? `${entity}${SERIES_KEY_SEPARATOR}${name}` : name, + entity, + column: slug, + }) + } + } + return defs +} + +/** + * Fixed-colour map for assignColours, in the precedence: per-chart + * entityColours → column colour (metric series) → registry entity colour. + */ +function fixedColours(ctx: LayoutContext, defs: readonly SeriesDef[], strategy: SeriesStrategy): Map { + const registry = new Map() + for (const entity of ctx.dataset.manifest.entities ?? []) { + if (entity.colour !== undefined) registry.set(entity.name, entity.colour) + } + const fixed = new Map() + for (const def of defs) { + const entityColour = def.entity !== undefined ? ctx.definition.entityColours?.[def.entity] : undefined + const columnColour = strategy === "metric" ? ctx.columns[def.column]?.colour : undefined + const registryColour = def.entity !== undefined ? registry.get(def.entity) : undefined + const colour = entityColour ?? columnColour ?? registryColour + if (colour !== undefined) fixed.set(def.key, colour) + } + return fixed +} + +export function buildSeriesModels(ctx: LayoutContext, chartType: ChartType): BuildSeriesResult { + const diagnostics: Diagnostic[] = [] + const strategy = resolveSeriesStrategy(ctx, chartType) + const defs = seriesDefsFor(ctx, strategy) + + const pointTimes: (TimeOrdinal | null)[] = ctx.grain === "none" ? [null] : ctx.times + const expectedCount = pointTimes.length + + const built: SeriesModel[] = [] + for (const def of defs) { + if (def.entity === undefined) continue + const overrides = ctx.definition.bindings?.[def.column] + const points: SeriesPoint[] = [] + for (const time of pointTimes) { + const resolved = resolveValue(ctx.dataset, def.column, def.entity, time, overrides) + if (resolved.status !== "value" || !Number.isFinite(resolved.value)) continue + points.push({ + time, + value: resolved.value, + sourceTime: resolved.sourceTime, + ...(resolved.projected ? { projected: true } : {}), + ...(resolved.interpolated ? { interpolated: true } : {}), + }) + } + if (points.length === 0) continue + if (ctx.definition.missingData === "hide" && points.length < expectedCount) { + diagnostics.push({ + severity: "warning", + code: "series-hidden-missing-data", + message: `Series "${def.label}" hidden: it is missing data in the selected window (missingData: hide)`, + context: { series: def.key }, + }) + continue + } + built.push({ + key: def.key, + label: def.label, + colour: "#000000", + entity: def.entity, + column: def.column, + points, + }) + } + + const fixed = fixedColours(ctx, defs, strategy) + const state = createColourState(ctx.theme.palette.categorical) + const colours = assignColours( + state, + built.map((s) => s.key), + fixed, + ) + for (const series of built) { + series.colour = colours.get(series.key) ?? series.colour + } + + return { series: built, strategy, diagnostics } +} + +// --------------------------------------------------------------------------- +// Relative-mode transforms +// --------------------------------------------------------------------------- + +export interface RelativeSeriesResult { + series: SeriesModel[] + diagnostics: Diagnostic[] +} + +/** + * Line relative mode (spec 11): cumulative % change since the first point in + * the window. Series whose base value is 0 have no defined change and are + * hidden with a warning — never shown as 0. + */ +export function toRelativeLineSeries(seriesList: readonly SeriesModel[]): RelativeSeriesResult { + const out: SeriesModel[] = [] + const diagnostics: Diagnostic[] = [] + for (const series of seriesList) { + if (series.points.length === 0) { + out.push(series) + continue + } + const base = series.points[0].value + if (base === 0) { + diagnostics.push({ + severity: "warning", + code: "relative-zero-base", + message: `Series "${series.label}" starts at 0 in the selected window; relative change is undefined so it is hidden`, + context: { series: series.key }, + }) + continue + } + out.push({ + ...series, + points: series.points.map((point) => ({ + ...point, + value: ((point.value - base) / Math.abs(base)) * 100, + })), + }) + } + return { series: out, diagnostics } +} + +/** + * Stacked relative mode (specs 14/15/16): share of the per-time total using + * absolute-value weights, sign preserved. + */ +export function toShareOfTotalSeries(seriesList: readonly SeriesModel[]): SeriesModel[] { + const totals = new Map() + for (const series of seriesList) { + for (const point of series.points) { + if (point.time === null) continue + totals.set(point.time, (totals.get(point.time) ?? 0) + Math.abs(point.value)) + } + } + return seriesList.map((series) => ({ + ...series, + points: series.points.map((point) => { + const total = point.time !== null ? (totals.get(point.time) ?? 0) : 0 + return { ...point, value: total > 0 ? (point.value / total) * 100 : 0 } + }), + })) +} diff --git a/packages/charts2/src/core/layout/stacking.test.ts b/packages/charts2/src/core/layout/stacking.test.ts new file mode 100644 index 00000000000..029fc60c328 --- /dev/null +++ b/packages/charts2/src/core/layout/stacking.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest" + +import { + stackSeries, + stackSeriesInBothDirections, + withMissingValuesAsZeroes, + withUniformSpacing, + type StackedSeries, +} from "./stacking.ts" + +// Fixtures ported from owid-grapher StackedUtils.test.ts. +const seriesArr = (): StackedSeries[] => [ + { + seriesKey: "Canada", + points: [ + { position: 2000, time: 2000, value: 10, valueOffset: 0 }, + { position: 2002, time: 2002, value: 12, valueOffset: 0 }, + ], + }, + { + seriesKey: "USA", + points: [{ position: 2000, time: 2000, value: 2, valueOffset: 0 }], + }, + { + seriesKey: "France", + points: [ + { position: 2000, time: 2000, value: 6, valueOffset: 0 }, + { position: 2003, time: 2003, value: 4, valueOffset: 0 }, + ], + }, +] + +const seriesArrWithNegativeValues = (): StackedSeries[] => [ + { + seriesKey: "Canada", + points: [ + { position: 2000, time: 2000, value: -10, valueOffset: 0 }, + { position: 2002, time: 2002, value: 12, valueOffset: 0 }, + ], + }, + { + seriesKey: "USA", + points: [{ position: 2000, time: 2000, value: 2, valueOffset: 0 }], + }, + { + seriesKey: "France", + points: [ + { position: 2000, time: 2000, value: -6, valueOffset: 0 }, + { position: 2002, time: 2002, value: -4, valueOffset: 0 }, + ], + }, +] + +describe("withUniformSpacing", () => { + it("can add values to make an array evenly spaced", () => { + expect(withUniformSpacing([])).toEqual([]) + expect(withUniformSpacing([5])).toEqual([5]) + expect(withUniformSpacing([5, 10])).toEqual([5, 10]) + expect(withUniformSpacing([5, 10, 15])).toEqual([5, 10, 15]) + expect(withUniformSpacing([2, 4, 8])).toEqual([2, 4, 6, 8]) + expect(withUniformSpacing([1, 2, 4, 8])).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + expect(withUniformSpacing([7, 12, 17])).toEqual([7, 12, 17]) + }) +}) + +describe("withMissingValuesAsZeroes", () => { + it("can add fake points flagged as missing", () => { + const input = seriesArr() + expect(input[1].points[1]).toEqual(undefined) + const series = withMissingValuesAsZeroes(input) + expect(series[1].points[1].position).toEqual(2002) + expect(series[1].points[1].value).toEqual(0) + expect(series[1].points[1].missing).toBe(true) + expect(series[0].points[0].missing).toBe(false) + }) + + it("can enforce uniform spacing on the x-axis", () => { + const series = withMissingValuesAsZeroes(seriesArr(), { enforceUniformSpacing: true }) + expect(series[1].points[1].position).toEqual(2001) + expect(series[1].points[2].position).toEqual(2002) + expect(series[1].points[3].position).toEqual(2003) + }) + + it("never mutates its input", () => { + const input = seriesArr() + withMissingValuesAsZeroes(input) + expect(input[1].points.length).toBe(1) + }) +}) + +describe("stackSeries", () => { + it("can stack series", () => { + const input = withMissingValuesAsZeroes(seriesArr()) + expect(input[1].points[0].valueOffset).toEqual(0) + const series = stackSeries(input) + expect(series[1].points[0].valueOffset).toEqual(10) + expect(series[2].points[0].valueOffset).toEqual(12) + // Input untouched. + expect(input[1].points[0].valueOffset).toEqual(0) + }) +}) + +describe("stackSeriesInBothDirections", () => { + it("can stack positive values", () => { + const series = stackSeriesInBothDirections(withMissingValuesAsZeroes(seriesArr())) + expect(series[1].points[0].valueOffset).toEqual(10) // USA 2000 + expect(series[2].points[0].valueOffset).toEqual(12) // France 2000 + }) + + it("stacks negatives downward independently of positives", () => { + const series = stackSeriesInBothDirections(withMissingValuesAsZeroes(seriesArrWithNegativeValues())) + expect(series[1].points[0].valueOffset).toEqual(0) // USA 2000: first positive + expect(series[2].points[0].valueOffset).toEqual(-10) // France 2000: below Canada's −10 + expect(series[2].points[1].valueOffset).toEqual(0) // France 2002: first negative + }) +}) diff --git a/packages/charts2/src/core/layout/stacking.ts b/packages/charts2/src/core/layout/stacking.ts new file mode 100644 index 00000000000..e3507d16c5a --- /dev/null +++ b/packages/charts2/src/core/layout/stacking.ts @@ -0,0 +1,118 @@ +/** + * Stacking math — port of owid-grapher stackedCharts/StackedUtils.ts + * (stackSeries, stackSeriesInBothDirections, withMissingValuesAsZeroes, + * withUniformSpacing), de-MobX'd and made immutable: every function returns + * fresh series/point objects and never mutates its input. + * + * Contract highlights (spec 15/16): + * - stackSeries: simple cumulative offsets, first series at the bottom. + * - stackSeriesInBothDirections: positives stack upward, negatives stack + * downward independently — a negative segment with no negative below it + * has valueOffset 0 (negatives never offset positives). + */ + +import type { SeriesKey, TimeOrdinal } from "../types.ts" + +export interface StackedPoint { + /** X position: time ordinal, or row index for discrete charts. */ + position: number + time: TimeOrdinal + value: number + valueOffset: number + /** True when this point was zero-filled for a missing value. */ + missing?: boolean + interpolated?: boolean +} + +export interface StackedSeries { + seriesKey: SeriesKey + points: StackedPoint[] +} + +function cloneSeries(series: StackedSeries): StackedSeries { + return { ...series, points: series.points.map((point) => ({ ...point })) } +} + +/** Shift each series' offsets up by the series below it (positive stacks). */ +export function stackSeries(seriesArr: readonly StackedSeries[]): StackedSeries[] { + const out = seriesArr.map(cloneSeries) + out.forEach((series, seriesIndex) => { + if (seriesIndex === 0) return + series.points.forEach((point, pointIndex) => { + const below = out[seriesIndex - 1].points[pointIndex] + point.valueOffset = below.value + below.valueOffset + }) + }) + return out +} + +/** Positives stack upward, negatives stack downward independently. */ +export function stackSeriesInBothDirections(seriesArr: readonly StackedSeries[]): StackedSeries[] { + const out = seriesArr.map(cloneSeries) + out.forEach((series, seriesIndex) => { + if (seriesIndex === 0) return + series.points.forEach((point, pointIndex) => { + const pointsBelow = out.slice(0, seriesIndex).map((s) => s.points[pointIndex]) + const below = + point.value < 0 + ? pointsBelow.findLast((p) => p.value < 0) + : pointsBelow.findLast((p) => p.value >= 0) + point.valueOffset = below !== undefined ? below.value + below.valueOffset : 0 + }) + }) + return out +} + +function gcdTwo(a: number, b: number): number { + while (b !== 0) { + const t = b + b = a % b + a = t + } + return a +} + +/** Fill integer-spaced gaps so values become evenly spaced. */ +export function withUniformSpacing(values: number[]): number[] { + if (values.length < 2) return values + const deltas = values.slice(1).map((v, i) => v - values[i]) + if (!deltas.every((d) => Number.isInteger(d) && d > 0)) return values + const gcd = deltas.reduce((acc, d) => gcdTwo(acc, d)) + if (gcd <= 0) return values + const out: number[] = [] + for (let v = values[0]; v <= values[values.length - 1]; v += gcd) out.push(v) + return out +} + +/** + * Align every series onto the union of x positions, inserting value-0 + * points flagged `missing: true` where a series has no value. Missing is + * still missing — the flag is what keeps tooltips honest about it. + */ +export function withMissingValuesAsZeroes( + seriesArr: readonly StackedSeries[], + { enforceUniformSpacing = false }: { enforceUniformSpacing?: boolean } = {}, +): StackedSeries[] { + let positions = [...new Set(seriesArr.flatMap((series) => series.points.map((point) => point.position)))].sort( + (a, b) => a - b, + ) + if (enforceUniformSpacing) positions = withUniformSpacing(positions) + + return seriesArr.map((series) => { + const byPosition = new Map(series.points.map((point) => [point.position, point])) + return { + ...series, + points: positions.map((position) => { + const point = byPosition.get(position) + return { + position, + time: point?.time ?? 0, + value: point?.value ?? 0, + valueOffset: 0, + missing: point === undefined, + ...(point?.interpolated !== undefined ? { interpolated: point.interpolated } : {}), + } + }), + } + }) +} diff --git a/packages/charts2/src/core/scene/nodes.ts b/packages/charts2/src/core/scene/nodes.ts new file mode 100644 index 00000000000..130774f9571 --- /dev/null +++ b/packages/charts2/src/core/scene/nodes.ts @@ -0,0 +1,186 @@ +/** + * Frozen scene-graph contract. + * + * A ChartScene is the output of layout and the input to the single React + * renderer (SceneSVG). Geometry is plain numbers — never pre-built SVG path + * strings — so future video passes can interpolate between scenes. + * + * Determinism rules (spec 24 §3): + * - Every node key is stable across re-layouts of the same definition + * (derived from entity/metric/role, never an array index). + * - All coordinates pass through round2() before SVG serialization. + * - No node carries environment-derived data (timestamps, random ids). + */ + +import type { Diagnostic, HexColour, SeriesKey, TimeOrdinal } from "../types.ts" +import type { FontSpec, TextMetrics } from "../text/measurer.ts" + +// --------------------------------------------------------------------------- +// Geometry primitives +// --------------------------------------------------------------------------- + +export interface Vec2 { + x: number + y: number +} + +export interface Rect { + x: number + y: number + width: number + height: number +} + +// --------------------------------------------------------------------------- +// Styles — colours are resolved hex by layout time (theme applied in layout, +// not in the renderer). +// --------------------------------------------------------------------------- + +export interface StrokeStyle { + stroke: HexColour + strokeWidth: number + /** Dash pattern in px, e.g. [4, 2]. Solid when absent. */ + dash?: number[] + opacity?: number + lineCap?: "butt" | "round" +} + +export interface FillStyle { + fill: HexColour + opacity?: number + /** Reference to a defs pattern (e.g. projection hatch). */ + patternId?: string + stroke?: HexColour + strokeWidth?: number +} + +// --------------------------------------------------------------------------- +// Scene nodes +// --------------------------------------------------------------------------- + +export type NodeRole = "mark" | "axis" | "grid" | "label" | "annotation" | "chrome" + +interface MarkBase { + /** Stable across re-layouts of the same definition. */ + key: string + /** Present on series-owned nodes: drives emphasis/dimming and video keying. */ + seriesKey?: SeriesKey + role: NodeRole +} + +export type SceneNode = + | (MarkBase & { kind: "group"; children: SceneNode[]; clip?: Rect }) + /** Polyline series; separate segments encode data gaps. */ + | (MarkBase & { kind: "line"; segments: Vec2[][]; style: StrokeStyle }) + | (MarkBase & { kind: "area"; upper: Vec2[]; lower: Vec2[]; style: FillStyle }) + | (MarkBase & { kind: "image"; href: string; rect: Rect; preserveAspectRatio?: string; opacity?: number }) + | (MarkBase & { kind: "rect"; rect: Rect; style: FillStyle }) + | (MarkBase & { kind: "point"; center: Vec2; radius: number; style: FillStyle }) + | (MarkBase & { kind: "rule"; from: Vec2; to: Vec2; style: StrokeStyle }) + | (MarkBase & { + kind: "text" + position: Vec2 + text: string + font: FontSpec + anchor: "start" | "middle" | "end" + colour: HexColour + /** Measured by the layout's TextMeasurer; renderer trusts it. */ + measured: TextMetrics + opacity?: number + }) + +// --------------------------------------------------------------------------- +// Computed series model — the layer-2 testable contract (spec 26 §1.2) +// --------------------------------------------------------------------------- + +export interface SeriesPoint { + time: TimeOrdinal | null + /** Display value (post denominator/displayFactor). */ + value: number + /** Stacked offset where applicable. */ + valueOffset?: number + sourceTime?: TimeOrdinal + projected?: boolean + interpolated?: boolean +} + +export interface SeriesModel { + key: SeriesKey + /** Display label (entity name or metric name). */ + label: string + colour: HexColour + entity?: string + column?: string + points: SeriesPoint[] +} + +// --------------------------------------------------------------------------- +// Hover model — precomputed pure hit/tooltip data; React consumes it without +// recomputing layout. Hover NEVER triggers relayout. +// --------------------------------------------------------------------------- + +export interface TooltipRow { + seriesKey: SeriesKey + label: string + swatch: HexColour + /** Formatted display string (formatting service output). */ + valueText: string + emphasized: boolean + notice?: "toleranced" | "projected" | "missing" +} + +export interface TooltipModel { + title: string + titleAnnotation?: string + subtitle?: string + rows: TooltipRow[] + totalRow?: TooltipRow + footers: { icon: "notice" | "projection"; text: string }[] +} + +export type HitTarget = + | { kind: "time"; time: TimeOrdinal; x: number; tooltip: TooltipModel } + | { kind: "series"; seriesKey: SeriesKey; shape: Rect; tooltip: TooltipModel } + +export interface HoverModel { + targets: HitTarget[] + /** Vertical guide line bounds for time-hover charts (line, stacked area). */ + timeGuide?: { y0: number; y1: number } +} + +// --------------------------------------------------------------------------- +// The scene +// --------------------------------------------------------------------------- + +export interface LegendItem { + seriesKey: SeriesKey + label: string + swatch: HexColour +} + +export interface ChartScene { + width: number + height: number + background: HexColour + /** The data area, inside axes and chrome. */ + plotArea: Rect + nodes: SceneNode[] + series: SeriesModel[] + legend?: LegendItem[] + hover: HoverModel + diagnostics: Diagnostic[] +} + +// --------------------------------------------------------------------------- +// Coordinate formatting — THE single rounding rule (determinism, spec 24 §3) +// --------------------------------------------------------------------------- + +/** + * Round to 2 decimal places, normalizing -0 to 0. All scene coordinates and + * the SVG serializer must route numbers through this — never toFixed or raw + * floats (exponent notation like 1e-7 would leak into SVG output). + */ +export const round2 = (n: number): number => { + const r = Math.round(n * 100) / 100 + return r === 0 ? 0 : r +} diff --git a/packages/charts2/src/core/text/bounds.test.ts b/packages/charts2/src/core/text/bounds.test.ts new file mode 100644 index 00000000000..ebcac94eeec --- /dev/null +++ b/packages/charts2/src/core/text/bounds.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" +import { Bounds } from "./bounds.ts" + +describe("Bounds", () => { + const b = new Bounds(10, 20, 100, 50) + + it("derives edges and centers", () => { + expect(b.left).toBe(10) + expect(b.top).toBe(20) + expect(b.right).toBe(110) + expect(b.bottom).toBe(70) + expect(b.centerX).toBe(60) + expect(b.centerY).toBe(45) + expect(b.area).toBe(5000) + }) + + it("clamps negative dimensions to zero", () => { + const tiny = new Bounds(0, 0, 10, 10).pad(20) + expect(tiny.width).toBe(0) + expect(tiny.height).toBe(0) + }) + + it("pads uniformly and per side", () => { + expect(b.pad(5).toProps()).toEqual({ x: 15, y: 25, width: 90, height: 40 }) + expect(b.pad({ left: 5, top: 10 }).toProps()).toEqual({ x: 15, y: 30, width: 95, height: 40 }) + expect(b.expand(5).toProps()).toEqual({ x: 5, y: 15, width: 110, height: 60 }) + expect(b.expand({ right: 5 }).toProps()).toEqual({ x: 10, y: 20, width: 105, height: 50 }) + }) + + it("slices from edges", () => { + expect(b.fromLeft(30).toProps()).toEqual({ x: 10, y: 20, width: 30, height: 50 }) + expect(b.fromRight(30).toProps()).toEqual({ x: 80, y: 20, width: 30, height: 50 }) + expect(b.fromTop(10).toProps()).toEqual({ x: 10, y: 20, width: 100, height: 10 }) + expect(b.fromBottom(10).toProps()).toEqual({ x: 10, y: 60, width: 100, height: 10 }) + }) + + it("tests intersection and containment", () => { + expect(b.intersects(new Bounds(100, 60, 50, 50))).toBe(true) + expect(b.intersects(new Bounds(200, 200, 10, 10))).toBe(false) + expect(b.containsPoint(10, 20)).toBe(true) + expect(b.containsPoint(111, 20)).toBe(false) + expect(b.contains({ x: 60, y: 45 })).toBe(true) + expect(b.encloses(new Bounds(20, 30, 10, 10))).toBe(true) + expect(b.encloses(new Bounds(20, 30, 200, 10))).toBe(false) + }) + + it("splits into a grid", () => { + const cells = new Bounds(0, 0, 100, 100).grid({ rows: 2, columns: 2 }) + expect(cells).toHaveLength(4) + expect(cells[0]!.toProps()).toEqual({ x: 0, y: 0, width: 50, height: 50 }) + expect(cells[3]!.toProps()).toEqual({ x: 50, y: 50, width: 50, height: 50 }) + const padded = new Bounds(0, 0, 110, 100).grid( + { rows: 1, columns: 2, count: 2 }, + { columnPadding: 10 }, + ) + expect(padded[0]!.width).toBe(50) + expect(padded[1]!.x).toBe(60) + }) + + it("merges bounds", () => { + const merged = Bounds.merge([b, new Bounds(0, 0, 5, 5)]) + expect(merged.toProps()).toEqual({ x: 0, y: 0, width: 110, height: 70 }) + expect(Bounds.merge([]).equals(Bounds.empty())).toBe(true) + }) + + it("sets and equals immutably", () => { + const moved = b.set({ x: 0 }) + expect(moved.toProps()).toEqual({ x: 0, y: 20, width: 100, height: 50 }) + expect(b.x).toBe(10) + expect(moved.equals(new Bounds(0, 20, 100, 50))).toBe(true) + expect(b.scale(2).toProps()).toEqual({ x: 20, y: 40, width: 200, height: 100 }) + }) +}) diff --git a/packages/charts2/src/core/text/bounds.ts b/packages/charts2/src/core/text/bounds.ts new file mode 100644 index 00000000000..1807f0b95ec --- /dev/null +++ b/packages/charts2/src/core/text/bounds.ts @@ -0,0 +1,240 @@ +/** + * Pure rectangle math (ported from charts v1 Bounds, DOM and string-width + * estimation stripped). Immutable: every operation returns a new Bounds. + */ + +import type { Vec2 } from "../scene/nodes.ts" + +export interface BoundsPadding { + top?: number + right?: number + bottom?: number + left?: number +} + +export interface BoundsProps { + x: number + y: number + width: number + height: number +} + +export interface GridParameters { + rows: number + columns: number + /** Number of cells to produce (defaults to rows × columns). */ + count?: number +} + +export interface GridPadding { + columnPadding?: number + rowPadding?: number + outerPadding?: number +} + +export class Bounds { + static fromProps(props: BoundsProps): Bounds { + return new Bounds(props.x, props.y, props.width, props.height) + } + + static empty(): Bounds { + return new Bounds(0, 0, 0, 0) + } + + /** Merge a collection of bounds into a single encompassing Bounds. */ + static merge(boundsList: Bounds[]): Bounds { + if (boundsList.length === 0) return Bounds.empty() + let x1 = Infinity + let y1 = Infinity + let x2 = -Infinity + let y2 = -Infinity + for (const b of boundsList) { + x1 = Math.min(x1, b.x) + y1 = Math.min(y1, b.y) + x2 = Math.max(x2, b.right) + y2 = Math.max(y2, b.bottom) + } + return new Bounds(x1, y1, x2 - x1, y2 - y1) + } + + readonly x: number + readonly y: number + readonly width: number + readonly height: number + + constructor(x: number, y: number, width: number, height: number) { + this.x = x + this.y = y + this.width = Math.max(width, 0) + this.height = Math.max(height, 0) + } + + get left(): number { + return this.x + } + get top(): number { + return this.y + } + get right(): number { + return this.x + this.width + } + get bottom(): number { + return this.y + this.height + } + get centerX(): number { + return this.x + this.width / 2 + } + get centerY(): number { + return this.y + this.height / 2 + } + get area(): number { + return this.width * this.height + } + + padLeft(amount: number): Bounds { + return new Bounds(this.x + amount, this.y, this.width - amount, this.height) + } + + padRight(amount: number): Bounds { + return new Bounds(this.x, this.y, this.width - amount, this.height) + } + + padTop(amount: number): Bounds { + return new Bounds(this.x, this.y + amount, this.width, this.height - amount) + } + + padBottom(amount: number): Bounds { + return new Bounds(this.x, this.y, this.width, this.height - amount) + } + + padWidth(amount: number): Bounds { + return new Bounds(this.x + amount, this.y, this.width - amount * 2, this.height) + } + + padHeight(amount: number): Bounds { + return new Bounds(this.x, this.y + amount, this.width, this.height - amount * 2) + } + + pad(amount: number | BoundsPadding): Bounds { + if (typeof amount === "number") { + return new Bounds( + this.x + amount, + this.y + amount, + this.width - amount * 2, + this.height - amount * 2, + ) + } + return this.padTop(amount.top ?? 0) + .padRight(amount.right ?? 0) + .padBottom(amount.bottom ?? 0) + .padLeft(amount.left ?? 0) + } + + expand(amount: number | BoundsPadding): Bounds { + if (typeof amount === "number") return this.pad(-amount) + return this.pad({ + top: -(amount.top ?? 0), + right: -(amount.right ?? 0), + bottom: -(amount.bottom ?? 0), + left: -(amount.left ?? 0), + }) + } + + /** The leftmost `amount` px of this bounds. */ + fromLeft(amount: number): Bounds { + return this.padRight(this.width - amount) + } + + /** The rightmost `amount` px of this bounds. */ + fromRight(amount: number): Bounds { + return this.padLeft(this.width - amount) + } + + /** The topmost `amount` px of this bounds. */ + fromTop(amount: number): Bounds { + return this.padBottom(this.height - amount) + } + + /** The bottommost `amount` px of this bounds. */ + fromBottom(amount: number): Bounds { + return this.padTop(this.height - amount) + } + + set(props: Partial): Bounds { + return Bounds.fromProps({ ...this.toProps(), ...props }) + } + + scale(scale: number): Bounds { + return new Bounds(this.x * scale, this.y * scale, this.width * scale, this.height * scale) + } + + intersects(other: Bounds): boolean { + return !( + other.left > this.right || + other.right < this.left || + other.top > this.bottom || + other.bottom < this.top + ) + } + + hasVerticalOverlap(other: Bounds): boolean { + return !(other.top > this.bottom || other.bottom < this.top) + } + + hasHorizontalOverlap(other: Bounds): boolean { + return !(other.left > this.right || other.right < this.left) + } + + containsPoint(x: number, y: number): boolean { + return x >= this.left && x <= this.right && y >= this.top && y <= this.bottom + } + + contains(p: Vec2): boolean { + return this.containsPoint(p.x, p.y) + } + + encloses(other: Bounds): boolean { + return ( + this.containsPoint(other.left, other.top) && + this.containsPoint(other.right, other.bottom) + ) + } + + /** Split into a rows × columns grid of cell bounds, row-major. */ + grid(params: GridParameters, padding: GridPadding = {}): Bounds[] { + const { rows, columns } = params + const count = params.count ?? rows * columns + const { columnPadding = 0, rowPadding = 0, outerPadding = 0 } = padding + const contentWidth = this.width - columnPadding * (columns - 1) - outerPadding * 2 + const contentHeight = this.height - rowPadding * (rows - 1) - outerPadding * 2 + const cellWidth = contentWidth / columns + const cellHeight = contentHeight / rows + const cells: Bounds[] = [] + for (let index = 0; index < count; index++) { + const col = index % columns + const row = Math.floor(index / columns) + cells.push( + new Bounds( + this.x + outerPadding + col * (cellWidth + columnPadding), + this.y + outerPadding + row * (cellHeight + rowPadding), + cellWidth, + cellHeight, + ), + ) + } + return cells + } + + equals(other: Bounds): boolean { + return ( + this.x === other.x && + this.y === other.y && + this.width === other.width && + this.height === other.height + ) + } + + toProps(): BoundsProps { + return { x: this.x, y: this.y, width: this.width, height: this.height } + } +} diff --git a/packages/charts2/src/core/text/createMeasurer.test.ts b/packages/charts2/src/core/text/createMeasurer.test.ts new file mode 100644 index 00000000000..4d226632050 --- /dev/null +++ b/packages/charts2/src/core/text/createMeasurer.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest" +import { createMeasurer, defaultMeasurer } from "./createMeasurer.ts" +import type { FontSpec } from "./measurer.ts" +import { familyNameFor, headingTable, monoTable, serifTable, tables } from "./metricsTables.ts" + +const heading = (sizePx: number, letterSpacing?: number): FontSpec => ({ + family: "heading", + sizePx, + weight: 500, + letterSpacing, +}) + +/* + * Hand-computed expectations from src/fonts/metrics/soehne-kraftig.json + * (unitsPerEm 1000, ascent 1171, descent -423, defaultAdvance 632): + * "H" → 736 units + * "Hello" → H 736 + e 540 + l 246 + l 246 + o 566 = 2334 units + * "AV" → A 709 + V 687 + kern(65,86) -90 = 1306 units + * "Québec" → Q 730 + u 563 + é 540 + b 595 + e 540 + c 519 = 3487 units + */ +describe("createMeasurer", () => { + it("measures width as advances scaled by sizePx/unitsPerEm", () => { + expect(defaultMeasurer.measure("H", heading(20)).width).toBeCloseTo(736 * 0.02, 10) + expect(defaultMeasurer.measure("Hello", heading(10)).width).toBeCloseTo(2334 * 0.01, 10) + }) + + it("applies GPOS kerning pairs (AV differs from A + V)", () => { + const av = defaultMeasurer.measure("AV", heading(16)).width + const a = defaultMeasurer.measure("A", heading(16)).width + const v = defaultMeasurer.measure("V", heading(16)).width + expect(av).toBeCloseTo(1306 * 0.016, 10) + expect(a + v).toBeCloseTo((709 + 687) * 0.016, 10) + expect(av).toBeLessThan(a + v) + expect(a + v - av).toBeCloseTo(90 * 0.016, 10) + }) + + it("iterates by codepoint: Québec is 6 codepoints with é measured once", () => { + expect(defaultMeasurer.measure("Québec", heading(10)).width).toBeCloseTo(3487 * 0.01, 10) + expect([..."Québec"].length).toBe(6) + }) + + it("adds letterSpacing px per gap (codepoints − 1)", () => { + const base = defaultMeasurer.measure("Québec", heading(10)).width + const spaced = defaultMeasurer.measure("Québec", heading(10, 2)).width + expect(spaced).toBeCloseTo(base + 2 * 5, 10) + }) + + it("does not apply letterSpacing to single-codepoint text", () => { + expect(defaultMeasurer.measure("H", heading(10, 3)).width).toBeCloseTo(736 * 0.01, 10) + }) + + it("falls back to defaultAdvance for unknown codepoints", () => { + expect(headingTable.advances["20320"]).toBeUndefined() + expect(defaultMeasurer.measure("你", heading(10)).width).toBeCloseTo(632 * 0.01, 10) + }) + + it("scales ascent and descent from the table (descent positive)", () => { + const m = defaultMeasurer.measure("H", heading(10)) + expect(m.ascent).toBeCloseTo(1171 * 0.01, 10) + expect(m.descent).toBeCloseTo(423 * 0.01, 10) + }) + + it("measures empty text as zero width", () => { + expect(defaultMeasurer.measure("", heading(16)).width).toBe(0) + }) + + it("uses the mono table for the mono role (no kern pairs)", () => { + const mono: FontSpec = { family: "mono", sizePx: 10, weight: 400 } + expect(Object.keys(monoTable.kerning)).toHaveLength(0) + const expected = + ((monoTable.advances["65"]! + monoTable.advances["86"]!) * 10) / monoTable.unitsPerEm + expect(defaultMeasurer.measure("AV", mono).width).toBeCloseTo(expected, 10) + }) + + it("is deterministic across calls and across measurer instances", () => { + const fresh = createMeasurer(tables) + const first = defaultMeasurer.measure("Build Canada", heading(14)) + const second = defaultMeasurer.measure("Build Canada", heading(14)) + const third = fresh.measure("Build Canada", heading(14)) + expect(second).toEqual(first) + expect(third).toEqual(first) + }) + + it("weight does not change measurement (single-weight tables)", () => { + const w400 = defaultMeasurer.measure("Hello", { family: "body", sizePx: 12, weight: 400 }) + const w700 = defaultMeasurer.measure("Hello", { family: "body", sizePx: 12, weight: 700 }) + expect(w700.width).toBe(w400.width) + }) + + it("exposes table-derived family names for SVG attributes", () => { + expect(familyNameFor("heading")).toBe("Söhne Kräftig") + expect(familyNameFor("body")).toBe("Söhne Kräftig") + expect(familyNameFor("mono")).toBe("Founders Grotesk Mono") + expect(serifTable.familyName).toBe("Financier Text") + }) +}) diff --git a/packages/charts2/src/core/text/createMeasurer.ts b/packages/charts2/src/core/text/createMeasurer.ts new file mode 100644 index 00000000000..a42d8aef19b --- /dev/null +++ b/packages/charts2/src/core/text/createMeasurer.ts @@ -0,0 +1,63 @@ +/** + * Table-backed TextMeasurer implementation (frozen contract in measurer.ts). + * + * width = (Σ advances + Σ kerning pair adjustments) × sizePx / unitsPerEm + * + letterSpacing × (codepoints − 1) + * + * Text is iterated by CODEPOINTS (for...of), never UTF-16 code units, so + * accented characters and astral-plane glyphs are each one measurement unit. + * Unknown codepoints fall back to the table's defaultAdvance — measurement + * never throws. + * + * Weight: the committed tables are single-weight (Söhne Kräftig is already + * the brand weight). FontSpec.weight currently applies a factor of 1.0 — + * bold/regular variants need their own metrics tables before weight can + * affect measurement. Do NOT fake-scale widths by weight. + */ + +import type { CreateMeasurer, FontMetricsTable, FontSpec, TextMetrics } from "./measurer.ts" +import { tables } from "./metricsTables.ts" + +/** Bounded memoization: cache only affects speed, never output. */ +const CACHE_LIMIT = 10_000 + +function measureUncached(text: string, font: FontSpec, table: FontMetricsTable): TextMetrics { + const scale = font.sizePx / table.unitsPerEm + let units = 0 + let count = 0 + let prev: number | null = null + for (const ch of text) { + const cp = ch.codePointAt(0)! + units += table.advances[String(cp)] ?? table.defaultAdvance + if (prev !== null) units += table.kerning[`${prev},${cp}`] ?? 0 + prev = cp + count += 1 + } + const letterSpacing = font.letterSpacing ?? 0 + const width = units * scale + (count > 1 ? letterSpacing * (count - 1) : 0) + return { + width, + ascent: table.ascent * scale, + // Tables store descent as a (negative) font-unit offset; TextMetrics + // reports it as a positive distance below the baseline. + descent: Math.abs(table.descent) * scale, + } +} + +export const createMeasurer: CreateMeasurer = (roleTables) => { + const cache = new Map() + return { + measure(text: string, font: FontSpec): TextMetrics { + const key = `${font.family}|${font.sizePx}|${font.weight}|${font.letterSpacing ?? 0}|${text}` + const hit = cache.get(key) + if (hit !== undefined) return hit + const metrics = measureUncached(text, font, roleTables[font.family]) + if (cache.size >= CACHE_LIMIT) cache.clear() + cache.set(key, metrics) + return metrics + }, + } +} + +/** Default measurer over the committed brand tables. */ +export const defaultMeasurer = createMeasurer(tables) diff --git a/packages/charts2/src/core/text/index.ts b/packages/charts2/src/core/text/index.ts new file mode 100644 index 00000000000..e02233d85e0 --- /dev/null +++ b/packages/charts2/src/core/text/index.ts @@ -0,0 +1,9 @@ +// Frozen contract +export * from "./measurer.ts" + +// M4 implementation +export * from "./bounds.ts" +export * from "./createMeasurer.ts" +export * from "./metricsTables.ts" +export * from "./truncate.ts" +export * from "./wrap.ts" diff --git a/packages/charts2/src/core/text/inkWidth.test.ts b/packages/charts2/src/core/text/inkWidth.test.ts new file mode 100644 index 00000000000..53bd5def986 --- /dev/null +++ b/packages/charts2/src/core/text/inkWidth.test.ts @@ -0,0 +1,82 @@ +/** + * Acceptance test for the deterministic-fonts design (spec 28 §3): + * the table-based measured width must match the actual rasterized ink + * extent of the same string rendered by resvg from the real TTF. + * + * Ink width ≠ advance width (side bearings at the first/last glyph), so the + * test string is long and starts/ends with vertical-edge glyphs ("H") to + * keep the bearing contribution well under the 2% tolerance. + */ + +import { Resvg } from "@resvg/resvg-js" +import { existsSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" +import { defaultMeasurer } from "./createMeasurer.ts" +import type { FontSpec } from "./measurer.ts" +import { familyNameFor } from "./metricsTables.ts" + +// node:path over `new URL(...)` — happy-dom replaces the URL global +const fontPath = join( + dirname(fileURLToPath(import.meta.url)), + "../../../.fonts-cache/soehne-kraftig.ttf", +) + +describe("ink width acceptance", () => { + it("measured width matches rasterized ink width within 2%", () => { + if (!existsSync(fontPath)) { + throw new Error( + `Missing ${fontPath} — regenerate with: bun run scripts/extract-font-metrics.ts`, + ) + } + + const text = "Household income growth in Quebec and Ontario HHHH" + const sizePx = 100 + const font: FontSpec = { family: "heading", sizePx, weight: 500 } + const measured = defaultMeasurer.measure(text, font) + + const pad = 50 + const svgWidth = Math.ceil(measured.width + pad * 2) + const svgHeight = sizePx * 2 + const family = familyNameFor("heading") + const svg = + `` + + `` + + `${text}` + + const resvg = new Resvg(svg, { + font: { + loadSystemFonts: false, + fontFiles: [fontPath], + defaultFontFamily: family, + }, + }) + const rendered = resvg.render() + const { width, height } = rendered + const pixels = rendered.pixels + + // Scan RGBA pixels for the inked horizontal extent (alpha > 0). + let minX = Infinity + let maxX = -Infinity + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (pixels[(y * width + x) * 4 + 3]! > 0) { + if (x < minX) minX = x + if (x > maxX) maxX = x + } + } + } + expect(minX).toBeLessThan(maxX) + + const inkWidth = maxX - minX + 1 + const discrepancy = Math.abs(inkWidth - measured.width) / measured.width + // eslint-disable-next-line no-console + console.log( + `ink width: ${inkWidth}px, measured: ${measured.width.toFixed(2)}px, ` + + `discrepancy: ${(discrepancy * 100).toFixed(3)}%`, + ) + expect(discrepancy).toBeLessThan(0.02) + }) +}) diff --git a/packages/charts2/src/core/text/measurer.ts b/packages/charts2/src/core/text/measurer.ts new file mode 100644 index 00000000000..ce83b3400bb --- /dev/null +++ b/packages/charts2/src/core/text/measurer.ts @@ -0,0 +1,61 @@ +/** + * Frozen text-measurement contract. + * + * All text measurement flows through TextMeasurer — never canvas, never DOM. + * Implementations are backed by FontMetricsTable JSON committed under + * src/fonts/metrics/, generated at build time from the brand WOFF2s by + * scripts/extract-font-metrics.ts. This is what makes layout deterministic + * across browser, CLI, and CI (specs 24 §3, 28). + */ + +/** Logical font roles; themes map roles to families (spec 04 §4). */ +export type FontRole = "heading" | "body" | "mono" + +export interface FontSpec { + family: FontRole + sizePx: number + weight: 400 | 500 | 700 + /** Additional per-gap spacing in px. */ + letterSpacing?: number +} + +export interface TextMetrics { + width: number + ascent: number + descent: number +} + +export interface TextMeasurer { + measure(text: string, font: FontSpec): TextMetrics +} + +/** + * Committed metrics format (one JSON file per font role). + * Generated for a fixed charset: printable Latin-1, French accents, + * digits, and the typographic set %$+−–— (incl. NBSP and narrow NBSP). + */ +export interface FontMetricsTable { + /** Font family name, for SVG font-family attributes. */ + familyName: string + unitsPerEm: number + ascent: number + descent: number + capHeight: number + /** codepoint (decimal string) → advance width in font units. */ + advances: Record + /** "cp1,cp2" (decimal) → kerning adjustment in font units (GPOS pairs). */ + kerning: Record + /** Fallback advance (font units) for glyphs outside the charset. */ + defaultAdvance: number +} + +/** + * Create a measurer from per-role metrics tables. + * Width = Σ advances + Σ kerning + letterSpacing × (chars − 1), scaled by + * sizePx / unitsPerEm. Unknown glyphs use defaultAdvance (callers may surface + * a diagnostic; measurement itself never throws). + * + * Implemented in M4 (core/text). Declared here so layout (M6) can depend on + * the signature before the implementation lands. + */ +export type CreateMeasurer = (tables: Record) => TextMeasurer diff --git a/packages/charts2/src/core/text/metricsTables.ts b/packages/charts2/src/core/text/metricsTables.ts new file mode 100644 index 00000000000..bd946bc910f --- /dev/null +++ b/packages/charts2/src/core/text/metricsTables.ts @@ -0,0 +1,34 @@ +/** + * Committed font metrics tables (spec 28 §3). + * + * Role mapping: heading and body both use Söhne Kräftig (chart UI text), + * mono uses Founders Grotesk Mono. Financier Text is loaded and exported + * separately (serifTable) for future long-form use; it is not mapped to a + * FontRole yet. + */ + +import financierTextRegular from "../../fonts/metrics/financier-text-regular.json" +import foundersGroteskMonoRegular from "../../fonts/metrics/founders-grotesk-mono-regular.json" +import soehneKraftig from "../../fonts/metrics/soehne-kraftig.json" +import type { FontMetricsTable, FontRole } from "./measurer.ts" + +export const headingTable: FontMetricsTable = soehneKraftig +export const bodyTable: FontMetricsTable = soehneKraftig +export const monoTable: FontMetricsTable = foundersGroteskMonoRegular + +/** Financier Text — reserved for future long-form use (not a FontRole). */ +export const serifTable: FontMetricsTable = financierTextRegular + +export const tables: Record = { + heading: headingTable, + body: bodyTable, + mono: monoTable, +} + +/** + * SVG font-family name for a role, taken from the table's own familyName + * (e.g. "Söhne Kräftig" with umlaut) — never hand-typed strings (spec 28 §3). + */ +export function familyNameFor(role: FontRole): string { + return tables[role].familyName +} diff --git a/packages/charts2/src/core/text/truncate.test.ts b/packages/charts2/src/core/text/truncate.test.ts new file mode 100644 index 00000000000..ed27b8516ad --- /dev/null +++ b/packages/charts2/src/core/text/truncate.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest" +import { defaultMeasurer } from "./createMeasurer.ts" +import type { FontSpec } from "./measurer.ts" +import { truncateWithEllipsis } from "./truncate.ts" + +const font: FontSpec = { family: "heading", sizePx: 16, weight: 500 } + +describe("truncateWithEllipsis", () => { + it("returns text unchanged when it fits", () => { + const width = defaultMeasurer.measure("Hello", font).width + expect(truncateWithEllipsis("Hello", font, width, defaultMeasurer)).toBe("Hello") + }) + + it("truncates to the longest prefix plus a single ellipsis character", () => { + const text = "Provincial infrastructure spending" + const maxWidth = 100 + const result = truncateWithEllipsis(text, font, maxWidth, defaultMeasurer) + expect(result.endsWith("…")).toBe(true) + expect(result.length).toBeLessThan(text.length) + expect(defaultMeasurer.measure(result, font).width).toBeLessThanOrEqual(maxWidth) + // Longest fit: one more codepoint would overflow + const prefix = [...text].slice(0, [...result].length).join("").trimEnd() + "…" + expect(defaultMeasurer.measure(prefix, font).width).toBeGreaterThan(maxWidth) + }) + + it("trims trailing spaces before the ellipsis", () => { + const result = truncateWithEllipsis("Hello world", font, 50, defaultMeasurer) + expect(result).not.toContain(" …") + }) + + it("returns the empty string when not even the ellipsis fits", () => { + expect(truncateWithEllipsis("Hello", font, 1, defaultMeasurer)).toBe("") + }) +}) diff --git a/packages/charts2/src/core/text/truncate.ts b/packages/charts2/src/core/text/truncate.ts new file mode 100644 index 00000000000..8b8a957022b --- /dev/null +++ b/packages/charts2/src/core/text/truncate.ts @@ -0,0 +1,24 @@ +import type { FontSpec, TextMeasurer } from "./measurer.ts" + +const ELLIPSIS = "…" + +/** + * Truncate text to fit maxWidth, appending a single "…" character. + * Returns the text unchanged if it already fits. Trailing spaces are + * trimmed before the ellipsis. Returns "" if not even "…" fits. + */ +export function truncateWithEllipsis( + text: string, + font: FontSpec, + maxWidth: number, + measurer: TextMeasurer, +): string { + if (measurer.measure(text, font).width <= maxWidth) return text + if (measurer.measure(ELLIPSIS, font).width > maxWidth) return "" + const codepoints = [...text] + for (let n = codepoints.length - 1; n > 0; n--) { + const candidate = codepoints.slice(0, n).join("").trimEnd() + ELLIPSIS + if (measurer.measure(candidate, font).width <= maxWidth) return candidate + } + return ELLIPSIS +} diff --git a/packages/charts2/src/core/text/wrap.test.ts b/packages/charts2/src/core/text/wrap.test.ts new file mode 100644 index 00000000000..cc6c1da151a --- /dev/null +++ b/packages/charts2/src/core/text/wrap.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" +import { defaultMeasurer } from "./createMeasurer.ts" +import type { FontSpec } from "./measurer.ts" +import { LINE_HEIGHT, shrinkToFit, wrapText } from "./wrap.ts" + +const heading = (sizePx: number): FontSpec => ({ family: "heading", sizePx, weight: 500 }) + +describe("wrapText", () => { + it("keeps text that fits exactly on one line", () => { + const font = heading(16) + const width = defaultMeasurer.measure("Hello world", font).width + const wrapped = wrapText("Hello world", font, width, defaultMeasurer) + expect(wrapped.lines).toEqual(["Hello world"]) + expect(wrapped.width).toBeCloseTo(width, 10) + expect(wrapped.height).toBeCloseTo(LINE_HEIGHT * 16, 10) + }) + + it("breaks on spaces, never mid-word, when words fit", () => { + const font = heading(16) + // "world" is wider than "Hello" in Söhne; size to the wider word + const maxWidth = defaultMeasurer.measure("world", font).width + const wrapped = wrapText("Hello world", font, maxWidth, defaultMeasurer) + expect(wrapped.lines).toEqual(["Hello", "world"]) + expect(wrapped.height).toBeCloseTo(2 * LINE_HEIGHT * 16, 10) + for (const line of wrapped.lines) { + expect(defaultMeasurer.measure(line, font).width).toBeLessThanOrEqual(maxWidth) + } + }) + + it("hard-breaks a single word wider than maxWidth, without a hyphen", () => { + const font = heading(16) + const maxWidth = defaultMeasurer.measure("Hel", font).width + const wrapped = wrapText("Hello", font, maxWidth, defaultMeasurer) + expect(wrapped.lines.length).toBeGreaterThan(1) + expect(wrapped.lines.join("")).toBe("Hello") + for (const line of wrapped.lines) { + expect(line).not.toContain("-") + expect(defaultMeasurer.measure(line, font).width).toBeLessThanOrEqual(maxWidth) + } + }) + + it("wraps the empty string to zero lines", () => { + expect(wrapText("", heading(16), 100, defaultMeasurer)).toEqual({ + lines: [], + width: 0, + height: 0, + }) + }) + + it("wraps a single character to one line", () => { + const wrapped = wrapText("H", heading(16), 100, defaultMeasurer) + expect(wrapped.lines).toEqual(["H"]) + expect(wrapped.height).toBeCloseTo(LINE_HEIGHT * 16, 10) + }) + + it("treats explicit newlines as forced breaks", () => { + const wrapped = wrapText("one\ntwo", heading(16), 1000, defaultMeasurer) + expect(wrapped.lines).toEqual(["one", "two"]) + }) +}) + +describe("shrinkToFit", () => { + const text = "Government of Canada infrastructure spending by province" + + it("returns the original font when the text already fits", () => { + const font = heading(16) + const result = shrinkToFit(text, font, 10_000, 2, defaultMeasurer, 12) + expect(result.font.sizePx).toBe(16) + expect(result.lines.length).toBeLessThanOrEqual(2) + }) + + it("steps sizePx down in 0.5px increments until the text fits", () => { + const font = heading(20) + const result = shrinkToFit(text, font, 220, 2, defaultMeasurer, 10) + expect(result.font.sizePx).toBeLessThan(20) + expect(result.font.sizePx).toBeGreaterThanOrEqual(10) + expect((result.font.sizePx * 2) % 1).toBe(0) + expect(result.lines.length).toBeLessThanOrEqual(2) + for (const line of result.lines) { + expect(defaultMeasurer.measure(line, result.font).width).toBeLessThanOrEqual(220) + } + // No truncation was needed + expect(result.lines.join(" ")).toBe(text) + }) + + it("truncates with an ellipsis at minSizePx when shrinking is not enough", () => { + const font = heading(20) + const result = shrinkToFit(text, font, 80, 1, defaultMeasurer, 12) + expect(result.font.sizePx).toBe(12) + expect(result.lines).toHaveLength(1) + expect(result.lines[0]!.endsWith("…")).toBe(true) + expect(defaultMeasurer.measure(result.lines[0]!, result.font).width).toBeLessThanOrEqual(80) + }) +}) diff --git a/packages/charts2/src/core/text/wrap.ts b/packages/charts2/src/core/text/wrap.ts new file mode 100644 index 00000000000..5ab5107c3e0 --- /dev/null +++ b/packages/charts2/src/core/text/wrap.ts @@ -0,0 +1,115 @@ +import type { FontSpec, TextMeasurer } from "./measurer.ts" +import { truncateWithEllipsis } from "./truncate.ts" + +/** Line height multiplier: line box = LINE_HEIGHT × sizePx. */ +export const LINE_HEIGHT = 1.2 + +export interface WrappedText { + lines: string[] + /** Width of the widest line in px. */ + width: number + /** lines.length × LINE_HEIGHT × sizePx. */ + height: number +} + +export interface ShrunkText { + font: FontSpec + lines: string[] +} + +function wrapParagraph( + paragraph: string, + font: FontSpec, + maxWidth: number, + measurer: TextMeasurer, +): string[] { + const words = paragraph.split(" ").filter((w) => w.length > 0) + if (words.length === 0) return [""] + const lines: string[] = [] + let line = "" + for (const word of words) { + const candidate = line === "" ? word : `${line} ${word}` + if (measurer.measure(candidate, font).width <= maxWidth) { + line = candidate + continue + } + if (line !== "") { + lines.push(line) + line = "" + } + if (measurer.measure(word, font).width <= maxWidth) { + line = word + continue + } + // A single word wider than maxWidth: hard-break by codepoint, no hyphen. + let chunk = "" + for (const ch of word) { + const next = chunk + ch + if (chunk !== "" && measurer.measure(next, font).width > maxWidth) { + lines.push(chunk) + chunk = ch + } else { + chunk = next + } + } + line = chunk + } + if (line !== "") lines.push(line) + return lines +} + +/** + * Greedy word wrap. Breaks on spaces; never mid-word unless a single word + * exceeds maxWidth, in which case it hard-breaks by codepoint (no hyphen). + * Explicit "\n" forces a line break. Empty text wraps to zero lines. + */ +export function wrapText( + text: string, + font: FontSpec, + maxWidth: number, + measurer: TextMeasurer, +): WrappedText { + if (text === "") return { lines: [], width: 0, height: 0 } + const lines = text + .split("\n") + .flatMap((paragraph) => wrapParagraph(paragraph, font, maxWidth, measurer)) + let width = 0 + for (const line of lines) { + width = Math.max(width, measurer.measure(line, font).width) + } + return { lines, width, height: lines.length * LINE_HEIGHT * font.sizePx } +} + +/** + * Stepwise shrink-to-fit (spec 10 §2): step sizePx down 0.5px at a time + * until the wrapped text fits within maxWidth × maxLines, or minSizePx is + * reached — then keep maxLines lines and truncate the last with an ellipsis + * (never silently clipped). + */ +export function shrinkToFit( + text: string, + font: FontSpec, + maxWidth: number, + maxLines: number, + measurer: TextMeasurer, + minSizePx: number, +): ShrunkText { + let sizePx = font.sizePx + for (;;) { + const trial: FontSpec = { ...font, sizePx } + const wrapped = wrapText(text, trial, maxWidth, measurer) + if (wrapped.lines.length <= maxLines && wrapped.width <= maxWidth) { + return { font: trial, lines: wrapped.lines } + } + if (sizePx <= minSizePx) break + sizePx = Math.max(minSizePx, sizePx - 0.5) + } + const finalFont: FontSpec = { ...font, sizePx: minSizePx } + const wrapped = wrapText(text, finalFont, maxWidth, measurer) + const lines = wrapped.lines.slice(0, maxLines) + if (wrapped.lines.length > maxLines && lines.length > 0) { + const overflow = wrapped.lines.slice(maxLines - 1).join(" ") + lines[lines.length - 1] = truncateWithEllipsis(overflow, finalFont, maxWidth, measurer) + } + return { font: finalFont, lines } +} diff --git a/packages/charts2/src/core/theme/index.ts b/packages/charts2/src/core/theme/index.ts new file mode 100644 index 00000000000..1e4ff3ff1ec --- /dev/null +++ b/packages/charts2/src/core/theme/index.ts @@ -0,0 +1,3 @@ +export * from "./types.ts" +export * from "./themes.ts" +export * from "./registry.ts" diff --git a/packages/charts2/src/core/theme/logos.ts b/packages/charts2/src/core/theme/logos.ts new file mode 100644 index 00000000000..57ca8f75262 --- /dev/null +++ b/packages/charts2/src/core/theme/logos.ts @@ -0,0 +1,8 @@ +export const BUILD_CANADA_SQUARE_LOGO_ASPECT_RATIO = 1 +export const CANADA_SPENDS_LOGO_ASPECT_RATIO = 433 / 133 + +export const BUILD_CANADA_SQUARE_LOGO_DATA_URI = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjA0MCIgaGVpZ2h0PSIyMDQwIiB2aWV3Qm94PSIwIDAgMjA0MCAyMDQwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB4PSIzMCIgeT0iMzAiIHdpZHRoPSIxOTgwIiBoZWlnaHQ9IjE5ODAiIGZpbGw9IiM5MzJGMkYiLz4KPHJlY3QgeD0iMzAiIHk9IjMwIiB3aWR0aD0iMTk4MCIgaGVpZ2h0PSIxOTgwIiBzdHJva2U9IiMyNzI3MjciIHN0cm9rZS13aWR0aD0iNjAiLz4KPHBhdGggZD0iTTExMTIuMjMgOTk1LjMxQzEwNDUuOTMgOTk1LjMxIDEwMDguMDQgOTM5LjM1NiAxMDA4LjA0IDg3Mi4xMjJDMTAwOC4wNCA4MDQuODg3IDEwNDUuOTMgNzQ4LjkzMyAxMTEyLjIzIDc0OC45MzNDMTE0Mi45IDc0OC45MzMgMTE2NS40NSA3NjEuMTE2IDExODAuMzMgNzc4LjcxNVY2NjYuMzU1SDEyMzQuNDZWOTkwLjM0N0gxMTgwLjMzVjk2NS41MjhDMTE2NS40NSA5ODMuMTI3IDExNDIuOSA5OTUuMzEgMTExMi4yMyA5OTUuMzFaTTExODEuNjkgODY1LjM1M0MxMTgxLjY5IDgyMC42OCAxMTU2LjQzIDc5Ni4zMTMgMTEyMy4wNSA3OTYuMzEzQzEwODMuODEgNzk2LjMxMyAxMDYyLjYyIDgyNi45OTcgMTA2Mi42MiA4NzIuMTIyQzEwNjIuNjIgOTE3LjI0NiAxMDgzLjgxIDk0Ny45MyAxMTIzLjA1IDk0Ny45M0MxMTU2LjQzIDk0Ny45MyAxMTgxLjY5IDkyMy4xMTIgMTE4MS42OSA4NzkuMzQxVjg2NS4zNTNaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNOTY2LjA1NiA2NjYuMzU1Vjk5MC4zNDdIOTExLjkzNFY2NjYuMzU1SDk2Ni4wNTZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNODAwLjAzNyA3MjEuODU4VjY2Ni4zNTVIODU1Ljk2NFY3MjEuODU4SDgwMC4wMzdaTTg1NS4wNjIgNzUzLjQ0NVY5OTAuMzQ3SDgwMC45MzlWNzUzLjQ0NUg4NTUuMDYyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTc0My44OTQgNzUzLjQ0OFY5OTAuMzQ5SDY4OS43NzJWOTY1LjA4QzY3Ni4yNDEgOTgwLjg3MyA2NTUuOTQ1IDk5NS4zMTMgNjI1LjI3NiA5OTUuMzEzQzU3NS42NjQgOTk1LjMxMyA1NDQuOTk0IDk2MS40NyA1NDQuOTk0IDkxMC40NzlWNzUzLjQ0OEg1OTkuMTE3Vjg5OC43NDdDNTk5LjExNyA5MjcuNjI3IDYxMS43NDUgOTQ2LjEyOCA2NDEuMDYyIDk0Ni4xMjhDNjY1LjQxNyA5NDYuMTI4IDY4OS43NzIgOTI4LjA3OCA2ODkuNzcyIDg5NC4yMzVWNzUzLjQ0OEg3NDMuODk0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI2MSA5OTAuMzQ3VjY2Ni4zNTVIMzg5LjU0MUM0NTguOTk4IDY2Ni4zNTUgNDk1Ljk4MiA2OTYuNTg5IDQ5NS45ODIgNzUxLjY0QzQ5NS45ODIgNzkxLjgwMSA0NzIuNTI5IDgxMi41NTggNDQ5Ljk3OCA4MjEuNTgyQzQ4My44MDUgODMyLjQxMiA1MDYuMzU2IDg2MC4zODkgNTA2LjM1NiA5MDAuMDk4QzUwNi4zNTYgOTU2Ljk1NSA0NjQuNDExIDk5MC4zNDcgMzk3LjY1OSA5OTAuMzQ3SDI2MVpNMzE2LjQ3NiA4MDAuMzc0SDM4NC4xMjlDNDIxLjExMyA4MDAuMzc0IDQ0MS40MDggNzg1LjkzNCA0NDEuNDA4IDc1Ny41MDZDNDQxLjQwOCA3MjkuMDc4IDQyMS4xMTMgNzE1LjA5IDM4NC4xMjkgNzE1LjA5SDMxNi40NzZWODAwLjM3NFpNMzE2LjQ3NiA4NDkuMTA4Vjk0MS42MTNIMzk0LjUwMkM0MzEuMDM1IDk0MS42MTMgNDUwLjg4IDkyMi42NjEgNDUwLjg4IDg5NS4xMzVDNDUwLjg4IDg2OC4wNiA0MzEuMDM1IDg0OS4xMDggMzk0LjUwMiA4NDkuMTA4SDMxNi40NzZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTY0MC41NCAxMzczLjQ1QzE1OTUuNDMgMTM3My40NSAxNTYwLjcxIDEzNDUuOTMgMTU2MC43MSAxMzAzLjUxQzE1NjAuNzEgMTI1OC4zOSAxNTk0LjUzIDEyMzguNTMgMTY0MC4wOSAxMjI5LjA2TDE3MDUuNDggMTIxNS41MlYxMjExLjQ2QzE3MDUuNDggMTE4OC45IDE2OTMuNzYgMTE3NC45MSAxNjY0Ljg5IDExNzQuOTFDMTYzOS4xOCAxMTc0LjkxIDE2MjUuNjUgMTE4Ni42NCAxNjE5LjM0IDEyMDkuNjVMMTU2OC4zNyAxMTk3LjkyQzE1ODAuMSAxMTU4LjY2IDE2MTQuODMgMTEyNy45OCAxNjY3LjE1IDExMjcuOThDMTcyMy45OCAxMTI3Ljk4IDE3NTguMjUgMTE1NS4wNSAxNzU4LjI1IDEyMDkuNjVWMTMxMS42M0MxNzU4LjI1IDEzMjUuMTcgMTc2NC4xMiAxMzI5LjIzIDE3NzkgMTMyNy40M1YxMzY5LjM5QzE3MzkuNzYgMTM3My45IDE3MTkuMDEgMTM2Ni4yMyAxNzEwLjkgMTM0Ni44M0MxNjk2LjAxIDEzNjMuNTMgMTY3MS4yMSAxMzczLjQ1IDE2NDAuNTQgMTM3My40NVpNMTcwNS40OCAxMjg1LjkxVjEyNTcuMDNMMTY1NC41MiAxMjY3Ljg2QzE2MzEuNTIgMTI3Mi44MyAxNjE0LjM4IDEyODAuMDUgMTYxNC4zOCAxMzAxLjI1QzE2MTQuMzggMTMxOS43NiAxNjI3LjkxIDEzMzAuMTMgMTY0OC42NSAxMzMwLjEzQzE2NzcuNTIgMTMzMC4xMyAxNzA1LjQ4IDEzMTQuNzkgMTcwNS40OCAxMjg1LjkxWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEzOTguOTEgMTM3NC4zNUMxMzMyLjYxIDEzNzQuMzUgMTI5NC43MyAxMzE4LjQgMTI5NC43MyAxMjUxLjE3QzEyOTQuNzMgMTE4My45MyAxMzMyLjYxIDExMjcuOTggMTM5OC45MSAxMTI3Ljk4QzE0MjkuNTggMTEyNy45OCAxNDUyLjEzIDExNDAuMTYgMTQ2Ny4wMiAxMTU3Ljc2VjEwNDUuNEgxNTIxLjE0VjEzNjkuMzlIMTQ2Ny4wMlYxMzQ0LjU3QzE0NTIuMTMgMTM2Mi4xNyAxNDI5LjU4IDEzNzQuMzUgMTM5OC45MSAxMzc0LjM1Wk0xNDY4LjM3IDEyNDQuNEMxNDY4LjM3IDExOTkuNzIgMTQ0My4xMSAxMTc1LjM2IDE0MDkuNzQgMTE3NS4zNkMxMzcwLjUgMTE3NS4zNiAxMzQ5LjMgMTIwNi4wNCAxMzQ5LjMgMTI1MS4xN0MxMzQ5LjMgMTI5Ni4yOSAxMzcwLjUgMTMyNi45NyAxNDA5Ljc0IDEzMjYuOTdDMTQ0My4xMSAxMzI2Ljk3IDE0NjguMzcgMTMwMi4xNiAxNDY4LjM3IDEyNTguMzlWMTI0NC40WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTExMzIuNyAxMzczLjQ1QzEwODcuNTkgMTM3My40NSAxMDUyLjg3IDEzNDUuOTMgMTA1Mi44NyAxMzAzLjUxQzEwNTIuODcgMTI1OC4zOSAxMDg2LjY5IDEyMzguNTMgMTEzMi4yNSAxMjI5LjA2TDExOTcuNjQgMTIxNS41MlYxMjExLjQ2QzExOTcuNjQgMTE4OC45IDExODUuOTIgMTE3NC45MSAxMTU3LjA1IDExNzQuOTFDMTEzMS4zNCAxMTc0LjkxIDExMTcuODEgMTE4Ni42NCAxMTExLjUgMTIwOS42NUwxMDYwLjUzIDExOTcuOTJDMTA3Mi4yNiAxMTU4LjY2IDExMDYuOTkgMTEyNy45OCAxMTU5LjMxIDExMjcuOThDMTIxNi4xNCAxMTI3Ljk4IDEyNTAuNDEgMTE1NS4wNSAxMjUwLjQxIDEyMDkuNjVWMTMxMS42M0MxMjUwLjQxIDEzMjUuMTcgMTI1Ni4yOCAxMzI5LjIzIDEyNzEuMTYgMTMyNy40M1YxMzY5LjM5QzEyMzEuOTIgMTM3My45IDEyMTEuMTcgMTM2Ni4yMyAxMjAzLjA2IDEzNDYuODNDMTE4OC4xNyAxMzYzLjUzIDExNjMuMzcgMTM3My40NSAxMTMyLjcgMTM3My40NVpNMTE5Ny42NCAxMjg1LjkxVjEyNTcuMDNMMTE0Ni42OCAxMjY3Ljg2QzExMjMuNjggMTI3Mi44MyAxMTA2LjU0IDEyODAuMDUgMTEwNi41NCAxMzAxLjI1QzExMDYuNTQgMTMxOS43NiAxMTIwLjA3IDEzMzAuMTMgMTE0MC44MiAxMzMwLjEzQzExNjkuNjggMTMzMC4xMyAxMTk3LjY0IDEzMTQuNzkgMTE5Ny42NCAxMjg1LjkxWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTgxNS44NjYgMTM2OS4zOVYxMTMyLjQ5SDg2OS45ODhWMTE1Ny43NkM4ODMuNTE5IDExNDEuOTcgOTA0LjcxNyAxMTI3Ljk4IDkzNS4zODYgMTEyNy45OEM5ODQuOTk5IDExMjcuOTggMTAxNC43NyAxMTYyLjI3IDEwMTQuNzcgMTIxMy4yNlYxMzY5LjM5SDk2MC42NDNWMTIyOS4wNkM5NjAuNjQzIDExOTkuNzMgOTQ4LjkxNyAxMTc4LjUyIDkxOS4xNSAxMTc4LjUyQzg5NC43OTQgMTE3OC41MiA4NjkuOTg4IDExOTYuNTcgODY5Ljk4OCAxMjMwLjQxVjEzNjkuMzlIODE1Ljg2NloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik02MzguOTUyIDEzNzMuNDVDNTkzLjg1IDEzNzMuNDUgNTU5LjEyMiAxMzQ1LjkzIDU1OS4xMjIgMTMwMy41MUM1NTkuMTIyIDEyNTguMzkgNTkyLjk0OCAxMjM4LjUzIDYzOC41MDEgMTIyOS4wNkw3MDMuODk5IDEyMTUuNTJWMTIxMS40NkM3MDMuODk5IDExODguOSA2OTIuMTczIDExNzQuOTEgNjYzLjMwOCAxMTc0LjkxQzYzNy41OTkgMTE3NC45MSA2MjQuMDY5IDExODYuNjQgNjE3Ljc1NCAxMjA5LjY1TDU2Ni43ODkgMTE5Ny45MkM1NzguNTE1IDExNTguNjYgNjEzLjI0NCAxMTI3Ljk4IDY2NS41NjMgMTEyNy45OEM3MjIuMzkxIDExMjcuOTggNzU2LjY2OSAxMTU1LjA1IDc1Ni42NjkgMTIwOS42NVYxMzExLjYzQzc1Ni42NjkgMTMyNS4xNyA3NjIuNTMyIDEzMjkuMjMgNzc3LjQxNiAxMzI3LjQzVjEzNjkuMzlDNzM4LjE3NyAxMzczLjkgNzE3LjQzIDEzNjYuMjMgNzA5LjMxMiAxMzQ2LjgzQzY5NC40MjggMTM2My41MyA2NjkuNjIyIDEzNzMuNDUgNjM4Ljk1MiAxMzczLjQ1Wk03MDMuODk5IDEyODUuOTFWMTI1Ny4wM0w2NTIuOTM0IDEyNjcuODZDNjI5LjkzMiAxMjcyLjgzIDYxMi43OTMgMTI4MC4wNSA2MTIuNzkzIDEzMDEuMjVDNjEyLjc5MyAxMzE5Ljc2IDYyNi4zMjQgMTMzMC4xMyA2NDcuMDcxIDEzMzAuMTNDNjc1LjkzNiAxMzMwLjEzIDcwMy44OTkgMTMxNC43OSA3MDMuODk5IDEyODUuOTFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzE4LjczMSAxMjA3LjRDMzE4LjczMSAxMjc3LjM0IDM1Ni42MTcgMTMyMy4zNyA0MDkuODM3IDEzMjMuMzdDNDQ3LjcyMyAxMzIzLjM3IDQ3NS4yMzUgMTMwMC4zNSA0ODUuMTU4IDEyNjIuNDVMNTM5LjI4IDEyODAuOTVDNTIwLjMzNyAxMzM4LjI2IDQ3NS4yMzUgMTM3NC4zNiA0MDkuODM3IDEzNzQuMzZDMzIzLjY5MiAxMzc0LjM2IDI2MSAxMzA1Ljc3IDI2MSAxMjA3LjRDMjYxIDExMDkuMDMgMzIzLjY5MiAxMDQwLjQ0IDQwOS44MzcgMTA0MC40NEM0NzUuMjM1IDEwNDAuNDQgNTIwLjMzNyAxMDc2LjU0IDUzOS4yOCAxMTMzLjg0TDQ4NS4xNTggMTE1Mi4zNEM0NzUuMjM1IDExMTQuNDQgNDQ3LjcyMyAxMDkxLjQzIDQwOS44MzcgMTA5MS40M0MzNTYuNjE3IDEwOTEuNDMgMzE4LjczMSAxMTM3LjQ1IDMxOC43MzEgMTIwNy40WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" + +export const CANADA_SPENDS_LOGO_DATA_URI = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDMzIiBoZWlnaHQ9IjEzMyIgdmlld0JveD0iMCAwIDQzMyAxMzMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0xMDMuOTQzIDU3LjEyNDVMMTA3Ljg0NiA0Ni41NDU3TDk3LjI2NjggNTAuNDQ4Mkw5My4xMDAxIDQzLjI3OTVMODEuNjIwNyA1Ny4wMjg0TDg0LjAxMDMgMzQuNDY1OEw3NC41ODQyIDM2LjU5MTJMNjkuMDM2NiAyNi4zNjA2TDYzLjUwMSAzNi41OTEyTDU0LjA3NDkgMzQuNDY1OEw1Ni40NjQ0IDU3LjAyODRMNDQuOTczIDQzLjI3OTVMNDAuODA2MyA1MC40NDgyTDMwLjIzOTUgNDYuNTQ1N0wzNC4xNDIgNTcuMTI0NUwyNi45NjEzIDYxLjI5MTJMNTUuNzggODUuMzU0OEg4Mi4yOTMxTDExMS4xMjQgNjEuMjkxMkwxMDMuOTQzIDU3LjEyNDVaIiBmaWxsPSIjMjcyNzI3Ii8+CjxwYXRoIGQ9Ik02NC40NDgzIDkyLjI3MUg3My42MjIyQzczLjc5MDMgOTIuMjcxIDczLjkzNDQgOTIuNDAzMSA3My45NDY0IDkyLjU3MTJMNzUuMjMxMyAxMDYuNDg4Qzc1LjI0MzMgMTA2LjY4IDc1LjA5OTIgMTA2Ljg0OCA3NC45MDcxIDEwNi44NDhINjMuMTUxNUM2Mi45NTkzIDEwNi44NDggNjIuODAzMiAxMDYuNjggNjIuODI3MiAxMDYuNDg4TDY0LjExMjEgOTIuNTcxMkM2NC4xMjQxIDkyLjQwMzEgNjQuMjY4MiA5Mi4yNzEgNjQuNDM2MyA5Mi4yNzFINjQuNDQ4M1oiIGZpbGw9IiMyNzI3MjciLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01NC4xOTYyIDBIODMuNTk2NlYwLjAxMzYzMTJDODguMDk4NyAwLjAxMzYzMTIgOTIuNDkxNyAxLjQzMTI4IDk2LjEzNDMgNC4wNzU3NEwxMTkuOTE0IDIxLjM0NjVDMTIzLjU1NiAyMy45OTEgMTI2LjI3MSAyNy43MTIzIDEyNy42NjMgMzEuOTkyNUwxMzYuNzQ5IDU5LjkyMjlDMTM4LjE0MSA2NC4yMDMxIDEzOC4xNDEgNjguODEwNSAxMzYuNzQ5IDczLjA5MDdMMTI3LjY2MyAxMDEuMDIxQzEyNi4yNzEgMTA1LjI4OCAxMjMuNTU2IDEwOS4wMjMgMTE5LjkxNCAxMTEuNjY3TDk2LjEzNDMgMTI4LjkzOEM5Mi40OTE3IDEzMS41ODIgODguMDk4NyAxMzMgODMuNTk2NiAxMzNINTQuMTk2MkM0OS42OTQxIDEzMyA0NS4zMDExIDEzMS41ODIgNDEuNjU4NCAxMjguOTM4TDE3Ljg3OSAxMTEuNjY3QzE0LjIzNjMgMTA5LjAyMyAxMS41MjE0IDEwNS4zMDEgMTAuMTI5OCAxMDEuMDIxTDEuMDQzNjggNzMuMDkwN0MtMC4zNDc4OTMgNjguODEwNSAtMC4zNDc4OTMgNjQuMjAzMSAxLjA0MzY4IDU5LjkyMjlMMTAuMTI5OCAzMS45Nzg5QzExLjUyMTQgMjcuNzEyMyAxNC4yMzYzIDIzLjk3NzMgMTcuODc5IDIxLjMzMjlMNDEuNjU4NCA0LjA2MjExQzQ1LjMwMTEgMS40MTc2NSA0OS42OTQxIDAgNTQuMTk2MiAwWk01NS45NTg3IDcuOTIwMjJIODEuODM1NFY3LjkzMjIzQzg1Ljc5OCA3LjkzMjIzIDg5LjY2NDUgOS4xODEwMyA5Mi44NzA1IDExLjUxMDVMMTEzLjggMjYuNzI0NEMxMTcuMDA2IDI5LjA1MzkgMTE5LjM5NiAzMi4zMzIgMTIwLjYyIDM2LjEwMjRMMTI4LjYxOCA2MC43MDY0QzEyOS44NDIgNjQuNDc2OCAxMjkuODQyIDY4LjUzNTQgMTI4LjYxOCA3Mi4zMDU5TDEyMC42MiA5Ni45MDk4QzExOS4zOTYgMTAwLjY2OCAxMTcuMDA2IDEwMy45NTggMTEzLjggMTA2LjI4OEw5Mi44NzA1IDEyMS41MDJDODkuNjY0NSAxMjMuODMxIDg1Ljc5OCAxMjUuMDggODEuODM1NCAxMjUuMDhINTUuOTU4N0M1MS45OTYxIDEyNS4wOCA0OC4xMjk2IDEyMy44MzEgNDQuOTIzNSAxMjEuNTAyTDIzLjk5NCAxMDYuMjg4QzIwLjc4NzkgMTAzLjk1OCAxOC4zOTg0IDEwMC42OCAxNy4xNzM2IDk2LjkwOThMOS4xNzY0IDcyLjMwNTlDNy45NTE2IDY4LjUzNTQgNy45NTE2IDY0LjQ3NjggOS4xNzY0IDYwLjcwNjRMMTcuMTczNiAzNi4wOTA0QzE4LjM5ODQgMzIuMzMyIDIwLjc4NzkgMjkuMDQxOSAyMy45OTQgMjYuNzEyNEw0NC45MjM1IDExLjQ5ODVDNDguMTI5NiA5LjE2OTAzIDUxLjk5NjEgNy45MjAyMiA1NS45NTg3IDcuOTIwMjJaIiBmaWxsPSIjMjcyNzI3Ii8+CjxwYXRoIGQ9Ik0xODEuMzIxIDY3LjAxOUMxNzcuMjgyIDY3LjAxOSAxNzMuNjAxIDY2LjIwMTEgMTcwLjI3OCA2NC41NjUxQzE2Ni45NTUgNjIuOTI5MiAxNjQuMjk3IDYwLjQ3NTMgMTYyLjMwMyA1Ny4yMDM0QzE2MC4zNiA1My44ODA0IDE1OS40MTUgNDkuNzkwNiAxNTkuNDY2IDQ0LjkzMzlDMTU5LjUxNyA0MC4xMjgzIDE2MC40ODggMzYuMDM4NCAxNjIuMzggMzIuNjY0M0MxNjQuMzIyIDI5LjI5MDIgMTY2Ljk1NSAyNi43MzQgMTcwLjI3OCAyNC45OTU4QzE3My42MDEgMjMuMjA2NSAxNzcuMzU5IDIyLjMxMTkgMTgxLjU1MSAyMi4zMTE5QzE4NS42OTIgMjIuMzExOSAxODkuMjE5IDIzLjA1MzIgMTkyLjEzMyAyNC41MzU3QzE5NS4wNDcgMjYuMDE4MyAxOTcuMzIyIDI4LjAxMjEgMTk4Ljk1OCAzMC41MTcxQzIwMC42NDUgMzMuMDIyMiAyMDEuNzQ0IDM1Ljc4MjggMjAyLjI1NiAzOC43OTkxSDE5Mi42N0MxOTIuMzYzIDM2LjY1MTkgMTkxLjU5NyAzNS4wNDE1IDE5MC4zNyAzMy45Njc5QzE4OS4xNDMgMzIuODQzMiAxODcuNzExIDMyLjA3NjQgMTg2LjA3NSAzMS42Njc0QzE4NC40OSAzMS4yNTg0IDE4Mi45ODIgMzEuMDUzOSAxODEuNTUxIDMxLjA1MzlDMTc3LjMwOCAzMS4wNTM5IDE3NC4wODcgMzIuMjA0MiAxNzEuODg5IDM0LjUwNDdDMTY5LjY5IDM2LjgwNTMgMTY4LjU5MSA0MC4wNTE2IDE2OC41OTEgNDQuMjQzN0MxNjguNTkxIDQ4Ljc0MjUgMTY5LjY2NSA1Mi4yMTg5IDE3MS44MTIgNTQuNjcyOEMxNzMuOTU5IDU3LjEyNjcgMTc3LjE4IDU4LjM1MzcgMTgxLjQ3NCA1OC4zNTM3QzE4My4wMDggNTguMzUzNyAxODQuNTY3IDU4LjEyMzYgMTg2LjE1MiA1Ny42NjM1QzE4Ny43ODggNTcuMjAzNCAxODkuMjE5IDU2LjM4NTQgMTkwLjQ0NiA1NS4yMDk2QzE5MS43MjQgNTMuOTgyNiAxOTIuNTE3IDUyLjI5NTYgMTkyLjgyMyA1MC4xNDg0SDIwMi40MDlDMjAxLjg5OCA1My4yMTU4IDIwMC43OTkgNTYuMDUzMSAxOTkuMTEyIDU4LjY2MDRDMTk3LjQyNSA2MS4yMTY2IDE5NS4wOTggNjMuMjYxNSAxOTIuMTMzIDY0Ljc5NTJDMTg5LjIxOSA2Ni4yNzc4IDE4NS42MTUgNjcuMDE5IDE4MS4zMjEgNjcuMDE5Wk0yMDUuOTcyIDY2LjI1MjJMMjE5LjA4NiAyMy4wNzg3SDIzNC4yNjlMMjQ3Ljg0MiA2Ni4yNTIySDIzNy45NUwyMjYuOTA3IDI2LjkxM0wyMTUuOTQxIDY2LjI1MjJIMjA1Ljk3MlpNMjE1Ljg2NSA1NC43NDk1TDIxNS42MzUgNDcuNjE3OEgyMzcuODczTDIzNy43OTcgNTQuNzQ5NUgyMTUuODY1Wk0yODIuMzYgMjMuMDc4N0gyOTIuMDIyVjY2LjI1MjJIMjc0LjMwOEwyNjIuODgyIDI3LjE0M0gyNjIuNzI5VjY2LjI1MjJIMjUzLjE0M1YyMy4wNzg3SDI2OS41NTRMMjgyLjIwNyA2Mi4xODc5SDI4Mi4zNlYyMy4wNzg3Wk0yOTcuNzEgNjYuMjUyMkwzMTAuODIzIDIzLjA3ODdIMzI2LjAwNkwzMzkuNTc5IDY2LjI1MjJIMzI5LjY4N0wzMTguNjQ0IDI2LjkxM0wzMDcuNjc5IDY2LjI1MjJIMjk3LjcxWk0zMDcuNjAyIDU0Ljc0OTVMMzA3LjM3MiA0Ny42MTc4SDMyOS42MUwzMjkuNTM0IDU0Ljc0OTVIMzA3LjYwMlpNMzU0LjU0MiA1OC42NjA0QzM1Ni4yMjkgNTguNjYwNCAzNTcuNTA4IDU4LjY2MDQgMzU4LjM3NyA1OC42NjA0QzM1OS4yNDYgNTguNjYwNCAzNTkuODU5IDU4LjY2MDQgMzYwLjIxNyA1OC42NjA0QzM2Mi41NjkgNTguNjYwNCAzNjQuNjM5IDU4LjQ4MTUgMzY2LjQyOSA1OC4xMjM2QzM2OC4yNjkgNTcuNzE0NiAzNjkuODI4IDU2Ljk5ODkgMzcxLjEwNiA1NS45NzY1QzM3Mi4zODQgNTQuOTU0IDM3My4zNTYgNTMuNTIyNSAzNzQuMDIgNTEuNjgyMUMzNzQuNzM2IDQ5Ljg0MTcgMzc1LjA5NCA0Ny40NjQ1IDM3NS4wOTQgNDQuNTUwNEMzNzUuMDk0IDQxLjUzNDIgMzc0LjQ4IDM5LjAyOTEgMzczLjI1MyAzNy4wMzUzQzM3Mi4wNzggMzUuMDQxNSAzNzAuMzY1IDMzLjU1OSAzNjguMTE2IDMyLjU4NzZDMzY1LjkxNyAzMS41NjUyIDM2My4yODQgMzEuMDUzOSAzNjAuMjE3IDMxLjA1MzlIMzU0LjU0MlY1OC42NjA0Wk0zODQuOTEgNDQuNjI3MUMzODQuOTEgNTAuMzAxOCAzODMuNzg1IDU0LjcyMzkgMzgxLjUzNSA1Ny44OTM2QzM3OS4yODYgNjEuMDEyMSAzNzYuMDkxIDYzLjE4NDggMzcxLjk1IDY0LjQxMThDMzY3Ljg2IDY1LjYzODcgMzYzLjA1NCA2Ni4yNTIyIDM1Ny41MzMgNjYuMjUyMkMzNTYuNjY0IDY2LjI1MjIgMzU1LjI4NCA2Ni4yNTIyIDM1My4zOTIgNjYuMjUyMkMzNTEuNTUyIDY2LjI1MjIgMzQ4Ljg5MyA2Ni4yNTIyIDM0NS40MTcgNjYuMjUyMlYyMy4wNzg3QzM0OS4wOTggMjMuMDc4NyAzNTEuODMzIDIzLjA3ODcgMzUzLjYyMiAyMy4wNzg3QzM1NS40MTIgMjMuMDc4NyAzNTYuNzE1IDIzLjA3ODcgMzU3LjUzMyAyMy4wNzg3QzM2My4wNTQgMjMuMDc4NyAzNjcuODYgMjMuODk2NyAzNzEuOTUgMjUuNTMyNkMzNzYuMDkxIDI3LjExNzUgMzc5LjI4NiAyOS40OTQ3IDM4MS41MzUgMzIuNjY0M0MzODMuNzg1IDM1LjgzMzkgMzg0LjkxIDM5LjgyMTUgMzg0LjkxIDQ0LjYyNzFaTTM4OS4yOTcgNjYuMjUyMkw0MDIuNDEgMjMuMDc4N0g0MTcuNTkzTDQzMS4xNjcgNjYuMjUyMkg0MjEuMjc0TDQxMC4yMzIgMjYuOTEzTDM5OS4yNjYgNjYuMjUyMkgzODkuMjk3Wk0zOTkuMTg5IDU0Ljc0OTVMMzk4Ljk1OSA0Ny42MTc4SDQyMS4xOThMNDIxLjEyMSA1NC43NDk1SDM5OS4xODlaIiBmaWxsPSIjMjcyNzI3Ii8+CjxwYXRoIGQ9Ik0xNzMuNDYxIDExMC45ODJDMTcxLjc3NCAxMTAuOTgyIDE3MC4xNDQgMTEwLjc3MSAxNjguNTcyIDExMC4zNDlDMTY3IDEwOS45NjYgMTY1LjU2MiAxMDkuMzUyIDE2NC4yNTggMTA4LjUwOUMxNjIuOTU1IDEwNy42MjcgMTYxLjkyIDEwNi41MzQgMTYxLjE1MyAxMDUuMjNDMTYwLjM4NiAxMDMuODg4IDE1OS45ODMgMTAyLjI3OCAxNTkuOTQ1IDEwMC4zOTlIMTYyLjU5MUMxNjIuNTkxIDEwMC41MTQgMTYyLjYyOSAxMDAuODU5IDE2Mi43MDYgMTAxLjQzNUMxNjIuNzgyIDEwMi4wMSAxNjIuOTc0IDEwMi43IDE2My4yODEgMTAzLjUwNUMxNjMuNjI2IDEwNC4zMSAxNjQuMTgyIDEwNS4wOTYgMTY0Ljk0OSAxMDUuODYzQzE2NS43NTQgMTA2LjYzIDE2Ni44NDcgMTA3LjI4MiAxNjguMjI3IDEwNy44MTlDMTY5LjY0NiAxMDguMzE3IDE3MS40NDggMTA4LjU2NiAxNzMuNjMzIDEwOC41NjZDMTc1LjYyNyAxMDguNTY2IDE3Ny4yMTggMTA4LjMzNiAxNzguNDA3IDEwNy44NzZDMTc5LjYzNCAxMDcuMzc4IDE4MC41NTQgMTA2Ljc4MyAxODEuMTY3IDEwNi4wOTNDMTgxLjc4MSAxMDUuMzY1IDE4Mi4xODMgMTA0LjY3NCAxODIuMzc1IDEwNC4wMjNDMTgyLjYwNSAxMDMuMzcxIDE4Mi43MiAxMDIuODcyIDE4Mi43MiAxMDIuNTI3QzE4Mi43NTkgMTAxLjA3IDE4Mi4zOTQgOTkuOTAwOSAxODEuNjI3IDk5LjAxOUMxODAuODYxIDk4LjA5ODggMTc5LjgyNSA5Ny4zNzAzIDE3OC41MjIgOTYuODMzNUMxNzcuMjE4IDk2LjI1ODMgMTc1Ljc5OSA5NS43NzkxIDE3NC4yNjYgOTUuMzk1NkMxNzIuNzMyIDk1LjAxMjIgMTcxLjE3OSA5NC42MDk2IDE2OS42MDcgOTQuMTg3OUMxNjguMDczIDkzLjcyNzggMTY2LjY1NSA5My4xNzE4IDE2NS4zNTEgOTIuNTJDMTY0LjA0OCA5MS44Mjk4IDE2My4wMTIgOTAuOTI4OCAxNjIuMjQ1IDg5LjgxNjhDMTYxLjUxNyA4OC42NjY2IDE2MS4xOTEgODcuMTkwNCAxNjEuMjY4IDg1LjM4ODNDMTYxLjM0NCA4My43MDEyIDE2MS45MiA4Mi4yNjM0IDE2Mi45OTMgODEuMDc0OEMxNjQuMTA1IDc5Ljg0NzggMTY1LjU2MiA3OC45MDg0IDE2Ny4zNjQgNzguMjU2NkMxNjkuMjA1IDc3LjYwNDggMTcxLjIxOCA3Ny4yNzg5IDE3My40MDMgNzcuMjc4OUMxNzUuNTg5IDc3LjI3ODkgMTc3LjU2MyA3Ny42ODE1IDE3OS4zMjcgNzguNDg2N0MxODEuMTI5IDc5LjI1MzUgMTgyLjU0OCA4MC40MDM4IDE4My41ODMgODEuOTM3NUMxODQuNjU3IDgzLjQzMjggMTg1LjE1NSA4NS4zMzA4IDE4NS4wNzggODcuNjMxM0gxODIuNDlDMTgyLjU2NyA4Ni4wMjA5IDE4Mi4yNzkgODQuNjk4MSAxODEuNjI3IDgzLjY2MjlDMTgwLjk3NiA4Mi42Mjc2IDE4MC4xNTEgODEuODIyNSAxNzkuMTU0IDgxLjI0NzNDMTc4LjE1OCA4MC42NzIyIDE3Ny4xMjIgODAuMjg4OCAxNzYuMDQ5IDgwLjA5NzFDMTc0Ljk3NSA3OS44NjcgMTc0LjAxNyA3OS43NTIgMTczLjE3MyA3OS43NTJDMTcwLjUyNyA3OS43NTIgMTY4LjM0MiA4MC4zMDc5IDE2Ni42MTYgODEuNDE5OUMxNjQuOTI5IDgyLjQ5MzUgMTY0LjA0OCA4My44NzM4IDE2My45NzEgODUuNTYwOEMxNjMuOTMzIDg3LjAxNzggMTY0LjI5NyA4OC4xODczIDE2NS4wNjQgODkuMDY5MkMxNjUuODMgODkuOTUxIDE2Ni44NjYgOTAuNjYwNCAxNjguMTY5IDkxLjE5NzJDMTY5LjQ3MyA5MS42OTU2IDE3MC44OTIgOTIuMTM2NSAxNzIuNDI1IDkyLjUyQzE3My45OTcgOTIuOTAzNCAxNzUuNTUgOTMuMzA2IDE3Ny4wODQgOTMuNzI3OEMxNzguNjE4IDk0LjE0OTUgMTgwLjAxNyA5NC43MDU1IDE4MS4yODIgOTUuMzk1NkMxODIuNTg2IDk2LjA4NTggMTgzLjYwMiA5Ny4wMjUyIDE4NC4zMzEgOTguMjEzOEMxODUuMDk3IDk5LjM2NDEgMTg1LjQ2MiAxMDAuODU5IDE4NS40MjMgMTAyLjdDMTg1LjM0NyAxMDQuNDY0IDE4NC43NzIgMTA1Ljk1OSAxODMuNjk4IDEwNy4xODZDMTgyLjYyNCAxMDguNDEzIDE4MS4yMDYgMTA5LjM1MiAxNzkuNDQyIDExMC4wMDRDMTc3LjY3OCAxMTAuNjU2IDE3NS42ODQgMTEwLjk4MiAxNzMuNDYxIDExMC45ODJaTTE5My45NzggMTEwLjIzNEgxOTEuMzlWNzcuODU0SDE5My45NzhWMTEwLjIzNFpNMTkzLjUxOCA3Ny44NTRIMjAzLjM1M0MyMDYuODggNzcuODU0IDIwOS41NDUgNzguNjk3NiAyMTEuMzQ3IDgwLjM4NDZDMjEzLjE0OSA4Mi4wMzMzIDIxNC4wNSA4NC41NDQ4IDIxNC4wNSA4Ny45MTg5QzIxNC4wNSA5MS4wNjMgMjEzLjExMSA5My40OTc3IDIxMS4yMzIgOTUuMjIzMUMyMDkuMzkyIDk2LjkxMDIgMjA2Ljc2NSA5Ny43NTM3IDIwMy4zNTMgOTcuNzUzN0gxOTMuNDZWOTUuNTEwN0gyMDMuMTIzQzIwNS44ODMgOTUuNTEwNyAyMDcuOTkyIDk0Ljg3OCAyMDkuNDQ5IDkzLjYxMjdDMjEwLjkwNiA5Mi4zNDc0IDIxMS42MzUgOTAuNDMwMyAyMTEuNjM1IDg3Ljg2MTRDMjExLjYzNSA4NS4yNTQxIDIxMC45MjUgODMuMzE3OCAyMDkuNTA3IDgyLjA1MjVDMjA4LjA4OCA4MC43ODcyIDIwNS45NiA4MC4xNTQ2IDIwMy4xMjMgODAuMTU0NkgxOTMuNTE4Vjc3Ljg1NFpNMjE4LjczIDExMC4yMzRWNzcuODU0SDIzOS43OFY4MC4yNjk2SDIyMS4zNzZWOTIuNjkyNUgyMzYuMVY5NC45OTMxSDIyMS4zNzZWMTA3Ljk5MUgyNDAuMjk4VjExMC4yMzRIMjE4LjczWk0yNjkuMDQyIDc3Ljg1NEgyNzEuNjNWMTEwLjIzNEgyNjYuNzk5TDI0OC4xNjQgNzkuMDYxOEgyNDguMDQ5VjExMC4yMzRIMjQ1LjQ2MVY3Ny44NTRIMjUwLjAwNUwyNjguOTI3IDEwOS4wMjZIMjY5LjA0MlY3Ny44NTRaTTI4MS41OTkgMTA3LjkzNEMyODMuMzYzIDEwNy45MzQgMjg0LjcyNCAxMDcuOTM0IDI4NS42ODIgMTA3LjkzNEMyODYuNjQxIDEwNy45MzQgMjg3LjM1IDEwNy45MzQgMjg3LjgxIDEwNy45MzRDMjkwLjE4OCAxMDcuOTM0IDI5Mi4zNTQgMTA3Ljc0MiAyOTQuMzA5IDEwNy4zNThDMjk2LjI2NSAxMDYuOTc1IDI5Ny45NTIgMTA2LjI4NSAyOTkuMzcxIDEwNS4yODhDMzAwLjc4OSAxMDQuMjUzIDMwMS44NjMgMTAyLjgzNCAzMDIuNTkxIDEwMS4wMzJDMzAzLjMyIDk5LjE5MTUgMzAzLjY4NCA5Ni44NTI3IDMwMy42ODQgOTQuMDE1M0MzMDMuNjg0IDkwLjg3MTMgMzAzLjAzMiA4OC4yODMxIDMwMS43MjkgODYuMjUxQzMwMC40MjUgODQuMjE4OSAyOTguNTg1IDgyLjcwNDMgMjk2LjIwNyA4MS43MDc0QzI5My44MyA4MC43MTA1IDI5MS4wMzEgODAuMjEyMSAyODcuODEgODAuMjEyMUgyODEuNTk5VjEwNy45MzRaTTMwNi41MDIgOTQuMDE1M0MzMDYuNTAyIDk4LjI3MTMgMzA1LjY5NyAxMDEuNTg4IDMwNC4wODcgMTAzLjk2NUMzMDIuNTE1IDEwNi4zMDQgMzAwLjI5MSAxMDcuOTM0IDI5Ny40MTUgMTA4Ljg1NEMyOTQuNTc4IDEwOS43NzQgMjkxLjIyMyAxMTAuMjM0IDI4Ny4zNSAxMTAuMjM0QzI4Ni43NzUgMTEwLjIzNCAyODUuODM2IDExMC4yMzQgMjg0LjUzMiAxMTAuMjM0QzI4My4yMjkgMTEwLjIzNCAyODEuMzY5IDExMC4yMzQgMjc4Ljk1MyAxMTAuMjM0Vjc3Ljg1NEMyODEuNTIyIDc3Ljg1NCAyODMuNDM5IDc3Ljg1NCAyODQuNzA1IDc3Ljg1NEMyODUuOTcgNzcuODU0IDI4Ni44NTIgNzcuODU0IDI4Ny4zNSA3Ny44NTRDMjkxLjIyMyA3Ny44NTQgMjk0LjU3OCA3OC40Njc1IDI5Ny40MTUgNzkuNjk0NUMzMDAuMjkxIDgwLjg4MzEgMzAyLjUxNSA4Mi42NjYgMzA0LjA4NyA4NS4wNDMyQzMwNS42OTcgODcuNDIwNCAzMDYuNTAyIDkwLjQxMTEgMzA2LjUwMiA5NC4wMTUzWk0zMjMuNTkxIDExMC45ODJDMzIxLjkwNCAxMTAuOTgyIDMyMC4yNzQgMTEwLjc3MSAzMTguNzAyIDExMC4zNDlDMzE3LjEzIDEwOS45NjYgMzE1LjY5MyAxMDkuMzUyIDMxNC4zODkgMTA4LjUwOUMzMTMuMDg1IDEwNy42MjcgMzEyLjA1IDEwNi41MzQgMzExLjI4MyAxMDUuMjNDMzEwLjUxNiAxMDMuODg4IDMxMC4xMTQgMTAyLjI3OCAzMTAuMDc1IDEwMC4zOTlIMzEyLjcyMUMzMTIuNzIxIDEwMC41MTQgMzEyLjc1OSAxMDAuODU5IDMxMi44MzYgMTAxLjQzNUMzMTIuOTEzIDEwMi4wMSAzMTMuMTA0IDEwMi43IDMxMy40MTEgMTAzLjUwNUMzMTMuNzU2IDEwNC4zMSAzMTQuMzEyIDEwNS4wOTYgMzE1LjA3OSAxMDUuODYzQzMxNS44ODQgMTA2LjYzIDMxNi45NzcgMTA3LjI4MiAzMTguMzU3IDEwNy44MTlDMzE5Ljc3NiAxMDguMzE3IDMyMS41NzggMTA4LjU2NiAzMjMuNzY0IDEwOC41NjZDMzI1Ljc1NyAxMDguNTY2IDMyNy4zNDkgMTA4LjMzNiAzMjguNTM3IDEwNy44NzZDMzI5Ljc2NCAxMDcuMzc4IDMzMC42ODQgMTA2Ljc4MyAzMzEuMjk4IDEwNi4wOTNDMzMxLjkxMSAxMDUuMzY1IDMzMi4zMTQgMTA0LjY3NCAzMzIuNTA2IDEwNC4wMjNDMzMyLjczNiAxMDMuMzcxIDMzMi44NTEgMTAyLjg3MiAzMzIuODUxIDEwMi41MjdDMzMyLjg4OSAxMDEuMDcgMzMyLjUyNSA5OS45MDA5IDMzMS43NTggOTkuMDE5QzMzMC45OTEgOTguMDk4OCAzMjkuOTU2IDk3LjM3MDMgMzI4LjY1MiA5Ni44MzM1QzMyNy4zNDkgOTYuMjU4MyAzMjUuOTMgOTUuNzc5MSAzMjQuMzk2IDk1LjM5NTZDMzIyLjg2MyA5NS4wMTIyIDMyMS4zMSA5NC42MDk2IDMxOS43MzggOTQuMTg3OUMzMTguMjA0IDkzLjcyNzggMzE2Ljc4NSA5My4xNzE4IDMxNS40ODIgOTIuNTJDMzE0LjE3OCA5MS44Mjk4IDMxMy4xNDMgOTAuOTI4OCAzMTIuMzc2IDg5LjgxNjhDMzExLjY0NyA4OC42NjY2IDMxMS4zMjEgODcuMTkwNCAzMTEuMzk4IDg1LjM4ODNDMzExLjQ3NSA4My43MDEyIDMxMi4wNSA4Mi4yNjM0IDMxMy4xMjQgODEuMDc0OEMzMTQuMjM2IDc5Ljg0NzggMzE1LjY5MyA3OC45MDg0IDMxNy40OTUgNzguMjU2NkMzMTkuMzM1IDc3LjYwNDggMzIxLjM0OCA3Ny4yNzg5IDMyMy41MzQgNzcuMjc4OUMzMjUuNzE5IDc3LjI3ODkgMzI3LjY5NCA3Ny42ODE1IDMyOS40NTcgNzguNDg2N0MzMzEuMjYgNzkuMjUzNSAzMzIuNjc4IDgwLjQwMzggMzMzLjcxMyA4MS45Mzc1QzMzNC43ODcgODMuNDMyOCAzMzUuMjg1IDg1LjMzMDggMzM1LjIwOSA4Ny42MzEzSDMzMi42MjFDMzMyLjY5NyA4Ni4wMjA5IDMzMi40MSA4NC42OTgxIDMzMS43NTggODMuNjYyOUMzMzEuMTA2IDgyLjYyNzYgMzMwLjI4MiA4MS44MjI1IDMyOS4yODUgODEuMjQ3M0MzMjguMjg4IDgwLjY3MjIgMzI3LjI1MyA4MC4yODg4IDMyNi4xNzkgODAuMDk3MUMzMjUuMTA2IDc5Ljg2NyAzMjQuMTQ3IDc5Ljc1MiAzMjMuMzAzIDc5Ljc1MkMzMjAuNjU4IDc5Ljc1MiAzMTguNDcyIDgwLjMwNzkgMzE2Ljc0NyA4MS40MTk5QzMxNS4wNiA4Mi40OTM1IDMxNC4xNzggODMuODczOCAzMTQuMTAxIDg1LjU2MDhDMzE0LjA2MyA4Ny4wMTc4IDMxNC40MjcgODguMTg3MyAzMTUuMTk0IDg5LjA2OTJDMzE1Ljk2MSA4OS45NTEgMzE2Ljk5NiA5MC42NjA0IDMxOC4zIDkxLjE5NzJDMzE5LjYwMyA5MS42OTU2IDMyMS4wMjIgOTIuMTM2NSAzMjIuNTU2IDkyLjUyQzMyNC4xMjggOTIuOTAzNCAzMjUuNjgxIDkzLjMwNiAzMjcuMjE0IDkzLjcyNzhDMzI4Ljc0OCA5NC4xNDk1IDMzMC4xNDggOTQuNzA1NSAzMzEuNDEzIDk1LjM5NTZDMzMyLjcxNyA5Ni4wODU4IDMzMy43MzMgOTcuMDI1MiAzMzQuNDYxIDk4LjIxMzhDMzM1LjIyOCA5OS4zNjQxIDMzNS41OTIgMTAwLjg1OSAzMzUuNTU0IDEwMi43QzMzNS40NzcgMTA0LjQ2NCAzMzQuOTAyIDEwNS45NTkgMzMzLjgyOCAxMDcuMTg2QzMzMi43NTUgMTA4LjQxMyAzMzEuMzM2IDEwOS4zNTIgMzI5LjU3MiAxMTAuMDA0QzMyNy44MDkgMTEwLjY1NiAzMjUuODE1IDExMC45ODIgMzIzLjU5MSAxMTAuOTgyWiIgZmlsbD0iIzI3MjcyNyIvPgo8L3N2Zz4K" diff --git a/packages/charts2/src/core/theme/registry.ts b/packages/charts2/src/core/theme/registry.ts new file mode 100644 index 00000000000..dd2346f72d5 --- /dev/null +++ b/packages/charts2/src/core/theme/registry.ts @@ -0,0 +1,39 @@ +/** + * Theme registry (spec 04 §4). + * + * Exactly one theme is active per chart; the default comes from context + * (embedding site or CLI flag) and is overridable per chart definition. + * Lookup never throws and never logs: an unknown name falls back to the + * default theme and reports a Diagnostic-style warning string so callers + * decide how to surface it. + */ + +import type { Theme } from "./types.ts" +import { buildCanadaTheme, canadaSpendsTheme } from "./themes.ts" + +export const DEFAULT_THEME_NAME = "build-canada" + +const registry: ReadonlyMap = new Map( + [buildCanadaTheme, canadaSpendsTheme].map((theme) => [theme.name, theme]), +) + +export interface ThemeLookup { + theme: Theme + /** Present only when `name` was unknown and the default was substituted. */ + warning?: string +} + +/** Registered theme names, in registration order. */ +export function themeNames(): string[] { + return [...registry.keys()] +} + +export function getTheme(name?: string): ThemeLookup { + if (name === undefined) return { theme: buildCanadaTheme } + const theme = registry.get(name) + if (theme !== undefined) return { theme } + return { + theme: buildCanadaTheme, + warning: `Unknown theme "${name}"; falling back to "${DEFAULT_THEME_NAME}"`, + } +} diff --git a/packages/charts2/src/core/theme/themes.test.ts b/packages/charts2/src/core/theme/themes.test.ts new file mode 100644 index 00000000000..9fd7f755881 --- /dev/null +++ b/packages/charts2/src/core/theme/themes.test.ts @@ -0,0 +1,82 @@ +import { auburn, lake } from "@buildcanada/colours" +import { describe, expect, it } from "vitest" + +import type { Theme } from "./types.ts" +import { buildCanadaTheme, canadaSpendsTheme, grapherDistinctLinesPalette, grapherDistinctPalette } from "./themes.ts" +import { DEFAULT_THEME_NAME, getTheme, themeNames } from "./registry.ts" + +describe("themes", () => { + it("both themes satisfy the frozen Theme contract", () => { + const themes: Theme[] = [buildCanadaTheme, canadaSpendsTheme] + expect(themes.map((t) => t.name)).toEqual([ + "build-canada", + "canada-spends", + ]) + }) + + it("buildCanadaTheme uses the charts package distinct lines palette", () => { + expect(buildCanadaTheme.palette.categorical).toBe(grapherDistinctLinesPalette) + expect(buildCanadaTheme.palette.categorical[0]).toBe("#4c6a9c") + expect(buildCanadaTheme.palette.sequentialScale).toBe(lake) + }) + + it("canadaSpendsTheme uses the charts package distinct palette", () => { + expect(canadaSpendsTheme.palette.categorical).toBe(grapherDistinctPalette) + expect(canadaSpendsTheme.palette.categorical[0]).toBe("#4c6a9c") + expect(canadaSpendsTheme.palette.sequentialScale).toBe(auburn) + }) + + it("categorical palettes cover production chart defaults", () => { + expect(buildCanadaTheme.palette.categorical).toHaveLength(24) + expect(canadaSpendsTheme.palette.categorical).toHaveLength(12) + }) + + it("themes declare a top-right logo", () => { + expect(buildCanadaTheme.branding.logo).toBe("build-canada-square") + expect(canadaSpendsTheme.branding.logo).toBe("canada-spends") + }) + + it("noData is a reserved neutral, never in the categorical palette", () => { + for (const theme of [buildCanadaTheme, canadaSpendsTheme]) { + expect(theme.palette.categorical).not.toContain(theme.palette.noData) + } + }) + + it("uses the exact font family names from the metrics tables", () => { + const { fonts } = buildCanadaTheme.typography + expect(fonts.heading.stack).toBe( + "\"Söhne Kräftig\", \"Helvetica Neue\", Arial, sans-serif", + ) + expect(fonts.heading.metricsId).toBe("soehne-kraftig") + expect(fonts.body.metricsId).toBe("soehne-kraftig") + expect(fonts.mono.stack).toBe( + "\"Founders Grotesk Mono\", Menlo, monospace", + ) + expect(fonts.mono.metricsId).toBe("founders-grotesk-mono-regular") + }) +}) + +describe("getTheme", () => { + it("defaults to build-canada when no name is given", () => { + const { theme, warning } = getTheme() + expect(theme).toBe(buildCanadaTheme) + expect(warning).toBeUndefined() + }) + + it("resolves registered themes by name without warnings", () => { + expect(getTheme("build-canada").theme).toBe(buildCanadaTheme) + expect(getTheme("canada-spends").theme).toBe(canadaSpendsTheme) + expect(getTheme("canada-spends").warning).toBeUndefined() + }) + + it("falls back to the default with a warning for unknown names", () => { + const { theme, warning } = getTheme("not-a-theme") + expect(theme).toBe(buildCanadaTheme) + expect(warning).toContain("not-a-theme") + expect(warning).toContain(DEFAULT_THEME_NAME) + }) + + it("lists registered theme names", () => { + expect(themeNames()).toEqual(["build-canada", "canada-spends"]) + }) +}) diff --git a/packages/charts2/src/core/theme/themes.ts b/packages/charts2/src/core/theme/themes.ts new file mode 100644 index 00000000000..75edd871556 --- /dev/null +++ b/packages/charts2/src/core/theme/themes.ts @@ -0,0 +1,130 @@ +/** + * Built-in themes (spec 04 §4). + * + * Build Canada and Canada Spends ship as the first two themes. A new brand + * is a new Theme document here (plus, optionally, new palettes in + * @buildcanada/colours) — zero chart-code changes. + * + * Categorical palettes mirror the production `charts` Grapher defaults + * (Distinct and Distinct lines). Sequential scales still come from + * @buildcanada/colours by identity. + */ + +import { auburn, charcoal, lake, linen, nickel } from "@buildcanada/colours" + +import type { HexColour } from "../types.ts" +import type { Theme } from "./types.ts" + +const soehneStack = "\"Söhne Kräftig\", \"Helvetica Neue\", Arial, sans-serif" +const monoStack = "\"Founders Grotesk Mono\", Menlo, monospace" + +// Ported from packages/charts/src/grapher/color/CustomSchemes.ts: +// Grapher's Distinct (Palette A) and Distinct lines palettes are the current +// production defaults for multi-series charts. +export const grapherDistinctPalette: readonly HexColour[] = [ + "#4c6a9c", + "#883039", + "#578145", + "#b13507", + "#b16214", + "#970046", + "#d73c50", + "#00295b", + "#00847e", + "#bc8e5a", + "#a2559c", + "#18470f", +] + +export const grapherDistinctLinesPalette: readonly HexColour[] = [ + "#4c6a9c", + "#b13507", + "#996d39", + "#2c8465", + "#6d3e91", + "#883039", + "#00295b", + "#a2559c", + "#9a5129", + "#008291", + "#970046", + "#338711", + "#c4523e", + "#286bbb", + "#18470f", + "#d73c50", + "#b16214", + "#00847e", + "#cf0a66", + "#578145", + "#be5915", + "#8c4569", + "#00875e", + "#c15065", +] + +/** Shared typography: both brands set in Söhne with Founders Grotesk Mono. */ +const typography: Theme["typography"] = { + fonts: { + heading: { stack: soehneStack, metricsId: "soehne-kraftig" }, + body: { stack: soehneStack, metricsId: "soehne-kraftig" }, + mono: { stack: monoStack, metricsId: "founders-grotesk-mono-regular" }, + }, + baseSizePx: 16, +} + +export const buildCanadaTheme: Theme = { + name: "build-canada", + palette: { + categorical: grapherDistinctLinesPalette, + noData: nickel["300"], + dimOpacity: 0.35, + sequentialScale: lake, + }, + branding: { + logo: "build-canada-square", + }, + typography, + chrome: { + background: linen["50"], + gridline: nickel["200"], + axisLine: nickel["400"], + tickLabel: charcoal["600"], + title: charcoal["1000"], + subtitle: charcoal["700"], + padding: { top: 16, right: 16, bottom: 16, left: 16 }, + }, + attribution: { + text: "Powered by Build Canada Charts", + url: "https://buildcanada.com", + }, + localeDefault: "en", +} + +export const canadaSpendsTheme: Theme = { + name: "canada-spends", + palette: { + categorical: grapherDistinctPalette, + noData: nickel["300"], + dimOpacity: 0.35, + sequentialScale: auburn, + }, + branding: { + logo: "canada-spends", + }, + typography, + chrome: { + background: "#ffffff", + gridline: charcoal["200"], + axisLine: charcoal["400"], + tickLabel: charcoal["600"], + title: charcoal["1000"], + subtitle: charcoal["700"], + padding: { top: 16, right: 16, bottom: 16, left: 16 }, + }, + attribution: { + text: "Canada Spends", + url: "https://canadaspends.com", + }, + localeDefault: "en", +} diff --git a/packages/charts2/src/core/theme/types.ts b/packages/charts2/src/core/theme/types.ts new file mode 100644 index 00000000000..c028adf3c4c --- /dev/null +++ b/packages/charts2/src/core/theme/types.ts @@ -0,0 +1,62 @@ +/** + * Frozen theme contract (spec 04 §4). + * + * A theme bundles every brand visual decision: series colours, typography, + * chrome, attribution, and the brand mark rendered into chart output. + */ + +import type { HexColour, Locale } from "../types.ts" +import type { FontRole } from "../text/measurer.ts" + +export interface FontFamilyDef { + /** CSS font-family stack; first entry is the brand font family name. */ + stack: string + /** Metrics table id under src/fonts/metrics/ (e.g. "soehne-kraftig"). */ + metricsId: string +} + +export interface ThemePalette { + /** Ordered categorical palette used for series assignment. */ + categorical: readonly HexColour[] + /** Reserved neutral for missing data; never assigned to a series. */ + noData: HexColour + /** Opacity applied to dimmed (non-emphasized) series. */ + dimOpacity: number + /** Named 50–950 scale used for sequential ramps (future maps pass). */ + sequentialScale: Readonly> +} + +export interface ThemeChrome { + background: HexColour + plotBackground?: HexColour + gridline: HexColour + axisLine: HexColour + tickLabel: HexColour + title: HexColour + subtitle: HexColour + /** Frame padding in px. */ + padding: { top: number; right: number; bottom: number; left: number } +} + +export interface ThemeAttribution { + text: string + url?: string + licenseText?: string +} + +export interface ThemeBranding { + logo: "build-canada-square" | "canada-spends" +} + +export interface Theme { + name: string + palette: ThemePalette + branding: ThemeBranding + typography: { + fonts: Record + baseSizePx: number + } + chrome: ThemeChrome + attribution: ThemeAttribution + localeDefault: Locale +} diff --git a/packages/charts2/src/core/types.ts b/packages/charts2/src/core/types.ts new file mode 100644 index 00000000000..8c030550a28 --- /dev/null +++ b/packages/charts2/src/core/types.ts @@ -0,0 +1,315 @@ +/** + * Frozen shared contracts for @buildcanada/charts2. + * + * These types are the interfaces between the milestone work packages + * (data layer, formatting, theme, text, layout, react, cli). Changes here + * require coordination — implementation modules depend on these shapes. + * + * Normative behavior lives in bcds/specs/: 01 (data format), 02 (chart + * definition), 03 (axes/formatting), 08 (time), 24 (CLI rendering). + */ + +// --------------------------------------------------------------------------- +// Scalars +// --------------------------------------------------------------------------- + +export type Locale = "en" | "fr" + +/** A series identity: entity name, metric slug, or "Entity – Metric". */ +export type SeriesKey = string + +/** Resolved colour: always a hex string by the time it reaches a scene. */ +export type HexColour = string + +// --------------------------------------------------------------------------- +// Time (spec 01 §3, spec 08) +// --------------------------------------------------------------------------- + +export type TimeGrain = "year" | "fiscal-year" | "quarter" | "month" | "date" | "none" + +/** + * Times are integer ordinals, uniform per grain: + * year → the year (2024) + * fiscal-year → the start year (2024 for "2024-25") + * quarter → year * 4 + (q - 1) + * month → year * 12 + (m - 1) + * date → days since 1970-01-01 (UTC, no timezone math) + * none → no time column; ordinals never occur + * Integer math keeps tolerance, snapping, and playback deterministic. + * Display strings derive from (ordinal, grain) via core/format/timeLabels. + */ +export type TimeOrdinal = number + +export type TimeBound = TimeOrdinal | "earliest" | "latest" + +export interface TimeSelection { + start: TimeBound + end: TimeBound +} + +// --------------------------------------------------------------------------- +// Manifest (spec 01 §4–5) +// --------------------------------------------------------------------------- + +export type ColumnType = + | "numeric" + | "integer" + | "percentage" + | "currency" + | "categorical" + | "ordinal" + +export type ToleranceDirection = "both" | "backwards" | "forwards" + +export interface ColumnMeta { + name: string + type: ColumnType + unit?: string + shortUnit?: string + /** ISO currency code when type is "currency". Default "CAD". */ + currency?: string + /** Multiplier applied for display only. Default 1. Applied AFTER denominator division. */ + displayFactor: number + decimals?: number + /** Max distance (grain units) to borrow a value from a neighbouring time. Default 0. */ + tolerance: number + toleranceDirection: ToleranceDirection + /** All values in this column are forecasts. */ + projection: boolean + /** Values at/after this ordinal are forecasts (alternative to `projection`). */ + projectionFrom?: TimeOrdinal + /** Column slug to divide by, per (entity, time) cell. Spec 01 §7. */ + denominator?: string + derivedUnit?: string + derivedShortUnit?: string + /** Fixed series colour (token or hex). */ + colour?: string + /** Explicit value ordering for type "ordinal". */ + order?: string[] + description?: string + /** Index into Manifest.sources when columns differ in provenance. */ + source?: number +} + +export interface EntityMeta { + name: string + code?: string + nameFr?: string + /** Alternate names resolving to this entity (renames, abbreviations). */ + aliases?: string[] + /** Grouping for the entity picker. */ + group?: string + /** Persistent colour token for this entity across all charts. */ + colour?: string +} + +export interface SourceMeta { + name: string + url?: string + publisher?: string + retrieved?: string + citation?: string + license?: string +} + +export interface Manifest { + name: string + title?: string + timeGrain: TimeGrain + /** 1–12. Default 4 (April) for Canadian fiscal years. */ + fiscalYearStartMonth: number + entity: { + /** Singular noun used in UI copy, e.g. "province". */ + label: string + labelPlural: string + /** Optional link to an entity registry kind (future maps pass). */ + kind?: string + } + columns: Record + /** Extra dimension columns for long-format data (spec 01 §6). */ + dimensions?: string[] + entities?: EntityMeta[] + sources: SourceMeta[] +} + +// --------------------------------------------------------------------------- +// Dataset (spec 01 §2) — produced by core/data, consumed by layout and table +// --------------------------------------------------------------------------- + +export type CellValue = number | string | null + +export interface ColumnData { + slug: string + meta: ColumnMeta + /** Row-aligned with Dataset.rows ordering. null = missing (never zero). */ + values: readonly CellValue[] +} + +export interface Dataset { + manifest: Manifest + /** Canonical entity order (order of first appearance). */ + entities: readonly string[] + /** Sorted unique time ordinals. Empty when grain is "none". */ + times: readonly TimeOrdinal[] + /** Row index lookup: rowIndexOf(entity, time) → row, or -1. */ + rowIndexOf: (entity: string, time: TimeOrdinal | null) => number + columns: ReadonlyMap +} + +export type MissingReason = "no-data" | "zero-denominator" | "non-positive-on-log" + +/** + * THE data-access contract every chart, tooltip, and table cell consumes. + * "missing ≠ zero" lives here: a missing cell is status "missing", never 0. + */ +export type ResolvedValue = + | { + status: "value" + /** Display value: raw → ÷denominator → ×displayFactor. */ + value: number + /** The requested time. */ + time: TimeOrdinal + /** The time the value actually came from. ≠ time ⇒ toleranced. */ + sourceTime: TimeOrdinal + projected: boolean + interpolated: boolean + /** Present for denominator-derived cells (auditability, spec 01 §7). */ + raw?: { numerator: number; denominator: number } + } + | { status: "missing"; reason: MissingReason } + +// --------------------------------------------------------------------------- +// Chart definition (spec 02) +// --------------------------------------------------------------------------- + +export type ChartType = + | "line" + | "discrete-bar" + | "stacked-area" + | "stacked-bar" + | "stacked-discrete-bar" + +export type Tab = ChartType | "table" + +export type ScaleType = "linear" | "log" + +export interface AxisConfig { + min?: number | "auto" + max?: number | "auto" + scale?: ScaleType + /** Expose the linear/log toggle to readers. */ + canToggleScale?: boolean + label?: string + hideGridlines?: boolean + hideTickLabels?: boolean +} + +export type SortBy = "total" | "name" | "column" | "change" | "custom" +export type SortOrder = "asc" | "desc" + +export interface SortConfig { + by: SortBy + order: SortOrder + column?: string +} + +export type StackMode = "absolute" | "relative" +export type FacetStrategy = "none" | "entity" | "metric" +export type MissingDataStrategy = "auto" | "hide" | "show" +export type SelectionMode = "multi" | "single" | "fixed" +export type SeriesStrategy = "entity" | "metric" + +export interface ComparisonLine { + /** Horizontal line at this y value. */ + y?: number + /** Vertical line at this time ordinal. */ + x?: TimeOrdinal + label?: string +} + +export interface TitleAnnotations { + entity: boolean + time: boolean + changePrefix: boolean +} + +export interface ChartDefinition { + schemaVersion: number + slug?: string + title: string + subtitle?: string + note?: string + sourceText?: string + titleAnnotations: TitleAnnotations + + /** Dataset reference: name, path, or URL. */ + data: string + /** Metric column slugs (≥1). */ + y: string[] + /** Dimension filters for long-format datasets. */ + filter?: Record + /** Per-binding overrides of column metadata. */ + bindings?: Record> + + /** Ordered chart types this definition supports. */ + types: ChartType[] + defaultTab?: Tab + + selectedEntities?: string[] + includedEntities?: string[] + excludedEntities?: string[] + entityColours?: Record + selectionMode: SelectionMode + focusedSeries?: SeriesKey[] + + time?: TimeSelection + timelineRange?: TimeSelection + hideTimeline: boolean + + xAxis?: AxisConfig + yAxis?: AxisConfig + stackMode: StackMode + sort?: SortConfig + facet: FacetStrategy + missingData: MissingDataStrategy + comparisonLines?: ComparisonLine[] + seriesStrategy?: SeriesStrategy + + hideLegend: boolean + hideSeriesLabels: boolean + hideRelativeToggle: boolean + hideTotalLabel: boolean + + theme?: string + locale?: Locale +} + +// --------------------------------------------------------------------------- +// View state (spec 02 §3) — reader state layered over the definition. +// Round-trips through the URL. NEVER mutates the definition. +// Hover is NOT view state: emphasis styling never relayouts the chart. +// --------------------------------------------------------------------------- + +export interface ViewState { + tab?: Tab + time?: TimeSelection + entities?: string[] + focus?: SeriesKey[] + yScale?: ScaleType + stackMode?: StackMode + facet?: FacetStrategy + tableSort?: { column: string; order: SortOrder } + tableScope?: "selected" | "all" +} + +// --------------------------------------------------------------------------- +// Layout warnings — non-fatal diagnostics surfaced by layout and validation +// --------------------------------------------------------------------------- + +export interface Diagnostic { + severity: "warning" | "error" + code: string + message: string + /** e.g. row number, entity name, column slug */ + context?: Record +} diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg new file mode 100644 index 00000000000..2ada662f1af --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg @@ -0,0 +1,2 @@ + +Population by province and territorySource: Statistics CanadaPowered by Build Canada Charts02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg new file mode 100644 index 00000000000..f51a4ec735e --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg @@ -0,0 +1,2 @@ + +Population by province andterritoryPowered by Build Canada Charts02M4M6M8M12M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg new file mode 100644 index 00000000000..c59966e45ea --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg @@ -0,0 +1,2 @@ + +Population by province and territorySource: Statistics CanadaPowered by Build Canada Charts02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg new file mode 100644 index 00000000000..23cb2c0c120 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg @@ -0,0 +1,2 @@ + +Net balance by place, 2021Source: Synthetic test dataPowered by Build Canada Charts−10−8−6−4−20Î.-P.-É.−1Québec−6Lonely Station−9 diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg new file mode 100644 index 00000000000..617e33807a2 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg @@ -0,0 +1,2 @@ + +Population by province and territorySource: Statistics CanadaPowered by Build Canada Charts02M4M6M8M10M12M14M16MAlberta5MBritish Columbia6MManitoba1MNew Brunswick832kNova Scotia1MOntario16MQuebec9MSaskatchewan1M diff --git a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg new file mode 100644 index 00000000000..ebe95a7e544 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg @@ -0,0 +1,2 @@ + +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--default--300x160.svg b/packages/charts2/src/corpus/__golden__/line--default--300x160.svg new file mode 100644 index 00000000000..2a800c2a0ad --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--default--300x160.svg @@ -0,0 +1,2 @@ + +Provincial budget spending,2019–20 to 2024–25Powered by Build Canada Charts$0.0$100.0$200.02019–202022–232024–25Ontario diff --git a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg new file mode 100644 index 00000000000..35e812e0efe --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg @@ -0,0 +1,2 @@ + +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg new file mode 100644 index 00000000000..d917e43ae0d --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg @@ -0,0 +1,2 @@ + +Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg new file mode 100644 index 00000000000..3366f66b8c6 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg @@ -0,0 +1,2 @@ + +Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of CanadaPowered by Build Canada Charts$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada diff --git a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg new file mode 100644 index 00000000000..dfb36f4fddf --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg @@ -0,0 +1,2 @@ + +Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg new file mode 100644 index 00000000000..b0fcb3823b1 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg @@ -0,0 +1,2 @@ + +Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta diff --git a/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg b/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg new file mode 100644 index 00000000000..9b84b9125ba --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg @@ -0,0 +1,2 @@ + +Provincial budget spending, 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5Quebec$161.0British Columbia$84.2Alberta$71.2Nova Scotia$16.5 diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg new file mode 100644 index 00000000000..e735d844509 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg @@ -0,0 +1,2 @@ + +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg new file mode 100644 index 00000000000..d094b43598c --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg @@ -0,0 +1,2 @@ + +Government debt as a share ofGDP, Canada, 2019–20 to 2023–24Powered by Build Canada Charts0.0%50.0%100.0%2019–202023–24Federal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg new file mode 100644 index 00000000000..ae61313094e --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg @@ -0,0 +1,2 @@ + +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg new file mode 100644 index 00000000000..8a15a6e3113 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg @@ -0,0 +1,2 @@ + +Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg new file mode 100644 index 00000000000..3c7700b834f --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg @@ -0,0 +1,2 @@ + +Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg new file mode 100644 index 00000000000..3484e3b031a --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg @@ -0,0 +1,2 @@ + +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada ChartsFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg new file mode 100644 index 00000000000..e201a630daa --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg @@ -0,0 +1,2 @@ + +Government debt as a share ofGDP, Canada, 2019–20 to 2023–24Powered by Build Canada Charts0.0%50.0%100.0%2019–202021–222023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg new file mode 100644 index 00000000000..fc0aec9fb61 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg @@ -0,0 +1,2 @@ + +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada ChartsFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg new file mode 100644 index 00000000000..b77f1253940 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg @@ -0,0 +1,2 @@ + +Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada ChartsFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg new file mode 100644 index 00000000000..f3843aaf373 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg @@ -0,0 +1,2 @@ + +Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg new file mode 100644 index 00000000000..ae4e021dafa --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg @@ -0,0 +1,2 @@ + +Provincial spending composition,2019–20 to 2024–25Powered by Build Canada Charts$0.0$50.0$150.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg new file mode 100644 index 00000000000..fe6aee4c853 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg @@ -0,0 +1,2 @@ + +Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg new file mode 100644 index 00000000000..317a7573a38 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg @@ -0,0 +1,2 @@ + +Provincial spending composition, 2022–23Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0Ontario$192.9Quebec$147.3British Columbia$73.6Alberta$64.3Nova Scotia$0.7 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg new file mode 100644 index 00000000000..7d75094ec87 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg @@ -0,0 +1,2 @@ + +Change in Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges0%+20%+40%+60%+80%+100%OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/bless.ts b/packages/charts2/src/corpus/bless.ts new file mode 100644 index 00000000000..f3db1bea427 --- /dev/null +++ b/packages/charts2/src/corpus/bless.ts @@ -0,0 +1,37 @@ +/** + * Bless the golden SVG corpus: render every corpus case and write + * __golden__/.svg, removing any stale goldens for deleted cases. + * + * bun src/corpus/bless.ts (or: bun run corpus:bless) + * + * Re-bless ONLY for intentional rendering changes, and review the diffs in + * the same PR (spec 26 §1.3). + */ + +import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import { corpusCases, renderCorpusCase } from "./corpus.ts" + +const goldenDir = join(dirname(fileURLToPath(import.meta.url)), "__golden__") +mkdirSync(goldenDir, { recursive: true }) + +const expected = new Set(corpusCases.map((corpusCase) => `${corpusCase.name}.svg`)) + +let removed = 0 +for (const file of readdirSync(goldenDir)) { + if (file.endsWith(".svg") && !expected.has(file)) { + unlinkSync(join(goldenDir, file)) + removed += 1 + } +} + +for (const corpusCase of corpusCases) { + const svg = renderCorpusCase(corpusCase) + writeFileSync(join(goldenDir, `${corpusCase.name}.svg`), `${svg}\n`) +} + +process.stdout.write( + `blessed ${corpusCases.length} golden(s) in ${goldenDir}${removed > 0 ? `, removed ${removed} stale` : ""}\n`, +) diff --git a/packages/charts2/src/corpus/corpus.test.ts b/packages/charts2/src/corpus/corpus.test.ts new file mode 100644 index 00000000000..b9cf0bf6bca --- /dev/null +++ b/packages/charts2/src/corpus/corpus.test.ts @@ -0,0 +1,86 @@ +/** + * Golden SVG corpus tests (spec 26 §1.3 + §3). + * + * Every corpus case is re-rendered and compared byte-for-byte against its + * committed golden in __golden__/. Cross-cutting invariants run on every + * generated SVG: no NaN/Infinity, no exponent-notation coordinates, light + * XML well-formedness, and same-inputs → same-bytes determinism. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { Window } from "happy-dom" +import { describe, expect, it } from "vitest" + +import { corpusCases, renderCorpusCase, XML_DECLARATION } from "./corpus.ts" + +const goldenDir = join(dirname(fileURLToPath(import.meta.url)), "__golden__") + +const REBLESS = "run `bun src/corpus/bless.ts` to re-bless after intentional changes" + +/** Exponent-notation numeric token like "1e-7" or "2.5E+10". + * The delimiter guards prevent false positives in hex colours such as #be5915. */ +const EXPONENT_COORD = /(?:^|[\s=",])[-+]?\d+(?:\.\d+)?[eE][-+]?\d+(?=$|[\s=",])/ +const DOMParser = new Window().DOMParser + +describe("corpus definition", () => { + it("case names are unique", () => { + const names = corpusCases.map((corpusCase) => corpusCase.name) + expect(new Set(names).size).toBe(names.length) + }) + + it("covers all five chart types at all three sizes", () => { + const types = ["line", "discrete-bar", "stacked-area", "stacked-bar", "stacked-discrete-bar"] + for (const type of types) { + const sizes = new Set( + corpusCases + .filter((corpusCase) => corpusCase.name.startsWith(`${type}--`)) + .map((corpusCase) => `${corpusCase.size.width}x${corpusCase.size.height}`), + ) + expect(sizes, `sizes covered for ${type}`).toEqual(new Set(["300x160", "850x600", "1200x600"])) + } + }) + + it("the golden directory has exactly one .svg per case", () => { + const files = readdirSync(goldenDir) + .filter((file) => file.endsWith(".svg")) + .sort() + const expected = corpusCases.map((corpusCase) => `${corpusCase.name}.svg`).sort() + expect(files, REBLESS).toEqual(expected) + }) +}) + +describe.each(corpusCases.map((corpusCase) => [corpusCase.name, corpusCase] as const))( + "corpus case %s", + (name, corpusCase) => { + const svg = renderCorpusCase(corpusCase) + + it("matches the committed golden byte-for-byte", () => { + const goldenPath = join(goldenDir, `${name}.svg`) + expect(existsSync(goldenPath), `missing golden ${goldenPath} — ${REBLESS}`).toBe(true) + const golden = readFileSync(goldenPath, "utf8") + expect(`${svg}\n`, REBLESS).toBe(golden) + }) + + it("two consecutive generations are byte-identical", () => { + expect(renderCorpusCase(corpusCase)).toBe(svg) + }) + + it("contains no NaN, Infinity, or exponent-notation coordinates", () => { + expect(svg).not.toContain("NaN") + expect(svg).not.toContain("Infinity") + expect(EXPONENT_COORD.test(svg)).toBe(false) + }) + + it("starts with the XML declaration and parses as XML", () => { + expect(svg.startsWith(`${XML_DECLARATION}\n) with the XML declaration prepended. + * Committed references live in __golden__/.svg; re-bless with + * `bun src/corpus/bless.ts` after intentional rendering changes. + */ + +import { createElement } from "react" +import { renderToStaticMarkup } from "react-dom/server" + +import { layoutChart, parseDefinition, resolveDefinitionTimes, type ChromeMode } from "../core/index.ts" +import type { ChartDefinition, ViewState } from "../core/types.ts" +import { loadFixtureDataset, type FixtureName } from "../fixtures/index.ts" +import { SceneSVG } from "../react/SceneSVG.tsx" + +export const XML_DECLARATION = '' + +// --------------------------------------------------------------------------- +// Sizes (spec 26 §1.3: thumbnail / default / wide) +// --------------------------------------------------------------------------- + +export interface CorpusSize { + width: number + height: number +} + +export const THUMBNAIL: CorpusSize = { width: 300, height: 160 } +export const DEFAULT: CorpusSize = { width: 850, height: 600 } +export const WIDE: CorpusSize = { width: 1200, height: 600 } + +export interface CorpusCase { + /** `----x` — also the golden filename and idPrefix. */ + name: string + definition: ChartDefinition + fixture: FixtureName + view?: ViewState + size: CorpusSize + /** Defaults to "thumbnail" for the 300×160 size, "full" otherwise. */ + chrome?: ChromeMode +} + +// --------------------------------------------------------------------------- +// Case construction +// --------------------------------------------------------------------------- + +/** Corpus definitions are committed literals — a parse error is a bug. */ +function defineCorpusDefinition(raw: unknown): ChartDefinition { + const { definition, diagnostics } = parseDefinition(raw) + if (definition === null) { + const messages = diagnostics.map((d) => d.message).join("; ") + throw new Error(`Corpus definition failed to parse: ${messages}`) + } + return definition +} + +function sizeSuffix(size: CorpusSize): string { + return `${size.width}x${size.height}` +} + +interface CaseSpec { + type: string + state: string + fixture: FixtureName + raw: Record + size: CorpusSize + view?: ViewState +} + +function makeCase(spec: CaseSpec): CorpusCase { + return { + name: `${spec.type}--${spec.state}--${sizeSuffix(spec.size)}`, + definition: defineCorpusDefinition(spec.raw), + fixture: spec.fixture, + ...(spec.view !== undefined ? { view: spec.view } : {}), + size: spec.size, + chrome: spec.size === THUMBNAIL ? "thumbnail" : "full", + } +} + +// --------------------------------------------------------------------------- +// Base definitions (literals, one per chart type) +// --------------------------------------------------------------------------- + +const lineBudgets = { + title: "Provincial budget spending", + subtitle: "Total budgetary expenditure, public accounts basis", + data: "provincial-budgets", + y: ["total_spending"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + sourceText: "Provincial public accounts", +} + +const lineManyEntities = { + title: "Federal departmental spending", + data: "federal-departments", + y: ["spending"], + sourceText: "Public Accounts of Canada", +} + +const discreteBarPopulation = { + title: "Population by province and territory", + data: "population-snapshot", + y: ["population"], + types: ["discrete-bar"], + sourceText: "Statistics Canada", +} + +const discreteBarNegatives = { + title: "Net balance by place", + data: "pathological", + y: ["negatives"], + types: ["discrete-bar"], + selectedEntities: ["Québec", "Î.-P.-É.", "Lonely Station"], + time: 2021, +} + +const stackedAreaDebt = { + title: "Government debt as a share of GDP", + subtitle: "Federal, provincial, and municipal debt divided by nominal GDP", + data: "government-debt", + y: ["federal_debt", "provincial_debt", "municipal_debt"], + types: ["stacked-area"], + sourceText: "Fiscal reference tables", +} + +const stackedBarDebt = { + ...stackedAreaDebt, + types: ["stacked-bar"], +} + +const stackedDiscreteBudgets = { + title: "Provincial spending composition", + subtitle: "Program spending and debt charges by province", + data: "provincial-budgets", + y: ["program_spending", "debt_charges"], + types: ["stacked-discrete-bar"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + sourceText: "Provincial public accounts", +} + +// --------------------------------------------------------------------------- +// The corpus +// --------------------------------------------------------------------------- + +export const corpusCases: readonly CorpusCase[] = [ + // --- line (provincial-budgets) ----------------------------------------- + makeCase({ type: "line", state: "default", fixture: "provincial-budgets", raw: lineBudgets, size: THUMBNAIL }), + makeCase({ type: "line", state: "default", fixture: "provincial-budgets", raw: lineBudgets, size: DEFAULT }), + makeCase({ type: "line", state: "default", fixture: "provincial-budgets", raw: lineBudgets, size: WIDE }), + makeCase({ + type: "line", + state: "relative", + fixture: "provincial-budgets", + raw: { ...lineBudgets, stackMode: "relative" }, + size: DEFAULT, + }), + makeCase({ + type: "line", + state: "fr", + fixture: "provincial-budgets", + raw: { ...lineBudgets, locale: "fr" }, + size: DEFAULT, + }), + // Single-time window collapses the line chart to a discrete bar (spec 11). + makeCase({ + type: "line", + state: "single-time", + fixture: "provincial-budgets", + raw: { ...lineBudgets, time: "2024-25" }, + size: DEFAULT, + }), + // program_spending has missing cells without tolerance → visible gaps. + makeCase({ + type: "line", + state: "missing-data", + fixture: "provincial-budgets", + raw: { ...lineBudgets, title: "Provincial program spending", y: ["program_spending"] }, + size: DEFAULT, + }), + makeCase({ + type: "line", + state: "many-entities", + fixture: "federal-departments", + raw: lineManyEntities, + size: WIDE, + }), + + // --- discrete-bar (population-snapshot, grain "none") ------------------- + makeCase({ + type: "discrete-bar", + state: "default", + fixture: "population-snapshot", + raw: discreteBarPopulation, + size: THUMBNAIL, + }), + makeCase({ + type: "discrete-bar", + state: "default", + fixture: "population-snapshot", + raw: discreteBarPopulation, + size: DEFAULT, + }), + makeCase({ + type: "discrete-bar", + state: "default", + fixture: "population-snapshot", + raw: discreteBarPopulation, + size: WIDE, + }), + makeCase({ + type: "discrete-bar", + state: "sort-name", + fixture: "population-snapshot", + raw: { ...discreteBarPopulation, sort: { by: "name", order: "asc" } }, + size: DEFAULT, + }), + // All-negative values from the pathological fixture. + makeCase({ + type: "discrete-bar", + state: "negatives", + fixture: "pathological", + raw: discreteBarNegatives, + size: DEFAULT, + }), + + // --- stacked-area (government-debt, the flagship demo) ------------------ + makeCase({ type: "stacked-area", state: "default", fixture: "government-debt", raw: stackedAreaDebt, size: THUMBNAIL }), + makeCase({ type: "stacked-area", state: "default", fixture: "government-debt", raw: stackedAreaDebt, size: DEFAULT }), + makeCase({ type: "stacked-area", state: "default", fixture: "government-debt", raw: stackedAreaDebt, size: WIDE }), + makeCase({ + type: "stacked-area", + state: "relative", + fixture: "government-debt", + raw: { ...stackedAreaDebt, stackMode: "relative" }, + size: DEFAULT, + }), + makeCase({ + type: "stacked-area", + state: "fr", + fixture: "government-debt", + raw: { ...stackedAreaDebt, locale: "fr" }, + size: DEFAULT, + }), + + // --- stacked-bar (government-debt) --------------------------------------- + makeCase({ type: "stacked-bar", state: "default", fixture: "government-debt", raw: stackedBarDebt, size: THUMBNAIL }), + makeCase({ type: "stacked-bar", state: "default", fixture: "government-debt", raw: stackedBarDebt, size: DEFAULT }), + makeCase({ type: "stacked-bar", state: "default", fixture: "government-debt", raw: stackedBarDebt, size: WIDE }), + makeCase({ + type: "stacked-bar", + state: "relative", + fixture: "government-debt", + raw: { ...stackedBarDebt, stackMode: "relative" }, + size: DEFAULT, + }), + + // --- stacked-discrete-bar (provincial-budgets) --------------------------- + makeCase({ + type: "stacked-discrete-bar", + state: "default", + fixture: "provincial-budgets", + raw: stackedDiscreteBudgets, + size: THUMBNAIL, + }), + makeCase({ + type: "stacked-discrete-bar", + state: "default", + fixture: "provincial-budgets", + raw: stackedDiscreteBudgets, + size: DEFAULT, + }), + makeCase({ + type: "stacked-discrete-bar", + state: "default", + fixture: "provincial-budgets", + raw: stackedDiscreteBudgets, + size: WIDE, + }), + makeCase({ + type: "stacked-discrete-bar", + state: "relative", + fixture: "provincial-budgets", + raw: { ...stackedDiscreteBudgets, stackMode: "relative" }, + size: DEFAULT, + }), + // Nova Scotia 2022-23 is missing program_spending (thinned cells). + makeCase({ + type: "stacked-discrete-bar", + state: "missing-data", + fixture: "provincial-budgets", + raw: { ...stackedDiscreteBudgets, time: "2022-23" }, + size: DEFAULT, + }), +] + +// --------------------------------------------------------------------------- +// Rendering — the CLI pipeline, minus file I/O (spec 24 §3 determinism) +// --------------------------------------------------------------------------- + +/** Render one corpus case to the exact SVG string the CLI would emit. */ +export function renderCorpusCase(corpusCase: CorpusCase): string { + const { dataset } = loadFixtureDataset(corpusCase.fixture) + const resolved = resolveDefinitionTimes(corpusCase.definition, dataset.manifest.timeGrain) + const scene = layoutChart({ + definition: resolved.definition, + dataset, + ...(corpusCase.view !== undefined ? { view: corpusCase.view } : {}), + size: corpusCase.size, + chrome: corpusCase.chrome ?? "full", + }) + const markup = renderToStaticMarkup(createElement(SceneSVG, { scene, idPrefix: corpusCase.name })) + return `${XML_DECLARATION}\n${markup}` +} diff --git a/packages/charts2/src/fixtures/federal-departments.ts b/packages/charts2/src/fixtures/federal-departments.ts new file mode 100644 index 00000000000..3798e3362c6 --- /dev/null +++ b/packages/charts2/src/fixtures/federal-departments.ts @@ -0,0 +1,151 @@ +/** + * Fixture: federal-departments (spec 26 §2). + * Exercises: many entities (15), long names (incl. quoted CSV fields with + * commas), alias resolution, group metadata by portfolio. + * + * Two departments carry aliases and appear in the CSV under their former + * names for early years: + * "Industry Canada" (2019-20, 2020-21) → Innovation, Science and Economic Development Canada + * "DFAIT" (2019-20) → Global Affairs Canada + * + * 15 departments × 5 fiscal years (2019-20 .. 2023-24), spending in billion CAD. + * Values follow a hand-checkable pattern: department i (1-based, manifest + * order) spends i*10 + yearIndex (yearIndex 0 for 2019-20 .. 4 for 2023-24). + */ + +import type { Fixture } from "./types.ts" + +const csv = `entity,time,spending +National Defence,2019-20,10 +National Defence,2020-21,11 +National Defence,2021-22,12 +National Defence,2022-23,13 +National Defence,2023-24,14 +Employment and Social Development Canada,2019-20,20 +Employment and Social Development Canada,2020-21,21 +Employment and Social Development Canada,2021-22,22 +Employment and Social Development Canada,2022-23,23 +Employment and Social Development Canada,2023-24,24 +Indigenous Services Canada,2019-20,30 +Indigenous Services Canada,2020-21,31 +Indigenous Services Canada,2021-22,32 +Indigenous Services Canada,2022-23,33 +Indigenous Services Canada,2023-24,34 +Health Canada,2019-20,40 +Health Canada,2020-21,41 +Health Canada,2021-22,42 +Health Canada,2022-23,43 +Health Canada,2023-24,44 +Industry Canada,2019-20,50 +Industry Canada,2020-21,51 +"Innovation, Science and Economic Development Canada",2021-22,52 +"Innovation, Science and Economic Development Canada",2022-23,53 +"Innovation, Science and Economic Development Canada",2023-24,54 +DFAIT,2019-20,60 +Global Affairs Canada,2020-21,61 +Global Affairs Canada,2021-22,62 +Global Affairs Canada,2022-23,63 +Global Affairs Canada,2023-24,64 +Public Safety Canada,2019-20,70 +Public Safety Canada,2020-21,71 +Public Safety Canada,2021-22,72 +Public Safety Canada,2022-23,73 +Public Safety Canada,2023-24,74 +Transport Canada,2019-20,80 +Transport Canada,2020-21,81 +Transport Canada,2021-22,82 +Transport Canada,2022-23,83 +Transport Canada,2023-24,84 +Environment and Climate Change Canada,2019-20,90 +Environment and Climate Change Canada,2020-21,91 +Environment and Climate Change Canada,2021-22,92 +Environment and Climate Change Canada,2022-23,93 +Environment and Climate Change Canada,2023-24,94 +Agriculture and Agri-Food Canada,2019-20,100 +Agriculture and Agri-Food Canada,2020-21,101 +Agriculture and Agri-Food Canada,2021-22,102 +Agriculture and Agri-Food Canada,2022-23,103 +Agriculture and Agri-Food Canada,2023-24,104 +Canada Revenue Agency,2019-20,110 +Canada Revenue Agency,2020-21,111 +Canada Revenue Agency,2021-22,112 +Canada Revenue Agency,2022-23,113 +Canada Revenue Agency,2023-24,114 +Fisheries and Oceans Canada,2019-20,120 +Fisheries and Oceans Canada,2020-21,121 +Fisheries and Oceans Canada,2021-22,122 +Fisheries and Oceans Canada,2022-23,123 +Fisheries and Oceans Canada,2023-24,124 +Veterans Affairs Canada,2019-20,130 +Veterans Affairs Canada,2020-21,131 +Veterans Affairs Canada,2021-22,132 +Veterans Affairs Canada,2022-23,133 +Veterans Affairs Canada,2023-24,134 +Natural Resources Canada,2019-20,140 +Natural Resources Canada,2020-21,141 +Natural Resources Canada,2021-22,142 +Natural Resources Canada,2022-23,143 +Natural Resources Canada,2023-24,144 +Crown-Indigenous Relations and Northern Affairs Canada,2019-20,150 +Crown-Indigenous Relations and Northern Affairs Canada,2020-21,151 +Crown-Indigenous Relations and Northern Affairs Canada,2021-22,152 +Crown-Indigenous Relations and Northern Affairs Canada,2022-23,153 +Crown-Indigenous Relations and Northern Affairs Canada,2023-24,154 +` + +const manifest = { + name: "federal-departments", + title: "Federal departmental spending", + timeGrain: "fiscal-year", + entity: { + label: "department", + labelPlural: "departments", + }, + columns: { + spending: { + name: "Spending", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + decimals: 1, + }, + }, + entities: [ + { name: "National Defence", group: "Defence and Security" }, + { name: "Employment and Social Development Canada", group: "Social" }, + { name: "Indigenous Services Canada", group: "Indigenous" }, + { name: "Health Canada", group: "Social" }, + { + name: "Innovation, Science and Economic Development Canada", + aliases: ["Industry Canada", "ISED"], + group: "Economic", + }, + { + name: "Global Affairs Canada", + aliases: ["Foreign Affairs and International Trade", "DFAIT"], + group: "International", + }, + { name: "Public Safety Canada", group: "Defence and Security" }, + { name: "Transport Canada", group: "Economic" }, + { name: "Environment and Climate Change Canada", group: "Environment" }, + { name: "Agriculture and Agri-Food Canada", group: "Economic" }, + { name: "Canada Revenue Agency", group: "Economic" }, + { name: "Fisheries and Oceans Canada", group: "Environment" }, + { name: "Veterans Affairs Canada", group: "Social" }, + { name: "Natural Resources Canada", group: "Economic" }, + { name: "Crown-Indigenous Relations and Northern Affairs Canada", group: "Indigenous" }, + ], + sources: [ + { + name: "Public Accounts of Canada", + publisher: "Receiver General for Canada", + retrieved: "2026-05-01", + }, + ], +} + +export const federalDepartments: Fixture = { + name: "federal-departments", + csv, + manifest, +} diff --git a/packages/charts2/src/fixtures/government-debt.ts b/packages/charts2/src/fixtures/government-debt.ts new file mode 100644 index 00000000000..6404816455e --- /dev/null +++ b/packages/charts2/src/fixtures/government-debt.ts @@ -0,0 +1,92 @@ +/** + * Fixture: government-debt (spec 26 §2, scenario 27 A). + * Exercises: shared-denominator ratios (debt ÷ GDP), stacked derived values, + * single-entity metric series. + * + * All debt columns divide by gdp with displayFactor 100 ("% of GDP"). + * Hand-computable expected display values (clean numbers): + * + * fiscal year | federal | provincial | municipal + * 2019-20 | 50 | 30 | 5 + * 2020-21 | 60 | 35 | 5 + * 2021-22 | 51 | 30 | 5 + * 2022-23 | 50 | 30 | 5 + * 2023-24 | 48 | 30 | 5 + * + * e.g. federal 2019-20: 1100 / 2200 × 100 = 50. + */ + +import type { Fixture } from "./types.ts" + +const csv = `entity,time,federal_debt,provincial_debt,municipal_debt,gdp +Canada,2019-20,1100,660,110,2200 +Canada,2020-21,1200,700,100,2000 +Canada,2021-22,1224,720,120,2400 +Canada,2022-23,1250,750,125,2500 +Canada,2023-24,1248,780,130,2600 +` + +const manifest = { + name: "government-debt", + title: "Government debt as a share of GDP", + timeGrain: "fiscal-year", + entity: { + label: "country", + labelPlural: "countries", + }, + columns: { + federal_debt: { + name: "Federal debt", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + denominator: "gdp", + derivedUnit: "% of GDP", + derivedShortUnit: "%", + displayFactor: 100, + decimals: 1, + }, + provincial_debt: { + name: "Provincial debt", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + denominator: "gdp", + derivedUnit: "% of GDP", + derivedShortUnit: "%", + displayFactor: 100, + decimals: 1, + }, + municipal_debt: { + name: "Municipal debt", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + denominator: "gdp", + derivedUnit: "% of GDP", + derivedShortUnit: "%", + displayFactor: 100, + decimals: 1, + }, + gdp: { + name: "GDP", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + decimals: 0, + }, + }, + sources: [ + { + name: "Fiscal reference tables", + publisher: "Department of Finance Canada", + retrieved: "2026-05-01", + }, + ], +} + +export const governmentDebt: Fixture = { + name: "government-debt", + csv, + manifest, +} diff --git a/packages/charts2/src/fixtures/index.test.ts b/packages/charts2/src/fixtures/index.test.ts new file mode 100644 index 00000000000..5538fb412e9 --- /dev/null +++ b/packages/charts2/src/fixtures/index.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest" + +import { resolveValue } from "../core/data/derived.ts" +import { parseManifest } from "../core/data/manifest.ts" +import { parseCsv } from "../core/data/parse.ts" +import { validateDataset } from "../core/data/validate.ts" +import { fixtureNames, loadFixture, loadFixtureDataset } from "./index.ts" + +describe("fixture corpus", () => { + it("exposes the five spec-26 fixtures by name", () => { + expect(fixtureNames.sort()).toEqual([ + "federal-departments", + "government-debt", + "pathological", + "population-snapshot", + "provincial-budgets", + ]) + }) + + it("every fixture's manifest parses without errors", () => { + for (const name of fixtureNames) { + const { manifest, diagnostics } = parseManifest(loadFixture(name).manifest) + expect(manifest, name).not.toBeNull() + expect(diagnostics.filter((d) => d.severity === "error"), name).toEqual([]) + } + }) + + it("every fixture except pathological loads and validates clean", () => { + for (const name of fixtureNames) { + if (name === "pathological") continue + const { manifest, dataset, diagnostics } = loadFixtureDataset(name) + expect(diagnostics, name).toEqual([]) + const parsed = parseCsv(loadFixture(name).csv, manifest) + expect(validateDataset(manifest, parsed.rows), name).toEqual([]) + expect(dataset.entities.length, name).toBeGreaterThan(0) + } + }) +}) + +describe("provincial-budgets", () => { + const { dataset } = loadFixtureDataset("provincial-budgets") + + it("has 5 provinces × 6 fiscal years", () => { + expect(dataset.entities).toHaveLength(5) + expect(dataset.times).toEqual([2019, 2020, 2021, 2022, 2023, 2024]) + }) + + it("keeps missing cells missing (program_spending has no tolerance)", () => { + expect(resolveValue(dataset, "program_spending", "Quebec", 2024)).toEqual({ + status: "missing", + reason: "no-data", + }) + }) + + it("borrows debt_charges across its tolerance of 2, flagged via sourceTime", () => { + expect(resolveValue(dataset, "debt_charges", "Quebec", 2024)).toMatchObject({ + status: "value", + value: 9.3, + time: 2024, + sourceTime: 2023, + }) + }) +}) + +describe("federal-departments", () => { + const { dataset } = loadFixtureDataset("federal-departments") + + it("has 15 canonical departments × 5 fiscal years", () => { + expect(dataset.entities).toHaveLength(15) + expect(dataset.times).toEqual([2019, 2020, 2021, 2022, 2023]) + }) + + it("carries portfolio groups on entity metadata", () => { + const defence = dataset.manifest.entities!.find((e) => e.name === "National Defence") + expect(defence?.group).toBe("Defence and Security") + }) +}) + +describe("population-snapshot", () => { + const { dataset } = loadFixtureDataset("population-snapshot") + + it("has 13 provinces/territories and no time dimension", () => { + expect(dataset.entities).toHaveLength(13) + expect(dataset.times).toEqual([]) + expect(resolveValue(dataset, "population", "Nunavut", null)).toMatchObject({ value: 40000 }) + }) +}) + +describe("government-debt", () => { + const { dataset } = loadFixtureDataset("government-debt") + + it("is a single-entity dataset", () => { + expect(dataset.entities).toEqual(["Canada"]) + }) + + it("derives the hand-computed % of GDP table", () => { + const expected: Record = { + federal_debt: [50, 60, 51, 50, 48], + provincial_debt: [30, 35, 30, 30, 30], + municipal_debt: [5, 5, 5, 5, 5], + } + for (const [slug, perYear] of Object.entries(expected)) { + dataset.times.forEach((time, i) => { + expect(resolveValue(dataset, slug, "Canada", time), `${slug} @ ${time}`).toMatchObject({ + status: "value", + value: perYear[i], + }) + }) + } + }) + + it("attaches raw numerator/denominator for the tooltip detail line", () => { + expect(resolveValue(dataset, "federal_debt", "Canada", 2019)).toMatchObject({ + raw: { numerator: 1100, denominator: 2200 }, + }) + }) +}) + +describe("pathological", () => { + it("loads with the duplicate and non-numeric problems reported", () => { + const { dataset, diagnostics } = loadFixtureDataset("pathological") + const codes = diagnostics.map((d) => d.code) + expect(codes).toContain("duplicate-row") + expect(codes).toContain("non-numeric-cell") + // the dataset still builds: first duplicate wins, unicode names intact + expect(dataset.entities).toContain("Québec") + expect(dataset.entities).toContain("Î.-P.-É.") + }) + + it("zero denominator yields missing, never Infinity", () => { + const { dataset } = loadFixtureDataset("pathological") + expect(resolveValue(dataset, "spending", "Québec", 2023)).toEqual({ + status: "missing", + reason: "zero-denominator", + }) + }) + + it("the single-time entity resolves at its one time", () => { + const { dataset } = loadFixtureDataset("pathological") + expect(resolveValue(dataset, "spending", "Lonely Station", 2021)).toMatchObject({ + status: "value", + value: 7 / 3, + }) + expect(resolveValue(dataset, "spending", "Lonely Station", 2022)).toEqual({ + status: "missing", + reason: "no-data", + }) + }) +}) diff --git a/packages/charts2/src/fixtures/index.ts b/packages/charts2/src/fixtures/index.ts new file mode 100644 index 00000000000..82be674d689 --- /dev/null +++ b/packages/charts2/src/fixtures/index.ts @@ -0,0 +1,64 @@ +/** + * Committed fixture datasets (spec 26 §2). Each fixture is a TypeScript + * module exporting CSV text and a raw manifest object — no fs access + * needed in tests, and the browser/Storybook can import them directly. + */ + +import { buildDataset, type BuildDatasetResult } from "../core/data/dataset.ts" +import { parseManifest } from "../core/data/manifest.ts" +import { parseCsv } from "../core/data/parse.ts" +import type { Dataset, Diagnostic, Manifest } from "../core/types.ts" +import { federalDepartments } from "./federal-departments.ts" +import { governmentDebt } from "./government-debt.ts" +import { pathological } from "./pathological.ts" +import { populationSnapshot } from "./population-snapshot.ts" +import { provincialBudgets } from "./provincial-budgets.ts" +import type { Fixture } from "./types.ts" + +export type { Fixture } from "./types.ts" +export { federalDepartments, governmentDebt, pathological, populationSnapshot, provincialBudgets } + +export const fixtures = { + "provincial-budgets": provincialBudgets, + "federal-departments": federalDepartments, + "population-snapshot": populationSnapshot, + "government-debt": governmentDebt, + pathological: pathological, +} as const + +export type FixtureName = keyof typeof fixtures + +export const fixtureNames = Object.keys(fixtures) as FixtureName[] + +/** Look up a fixture's raw CSV + manifest by name. */ +export function loadFixture(name: FixtureName): Fixture { + return fixtures[name] +} + +export interface LoadedFixtureDataset { + manifest: Manifest + dataset: Dataset + /** Manifest + parse + build diagnostics, concatenated. */ + diagnostics: Diagnostic[] +} + +/** + * Convenience loader: parse the fixture's manifest and CSV and build the + * Dataset. Throws if the manifest itself is invalid (fixtures are + * committed, so that is a programming error); data-level diagnostics + * (e.g. the pathological fixture's duplicates) are returned, not thrown. + */ +export function loadFixtureDataset(name: FixtureName): LoadedFixtureDataset { + const fixture = fixtures[name] + const { manifest, diagnostics: manifestDiagnostics } = parseManifest(fixture.manifest) + if (manifest === null) { + throw new Error(`Fixture "${name}" has an invalid manifest: ${manifestDiagnostics.map((d) => d.message).join("; ")}`) + } + const parsed = parseCsv(fixture.csv, manifest) + const built: BuildDatasetResult = buildDataset(manifest, parsed.rows) + return { + manifest, + dataset: built.dataset, + diagnostics: [...manifestDiagnostics, ...parsed.diagnostics, ...built.diagnostics], + } +} diff --git a/packages/charts2/src/fixtures/pathological.ts b/packages/charts2/src/fixtures/pathological.ts new file mode 100644 index 00000000000..1d6d09cd81b --- /dev/null +++ b/packages/charts2/src/fixtures/pathological.ts @@ -0,0 +1,71 @@ +/** + * Fixture: pathological (spec 26 §2). + * Exercises, by data-row number: + * rows 2+3 duplicate (Québec, 2021) — validate error + * row 4 gap: Québec has no 2022 row + * row 4 zero denominator: population 0 (spending ÷ population missing) + * row 6 non-numeric cell: spending "n/a" — validate error + * all rows "negatives" column is all-negative + * all rows "huge" column has huge magnitudes (~9e14) + * row 8 single-time entity ("Lonely Station" only at 2021) + * names French/unicode entities: "Québec", "Î.-P.-É." + */ + +import type { Fixture } from "./types.ts" + +const csv = `entity,time,spending,population,negatives,huge +Québec,2020,100,50,-5,910000000000000 +Québec,2021,110,55,-6,920000000000000 +Québec,2021,111,55,-6,920000000000000 +Québec,2023,130,0,-7,940000000000000 +Î.-P.-É.,2020,10,5,-1,900000000000000 +Î.-P.-É.,2021,n/a,5,-1,905000000000000 +Î.-P.-É.,2022,12,6,-2,910000000000000 +Lonely Station,2021,7,3,-9,900000000000000 +` + +const manifest = { + name: "pathological", + title: "Pathological dataset", + timeGrain: "year", + entity: { + label: "place", + labelPlural: "places", + }, + columns: { + spending: { + name: "Spending", + type: "numeric", + unit: "million CAD", + shortUnit: "$", + denominator: "population", + derivedUnit: "per person", + }, + population: { + name: "Population", + type: "integer", + unit: "thousand people", + }, + negatives: { + name: "Net balance", + type: "numeric", + unit: "million CAD", + }, + huge: { + name: "Huge magnitude", + type: "numeric", + unit: "units", + }, + }, + sources: [ + { + name: "Synthetic test data", + }, + ], +} + +export const pathological: Fixture = { + name: "pathological", + csv, + manifest, +} diff --git a/packages/charts2/src/fixtures/population-snapshot.ts b/packages/charts2/src/fixtures/population-snapshot.ts new file mode 100644 index 00000000000..c75edf8440e --- /dev/null +++ b/packages/charts2/src/fixtures/population-snapshot.ts @@ -0,0 +1,61 @@ +/** + * Fixture: population-snapshot (spec 26 §2). + * Exercises: no time dimension (timeGrain "none", no time column). + * 13 provinces and territories, population + median_age. + */ + +import type { Fixture } from "./types.ts" + +const csv = `entity,population,median_age +Ontario,15608000,40.1 +Quebec,8874000,42.4 +British Columbia,5519000,42.0 +Alberta,4696000,38.1 +Manitoba,1454000,38.5 +Saskatchewan,1209000,38.6 +Nova Scotia,1058000,43.6 +New Brunswick,832000,44.6 +Newfoundland and Labrador,533000,46.4 +Prince Edward Island,173000,42.4 +Northwest Territories,45000,35.7 +Yukon,44000,39.6 +Nunavut,40000,26.6 +` + +const manifest = { + name: "population-snapshot", + title: "Population snapshot", + timeGrain: "none", + entity: { + label: "province or territory", + labelPlural: "provinces and territories", + kind: "province", + }, + columns: { + population: { + name: "Population", + type: "integer", + unit: "people", + decimals: 0, + }, + median_age: { + name: "Median age", + type: "numeric", + unit: "years", + decimals: 1, + }, + }, + sources: [ + { + name: "Quarterly population estimates", + publisher: "Statistics Canada", + retrieved: "2026-05-01", + }, + ], +} + +export const populationSnapshot: Fixture = { + name: "population-snapshot", + csv, + manifest, +} diff --git a/packages/charts2/src/fixtures/provincial-budgets.ts b/packages/charts2/src/fixtures/provincial-budgets.ts new file mode 100644 index 00000000000..57a2bc6fc97 --- /dev/null +++ b/packages/charts2/src/fixtures/provincial-budgets.ts @@ -0,0 +1,95 @@ +/** + * Fixture: provincial-budgets (spec 26 §2). + * Exercises: fiscal years, multi-metric, missing cells, tolerance borrowing. + * 5 provinces × 6 fiscal years (2019-20 .. 2024-25), values in billion CAD. + * + * Missing cells (data-row numbers): + * row 12 Quebec 2024-25 program_spending + debt_charges empty + * row 18 British Columbia 2024-25 debt_charges empty + * row 28 Nova Scotia 2022-23 program_spending empty + * debt_charges has tolerance 2, so Quebec/BC 2024-25 borrow from 2023-24. + */ + +import type { Fixture } from "./types.ts" + +const csv = `entity,time,total_spending,program_spending,debt_charges +Ontario,2019-20,165.1,152.3,12.8 +Ontario,2020-21,181.3,168.4,12.9 +Ontario,2021-22,186.4,173.6,12.8 +Ontario,2022-23,192.9,180.4,12.5 +Ontario,2023-24,204.3,191.0,13.3 +Ontario,2024-25,214.5,200.1,14.4 +Quebec,2019-20,118.6,110.4,8.2 +Quebec,2020-21,135.2,127.5,7.7 +Quebec,2021-22,140.5,132.6,7.9 +Quebec,2022-23,147.3,138.9,8.4 +Quebec,2023-24,156.1,146.8,9.3 +Quebec,2024-25,161.0,, +British Columbia,2019-20,58.5,55.8,2.7 +British Columbia,2020-21,64.8,62.1,2.7 +British Columbia,2021-22,68.2,65.4,2.8 +British Columbia,2022-23,73.6,70.5,3.1 +British Columbia,2023-24,79.5,76.1,3.4 +British Columbia,2024-25,84.2,80.5, +Alberta,2019-20,58.7,56.5,2.2 +Alberta,2020-21,60.1,57.7,2.4 +Alberta,2021-22,61.9,59.2,2.7 +Alberta,2022-23,64.3,61.6,2.7 +Alberta,2023-24,68.3,65.2,3.1 +Alberta,2024-25,71.2,68.0,3.2 +Nova Scotia,2019-20,11.3,10.4,0.9 +Nova Scotia,2020-21,12.2,11.4,0.8 +Nova Scotia,2021-22,13.1,12.4,0.7 +Nova Scotia,2022-23,14.0,,0.7 +Nova Scotia,2023-24,15.4,14.7,0.7 +Nova Scotia,2024-25,16.5,15.8,0.7 +` + +const manifest = { + name: "provincial-budgets", + title: "Provincial budget expenditures", + timeGrain: "fiscal-year", + entity: { + label: "province", + labelPlural: "provinces", + kind: "province", + }, + columns: { + total_spending: { + name: "Total spending", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + decimals: 1, + description: "Total budgetary expenditure, public accounts basis", + }, + program_spending: { + name: "Program spending", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + decimals: 1, + }, + debt_charges: { + name: "Debt charges", + type: "currency", + unit: "billion CAD", + shortUnit: "$", + decimals: 1, + tolerance: 2, + }, + }, + sources: [ + { + name: "Provincial public accounts", + publisher: "Provincial treasury boards", + retrieved: "2026-05-01", + }, + ], +} + +export const provincialBudgets: Fixture = { + name: "provincial-budgets", + csv, + manifest, +} diff --git a/packages/charts2/src/fixtures/types.ts b/packages/charts2/src/fixtures/types.ts new file mode 100644 index 00000000000..a17fa71782e --- /dev/null +++ b/packages/charts2/src/fixtures/types.ts @@ -0,0 +1,7 @@ +/** A committed fixture dataset: raw CSV text + raw (pre-parse) manifest JSON. */ +export interface Fixture { + name: string + csv: string + /** Raw manifest object, exactly as it would appear in manifest.json. */ + manifest: Record +} diff --git a/packages/charts2/src/fonts/metrics/financier-text-regular.json b/packages/charts2/src/fonts/metrics/financier-text-regular.json new file mode 100644 index 00000000000..486726e29e3 --- /dev/null +++ b/packages/charts2/src/fonts/metrics/financier-text-regular.json @@ -0,0 +1,5223 @@ +{ + "familyName": "Financier Text", + "unitsPerEm": 1000, + "ascent": 993, + "descent": -245, + "capHeight": 582, + "advances": { + "32": 230, + "33": 270, + "34": 385, + "35": 557, + "36": 468, + "37": 739, + "38": 637, + "39": 228, + "40": 397, + "41": 397, + "42": 457, + "43": 560, + "44": 250, + "45": 318, + "46": 250, + "47": 425, + "48": 542, + "49": 407, + "50": 471, + "51": 465, + "52": 537, + "53": 450, + "54": 484, + "55": 445, + "56": 481, + "57": 484, + "58": 250, + "59": 250, + "60": 560, + "61": 560, + "62": 560, + "63": 373, + "64": 850, + "65": 631, + "66": 551, + "67": 545, + "68": 629, + "69": 555, + "70": 532, + "71": 600, + "72": 697, + "73": 309, + "74": 302, + "75": 637, + "76": 527, + "77": 796, + "78": 660, + "79": 636, + "80": 526, + "81": 636, + "82": 604, + "83": 464, + "84": 576, + "85": 639, + "86": 629, + "87": 869, + "88": 639, + "89": 608, + "90": 539, + "91": 384, + "92": 425, + "93": 384, + "94": 422, + "95": 381, + "96": 500, + "97": 452, + "98": 505, + "99": 416, + "100": 514, + "101": 438, + "102": 302, + "103": 436, + "104": 526, + "105": 266, + "106": 256, + "107": 521, + "108": 266, + "109": 797, + "110": 528, + "111": 478, + "112": 511, + "113": 497, + "114": 382, + "115": 376, + "116": 324, + "117": 522, + "118": 483, + "119": 663, + "120": 498, + "121": 483, + "122": 421, + "123": 424, + "124": 294, + "125": 424, + "126": 432, + "160": 230, + "161": 270, + "162": 427, + "163": 567, + "165": 602, + "167": 441, + "168": 500, + "169": 677, + "170": 367, + "171": 455, + "174": 485, + "175": 500, + "176": 363, + "177": 560, + "180": 500, + "182": 577, + "183": 250, + "184": 500, + "186": 389, + "187": 455, + "188": 791, + "189": 799, + "190": 810, + "191": 373, + "192": 631, + "193": 631, + "194": 631, + "195": 631, + "196": 631, + "197": 631, + "198": 875, + "199": 545, + "200": 555, + "201": 555, + "202": 555, + "203": 555, + "204": 309, + "205": 309, + "206": 309, + "207": 309, + "208": 639, + "209": 660, + "210": 636, + "211": 636, + "212": 636, + "213": 636, + "214": 636, + "215": 560, + "216": 636, + "217": 639, + "218": 639, + "219": 639, + "220": 639, + "221": 608, + "222": 526, + "223": 571, + "224": 452, + "225": 452, + "226": 452, + "227": 452, + "228": 452, + "229": 452, + "230": 677, + "231": 416, + "232": 438, + "233": 438, + "234": 438, + "235": 438, + "236": 266, + "237": 266, + "238": 266, + "239": 266, + "240": 478, + "241": 528, + "242": 478, + "243": 478, + "244": 478, + "245": 478, + "246": 478, + "247": 560, + "248": 488, + "249": 522, + "250": 522, + "251": 522, + "252": 522, + "253": 483, + "254": 511, + "255": 483, + "8211": 408, + "8212": 698, + "8216": 244, + "8217": 244, + "8220": 424, + "8221": 424, + "8230": 650, + "8240": 1072, + "8364": 571, + "8722": 560 + }, + "kerning": { + "34,52": -80, + "34,53": -20, + "34,54": -40, + "34,55": 20, + "34,56": -20, + "35,52": -30, + "38,55": -25, + "38,84": -20, + "38,85": -20, + "38,86": -60, + "38,87": -55, + "38,89": -65, + "38,217": -20, + "38,218": -20, + "38,219": -20, + "38,220": -20, + "38,221": -65, + "39,52": -80, + "39,53": -20, + "39,54": -40, + "39,55": 20, + "39,56": -20, + "40,52": -30, + "40,74": 80, + "40,103": 20, + "40,106": 50, + "42,65": -80, + "42,67": -20, + "42,71": -20, + "42,77": -20, + "42,79": -20, + "42,81": -20, + "42,192": -80, + "42,193": -80, + "42,194": -80, + "42,195": -80, + "42,196": -80, + "42,197": -80, + "42,198": -120, + "42,199": -20, + "42,210": -20, + "42,211": -20, + "42,212": -20, + "42,213": -20, + "42,214": -20, + "42,216": -20, + "43,49": -20, + "43,50": -20, + "43,51": -5, + "43,55": -40, + "44,48": -50, + "44,51": -20, + "44,52": -55, + "44,53": -15, + "44,54": -35, + "44,55": -50, + "44,56": -15, + "44,57": -10, + "44,65": 10, + "44,67": -30, + "44,71": -30, + "44,74": 40, + "44,79": -30, + "44,81": -30, + "44,83": 10, + "44,84": -70, + "44,85": -40, + "44,86": -90, + "44,87": -80, + "44,88": 20, + "44,89": -100, + "44,98": -25, + "44,99": -25, + "44,100": -20, + "44,101": -25, + "44,111": -25, + "44,113": -20, + "44,116": -25, + "44,117": -35, + "44,118": -70, + "44,119": -50, + "44,121": -70, + "44,192": 10, + "44,193": 10, + "44,194": 10, + "44,195": 10, + "44,196": 10, + "44,197": 10, + "44,199": -30, + "44,210": -30, + "44,211": -30, + "44,212": -30, + "44,213": -30, + "44,214": -30, + "44,216": -30, + "44,217": -40, + "44,218": -40, + "44,219": -40, + "44,220": -40, + "44,221": -100, + "44,231": -25, + "44,232": -25, + "44,233": -25, + "44,234": -25, + "44,235": -25, + "44,240": -25, + "44,242": -25, + "44,243": -25, + "44,244": -25, + "44,245": -25, + "44,246": -25, + "44,248": -25, + "44,249": -35, + "44,250": -35, + "44,251": -35, + "44,252": -35, + "44,253": -70, + "44,254": -20, + "44,255": -70, + "44,8216": -90, + "44,8217": -90, + "44,8220": -90, + "44,8221": -90, + "45,48": 20, + "45,49": -20, + "45,50": -30, + "45,52": 20, + "45,54": 20, + "45,55": -30, + "45,65": -30, + "45,67": 20, + "45,71": 20, + "45,79": 20, + "45,81": 20, + "45,84": -20, + "45,86": -50, + "45,87": -40, + "45,88": -40, + "45,89": -60, + "45,99": 20, + "45,100": 20, + "45,101": 20, + "45,111": 20, + "45,113": 20, + "45,118": -15, + "45,119": -10, + "45,120": -20, + "45,121": -15, + "45,192": -30, + "45,193": -30, + "45,194": -30, + "45,195": -30, + "45,196": -30, + "45,197": -30, + "45,198": -40, + "45,199": 20, + "45,210": 20, + "45,211": 20, + "45,212": 20, + "45,213": 20, + "45,214": 20, + "45,216": 20, + "45,221": -60, + "45,231": 20, + "45,232": 20, + "45,233": 20, + "45,234": 20, + "45,235": 20, + "45,240": 20, + "45,242": 20, + "45,243": 20, + "45,244": 20, + "45,245": 20, + "45,246": 20, + "45,248": 20, + "45,253": -15, + "45,255": -15, + "46,48": -50, + "46,51": -20, + "46,52": -55, + "46,53": -15, + "46,54": -35, + "46,55": -50, + "46,56": -15, + "46,57": -10, + "46,65": 10, + "46,67": -30, + "46,71": -30, + "46,79": -30, + "46,81": -30, + "46,83": 10, + "46,84": -70, + "46,85": -40, + "46,86": -90, + "46,87": -80, + "46,88": 20, + "46,89": -100, + "46,98": -25, + "46,99": -25, + "46,100": -20, + "46,101": -25, + "46,111": -25, + "46,113": -20, + "46,116": -25, + "46,117": -35, + "46,118": -70, + "46,119": -50, + "46,121": -70, + "46,192": 10, + "46,193": 10, + "46,194": 10, + "46,195": 10, + "46,196": 10, + "46,197": 10, + "46,199": -30, + "46,210": -30, + "46,211": -30, + "46,212": -30, + "46,213": -30, + "46,214": -30, + "46,216": -30, + "46,217": -40, + "46,218": -40, + "46,219": -40, + "46,220": -40, + "46,221": -100, + "46,231": -25, + "46,232": -25, + "46,233": -25, + "46,234": -25, + "46,235": -25, + "46,240": -25, + "46,242": -25, + "46,243": -25, + "46,244": -25, + "46,245": -25, + "46,246": -25, + "46,248": -25, + "46,249": -35, + "46,250": -35, + "46,251": -35, + "46,252": -35, + "46,253": -70, + "46,254": -20, + "46,255": -70, + "46,8216": -90, + "46,8217": -90, + "46,8220": -90, + "46,8221": -90, + "47,47": -170, + "47,48": -45, + "47,50": -15, + "47,52": -70, + "47,53": -20, + "47,54": -40, + "47,56": -30, + "47,57": -15, + "47,65": -100, + "47,67": -30, + "47,71": -30, + "47,77": -50, + "47,79": -30, + "47,81": -30, + "47,83": -15, + "47,90": -5, + "47,97": -75, + "47,98": 20, + "47,99": -80, + "47,100": -65, + "47,101": -80, + "47,103": -85, + "47,104": 20, + "47,107": 20, + "47,108": 20, + "47,109": -50, + "47,110": -50, + "47,111": -80, + "47,113": -65, + "47,114": -50, + "47,115": -55, + "47,117": -40, + "47,118": -20, + "47,119": -20, + "47,120": -45, + "47,121": -20, + "47,122": -55, + "47,192": -100, + "47,193": -100, + "47,194": -100, + "47,195": -100, + "47,196": -100, + "47,197": -100, + "47,198": -110, + "47,199": -30, + "47,210": -30, + "47,211": -30, + "47,212": -30, + "47,213": -30, + "47,214": -30, + "47,216": -30, + "47,224": -75, + "47,225": -75, + "47,226": -75, + "47,227": -75, + "47,228": -75, + "47,229": -75, + "47,230": -75, + "47,231": -80, + "47,232": -80, + "47,233": -80, + "47,234": -80, + "47,235": -80, + "47,240": -35, + "47,241": -50, + "47,242": -80, + "47,243": -80, + "47,244": -80, + "47,245": -80, + "47,246": -80, + "47,248": -80, + "47,249": -40, + "47,250": -40, + "47,251": -40, + "47,252": -40, + "47,253": -20, + "47,254": 20, + "47,255": -20, + "48,44": -50, + "48,45": 20, + "48,46": -50, + "48,47": -55, + "48,63": -10, + "48,183": 20, + "48,8211": 20, + "48,8212": 20, + "48,8216": -30, + "48,8220": -30, + "48,8230": -50, + "49,34": -50, + "49,37": -25, + "49,39": -50, + "49,43": -30, + "49,45": -30, + "49,52": -15, + "49,55": -10, + "49,60": -30, + "49,61": -30, + "49,63": -30, + "49,171": -30, + "49,176": -40, + "49,183": -30, + "49,186": -40, + "49,215": -20, + "49,247": -30, + "49,8211": -30, + "49,8212": -30, + "49,8216": -45, + "49,8217": -40, + "49,8220": -45, + "49,8221": -40, + "49,8722": -30, + "49,8364": -35, + "49,8240": -25, + "50,34": -15, + "50,37": -15, + "50,39": -15, + "50,43": -15, + "50,51": -5, + "50,52": -10, + "50,55": -10, + "50,63": -20, + "50,165": -10, + "50,171": -15, + "50,247": -15, + "50,8216": -30, + "50,8217": -15, + "50,8220": -30, + "50,8221": -15, + "50,8722": -15, + "50,8240": -15, + "51,37": -10, + "51,44": -15, + "51,46": -15, + "51,47": -30, + "51,50": -5, + "51,56": 10, + "51,63": -15, + "51,8230": -15, + "51,8240": -10, + "52,34": -60, + "52,37": -35, + "52,39": -60, + "52,41": -20, + "52,44": -20, + "52,45": 20, + "52,46": -20, + "52,47": -30, + "52,50": -10, + "52,51": -5, + "52,53": -5, + "52,55": -30, + "52,63": -35, + "52,171": 10, + "52,176": -40, + "52,183": 20, + "52,187": -15, + "52,8211": 20, + "52,8212": 20, + "52,8216": -65, + "52,8217": -50, + "52,8220": -65, + "52,8221": -50, + "52,8230": -20, + "52,8240": -35, + "53,44": -10, + "53,46": -10, + "53,47": -25, + "53,8230": -10, + "54,34": 10, + "54,39": 10, + "54,44": -15, + "54,45": 20, + "54,46": -15, + "54,47": -25, + "54,55": 10, + "54,56": 5, + "54,183": 20, + "54,8211": 20, + "54,8212": 20, + "54,8230": -15, + "55,34": 20, + "55,38": -20, + "55,39": 20, + "55,43": -50, + "55,44": -80, + "55,45": -40, + "55,46": -80, + "55,47": -95, + "55,48": -10, + "55,52": -40, + "55,53": -10, + "55,54": -20, + "55,55": 10, + "55,58": -20, + "55,59": -20, + "55,60": -60, + "55,61": -30, + "55,64": -40, + "55,162": -30, + "55,165": 15, + "55,170": 10, + "55,171": -55, + "55,176": 20, + "55,177": -50, + "55,183": -40, + "55,187": -30, + "55,215": -25, + "55,247": -50, + "55,8211": -40, + "55,8212": -40, + "55,8216": 20, + "55,8217": 30, + "55,8220": 20, + "55,8221": 30, + "55,8230": -80, + "55,8722": -50, + "55,8364": -30, + "56,34": -20, + "56,39": -20, + "56,44": -10, + "56,46": -10, + "56,47": -20, + "56,50": -10, + "56,63": -10, + "56,8216": -20, + "56,8217": -10, + "56,8220": -20, + "56,8221": -10, + "56,8230": -10, + "57,34": -15, + "57,39": -15, + "57,44": -60, + "57,45": 20, + "57,46": -60, + "57,47": -70, + "57,50": -15, + "57,51": -5, + "57,53": -10, + "57,63": -10, + "57,183": 20, + "57,8211": 20, + "57,8212": 20, + "57,8216": -15, + "57,8220": -15, + "57,8230": -60, + "58,47": -30, + "58,55": -10, + "58,84": -20, + "58,85": -20, + "58,86": -60, + "58,87": -50, + "58,89": -70, + "58,217": -20, + "58,218": -20, + "58,219": -20, + "58,220": -20, + "58,221": -70, + "59,47": -30, + "59,55": -10, + "59,74": 20, + "59,84": -20, + "59,85": -20, + "59,86": -60, + "59,87": -50, + "59,89": -70, + "59,217": -20, + "59,218": -20, + "59,219": -20, + "59,220": -20, + "59,221": -70, + "61,49": -10, + "61,54": 10, + "61,55": 20, + "62,55": -40, + "64,55": -10, + "64,65": -30, + "64,67": 20, + "64,71": 20, + "64,74": 20, + "64,77": -20, + "64,79": 20, + "64,81": 20, + "64,86": -30, + "64,87": -25, + "64,88": -35, + "64,89": -45, + "64,90": -10, + "64,99": 10, + "64,101": 10, + "64,111": 10, + "64,120": -15, + "64,192": -30, + "64,193": -30, + "64,194": -30, + "64,195": -30, + "64,196": -30, + "64,197": -30, + "64,198": -50, + "64,199": 20, + "64,210": 20, + "64,211": 20, + "64,212": 20, + "64,213": 20, + "64,214": 20, + "64,216": 20, + "64,221": -45, + "64,231": 10, + "64,232": 10, + "64,233": 10, + "64,234": 10, + "64,235": 10, + "64,240": 10, + "64,242": 10, + "64,243": 10, + "64,244": 10, + "64,245": 10, + "64,246": 10, + "64,248": 10, + "65,38": -20, + "65,42": -80, + "65,44": 10, + "65,45": -30, + "65,46": 10, + "65,63": -50, + "65,67": -20, + "65,71": -20, + "65,79": -20, + "65,81": -20, + "65,84": -40, + "65,85": -35, + "65,86": -70, + "65,87": -70, + "65,89": -60, + "65,98": -20, + "65,99": -15, + "65,100": -15, + "65,101": -15, + "65,103": -10, + "65,111": -15, + "65,112": -10, + "65,113": -15, + "65,116": -10, + "65,117": -15, + "65,118": -50, + "65,119": -35, + "65,121": -30, + "65,171": -30, + "65,183": -30, + "65,199": -20, + "65,210": -20, + "65,211": -20, + "65,212": -20, + "65,213": -20, + "65,214": -20, + "65,216": -5, + "65,217": -35, + "65,218": -35, + "65,219": -35, + "65,220": -35, + "65,221": -60, + "65,231": -15, + "65,232": -15, + "65,233": -15, + "65,234": -15, + "65,235": -15, + "65,240": -15, + "65,242": -15, + "65,243": -15, + "65,244": -15, + "65,245": -15, + "65,246": -15, + "65,248": -15, + "65,249": -15, + "65,250": -15, + "65,251": -15, + "65,252": -15, + "65,253": -30, + "65,254": -20, + "65,255": -30, + "65,8211": -30, + "65,8212": -30, + "65,8216": -110, + "65,8217": -100, + "65,8220": -110, + "65,8221": -100, + "65,8230": 10, + "66,38": 10, + "66,45": 10, + "66,47": -30, + "66,64": 20, + "66,67": 5, + "66,71": 5, + "66,79": 5, + "66,81": 5, + "66,86": -10, + "66,87": -10, + "66,89": -10, + "66,99": 5, + "66,100": 5, + "66,101": 5, + "66,111": 5, + "66,113": 5, + "66,171": 20, + "66,183": 10, + "66,198": -15, + "66,199": 5, + "66,210": 5, + "66,211": 5, + "66,212": 5, + "66,213": 5, + "66,214": 5, + "66,216": 5, + "66,221": -10, + "66,231": 5, + "66,232": 5, + "66,233": 5, + "66,234": 5, + "66,235": 5, + "66,240": 5, + "66,242": 5, + "66,243": 5, + "66,244": 5, + "66,245": 5, + "66,246": 5, + "66,248": 5, + "66,8211": 10, + "66,8212": 10, + "67,42": 30, + "67,47": -20, + "67,99": -5, + "67,101": -5, + "67,111": -5, + "67,116": 10, + "67,231": -5, + "67,232": -5, + "67,233": -5, + "67,234": -5, + "67,235": -5, + "67,238": 25, + "67,239": 25, + "67,240": -5, + "67,242": -5, + "67,243": -5, + "67,244": -5, + "67,245": -5, + "67,246": -5, + "67,248": -5, + "67,8216": 20, + "67,8220": 20, + "68,42": -20, + "68,44": -30, + "68,45": 20, + "68,46": -30, + "68,47": -50, + "68,64": 20, + "68,65": -20, + "68,67": 10, + "68,71": 10, + "68,77": -15, + "68,79": 10, + "68,81": 10, + "68,86": -20, + "68,87": -25, + "68,88": -20, + "68,89": -20, + "68,99": 5, + "68,100": 5, + "68,101": 5, + "68,111": 5, + "68,113": 5, + "68,116": 10, + "68,121": 10, + "68,171": 20, + "68,183": 20, + "68,192": -20, + "68,193": -20, + "68,194": -20, + "68,195": -20, + "68,196": -20, + "68,197": -20, + "68,198": -45, + "68,199": 10, + "68,210": 10, + "68,211": 10, + "68,212": 10, + "68,213": 10, + "68,214": 10, + "68,216": 10, + "68,221": -20, + "68,231": 5, + "68,232": 5, + "68,233": 5, + "68,234": 5, + "68,235": 5, + "68,240": 5, + "68,242": 5, + "68,243": 5, + "68,244": 5, + "68,245": 5, + "68,246": 5, + "68,248": 5, + "68,253": 10, + "68,255": 10, + "68,8211": 20, + "68,8212": 20, + "68,8216": -20, + "68,8217": -10, + "68,8220": -20, + "68,8221": -10, + "68,8230": -30, + "69,38": 10, + "69,98": -10, + "69,118": -10, + "69,119": -5, + "69,121": -10, + "69,238": 10, + "69,239": 10, + "69,253": -10, + "69,255": -10, + "70,38": -30, + "70,44": -80, + "70,45": -30, + "70,46": -80, + "70,47": -80, + "70,63": 10, + "70,64": -30, + "70,65": -45, + "70,67": -15, + "70,71": -15, + "70,77": -20, + "70,79": -15, + "70,81": -15, + "70,88": -15, + "70,90": -15, + "70,97": -35, + "70,99": -45, + "70,100": -55, + "70,101": -45, + "70,103": -30, + "70,109": -10, + "70,110": -10, + "70,111": -45, + "70,112": -10, + "70,113": -55, + "70,114": -10, + "70,115": -35, + "70,117": -10, + "70,118": -5, + "70,119": -5, + "70,120": -20, + "70,121": -5, + "70,122": -40, + "70,171": -40, + "70,183": -30, + "70,187": -20, + "70,192": -45, + "70,193": -45, + "70,194": -45, + "70,195": -45, + "70,196": -45, + "70,197": -45, + "70,198": -90, + "70,199": -15, + "70,210": -15, + "70,211": -15, + "70,212": -15, + "70,213": -15, + "70,214": -15, + "70,216": -15, + "70,224": -35, + "70,225": -35, + "70,226": -35, + "70,227": -15, + "70,228": -35, + "70,229": -35, + "70,230": -35, + "70,231": -45, + "70,232": -45, + "70,233": -45, + "70,234": -45, + "70,235": -45, + "70,240": -45, + "70,241": -10, + "70,242": -45, + "70,243": -45, + "70,244": -45, + "70,245": -45, + "70,246": -45, + "70,248": -45, + "70,249": -10, + "70,250": -10, + "70,251": -10, + "70,252": -10, + "70,253": -5, + "70,255": -5, + "70,8211": -30, + "70,8212": -30, + "70,8216": 20, + "70,8217": 20, + "70,8220": 20, + "70,8221": 20, + "70,8230": -80, + "71,45": 20, + "71,47": -10, + "71,63": -20, + "71,64": 20, + "71,67": 15, + "71,71": 15, + "71,79": 15, + "71,81": 15, + "71,86": -10, + "71,87": -5, + "71,89": -10, + "71,99": 10, + "71,100": 10, + "71,101": 10, + "71,111": 10, + "71,113": 10, + "71,116": 10, + "71,118": -10, + "71,119": -5, + "71,171": 20, + "71,183": 20, + "71,198": -10, + "71,199": 15, + "71,210": 15, + "71,211": 15, + "71,212": 15, + "71,213": 15, + "71,214": 15, + "71,216": 15, + "71,221": -10, + "71,231": 10, + "71,232": 10, + "71,233": 10, + "71,234": 10, + "71,235": 10, + "71,238": 10, + "71,239": 10, + "71,240": 10, + "71,242": 10, + "71,243": 10, + "71,244": 10, + "71,245": 10, + "71,246": 10, + "71,248": 10, + "71,8211": 20, + "71,8212": 20, + "72,238": 20, + "72,239": 30, + "73,238": 20, + "73,239": 30, + "74,38": -15, + "74,44": -20, + "74,46": -20, + "74,47": -25, + "74,65": -25, + "74,77": -10, + "74,90": -10, + "74,97": -10, + "74,99": -10, + "74,100": -10, + "74,101": -10, + "74,111": -10, + "74,113": -10, + "74,117": -5, + "74,118": -10, + "74,119": -10, + "74,120": -15, + "74,121": -5, + "74,171": -10, + "74,192": -25, + "74,193": -25, + "74,194": -25, + "74,195": -25, + "74,196": -25, + "74,197": -25, + "74,198": -35, + "74,224": -10, + "74,225": -10, + "74,226": -10, + "74,227": -10, + "74,228": -10, + "74,229": -10, + "74,230": -10, + "74,231": -10, + "74,232": -10, + "74,233": -10, + "74,234": -10, + "74,235": -10, + "74,238": 10, + "74,239": 30, + "74,240": -10, + "74,242": -10, + "74,243": -10, + "74,244": -10, + "74,245": -10, + "74,246": -10, + "74,248": -10, + "74,249": -5, + "74,250": -5, + "74,251": -5, + "74,252": -5, + "74,253": -5, + "74,255": -5, + "74,8230": -20, + "75,38": -10, + "75,42": -20, + "75,45": -60, + "75,63": -30, + "75,67": -35, + "75,71": -35, + "75,79": -35, + "75,81": -35, + "75,84": -20, + "75,85": -20, + "75,86": -20, + "75,87": -20, + "75,89": -20, + "75,98": -15, + "75,99": -30, + "75,100": -20, + "75,101": -30, + "75,103": -10, + "75,111": -30, + "75,112": -20, + "75,113": -20, + "75,115": -5, + "75,116": -20, + "75,117": -30, + "75,118": -55, + "75,119": -40, + "75,121": -40, + "75,171": -30, + "75,183": -60, + "75,199": -35, + "75,210": -35, + "75,211": -35, + "75,212": -35, + "75,213": -35, + "75,214": -35, + "75,216": -15, + "75,217": -20, + "75,218": -20, + "75,219": -20, + "75,220": -20, + "75,221": -20, + "75,231": -30, + "75,232": -30, + "75,233": -30, + "75,234": -30, + "75,235": -30, + "75,239": 10, + "75,240": -30, + "75,242": -30, + "75,243": -30, + "75,244": -30, + "75,245": -30, + "75,246": -30, + "75,248": -10, + "75,249": -30, + "75,250": -30, + "75,251": -30, + "75,252": -30, + "75,253": -40, + "75,255": -40, + "75,8211": -60, + "75,8212": -60, + "76,42": -80, + "76,44": 20, + "76,46": 20, + "76,63": -30, + "76,84": -45, + "76,85": -35, + "76,86": -65, + "76,87": -70, + "76,89": -45, + "76,97": 10, + "76,99": 5, + "76,100": 5, + "76,101": 5, + "76,111": 5, + "76,113": 5, + "76,117": -5, + "76,118": -40, + "76,119": -30, + "76,121": -30, + "76,217": -35, + "76,218": -35, + "76,219": -35, + "76,220": -35, + "76,221": -45, + "76,224": 10, + "76,225": 10, + "76,226": 10, + "76,227": 10, + "76,228": 10, + "76,229": 10, + "76,230": 10, + "76,231": 5, + "76,232": 5, + "76,233": 5, + "76,234": 5, + "76,235": 5, + "76,240": 5, + "76,242": 5, + "76,243": 5, + "76,244": 5, + "76,245": 5, + "76,246": 5, + "76,248": 5, + "76,249": -5, + "76,250": -5, + "76,251": -5, + "76,252": -5, + "76,253": -30, + "76,255": -30, + "76,8216": -70, + "76,8217": -70, + "76,8220": -70, + "76,8221": -70, + "76,8230": 20, + "77,42": -20, + "77,63": -20, + "77,67": -15, + "77,71": -15, + "77,79": -15, + "77,81": -15, + "77,84": -10, + "77,99": -10, + "77,101": -10, + "77,111": -10, + "77,112": -10, + "77,118": -10, + "77,119": -10, + "77,121": -10, + "77,171": -10, + "77,199": -15, + "77,210": -15, + "77,211": -15, + "77,212": -15, + "77,213": -15, + "77,214": -15, + "77,216": -15, + "77,231": -10, + "77,232": -10, + "77,233": -10, + "77,234": -10, + "77,235": -10, + "77,240": -10, + "77,242": -10, + "77,243": -10, + "77,244": -10, + "77,245": -10, + "77,246": -10, + "77,248": -10, + "77,253": -10, + "77,255": -10, + "77,8216": -20, + "77,8217": -20, + "77,8220": -20, + "77,8221": -20, + "78,44": -20, + "78,46": -20, + "78,47": -40, + "78,65": -10, + "78,99": -5, + "78,100": -5, + "78,101": -5, + "78,111": -5, + "78,113": -5, + "78,117": -10, + "78,118": -10, + "78,119": -10, + "78,120": -15, + "78,121": -10, + "78,192": -10, + "78,193": -10, + "78,194": -10, + "78,195": -10, + "78,196": -10, + "78,197": -10, + "78,198": -40, + "78,231": -5, + "78,232": -5, + "78,233": -5, + "78,234": -5, + "78,235": -5, + "78,236": 10, + "78,238": 30, + "78,239": 40, + "78,240": -5, + "78,242": -5, + "78,243": -5, + "78,244": -5, + "78,245": -5, + "78,246": -5, + "78,248": -5, + "78,249": -10, + "78,250": -10, + "78,251": -10, + "78,252": -10, + "78,253": -10, + "78,255": -10, + "78,8230": -20, + "79,42": -20, + "79,44": -30, + "79,45": 20, + "79,46": -30, + "79,47": -35, + "79,64": 20, + "79,65": -20, + "79,67": 10, + "79,71": 10, + "79,77": -15, + "79,79": 10, + "79,81": 10, + "79,86": -25, + "79,87": -25, + "79,88": -25, + "79,89": -30, + "79,99": 5, + "79,100": 5, + "79,101": 5, + "79,111": 5, + "79,113": 5, + "79,116": 10, + "79,171": 20, + "79,183": 20, + "79,192": -20, + "79,193": -20, + "79,194": -20, + "79,195": -20, + "79,196": -20, + "79,197": -20, + "79,198": -50, + "79,199": 10, + "79,210": 10, + "79,211": 10, + "79,212": 10, + "79,213": 10, + "79,214": 10, + "79,216": 10, + "79,221": -30, + "79,231": 5, + "79,232": 5, + "79,233": 5, + "79,234": 5, + "79,235": 5, + "79,240": 5, + "79,242": 5, + "79,243": 5, + "79,244": 5, + "79,245": 5, + "79,246": 5, + "79,248": 5, + "79,8211": 20, + "79,8212": 20, + "79,8216": -15, + "79,8217": -20, + "79,8220": -15, + "79,8221": -20, + "79,8230": -30, + "80,38": -15, + "80,42": 20, + "80,44": -70, + "80,45": -15, + "80,46": -70, + "80,47": -60, + "80,63": 20, + "80,64": -15, + "80,65": -40, + "80,67": 10, + "80,71": 10, + "80,77": -25, + "80,79": 10, + "80,81": 10, + "80,84": 10, + "80,88": -10, + "80,89": -10, + "80,99": -25, + "80,100": -25, + "80,101": -25, + "80,103": -10, + "80,111": -25, + "80,113": -25, + "80,115": -10, + "80,116": 15, + "80,171": -30, + "80,183": -15, + "80,192": -40, + "80,193": -40, + "80,194": -40, + "80,195": -40, + "80,196": -40, + "80,197": -40, + "80,198": -95, + "80,199": 10, + "80,210": 10, + "80,211": 10, + "80,212": 10, + "80,213": 10, + "80,214": 10, + "80,216": 10, + "80,221": -10, + "80,231": -25, + "80,232": -25, + "80,233": -25, + "80,234": -25, + "80,235": -25, + "80,238": 20, + "80,240": -25, + "80,242": -25, + "80,243": -25, + "80,244": -25, + "80,245": -25, + "80,246": -25, + "80,248": -25, + "80,8211": -15, + "80,8212": -15, + "80,8217": 10, + "80,8221": 10, + "80,8230": -70, + "81,42": -20, + "81,45": 20, + "81,46": -30, + "81,47": -5, + "81,59": 20, + "81,64": 20, + "81,65": -20, + "81,67": 10, + "81,71": 10, + "81,74": 30, + "81,77": -15, + "81,79": 10, + "81,81": 10, + "81,86": -25, + "81,87": -25, + "81,88": -25, + "81,89": -30, + "81,99": 5, + "81,100": 5, + "81,101": 5, + "81,111": 5, + "81,113": 5, + "81,116": 10, + "81,171": 20, + "81,183": 20, + "81,192": -20, + "81,193": -20, + "81,194": -20, + "81,195": -20, + "81,196": -20, + "81,197": -20, + "81,198": -50, + "81,199": 10, + "81,210": 10, + "81,211": 10, + "81,212": 10, + "81,213": 10, + "81,214": 10, + "81,216": 10, + "81,221": -30, + "81,231": 5, + "81,232": 5, + "81,233": 5, + "81,234": 5, + "81,235": 5, + "81,240": 5, + "81,242": 5, + "81,243": 5, + "81,244": 5, + "81,245": 5, + "81,246": 5, + "81,248": 5, + "81,8211": 20, + "81,8212": 20, + "81,8216": -15, + "81,8217": -20, + "81,8220": -15, + "81,8221": -20, + "81,8230": -30, + "82,42": -40, + "82,44": 10, + "82,45": -30, + "82,46": 10, + "82,63": -30, + "82,67": -15, + "82,71": -15, + "82,79": -15, + "82,81": -15, + "82,84": -25, + "82,85": -20, + "82,86": -40, + "82,87": -40, + "82,89": -35, + "82,99": -5, + "82,101": -5, + "82,111": -5, + "82,112": -10, + "82,117": -10, + "82,118": -15, + "82,119": -15, + "82,121": -15, + "82,183": -30, + "82,199": -15, + "82,210": -15, + "82,211": -15, + "82,212": -15, + "82,213": -15, + "82,214": -15, + "82,217": -20, + "82,218": -20, + "82,219": -20, + "82,220": -20, + "82,221": -35, + "82,231": -5, + "82,232": -5, + "82,233": -5, + "82,234": -5, + "82,235": -5, + "82,240": -5, + "82,242": -5, + "82,243": -5, + "82,244": -5, + "82,245": -5, + "82,246": -5, + "82,248": 15, + "82,249": -10, + "82,250": -10, + "82,251": -10, + "82,252": -10, + "82,253": -15, + "82,255": -15, + "82,8211": -30, + "82,8212": -30, + "82,8216": -50, + "82,8217": -40, + "82,8220": -50, + "82,8221": -40, + "82,8230": 10, + "83,44": -10, + "83,46": -10, + "83,47": -20, + "83,65": -5, + "83,77": -10, + "83,86": -5, + "83,87": -10, + "83,89": -15, + "83,90": -5, + "83,98": -10, + "83,118": -5, + "83,121": -5, + "83,171": 10, + "83,192": -5, + "83,193": -5, + "83,194": -5, + "83,195": -5, + "83,196": -5, + "83,197": -5, + "83,198": -30, + "83,221": -15, + "83,238": 10, + "83,239": 10, + "83,253": -5, + "83,255": -5, + "83,8216": 10, + "83,8220": 10, + "83,8230": -10, + "84,38": -10, + "84,44": -70, + "84,45": -20, + "84,46": -70, + "84,47": -75, + "84,58": -20, + "84,59": -20, + "84,63": 20, + "84,64": -30, + "84,65": -40, + "84,77": -10, + "84,97": -25, + "84,99": -45, + "84,100": -40, + "84,101": -45, + "84,103": -30, + "84,111": -45, + "84,113": -40, + "84,115": -35, + "84,122": -15, + "84,171": -50, + "84,183": -20, + "84,187": -20, + "84,192": -40, + "84,193": -40, + "84,194": -40, + "84,195": -40, + "84,196": -40, + "84,197": -40, + "84,198": -70, + "84,224": -25, + "84,225": -25, + "84,226": -5, + "84,228": -5, + "84,229": -25, + "84,230": -25, + "84,231": -45, + "84,232": -45, + "84,233": -45, + "84,234": -45, + "84,235": -20, + "84,236": 20, + "84,238": 35, + "84,239": 40, + "84,240": -45, + "84,242": -45, + "84,243": -45, + "84,244": -45, + "84,245": -45, + "84,246": -45, + "84,248": -45, + "84,8211": -20, + "84,8212": -20, + "84,8216": 20, + "84,8217": 20, + "84,8220": 20, + "84,8221": 20, + "84,8230": -70, + "85,38": -15, + "85,44": -40, + "85,46": -40, + "85,47": -65, + "85,58": -20, + "85,59": -20, + "85,64": -15, + "85,65": -40, + "85,97": -15, + "85,99": -20, + "85,100": -15, + "85,101": -20, + "85,103": -15, + "85,109": -10, + "85,110": -10, + "85,111": -20, + "85,112": -15, + "85,113": -15, + "85,114": -10, + "85,115": -15, + "85,117": -10, + "85,118": -10, + "85,119": -10, + "85,120": -15, + "85,121": -10, + "85,122": -20, + "85,171": -20, + "85,187": -30, + "85,192": -40, + "85,193": -40, + "85,194": -40, + "85,195": -40, + "85,196": -40, + "85,197": -40, + "85,198": -65, + "85,224": -15, + "85,225": -15, + "85,226": -15, + "85,227": -15, + "85,228": -15, + "85,229": -15, + "85,230": -15, + "85,231": -20, + "85,232": -20, + "85,233": -20, + "85,234": -20, + "85,235": -20, + "85,238": 30, + "85,239": 50, + "85,240": -20, + "85,241": -10, + "85,242": -20, + "85,243": -20, + "85,244": -20, + "85,245": -20, + "85,246": -20, + "85,248": -20, + "85,249": -10, + "85,250": -10, + "85,251": -10, + "85,252": -10, + "85,253": -10, + "85,255": -10, + "85,8216": 20, + "85,8217": 10, + "85,8220": 20, + "85,8221": 10, + "85,8230": -40, + "86,38": -40, + "86,44": -90, + "86,45": -50, + "86,46": -90, + "86,47": -100, + "86,58": -60, + "86,59": -60, + "86,64": -65, + "86,65": -70, + "86,67": -25, + "86,71": -25, + "86,77": -10, + "86,79": -25, + "86,81": -25, + "86,83": -15, + "86,97": -55, + "86,99": -65, + "86,100": -65, + "86,101": -65, + "86,103": -55, + "86,109": -35, + "86,110": -35, + "86,111": -65, + "86,112": -35, + "86,113": -65, + "86,114": -35, + "86,115": -60, + "86,117": -35, + "86,118": -30, + "86,119": -30, + "86,120": -40, + "86,121": -30, + "86,122": -45, + "86,171": -70, + "86,183": -50, + "86,187": -70, + "86,192": -70, + "86,193": -70, + "86,194": -70, + "86,195": -70, + "86,196": -70, + "86,197": -70, + "86,198": -130, + "86,199": -25, + "86,210": -25, + "86,211": -25, + "86,212": -25, + "86,213": -25, + "86,214": -25, + "86,216": -25, + "86,224": -55, + "86,225": -55, + "86,226": -35, + "86,227": -25, + "86,228": -25, + "86,229": -55, + "86,230": -55, + "86,231": -65, + "86,232": -65, + "86,233": -65, + "86,234": -65, + "86,235": -45, + "86,236": 20, + "86,238": 30, + "86,239": 30, + "86,240": -65, + "86,241": -35, + "86,242": -65, + "86,243": -65, + "86,244": -65, + "86,245": -45, + "86,246": -45, + "86,248": -65, + "86,249": -35, + "86,250": -35, + "86,251": -35, + "86,252": -35, + "86,253": -30, + "86,255": -30, + "86,8211": -50, + "86,8212": -50, + "86,8216": 10, + "86,8217": 10, + "86,8220": 10, + "86,8221": 10, + "86,8230": -90, + "87,38": -40, + "87,44": -80, + "87,45": -40, + "87,46": -80, + "87,47": -95, + "87,58": -50, + "87,59": -50, + "87,64": -60, + "87,65": -80, + "87,67": -25, + "87,71": -25, + "87,77": -10, + "87,79": -25, + "87,81": -25, + "87,83": -15, + "87,97": -65, + "87,99": -65, + "87,100": -60, + "87,101": -65, + "87,103": -60, + "87,109": -50, + "87,110": -50, + "87,111": -65, + "87,112": -50, + "87,113": -60, + "87,114": -50, + "87,115": -65, + "87,116": -10, + "87,117": -50, + "87,118": -40, + "87,119": -40, + "87,120": -50, + "87,121": -40, + "87,122": -50, + "87,171": -60, + "87,183": -40, + "87,187": -60, + "87,192": -80, + "87,193": -80, + "87,194": -80, + "87,195": -80, + "87,196": -80, + "87,197": -80, + "87,198": -120, + "87,199": -25, + "87,210": -25, + "87,211": -25, + "87,212": -25, + "87,213": -25, + "87,214": -25, + "87,216": -25, + "87,224": -65, + "87,225": -65, + "87,226": -40, + "87,227": -20, + "87,228": -35, + "87,229": -45, + "87,230": -65, + "87,231": -65, + "87,232": -65, + "87,233": -65, + "87,234": -65, + "87,235": -45, + "87,238": 30, + "87,239": 40, + "87,240": -65, + "87,241": -50, + "87,242": -65, + "87,243": -65, + "87,244": -65, + "87,245": -45, + "87,246": -45, + "87,248": -65, + "87,249": -50, + "87,250": -50, + "87,251": -50, + "87,252": -50, + "87,253": -40, + "87,255": -40, + "87,8211": -40, + "87,8212": -40, + "87,8216": 10, + "87,8217": 10, + "87,8220": 10, + "87,8221": 10, + "87,8230": -80, + "88,38": -20, + "88,44": 20, + "88,45": -40, + "88,46": 20, + "88,63": -40, + "88,64": -5, + "88,67": -25, + "88,71": -25, + "88,79": -25, + "88,81": -25, + "88,99": -20, + "88,100": -20, + "88,101": -20, + "88,111": -20, + "88,112": -10, + "88,113": -20, + "88,116": -15, + "88,117": -30, + "88,118": -50, + "88,119": -30, + "88,121": -30, + "88,171": -20, + "88,183": -40, + "88,199": -25, + "88,210": -25, + "88,211": -25, + "88,212": -25, + "88,213": -25, + "88,214": -25, + "88,216": -25, + "88,231": -20, + "88,232": -20, + "88,233": -20, + "88,234": -20, + "88,235": -20, + "88,240": -20, + "88,242": -20, + "88,243": -20, + "88,244": -20, + "88,245": -20, + "88,246": -20, + "88,249": -30, + "88,250": -30, + "88,251": -30, + "88,252": -30, + "88,253": -30, + "88,255": -30, + "88,8211": -40, + "88,8212": -40, + "88,8217": -10, + "88,8221": -10, + "88,8230": 20, + "89,38": -45, + "89,44": -100, + "89,45": -60, + "89,46": -100, + "89,47": -90, + "89,58": -70, + "89,59": -70, + "89,64": -65, + "89,65": -60, + "89,67": -30, + "89,71": -30, + "89,77": -10, + "89,79": -30, + "89,81": -30, + "89,83": -20, + "89,97": -50, + "89,99": -75, + "89,100": -75, + "89,101": -75, + "89,102": -20, + "89,103": -65, + "89,109": -50, + "89,110": -50, + "89,111": -75, + "89,112": -50, + "89,113": -75, + "89,114": -50, + "89,115": -60, + "89,116": -10, + "89,117": -50, + "89,118": -50, + "89,119": -50, + "89,120": -60, + "89,121": -50, + "89,122": -65, + "89,171": -80, + "89,183": -60, + "89,187": -60, + "89,192": -60, + "89,193": -60, + "89,194": -60, + "89,195": -60, + "89,196": -60, + "89,197": -60, + "89,198": -95, + "89,199": -30, + "89,210": -30, + "89,211": -30, + "89,212": -30, + "89,213": -30, + "89,214": -30, + "89,216": -30, + "89,223": -20, + "89,224": -50, + "89,225": -50, + "89,226": -30, + "89,227": -20, + "89,228": -20, + "89,229": -50, + "89,230": -50, + "89,231": -75, + "89,232": -75, + "89,233": -75, + "89,234": -45, + "89,235": -45, + "89,240": -75, + "89,241": -50, + "89,242": -75, + "89,243": -75, + "89,244": -45, + "89,245": -45, + "89,246": -45, + "89,248": -75, + "89,249": -50, + "89,250": -50, + "89,251": -50, + "89,252": -50, + "89,253": -50, + "89,255": -50, + "89,8211": -60, + "89,8212": -60, + "89,8216": 10, + "89,8217": 10, + "89,8220": 10, + "89,8221": 10, + "89,8230": -100, + "90,47": -5, + "90,63": -20, + "90,85": -5, + "90,118": -30, + "90,119": -20, + "90,121": -15, + "90,217": -5, + "90,218": -5, + "90,219": -5, + "90,220": -5, + "90,238": 20, + "90,239": 20, + "90,253": -15, + "90,255": -15, + "91,74": 70, + "91,103": 30, + "91,106": 50, + "95,74": 80, + "95,103": 40, + "95,106": 40, + "97,63": -35, + "97,118": -15, + "97,119": -5, + "97,121": -5, + "97,248": 10, + "97,253": -5, + "97,255": -5, + "97,8216": -50, + "97,8217": -35, + "97,8220": -50, + "97,8221": -35, + "98,44": -20, + "98,45": 20, + "98,46": -20, + "98,47": -30, + "98,63": -15, + "98,64": 10, + "98,99": 5, + "98,101": 5, + "98,111": 5, + "98,121": -5, + "98,171": 20, + "98,183": 20, + "98,231": 5, + "98,232": 5, + "98,233": 5, + "98,234": 5, + "98,235": 5, + "98,240": 5, + "98,242": 5, + "98,243": 5, + "98,244": 5, + "98,245": 5, + "98,246": 5, + "98,248": 5, + "98,253": -5, + "98,255": -5, + "98,8211": 20, + "98,8212": 20, + "98,8216": -40, + "98,8217": -15, + "98,8220": -40, + "98,8221": -15, + "98,8230": -20, + "99,116": 10, + "99,118": 10, + "99,119": 10, + "99,121": 10, + "99,253": 10, + "99,255": 10, + "100,118": -10, + "100,119": -5, + "100,121": -10, + "100,253": -10, + "100,255": -10, + "101,45": 20, + "101,47": -5, + "101,64": 10, + "101,99": 5, + "101,100": 5, + "101,101": 5, + "101,111": 5, + "101,113": 5, + "101,115": 5, + "101,118": -5, + "101,120": -5, + "101,121": -5, + "101,171": 20, + "101,183": 20, + "101,231": 5, + "101,232": 5, + "101,233": 5, + "101,234": 5, + "101,235": 5, + "101,240": 5, + "101,242": 5, + "101,243": 5, + "101,244": 5, + "101,245": 5, + "101,246": 5, + "101,248": 5, + "101,253": -5, + "101,255": -5, + "101,8211": 20, + "101,8212": 20, + "101,8216": -30, + "101,8217": -25, + "101,8220": -30, + "101,8221": -25, + "102,33": 40, + "102,41": 50, + "102,42": 50, + "102,63": 40, + "102,93": 40, + "102,124": 20, + "102,125": 40, + "102,8216": 65, + "102,8217": 60, + "102,8220": 65, + "102,8221": 60, + "103,41": 20, + "103,44": 30, + "103,47": 45, + "103,59": 25, + "103,63": 15, + "103,93": 30, + "103,95": 40, + "103,103": 20, + "103,106": 30, + "103,118": -10, + "103,119": -10, + "103,121": -10, + "103,125": 30, + "103,253": -10, + "103,255": -10, + "103,8217": 25, + "103,8221": 25, + "104,63": -25, + "104,118": -10, + "104,119": -5, + "104,8216": -40, + "104,8217": -35, + "104,8220": -40, + "104,8221": -35, + "105,118": -5, + "106,106": 10, + "107,45": -40, + "107,99": -10, + "107,101": -10, + "107,111": -10, + "107,117": -10, + "107,118": -10, + "107,119": -10, + "107,121": -10, + "107,183": -40, + "107,231": -10, + "107,232": -10, + "107,233": -10, + "107,234": -10, + "107,235": -10, + "107,240": -10, + "107,242": -10, + "107,243": -10, + "107,244": -10, + "107,245": -10, + "107,246": -10, + "107,248": 10, + "107,249": -10, + "107,250": -10, + "107,251": -10, + "107,252": -10, + "107,253": -10, + "107,255": -10, + "107,8211": -40, + "107,8212": -40, + "108,118": -10, + "108,119": -5, + "108,121": -10, + "108,253": -10, + "108,255": -10, + "109,63": -25, + "109,118": -10, + "109,119": -5, + "109,8216": -40, + "109,8217": -35, + "109,8220": -40, + "109,8221": -35, + "110,63": -25, + "110,118": -10, + "110,119": -5, + "110,8216": -40, + "110,8217": -35, + "110,8220": -40, + "110,8221": -35, + "111,44": -25, + "111,45": 20, + "111,46": -25, + "111,47": -25, + "111,63": -35, + "111,64": 10, + "111,99": 5, + "111,100": 5, + "111,101": 5, + "111,111": 5, + "111,113": 5, + "111,118": -10, + "111,119": -5, + "111,120": -15, + "111,121": -10, + "111,171": 20, + "111,183": 20, + "111,231": 5, + "111,232": 5, + "111,233": 5, + "111,234": 5, + "111,235": 5, + "111,240": 5, + "111,242": 5, + "111,243": 5, + "111,244": 5, + "111,245": 5, + "111,246": 5, + "111,248": 5, + "111,253": -10, + "111,255": -10, + "111,8211": 20, + "111,8212": 20, + "111,8216": -55, + "111,8217": -40, + "111,8220": -55, + "111,8221": -40, + "111,8230": -25, + "112,44": -20, + "112,45": 20, + "112,46": -20, + "112,47": -30, + "112,63": -15, + "112,64": 10, + "112,99": 5, + "112,101": 5, + "112,111": 5, + "112,121": -5, + "112,171": 20, + "112,183": 20, + "112,231": 5, + "112,232": 5, + "112,233": 5, + "112,234": 5, + "112,235": 5, + "112,240": 5, + "112,242": 5, + "112,243": 5, + "112,244": 5, + "112,245": 5, + "112,246": 5, + "112,248": 5, + "112,253": -5, + "112,255": -5, + "112,8211": 20, + "112,8212": 20, + "112,8216": -40, + "112,8217": -15, + "112,8220": -40, + "112,8221": -15, + "112,8230": -20, + "113,47": 20, + "113,103": 10, + "113,8216": -20, + "113,8220": -20, + "114,44": -60, + "114,46": -60, + "114,47": -60, + "114,63": 20, + "114,64": -20, + "114,99": -10, + "114,101": -10, + "114,111": -10, + "114,231": -10, + "114,232": -10, + "114,233": -10, + "114,234": -10, + "114,235": -10, + "114,240": -10, + "114,242": -10, + "114,243": -10, + "114,244": -10, + "114,245": -10, + "114,246": -10, + "114,248": -10, + "114,8230": -60, + "115,44": -15, + "115,46": -15, + "115,47": -5, + "115,63": -15, + "115,121": -5, + "115,171": 10, + "115,253": -5, + "115,255": -5, + "115,8216": -35, + "115,8217": -15, + "115,8220": -35, + "115,8221": -15, + "115,8230": -15, + "116,44": 10, + "116,45": -20, + "116,46": 10, + "116,183": -20, + "116,248": 10, + "116,8211": -20, + "116,8212": -20, + "116,8216": -5, + "116,8220": -5, + "116,8230": 10, + "117,63": -20, + "117,118": -5, + "117,8216": -45, + "117,8217": -20, + "117,8220": -45, + "117,8221": -20, + "118,38": -10, + "118,44": -70, + "118,45": -15, + "118,46": -70, + "118,47": -80, + "118,63": 20, + "118,64": -20, + "118,99": -10, + "118,100": -10, + "118,101": -10, + "118,103": -10, + "118,111": -10, + "118,113": -10, + "118,171": -20, + "118,183": -15, + "118,231": -10, + "118,232": -10, + "118,233": -10, + "118,234": -10, + "118,235": -10, + "118,240": -10, + "118,242": -10, + "118,243": -10, + "118,244": -10, + "118,245": -10, + "118,246": -10, + "118,248": -10, + "118,8211": -15, + "118,8212": -15, + "118,8230": -70, + "119,38": -10, + "119,44": -50, + "119,45": -10, + "119,46": -50, + "119,47": -65, + "119,63": 20, + "119,64": -20, + "119,99": -5, + "119,100": -5, + "119,101": -5, + "119,103": -10, + "119,111": -5, + "119,113": -5, + "119,171": -10, + "119,183": -10, + "119,231": -5, + "119,232": -5, + "119,233": -5, + "119,234": -5, + "119,235": -5, + "119,240": -5, + "119,242": -5, + "119,243": -5, + "119,244": -5, + "119,245": -5, + "119,246": -5, + "119,248": -5, + "119,8211": -10, + "119,8212": -10, + "119,8230": -50, + "120,38": -10, + "120,45": -20, + "120,99": -15, + "120,100": -15, + "120,101": -15, + "120,111": -15, + "120,113": -15, + "120,171": -20, + "120,183": -20, + "120,231": -15, + "120,232": -15, + "120,233": -15, + "120,234": -15, + "120,235": -15, + "120,240": -15, + "120,242": -15, + "120,243": -15, + "120,244": -15, + "120,245": -15, + "120,246": -15, + "120,248": -15, + "120,8211": -20, + "120,8212": -20, + "120,8216": -10, + "120,8220": -10, + "121,38": -20, + "121,44": -70, + "121,45": -20, + "121,46": -70, + "121,47": -55, + "121,63": 20, + "121,64": -35, + "121,97": -5, + "121,99": -15, + "121,100": -15, + "121,101": -15, + "121,103": -15, + "121,111": -15, + "121,113": -15, + "121,115": -5, + "121,171": -30, + "121,183": -20, + "121,224": -5, + "121,225": -5, + "121,226": -5, + "121,227": -5, + "121,228": -5, + "121,229": -5, + "121,230": -5, + "121,231": -15, + "121,232": -15, + "121,233": -15, + "121,234": -15, + "121,235": -15, + "121,240": -15, + "121,242": -15, + "121,243": -15, + "121,244": -15, + "121,245": -15, + "121,246": -15, + "121,248": -15, + "121,8211": -20, + "121,8212": -20, + "121,8230": -70, + "122,63": -15, + "122,8216": -15, + "122,8220": -15, + "123,74": 70, + "123,103": 30, + "123,106": 50, + "124,74": 40, + "124,106": 20, + "161,52": -10, + "161,74": 40, + "161,85": -20, + "161,86": -40, + "161,87": -40, + "161,89": -40, + "161,103": 15, + "161,217": -20, + "161,218": -20, + "161,219": -20, + "161,220": -20, + "161,221": -40, + "163,55": -10, + "165,52": -40, + "165,53": -20, + "165,54": -10, + "165,55": 20, + "171,51": -20, + "171,52": -20, + "171,55": -20, + "171,84": -20, + "171,85": -30, + "171,86": -70, + "171,87": -60, + "171,89": -60, + "171,217": -30, + "171,218": -30, + "171,219": -30, + "171,220": -30, + "171,221": -60, + "177,55": -10, + "182,52": -20, + "183,48": 20, + "183,49": -20, + "183,50": -30, + "183,52": 20, + "183,54": 20, + "183,55": -30, + "183,65": -30, + "183,67": 20, + "183,71": 20, + "183,79": 20, + "183,81": 20, + "183,84": -20, + "183,86": -50, + "183,87": -40, + "183,88": -40, + "183,89": -60, + "183,99": 20, + "183,100": 20, + "183,101": 20, + "183,111": 20, + "183,113": 20, + "183,118": -15, + "183,119": -10, + "183,120": -20, + "183,121": -15, + "183,192": -30, + "183,193": -30, + "183,194": -30, + "183,195": -30, + "183,196": -30, + "183,197": -30, + "183,198": -40, + "183,199": 20, + "183,210": 20, + "183,211": 20, + "183,212": 20, + "183,213": 20, + "183,214": 20, + "183,216": 20, + "183,221": -60, + "183,231": 20, + "183,232": 20, + "183,233": 20, + "183,234": 20, + "183,235": 20, + "183,240": 20, + "183,242": 20, + "183,243": 20, + "183,244": 20, + "183,245": 20, + "183,246": 20, + "183,248": 20, + "183,253": -15, + "183,255": -15, + "187,49": -50, + "187,50": -35, + "187,51": -15, + "187,52": 10, + "187,53": -10, + "187,55": -55, + "187,57": -20, + "187,65": -20, + "187,67": 20, + "187,71": 20, + "187,74": -10, + "187,77": -10, + "187,79": 20, + "187,81": 20, + "187,83": 10, + "187,84": -50, + "187,85": -20, + "187,86": -70, + "187,87": -60, + "187,88": -30, + "187,89": -80, + "187,99": 20, + "187,100": 20, + "187,101": 20, + "187,111": 20, + "187,113": 20, + "187,118": -20, + "187,119": -10, + "187,120": -20, + "187,121": -30, + "187,192": -20, + "187,193": -20, + "187,194": -20, + "187,195": -20, + "187,196": -20, + "187,197": -20, + "187,198": -35, + "187,199": 20, + "187,210": 20, + "187,211": 20, + "187,212": 20, + "187,213": 20, + "187,214": 20, + "187,216": 20, + "187,217": -20, + "187,218": -20, + "187,219": -20, + "187,220": -20, + "187,221": -80, + "187,231": 20, + "187,232": 20, + "187,233": 20, + "187,234": 20, + "187,235": 20, + "187,240": 20, + "187,242": 20, + "187,243": 20, + "187,244": 20, + "187,245": 20, + "187,246": 20, + "187,248": 20, + "187,253": -30, + "187,255": -30, + "191,48": -25, + "191,51": -5, + "191,52": -55, + "191,54": -10, + "191,55": -45, + "191,65": 20, + "191,67": -30, + "191,71": -30, + "191,79": -30, + "191,81": -30, + "191,84": -60, + "191,85": -50, + "191,86": -80, + "191,87": -80, + "191,88": 20, + "191,89": -70, + "191,99": -15, + "191,101": -15, + "191,111": -15, + "191,115": 5, + "191,116": -15, + "191,117": -30, + "191,118": -30, + "191,119": -25, + "191,121": -25, + "191,122": 5, + "191,192": 20, + "191,193": 20, + "191,194": 20, + "191,195": 20, + "191,196": 20, + "191,197": 20, + "191,198": 20, + "191,199": -30, + "191,210": -30, + "191,211": -30, + "191,212": -30, + "191,213": -30, + "191,214": -30, + "191,216": -30, + "191,217": -50, + "191,218": -50, + "191,219": -50, + "191,220": -50, + "191,221": -70, + "191,231": -15, + "191,232": -15, + "191,233": -15, + "191,234": -15, + "191,235": -15, + "191,240": -15, + "191,242": -15, + "191,243": -15, + "191,244": -15, + "191,245": -15, + "191,246": -15, + "191,248": -15, + "191,249": -30, + "191,250": -30, + "191,251": -30, + "191,252": -30, + "191,253": -25, + "191,255": -25, + "192,38": -20, + "192,42": -80, + "192,44": 10, + "192,45": -30, + "192,46": 10, + "192,63": -50, + "192,67": -20, + "192,71": -20, + "192,79": -20, + "192,81": -20, + "192,84": -40, + "192,85": -35, + "192,86": -70, + "192,87": -70, + "192,89": -60, + "192,98": -20, + "192,99": -15, + "192,100": -15, + "192,101": -15, + "192,103": -10, + "192,111": -15, + "192,112": -10, + "192,113": -15, + "192,116": -10, + "192,117": -15, + "192,118": -50, + "192,119": -35, + "192,121": -30, + "192,171": -30, + "192,183": -30, + "192,199": -20, + "192,210": -20, + "192,211": -20, + "192,212": -20, + "192,213": -20, + "192,214": -20, + "192,216": -5, + "192,217": -35, + "192,218": -35, + "192,219": -35, + "192,220": -35, + "192,221": -60, + "192,231": -15, + "192,232": -15, + "192,233": -15, + "192,234": -15, + "192,235": -15, + "192,240": -15, + "192,242": -15, + "192,243": -15, + "192,244": -15, + "192,245": -15, + "192,246": -15, + "192,248": -15, + "192,249": -15, + "192,250": -15, + "192,251": -15, + "192,252": -15, + "192,253": -30, + "192,254": -20, + "192,255": -30, + "192,8211": -30, + "192,8212": -30, + "192,8216": -110, + "192,8217": -100, + "192,8220": -110, + "192,8221": -100, + "192,8230": 10, + "193,38": -20, + "193,42": -80, + "193,44": 10, + "193,45": -30, + "193,46": 10, + "193,63": -50, + "193,67": -20, + "193,71": -20, + "193,79": -20, + "193,81": -20, + "193,84": -40, + "193,85": -35, + "193,86": -70, + "193,87": -70, + "193,89": -60, + "193,98": -20, + "193,99": -15, + "193,100": -15, + "193,101": -15, + "193,103": -10, + "193,111": -15, + "193,112": -10, + "193,113": -15, + "193,116": -10, + "193,117": -15, + "193,118": -50, + "193,119": -35, + "193,121": -30, + "193,171": -30, + "193,183": -30, + "193,199": -20, + "193,210": -20, + "193,211": -20, + "193,212": -20, + "193,213": -20, + "193,214": -20, + "193,216": -5, + "193,217": -35, + "193,218": -35, + "193,219": -35, + "193,220": -35, + "193,221": -60, + "193,231": -15, + "193,232": -15, + "193,233": -15, + "193,234": -15, + "193,235": -15, + "193,240": -15, + "193,242": -15, + "193,243": -15, + "193,244": -15, + "193,245": -15, + "193,246": -15, + "193,248": -15, + "193,249": -15, + "193,250": -15, + "193,251": -15, + "193,252": -15, + "193,253": -30, + "193,254": -20, + "193,255": -30, + "193,8211": -30, + "193,8212": -30, + "193,8216": -110, + "193,8217": -100, + "193,8220": -110, + "193,8221": -100, + "193,8230": 10, + "194,38": -20, + "194,42": -80, + "194,44": 10, + "194,45": -30, + "194,46": 10, + "194,63": -50, + "194,67": -20, + "194,71": -20, + "194,79": -20, + "194,81": -20, + "194,84": -40, + "194,85": -35, + "194,86": -70, + "194,87": -70, + "194,89": -60, + "194,98": -20, + "194,99": -15, + "194,100": -15, + "194,101": -15, + "194,103": -10, + "194,111": -15, + "194,112": -10, + "194,113": -15, + "194,116": -10, + "194,117": -15, + "194,118": -50, + "194,119": -35, + "194,121": -30, + "194,171": -30, + "194,183": -30, + "194,199": -20, + "194,210": -20, + "194,211": -20, + "194,212": -20, + "194,213": -20, + "194,214": -20, + "194,216": -5, + "194,217": -35, + "194,218": -35, + "194,219": -35, + "194,220": -35, + "194,221": -60, + "194,231": -15, + "194,232": -15, + "194,233": -15, + "194,234": -15, + "194,235": -15, + "194,240": -15, + "194,242": -15, + "194,243": -15, + "194,244": -15, + "194,245": -15, + "194,246": -15, + "194,248": -15, + "194,249": -15, + "194,250": -15, + "194,251": -15, + "194,252": -15, + "194,253": -30, + "194,254": -20, + "194,255": -30, + "194,8211": -30, + "194,8212": -30, + "194,8216": -110, + "194,8217": -100, + "194,8220": -110, + "194,8221": -100, + "194,8230": 10, + "195,38": -20, + "195,42": -80, + "195,44": 10, + "195,45": -30, + "195,46": 10, + "195,63": -50, + "195,67": -20, + "195,71": -20, + "195,79": -20, + "195,81": -20, + "195,84": -40, + "195,85": -35, + "195,86": -70, + "195,87": -70, + "195,89": -60, + "195,98": -20, + "195,99": -15, + "195,100": -15, + "195,101": -15, + "195,103": -10, + "195,111": -15, + "195,112": -10, + "195,113": -15, + "195,116": -10, + "195,117": -15, + "195,118": -50, + "195,119": -35, + "195,121": -30, + "195,171": -30, + "195,183": -30, + "195,199": -20, + "195,210": -20, + "195,211": -20, + "195,212": -20, + "195,213": -20, + "195,214": -20, + "195,216": -5, + "195,217": -35, + "195,218": -35, + "195,219": -35, + "195,220": -35, + "195,221": -60, + "195,231": -15, + "195,232": -15, + "195,233": -15, + "195,234": -15, + "195,235": -15, + "195,240": -15, + "195,242": -15, + "195,243": -15, + "195,244": -15, + "195,245": -15, + "195,246": -15, + "195,248": -15, + "195,249": -15, + "195,250": -15, + "195,251": -15, + "195,252": -15, + "195,253": -30, + "195,254": -20, + "195,255": -30, + "195,8211": -30, + "195,8212": -30, + "195,8216": -110, + "195,8217": -100, + "195,8220": -110, + "195,8221": -100, + "195,8230": 10, + "196,38": -20, + "196,42": -80, + "196,44": 10, + "196,45": -30, + "196,46": 10, + "196,63": -50, + "196,67": -20, + "196,71": -20, + "196,79": -20, + "196,81": -20, + "196,84": -40, + "196,85": -35, + "196,86": -70, + "196,87": -70, + "196,89": -60, + "196,98": -20, + "196,99": -15, + "196,100": -15, + "196,101": -15, + "196,103": -10, + "196,111": -15, + "196,112": -10, + "196,113": -15, + "196,116": -10, + "196,117": -15, + "196,118": -50, + "196,119": -35, + "196,121": -30, + "196,171": -30, + "196,183": -30, + "196,199": -20, + "196,210": -20, + "196,211": -20, + "196,212": -20, + "196,213": -20, + "196,214": -20, + "196,216": -5, + "196,217": -35, + "196,218": -35, + "196,219": -35, + "196,220": -35, + "196,221": -60, + "196,231": -15, + "196,232": -15, + "196,233": -15, + "196,234": -15, + "196,235": -15, + "196,240": -15, + "196,242": -15, + "196,243": -15, + "196,244": -15, + "196,245": -15, + "196,246": -15, + "196,248": -15, + "196,249": -15, + "196,250": -15, + "196,251": -15, + "196,252": -15, + "196,253": -30, + "196,254": -20, + "196,255": -30, + "196,8211": -30, + "196,8212": -30, + "196,8216": -110, + "196,8217": -100, + "196,8220": -110, + "196,8221": -100, + "196,8230": 10, + "197,38": -20, + "197,42": -80, + "197,44": 10, + "197,45": -30, + "197,46": 10, + "197,63": -50, + "197,67": -20, + "197,71": -20, + "197,79": -20, + "197,81": -20, + "197,84": -40, + "197,85": -35, + "197,86": -70, + "197,87": -70, + "197,89": -60, + "197,98": -20, + "197,99": -15, + "197,100": -15, + "197,101": -15, + "197,103": -10, + "197,111": -15, + "197,112": -10, + "197,113": -15, + "197,116": -10, + "197,117": -15, + "197,118": -50, + "197,119": -35, + "197,121": -30, + "197,171": -30, + "197,183": -30, + "197,199": -20, + "197,210": -20, + "197,211": -20, + "197,212": -20, + "197,213": -20, + "197,214": -20, + "197,216": -5, + "197,217": -35, + "197,218": -35, + "197,219": -35, + "197,220": -35, + "197,221": -60, + "197,231": -15, + "197,232": -15, + "197,233": -15, + "197,234": -15, + "197,235": -15, + "197,240": -15, + "197,242": -15, + "197,243": -15, + "197,244": -15, + "197,245": -15, + "197,246": -15, + "197,248": -15, + "197,249": -15, + "197,250": -15, + "197,251": -15, + "197,252": -15, + "197,253": -30, + "197,254": -20, + "197,255": -30, + "197,8211": -30, + "197,8212": -30, + "197,8216": -110, + "197,8217": -100, + "197,8220": -110, + "197,8221": -100, + "197,8230": 10, + "198,38": 10, + "198,98": -10, + "198,118": -10, + "198,119": -5, + "198,121": -10, + "198,238": 10, + "198,239": 10, + "198,253": -10, + "198,255": -10, + "199,42": 30, + "199,47": -20, + "199,99": -5, + "199,101": -5, + "199,111": -5, + "199,116": 10, + "199,231": -5, + "199,232": -5, + "199,233": -5, + "199,234": -5, + "199,235": -5, + "199,238": 25, + "199,239": 25, + "199,240": -5, + "199,242": -5, + "199,243": -5, + "199,244": -5, + "199,245": -5, + "199,246": -5, + "199,248": -5, + "199,8216": 20, + "199,8220": 20, + "200,38": 10, + "200,98": -10, + "200,118": -10, + "200,119": -5, + "200,121": -10, + "200,238": 10, + "200,239": 10, + "200,253": -10, + "200,255": -10, + "201,38": 10, + "201,98": -10, + "201,118": -10, + "201,119": -5, + "201,121": -10, + "201,238": 10, + "201,239": 10, + "201,253": -10, + "201,255": -10, + "202,38": 10, + "202,98": -10, + "202,118": -10, + "202,119": -5, + "202,121": -10, + "202,238": 10, + "202,239": 10, + "202,253": -10, + "202,255": -10, + "203,38": 10, + "203,98": -10, + "203,118": -10, + "203,119": -5, + "203,121": -10, + "203,238": 10, + "203,239": 10, + "203,253": -10, + "203,255": -10, + "204,238": 20, + "204,239": 30, + "205,238": 20, + "205,239": 30, + "206,238": 20, + "206,239": 30, + "207,238": 20, + "207,239": 30, + "208,42": -20, + "208,44": -30, + "208,45": 20, + "208,46": -30, + "208,47": -50, + "208,64": 20, + "208,65": -20, + "208,67": 10, + "208,71": 10, + "208,77": -15, + "208,79": 10, + "208,81": 10, + "208,86": -20, + "208,87": -25, + "208,88": -20, + "208,89": -20, + "208,99": 5, + "208,100": 5, + "208,101": 5, + "208,111": 5, + "208,113": 5, + "208,116": 10, + "208,121": 10, + "208,171": 20, + "208,183": 20, + "208,192": -20, + "208,193": -20, + "208,194": -20, + "208,195": -20, + "208,196": -20, + "208,197": -20, + "208,198": -45, + "208,199": 10, + "208,210": 10, + "208,211": 10, + "208,212": 10, + "208,213": 10, + "208,214": 10, + "208,216": 10, + "208,221": -20, + "208,231": 5, + "208,232": 5, + "208,233": 5, + "208,234": 5, + "208,235": 5, + "208,240": 5, + "208,242": 5, + "208,243": 5, + "208,244": 5, + "208,245": 5, + "208,246": 5, + "208,248": 5, + "208,253": 10, + "208,255": 10, + "208,8211": 20, + "208,8212": 20, + "208,8216": -20, + "208,8217": -10, + "208,8220": -20, + "208,8221": -10, + "208,8230": -30, + "209,44": -20, + "209,46": -20, + "209,47": -40, + "209,65": -10, + "209,99": -5, + "209,100": -5, + "209,101": -5, + "209,111": -5, + "209,113": -5, + "209,117": -10, + "209,118": -10, + "209,119": -10, + "209,120": -15, + "209,121": -10, + "209,192": -10, + "209,193": -10, + "209,194": -10, + "209,195": -10, + "209,196": -10, + "209,197": -10, + "209,198": -40, + "209,231": -5, + "209,232": -5, + "209,233": -5, + "209,234": -5, + "209,235": -5, + "209,236": 10, + "209,238": 30, + "209,239": 40, + "209,240": -5, + "209,242": -5, + "209,243": -5, + "209,244": -5, + "209,245": -5, + "209,246": -5, + "209,248": -5, + "209,249": -10, + "209,250": -10, + "209,251": -10, + "209,252": -10, + "209,253": -10, + "209,255": -10, + "209,8230": -20, + "210,42": -20, + "210,44": -30, + "210,45": 20, + "210,46": -30, + "210,47": -35, + "210,64": 20, + "210,65": -20, + "210,67": 10, + "210,71": 10, + "210,77": -15, + "210,79": 10, + "210,81": 10, + "210,86": -25, + "210,87": -25, + "210,88": -25, + "210,89": -30, + "210,99": 5, + "210,100": 5, + "210,101": 5, + "210,111": 5, + "210,113": 5, + "210,116": 10, + "210,171": 20, + "210,183": 20, + "210,192": -20, + "210,193": -20, + "210,194": -20, + "210,195": -20, + "210,196": -20, + "210,197": -20, + "210,198": -50, + "210,199": 10, + "210,210": 10, + "210,211": 10, + "210,212": 10, + "210,213": 10, + "210,214": 10, + "210,216": 10, + "210,221": -30, + "210,231": 5, + "210,232": 5, + "210,233": 5, + "210,234": 5, + "210,235": 5, + "210,240": 5, + "210,242": 5, + "210,243": 5, + "210,244": 5, + "210,245": 5, + "210,246": 5, + "210,248": 5, + "210,8211": 20, + "210,8212": 20, + "210,8216": -15, + "210,8217": -20, + "210,8220": -15, + "210,8221": -20, + "210,8230": -30, + "211,42": -20, + "211,44": -30, + "211,45": 20, + "211,46": -30, + "211,47": -35, + "211,64": 20, + "211,65": -20, + "211,67": 10, + "211,71": 10, + "211,77": -15, + "211,79": 10, + "211,81": 10, + "211,86": -25, + "211,87": -25, + "211,88": -25, + "211,89": -30, + "211,99": 5, + "211,100": 5, + "211,101": 5, + "211,111": 5, + "211,113": 5, + "211,116": 10, + "211,171": 20, + "211,183": 20, + "211,192": -20, + "211,193": -20, + "211,194": -20, + "211,195": -20, + "211,196": -20, + "211,197": -20, + "211,198": -50, + "211,199": 10, + "211,210": 10, + "211,211": 10, + "211,212": 10, + "211,213": 10, + "211,214": 10, + "211,216": 10, + "211,221": -30, + "211,231": 5, + "211,232": 5, + "211,233": 5, + "211,234": 5, + "211,235": 5, + "211,240": 5, + "211,242": 5, + "211,243": 5, + "211,244": 5, + "211,245": 5, + "211,246": 5, + "211,248": 5, + "211,8211": 20, + "211,8212": 20, + "211,8216": -15, + "211,8217": -20, + "211,8220": -15, + "211,8221": -20, + "211,8230": -30, + "212,42": -20, + "212,44": -30, + "212,45": 20, + "212,46": -30, + "212,47": -35, + "212,64": 20, + "212,65": -20, + "212,67": 10, + "212,71": 10, + "212,77": -15, + "212,79": 10, + "212,81": 10, + "212,86": -25, + "212,87": -25, + "212,88": -25, + "212,89": -30, + "212,99": 5, + "212,100": 5, + "212,101": 5, + "212,111": 5, + "212,113": 5, + "212,116": 10, + "212,171": 20, + "212,183": 20, + "212,192": -20, + "212,193": -20, + "212,194": -20, + "212,195": -20, + "212,196": -20, + "212,197": -20, + "212,198": -50, + "212,199": 10, + "212,210": 10, + "212,211": 10, + "212,212": 10, + "212,213": 10, + "212,214": 10, + "212,216": 10, + "212,221": -30, + "212,231": 5, + "212,232": 5, + "212,233": 5, + "212,234": 5, + "212,235": 5, + "212,240": 5, + "212,242": 5, + "212,243": 5, + "212,244": 5, + "212,245": 5, + "212,246": 5, + "212,248": 5, + "212,8211": 20, + "212,8212": 20, + "212,8216": -15, + "212,8217": -20, + "212,8220": -15, + "212,8221": -20, + "212,8230": -30, + "213,42": -20, + "213,44": -30, + "213,45": 20, + "213,46": -30, + "213,47": -35, + "213,64": 20, + "213,65": -20, + "213,67": 10, + "213,71": 10, + "213,77": -15, + "213,79": 10, + "213,81": 10, + "213,86": -25, + "213,87": -25, + "213,88": -25, + "213,89": -30, + "213,99": 5, + "213,100": 5, + "213,101": 5, + "213,111": 5, + "213,113": 5, + "213,116": 10, + "213,171": 20, + "213,183": 20, + "213,192": -20, + "213,193": -20, + "213,194": -20, + "213,195": -20, + "213,196": -20, + "213,197": -20, + "213,198": -50, + "213,199": 10, + "213,210": 10, + "213,211": 10, + "213,212": 10, + "213,213": 10, + "213,214": 10, + "213,216": 10, + "213,221": -30, + "213,231": 5, + "213,232": 5, + "213,233": 5, + "213,234": 5, + "213,235": 5, + "213,240": 5, + "213,242": 5, + "213,243": 5, + "213,244": 5, + "213,245": 5, + "213,246": 5, + "213,248": 5, + "213,8211": 20, + "213,8212": 20, + "213,8216": -15, + "213,8217": -20, + "213,8220": -15, + "213,8221": -20, + "213,8230": -30, + "214,42": -20, + "214,44": -30, + "214,45": 20, + "214,46": -30, + "214,47": -35, + "214,64": 20, + "214,65": -20, + "214,67": 10, + "214,71": 10, + "214,77": -15, + "214,79": 10, + "214,81": 10, + "214,86": -25, + "214,87": -25, + "214,88": -25, + "214,89": -30, + "214,99": 5, + "214,100": 5, + "214,101": 5, + "214,111": 5, + "214,113": 5, + "214,116": 10, + "214,171": 20, + "214,183": 20, + "214,192": -20, + "214,193": -20, + "214,194": -20, + "214,195": -20, + "214,196": -20, + "214,197": -20, + "214,198": -50, + "214,199": 10, + "214,210": 10, + "214,211": 10, + "214,212": 10, + "214,213": 10, + "214,214": 10, + "214,216": 10, + "214,221": -30, + "214,231": 5, + "214,232": 5, + "214,233": 5, + "214,234": 5, + "214,235": 5, + "214,240": 5, + "214,242": 5, + "214,243": 5, + "214,244": 5, + "214,245": 5, + "214,246": 5, + "214,248": 5, + "214,8211": 20, + "214,8212": 20, + "214,8216": -15, + "214,8217": -20, + "214,8220": -15, + "214,8221": -20, + "214,8230": -30, + "215,49": -20, + "215,50": -15, + "215,55": -10, + "216,42": -20, + "216,44": -30, + "216,45": 20, + "216,46": -30, + "216,47": -35, + "216,64": 20, + "216,65": -20, + "216,67": 10, + "216,71": 10, + "216,77": -15, + "216,79": 10, + "216,81": 10, + "216,86": -25, + "216,87": -25, + "216,88": -25, + "216,89": -30, + "216,99": 5, + "216,100": 5, + "216,101": 5, + "216,111": 5, + "216,113": 5, + "216,116": 10, + "216,171": 20, + "216,183": 20, + "216,192": -20, + "216,193": -20, + "216,194": -20, + "216,195": -20, + "216,196": -20, + "216,197": -20, + "216,198": -50, + "216,199": 10, + "216,210": 10, + "216,211": 10, + "216,212": 10, + "216,213": 10, + "216,214": 10, + "216,216": 10, + "216,221": -30, + "216,231": 5, + "216,232": 5, + "216,233": 5, + "216,234": 5, + "216,235": 5, + "216,240": 5, + "216,242": 5, + "216,243": 5, + "216,244": 5, + "216,245": 5, + "216,246": 5, + "216,248": 5, + "216,8211": 20, + "216,8212": 20, + "216,8216": -15, + "216,8217": -20, + "216,8220": -15, + "216,8221": -20, + "216,8230": -30, + "217,38": -15, + "217,44": -40, + "217,46": -40, + "217,47": -65, + "217,58": -20, + "217,59": -20, + "217,64": -15, + "217,65": -40, + "217,97": -15, + "217,99": -20, + "217,100": -15, + "217,101": -20, + "217,103": -15, + "217,109": -10, + "217,110": -10, + "217,111": -20, + "217,112": -15, + "217,113": -15, + "217,114": -10, + "217,115": -15, + "217,117": -10, + "217,118": -10, + "217,119": -10, + "217,120": -15, + "217,121": -10, + "217,122": -20, + "217,171": -20, + "217,187": -30, + "217,192": -40, + "217,193": -40, + "217,194": -40, + "217,195": -40, + "217,196": -40, + "217,197": -40, + "217,198": -65, + "217,224": -15, + "217,225": -15, + "217,226": -15, + "217,227": -15, + "217,228": -15, + "217,229": -15, + "217,230": -15, + "217,231": -20, + "217,232": -20, + "217,233": -20, + "217,234": -20, + "217,235": -20, + "217,238": 30, + "217,239": 50, + "217,240": -20, + "217,241": -10, + "217,242": -20, + "217,243": -20, + "217,244": -20, + "217,245": -20, + "217,246": -20, + "217,248": -20, + "217,249": -10, + "217,250": -10, + "217,251": -10, + "217,252": -10, + "217,253": -10, + "217,255": -10, + "217,8216": 20, + "217,8217": 10, + "217,8220": 20, + "217,8221": 10, + "217,8230": -40, + "218,38": -15, + "218,44": -40, + "218,46": -40, + "218,47": -65, + "218,58": -20, + "218,59": -20, + "218,64": -15, + "218,65": -40, + "218,97": -15, + "218,99": -20, + "218,100": -15, + "218,101": -20, + "218,103": -15, + "218,109": -10, + "218,110": -10, + "218,111": -20, + "218,112": -15, + "218,113": -15, + "218,114": -10, + "218,115": -15, + "218,117": -10, + "218,118": -10, + "218,119": -10, + "218,120": -15, + "218,121": -10, + "218,122": -20, + "218,171": -20, + "218,187": -30, + "218,192": -40, + "218,193": -40, + "218,194": -40, + "218,195": -40, + "218,196": -40, + "218,197": -40, + "218,198": -65, + "218,224": -15, + "218,225": -15, + "218,226": -15, + "218,227": -15, + "218,228": -15, + "218,229": -15, + "218,230": -15, + "218,231": -20, + "218,232": -20, + "218,233": -20, + "218,234": -20, + "218,235": -20, + "218,238": 30, + "218,239": 50, + "218,240": -20, + "218,241": -10, + "218,242": -20, + "218,243": -20, + "218,244": -20, + "218,245": -20, + "218,246": -20, + "218,248": -20, + "218,249": -10, + "218,250": -10, + "218,251": -10, + "218,252": -10, + "218,253": -10, + "218,255": -10, + "218,8216": 20, + "218,8217": 10, + "218,8220": 20, + "218,8221": 10, + "218,8230": -40, + "219,38": -15, + "219,44": -40, + "219,46": -40, + "219,47": -65, + "219,58": -20, + "219,59": -20, + "219,64": -15, + "219,65": -40, + "219,97": -15, + "219,99": -20, + "219,100": -15, + "219,101": -20, + "219,103": -15, + "219,109": -10, + "219,110": -10, + "219,111": -20, + "219,112": -15, + "219,113": -15, + "219,114": -10, + "219,115": -15, + "219,117": -10, + "219,118": -10, + "219,119": -10, + "219,120": -15, + "219,121": -10, + "219,122": -20, + "219,171": -20, + "219,187": -30, + "219,192": -40, + "219,193": -40, + "219,194": -40, + "219,195": -40, + "219,196": -40, + "219,197": -40, + "219,198": -65, + "219,224": -15, + "219,225": -15, + "219,226": -15, + "219,227": -15, + "219,228": -15, + "219,229": -15, + "219,230": -15, + "219,231": -20, + "219,232": -20, + "219,233": -20, + "219,234": -20, + "219,235": -20, + "219,238": 30, + "219,239": 50, + "219,240": -20, + "219,241": -10, + "219,242": -20, + "219,243": -20, + "219,244": -20, + "219,245": -20, + "219,246": -20, + "219,248": -20, + "219,249": -10, + "219,250": -10, + "219,251": -10, + "219,252": -10, + "219,253": -10, + "219,255": -10, + "219,8216": 20, + "219,8217": 10, + "219,8220": 20, + "219,8221": 10, + "219,8230": -40, + "220,38": -15, + "220,44": -40, + "220,46": -40, + "220,47": -65, + "220,58": -20, + "220,59": -20, + "220,64": -15, + "220,65": -40, + "220,97": -15, + "220,99": -20, + "220,100": -15, + "220,101": -20, + "220,103": -15, + "220,109": -10, + "220,110": -10, + "220,111": -20, + "220,112": -15, + "220,113": -15, + "220,114": -10, + "220,115": -15, + "220,117": -10, + "220,118": -10, + "220,119": -10, + "220,120": -15, + "220,121": -10, + "220,122": -20, + "220,171": -20, + "220,187": -30, + "220,192": -40, + "220,193": -40, + "220,194": -40, + "220,195": -40, + "220,196": -40, + "220,197": -40, + "220,198": -65, + "220,224": -15, + "220,225": -15, + "220,226": -15, + "220,227": -15, + "220,228": -15, + "220,229": -15, + "220,230": -15, + "220,231": -20, + "220,232": -20, + "220,233": -20, + "220,234": -20, + "220,235": -20, + "220,238": 30, + "220,239": 50, + "220,240": -20, + "220,241": -10, + "220,242": -20, + "220,243": -20, + "220,244": -20, + "220,245": -20, + "220,246": -20, + "220,248": -20, + "220,249": -10, + "220,250": -10, + "220,251": -10, + "220,252": -10, + "220,253": -10, + "220,255": -10, + "220,8216": 20, + "220,8217": 10, + "220,8220": 20, + "220,8221": 10, + "220,8230": -40, + "221,38": -45, + "221,44": -100, + "221,45": -60, + "221,46": -100, + "221,47": -90, + "221,58": -70, + "221,59": -70, + "221,64": -65, + "221,65": -60, + "221,67": -30, + "221,71": -30, + "221,77": -10, + "221,79": -30, + "221,81": -30, + "221,83": -20, + "221,97": -50, + "221,99": -75, + "221,100": -75, + "221,101": -75, + "221,102": -20, + "221,103": -65, + "221,109": -50, + "221,110": -50, + "221,111": -75, + "221,112": -50, + "221,113": -75, + "221,114": -50, + "221,115": -60, + "221,116": -10, + "221,117": -50, + "221,118": -50, + "221,119": -50, + "221,120": -60, + "221,121": -50, + "221,122": -65, + "221,171": -80, + "221,183": -60, + "221,187": -60, + "221,192": -60, + "221,193": -60, + "221,194": -60, + "221,195": -60, + "221,196": -60, + "221,197": -60, + "221,198": -95, + "221,199": -30, + "221,210": -30, + "221,211": -30, + "221,212": -30, + "221,213": -30, + "221,214": -30, + "221,216": -30, + "221,223": -20, + "221,224": -50, + "221,225": -50, + "221,226": -30, + "221,227": -20, + "221,228": -20, + "221,229": -50, + "221,230": -50, + "221,231": -75, + "221,232": -75, + "221,233": -75, + "221,234": -45, + "221,235": -45, + "221,240": -75, + "221,241": -50, + "221,242": -75, + "221,243": -75, + "221,244": -45, + "221,245": -45, + "221,246": -45, + "221,248": -75, + "221,249": -50, + "221,250": -50, + "221,251": -50, + "221,252": -50, + "221,253": -50, + "221,255": -50, + "221,8211": -60, + "221,8212": -60, + "221,8216": 10, + "221,8217": 10, + "221,8220": 10, + "221,8221": 10, + "221,8230": -100, + "222,42": -20, + "222,44": -20, + "222,45": 20, + "222,46": -20, + "222,47": -50, + "222,64": 20, + "222,65": -30, + "222,67": 20, + "222,71": 20, + "222,77": -10, + "222,79": 20, + "222,81": 20, + "222,86": -30, + "222,87": -30, + "222,88": -20, + "222,89": -25, + "222,99": 10, + "222,100": 10, + "222,101": 10, + "222,111": 10, + "222,113": 10, + "222,118": 10, + "222,119": 10, + "222,121": 10, + "222,171": 20, + "222,183": 20, + "222,192": -30, + "222,193": -30, + "222,194": -30, + "222,195": -30, + "222,196": -30, + "222,197": -30, + "222,198": -55, + "222,199": 20, + "222,210": 20, + "222,211": 20, + "222,212": 20, + "222,213": 20, + "222,214": 20, + "222,216": 20, + "222,221": -25, + "222,231": 10, + "222,232": 10, + "222,233": 10, + "222,234": 10, + "222,235": 10, + "222,240": 10, + "222,242": 10, + "222,243": 10, + "222,244": 10, + "222,245": 10, + "222,246": 10, + "222,248": 10, + "222,253": 10, + "222,255": 10, + "222,8211": 20, + "222,8212": 20, + "222,8216": -40, + "222,8220": -40, + "222,8230": -20, + "223,63": -20, + "223,98": -10, + "223,117": -5, + "223,118": -20, + "223,119": -20, + "223,121": -15, + "223,249": -5, + "223,250": -5, + "223,251": -5, + "223,252": -5, + "223,253": -15, + "223,255": -15, + "223,8217": -20, + "223,8221": -20, + "224,63": -35, + "224,118": -15, + "224,119": -5, + "224,121": -5, + "224,248": 10, + "224,253": -5, + "224,255": -5, + "224,8216": -50, + "224,8217": -35, + "224,8220": -50, + "224,8221": -35, + "225,63": -35, + "225,118": -15, + "225,119": -5, + "225,121": -5, + "225,248": 10, + "225,253": -5, + "225,255": -5, + "225,8216": -50, + "225,8217": -35, + "225,8220": -50, + "225,8221": -35, + "226,63": -35, + "226,118": -15, + "226,119": -5, + "226,121": -5, + "226,248": 10, + "226,253": -5, + "226,255": -5, + "226,8216": -50, + "226,8217": -35, + "226,8220": -50, + "226,8221": -35, + "227,63": -35, + "227,118": -15, + "227,119": -5, + "227,121": -5, + "227,248": 10, + "227,253": -5, + "227,255": -5, + "227,8216": -50, + "227,8217": -35, + "227,8220": -50, + "227,8221": -35, + "228,63": -35, + "228,118": -15, + "228,119": -5, + "228,121": -5, + "228,248": 10, + "228,253": -5, + "228,255": -5, + "228,8216": -50, + "228,8217": -35, + "228,8220": -50, + "228,8221": -35, + "229,63": -35, + "229,118": -15, + "229,119": -5, + "229,121": -5, + "229,248": 10, + "229,253": -5, + "229,255": -5, + "229,8216": -50, + "229,8217": -35, + "229,8220": -50, + "229,8221": -35, + "230,45": 20, + "230,47": -5, + "230,64": 10, + "230,99": 5, + "230,100": 5, + "230,101": 5, + "230,111": 5, + "230,113": 5, + "230,115": 5, + "230,118": -5, + "230,120": -5, + "230,121": -5, + "230,171": 20, + "230,183": 20, + "230,231": 5, + "230,232": 5, + "230,233": 5, + "230,234": 5, + "230,235": 5, + "230,240": 5, + "230,242": 5, + "230,243": 5, + "230,244": 5, + "230,245": 5, + "230,246": 5, + "230,248": 5, + "230,253": -5, + "230,255": -5, + "230,8211": 20, + "230,8212": 20, + "230,8216": -30, + "230,8217": -25, + "230,8220": -30, + "230,8221": -25, + "231,116": 10, + "231,118": 10, + "231,119": 10, + "231,121": 10, + "231,253": 10, + "231,255": 10, + "232,45": 20, + "232,47": -5, + "232,64": 10, + "232,99": 5, + "232,100": 5, + "232,101": 5, + "232,111": 5, + "232,113": 5, + "232,115": 5, + "232,118": -5, + "232,120": -5, + "232,121": -5, + "232,171": 20, + "232,183": 20, + "232,231": 5, + "232,232": 5, + "232,233": 5, + "232,234": 5, + "232,235": 5, + "232,240": 5, + "232,242": 5, + "232,243": 5, + "232,244": 5, + "232,245": 5, + "232,246": 5, + "232,248": 5, + "232,253": -5, + "232,255": -5, + "232,8211": 20, + "232,8212": 20, + "232,8216": -30, + "232,8217": -25, + "232,8220": -30, + "232,8221": -25, + "233,45": 20, + "233,47": -5, + "233,64": 10, + "233,99": 5, + "233,100": 5, + "233,101": 5, + "233,111": 5, + "233,113": 5, + "233,115": 5, + "233,118": -5, + "233,120": -5, + "233,121": -5, + "233,171": 20, + "233,183": 20, + "233,231": 5, + "233,232": 5, + "233,233": 5, + "233,234": 5, + "233,235": 5, + "233,240": 5, + "233,242": 5, + "233,243": 5, + "233,244": 5, + "233,245": 5, + "233,246": 5, + "233,248": 5, + "233,253": -5, + "233,255": -5, + "233,8211": 20, + "233,8212": 20, + "233,8216": -30, + "233,8217": -25, + "233,8220": -30, + "233,8221": -25, + "234,45": 20, + "234,47": -5, + "234,64": 10, + "234,99": 5, + "234,100": 5, + "234,101": 5, + "234,111": 5, + "234,113": 5, + "234,115": 5, + "234,118": -5, + "234,120": -5, + "234,121": -5, + "234,171": 20, + "234,183": 20, + "234,231": 5, + "234,232": 5, + "234,233": 5, + "234,234": 5, + "234,235": 5, + "234,240": 5, + "234,242": 5, + "234,243": 5, + "234,244": 5, + "234,245": 5, + "234,246": 5, + "234,248": 5, + "234,253": -5, + "234,255": -5, + "234,8211": 20, + "234,8212": 20, + "234,8216": -30, + "234,8217": -25, + "234,8220": -30, + "234,8221": -25, + "235,45": 20, + "235,47": -5, + "235,64": 10, + "235,99": 5, + "235,100": 5, + "235,101": 5, + "235,111": 5, + "235,113": 5, + "235,115": 5, + "235,118": -5, + "235,120": -5, + "235,121": -5, + "235,171": 20, + "235,183": 20, + "235,231": 5, + "235,232": 5, + "235,233": 5, + "235,234": 5, + "235,235": 5, + "235,240": 5, + "235,242": 5, + "235,243": 5, + "235,244": 5, + "235,245": 5, + "235,246": 5, + "235,248": 5, + "235,253": -5, + "235,255": -5, + "235,8211": 20, + "235,8212": 20, + "235,8216": -30, + "235,8217": -25, + "235,8220": -30, + "235,8221": -25, + "236,118": -5, + "237,118": -5, + "238,118": -5, + "239,104": 20, + "239,107": 20, + "239,108": 20, + "239,118": -5, + "240,44": -25, + "240,45": 20, + "240,46": -25, + "240,47": -35, + "240,99": 5, + "240,101": 5, + "240,111": 5, + "240,171": 20, + "240,183": 20, + "240,231": 5, + "240,232": 5, + "240,233": 5, + "240,234": 5, + "240,235": 5, + "240,240": 5, + "240,242": 5, + "240,243": 5, + "240,244": 5, + "240,245": 5, + "240,246": 5, + "240,248": 5, + "240,8211": 20, + "240,8212": 20, + "240,8230": -25, + "241,63": -25, + "241,118": -10, + "241,119": -5, + "241,8216": -40, + "241,8217": -35, + "241,8220": -40, + "241,8221": -35, + "242,44": -25, + "242,45": 20, + "242,46": -25, + "242,47": -25, + "242,63": -35, + "242,64": 10, + "242,99": 5, + "242,100": 5, + "242,101": 5, + "242,111": 5, + "242,113": 5, + "242,118": -10, + "242,119": -5, + "242,120": -15, + "242,121": -10, + "242,171": 20, + "242,183": 20, + "242,231": 5, + "242,232": 5, + "242,233": 5, + "242,234": 5, + "242,235": 5, + "242,240": 5, + "242,242": 5, + "242,243": 5, + "242,244": 5, + "242,245": 5, + "242,246": 5, + "242,248": 5, + "242,253": -10, + "242,255": -10, + "242,8211": 20, + "242,8212": 20, + "242,8216": -55, + "242,8217": -40, + "242,8220": -55, + "242,8221": -40, + "242,8230": -25, + "243,44": -25, + "243,45": 20, + "243,46": -25, + "243,47": -25, + "243,63": -35, + "243,64": 10, + "243,99": 5, + "243,100": 5, + "243,101": 5, + "243,111": 5, + "243,113": 5, + "243,118": -10, + "243,119": -5, + "243,120": -15, + "243,121": -10, + "243,171": 20, + "243,183": 20, + "243,231": 5, + "243,232": 5, + "243,233": 5, + "243,234": 5, + "243,235": 5, + "243,240": 5, + "243,242": 5, + "243,243": 5, + "243,244": 5, + "243,245": 5, + "243,246": 5, + "243,248": 5, + "243,253": -10, + "243,255": -10, + "243,8211": 20, + "243,8212": 20, + "243,8216": -55, + "243,8217": -40, + "243,8220": -55, + "243,8221": -40, + "243,8230": -25, + "244,44": -25, + "244,45": 20, + "244,46": -25, + "244,47": -25, + "244,63": -35, + "244,64": 10, + "244,99": 5, + "244,100": 5, + "244,101": 5, + "244,111": 5, + "244,113": 5, + "244,118": -10, + "244,119": -5, + "244,120": -15, + "244,121": -10, + "244,171": 20, + "244,183": 20, + "244,231": 5, + "244,232": 5, + "244,233": 5, + "244,234": 5, + "244,235": 5, + "244,240": 5, + "244,242": 5, + "244,243": 5, + "244,244": 5, + "244,245": 5, + "244,246": 5, + "244,248": 5, + "244,253": -10, + "244,255": -10, + "244,8211": 20, + "244,8212": 20, + "244,8216": -55, + "244,8217": -40, + "244,8220": -55, + "244,8221": -40, + "244,8230": -25, + "245,44": -25, + "245,45": 20, + "245,46": -25, + "245,47": -25, + "245,63": -35, + "245,64": 10, + "245,99": 5, + "245,100": 5, + "245,101": 5, + "245,111": 5, + "245,113": 5, + "245,118": -10, + "245,119": -5, + "245,120": -15, + "245,121": -10, + "245,171": 20, + "245,183": 20, + "245,231": 5, + "245,232": 5, + "245,233": 5, + "245,234": 5, + "245,235": 5, + "245,240": 5, + "245,242": 5, + "245,243": 5, + "245,244": 5, + "245,245": 5, + "245,246": 5, + "245,248": 5, + "245,253": -10, + "245,255": -10, + "245,8211": 20, + "245,8212": 20, + "245,8216": -55, + "245,8217": -40, + "245,8220": -55, + "245,8221": -40, + "245,8230": -25, + "246,44": -25, + "246,45": 20, + "246,46": -25, + "246,47": -25, + "246,63": -35, + "246,64": 10, + "246,99": 5, + "246,100": 5, + "246,101": 5, + "246,111": 5, + "246,113": 5, + "246,118": -10, + "246,119": -5, + "246,120": -15, + "246,121": -10, + "246,171": 20, + "246,183": 20, + "246,231": 5, + "246,232": 5, + "246,233": 5, + "246,234": 5, + "246,235": 5, + "246,240": 5, + "246,242": 5, + "246,243": 5, + "246,244": 5, + "246,245": 5, + "246,246": 5, + "246,248": 5, + "246,253": -10, + "246,255": -10, + "246,8211": 20, + "246,8212": 20, + "246,8216": -55, + "246,8217": -40, + "246,8220": -55, + "246,8221": -40, + "246,8230": -25, + "247,49": -20, + "247,50": -20, + "247,51": -5, + "247,55": -40, + "248,44": -25, + "248,45": 20, + "248,46": -25, + "248,47": -25, + "248,63": -35, + "248,64": 10, + "248,99": 5, + "248,100": 5, + "248,101": 5, + "248,106": 10, + "248,111": 5, + "248,113": 5, + "248,116": 20, + "248,119": -5, + "248,120": -15, + "248,121": 10, + "248,171": 20, + "248,183": 20, + "248,231": 5, + "248,232": 5, + "248,233": 5, + "248,234": 5, + "248,235": 5, + "248,240": 5, + "248,242": 5, + "248,243": 5, + "248,244": 5, + "248,245": 5, + "248,246": 5, + "248,248": 5, + "248,253": 10, + "248,255": 10, + "248,8211": 20, + "248,8212": 20, + "248,8216": -55, + "248,8217": -40, + "248,8220": -55, + "248,8221": -40, + "248,8230": -25, + "249,63": -20, + "249,118": -5, + "249,8216": -45, + "249,8217": -20, + "249,8220": -45, + "249,8221": -20, + "250,63": -20, + "250,118": -5, + "250,8216": -45, + "250,8217": -20, + "250,8220": -45, + "250,8221": -20, + "251,63": -20, + "251,118": -5, + "251,8216": -45, + "251,8217": -20, + "251,8220": -45, + "251,8221": -20, + "252,63": -20, + "252,118": -5, + "252,8216": -45, + "252,8217": -20, + "252,8220": -45, + "252,8221": -20, + "253,38": -20, + "253,44": -70, + "253,45": -20, + "253,46": -70, + "253,47": -55, + "253,63": 20, + "253,64": -35, + "253,97": -5, + "253,99": -15, + "253,100": -15, + "253,101": -15, + "253,103": -15, + "253,111": -15, + "253,113": -15, + "253,115": -5, + "253,171": -30, + "253,183": -20, + "253,224": -5, + "253,225": -5, + "253,226": -5, + "253,227": -5, + "253,228": -5, + "253,229": -5, + "253,230": -5, + "253,231": -15, + "253,232": -15, + "253,233": -15, + "253,234": -15, + "253,235": -15, + "253,240": -15, + "253,242": -15, + "253,243": -15, + "253,244": -15, + "253,245": -15, + "253,246": -15, + "253,248": -15, + "253,8211": -20, + "253,8212": -20, + "253,8230": -70, + "254,44": -20, + "254,45": 20, + "254,46": -20, + "254,47": -30, + "254,63": -15, + "254,64": 10, + "254,99": 5, + "254,101": 5, + "254,111": 5, + "254,121": -5, + "254,171": 20, + "254,183": 20, + "254,231": 5, + "254,232": 5, + "254,233": 5, + "254,234": 5, + "254,235": 5, + "254,240": 5, + "254,242": 5, + "254,243": 5, + "254,244": 5, + "254,245": 5, + "254,246": 5, + "254,248": 5, + "254,253": -5, + "254,255": -5, + "254,8211": 20, + "254,8212": 20, + "254,8216": -40, + "254,8217": -15, + "254,8220": -40, + "254,8221": -15, + "254,8230": -20, + "255,38": -20, + "255,44": -70, + "255,45": -20, + "255,46": -70, + "255,47": -55, + "255,63": 20, + "255,64": -35, + "255,97": -5, + "255,99": -15, + "255,100": -15, + "255,101": -15, + "255,103": -15, + "255,111": -15, + "255,113": -15, + "255,115": -5, + "255,171": -30, + "255,183": -20, + "255,224": -5, + "255,225": -5, + "255,226": -5, + "255,227": -5, + "255,228": -5, + "255,229": -5, + "255,230": -5, + "255,231": -15, + "255,232": -15, + "255,233": -15, + "255,234": -15, + "255,235": -15, + "255,240": -15, + "255,242": -15, + "255,243": -15, + "255,244": -15, + "255,245": -15, + "255,246": -15, + "255,248": -15, + "255,8211": -20, + "255,8212": -20, + "255,8230": -70, + "8211,48": 20, + "8211,49": -20, + "8211,50": -30, + "8211,52": 20, + "8211,54": 20, + "8211,55": -30, + "8211,65": -30, + "8211,67": 20, + "8211,71": 20, + "8211,79": 20, + "8211,81": 20, + "8211,84": -20, + "8211,86": -50, + "8211,87": -40, + "8211,88": -40, + "8211,89": -60, + "8211,99": 20, + "8211,100": 20, + "8211,101": 20, + "8211,111": 20, + "8211,113": 20, + "8211,118": -15, + "8211,119": -10, + "8211,120": -20, + "8211,121": -15, + "8211,192": -30, + "8211,193": -30, + "8211,194": -30, + "8211,195": -30, + "8211,196": -30, + "8211,197": -30, + "8211,198": -40, + "8211,199": 20, + "8211,210": 20, + "8211,211": 20, + "8211,212": 20, + "8211,213": 20, + "8211,214": 20, + "8211,216": 20, + "8211,221": -60, + "8211,231": 20, + "8211,232": 20, + "8211,233": 20, + "8211,234": 20, + "8211,235": 20, + "8211,240": 20, + "8211,242": 20, + "8211,243": 20, + "8211,244": 20, + "8211,245": 20, + "8211,246": 20, + "8211,248": 20, + "8211,253": -15, + "8211,255": -15, + "8212,48": 20, + "8212,49": -20, + "8212,50": -30, + "8212,52": 20, + "8212,54": 20, + "8212,55": -30, + "8212,65": -30, + "8212,67": 20, + "8212,71": 20, + "8212,79": 20, + "8212,81": 20, + "8212,84": -20, + "8212,86": -50, + "8212,87": -40, + "8212,88": -40, + "8212,89": -60, + "8212,99": 20, + "8212,100": 20, + "8212,101": 20, + "8212,111": 20, + "8212,113": 20, + "8212,118": -15, + "8212,119": -10, + "8212,120": -20, + "8212,121": -15, + "8212,192": -30, + "8212,193": -30, + "8212,194": -30, + "8212,195": -30, + "8212,196": -30, + "8212,197": -30, + "8212,198": -40, + "8212,199": 20, + "8212,210": 20, + "8212,211": 20, + "8212,212": 20, + "8212,213": 20, + "8212,214": 20, + "8212,216": 20, + "8212,221": -60, + "8212,231": 20, + "8212,232": 20, + "8212,233": 20, + "8212,234": 20, + "8212,235": 20, + "8212,240": 20, + "8212,242": 20, + "8212,243": 20, + "8212,244": 20, + "8212,245": 20, + "8212,246": 20, + "8212,248": 20, + "8212,253": -15, + "8212,255": -15, + "8216,44": -90, + "8216,46": -90, + "8216,48": -15, + "8216,52": -65, + "8216,53": -25, + "8216,54": -35, + "8216,55": 30, + "8216,56": -10, + "8216,65": -100, + "8216,67": -35, + "8216,71": -35, + "8216,77": -30, + "8216,79": -35, + "8216,81": -35, + "8216,83": -10, + "8216,88": -10, + "8216,97": -25, + "8216,98": 10, + "8216,99": -45, + "8216,100": -45, + "8216,101": -45, + "8216,103": -45, + "8216,104": 10, + "8216,107": 10, + "8216,108": 10, + "8216,111": -45, + "8216,113": -45, + "8216,115": -30, + "8216,117": -10, + "8216,120": -15, + "8216,122": -10, + "8216,192": -100, + "8216,193": -100, + "8216,194": -100, + "8216,195": -100, + "8216,196": -100, + "8216,197": -100, + "8216,198": -140, + "8216,199": -35, + "8216,210": -35, + "8216,211": -35, + "8216,212": -35, + "8216,213": -35, + "8216,214": -35, + "8216,216": -35, + "8216,224": -25, + "8216,225": -25, + "8216,226": -25, + "8216,227": -25, + "8216,228": -25, + "8216,229": -25, + "8216,230": -25, + "8216,231": -45, + "8216,232": -45, + "8216,233": -45, + "8216,234": -45, + "8216,235": -45, + "8216,240": -15, + "8216,242": -45, + "8216,243": -45, + "8216,244": -45, + "8216,245": -45, + "8216,246": -45, + "8216,248": -45, + "8216,249": -10, + "8216,250": -10, + "8216,251": -10, + "8216,252": -10, + "8216,8230": -90, + "8217,44": -90, + "8217,46": -90, + "8217,48": -55, + "8217,50": -10, + "8217,51": -10, + "8217,52": -95, + "8217,53": -45, + "8217,54": -60, + "8217,55": 20, + "8217,56": -30, + "8217,57": -15, + "8217,65": -100, + "8217,67": -45, + "8217,71": -45, + "8217,77": -30, + "8217,79": -45, + "8217,81": -45, + "8217,83": -5, + "8217,97": -50, + "8217,99": -75, + "8217,100": -75, + "8217,101": -75, + "8217,103": -45, + "8217,109": -35, + "8217,110": -35, + "8217,111": -75, + "8217,112": -35, + "8217,113": -75, + "8217,114": -35, + "8217,115": -70, + "8217,116": -15, + "8217,117": -20, + "8217,118": -10, + "8217,119": -10, + "8217,120": -20, + "8217,121": -10, + "8217,122": -20, + "8217,192": -100, + "8217,193": -100, + "8217,194": -100, + "8217,195": -100, + "8217,196": -100, + "8217,197": -100, + "8217,198": -120, + "8217,199": -45, + "8217,210": -45, + "8217,211": -45, + "8217,212": -45, + "8217,213": -45, + "8217,214": -45, + "8217,216": -45, + "8217,224": -50, + "8217,225": -50, + "8217,226": -50, + "8217,227": -50, + "8217,228": -50, + "8217,229": -50, + "8217,230": -50, + "8217,231": -75, + "8217,232": -75, + "8217,233": -75, + "8217,234": -75, + "8217,235": -75, + "8217,240": -10, + "8217,241": -35, + "8217,242": -75, + "8217,243": -75, + "8217,244": -75, + "8217,245": -75, + "8217,246": -75, + "8217,248": -75, + "8217,249": -20, + "8217,250": -20, + "8217,251": -20, + "8217,252": -20, + "8217,253": -10, + "8217,255": -10, + "8217,8230": -90, + "8220,44": -90, + "8220,46": -90, + "8220,48": -15, + "8220,52": -65, + "8220,53": -25, + "8220,54": -35, + "8220,55": 30, + "8220,56": -10, + "8220,65": -100, + "8220,67": -35, + "8220,71": -35, + "8220,77": -30, + "8220,79": -35, + "8220,81": -35, + "8220,83": -10, + "8220,88": -10, + "8220,97": -25, + "8220,98": 10, + "8220,99": -45, + "8220,100": -45, + "8220,101": -45, + "8220,103": -45, + "8220,104": 10, + "8220,107": 10, + "8220,108": 10, + "8220,111": -45, + "8220,113": -45, + "8220,115": -30, + "8220,117": -10, + "8220,120": -15, + "8220,122": -10, + "8220,192": -100, + "8220,193": -100, + "8220,194": -100, + "8220,195": -100, + "8220,196": -100, + "8220,197": -100, + "8220,198": -140, + "8220,199": -35, + "8220,210": -35, + "8220,211": -35, + "8220,212": -35, + "8220,213": -35, + "8220,214": -35, + "8220,216": -35, + "8220,224": -25, + "8220,225": -25, + "8220,226": -25, + "8220,227": -25, + "8220,228": -25, + "8220,229": -25, + "8220,230": -25, + "8220,231": -45, + "8220,232": -45, + "8220,233": -45, + "8220,234": -45, + "8220,235": -45, + "8220,240": -15, + "8220,242": -45, + "8220,243": -45, + "8220,244": -45, + "8220,245": -45, + "8220,246": -45, + "8220,248": -45, + "8220,249": -10, + "8220,250": -10, + "8220,251": -10, + "8220,252": -10, + "8220,8230": -90, + "8221,44": -90, + "8221,46": -90, + "8221,48": -55, + "8221,50": -10, + "8221,51": -10, + "8221,52": -95, + "8221,53": -45, + "8221,54": -60, + "8221,55": 20, + "8221,56": -30, + "8221,57": -15, + "8221,65": -100, + "8221,67": -45, + "8221,71": -45, + "8221,77": -30, + "8221,79": -45, + "8221,81": -45, + "8221,83": -5, + "8221,97": -50, + "8221,99": -75, + "8221,100": -75, + "8221,101": -75, + "8221,103": -45, + "8221,109": -35, + "8221,110": -35, + "8221,111": -75, + "8221,112": -35, + "8221,113": -75, + "8221,114": -35, + "8221,115": -70, + "8221,116": -15, + "8221,117": -20, + "8221,118": -10, + "8221,119": -10, + "8221,120": -20, + "8221,121": -10, + "8221,122": -20, + "8221,192": -100, + "8221,193": -100, + "8221,194": -100, + "8221,195": -100, + "8221,196": -100, + "8221,197": -100, + "8221,198": -120, + "8221,199": -45, + "8221,210": -45, + "8221,211": -45, + "8221,212": -45, + "8221,213": -45, + "8221,214": -45, + "8221,216": -45, + "8221,224": -50, + "8221,225": -50, + "8221,226": -50, + "8221,227": -50, + "8221,228": -50, + "8221,229": -50, + "8221,230": -50, + "8221,231": -75, + "8221,232": -75, + "8221,233": -75, + "8221,234": -75, + "8221,235": -75, + "8221,240": -10, + "8221,241": -35, + "8221,242": -75, + "8221,243": -75, + "8221,244": -75, + "8221,245": -75, + "8221,246": -75, + "8221,248": -75, + "8221,249": -20, + "8221,250": -20, + "8221,251": -20, + "8221,252": -20, + "8221,253": -10, + "8221,255": -10, + "8221,8230": -90, + "8230,48": -50, + "8230,51": -20, + "8230,52": -55, + "8230,53": -15, + "8230,54": -35, + "8230,55": -50, + "8230,56": -15, + "8230,57": -10, + "8230,65": 10, + "8230,67": -30, + "8230,71": -30, + "8230,79": -30, + "8230,81": -30, + "8230,83": 10, + "8230,84": -70, + "8230,85": -40, + "8230,86": -90, + "8230,87": -80, + "8230,88": 20, + "8230,89": -100, + "8230,98": -25, + "8230,99": -25, + "8230,100": -20, + "8230,101": -25, + "8230,111": -25, + "8230,113": -20, + "8230,116": -25, + "8230,117": -35, + "8230,118": -70, + "8230,119": -50, + "8230,121": -70, + "8230,192": 10, + "8230,193": 10, + "8230,194": 10, + "8230,195": 10, + "8230,196": 10, + "8230,197": 10, + "8230,199": -30, + "8230,210": -30, + "8230,211": -30, + "8230,212": -30, + "8230,213": -30, + "8230,214": -30, + "8230,216": -30, + "8230,217": -40, + "8230,218": -40, + "8230,219": -40, + "8230,220": -40, + "8230,221": -100, + "8230,231": -25, + "8230,232": -25, + "8230,233": -25, + "8230,234": -25, + "8230,235": -25, + "8230,240": -25, + "8230,242": -25, + "8230,243": -25, + "8230,244": -25, + "8230,245": -25, + "8230,246": -25, + "8230,248": -25, + "8230,249": -35, + "8230,250": -35, + "8230,251": -35, + "8230,252": -35, + "8230,253": -70, + "8230,254": -20, + "8230,255": -70, + "8230,8216": -90, + "8230,8217": -90, + "8230,8220": -90, + "8230,8221": -90, + "8722,49": -20, + "8722,50": -20, + "8722,51": -5, + "8722,55": -40, + "8364,49": 15, + "8364,52": -35, + "8364,55": 15, + "8364,57": 5 + }, + "defaultAdvance": 542 +} diff --git a/packages/charts2/src/fonts/metrics/founders-grotesk-mono-regular.json b/packages/charts2/src/fonts/metrics/founders-grotesk-mono-regular.json new file mode 100644 index 00000000000..e5c654e60f3 --- /dev/null +++ b/packages/charts2/src/fonts/metrics/founders-grotesk-mono-regular.json @@ -0,0 +1,207 @@ +{ + "familyName": "Founders Grotesk Mono", + "unitsPerEm": 1000, + "ascent": 885, + "descent": -250, + "capHeight": 630, + "advances": { + "32": 614, + "33": 614, + "34": 614, + "35": 614, + "36": 614, + "37": 614, + "38": 614, + "39": 614, + "40": 614, + "41": 614, + "42": 614, + "43": 614, + "44": 614, + "45": 614, + "46": 614, + "47": 614, + "48": 614, + "49": 614, + "50": 614, + "51": 614, + "52": 614, + "53": 614, + "54": 614, + "55": 614, + "56": 614, + "57": 614, + "58": 614, + "59": 614, + "60": 614, + "61": 614, + "62": 614, + "63": 614, + "64": 614, + "65": 614, + "66": 614, + "67": 614, + "68": 614, + "69": 614, + "70": 614, + "71": 614, + "72": 614, + "73": 614, + "74": 614, + "75": 614, + "76": 614, + "77": 614, + "78": 614, + "79": 614, + "80": 614, + "81": 614, + "82": 614, + "83": 614, + "84": 614, + "85": 614, + "86": 614, + "87": 614, + "88": 614, + "89": 614, + "90": 614, + "91": 614, + "92": 614, + "93": 614, + "94": 614, + "95": 614, + "96": 614, + "97": 614, + "98": 614, + "99": 614, + "100": 614, + "101": 614, + "102": 614, + "103": 614, + "104": 614, + "105": 614, + "106": 614, + "107": 614, + "108": 614, + "109": 614, + "110": 614, + "111": 614, + "112": 614, + "113": 614, + "114": 614, + "115": 614, + "116": 614, + "117": 614, + "118": 614, + "119": 614, + "120": 614, + "121": 614, + "122": 614, + "123": 614, + "124": 614, + "125": 614, + "126": 614, + "160": 614, + "161": 614, + "162": 614, + "163": 614, + "165": 614, + "167": 614, + "168": 614, + "169": 614, + "170": 614, + "171": 614, + "174": 614, + "175": 614, + "176": 614, + "177": 614, + "178": 614, + "179": 614, + "180": 614, + "182": 614, + "183": 614, + "184": 614, + "185": 614, + "186": 614, + "187": 614, + "188": 614, + "189": 614, + "190": 614, + "191": 614, + "192": 614, + "193": 614, + "194": 614, + "195": 614, + "196": 614, + "197": 614, + "198": 614, + "199": 614, + "200": 614, + "201": 614, + "202": 614, + "203": 614, + "204": 614, + "205": 614, + "206": 614, + "207": 614, + "208": 614, + "209": 614, + "210": 614, + "211": 614, + "212": 614, + "213": 614, + "214": 614, + "215": 614, + "216": 614, + "217": 614, + "218": 614, + "219": 614, + "220": 614, + "221": 614, + "222": 614, + "223": 614, + "224": 614, + "225": 614, + "226": 614, + "227": 614, + "228": 614, + "229": 614, + "230": 614, + "231": 614, + "232": 614, + "233": 614, + "234": 614, + "235": 614, + "236": 614, + "237": 614, + "238": 614, + "239": 614, + "240": 614, + "241": 614, + "242": 614, + "243": 614, + "244": 614, + "245": 614, + "246": 614, + "247": 614, + "248": 614, + "249": 614, + "250": 614, + "251": 614, + "252": 614, + "253": 614, + "254": 614, + "255": 614, + "8211": 614, + "8212": 614, + "8216": 614, + "8217": 614, + "8220": 614, + "8221": 614, + "8230": 614, + "8240": 614, + "8364": 614, + "8722": 614 + }, + "kerning": {}, + "defaultAdvance": 614 +} diff --git a/packages/charts2/src/fonts/metrics/soehne-kraftig.json b/packages/charts2/src/fonts/metrics/soehne-kraftig.json new file mode 100644 index 00000000000..993bf271813 --- /dev/null +++ b/packages/charts2/src/fonts/metrics/soehne-kraftig.json @@ -0,0 +1,5551 @@ +{ + "familyName": "Söhne Kräftig", + "unitsPerEm": 1000, + "ascent": 1171, + "descent": -423, + "capHeight": 718, + "advances": { + "32": 206, + "33": 224, + "34": 355, + "35": 678, + "36": 591, + "37": 769, + "38": 706, + "39": 180, + "40": 319, + "41": 319, + "42": 444, + "43": 608, + "44": 225, + "45": 360, + "46": 225, + "47": 432, + "48": 632, + "49": 395, + "50": 568, + "51": 575, + "52": 610, + "53": 574, + "54": 595, + "55": 545, + "56": 602, + "57": 595, + "58": 225, + "59": 225, + "60": 608, + "61": 608, + "62": 608, + "63": 516, + "64": 889, + "65": 709, + "66": 639, + "67": 667, + "68": 701, + "69": 594, + "70": 577, + "71": 727, + "72": 736, + "73": 264, + "74": 403, + "75": 657, + "76": 549, + "77": 854, + "78": 727, + "79": 730, + "80": 632, + "81": 730, + "82": 653, + "83": 590, + "84": 620, + "85": 693, + "86": 687, + "87": 932, + "88": 673, + "89": 647, + "90": 629, + "91": 301, + "92": 432, + "93": 301, + "94": 470, + "95": 434, + "96": 0, + "97": 531, + "98": 595, + "99": 519, + "100": 595, + "101": 540, + "102": 310, + "103": 594, + "104": 563, + "105": 246, + "106": 246, + "107": 546, + "108": 246, + "109": 866, + "110": 563, + "111": 566, + "112": 595, + "113": 595, + "114": 381, + "115": 494, + "116": 338, + "117": 563, + "118": 509, + "119": 720, + "120": 512, + "121": 509, + "122": 499, + "123": 361, + "124": 258, + "125": 361, + "126": 493, + "160": 206, + "161": 224, + "162": 556, + "163": 610, + "165": 623, + "167": 517, + "168": 0, + "169": 815, + "170": 398, + "171": 411, + "174": 518, + "175": 0, + "176": 321, + "177": 608, + "178": 380, + "179": 380, + "180": 0, + "182": 595, + "183": 225, + "184": 0, + "185": 380, + "186": 421, + "187": 411, + "188": 880, + "189": 907, + "190": 870, + "191": 516, + "192": 709, + "193": 709, + "194": 709, + "195": 709, + "196": 709, + "197": 709, + "198": 1024, + "199": 667, + "200": 594, + "201": 594, + "202": 594, + "203": 594, + "204": 264, + "205": 264, + "206": 264, + "207": 264, + "208": 711, + "209": 727, + "210": 730, + "211": 730, + "212": 730, + "213": 730, + "214": 730, + "215": 608, + "216": 730, + "217": 693, + "218": 693, + "219": 693, + "220": 693, + "221": 647, + "222": 632, + "223": 578, + "224": 531, + "225": 531, + "226": 531, + "227": 531, + "228": 531, + "229": 531, + "230": 850, + "231": 519, + "232": 540, + "233": 540, + "234": 540, + "235": 540, + "236": 246, + "237": 246, + "238": 246, + "239": 246, + "240": 566, + "241": 563, + "242": 566, + "243": 566, + "244": 566, + "245": 566, + "246": 566, + "247": 608, + "248": 566, + "249": 563, + "250": 563, + "251": 563, + "252": 563, + "253": 509, + "254": 595, + "255": 509, + "8211": 542, + "8212": 976, + "8216": 185, + "8217": 185, + "8220": 384, + "8221": 384, + "8230": 645, + "8240": 1113, + "8364": 642, + "8722": 608 + }, + "kerning": { + "34,48": -30, + "34,50": -20, + "34,51": -30, + "34,52": -80, + "34,53": -35, + "34,54": -30, + "34,55": 25, + "34,56": -40, + "34,57": -15, + "36,52": 10, + "38,48": -15, + "38,49": -60, + "38,51": -15, + "38,53": -10, + "38,54": -10, + "38,55": -55, + "38,56": -10, + "38,57": -15, + "38,65": -20, + "38,67": -10, + "38,71": -10, + "38,79": -10, + "38,81": -10, + "38,84": -105, + "38,85": -20, + "38,86": -100, + "38,87": -45, + "38,88": -30, + "38,89": -110, + "38,118": -40, + "38,119": -25, + "38,121": -40, + "38,192": -20, + "38,193": -20, + "38,194": -20, + "38,195": -20, + "38,196": -20, + "38,197": -20, + "38,199": -10, + "38,210": -10, + "38,211": -10, + "38,212": -10, + "38,213": -10, + "38,214": -10, + "38,216": -10, + "38,217": -20, + "38,218": -20, + "38,219": -20, + "38,220": -20, + "38,221": -110, + "38,253": -40, + "38,255": -40, + "39,48": -30, + "39,50": -20, + "39,51": -30, + "39,52": -80, + "39,53": -35, + "39,54": -30, + "39,55": 25, + "39,56": -40, + "39,57": -15, + "40,52": -20, + "40,55": 20, + "40,106": 60, + "40,236": 30, + "43,49": -35, + "43,50": -35, + "43,51": -45, + "43,55": -50, + "43,56": -20, + "43,57": -15, + "44,48": -45, + "44,49": -80, + "44,50": 20, + "44,51": -15, + "44,52": -10, + "44,53": -15, + "44,54": -55, + "44,55": -60, + "44,56": -20, + "44,57": -20, + "44,65": 20, + "44,67": -50, + "44,71": -50, + "44,74": 30, + "44,79": -50, + "44,81": -50, + "44,83": 20, + "44,84": -100, + "44,85": -40, + "44,86": -100, + "44,87": -60, + "44,89": -110, + "44,90": 20, + "44,99": -25, + "44,100": -25, + "44,101": -25, + "44,102": -15, + "44,103": -20, + "44,106": 35, + "44,111": -25, + "44,113": -25, + "44,116": -30, + "44,117": -30, + "44,118": -80, + "44,119": -55, + "44,121": -50, + "44,192": 20, + "44,193": 20, + "44,194": 20, + "44,195": 20, + "44,196": 20, + "44,197": 20, + "44,199": -50, + "44,210": -50, + "44,211": -50, + "44,212": -50, + "44,213": -50, + "44,214": -50, + "44,216": -50, + "44,217": -40, + "44,218": -40, + "44,219": -40, + "44,220": -40, + "44,221": -110, + "44,231": -25, + "44,232": -25, + "44,233": -25, + "44,234": -25, + "44,235": -25, + "44,240": -25, + "44,242": -25, + "44,243": -25, + "44,244": -25, + "44,245": -25, + "44,246": -25, + "44,248": -25, + "44,249": -30, + "44,250": -30, + "44,251": -30, + "44,252": -30, + "44,253": -50, + "44,255": -50, + "44,8216": -60, + "44,8217": -60, + "44,8220": -60, + "44,8221": -60, + "45,48": 10, + "45,49": -50, + "45,50": -25, + "45,52": 20, + "45,54": 10, + "45,55": -80, + "45,57": -10, + "45,65": -40, + "45,67": 10, + "45,71": 10, + "45,74": -30, + "45,79": 10, + "45,81": 10, + "45,84": -100, + "45,86": -80, + "45,87": -50, + "45,88": -90, + "45,89": -120, + "45,90": -30, + "45,99": 20, + "45,100": 20, + "45,101": 20, + "45,103": 20, + "45,111": 20, + "45,113": 20, + "45,118": -15, + "45,119": -5, + "45,120": -50, + "45,121": -20, + "45,122": -30, + "45,192": -40, + "45,193": -40, + "45,194": -40, + "45,195": -40, + "45,196": -40, + "45,197": -40, + "45,198": -75, + "45,199": 10, + "45,208": 20, + "45,210": 10, + "45,211": 10, + "45,212": 10, + "45,213": 10, + "45,214": 10, + "45,216": 10, + "45,221": -120, + "45,231": 20, + "45,232": 20, + "45,233": 20, + "45,234": 20, + "45,235": 20, + "45,240": 20, + "45,242": 20, + "45,243": 20, + "45,244": 20, + "45,245": 20, + "45,246": 20, + "45,248": 20, + "45,253": -20, + "45,255": -20, + "46,48": -45, + "46,49": -80, + "46,50": 20, + "46,51": -15, + "46,52": -10, + "46,53": -15, + "46,54": -55, + "46,55": -60, + "46,56": -20, + "46,57": -20, + "46,65": 20, + "46,67": -50, + "46,71": -50, + "46,74": 30, + "46,79": -50, + "46,81": -50, + "46,83": 20, + "46,84": -100, + "46,85": -40, + "46,86": -100, + "46,87": -60, + "46,89": -110, + "46,90": 20, + "46,99": -25, + "46,100": -25, + "46,101": -25, + "46,102": -15, + "46,103": -20, + "46,111": -25, + "46,113": -25, + "46,116": -30, + "46,117": -30, + "46,118": -80, + "46,119": -55, + "46,121": -75, + "46,192": 20, + "46,193": 20, + "46,194": 20, + "46,195": 20, + "46,196": 20, + "46,197": 20, + "46,199": -50, + "46,210": -50, + "46,211": -50, + "46,212": -50, + "46,213": -50, + "46,214": -50, + "46,216": -50, + "46,217": -40, + "46,218": -40, + "46,219": -40, + "46,220": -40, + "46,221": -110, + "46,231": -25, + "46,232": -25, + "46,233": -25, + "46,234": -25, + "46,235": -25, + "46,240": -25, + "46,242": -25, + "46,243": -25, + "46,244": -25, + "46,245": -25, + "46,246": -25, + "46,248": -25, + "46,249": -30, + "46,250": -30, + "46,251": -30, + "46,252": -30, + "46,253": -75, + "46,255": -75, + "46,8216": -60, + "46,8217": -60, + "46,8220": -60, + "46,8221": -60, + "47,47": -180, + "47,48": -40, + "47,49": -10, + "47,50": -30, + "47,51": -35, + "47,52": -80, + "47,53": -30, + "47,54": -50, + "47,55": 30, + "47,56": -45, + "47,57": -30, + "47,65": -85, + "47,67": -30, + "47,71": -30, + "47,74": -90, + "47,79": -30, + "47,81": -30, + "47,83": -5, + "47,97": -80, + "47,99": -80, + "47,100": -75, + "47,101": -80, + "47,103": -75, + "47,109": -50, + "47,110": -50, + "47,111": -80, + "47,112": -50, + "47,113": -75, + "47,114": -50, + "47,115": -65, + "47,117": -50, + "47,118": -20, + "47,119": -20, + "47,120": -40, + "47,121": -20, + "47,122": -40, + "47,192": -85, + "47,193": -85, + "47,194": -85, + "47,195": -85, + "47,196": -85, + "47,197": -85, + "47,198": -135, + "47,199": -30, + "47,210": -30, + "47,211": -30, + "47,212": -30, + "47,213": -30, + "47,214": -30, + "47,216": -30, + "47,223": -25, + "47,224": -80, + "47,225": -80, + "47,226": -80, + "47,227": -80, + "47,228": -80, + "47,229": -80, + "47,230": -80, + "47,231": -80, + "47,232": -80, + "47,233": -80, + "47,234": -80, + "47,235": -80, + "47,240": -45, + "47,241": -50, + "47,242": -80, + "47,243": -80, + "47,244": -80, + "47,245": -80, + "47,246": -80, + "47,248": -80, + "47,249": -50, + "47,250": -50, + "47,251": -50, + "47,252": -50, + "47,253": -20, + "47,255": -20, + "48,34": -30, + "48,39": -30, + "48,44": -45, + "48,45": 10, + "48,46": -45, + "48,47": -65, + "48,49": -5, + "48,50": -10, + "48,51": -5, + "48,53": -5, + "48,56": -10, + "48,165": -30, + "48,183": 10, + "48,8211": 10, + "48,8212": 10, + "48,8230": -45, + "49,34": -20, + "49,37": -10, + "49,38": -10, + "49,39": -20, + "49,43": -15, + "49,44": -15, + "49,45": -15, + "49,46": -15, + "49,47": -5, + "49,49": -10, + "49,50": -10, + "49,51": -10, + "49,53": -10, + "49,63": -15, + "49,171": -10, + "49,176": -20, + "49,183": -15, + "49,187": -15, + "49,215": -30, + "49,247": -15, + "49,8211": -15, + "49,8212": -15, + "49,8216": -25, + "49,8217": -25, + "49,8220": -25, + "49,8221": -25, + "49,8230": -15, + "49,8722": -15, + "50,34": -20, + "50,39": -20, + "50,43": -30, + "50,44": 20, + "50,45": -25, + "50,46": 20, + "50,48": -10, + "50,51": -10, + "50,52": -25, + "50,53": -10, + "50,54": -10, + "50,55": 5, + "50,56": -10, + "50,162": -10, + "50,165": -35, + "50,171": -35, + "50,183": -25, + "50,215": -10, + "50,247": -30, + "50,8211": -25, + "50,8212": -25, + "50,8217": 5, + "50,8221": 5, + "50,8230": 20, + "50,8722": -30, + "50,8364": -10, + "51,34": -30, + "51,37": -10, + "51,39": -30, + "51,43": -15, + "51,44": -25, + "51,46": -25, + "51,47": -40, + "51,48": -10, + "51,49": -10, + "51,50": -10, + "51,52": 5, + "51,54": -5, + "51,57": -10, + "51,63": -10, + "51,165": -25, + "51,176": -10, + "51,247": -15, + "51,8230": -25, + "51,8722": -15, + "52,34": -70, + "52,37": -35, + "52,39": -70, + "52,44": -10, + "52,45": 20, + "52,46": -10, + "52,47": -35, + "52,49": -50, + "52,50": -15, + "52,52": 10, + "52,55": -30, + "52,57": -10, + "52,63": -70, + "52,64": 10, + "52,163": 10, + "52,165": -25, + "52,171": 20, + "52,176": -60, + "52,183": 20, + "52,187": 20, + "52,8211": 20, + "52,8212": 20, + "52,8216": -60, + "52,8217": -45, + "52,8220": -60, + "52,8221": -45, + "52,8230": -10, + "53,34": -25, + "53,37": -20, + "53,39": -25, + "53,44": -30, + "53,46": -30, + "53,47": -45, + "53,48": -5, + "53,49": -30, + "53,50": -20, + "53,53": -5, + "53,54": -5, + "53,55": -10, + "53,57": -15, + "53,63": -25, + "53,165": -20, + "53,176": -20, + "53,8216": -30, + "53,8217": -15, + "53,8220": -30, + "53,8221": -15, + "53,8230": -30, + "54,34": -15, + "54,39": -15, + "54,44": -25, + "54,45": 5, + "54,46": -25, + "54,47": -45, + "54,49": -5, + "54,50": -5, + "54,51": -5, + "54,54": -5, + "54,165": -20, + "54,183": 5, + "54,8211": 5, + "54,8212": 5, + "54,8216": -5, + "54,8220": -5, + "54,8230": -25, + "55,33": 20, + "55,34": 25, + "55,37": 20, + "55,38": -35, + "55,39": 25, + "55,41": 20, + "55,43": -70, + "55,44": -105, + "55,45": -85, + "55,46": -105, + "55,47": -120, + "55,48": -15, + "55,50": -10, + "55,51": -15, + "55,52": -55, + "55,53": -20, + "55,54": -30, + "55,55": 20, + "55,56": -25, + "55,57": -10, + "55,58": -45, + "55,59": -45, + "55,60": -80, + "55,61": -25, + "55,64": -70, + "55,93": 20, + "55,125": 20, + "55,162": -50, + "55,165": 20, + "55,171": -105, + "55,176": 40, + "55,183": -85, + "55,187": -65, + "55,215": -35, + "55,247": -70, + "55,8211": -85, + "55,8212": -85, + "55,8216": 20, + "55,8217": 30, + "55,8220": 20, + "55,8221": 30, + "55,8230": -105, + "55,8722": -70, + "55,8364": -35, + "56,34": -40, + "56,37": -20, + "56,39": -40, + "56,43": -20, + "56,44": -20, + "56,46": -20, + "56,47": -40, + "56,48": -10, + "56,49": -25, + "56,50": -15, + "56,51": -5, + "56,53": -10, + "56,54": -10, + "56,55": -15, + "56,57": -10, + "56,63": -25, + "56,165": -35, + "56,176": -15, + "56,187": 10, + "56,247": -20, + "56,8216": -30, + "56,8217": -15, + "56,8220": -30, + "56,8221": -15, + "56,8230": -20, + "56,8722": -20, + "57,34": -25, + "57,38": -5, + "57,39": -25, + "57,44": -60, + "57,45": 10, + "57,46": -60, + "57,47": -75, + "57,49": -10, + "57,50": -15, + "57,51": -15, + "57,53": -10, + "57,56": -15, + "57,57": -5, + "57,63": -10, + "57,165": -35, + "57,176": -10, + "57,183": 10, + "57,8211": 10, + "57,8212": 10, + "57,8216": -5, + "57,8217": -5, + "57,8220": -5, + "57,8221": -5, + "57,8230": -60, + "58,84": -70, + "58,86": -50, + "58,87": -30, + "58,89": -80, + "58,221": -80, + "59,84": -70, + "59,86": -50, + "59,87": -30, + "59,89": -80, + "59,221": -80, + "60,55": 20, + "61,55": 10, + "62,55": -20, + "63,8216": 30, + "63,8217": 30, + "63,8220": 30, + "63,8221": 30, + "64,49": -25, + "64,52": 10, + "64,55": -20, + "64,57": -10, + "64,65": -30, + "64,67": 15, + "64,71": 15, + "64,74": -35, + "64,79": 15, + "64,81": 15, + "64,84": -75, + "64,86": -70, + "64,87": -40, + "64,88": -65, + "64,89": -100, + "64,90": -25, + "64,118": -10, + "64,119": -10, + "64,120": -35, + "64,121": -10, + "64,122": -20, + "64,192": -30, + "64,193": -30, + "64,194": -30, + "64,195": -30, + "64,196": -30, + "64,197": -30, + "64,198": -55, + "64,199": 15, + "64,210": 15, + "64,211": 15, + "64,212": 15, + "64,213": 15, + "64,214": 15, + "64,216": 15, + "64,221": -100, + "64,253": -10, + "64,255": -10, + "65,38": -20, + "65,44": 20, + "65,45": -40, + "65,46": 20, + "65,63": -90, + "65,64": -5, + "65,67": -40, + "65,71": -40, + "65,79": -40, + "65,81": -40, + "65,83": -5, + "65,84": -85, + "65,85": -35, + "65,86": -90, + "65,87": -55, + "65,88": -10, + "65,89": -85, + "65,98": -10, + "65,99": -25, + "65,100": -20, + "65,101": -25, + "65,102": -10, + "65,103": -15, + "65,104": -10, + "65,105": -10, + "65,106": -10, + "65,107": -10, + "65,108": -10, + "65,109": -10, + "65,110": -10, + "65,111": -25, + "65,112": -10, + "65,113": -20, + "65,114": -10, + "65,116": -15, + "65,117": -20, + "65,118": -45, + "65,119": -35, + "65,121": -35, + "65,171": -20, + "65,183": -40, + "65,199": -40, + "65,210": -40, + "65,211": -40, + "65,212": -40, + "65,213": -40, + "65,214": -40, + "65,216": -15, + "65,217": -35, + "65,218": -35, + "65,219": -35, + "65,220": -35, + "65,221": -85, + "65,223": -10, + "65,231": -25, + "65,232": -25, + "65,233": -25, + "65,234": -25, + "65,235": -25, + "65,236": -10, + "65,237": -10, + "65,238": -10, + "65,239": -10, + "65,240": -25, + "65,241": -10, + "65,242": -25, + "65,243": -25, + "65,244": -25, + "65,245": -25, + "65,246": -25, + "65,248": -25, + "65,249": -20, + "65,250": -20, + "65,251": -20, + "65,252": -20, + "65,253": -35, + "65,255": -35, + "65,8211": -40, + "65,8212": -40, + "65,8216": -95, + "65,8217": -75, + "65,8220": -95, + "65,8221": -75, + "65,8230": 20, + "66,38": 10, + "66,47": -15, + "66,64": 15, + "66,65": -10, + "66,74": -10, + "66,83": 10, + "66,84": -10, + "66,86": -20, + "66,87": -10, + "66,88": -20, + "66,89": -25, + "66,90": -5, + "66,99": 5, + "66,100": 5, + "66,101": 5, + "66,111": 5, + "66,113": 5, + "66,116": 10, + "66,171": 20, + "66,192": -10, + "66,193": -10, + "66,194": -10, + "66,195": -10, + "66,196": -10, + "66,197": -10, + "66,198": -30, + "66,221": -25, + "66,231": 5, + "66,232": 5, + "66,233": 5, + "66,234": 5, + "66,235": 5, + "66,238": 20, + "66,239": 20, + "66,240": 5, + "66,242": 5, + "66,243": 5, + "66,244": 5, + "66,245": 5, + "66,246": 5, + "66,248": 5, + "67,47": -25, + "67,63": 20, + "67,65": -25, + "67,74": -20, + "67,84": -10, + "67,86": -20, + "67,87": -10, + "67,88": -35, + "67,89": -30, + "67,90": -15, + "67,171": 20, + "67,192": -25, + "67,193": -25, + "67,194": -25, + "67,195": -25, + "67,196": -25, + "67,197": -25, + "67,198": -45, + "67,221": -30, + "67,238": 25, + "67,239": 25, + "67,8216": 10, + "67,8220": 10, + "68,44": -50, + "68,45": 10, + "68,46": -50, + "68,47": -55, + "68,64": 10, + "68,65": -40, + "68,67": 5, + "68,71": 5, + "68,74": -30, + "68,79": 5, + "68,81": 5, + "68,84": -50, + "68,86": -40, + "68,87": -25, + "68,88": -50, + "68,89": -60, + "68,90": -30, + "68,102": 5, + "68,116": 10, + "68,120": -15, + "68,122": -10, + "68,171": 15, + "68,183": 10, + "68,192": -40, + "68,193": -40, + "68,194": -40, + "68,195": -40, + "68,196": -40, + "68,197": -40, + "68,198": -65, + "68,199": 5, + "68,210": 5, + "68,211": 5, + "68,212": 5, + "68,213": 5, + "68,214": 5, + "68,216": 5, + "68,221": -60, + "68,8211": 10, + "68,8212": 10, + "68,8230": -50, + "69,44": 20, + "69,45": -30, + "69,46": 20, + "69,67": -15, + "69,71": -15, + "69,79": -15, + "69,81": -15, + "69,97": -5, + "69,98": -10, + "69,99": -15, + "69,100": -15, + "69,101": -15, + "69,102": -10, + "69,103": -15, + "69,104": -10, + "69,105": -10, + "69,106": -10, + "69,107": -10, + "69,108": -10, + "69,109": -10, + "69,110": -10, + "69,111": -15, + "69,112": -10, + "69,113": -15, + "69,114": -10, + "69,117": -15, + "69,118": -20, + "69,119": -20, + "69,121": -20, + "69,171": -15, + "69,183": -30, + "69,199": -15, + "69,210": -15, + "69,211": -15, + "69,212": -15, + "69,213": -15, + "69,214": -15, + "69,216": -15, + "69,223": -10, + "69,224": -5, + "69,225": -5, + "69,226": -5, + "69,227": -5, + "69,228": -5, + "69,229": -5, + "69,230": -5, + "69,231": -15, + "69,232": -15, + "69,233": -15, + "69,234": -15, + "69,235": -15, + "69,236": 10, + "69,237": -10, + "69,238": 15, + "69,239": 30, + "69,240": -15, + "69,241": -10, + "69,242": -15, + "69,243": -15, + "69,244": -15, + "69,245": -15, + "69,246": -15, + "69,248": -15, + "69,249": -15, + "69,250": -15, + "69,251": -15, + "69,252": -15, + "69,253": -20, + "69,255": -20, + "69,8211": -30, + "69,8212": -30, + "69,8217": 20, + "69,8221": 20, + "69,8230": 20, + "70,38": -55, + "70,44": -70, + "70,45": -15, + "70,46": -70, + "70,47": -100, + "70,58": -30, + "70,59": -30, + "70,64": -45, + "70,65": -90, + "70,67": -30, + "70,71": -30, + "70,74": -115, + "70,79": -30, + "70,81": -30, + "70,83": -15, + "70,97": -55, + "70,98": -20, + "70,99": -50, + "70,100": -50, + "70,101": -50, + "70,102": -30, + "70,103": -50, + "70,104": -20, + "70,105": -20, + "70,106": -20, + "70,107": -20, + "70,108": -20, + "70,109": -50, + "70,110": -50, + "70,111": -50, + "70,112": -50, + "70,113": -50, + "70,114": -50, + "70,115": -50, + "70,116": -20, + "70,117": -50, + "70,118": -40, + "70,119": -40, + "70,120": -60, + "70,121": -40, + "70,122": -45, + "70,171": -30, + "70,183": -15, + "70,192": -90, + "70,193": -90, + "70,194": -90, + "70,195": -90, + "70,196": -90, + "70,197": -90, + "70,198": -135, + "70,199": -30, + "70,210": -30, + "70,211": -30, + "70,212": -30, + "70,213": -30, + "70,214": -30, + "70,216": -30, + "70,223": -35, + "70,224": -55, + "70,225": -55, + "70,226": -55, + "70,227": -55, + "70,228": -55, + "70,229": -55, + "70,230": -55, + "70,231": -50, + "70,232": -50, + "70,233": -50, + "70,234": -50, + "70,235": -50, + "70,236": -20, + "70,237": -20, + "70,238": -20, + "70,239": -20, + "70,240": -50, + "70,241": -50, + "70,242": -50, + "70,243": -50, + "70,244": -50, + "70,245": -50, + "70,246": -50, + "70,248": -50, + "70,249": -50, + "70,250": -50, + "70,251": -50, + "70,252": -50, + "70,253": -40, + "70,254": -20, + "70,255": -40, + "70,8211": -15, + "70,8212": -15, + "70,8216": 10, + "70,8217": 20, + "70,8220": 10, + "70,8221": 20, + "70,8230": -70, + "71,84": -50, + "71,86": -40, + "71,87": -35, + "71,88": -10, + "71,89": -55, + "71,221": -55, + "71,8216": -25, + "71,8217": -20, + "71,8220": -25, + "71,8221": -20, + "72,236": 10, + "72,238": 10, + "72,239": 20, + "73,236": 10, + "73,238": 10, + "73,239": 20, + "74,44": -25, + "74,46": -25, + "74,47": -40, + "74,65": -35, + "74,88": -25, + "74,192": -35, + "74,193": -35, + "74,194": -35, + "74,195": -35, + "74,196": -35, + "74,197": -35, + "74,198": -40, + "74,236": 10, + "74,238": 20, + "74,239": 30, + "74,8230": -25, + "75,38": -20, + "75,44": 20, + "75,45": -70, + "75,46": 20, + "75,63": -25, + "75,67": -45, + "75,71": -45, + "75,79": -45, + "75,81": -45, + "75,83": -10, + "75,84": -10, + "75,85": -25, + "75,86": -20, + "75,87": -20, + "75,88": -20, + "75,89": -20, + "75,97": -5, + "75,99": -30, + "75,100": -25, + "75,101": -30, + "75,103": -30, + "75,111": -30, + "75,113": -25, + "75,116": -15, + "75,117": -25, + "75,118": -50, + "75,119": -40, + "75,121": -35, + "75,171": -40, + "75,183": -70, + "75,199": -45, + "75,210": -45, + "75,211": -45, + "75,212": -45, + "75,213": -45, + "75,214": -45, + "75,216": -15, + "75,217": -25, + "75,218": -25, + "75,219": -25, + "75,220": -25, + "75,221": -20, + "75,224": -5, + "75,225": -5, + "75,226": -5, + "75,227": -5, + "75,228": -5, + "75,229": -5, + "75,230": -5, + "75,231": -30, + "75,232": -30, + "75,233": -30, + "75,234": -30, + "75,235": -30, + "75,239": 25, + "75,240": -30, + "75,242": -30, + "75,243": -30, + "75,244": -30, + "75,245": -30, + "75,246": -30, + "75,248": -5, + "75,249": -25, + "75,250": -25, + "75,251": -25, + "75,252": -25, + "75,253": -35, + "75,255": -35, + "75,8211": -70, + "75,8212": -70, + "75,8216": -30, + "75,8220": -30, + "75,8230": 20, + "76,44": 40, + "76,45": -40, + "76,46": 40, + "76,63": -90, + "76,65": 10, + "76,67": -25, + "76,71": -25, + "76,74": 20, + "76,79": -25, + "76,81": -25, + "76,83": 10, + "76,84": -120, + "76,85": -30, + "76,86": -95, + "76,87": -60, + "76,89": -110, + "76,97": 10, + "76,99": -10, + "76,100": -10, + "76,101": -10, + "76,103": -5, + "76,111": -10, + "76,113": -10, + "76,115": 10, + "76,116": -15, + "76,117": -15, + "76,118": -50, + "76,119": -30, + "76,120": 10, + "76,121": -30, + "76,183": -40, + "76,192": 10, + "76,193": 10, + "76,194": 10, + "76,195": 10, + "76,196": 10, + "76,197": 10, + "76,199": -25, + "76,210": -25, + "76,211": -25, + "76,212": -25, + "76,213": -25, + "76,214": -25, + "76,216": -25, + "76,217": -30, + "76,218": -30, + "76,219": -30, + "76,220": -30, + "76,221": -110, + "76,224": 10, + "76,225": 10, + "76,226": 10, + "76,227": 10, + "76,228": 10, + "76,229": 10, + "76,230": 10, + "76,231": -10, + "76,232": -10, + "76,233": -10, + "76,234": -10, + "76,235": -10, + "76,240": -10, + "76,242": -10, + "76,243": -10, + "76,244": -10, + "76,245": -10, + "76,246": -10, + "76,248": -10, + "76,249": -15, + "76,250": -15, + "76,251": -15, + "76,252": -15, + "76,253": -30, + "76,255": -30, + "76,8211": -40, + "76,8212": -40, + "76,8216": -120, + "76,8217": -70, + "76,8220": -120, + "76,8221": -70, + "76,8230": 40, + "77,236": 10, + "77,238": 10, + "77,239": 20, + "78,236": 10, + "78,238": 10, + "78,239": 20, + "79,44": -50, + "79,45": 10, + "79,46": -50, + "79,47": -55, + "79,64": 10, + "79,65": -40, + "79,67": 5, + "79,71": 5, + "79,74": -30, + "79,79": 5, + "79,81": 5, + "79,84": -50, + "79,86": -40, + "79,87": -25, + "79,88": -50, + "79,89": -60, + "79,90": -30, + "79,102": 5, + "79,116": 10, + "79,120": -15, + "79,122": -10, + "79,171": 15, + "79,183": 10, + "79,192": -40, + "79,193": -40, + "79,194": -40, + "79,195": -40, + "79,196": -40, + "79,197": -40, + "79,198": -65, + "79,199": 5, + "79,210": 5, + "79,211": 5, + "79,212": 5, + "79,213": 5, + "79,214": 5, + "79,216": 5, + "79,221": -60, + "79,8211": 10, + "79,8212": 10, + "79,8230": -50, + "80,38": -30, + "80,44": -100, + "80,45": -25, + "80,46": -100, + "80,47": -100, + "80,63": 30, + "80,64": -15, + "80,65": -80, + "80,67": 5, + "80,71": 5, + "80,74": -85, + "80,79": 5, + "80,81": 5, + "80,83": 10, + "80,88": -30, + "80,89": -20, + "80,90": -20, + "80,97": -20, + "80,98": -10, + "80,99": -25, + "80,100": -25, + "80,101": -25, + "80,102": 20, + "80,103": -25, + "80,104": -10, + "80,105": -10, + "80,106": -10, + "80,107": -10, + "80,108": -10, + "80,109": -10, + "80,110": -10, + "80,111": -25, + "80,112": -10, + "80,113": -25, + "80,114": -10, + "80,115": -20, + "80,116": 20, + "80,117": -10, + "80,118": 20, + "80,119": 20, + "80,121": 20, + "80,171": -20, + "80,183": -25, + "80,192": -80, + "80,193": -80, + "80,194": -80, + "80,195": -80, + "80,196": -80, + "80,197": -80, + "80,198": -145, + "80,199": 5, + "80,210": 5, + "80,211": 5, + "80,212": 5, + "80,213": 5, + "80,214": 5, + "80,216": 5, + "80,221": -20, + "80,223": -10, + "80,224": -20, + "80,225": -20, + "80,226": -20, + "80,227": -20, + "80,228": -20, + "80,229": -20, + "80,230": -20, + "80,231": -25, + "80,232": -25, + "80,233": -25, + "80,234": -25, + "80,235": -25, + "80,237": -10, + "80,238": 30, + "80,239": -10, + "80,240": -25, + "80,241": -10, + "80,242": -25, + "80,243": -25, + "80,244": -25, + "80,245": -25, + "80,246": -25, + "80,248": -25, + "80,249": -10, + "80,250": -10, + "80,251": -10, + "80,252": -10, + "80,253": 20, + "80,255": 20, + "80,8211": -25, + "80,8212": -25, + "80,8216": 25, + "80,8217": 20, + "80,8220": 25, + "80,8221": 20, + "80,8230": -100, + "81,44": -30, + "81,45": 10, + "81,46": -30, + "81,65": -30, + "81,67": 5, + "81,71": 5, + "81,74": -30, + "81,79": 5, + "81,81": 5, + "81,84": -50, + "81,86": -35, + "81,87": -25, + "81,88": -30, + "81,89": -55, + "81,90": -15, + "81,102": 20, + "81,116": 20, + "81,171": 15, + "81,183": 10, + "81,192": -30, + "81,193": -30, + "81,194": -30, + "81,195": -30, + "81,196": -30, + "81,197": -30, + "81,198": -40, + "81,199": 5, + "81,210": 5, + "81,211": 5, + "81,212": 5, + "81,213": 5, + "81,214": 5, + "81,216": 5, + "81,221": -55, + "81,8211": 10, + "81,8212": 10, + "81,8230": -30, + "82,45": -30, + "82,63": 15, + "82,64": -15, + "82,65": -5, + "82,84": -10, + "82,86": -10, + "82,88": -10, + "82,89": -35, + "82,97": -5, + "82,98": -5, + "82,99": -10, + "82,100": -10, + "82,101": -10, + "82,102": 10, + "82,103": -10, + "82,104": -5, + "82,105": -5, + "82,106": -5, + "82,107": -5, + "82,108": -5, + "82,109": -5, + "82,110": -5, + "82,111": -10, + "82,112": -5, + "82,113": -10, + "82,114": -5, + "82,115": -10, + "82,116": 10, + "82,117": -10, + "82,171": -40, + "82,183": -30, + "82,192": -5, + "82,193": -5, + "82,194": -5, + "82,195": -5, + "82,196": -5, + "82,197": -5, + "82,221": -35, + "82,224": -5, + "82,225": -5, + "82,226": -5, + "82,227": -5, + "82,228": -5, + "82,229": -5, + "82,230": -5, + "82,231": -10, + "82,232": -10, + "82,233": -10, + "82,234": -10, + "82,235": -10, + "82,236": -5, + "82,237": -5, + "82,238": 20, + "82,239": 15, + "82,240": -10, + "82,241": -5, + "82,242": -10, + "82,243": -10, + "82,244": -10, + "82,245": -10, + "82,246": -10, + "82,248": -10, + "82,249": -10, + "82,250": -10, + "82,251": -10, + "82,252": -10, + "82,8211": -30, + "82,8212": -30, + "82,8216": 20, + "82,8220": 20, + "83,38": 15, + "83,45": 10, + "83,47": -15, + "83,63": -10, + "83,64": 15, + "83,65": -20, + "83,74": -10, + "83,83": 10, + "83,84": -15, + "83,86": -20, + "83,87": -15, + "83,88": -30, + "83,89": -30, + "83,90": -5, + "83,97": 5, + "83,99": 10, + "83,100": 10, + "83,101": 10, + "83,102": 10, + "83,103": 5, + "83,111": 10, + "83,113": 10, + "83,115": 10, + "83,116": 10, + "83,118": -10, + "83,119": -5, + "83,120": -10, + "83,121": -10, + "83,171": 15, + "83,183": 10, + "83,187": 15, + "83,192": -20, + "83,193": -20, + "83,194": -20, + "83,195": -20, + "83,196": -20, + "83,197": -20, + "83,198": -35, + "83,221": -30, + "83,224": 5, + "83,225": 5, + "83,226": 5, + "83,227": 5, + "83,228": 5, + "83,229": 5, + "83,230": 5, + "83,231": 10, + "83,232": 10, + "83,233": 10, + "83,234": 10, + "83,235": 10, + "83,236": 10, + "83,238": 25, + "83,239": 25, + "83,240": 10, + "83,242": 10, + "83,243": 10, + "83,244": 10, + "83,245": 10, + "83,246": 10, + "83,248": 10, + "83,253": -10, + "83,255": -10, + "83,8211": 10, + "83,8212": 10, + "83,8216": -5, + "83,8220": -5, + "84,38": -55, + "84,44": -100, + "84,45": -100, + "84,46": -100, + "84,47": -130, + "84,58": -70, + "84,59": -70, + "84,64": -95, + "84,65": -85, + "84,67": -50, + "84,71": -50, + "84,74": -100, + "84,79": -50, + "84,81": -50, + "84,83": -25, + "84,97": -105, + "84,98": -10, + "84,99": -120, + "84,100": -120, + "84,101": -120, + "84,102": -40, + "84,103": -115, + "84,104": -10, + "84,105": -10, + "84,106": -10, + "84,107": -10, + "84,108": -10, + "84,109": -95, + "84,110": -95, + "84,111": -120, + "84,112": -95, + "84,113": -120, + "84,114": -95, + "84,115": -115, + "84,116": -15, + "84,117": -105, + "84,118": -90, + "84,119": -85, + "84,120": -95, + "84,121": -90, + "84,122": -100, + "84,171": -120, + "84,183": -100, + "84,187": -100, + "84,192": -85, + "84,193": -85, + "84,194": -85, + "84,195": -85, + "84,196": -85, + "84,197": -85, + "84,198": -130, + "84,199": -50, + "84,210": -50, + "84,211": -50, + "84,212": -50, + "84,213": -50, + "84,214": -50, + "84,216": -50, + "84,223": -35, + "84,224": -90, + "84,225": -105, + "84,226": -95, + "84,227": -80, + "84,228": -75, + "84,229": -105, + "84,230": -105, + "84,231": -120, + "84,232": -110, + "84,233": -120, + "84,234": -100, + "84,235": -90, + "84,236": 35, + "84,237": -45, + "84,238": 30, + "84,239": 55, + "84,240": -70, + "84,241": -95, + "84,242": -120, + "84,243": -120, + "84,244": -100, + "84,245": -95, + "84,246": -95, + "84,248": -120, + "84,249": -105, + "84,250": -105, + "84,251": -95, + "84,252": -80, + "84,253": -90, + "84,255": -65, + "84,8211": -100, + "84,8212": -100, + "84,8217": 20, + "84,8221": 20, + "84,8230": -100, + "85,44": -40, + "85,46": -40, + "85,47": -60, + "85,65": -35, + "85,74": -35, + "85,88": -20, + "85,90": -10, + "85,120": -10, + "85,122": -10, + "85,192": -35, + "85,193": -35, + "85,194": -35, + "85,195": -35, + "85,196": -35, + "85,197": -35, + "85,198": -65, + "85,8230": -40, + "86,38": -55, + "86,44": -100, + "86,45": -80, + "86,46": -100, + "86,47": -110, + "86,58": -50, + "86,59": -50, + "86,63": -10, + "86,64": -80, + "86,65": -90, + "86,67": -40, + "86,71": -40, + "86,74": -100, + "86,79": -40, + "86,81": -40, + "86,83": -25, + "86,88": -10, + "86,97": -75, + "86,98": -10, + "86,99": -75, + "86,100": -75, + "86,101": -75, + "86,102": -20, + "86,103": -75, + "86,104": -10, + "86,105": -10, + "86,106": -10, + "86,107": -10, + "86,108": -10, + "86,109": -55, + "86,110": -55, + "86,111": -75, + "86,112": -55, + "86,113": -75, + "86,114": -55, + "86,115": -75, + "86,117": -55, + "86,118": -30, + "86,119": -30, + "86,120": -50, + "86,121": -30, + "86,122": -55, + "86,171": -90, + "86,183": -80, + "86,187": -70, + "86,192": -90, + "86,193": -90, + "86,194": -90, + "86,195": -90, + "86,196": -90, + "86,197": -90, + "86,198": -120, + "86,199": -40, + "86,210": -40, + "86,211": -40, + "86,212": -40, + "86,213": -40, + "86,214": -40, + "86,216": -40, + "86,223": -50, + "86,224": -75, + "86,225": -75, + "86,226": -75, + "86,227": -65, + "86,228": -60, + "86,229": -75, + "86,230": -75, + "86,231": -75, + "86,232": -75, + "86,233": -75, + "86,234": -75, + "86,235": -75, + "86,236": 20, + "86,237": -30, + "86,238": 25, + "86,239": 45, + "86,240": -60, + "86,241": -55, + "86,242": -75, + "86,243": -75, + "86,244": -75, + "86,245": -75, + "86,246": -75, + "86,248": -75, + "86,249": -55, + "86,250": -55, + "86,251": -55, + "86,252": -55, + "86,253": -30, + "86,254": -10, + "86,255": -30, + "86,8211": -80, + "86,8212": -80, + "86,8217": 20, + "86,8221": 20, + "86,8230": -100, + "87,38": -50, + "87,44": -60, + "87,45": -50, + "87,46": -60, + "87,47": -90, + "87,58": -30, + "87,59": -30, + "87,64": -55, + "87,65": -55, + "87,67": -25, + "87,71": -25, + "87,74": -70, + "87,79": -25, + "87,81": -25, + "87,83": -20, + "87,97": -50, + "87,98": -5, + "87,99": -50, + "87,100": -50, + "87,101": -50, + "87,102": -10, + "87,103": -50, + "87,104": -5, + "87,105": -5, + "87,106": -5, + "87,107": -5, + "87,108": -5, + "87,109": -40, + "87,110": -40, + "87,111": -50, + "87,112": -40, + "87,113": -50, + "87,114": -40, + "87,115": -50, + "87,117": -40, + "87,118": -20, + "87,119": -20, + "87,120": -30, + "87,121": -20, + "87,122": -30, + "87,171": -40, + "87,183": -50, + "87,187": -30, + "87,192": -55, + "87,193": -55, + "87,194": -55, + "87,195": -55, + "87,196": -55, + "87,197": -55, + "87,198": -110, + "87,199": -25, + "87,210": -25, + "87,211": -25, + "87,212": -25, + "87,213": -25, + "87,214": -25, + "87,216": -25, + "87,223": -30, + "87,224": -50, + "87,225": -50, + "87,226": -50, + "87,227": -50, + "87,228": -50, + "87,229": -50, + "87,230": -50, + "87,231": -50, + "87,232": -50, + "87,233": -50, + "87,234": -50, + "87,235": -50, + "87,236": 35, + "87,237": -5, + "87,238": 25, + "87,239": 50, + "87,240": -35, + "87,241": -40, + "87,242": -50, + "87,243": -50, + "87,244": -50, + "87,245": -50, + "87,246": -50, + "87,248": -50, + "87,249": -40, + "87,250": -40, + "87,251": -40, + "87,252": -40, + "87,253": -20, + "87,255": -20, + "87,8211": -50, + "87,8212": -50, + "87,8217": 20, + "87,8221": 20, + "87,8230": -60, + "88,38": -35, + "88,45": -90, + "88,63": -50, + "88,64": -35, + "88,65": -10, + "88,67": -50, + "88,71": -50, + "88,79": -50, + "88,81": -50, + "88,83": -30, + "88,85": -20, + "88,86": -10, + "88,89": -10, + "88,97": -15, + "88,98": -10, + "88,99": -40, + "88,100": -35, + "88,101": -40, + "88,102": -20, + "88,103": -45, + "88,104": -10, + "88,105": -10, + "88,106": -10, + "88,107": -10, + "88,108": -10, + "88,109": -10, + "88,110": -10, + "88,111": -40, + "88,112": -10, + "88,113": -35, + "88,114": -10, + "88,115": -10, + "88,116": -15, + "88,117": -25, + "88,118": -50, + "88,119": -30, + "88,121": -35, + "88,171": -60, + "88,183": -90, + "88,187": -20, + "88,192": -10, + "88,193": -10, + "88,194": -10, + "88,195": -10, + "88,196": -10, + "88,197": -10, + "88,199": -50, + "88,210": -50, + "88,211": -50, + "88,212": -50, + "88,213": -50, + "88,214": -50, + "88,216": -50, + "88,217": -20, + "88,218": -20, + "88,219": -20, + "88,220": -20, + "88,221": -10, + "88,224": -15, + "88,225": -15, + "88,226": -15, + "88,227": -15, + "88,228": -15, + "88,229": -15, + "88,230": -15, + "88,231": -40, + "88,232": -40, + "88,233": -40, + "88,234": -40, + "88,235": -40, + "88,236": 20, + "88,237": -10, + "88,238": -10, + "88,239": -10, + "88,240": -40, + "88,241": -10, + "88,242": -40, + "88,243": -40, + "88,244": -40, + "88,245": -40, + "88,246": -40, + "88,248": -40, + "88,249": -25, + "88,250": -25, + "88,251": -25, + "88,252": -25, + "88,253": -35, + "88,255": -35, + "88,8211": -90, + "88,8212": -90, + "88,8216": -35, + "88,8220": -35, + "89,38": -70, + "89,44": -110, + "89,45": -120, + "89,46": -110, + "89,47": -120, + "89,58": -80, + "89,59": -80, + "89,63": -30, + "89,64": -120, + "89,65": -85, + "89,67": -60, + "89,71": -60, + "89,74": -115, + "89,79": -60, + "89,81": -60, + "89,83": -35, + "89,88": -10, + "89,90": -10, + "89,97": -100, + "89,98": -10, + "89,99": -105, + "89,100": -105, + "89,101": -105, + "89,102": -30, + "89,103": -100, + "89,104": -10, + "89,105": -10, + "89,106": -10, + "89,107": -10, + "89,108": -10, + "89,109": -85, + "89,110": -85, + "89,111": -105, + "89,112": -85, + "89,113": -105, + "89,114": -85, + "89,115": -100, + "89,116": -20, + "89,117": -85, + "89,118": -65, + "89,119": -65, + "89,120": -80, + "89,121": -65, + "89,122": -80, + "89,171": -120, + "89,183": -120, + "89,187": -90, + "89,192": -85, + "89,193": -85, + "89,194": -85, + "89,195": -85, + "89,196": -85, + "89,197": -85, + "89,198": -140, + "89,199": -60, + "89,210": -60, + "89,211": -60, + "89,212": -60, + "89,213": -60, + "89,214": -60, + "89,216": -60, + "89,223": -65, + "89,224": -80, + "89,225": -100, + "89,226": -100, + "89,227": -100, + "89,228": -75, + "89,229": -100, + "89,230": -100, + "89,231": -105, + "89,232": -85, + "89,233": -105, + "89,234": -105, + "89,235": -105, + "89,236": -10, + "89,237": -60, + "89,238": -10, + "89,239": -10, + "89,240": -75, + "89,241": -85, + "89,242": -105, + "89,243": -105, + "89,244": -105, + "89,245": -105, + "89,246": -90, + "89,248": -105, + "89,249": -85, + "89,250": -85, + "89,251": -85, + "89,252": -75, + "89,253": -65, + "89,254": -10, + "89,255": -65, + "89,8211": -120, + "89,8212": -120, + "89,8217": 20, + "89,8221": 20, + "89,8230": -110, + "90,44": 20, + "90,45": -45, + "90,46": 20, + "90,63": -15, + "90,64": -5, + "90,67": -30, + "90,71": -30, + "90,79": -30, + "90,81": -30, + "90,85": -10, + "90,98": -10, + "90,99": -20, + "90,100": -20, + "90,101": -20, + "90,103": -15, + "90,104": -10, + "90,105": -10, + "90,106": -10, + "90,107": -10, + "90,108": -10, + "90,109": -10, + "90,110": -10, + "90,111": -20, + "90,112": -10, + "90,113": -20, + "90,114": -10, + "90,117": -20, + "90,118": -30, + "90,119": -25, + "90,121": -15, + "90,171": -30, + "90,183": -45, + "90,199": -30, + "90,210": -30, + "90,211": -30, + "90,212": -30, + "90,213": -30, + "90,214": -30, + "90,216": -30, + "90,217": -10, + "90,218": -10, + "90,219": -10, + "90,220": -10, + "90,231": -20, + "90,232": -20, + "90,233": -20, + "90,234": -20, + "90,235": -20, + "90,236": 20, + "90,237": -10, + "90,238": 15, + "90,239": 40, + "90,240": -20, + "90,241": -10, + "90,242": -20, + "90,243": -20, + "90,244": -20, + "90,245": -20, + "90,246": -20, + "90,248": -20, + "90,249": -20, + "90,250": -20, + "90,251": -20, + "90,252": -20, + "90,253": -15, + "90,255": -15, + "90,8211": -45, + "90,8212": -45, + "90,8217": 10, + "90,8221": 10, + "90,8230": 20, + "91,52": -20, + "91,55": 20, + "91,106": 60, + "91,236": 30, + "95,106": 60, + "97,44": 10, + "97,45": -10, + "97,46": 10, + "97,63": -50, + "97,118": -20, + "97,119": -10, + "97,121": -10, + "97,183": -10, + "97,253": -10, + "97,255": -10, + "97,8211": -10, + "97,8212": -10, + "97,8216": -35, + "97,8217": -40, + "97,8220": -35, + "97,8221": -40, + "97,8230": 10, + "98,44": -25, + "98,45": 20, + "98,46": -25, + "98,47": -30, + "98,63": -45, + "98,64": 5, + "98,118": -12, + "98,119": -10, + "98,120": -25, + "98,121": -10, + "98,122": -15, + "98,171": 20, + "98,183": 20, + "98,253": -10, + "98,255": -10, + "98,8211": 20, + "98,8212": 20, + "98,8216": -25, + "98,8217": -25, + "98,8220": -25, + "98,8221": -25, + "98,8230": -25, + "99,44": 10, + "99,46": 10, + "99,97": 10, + "99,115": 10, + "99,120": -10, + "99,122": -5, + "99,224": 10, + "99,225": 10, + "99,226": 10, + "99,227": 10, + "99,228": 10, + "99,229": 10, + "99,230": 10, + "99,8230": 10, + "100,239": 20, + "101,45": 15, + "101,47": -15, + "101,63": -40, + "101,97": 10, + "101,118": -10, + "101,119": -5, + "101,120": -25, + "101,121": -10, + "101,122": -10, + "101,171": 20, + "101,183": 15, + "101,224": 10, + "101,225": 10, + "101,226": 10, + "101,227": 10, + "101,228": 10, + "101,229": 10, + "101,230": 10, + "101,253": -10, + "101,255": -10, + "101,8211": 15, + "101,8212": 15, + "101,8216": -25, + "101,8217": -25, + "101,8220": -25, + "101,8221": -25, + "102,33": 25, + "102,41": 30, + "102,42": 30, + "102,44": -40, + "102,45": -20, + "102,46": -40, + "102,47": -40, + "102,63": 35, + "102,93": 30, + "102,99": -5, + "102,100": -10, + "102,101": -5, + "102,102": 15, + "102,103": -10, + "102,111": -5, + "102,113": -10, + "102,116": 20, + "102,118": 20, + "102,119": 20, + "102,121": 20, + "102,125": 30, + "102,171": -25, + "102,178": 30, + "102,179": 30, + "102,183": -20, + "102,185": 10, + "102,188": 10, + "102,189": 10, + "102,190": 30, + "102,231": -5, + "102,232": -5, + "102,233": -5, + "102,234": -5, + "102,235": -5, + "102,240": -5, + "102,242": -5, + "102,243": -5, + "102,244": -5, + "102,245": -5, + "102,246": -5, + "102,248": -5, + "102,253": 20, + "102,255": 20, + "102,8211": -20, + "102,8212": -20, + "102,8216": 40, + "102,8217": 35, + "102,8220": 40, + "102,8221": 35, + "102,8230": -40, + "103,103": -5, + "103,121": -5, + "103,253": -5, + "103,255": -5, + "103,8216": -10, + "103,8217": -10, + "103,8220": -10, + "103,8221": -10, + "104,63": -35, + "104,118": -10, + "104,119": -5, + "104,121": -5, + "104,253": -5, + "104,255": -5, + "104,8216": -25, + "104,8217": -25, + "104,8220": -25, + "104,8221": -25, + "105,239": 30, + "106,106": 10, + "106,239": 30, + "107,38": -20, + "107,45": -55, + "107,64": -15, + "107,97": -10, + "107,99": -25, + "107,100": -25, + "107,101": -25, + "107,103": -20, + "107,111": -25, + "107,113": -25, + "107,115": -10, + "107,117": -20, + "107,118": -20, + "107,119": -20, + "107,121": -20, + "107,171": -40, + "107,183": -55, + "107,224": -10, + "107,225": -10, + "107,226": -10, + "107,227": -10, + "107,228": -10, + "107,229": -10, + "107,230": -10, + "107,231": -25, + "107,232": -25, + "107,233": -25, + "107,234": -25, + "107,235": -25, + "107,240": -25, + "107,242": -25, + "107,243": -25, + "107,244": -25, + "107,245": -25, + "107,246": -25, + "107,248": -5, + "107,249": -20, + "107,250": -20, + "107,251": -20, + "107,252": -20, + "107,253": -20, + "107,255": -20, + "107,8211": -55, + "107,8212": -55, + "108,239": 20, + "109,63": -35, + "109,118": -10, + "109,119": -5, + "109,121": -5, + "109,253": -5, + "109,255": -5, + "109,8216": -25, + "109,8217": -25, + "109,8220": -25, + "109,8221": -25, + "110,63": -35, + "110,118": -10, + "110,119": -5, + "110,121": -5, + "110,253": -5, + "110,255": -5, + "110,8216": -25, + "110,8217": -25, + "110,8220": -25, + "110,8221": -25, + "111,44": -25, + "111,45": 20, + "111,46": -25, + "111,47": -30, + "111,63": -55, + "111,64": 5, + "111,118": -13, + "111,119": -10, + "111,120": -30, + "111,121": -15, + "111,122": -15, + "111,171": 15, + "111,183": 20, + "111,253": -15, + "111,255": -15, + "111,8211": 20, + "111,8212": 20, + "111,8216": -35, + "111,8217": -30, + "111,8220": -35, + "111,8221": -30, + "111,8230": -25, + "112,44": -25, + "112,45": 20, + "112,46": -25, + "112,47": -30, + "112,63": -45, + "112,64": 5, + "112,118": -12, + "112,119": -10, + "112,120": -25, + "112,121": -10, + "112,122": -15, + "112,171": 20, + "112,183": 20, + "112,253": -10, + "112,255": -10, + "112,8211": 20, + "112,8212": 20, + "112,8216": -25, + "112,8217": -25, + "112,8220": -25, + "112,8221": -25, + "112,8230": -25, + "113,106": 10, + "114,38": -30, + "114,44": -75, + "114,45": -15, + "114,46": -75, + "114,47": -65, + "114,63": 20, + "114,64": -20, + "114,97": -15, + "114,99": -25, + "114,100": -20, + "114,101": -25, + "114,102": 10, + "114,103": -20, + "114,111": -25, + "114,113": -20, + "114,115": -10, + "114,116": 10, + "114,118": 10, + "114,119": 10, + "114,120": -5, + "114,121": 10, + "114,183": -15, + "114,224": -15, + "114,225": -15, + "114,226": -15, + "114,227": -15, + "114,228": -15, + "114,229": -15, + "114,230": -15, + "114,231": -25, + "114,232": -25, + "114,233": -25, + "114,234": -25, + "114,235": -25, + "114,240": -25, + "114,242": -25, + "114,243": -25, + "114,244": -25, + "114,245": -25, + "114,246": -25, + "114,248": -25, + "114,253": 10, + "114,255": 10, + "114,8211": -15, + "114,8212": -15, + "114,8216": 20, + "114,8217": 25, + "114,8220": 20, + "114,8221": 25, + "114,8230": -75, + "115,44": -10, + "115,46": -10, + "115,47": -5, + "115,63": -15, + "115,118": -10, + "115,119": -10, + "115,120": -20, + "115,121": -10, + "115,122": -10, + "115,253": -10, + "115,255": -10, + "115,8216": -5, + "115,8217": -5, + "115,8220": -5, + "115,8221": -5, + "115,8230": -10, + "116,44": 15, + "116,45": -30, + "116,46": 15, + "116,99": -5, + "116,101": -5, + "116,102": -10, + "116,111": -5, + "116,117": -5, + "116,171": -25, + "116,183": -30, + "116,231": -5, + "116,232": -5, + "116,233": -5, + "116,234": -5, + "116,235": -5, + "116,240": -5, + "116,242": -5, + "116,243": -5, + "116,244": -5, + "116,245": -5, + "116,246": -5, + "116,248": -5, + "116,249": -5, + "116,250": -5, + "116,251": -5, + "116,252": -5, + "116,8211": -30, + "116,8212": -30, + "116,8216": 10, + "116,8217": 5, + "116,8220": 10, + "116,8221": 5, + "116,8230": 15, + "117,8216": -10, + "117,8220": -10, + "118,38": -20, + "118,44": -80, + "118,45": -15, + "118,46": -80, + "118,47": -50, + "118,63": 30, + "118,64": -25, + "118,97": -20, + "118,99": -13, + "118,100": -12, + "118,101": -13, + "118,102": 20, + "118,103": -12, + "118,111": -13, + "118,113": -12, + "118,115": -10, + "118,116": 15, + "118,171": -20, + "118,183": -15, + "118,224": -20, + "118,225": -20, + "118,226": -20, + "118,227": -20, + "118,228": -20, + "118,229": -20, + "118,230": -20, + "118,231": -13, + "118,232": -13, + "118,233": -13, + "118,234": -13, + "118,235": -13, + "118,240": -13, + "118,242": -13, + "118,243": -13, + "118,244": -13, + "118,245": -13, + "118,246": -13, + "118,248": -13, + "118,8211": -15, + "118,8212": -15, + "118,8216": 20, + "118,8217": 20, + "118,8220": 20, + "118,8221": 20, + "118,8230": -80, + "119,38": -10, + "119,44": -55, + "119,45": -5, + "119,46": -55, + "119,47": -40, + "119,63": 30, + "119,64": -15, + "119,97": -15, + "119,99": -10, + "119,100": -10, + "119,101": -10, + "119,102": 20, + "119,103": -10, + "119,111": -10, + "119,113": -10, + "119,115": -10, + "119,116": 15, + "119,171": -10, + "119,183": -5, + "119,224": -15, + "119,225": -15, + "119,226": -15, + "119,227": -15, + "119,228": -15, + "119,229": -15, + "119,230": -15, + "119,231": -10, + "119,232": -10, + "119,233": -10, + "119,234": -10, + "119,235": -10, + "119,240": -10, + "119,242": -10, + "119,243": -10, + "119,244": -10, + "119,245": -10, + "119,246": -10, + "119,248": -10, + "119,8211": -5, + "119,8212": -5, + "119,8216": 20, + "119,8217": 20, + "119,8220": 20, + "119,8221": 20, + "119,8230": -55, + "120,38": -30, + "120,45": -50, + "120,63": 20, + "120,64": -25, + "120,97": -10, + "120,99": -30, + "120,100": -25, + "120,101": -30, + "120,103": -10, + "120,111": -30, + "120,113": -25, + "120,115": -15, + "120,117": -10, + "120,171": -40, + "120,183": -50, + "120,187": -10, + "120,224": -10, + "120,225": -10, + "120,226": -10, + "120,227": -10, + "120,228": -10, + "120,229": -10, + "120,230": -10, + "120,231": -30, + "120,232": -30, + "120,233": -30, + "120,234": -30, + "120,235": -30, + "120,240": -30, + "120,242": -30, + "120,243": -30, + "120,244": -30, + "120,245": -30, + "120,246": -30, + "120,248": -30, + "120,249": -10, + "120,250": -10, + "120,251": -10, + "120,252": -10, + "120,8211": -50, + "120,8212": -50, + "121,38": -35, + "121,44": -95, + "121,45": -30, + "121,46": -95, + "121,47": -55, + "121,63": 30, + "121,64": -30, + "121,97": -25, + "121,99": -20, + "121,100": -16, + "121,101": -20, + "121,102": 20, + "121,103": -16, + "121,111": -20, + "121,113": -16, + "121,115": -15, + "121,116": 15, + "121,122": -5, + "121,171": -20, + "121,183": -30, + "121,224": -25, + "121,225": -25, + "121,226": -25, + "121,227": -25, + "121,228": -25, + "121,229": -25, + "121,230": -25, + "121,231": -20, + "121,232": -20, + "121,233": -20, + "121,234": -20, + "121,235": -20, + "121,240": -20, + "121,242": -20, + "121,243": -20, + "121,244": -20, + "121,245": -20, + "121,246": -20, + "121,248": -20, + "121,8211": -30, + "121,8212": -30, + "121,8216": 20, + "121,8217": 20, + "121,8220": 20, + "121,8221": 20, + "121,8230": -95, + "122,38": -15, + "122,45": -30, + "122,64": -15, + "122,97": -5, + "122,99": -15, + "122,100": -15, + "122,101": -15, + "122,111": -15, + "122,113": -15, + "122,115": -5, + "122,117": -10, + "122,171": -30, + "122,183": -30, + "122,224": -5, + "122,225": -5, + "122,226": -5, + "122,227": -5, + "122,228": -5, + "122,229": -5, + "122,230": -5, + "122,231": -15, + "122,232": -15, + "122,233": -15, + "122,234": -15, + "122,235": -15, + "122,240": -15, + "122,242": -15, + "122,243": -15, + "122,244": -15, + "122,245": -15, + "122,246": -15, + "122,248": -15, + "122,249": -10, + "122,250": -10, + "122,251": -10, + "122,252": -10, + "122,8211": -30, + "122,8212": -30, + "123,52": -20, + "123,55": 20, + "123,106": 60, + "123,236": 30, + "124,106": 20, + "161,55": 20, + "163,52": 10, + "163,54": -5, + "165,48": -30, + "165,49": -35, + "165,50": -30, + "165,51": -45, + "165,52": -20, + "165,53": -25, + "165,54": -35, + "165,55": 20, + "165,56": -35, + "165,57": -35, + "171,49": -10, + "171,52": 20, + "171,55": -15, + "171,56": 10, + "171,83": 25, + "171,84": -100, + "171,86": -70, + "171,87": -30, + "171,88": -20, + "171,89": -90, + "171,120": -10, + "171,221": -90, + "176,49": 20, + "176,52": -70, + "176,53": -10, + "176,55": 40, + "176,56": -15, + "183,48": 10, + "183,49": -50, + "183,50": -25, + "183,52": 20, + "183,54": 10, + "183,55": -80, + "183,57": -10, + "183,65": -40, + "183,67": 10, + "183,71": 10, + "183,74": -30, + "183,79": 10, + "183,81": 10, + "183,84": -100, + "183,86": -80, + "183,87": -50, + "183,88": -90, + "183,89": -120, + "183,90": -30, + "183,99": 20, + "183,100": 20, + "183,101": 20, + "183,103": 20, + "183,111": 20, + "183,113": 20, + "183,118": -15, + "183,119": -5, + "183,120": -50, + "183,121": -20, + "183,122": -30, + "183,192": -40, + "183,193": -40, + "183,194": -40, + "183,195": -40, + "183,196": -40, + "183,197": -40, + "183,198": -75, + "183,199": 10, + "183,208": 20, + "183,210": 10, + "183,211": 10, + "183,212": 10, + "183,213": 10, + "183,214": 10, + "183,216": 10, + "183,221": -120, + "183,231": 20, + "183,232": 20, + "183,233": 20, + "183,234": 20, + "183,235": 20, + "183,240": 20, + "183,242": 20, + "183,243": 20, + "183,244": 20, + "183,245": 20, + "183,246": 20, + "183,248": 20, + "183,253": -20, + "183,255": -20, + "187,49": -80, + "187,50": -40, + "187,52": 20, + "187,55": -65, + "187,57": -10, + "187,65": -10, + "187,67": 15, + "187,71": 15, + "187,74": -20, + "187,79": 15, + "187,81": 15, + "187,84": -120, + "187,86": -90, + "187,87": -40, + "187,88": -60, + "187,89": -120, + "187,90": -25, + "187,99": 15, + "187,100": 20, + "187,101": 15, + "187,103": 20, + "187,111": 15, + "187,113": 20, + "187,118": -20, + "187,119": -10, + "187,120": -40, + "187,121": -20, + "187,122": -30, + "187,192": -10, + "187,193": -10, + "187,194": -10, + "187,195": -10, + "187,196": -10, + "187,197": -10, + "187,198": -45, + "187,199": 15, + "187,210": 15, + "187,211": 15, + "187,212": 15, + "187,213": 15, + "187,214": 15, + "187,216": 15, + "187,221": -120, + "187,231": 15, + "187,232": 15, + "187,233": 15, + "187,234": 15, + "187,235": 15, + "187,240": 15, + "187,242": 15, + "187,243": 15, + "187,244": 15, + "187,245": 15, + "187,246": 15, + "187,248": 15, + "187,253": -20, + "187,255": -20, + "191,49": -85, + "191,50": -10, + "191,51": 20, + "191,52": 20, + "191,53": 20, + "191,55": -75, + "191,56": 10, + "191,57": -15, + "191,83": 20, + "191,84": -100, + "191,86": -100, + "191,87": -70, + "191,88": -45, + "191,89": -130, + "191,90": -20, + "191,97": 20, + "191,99": 20, + "191,100": 20, + "191,101": 20, + "191,103": 15, + "191,111": 20, + "191,113": 20, + "191,116": -10, + "191,118": -40, + "191,119": -30, + "191,120": -35, + "191,121": -40, + "191,122": -15, + "191,221": -130, + "191,224": 20, + "191,225": 20, + "191,226": 20, + "191,227": 20, + "191,228": 20, + "191,229": 20, + "191,230": 20, + "191,231": 20, + "191,232": 20, + "191,233": 20, + "191,234": 20, + "191,235": 20, + "191,240": 20, + "191,242": 20, + "191,243": 20, + "191,244": 20, + "191,245": 20, + "191,246": 20, + "191,248": 20, + "191,253": -40, + "191,255": -40, + "192,38": -20, + "192,44": 20, + "192,45": -40, + "192,46": 20, + "192,63": -90, + "192,64": -5, + "192,67": -40, + "192,71": -40, + "192,79": -40, + "192,81": -40, + "192,83": -5, + "192,84": -85, + "192,85": -35, + "192,86": -90, + "192,87": -55, + "192,88": -10, + "192,89": -85, + "192,98": -10, + "192,99": -25, + "192,100": -20, + "192,101": -25, + "192,102": -10, + "192,103": -15, + "192,104": -10, + "192,105": -10, + "192,106": -10, + "192,107": -10, + "192,108": -10, + "192,109": -10, + "192,110": -10, + "192,111": -25, + "192,112": -10, + "192,113": -20, + "192,114": -10, + "192,116": -15, + "192,117": -20, + "192,118": -45, + "192,119": -35, + "192,121": -35, + "192,171": -20, + "192,183": -40, + "192,199": -40, + "192,210": -40, + "192,211": -40, + "192,212": -40, + "192,213": -40, + "192,214": -40, + "192,216": -15, + "192,217": -35, + "192,218": -35, + "192,219": -35, + "192,220": -35, + "192,221": -85, + "192,223": -10, + "192,231": -25, + "192,232": -25, + "192,233": -25, + "192,234": -25, + "192,235": -25, + "192,236": -10, + "192,237": -10, + "192,238": -10, + "192,239": -10, + "192,240": -25, + "192,241": -10, + "192,242": -25, + "192,243": -25, + "192,244": -25, + "192,245": -25, + "192,246": -25, + "192,248": -25, + "192,249": -20, + "192,250": -20, + "192,251": -20, + "192,252": -20, + "192,253": -35, + "192,255": -35, + "192,8211": -40, + "192,8212": -40, + "192,8216": -95, + "192,8217": -75, + "192,8220": -95, + "192,8221": -75, + "192,8230": 20, + "193,38": -20, + "193,44": 20, + "193,45": -40, + "193,46": 20, + "193,63": -90, + "193,64": -5, + "193,67": -40, + "193,71": -40, + "193,79": -40, + "193,81": -40, + "193,83": -5, + "193,84": -85, + "193,85": -35, + "193,86": -90, + "193,87": -55, + "193,88": -10, + "193,89": -85, + "193,98": -10, + "193,99": -25, + "193,100": -20, + "193,101": -25, + "193,102": -10, + "193,103": -15, + "193,104": -10, + "193,105": -10, + "193,106": -10, + "193,107": -10, + "193,108": -10, + "193,109": -10, + "193,110": -10, + "193,111": -25, + "193,112": -10, + "193,113": -20, + "193,114": -10, + "193,116": -15, + "193,117": -20, + "193,118": -45, + "193,119": -35, + "193,121": -35, + "193,171": -20, + "193,183": -40, + "193,199": -40, + "193,210": -40, + "193,211": -40, + "193,212": -40, + "193,213": -40, + "193,214": -40, + "193,216": -15, + "193,217": -35, + "193,218": -35, + "193,219": -35, + "193,220": -35, + "193,221": -85, + "193,223": -10, + "193,231": -25, + "193,232": -25, + "193,233": -25, + "193,234": -25, + "193,235": -25, + "193,236": -10, + "193,237": -10, + "193,238": -10, + "193,239": -10, + "193,240": -25, + "193,241": -10, + "193,242": -25, + "193,243": -25, + "193,244": -25, + "193,245": -25, + "193,246": -25, + "193,248": -25, + "193,249": -20, + "193,250": -20, + "193,251": -20, + "193,252": -20, + "193,253": -35, + "193,255": -35, + "193,8211": -40, + "193,8212": -40, + "193,8216": -95, + "193,8217": -75, + "193,8220": -95, + "193,8221": -75, + "193,8230": 20, + "194,38": -20, + "194,44": 20, + "194,45": -40, + "194,46": 20, + "194,63": -90, + "194,64": -5, + "194,67": -40, + "194,71": -40, + "194,79": -40, + "194,81": -40, + "194,83": -5, + "194,84": -85, + "194,85": -35, + "194,86": -90, + "194,87": -55, + "194,88": -10, + "194,89": -85, + "194,98": -10, + "194,99": -25, + "194,100": -20, + "194,101": -25, + "194,102": -10, + "194,103": -15, + "194,104": -10, + "194,105": -10, + "194,106": -10, + "194,107": -10, + "194,108": -10, + "194,109": -10, + "194,110": -10, + "194,111": -25, + "194,112": -10, + "194,113": -20, + "194,114": -10, + "194,116": -15, + "194,117": -20, + "194,118": -45, + "194,119": -35, + "194,121": -35, + "194,171": -20, + "194,183": -40, + "194,199": -40, + "194,210": -40, + "194,211": -40, + "194,212": -40, + "194,213": -40, + "194,214": -40, + "194,216": -15, + "194,217": -35, + "194,218": -35, + "194,219": -35, + "194,220": -35, + "194,221": -85, + "194,223": -10, + "194,231": -25, + "194,232": -25, + "194,233": -25, + "194,234": -25, + "194,235": -25, + "194,236": -10, + "194,237": -10, + "194,238": -10, + "194,239": -10, + "194,240": -25, + "194,241": -10, + "194,242": -25, + "194,243": -25, + "194,244": -25, + "194,245": -25, + "194,246": -25, + "194,248": -25, + "194,249": -20, + "194,250": -20, + "194,251": -20, + "194,252": -20, + "194,253": -35, + "194,255": -35, + "194,8211": -40, + "194,8212": -40, + "194,8216": -95, + "194,8217": -75, + "194,8220": -95, + "194,8221": -75, + "194,8230": 20, + "195,38": -20, + "195,44": 20, + "195,45": -40, + "195,46": 20, + "195,63": -90, + "195,64": -5, + "195,67": -40, + "195,71": -40, + "195,79": -40, + "195,81": -40, + "195,83": -5, + "195,84": -85, + "195,85": -35, + "195,86": -90, + "195,87": -55, + "195,88": -10, + "195,89": -85, + "195,98": -10, + "195,99": -25, + "195,100": -20, + "195,101": -25, + "195,102": -10, + "195,103": -15, + "195,104": -10, + "195,105": -10, + "195,106": -10, + "195,107": -10, + "195,108": -10, + "195,109": -10, + "195,110": -10, + "195,111": -25, + "195,112": -10, + "195,113": -20, + "195,114": -10, + "195,116": -15, + "195,117": -20, + "195,118": -45, + "195,119": -35, + "195,121": -35, + "195,171": -20, + "195,183": -40, + "195,199": -40, + "195,210": -40, + "195,211": -40, + "195,212": -40, + "195,213": -40, + "195,214": -40, + "195,216": -15, + "195,217": -35, + "195,218": -35, + "195,219": -35, + "195,220": -35, + "195,221": -85, + "195,223": -10, + "195,231": -25, + "195,232": -25, + "195,233": -25, + "195,234": -25, + "195,235": -25, + "195,236": -10, + "195,237": -10, + "195,238": -10, + "195,239": -10, + "195,240": -25, + "195,241": -10, + "195,242": -25, + "195,243": -25, + "195,244": -25, + "195,245": -25, + "195,246": -25, + "195,248": -25, + "195,249": -20, + "195,250": -20, + "195,251": -20, + "195,252": -20, + "195,253": -35, + "195,255": -35, + "195,8211": -40, + "195,8212": -40, + "195,8216": -95, + "195,8217": -75, + "195,8220": -95, + "195,8221": -75, + "195,8230": 20, + "196,38": -20, + "196,44": 20, + "196,45": -40, + "196,46": 20, + "196,63": -90, + "196,64": -5, + "196,67": -40, + "196,71": -40, + "196,79": -40, + "196,81": -40, + "196,83": -5, + "196,84": -85, + "196,85": -35, + "196,86": -90, + "196,87": -55, + "196,88": -10, + "196,89": -85, + "196,98": -10, + "196,99": -25, + "196,100": -20, + "196,101": -25, + "196,102": -10, + "196,103": -15, + "196,104": -10, + "196,105": -10, + "196,106": -10, + "196,107": -10, + "196,108": -10, + "196,109": -10, + "196,110": -10, + "196,111": -25, + "196,112": -10, + "196,113": -20, + "196,114": -10, + "196,116": -15, + "196,117": -20, + "196,118": -45, + "196,119": -35, + "196,121": -35, + "196,171": -20, + "196,183": -40, + "196,199": -40, + "196,210": -40, + "196,211": -40, + "196,212": -40, + "196,213": -40, + "196,214": -40, + "196,216": -15, + "196,217": -35, + "196,218": -35, + "196,219": -35, + "196,220": -35, + "196,221": -85, + "196,223": -10, + "196,231": -25, + "196,232": -25, + "196,233": -25, + "196,234": -25, + "196,235": -25, + "196,236": -10, + "196,237": -10, + "196,238": -10, + "196,239": -10, + "196,240": -25, + "196,241": -10, + "196,242": -25, + "196,243": -25, + "196,244": -25, + "196,245": -25, + "196,246": -25, + "196,248": -25, + "196,249": -20, + "196,250": -20, + "196,251": -20, + "196,252": -20, + "196,253": -35, + "196,255": -35, + "196,8211": -40, + "196,8212": -40, + "196,8216": -95, + "196,8217": -75, + "196,8220": -95, + "196,8221": -75, + "196,8230": 20, + "197,38": -20, + "197,44": 20, + "197,45": -40, + "197,46": 20, + "197,63": -90, + "197,64": -5, + "197,67": -40, + "197,71": -40, + "197,79": -40, + "197,81": -40, + "197,83": -5, + "197,84": -85, + "197,85": -35, + "197,86": -90, + "197,87": -55, + "197,88": -10, + "197,89": -85, + "197,98": -10, + "197,99": -25, + "197,100": -20, + "197,101": -25, + "197,102": -10, + "197,103": -15, + "197,104": -10, + "197,105": -10, + "197,106": -10, + "197,107": -10, + "197,108": -10, + "197,109": -10, + "197,110": -10, + "197,111": -25, + "197,112": -10, + "197,113": -20, + "197,114": -10, + "197,116": -15, + "197,117": -20, + "197,118": -45, + "197,119": -35, + "197,121": -35, + "197,171": -20, + "197,183": -40, + "197,199": -40, + "197,210": -40, + "197,211": -40, + "197,212": -40, + "197,213": -40, + "197,214": -40, + "197,216": -15, + "197,217": -35, + "197,218": -35, + "197,219": -35, + "197,220": -35, + "197,221": -85, + "197,223": -10, + "197,231": -25, + "197,232": -25, + "197,233": -25, + "197,234": -25, + "197,235": -25, + "197,236": -10, + "197,237": -10, + "197,238": -10, + "197,239": -10, + "197,240": -25, + "197,241": -10, + "197,242": -25, + "197,243": -25, + "197,244": -25, + "197,245": -25, + "197,246": -25, + "197,248": -25, + "197,249": -20, + "197,250": -20, + "197,251": -20, + "197,252": -20, + "197,253": -35, + "197,255": -35, + "197,8211": -40, + "197,8212": -40, + "197,8216": -95, + "197,8217": -75, + "197,8220": -95, + "197,8221": -75, + "197,8230": 20, + "198,44": 20, + "198,45": -30, + "198,46": 20, + "198,67": -15, + "198,71": -15, + "198,79": -15, + "198,81": -15, + "198,97": -5, + "198,98": -10, + "198,99": -15, + "198,100": -15, + "198,101": -15, + "198,102": -10, + "198,103": -15, + "198,104": -10, + "198,105": -10, + "198,106": -10, + "198,107": -10, + "198,108": -10, + "198,109": -10, + "198,110": -10, + "198,111": -15, + "198,112": -10, + "198,113": -15, + "198,114": -10, + "198,117": -15, + "198,118": -20, + "198,119": -20, + "198,121": -20, + "198,171": -15, + "198,183": -30, + "198,199": -15, + "198,210": -15, + "198,211": -15, + "198,212": -15, + "198,213": -15, + "198,214": -15, + "198,216": -15, + "198,223": -10, + "198,224": -5, + "198,225": -5, + "198,226": -5, + "198,227": -5, + "198,228": -5, + "198,229": -5, + "198,230": -5, + "198,231": -15, + "198,232": -15, + "198,233": -15, + "198,234": -15, + "198,235": -15, + "198,236": 10, + "198,237": -10, + "198,238": 15, + "198,239": 30, + "198,240": -15, + "198,241": -10, + "198,242": -15, + "198,243": -15, + "198,244": -15, + "198,245": -15, + "198,246": -15, + "198,248": -15, + "198,249": -15, + "198,250": -15, + "198,251": -15, + "198,252": -15, + "198,253": -20, + "198,255": -20, + "198,8211": -30, + "198,8212": -30, + "198,8217": 20, + "198,8221": 20, + "198,8230": 20, + "199,47": -25, + "199,63": 20, + "199,65": -25, + "199,74": -20, + "199,84": -10, + "199,86": -20, + "199,87": -10, + "199,88": -35, + "199,89": -30, + "199,90": -15, + "199,171": 20, + "199,192": -25, + "199,193": -25, + "199,194": -25, + "199,195": -25, + "199,196": -25, + "199,197": -25, + "199,198": -45, + "199,221": -30, + "199,238": 25, + "199,239": 25, + "199,8216": 10, + "199,8220": 10, + "200,44": 20, + "200,45": -30, + "200,46": 20, + "200,67": -15, + "200,71": -15, + "200,79": -15, + "200,81": -15, + "200,97": -5, + "200,98": -10, + "200,99": -15, + "200,100": -15, + "200,101": -15, + "200,102": -10, + "200,103": -15, + "200,104": -10, + "200,105": -10, + "200,106": -10, + "200,107": -10, + "200,108": -10, + "200,109": -10, + "200,110": -10, + "200,111": -15, + "200,112": -10, + "200,113": -15, + "200,114": -10, + "200,117": -15, + "200,118": -20, + "200,119": -20, + "200,121": -20, + "200,171": -15, + "200,183": -30, + "200,199": -15, + "200,210": -15, + "200,211": -15, + "200,212": -15, + "200,213": -15, + "200,214": -15, + "200,216": -15, + "200,223": -10, + "200,224": -5, + "200,225": -5, + "200,226": -5, + "200,227": -5, + "200,228": -5, + "200,229": -5, + "200,230": -5, + "200,231": -15, + "200,232": -15, + "200,233": -15, + "200,234": -15, + "200,235": -15, + "200,236": 10, + "200,237": -10, + "200,238": 15, + "200,239": 30, + "200,240": -15, + "200,241": -10, + "200,242": -15, + "200,243": -15, + "200,244": -15, + "200,245": -15, + "200,246": -15, + "200,248": -15, + "200,249": -15, + "200,250": -15, + "200,251": -15, + "200,252": -15, + "200,253": -20, + "200,255": -20, + "200,8211": -30, + "200,8212": -30, + "200,8217": 20, + "200,8221": 20, + "200,8230": 20, + "201,44": 20, + "201,45": -30, + "201,46": 20, + "201,67": -15, + "201,71": -15, + "201,79": -15, + "201,81": -15, + "201,97": -5, + "201,98": -10, + "201,99": -15, + "201,100": -15, + "201,101": -15, + "201,102": -10, + "201,103": -15, + "201,104": -10, + "201,105": -10, + "201,106": -10, + "201,107": -10, + "201,108": -10, + "201,109": -10, + "201,110": -10, + "201,111": -15, + "201,112": -10, + "201,113": -15, + "201,114": -10, + "201,117": -15, + "201,118": -20, + "201,119": -20, + "201,121": -20, + "201,171": -15, + "201,183": -30, + "201,199": -15, + "201,210": -15, + "201,211": -15, + "201,212": -15, + "201,213": -15, + "201,214": -15, + "201,216": -15, + "201,223": -10, + "201,224": -5, + "201,225": -5, + "201,226": -5, + "201,227": -5, + "201,228": -5, + "201,229": -5, + "201,230": -5, + "201,231": -15, + "201,232": -15, + "201,233": -15, + "201,234": -15, + "201,235": -15, + "201,236": 10, + "201,237": -10, + "201,238": 15, + "201,239": 30, + "201,240": -15, + "201,241": -10, + "201,242": -15, + "201,243": -15, + "201,244": -15, + "201,245": -15, + "201,246": -15, + "201,248": -15, + "201,249": -15, + "201,250": -15, + "201,251": -15, + "201,252": -15, + "201,253": -20, + "201,255": -20, + "201,8211": -30, + "201,8212": -30, + "201,8217": 20, + "201,8221": 20, + "201,8230": 20, + "202,44": 20, + "202,45": -30, + "202,46": 20, + "202,67": -15, + "202,71": -15, + "202,79": -15, + "202,81": -15, + "202,97": -5, + "202,98": -10, + "202,99": -15, + "202,100": -15, + "202,101": -15, + "202,102": -10, + "202,103": -15, + "202,104": -10, + "202,105": -10, + "202,106": -10, + "202,107": -10, + "202,108": -10, + "202,109": -10, + "202,110": -10, + "202,111": -15, + "202,112": -10, + "202,113": -15, + "202,114": -10, + "202,117": -15, + "202,118": -20, + "202,119": -20, + "202,121": -20, + "202,171": -15, + "202,183": -30, + "202,199": -15, + "202,210": -15, + "202,211": -15, + "202,212": -15, + "202,213": -15, + "202,214": -15, + "202,216": -15, + "202,223": -10, + "202,224": -5, + "202,225": -5, + "202,226": -5, + "202,227": -5, + "202,228": -5, + "202,229": -5, + "202,230": -5, + "202,231": -15, + "202,232": -15, + "202,233": -15, + "202,234": -15, + "202,235": -15, + "202,236": 10, + "202,237": -10, + "202,238": 15, + "202,239": 30, + "202,240": -15, + "202,241": -10, + "202,242": -15, + "202,243": -15, + "202,244": -15, + "202,245": -15, + "202,246": -15, + "202,248": -15, + "202,249": -15, + "202,250": -15, + "202,251": -15, + "202,252": -15, + "202,253": -20, + "202,255": -20, + "202,8211": -30, + "202,8212": -30, + "202,8217": 20, + "202,8221": 20, + "202,8230": 20, + "203,44": 20, + "203,45": -30, + "203,46": 20, + "203,67": -15, + "203,71": -15, + "203,79": -15, + "203,81": -15, + "203,97": -5, + "203,98": -10, + "203,99": -15, + "203,100": -15, + "203,101": -15, + "203,102": -10, + "203,103": -15, + "203,104": -10, + "203,105": -10, + "203,106": -10, + "203,107": -10, + "203,108": -10, + "203,109": -10, + "203,110": -10, + "203,111": -15, + "203,112": -10, + "203,113": -15, + "203,114": -10, + "203,117": -15, + "203,118": -20, + "203,119": -20, + "203,121": -20, + "203,171": -15, + "203,183": -30, + "203,199": -15, + "203,210": -15, + "203,211": -15, + "203,212": -15, + "203,213": -15, + "203,214": -15, + "203,216": -15, + "203,223": -10, + "203,224": -5, + "203,225": -5, + "203,226": -5, + "203,227": -5, + "203,228": -5, + "203,229": -5, + "203,230": -5, + "203,231": -15, + "203,232": -15, + "203,233": -15, + "203,234": -15, + "203,235": -15, + "203,236": 10, + "203,237": -10, + "203,238": 15, + "203,239": 30, + "203,240": -15, + "203,241": -10, + "203,242": -15, + "203,243": -15, + "203,244": -15, + "203,245": -15, + "203,246": -15, + "203,248": -15, + "203,249": -15, + "203,250": -15, + "203,251": -15, + "203,252": -15, + "203,253": -20, + "203,255": -20, + "203,8211": -30, + "203,8212": -30, + "203,8217": 20, + "203,8221": 20, + "203,8230": 20, + "204,236": 10, + "204,238": 10, + "204,239": 20, + "205,236": 10, + "205,238": 10, + "205,239": 20, + "206,236": 10, + "206,238": 10, + "206,239": 20, + "207,236": 10, + "207,238": 10, + "207,239": 20, + "208,44": -50, + "208,45": 10, + "208,46": -50, + "208,47": -55, + "208,64": 10, + "208,65": -40, + "208,67": 5, + "208,71": 5, + "208,74": -30, + "208,79": 5, + "208,81": 5, + "208,84": -50, + "208,86": -40, + "208,87": -25, + "208,88": -50, + "208,89": -60, + "208,90": -30, + "208,102": 5, + "208,116": 10, + "208,120": -15, + "208,122": -10, + "208,171": 15, + "208,183": 10, + "208,192": -40, + "208,193": -40, + "208,194": -40, + "208,195": -40, + "208,196": -40, + "208,197": -40, + "208,198": -65, + "208,199": 5, + "208,210": 5, + "208,211": 5, + "208,212": 5, + "208,213": 5, + "208,214": 5, + "208,216": 5, + "208,221": -60, + "208,8211": 10, + "208,8212": 10, + "208,8230": -50, + "209,236": 10, + "209,238": 10, + "209,239": 20, + "210,44": -50, + "210,45": 10, + "210,46": -50, + "210,47": -55, + "210,64": 10, + "210,65": -40, + "210,67": 5, + "210,71": 5, + "210,74": -30, + "210,79": 5, + "210,81": 5, + "210,84": -50, + "210,86": -40, + "210,87": -25, + "210,88": -50, + "210,89": -60, + "210,90": -30, + "210,102": 5, + "210,116": 10, + "210,120": -15, + "210,122": -10, + "210,171": 15, + "210,183": 10, + "210,192": -40, + "210,193": -40, + "210,194": -40, + "210,195": -40, + "210,196": -40, + "210,197": -40, + "210,198": -65, + "210,199": 5, + "210,210": 5, + "210,211": 5, + "210,212": 5, + "210,213": 5, + "210,214": 5, + "210,216": 5, + "210,221": -60, + "210,8211": 10, + "210,8212": 10, + "210,8230": -50, + "211,44": -50, + "211,45": 10, + "211,46": -50, + "211,47": -55, + "211,64": 10, + "211,65": -40, + "211,67": 5, + "211,71": 5, + "211,74": -30, + "211,79": 5, + "211,81": 5, + "211,84": -50, + "211,86": -40, + "211,87": -25, + "211,88": -50, + "211,89": -60, + "211,90": -30, + "211,102": 5, + "211,116": 10, + "211,120": -15, + "211,122": -10, + "211,171": 15, + "211,183": 10, + "211,192": -40, + "211,193": -40, + "211,194": -40, + "211,195": -40, + "211,196": -40, + "211,197": -40, + "211,198": -65, + "211,199": 5, + "211,210": 5, + "211,211": 5, + "211,212": 5, + "211,213": 5, + "211,214": 5, + "211,216": 5, + "211,221": -60, + "211,8211": 10, + "211,8212": 10, + "211,8230": -50, + "212,44": -50, + "212,45": 10, + "212,46": -50, + "212,47": -55, + "212,64": 10, + "212,65": -40, + "212,67": 5, + "212,71": 5, + "212,74": -30, + "212,79": 5, + "212,81": 5, + "212,84": -50, + "212,86": -40, + "212,87": -25, + "212,88": -50, + "212,89": -60, + "212,90": -30, + "212,102": 5, + "212,116": 10, + "212,120": -15, + "212,122": -10, + "212,171": 15, + "212,183": 10, + "212,192": -40, + "212,193": -40, + "212,194": -40, + "212,195": -40, + "212,196": -40, + "212,197": -40, + "212,198": -65, + "212,199": 5, + "212,210": 5, + "212,211": 5, + "212,212": 5, + "212,213": 5, + "212,214": 5, + "212,216": 5, + "212,221": -60, + "212,8211": 10, + "212,8212": 10, + "212,8230": -50, + "213,44": -50, + "213,45": 10, + "213,46": -50, + "213,47": -55, + "213,64": 10, + "213,65": -40, + "213,67": 5, + "213,71": 5, + "213,74": -30, + "213,79": 5, + "213,81": 5, + "213,84": -50, + "213,86": -40, + "213,87": -25, + "213,88": -50, + "213,89": -60, + "213,90": -30, + "213,102": 5, + "213,116": 10, + "213,120": -15, + "213,122": -10, + "213,171": 15, + "213,183": 10, + "213,192": -40, + "213,193": -40, + "213,194": -40, + "213,195": -40, + "213,196": -40, + "213,197": -40, + "213,198": -65, + "213,199": 5, + "213,210": 5, + "213,211": 5, + "213,212": 5, + "213,213": 5, + "213,214": 5, + "213,216": 5, + "213,221": -60, + "213,8211": 10, + "213,8212": 10, + "213,8230": -50, + "214,44": -50, + "214,45": 10, + "214,46": -50, + "214,47": -55, + "214,64": 10, + "214,65": -40, + "214,67": 5, + "214,71": 5, + "214,74": -30, + "214,79": 5, + "214,81": 5, + "214,84": -50, + "214,86": -40, + "214,87": -25, + "214,88": -50, + "214,89": -60, + "214,90": -30, + "214,102": 5, + "214,116": 10, + "214,120": -15, + "214,122": -10, + "214,171": 15, + "214,183": 10, + "214,192": -40, + "214,193": -40, + "214,194": -40, + "214,195": -40, + "214,196": -40, + "214,197": -40, + "214,198": -65, + "214,199": 5, + "214,210": 5, + "214,211": 5, + "214,212": 5, + "214,213": 5, + "214,214": 5, + "214,216": 5, + "214,221": -60, + "214,8211": 10, + "214,8212": 10, + "214,8230": -50, + "216,44": -50, + "216,45": 10, + "216,46": -50, + "216,47": -55, + "216,64": 10, + "216,65": -40, + "216,67": 5, + "216,71": 5, + "216,74": -30, + "216,79": 5, + "216,81": 5, + "216,84": -50, + "216,86": -40, + "216,87": -25, + "216,88": -50, + "216,89": -60, + "216,90": -30, + "216,102": 5, + "216,116": 10, + "216,120": -15, + "216,122": -10, + "216,171": 15, + "216,183": 10, + "216,192": -40, + "216,193": -40, + "216,194": -40, + "216,195": -40, + "216,196": -40, + "216,197": -40, + "216,198": -65, + "216,199": 5, + "216,210": 5, + "216,211": 5, + "216,212": 5, + "216,213": 5, + "216,214": 5, + "216,216": 5, + "216,221": -60, + "216,8211": 10, + "216,8212": 10, + "216,8230": -50, + "217,44": -40, + "217,46": -40, + "217,47": -60, + "217,65": -35, + "217,74": -35, + "217,88": -20, + "217,90": -10, + "217,120": -10, + "217,122": -10, + "217,192": -35, + "217,193": -35, + "217,194": -35, + "217,195": -35, + "217,196": -35, + "217,197": -35, + "217,198": -65, + "217,8230": -40, + "218,44": -40, + "218,46": -40, + "218,47": -60, + "218,65": -35, + "218,74": -35, + "218,88": -20, + "218,90": -10, + "218,120": -10, + "218,122": -10, + "218,192": -35, + "218,193": -35, + "218,194": -35, + "218,195": -35, + "218,196": -35, + "218,197": -35, + "218,198": -65, + "218,8230": -40, + "219,44": -40, + "219,46": -40, + "219,47": -60, + "219,65": -35, + "219,74": -35, + "219,88": -20, + "219,90": -10, + "219,120": -10, + "219,122": -10, + "219,192": -35, + "219,193": -35, + "219,194": -35, + "219,195": -35, + "219,196": -35, + "219,197": -35, + "219,198": -65, + "219,8230": -40, + "220,44": -40, + "220,46": -40, + "220,47": -60, + "220,65": -35, + "220,74": -35, + "220,88": -20, + "220,90": -10, + "220,120": -10, + "220,122": -10, + "220,192": -35, + "220,193": -35, + "220,194": -35, + "220,195": -35, + "220,196": -35, + "220,197": -35, + "220,198": -65, + "220,8230": -40, + "221,38": -70, + "221,44": -110, + "221,45": -120, + "221,46": -110, + "221,47": -120, + "221,58": -80, + "221,59": -80, + "221,63": -30, + "221,64": -120, + "221,65": -85, + "221,67": -60, + "221,71": -60, + "221,74": -115, + "221,79": -60, + "221,81": -60, + "221,83": -35, + "221,88": -10, + "221,90": -10, + "221,97": -100, + "221,98": -10, + "221,99": -105, + "221,100": -105, + "221,101": -105, + "221,102": -30, + "221,103": -100, + "221,104": -10, + "221,105": -10, + "221,106": -10, + "221,107": -10, + "221,108": -10, + "221,109": -85, + "221,110": -85, + "221,111": -105, + "221,112": -85, + "221,113": -105, + "221,114": -85, + "221,115": -100, + "221,116": -20, + "221,117": -85, + "221,118": -65, + "221,119": -65, + "221,120": -80, + "221,121": -65, + "221,122": -80, + "221,171": -120, + "221,183": -120, + "221,187": -90, + "221,192": -85, + "221,193": -85, + "221,194": -85, + "221,195": -85, + "221,196": -85, + "221,197": -85, + "221,198": -140, + "221,199": -60, + "221,210": -60, + "221,211": -60, + "221,212": -60, + "221,213": -60, + "221,214": -60, + "221,216": -60, + "221,223": -65, + "221,224": -80, + "221,225": -100, + "221,226": -100, + "221,227": -100, + "221,228": -75, + "221,229": -100, + "221,230": -100, + "221,231": -105, + "221,232": -85, + "221,233": -105, + "221,234": -105, + "221,235": -105, + "221,236": -10, + "221,237": -60, + "221,238": -10, + "221,239": -10, + "221,240": -75, + "221,241": -85, + "221,242": -105, + "221,243": -105, + "221,244": -105, + "221,245": -105, + "221,246": -90, + "221,248": -105, + "221,249": -85, + "221,250": -85, + "221,251": -85, + "221,252": -75, + "221,253": -65, + "221,254": -10, + "221,255": -65, + "221,8211": -120, + "221,8212": -120, + "221,8217": 20, + "221,8221": 20, + "221,8230": -110, + "222,44": -55, + "222,45": 20, + "222,46": -55, + "222,47": -65, + "222,64": 10, + "222,65": -50, + "222,67": 10, + "222,71": 10, + "222,74": -55, + "222,79": 10, + "222,81": 10, + "222,84": -60, + "222,86": -35, + "222,88": -60, + "222,89": -65, + "222,90": -50, + "222,116": 20, + "222,120": -15, + "222,171": 20, + "222,183": 20, + "222,187": 20, + "222,192": -50, + "222,193": -50, + "222,194": -50, + "222,195": -50, + "222,196": -50, + "222,197": -50, + "222,198": -80, + "222,199": 10, + "222,210": 10, + "222,211": 10, + "222,212": 10, + "222,213": 10, + "222,214": 10, + "222,216": 10, + "222,221": -65, + "222,8211": 20, + "222,8212": 20, + "222,8230": -55, + "223,45": 10, + "223,47": -10, + "223,99": 5, + "223,101": 5, + "223,111": 5, + "223,118": -5, + "223,120": -10, + "223,121": -5, + "223,122": -5, + "223,183": 10, + "223,231": 5, + "223,232": 5, + "223,233": 5, + "223,234": 5, + "223,235": 5, + "223,240": 5, + "223,242": 5, + "223,243": 5, + "223,244": 5, + "223,245": 5, + "223,246": 5, + "223,248": 5, + "223,253": -5, + "223,255": -5, + "223,8211": 10, + "223,8212": 10, + "224,44": 10, + "224,45": -10, + "224,46": 10, + "224,63": -50, + "224,118": -20, + "224,119": -10, + "224,121": -10, + "224,183": -10, + "224,253": -10, + "224,255": -10, + "224,8211": -10, + "224,8212": -10, + "224,8216": -35, + "224,8217": -40, + "224,8220": -35, + "224,8221": -40, + "224,8230": 10, + "225,44": 10, + "225,45": -10, + "225,46": 10, + "225,63": -50, + "225,118": -20, + "225,119": -10, + "225,121": -10, + "225,183": -10, + "225,253": -10, + "225,255": -10, + "225,8211": -10, + "225,8212": -10, + "225,8216": -35, + "225,8217": -40, + "225,8220": -35, + "225,8221": -40, + "225,8230": 10, + "226,44": 10, + "226,45": -10, + "226,46": 10, + "226,63": -50, + "226,118": -20, + "226,119": -10, + "226,121": -10, + "226,183": -10, + "226,253": -10, + "226,255": -10, + "226,8211": -10, + "226,8212": -10, + "226,8216": -35, + "226,8217": -40, + "226,8220": -35, + "226,8221": -40, + "226,8230": 10, + "227,44": 10, + "227,45": -10, + "227,46": 10, + "227,63": -50, + "227,118": -20, + "227,119": -10, + "227,121": -10, + "227,183": -10, + "227,253": -10, + "227,255": -10, + "227,8211": -10, + "227,8212": -10, + "227,8216": -35, + "227,8217": -40, + "227,8220": -35, + "227,8221": -40, + "227,8230": 10, + "228,44": 10, + "228,45": -10, + "228,46": 10, + "228,63": -50, + "228,118": -20, + "228,119": -10, + "228,121": -10, + "228,183": -10, + "228,253": -10, + "228,255": -10, + "228,8211": -10, + "228,8212": -10, + "228,8216": -35, + "228,8217": -40, + "228,8220": -35, + "228,8221": -40, + "228,8230": 10, + "229,44": 10, + "229,45": -10, + "229,46": 10, + "229,63": -50, + "229,118": -20, + "229,119": -10, + "229,121": -10, + "229,183": -10, + "229,253": -10, + "229,255": -10, + "229,8211": -10, + "229,8212": -10, + "229,8216": -35, + "229,8217": -40, + "229,8220": -35, + "229,8221": -40, + "229,8230": 10, + "230,45": 15, + "230,47": -15, + "230,63": -40, + "230,97": 10, + "230,118": -10, + "230,119": -5, + "230,120": -25, + "230,121": -10, + "230,122": -10, + "230,171": 20, + "230,183": 15, + "230,224": 10, + "230,225": 10, + "230,226": 10, + "230,227": 10, + "230,228": 10, + "230,229": 10, + "230,230": 10, + "230,253": -10, + "230,255": -10, + "230,8211": 15, + "230,8212": 15, + "230,8216": -25, + "230,8217": -25, + "230,8220": -25, + "230,8221": -25, + "231,44": 10, + "231,46": 10, + "231,97": 10, + "231,115": 10, + "231,120": -10, + "231,122": -5, + "231,224": 10, + "231,225": 10, + "231,226": 10, + "231,227": 10, + "231,228": 10, + "231,229": 10, + "231,230": 10, + "231,8230": 10, + "232,45": 15, + "232,47": -15, + "232,63": -40, + "232,97": 10, + "232,118": -10, + "232,119": -5, + "232,120": -25, + "232,121": -10, + "232,122": -10, + "232,171": 20, + "232,183": 15, + "232,224": 10, + "232,225": 10, + "232,226": 10, + "232,227": 10, + "232,228": 10, + "232,229": 10, + "232,230": 10, + "232,253": -10, + "232,255": -10, + "232,8211": 15, + "232,8212": 15, + "232,8216": -25, + "232,8217": -25, + "232,8220": -25, + "232,8221": -25, + "233,45": 15, + "233,47": -15, + "233,63": -40, + "233,97": 10, + "233,118": -10, + "233,119": -5, + "233,120": -25, + "233,121": -10, + "233,122": -10, + "233,171": 20, + "233,183": 15, + "233,224": 10, + "233,225": 10, + "233,226": 10, + "233,227": 10, + "233,228": 10, + "233,229": 10, + "233,230": 10, + "233,253": -10, + "233,255": -10, + "233,8211": 15, + "233,8212": 15, + "233,8216": -25, + "233,8217": -25, + "233,8220": -25, + "233,8221": -25, + "234,45": 15, + "234,47": -15, + "234,63": -40, + "234,97": 10, + "234,118": -10, + "234,119": -5, + "234,120": -25, + "234,121": -10, + "234,122": -10, + "234,171": 20, + "234,183": 15, + "234,224": 10, + "234,225": 10, + "234,226": 10, + "234,227": 10, + "234,228": 10, + "234,229": 10, + "234,230": 10, + "234,253": -10, + "234,255": -10, + "234,8211": 15, + "234,8212": 15, + "234,8216": -25, + "234,8217": -25, + "234,8220": -25, + "234,8221": -25, + "235,45": 15, + "235,47": -15, + "235,63": -40, + "235,97": 10, + "235,118": -10, + "235,119": -5, + "235,120": -25, + "235,121": -10, + "235,122": -10, + "235,171": 20, + "235,183": 15, + "235,224": 10, + "235,225": 10, + "235,226": 10, + "235,227": 10, + "235,228": 10, + "235,229": 10, + "235,230": 10, + "235,253": -10, + "235,255": -10, + "235,8211": 15, + "235,8212": 15, + "235,8216": -25, + "235,8217": -25, + "235,8220": -25, + "235,8221": -25, + "236,239": 30, + "237,41": 40, + "237,93": 40, + "237,116": 10, + "237,125": 40, + "237,239": 30, + "238,239": 30, + "239,98": 20, + "239,104": 20, + "239,107": 20, + "239,108": 20, + "239,239": 30, + "240,44": -25, + "240,45": 20, + "240,46": -25, + "240,47": -30, + "240,63": -25, + "240,64": 5, + "240,118": -13, + "240,119": -10, + "240,120": -30, + "240,121": -15, + "240,122": -15, + "240,171": 15, + "240,183": 20, + "240,253": -15, + "240,255": -15, + "240,8211": 20, + "240,8212": 20, + "240,8216": -10, + "240,8217": -10, + "240,8220": -10, + "240,8221": -10, + "240,8230": -25, + "241,63": -35, + "241,118": -10, + "241,119": -5, + "241,121": -5, + "241,253": -5, + "241,255": -5, + "241,8216": -25, + "241,8217": -25, + "241,8220": -25, + "241,8221": -25, + "242,44": -25, + "242,45": 20, + "242,46": -25, + "242,47": -30, + "242,63": -55, + "242,64": 5, + "242,118": -13, + "242,119": -10, + "242,120": -30, + "242,121": -15, + "242,122": -15, + "242,171": 15, + "242,183": 20, + "242,253": -15, + "242,255": -15, + "242,8211": 20, + "242,8212": 20, + "242,8216": -35, + "242,8217": -30, + "242,8220": -35, + "242,8221": -30, + "242,8230": -25, + "243,44": -25, + "243,45": 20, + "243,46": -25, + "243,47": -30, + "243,63": -55, + "243,64": 5, + "243,118": -13, + "243,119": -10, + "243,120": -30, + "243,121": -15, + "243,122": -15, + "243,171": 15, + "243,183": 20, + "243,253": -15, + "243,255": -15, + "243,8211": 20, + "243,8212": 20, + "243,8216": -35, + "243,8217": -30, + "243,8220": -35, + "243,8221": -30, + "243,8230": -25, + "244,44": -25, + "244,45": 20, + "244,46": -25, + "244,47": -30, + "244,63": -55, + "244,64": 5, + "244,118": -13, + "244,119": -10, + "244,120": -30, + "244,121": -15, + "244,122": -15, + "244,171": 15, + "244,183": 20, + "244,253": -15, + "244,255": -15, + "244,8211": 20, + "244,8212": 20, + "244,8216": -35, + "244,8217": -30, + "244,8220": -35, + "244,8221": -30, + "244,8230": -25, + "245,44": -25, + "245,45": 20, + "245,46": -25, + "245,47": -30, + "245,63": -55, + "245,64": 5, + "245,118": -13, + "245,119": -10, + "245,120": -30, + "245,121": -15, + "245,122": -15, + "245,171": 15, + "245,183": 20, + "245,253": -15, + "245,255": -15, + "245,8211": 20, + "245,8212": 20, + "245,8216": -35, + "245,8217": -30, + "245,8220": -35, + "245,8221": -30, + "245,8230": -25, + "246,44": -25, + "246,45": 20, + "246,46": -25, + "246,47": -30, + "246,63": -55, + "246,64": 5, + "246,118": -13, + "246,119": -10, + "246,120": -30, + "246,121": -15, + "246,122": -15, + "246,171": 15, + "246,183": 20, + "246,253": -15, + "246,255": -15, + "246,8211": 20, + "246,8212": 20, + "246,8216": -35, + "246,8217": -30, + "246,8220": -35, + "246,8221": -30, + "246,8230": -25, + "247,49": -35, + "247,50": -35, + "247,51": -45, + "247,55": -50, + "247,56": -20, + "247,57": -15, + "248,44": -25, + "248,45": 20, + "248,46": -25, + "248,47": -30, + "248,63": -55, + "248,64": 5, + "248,116": 15, + "248,118": -13, + "248,119": -10, + "248,120": -30, + "248,121": -15, + "248,122": -15, + "248,171": 15, + "248,183": 20, + "248,253": -15, + "248,255": -15, + "248,8211": 20, + "248,8212": 20, + "248,8216": -35, + "248,8217": -30, + "248,8220": -35, + "248,8221": -30, + "248,8230": -25, + "249,8216": -10, + "249,8220": -10, + "250,8216": -10, + "250,8220": -10, + "251,8216": -10, + "251,8220": -10, + "252,8216": -10, + "252,8220": -10, + "253,38": -35, + "253,44": -95, + "253,45": -30, + "253,46": -95, + "253,47": -55, + "253,63": 30, + "253,64": -30, + "253,97": -25, + "253,99": -20, + "253,100": -16, + "253,101": -20, + "253,102": 20, + "253,103": -16, + "253,111": -20, + "253,113": -16, + "253,115": -15, + "253,116": 15, + "253,122": -5, + "253,171": -20, + "253,183": -30, + "253,224": -25, + "253,225": -25, + "253,226": -25, + "253,227": -25, + "253,228": -25, + "253,229": -25, + "253,230": -25, + "253,231": -20, + "253,232": -20, + "253,233": -20, + "253,234": -20, + "253,235": -20, + "253,240": -20, + "253,242": -20, + "253,243": -20, + "253,244": -20, + "253,245": -20, + "253,246": -20, + "253,248": -20, + "253,8211": -30, + "253,8212": -30, + "253,8216": 20, + "253,8217": 20, + "253,8220": 20, + "253,8221": 20, + "253,8230": -95, + "254,44": -25, + "254,45": 20, + "254,46": -25, + "254,47": -30, + "254,63": -45, + "254,64": 5, + "254,118": -12, + "254,119": -10, + "254,120": -25, + "254,121": -10, + "254,122": -15, + "254,171": 20, + "254,183": 20, + "254,253": -10, + "254,255": -10, + "254,8211": 20, + "254,8212": 20, + "254,8216": -25, + "254,8217": -25, + "254,8220": -25, + "254,8221": -25, + "254,8230": -25, + "255,38": -35, + "255,44": -95, + "255,45": -30, + "255,46": -95, + "255,47": -55, + "255,63": 30, + "255,64": -30, + "255,97": -25, + "255,99": -20, + "255,100": -16, + "255,101": -20, + "255,102": 20, + "255,103": -16, + "255,111": -20, + "255,113": -16, + "255,115": -15, + "255,116": 15, + "255,122": -5, + "255,171": -20, + "255,183": -30, + "255,224": -25, + "255,225": -25, + "255,226": -25, + "255,227": -25, + "255,228": -25, + "255,229": -25, + "255,230": -25, + "255,231": -20, + "255,232": -20, + "255,233": -20, + "255,234": -20, + "255,235": -20, + "255,240": -20, + "255,242": -20, + "255,243": -20, + "255,244": -20, + "255,245": -20, + "255,246": -20, + "255,248": -20, + "255,8211": -30, + "255,8212": -30, + "255,8216": 20, + "255,8217": 20, + "255,8220": 20, + "255,8221": 20, + "255,8230": -95, + "8211,48": 10, + "8211,49": -50, + "8211,50": -25, + "8211,52": 20, + "8211,54": 10, + "8211,55": -80, + "8211,57": -10, + "8211,65": -40, + "8211,67": 10, + "8211,71": 10, + "8211,74": -30, + "8211,79": 10, + "8211,81": 10, + "8211,84": -100, + "8211,86": -80, + "8211,87": -50, + "8211,88": -90, + "8211,89": -120, + "8211,90": -30, + "8211,99": 20, + "8211,100": 20, + "8211,101": 20, + "8211,103": 20, + "8211,111": 20, + "8211,113": 20, + "8211,118": -15, + "8211,119": -5, + "8211,120": -50, + "8211,121": -20, + "8211,122": -30, + "8211,192": -40, + "8211,193": -40, + "8211,194": -40, + "8211,195": -40, + "8211,196": -40, + "8211,197": -40, + "8211,198": -75, + "8211,199": 10, + "8211,208": 20, + "8211,210": 10, + "8211,211": 10, + "8211,212": 10, + "8211,213": 10, + "8211,214": 10, + "8211,216": 10, + "8211,221": -120, + "8211,231": 20, + "8211,232": 20, + "8211,233": 20, + "8211,234": 20, + "8211,235": 20, + "8211,240": 20, + "8211,242": 20, + "8211,243": 20, + "8211,244": 20, + "8211,245": 20, + "8211,246": 20, + "8211,248": 20, + "8211,253": -20, + "8211,255": -20, + "8212,48": 10, + "8212,49": -50, + "8212,50": -25, + "8212,52": 20, + "8212,54": 10, + "8212,55": -80, + "8212,57": -10, + "8212,65": -40, + "8212,67": 10, + "8212,71": 10, + "8212,74": -30, + "8212,79": 10, + "8212,81": 10, + "8212,84": -100, + "8212,86": -80, + "8212,87": -50, + "8212,88": -90, + "8212,89": -120, + "8212,90": -30, + "8212,99": 20, + "8212,100": 20, + "8212,101": 20, + "8212,103": 20, + "8212,111": 20, + "8212,113": 20, + "8212,118": -15, + "8212,119": -5, + "8212,120": -50, + "8212,121": -20, + "8212,122": -30, + "8212,192": -40, + "8212,193": -40, + "8212,194": -40, + "8212,195": -40, + "8212,196": -40, + "8212,197": -40, + "8212,198": -75, + "8212,199": 10, + "8212,208": 20, + "8212,210": 10, + "8212,211": 10, + "8212,212": 10, + "8212,213": 10, + "8212,214": 10, + "8212,216": 10, + "8212,221": -120, + "8212,231": 20, + "8212,232": 20, + "8212,233": 20, + "8212,234": 20, + "8212,235": 20, + "8212,240": 20, + "8212,242": 20, + "8212,243": 20, + "8212,244": 20, + "8212,245": 20, + "8212,246": 20, + "8212,248": 20, + "8212,253": -20, + "8212,255": -20, + "8216,44": -60, + "8216,46": -60, + "8216,49": 20, + "8216,50": 10, + "8216,52": -55, + "8216,53": -15, + "8216,55": 30, + "8216,56": -15, + "8216,65": -75, + "8216,74": -100, + "8216,84": 20, + "8216,86": 20, + "8216,87": 20, + "8216,88": -10, + "8216,89": 20, + "8216,97": -20, + "8216,99": -35, + "8216,100": -35, + "8216,101": -35, + "8216,102": 30, + "8216,103": -35, + "8216,109": -10, + "8216,110": -10, + "8216,111": -35, + "8216,112": -10, + "8216,113": -35, + "8216,114": -10, + "8216,115": -25, + "8216,116": 40, + "8216,118": 20, + "8216,119": 20, + "8216,121": 20, + "8216,191": -90, + "8216,192": -75, + "8216,193": -75, + "8216,194": -75, + "8216,195": -75, + "8216,196": -75, + "8216,197": -75, + "8216,198": -150, + "8216,221": 20, + "8216,224": -20, + "8216,225": -20, + "8216,226": -20, + "8216,227": -20, + "8216,228": -20, + "8216,229": -20, + "8216,230": -20, + "8216,231": -35, + "8216,232": -35, + "8216,233": -35, + "8216,234": -35, + "8216,235": -35, + "8216,240": -35, + "8216,241": -10, + "8216,242": -35, + "8216,243": -35, + "8216,244": -35, + "8216,245": -35, + "8216,246": -35, + "8216,248": -35, + "8216,253": 20, + "8216,255": 20, + "8216,8230": -60, + "8217,44": -60, + "8217,46": -60, + "8217,48": -25, + "8217,50": -15, + "8217,51": -10, + "8217,52": -95, + "8217,53": -35, + "8217,54": -30, + "8217,55": 30, + "8217,56": -40, + "8217,57": -10, + "8217,65": -115, + "8217,67": -25, + "8217,71": -25, + "8217,74": -110, + "8217,79": -25, + "8217,81": -25, + "8217,84": 20, + "8217,86": 20, + "8217,87": 20, + "8217,88": -10, + "8217,89": 20, + "8217,97": -60, + "8217,99": -70, + "8217,100": -65, + "8217,101": -70, + "8217,102": 10, + "8217,103": -65, + "8217,109": -30, + "8217,110": -30, + "8217,111": -70, + "8217,112": -30, + "8217,113": -65, + "8217,114": -30, + "8217,115": -55, + "8217,116": 20, + "8217,117": -30, + "8217,120": -10, + "8217,122": -20, + "8217,191": -105, + "8217,192": -115, + "8217,193": -115, + "8217,194": -115, + "8217,195": -115, + "8217,196": -115, + "8217,197": -115, + "8217,198": -185, + "8217,199": -25, + "8217,210": -25, + "8217,211": -25, + "8217,212": -25, + "8217,213": -25, + "8217,214": -25, + "8217,216": -25, + "8217,221": 20, + "8217,224": -60, + "8217,225": -60, + "8217,226": -60, + "8217,227": -60, + "8217,228": -60, + "8217,229": -60, + "8217,230": -60, + "8217,231": -70, + "8217,232": -70, + "8217,233": -70, + "8217,234": -70, + "8217,235": -70, + "8217,240": -40, + "8217,241": -30, + "8217,242": -70, + "8217,243": -70, + "8217,244": -70, + "8217,245": -70, + "8217,246": -70, + "8217,248": -70, + "8217,249": -30, + "8217,250": -30, + "8217,251": -30, + "8217,252": -30, + "8217,8230": -60, + "8220,44": -60, + "8220,46": -60, + "8220,49": 20, + "8220,50": 10, + "8220,52": -55, + "8220,53": -15, + "8220,55": 30, + "8220,56": -15, + "8220,65": -75, + "8220,74": -100, + "8220,84": 20, + "8220,86": 20, + "8220,87": 20, + "8220,88": -10, + "8220,89": 20, + "8220,97": -20, + "8220,99": -35, + "8220,100": -35, + "8220,101": -35, + "8220,102": 30, + "8220,103": -35, + "8220,109": -10, + "8220,110": -10, + "8220,111": -35, + "8220,112": -10, + "8220,113": -35, + "8220,114": -10, + "8220,115": -25, + "8220,116": 40, + "8220,118": 20, + "8220,119": 20, + "8220,121": 20, + "8220,191": -90, + "8220,192": -75, + "8220,193": -75, + "8220,194": -75, + "8220,195": -75, + "8220,196": -75, + "8220,197": -75, + "8220,198": -150, + "8220,221": 20, + "8220,224": -20, + "8220,225": -20, + "8220,226": -20, + "8220,227": -20, + "8220,228": -20, + "8220,229": -20, + "8220,230": -20, + "8220,231": -35, + "8220,232": -35, + "8220,233": -35, + "8220,234": -35, + "8220,235": -35, + "8220,240": -35, + "8220,241": -10, + "8220,242": -35, + "8220,243": -35, + "8220,244": -35, + "8220,245": -35, + "8220,246": -35, + "8220,248": -35, + "8220,253": 20, + "8220,255": 20, + "8220,8230": -60, + "8221,44": -60, + "8221,46": -60, + "8221,48": -25, + "8221,50": -15, + "8221,51": -10, + "8221,52": -95, + "8221,53": -35, + "8221,54": -30, + "8221,55": 30, + "8221,56": -40, + "8221,57": -10, + "8221,65": -115, + "8221,67": -25, + "8221,71": -25, + "8221,74": -110, + "8221,79": -25, + "8221,81": -25, + "8221,84": 20, + "8221,86": 20, + "8221,87": 20, + "8221,88": -10, + "8221,89": 20, + "8221,97": -60, + "8221,99": -70, + "8221,100": -65, + "8221,101": -70, + "8221,102": 10, + "8221,103": -65, + "8221,109": -30, + "8221,110": -30, + "8221,111": -70, + "8221,112": -30, + "8221,113": -65, + "8221,114": -30, + "8221,115": -55, + "8221,116": 20, + "8221,117": -30, + "8221,120": -10, + "8221,122": -20, + "8221,191": -105, + "8221,192": -115, + "8221,193": -115, + "8221,194": -115, + "8221,195": -115, + "8221,196": -115, + "8221,197": -115, + "8221,198": -185, + "8221,199": -25, + "8221,210": -25, + "8221,211": -25, + "8221,212": -25, + "8221,213": -25, + "8221,214": -25, + "8221,216": -25, + "8221,221": 20, + "8221,224": -60, + "8221,225": -60, + "8221,226": -60, + "8221,227": -60, + "8221,228": -60, + "8221,229": -60, + "8221,230": -60, + "8221,231": -70, + "8221,232": -70, + "8221,233": -70, + "8221,234": -70, + "8221,235": -70, + "8221,240": -40, + "8221,241": -30, + "8221,242": -70, + "8221,243": -70, + "8221,244": -70, + "8221,245": -70, + "8221,246": -70, + "8221,248": -70, + "8221,249": -30, + "8221,250": -30, + "8221,251": -30, + "8221,252": -30, + "8221,8230": -60, + "8230,48": -45, + "8230,49": -80, + "8230,50": 20, + "8230,51": -15, + "8230,52": -10, + "8230,53": -15, + "8230,54": -55, + "8230,55": -60, + "8230,56": -20, + "8230,57": -20, + "8230,65": 20, + "8230,67": -50, + "8230,71": -50, + "8230,74": 30, + "8230,79": -50, + "8230,81": -50, + "8230,83": 20, + "8230,84": -100, + "8230,85": -40, + "8230,86": -100, + "8230,87": -60, + "8230,89": -110, + "8230,90": 20, + "8230,99": -25, + "8230,100": -25, + "8230,101": -25, + "8230,102": -15, + "8230,103": -20, + "8230,111": -25, + "8230,113": -25, + "8230,116": -30, + "8230,117": -30, + "8230,118": -80, + "8230,119": -55, + "8230,121": -75, + "8230,192": 20, + "8230,193": 20, + "8230,194": 20, + "8230,195": 20, + "8230,196": 20, + "8230,197": 20, + "8230,199": -50, + "8230,210": -50, + "8230,211": -50, + "8230,212": -50, + "8230,213": -50, + "8230,214": -50, + "8230,216": -50, + "8230,217": -40, + "8230,218": -40, + "8230,219": -40, + "8230,220": -40, + "8230,221": -110, + "8230,231": -25, + "8230,232": -25, + "8230,233": -25, + "8230,234": -25, + "8230,235": -25, + "8230,240": -25, + "8230,242": -25, + "8230,243": -25, + "8230,244": -25, + "8230,245": -25, + "8230,246": -25, + "8230,248": -25, + "8230,249": -30, + "8230,250": -30, + "8230,251": -30, + "8230,252": -30, + "8230,253": -75, + "8230,255": -75, + "8230,8216": -60, + "8230,8217": -60, + "8230,8220": -60, + "8230,8221": -60, + "8722,49": -35, + "8722,50": -35, + "8722,51": -45, + "8722,55": -50, + "8722,56": -20, + "8722,57": -15, + "8364,48": -20, + "8364,50": -10, + "8364,54": -20, + "8364,56": -5, + "8364,57": -5 + }, + "defaultAdvance": 632 +} diff --git a/packages/charts2/src/index.ts b/packages/charts2/src/index.ts new file mode 100644 index 00000000000..b78185a5cdb --- /dev/null +++ b/packages/charts2/src/index.ts @@ -0,0 +1,2 @@ +export * from "./core/index.ts" +export * from "./react/index.ts" diff --git a/packages/charts2/src/react/Chart.test.tsx b/packages/charts2/src/react/Chart.test.tsx new file mode 100644 index 00000000000..4c79975e7ed --- /dev/null +++ b/packages/charts2/src/react/Chart.test.tsx @@ -0,0 +1,139 @@ +/** + * Chart interaction tests (happy-dom, spec 26 §3). The load-bearing + * assertion: hover and focus apply emphasis on the EXISTING scene and never + * call layoutChart — re-layout happens only for definition/dataset/view/size + * changes (spec 07 §3, spec 28 §1). + */ + +import { fireEvent, render } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { loadFixtureDataset } from "../fixtures/index.ts" +import { parseDefinition } from "../core/definition/schema.ts" +import { layoutChart } from "../core/layout/layoutChart.ts" +import { buildCanadaTheme } from "../core/theme/themes.ts" +import type { ChartDefinition } from "../core/types.ts" +import { Chart } from "./Chart.tsx" + +vi.mock("../core/layout/layoutChart.ts", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, layoutChart: vi.fn(actual.layoutChart) } +}) + +const layoutSpy = vi.mocked(layoutChart) + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +const { dataset } = loadFixtureDataset("provincial-budgets") + +beforeEach(() => { + layoutSpy.mockClear() +}) + +describe("Chart", () => { + it("renders a chart scene from a fixture definition", () => { + const { container } = render( + , + ) + const svg = container.querySelector("svg") + expect(svg).not.toBeNull() + expect(svg?.getAttribute("role")).toBe("img") + expect(svg?.getAttribute("width")).toBe("850") + expect(container.querySelectorAll("path").length).toBeGreaterThan(0) + expect(container.querySelectorAll("[data-bc-hit]").length).toBeGreaterThan(0) + }) + + it("uses the 850×600 default size before the container is measured", () => { + const { container } = render( + , + ) + const svg = container.querySelector("svg") + expect(svg?.getAttribute("viewBox")).toBe("0 0 850 600") + }) + + it("hover shows the tooltip via the render prop and never relayouts", () => { + const seen: string[] = [] + const { container } = render( + { + seen.push(tooltip.title) + return {tooltip.title} + }} + />, + ) + const layoutCallsAfterMount = layoutSpy.mock.calls.length + expect(layoutCallsAfterMount).toBeGreaterThan(0) + + const strips = [...container.querySelectorAll('[data-bc-hit^="time:"]')] + expect(strips.length).toBeGreaterThan(1) + + fireEvent.pointerMove(strips[0], { clientX: 100, clientY: 100 }) + expect(seen.length).toBeGreaterThan(0) + expect(container.querySelector('[data-testid="tip"]')).not.toBeNull() + + fireEvent.pointerMove(strips[1], { clientX: 200, clientY: 100 }) + // Hover NEVER calls layoutChart (spec 07 §3). + expect(layoutSpy.mock.calls.length).toBe(layoutCallsAfterMount) + }) + + it("hover keeps the rendered scene referentially stable", () => { + const { container } = render( + {tooltip.title}} + />, + ) + const pathBefore = container.querySelector("path") + const strip = container.querySelector('[data-bc-hit^="time:"]') + expect(strip).not.toBeNull() + fireEvent.pointerMove(strip as Element, { clientX: 120, clientY: 90 }) + // Same scene, same React elements → the mark DOM nodes are untouched. + expect(container.querySelector("path")).toBe(pathBefore) + const results = layoutSpy.mock.results + const scenes = new Set(results.map((r) => r.value)) + expect(scenes.size).toBe(1) + }) + + it("click toggles focus emphasis and Escape clears it — without relayout", () => { + const { container } = render( + , + ) + const layoutCallsAfterMount = layoutSpy.mock.calls.length + const dim = buildCanadaTheme.palette.dimOpacity.toString() + + const target = container.querySelector('[data-bc-hit="series:Ontario"]') + expect(target).not.toBeNull() + fireEvent.click(target as Element) + + // Other series are dimmed by the theme dim factor; Ontario is not. + const dimmed = [...container.querySelectorAll(`[opacity="${dim}"]`)] + expect(dimmed.length).toBeGreaterThan(0) + + fireEvent.keyDown(window, { key: "Escape" }) + expect(container.querySelectorAll(`[opacity="${dim}"]`).length).toBe(0) + + // Focus and escape NEVER call layoutChart. + expect(layoutSpy.mock.calls.length).toBe(layoutCallsAfterMount) + }) +}) diff --git a/packages/charts2/src/react/Chart.tsx b/packages/charts2/src/react/Chart.tsx new file mode 100644 index 00000000000..70a9ecdd0c4 --- /dev/null +++ b/packages/charts2/src/react/Chart.tsx @@ -0,0 +1,212 @@ +/** + * Chart — the interactive component: definition + dataset → SceneSVG with + * hover/focus emphasis, tooltips (via render prop), URL state, and + * container-driven sizing. + * + * Re-layout happens ONLY when definition/dataset/view/size change. Hover and + * focus apply styling through seriesKey emphasis on the already-built scene — + * they NEVER call layoutChart (spec 07 §3, spec 28 §1). Focus round-trips + * through ViewState (`focus=` in the URL) but is excluded from the layout + * inputs, so toggling it cannot invalidate the scene memo. + */ + +import { useEffect, useMemo, useReducer, useRef, useState } from "react" +import type { ReactNode } from "react" + +import { layoutChart } from "../core/layout/layoutChart.ts" +import type { HitTarget, TooltipModel, Vec2 } from "../core/scene/nodes.ts" +import { getTheme } from "../core/theme/registry.ts" +import type { Theme } from "../core/theme/types.ts" +import type { ChartDefinition, Dataset, ViewState } from "../core/types.ts" +import { emphasisFor, emphasisReducer, type EmphasisState } from "./interaction/emphasisReducer.ts" +import { useUrlState } from "./interaction/useUrlState.ts" +import { SceneSVG } from "./SceneSVG.tsx" + +export interface RenderTooltipArgs { + tooltip: TooltipModel + /** Anchor position in scene coordinates, clamped inside the plot area. */ + x: number + y: number +} + +export interface ChartProps { + definition: ChartDefinition + dataset: Dataset + /** Defaults to the registry lookup of definition.theme. */ + theme?: Theme + initialView?: ViewState + /** Sync ViewState with window.location.search (history.replaceState). */ + syncUrl?: boolean + /** Fixed size; when omitted the container is measured (ResizeObserver). */ + width?: number + height?: number + /** Tooltip render prop — the chrome Tooltip component plugs in here. */ + renderTooltip?: (args: RenderTooltipArgs) => ReactNode +} + +/** SSR/first-paint size before the container has been measured. */ +const DEFAULT_SIZE = { width: 850, height: 600 } + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)) +} + +export function Chart({ + definition, + dataset, + theme, + initialView, + syncUrl = false, + width, + height, + renderTooltip, +}: ChartProps): ReactNode { + const grain = dataset.manifest.timeGrain + + // --- View state (URL-synced when requested) ---------------------------- + const [view, setView] = useUrlState(grain, { initial: initialView, enabled: syncUrl }) + + // --- Container-driven sizing (SSR-safe) --------------------------------- + const containerRef = useRef(null) + const [measured, setMeasured] = useState(DEFAULT_SIZE) + const chartWidth = width ?? measured.width + const chartHeight = height ?? measured.height + + useEffect(() => { + if (width !== undefined && height !== undefined) return + const element = containerRef.current + if (element === null) return + const update = () => { + const bounds = element.getBoundingClientRect() + setMeasured((prev) => { + const next = { + width: bounds.width > 0 ? bounds.width : DEFAULT_SIZE.width, + height: bounds.height > 0 ? bounds.height : DEFAULT_SIZE.height, + } + const same = + Math.abs(next.width - prev.width) < 1 && Math.abs(next.height - prev.height) < 1 + return same ? prev : next + }) + } + update() + if (typeof ResizeObserver === "undefined") return + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, [width, height]) + + // --- Emphasis (hover/focus) — render-time styling, never layout --------- + const [emphasisState, dispatch] = useReducer( + emphasisReducer, + null, + (): EmphasisState => ({ + hover: null, + focus: new Set(view.focus ?? definition.focusedSeries ?? []), + }), + ) + + // Persist focus into ViewState (URL `focus=`). Focus is deliberately + // excluded from layoutView below, so this never causes a relayout. + useEffect(() => { + setView((prev) => { + const keys = [...emphasisState.focus] + const prevKeys = prev.focus ?? [] + if (keys.length === prevKeys.length && keys.every((key, i) => key === prevKeys[i])) { + return prev + } + if (keys.length === 0) { + const { focus: _focus, ...rest } = prev + return rest + } + return { ...prev, focus: keys } + }) + }, [emphasisState.focus, setView]) + + // Escape clears focus (spec 07 §3). + useEffect(() => { + if (typeof window === "undefined") return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") dispatch({ type: "escape" }) + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, []) + + // --- Layout — the ONLY place layoutChart is called ---------------------- + // Identity is stable across hover/focus: deps are the layout-relevant view + // fields, not the view object (whose identity changes when focus is written). + const layoutView = useMemo(() => { + const { focus: _focus, ...rest } = view + return rest + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view.tab, view.time, view.entities, view.yScale, view.stackMode, view.facet, view.tableSort, view.tableScope]) + + const scene = useMemo( + () => + layoutChart({ + definition, + dataset, + view: layoutView, + ...(theme !== undefined ? { theme } : {}), + size: { width: chartWidth, height: chartHeight }, + }), + [definition, dataset, theme, layoutView, chartWidth, chartHeight], + ) + + const resolvedTheme = theme ?? getTheme(definition.theme).theme + const idPrefix = definition.slug ?? "chart" + + // --- Tooltip ------------------------------------------------------------ + const [tooltipState, setTooltipState] = useState(null) + + const handleHover = (target: HitTarget, point: Vec2) => { + if (target.kind === "series") dispatch({ type: "hover-series", key: target.seriesKey }) + const plot = scene.plotArea + const x = clamp(target.kind === "time" ? target.x : point.x, plot.x, plot.x + plot.width) + const y = clamp(point.y, plot.y, plot.y + plot.height) + setTooltipState((prev) => + prev !== null && prev.tooltip === target.tooltip && prev.x === x && prev.y === y + ? prev + : { tooltip: target.tooltip, x, y }, + ) + } + + const handleLeave = () => { + dispatch({ type: "hover-clear" }) + setTooltipState(null) + } + + const handleActivate = (target: HitTarget) => { + if (target.kind === "series") dispatch({ type: "toggle-focus", key: target.seriesKey }) + } + + return ( +
+ + {tooltipState !== null && renderTooltip !== undefined ? ( +
+ {renderTooltip(tooltipState)} +
+ ) : null} +
+ ) +} diff --git a/packages/charts2/src/react/SceneSVG.test.tsx b/packages/charts2/src/react/SceneSVG.test.tsx new file mode 100644 index 00000000000..d054d4a3fcd --- /dev/null +++ b/packages/charts2/src/react/SceneSVG.test.tsx @@ -0,0 +1,271 @@ +/** + * SceneSVG renderer tests (spec 26 §3, spec 28 §2): every node kind renders, + * output is byte-deterministic under renderToStaticMarkup, numbers are plain + * decimals (no exponents, no -0), ids never collide across idPrefixes, and + * dimming touches only seriesKey-owning nodes. + */ + +import { renderToStaticMarkup } from "react-dom/server" +import { describe, expect, it } from "vitest" + +import type { ChartScene, SceneNode } from "../core/scene/nodes.ts" +import type { FontSpec } from "../core/text/measurer.ts" +import { SceneSVG } from "./SceneSVG.tsx" + +const font: FontSpec = { family: "body", sizePx: 12, weight: 400 } + +function minimalScene(): ChartScene { + const nodes: SceneNode[] = [ + { + kind: "group", + key: "plot", + role: "mark", + clip: { x: 10, y: 10, width: 180, height: 80 }, + children: [ + { + kind: "line", + key: "series/alpha/line", + seriesKey: "alpha", + role: "mark", + // Two segments encode a data gap → one d with two subpaths. + segments: [ + [ + { x: 10, y: 80 }, + { x: 50, y: 40.5 }, + ], + [ + { x: 90, y: 30 }, + { x: 130, y: 20 }, + ], + ], + style: { stroke: "#112233", strokeWidth: 2 }, + }, + { + kind: "area", + key: "series/beta/area", + seriesKey: "beta", + role: "mark", + upper: [ + { x: 10, y: 40 }, + { x: 130, y: 30 }, + ], + lower: [ + { x: 10, y: 80 }, + { x: 130, y: 80 }, + ], + style: { fill: "#334455", opacity: 0.8 }, + }, + { + kind: "rect", + key: "series/beta/bar", + seriesKey: "beta", + role: "mark", + // -0.0001 must serialize as "0", never "-0". + rect: { x: 20, y: -0.0001, width: 10, height: 30 }, + style: { fill: "#556677" }, + }, + { + kind: "point", + key: "series/alpha/point", + seriesKey: "alpha", + role: "mark", + center: { x: 50, y: 40.5 }, + radius: 3, + style: { fill: "#112233" }, + }, + ], + }, + { + kind: "image", + key: "chrome/logo/build-canada-square", + role: "chrome", + href: "data:image/svg+xml;base64,PHN2Zy8+", + rect: { x: 160, y: 5, width: 24, height: 24 }, + preserveAspectRatio: "xMidYMid meet", + }, + { + kind: "rule", + key: "axis/x/domain", + role: "axis", + from: { x: 10, y: 90 }, + to: { x: 190, y: 90 }, + style: { stroke: "#000000", strokeWidth: 1, dash: [4, 2] }, + }, + { + kind: "text", + key: "chrome/title", + role: "chrome", + // 1e-7 must serialize as "0", never exponent notation. + position: { x: 100, y: 0.0000001 }, + text: "Hello chart", + font, + anchor: "middle", + colour: "#101010", + measured: { width: 60, ascent: 9, descent: 3 }, + }, + ] + return { + width: 200, + height: 100, + background: "#fffdf5", + plotArea: { x: 10, y: 10, width: 180, height: 80 }, + nodes, + series: [], + hover: { targets: [] }, + diagnostics: [], + } +} + +const emptyTooltip = { title: "t", rows: [], footers: [] } + +function sceneWithTargets(): ChartScene { + const scene = minimalScene() + return { + ...scene, + hover: { + targets: [ + { kind: "time", time: 2019, x: 50, tooltip: emptyTooltip }, + { kind: "time", time: 2020, x: 150, tooltip: emptyTooltip }, + { + kind: "series", + seriesKey: "beta", + shape: { x: 20, y: 0, width: 10, height: 30 }, + tooltip: emptyTooltip, + }, + ], + timeGuide: { y0: 10, y1: 90 }, + }, + } +} + +describe("SceneSVG", () => { + it("renders every scene node kind into the expected SVG elements", () => { + const markup = renderToStaticMarkup() + expect(markup).toContain(" { + const markup = renderToStaticMarkup() + expect(markup).not.toMatch(/\d[eE][+-]\d/) + expect(markup).not.toMatch(/(? { + const scene = minimalScene() + const first = renderToStaticMarkup() + const second = renderToStaticMarkup() + expect(second).toBe(first) + }) + + it("two side-by-side idPrefixes share no element ids", () => { + const scene = minimalScene() + const left = renderToStaticMarkup() + const right = renderToStaticMarkup() + const ids = (markup: string) => [...markup.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]) + const leftIds = ids(left) + const rightIds = new Set(ids(right)) + expect(leftIds.length).toBeGreaterThan(0) + for (const id of leftIds) expect(rightIds.has(id)).toBe(false) + }) + + it("dims only nodes carrying a seriesKey outside the emphasized set", () => { + const markup = renderToStaticMarkup( + , + ) + // beta area: 0.8 base × 0.4 dim = 0.32; beta bar: 1 × 0.4. + expect(markup).toContain('opacity="0.32"') + expect(markup).toContain('opacity="0.4"') + // alpha line keeps full opacity (no opacity attribute on its path). + expect(markup).toContain('d="M10,80L50,40.5M90,30L130,20" fill="none" stroke="#112233" stroke-width="2">') + // Non-series chrome (rule, text) is unaffected. + expect(markup).toMatch(/]*opacity)/) + expect(markup).toMatch(/]*opacity)/) + }) + + it("is a pass-through when emphasis is idle", () => { + const scene = minimalScene() + const idle = renderToStaticMarkup( + , + ) + const bare = renderToStaticMarkup() + expect(idle).toBe(bare) + }) + + it("renders pattern defs and a hatch overlay for patternId fills", () => { + const scene = minimalScene() + const projected: SceneNode = { + kind: "rect", + key: "series/beta/bar/projected", + seriesKey: "beta", + role: "mark", + rect: { x: 40, y: 10, width: 10, height: 20 }, + style: { fill: "#556677", patternId: "projection", opacity: 0.85 }, + } + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain("") + expect(markup).toContain(' { + const markup = renderToStaticMarkup() + expect(markup).not.toContain("data-bc-hit") + expect(markup).not.toContain("pointer-events") + }) + + it("interactive mode builds time strips between midpoints and series hit rects", () => { + const markup = renderToStaticMarkup( + , + ) + // Marks are inert; hits live on the overlay only. + expect(markup).toContain("pointer-events:none") + // Strip 1: plot left edge (10) to midpoint (100); strip 2: 100 → 190. + expect(markup).toContain( + '', + ) + expect(markup).toContain( + '', + ) + // Series target covers its precomputed shape. + expect(markup).toContain( + '', + ) + }) +}) diff --git a/packages/charts2/src/react/SceneSVG.tsx b/packages/charts2/src/react/SceneSVG.tsx new file mode 100644 index 00000000000..f38d60a1eeb --- /dev/null +++ b/packages/charts2/src/react/SceneSVG.tsx @@ -0,0 +1,384 @@ +/** + * SceneSVG — THE single scene→SVG renderer (spec 28 §1). + * + * Pure presentational: (ChartScene, idPrefix, emphasis) → . Works in the + * browser and under renderToStaticMarkup — zero hooks, zero environment reads. + * Determinism (spec 28 §2): no useId, element ids derive from idPrefix + + * stable node keys, every numeric attribute is formatted through fmt() + * (round2, plain decimals, no exponents, no -0). + * + * Interactivity: when `interactive`, marks render with pointer-events: none + * and a transparent hit layer built from scene.hover.targets carries ALL + * pointer handlers — hit logic is purely the precomputed targets, hover + * never relayouts (spec 07 §3). + */ + +import { line as d3Line } from "d3-shape" +import type { PointerEvent as ReactPointerEvent, ReactNode } from "react" + +import type { + ChartScene, + FillStyle, + HitTarget, + Rect, + SceneNode, + StrokeStyle, + Vec2, +} from "../core/scene/nodes.ts" +import { round2 } from "../core/scene/nodes.ts" +import { familyNameFor } from "../core/text/metricsTables.ts" +import type { EmphasisModel } from "./interaction/emphasisReducer.ts" + +export interface SceneSVGProps { + scene: ChartScene + /** Stable id namespace (chart slug); never random (spec 28 §2). */ + idPrefix: string + /** Attach the hit layer and disable pointer events on marks. */ + interactive?: boolean + emphasis?: EmphasisModel + /** Theme dim factor applied to non-emphasized series. */ + dimOpacity?: number + /** Pointer entered/moved over a hit target. Point is in scene coordinates. */ + onHover?: (target: HitTarget, point: Vec2) => void + onLeave?: () => void + /** Click on a hit target (Chart toggles focus for series targets). */ + onActivate?: (target: HitTarget) => void +} + +// --------------------------------------------------------------------------- +// Number formatting — the one place scene numbers become SVG strings +// --------------------------------------------------------------------------- + +/** + * Format a coordinate as a plain decimal string: round2 (which normalizes + * -0), never exponent notation. round2 output has at most 2 decimals, so + * Number#toString is exponent-free for any plausible coordinate magnitude. + */ +function fmt(n: number): string { + const r = round2(n) + if (!Number.isFinite(r)) return "0" + return r.toString() +} + +// --------------------------------------------------------------------------- +// Path serialization (d3-shape lives HERE only — spec 28 §4) +// --------------------------------------------------------------------------- + +const pathLine = d3Line() + .x((d) => round2(d.x)) + .y((d) => round2(d.y)) + +/** One d attribute for a polyline series; separate segments encode gaps. */ +function lineD(segments: Vec2[][]): string { + return segments + .filter((segment) => segment.length > 0) + .map((segment) => pathLine(segment) ?? "") + .join("") +} + +/** Closed band: upper polyline, then the lower boundary reversed, then Z. */ +function areaD(upper: Vec2[], lower: Vec2[]): string { + const ring = [...upper, ...[...lower].reverse()] + if (ring.length === 0) return "" + return `${pathLine(ring) ?? ""}Z` +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +function combinedOpacity(base: number | undefined, dim: number): string | undefined { + const opacity = (base ?? 1) * dim + return opacity === 1 ? undefined : fmt(opacity) +} + +function strokeAttrs(style: StrokeStyle, dim: number) { + return { + fill: "none", + stroke: style.stroke, + strokeWidth: fmt(style.strokeWidth), + strokeDasharray: style.dash !== undefined ? style.dash.map(fmt).join(" ") : undefined, + strokeLinecap: style.lineCap, + opacity: combinedOpacity(style.opacity, dim), + } +} + +function fillAttrs(style: FillStyle, dim: number) { + return { + fill: style.fill, + stroke: style.stroke, + strokeWidth: style.strokeWidth !== undefined ? fmt(style.strokeWidth) : undefined, + opacity: combinedOpacity(style.opacity, dim), + } +} + +function rectAttrs(rect: Rect) { + return { x: fmt(rect.x), y: fmt(rect.y), width: fmt(rect.width), height: fmt(rect.height) } +} + +// --------------------------------------------------------------------------- +// Pattern defs — layout references patterns by id (e.g. projection hatch) +// --------------------------------------------------------------------------- + +function collectPatternIds(nodes: SceneNode[], into: Set): void { + for (const node of nodes) { + switch (node.kind) { + case "group": + collectPatternIds(node.children, into) + break + case "area": + case "rect": + case "point": + if (node.style.patternId !== undefined) into.add(node.style.patternId) + break + default: + break + } + } +} + +/** + * Diagonal hatch knocked out of the fill in the scene background colour. + * Marks with a patternId render their solid fill plus this overlay. + */ +function patternDef(patternId: string, idPrefix: string, background: string): ReactNode { + return ( + + + + ) +} + +// --------------------------------------------------------------------------- +// Node rendering +// --------------------------------------------------------------------------- + +interface RenderContext { + idPrefix: string + emphasis: EmphasisModel + dimOpacity: number +} + +/** Dim factor for this node; 1 when idle, ancestor-dimmed, or emphasized. */ +function dimFor(node: SceneNode, ctx: RenderContext, ancestorDimmed: boolean): number { + if (ancestorDimmed) return 1 + if (ctx.emphasis.mode !== "emphasis") return 1 + if (node.seriesKey === undefined) return 1 + return ctx.emphasis.keys.has(node.seriesKey) ? 1 : ctx.dimOpacity +} + +function renderNode(node: SceneNode, ctx: RenderContext, ancestorDimmed: boolean): ReactNode { + const dim = dimFor(node, ctx, ancestorDimmed) + const childDimmed = ancestorDimmed || dim !== 1 + + switch (node.kind) { + case "group": { + const clipId = `${ctx.idPrefix}-clip-${node.key}` + return ( + + {node.clip !== undefined ? ( + + + + ) : null} + {node.children.map((child) => renderNode(child, ctx, childDimmed))} + + ) + } + case "line": + return + case "area": { + const d = areaD(node.upper, node.lower) + if (node.style.patternId === undefined) { + return + } + return ( + + + + + ) + } + case "image": + return ( + + ) + case "rect": { + const base = + if (node.style.patternId === undefined) return base + return ( + + + + + ) + } + case "point": + return ( + + ) + case "rule": + return ( + + ) + case "text": + return ( + + {node.text} + + ) + } +} + +// --------------------------------------------------------------------------- +// Hit layer — invisible overlay carrying ALL pointer handlers +// --------------------------------------------------------------------------- + +function pointFromEvent(event: ReactPointerEvent): Vec2 { + const svg = event.currentTarget.closest("svg") + if (svg === null) return { x: 0, y: 0 } + const bounds = svg.getBoundingClientRect() + return { x: event.clientX - bounds.x, y: event.clientY - bounds.y } +} + +interface HitHandlers { + onHover?: (target: HitTarget, point: Vec2) => void + onActivate?: (target: HitTarget) => void +} + +function hitRect(target: HitTarget, rect: Rect, label: string, handlers: HitHandlers): ReactNode { + const hover = + handlers.onHover !== undefined + ? (event: ReactPointerEvent) => + handlers.onHover?.(target, pointFromEvent(event)) + : undefined + return ( + handlers.onActivate?.(target) : undefined} + /> + ) +} + +/** + * Time targets become vertical strips spanning the plot area between + * midpoints of adjacent target x positions; series targets cover their + * precomputed shapes. + */ +function renderHitLayer(scene: ChartScene, handlers: HitHandlers, onLeave?: () => void): ReactNode { + const plot = scene.plotArea + const timeTargets = scene.hover.targets + .filter((target) => target.kind === "time") + .sort((a, b) => a.x - b.x) + const strips = timeTargets.map((target, i) => { + const left = i === 0 ? plot.x : (timeTargets[i - 1].x + target.x) / 2 + const right = + i === timeTargets.length - 1 ? plot.x + plot.width : (target.x + timeTargets[i + 1].x) / 2 + const rect: Rect = { x: left, y: plot.y, width: Math.max(0, right - left), height: plot.height } + return hitRect(target, rect, `time:${target.time}`, handlers) + }) + const shapes = scene.hover.targets + .filter((target) => target.kind === "series") + .map((target) => hitRect(target, target.shape, `series:${target.seriesKey}`, handlers)) + return ( + onLeave() : undefined}> + {strips} + {shapes} + + ) +} + +// --------------------------------------------------------------------------- +// The component +// --------------------------------------------------------------------------- + +const IDLE: EmphasisModel = { mode: "idle" } + +export function SceneSVG({ + scene, + idPrefix, + interactive = false, + emphasis = IDLE, + dimOpacity = 0.35, + onHover, + onLeave, + onActivate, +}: SceneSVGProps): ReactNode { + const ctx: RenderContext = { idPrefix, emphasis, dimOpacity } + const patternIds = new Set() + collectPatternIds(scene.nodes, patternIds) + return ( + + {patternIds.size > 0 ? ( + + {[...patternIds].sort().map((patternId) => + patternDef(patternId, idPrefix, scene.background), + )} + + ) : null} + + + {scene.nodes.map((node) => renderNode(node, ctx, false))} + + {interactive ? renderHitLayer(scene, { onHover, onActivate }, onLeave) : null} + + ) +} diff --git a/packages/charts2/src/react/chrome/DataTable.test.tsx b/packages/charts2/src/react/chrome/DataTable.test.tsx new file mode 100644 index 00000000000..318547921b7 --- /dev/null +++ b/packages/charts2/src/react/chrome/DataTable.test.tsx @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" + +import { formatChange } from "../../core/format/number.ts" +import type { ColumnMeta, TimeSelection } from "../../core/types.ts" +import { loadFixtureDataset } from "../../fixtures/index.ts" +import { DataTable, EM_DASH } from "./DataTable.tsx" +import type { DataTableProps } from "./DataTable.tsx" + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +afterEach(cleanup) + +const { dataset } = loadFixtureDataset("provincial-budgets") +const slugs = ["total_spending", "program_spending", "debt_charges"] +const columns: Record = Object.fromEntries(slugs.map((slug) => [slug, dataset.manifest.columns[slug]])) + +const ALL_PROVINCES = [...dataset.entities] + +function renderTable(overrides: Partial = {}) { + const props: DataTableProps = { + dataset, + columns, + entities: ALL_PROVINCES, + timeSelection: { start: 2024, end: 2024 }, + grain: "fiscal-year", + locale: "en", + scope: "all", + onScopeChange: () => undefined, + sort: { column: "entity", order: "asc" }, + onSortChange: () => undefined, + searchQuery: "", + onSearchChange: () => undefined, + ...overrides, + } + return render() +} + +function bodyRow(container: HTMLElement, entity: string): HTMLTableRowElement { + const rows = [...container.querySelectorAll("tbody tr")] + const row = rows.find((candidate) => candidate.querySelector("th")?.textContent === entity) + if (row === undefined) throw new Error(`No row for ${entity}`) + return row +} + +function cellTexts(row: HTMLTableRowElement): string[] { + return [...row.querySelectorAll("td")].map((cell) => cell.textContent ?? "") +} + +describe("DataTable column set (spec 22 §1)", () => { + const cases: { name: string; selection: TimeSelection; headerRows: number; valueColumns: number }[] = [ + { name: "single time → one value column per metric", selection: { start: 2024, end: 2024 }, headerRows: 1, valueColumns: 3 }, + { name: "time range → start/end/change/% change per metric", selection: { start: 2019, end: 2024 }, headerRows: 2, valueColumns: 12 }, + ] + + for (const testCase of cases) { + it(testCase.name, () => { + const { container } = renderTable({ timeSelection: testCase.selection }) + expect(container.querySelectorAll("thead tr").length).toBe(testCase.headerRows) + const ontario = bodyRow(container, "Ontario") + expect(ontario.querySelectorAll("td").length).toBe(testCase.valueColumns) + }) + } + + it("labels single-time columns with metric name, unit, and time", () => { + const { container } = renderTable({ timeSelection: { start: 2024, end: 2024 } }) + const header = container.querySelector("thead") + expect(header?.textContent).toContain("Total spending") + expect(header?.textContent).toContain("billion CAD") + expect(header?.textContent).toContain("2024–25") + expect(header?.textContent).not.toContain("Change") + }) + + it("labels range sub-columns with both endpoints and change columns", () => { + const { container } = renderTable({ timeSelection: { start: 2019, end: 2024 } }) + const subHeaders = [...container.querySelectorAll("thead tr")[1].querySelectorAll("th")].map( + (th) => th.textContent ?? "", + ) + expect(subHeaders.length).toBe(12) + expect(subHeaders[0]).toContain("2019–20") + expect(subHeaders[1]).toContain("2024–25") + expect(subHeaders[2]).toContain("Change") + expect(subHeaders[3]).toContain("% change") + }) +}) + +describe("DataTable change math (spec 22 §1)", () => { + it("computes absolute and relative change via formatChange (format parity)", () => { + const { container } = renderTable({ timeSelection: { start: 2019, end: 2024 } }) + const ontario = cellTexts(bodyRow(container, "Ontario")) + + // Ontario total_spending: 165.1 → 214.5. + const expected = formatChange(165.1, 214.5, columns.total_spending, { locale: "en" }) + expect(ontario[0]).toBe("$165.1") + expect(ontario[1]).toBe("$214.5") + expect(ontario[2]).toBe(expected.absolute) + expect(ontario[3]).toBe(expected.relative) + expect(expected.relative).not.toBeNull() + }) +}) + +describe("DataTable annotations (spec 22 §2)", () => { + it("renders missing values as an em-dash, never blank or zero", () => { + const { container } = renderTable({ timeSelection: { start: 2024, end: 2024 } }) + const quebec = cellTexts(bodyRow(container, "Quebec")) + // program_spending has no tolerance: Quebec 2024-25 is missing. + expect(quebec[1]).toBe(EM_DASH) + expect(quebec[1]).not.toBe("") + expect(quebec[1]).not.toBe("0") + }) + + it("suppresses change columns to em-dashes when an endpoint is missing", () => { + const { container } = renderTable({ timeSelection: { start: 2019, end: 2024 } }) + const quebec = cellTexts(bodyRow(container, "Quebec")) + // program_spending occupies columns 4–7: start, end (missing), change, % change. + expect(quebec[4]).toBe("$110.4") + expect(quebec[5]).toBe(EM_DASH) + expect(quebec[6]).toBe(EM_DASH) + expect(quebec[7]).toBe(EM_DASH) + }) + + it("marks toleranced cells with an info marker carrying the actual time", () => { + const { container } = renderTable({ timeSelection: { start: 2024, end: 2024 } }) + // debt_charges (tolerance 2): Quebec 2024-25 borrows from 2023-24. + const quebec = bodyRow(container, "Quebec") + const marker = quebec.querySelector(".bcds2-data-table__marker--toleranced") + expect(marker).not.toBeNull() + expect(marker?.getAttribute("title")).toBe("Data from 2023–24") + + // Ontario has a real 2024-25 value: no marker. + expect(bodyRow(container, "Ontario").querySelector(".bcds2-data-table__marker--toleranced")).toBeNull() + }) +}) + +describe("DataTable scope, sort, and search (spec 22 §3)", () => { + it("applies the sort prop to row order", () => { + const { container } = renderTable({ sort: { column: "total_spending", order: "desc" } }) + const names = [...container.querySelectorAll("tbody th")].map((th) => th.textContent) + expect(names[0]).toBe("Ontario") + expect(names[names.length - 1]).toBe("Nova Scotia") + }) + + it("shows only the selected entities when scope is selected", () => { + const { container } = renderTable({ scope: "selected", entities: ["Ontario", "Alberta"] }) + const names = [...container.querySelectorAll("tbody th")].map((th) => th.textContent) + expect(names).toEqual(["Alberta", "Ontario"]) + }) + + it("filters rows by entity search", () => { + const { container } = renderTable({ searchQuery: "ont" }) + const names = [...container.querySelectorAll("tbody th")].map((th) => th.textContent) + expect(names).toEqual(["Ontario"]) + }) + + it("emits scope, sort, and search changes without owning the state", () => { + const onScopeChange = vi.fn() + const onSortChange = vi.fn() + const onSearchChange = vi.fn() + const { getByText, getByLabelText } = renderTable({ + scope: "selected", + onScopeChange, + onSortChange, + onSearchChange, + }) + + fireEvent.click(getByText("All")) + expect(onScopeChange).toHaveBeenCalledWith("all") + + fireEvent.click(getByText("Total spending")) + expect(onSortChange).toHaveBeenCalledWith({ column: "total_spending", order: "desc" }) + + fireEvent.change(getByLabelText("Search provinces"), { target: { value: "que" } }) + expect(onSearchChange).toHaveBeenCalledWith("que") + }) + + it("toggles the order when the active sort column is clicked again", () => { + const onSortChange = vi.fn() + const { getByText } = renderTable({ sort: { column: "entity", order: "asc" }, onSortChange }) + fireEvent.click(getByText("Province")) + expect(onSortChange).toHaveBeenCalledWith({ column: "entity", order: "desc" }) + }) +}) diff --git a/packages/charts2/src/react/chrome/DataTable.tsx b/packages/charts2/src/react/chrome/DataTable.tsx new file mode 100644 index 00000000000..42684604f43 --- /dev/null +++ b/packages/charts2/src/react/chrome/DataTable.tsx @@ -0,0 +1,362 @@ +/** + * Data table tab (spec 22): one row per entity (entity column pinned left + * via CSS), per metric either one value column (single time) or start / + * end / absolute change / relative change columns (time range). Every cell + * resolves through resolveValue, so missing ≠ zero, tolerance is marked + * with the actual time, and projected values are flagged. + * + * Sort, search, and scope STATE live in the caller (they persist in URL + * state per spec 22 §3) — this component renders the given state and emits + * change events. + */ + +import { useMemo } from "react" +import type { ReactNode } from "react" +import { resolveValue } from "../../core/data/derived.ts" +import { snapToAvailable } from "../../core/data/time.ts" +import { formatChange, formatValue } from "../../core/format/number.ts" +import { formatTime } from "../../core/format/timeLabels.ts" +import type { + ColumnMeta, + Dataset, + Locale, + ResolvedValue, + SortOrder, + TimeBound, + TimeGrain, + TimeOrdinal, + TimeSelection, +} from "../../core/types.ts" +import { createFuzzySearch, foldAccents } from "./fuzzySearch.ts" + +export const EM_DASH = "—" + +export type DataTableScope = "selected" | "all" + +export interface DataTableSort { + /** "entity", a metric slug (single time), or "slug.start|.end|.change|.relativeChange". */ + column: string + order: SortOrder +} + +export interface DataTableProps { + dataset: Dataset + /** Metric columns to show, in order (slug → bound column metadata). */ + columns: Record + /** The selected entities (the rows when scope is "selected"). */ + entities: string[] + /** Resolved time selection (ordinals; earliest/latest tolerated). */ + timeSelection: TimeSelection + grain: TimeGrain + locale: Locale + scope: DataTableScope + onScopeChange: (scope: DataTableScope) => void + sort: DataTableSort + onSortChange: (sort: DataTableSort) => void + searchQuery: string + onSearchChange: (query: string) => void +} + +const SUB_COLUMN_PATTERN = /^(.*)\.(start|end|change|relativeChange)$/ + +function resolveBound(bound: TimeBound, times: readonly TimeOrdinal[]): TimeOrdinal | null { + if (times.length === 0) return null + if (bound === "earliest") return times[0] + if (bound === "latest") return times[times.length - 1] + return snapToAvailable(bound, times) +} + +function compareNames(a: string, b: string): number { + const left = foldAccents(a).toLowerCase() + const right = foldAccents(b).toLowerCase() + return left < right ? -1 : left > right ? 1 : 0 +} + +function capitalize(word: string): string { + return word.length === 0 ? word : word[0].toUpperCase() + word.slice(1) +} + +export function DataTable({ + dataset, + columns, + entities, + timeSelection, + grain, + locale, + scope, + onScopeChange, + sort, + onSortChange, + searchQuery, + onSearchChange, +}: DataTableProps) { + const slugs = Object.keys(columns) + const startTime = resolveBound(timeSelection.start, dataset.times) + const endTime = resolveBound(timeSelection.end, dataset.times) + const isRange = startTime !== null && endTime !== null && startTime !== endTime + const rangeStart = startTime ?? 0 + const rangeEnd = endTime ?? 0 + + const metaByEntity = useMemo(() => { + const map = new Map() + for (const meta of dataset.manifest.entities ?? []) { + const aliases: string[] = [] + if (meta.nameFr !== undefined) aliases.push(meta.nameFr) + if (meta.code !== undefined) aliases.push(meta.code) + aliases.push(...(meta.aliases ?? [])) + map.set(meta.name, { aliases }) + } + return map + }, [dataset]) + + const searcher = useMemo( + () => createFuzzySearch(dataset.entities, (name) => [name, ...(metaByEntity.get(name)?.aliases ?? [])]), + [dataset, metaByEntity], + ) + + function resolveCell(entity: string, slug: string, time: TimeOrdinal | null): ResolvedValue { + return resolveValue(dataset, slug, entity, time, columns[slug]) + } + + function sortValueFor(entity: string, columnId: string): number | string | null { + if (columnId === "entity") return entity + const match = SUB_COLUMN_PATTERN.exec(columnId) + const slug = match !== null && slugs.includes(match[1]) ? match[1] : columnId + const part = match !== null && slugs.includes(match[1]) ? match[2] : isRange ? "end" : "value" + if (!slugs.includes(slug)) return null + + if (part === "start") { + const cell = resolveCell(entity, slug, startTime) + return cell.status === "value" ? cell.value : null + } + if (part === "change" || part === "relativeChange") { + const startCell = resolveCell(entity, slug, startTime) + const endCell = resolveCell(entity, slug, endTime) + if (startCell.status !== "value" || endCell.status !== "value") return null + const diff = endCell.value - startCell.value + if (part === "change") return diff + return startCell.value === 0 ? null : diff / Math.abs(startCell.value) + } + const cell = resolveCell(entity, slug, endTime) + return cell.status === "value" ? cell.value : null + } + + const visibleEntities = useMemo(() => { + const base = scope === "all" ? [...dataset.entities] : entities.filter((name) => dataset.entities.includes(name)) + const filtered = + searchQuery.trim() === "" ? base : searcher.search(searchQuery).filter((name) => base.includes(name)) + const direction = sort.order === "asc" ? 1 : -1 + const sorted = [...filtered].sort((a, b) => { + const left = sortValueFor(a, sort.column) + const right = sortValueFor(b, sort.column) + if (typeof left === "string" && typeof right === "string") return direction * compareNames(left, right) + if (left === null && right === null) return compareNames(a, b) + if (left === null) return 1 + if (right === null) return -1 + if (typeof left === "string" || typeof right === "string") return 0 + return direction * (left - right) + }) + return sorted + }, [dataset, entities, scope, searchQuery, searcher, sort, startTime, endTime, columns]) + + function headerButton(columnId: string, content: ReactNode): ReactNode { + const isActive = sort.column === columnId + const nextOrder: SortOrder = isActive ? (sort.order === "asc" ? "desc" : "asc") : columnId === "entity" ? "asc" : "desc" + return ( + + )} + + ) + } + + function ariaSort(columnId: string): "ascending" | "descending" | undefined { + if (sort.column !== columnId) return undefined + return sort.order === "asc" ? "ascending" : "descending" + } + + function renderValueCell(entity: string, slug: string, time: TimeOrdinal | null, key: string): ReactNode { + const cell = resolveCell(entity, slug, time) + if (cell.status === "missing") { + return ( + + {EM_DASH} + + ) + } + const toleranced = cell.sourceTime !== cell.time + const classes = ["bcds2-data-table__cell", "bcds2-data-table__cell--numeric"] + if (cell.projected) classes.push("bcds2-data-table__cell--projected") + return ( + + {formatValue(cell.value, columns[slug], { locale, verbosity: "long" })} + {toleranced && ( + + ⓘ + + )} + {cell.projected && ( + + * + + )} + + ) + } + + function renderChangeCells(entity: string, slug: string): ReactNode[] { + const startCell = resolveCell(entity, slug, startTime) + const endCell = resolveCell(entity, slug, endTime) + const missingClass = "bcds2-data-table__cell bcds2-data-table__cell--numeric bcds2-data-table__cell--missing" + if (startCell.status !== "value" || endCell.status !== "value") { + return [ + + {EM_DASH} + , + + {EM_DASH} + , + ] + } + const change = formatChange(startCell.value, endCell.value, columns[slug], { locale }) + return [ + + {change.absolute} + , + + {change.relative ?? EM_DASH} + , + ] + } + + function metricHeading(slug: string): ReactNode { + const meta = columns[slug] + const unit = meta.denominator !== undefined ? (meta.derivedUnit ?? meta.derivedShortUnit) : (meta.unit ?? meta.shortUnit) + return ( + + {meta.name} + {unit !== undefined && {unit}} + + ) + } + + const entityHeading = capitalize(dataset.manifest.entity.label) + + return ( +
+
+
+ + +
+ onSearchChange(event.target.value)} + /> +
+
+ + + {isRange ? ( + <> + + + {slugs.map((slug) => ( + + ))} + + + {slugs.flatMap((slug) => [ + , + , + , + , + ])} + + + ) : ( + + + {slugs.map((slug) => ( + + ))} + + )} + + + {visibleEntities.map((entity) => ( + + + {isRange + ? slugs.flatMap((slug) => [ + renderValueCell(entity, slug, startTime, `${slug}.start`), + renderValueCell(entity, slug, endTime, `${slug}.end`), + ...renderChangeCells(entity, slug), + ]) + : slugs.map((slug) => renderValueCell(entity, slug, endTime, slug))} + + ))} + +
+ {headerButton("entity", entityHeading)} + + {metricHeading(slug)} +
+ {headerButton(`${slug}.start`, formatTime(rangeStart, grain, locale))} + + {headerButton(`${slug}.end`, formatTime(rangeEnd, grain, locale))} + + {headerButton(`${slug}.change`, "Change")} + + {headerButton(`${slug}.relativeChange`, "% change")} +
+ {headerButton("entity", entityHeading)} + + {headerButton( + slug, + + {metricHeading(slug)} + {endTime !== null && ( + {formatTime(endTime, grain, locale)} + )} + , + )} +
+ {entity} +
+
+
+ ) +} diff --git a/packages/charts2/src/react/chrome/EntitySelector.test.tsx b/packages/charts2/src/react/chrome/EntitySelector.test.tsx new file mode 100644 index 00000000000..837e336e496 --- /dev/null +++ b/packages/charts2/src/react/chrome/EntitySelector.test.tsx @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" + +import { loadFixtureDataset } from "../../fixtures/index.ts" +import { EntitySelector } from "./EntitySelector.tsx" + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +afterEach(cleanup) + +const pathological = loadFixtureDataset("pathological").dataset +const federal = loadFixtureDataset("federal-departments").dataset + +function rowNames(container: HTMLElement): string[] { + return [...container.querySelectorAll(".bcds2-entity-selector__name")].map((el) => el.textContent ?? "") +} + +describe("EntitySelector search (spec 07 §2)", () => { + it("finds accented entities from unaccented queries (quebec → Québec)", () => { + const { container, getByLabelText } = render( + undefined} locale="en" />, + ) + fireEvent.change(getByLabelText("Search places"), { target: { value: "quebec" } }) + expect(rowNames(container)).toEqual(["Québec"]) + }) + + it("finds entities via manifest aliases (DFAIT → Global Affairs Canada)", () => { + const { container, getByLabelText } = render( + undefined} locale="en" />, + ) + fireEvent.change(getByLabelText("Search departments"), { target: { value: "DFAIT" } }) + expect(rowNames(container)).toEqual(["Global Affairs Canada"]) + + fireEvent.change(getByLabelText("Search departments"), { target: { value: "Industry Canada" } }) + expect(rowNames(container)).toContain("Innovation, Science and Economic Development Canada") + }) +}) + +describe("EntitySelector groups (spec 07 §2)", () => { + it("renders group headers from entity metadata", () => { + const { container } = render( + undefined} locale="en" />, + ) + const headers = [...container.querySelectorAll(".bcds2-entity-selector__group-header")].map( + (el) => el.textContent ?? "", + ) + expect(headers).toContain("Social") + expect(headers).toContain("Defence and Security") + }) + + it("selects a whole group at once and deselects it on a second toggle", () => { + const onChange = vi.fn() + const social = ["Employment and Social Development Canada", "Health Canada", "Veterans Affairs Canada"] + + const first = render( + , + ) + fireEvent.click(first.getByLabelText("Select all in Social")) + expect(onChange).toHaveBeenCalledWith(social) + first.unmount() + + onChange.mockClear() + const second = render( + , + ) + fireEvent.click(second.getByLabelText("Select all in Social")) + expect(onChange).toHaveBeenCalledWith([]) + }) +}) + +describe("EntitySelector sorting (spec 07 §2)", () => { + it("sorts by a numeric column and shows the formatted value beside each entity", () => { + const { container, getByLabelText } = render( + undefined} + sortColumns={["spending"]} + locale="en" + />, + ) + fireEvent.change(getByLabelText("Sort by"), { target: { value: "spending" } }) + + // Descending by latest spending; groups keep first-appearance order, + // so the top spender (Crown-Indigenous, 154) leads and rows sort + // descending within each group (Social: Veterans 134 > Health 44 > ESDC 24). + const names = rowNames(container) + expect(names[0]).toBe("Crown-Indigenous Relations and Northern Affairs Canada") + const veterans = names.indexOf("Veterans Affairs Canada") + const health = names.indexOf("Health Canada") + const esdc = names.indexOf("Employment and Social Development Canada") + expect(veterans).toBeLessThan(health) + expect(health).toBeLessThan(esdc) + + const values = [...container.querySelectorAll(".bcds2-entity-selector__value")].map((el) => el.textContent) + expect(values).toContain("$154.0") + expect(values).toContain("$14.0") + }) + + it("sorts by name by default, accent-insensitively", () => { + const { container } = render( + undefined} locale="en" />, + ) + expect(rowNames(container)).toEqual(["Î.-P.-É.", "Lonely Station", "Québec"]) + }) +}) + +describe("EntitySelector selection modes (spec 07 §1)", () => { + it("toggles entities in multi mode", () => { + const onChange = vi.fn() + const { container } = render( + , + ) + const rows = [...container.querySelectorAll(".bcds2-entity-selector__row")] + const lonely = rows.find((row) => row.textContent?.includes("Lonely Station")) + fireEvent.click(lonely!.querySelector("input")!) + expect(onChange).toHaveBeenCalledWith(["Québec", "Lonely Station"]) + }) + + it("replaces the selection in single mode", () => { + const onChange = vi.fn() + const { container } = render( + , + ) + const rows = [...container.querySelectorAll(".bcds2-entity-selector__row")] + const lonely = rows.find((row) => row.textContent?.includes("Lonely Station")) + expect(lonely?.querySelector("input")?.getAttribute("type")).toBe("radio") + fireEvent.click(lonely!.querySelector("input")!) + expect(onChange).toHaveBeenCalledWith(["Lonely Station"]) + }) + + it("selects all and clears in multi mode", () => { + const onChange = vi.fn() + const { getByText } = render( + , + ) + fireEvent.click(getByText("Select all")) + expect(onChange).toHaveBeenCalledWith(["Québec", "Î.-P.-É.", "Lonely Station"]) + + onChange.mockClear() + fireEvent.click(getByText("Clear")) + expect(onChange).toHaveBeenCalledWith([]) + }) + + it("hides bulk actions in single mode", () => { + const { queryByText } = render( + undefined} locale="en" />, + ) + expect(queryByText("Select all")).toBeNull() + expect(queryByText("Clear")).toBeNull() + }) +}) diff --git a/packages/charts2/src/react/chrome/EntitySelector.tsx b/packages/charts2/src/react/chrome/EntitySelector.tsx new file mode 100644 index 00000000000..fdb149ee063 --- /dev/null +++ b/packages/charts2/src/react/chrome/EntitySelector.tsx @@ -0,0 +1,259 @@ +/** + * Entity selector panel (spec 07 §2): accent/alias-tolerant search, group + * headers with group-level select, sort by name or by a numeric column + * (showing the sort value beside each entity), select all / clear, and + * "no data" tagging. Rendered as a plain panel — the host app decides + * whether it lives in a drawer, modal, or sidebar. + */ + +import { useMemo, useState } from "react" +import { resolveValue } from "../../core/data/derived.ts" +import { formatValue } from "../../core/format/number.ts" +import type { Dataset, EntityMeta, Locale, SortOrder } from "../../core/types.ts" +import { createFuzzySearch, foldAccents } from "./fuzzySearch.ts" + +export type EntitySelectorMode = "multi" | "single" + +export interface EntitySelectorProps { + dataset: Dataset + selected: string[] + mode: EntitySelectorMode + onChange: (selected: string[]) => void + /** Numeric column slugs offered as sort options ("sort by Total spending"). */ + sortColumns?: string[] + locale: Locale +} + +interface RowModel { + name: string + group: string | null + hasData: boolean + value: number | null + valueText: string | null +} + +function compareNames(a: string, b: string): number { + const left = foldAccents(a).toLowerCase() + const right = foldAccents(b).toLowerCase() + return left < right ? -1 : left > right ? 1 : 0 +} + +export function EntitySelector({ dataset, selected, mode, onChange, sortColumns = [], locale }: EntitySelectorProps) { + const [query, setQuery] = useState("") + const [sortBy, setSortBy] = useState("name") + const [sortOrder, setSortOrder] = useState("asc") + + const labelPlural = dataset.manifest.entity.labelPlural + const latestTime = dataset.times.length > 0 ? dataset.times[dataset.times.length - 1] : null + + const metaByName = useMemo(() => { + const map = new Map() + for (const meta of dataset.manifest.entities ?? []) { + map.set(meta.name, meta) + } + return map + }, [dataset]) + + const searcher = useMemo( + () => + createFuzzySearch(dataset.entities, (name) => { + const meta = metaByName.get(name) + const keys = [name] + if (meta?.nameFr !== undefined) keys.push(meta.nameFr) + if (meta?.code !== undefined) keys.push(meta.code) + for (const alias of meta?.aliases ?? []) keys.push(alias) + return keys + }), + [dataset, metaByName], + ) + + /** Entities with at least one non-missing cell anywhere in the dataset. */ + const entitiesWithData = useMemo(() => { + const set = new Set() + const timesToCheck: (number | null)[] = dataset.times.length > 0 ? [...dataset.times] : [null] + const columns = [...dataset.columns.values()] + for (const entity of dataset.entities) { + let found = false + for (const time of timesToCheck) { + const row = dataset.rowIndexOf(entity, time) + if (row < 0) continue + if (columns.some((column) => column.values[row] !== null && column.values[row] !== undefined)) { + found = true + break + } + } + if (found) set.add(entity) + } + return set + }, [dataset]) + + const rows = useMemo(() => { + const names = query.trim() === "" ? [...dataset.entities] : searcher.search(query) + const sortColumn = sortBy === "name" ? null : sortBy + const columnMeta = sortColumn !== null ? dataset.columns.get(sortColumn)?.meta : undefined + + const models = names.map((name) => { + let value: number | null = null + let valueText: string | null = null + if (sortColumn !== null && columnMeta !== undefined) { + const cell = resolveValue(dataset, sortColumn, name, latestTime) + if (cell.status === "value") { + value = cell.value + valueText = formatValue(cell.value, columnMeta, { locale, verbosity: "label" }) + } + } + return { + name, + group: metaByName.get(name)?.group ?? null, + hasData: entitiesWithData.has(name), + value, + valueText, + } + }) + + const direction = sortOrder === "asc" ? 1 : -1 + models.sort((a, b) => { + if (sortBy === "name") return direction * compareNames(a.name, b.name) + if (a.value === null && b.value === null) return compareNames(a.name, b.name) + if (a.value === null) return 1 + if (b.value === null) return -1 + return direction * (a.value - b.value) + }) + return models + }, [dataset, searcher, metaByName, entitiesWithData, query, sortBy, sortOrder, latestTime, locale]) + + const groups = useMemo(() => { + const list: { name: string | null; rows: RowModel[] }[] = [] + const byName = new Map() + for (const row of rows) { + let bucket = byName.get(row.group) + if (bucket === undefined) { + bucket = { name: row.group, rows: [] } + byName.set(row.group, bucket) + list.push(bucket) + } + bucket.rows.push(row) + } + return list + }, [rows]) + + function toggleEntity(name: string): void { + if (mode === "single") { + onChange([name]) + return + } + if (selected.includes(name)) { + onChange(selected.filter((entity) => entity !== name)) + } else { + onChange([...selected, name]) + } + } + + function toggleGroup(names: readonly string[]): void { + const allSelected = names.every((name) => selected.includes(name)) + if (allSelected) { + onChange(selected.filter((entity) => !names.includes(entity))) + } else { + const merged = [...selected] + for (const name of names) { + if (!merged.includes(name)) merged.push(name) + } + onChange(merged) + } + } + + function handleSortByChange(value: string): void { + setSortBy(value) + setSortOrder(value === "name" ? "asc" : "desc") + } + + return ( +
+ setQuery(event.target.value)} + /> +
+ + + {mode === "multi" && ( + + + + + )} +
+
+ {groups.map((group) => { + const groupNames = group.rows.map((row) => row.name) + const allSelected = groupNames.length > 0 && groupNames.every((name) => selected.includes(name)) + return ( +
+ {group.name !== null && + (mode === "multi" ? ( + + ) : ( +
+ {group.name} +
+ ))} + {group.rows.map((row) => ( + + ))} +
+ ) + })} +
+
+ ) +} diff --git a/packages/charts2/src/react/chrome/SettingsMenu.test.tsx b/packages/charts2/src/react/chrome/SettingsMenu.test.tsx new file mode 100644 index 00000000000..919fcfa4b69 --- /dev/null +++ b/packages/charts2/src/react/chrome/SettingsMenu.test.tsx @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" + +import type { SettingsItem } from "./SettingsMenu.tsx" +import { SettingsMenu } from "./SettingsMenu.tsx" + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +afterEach(cleanup) + +function makeItems(onToggle = vi.fn(), onScale = vi.fn()): SettingsItem[] { + return [ + { kind: "toggle", id: "relative", label: "Relative", value: false, onChange: onToggle }, + { + kind: "radio", + id: "scale", + label: "Y-axis scale", + options: [ + { value: "linear", label: "Linear" }, + { value: "log", label: "Log" }, + ], + value: "linear", + onChange: onScale, + }, + ] +} + +describe("SettingsMenu (spec 10 §4)", () => { + it("opens the popover from the gear button and lists only the passed items", () => { + const { container, getByLabelText, getByText } = render() + expect(container.querySelector(".bcds2-settings__popover")).toBeNull() + + fireEvent.click(getByLabelText("Settings")) + expect(container.querySelector(".bcds2-settings__popover")).not.toBeNull() + expect(getByText("Relative")).not.toBeNull() + expect(getByText("Y-axis scale")).not.toBeNull() + expect(container.querySelectorAll(".bcds2-settings__item").length).toBe(2) + }) + + it("emits toggle and radio changes", () => { + const onToggle = vi.fn() + const onScale = vi.fn() + const { container, getByLabelText } = render() + fireEvent.click(getByLabelText("Settings")) + + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement + fireEvent.click(checkbox) + expect(onToggle).toHaveBeenCalledWith(true) + + const log = container.querySelector('input[type="radio"][value="log"]') as HTMLInputElement + fireEvent.click(log) + expect(onScale).toHaveBeenCalledWith("log") + }) + + it("closes on Escape", () => { + const { container, getByLabelText } = render() + fireEvent.click(getByLabelText("Settings")) + expect(container.querySelector(".bcds2-settings__popover")).not.toBeNull() + + fireEvent.keyDown(document, { key: "Escape" }) + expect(container.querySelector(".bcds2-settings__popover")).toBeNull() + }) + + it("closes on outside click but not on inside click", () => { + const { container, getByLabelText, getByText } = render() + fireEvent.click(getByLabelText("Settings")) + + fireEvent.pointerDown(getByText("Relative")) + expect(container.querySelector(".bcds2-settings__popover")).not.toBeNull() + + fireEvent.pointerDown(document.body) + expect(container.querySelector(".bcds2-settings__popover")).toBeNull() + }) +}) diff --git a/packages/charts2/src/react/chrome/SettingsMenu.tsx b/packages/charts2/src/react/chrome/SettingsMenu.tsx new file mode 100644 index 00000000000..6c946f33405 --- /dev/null +++ b/packages/charts2/src/react/chrome/SettingsMenu.tsx @@ -0,0 +1,108 @@ +/** + * Settings menu (spec 10 §4): a gear button opening a popover that lists + * only the items the caller passes — relevance to the current view is the + * caller's decision. Closes on Escape and on outside click. + */ + +import { useEffect, useRef, useState } from "react" + +export type SettingsItem = + | { + kind: "toggle" + id: string + label: string + value: boolean + onChange: (value: boolean) => void + } + | { + kind: "radio" + id: string + label: string + options: { value: string; label: string }[] + value: string + onChange: (value: string) => void + } + +export interface SettingsMenuProps { + items: SettingsItem[] + /** Accessible label for the gear button. Default "Settings". */ + label?: string +} + +export function SettingsMenu({ items, label = "Settings" }: SettingsMenuProps) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + function handlePointerDown(event: PointerEvent): void { + const root = rootRef.current + if (root !== null && event.target instanceof Node && root.contains(event.target)) return + setOpen(false) + } + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") setOpen(false) + } + document.addEventListener("pointerdown", handlePointerDown) + document.addEventListener("keydown", handleKeyDown) + return () => { + document.removeEventListener("pointerdown", handlePointerDown) + document.removeEventListener("keydown", handleKeyDown) + } + }, [open]) + + return ( +
+ + {open && ( +
+ {items.map((item) => + item.kind === "toggle" ? ( + + ) : ( +
+ {item.label} + {item.options.map((option) => ( + + ))} +
+ ), + )} +
+ )} +
+ ) +} diff --git a/packages/charts2/src/react/chrome/Tabs.test.tsx b/packages/charts2/src/react/chrome/Tabs.test.tsx new file mode 100644 index 00000000000..25a53332c37 --- /dev/null +++ b/packages/charts2/src/react/chrome/Tabs.test.tsx @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" + +import type { Tab } from "../../core/types.ts" +import { Tabs } from "./Tabs.tsx" + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +afterEach(cleanup) + +const TABS: Tab[] = ["line", "discrete-bar", "table"] + +describe("Tabs (spec 10 §3)", () => { + it("renders an accessible tablist with the active tab selected", () => { + const { container } = render( undefined} />) + expect(container.querySelector('[role="tablist"]')).not.toBeNull() + const tabs = [...container.querySelectorAll('[role="tab"]')] + expect(tabs.length).toBe(3) + expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["true", "false", "false"]) + expect(tabs.map((tab) => tab.textContent)).toEqual(["Line", "Bar", "Table"]) + // Roving tabindex: only the active tab is in the tab order. + expect(tabs.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "-1", "-1"]) + }) + + it("activates tabs on click", () => { + const onChange = vi.fn() + const { getByText } = render() + fireEvent.click(getByText("Table")) + expect(onChange).toHaveBeenCalledWith("table") + }) + + it("moves with arrow keys, wrapping at both ends", () => { + const onChange = vi.fn() + const { getByText } = render() + + fireEvent.keyDown(getByText("Line"), { key: "ArrowRight" }) + expect(onChange).toHaveBeenLastCalledWith("discrete-bar") + + fireEvent.keyDown(getByText("Line"), { key: "ArrowLeft" }) + expect(onChange).toHaveBeenLastCalledWith("table") + }) + + it("jumps to the first and last tab with Home and End", () => { + const onChange = vi.fn() + const { getByText } = render() + + fireEvent.keyDown(getByText("Bar"), { key: "Home" }) + expect(onChange).toHaveBeenLastCalledWith("line") + + fireEvent.keyDown(getByText("Bar"), { key: "End" }) + expect(onChange).toHaveBeenLastCalledWith("table") + }) + + it("supports label overrides", () => { + const { getByText } = render( + undefined} labels={{ "discrete-bar": "Bars!" }} />, + ) + expect(getByText("Bars!")).not.toBeNull() + }) +}) diff --git a/packages/charts2/src/react/chrome/Tabs.tsx b/packages/charts2/src/react/chrome/Tabs.tsx new file mode 100644 index 00000000000..bb1dabd48ee --- /dev/null +++ b/packages/charts2/src/react/chrome/Tabs.tsx @@ -0,0 +1,80 @@ +/** + * Tab row (spec 10 §3): one tab per chart type plus Table. Accessible + * tablist with arrow-key navigation (wrapping), Home/End, and a roving + * tab index. The caller owns the active tab and tab list. + */ + +import { useRef } from "react" +import type { KeyboardEvent as ReactKeyboardEvent } from "react" +import type { Tab } from "../../core/types.ts" + +const DEFAULT_LABELS: Record = { + "line": "Line", + "discrete-bar": "Bar", + "stacked-area": "Stacked area", + "stacked-bar": "Stacked bar", + "stacked-discrete-bar": "Stacked bar", + "table": "Table", +} + +export interface TabsProps { + tabs: Tab[] + active: Tab + onChange: (tab: Tab) => void + /** Label overrides per tab (e.g. localized copy). */ + labels?: Partial> +} + +export function Tabs({ tabs, active, onChange, labels }: TabsProps) { + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) + + function activate(index: number): void { + if (tabs.length === 0) return + const wrapped = (index + tabs.length) % tabs.length + onChange(tabs[wrapped]) + buttonRefs.current[wrapped]?.focus() + } + + function handleKeyDown(event: ReactKeyboardEvent, index: number): void { + switch (event.key) { + case "ArrowRight": + event.preventDefault() + activate(index + 1) + break + case "ArrowLeft": + event.preventDefault() + activate(index - 1) + break + case "Home": + event.preventDefault() + activate(0) + break + case "End": + event.preventDefault() + activate(tabs.length - 1) + break + } + } + + return ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ ) +} diff --git a/packages/charts2/src/react/chrome/Timeline.test.tsx b/packages/charts2/src/react/chrome/Timeline.test.tsx new file mode 100644 index 00000000000..6ecceb6eca8 --- /dev/null +++ b/packages/charts2/src/react/chrome/Timeline.test.tsx @@ -0,0 +1,353 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { act, cleanup, fireEvent, render } from "@testing-library/react" + +import type { TimeSelection } from "../../core/types.ts" +import { playStepMs, shouldUseEqualSpacing, Timeline } from "./Timeline.tsx" + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +const TIMES_5 = [2019, 2020, 2021, 2022, 2023] + +function stubTrackRect(container: HTMLElement, width = 100): HTMLElement { + const track = container.querySelector(".bcds2-timeline__track") as HTMLElement + track.getBoundingClientRect = () => + ({ left: 0, top: 0, right: width, bottom: 24, x: 0, y: 0, width, height: 24, toJSON: () => ({}) }) as DOMRect + return track +} + +describe("shouldUseEqualSpacing (spec 08 §2)", () => { + it("keeps proportional spacing for dense regular data", () => { + const dense = Array.from({ length: 30 }, (_, i) => 1990 + i) + expect(shouldUseEqualSpacing(dense)).toBe(false) + }) + + it("switches to equal spacing when a sparse tail dominates the span", () => { + const sparseTail = [...Array.from({ length: 19 }, (_, i) => i), 1000] + expect(sparseTail.length).toBe(20) + expect(shouldUseEqualSpacing(sparseTail)).toBe(true) + }) + + it("keeps proportional spacing below 20 points even when gaps dominate", () => { + const few = [0, 1, 2, 3, 1000] + expect(shouldUseEqualSpacing(few)).toBe(false) + }) + + it("keeps proportional spacing for evenly spaced sparse data", () => { + const decades = Array.from({ length: 25 }, (_, i) => i * 10) + expect(shouldUseEqualSpacing(decades)).toBe(false) + }) + + it("handles degenerate inputs", () => { + expect(shouldUseEqualSpacing([])).toBe(false) + expect(shouldUseEqualSpacing([2020])).toBe(false) + }) +}) + +describe("playStepMs (spec 08 §3)", () => { + it("targets ~4s total with a 100–200ms per-step clamp", () => { + expect(playStepMs(5)).toBe(200) + expect(playStepMs(100)).toBe(100) + expect(playStepMs(25)).toBe(160) + }) +}) + +describe("Timeline pointer interaction (spec 08 §2)", () => { + it("snaps a track click to the nearest available time and moves the nearest handle", () => { + const onChange = vi.fn() + const { container } = render( + , + ) + const track = stubTrackRect(container) + + // 52% of the span: 2019 + 0.52 × 4 = 2021.08 → snaps to 2021; + // closer to the end handle than the start handle. + fireEvent.pointerDown(track, { clientX: 52 }) + expect(onChange).toHaveBeenCalledWith({ start: 2019, end: 2021 }) + }) + + it("drags via pointer events with snapping", () => { + const onChange = vi.fn() + const { container } = render( + , + ) + const track = stubTrackRect(container) + + fireEvent.pointerDown(track, { clientX: 52 }) + fireEvent.pointerMove(window, { clientX: 80 }) + expect(onChange).toHaveBeenLastCalledWith({ start: 2019, end: 2022 }) + + fireEvent.pointerUp(window) + onChange.mockClear() + fireEvent.pointerMove(window, { clientX: 10 }) + expect(onChange).not.toHaveBeenCalled() + }) + + it("never lets the handles cross while dragging", () => { + const onChange = vi.fn() + const { container } = render( + , + ) + const track = stubTrackRect(container) + + // Grab the start handle (fraction 0.74 is nearest to start at 0.75)… + fireEvent.pointerDown(track, { clientX: 74 }) + expect(onChange).not.toHaveBeenCalled() + // …and drag past the end handle: start clamps to end, never beyond. + fireEvent.pointerMove(window, { clientX: 100 }) + expect(onChange).toHaveBeenLastCalledWith({ start: 2023, end: 2023 }) + }) + + it("moves the single handle in single mode", () => { + const onChange = vi.fn() + const { container } = render( + , + ) + const track = stubTrackRect(container) + fireEvent.pointerDown(track, { clientX: 26 }) + expect(onChange).toHaveBeenCalledWith({ start: 2020, end: 2020 }) + }) +}) + +describe("Timeline keyboard (spec 08 §2)", () => { + it("steps one available time with arrow keys", () => { + const onChange = vi.fn() + const { getByLabelText } = render( + , + ) + fireEvent.keyDown(getByLabelText("End time"), { key: "ArrowLeft" }) + expect(onChange).toHaveBeenCalledWith({ start: 2019, end: 2022 }) + + onChange.mockClear() + fireEvent.keyDown(getByLabelText("Start time"), { key: "ArrowRight" }) + expect(onChange).toHaveBeenCalledWith({ start: 2020, end: 2023 }) + }) + + it("jumps to extremes with Home/End without crossing the other handle", () => { + const onChange = vi.fn() + const { getByLabelText } = render( + , + ) + fireEvent.keyDown(getByLabelText("Start time"), { key: "Home" }) + expect(onChange).toHaveBeenCalledWith({ start: 2019, end: 2021 }) + + onChange.mockClear() + // End on the start handle clamps at the end handle, never beyond. + fireEvent.keyDown(getByLabelText("Start time"), { key: "End" }) + expect(onChange).toHaveBeenCalledWith({ start: 2021, end: 2021 }) + }) + + it("does not emit when stepping past the data extent", () => { + const onChange = vi.fn() + const { getByLabelText } = render( + , + ) + fireEvent.keyDown(getByLabelText("End time"), { key: "ArrowRight" }) + expect(onChange).not.toHaveBeenCalled() + }) +}) + +describe("Timeline playback (spec 08 §3)", () => { + it("advances the handle every 200ms for 5 times (4000/5 = 800 clamps to 200) and stops at the end", () => { + vi.useFakeTimers() + const onChange = vi.fn() + const onPlayStateChange = vi.fn() + const { getByLabelText } = render( + , + ) + + fireEvent.click(getByLabelText("Play")) + expect(onPlayStateChange).toHaveBeenLastCalledWith(true) + + act(() => { + vi.advanceTimersByTime(199) + }) + expect(onChange).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith({ start: 2020, end: 2020 }) + + act(() => { + vi.advanceTimersByTime(600) + }) + expect(onChange).toHaveBeenCalledTimes(4) + expect(onChange).toHaveBeenLastCalledWith({ start: 2023, end: 2023 }) + expect(onPlayStateChange).toHaveBeenLastCalledWith(false) + + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(onChange).toHaveBeenCalledTimes(4) + }) + + it("clamps to 100ms steps for 100 times (4000/100 = 40 clamps to 100)", () => { + vi.useFakeTimers() + const times = Array.from({ length: 100 }, (_, i) => 1900 + i) + const onChange = vi.fn() + const { getByLabelText } = render( + , + ) + + fireEvent.click(getByLabelText("Play")) + act(() => { + vi.advanceTimersByTime(99) + }) + expect(onChange).not.toHaveBeenCalled() + act(() => { + vi.advanceTimersByTime(1) + }) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith({ start: 1901, end: 1901 }) + }) + + it("keeps the start handle fixed in range mode", () => { + vi.useFakeTimers() + const onChange = vi.fn() + const { getByLabelText } = render( + , + ) + fireEvent.click(getByLabelText("Play")) + act(() => { + vi.advanceTimersByTime(200) + }) + expect(onChange).toHaveBeenLastCalledWith({ start: 2020, end: 2022 }) + }) + + it("replays from the beginning when already at the end", () => { + vi.useFakeTimers() + const onChange = vi.fn() + const { getByLabelText } = render( + , + ) + fireEvent.click(getByLabelText("Play")) + expect(onChange).toHaveBeenCalledWith({ start: 2019, end: 2019 }) + act(() => { + vi.advanceTimersByTime(200) + }) + expect(onChange).toHaveBeenLastCalledWith({ start: 2020, end: 2020 }) + }) +}) + +describe("Timeline chrome", () => { + it("renders ticks at every available time and a text readout of the selection", () => { + const { container } = render( + undefined} + />, + ) + expect(container.querySelectorAll(".bcds2-timeline__tick").length).toBe(5) + expect(container.querySelector(".bcds2-timeline__readout")?.textContent).toBe("2019–2023") + }) + + it("hides entirely with fewer than two time points", () => { + const { container } = render( + undefined} + />, + ) + expect(container.querySelector(".bcds2-timeline")).toBeNull() + }) + + it("resolves earliest/latest bounds against the data", () => { + const selection: TimeSelection = { start: "earliest", end: "latest" } + const { container } = render( + undefined} />, + ) + expect(container.querySelector(".bcds2-timeline__readout")?.textContent).toBe("2019–2023") + }) +}) diff --git a/packages/charts2/src/react/chrome/Timeline.tsx b/packages/charts2/src/react/chrome/Timeline.tsx new file mode 100644 index 00000000000..2742736c064 --- /dev/null +++ b/packages/charts2/src/react/chrome/Timeline.tsx @@ -0,0 +1,367 @@ +/** + * Timeline control (spec 08 §2–3): a slider over the available times with + * tick marks, one handle (single-time charts) or two handles (range charts), + * keyboard stepping, and a play button that advances the end handle through + * available times targeting a ~4 second total sweep. + * + * Self-contained chrome: receives the available times via props, emits + * snapped TimeSelections, and never reads chart or layout state. Spacing is + * proportional to time by default and switches to equal spacing when the + * data is sparse/irregular (see shouldUseEqualSpacing). + */ + +import { useEffect, useRef, useState } from "react" +import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react" +import { snapToAvailable } from "../../core/data/time.ts" +import { formatTime, formatTimeRange } from "../../core/format/timeLabels.ts" +import type { Locale, TimeBound, TimeGrain, TimeOrdinal, TimeSelection } from "../../core/types.ts" + +export type TimelineMode = "range" | "single" + +export interface TimelineProps { + /** Sorted available time ordinals. Fewer than 2 hides the control. */ + times: readonly TimeOrdinal[] + grain: TimeGrain + locale: Locale + /** Current selection; ordinals are snapped, earliest/latest resolved. */ + selection: TimeSelection + mode: TimelineMode + onChange: (selection: TimeSelection) => void + /** Render the play button. Default true. */ + playable?: boolean + onPlayStateChange?: (playing: boolean) => void +} + +// --------------------------------------------------------------------------- +// Playback pacing (spec 08 §3): ~4s sweep, per-step clamp 100–200ms +// --------------------------------------------------------------------------- + +export const PLAY_TOTAL_MS = 4000 +export const PLAY_STEP_MIN_MS = 100 +export const PLAY_STEP_MAX_MS = 200 + +/** Per-step playback duration for a dataset with `pointCount` times. */ +export function playStepMs(pointCount: number): number { + if (pointCount <= 0) return PLAY_STEP_MAX_MS + return Math.min(PLAY_STEP_MAX_MS, Math.max(PLAY_STEP_MIN_MS, PLAY_TOTAL_MS / pointCount)) +} + +// --------------------------------------------------------------------------- +// Spacing rule (spec 08 §2): proportional by default; equal when sparse +// --------------------------------------------------------------------------- + +/** + * Equal spacing when proportional spacing would crush most points into a + * corner: many points (≥20) AND the top 10% of gaps dominate (>50% of the + * span). Dense regular data stays proportional. + */ +export function shouldUseEqualSpacing(times: readonly TimeOrdinal[]): boolean { + if (times.length < 20) return false + const span = times[times.length - 1] - times[0] + if (span <= 0) return false + + const gaps: number[] = [] + for (let i = 1; i < times.length; i++) { + gaps.push(times[i] - times[i - 1]) + } + gaps.sort((a, b) => b - a) + + const topCount = Math.max(1, Math.ceil(gaps.length * 0.1)) + let topSum = 0 + for (let i = 0; i < topCount; i++) { + topSum += gaps[i] + } + return topSum > span * 0.5 +} + +// --------------------------------------------------------------------------- +// Pure position/selection helpers +// --------------------------------------------------------------------------- + +function resolveBound(bound: TimeBound, times: readonly TimeOrdinal[]): TimeOrdinal { + if (times.length === 0) return 0 + if (bound === "earliest") return times[0] + if (bound === "latest") return times[times.length - 1] + return snapToAvailable(bound, times) ?? times[0] +} + +function fractionOf(times: readonly TimeOrdinal[], equal: boolean, time: TimeOrdinal): number { + if (times.length <= 1) return 0 + const span = times[times.length - 1] - times[0] + if (equal || span === 0) { + const index = times.indexOf(time) + return index < 0 ? 0 : index / (times.length - 1) + } + return (time - times[0]) / span +} + +function timeAtFraction(times: readonly TimeOrdinal[], equal: boolean, fraction: number): TimeOrdinal { + if (times.length === 0) return 0 + if (times.length === 1) return times[0] + const clamped = Math.min(1, Math.max(0, fraction)) + const span = times[times.length - 1] - times[0] + if (equal || span === 0) { + return times[Math.round(clamped * (times.length - 1))] + } + return snapToAvailable(times[0] + clamped * span, times) ?? times[0] +} + +function trackFraction(track: HTMLDivElement | null, clientX: number): number | null { + if (track === null) return null + const rect = track.getBoundingClientRect() + if (rect.width <= 0) return null + return (clientX - rect.left) / rect.width +} + +type HandleId = "start" | "end" + +interface ResolvedSelection { + start: TimeOrdinal + end: TimeOrdinal +} + +interface LatestState { + times: readonly TimeOrdinal[] + equal: boolean + mode: TimelineMode + resolved: ResolvedSelection + onChange: (selection: TimeSelection) => void + onPlayStateChange?: (playing: boolean) => void +} + +/** Move one handle to a snapped time, never letting handles cross. */ +function emitHandleMove(handle: HandleId, time: TimeOrdinal, state: LatestState): void { + const { mode, resolved, onChange } = state + if (mode === "single") { + if (time !== resolved.start || time !== resolved.end) onChange({ start: time, end: time }) + return + } + if (handle === "start") { + const clamped = Math.min(time, resolved.end) + if (clamped !== resolved.start) onChange({ start: clamped, end: resolved.end }) + } else { + const clamped = Math.max(time, resolved.start) + if (clamped !== resolved.end) onChange({ start: resolved.start, end: clamped }) + } +} + +function percent(fraction: number): string { + return `${(fraction * 100).toFixed(4)}%` +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function Timeline({ times, grain, locale, selection, mode, onChange, playable = true, onPlayStateChange }: TimelineProps) { + const equal = shouldUseEqualSpacing(times) + const resolved: ResolvedSelection = { + start: resolveBound(selection.start, times), + end: resolveBound(selection.end, times), + } + + const latest = useRef({ times, equal, mode, resolved, onChange, onPlayStateChange }) + latest.current = { times, equal, mode, resolved, onChange, onPlayStateChange } + + const trackRef = useRef(null) + const dragHandleRef = useRef(null) + const playTimeoutRef = useRef | null>(null) + const playheadRef = useRef(resolved.end) + const [playing, setPlaying] = useState(false) + + // Window-level drag listeners: created once, reading current state via refs. + const dragHandlersRef = useRef<{ move: (event: PointerEvent) => void; up: () => void } | null>(null) + if (dragHandlersRef.current === null) { + const move = (event: PointerEvent) => { + const handle = dragHandleRef.current + if (handle === null) return + const fraction = trackFraction(trackRef.current, event.clientX) + if (fraction === null) return + const state = latest.current + emitHandleMove(handle, timeAtFraction(state.times, state.equal, fraction), state) + } + const up = () => { + dragHandleRef.current = null + window.removeEventListener("pointermove", move) + window.removeEventListener("pointerup", up) + } + dragHandlersRef.current = { move, up } + } + + useEffect(() => { + return () => { + if (playTimeoutRef.current !== null) clearTimeout(playTimeoutRef.current) + const handlers = dragHandlersRef.current + if (handlers !== null) { + window.removeEventListener("pointermove", handlers.move) + window.removeEventListener("pointerup", handlers.up) + } + } + }, []) + + if (times.length < 2) return null + + function stopPlayback(): void { + if (playTimeoutRef.current !== null) { + clearTimeout(playTimeoutRef.current) + playTimeoutRef.current = null + } + setPlaying(false) + latest.current.onPlayStateChange?.(false) + } + + function tick(): void { + playTimeoutRef.current = null + const state = latest.current + const index = state.times.indexOf(playheadRef.current) + if (index < 0 || index >= state.times.length - 1) { + stopPlayback() + return + } + const next = state.times[index + 1] + playheadRef.current = next + if (state.mode === "single") { + state.onChange({ start: next, end: next }) + } else { + state.onChange({ start: state.resolved.start, end: next }) + } + if (index + 1 >= state.times.length - 1) { + stopPlayback() + } else { + playTimeoutRef.current = setTimeout(tick, playStepMs(state.times.length)) + } + } + + function handlePlayClick(): void { + if (playing) { + stopPlayback() + return + } + let head = resolved.end + const lastTime = times[times.length - 1] + if (head >= lastTime) { + // Replay: restart from the beginning (spec 08 §3). + head = mode === "single" ? times[0] : resolved.start + if (mode === "single") { + onChange({ start: head, end: head }) + } else if (head !== resolved.end) { + onChange({ start: resolved.start, end: head }) + } + } + playheadRef.current = head + setPlaying(true) + onPlayStateChange?.(true) + playTimeoutRef.current = setTimeout(tick, playStepMs(times.length)) + } + + function handlePointerDown(event: ReactPointerEvent): void { + const fraction = trackFraction(trackRef.current, event.clientX) + if (fraction === null) return + event.preventDefault() + const startDistance = Math.abs(fraction - fractionOf(times, equal, resolved.start)) + const endDistance = Math.abs(fraction - fractionOf(times, equal, resolved.end)) + const handle: HandleId = mode === "single" ? "end" : startDistance < endDistance ? "start" : "end" + dragHandleRef.current = handle + emitHandleMove(handle, timeAtFraction(times, equal, fraction), latest.current) + const handlers = dragHandlersRef.current + if (handlers !== null) { + window.addEventListener("pointermove", handlers.move) + window.addEventListener("pointerup", handlers.up) + } + } + + function handleKeyDown(handle: HandleId, event: ReactKeyboardEvent): void { + const currentTime = handle === "start" ? resolved.start : resolved.end + const currentIndex = times.indexOf(currentTime) + let nextIndex: number + switch (event.key) { + case "ArrowLeft": + case "ArrowDown": + nextIndex = currentIndex - 1 + break + case "ArrowRight": + case "ArrowUp": + nextIndex = currentIndex + 1 + break + case "Home": + nextIndex = 0 + break + case "End": + nextIndex = times.length - 1 + break + default: + return + } + event.preventDefault() + const clampedIndex = Math.max(0, Math.min(times.length - 1, nextIndex)) + emitHandleMove(handle, times[clampedIndex], latest.current) + } + + const startFraction = fractionOf(times, equal, resolved.start) + const endFraction = fractionOf(times, equal, resolved.end) + const readout = + resolved.start === resolved.end + ? formatTime(resolved.end, grain, locale) + : formatTimeRange(resolved.start, resolved.end, grain, locale) + + function renderHandle(handle: HandleId, fraction: number, label: string) { + const time = handle === "start" ? resolved.start : resolved.end + return ( +
handleKeyDown(handle, event)} + /> + ) + } + + return ( +
+ {playable && ( + + )} +
+ + ) +} diff --git a/packages/charts2/src/react/chrome/Tooltip.test.tsx b/packages/charts2/src/react/chrome/Tooltip.test.tsx new file mode 100644 index 00000000000..3ebc1d65efa --- /dev/null +++ b/packages/charts2/src/react/chrome/Tooltip.test.tsx @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from "vitest" +import { cleanup, render } from "@testing-library/react" + +import type { TooltipModel } from "../../core/scene/nodes.ts" +import { computeTooltipPlacement, Tooltip, TOOLTIP_CURSOR_OFFSET } from "./Tooltip.tsx" + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +afterEach(cleanup) + +const CARD = { width: 100, height: 60 } +const BOUNDS = { width: 400, height: 300 } + +describe("computeTooltipPlacement (spec 06 §3)", () => { + it("places the card right and below the cursor by default (center)", () => { + expect(computeTooltipPlacement(200, 150, CARD, BOUNDS)).toEqual({ left: 212, top: 162 }) + }) + + it("stays inside bounds at the top-left corner", () => { + expect(computeTooltipPlacement(0, 0, CARD, BOUNDS)).toEqual({ + left: TOOLTIP_CURSOR_OFFSET, + top: TOOLTIP_CURSOR_OFFSET, + }) + }) + + it("flips left of the cursor at the top-right corner", () => { + expect(computeTooltipPlacement(395, 5, CARD, BOUNDS)).toEqual({ left: 283, top: 17 }) + }) + + it("flips above the cursor at the bottom-left corner", () => { + expect(computeTooltipPlacement(5, 295, CARD, BOUNDS)).toEqual({ left: 17, top: 223 }) + }) + + it("flips both axes at the bottom-right corner", () => { + expect(computeTooltipPlacement(395, 295, CARD, BOUNDS)).toEqual({ left: 283, top: 223 }) + }) + + it("clamps to the frame when the card cannot fit either side", () => { + const placement = computeTooltipPlacement(50, 150, CARD, { width: 90, height: 300 }) + expect(placement.left).toBe(0) + }) +}) + +const model: TooltipModel = { + title: "2021–22", + titleAnnotation: "fiscal year", + subtitle: "Total spending (billion CAD)", + rows: [ + { seriesKey: "Ontario", label: "Ontario", swatch: "#112233", valueText: "$186.4 billion", emphasized: true }, + { seriesKey: "Quebec", label: "Quebec", swatch: "#445566", valueText: "$140.5 billion", emphasized: false }, + { seriesKey: "Nova Scotia", label: "Nova Scotia", swatch: "#778899", valueText: "No data", emphasized: false, notice: "missing" }, + { seriesKey: "Alberta", label: "Alberta", swatch: "#99aabb", valueText: "—", emphasized: false, notice: "missing" }, + ], + totalRow: { seriesKey: "_total", label: "Total", swatch: "#000000", valueText: "$326.9 billion", emphasized: false }, + footers: [ + { icon: "notice", text: "Data from 2019" }, + { icon: "projection", text: "Projected data" }, + ], +} + +describe("Tooltip rendering (spec 06 §1)", () => { + it("renders the model verbatim: title, annotation, subtitle, rows in order, total, footers", () => { + const { container } = render() + + const title = container.querySelector(".bcds2-tooltip__title") + expect(title?.textContent).toBe("2021–22 fiscal year") + expect(container.querySelector(".bcds2-tooltip__subtitle")?.textContent).toBe("Total spending (billion CAD)") + + const rows = [...container.querySelectorAll(".bcds2-tooltip__rows .bcds2-tooltip__row")] + expect(rows.map((row) => row.querySelector(".bcds2-tooltip__label")?.textContent)).toEqual([ + "Ontario", + "Quebec", + "Nova Scotia", + "Alberta", + ]) + expect(rows.map((row) => row.querySelector(".bcds2-tooltip__value")?.textContent)).toEqual([ + "$186.4 billion", + "$140.5 billion", + "No data", + "—", + ]) + + const total = container.querySelector(".bcds2-tooltip__row--total") + expect(total?.querySelector(".bcds2-tooltip__label")?.textContent).toBe("Total") + expect(total?.querySelector(".bcds2-tooltip__value")?.textContent).toBe("$326.9 billion") + + const footers = [...container.querySelectorAll(".bcds2-tooltip__footer-text")] + expect(footers.map((footer) => footer.textContent)).toEqual(["Data from 2019", "Projected data"]) + expect(container.querySelector(".bcds2-tooltip__footer--notice .bcds2-tooltip__footer-icon")).not.toBeNull() + expect(container.querySelector(".bcds2-tooltip__footer--projection svg")).not.toBeNull() + }) + + it("emphasizes the hovered row and mutes missing rows", () => { + const { container } = render() + + const ontario = container.querySelector('[data-series-key="Ontario"]') + expect(ontario?.className).toContain("bcds2-tooltip__row--emphasized") + + const novaScotia = container.querySelector('[data-series-key="Nova Scotia"]') + expect(novaScotia?.className).toContain("bcds2-tooltip__row--missing") + expect(novaScotia?.className).not.toContain("emphasized") + }) + + it("positions itself via computeTooltipPlacement", () => { + const { container } = render() + const card = container.querySelector(".bcds2-tooltip") as HTMLElement + // happy-dom measures 0×0, so the card sits at cursor + offset. + expect(card.style.left).toBe("42px") + expect(card.style.top).toBe("52px") + }) + + it("omits subtitle, total, and footers when absent", () => { + const sparse: TooltipModel = { title: "2020", rows: model.rows.slice(0, 1), footers: [] } + const { container } = render() + expect(container.querySelector(".bcds2-tooltip__subtitle")).toBeNull() + expect(container.querySelector(".bcds2-tooltip__row--total")).toBeNull() + expect(container.querySelector(".bcds2-tooltip__footers")).toBeNull() + }) +}) diff --git a/packages/charts2/src/react/chrome/Tooltip.tsx b/packages/charts2/src/react/chrome/Tooltip.tsx new file mode 100644 index 00000000000..3a93a2cd8ef --- /dev/null +++ b/packages/charts2/src/react/chrome/Tooltip.tsx @@ -0,0 +1,136 @@ +/** + * Tooltip card (spec 06). Renders a precomputed TooltipModel verbatim — + * all formatting happened upstream in the hover model — and positions + * itself near the cursor with smart flipping so the card always stays + * inside the chart frame. Pointer-events are disabled (CSS) so the card + * never steals hover from the plot. + */ + +import { useLayoutEffect, useRef, useState } from "react" +import type { TooltipModel, TooltipRow } from "../../core/scene/nodes.ts" + +export interface TooltipBounds { + width: number + height: number +} + +export interface TooltipSize { + width: number + height: number +} + +export interface TooltipPlacement { + left: number + top: number +} + +export interface TooltipProps { + model: TooltipModel + /** Cursor position, in the same coordinate space as `bounds`. */ + x: number + y: number + /** The frame the card must stay inside (usually the chart frame). */ + bounds: TooltipBounds +} + +/** Gap between the cursor and the near edge of the card. */ +export const TOOLTIP_CURSOR_OFFSET = 12 + +/** + * Place the card beside the cursor, flipping left/above when the default + * right/below placement would overflow `bounds`, then clamping into the + * frame (spec 06 §3: tooltip remains within frame bounds at all corners). + */ +export function computeTooltipPlacement(x: number, y: number, cardSize: TooltipSize, bounds: TooltipBounds): TooltipPlacement { + let left = x + TOOLTIP_CURSOR_OFFSET + if (left + cardSize.width > bounds.width) { + left = x - TOOLTIP_CURSOR_OFFSET - cardSize.width + } + let top = y + TOOLTIP_CURSOR_OFFSET + if (top + cardSize.height > bounds.height) { + top = y - TOOLTIP_CURSOR_OFFSET - cardSize.height + } + left = Math.max(0, Math.min(left, bounds.width - cardSize.width)) + top = Math.max(0, Math.min(top, bounds.height - cardSize.height)) + return { left, top } +} + +function ProjectionIcon() { + return ( + + ) +} + +function rowClassName(row: TooltipRow, total: boolean): string { + const classes = ["bcds2-tooltip__row"] + if (total) classes.push("bcds2-tooltip__row--total") + if (row.emphasized) classes.push("bcds2-tooltip__row--emphasized") + if (row.notice === "missing") classes.push("bcds2-tooltip__row--missing") + if (row.notice === "toleranced") classes.push("bcds2-tooltip__row--toleranced") + if (row.notice === "projected") classes.push("bcds2-tooltip__row--projected") + return classes.join(" ") +} + +function TooltipValueRow({ row, total = false }: { row: TooltipRow; total?: boolean }) { + return ( +
+
+ ) +} + +export function Tooltip({ model, x, y, bounds }: TooltipProps) { + const cardRef = useRef(null) + const [size, setSize] = useState({ width: 0, height: 0 }) + + useLayoutEffect(() => { + const card = cardRef.current + if (card === null) return + const width = card.offsetWidth + const height = card.offsetHeight + setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height })) + }) + + const placement = computeTooltipPlacement(x, y, size, bounds) + + return ( +
+
+ {model.title} + {model.titleAnnotation !== undefined && ( + {model.titleAnnotation} + )} +
+ {model.subtitle !== undefined &&
{model.subtitle}
} + {model.rows.length > 0 && ( +
+ {model.rows.map((row) => ( + + ))} +
+ )} + {model.totalRow !== undefined && } + {model.footers.length > 0 && ( +
+ {model.footers.map((footer, index) => ( +
+ {footer.icon === "projection" ? ( + + ) : ( + + )} + {footer.text} +
+ ))} +
+ )} +
+ ) +} diff --git a/packages/charts2/src/react/chrome/fuzzySearch.test.ts b/packages/charts2/src/react/chrome/fuzzySearch.test.ts new file mode 100644 index 00000000000..50c608f2c53 --- /dev/null +++ b/packages/charts2/src/react/chrome/fuzzySearch.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest" + +import { createFuzzySearch, foldAccents, fuzzyMatches, fuzzyScore } from "./fuzzySearch.ts" + +interface Entity { + name: string + aliases: string[] +} + +const entities: Entity[] = [ + { name: "Québec", aliases: ["QC"] }, + { name: "Ontario", aliases: ["ON"] }, + { name: "Global Affairs Canada", aliases: ["Foreign Affairs and International Trade", "DFAIT"] }, + { name: "Innovation, Science and Economic Development Canada", aliases: ["Industry Canada", "ISED"] }, +] + +function searcher() { + return createFuzzySearch(entities, (entity) => [entity.name, ...entity.aliases]) +} + +describe("foldAccents", () => { + it("strips combining diacritics", () => { + expect(foldAccents("Québec")).toBe("Quebec") + expect(foldAccents("Î.-P.-É.")).toBe("I.-P.-E.") + expect(foldAccents("plain")).toBe("plain") + }) +}) + +describe("fuzzy search (spec 07 §2)", () => { + it("matches accented names from unaccented queries", () => { + const results = searcher().search("quebec") + expect(results.map((entity) => entity.name)).toEqual(["Québec"]) + }) + + it("matches via aliases and dedupes to one result per entity", () => { + const dfait = searcher().search("DFAIT") + expect(dfait.map((entity) => entity.name)).toEqual(["Global Affairs Canada"]) + + const industry = searcher().search("industry") + expect(industry.map((entity) => entity.name)).toContain("Innovation, Science and Economic Development Canada") + expect(industry.length).toBe(new Set(industry).size) + }) + + it("ranks substring matches above subsequence matches", () => { + const substring = fuzzyScore("ontario", "ontario") + const subsequence = fuzzyScore("onro", "ontario") + expect(substring).not.toBeNull() + expect(subsequence).not.toBeNull() + expect(substring as number).toBeGreaterThan(subsequence as number) + }) + + it("returns no results for empty or whitespace queries", () => { + expect(searcher().search("")).toEqual([]) + expect(searcher().search(" ")).toEqual([]) + }) + + it("returns no results when nothing matches", () => { + expect(searcher().search("zzzz")).toEqual([]) + }) +}) + +describe("fuzzyMatches", () => { + it("treats empty queries as matching and respects accents/aliases", () => { + expect(fuzzyMatches("", ["Québec"])).toBe(true) + expect(fuzzyMatches("quebec", ["Québec"])).toBe(true) + expect(fuzzyMatches("dfait", ["Global Affairs Canada", "DFAIT"])).toBe(true) + expect(fuzzyMatches("xyzq", ["Ontario"])).toBe(false) + }) +}) diff --git a/packages/charts2/src/react/chrome/fuzzySearch.ts b/packages/charts2/src/react/chrome/fuzzySearch.ts new file mode 100644 index 00000000000..300183c7fe1 --- /dev/null +++ b/packages/charts2/src/react/chrome/fuzzySearch.ts @@ -0,0 +1,89 @@ +/** + * Accent- and alias-tolerant fuzzy search (spec 07 §2, spec 22 §3). + * + * Port of charts v1 `utils/FuzzySearch.ts`, stripped of its fuzzysort and + * lodash dependencies: a pure module with no imports. Matching is + * case-insensitive and accent-folded ("quebec" finds "Québec") and works + * over multiple keys per item (canonical name, French name, aliases, + * codes), deduping to the best-scoring key per item: + * 1. substring matches rank highest (earlier and tighter is better) + * 2. in-order subsequence matches rank below, with a word-start bonus + */ + +/** Strip combining diacritics: "Québec" → "Quebec", "Î.-P.-É." → "I.-P.-E.". */ +export function foldAccents(input: string): string { + return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +} + +function normalize(input: string): string { + return foldAccents(input).toLowerCase() +} + +/** + * Score a normalized query against a normalized target. Higher is better; + * null means no match. Substring matches always outrank subsequence matches. + */ +export function fuzzyScore(query: string, target: string): number | null { + if (query.length === 0) return null + + const index = target.indexOf(query) + if (index >= 0) { + return 1000 - index - (target.length - query.length) * 0.01 + } + + let score = 0 + let at = 0 + for (const ch of query) { + if (ch === " ") continue + const found = target.indexOf(ch, at) + if (found < 0) return null + const atWordStart = found === 0 || target[found - 1] === " " || target[found - 1] === "-" + score += atWordStart ? 4 : 1 + score -= (found - at) * 0.01 + at = found + 1 + } + return score +} + +export interface FuzzySearcher { + /** Best-first matches; empty queries return no results. */ + search: (query: string) => T[] +} + +/** + * Build a searcher over `items`, each exposing one or more searchable keys + * (e.g. name + aliases). Each item appears at most once in results, ranked + * by its best-matching key. Result order is stable for equal scores. + */ +export function createFuzzySearch(items: readonly T[], keysOf: (item: T) => readonly string[]): FuzzySearcher { + const entries = items.map((item) => ({ + item, + keys: keysOf(item).map(normalize), + })) + + return { + search(rawQuery: string): T[] { + const query = normalize(rawQuery.trim()) + if (query.length === 0) return [] + + const scored: { item: T; score: number }[] = [] + for (const entry of entries) { + let best: number | null = null + for (const key of entry.keys) { + const score = fuzzyScore(query, key) + if (score !== null && (best === null || score > best)) best = score + } + if (best !== null) scored.push({ item: entry.item, score: best }) + } + scored.sort((a, b) => b.score - a.score) + return scored.map((s) => s.item) + }, + } +} + +/** Convenience predicate: does the query match any of the keys? Empty queries match. */ +export function fuzzyMatches(query: string, keys: readonly string[]): boolean { + const normalized = normalize(query.trim()) + if (normalized.length === 0) return true + return keys.some((key) => fuzzyScore(normalized, normalize(key)) !== null) +} diff --git a/packages/charts2/src/react/chrome/index.ts b/packages/charts2/src/react/chrome/index.ts new file mode 100644 index 00000000000..dbf1492a950 --- /dev/null +++ b/packages/charts2/src/react/chrome/index.ts @@ -0,0 +1,11 @@ +// Interactive chrome (M9): tooltip, timeline, entity selector, tabs, +// settings, data table. Self-contained components — data in via props, +// state changes out via callbacks. Styles live in ../styles/charts.scss. + +export * from "./DataTable.tsx" +export * from "./EntitySelector.tsx" +export * from "./fuzzySearch.ts" +export * from "./SettingsMenu.tsx" +export * from "./Tabs.tsx" +export * from "./Timeline.tsx" +export * from "./Tooltip.tsx" diff --git a/packages/charts2/src/react/index.ts b/packages/charts2/src/react/index.ts new file mode 100644 index 00000000000..9570877d244 --- /dev/null +++ b/packages/charts2/src/react/index.ts @@ -0,0 +1,15 @@ +// React renderer + interaction layer (M7). SceneSVG is THE single +// scene→SVG renderer (browser and renderToStaticMarkup — spec 28 §1). + +export * from "./chrome/index.ts" // M9: Tooltip, Timeline, EntitySelector, Tabs, SettingsMenu, DataTable +export { Chart, type ChartProps, type RenderTooltipArgs } from "./Chart.tsx" +export { + emphasisFor, + emphasisReducer, + initialEmphasisState, + type EmphasisEvent, + type EmphasisModel, + type EmphasisState, +} from "./interaction/emphasisReducer.ts" +export { useUrlState, type SetViewState, type UseUrlStateOptions } from "./interaction/useUrlState.ts" +export { SceneSVG, type SceneSVGProps } from "./SceneSVG.tsx" diff --git a/packages/charts2/src/react/interaction/emphasisReducer.test.ts b/packages/charts2/src/react/interaction/emphasisReducer.test.ts new file mode 100644 index 00000000000..bd6ed809b51 --- /dev/null +++ b/packages/charts2/src/react/interaction/emphasisReducer.test.ts @@ -0,0 +1,135 @@ +/** + * Property tests for the emphasis state machine (spec 07 §3, spec 26 §3): + * random event sequences from a seeded PRNG (no Math.random — determinism) + * must never strand a state that references unknown keys or a dimmed chart + * with nothing emphasized. + */ + +import { describe, expect, it } from "vitest" + +import type { SeriesKey } from "../../core/types.ts" +import { + emphasisFor, + emphasisReducer, + initialEmphasisState, + type EmphasisEvent, + type EmphasisState, +} from "./emphasisReducer.ts" + +// --------------------------------------------------------------------------- +// Seeded PRNG — tiny LCG (numerical recipes constants), deterministic +// --------------------------------------------------------------------------- + +function lcg(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 4294967296 + } +} + +const KEYS: SeriesKey[] = ["Ontario", "Quebec", "Alberta", "Nova Scotia"] + +function randomEvent(next: () => number): EmphasisEvent { + const key = KEYS[Math.floor(next() * KEYS.length)] + const roll = next() + if (roll < 0.35) return { type: "hover-series", key } + if (roll < 0.55) return { type: "hover-clear" } + if (roll < 0.85) return { type: "toggle-focus", key } + if (roll < 0.93) return { type: "clear-focus" } + return { type: "escape" } +} + +function setEquals(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false + for (const key of a) if (!b.has(key)) return false + return true +} + +describe("emphasisReducer properties", () => { + it("random event sequences never strand an invalid state", () => { + const next = lcg(20260611) + for (let run = 0; run < 200; run++) { + let state = initialEmphasisState + for (let step = 0; step < 60; step++) { + const event = randomEvent(next) + const previous = state + state = emphasisReducer(state, event) + + // States only reference known keys. + expect(state.hover === null || KEYS.includes(state.hover)).toBe(true) + for (const key of state.focus) expect(KEYS).toContain(key) + + // Event-specific invariants. + if (event.type === "hover-series") { + expect(state.hover).toBe(event.key) + expect(setEquals(state.focus, previous.focus)).toBe(true) + } + if (event.type === "hover-clear") { + expect(state.hover).toBeNull() + expect(setEquals(state.focus, previous.focus)).toBe(true) + } + if (event.type === "toggle-focus") { + expect(state.focus.has(event.key)).toBe(!previous.focus.has(event.key)) + expect(state.hover).toBe(previous.hover) + } + if (event.type === "escape" || event.type === "clear-focus") { + expect(state.focus.size).toBe(0) + expect(state.hover).toBe(previous.hover) + } + + // Derived emphasis = focus ∪ hover; never an empty emphasis set. + const emphasis = emphasisFor(state) + if (state.hover === null && state.focus.size === 0) { + expect(emphasis.mode).toBe("idle") + } else { + expect(emphasis.mode).toBe("emphasis") + if (emphasis.mode === "emphasis") { + expect(emphasis.keys.size).toBeGreaterThan(0) + const expected = new Set(state.focus) + if (state.hover !== null) expected.add(state.hover) + expect(setEquals(emphasis.keys, expected)).toBe(true) + } + } + } + } + }) + + it("hover is transient and never alters focus", () => { + let state: EmphasisState = initialEmphasisState + state = emphasisReducer(state, { type: "toggle-focus", key: "Ontario" }) + const focusBefore = state.focus + state = emphasisReducer(state, { type: "hover-series", key: "Quebec" }) + expect(state.focus).toBe(focusBefore) + state = emphasisReducer(state, { type: "hover-clear" }) + expect(state.focus).toBe(focusBefore) + expect(state.hover).toBeNull() + }) + + it("escape clears focus only, leaving hover untouched", () => { + let state: EmphasisState = initialEmphasisState + state = emphasisReducer(state, { type: "toggle-focus", key: "Ontario" }) + state = emphasisReducer(state, { type: "hover-series", key: "Quebec" }) + state = emphasisReducer(state, { type: "escape" }) + expect(state.focus.size).toBe(0) + expect(state.hover).toBe("Quebec") + }) + + it("toggling focus twice round-trips to an empty set", () => { + let state: EmphasisState = initialEmphasisState + state = emphasisReducer(state, { type: "toggle-focus", key: "Alberta" }) + expect(state.focus.has("Alberta")).toBe(true) + state = emphasisReducer(state, { type: "toggle-focus", key: "Alberta" }) + expect(state.focus.size).toBe(0) + expect(emphasisFor(state).mode).toBe("idle") + }) + + it("no-op events return the same state reference (cheap renders)", () => { + const cleared = emphasisReducer(initialEmphasisState, { type: "hover-clear" }) + expect(cleared).toBe(initialEmphasisState) + const escaped = emphasisReducer(initialEmphasisState, { type: "escape" }) + expect(escaped).toBe(initialEmphasisState) + const hovered = emphasisReducer(initialEmphasisState, { type: "hover-series", key: "Quebec" }) + expect(emphasisReducer(hovered, { type: "hover-series", key: "Quebec" })).toBe(hovered) + }) +}) diff --git a/packages/charts2/src/react/interaction/emphasisReducer.ts b/packages/charts2/src/react/interaction/emphasisReducer.ts new file mode 100644 index 00000000000..565c77716a3 --- /dev/null +++ b/packages/charts2/src/react/interaction/emphasisReducer.ts @@ -0,0 +1,60 @@ +/** + * Emphasis state machine (spec 07 §3): hover / focus / dimming. + * + * Pure functions — no React. The Chart component drives this through + * useReducer; tests exercise it directly. Hover is transient and never + * alters focus; Escape clears focus only. Emphasis styling is applied by + * SceneSVG via seriesKey opacity — it NEVER triggers relayout. + */ + +import type { SeriesKey } from "../../core/types.ts" + +export interface EmphasisState { + /** Series under the pointer, or null. Transient. */ + hover: SeriesKey | null + /** Clicked-in focus set. Persists in the URL (`focus=`). */ + focus: ReadonlySet +} + +export type EmphasisEvent = + | { type: "hover-series"; key: SeriesKey } + | { type: "hover-clear" } + | { type: "toggle-focus"; key: SeriesKey } + | { type: "clear-focus" } + | { type: "escape" } + +export const initialEmphasisState: EmphasisState = { hover: null, focus: new Set() } + +export function emphasisReducer(state: EmphasisState, event: EmphasisEvent): EmphasisState { + switch (event.type) { + case "hover-series": + return state.hover === event.key ? state : { hover: event.key, focus: state.focus } + case "hover-clear": + return state.hover === null ? state : { hover: null, focus: state.focus } + case "toggle-focus": { + const focus = new Set(state.focus) + if (focus.has(event.key)) focus.delete(event.key) + else focus.add(event.key) + return { hover: state.hover, focus } + } + case "clear-focus": + case "escape": + // Escape clears focus only — hover is owned by the pointer. + return state.focus.size === 0 ? state : { hover: state.hover, focus: new Set() } + } +} + +/** + * What SceneSVG consumes: idle (everything full opacity) or an emphasized + * key set (everything else dimmed). Derived, never stored. + */ +export type EmphasisModel = + | { mode: "idle" } + | { mode: "emphasis"; keys: ReadonlySet } + +export function emphasisFor(state: EmphasisState): EmphasisModel { + if (state.hover === null && state.focus.size === 0) return { mode: "idle" } + const keys = new Set(state.focus) + if (state.hover !== null) keys.add(state.hover) + return { mode: "emphasis", keys } +} diff --git a/packages/charts2/src/react/interaction/useUrlState.test.ts b/packages/charts2/src/react/interaction/useUrlState.test.ts new file mode 100644 index 00000000000..39903bd6a47 --- /dev/null +++ b/packages/charts2/src/react/interaction/useUrlState.test.ts @@ -0,0 +1,115 @@ +/** + * useUrlState round-trips ViewState through window.location.search using the + * core codec: read once on mount, debounced history.replaceState writes, + * foreign params untouched (spec 02 §3). + */ + +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { useUrlState } from "./useUrlState.ts" + +function setUrl(search: string): void { + window.history.replaceState(null, "", `/page${search}`) +} + +beforeEach(() => { + vi.useFakeTimers() + setUrl("") +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe("useUrlState", () => { + it("reads owned params from the URL once on mount, layered over the initial state", () => { + setUrl("?time=2019..2024&entities=Ontario~Quebec&foreign=1&yScale=log") + const { result } = renderHook(() => + useUrlState("year", { initial: { stackMode: "relative" } }), + ) + const [state] = result.current + expect(state.time).toEqual({ start: 2019, end: 2024 }) + expect(state.entities).toEqual(["Ontario", "Quebec"]) + expect(state.yScale).toBe("log") + // Initial state survives where the URL says nothing. + expect(state.stackMode).toBe("relative") + }) + + it("does not write the URL back on mount", () => { + setUrl("?time=2020&foreign=1") + renderHook(() => useUrlState("year")) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(window.location.search).toBe("?time=2020&foreign=1") + }) + + it("writes state changes via debounced replaceState, preserving foreign params", () => { + setUrl("?foreign=1&time=2019..2024") + const { result } = renderHook(() => useUrlState("year")) + act(() => { + const [, setState] = result.current + setState((prev) => ({ ...prev, yScale: "log", entities: ["Ontario"] })) + }) + // Debounced: nothing yet. + expect(window.location.search).toBe("?foreign=1&time=2019..2024") + act(() => { + vi.advanceTimersByTime(200) + }) + const params = new URLSearchParams(window.location.search) + expect(params.get("foreign")).toBe("1") + expect(params.get("yScale")).toBe("log") + expect(params.get("entities")).toBe("Ontario") + expect(params.get("time")).toBe("2019..2024") + }) + + it("round-trips: written params decode back to the same state", () => { + const first = renderHook(() => useUrlState("year")) + act(() => { + const [, setState] = first.result.current + setState({ + time: { start: 2019, end: "latest" }, + entities: ["Nova Scotia", "Ontario"], + focus: ["Ontario"], + yScale: "log", + }) + }) + act(() => { + vi.advanceTimersByTime(200) + }) + first.unmount() + + const second = renderHook(() => useUrlState("year")) + expect(second.result.current[0]).toEqual({ + time: { start: 2019, end: "latest" }, + entities: ["Nova Scotia", "Ontario"], + focus: ["Ontario"], + yScale: "log", + }) + }) + + it("drops unknown owned-param values with a clean state (codec never throws)", () => { + setUrl("?yScale=banana&tab=line") + const { result } = renderHook(() => useUrlState("year")) + expect(result.current[0].yScale).toBeUndefined() + expect(result.current[0].tab).toBe("line") + }) + + it("is inert when disabled: no URL read, no URL write", () => { + setUrl("?time=2020&foreign=1") + const { result } = renderHook(() => + useUrlState("year", { initial: { yScale: "log" }, enabled: false }), + ) + expect(result.current[0]).toEqual({ yScale: "log" }) + act(() => { + const [, setState] = result.current + setState({ yScale: "linear" }) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(window.location.search).toBe("?time=2020&foreign=1") + expect(result.current[0]).toEqual({ yScale: "linear" }) + }) +}) diff --git a/packages/charts2/src/react/interaction/useUrlState.ts b/packages/charts2/src/react/interaction/useUrlState.ts new file mode 100644 index 00000000000..e88d8c57396 --- /dev/null +++ b/packages/charts2/src/react/interaction/useUrlState.ts @@ -0,0 +1,71 @@ +/** + * useUrlState — ViewState ↔ window.location.search (spec 02 §3). + * + * Reads once on mount via paramsToViewState (which ignores unknown values + * with diagnostics and unknown names silently), writes via a debounced + * history.replaceState. Params the codec does not own are preserved on + * write — chart params share the page URL with the host application. + * SSR-safe: no window access during render beyond a typeof guard. + */ + +import { useEffect, useRef, useState } from "react" + +import { paramsToViewState, viewStateToParams } from "../../core/definition/urlState.ts" +import type { TimeGrain, ViewState } from "../../core/types.ts" + +/** Param names written by viewStateToParams; cleared before each write. */ +const OWNED_PARAMS = [ + "tab", + "time", + "entities", + "focus", + "yScale", + "stackMode", + "facet", + "tableSort", + "tableScope", +] as const + +const WRITE_DEBOUNCE_MS = 150 + +export type SetViewState = (next: ViewState | ((prev: ViewState) => ViewState)) => void + +export interface UseUrlStateOptions { + /** Base state; URL params layer on top of it at mount. */ + initial?: ViewState + /** When false, behaves as plain local state (no URL reads or writes). */ + enabled?: boolean +} + +export function useUrlState(grain: TimeGrain, options: UseUrlStateOptions = {}): [ViewState, SetViewState] { + const enabled = options.enabled ?? true + + const [state, setState] = useState(() => { + const initial = options.initial ?? {} + if (!enabled || typeof window === "undefined") return initial + const { state: fromUrl } = paramsToViewState(new URLSearchParams(window.location.search), grain) + return { ...initial, ...fromUrl } + }) + + const mounted = useRef(false) + useEffect(() => { + if (!enabled || typeof window === "undefined") return + if (!mounted.current) { + // The first state came FROM the URL — writing it back would be a no-op + // at best and would clobber host params present before hydration. + mounted.current = true + return + } + const timer = window.setTimeout(() => { + const params = new URLSearchParams(window.location.search) + for (const name of OWNED_PARAMS) params.delete(name) + for (const [name, value] of viewStateToParams(state, grain)) params.set(name, value) + const query = params.toString() + const url = `${window.location.pathname}${query === "" ? "" : `?${query}`}${window.location.hash}` + window.history.replaceState(window.history.state, "", url) + }, WRITE_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [state, grain, enabled]) + + return [state, setState] +} diff --git a/packages/charts2/src/react/styles/charts.scss b/packages/charts2/src/react/styles/charts.scss new file mode 100644 index 00000000000..e20eaae6bad --- /dev/null +++ b/packages/charts2/src/react/styles/charts.scss @@ -0,0 +1,635 @@ +// ============================================================================= +// @buildcanada/charts2 — chrome styles +// +// Standalone stylesheet: no imports, no external colour dependencies. +// All classes are BEM under the `bcds2-` prefix. +// +// Theming contract — override these CSS custom properties on any ancestor +// (every colour in this sheet derives from one of them, with the listed +// fallback used when unset): +// +// --bcds2-bg surfaces: tooltip card, popovers, table cells (#ffffff) +// --bcds2-text primary text (#1f2a33) +// --bcds2-grid hairlines: borders, tracks, ticks, gridlines (#d4dadf) +// --bcds2-accent interactive accents: handles, active tab, +// focus rings, sort arrows (#1d4ed8) +// +// Muted/secondary text is the text colour at reduced opacity, so a single +// --bcds2-text override restyles every label consistently. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Tooltip (spec 06) +// ----------------------------------------------------------------------------- + +.bcds2-tooltip { + position: absolute; + z-index: 10; + pointer-events: none; + min-width: 160px; + max-width: 320px; + padding: 8px 10px; + background: var(--bcds2-bg, #ffffff); + color: var(--bcds2-text, #1f2a33); + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + font-size: 13px; + line-height: 1.35; + + &__title { + font-weight: 700; + margin-bottom: 2px; + } + + &__title-annotation { + font-weight: 400; + opacity: 0.6; + } + + &__subtitle { + opacity: 0.6; + font-size: 12px; + margin-bottom: 4px; + } + + &__rows { + display: flex; + flex-direction: column; + gap: 2px; + margin: 4px 0; + } + + &__row { + display: flex; + align-items: center; + gap: 6px; + + &--emphasized { + font-weight: 700; + } + + &--missing { + opacity: 0.55; + } + + &--total { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--bcds2-grid, #d4dadf); + font-weight: 600; + } + } + + &__swatch { + flex: none; + width: 10px; + height: 10px; + border-radius: 2px; + } + + &__label { + margin-right: 12px; + } + + &__value { + margin-left: auto; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + &__footers { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--bcds2-grid, #d4dadf); + display: flex; + flex-direction: column; + gap: 3px; + font-size: 12px; + opacity: 0.7; + } + + &__footer { + display: flex; + align-items: center; + gap: 6px; + } + + &__footer-icon { + flex: none; + } +} + +// ----------------------------------------------------------------------------- +// Timeline (spec 08 §2–3) +// ----------------------------------------------------------------------------- + +.bcds2-timeline { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; + color: var(--bcds2-text, #1f2a33); + font-size: 12px; + + &__play { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 50%; + background: var(--bcds2-bg, #ffffff); + color: var(--bcds2-accent, #1d4ed8); + cursor: pointer; + + &:hover { + border-color: var(--bcds2-accent, #1d4ed8); + } + + &:focus-visible { + outline: 2px solid var(--bcds2-accent, #1d4ed8); + outline-offset: 1px; + } + } + + &__track { + position: relative; + flex: 1; + height: 24px; + cursor: pointer; + touch-action: none; + } + + &__rail { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 2px; + transform: translateY(-50%); + background: var(--bcds2-grid, #d4dadf); + border-radius: 1px; + } + + &__tick { + position: absolute; + top: 50%; + width: 1px; + height: 8px; + transform: translate(-50%, -50%); + background: var(--bcds2-grid, #d4dadf); + } + + &__range { + position: absolute; + top: 50%; + height: 4px; + transform: translateY(-50%); + background: var(--bcds2-accent, #1d4ed8); + opacity: 0.35; + border-radius: 2px; + pointer-events: none; + } + + &__handle { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + transform: translate(-50%, -50%); + background: var(--bcds2-accent, #1d4ed8); + border: 2px solid var(--bcds2-bg, #ffffff); + border-radius: 50%; + box-shadow: 0 0 0 1px var(--bcds2-grid, #d4dadf); + cursor: grab; + + &:focus-visible { + outline: 2px solid var(--bcds2-accent, #1d4ed8); + outline-offset: 2px; + } + } + + &__readout { + flex: none; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } +} + +// ----------------------------------------------------------------------------- +// Entity selector (spec 07 §2) +// ----------------------------------------------------------------------------- + +.bcds2-entity-selector { + display: flex; + flex-direction: column; + gap: 8px; + color: var(--bcds2-text, #1f2a33); + font-size: 13px; + + &__search { + padding: 6px 8px; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + background: var(--bcds2-bg, #ffffff); + color: inherit; + font: inherit; + + &:focus-visible { + outline: 2px solid var(--bcds2-accent, #1d4ed8); + outline-offset: -1px; + } + } + + &__controls { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + } + + &__sort { + display: inline-flex; + align-items: center; + gap: 4px; + + select { + font: inherit; + color: inherit; + background: var(--bcds2-bg, #ffffff); + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + padding: 2px 4px; + } + } + + &__order { + padding: 2px 6px; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + background: var(--bcds2-bg, #ffffff); + color: inherit; + cursor: pointer; + } + + &__bulk { + margin-left: auto; + display: inline-flex; + gap: 8px; + } + + &__action { + padding: 0; + border: none; + background: none; + color: var(--bcds2-accent, #1d4ed8); + font: inherit; + font-size: 12px; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + &__groups { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + } + + &__group-header { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.65; + } + + &__row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 4px; + border-radius: 3px; + cursor: pointer; + + &:hover { + background: var(--bcds2-grid, #d4dadf); + } + + &--no-data .bcds2-entity-selector__name { + opacity: 0.5; + } + } + + &__tag { + font-size: 10px; + padding: 0 4px; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 8px; + opacity: 0.65; + white-space: nowrap; + } + + &__value { + margin-left: auto; + font-variant-numeric: tabular-nums; + opacity: 0.75; + white-space: nowrap; + } +} + +// ----------------------------------------------------------------------------- +// Tabs (spec 10 §3) +// ----------------------------------------------------------------------------- + +.bcds2-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--bcds2-grid, #d4dadf); + + &__tab { + padding: 6px 12px; + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--bcds2-text, #1f2a33); + font: inherit; + font-size: 13px; + opacity: 0.7; + cursor: pointer; + + &:hover { + opacity: 1; + } + + &:focus-visible { + outline: 2px solid var(--bcds2-accent, #1d4ed8); + outline-offset: -2px; + } + + &--active { + opacity: 1; + font-weight: 600; + border-bottom-color: var(--bcds2-accent, #1d4ed8); + } + } +} + +// ----------------------------------------------------------------------------- +// Settings menu (spec 10 §4) +// ----------------------------------------------------------------------------- + +.bcds2-settings { + position: relative; + display: inline-block; + + &__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + background: var(--bcds2-bg, #ffffff); + color: var(--bcds2-text, #1f2a33); + cursor: pointer; + + &:hover { + border-color: var(--bcds2-accent, #1d4ed8); + } + + &[aria-expanded="true"] { + color: var(--bcds2-accent, #1d4ed8); + border-color: var(--bcds2-accent, #1d4ed8); + } + } + + &__popover { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + min-width: 200px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + background: var(--bcds2-bg, #ffffff); + color: var(--bcds2-text, #1f2a33); + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + font-size: 13px; + } + + &__item { + margin: 0; + + &--toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + } + + &--radio { + border: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 3px; + } + } + + &__legend { + padding: 0; + margin-bottom: 2px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.65; + } + + &__option { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + } +} + +// ----------------------------------------------------------------------------- +// Data table (spec 22) +// ----------------------------------------------------------------------------- + +.bcds2-data-table { + display: flex; + flex-direction: column; + gap: 8px; + color: var(--bcds2-text, #1f2a33); + font-size: 13px; + + &__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + &__scope { + display: inline-flex; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + overflow: hidden; + } + + &__scope-button { + padding: 4px 10px; + border: none; + background: var(--bcds2-bg, #ffffff); + color: inherit; + font: inherit; + font-size: 12px; + cursor: pointer; + + & + & { + border-left: 1px solid var(--bcds2-grid, #d4dadf); + } + + &[aria-pressed="true"] { + background: var(--bcds2-accent, #1d4ed8); + color: var(--bcds2-bg, #ffffff); + } + } + + &__search { + padding: 4px 8px; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + background: var(--bcds2-bg, #ffffff); + color: inherit; + font: inherit; + font-size: 12px; + } + + &__scroll { + overflow: auto; + border: 1px solid var(--bcds2-grid, #d4dadf); + border-radius: 4px; + } + + &__table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + font-variant-numeric: tabular-nums; + } + + &__header { + position: sticky; + top: 0; + z-index: 1; + padding: 6px 10px; + background: var(--bcds2-bg, #ffffff); + border-bottom: 1px solid var(--bcds2-grid, #d4dadf); + text-align: left; + font-weight: 600; + white-space: nowrap; + + &--numeric { + text-align: right; + } + + // Entity header pinned both top and left. + &--entity { + left: 0; + z-index: 2; + } + } + + &__sort-button { + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + font-weight: inherit; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bcds2-accent, #1d4ed8); + outline-offset: 1px; + } + } + + &__sort-arrow { + margin-left: 4px; + font-size: 9px; + color: var(--bcds2-accent, #1d4ed8); + } + + &__metric { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + } + + &__metric-unit, + &__metric-time { + font-weight: 400; + font-size: 11px; + opacity: 0.65; + } + + &__cell { + padding: 5px 10px; + border-bottom: 1px solid var(--bcds2-grid, #d4dadf); + text-align: left; + font-weight: 400; + white-space: nowrap; + + &--numeric { + text-align: right; + } + + &--missing { + opacity: 0.45; + } + + // Entity column pinned left while the table scrolls horizontally. + &--entity { + position: sticky; + left: 0; + z-index: 1; + background: var(--bcds2-bg, #ffffff); + font-weight: 600; + } + } + + &__marker { + margin-left: 4px; + font-size: 10px; + opacity: 0.65; + cursor: help; + } + + &__row:hover &__cell { + background: var(--bcds2-bg, #ffffff); + filter: brightness(0.97); + } +} diff --git a/packages/charts2/src/samples.test.ts b/packages/charts2/src/samples.test.ts new file mode 100644 index 00000000000..768d6ce18aa --- /dev/null +++ b/packages/charts2/src/samples.test.ts @@ -0,0 +1,43 @@ +import { readdirSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" + +import { renderDefinitionToSvg, XML_DECLARATION } from "./cli/render.ts" +import { validateInput } from "./cli/validate.ts" + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const samplesDir = join(packageRoot, "samples") +const sampleFiles = readdirSync(samplesDir) + .filter((file) => file.endsWith(".json")) + .sort() + +describe("samples", () => { + it("has committed sample definitions", () => { + expect(sampleFiles).toEqual([ + "discrete-bar-population.json", + "line-federal-departments.json", + "line-provincial-budgets.json", + "stacked-area-government-debt.json", + "stacked-bar-government-debt.json", + "stacked-discrete-bar-provincial-composition.json", + ]) + }) + + it.each(sampleFiles)("%s validates and renders deterministically", (file) => { + const path = join(samplesDir, file) + const validation = validateInput(path) + expect(validation.errors, JSON.stringify(validation.diagnostics, null, 2)).toBe(0) + expect(validation.diagnostics).toEqual([]) + + const first = renderDefinitionToSvg({ definitionPath: path }) + const second = renderDefinitionToSvg({ definitionPath: path }) + + expect(first.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]) + expect(first.svg).not.toBeNull() + expect(first.svg).toBe(second.svg) + expect(first.svg?.startsWith(`${XML_DECLARATION}\n({ start: 2016, end: 2022 }) + return ( +
+ +
+ ) +} + +export const TimelineRange: Story = { + render: () => , +} + +function TimelineSingleHarness() { + const times = [2019, 2020, 2021, 2022, 2023] + const [selection, setSelection] = useState({ start: 2023, end: 2023 }) + return ( +
+ +
+ ) +} + +export const TimelineSingle: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// EntitySelector (spec 07 §2) +// --------------------------------------------------------------------------- + +function EntitySelectorHarness() { + const dataset = storyDataset("federal-departments") + const [selected, setSelected] = useState(["National Defence", "Health Canada"]) + return ( +
+ +
+ ) +} + +export const EntitySelectorMulti: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// DataTable (spec 22) +// --------------------------------------------------------------------------- + +function DataTableHarness() { + const dataset = storyDataset("provincial-budgets") + const manifest = dataset.manifest + const [scope, setScope] = useState("selected") + const [sort, setSort] = useState({ column: "entity", order: "asc" }) + const [searchQuery, setSearchQuery] = useState("") + return ( +
+ +
+ ) +} + +export const DataTableRange: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Tooltip (spec 06) — a hand-built model, positioned statically +// --------------------------------------------------------------------------- + +const tooltipModel: TooltipModel = { + title: "2024–25", + titleAnnotation: "fiscal year", + subtitle: "Total spending (billion CAD)", + rows: [ + { seriesKey: "entity:Ontario", label: "Ontario", swatch: "#516c50", valueText: "$214.5", emphasized: true }, + { seriesKey: "entity:Quebec", label: "Quebec", swatch: "#89926c", valueText: "$161.0", emphasized: false }, + { + seriesKey: "entity:Nova Scotia", + label: "Nova Scotia", + swatch: "#b8b3a0", + valueText: "$16.5", + emphasized: false, + notice: "toleranced", + }, + ], + totalRow: { + seriesKey: "total", + label: "Total", + swatch: "transparent", + valueText: "$392.0", + emphasized: false, + }, + footers: [ + { icon: "notice", text: "Nova Scotia: value from 2023–24 (nearest within tolerance)" }, + { icon: "projection", text: "2024–25 values are projections" }, + ], +} + +export const TooltipCard: Story = { + render: () => ( +
+ +
+ ), +} + +// --------------------------------------------------------------------------- +// SettingsMenu (spec 10 §4) +// --------------------------------------------------------------------------- + +function SettingsMenuHarness() { + const [relative, setRelative] = useState(false) + const [scale, setScale] = useState("linear") + const items: SettingsItem[] = [ + { kind: "toggle", id: "relative", label: "Relative", value: relative, onChange: setRelative }, + { + kind: "radio", + id: "scale", + label: "Y scale", + options: [ + { value: "linear", label: "Linear" }, + { value: "log", label: "Log" }, + ], + value: scale, + onChange: setScale, + }, + ] + return ( +
+ +
+ ) +} + +export const Settings: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Tabs (spec 10 §3) +// --------------------------------------------------------------------------- + +function TabsHarness() { + const [active, setActive] = useState("line") + return +} + +export const TabRow: Story = { + render: () => , +} diff --git a/packages/charts2/src/stories/DiscreteBar.stories.tsx b/packages/charts2/src/stories/DiscreteBar.stories.tsx new file mode 100644 index 00000000000..5771529c161 --- /dev/null +++ b/packages/charts2/src/stories/DiscreteBar.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/DiscreteBar", + component: Chart, + parameters: { + docs: { + description: { + component: + "Discrete bar chart (spec 13): one bar per entity at a single time (or no " + + "time dimension at all), with value labels and sort control. Negative values " + + "extend left of the zero line.", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const populationSnapshot = storyDataset("population-snapshot") +const pathological = storyDataset("pathological") + +const populationDefinition = { + title: "Population by province and territory", + data: "population-snapshot", + y: ["population"], + types: ["discrete-bar"], + sourceText: "Statistics Canada", +} + +export const Population: Story = { + render: () => ( + + ), +} + +export const SortedByName: Story = { + render: () => ( + + ), +} + +export const SortedAscending: Story = { + render: () => ( + + ), +} + +/** All-negative values from the pathological fixture (spec 26 §2). */ +export const NegativeValues: Story = { + render: () => ( + + ), +} diff --git a/packages/charts2/src/stories/Line.stories.tsx b/packages/charts2/src/stories/Line.stories.tsx new file mode 100644 index 00000000000..0da95cdbbd8 --- /dev/null +++ b/packages/charts2/src/stories/Line.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/Line", + component: Chart, + parameters: { + docs: { + description: { + component: + "Line chart (spec 11): one line per selected entity, with hover emphasis, " + + "click-to-focus, and single-time collapse to a discrete bar. Data comes from " + + "the committed fixtures (spec 26 §2).", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const provincialBudgets = storyDataset("provincial-budgets") +const federalDepartments = storyDataset("federal-departments") + +const budgetsDefinition = { + title: "Provincial budget spending", + subtitle: "Total budgetary expenditure, public accounts basis", + data: "provincial-budgets", + y: ["total_spending"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + sourceText: "Provincial public accounts", +} + +export const ProvincialBudgets: Story = { + render: () => ( + + ), +} + +export const Relative: Story = { + render: () => ( + + ), +} + +export const French: Story = { + render: () => ( + + ), +} + +export const ManyEntities: Story = { + render: () => ( + + ), +} diff --git a/packages/charts2/src/stories/StackedArea.stories.tsx b/packages/charts2/src/stories/StackedArea.stories.tsx new file mode 100644 index 00000000000..a5373196ba8 --- /dev/null +++ b/packages/charts2/src/stories/StackedArea.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/StackedArea", + component: Chart, + parameters: { + docs: { + description: { + component: + "Stacked area chart (spec 14). The flagship demo: government debt as a " + + "share of GDP (scenario 27 A) — three debt levels divided by a shared GDP " + + "denominator, stacked over fiscal years.", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const governmentDebt = storyDataset("government-debt") + +const debtToGdpDefinition = { + title: "Government debt as a share of GDP", + subtitle: "Federal, provincial, and municipal debt divided by nominal GDP", + data: "government-debt", + y: ["federal_debt", "provincial_debt", "municipal_debt"], + types: ["stacked-area"], + sourceText: "Fiscal reference tables", +} + +/** The flagship demo: debt-to-GDP stacked over fiscal years. */ +export const DebtToGdp: Story = { + render: () => ( + + ), +} + +export const Relative: Story = { + render: () => ( + + ), +} + +export const French: Story = { + render: () => ( + + ), +} diff --git a/packages/charts2/src/stories/StackedBar.stories.tsx b/packages/charts2/src/stories/StackedBar.stories.tsx new file mode 100644 index 00000000000..daef1839de2 --- /dev/null +++ b/packages/charts2/src/stories/StackedBar.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/StackedBar", + component: Chart, + parameters: { + docs: { + description: { + component: + "Stacked bar chart (spec 15): one stacked column per time step, with a " + + "legend and absolute/relative stack modes.", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const governmentDebt = storyDataset("government-debt") + +const debtDefinition = { + title: "Government debt as a share of GDP", + subtitle: "Federal, provincial, and municipal debt divided by nominal GDP", + data: "government-debt", + y: ["federal_debt", "provincial_debt", "municipal_debt"], + types: ["stacked-bar"], + sourceText: "Fiscal reference tables", +} + +export const DebtToGdp: Story = { + render: () => ( + + ), +} + +export const Relative: Story = { + render: () => ( + + ), +} diff --git a/packages/charts2/src/stories/StackedDiscreteBar.stories.tsx b/packages/charts2/src/stories/StackedDiscreteBar.stories.tsx new file mode 100644 index 00000000000..4d2700928d0 --- /dev/null +++ b/packages/charts2/src/stories/StackedDiscreteBar.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/StackedDiscreteBar", + component: Chart, + parameters: { + docs: { + description: { + component: + "Stacked discrete bar chart (spec 16): one stacked bar per entity at a " + + "single time, composition across metrics, with total labels and " + + "absolute/relative modes.", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const provincialBudgets = storyDataset("provincial-budgets") + +const compositionDefinition = { + title: "Provincial spending composition", + subtitle: "Program spending and debt charges by province", + data: "provincial-budgets", + y: ["program_spending", "debt_charges"], + types: ["stacked-discrete-bar"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + sourceText: "Provincial public accounts", +} + +export const SpendingComposition: Story = { + render: () => ( + + ), +} + +export const Relative: Story = { + render: () => ( + + ), +} + +/** Nova Scotia 2022-23 is missing program_spending — missing never renders as zero. */ +export const MissingData: Story = { + render: () => ( + + ), +} diff --git a/packages/charts2/src/stories/helpers.tsx b/packages/charts2/src/stories/helpers.tsx new file mode 100644 index 00000000000..12372925e87 --- /dev/null +++ b/packages/charts2/src/stories/helpers.tsx @@ -0,0 +1,30 @@ +/** + * Shared story plumbing: fixture → Dataset, literal → ChartDefinition, and + * the standard tooltip wiring (the chrome Tooltip plugged into Chart's + * render prop). Stories are excluded from the published build; this helper + * keeps each story file down to definitions and render calls. + */ + +import { parseDefinition } from "../core/index.ts" +import type { ChartDefinition, Dataset } from "../core/types.ts" +import { loadFixtureDataset, type FixtureName } from "../fixtures/index.ts" +import { Tooltip } from "../react/index.ts" +import type { RenderTooltipArgs } from "../react/index.ts" + +/** Story definitions are literals — a parse failure is a story bug. */ +export function storyDefinition(raw: unknown): ChartDefinition { + const { definition, diagnostics } = parseDefinition(raw) + if (definition === null) { + throw new Error(`Story definition failed to parse: ${diagnostics.map((d) => d.message).join("; ")}`) + } + return definition +} + +export function storyDataset(name: FixtureName): Dataset { + return loadFixtureDataset(name).dataset +} + +/** Chart renderTooltip prop: the chrome Tooltip card beside the cursor. */ +export function renderStoryTooltip({ tooltip }: RenderTooltipArgs) { + return +} diff --git a/packages/charts2/src/stories/scss.d.ts b/packages/charts2/src/stories/scss.d.ts new file mode 100644 index 00000000000..5e8e158dda9 --- /dev/null +++ b/packages/charts2/src/stories/scss.d.ts @@ -0,0 +1,3 @@ +// Allow stories to side-effect-import the chrome stylesheet under tsc; +// Vite (Storybook) compiles the SCSS at build time. +declare module "*.scss" diff --git a/packages/charts2/tsconfig.build.json b/packages/charts2/tsconfig.build.json new file mode 100644 index 00000000000..09b0493006a --- /dev/null +++ b/packages/charts2/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "types": [] + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.stories.tsx", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/corpus/**", + "src/stories/**" + ] +} diff --git a/packages/charts2/tsconfig.json b/packages/charts2/tsconfig.json new file mode 100644 index 00000000000..c90154c72c2 --- /dev/null +++ b/packages/charts2/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "lib": ["dom", "dom.iterable", "ESNext"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/charts2/vitest.config.ts b/packages/charts2/vitest.config.ts new file mode 100644 index 00000000000..da8651d4c4d --- /dev/null +++ b/packages/charts2/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + passWithNoTests: true, + environment: "happy-dom", + include: ["src/**/*.test.{ts,tsx}"], + }, +}) From 5c4230d7d86a77d8b60d89ddcd3d10865560d157 Mon Sep 17 00:00:00 2001 From: xrendan Date: Fri, 12 Jun 2026 10:40:31 -0600 Subject: [PATCH 02/13] Ensure charts2 samples have subtitles --- packages/charts2/samples/discrete-bar-population.json | 1 + packages/charts2/samples/line-federal-departments.json | 1 + packages/charts2/src/samples.test.ts | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/charts2/samples/discrete-bar-population.json b/packages/charts2/samples/discrete-bar-population.json index cc2aa126952..963d0858a50 100644 --- a/packages/charts2/samples/discrete-bar-population.json +++ b/packages/charts2/samples/discrete-bar-population.json @@ -1,6 +1,7 @@ { "slug": "discrete-bar-population", "title": "Population by province and territory", + "subtitle": "Latest population estimates by province and territory", "data": "population-snapshot", "y": ["population"], "types": ["discrete-bar"], diff --git a/packages/charts2/samples/line-federal-departments.json b/packages/charts2/samples/line-federal-departments.json index 5b499ac8873..64c141073c1 100644 --- a/packages/charts2/samples/line-federal-departments.json +++ b/packages/charts2/samples/line-federal-departments.json @@ -1,6 +1,7 @@ { "slug": "line-federal-departments", "title": "Federal departmental spending", + "subtitle": "Annual budgetary expenditure by department", "data": "federal-departments", "y": ["spending"], "sourceText": "Public Accounts of Canada" diff --git a/packages/charts2/src/samples.test.ts b/packages/charts2/src/samples.test.ts index 768d6ce18aa..b6a46374b2f 100644 --- a/packages/charts2/src/samples.test.ts +++ b/packages/charts2/src/samples.test.ts @@ -1,4 +1,4 @@ -import { readdirSync } from "node:fs" +import { readFileSync, readdirSync } from "node:fs" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" @@ -26,6 +26,12 @@ describe("samples", () => { it.each(sampleFiles)("%s validates and renders deterministically", (file) => { const path = join(samplesDir, file) + const raw = JSON.parse(readFileSync(path, "utf8")) as { subtitle?: unknown } + const subtitle = typeof raw.subtitle === "string" ? raw.subtitle : "" + + expect(typeof raw.subtitle).toBe("string") + expect(subtitle.trim()).not.toBe("") + const validation = validateInput(path) expect(validation.errors, JSON.stringify(validation.diagnostics, null, 2)).toBe(0) expect(validation.diagnostics).toEqual([]) @@ -37,6 +43,7 @@ describe("samples", () => { expect(first.svg).not.toBeNull() expect(first.svg).toBe(second.svg) expect(first.svg?.startsWith(`${XML_DECLARATION}\n Date: Fri, 12 Jun 2026 10:50:53 -0600 Subject: [PATCH 03/13] Fix charts2 CLI PNG font fallback --- packages/charts2/src/cli/render.test.ts | 19 +++++++++++++++++-- packages/charts2/src/cli/render.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/charts2/src/cli/render.test.ts b/packages/charts2/src/cli/render.test.ts index ce81429d79c..5ee5fc5acef 100644 --- a/packages/charts2/src/cli/render.test.ts +++ b/packages/charts2/src/cli/render.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_WIDTH, XML_DECLARATION, defaultFontsDir, + listFontFiles, outputPathFor, parseFormats, rasterize, @@ -159,12 +160,26 @@ describe("renderDefinitionToSvg", () => { // --------------------------------------------------------------------------- describe("rasterize", () => { - it("renders a thumbnail PNG of provincial-budgets", (ctx) => { + it("loads Söhne before inactive brand fonts so resvg fallback uses the chart UI face", () => { + const dir = makeTmpDir() + writeFileSync(join(dir, "financier-text-regular.ttf"), "") + writeFileSync(join(dir, "soehne-kraftig.ttf"), "") + writeFileSync(join(dir, "founders-grotesk-mono-regular.ttf"), "") + writeFileSync(join(dir, "other.ttf"), "") + + expect(listFontFiles(dir).map((path) => path.slice(dir.length + 1))).toEqual([ + "soehne-kraftig.ttf", + "founders-grotesk-mono-regular.ttf", + "financier-text-regular.ttf", + "other.ttf", + ]) + }) + + it("renders a thumbnail PNG of provincial-budgets", () => { if (!existsSync(defaultFontsDir())) { console.warn( "skipping PNG smoke test: .fonts-cache missing — run `bun run extract-font-metrics` in packages/charts2", ) - ctx.skip() return } const dir = makeTmpDir() diff --git a/packages/charts2/src/cli/render.ts b/packages/charts2/src/cli/render.ts index ad5b00c5d46..28beb529ee5 100644 --- a/packages/charts2/src/cli/render.ts +++ b/packages/charts2/src/cli/render.ts @@ -216,9 +216,20 @@ export function defaultFontsDir(): string { } export function listFontFiles(fontsDir: string): string[] { + const preferredOrder = [ + // resvg currently fails to match Söhne by family name when several + // brand fonts are loaded, so keep the active chart UI font first. + "soehne-kraftig.ttf", + "founders-grotesk-mono-regular.ttf", + "financier-text-regular.ttf", + ] + const orderOf = (file: string): number => { + const index = preferredOrder.indexOf(file.toLowerCase()) + return index === -1 ? preferredOrder.length : index + } return readdirSync(fontsDir) .filter((file) => file.toLowerCase().endsWith(".ttf")) - .sort() + .sort((a, b) => orderOf(a) - orderOf(b) || a.localeCompare(b)) .map((file) => join(fontsDir, file)) } From 53fdd448a123058dccf0c75e52a3c779cd3453bf Mon Sep 17 00:00:00 2001 From: xrendan Date: Fri, 12 Jun 2026 10:58:11 -0600 Subject: [PATCH 04/13] Remove charts2 powered-by footer --- .../__snapshots__/layoutChart.test.ts.snap | 122 +++++++++--------- .../charts2/src/core/layout/chrome.test.ts | 6 + packages/charts2/src/core/layout/chrome.ts | 6 +- .../src/core/layout/layoutChart.test.ts | 4 +- packages/charts2/src/core/theme/themes.ts | 2 +- .../discrete-bar--default--1200x600.svg | 2 +- .../discrete-bar--default--300x160.svg | 2 +- .../discrete-bar--default--850x600.svg | 2 +- .../discrete-bar--negatives--850x600.svg | 2 +- .../discrete-bar--sort-name--850x600.svg | 2 +- .../__golden__/line--default--1200x600.svg | 2 +- .../__golden__/line--default--300x160.svg | 2 +- .../__golden__/line--default--850x600.svg | 2 +- .../corpus/__golden__/line--fr--850x600.svg | 2 +- .../line--many-entities--1200x600.svg | 2 +- .../line--missing-data--850x600.svg | 2 +- .../__golden__/line--relative--850x600.svg | 2 +- .../__golden__/line--single-time--850x600.svg | 2 +- .../stacked-area--default--1200x600.svg | 2 +- .../stacked-area--default--300x160.svg | 2 +- .../stacked-area--default--850x600.svg | 2 +- .../__golden__/stacked-area--fr--850x600.svg | 2 +- .../stacked-area--relative--850x600.svg | 2 +- .../stacked-bar--default--1200x600.svg | 2 +- .../stacked-bar--default--300x160.svg | 2 +- .../stacked-bar--default--850x600.svg | 2 +- .../stacked-bar--relative--850x600.svg | 2 +- ...tacked-discrete-bar--default--1200x600.svg | 2 +- ...stacked-discrete-bar--default--300x160.svg | 2 +- ...stacked-discrete-bar--default--850x600.svg | 2 +- ...ed-discrete-bar--missing-data--850x600.svg | 2 +- ...tacked-discrete-bar--relative--850x600.svg | 2 +- 32 files changed, 98 insertions(+), 96 deletions(-) diff --git a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap index e72acbf8b0a..687cbfd2678 100644 --- a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap +++ b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap @@ -1,66 +1,66 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`layoutChart matrix: every fixture × applicable type × three sizes > scene JSON is snapshot-stable across runs 1`] = ` +exports[`layoutChart matrix: every fixture × applicable type × three sizes scene JSON is snapshot-stable across runs 1`] = ` { - "federal-departments discrete-bar @ default 850x600": "f1683ff2", - "federal-departments discrete-bar @ thumbnail 300x160": "fb04f9ee", - "federal-departments discrete-bar @ wide 1200x600": "b7651001", - "federal-departments line @ default 850x600": "5d1f4018", - "federal-departments line @ thumbnail 300x160": "8942dc13", - "federal-departments line @ wide 1200x600": "183e392d", - "federal-departments stacked-area @ default 850x600": "5796d8f0", - "federal-departments stacked-area @ thumbnail 300x160": "93b08596", - "federal-departments stacked-area @ wide 1200x600": "116cd37a", - "federal-departments stacked-bar @ default 850x600": "2fa67ce2", - "federal-departments stacked-bar @ thumbnail 300x160": "ed5d9683", - "federal-departments stacked-bar @ wide 1200x600": "6565ed86", - "government-debt discrete-bar @ default 850x600": "5703773c", - "government-debt discrete-bar @ thumbnail 300x160": "c061d3b6", - "government-debt discrete-bar @ wide 1200x600": "0b750ee6", - "government-debt line @ default 850x600": "755a0701", - "government-debt line @ thumbnail 300x160": "8a09389a", - "government-debt line @ wide 1200x600": "c6655c6b", - "government-debt stacked-area @ default 850x600": "7bd0c1b8", - "government-debt stacked-area @ thumbnail 300x160": "e4f8c0fb", - "government-debt stacked-area @ wide 1200x600": "9c981175", - "government-debt stacked-bar @ default 850x600": "6dc04bbb", - "government-debt stacked-bar @ thumbnail 300x160": "fe95ff1d", - "government-debt stacked-bar @ wide 1200x600": "a164f460", - "government-debt stacked-discrete-bar @ default 850x600": "8b095dc4", - "government-debt stacked-discrete-bar @ thumbnail 300x160": "c5273f3d", - "government-debt stacked-discrete-bar @ wide 1200x600": "72020187", - "pathological discrete-bar @ default 850x600": "b8a52ba3", - "pathological discrete-bar @ thumbnail 300x160": "a8aa113c", - "pathological discrete-bar @ wide 1200x600": "e0e21bc7", - "pathological huge line @ default 850x600": "8d39739d", - "pathological huge line @ thumbnail 300x160": "df215a65", - "pathological huge line @ wide 1200x600": "90ef87b4", - "pathological line @ default 850x600": "0870edc5", - "pathological line @ thumbnail 300x160": "50d611ca", - "pathological line @ wide 1200x600": "8691aec8", - "pathological stacked-bar @ default 850x600": "fc018b0b", - "pathological stacked-bar @ thumbnail 300x160": "0e1dd920", - "pathological stacked-bar @ wide 1200x600": "5a3928ca", - "population-snapshot discrete-bar @ default 850x600": "1495c2f3", - "population-snapshot discrete-bar @ thumbnail 300x160": "a2d8c044", - "population-snapshot discrete-bar @ wide 1200x600": "131d37e0", - "population-snapshot stacked-discrete-bar @ default 850x600": "8f70c28b", - "population-snapshot stacked-discrete-bar @ thumbnail 300x160": "5bd76470", - "population-snapshot stacked-discrete-bar @ wide 1200x600": "6ca6b40b", - "provincial-budgets discrete-bar @ default 850x600": "75a00f49", - "provincial-budgets discrete-bar @ thumbnail 300x160": "3482f6dd", - "provincial-budgets discrete-bar @ wide 1200x600": "051a9351", - "provincial-budgets line @ default 850x600": "172142b6", - "provincial-budgets line @ thumbnail 300x160": "b3ce9182", - "provincial-budgets line @ wide 1200x600": "0d341b16", - "provincial-budgets stacked-area @ default 850x600": "226a027c", - "provincial-budgets stacked-area @ thumbnail 300x160": "23c804f3", - "provincial-budgets stacked-area @ wide 1200x600": "3480ea01", - "provincial-budgets stacked-bar @ default 850x600": "0f450b2e", - "provincial-budgets stacked-bar @ thumbnail 300x160": "0000697c", - "provincial-budgets stacked-bar @ wide 1200x600": "b3ed0821", - "provincial-budgets stacked-discrete-bar @ default 850x600": "2c6015e4", - "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "26a8494a", - "provincial-budgets stacked-discrete-bar @ wide 1200x600": "6d6a92a2", + "federal-departments discrete-bar @ default 850x600": "d85638ad", + "federal-departments discrete-bar @ thumbnail 300x160": "17748abd", + "federal-departments discrete-bar @ wide 1200x600": "8367091a", + "federal-departments line @ default 850x600": "12a63af9", + "federal-departments line @ thumbnail 300x160": "a55aae07", + "federal-departments line @ wide 1200x600": "515abf32", + "federal-departments stacked-area @ default 850x600": "c68e6bc8", + "federal-departments stacked-area @ thumbnail 300x160": "e20ec344", + "federal-departments stacked-area @ wide 1200x600": "bb87d638", + "federal-departments stacked-bar @ default 850x600": "acde5bfd", + "federal-departments stacked-bar @ thumbnail 300x160": "1326cdcc", + "federal-departments stacked-bar @ wide 1200x600": "b1b040a1", + "government-debt discrete-bar @ default 850x600": "23661d9a", + "government-debt discrete-bar @ thumbnail 300x160": "1aed79c3", + "government-debt discrete-bar @ wide 1200x600": "661c34e8", + "government-debt line @ default 850x600": "130f6e51", + "government-debt line @ thumbnail 300x160": "b558a27a", + "government-debt line @ wide 1200x600": "f0900d1d", + "government-debt stacked-area @ default 850x600": "22a77a63", + "government-debt stacked-area @ thumbnail 300x160": "9163597d", + "government-debt stacked-area @ wide 1200x600": "f1bb9cc2", + "government-debt stacked-bar @ default 850x600": "d7092fbd", + "government-debt stacked-bar @ thumbnail 300x160": "bb207ee2", + "government-debt stacked-bar @ wide 1200x600": "51bbd1a4", + "government-debt stacked-discrete-bar @ default 850x600": "fa891b93", + "government-debt stacked-discrete-bar @ thumbnail 300x160": "51962ee1", + "government-debt stacked-discrete-bar @ wide 1200x600": "e031b6b2", + "pathological discrete-bar @ default 850x600": "433da7fa", + "pathological discrete-bar @ thumbnail 300x160": "51c05fe0", + "pathological discrete-bar @ wide 1200x600": "70299d42", + "pathological huge line @ default 850x600": "4033eb37", + "pathological huge line @ thumbnail 300x160": "c6fa6bcf", + "pathological huge line @ wide 1200x600": "f9aaf26a", + "pathological line @ default 850x600": "34b16391", + "pathological line @ thumbnail 300x160": "3844ccc7", + "pathological line @ wide 1200x600": "dd90611a", + "pathological stacked-bar @ default 850x600": "f5c2a9da", + "pathological stacked-bar @ thumbnail 300x160": "5f04b215", + "pathological stacked-bar @ wide 1200x600": "29e21f71", + "population-snapshot discrete-bar @ default 850x600": "cd2db986", + "population-snapshot discrete-bar @ thumbnail 300x160": "10d54cba", + "population-snapshot discrete-bar @ wide 1200x600": "ba91917b", + "population-snapshot stacked-discrete-bar @ default 850x600": "fa8ee09e", + "population-snapshot stacked-discrete-bar @ thumbnail 300x160": "86049ac8", + "population-snapshot stacked-discrete-bar @ wide 1200x600": "865daba4", + "provincial-budgets discrete-bar @ default 850x600": "13e3dd11", + "provincial-budgets discrete-bar @ thumbnail 300x160": "617bb119", + "provincial-budgets discrete-bar @ wide 1200x600": "ad8969b1", + "provincial-budgets line @ default 850x600": "382a83e2", + "provincial-budgets line @ thumbnail 300x160": "4136320e", + "provincial-budgets line @ wide 1200x600": "2402a32e", + "provincial-budgets stacked-area @ default 850x600": "062f2295", + "provincial-budgets stacked-area @ thumbnail 300x160": "eb20e4d9", + "provincial-budgets stacked-area @ wide 1200x600": "dddfc098", + "provincial-budgets stacked-bar @ default 850x600": "bb5b19fc", + "provincial-budgets stacked-bar @ thumbnail 300x160": "5553a1f9", + "provincial-budgets stacked-bar @ wide 1200x600": "86faba0f", + "provincial-budgets stacked-discrete-bar @ default 850x600": "9cc32be0", + "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "3a88c78e", + "provincial-budgets stacked-discrete-bar @ wide 1200x600": "c8a1ac08", } `; diff --git a/packages/charts2/src/core/layout/chrome.test.ts b/packages/charts2/src/core/layout/chrome.test.ts index 343e81896ca..27520b72150 100644 --- a/packages/charts2/src/core/layout/chrome.test.ts +++ b/packages/charts2/src/core/layout/chrome.test.ts @@ -141,4 +141,10 @@ describe("chrome logo", () => { }) expect(layout.contentArea.y).toBe(71.6) }) + + it("does not render powered-by attribution text", () => { + const layout = layoutChrome({ ...base, theme: buildCanadaTheme }) + expect(layout.nodes.find((node) => node.key === "chrome/attribution")).toBeUndefined() + expect(layout.nodes.some((node) => node.kind === "text" && node.text.startsWith("Powered by"))).toBe(false) + }) }) diff --git a/packages/charts2/src/core/layout/chrome.ts b/packages/charts2/src/core/layout/chrome.ts index f3078d8637c..1400730c679 100644 --- a/packages/charts2/src/core/layout/chrome.ts +++ b/packages/charts2/src/core/layout/chrome.ts @@ -1,6 +1,6 @@ /** * Frame chrome geometry (spec 10 §1–2): header (title + subtitle) and footer - * (source, note, attribution) text nodes, plus the content rectangle left + * (source and note) text nodes, plus the content rectangle left * for legend + plot. Interactive chrome components (tabs, controls, * timeline) are M9's — this module lays out static text geometry only. * @@ -193,10 +193,6 @@ export function layoutChrome(input: ChromeInput): ChromeLayout { const source = sourceLineText(definition, manifest, locale) if (source !== "") footerLines.push({ key: "chrome/source", text: source, anchor: "start" }) } - if (mode !== "none" && theme.attribution.text !== "") { - footerLines.push({ key: "chrome/attribution", text: theme.attribution.text, anchor: "end" }) - } - let footerTop = size.height - padding.bottom if (footerLines.length > 0) { footerTop -= footerLines.length * lineHeight + (footerLines.length - 1) * FOOTER_GAP + FOOTER_TOP_GAP diff --git a/packages/charts2/src/core/layout/layoutChart.test.ts b/packages/charts2/src/core/layout/layoutChart.test.ts index 6f9757c1ae1..92c010f034c 100644 --- a/packages/charts2/src/core/layout/layoutChart.test.ts +++ b/packages/charts2/src/core/layout/layoutChart.test.ts @@ -259,10 +259,10 @@ describe("layoutChart behaviours", () => { expect(scene.nodes.some((n) => n.key.startsWith("legend/"))).toBe(true) }) - it("thumbnail chrome renders title + plot + attribution only", () => { + it("thumbnail chrome renders title + plot without footer text", () => { const scene = sceneFor("government-debt", { y: DEBT_Y, types: ["line"] }, { width: 300, height: 160 }, "thumbnail") expect(scene.nodes.some((n) => n.key.startsWith("chrome/title"))).toBe(true) - expect(scene.nodes.some((n) => n.key === "chrome/attribution")).toBe(true) + expect(scene.nodes.some((n) => n.key === "chrome/attribution")).toBe(false) expect(scene.nodes.some((n) => n.key === "chrome/source")).toBe(false) expect(scene.nodes.some((n) => n.key.startsWith("chrome/subtitle"))).toBe(false) }) diff --git a/packages/charts2/src/core/theme/themes.ts b/packages/charts2/src/core/theme/themes.ts index 75edd871556..290226f7a9a 100644 --- a/packages/charts2/src/core/theme/themes.ts +++ b/packages/charts2/src/core/theme/themes.ts @@ -95,7 +95,7 @@ export const buildCanadaTheme: Theme = { padding: { top: 16, right: 16, bottom: 16, left: 16 }, }, attribution: { - text: "Powered by Build Canada Charts", + text: "", url: "https://buildcanada.com", }, localeDefault: "en", diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg index 2ada662f1af..5627b9cb340 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Population by province and territorySource: Statistics CanadaPowered by Build Canada Charts02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k +Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg index f51a4ec735e..e239ff433d0 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Population by province andterritoryPowered by Build Canada Charts02M4M6M8M12M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k +Population by province andterritory02M4M6M8M12M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg index c59966e45ea..34cbc84c172 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Population by province and territorySource: Statistics CanadaPowered by Build Canada Charts02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k +Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg index 23cb2c0c120..810e9959e8f 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg @@ -1,2 +1,2 @@ -Net balance by place, 2021Source: Synthetic test dataPowered by Build Canada Charts−10−8−6−4−20Î.-P.-É.−1Québec−6Lonely Station−9 +Net balance by place, 2021Source: Synthetic test data−10−8−6−4−20Î.-P.-É.−1Québec−6Lonely Station−9 diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg index 617e33807a2..a2c9051449a 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg @@ -1,2 +1,2 @@ -Population by province and territorySource: Statistics CanadaPowered by Build Canada Charts02M4M6M8M10M12M14M16MAlberta5MBritish Columbia6MManitoba1MNew Brunswick832kNova Scotia1MOntario16MQuebec9MSaskatchewan1M +Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MAlberta5MBritish Columbia6MManitoba1MNew Brunswick832kNova Scotia1MOntario16MQuebec9MSaskatchewan1M diff --git a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg index ebe95a7e544..48633230886 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--default--300x160.svg b/packages/charts2/src/corpus/__golden__/line--default--300x160.svg index 2a800c2a0ad..f64da24c4aa 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--300x160.svg @@ -1,2 +1,2 @@ -Provincial budget spending,2019–20 to 2024–25Powered by Build Canada Charts$0.0$100.0$200.02019–202022–232024–25Ontario +Provincial budget spending,2019–20 to 2024–25$0.0$100.0$200.02019–202022–232024–25OntarioQuebec diff --git a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg index 35e812e0efe..6d84035d039 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg index d917e43ae0d..927c0f356b2 100644 --- a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg index 3366f66b8c6..323995c91e4 100644 --- a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg @@ -1,2 +1,2 @@ -Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of CanadaPowered by Build Canada Charts$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada +Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of Canada$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada diff --git a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg index dfb36f4fddf..f0b05b89a8e 100644 --- a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg @@ -1,2 +1,2 @@ -Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg index b0fcb3823b1..696ad64d171 100644 --- a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta +Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta diff --git a/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg b/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg index 9b84b9125ba..283fc2bf8a5 100644 --- a/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accountsPowered by Build Canada Charts$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5Quebec$161.0British Columbia$84.2Alberta$71.2Nova Scotia$16.5 +Provincial budget spending, 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5Quebec$161.0British Columbia$84.2Alberta$71.2Nova Scotia$16.5 diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg index e735d844509..aa4450a3c11 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg index d094b43598c..7231c45c93d 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg @@ -1,2 +1,2 @@ -Government debt as a share ofGDP, Canada, 2019–20 to 2023–24Powered by Build Canada Charts0.0%50.0%100.0%2019–202023–24Federal debt +Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202023–24Provincial de…Federal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg index ae61313094e..473e56d627c 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg index 8a15a6e3113..d58cb87add7 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg index 3c7700b834f..de763bedc87 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada Charts0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg index 3484e3b031a..f4ccce13110 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada ChartsFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg index e201a630daa..2ce925dbd91 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Government debt as a share ofGDP, Canada, 2019–20 to 2023–24Powered by Build Canada Charts0.0%50.0%100.0%2019–202021–222023–24 +Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202021–222023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg index fc0aec9fb61..43a03f0ee36 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada ChartsFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg index b77f1253940..28be07f0750 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesPowered by Build Canada ChartsFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 +Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg index f3843aaf373..196c665dc15 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 +Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg index ae4e021dafa..d8cfde6a768 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Provincial spending composition,2019–20 to 2024–25Powered by Build Canada Charts$0.0$50.0$150.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 +Provincial spending composition,2019–20 to 2024–25$0.0$50.0$150.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg index fe6aee4c853..432e936e89d 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 +Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg index 317a7573a38..41d9dd03839 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg @@ -1,2 +1,2 @@ -Provincial spending composition, 2022–23Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0Ontario$192.9Quebec$147.3British Columbia$73.6Alberta$64.3Nova Scotia$0.7 +Provincial spending composition, 2022–23Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0Ontario$192.9Quebec$147.3British Columbia$73.6Alberta$64.3Nova Scotia$0.7 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg index 7d75094ec87..f3007b27176 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsPowered by Build Canada ChartsProgram spendingDebt charges0%+20%+40%+60%+80%+100%OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Change in Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges0%+20%+40%+60%+80%+100%OntarioQuebecBritish ColumbiaAlbertaNova Scotia From c13130efa52b29910ea2b8926c92b836c07b74a4 Mon Sep 17 00:00:00 2001 From: xrendan Date: Fri, 12 Jun 2026 11:03:44 -0600 Subject: [PATCH 05/13] Align charts2 y-axis labels with gridlines --- .../__snapshots__/layoutChart.test.ts.snap | 68 +++++++++---------- packages/charts2/src/core/layout/axis.test.ts | 26 +++++++ packages/charts2/src/core/layout/axis.ts | 2 +- .../__golden__/line--default--1200x600.svg | 2 +- .../__golden__/line--default--850x600.svg | 2 +- .../corpus/__golden__/line--fr--850x600.svg | 2 +- .../line--many-entities--1200x600.svg | 2 +- .../line--missing-data--850x600.svg | 2 +- .../__golden__/line--relative--850x600.svg | 2 +- .../stacked-area--default--1200x600.svg | 2 +- .../stacked-area--default--300x160.svg | 2 +- .../stacked-area--default--850x600.svg | 2 +- .../__golden__/stacked-area--fr--850x600.svg | 2 +- .../stacked-area--relative--850x600.svg | 2 +- .../stacked-bar--default--1200x600.svg | 2 +- .../stacked-bar--default--300x160.svg | 2 +- .../stacked-bar--default--850x600.svg | 2 +- .../stacked-bar--relative--850x600.svg | 2 +- 18 files changed, 76 insertions(+), 50 deletions(-) diff --git a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap index 687cbfd2678..0c663a2cf36 100644 --- a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap +++ b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap @@ -1,46 +1,46 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots exports[`layoutChart matrix: every fixture × applicable type × three sizes scene JSON is snapshot-stable across runs 1`] = ` { "federal-departments discrete-bar @ default 850x600": "d85638ad", "federal-departments discrete-bar @ thumbnail 300x160": "17748abd", "federal-departments discrete-bar @ wide 1200x600": "8367091a", - "federal-departments line @ default 850x600": "12a63af9", - "federal-departments line @ thumbnail 300x160": "a55aae07", - "federal-departments line @ wide 1200x600": "515abf32", - "federal-departments stacked-area @ default 850x600": "c68e6bc8", - "federal-departments stacked-area @ thumbnail 300x160": "e20ec344", - "federal-departments stacked-area @ wide 1200x600": "bb87d638", - "federal-departments stacked-bar @ default 850x600": "acde5bfd", - "federal-departments stacked-bar @ thumbnail 300x160": "1326cdcc", - "federal-departments stacked-bar @ wide 1200x600": "b1b040a1", + "federal-departments line @ default 850x600": "56ed676c", + "federal-departments line @ thumbnail 300x160": "10ae39f6", + "federal-departments line @ wide 1200x600": "47961845", + "federal-departments stacked-area @ default 850x600": "1db82b95", + "federal-departments stacked-area @ thumbnail 300x160": "e8563644", + "federal-departments stacked-area @ wide 1200x600": "1da03b31", + "federal-departments stacked-bar @ default 850x600": "e0b6ed01", + "federal-departments stacked-bar @ thumbnail 300x160": "5a77c86c", + "federal-departments stacked-bar @ wide 1200x600": "19f17c3d", "government-debt discrete-bar @ default 850x600": "23661d9a", "government-debt discrete-bar @ thumbnail 300x160": "1aed79c3", "government-debt discrete-bar @ wide 1200x600": "661c34e8", - "government-debt line @ default 850x600": "130f6e51", - "government-debt line @ thumbnail 300x160": "b558a27a", - "government-debt line @ wide 1200x600": "f0900d1d", - "government-debt stacked-area @ default 850x600": "22a77a63", - "government-debt stacked-area @ thumbnail 300x160": "9163597d", - "government-debt stacked-area @ wide 1200x600": "f1bb9cc2", - "government-debt stacked-bar @ default 850x600": "d7092fbd", - "government-debt stacked-bar @ thumbnail 300x160": "bb207ee2", - "government-debt stacked-bar @ wide 1200x600": "51bbd1a4", + "government-debt line @ default 850x600": "86bff374", + "government-debt line @ thumbnail 300x160": "28d4b25e", + "government-debt line @ wide 1200x600": "bc986032", + "government-debt stacked-area @ default 850x600": "2a063c24", + "government-debt stacked-area @ thumbnail 300x160": "77b4c155", + "government-debt stacked-area @ wide 1200x600": "942d7b49", + "government-debt stacked-bar @ default 850x600": "db5c9911", + "government-debt stacked-bar @ thumbnail 300x160": "5e80310e", + "government-debt stacked-bar @ wide 1200x600": "77d73098", "government-debt stacked-discrete-bar @ default 850x600": "fa891b93", "government-debt stacked-discrete-bar @ thumbnail 300x160": "51962ee1", "government-debt stacked-discrete-bar @ wide 1200x600": "e031b6b2", "pathological discrete-bar @ default 850x600": "433da7fa", "pathological discrete-bar @ thumbnail 300x160": "51c05fe0", "pathological discrete-bar @ wide 1200x600": "70299d42", - "pathological huge line @ default 850x600": "4033eb37", - "pathological huge line @ thumbnail 300x160": "c6fa6bcf", - "pathological huge line @ wide 1200x600": "f9aaf26a", - "pathological line @ default 850x600": "34b16391", - "pathological line @ thumbnail 300x160": "3844ccc7", - "pathological line @ wide 1200x600": "dd90611a", - "pathological stacked-bar @ default 850x600": "f5c2a9da", - "pathological stacked-bar @ thumbnail 300x160": "5f04b215", - "pathological stacked-bar @ wide 1200x600": "29e21f71", + "pathological huge line @ default 850x600": "82faba64", + "pathological huge line @ thumbnail 300x160": "a52dcecf", + "pathological huge line @ wide 1200x600": "288f795b", + "pathological line @ default 850x600": "0b4bd3d8", + "pathological line @ thumbnail 300x160": "23ea99c7", + "pathological line @ wide 1200x600": "f4e7fbd9", + "pathological stacked-bar @ default 850x600": "a2a4ada6", + "pathological stacked-bar @ thumbnail 300x160": "01e36d15", + "pathological stacked-bar @ wide 1200x600": "80a0a655", "population-snapshot discrete-bar @ default 850x600": "cd2db986", "population-snapshot discrete-bar @ thumbnail 300x160": "10d54cba", "population-snapshot discrete-bar @ wide 1200x600": "ba91917b", @@ -50,15 +50,15 @@ exports[`layoutChart matrix: every fixture × applicable type × three sizes sce "provincial-budgets discrete-bar @ default 850x600": "13e3dd11", "provincial-budgets discrete-bar @ thumbnail 300x160": "617bb119", "provincial-budgets discrete-bar @ wide 1200x600": "ad8969b1", - "provincial-budgets line @ default 850x600": "382a83e2", + "provincial-budgets line @ default 850x600": "770ca8cf", "provincial-budgets line @ thumbnail 300x160": "4136320e", - "provincial-budgets line @ wide 1200x600": "2402a32e", - "provincial-budgets stacked-area @ default 850x600": "062f2295", + "provincial-budgets line @ wide 1200x600": "b1f26af5", + "provincial-budgets stacked-area @ default 850x600": "1f1833ce", "provincial-budgets stacked-area @ thumbnail 300x160": "eb20e4d9", - "provincial-budgets stacked-area @ wide 1200x600": "dddfc098", - "provincial-budgets stacked-bar @ default 850x600": "bb5b19fc", + "provincial-budgets stacked-area @ wide 1200x600": "6c9adfbf", + "provincial-budgets stacked-bar @ default 850x600": "5b82d720", "provincial-budgets stacked-bar @ thumbnail 300x160": "5553a1f9", - "provincial-budgets stacked-bar @ wide 1200x600": "86faba0f", + "provincial-budgets stacked-bar @ wide 1200x600": "a290a49b", "provincial-budgets stacked-discrete-bar @ default 850x600": "9cc32be0", "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "3a88c78e", "provincial-budgets stacked-discrete-bar @ wide 1200x600": "c8a1ac08", diff --git a/packages/charts2/src/core/layout/axis.test.ts b/packages/charts2/src/core/layout/axis.test.ts index 834e9b57d21..d16e1940f08 100644 --- a/packages/charts2/src/core/layout/axis.test.ts +++ b/packages/charts2/src/core/layout/axis.test.ts @@ -7,6 +7,7 @@ import { logTicks, prepareValueAxis, timeAxisNodes, + layoutVerticalAxes, verticalValueAxisNodes, } from "./axis.ts" import { computeValueDomain, createValueScale, niceLinearDomain, targetTickCount } from "./scales.ts" @@ -165,6 +166,31 @@ describe("axis nodes", () => { expect(zero?.kind === "rule" ? zero.style.dash : undefined).toBeUndefined() }) + it("centres left y-axis tick labels on their gridlines, including the top tick", () => { + const result = layoutVerticalAxes({ + area: { x: 16, y: 72, width: 818, height: 500 }, + values: [0, 250], + markType: "line", + scaleType: "linear", + meta: { type: "currency", currency: "CAD" }, + locale: "en", + theme: buildCanadaTheme, + measurer: defaultMeasurer, + font, + rightReserve: 80, + }) + + for (const text of result.nodes) { + if (text.kind !== "text" || !text.key.startsWith("axis/y/tick/")) continue + const grid = result.nodes.find((node) => node.kind === "rule" && node.key === text.key.replace("/tick/", "/grid/")) + expect(grid?.kind).toBe("rule") + if (grid?.kind !== "rule") continue + + const textCenter = text.position.y - (text.measured.ascent - text.measured.descent) / 2 + expect(textCenter).toBeCloseTo(grid.from.y, 6) + } + }) + it("renders vertical value-axis gridlines as dashed without duplicate bottom tick marks", () => { const spec = prepareValueAxis({ markType: "bar", diff --git a/packages/charts2/src/core/layout/axis.ts b/packages/charts2/src/core/layout/axis.ts index 7160335779b..ea378c19a71 100644 --- a/packages/charts2/src/core/layout/axis.ts +++ b/packages/charts2/src/core/layout/axis.ts @@ -486,7 +486,7 @@ export function layoutVerticalAxes(input: VerticalAxesInput): VerticalAxesResult } const yScale = createValueScale(input.scaleType, spec.domain, [plotArea.y + plotArea.height, plotArea.y]) - const nodes = verticalValueAxisNodes(spec, yScale, plotArea, area.y, { + const nodes = verticalValueAxisNodes(spec, yScale, plotArea, Math.max(0, area.y - PLOT_TOP_PAD), { theme, font, hideGridlines: input.config?.hideGridlines, diff --git a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg index 48633230886..6704929f9dd 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg index 6d84035d039..c75a704f263 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg index 927c0f356b2..6560bd5c32f 100644 --- a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg index 323995c91e4..42cf85e33e5 100644 --- a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg @@ -1,2 +1,2 @@ -Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of Canada$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada +Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of Canada$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada diff --git a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg index f0b05b89a8e..7d64fcb5879 100644 --- a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg @@ -1,2 +1,2 @@ -Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg index 696ad64d171..e06f90c0777 100644 --- a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta +Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg index aa4450a3c11..b5f0ecedfd6 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg index 7231c45c93d..1765e429209 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg @@ -1,2 +1,2 @@ -Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202023–24Provincial de…Federal debt +Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202023–24Provincial de…Federal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg index 473e56d627c..772b7f9cac8 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg index d58cb87add7..171d10b497f 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg index de763bedc87..dda83a9c165 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg index f4ccce13110..eaafc00ec1b 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg index 2ce925dbd91..a7a585fc131 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202021–222023–24 +Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202021–222023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg index 43a03f0ee36..dfd0ac3e314 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg index 28be07f0750..ab5020746dc 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 +Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 From b4666f3b9ad16982fdda654799fd28a8a602084c Mon Sep 17 00:00:00 2001 From: xrendan Date: Thu, 9 Jul 2026 08:19:28 -0600 Subject: [PATCH 06/13] Add charts2 faceting and comparison lines Implements two chart-definition features that previously parsed but did not render (spec 09, spec 02). Faceting (small multiples): - facet: "entity" | "metric" lays out a grid of panels balanced to the frame aspect ratio, with per-panel titles and a 16-panel cap. - Panels share one value domain (two-pass union) so gridlines align, and a shared legend sits above the grid for multi-series facets. Falls back to a single chart when only one panel would result. Comparison lines: - comparisonLines render horizontal (y) and vertical (x/time) reference lines with labels on line and stacked-area charts; out-of-range lines are skipped. Other chart types emit a comparison-lines-unsupported warning instead of dropping the config silently. Chart layers now expose valueDomain for the faceting two-pass. Adds unit tests for both features, Storybook stories (Charts2/Faceting, Charts2/ComparisonLines), and CLI sample definitions. --- .../line-comparison-provincial-budgets.json | 14 + .../line-faceted-provincial-budgets.json | 11 + .../layout/charts/comparisonLines.test.ts | 94 ++++++ .../src/core/layout/charts/comparisonLines.ts | 112 ++++++++ .../src/core/layout/charts/discreteBar.ts | 1 + .../charts2/src/core/layout/charts/line.ts | 17 ++ .../charts2/src/core/layout/charts/shared.ts | 3 + .../src/core/layout/charts/stackedArea.ts | 17 ++ .../src/core/layout/charts/stackedBar.ts | 1 + .../core/layout/charts/stackedDiscreteBar.ts | 1 + .../charts2/src/core/layout/facet.test.ts | 125 ++++++++ packages/charts2/src/core/layout/facet.ts | 267 ++++++++++++++++++ .../charts2/src/core/layout/layoutChart.ts | 49 ++++ packages/charts2/src/samples.test.ts | 2 + .../src/stories/ComparisonLines.stories.tsx | 72 +++++ .../charts2/src/stories/Faceting.stories.tsx | 63 +++++ 16 files changed, 849 insertions(+) create mode 100644 packages/charts2/samples/line-comparison-provincial-budgets.json create mode 100644 packages/charts2/samples/line-faceted-provincial-budgets.json create mode 100644 packages/charts2/src/core/layout/charts/comparisonLines.test.ts create mode 100644 packages/charts2/src/core/layout/charts/comparisonLines.ts create mode 100644 packages/charts2/src/core/layout/facet.test.ts create mode 100644 packages/charts2/src/core/layout/facet.ts create mode 100644 packages/charts2/src/stories/ComparisonLines.stories.tsx create mode 100644 packages/charts2/src/stories/Faceting.stories.tsx diff --git a/packages/charts2/samples/line-comparison-provincial-budgets.json b/packages/charts2/samples/line-comparison-provincial-budgets.json new file mode 100644 index 00000000000..3dcf6ec1396 --- /dev/null +++ b/packages/charts2/samples/line-comparison-provincial-budgets.json @@ -0,0 +1,14 @@ +{ + "slug": "line-comparison-provincial-budgets", + "title": "Provincial budget spending", + "subtitle": "Total budgetary expenditure, with reference lines", + "data": "provincial-budgets", + "y": ["total_spending"], + "types": ["line"], + "selectedEntities": ["Ontario", "Quebec"], + "comparisonLines": [ + { "y": 100, "label": "$100B reference" }, + { "x": 2022, "label": "2022" } + ], + "sourceText": "Provincial public accounts" +} diff --git a/packages/charts2/samples/line-faceted-provincial-budgets.json b/packages/charts2/samples/line-faceted-provincial-budgets.json new file mode 100644 index 00000000000..069adbafe8d --- /dev/null +++ b/packages/charts2/samples/line-faceted-provincial-budgets.json @@ -0,0 +1,11 @@ +{ + "slug": "line-faceted-provincial-budgets", + "title": "Provincial spending composition", + "subtitle": "Program spending and debt charges, faceted by province", + "data": "provincial-budgets", + "y": ["program_spending", "debt_charges"], + "types": ["line"], + "facet": "entity", + "selectedEntities": ["Ontario", "Quebec", "British Columbia", "Alberta"], + "sourceText": "Provincial public accounts" +} diff --git a/packages/charts2/src/core/layout/charts/comparisonLines.test.ts b/packages/charts2/src/core/layout/charts/comparisonLines.test.ts new file mode 100644 index 00000000000..0f28857703e --- /dev/null +++ b/packages/charts2/src/core/layout/charts/comparisonLines.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import type { Rect } from "../../scene/nodes.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { ChartDefinition } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutChart } from "../layoutChart.ts" +import { layoutLineChart } from "./line.ts" +import type { ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("comparison lines (spec 02 §2)", () => { + it("renders a horizontal reference line spanning the plot, with its label", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario"], + comparisonLines: [{ y: 0, label: "Balanced" }], + }) + const layer = layoutLineChart(ctx, AREA, OPTS) + + const rule = layer.nodes.find((n) => n.key === "annotation/comparison/0/h") + expect(rule?.kind).toBe("rule") + if (rule?.kind !== "rule") return + expect(rule.role).toBe("annotation") + expect(rule.from.y).toBe(rule.to.y) // horizontal + expect(rule.from.x).toBeCloseTo(layer.plotArea.x) + expect(rule.to.x).toBeCloseTo(layer.plotArea.x + layer.plotArea.width) + + const label = layer.nodes.find((n) => n.key === "annotation/comparison/0/h-label") + expect(label?.kind).toBe("text") + if (label?.kind === "text") expect(label.text).toBe("Balanced") + }) + + it("renders a vertical reference line at a time ordinal", () => { + const ctx = ctxFor("provincial-budgets", { y: ["total_spending"], selectedEntities: ["Ontario"] }) + const at = ctx.times[1] + const ctxWithLine = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario"], + comparisonLines: [{ x: at }], + }) + const layer = layoutLineChart(ctxWithLine, AREA, OPTS) + + const rule = layer.nodes.find((n) => n.key === "annotation/comparison/0/v") + expect(rule?.kind).toBe("rule") + if (rule?.kind !== "rule") return + expect(rule.from.x).toBe(rule.to.x) // vertical + expect(rule.from.y).toBeCloseTo(layer.plotArea.y) + expect(rule.to.y).toBeCloseTo(layer.plotArea.y + layer.plotArea.height) + }) + + it("skips a reference line whose value is outside the plot range", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Ontario"], + comparisonLines: [{ y: 1e18, label: "Way up there" }], + }) + const layer = layoutLineChart(ctx, AREA, OPTS) + expect(layer.nodes.some((n) => n.key.startsWith("annotation/comparison/"))).toBe(false) + }) + + it("warns when comparison lines are set on a chart type that cannot render them", () => { + const { dataset } = loadFixtureDataset("provincial-budgets") + const scene = layoutChart({ + definition: definitionFor({ + y: ["total_spending"], + types: ["discrete-bar"], + comparisonLines: [{ y: 0, label: "Balanced" }], + }), + dataset, + theme: buildCanadaTheme, + measurer: defaultMeasurer, + size: { width: 800, height: 500 }, + }) + expect(scene.diagnostics.some((d) => d.code === "comparison-lines-unsupported")).toBe(true) + expect(scene.nodes.some((n) => n.key.startsWith("annotation/comparison/"))).toBe(false) + }) +}) diff --git a/packages/charts2/src/core/layout/charts/comparisonLines.ts b/packages/charts2/src/core/layout/charts/comparisonLines.ts new file mode 100644 index 00000000000..fc315b2d916 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/comparisonLines.ts @@ -0,0 +1,112 @@ +/** + * Comparison (reference) lines — spec 02 §2, spec 18. + * + * Straight annotation lines drawn over a cartesian plot: a horizontal rule at + * a fixed y value and/or a vertical rule at a fixed x (time ordinal), each + * with an optional label. Rendered for charts with a continuous value axis + * and a continuous time axis (line, stacked area). A line whose target falls + * outside the current plot range is skipped rather than clamped, so a + * reference the reader has scrolled past simply disappears. + */ + +import type { Rect, SceneNode } from "../../scene/nodes.ts" +import type { TextMeasurer } from "../../text/measurer.ts" +import type { Theme } from "../../theme/types.ts" +import type { ComparisonLine } from "../../types.ts" +import type { ValueScale } from "../scales.ts" +import { footerFont, textNode } from "./shared.ts" + +const LINE_DASH = [6, 4] +const LABEL_PAD = 4 + +export interface ComparisonLineInput { + lines: readonly ComparisonLine[] + plotArea: Rect + /** value → pixel on the vertical (value) axis. */ + yScale: ValueScale + /** time/x → pixel on the horizontal axis. */ + xScale: ValueScale + theme: Theme + measurer: TextMeasurer + fontScale: number +} + +/** Inclusive membership with a half-pixel slack so an on-edge target counts. */ +function within(value: number, a: number, b: number): boolean { + return value >= Math.min(a, b) - 0.5 && value <= Math.max(a, b) + 0.5 +} + +export function comparisonLineNodes(input: ComparisonLineInput): SceneNode[] { + const { lines, plotArea, yScale, xScale, theme, measurer, fontScale } = input + const nodes: SceneNode[] = [] + const font = footerFont(fontScale) + const stroke = { stroke: theme.chrome.axisLine, strokeWidth: 1, dash: [...LINE_DASH], opacity: 0.75 } + const left = plotArea.x + const right = plotArea.x + plotArea.width + const top = plotArea.y + const bottom = plotArea.y + plotArea.height + + lines.forEach((line, index) => { + if (line.y !== undefined) { + const py = yScale.place(line.y) + if (Number.isFinite(py) && within(py, top, bottom)) { + nodes.push({ + key: `annotation/comparison/${index}/h`, + role: "annotation", + kind: "rule", + from: { x: left, y: py }, + to: { x: right, y: py }, + style: { ...stroke }, + }) + if (line.label !== undefined && line.label !== "") { + const metrics = measurer.measure(line.label, font) + nodes.push( + textNode({ + key: `annotation/comparison/${index}/h-label`, + role: "annotation", + text: line.label, + font, + anchor: "start", + x: left + LABEL_PAD, + baselineY: Math.max(py - LABEL_PAD, top + metrics.ascent), + colour: theme.chrome.subtitle, + measurer, + }), + ) + } + } + } + if (line.x !== undefined) { + const px = xScale.place(line.x) + if (Number.isFinite(px) && within(px, left, right)) { + nodes.push({ + key: `annotation/comparison/${index}/v`, + role: "annotation", + kind: "rule", + from: { x: px, y: top }, + to: { x: px, y: bottom }, + style: { ...stroke }, + }) + if (line.label !== undefined && line.label !== "") { + const metrics = measurer.measure(line.label, font) + const anchorEnd = px + LABEL_PAD + metrics.width > right + nodes.push( + textNode({ + key: `annotation/comparison/${index}/v-label`, + role: "annotation", + text: line.label, + font, + anchor: anchorEnd ? "end" : "start", + x: anchorEnd ? px - LABEL_PAD : px + LABEL_PAD, + baselineY: top + metrics.ascent + LABEL_PAD, + colour: theme.chrome.subtitle, + measurer, + }), + ) + } + } + } + }) + + return nodes +} diff --git a/packages/charts2/src/core/layout/charts/discreteBar.ts b/packages/charts2/src/core/layout/charts/discreteBar.ts index bd725720b44..027205a844f 100644 --- a/packages/charts2/src/core/layout/charts/discreteBar.ts +++ b/packages/charts2/src/core/layout/charts/discreteBar.ts @@ -292,6 +292,7 @@ export function layoutDiscreteBar(ctx: LayoutContext, area: Rect, opts: ChartLay greyedLegendKeys: [], needsLegendFallback: false, empty: false, + valueDomain: spec.domain, diagnostics, } } diff --git a/packages/charts2/src/core/layout/charts/line.ts b/packages/charts2/src/core/layout/charts/line.ts index c312bcac651..27bb07f445c 100644 --- a/packages/charts2/src/core/layout/charts/line.ts +++ b/packages/charts2/src/core/layout/charts/line.ts @@ -21,6 +21,7 @@ import type { LayoutContext } from "../context.ts" import { declutterLabels, type LabelCandidate } from "../declutter.ts" import { createValueScale } from "../scales.ts" import { buildSeriesModels, toRelativeLineSeries } from "../series.ts" +import { comparisonLineNodes } from "./comparisonLines.ts" import { buildFooters, centeredBaseline, @@ -267,6 +268,21 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer needsLegendFallback = true } + // --- Comparison (reference) lines ------------------------------------------ + if (ctx.definition.comparisonLines !== undefined && ctx.definition.comparisonLines.length > 0) { + nodes.push( + ...comparisonLineNodes({ + lines: ctx.definition.comparisonLines, + plotArea, + yScale, + xScale, + theme, + measurer, + fontScale: scale, + }), + ) + } + // --- Hover ------------------------------------------------------------------- const targets: HitTarget[] = [] const subtitle = builtResult.strategy === "entity" ? metricSubtitle(ctx, slug) : undefined @@ -318,6 +334,7 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer greyedLegendKeys: [], needsLegendFallback, empty: false, + valueDomain: axes.spec.domain, diagnostics, } } diff --git a/packages/charts2/src/core/layout/charts/shared.ts b/packages/charts2/src/core/layout/charts/shared.ts index 721d0b70bf6..c0b0024d231 100644 --- a/packages/charts2/src/core/layout/charts/shared.ts +++ b/packages/charts2/src/core/layout/charts/shared.ts @@ -38,6 +38,9 @@ export interface ChartLayer { needsLegendFallback: boolean /** No drawable data — layoutChart renders the no-data panel instead. */ empty: boolean + /** Value-axis domain [min, max] after nice-ing; faceting reads it to share + * one scale across panels. Absent for empty/degenerate layers. */ + valueDomain?: [number, number] diagnostics: Diagnostic[] } diff --git a/packages/charts2/src/core/layout/charts/stackedArea.ts b/packages/charts2/src/core/layout/charts/stackedArea.ts index ea6050b2492..9f8db7f2f24 100644 --- a/packages/charts2/src/core/layout/charts/stackedArea.ts +++ b/packages/charts2/src/core/layout/charts/stackedArea.ts @@ -23,6 +23,7 @@ import { declutterLabels, type LabelCandidate } from "../declutter.ts" import { createValueScale } from "../scales.ts" import { buildSeriesModels, toShareOfTotalSeries } from "../series.ts" import { stackSeries, withMissingValuesAsZeroes, type StackedSeries } from "../stacking.ts" +import { comparisonLineNodes } from "./comparisonLines.ts" import { buildFooters, collectFooterFlags, @@ -279,6 +280,21 @@ export function layoutStackedArea(ctx: LayoutContext, area: Rect, opts: ChartLay needsLegendFallback = true } + // --- Comparison (reference) lines ------------------------------------------ + if (ctx.definition.comparisonLines !== undefined && ctx.definition.comparisonLines.length > 0) { + nodes.push( + ...comparisonLineNodes({ + lines: ctx.definition.comparisonLines, + plotArea, + yScale, + xScale, + theme, + measurer, + fontScale: scale, + }), + ) + } + // --- Hover --------------------------------------------------------------------- const targets: HitTarget[] = [] const t = strings(locale) @@ -358,6 +374,7 @@ export function layoutStackedArea(ctx: LayoutContext, area: Rect, opts: ChartLay greyedLegendKeys, needsLegendFallback, empty: false, + valueDomain: axes.spec.domain, diagnostics, } } diff --git a/packages/charts2/src/core/layout/charts/stackedBar.ts b/packages/charts2/src/core/layout/charts/stackedBar.ts index 5a819ba8f81..a63c90fd7ab 100644 --- a/packages/charts2/src/core/layout/charts/stackedBar.ts +++ b/packages/charts2/src/core/layout/charts/stackedBar.ts @@ -206,6 +206,7 @@ export function layoutStackedBar(ctx: LayoutContext, area: Rect, opts: ChartLaye greyedLegendKeys: [], needsLegendFallback: false, empty: false, + valueDomain: axes.spec.domain, diagnostics, } } diff --git a/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts b/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts index 6a75e72ce87..1b43ae3b925 100644 --- a/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts +++ b/packages/charts2/src/core/layout/charts/stackedDiscreteBar.ts @@ -380,6 +380,7 @@ export function layoutStackedDiscreteBar(ctx: LayoutContext, area: Rect, opts: C greyedLegendKeys: [], needsLegendFallback: false, empty: false, + valueDomain: spec.domain, diagnostics, } } diff --git a/packages/charts2/src/core/layout/facet.test.ts b/packages/charts2/src/core/layout/facet.test.ts new file mode 100644 index 00000000000..85d2fbeb493 --- /dev/null +++ b/packages/charts2/src/core/layout/facet.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../fixtures/index.ts" +import { parseDefinition } from "../definition/schema.ts" +import type { ChartScene, SceneNode } from "../scene/nodes.ts" +import { defaultMeasurer } from "../text/createMeasurer.ts" +import { buildCanadaTheme } from "../theme/themes.ts" +import type { ChartDefinition } from "../types.ts" +import { layoutChart } from "./layoutChart.ts" + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function sceneFor(fixture: FixtureName, raw: Record): ChartScene { + const { dataset } = loadFixtureDataset(fixture) + return layoutChart({ + definition: definitionFor(raw), + dataset, + theme: buildCanadaTheme, + measurer: defaultMeasurer, + size: { width: 900, height: 640 }, + chrome: "full", + }) +} + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => (node.kind === "group" ? [node, ...flatten(node.children)] : [node])) +} + +function assertUniqueKeys(scene: ChartScene): void { + const seen = new Set() + for (const node of flatten(scene.nodes)) { + expect(seen.has(node.key), `duplicate key: ${node.key}`).toBe(false) + seen.add(node.key) + } +} + +function assertFinite(scene: ChartScene): void { + const walk = (value: unknown): void => { + if (typeof value === "number") expect(Number.isFinite(value)).toBe(true) + else if (Array.isArray(value)) value.forEach(walk) + else if (typeof value === "object" && value !== null) Object.values(value).forEach(walk) + } + walk(scene.nodes) +} + +describe("faceting (spec 09)", () => { + it("entity facet renders one titled panel per selected entity", () => { + const scene = sceneFor("provincial-budgets", { + y: ["total_spending"], + types: ["line"], + facet: "entity", + selectedEntities: ["Ontario", "Quebec", "Alberta"], + }) + const titles = flatten(scene.nodes) + .filter((n) => n.kind === "text" && /^facet\/\d+\/title$/.test(n.key)) + .map((n) => (n.kind === "text" ? n.text : "")) + expect(titles).toEqual(["Ontario", "Quebec", "Alberta"]) + // Each panel carries its own prefixed chart nodes. + expect(scene.nodes.some((n) => n.key.startsWith("facet/0/series/"))).toBe(true) + expect(scene.nodes.some((n) => n.key.startsWith("facet/2/series/"))).toBe(true) + assertUniqueKeys(scene) + assertFinite(scene) + }) + + it("shares one value domain across panels so gridlines align", () => { + const scene = sceneFor("provincial-budgets", { + y: ["total_spending"], + types: ["line"], + facet: "entity", + selectedEntities: ["Ontario", "Alberta"], + }) + const gridY = (panel: number, tick: string): number | undefined => { + const node = scene.nodes.find((n) => n.key === `facet/${panel}/axis/y/grid/${tick}`) + return node?.kind === "rule" ? node.from.y : undefined + } + // A shared domain places the same tick value at the same y in every panel. + const y0 = gridY(0, "0") + const y1 = gridY(1, "0") + expect(y0).toBeDefined() + expect(y1).toBeDefined() + expect(y0).toBeCloseTo(y1 as number) + }) + + it("metric facet renders one panel per metric with a shared legend", () => { + const scene = sceneFor("provincial-budgets", { + y: ["program_spending", "debt_charges"], + types: ["line"], + facet: "metric", + selectedEntities: ["Ontario", "Alberta"], + }) + const titles = flatten(scene.nodes) + .filter((n) => n.kind === "text" && /^facet\/\d+\/title$/.test(n.key)) + .map((n) => (n.kind === "text" ? n.text : "")) + expect(titles.length).toBe(2) + // Two entities per panel → a shared legend above the grid. + expect(scene.legend?.length).toBe(2) + expect(scene.nodes.some((n) => n.key.startsWith("legend/"))).toBe(true) + assertUniqueKeys(scene) + }) + + it("falls back to a single chart when only one panel would result", () => { + const scene = sceneFor("provincial-budgets", { + y: ["total_spending"], + types: ["line"], + facet: "entity", + selectedEntities: ["Ontario"], + }) + expect(scene.nodes.some((n) => n.key.startsWith("facet/"))).toBe(false) + expect(scene.nodes.some((n) => n.key.startsWith("series/"))).toBe(true) + }) + + it("is deterministic across identical layouts", () => { + const raw = { + y: ["total_spending"], + types: ["line"], + facet: "entity", + selectedEntities: ["Ontario", "Quebec", "Alberta", "British Columbia"], + } + expect(sceneFor("provincial-budgets", raw)).toEqual(sceneFor("provincial-budgets", raw)) + }) +}) diff --git a/packages/charts2/src/core/layout/facet.ts b/packages/charts2/src/core/layout/facet.ts new file mode 100644 index 00000000000..734765688ab --- /dev/null +++ b/packages/charts2/src/core/layout/facet.ts @@ -0,0 +1,267 @@ +/** + * Faceting — small multiples (spec 09). + * + * Splits one chart into a grid of panels: one per entity (each panel shows all + * metrics) or one per metric (each shows all entities). Panels share a common + * value domain by default so they read on the same scale, and colours stay + * consistent across panels (same series → same colour everywhere), driven by + * the deterministic series-colour assignment the chart layers already do. A + * single shared legend sits above the grid when panels carry more than one + * series; a single-series facet (e.g. one metric per entity) needs none — the + * panel title is the identity. + * + * Two passes: pass 1 lays every panel out independently to collect the union + * value domain and the legend set; pass 2 re-lays them with that domain pinned + * into the value-axis config, so gridlines align across the grid. + * + * Deferred (documented, spec 09 §3/§4/§6): the reader-togglable independent- + * axis mode, leftmost-column / bottom-row tick-label thinning, monochrome + * single-metric entity facets, and faceted maps. + */ + +import type { HoverModel, LegendItem, Rect, SceneNode, SeriesModel } from "../scene/nodes.ts" +import type { TextMeasurer } from "../text/measurer.ts" +import { truncateWithEllipsis } from "../text/truncate.ts" +import type { Theme } from "../theme/types.ts" +import type { AxisConfig, ChartDefinition, ChartType, Diagnostic } from "../types.ts" +import { legendFont, seriesLabelFont, textNode, type ChartLayer, type ChartLayerOptions } from "./charts/shared.ts" +import type { LayoutContext } from "./context.ts" +import { layoutLegend } from "./legend.ts" + +export const MAX_FACET_PANELS = 16 +const PANEL_GAP = 16 +const TITLE_GAP = 4 + +type ChartLayoutFn = (ctx: LayoutContext, area: Rect, opts: ChartLayerOptions) => ChartLayer + +interface PanelDescriptor { + key: string + title: string + entities: string[] + y: string[] +} + +export interface FacetInput { + ctx: LayoutContext + chartType: ChartType + run: ChartLayoutFn + area: Rect + theme: Theme + measurer: TextMeasurer + fontScale: number +} + +export interface FacetResult { + nodes: SceneNode[] + series: SeriesModel[] + hover: HoverModel + legend: LegendItem[] | null + plotArea: Rect + diagnostics: Diagnostic[] + /** Every panel was empty — the caller renders the no-data panel instead. */ + empty: boolean +} + +function panelsFor(ctx: LayoutContext): PanelDescriptor[] { + if (ctx.definition.facet === "entity") { + return ctx.entities.map((entity) => ({ key: entity, title: entity, entities: [entity], y: ctx.definition.y })) + } + if (ctx.definition.facet === "metric") { + return ctx.definition.y.map((slug) => ({ + key: slug, + title: ctx.columns[slug]?.name ?? slug, + entities: ctx.entities, + y: [slug], + })) + } + return [] +} + +/** Balance columns/rows to the frame's aspect ratio, filling left-to-right. */ +function gridDimensions(count: number, area: Rect): { cols: number; rows: number } { + const aspect = area.height > 0 ? area.width / area.height : 1 + let cols = Math.max(1, Math.min(count, Math.round(Math.sqrt(count * Math.max(aspect, 0.1))))) + const rows = Math.ceil(count / cols) + cols = Math.ceil(count / rows) // tighten so the last row has no empty leading column + return { cols, rows } +} + +/** The value axis is horizontal for the discrete-bar family, vertical elsewhere. */ +function valueAxisKey(chartType: ChartType): "xAxis" | "yAxis" { + return chartType === "discrete-bar" || chartType === "stacked-discrete-bar" ? "xAxis" : "yAxis" +} + +function panelContext( + base: LayoutContext, + panel: PanelDescriptor, + sharedDomain: { min: number; max: number } | null, + axisKey: "xAxis" | "yAxis", +): LayoutContext { + const definition: ChartDefinition = { + ...base.definition, + y: panel.y, + facet: "none", + hideLegend: true, + hideSeriesLabels: true, + } + if (sharedDomain !== null) { + const merged: AxisConfig = { ...(base.definition[axisKey] ?? {}), min: sharedDomain.min, max: sharedDomain.max } + definition[axisKey] = merged + } + return { ...base, definition, entities: panel.entities } +} + +/** Rewrite node keys under a stable panel prefix; seriesKey is left intact so + * hover emphasis and the shared legend key the same series across every panel. */ +function prefixKeys(nodes: readonly SceneNode[], prefix: string): SceneNode[] { + return nodes.map((node) => + node.kind === "group" + ? { ...node, key: `${prefix}/${node.key}`, children: prefixKeys(node.children, prefix) } + : { ...node, key: `${prefix}/${node.key}` }, + ) +} + +/** + * Lay a faceted chart out, or return null when the strategy yields fewer than + * two panels (the caller then renders a single, unfaceted chart). + */ +export function layoutFacetedChart(input: FacetInput): FacetResult | null { + const { ctx, chartType, run, area, theme, measurer, fontScale } = input + let panels = panelsFor(ctx) + if (panels.length < 2) return null + + const diagnostics: Diagnostic[] = [] + if (panels.length > MAX_FACET_PANELS) { + diagnostics.push({ + severity: "warning", + code: "facet-panel-cap", + message: `Faceting shows the first ${MAX_FACET_PANELS} of ${panels.length} panels; narrow the selection to see the rest`, + context: { shown: MAX_FACET_PANELS, total: panels.length }, + }) + panels = panels.slice(0, MAX_FACET_PANELS) + } + + const axisKey = valueAxisKey(chartType) + const panelOpts: ChartLayerOptions = { legendReserved: true, thumbnail: false, fontScale } + + // --- Pass 1: independent layout to collect the union domain + legend -------- + const probeCell: Rect = { x: 0, y: 0, width: Math.max(10, area.width), height: Math.max(10, area.height) } + let domainMin = Number.POSITIVE_INFINITY + let domainMax = Number.NEGATIVE_INFINITY + let haveDomain = true + const legendItems: LegendItem[] = [] + const seenLegend = new Set() + for (const panel of panels) { + const probe = run(panelContext(ctx, panel, null, axisKey), probeCell, panelOpts) + if (probe.valueDomain !== undefined) { + domainMin = Math.min(domainMin, probe.valueDomain[0]) + domainMax = Math.max(domainMax, probe.valueDomain[1]) + } else { + haveDomain = false + } + for (const item of probe.legendItems) { + if (!seenLegend.has(item.seriesKey)) { + seenLegend.add(item.seriesKey) + legendItems.push(item) + } + } + } + const sharedDomain = + haveDomain && Number.isFinite(domainMin) && Number.isFinite(domainMax) && domainMin !== domainMax + ? { min: domainMin, max: domainMax } + : null + + // --- Shared legend above the grid ------------------------------------------- + const nodes: SceneNode[] = [] + let legendModelItems: LegendItem[] | null = null + let gridTop = area.y + if (legendItems.length > 1) { + const legend = layoutLegend({ + items: legendItems, + x: area.x, + y: area.y, + width: area.width, + theme, + measurer, + font: legendFont(fontScale), + }) + nodes.push(...legend.nodes) + legendModelItems = legend.items + gridTop = area.y + legend.height + } + + const gridArea: Rect = { + x: area.x, + y: gridTop, + width: area.width, + height: Math.max(10, area.height - (gridTop - area.y)), + } + const { cols, rows } = gridDimensions(panels.length, gridArea) + const cellW = Math.max(10, (gridArea.width - PANEL_GAP * (cols - 1)) / cols) + const cellH = Math.max(10, (gridArea.height - PANEL_GAP * (rows - 1)) / rows) + + const titleFont = seriesLabelFont(fontScale) + const titleSample = measurer.measure("Ag", titleFont) + const titleHeight = titleSample.ascent + titleSample.descent + TITLE_GAP + + // --- Pass 2: final panels ---------------------------------------------------- + const hoverTargets: HoverModel["targets"] = [] + const series: SeriesModel[] = [] + const seenSeries = new Set() + let anyContent = false + + panels.forEach((panel, index) => { + const col = index % cols + const rowIndex = Math.floor(index / cols) + const cellX = gridArea.x + col * (cellW + PANEL_GAP) + const cellY = gridArea.y + rowIndex * (cellH + PANEL_GAP) + + const titleText = truncateWithEllipsis(panel.title, titleFont, cellW, measurer) + const titleMetrics = measurer.measure(titleText, titleFont) + nodes.push( + textNode({ + key: `facet/${index}/title`, + role: "label", + text: titleText, + font: titleFont, + anchor: "start", + x: cellX, + baselineY: cellY + titleMetrics.ascent, + colour: theme.chrome.title, + measurer, + }), + ) + + const panelArea: Rect = { + x: cellX, + y: cellY + titleHeight, + width: cellW, + height: Math.max(10, cellH - titleHeight), + } + const layer = run(panelContext(ctx, panel, sharedDomain, axisKey), panelArea, panelOpts) + if (layer.empty) return + anyContent = true + nodes.push(...prefixKeys(layer.nodes, `facet/${index}`)) + hoverTargets.push(...layer.hover.targets) + for (const s of layer.series) { + if (!seenSeries.has(s.key)) { + seenSeries.add(s.key) + series.push(s) + } + } + }) + + if (!anyContent) { + return { nodes: [], series: [], hover: { targets: [] }, legend: null, plotArea: gridArea, diagnostics, empty: true } + } + + return { + nodes, + series, + hover: { targets: hoverTargets }, + legend: legendModelItems, + plotArea: gridArea, + diagnostics, + empty: false, + } +} diff --git a/packages/charts2/src/core/layout/layoutChart.ts b/packages/charts2/src/core/layout/layoutChart.ts index 689c1832a87..cacacb7b4b4 100644 --- a/packages/charts2/src/core/layout/layoutChart.ts +++ b/packages/charts2/src/core/layout/layoutChart.ts @@ -36,6 +36,7 @@ import { import { activeChartType } from "./chooseType.ts" import { layoutChrome, type ChromeMode } from "./chrome.ts" import { buildContext, type LayoutContext } from "./context.ts" +import { layoutFacetedChart } from "./facet.ts" import { layoutLegend, type LegendLayout } from "./legend.ts" export interface LayoutChartOptions { @@ -86,6 +87,22 @@ export function layoutChart(options: LayoutChartOptions): ChartScene { const chartType = activeChartType(ctx.definition.types, view, ctx.collapsed, ctx.definition.defaultTab) + // Comparison lines render on the continuous-axis charts only (spec 02 §2); + // flag the request on any other type rather than silently dropping it. + if ( + ctx.definition.comparisonLines !== undefined && + ctx.definition.comparisonLines.length > 0 && + chartType !== "line" && + chartType !== "stacked-area" + ) { + diagnostics.push({ + severity: "warning", + code: "comparison-lines-unsupported", + message: `Comparison lines are not yet rendered for ${chartType} charts`, + context: { chartType }, + }) + } + const chrome = layoutChrome({ definition: ctx.definition, manifest: dataset.manifest, @@ -107,6 +124,38 @@ export function layoutChart(options: LayoutChartOptions): ChartScene { return noDataScene(ctx, size, theme, chrome.nodes, chrome.contentArea, fontScale, diagnostics) } + // --- Faceting: a grid of small multiples replaces the single chart -------- + if (mode === "full" && ctx.definition.facet !== "none") { + const facet = layoutFacetedChart({ + ctx, + chartType, + run: CHART_LAYOUTS[chartType], + area: chrome.contentArea, + theme, + measurer, + fontScale, + }) + // null → fewer than two panels; fall through to the single chart. + if (facet !== null) { + diagnostics.push(...facet.diagnostics) + if (facet.empty) { + return noDataScene(ctx, size, theme, chrome.nodes, chrome.contentArea, fontScale, diagnostics) + } + const facetNodes: SceneNode[] = [...chrome.nodes, ...facet.nodes] + return { + width: size.width, + height: size.height, + background: theme.chrome.background, + plotArea: roundRect(facet.plotArea), + nodes: facetNodes.map(roundNode), + series: facet.series, + ...(facet.legend !== null ? { legend: facet.legend } : {}), + hover: roundHover(facet.hover), + diagnostics, + } + } + } + // --- Chart layout, with the legend two-pass ------------------------------- const run = CHART_LAYOUTS[chartType] let wantLegend = legendPlanned(chartType, ctx.definition, mode) diff --git a/packages/charts2/src/samples.test.ts b/packages/charts2/src/samples.test.ts index b6a46374b2f..dd0dbc0b1bd 100644 --- a/packages/charts2/src/samples.test.ts +++ b/packages/charts2/src/samples.test.ts @@ -16,6 +16,8 @@ describe("samples", () => { it("has committed sample definitions", () => { expect(sampleFiles).toEqual([ "discrete-bar-population.json", + "line-comparison-provincial-budgets.json", + "line-faceted-provincial-budgets.json", "line-federal-departments.json", "line-provincial-budgets.json", "stacked-area-government-debt.json", diff --git a/packages/charts2/src/stories/ComparisonLines.stories.tsx b/packages/charts2/src/stories/ComparisonLines.stories.tsx new file mode 100644 index 00000000000..bb4d33cab8b --- /dev/null +++ b/packages/charts2/src/stories/ComparisonLines.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/ComparisonLines", + component: Chart, + parameters: { + docs: { + description: { + component: + "Comparison lines (spec 02 §2). Dashed reference lines drawn over the plot: a " + + "horizontal line at a fixed y value and/or a vertical line at a fixed time, each " + + "with an optional label. Lines outside the current plot range are skipped. Rendered " + + "on the continuous-axis charts (line, stacked area).", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const provincialBudgets = storyDataset("provincial-budgets") + +const budgetsDefinition = { + title: "Provincial budget spending", + subtitle: "Total budgetary expenditure, with reference lines", + data: "provincial-budgets", + y: ["total_spending"], + types: ["line"], + selectedEntities: ["Ontario", "Quebec"], + sourceText: "Provincial public accounts", +} + +/** A single horizontal reference line at a fixed value. */ +export const Horizontal: Story = { + render: () => ( + + ), +} + +/** A horizontal value line plus a vertical line at a fixed fiscal year. */ +export const HorizontalAndVertical: Story = { + render: () => ( + + ), +} diff --git a/packages/charts2/src/stories/Faceting.stories.tsx b/packages/charts2/src/stories/Faceting.stories.tsx new file mode 100644 index 00000000000..4d4163bf0e6 --- /dev/null +++ b/packages/charts2/src/stories/Faceting.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Chart } from "../react/index.ts" +import { renderStoryTooltip, storyDataset, storyDefinition } from "./helpers.tsx" + +import "../react/styles/charts.scss" + +const meta: Meta = { + title: "Charts2/Faceting", + component: Chart, + parameters: { + docs: { + description: { + component: + "Faceting — small multiples (spec 09). One chart splits into a grid of panels, " + + "either one per entity (each panel shows all metrics) or one per metric (each shows " + + "all entities). Panels share a common value domain so they read on the same scale, " + + "colours stay consistent across panels, and a single shared legend sits above the grid.", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +const provincialBudgets = storyDataset("provincial-budgets") + +const facetDefinition = { + title: "Provincial spending composition", + subtitle: "Program spending and debt charges by province", + data: "provincial-budgets", + y: ["program_spending", "debt_charges"], + types: ["line"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta"], + sourceText: "Provincial public accounts", +} + +/** One panel per entity; each panel carries every metric. */ +export const ByEntity: Story = { + render: () => ( + + ), +} + +/** One panel per metric; each panel carries every entity, with a shared legend. */ +export const ByMetric: Story = { + render: () => ( + + ), +} From 37305ee30e7e4afb864bcc6ab77447cd07130462 Mon Sep 17 00:00:00 2001 From: xrendan Date: Fri, 10 Jul 2026 08:45:36 -0600 Subject: [PATCH 07/13] WIP checkpoint: charts2 theming, new chart types, chrome retheme, line focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single checkpoint of in-flight work across several streams on this branch: - Theme rework: chrome colours sourced from @buildcanada/colours (linen/charcoal/nickel/auburn); larger title/subtitle fonts; emphasis dimOpacity 0.35 -> 0.2. Corpus goldens re-blessed to match. - New chart types: slope, dumbbell, scatter, marimekko (layouts, goldens, stories, tests) wired into the layout registry. - Interactive chrome rethemed to the Build Canada palette; flush tab row; new @buildcanada/components primitives (Popover, RadioGroup, Select, SegmentedControl, Slider, IconButton). - Line-chart focus: hovering/clicking a series' end label emphasizes it (spec 07 §3), non-focused lines/labels dim to 0.2 and their markers hide; forceable via `charts render --focus ` and the Chart `focusedSeries` prop. - CLI: scaffold + install-skill commands. - Adds the charts3 DOM-free engine experiment and the specs/ directory. Stray CLI render artifacts (chart.png/chart.svg) intentionally left untracked. --- .storybook/main.ts | 6 + bun.lock | 182 ++- package.json | 27 +- .../src/grapher/modal/DownloadModal.test.tsx | 175 +++ .../src/grapher/modal/DownloadModal.tsx | 6 +- packages/charts2/README.md | 83 +- packages/charts2/build.ts | 6 +- packages/charts2/package.json | 3 +- packages/charts2/skills/charts2-cli/SKILL.md | 275 +++++ .../skills/charts2-cli/agents/openai.yaml | 4 + packages/charts2/src/cli/index.ts | 11 +- packages/charts2/src/cli/installSkill.test.ts | 107 ++ packages/charts2/src/cli/installSkill.ts | 149 +++ packages/charts2/src/cli/render.test.ts | 48 +- packages/charts2/src/cli/render.ts | 49 +- packages/charts2/src/cli/scaffold.test.ts | 91 ++ packages/charts2/src/cli/scaffold.ts | 315 +++++ packages/charts2/src/cli/validate.ts | 2 +- .../src/core/definition/schema.test.ts | 8 +- .../charts2/src/core/definition/schema.ts | 26 +- .../charts2/src/core/definition/urlState.ts | 13 +- .../__snapshots__/layoutChart.test.ts.snap | 187 ++- .../src/core/layout/charts/dumbbell.test.ts | 243 ++++ .../src/core/layout/charts/dumbbell.ts | 517 ++++++++ .../src/core/layout/charts/line.test.ts | 31 +- .../charts2/src/core/layout/charts/line.ts | 45 +- .../src/core/layout/charts/marimekko.test.ts | 219 ++++ .../src/core/layout/charts/marimekko.ts | 557 +++++++++ .../src/core/layout/charts/scatter.test.ts | 168 +++ .../charts2/src/core/layout/charts/scatter.ts | 411 ++++++ .../charts2/src/core/layout/charts/shared.ts | 4 +- .../src/core/layout/charts/slope.test.ts | 226 ++++ .../charts2/src/core/layout/charts/slope.ts | 427 +++++++ .../charts2/src/core/layout/chrome.test.ts | 15 +- packages/charts2/src/core/layout/index.ts | 4 + .../src/core/layout/layoutChart.test.ts | 2 +- .../charts2/src/core/layout/layoutChart.ts | 10 +- .../charts2/src/core/text/metricsTables.ts | 6 +- .../charts2/src/core/theme/themes.test.ts | 7 +- packages/charts2/src/core/theme/themes.ts | 12 +- packages/charts2/src/core/types.ts | 25 + .../discrete-bar--default--1200x600.svg | 2 +- .../discrete-bar--default--300x160.svg | 2 +- .../discrete-bar--default--850x600.svg | 2 +- .../discrete-bar--negatives--850x600.svg | 2 +- .../discrete-bar--sort-name--850x600.svg | 2 +- .../dumbbell--change-labels--850x600.svg | 2 + .../dumbbell--default--1200x600.svg | 2 + .../__golden__/dumbbell--default--300x160.svg | 2 + .../__golden__/dumbbell--default--850x600.svg | 2 + .../__golden__/line--default--1200x600.svg | 2 +- .../__golden__/line--default--300x160.svg | 2 +- .../__golden__/line--default--850x600.svg | 2 +- .../corpus/__golden__/line--fr--850x600.svg | 2 +- .../line--many-entities--1200x600.svg | 2 +- .../line--missing-data--850x600.svg | 2 +- .../__golden__/line--relative--850x600.svg | 2 +- .../__golden__/line--single-time--850x600.svg | 2 +- .../marimekko--default--1200x600.svg | 2 + .../marimekko--default--300x160.svg | 2 + .../marimekko--default--850x600.svg | 2 + .../marimekko--relative--850x600.svg | 2 + .../__golden__/scatter--default--1200x600.svg | 2 + .../__golden__/scatter--default--300x160.svg | 2 + .../__golden__/scatter--default--850x600.svg | 2 + .../__golden__/slope--default--1200x600.svg | 2 + .../__golden__/slope--default--300x160.svg | 2 + .../__golden__/slope--default--850x600.svg | 2 + .../corpus/__golden__/slope--fr--850x600.svg | 2 + .../stacked-area--default--1200x600.svg | 2 +- .../stacked-area--default--300x160.svg | 2 +- .../stacked-area--default--850x600.svg | 2 +- .../__golden__/stacked-area--fr--850x600.svg | 2 +- .../stacked-area--relative--850x600.svg | 2 +- .../stacked-bar--default--1200x600.svg | 2 +- .../stacked-bar--default--300x160.svg | 2 +- .../stacked-bar--default--850x600.svg | 2 +- .../stacked-bar--relative--850x600.svg | 2 +- ...tacked-discrete-bar--default--1200x600.svg | 2 +- ...stacked-discrete-bar--default--300x160.svg | 2 +- ...stacked-discrete-bar--default--850x600.svg | 2 +- ...ed-discrete-bar--missing-data--850x600.svg | 2 +- ...tacked-discrete-bar--relative--850x600.svg | 2 +- packages/charts2/src/corpus/corpus.ts | 92 ++ packages/charts2/src/react/Chart.test.tsx | 19 + packages/charts2/src/react/Chart.tsx | 9 +- packages/charts2/src/react/SceneSVG.test.tsx | 25 + packages/charts2/src/react/SceneSVG.tsx | 14 + .../src/react/chrome/DataTable.test.tsx | 10 + .../charts2/src/react/chrome/DataTable.tsx | 42 +- .../src/react/chrome/EntitySelector.test.tsx | 34 +- .../src/react/chrome/EntitySelector.tsx | 136 +- .../src/react/chrome/SettingsMenu.test.tsx | 1 + .../charts2/src/react/chrome/SettingsMenu.tsx | 126 +- packages/charts2/src/react/chrome/Tabs.tsx | 67 +- .../charts2/src/react/chrome/Timeline.tsx | 27 +- packages/charts2/src/react/chrome/Tooltip.tsx | 7 +- packages/charts2/src/react/styles/charts.scss | 444 +++++-- .../charts2/src/stories/Dumbbell.stories.tsx | 88 ++ .../charts2/src/stories/Marimekko.stories.tsx | 82 ++ .../charts2/src/stories/Scatter.stories.tsx | 64 + .../charts2/src/stories/Slope.stories.tsx | 78 ++ packages/charts3/.gitignore | 3 + packages/charts3/build.ts | 77 ++ packages/charts3/docs/design-decisions.md | 52 + packages/charts3/package.json | 76 ++ packages/charts3/src/cli/cli.test.ts | 113 ++ packages/charts3/src/cli/index.ts | 274 ++++ .../charts3/src/core/allChartTypes.test.ts | 271 ++++ packages/charts3/src/core/data/index.ts | 260 ++++ packages/charts3/src/core/definition/index.ts | 45 + .../src/core/explorer/explorer.test.ts | 70 ++ packages/charts3/src/core/explorer/index.ts | 126 ++ packages/charts3/src/core/fixtures.test.ts | 136 ++ packages/charts3/src/core/format/index.ts | 48 + packages/charts3/src/core/index.ts | 13 + packages/charts3/src/core/model/index.ts | 245 ++++ packages/charts3/src/core/motion/index.ts | 70 ++ packages/charts3/src/core/renderers/index.ts | 1 + packages/charts3/src/core/renderers/svg.ts | 1099 +++++++++++++++++ packages/charts3/src/core/scene/index.ts | 44 + packages/charts3/src/core/state/index.ts | 94 ++ packages/charts3/src/core/table/index.ts | 20 + packages/charts3/src/core/table/table.test.ts | 31 + packages/charts3/src/core/theme/index.ts | 124 ++ packages/charts3/src/core/time/index.ts | 93 ++ packages/charts3/src/core/types.ts | 254 ++++ packages/charts3/src/index.ts | 6 + packages/charts3/src/react/Chart.tsx | 29 + packages/charts3/src/react/Explorer.tsx | 68 + packages/charts3/src/react/index.ts | 2 + packages/charts3/src/styles/charts3.scss | 39 + packages/charts3/tsconfig.build.json | 15 + packages/charts3/tsconfig.json | 25 + packages/charts3/vitest.config.ts | 8 + .../components/src/content/Card/Card.scss | 4 +- packages/components/src/index.ts | 6 + .../src/primitives/Button/Button.scss | 55 + .../src/primitives/Button/Button.test.tsx | 19 + .../src/primitives/Button/Button.tsx | 199 +-- .../src/primitives/Button/IconButton.tsx | 28 + .../components/src/primitives/Button/index.ts | 1 + .../src/primitives/Checkbox/Checkbox.scss | 96 +- .../src/primitives/Checkbox/Checkbox.test.tsx | 14 + .../src/primitives/Checkbox/Checkbox.tsx | 35 +- .../src/primitives/Popover/MenuButton.tsx | 123 ++ .../src/primitives/Popover/Popover.scss | 51 + .../primitives/Popover/Popover.stories.tsx | 31 + .../src/primitives/Popover/Popover.test.tsx | 29 + .../src/primitives/Popover/Popover.tsx | 165 +++ .../src/primitives/Popover/index.ts | 3 + .../src/primitives/RadioGroup/RadioGroup.scss | 116 ++ .../RadioGroup/RadioGroup.stories.tsx | 23 + .../primitives/RadioGroup/RadioGroup.test.tsx | 35 + .../src/primitives/RadioGroup/RadioGroup.tsx | 146 +++ .../src/primitives/RadioGroup/index.ts | 2 + .../SegmentedControl/SegmentedControl.scss | 108 ++ .../SegmentedControl.stories.tsx | 35 + .../SegmentedControl.test.tsx | 28 + .../SegmentedControl/SegmentedControl.tsx | 167 +++ .../src/primitives/SegmentedControl/index.ts | 2 + .../src/primitives/Select/Select.scss | 94 ++ .../src/primitives/Select/Select.stories.tsx | 21 + .../src/primitives/Select/Select.test.tsx | 39 + .../src/primitives/Select/Select.tsx | 108 ++ .../components/src/primitives/Select/index.ts | 2 + .../src/primitives/Slider/Slider.scss | 95 ++ .../src/primitives/Slider/Slider.stories.tsx | 34 + .../src/primitives/Slider/Slider.test.tsx | 24 + .../src/primitives/Slider/Slider.tsx | 223 ++++ .../components/src/primitives/Slider/index.ts | 2 + .../src/primitives/TextField/TextField.scss | 60 + .../primitives/TextField/TextField.test.tsx | 15 + .../src/primitives/TextField/TextField.tsx | 80 +- packages/components/src/styles/fonts.scss | 12 + packages/components/src/styles/main.scss | 5 + specs/00-overview.md | 62 + specs/01-data-format.md | 200 +++ specs/02-chart-definition.md | 101 ++ specs/03-axes-and-formatting.md | 56 + specs/04-colour-and-theming.md | 70 ++ specs/05-legends.md | 49 + specs/06-tooltips.md | 41 + specs/07-selection-and-focus.md | 53 + specs/08-time-and-timeline.md | 51 + specs/09-faceting.md | 49 + specs/10-layout-and-chrome.md | 66 + specs/11-line-chart.md | 66 + specs/12-slope-chart.md | 54 + specs/13-discrete-bar-chart.md | 55 + specs/14-stacked-area-chart.md | 57 + specs/15-stacked-bar-chart.md | 52 + specs/16-stacked-discrete-bar-chart.md | 53 + specs/17-dumbbell-chart.md | 54 + specs/18-scatter-chart.md | 64 + specs/19-marimekko-chart.md | 49 + specs/20-map-chart.md | 67 + specs/21-new-chart-types.md | 50 + specs/22-data-table.md | 38 + specs/23-explorer.md | 48 + specs/24-cli-rendering.md | 60 + specs/25-motion-and-video.md | 76 ++ specs/26-testing.md | 52 + specs/27-scenarios.md | 123 ++ specs/28-architecture.md | 78 ++ specs/29-component-primitives.md | 216 ++++ 206 files changed, 13796 insertions(+), 631 deletions(-) create mode 100644 packages/charts/src/grapher/modal/DownloadModal.test.tsx create mode 100644 packages/charts2/skills/charts2-cli/SKILL.md create mode 100644 packages/charts2/skills/charts2-cli/agents/openai.yaml create mode 100644 packages/charts2/src/cli/installSkill.test.ts create mode 100644 packages/charts2/src/cli/installSkill.ts create mode 100644 packages/charts2/src/cli/scaffold.test.ts create mode 100644 packages/charts2/src/cli/scaffold.ts create mode 100644 packages/charts2/src/core/layout/charts/dumbbell.test.ts create mode 100644 packages/charts2/src/core/layout/charts/dumbbell.ts create mode 100644 packages/charts2/src/core/layout/charts/marimekko.test.ts create mode 100644 packages/charts2/src/core/layout/charts/marimekko.ts create mode 100644 packages/charts2/src/core/layout/charts/scatter.test.ts create mode 100644 packages/charts2/src/core/layout/charts/scatter.ts create mode 100644 packages/charts2/src/core/layout/charts/slope.test.ts create mode 100644 packages/charts2/src/core/layout/charts/slope.ts create mode 100644 packages/charts2/src/corpus/__golden__/dumbbell--change-labels--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/dumbbell--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/dumbbell--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/dumbbell--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/marimekko--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/marimekko--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/marimekko--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/marimekko--relative--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/scatter--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/scatter--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/scatter--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/slope--default--1200x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/slope--default--300x160.svg create mode 100644 packages/charts2/src/corpus/__golden__/slope--default--850x600.svg create mode 100644 packages/charts2/src/corpus/__golden__/slope--fr--850x600.svg create mode 100644 packages/charts2/src/stories/Dumbbell.stories.tsx create mode 100644 packages/charts2/src/stories/Marimekko.stories.tsx create mode 100644 packages/charts2/src/stories/Scatter.stories.tsx create mode 100644 packages/charts2/src/stories/Slope.stories.tsx create mode 100644 packages/charts3/.gitignore create mode 100644 packages/charts3/build.ts create mode 100644 packages/charts3/docs/design-decisions.md create mode 100644 packages/charts3/package.json create mode 100644 packages/charts3/src/cli/cli.test.ts create mode 100644 packages/charts3/src/cli/index.ts create mode 100644 packages/charts3/src/core/allChartTypes.test.ts create mode 100644 packages/charts3/src/core/data/index.ts create mode 100644 packages/charts3/src/core/definition/index.ts create mode 100644 packages/charts3/src/core/explorer/explorer.test.ts create mode 100644 packages/charts3/src/core/explorer/index.ts create mode 100644 packages/charts3/src/core/fixtures.test.ts create mode 100644 packages/charts3/src/core/format/index.ts create mode 100644 packages/charts3/src/core/index.ts create mode 100644 packages/charts3/src/core/model/index.ts create mode 100644 packages/charts3/src/core/motion/index.ts create mode 100644 packages/charts3/src/core/renderers/index.ts create mode 100644 packages/charts3/src/core/renderers/svg.ts create mode 100644 packages/charts3/src/core/scene/index.ts create mode 100644 packages/charts3/src/core/state/index.ts create mode 100644 packages/charts3/src/core/table/index.ts create mode 100644 packages/charts3/src/core/table/table.test.ts create mode 100644 packages/charts3/src/core/theme/index.ts create mode 100644 packages/charts3/src/core/time/index.ts create mode 100644 packages/charts3/src/core/types.ts create mode 100644 packages/charts3/src/index.ts create mode 100644 packages/charts3/src/react/Chart.tsx create mode 100644 packages/charts3/src/react/Explorer.tsx create mode 100644 packages/charts3/src/react/index.ts create mode 100644 packages/charts3/src/styles/charts3.scss create mode 100644 packages/charts3/tsconfig.build.json create mode 100644 packages/charts3/tsconfig.json create mode 100644 packages/charts3/vitest.config.ts create mode 100644 packages/components/src/primitives/Button/Button.test.tsx create mode 100644 packages/components/src/primitives/Button/IconButton.tsx create mode 100644 packages/components/src/primitives/Checkbox/Checkbox.test.tsx create mode 100644 packages/components/src/primitives/Popover/MenuButton.tsx create mode 100644 packages/components/src/primitives/Popover/Popover.scss create mode 100644 packages/components/src/primitives/Popover/Popover.stories.tsx create mode 100644 packages/components/src/primitives/Popover/Popover.test.tsx create mode 100644 packages/components/src/primitives/Popover/Popover.tsx create mode 100644 packages/components/src/primitives/Popover/index.ts create mode 100644 packages/components/src/primitives/RadioGroup/RadioGroup.scss create mode 100644 packages/components/src/primitives/RadioGroup/RadioGroup.stories.tsx create mode 100644 packages/components/src/primitives/RadioGroup/RadioGroup.test.tsx create mode 100644 packages/components/src/primitives/RadioGroup/RadioGroup.tsx create mode 100644 packages/components/src/primitives/RadioGroup/index.ts create mode 100644 packages/components/src/primitives/SegmentedControl/SegmentedControl.scss create mode 100644 packages/components/src/primitives/SegmentedControl/SegmentedControl.stories.tsx create mode 100644 packages/components/src/primitives/SegmentedControl/SegmentedControl.test.tsx create mode 100644 packages/components/src/primitives/SegmentedControl/SegmentedControl.tsx create mode 100644 packages/components/src/primitives/SegmentedControl/index.ts create mode 100644 packages/components/src/primitives/Select/Select.scss create mode 100644 packages/components/src/primitives/Select/Select.stories.tsx create mode 100644 packages/components/src/primitives/Select/Select.test.tsx create mode 100644 packages/components/src/primitives/Select/Select.tsx create mode 100644 packages/components/src/primitives/Select/index.ts create mode 100644 packages/components/src/primitives/Slider/Slider.scss create mode 100644 packages/components/src/primitives/Slider/Slider.stories.tsx create mode 100644 packages/components/src/primitives/Slider/Slider.test.tsx create mode 100644 packages/components/src/primitives/Slider/Slider.tsx create mode 100644 packages/components/src/primitives/Slider/index.ts create mode 100644 packages/components/src/primitives/TextField/TextField.test.tsx create mode 100644 specs/00-overview.md create mode 100644 specs/01-data-format.md create mode 100644 specs/02-chart-definition.md create mode 100644 specs/03-axes-and-formatting.md create mode 100644 specs/04-colour-and-theming.md create mode 100644 specs/05-legends.md create mode 100644 specs/06-tooltips.md create mode 100644 specs/07-selection-and-focus.md create mode 100644 specs/08-time-and-timeline.md create mode 100644 specs/09-faceting.md create mode 100644 specs/10-layout-and-chrome.md create mode 100644 specs/11-line-chart.md create mode 100644 specs/12-slope-chart.md create mode 100644 specs/13-discrete-bar-chart.md create mode 100644 specs/14-stacked-area-chart.md create mode 100644 specs/15-stacked-bar-chart.md create mode 100644 specs/16-stacked-discrete-bar-chart.md create mode 100644 specs/17-dumbbell-chart.md create mode 100644 specs/18-scatter-chart.md create mode 100644 specs/19-marimekko-chart.md create mode 100644 specs/20-map-chart.md create mode 100644 specs/21-new-chart-types.md create mode 100644 specs/22-data-table.md create mode 100644 specs/23-explorer.md create mode 100644 specs/24-cli-rendering.md create mode 100644 specs/25-motion-and-video.md create mode 100644 specs/26-testing.md create mode 100644 specs/27-scenarios.md create mode 100644 specs/28-architecture.md create mode 100644 specs/29-component-primitives.md diff --git a/.storybook/main.ts b/.storybook/main.ts index 12927c1493e..b40bc77e69a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -74,6 +74,12 @@ const config: StorybookConfig = { "d3-transition", "d3-zoom", ], + // Keep local workspace packages out of Vite's optimized-deps + // cache so Storybook sees newly added exports immediately. + exclude: [ + ...(config.optimizeDeps?.exclude || []), + "@buildcanada/components", + ], }, esbuild: { ...config.esbuild, diff --git a/bun.lock b/bun.lock index ef8649c0f16..9be32116795 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@buildcanada/design-system", @@ -123,6 +122,7 @@ "@storybook/icons": "^1.4.0", "@storybook/instrumenter": "^8.6.14", "@storybook/react": "^10.1.11", + "@storybook/react-dom-shim": "^10.4.4", "@storybook/react-vite": "^10.1.11", "@storybook/test": "^8.6.15", "@swc/helpers": "^0.5.18", @@ -152,6 +152,8 @@ "@vitejs/plugin-react": "^5.1.1", "assert": "^2.1.0", "bail": "^2.0.2", + "call-bind-apply-helpers": "^1.0.2", + "call-bound": "^1.0.4", "ccount": "^2.0.1", "character-entities": "^2.0.2", "chromatic": "^13.3.5", @@ -192,19 +194,30 @@ "d3-transition": "^3.0.1", "d3-zoom": "^3.0.0", "dayjs": "^1.11.19", + "debug": "^4.4.3", "decimal.js": "^10.6.0", "decode-named-character-reference": "^1.2.0", "delaunator": "^5.0.1", "dequal": "^2.0.3", "devlop": "^1.1.0", + "dunder-proto": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", "escape-string-regexp": "^5.0.0", "estree-util-is-identifier-name": "^3.0.0", "extend": "^3.0.2", "flip-toolkit": "^7.2.6", "fparser": "^4.2.0", + "function-bind": "^1.1.2", "fuzzysort": "^3.1.0", + "get-intrinsic": "^1.3.0", "get-nonce": "^1.0.1", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "happy-dom": "^20.3.1", + "has-symbols": "^1.1.0", + "hasown": "^2.0.4", "hast-util-parse-selector": "^4.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "hast-util-whitespace": "^3.0.0", @@ -223,6 +236,7 @@ "lodash-es": "^4.17.22", "lodash.deburr": "^4.1.0", "markdown-table": "^3.0.4", + "math-intrinsics": "^1.1.0", "mdast-util-find-and-replace": "^3.0.2", "mdast-util-from-markdown": "^2.0.2", "mdast-util-to-hast": "^13.2.1", @@ -254,6 +268,9 @@ "mobx-react": "^7.6.0", "mobx-react-lite": "^4.1.1", "mousetrap": "^1.6.5", + "ms": "^2.1.3", + "object-assign": "^4.1.1", + "object-inspect": "^1.13.4", "papaparse": "^5.5.3", "point-in-polygon-hao": "^1.2.4", "prop-types": "^15.8.1", @@ -282,6 +299,10 @@ "sass": "^1.77.0", "semver": "^7.7.3", "serve": "^14.2.5", + "side-channel": "^1.1.1", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2", "simple-statistics": "^7.8.8", "space-separated-tokens": "^2.0.2", "storybook": "^10.1.11", @@ -402,6 +423,65 @@ "react-dom": "^19.0.0", }, }, + "packages/charts2": { + "name": "@buildcanada/charts2", + "version": "0.1.0", + "bin": { + "charts": "./dist/cli/index.js", + }, + "dependencies": { + "@buildcanada/colours": "^0.3.3", + "@buildcanada/components": "^0.3.5", + "@resvg/resvg-js": "^2.6.2", + "citty": "^0.1.6", + "d3-array": "^3.2.4", + "d3-dsv": "^3.0.1", + "d3-format": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "zod": "^4.3.5", + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@types/d3-array": "^3.2.2", + "@types/d3-dsv": "^3.0.7", + "@types/d3-format": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/node": "^22.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "fontkit": "^2.0.4", + "happy-dom": "^20.1.0", + "typescript": "~5.9.2", + "vitest": "^4.0.15", + "wawoff2": "^2.0.1", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, + "packages/charts3": { + "name": "@buildcanada/charts3", + "version": "3.0.0-alpha.0", + "dependencies": { + "@buildcanada/colours": "^0.3.3", + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "esbuild": "^0.27.2", + "happy-dom": "^20.1.0", + "typescript": "^5", + "vitest": "^4.0.15", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "packages/colours": { "name": "@buildcanada/colours", "version": "0.3.3", @@ -414,7 +494,7 @@ }, "packages/components": { "name": "@buildcanada/components", - "version": "0.3.4", + "version": "0.3.5", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", @@ -525,6 +605,10 @@ "@buildcanada/charts": ["@buildcanada/charts@workspace:packages/charts"], + "@buildcanada/charts2": ["@buildcanada/charts2@workspace:packages/charts2"], + + "@buildcanada/charts3": ["@buildcanada/charts3@workspace:packages/charts3"], + "@buildcanada/colours": ["@buildcanada/colours@workspace:packages/colours"], "@buildcanada/components": ["@buildcanada/components@workspace:packages/components"], @@ -981,6 +1065,32 @@ "@react-types/tooltip": ["@react-types/tooltip@3.5.0", "", { "dependencies": { "@react-types/overlays": "^3.9.2", "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-o/m1wlKlOD2sLb9vZLWdVkD5LFLHBMLGeeK/bhyUtp0IEdUeKy0ZRTS7pa/A50trov9RvdbzLK79xG8nKNxHew=="], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="], @@ -1057,7 +1167,7 @@ "@storybook/react": ["@storybook/react@10.1.11", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.1.11", "react-docgen": "^8.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.1.11", "typescript": ">= 4.9.x" }, "optionalPeers": ["typescript"] }, "sha512-rmMGmEwBaM2YpB8oDk2moM0MNjNMqtwyoPPZxjyruY9WVhYca8EDPGKEdRzUlb4qZJsTgLi7VU4eqg6LD/mL3Q=="], - "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.1.11", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.1.11" } }, "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ=="], + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.4.4", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-y6SObmoW78AydE6VfKQSUmCkuqiaMPy9LgMpMdMEyWfJ/pSxBDMIKycr9dlRMJP1cvNgByaJgrusWtA46ndSQw=="], "@storybook/react-vite": ["@storybook/react-vite@10.1.11", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.1.11", "@storybook/react": "10.1.11", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.1.11", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-qh1BCD25nIoiDfqwha+qBkl7pcG4WuzM+c8tsE63YEm8AFIbNKg5K8lVUoclF+4CpFz7IwBpWe61YUTDfp+91w=="], @@ -1229,7 +1339,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="], @@ -1341,6 +1451,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -1355,6 +1467,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], @@ -1409,6 +1523,8 @@ "ci-parallel-vars": ["ci-parallel-vars@1.0.1", "", {}, "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg=="], + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -1421,6 +1537,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], @@ -1569,6 +1687,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1597,7 +1717,7 @@ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], @@ -1671,6 +1791,8 @@ "flip-toolkit": ["flip-toolkit@7.2.6", "", { "dependencies": { "rematrix": "0.2.2" } }, "sha512-Sdef++xhYYBdkBwUmR4fwsQC9FGOf1lQYmsqiXpJK/1YDOxT0DPc0aGTltTcQ/ZIoJ7k4cuVxKjpBzolVAYpUg=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -1721,7 +1843,7 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -2067,6 +2189,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -2201,6 +2325,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], @@ -2241,9 +2367,9 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -2325,6 +2451,8 @@ "timezone-mock": ["timezone-mock@1.3.6", "", {}, "sha512-YcloWmZfLD9Li5m2VcobkCDNVaLMx8ohAb/97l/wYS3m+0TIEK5PFNMZZfRcusc6sFjIfxu8qcJT0CNnOdpqmg=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -2375,6 +2503,10 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -2433,6 +2565,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "wawoff2": ["wawoff2@2.0.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "woff2_compress.js": "bin/woff2_compress.js", "woff2_decompress.js": "bin/woff2_decompress.js" } }, "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], @@ -2503,9 +2637,13 @@ "@buildcanada/charts/@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@0.2.6", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", "react": "^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg=="], + "@buildcanada/charts/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.1.11", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.1.11" } }, "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ=="], + "@buildcanada/charts/fparser": ["fparser@3.1.0", "", {}, "sha512-P9hS9RjO7l4JvWHcDUqos0BXAGzJN4WwJBCh7gwja/23TuW7jfpOKZ+jlGoYp4ZUDnbAJ+rDyKLkIJFCLzgZ+w=="], - "@buildcanada/colours/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@buildcanada/charts/side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "@buildcanada/colours/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@buildcanada/components/@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.7.2", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" } }, "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA=="], @@ -2535,10 +2673,14 @@ "@storybook/addon-docs/@storybook/icons": ["@storybook/icons@2.0.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg=="], + "@storybook/addon-docs/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.1.11", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.1.11" } }, "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ=="], + "@storybook/addon-interactions/@storybook/test": ["@storybook/test@8.6.14", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/instrumenter": "8.6.14", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", "@vitest/expect": "2.0.5", "@vitest/spy": "2.0.5" }, "peerDependencies": { "storybook": "^8.6.14" } }, "sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw=="], "@storybook/builder-vite/@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + "@storybook/react/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.1.11", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.1.11" } }, "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ=="], + "@storybook/test/@storybook/instrumenter": ["@storybook/instrumenter@8.6.15", "", { "dependencies": { "@storybook/global": "^5.0.0", "@vitest/utils": "^2.1.1" }, "peerDependencies": { "storybook": "^8.6.15" } }, "sha512-TvHR/+yyIAOp/1bLulFai2kkhIBtAlBw7J6Jd9DKyInoGhTWNE1G1Y61jD5GWXX29AlwaHfzGUaX5NL1K+FJpg=="], "@storybook/test/@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="], @@ -2603,18 +2745,30 @@ "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "get-intrinsic/es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "get-proto/es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "happy-dom/@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "is-core-module/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-regex/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "kapellmeister/d3-timer": ["d3-timer@1.0.10", "", {}, "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw=="], @@ -2627,6 +2781,8 @@ "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "object.assign/es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "parent-module/callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -2637,6 +2793,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "qs/side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-flip-toolkit/flip-toolkit": ["flip-toolkit@7.2.4", "", { "dependencies": { "rematrix": "0.2.2" } }, "sha512-NT81ikyHPk72riMe1U01x698YIMSypMF5mQBhRklWVgf2xgWH3EPfrrVRAkz/+TSCq0rLPsr/uKIYkwHrowKZQ=="], @@ -2709,7 +2867,9 @@ "@buildcanada/charts/@fortawesome/free-solid-svg-icons/@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.7.2", "", {}, "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg=="], - "@buildcanada/colours/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "@buildcanada/charts/side-channel/side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "@buildcanada/colours/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "@buildcanada/components/@fortawesome/fontawesome-svg-core/@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.7.2", "", {}, "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg=="], @@ -2777,6 +2937,8 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "qs/side-channel/side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "react-flip-toolkit/flip-toolkit/rematrix": ["rematrix@0.2.2", "", {}, "sha512-agFFS3RzrLXJl5LY5xg/xYyXvUuVAnkhgKO7RaO9J1Ssth6yvbO+PIiV67V59MB5NCdAK2flvGvNT4mdKVniFA=="], "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], diff --git a/package.json b/package.json index cf3b3f2b0e2..83b6549d72c 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,13 @@ }, "scripts": { "build": "storybook build -o storybook-static", - "build:packages": "bun run build:colours && bun run build:components && bun run build:charts", + "build:packages": "bun run build:colours && bun run build:components && bun run build:charts && bun run build:charts2 && bun run build:charts3", "build:colours": "cd packages/colours && bun run build", "build:components": "cd packages/components && bun run build", "build:charts": "cd packages/charts && bun run build", + "build:charts2": "cd packages/charts2 && bun run build", + "build:charts3": "cd packages/charts3 && bun run build", + "charts2": "bun packages/charts2/src/cli/index.ts", "typecheck": "bun run --filter '*' typecheck", "test": "bun run --filter '*' test", "storybook": "storybook dev -p 6006", @@ -138,6 +141,7 @@ "@storybook/icons": "^1.4.0", "@storybook/instrumenter": "^8.6.14", "@storybook/react": "^10.1.11", + "@storybook/react-dom-shim": "^10.4.4", "@storybook/react-vite": "^10.1.11", "@storybook/test": "^8.6.15", "@swc/helpers": "^0.5.18", @@ -167,6 +171,8 @@ "@vitejs/plugin-react": "^5.1.1", "assert": "^2.1.0", "bail": "^2.0.2", + "call-bind-apply-helpers": "^1.0.2", + "call-bound": "^1.0.4", "ccount": "^2.0.1", "character-entities": "^2.0.2", "chromatic": "^13.3.5", @@ -207,19 +213,30 @@ "d3-transition": "^3.0.1", "d3-zoom": "^3.0.0", "dayjs": "^1.11.19", + "debug": "^4.4.3", "decimal.js": "^10.6.0", "decode-named-character-reference": "^1.2.0", "delaunator": "^5.0.1", "dequal": "^2.0.3", "devlop": "^1.1.0", + "dunder-proto": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", "escape-string-regexp": "^5.0.0", "estree-util-is-identifier-name": "^3.0.0", "extend": "^3.0.2", "flip-toolkit": "^7.2.6", "fparser": "^4.2.0", + "function-bind": "^1.1.2", "fuzzysort": "^3.1.0", + "get-intrinsic": "^1.3.0", "get-nonce": "^1.0.1", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "happy-dom": "^20.3.1", + "has-symbols": "^1.1.0", + "hasown": "^2.0.4", "hast-util-parse-selector": "^4.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "hast-util-whitespace": "^3.0.0", @@ -238,6 +255,7 @@ "lodash-es": "^4.17.22", "lodash.deburr": "^4.1.0", "markdown-table": "^3.0.4", + "math-intrinsics": "^1.1.0", "mdast-util-find-and-replace": "^3.0.2", "mdast-util-from-markdown": "^2.0.2", "mdast-util-to-hast": "^13.2.1", @@ -269,6 +287,9 @@ "mobx-react": "^7.6.0", "mobx-react-lite": "^4.1.1", "mousetrap": "^1.6.5", + "ms": "^2.1.3", + "object-assign": "^4.1.1", + "object-inspect": "^1.13.4", "papaparse": "^5.5.3", "point-in-polygon-hao": "^1.2.4", "prop-types": "^15.8.1", @@ -297,6 +318,10 @@ "sass": "^1.77.0", "semver": "^7.7.3", "serve": "^14.2.5", + "side-channel": "^1.1.1", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2", "simple-statistics": "^7.8.8", "space-separated-tokens": "^2.0.2", "storybook": "^10.1.11", diff --git a/packages/charts/src/grapher/modal/DownloadModal.test.tsx b/packages/charts/src/grapher/modal/DownloadModal.test.tsx new file mode 100644 index 00000000000..4f02e5f9d76 --- /dev/null +++ b/packages/charts/src/grapher/modal/DownloadModal.test.tsx @@ -0,0 +1,175 @@ +/** + * @vitest-environment jsdom + * + * Reproduces the "empty preview on initial open" bug in DownloadModalVisTab. + * + * Reported symptom: when the Download modal first mounts, the Image (PNG) and + * Vector graphic (SVG) preview thumbnails are empty. Toggling any checkbox + * (e.g. "Optimize SVG for Wikipedia upload") regenerates the preview correctly. + * + * Both the initial mount and the checkbox toggle call the same + * DownloadModalVisTab#export() method, so any difference between them implies + * that state-timing or initial inputs to rasterize differ on the first call. + */ + +import { afterEach, expect, it, vi } from "vitest" +import { act, cleanup, render, waitFor } from "@testing-library/react" +import * as React from "react" + +import { Bounds } from "../../utils/index.js" +import { + DownloadModalManager, + DownloadModalTabName, + DownloadModalVisTab, +} from "./DownloadModal.js" +import { GrapherRasterizeFn } from "../captionedChart/StaticChartRasterizer.js" +import { LifeExpectancyGrapher } from "../testData/TestData.sample.js" + +const PNG_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=" +const SVG_BLOB_CONTENT = + '' +const SVG_URL = + "data:image/svg+xml;charset=utf-8;base64," + btoa(SVG_BLOB_CONTENT) + +const makePngBlob = (): Blob => + new Blob([new Uint8Array([0])], { type: "image/png" }) +const makeSvgBlob = (): Blob => + new Blob([SVG_BLOB_CONTENT], { type: "image/svg+xml" }) + +const makeRasterizeStub = (): GrapherRasterizeFn => { + return vi.fn(async () => ({ + url: PNG_URL, + blob: makePngBlob(), + svgUrl: SVG_URL, + svgBlob: makeSvgBlob(), + })) +} + +const makeStubManager = ( + overrides: Partial = {} +): DownloadModalManager => { + return { + displaySlug: "test-chart", + rasterize: makeRasterizeStub(), + staticBounds: new Bounds(0, 0, 850, 600), + staticBoundsWithDetails: new Bounds(0, 0, 850, 600), + captionedChartBounds: new Bounds(0, 0, 850, 600), + frameBounds: new Bounds(0, 0, 850, 600), + isOnChartOrMapTab: true, + showAdminControls: true, + baseUrl: "https://example.com/grapher/test", + queryStr: "", + activeDownloadModalTab: DownloadModalTabName.Vis, + ...overrides, + } as DownloadModalManager +} + +afterEach(() => { + cleanup() +}) + +// --- Sanity / control: with a stub rasterize that always resolves with a +// valid data URL, both the initial mount and a checkbox toggle paint an +// SVG . This proves the modal's lifecycle works in principle. + +it("stub rasterize: SVG preview is populated after initial mount", async () => { + const manager = makeStubManager() + const { container } = render() + + await waitFor( + () => { + const previewImgs = container.querySelectorAll( + ".download-modal__download-preview-img img" + ) + expect(previewImgs.length).toBeGreaterThanOrEqual(2) + }, + { timeout: 2000 } + ) + + expect(manager.rasterize).toHaveBeenCalledTimes(1) + const srcs = Array.from( + container.querySelectorAll(".download-modal__download-preview-img img") + ).map((img) => img.getAttribute("src")) + expect(srcs.every((s) => !!s && s.startsWith("data:"))).toBe(true) +}) + +it("stub rasterize: SVG preview is regenerated after toggling the Wikipedia checkbox (control)", async () => { + const manager = makeStubManager() + const { container } = render() + + await waitFor( + () => { + expect( + container.querySelector(".download-modal__download-preview-img") + ).toBeTruthy() + }, + { timeout: 2000 } + ) + + const checkboxes = Array.from( + container.querySelectorAll("input[type=checkbox]") + ) as HTMLInputElement[] + const wikiCheckbox = checkboxes.find((cb) => { + const label = cb.closest("label")?.textContent ?? "" + return /Wikipedia/i.test(label) + }) + expect(wikiCheckbox, "wikipedia checkbox should exist").toBeTruthy() + + await act(async () => { + wikiCheckbox!.click() + }) + + await waitFor( + () => { + const previewImgs = container.querySelectorAll( + ".download-modal__download-preview-img img" + ) as NodeListOf + expect(previewImgs.length).toBeGreaterThanOrEqual(2) + const srcs = Array.from(previewImgs).map((img) => + img.getAttribute("src") + ) + expect(srcs.every((s) => !!s && s.startsWith("data:"))).toBe(true) + }, + { timeout: 2000 } + ) +}) + +// --- Failing test: drive the modal against a real GrapherState (the same +// `rasterize` implementation the app uses). This is the closest we can get +// to the real Chrome code path inside JSDOM. The test asserts the same thing +// the user expects: after the modal mounts, the preview images should be +// painted with non-empty data: URLs. + +it( + "real GrapherState: SVG preview is populated on initial mount (FAILS — reproduces bug)", + async () => { + const grapher = LifeExpectancyGrapher() + const manager = grapher as unknown as DownloadModalManager + + const { container } = render() + + // After the export pipeline finishes, the loading indicator should be + // replaced by the preview area with both PNG and SVG s. + await waitFor( + () => { + expect( + container.querySelector( + ".download-modal__download-preview-img" + ) + ).toBeTruthy() + }, + { timeout: 5000 } + ) + + const previewImgs = container.querySelectorAll( + ".download-modal__download-preview-img img" + ) as NodeListOf + expect(previewImgs.length).toBe(2) + const srcs = Array.from(previewImgs).map((img) => + img.getAttribute("src") + ) + expect(srcs.every((s) => !!s && s.startsWith("data:"))).toBe(true) + }, + 10000 +) diff --git a/packages/charts/src/grapher/modal/DownloadModal.tsx b/packages/charts/src/grapher/modal/DownloadModal.tsx index 6bf18384345..b2c4efbbe88 100644 --- a/packages/charts/src/grapher/modal/DownloadModal.tsx +++ b/packages/charts/src/grapher/modal/DownloadModal.tsx @@ -389,7 +389,11 @@ export class DownloadModalVisTab extends React.Component { } override componentDidMount(): void { - queueMicrotask(() => this.export()) + if (typeof requestAnimationFrame !== "undefined") { + requestAnimationFrame(() => this.export()) + } else { + queueMicrotask(() => this.export()) + } void canWriteToClipboard().then( (canWriteToClipboard) => diff --git a/packages/charts2/README.md b/packages/charts2/README.md index 260391ad0e6..e7a43288fd0 100644 --- a/packages/charts2/README.md +++ b/packages/charts2/README.md @@ -6,11 +6,11 @@ Build Canada charts v2: a pure-function layout core with a single React SVG rend - `src/core/` — DOM-free, React-free: data layer, formatting, themes, text metrics, layout → `ChartScene` - `src/react/` — `SceneSVG` (the only renderer) + `Chart` + interactive chrome (tooltip, timeline, entity selector, tabs, settings, data table) -- `src/cli/` — `bcds-charts render|validate` (same render path via `renderToStaticMarkup`) +- CLI — `charts render|validate` (same render path via `renderToStaticMarkup`) - `src/fixtures/` — committed fixture datasets (spec 26 §2), loadable by name in tests/stories/CLI -- `samples/` — CLI-ready chart definitions, tested against bundled fixtures +- `samples/` — CLI-ready chart definitions, tested against included fixtures - `src/corpus/` — golden SVG corpus + bless script (spec 26 §1.3) -- `src/stories/` — Storybook stories under `Charts2/` (run Storybook from the repo root) +- `src/stories/` — Storybook stories under `Charts2/` ## Quick start @@ -19,8 +19,7 @@ import { buildDataset, parseCsv, parseDefinition, parseManifest } from "@buildca import { Chart, Tooltip } from "@buildcanada/charts2" import "@buildcanada/charts2/styles.css" -// 1. Dataset: manifest + CSV → Dataset (in-repo code can shortcut with -// loadFixtureDataset("provincial-budgets") from src/fixtures). +// 1. Dataset: manifest + CSV → Dataset. const { manifest } = parseManifest(rawManifestJson) const { rows } = parseCsv(csvText, manifest!) const { dataset } = buildDataset(manifest!, rows) @@ -55,9 +54,19 @@ Headless (no React state, no DOM): `layoutChart({ definition, dataset, size })` ## CLI -`bcds-charts` ships as the package bin (`dist/cli/index.js`; run `bun run build` once before the workspace bin works). From source: `bun run --cwd ../.. charts2 …` or `bun src/cli/index.ts …`. +Install the CLI globally: -### `bcds-charts render ` +```bash +npm install -g @buildcanada/charts2 +``` + +Then call it with `charts`: + +```bash +charts --help +``` + +### `charts render ` Render one chart definition to SVG/PNG. The SVG string is a pure function of definition + dataset + flags (spec 24 §3) — same inputs, same bytes. On any error diagnostic, nothing is written. @@ -75,19 +84,46 @@ Render one chart definition to SVG/PNG. The SVG string is a pure function of def | `--no-chrome` | Plot only (no header/footer) | | `--fonts ` | TTF directory for PNG rasterization (default: the package `.fonts-cache`) | -The definition's `data` field may reference a dataset directory (`manifest.json` + `data.csv`), a `{manifest, rows}` JSON file, or a bundled fixture name (`provincial-budgets`, `federal-departments`, `population-snapshot`, `government-debt`, `pathological`). +The definition's `data` field may reference a dataset directory (`manifest.json` + `data.csv`), a `{manifest, rows}` JSON file, or an included fixture name (`provincial-budgets`, `federal-departments`, `population-snapshot`, `government-debt`, `pathological`). -Sample definitions live in `samples/` and can be rendered directly: +Sample definitions can be rendered directly: ```bash -bun src/cli/index.ts render samples/line-provincial-budgets.json --out chart.svg -bun src/cli/index.ts render samples/stacked-area-government-debt.json --preset social +charts render samples/line-provincial-budgets.json --out chart.svg +charts render samples/stacked-area-government-debt.json --preset social ``` -### `bcds-charts validate ` +### `charts validate ` Report ALL problems at once (spec 01 §8). Accepts a definition JSON, a dataset directory, a single `manifest.json`, a `{manifest, rows}` JSON file, or a fixture name. Diagnostics print to stderr one per line; a summary line goes to stdout. +### `charts scaffold ` + +Create a starter chart directory containing `definition.json`, `manifest.json`, and `data.csv`. + +```bash +charts scaffold line "provincial spending" +charts scaffold discrete-bar "population by province" +charts scaffold stacked-area "government debt" +charts scaffold stacked-bar "annual spending composition" +charts scaffold stacked-discrete-bar "program spending by province" +``` + +The generated `definition.json` uses `"data": "."`, so it resolves the colocated `manifest.json` and `data.csv`. Pass `--force` to replace an existing scaffold directory. + +### `charts install-skill` + +Install the included `charts2-cli` agent skill, which teaches coding agents how to validate and render charts with this CLI. + +```bash +charts install-skill +charts install-skill --agent claude +charts install-skill --agent codex --force +charts install-skill --path ~/.codex/skills +``` + +`--agent auto` is the default and installs into detected Codex and Claude skill directories. Use `--agent all` to install into both conventional locations, or `--path ` for any agent that reads Agent Skills-style folders containing `SKILL.md`. + ### Exit codes - `0` — success @@ -100,30 +136,23 @@ Report ALL problems at once (spec 01 §8). Accepts a definition JSON, a dataset After an **intentional** rendering change: -```bash -bun run corpus:bless # rewrites src/corpus/__golden__/*.svg -git diff src/corpus/__golden__ # review every diff; commit in the same PR -``` +Run the corpus bless script, then review every golden SVG diff before publishing the change. ## Develop -```bash -bun install -bun run extract-font-metrics # regenerates metrics JSON + .fonts-cache TTFs -bun run test -bun run build # required once before the workspace bin works -bun run --cwd ../.. charts2 render # CLI from source -bun run --cwd ../.. storybook # stories under "Charts2/" (repo-root Storybook) -``` +Use the package scripts for dependency installation, font metrics, tests, build, and Storybook. For CLI usage, install globally and call `charts`. Brand font binaries are never committed here or published — only metrics JSON. See `specs/28-architecture.md` §3. ## Deferred (later phases) -Implemented today: line, discrete-bar, stacked-area, stacked-bar, stacked-discrete-bar; themes; en/fr locales; URL state; interactive chrome; CLI render/validate. Per the phased plan, **not yet implemented**: +Implemented today: line, discrete-bar, stacked-area, stacked-bar, stacked-discrete-bar; themes; en/fr locales; URL state; interactive chrome; CLI render/validate; faceting (small multiples); comparison lines. Per the phased plan, **not yet implemented**: -- Faceting — `facet: entity|metric` parses but small multiples do not lay out yet (spec 09) -- Comparison lines — `comparisonLines` parses but does not render (spec 02) - Further chart types: maps (spec 20), scatter (spec 18), slope (spec 12), dumbbell (spec 17), marimekko (spec 19) - Motion/video rendering — `animate`, golden frames (spec 25) - Explorer — control sweeps over a chart family (spec 23) + +Partial (spec 09/02, with documented gaps): + +- Faceting — `facet: entity|metric` lays out a grid of small multiples with a shared value domain, per-panel titles, a shared legend, and a 16-panel cap. Not yet: the reader-togglable independent-axis mode, leftmost-column/bottom-row tick thinning, monochrome single-metric entity facets, and faceted maps. +- Comparison lines — `comparisonLines` render on line and stacked-area charts (horizontal `y` and vertical `x` reference lines with labels). Other chart types emit a `comparison-lines-unsupported` warning. diff --git a/packages/charts2/build.ts b/packages/charts2/build.ts index 2eaf2c6ba97..acd69ec4151 100644 --- a/packages/charts2/build.ts +++ b/packages/charts2/build.ts @@ -14,7 +14,8 @@ const runCommand = (command: string, args: string[]): Promise => { }) } -// SCSS and committed font-metrics JSON ship alongside the compiled JS. +// SCSS, committed font-metrics JSON, and the bundled agent skill ship alongside +// the compiled JS. // Brand font binaries (woff2) are intentionally NOT copied: the published // package must not redistribute licensed fonts (see specs/28-architecture.md). const copyAssets = async () => { @@ -28,6 +29,9 @@ const copyAssets = async () => { await cp(join(srcDir, file), destPath) } } + if (existsSync("skills")) { + await cp("skills", join(distDir, "skills"), { recursive: true }) + } } const makeBinExecutable = async () => { diff --git a/packages/charts2/package.json b/packages/charts2/package.json index ebfbdbb2019..a2465d8bb44 100644 --- a/packages/charts2/package.json +++ b/packages/charts2/package.json @@ -19,7 +19,7 @@ "./styles/*": "./dist/react/styles/*" }, "bin": { - "bcds-charts": "./dist/cli/index.js" + "charts": "./dist/cli/index.js" }, "files": [ "dist", @@ -61,6 +61,7 @@ }, "dependencies": { "@buildcanada/colours": "^0.3.3", + "@buildcanada/components": "^0.3.5", "@resvg/resvg-js": "^2.6.2", "citty": "^0.1.6", "d3-array": "^3.2.4", diff --git a/packages/charts2/skills/charts2-cli/SKILL.md b/packages/charts2/skills/charts2-cli/SKILL.md new file mode 100644 index 00000000000..5393f94b595 --- /dev/null +++ b/packages/charts2/skills/charts2-cli/SKILL.md @@ -0,0 +1,275 @@ +--- +name: charts2-cli +description: Use when Codex or another coding agent needs to generate, validate, debug, or automate Build Canada charts from the command line with the @buildcanada/charts2 `charts` binary. Trigger for chart definition JSON files, fixture rendering, SVG/PNG chart export, CLI chart validation, render flag selection, shell workflows around `charts render`, or troubleshooting `charts` diagnostics. +--- + +# Charts CLI + +Use the `charts` binary from `@buildcanada/charts2` to validate chart inputs and render deterministic SVG/PNG chart outputs. Prefer this CLI path when the task is to produce chart files rather than embed a React component. + +## Install + +Install the package globally when `charts` is not already available: + +```bash +npm install -g @buildcanada/charts2 +``` + +Confirm the binary is available: + +```bash +charts --help +``` + +## Workflow + +1. For a new chart, scaffold first instead of guessing file shapes: + +```bash +charts scaffold line "provincial spending" +charts scaffold discrete-bar "population by province" +charts scaffold stacked-area "government debt" +charts scaffold stacked-bar "annual spending composition" +charts scaffold stacked-discrete-bar "program spending by province" +``` + +2. Inspect the generated or provided `definition.json`. Confirm it has `title`, `data`, and `y`; note whether `data` points at a dataset directory with `manifest.json` and `data.csv`, a single dataset JSON file, or an included fixture name. +3. Run validation before rendering when the definition or data is new: + +```bash +charts validate path/to/definition.json +``` + +4. Render to SVG first for deterministic, inspectable output: + +```bash +charts render path/to/definition.json --out path/to/chart.svg +``` + +5. Render PNG only when the user needs raster output: + +```bash +charts render path/to/definition.json --format png --out path/to/chart.png +``` + +6. For visual changes, inspect generated SVG diffs instead of judging from a PNG alone. + +## Scaffold Output + +`charts scaffold ` creates a directory named from `` and writes: + +- `definition.json`: chart configuration +- `manifest.json`: dataset schema and metadata +- `data.csv`: starter table matching the manifest + +Supported chart types: + +- `line` +- `discrete-bar` +- `stacked-area` +- `stacked-bar` +- `stacked-discrete-bar` + +Use `--force` only when replacing an existing scaffold directory is intended. + +## Input Formats + +### definition.json + +The chart definition selects the dataset, chart type, and displayed columns. A minimal file: + +```json +{ + "slug": "provincial-spending", + "title": "Provincial spending", + "subtitle": "Replace this subtitle with the chart takeaway", + "data": ".", + "y": ["value"], + "types": ["line"], + "sourceText": "Source name" +} +``` + +Required fields: + +- `title`: display title +- `data`: dataset reference; `"."` means the same directory as `definition.json` +- `y`: one or more metric column slugs declared in `manifest.json` + +Common optional fields: + +- `slug`: output filename stem when `--out` is omitted +- `subtitle`, `note`, `sourceText`: chart text +- `types`: one or more supported chart types +- `selectedEntities`: initial entity selection +- `time`: a single time, `[start, end]`, `{ "start": "...", "end": "..." }`, `"earliest"`, or `"latest"` +- `sort`: `{ "by": "total" | "name" | "column" | "change" | "custom", "order": "asc" | "desc" }` +- `stackMode`: `"absolute"` or `"relative"` +- `theme`, `locale`: output overrides + +### manifest.json + +The manifest describes how to parse and format `data.csv`. + +```json +{ + "name": "provincial-spending", + "title": "Provincial spending dataset", + "timeGrain": "year", + "entity": { + "label": "province", + "labelPlural": "provinces" + }, + "columns": { + "value": { + "name": "Value", + "type": "numeric", + "unit": "dollars", + "shortUnit": "$", + "decimals": 0 + } + }, + "sources": [ + { + "name": "Source name", + "url": "https://example.com" + } + ] +} +``` + +Required fields: + +- `name`: dataset identifier +- `timeGrain`: `"year"`, `"fiscal-year"`, `"quarter"`, `"month"`, `"date"`, or `"none"` +- `columns`: object keyed by CSV metric column slug + +Common column fields: + +- `name`: display label +- `type`: `"numeric"`, `"integer"`, `"percentage"`, `"currency"`, `"categorical"`, or `"ordinal"` +- `unit`, `shortUnit`, `currency`, `decimals`, `displayFactor`: formatting +- `denominator`: divide this column by another column in the same row +- `projection` or `projectionFrom`: mark forecast values +- `colour`: fixed series colour +- `description`, `source`: metadata + +Use `dimensions` for long-format categorical dimensions. Use `entities` when aliases, groups, French names, or stable entity colours are needed. + +### data.csv + +CSV headers must include: + +- `entity` +- `time`, unless `timeGrain` is `"none"` +- every metric slug declared under `manifest.json` `columns` +- any dimension columns listed in `manifest.json` `dimensions` + +Example for a single-metric line or discrete-bar chart: + +```csv +entity,time,value +Canada,2021,100 +Canada,2022,110 +Ontario,2021,40 +Ontario,2022,46 +``` + +Example for stacked charts: + +```csv +entity,time,category_a,category_b,category_c +Canada,2021,40,35,25 +Canada,2022,44,38,30 +Canada,2023,52,41,34 +``` + +Rules: + +- Empty cells are missing values, never zero. +- Numeric columns must contain plain finite numbers, with no commas or unit text. +- Each `(entity, time)` pair must appear at most once. +- Time values must match `timeGrain`: `2024`, `2024-25`, `2024-Q3`, `2024-03`, or `2024-03-31` depending on grain. + +### Single-file dataset JSON + +Instead of a directory with `manifest.json` and `data.csv`, `data` can point at one JSON file shaped as: + +```json +{ + "manifest": { + "name": "dataset-name", + "timeGrain": "year", + "columns": { + "value": { "name": "Value", "type": "numeric" } + } + }, + "rows": [ + { "entity": "Canada", "time": "2023", "value": 125 } + ] +} +``` + +## Render Flags + +Use `--preset` for common target sizes: + +```bash +charts render chart.json --preset social --out social.svg +charts render chart.json --preset thumbnail --out thumb.svg +charts render chart.json --preset slide --format svg,png --out slide.svg +``` + +Use explicit geometry when the target dimensions are fixed: + +```bash +charts render chart.json --width 1200 --height 600 --out chart.svg +``` + +Use URL-style state to render a specific tab/time/entity selection: + +```bash +charts render chart.json --state "tab=line&time=2014-15..2024-25&entities=ON~QC" --out selected.svg +``` + +Use locale/theme overrides only when the user asks for them or the output target requires them: + +```bash +charts render chart.json --locale fr --theme build-canada --out chart-fr.svg +``` + +Use `--no-chrome` for plot-only output, and `--transparent` when the chart will be composited elsewhere. + +## PNG Fonts + +PNG rendering needs licensed TTF font files. If PNG rendering fails with a fonts error, pass a licensed font directory: + +```bash +charts render chart.json --format png --fonts /path/to/ttf-dir --out chart.png +``` + +Do not add font binaries to generated output unless the user explicitly asks for them and has the rights to use them. + +## Diagnostics + +`charts validate` and `charts render` print one diagnostic per stderr line. Treat any `error` diagnostic as blocking output; render intentionally writes nothing when errors are present. + +Exit codes: + +- `0`: success +- `1`: validation or render failure +- `2`: bad CLI usage, such as an unknown flag, format, preset, missing argument, or invalid positive integer + +For failures, fix inputs in this order: malformed JSON, definition schema, dataset loading, unknown `y` columns, bad time bounds, then render/layout diagnostics. + +## Useful Fixtures + +Included fixture names can be used in chart definitions and validation: + +- `provincial-budgets` +- `federal-departments` +- `population-snapshot` +- `government-debt` +- `pathological` + +Use `pathological` only for validation/error-path tests. diff --git a/packages/charts2/skills/charts2-cli/agents/openai.yaml b/packages/charts2/skills/charts2-cli/agents/openai.yaml new file mode 100644 index 00000000000..d11c93e4b81 --- /dev/null +++ b/packages/charts2/skills/charts2-cli/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Charts CLI" + short_description: "Generate charts with the charts CLI" + default_prompt: "Use $charts2-cli to render a Build Canada chart from a definition JSON with the charts CLI." diff --git a/packages/charts2/src/cli/index.ts b/packages/charts2/src/cli/index.ts index b251fa5df7c..03479a366d1 100644 --- a/packages/charts2/src/cli/index.ts +++ b/packages/charts2/src/cli/index.ts @@ -1,7 +1,8 @@ /** - * bcds-charts — CLI entry point (spec 24, spec 28 §5). + * charts — CLI entry point (spec 24, spec 28 §5). * - * Subcommands: render (definition → SVG/PNG), validate (all errors at once). + * Subcommands: render (definition → SVG/PNG), validate (all errors at once), + * scaffold (starter files), install-skill (copy the bundled agent skill). * Exit codes: 0 success, 1 validation/render errors, 2 bad usage. * * No shebang in this source file — build.ts prepends `#!/usr/bin/env node` @@ -14,7 +15,9 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { CliFailure, CliUsageError } from "./errors.ts" +import { installSkillCommand } from "./installSkill.ts" import { renderCommand } from "./render.ts" +import { scaffoldCommand } from "./scaffold.ts" import { validateCommand } from "./validate.ts" function packageVersion(): string { @@ -28,13 +31,15 @@ function packageVersion(): string { } const subCommands: Record = { + "install-skill": installSkillCommand as CommandDef, render: renderCommand as CommandDef, + scaffold: scaffoldCommand as CommandDef, validate: validateCommand as CommandDef, } const main = defineCommand({ meta: { - name: "bcds-charts", + name: "charts", version: packageVersion(), description: "Render and validate Build Canada chart definitions (spec 24)", }, diff --git a/packages/charts2/src/cli/installSkill.test.ts b/packages/charts2/src/cli/installSkill.test.ts new file mode 100644 index 00000000000..ea082962470 --- /dev/null +++ b/packages/charts2/src/cli/installSkill.test.ts @@ -0,0 +1,107 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +import { CliUsageError } from "./errors.ts" +import { CHARTS2_SKILL_NAME, installSkill, resolveInstallTargets, resolveSkillSourceDir } from "./installSkill.ts" + +const tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "bcds-cli-skill-")) + tmpDirs.push(dir) + return dir +} + +function makeSourceSkill(): string { + const dir = join(makeTmpDir(), CHARTS2_SKILL_NAME) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, "SKILL.md"), + [ + "---", + `name: ${CHARTS2_SKILL_NAME}`, + "description: Test skill", + "---", + "", + "# Test", + "", + ].join("\n"), + ) + return dir +} + +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop() as string, { recursive: true, force: true }) + } +}) + +describe("resolveSkillSourceDir", () => { + it("finds the bundled charts2 CLI skill", () => { + const sourceDir = resolveSkillSourceDir() + expect(readFileSync(join(sourceDir, "SKILL.md"), "utf8")).toContain(`name: ${CHARTS2_SKILL_NAME}`) + }) +}) + +describe("resolveInstallTargets", () => { + it("uses a custom skills directory when --path is provided", () => { + const root = join(makeTmpDir(), "skills") + expect(resolveInstallTargets({ agent: "claude", path: root })).toEqual([ + { + agent: "custom", + skillsRoot: root, + destination: join(root, CHARTS2_SKILL_NAME), + }, + ]) + }) + + it("rejects unknown agents", () => { + expect(() => resolveInstallTargets({ agent: "cursor" })).toThrow(CliUsageError) + }) + + it("rejects an empty custom path", () => { + expect(() => resolveInstallTargets({ path: "" })).toThrow(CliUsageError) + }) +}) + +describe("installSkill", () => { + it("copies the skill to every target", () => { + const sourceDir = makeSourceSkill() + const skillsRoot = join(makeTmpDir(), "skills") + const destination = join(skillsRoot, CHARTS2_SKILL_NAME) + + installSkill({ + sourceDir, + targets: [{ agent: "custom", skillsRoot, destination }], + force: false, + }) + + expect(existsSync(join(destination, "SKILL.md"))).toBe(true) + }) + + it("refuses to overwrite unless --force is set", () => { + const sourceDir = makeSourceSkill() + const skillsRoot = join(makeTmpDir(), "skills") + const destination = join(skillsRoot, CHARTS2_SKILL_NAME) + mkdirSync(destination, { recursive: true }) + writeFileSync(join(destination, "SKILL.md"), "old") + + expect(() => + installSkill({ + sourceDir, + targets: [{ agent: "custom", skillsRoot, destination }], + force: false, + }), + ).toThrow(CliUsageError) + + installSkill({ + sourceDir, + targets: [{ agent: "custom", skillsRoot, destination }], + force: true, + }) + + expect(readFileSync(join(destination, "SKILL.md"), "utf8")).toContain(`name: ${CHARTS2_SKILL_NAME}`) + }) +}) diff --git a/packages/charts2/src/cli/installSkill.ts b/packages/charts2/src/cli/installSkill.ts new file mode 100644 index 00000000000..9246d539275 --- /dev/null +++ b/packages/charts2/src/cli/installSkill.ts @@ -0,0 +1,149 @@ +/** + * charts install-skill - install the bundled charts2 CLI agent skill. + * + * The skill is stored in packages/charts2/skills during development and copied + * into dist/skills for published packages. This command supports both layouts. + */ + +import { defineCommand } from "citty" +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs" +import { homedir } from "node:os" +import { basename, dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { CliUsageError } from "./errors.ts" + +export const CHARTS2_SKILL_NAME = "charts2-cli" + +export type SkillAgent = "auto" | "all" | "codex" | "claude" + +export interface SkillInstallTarget { + agent: string + skillsRoot: string + destination: string +} + +interface InstallSkillArgs { + agent?: string + path?: string + force: boolean +} + +function expandHome(path: string): string { + if (path === "~") return homedir() + if (path.startsWith("~/")) return join(homedir(), path.slice(2)) + return path +} + +function agentHome(agent: "codex" | "claude"): string { + if (agent === "codex") return process.env.CODEX_HOME ?? join(homedir(), ".codex") + return process.env.CLAUDE_HOME ?? join(homedir(), ".claude") +} + +function skillsRootFor(agent: "codex" | "claude"): string { + return join(agentHome(agent), "skills") +} + +function parseAgent(value: string | undefined): SkillAgent { + const agent = value ?? "auto" + if (agent === "auto" || agent === "all" || agent === "codex" || agent === "claude") return agent + throw new CliUsageError(`--agent must be one of: auto, all, codex, claude (got "${agent}")`) +} + +function dedupeTargets(targets: SkillInstallTarget[]): SkillInstallTarget[] { + const seen = new Set() + const out: SkillInstallTarget[] = [] + for (const target of targets) { + if (seen.has(target.destination)) continue + seen.add(target.destination) + out.push(target) + } + return out +} + +export function resolveSkillSourceDir(): string { + const cliDir = dirname(fileURLToPath(import.meta.url)) + const candidates = [ + // Published build: dist/cli/installSkill.js -> dist/skills/charts2-cli + resolve(cliDir, "../skills", CHARTS2_SKILL_NAME), + // Source and monorepo dev: src/cli/installSkill.ts -> skills/charts2-cli + resolve(cliDir, "../../skills", CHARTS2_SKILL_NAME), + ] + for (const candidate of candidates) { + if (existsSync(join(candidate, "SKILL.md"))) return candidate + } + throw new CliUsageError(`Could not find bundled skill "${CHARTS2_SKILL_NAME}"`) +} + +export function resolveInstallTargets(args: { agent?: string; path?: string }): SkillInstallTarget[] { + if (args.path !== undefined) { + if (args.path === "") throw new CliUsageError("--path requires a value") + const skillsRoot = resolve(expandHome(args.path)) + return [{ agent: "custom", skillsRoot, destination: join(skillsRoot, CHARTS2_SKILL_NAME) }] + } + + const agent = parseAgent(args.agent) + const makeTarget = (name: "codex" | "claude"): SkillInstallTarget => { + const skillsRoot = skillsRootFor(name) + return { agent: name, skillsRoot, destination: join(skillsRoot, CHARTS2_SKILL_NAME) } + } + + if (agent === "codex" || agent === "claude") return [makeTarget(agent)] + if (agent === "all") return dedupeTargets([makeTarget("codex"), makeTarget("claude")]) + + const targets: SkillInstallTarget[] = [] + const codexHome = agentHome("codex") + const claudeHome = agentHome("claude") + if (process.env.CODEX_HOME !== undefined || existsSync(codexHome)) targets.push(makeTarget("codex")) + if (process.env.CLAUDE_HOME !== undefined || existsSync(claudeHome)) targets.push(makeTarget("claude")) + + return dedupeTargets(targets.length > 0 ? targets : [makeTarget("codex")]) +} + +export function installSkill(args: { sourceDir: string; targets: SkillInstallTarget[]; force: boolean }): void { + for (const target of args.targets) { + if (basename(target.destination) !== CHARTS2_SKILL_NAME) { + throw new CliUsageError(`Internal error: destination must end with ${CHARTS2_SKILL_NAME}`) + } + if (existsSync(target.destination)) { + if (!args.force) { + throw new CliUsageError(`Skill already exists at ${target.destination}; pass --force to replace it`) + } + rmSync(target.destination, { recursive: true, force: true }) + } + mkdirSync(target.skillsRoot, { recursive: true }) + cpSync(args.sourceDir, target.destination, { recursive: true }) + process.stdout.write(`installed ${CHARTS2_SKILL_NAME} for ${target.agent}: ${target.destination}\n`) + } +} + +export function runInstallSkill(args: InstallSkillArgs): void { + const sourceDir = resolveSkillSourceDir() + const targets = resolveInstallTargets(args) + installSkill({ sourceDir, targets, force: args.force }) +} + +export const installSkillCommand = defineCommand({ + meta: { + name: "install-skill", + description: "Install the bundled charts2 CLI agent skill into Codex, Claude, or a custom skills directory", + }, + args: { + agent: { + type: "string", + description: "auto | all | codex | claude (default auto; auto installs into detected agent homes)", + }, + path: { + type: "string", + description: "Custom agent skills directory; installs /charts2-cli and ignores --agent", + }, + force: { + type: "boolean", + description: "Replace an existing charts2-cli skill at the destination", + default: false, + }, + }, + run({ args }) { + runInstallSkill(args as unknown as InstallSkillArgs) + }, +}) diff --git a/packages/charts2/src/cli/render.test.ts b/packages/charts2/src/cli/render.test.ts index 5ee5fc5acef..4334bd461a4 100644 --- a/packages/charts2/src/cli/render.test.ts +++ b/packages/charts2/src/cli/render.test.ts @@ -13,6 +13,7 @@ import { defaultFontsDir, listFontFiles, outputPathFor, + parseFocusKeys, parseFormats, rasterize, renderDefinitionToSvg, @@ -99,6 +100,15 @@ describe("parseFormats", () => { }) }) +describe("parseFocusKeys", () => { + it("splits comma-separated and repeated values, trims, and dedupes in order", () => { + expect(parseFocusKeys(undefined)).toEqual([]) + expect(parseFocusKeys("Ontario")).toEqual(["Ontario"]) + expect(parseFocusKeys(" Ontario , Quebec ")).toEqual(["Ontario", "Quebec"]) + expect(parseFocusKeys(["Ontario", "Quebec,Ontario"])).toEqual(["Ontario", "Quebec"]) + }) +}) + describe("outputPathFor", () => { it("defaults to .", () => { expect(outputPathFor(undefined, "my-chart", "svg", 1)).toBe("my-chart.svg") @@ -153,6 +163,42 @@ describe("renderDefinitionToSvg", () => { expect(result.svg).toBeNull() expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true) }) + + it("--focus dims the other series and hides their line markers (spec 07 §3)", () => { + const dir = makeTmpDir() + const path = writeDefinition(dir, { + title: "Focus", + data: "provincial-budgets", + y: ["total_spending"], + types: ["line"], + selectedEntities: ["Ontario", "Quebec", "Alberta"], + }) + const plain = renderDefinitionToSvg({ definitionPath: path }) + const focused = renderDefinitionToSvg({ definitionPath: path, focus: ["Ontario"] }) + expect(plain.svg).not.toBeNull() + expect(focused.svg).not.toBeNull() + // Non-focused series dim to the theme dim (0.2); the plain render never dims. + expect(plain.svg).not.toContain('opacity="0.2"') + expect(focused.svg).toContain('opacity="0.2"') + // Non-focused markers are hidden → strictly fewer than plain. + const circles = (svg: string): number => (svg.match(/ { + const dir = makeTmpDir() + const path = writeDefinition(dir, { + title: "Focus", + data: "provincial-budgets", + y: ["total_spending"], + types: ["line"], + selectedEntities: ["Ontario", "Quebec"], + }) + const result = renderDefinitionToSvg({ definitionPath: path, focus: ["Nowhere"] }) + expect(result.svg).not.toBeNull() + expect(result.diagnostics.some((d) => d.code === "unknown-focus-series")).toBe(true) + expect(result.svg).not.toContain('opacity="0.2"') + }) }) // --------------------------------------------------------------------------- @@ -204,7 +250,7 @@ describe("rasterize", () => { // End-to-end spawn smoke test (the ONE spawned-process test) // --------------------------------------------------------------------------- -describe("bcds-charts render (spawned)", () => { +describe("charts render (spawned)", () => { it("renders a definition file to SVG with exit code 0", () => { const dir = makeTmpDir() const definitionPath = writeDefinition(dir, { diff --git a/packages/charts2/src/cli/render.ts b/packages/charts2/src/cli/render.ts index 28beb529ee5..dcc2417b28c 100644 --- a/packages/charts2/src/cli/render.ts +++ b/packages/charts2/src/cli/render.ts @@ -1,5 +1,5 @@ /** - * bcds-charts render — one chart definition → SVG/PNG (spec 24). + * charts render — one chart definition → SVG/PNG (spec 24). * * Pipeline: loadDefinition → loadDataset → resolveDefinitionTimes → * layoutChart → renderToStaticMarkup() → XML declaration → file @@ -23,6 +23,7 @@ import { renderToStaticMarkup } from "react-dom/server" import { getTheme, layoutChart, resolveDefinitionTimes, type ChromeMode, type Theme } from "../core/index.ts" import type { Diagnostic, Locale, ViewState } from "../core/types.ts" +import type { EmphasisModel } from "../react/interaction/emphasisReducer.ts" import { SceneSVG } from "../react/SceneSVG.tsx" import { CliFailure, CliUsageError, countErrors, hasErrors, printDiagnostics } from "./errors.ts" import { loadDataset, loadDefinition, parseState } from "./loadInputs.ts" @@ -126,6 +127,9 @@ export interface RenderSvgOptions extends GeometryFlags { themeName?: string locale?: Locale transparent?: boolean + /** Series keys to force-focus (spec 07 §3); overrides the definition's + * focusedSeries. Unknown keys are dropped with a warning. */ + focus?: string[] } export interface RenderSvgResult { @@ -196,7 +200,29 @@ export function renderDefinitionToSvg(options: RenderSvgOptions): RenderSvgResul if (options.transparent === true) scene = { ...scene, background: "transparent" } - const markup = renderToStaticMarkup(createElement(SceneSVG, { scene, idPrefix: slug })) + // Force-focus (spec 07 §3): --focus overrides the definition's focusedSeries; + // the focused series stay full-opacity while the rest dim and their markers + // hide. Unknown keys are dropped with a warning so a typo can't blank the chart. + const requestedFocus = + options.focus !== undefined && options.focus.length > 0 ? options.focus : (definition.focusedSeries ?? []) + const knownKeys = new Set(scene.series.map((s) => s.key)) + const unknownFocus = requestedFocus.filter((key) => !knownKeys.has(key)) + if (unknownFocus.length > 0) { + diagnostics.push({ + severity: "warning", + code: "unknown-focus-series", + message: `--focus: no series named ${unknownFocus.join(", ")}`, + context: { unknown: unknownFocus.join(", ") }, + }) + } + const focusKeys = requestedFocus.filter((key) => knownKeys.has(key)) + const emphasis: EmphasisModel = + focusKeys.length > 0 ? { mode: "emphasis", keys: new Set(focusKeys) } : { mode: "idle" } + const dimTheme = theme ?? getTheme(definition.theme).theme + + const markup = renderToStaticMarkup( + createElement(SceneSVG, { scene, idPrefix: slug, emphasis, dimOpacity: dimTheme.palette.dimOpacity }), + ) return { svg: `${XML_DECLARATION}\n${markup}`, slug, @@ -287,6 +313,18 @@ export function parseFormats(value: string | string[] | undefined): OutputFormat return formats } +/** --focus A --focus B and --focus A,B both work; trimmed, deduped, ordered. */ +export function parseFocusKeys(value: string | string[] | undefined): string[] { + if (value === undefined) return [] + const tokens = (Array.isArray(value) ? value : [value]) + .flatMap((entry) => entry.split(",")) + .map((token) => token.trim()) + .filter((token) => token !== "") + const keys: string[] = [] + for (const token of tokens) if (!keys.includes(token)) keys.push(token) + return keys +} + function parsePositiveInt(value: string | undefined, flag: string): number | undefined { if (value === undefined) return undefined const parsed = Number(value) @@ -337,6 +375,7 @@ interface RenderArgs { theme?: string locale?: string state?: string + focus?: string | string[] transparent: boolean chrome: boolean fonts?: string @@ -357,6 +396,7 @@ export function runRender(args: RenderArgs): void { themeName: args.theme, locale: parseLocale(args.locale), transparent: args.transparent, + focus: parseFocusKeys(args.focus), }) printDiagnostics(result.diagnostics) @@ -426,6 +466,11 @@ export const renderCommand = defineCommand({ type: "string", description: 'URL-style view state, e.g. "tab=line&time=2014-15..2024-25&entities=ON~QC"', }, + focus: { + type: "string", + description: + "Series key(s) to focus — dim the rest to the theme dim and hide their line markers; repeatable or comma-separated (default: the definition's focusedSeries)", + }, transparent: { type: "boolean", description: "No background fill", default: false }, chrome: { type: "boolean", diff --git a/packages/charts2/src/cli/scaffold.test.ts b/packages/charts2/src/cli/scaffold.test.ts new file mode 100644 index 00000000000..253c04809e0 --- /dev/null +++ b/packages/charts2/src/cli/scaffold.test.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +import type { ChartType } from "../core/types.ts" +import { CliUsageError } from "./errors.ts" +import { renderDefinitionToSvg } from "./render.ts" +import { runScaffold, scaffoldFiles, slugifyName } from "./scaffold.ts" +import { validateInput } from "./validate.ts" + +const chartTypes: ChartType[] = ["line", "discrete-bar", "stacked-area", "stacked-bar", "stacked-discrete-bar"] +const tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "bcds-cli-scaffold-")) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop() as string, { recursive: true, force: true }) + } +}) + +describe("slugifyName", () => { + it("turns display names into directory slugs", () => { + expect(slugifyName("Provincial Spending: 2024")).toBe("provincial-spending-2024") + }) +}) + +describe("scaffoldFiles", () => { + it.each(chartTypes)("creates valid starter files for %s", (chartType) => { + const { slug, files } = scaffoldFiles(chartType, `${chartType} starter`) + expect(slug).toBe(`${chartType}-starter`) + expect(files.definition.data).toBe(".") + expect(files.definition.types).toEqual([chartType]) + expect(files.csv).toContain("entity,time") + }) + + it("rejects empty names", () => { + expect(() => scaffoldFiles("line", "!!!")).toThrow(CliUsageError) + }) +}) + +describe("charts scaffold", () => { + it.each(chartTypes)("writes a %s scaffold that validates and renders", (chartType) => { + const dir = makeTmpDir() + const prev = process.cwd() + process.chdir(dir) + try { + runScaffold({ chartType, name: `${chartType} starter`, force: false }) + } finally { + process.chdir(prev) + } + + const slug = `${chartType}-starter` + const scaffoldDir = join(dir, slug) + const definitionPath = join(scaffoldDir, "definition.json") + expect(existsSync(definitionPath)).toBe(true) + expect(existsSync(join(scaffoldDir, "manifest.json"))).toBe(true) + expect(existsSync(join(scaffoldDir, "data.csv"))).toBe(true) + + const definition = JSON.parse(readFileSync(definitionPath, "utf8")) as { data: string; types: string[] } + expect(definition.data).toBe(".") + expect(definition.types).toEqual([chartType]) + + const validation = validateInput(definitionPath) + expect(validation.errors, validation.diagnostics.map((d) => d.message).join("\n")).toBe(0) + + const rendered = renderDefinitionToSvg({ definitionPath }) + expect(rendered.svg, rendered.diagnostics.map((d) => d.message).join("\n")).not.toBeNull() + expect(rendered.svg).toContain(" { + const dir = makeTmpDir() + const prev = process.cwd() + process.chdir(dir) + try { + runScaffold({ chartType: "line", name: "overwrite me", force: false }) + expect(() => runScaffold({ chartType: "line", name: "overwrite me", force: false })).toThrow( + CliUsageError, + ) + runScaffold({ chartType: "line", name: "overwrite me", force: true }) + } finally { + process.chdir(prev) + } + }) +}) diff --git a/packages/charts2/src/cli/scaffold.ts b/packages/charts2/src/cli/scaffold.ts new file mode 100644 index 00000000000..fdedf6a9105 --- /dev/null +++ b/packages/charts2/src/cli/scaffold.ts @@ -0,0 +1,315 @@ +/** + * charts scaffold - create starter chart definition and dataset files. + */ + +import { defineCommand } from "citty" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join, resolve } from "node:path" + +import type { ChartType } from "../core/types.ts" +import { CliUsageError } from "./errors.ts" + +const CHART_TYPES = [ + "line", + "discrete-bar", + "stacked-area", + "stacked-bar", + "stacked-discrete-bar", + "slope", + "dumbbell", + "scatter", + "marimekko", +] as const + +interface ScaffoldArgs { + chartType: string + name: string + force: boolean +} + +interface ScaffoldFiles { + definition: Record + manifest: Record + csv: string +} + +function isChartType(value: string): value is ChartType { + return (CHART_TYPES as readonly string[]).includes(value) +} + +export function slugifyName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function titleFromName(name: string, slug: string): string { + const trimmed = name.trim() + if (trimmed.includes(" ") && /[A-Z]/.test(trimmed)) return trimmed + return slug + .split("-") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n` +} + +function baseManifest(slug: string, title: string, columns: Record): Record { + return { + name: slug, + title: `${title} dataset`, + timeGrain: "year", + entity: { label: "region", labelPlural: "regions" }, + columns, + sources: [{ name: "Source name" }], + } +} + +function baseDefinition(slug: string, title: string, y: string[], type: ChartType): Record { + return { + slug, + title, + subtitle: "Replace this subtitle with the chart takeaway", + data: ".", + y, + types: [type], + sourceText: "Source name", + } +} + +function singleMetricTemplate(slug: string, title: string, type: "line" | "discrete-bar"): ScaffoldFiles { + const definition = baseDefinition(slug, title, ["value"], type) + if (type === "line") { + definition.selectedEntities = ["Canada", "Ontario", "Quebec"] + } else { + definition.time = "latest" + definition.sort = { by: "total", order: "desc" } + } + + return { + definition, + manifest: baseManifest(slug, title, { + value: { + name: "Value", + type: "numeric", + unit: "units", + shortUnit: "", + decimals: 0, + }, + }), + csv: + "entity,time,value\n" + + "Canada,2021,100\n" + + "Canada,2022,110\n" + + "Canada,2023,125\n" + + "Ontario,2021,40\n" + + "Ontario,2022,46\n" + + "Ontario,2023,54\n" + + "Quebec,2021,32\n" + + "Quebec,2022,34\n" + + "Quebec,2023,37\n", + } +} + +function stackedTimeTemplate(slug: string, title: string, type: "stacked-area" | "stacked-bar"): ScaffoldFiles { + return { + definition: { + ...baseDefinition(slug, title, ["category_a", "category_b", "category_c"], type), + selectedEntities: ["Canada"], + }, + manifest: baseManifest(slug, title, { + category_a: { name: "Category A", type: "numeric", unit: "units", decimals: 0 }, + category_b: { name: "Category B", type: "numeric", unit: "units", decimals: 0 }, + category_c: { name: "Category C", type: "numeric", unit: "units", decimals: 0 }, + }), + csv: + "entity,time,category_a,category_b,category_c\n" + + "Canada,2021,40,35,25\n" + + "Canada,2022,44,38,30\n" + + "Canada,2023,52,41,34\n", + } +} + +function stackedDiscreteTemplate(slug: string, title: string): ScaffoldFiles { + return { + definition: { + ...baseDefinition(slug, title, ["category_a", "category_b", "category_c"], "stacked-discrete-bar"), + time: "latest", + sort: { by: "total", order: "desc" }, + }, + manifest: baseManifest(slug, title, { + category_a: { name: "Category A", type: "numeric", unit: "units", decimals: 0 }, + category_b: { name: "Category B", type: "numeric", unit: "units", decimals: 0 }, + category_c: { name: "Category C", type: "numeric", unit: "units", decimals: 0 }, + }), + csv: + "entity,time,category_a,category_b,category_c\n" + + "Canada,2023,52,41,34\n" + + "Ontario,2023,22,18,14\n" + + "Quebec,2023,16,12,9\n" + + "Alberta,2023,11,9,7\n", + } +} + +/** Slope: one metric across ≥2 times for several entities (spec 12). */ +function slopeTemplate(slug: string, title: string): ScaffoldFiles { + return { + definition: { + ...baseDefinition(slug, title, ["value"], "slope"), + selectedEntities: ["Ontario", "Quebec", "Alberta"], + }, + manifest: baseManifest(slug, title, { + value: { name: "Value", type: "numeric", unit: "units", decimals: 0 }, + }), + csv: + "entity,time,value\n" + + "Ontario,2014,40\nOntario,2024,54\n" + + "Quebec,2014,32\nQuebec,2024,37\n" + + "Alberta,2014,28\nAlberta,2024,25\n", + } +} + +/** Dumbbell: two metrics at one time, one row per entity (spec 17). */ +function dumbbellTemplate(slug: string, title: string): ScaffoldFiles { + return { + definition: { + ...baseDefinition(slug, title, ["start_value", "end_value"], "dumbbell"), + time: "latest", + sort: { by: "change", order: "desc" }, + }, + manifest: baseManifest(slug, title, { + start_value: { name: "Start", type: "numeric", unit: "units", decimals: 0 }, + end_value: { name: "End", type: "numeric", unit: "units", decimals: 0 }, + }), + csv: + "entity,time,start_value,end_value\n" + + "Ontario,2023,40,54\n" + + "Quebec,2023,32,37\n" + + "Alberta,2023,28,25\n", + } +} + +/** Scatter: x vs y metric, one point per entity at a target time (spec 18). */ +function scatterTemplate(slug: string, title: string): ScaffoldFiles { + return { + definition: { + ...baseDefinition(slug, title, ["metric_y"], "scatter"), + x: "metric_x", + time: "latest", + }, + manifest: baseManifest(slug, title, { + metric_x: { name: "Metric X", type: "numeric", unit: "units", decimals: 0 }, + metric_y: { name: "Metric Y", type: "numeric", unit: "units", decimals: 0 }, + }), + csv: + "entity,time,metric_x,metric_y\n" + + "Ontario,2023,40,120\n" + + "Quebec,2023,32,90\n" + + "Alberta,2023,28,110\n" + + "British Columbia,2023,24,70\n", + } +} + +/** Marimekko: stacked metrics with column widths from an x metric (spec 19). */ +function marimekkoTemplate(slug: string, title: string): ScaffoldFiles { + return { + definition: { + ...baseDefinition(slug, title, ["category_a", "category_b", "category_c"], "marimekko"), + x: "width", + time: "latest", + }, + manifest: baseManifest(slug, title, { + category_a: { name: "Category A", type: "numeric", unit: "units", decimals: 0 }, + category_b: { name: "Category B", type: "numeric", unit: "units", decimals: 0 }, + category_c: { name: "Category C", type: "numeric", unit: "units", decimals: 0 }, + width: { name: "Population", type: "numeric", unit: "people", decimals: 0 }, + }), + csv: + "entity,time,category_a,category_b,category_c,width\n" + + "Ontario,2023,52,41,34,15000\n" + + "Quebec,2023,22,18,14,8500\n" + + "Alberta,2023,16,12,9,4400\n", + } +} + +export function scaffoldFiles(chartType: ChartType, name: string): { slug: string; files: ScaffoldFiles } { + const slug = slugifyName(name) + if (slug === "") throw new CliUsageError("name must contain at least one letter or number") + + const title = titleFromName(name, slug) + switch (chartType) { + case "line": + case "discrete-bar": + return { slug, files: singleMetricTemplate(slug, title, chartType) } + case "stacked-area": + case "stacked-bar": + return { slug, files: stackedTimeTemplate(slug, title, chartType) } + case "stacked-discrete-bar": + return { slug, files: stackedDiscreteTemplate(slug, title) } + case "slope": + return { slug, files: slopeTemplate(slug, title) } + case "dumbbell": + return { slug, files: dumbbellTemplate(slug, title) } + case "scatter": + return { slug, files: scatterTemplate(slug, title) } + case "marimekko": + return { slug, files: marimekkoTemplate(slug, title) } + } +} + +export function runScaffold(args: ScaffoldArgs): void { + if (!isChartType(args.chartType)) { + throw new CliUsageError(`chart-type must be one of: ${CHART_TYPES.join(", ")} (got "${args.chartType}")`) + } + + const { slug, files } = scaffoldFiles(args.chartType, args.name) + const dir = resolve(slug) + if (existsSync(dir)) { + if (!args.force) { + throw new CliUsageError(`Directory already exists: ${dir}; pass --force to replace it`) + } + rmSync(dir, { recursive: true, force: true }) + } + + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "definition.json"), json(files.definition)) + writeFileSync(join(dir, "manifest.json"), json(files.manifest)) + writeFileSync(join(dir, "data.csv"), files.csv) + + process.stdout.write(`created ${slug}/definition.json\n`) + process.stdout.write(`created ${slug}/manifest.json\n`) + process.stdout.write(`created ${slug}/data.csv\n`) + process.stdout.write(`render with: charts render ${slug}/definition.json --out ${slug}/${slug}.svg\n`) +} + +export const scaffoldCommand = defineCommand({ + meta: { + name: "scaffold", + description: "Create starter definition.json, manifest.json, and data.csv files for a chart type", + }, + args: { + chartType: { + type: "positional", + description: "line | discrete-bar | stacked-area | stacked-bar | stacked-discrete-bar", + required: true, + }, + name: { + type: "positional", + description: "Chart name; converted to the output directory slug", + required: true, + }, + force: { + type: "boolean", + description: "Replace an existing scaffold directory", + default: false, + }, + }, + run({ args }) { + runScaffold(args as unknown as ScaffoldArgs) + }, +}) diff --git a/packages/charts2/src/cli/validate.ts b/packages/charts2/src/cli/validate.ts index ea259c3d8f5..5af3201ba28 100644 --- a/packages/charts2/src/cli/validate.ts +++ b/packages/charts2/src/cli/validate.ts @@ -1,5 +1,5 @@ /** - * bcds-charts validate — report ALL problems at once (spec 01 §8, spec 24). + * charts validate — report ALL problems at once (spec 01 §8, spec 24). * * Accepts a chart definition file, a dataset directory (manifest.json + * data.csv), a single manifest.json, a {manifest, rows} JSON file, or a diff --git a/packages/charts2/src/core/definition/schema.test.ts b/packages/charts2/src/core/definition/schema.test.ts index e320cac0b89..5c414ce63c0 100644 --- a/packages/charts2/src/core/definition/schema.test.ts +++ b/packages/charts2/src/core/definition/schema.test.ts @@ -98,10 +98,14 @@ describe("parseDefinition required fields", () => { describe("parseDefinition unknown fields (spec 02 §4)", () => { it("warns about unknown top-level fields instead of silently ignoring them", () => { - const { definition, diagnostics } = parseDefinition({ ...minimal, x: "gdp" }) + const { definition, diagnostics } = parseDefinition({ ...minimal, notARealField: "gdp" }) expect(definition).not.toBeNull() expect(diagnostics).toEqual([ - expect.objectContaining({ severity: "warning", code: "unknown-definition-field", context: { field: "x" } }), + expect.objectContaining({ + severity: "warning", + code: "unknown-definition-field", + context: { field: "notARealField" }, + }), ]) }) diff --git a/packages/charts2/src/core/definition/schema.ts b/packages/charts2/src/core/definition/schema.ts index 0dd1b887aea..34f9f132973 100644 --- a/packages/charts2/src/core/definition/schema.ts +++ b/packages/charts2/src/core/definition/schema.ts @@ -27,7 +27,17 @@ export const DEFAULT_CHART_TYPES: readonly ChartType[] = ["line", "discrete-bar" // Schemas (zod v4) — unknown keys are stripped here and warned about below. // --------------------------------------------------------------------------- -const chartTypeSchema = z.enum(["line", "discrete-bar", "stacked-area", "stacked-bar", "stacked-discrete-bar"]) +const chartTypeSchema = z.enum([ + "line", + "discrete-bar", + "stacked-area", + "stacked-bar", + "stacked-discrete-bar", + "slope", + "dumbbell", + "scatter", + "marimekko", +]) const tabSchema = z.union([chartTypeSchema, z.literal("table")]) @@ -110,6 +120,13 @@ const definitionSchema = z.object({ data: z.string(), y: z.array(z.string()).min(1), + x: z.string().optional(), + sizeMetric: z.string().optional(), + colourMetric: z.string().optional(), + connector: z.enum(["arrow", "line"]).optional(), + valueLabelMode: z.enum(["absolute", "change", "percentChange", "none"]).optional(), + trendColouring: z.boolean().optional(), + showNoDataArea: z.boolean().optional(), filter: z.record(z.string(), z.string()).optional(), bindings: z.record(z.string(), bindingOverrideSchema).optional(), @@ -281,6 +298,13 @@ export function parseDefinition(raw: unknown): ParseDefinitionResult { ...(parsed.subtitle !== undefined ? { subtitle: parsed.subtitle } : {}), ...(parsed.note !== undefined ? { note: parsed.note } : {}), ...(parsed.sourceText !== undefined ? { sourceText: parsed.sourceText } : {}), + ...(parsed.x !== undefined ? { x: parsed.x } : {}), + ...(parsed.sizeMetric !== undefined ? { sizeMetric: parsed.sizeMetric } : {}), + ...(parsed.colourMetric !== undefined ? { colourMetric: parsed.colourMetric } : {}), + ...(parsed.connector !== undefined ? { connector: parsed.connector } : {}), + ...(parsed.valueLabelMode !== undefined ? { valueLabelMode: parsed.valueLabelMode } : {}), + ...(parsed.trendColouring !== undefined ? { trendColouring: parsed.trendColouring } : {}), + ...(parsed.showNoDataArea !== undefined ? { showNoDataArea: parsed.showNoDataArea } : {}), ...(parsed.filter !== undefined ? { filter: parsed.filter } : {}), ...(parsed.bindings !== undefined ? { bindings: parsed.bindings } : {}), ...(parsed.defaultTab !== undefined ? { defaultTab: parsed.defaultTab } : {}), diff --git a/packages/charts2/src/core/definition/urlState.ts b/packages/charts2/src/core/definition/urlState.ts index 438fc685f61..d24da3522c7 100644 --- a/packages/charts2/src/core/definition/urlState.ts +++ b/packages/charts2/src/core/definition/urlState.ts @@ -23,7 +23,18 @@ import { formatTimeOrdinalRaw, parseTime } from "../data/time.ts" import type { Diagnostic, SortOrder, Tab, TimeBound, TimeGrain, TimeSelection, ViewState } from "../types.ts" -const TABS = new Set(["line", "discrete-bar", "stacked-area", "stacked-bar", "stacked-discrete-bar", "table"]) +const TABS = new Set([ + "line", + "discrete-bar", + "stacked-area", + "stacked-bar", + "stacked-discrete-bar", + "slope", + "dumbbell", + "scatter", + "marimekko", + "table", +]) const SCALES = new Set(["linear", "log"]) const STACK_MODES = new Set(["absolute", "relative"]) const FACETS = new Set(["none", "entity", "metric"]) diff --git a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap index 0c663a2cf36..547affd2401 100644 --- a/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap +++ b/packages/charts2/src/core/layout/__snapshots__/layoutChart.test.ts.snap @@ -1,66 +1,131 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`layoutChart matrix: every fixture × applicable type × three sizes > scene JSON is snapshot-stable across runs 1`] = ` +{ + "federal-departments discrete-bar @ default 850x600": "259fe2ca", + "federal-departments discrete-bar @ thumbnail 300x160": "052c96f6", + "federal-departments discrete-bar @ wide 1200x600": "d46dc287", + "federal-departments line @ default 850x600": "080e2bfe", + "federal-departments line @ thumbnail 300x160": "a3c99fce", + "federal-departments line @ wide 1200x600": "a14df725", + "federal-departments stacked-area @ default 850x600": "76c0e809", + "federal-departments stacked-area @ thumbnail 300x160": "c1264166", + "federal-departments stacked-area @ wide 1200x600": "ac81bed1", + "federal-departments stacked-bar @ default 850x600": "7f90e4fe", + "federal-departments stacked-bar @ thumbnail 300x160": "89a241b2", + "federal-departments stacked-bar @ wide 1200x600": "f2537804", + "government-debt discrete-bar @ default 850x600": "f16c00ae", + "government-debt discrete-bar @ thumbnail 300x160": "f4200191", + "government-debt discrete-bar @ wide 1200x600": "f96c6280", + "government-debt line @ default 850x600": "44653fa8", + "government-debt line @ thumbnail 300x160": "a57c8fc2", + "government-debt line @ wide 1200x600": "a2f1e0e8", + "government-debt stacked-area @ default 850x600": "27f224f3", + "government-debt stacked-area @ thumbnail 300x160": "a4ff1560", + "government-debt stacked-area @ wide 1200x600": "824614a4", + "government-debt stacked-bar @ default 850x600": "a6993786", + "government-debt stacked-bar @ thumbnail 300x160": "3233ff0e", + "government-debt stacked-bar @ wide 1200x600": "4296225f", + "government-debt stacked-discrete-bar @ default 850x600": "a78c28b1", + "government-debt stacked-discrete-bar @ thumbnail 300x160": "e68912f5", + "government-debt stacked-discrete-bar @ wide 1200x600": "00e8b712", + "pathological discrete-bar @ default 850x600": "47f452f4", + "pathological discrete-bar @ thumbnail 300x160": "3c432a25", + "pathological discrete-bar @ wide 1200x600": "1b69df6c", + "pathological huge line @ default 850x600": "1452653d", + "pathological huge line @ thumbnail 300x160": "57d728a3", + "pathological huge line @ wide 1200x600": "a99cc060", + "pathological line @ default 850x600": "4aff8115", + "pathological line @ thumbnail 300x160": "9ad45ee8", + "pathological line @ wide 1200x600": "90178914", + "pathological stacked-bar @ default 850x600": "99a9b4e0", + "pathological stacked-bar @ thumbnail 300x160": "8b7a1c51", + "pathological stacked-bar @ wide 1200x600": "540a0f2d", + "population-snapshot discrete-bar @ default 850x600": "a9934129", + "population-snapshot discrete-bar @ thumbnail 300x160": "d979fddd", + "population-snapshot discrete-bar @ wide 1200x600": "a3f62aa4", + "population-snapshot stacked-discrete-bar @ default 850x600": "8c7a0ce0", + "population-snapshot stacked-discrete-bar @ thumbnail 300x160": "2bb6ae1b", + "population-snapshot stacked-discrete-bar @ wide 1200x600": "fb1ef338", + "provincial-budgets discrete-bar @ default 850x600": "36a60b3e", + "provincial-budgets discrete-bar @ thumbnail 300x160": "52d2b6d0", + "provincial-budgets discrete-bar @ wide 1200x600": "cfb23f02", + "provincial-budgets line @ default 850x600": "195d0290", + "provincial-budgets line @ thumbnail 300x160": "c26f3ca8", + "provincial-budgets line @ wide 1200x600": "20a837ff", + "provincial-budgets stacked-area @ default 850x600": "e62f474b", + "provincial-budgets stacked-area @ thumbnail 300x160": "459bc3e1", + "provincial-budgets stacked-area @ wide 1200x600": "53e60b44", + "provincial-budgets stacked-bar @ default 850x600": "666331fc", + "provincial-budgets stacked-bar @ thumbnail 300x160": "62ab199b", + "provincial-budgets stacked-bar @ wide 1200x600": "eb78b957", + "provincial-budgets stacked-discrete-bar @ default 850x600": "e7b0110b", + "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "998e90ed", + "provincial-budgets stacked-discrete-bar @ wide 1200x600": "c7d32841", +} +`; exports[`layoutChart matrix: every fixture × applicable type × three sizes scene JSON is snapshot-stable across runs 1`] = ` { - "federal-departments discrete-bar @ default 850x600": "d85638ad", - "federal-departments discrete-bar @ thumbnail 300x160": "17748abd", - "federal-departments discrete-bar @ wide 1200x600": "8367091a", - "federal-departments line @ default 850x600": "56ed676c", - "federal-departments line @ thumbnail 300x160": "10ae39f6", - "federal-departments line @ wide 1200x600": "47961845", - "federal-departments stacked-area @ default 850x600": "1db82b95", - "federal-departments stacked-area @ thumbnail 300x160": "e8563644", - "federal-departments stacked-area @ wide 1200x600": "1da03b31", - "federal-departments stacked-bar @ default 850x600": "e0b6ed01", - "federal-departments stacked-bar @ thumbnail 300x160": "5a77c86c", - "federal-departments stacked-bar @ wide 1200x600": "19f17c3d", - "government-debt discrete-bar @ default 850x600": "23661d9a", - "government-debt discrete-bar @ thumbnail 300x160": "1aed79c3", - "government-debt discrete-bar @ wide 1200x600": "661c34e8", - "government-debt line @ default 850x600": "86bff374", - "government-debt line @ thumbnail 300x160": "28d4b25e", - "government-debt line @ wide 1200x600": "bc986032", - "government-debt stacked-area @ default 850x600": "2a063c24", - "government-debt stacked-area @ thumbnail 300x160": "77b4c155", - "government-debt stacked-area @ wide 1200x600": "942d7b49", - "government-debt stacked-bar @ default 850x600": "db5c9911", - "government-debt stacked-bar @ thumbnail 300x160": "5e80310e", - "government-debt stacked-bar @ wide 1200x600": "77d73098", - "government-debt stacked-discrete-bar @ default 850x600": "fa891b93", - "government-debt stacked-discrete-bar @ thumbnail 300x160": "51962ee1", - "government-debt stacked-discrete-bar @ wide 1200x600": "e031b6b2", - "pathological discrete-bar @ default 850x600": "433da7fa", - "pathological discrete-bar @ thumbnail 300x160": "51c05fe0", - "pathological discrete-bar @ wide 1200x600": "70299d42", - "pathological huge line @ default 850x600": "82faba64", - "pathological huge line @ thumbnail 300x160": "a52dcecf", - "pathological huge line @ wide 1200x600": "288f795b", - "pathological line @ default 850x600": "0b4bd3d8", - "pathological line @ thumbnail 300x160": "23ea99c7", - "pathological line @ wide 1200x600": "f4e7fbd9", - "pathological stacked-bar @ default 850x600": "a2a4ada6", - "pathological stacked-bar @ thumbnail 300x160": "01e36d15", - "pathological stacked-bar @ wide 1200x600": "80a0a655", - "population-snapshot discrete-bar @ default 850x600": "cd2db986", - "population-snapshot discrete-bar @ thumbnail 300x160": "10d54cba", - "population-snapshot discrete-bar @ wide 1200x600": "ba91917b", - "population-snapshot stacked-discrete-bar @ default 850x600": "fa8ee09e", - "population-snapshot stacked-discrete-bar @ thumbnail 300x160": "86049ac8", - "population-snapshot stacked-discrete-bar @ wide 1200x600": "865daba4", - "provincial-budgets discrete-bar @ default 850x600": "13e3dd11", - "provincial-budgets discrete-bar @ thumbnail 300x160": "617bb119", - "provincial-budgets discrete-bar @ wide 1200x600": "ad8969b1", - "provincial-budgets line @ default 850x600": "770ca8cf", - "provincial-budgets line @ thumbnail 300x160": "4136320e", - "provincial-budgets line @ wide 1200x600": "b1f26af5", - "provincial-budgets stacked-area @ default 850x600": "1f1833ce", - "provincial-budgets stacked-area @ thumbnail 300x160": "eb20e4d9", - "provincial-budgets stacked-area @ wide 1200x600": "6c9adfbf", - "provincial-budgets stacked-bar @ default 850x600": "5b82d720", - "provincial-budgets stacked-bar @ thumbnail 300x160": "5553a1f9", - "provincial-budgets stacked-bar @ wide 1200x600": "a290a49b", - "provincial-budgets stacked-discrete-bar @ default 850x600": "9cc32be0", - "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "3a88c78e", - "provincial-budgets stacked-discrete-bar @ wide 1200x600": "c8a1ac08", + "federal-departments discrete-bar @ default 850x600": "259fe2ca", + "federal-departments discrete-bar @ thumbnail 300x160": "052c96f6", + "federal-departments discrete-bar @ wide 1200x600": "d46dc287", + "federal-departments line @ default 850x600": "080e2bfe", + "federal-departments line @ thumbnail 300x160": "a3c99fce", + "federal-departments line @ wide 1200x600": "a14df725", + "federal-departments stacked-area @ default 850x600": "76c0e809", + "federal-departments stacked-area @ thumbnail 300x160": "c1264166", + "federal-departments stacked-area @ wide 1200x600": "ac81bed1", + "federal-departments stacked-bar @ default 850x600": "7f90e4fe", + "federal-departments stacked-bar @ thumbnail 300x160": "89a241b2", + "federal-departments stacked-bar @ wide 1200x600": "f2537804", + "government-debt discrete-bar @ default 850x600": "f16c00ae", + "government-debt discrete-bar @ thumbnail 300x160": "f4200191", + "government-debt discrete-bar @ wide 1200x600": "f96c6280", + "government-debt line @ default 850x600": "44653fa8", + "government-debt line @ thumbnail 300x160": "a57c8fc2", + "government-debt line @ wide 1200x600": "a2f1e0e8", + "government-debt stacked-area @ default 850x600": "27f224f3", + "government-debt stacked-area @ thumbnail 300x160": "a4ff1560", + "government-debt stacked-area @ wide 1200x600": "824614a4", + "government-debt stacked-bar @ default 850x600": "a6993786", + "government-debt stacked-bar @ thumbnail 300x160": "3233ff0e", + "government-debt stacked-bar @ wide 1200x600": "4296225f", + "government-debt stacked-discrete-bar @ default 850x600": "a78c28b1", + "government-debt stacked-discrete-bar @ thumbnail 300x160": "e68912f5", + "government-debt stacked-discrete-bar @ wide 1200x600": "00e8b712", + "pathological discrete-bar @ default 850x600": "47f452f4", + "pathological discrete-bar @ thumbnail 300x160": "3c432a25", + "pathological discrete-bar @ wide 1200x600": "1b69df6c", + "pathological huge line @ default 850x600": "1452653d", + "pathological huge line @ thumbnail 300x160": "57d728a3", + "pathological huge line @ wide 1200x600": "a99cc060", + "pathological line @ default 850x600": "4aff8115", + "pathological line @ thumbnail 300x160": "9ad45ee8", + "pathological line @ wide 1200x600": "90178914", + "pathological stacked-bar @ default 850x600": "99a9b4e0", + "pathological stacked-bar @ thumbnail 300x160": "8b7a1c51", + "pathological stacked-bar @ wide 1200x600": "540a0f2d", + "population-snapshot discrete-bar @ default 850x600": "a9934129", + "population-snapshot discrete-bar @ thumbnail 300x160": "d979fddd", + "population-snapshot discrete-bar @ wide 1200x600": "a3f62aa4", + "population-snapshot stacked-discrete-bar @ default 850x600": "8c7a0ce0", + "population-snapshot stacked-discrete-bar @ thumbnail 300x160": "2bb6ae1b", + "population-snapshot stacked-discrete-bar @ wide 1200x600": "fb1ef338", + "provincial-budgets discrete-bar @ default 850x600": "36a60b3e", + "provincial-budgets discrete-bar @ thumbnail 300x160": "52d2b6d0", + "provincial-budgets discrete-bar @ wide 1200x600": "cfb23f02", + "provincial-budgets line @ default 850x600": "195d0290", + "provincial-budgets line @ thumbnail 300x160": "c26f3ca8", + "provincial-budgets line @ wide 1200x600": "20a837ff", + "provincial-budgets stacked-area @ default 850x600": "e62f474b", + "provincial-budgets stacked-area @ thumbnail 300x160": "459bc3e1", + "provincial-budgets stacked-area @ wide 1200x600": "53e60b44", + "provincial-budgets stacked-bar @ default 850x600": "666331fc", + "provincial-budgets stacked-bar @ thumbnail 300x160": "62ab199b", + "provincial-budgets stacked-bar @ wide 1200x600": "eb78b957", + "provincial-budgets stacked-discrete-bar @ default 850x600": "e7b0110b", + "provincial-budgets stacked-discrete-bar @ thumbnail 300x160": "998e90ed", + "provincial-budgets stacked-discrete-bar @ wide 1200x600": "c7d32841", } `; diff --git a/packages/charts2/src/core/layout/charts/dumbbell.test.ts b/packages/charts2/src/core/layout/charts/dumbbell.test.ts new file mode 100644 index 00000000000..525b0e9d942 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/dumbbell.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest" + +import { buildDataset } from "../../data/dataset.ts" +import { parseManifest } from "../../data/manifest.ts" +import { parseCsv } from "../../data/parse.ts" +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect, SceneNode } from "../../scene/nodes.ts" +import type { ChartDefinition, Dataset, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutDumbbell } from "./dumbbell.ts" +import type { ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } +const ALL_PROVINCES = ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"] + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +/** Build a LayoutContext from an inline CSV + manifest (for scenarios the + * committed fixtures do not cover, e.g. a zero start value). */ +function ctxForCsv(csv: string, manifest: Record, raw: Record): LayoutContext { + const parsedManifest = parseManifest(manifest) + if (parsedManifest.manifest === null) throw new Error("test manifest failed to parse") + const parsed = parseCsv(csv, parsedManifest.manifest) + const built = buildDataset(parsedManifest.manifest, parsed.rows) + const dataset: Dataset = built.dataset + return buildContext({ definition: definitionFor(raw), dataset, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +const points = (nodes: SceneNode[]): SceneNode[] => nodes.filter((n) => n.kind === "point") +const withKey = (nodes: SceneNode[], fragment: string): SceneNode[] => nodes.filter((n) => n.key.includes(fragment)) +const textOf = (nodes: SceneNode[], key: string): string | undefined => { + const node = nodes.find((n) => n.key === key) + return node?.kind === "text" ? node.text : undefined +} + +describe("dumbbell two-metric mode (spec 17)", () => { + const raw = { y: ["program_spending", "debt_charges"], types: ["dumbbell"], time: "2024-25", selectedEntities: ALL_PROVINCES } + + it("renders two dots and a connector per renderable entity", () => { + const layer = layoutDumbbell(ctxFor("provincial-budgets", raw), AREA, OPTS) + // Quebec 2024-25 has no program_spending, so it is excluded → 4 rows. + expect(layer.series.length).toBe(4) + expect(layer.series.map((s) => s.entity)).not.toContain("Quebec") + for (const s of layer.series) expect(s.points.length).toBe(2) + expect(points(layer.nodes).length).toBe(8) // 2 dots × 4 rows + expect(withKey(layer.nodes, "/connector").length).toBe(4) + }) + + it("emits one full-row series hover target per entity with a single OWID range row", () => { + const layer = layoutDumbbell(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.hover.targets.length).toBe(4) + const target = layer.hover.targets[0] + expect(target.kind).toBe("series") + if (target.kind !== "series") return + expect(target.shape.width).toBeCloseTo(layer.plotArea.width, 5) + // OWID format: a single "start → end" row with a trend arrow. + expect(target.tooltip.rows.length).toBe(1) + expect(target.tooltip.rows[0].emphasized).toBe(true) + expect(target.tooltip.rows[0].valueText).toMatch(/[↑↓→]/) + }) +}) + +describe("dumbbell time-range mode (spec 17)", () => { + it("uses the two window handles for a single metric", () => { + const layer = layoutDumbbell( + ctxFor("provincial-budgets", { y: ["total_spending"], types: ["dumbbell"], selectedEntities: ALL_PROVINCES }), + AREA, + OPTS, + ) + expect(layer.series.length).toBe(5) + for (const s of layer.series) { + expect(s.points.length).toBe(2) + expect(s.points[0].time).not.toBeNull() + expect((s.points[0].time as number) < (s.points[1].time as number)).toBe(true) + } + // Default sort: end value descending. + expect(layer.series.map((s) => s.label)).toEqual([ + "Ontario", + "Quebec", + "British Columbia", + "Alberta", + "Nova Scotia", + ]) + }) +}) + +describe("dumbbell endpoint filtering (spec 17)", () => { + it("excludes entities missing an endpoint and lists them as warnings", () => { + const layer = layoutDumbbell( + ctxFor("provincial-budgets", { + y: ["program_spending", "debt_charges"], + types: ["dumbbell"], + time: "2024-25", + selectedEntities: ALL_PROVINCES, + }), + AREA, + OPTS, + ) + const excluded = layer.diagnostics.filter((d) => d.code === "dumbbell-incomplete-endpoints") + expect(excluded.length).toBe(1) + expect(excluded[0].context?.entity).toBe("Quebec") + }) + + it("renders the no-data panel when nothing is drawable", () => { + // Every selected entity lacks program_spending at this time. + const layer = layoutDumbbell( + ctxFor("provincial-budgets", { + y: ["program_spending", "debt_charges"], + types: ["dumbbell"], + time: "2024-25", + selectedEntities: ["Quebec"], + }), + AREA, + OPTS, + ) + expect(layer.empty).toBe(true) + expect(layer.series.length).toBe(0) + }) +}) + +describe("dumbbell value-label modes (spec 17)", () => { + const base = { y: ["program_spending", "debt_charges"], types: ["dumbbell"], time: "2024-25", selectedEntities: ["Ontario"] } + + it("absolute: both endpoint values beside their dots", () => { + const layer = layoutDumbbell(ctxFor("provincial-budgets", { ...base, valueLabelMode: "absolute" }), AREA, OPTS) + expect(textOf(layer.nodes, "value/Ontario/start")).toBe("$200.1") + expect(textOf(layer.nodes, "value/Ontario/end")).toBe("$14.4") + }) + + it("change: a single signed difference near the end", () => { + const layer = layoutDumbbell(ctxFor("provincial-budgets", { ...base, valueLabelMode: "change" }), AREA, OPTS) + expect(textOf(layer.nodes, "value/Ontario/change")).toBe("−$185.7") + expect(layer.nodes.find((n) => n.key === "value/Ontario/start")).toBeUndefined() + }) + + it("percentChange: a signed percentage", () => { + const layer = layoutDumbbell(ctxFor("provincial-budgets", { ...base, valueLabelMode: "percentChange" }), AREA, OPTS) + expect(textOf(layer.nodes, "value/Ontario/change")).toBe("−92.8%") + }) + + it("none: no value labels", () => { + const layer = layoutDumbbell(ctxFor("provincial-budgets", { ...base, valueLabelMode: "none" }), AREA, OPTS) + expect(layer.nodes.filter((n) => n.key.startsWith("value/")).length).toBe(0) + }) + + it("percentChange guards a zero start with an em dash, never infinity", () => { + const csv = "entity,time,a,b\nZeroland,2020,0,50\nRiseville,2020,20,80\n" + const manifest = { + name: "zero-start", + title: "Zero start", + timeGrain: "year", + entity: { label: "place", labelPlural: "places" }, + columns: { + a: { name: "Metric A", type: "numeric" }, + b: { name: "Metric B", type: "numeric" }, + }, + sources: [{ name: "Synthetic" }], + } + const layer = layoutDumbbell( + ctxForCsv(csv, manifest, { + y: ["a", "b"], + types: ["dumbbell"], + time: 2020, + valueLabelMode: "percentChange", + selectedEntities: ["Zeroland", "Riseville"], + }), + AREA, + OPTS, + ) + expect(textOf(layer.nodes, "value/Zeroland/change")).toBe("—") + expect(textOf(layer.nodes, "value/Riseville/change")).toBe("+300%") + }) +}) + +describe("dumbbell sorting (spec 17)", () => { + it("sorts by change with mixed signs", () => { + // debt_charges 2019-20 → 2024-25: Nova Scotia falls, the rest rise. + const layer = layoutDumbbell( + ctxFor("provincial-budgets", { + y: ["debt_charges"], + types: ["dumbbell"], + sort: { by: "change", order: "asc" }, + selectedEntities: ALL_PROVINCES, + }), + AREA, + OPTS, + ) + expect(layer.series.map((s) => s.label)).toEqual([ + "Nova Scotia", + "British Columbia", + "Alberta", + "Quebec", + "Ontario", + ]) + }) +}) + +describe("dumbbell connector style (spec 17)", () => { + const raw = { y: ["debt_charges"], types: ["dumbbell"], selectedEntities: ALL_PROVINCES } + + it("arrow (default) adds arrowhead strokes; line does not", () => { + const arrow = layoutDumbbell(ctxFor("provincial-budgets", { ...raw, connector: "arrow" }), AREA, OPTS) + const line = layoutDumbbell(ctxFor("provincial-budgets", { ...raw, connector: "line" }), AREA, OPTS) + expect(withKey(arrow.nodes, "/arrow/").length).toBeGreaterThan(0) + expect(withKey(line.nodes, "/arrow/").length).toBe(0) + // Both draw a connector; arrow uses a rule, line uses a polyline. + expect(arrow.nodes.find((n) => n.key === "series/Ontario/connector")?.kind).toBe("rule") + expect(line.nodes.find((n) => n.key === "series/Ontario/connector")?.kind).toBe("line") + }) +}) + +describe("dumbbell no-change edge case (spec 17)", () => { + it("renders a single dot with no connector when start equals end", () => { + // A single-time window collapses start and end onto one value per row. + const layer = layoutDumbbell( + ctxFor("provincial-budgets", { + y: ["total_spending"], + types: ["dumbbell"], + time: "2024-25", + selectedEntities: ALL_PROVINCES, + }), + AREA, + OPTS, + ) + expect(layer.series.length).toBe(5) + expect(withKey(layer.nodes, "/connector").length).toBe(0) + expect(withKey(layer.nodes, "/arrow/").length).toBe(0) + expect(points(layer.nodes).length).toBe(5) // one dot per row + }) +}) diff --git a/packages/charts2/src/core/layout/charts/dumbbell.ts b/packages/charts2/src/core/layout/charts/dumbbell.ts new file mode 100644 index 00000000000..079f6c267e1 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/dumbbell.ts @@ -0,0 +1,517 @@ +/** + * Dumbbell chart layout (spec 17). + * + * One row per entity: dots at a start and an end value joined by a connector + * (an arrow pointing at the end value by default, or a plain line). Two modes: + * + * - Two-metric mode (y.length ≥ 2): start = y[0], end = y[1], both at a single + * target time (window.end, or null for grain "none"). + * - Time-range mode (y.length === 1): start = the metric at window.start, + * end = the metric at window.end. + * + * Entities missing either endpoint are excluded and listed as warnings. The + * value axis is shared across every endpoint and only includes zero when the + * data spans it. Rows sort by end value (default), start value, change, name, + * or custom. + * + * Trend colouring (spec 17's default) needs a themed semantic palette for + * rising/falling/flat marks; the current Theme contract exposes no such + * palette, so marks fall back to the entity identity colour. The logic is kept + * so a future theme field lights it up without touching this file. + */ + +import { resolveValue } from "../../data/derived.ts" +import { formatChange } from "../../format/number.ts" +import { formatTime, formatTimeRange } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint, TooltipRow } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { AxisConfig, HexColour, SortConfig, TimeOrdinal } from "../../types.ts" +import { horizontalValueAxisNodes, prepareValueAxis, PLOT_TOP_PAD } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { bandPositions, createValueScale } from "../scales.ts" +import { buildSeriesModels } from "../series.ts" +import { + buildFooters, + centeredBaseline, + collectFooterFlags, + compareStrings, + emptyLayer, + labelValueText, + metaFor, + noteFooterFlags, + noticeFor, + pointByTime, + seriesLabelFont, + textNode, + tickFont, + tooltipValueText, + valueLabelFont, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +/** Default sort: largest end value first (spec 17). */ +export const DEFAULT_DUMBBELL_SORT: SortConfig = { by: "total", order: "desc" } + +const LABEL_GAP = 6 +const START_RADIUS = 4 +const END_RADIUS = 5.5 +const CONNECTOR_WIDTH = 2 +const ARROW_LENGTH = 5 +const ARROW_SPREAD = 3.5 + +type Trend = "increase" | "decrease" | "flat" + +interface Row { + key: string + label: string + entity: string + startColumn: string + endColumn: string + startTime: TimeOrdinal | null + endTime: TimeOrdinal | null + start: SeriesPoint + end: SeriesPoint + colour: HexColour + trend: Trend + /** Value-label text on the left dot side (empty when none). */ + leftText: string + /** Value-label text on the right dot side (empty when none). */ + rightText: string + /** Which endpoint the left/right label belongs to, for stable node keys. */ + startSide: "left" | "right" | "hidden" + endSide: "left" | "right" +} + +function trendOf(startValue: number, endValue: number): Trend { + if (endValue > startValue) return "increase" + if (endValue < startValue) return "decrease" + return "flat" +} + +function trendArrow(trend: Trend): string { + return trend === "increase" ? "↑" : trend === "decrease" ? "↓" : "→" +} + +/** Precomputed value-point lookup per (entity, column). */ +interface Slot { + series: SeriesModel + points: Map +} + +function sortRows(ctx: LayoutContext, rows: Row[]): Row[] { + const sort = ctx.definition.sort ?? DEFAULT_DUMBBELL_SORT + const direction = sort.order === "asc" ? 1 : -1 + const sorted = [...rows] + switch (sort.by) { + case "name": + sorted.sort((a, b) => direction * compareStrings(a.label, b.label)) + break + case "change": + sorted.sort((a, b) => direction * (a.end.value - a.start.value - (b.end.value - b.start.value))) + break + case "column": { + const slug = sort.column ?? ctx.definition.y[0] + const keyOf = (row: Row): number => { + const resolved = resolveValue(ctx.dataset, slug, row.entity, row.endTime, ctx.definition.bindings?.[slug]) + return resolved.status === "value" ? resolved.value : Number.NEGATIVE_INFINITY + } + sorted.sort((a, b) => direction * (keyOf(a) - keyOf(b))) + break + } + case "custom": + break + case "total": + default: + sorted.sort((a, b) => direction * (a.end.value - b.end.value)) + break + } + return sorted +} + +export function layoutDumbbell(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + + const twoMetric = ctx.definition.y.length >= 2 + const startColumn = ctx.definition.y[0] + const endColumn = twoMetric ? ctx.definition.y[1] : ctx.definition.y[0] + + // Endpoint times. Two-metric: one target time for both endpoints. Time-range: + // the two window handles. + const target = ctx.window?.end ?? null + const startTime = twoMetric ? target : (ctx.window?.start ?? null) + const endTime = target + + const built = buildSeriesModels(ctx, "dumbbell") + const diagnostics = [...built.diagnostics] + + // Time-range mode needs a real window; without one there is nothing to span. + if (!twoMetric && ctx.window === null) return emptyLayer(area, diagnostics) + + // Index built points by (entity, column) so both modes read endpoints the + // same way. Colour identity comes from the series model. + const slots = new Map>() + for (const series of built.series) { + if (series.entity === undefined || series.column === undefined) continue + const byColumn = slots.get(series.entity) ?? new Map() + byColumn.set(series.column, { series, points: pointByTime(series) }) + slots.set(series.entity, byColumn) + } + + const pointFor = (entity: string, column: string, time: TimeOrdinal | null): SeriesPoint | undefined => + slots.get(entity)?.get(column)?.points.get(time) + const colourFor = (entity: string, column: string): HexColour | undefined => slots.get(entity)?.get(column)?.series.colour + + // Trend colouring (spec 17 default) requires a themed semantic palette; the + // Theme contract has none, so this is always undefined and marks fall back + // to the entity identity colour below. + const semantic: Record | undefined = undefined + const wantTrend = ctx.definition.trendColouring !== false + + const valueFont = valueLabelFont(scale) + const rows: Row[] = [] + for (const entity of ctx.entities) { + const start = pointFor(entity, startColumn, startTime) + const end = pointFor(entity, endColumn, endTime) + if (start === undefined || end === undefined) { + diagnostics.push({ + severity: "warning", + code: "dumbbell-incomplete-endpoints", + message: `"${entity}" is missing a start or end value and is not shown`, + context: { entity }, + }) + continue + } + + const trend = trendOf(start.value, end.value) + const identity = colourFor(entity, endColumn) ?? colourFor(entity, startColumn) ?? theme.palette.categorical[0] + const colour = wantTrend && semantic !== undefined ? semantic[trend] : identity + + // Value-label text per mode. Absolute puts each endpoint's value on the + // outside of its dot; change/percentChange put one figure past the end. + let leftText = "" + let rightText = "" + let startSide: Row["startSide"] = "hidden" + let endSide: Row["endSide"] = "right" + const mode = ctx.definition.valueLabelMode ?? "absolute" + if (mode === "absolute") { + const startText = tooltipLabelValue(ctx, startColumn, start.value) + const endText = tooltipLabelValue(ctx, endColumn, end.value) + if (start.value === end.value) { + // No change: a single dot with its value to the right. + rightText = endText + endSide = "right" + startSide = "hidden" + } else if (start.value < end.value) { + leftText = startText + rightText = endText + startSide = "left" + endSide = "right" + } else { + leftText = endText + rightText = startText + startSide = "right" + endSide = "left" + } + } else if (mode === "change" || mode === "percentChange") { + const change = formatChange(start.value, end.value, metaFor(ctx, endColumn), { locale }) + rightText = mode === "change" ? change.absolute : (change.relative ?? "—") + } + + rows.push({ + key: entity, + label: entity, + entity, + startColumn, + endColumn, + startTime, + endTime, + start, + end, + colour, + trend, + leftText, + rightText, + startSide, + endSide, + }) + } + + if (rows.length === 0) return emptyLayer(area, diagnostics) + + const ordered = sortRows(ctx, rows) + + // --- Geometry -------------------------------------------------------------- + const labelFont = seriesLabelFont(scale) + const labelMaxWidth = Math.min( + Math.max(0, ...ordered.map((row) => measurer.measure(row.label, labelFont).width)), + area.width * 0.3, + ) + const labelColWidth = labelMaxWidth + 6 + + let leftReserve = 0 + let rightReserve = 0 + for (const row of ordered) { + if (row.leftText !== "") leftReserve = Math.max(leftReserve, measurer.measure(row.leftText, valueFont).width + LABEL_GAP) + if (row.rightText !== "") rightReserve = Math.max(rightReserve, measurer.measure(row.rightText, valueFont).width + LABEL_GAP) + } + // Room for the end dot on either flank even without value labels. + leftReserve = Math.max(leftReserve, END_RADIUS + 1) + rightReserve = Math.max(rightReserve, END_RADIUS + 1) + + const axisFont = tickFont(scale) + const sample = measurer.measure("0", axisFont) + const axisHeight = sample.ascent + sample.descent + PLOT_TOP_PAD + 2 + + const plotArea: Rect = { + x: area.x + labelColWidth + leftReserve, + y: area.y + 4, + width: Math.max(10, area.width - labelColWidth - leftReserve - rightReserve), + height: Math.max(10, area.height - 4 - axisHeight), + } + + // Value axis: zero only when the data spans it (release the bar-style zero + // floor with min "auto", honouring an explicit author override). + const axisConfig: AxisConfig = { ...ctx.definition.xAxis, min: ctx.definition.xAxis?.min ?? "auto" } + const spec = prepareValueAxis({ + values: ordered.flatMap((row) => [row.start.value, row.end.value]), + markType: "line", + scaleType: "linear", + config: axisConfig, + pixelLength: plotArea.width, + font: axisFont, + meta: metaFor(ctx, endColumn), + locale, + measurer, + }) + diagnostics.push(...spec.diagnostics) + const xScale = createValueScale("linear", spec.domain, [plotArea.x, plotArea.x + plotArea.width]) + + const nodes: SceneNode[] = horizontalValueAxisNodes(spec, xScale, plotArea, area, { + theme, + font: axisFont, + hideGridlines: ctx.definition.xAxis?.hideGridlines, + hideTickLabels: ctx.definition.xAxis?.hideTickLabels, + }) + + // --- Rows: dots, connectors, labels, hover --------------------------------- + const bands = bandPositions(ordered.length, [plotArea.y, plotArea.y + plotArea.height], 1) + const connector = ctx.definition.connector ?? "arrow" + const targets: HitTarget[] = [] + const outSeries: SeriesModel[] = [] + + ordered.forEach((row, index) => { + const band = bands[index] + const cy = band.center + const startX = xScale.place(row.start.value) + const endX = xScale.place(row.end.value) + const noChange = row.start.value === row.end.value + + if (!noChange) { + // Connector between the two dots. + const connectorStyle = { stroke: row.colour, strokeWidth: CONNECTOR_WIDTH, lineCap: "round" as const } + if (connector === "line") { + nodes.push({ + key: `series/${row.key}/connector`, + seriesKey: row.key, + role: "mark", + kind: "line", + segments: [ + [ + { x: startX, y: cy }, + { x: endX, y: cy }, + ], + ], + style: connectorStyle, + }) + } else { + nodes.push({ + key: `series/${row.key}/connector`, + seriesKey: row.key, + role: "mark", + kind: "rule", + from: { x: startX, y: cy }, + to: { x: endX, y: cy }, + style: connectorStyle, + }) + } + + if (connector === "arrow") { + // Two short strokes forming a "V" that points at the end value. + const dir = endX >= startX ? 1 : -1 + const tipX = endX - dir * END_RADIUS + const arrow = (suffix: string, dy: number): SceneNode => ({ + key: `series/${row.key}/arrow/${suffix}`, + seriesKey: row.key, + role: "mark", + kind: "rule", + from: { x: tipX - dir * ARROW_LENGTH, y: cy + dy }, + to: { x: tipX, y: cy }, + style: { stroke: row.colour, strokeWidth: CONNECTOR_WIDTH, lineCap: "round" }, + }) + nodes.push(arrow("1", -ARROW_SPREAD), arrow("2", ARROW_SPREAD)) + } + + // Start dot (smaller); the end dot is emphasised below. + nodes.push({ + key: `series/${row.key}/start`, + seriesKey: row.key, + role: "mark", + kind: "point", + center: { x: startX, y: cy }, + radius: START_RADIUS * scale, + style: { + fill: row.colour, + ...(row.start.projected === true ? { patternId: "projection", opacity: 0.85 } : {}), + }, + }) + } + + // End dot (emphasised). For a no-change row this is the only dot. + nodes.push({ + key: `series/${row.key}/end`, + seriesKey: row.key, + role: "mark", + kind: "point", + center: { x: endX, y: cy }, + radius: END_RADIUS * scale, + style: { + fill: row.colour, + ...(row.end.projected === true ? { patternId: "projection", opacity: 0.85 } : {}), + }, + }) + + // Row label (left column, right-anchored against the plot). + const rowLabel = truncateWithEllipsis(row.label, labelFont, Math.max(10, labelMaxWidth), measurer) + const rowLabelMetrics = measurer.measure(rowLabel, labelFont) + nodes.push( + textNode({ + key: `label/${row.key}`, + role: "label", + text: rowLabel, + font: labelFont, + anchor: "end", + x: area.x + labelColWidth - 6, + baselineY: centeredBaseline(cy, rowLabelMetrics), + colour: theme.chrome.tickLabel, + measurer, + seriesKey: row.key, + }), + ) + + // Value labels. + const placeValue = (key: string, text: string, atX: number, side: "left" | "right"): void => { + if (text === "") return + const metrics = measurer.measure(text, valueFont) + nodes.push( + textNode({ + key, + role: "label", + text, + font: valueFont, + anchor: side === "left" ? "end" : "start", + x: side === "left" ? atX - LABEL_GAP : atX + LABEL_GAP, + baselineY: centeredBaseline(cy, metrics), + colour: theme.chrome.tickLabel, + measurer, + seriesKey: row.key, + }), + ) + } + const mode = ctx.definition.valueLabelMode ?? "absolute" + if (mode === "absolute") { + if (row.startSide !== "hidden") { + const startText = tooltipLabelValue(ctx, row.startColumn, row.start.value) + placeValue(`value/${row.key}/start`, startText, startX, row.startSide) + } + const endText = tooltipLabelValue(ctx, row.endColumn, row.end.value) + placeValue(`value/${row.key}/end`, endText, endX, row.endSide) + } else if (mode === "change" || mode === "percentChange") { + placeValue(`value/${row.key}/change`, row.rightText, Math.max(startX, endX), "right") + } + + // Hover (OWID format): title = entity, a single "start → end" row with a + // trend arrow. Time-range mode subtitles the year range; two-metric mode + // subtitles the two metric names and annotates the target time. + const flags = collectFooterFlags() + noteFooterFlags(flags, row.start, row.startTime) + noteFooterFlags(flags, row.end, row.endTime) + const startNotice = noticeFor(row.start, row.startTime) + const endNotice = noticeFor(row.end, row.endTime) + const notice = + startNotice === "projected" || endNotice === "projected" ? "projected" : (startNotice ?? endNotice) + + const startText = tooltipValueText(ctx, row.startColumn, row.start.value, false) + const endText = tooltipValueText(ctx, row.endColumn, row.end.value, false) + const rangeText = `${startText} ${trendArrow(row.trend)} ${endText}` + + let subtitle: string | undefined + let tooltipRowLabel: string + let titleAnnotation: string | undefined + if (twoMetric) { + const startName = ctx.columns[row.startColumn]?.name ?? row.startColumn + const endName = ctx.columns[row.endColumn]?.name ?? row.endColumn + subtitle = `${startName} → ${endName}` + tooltipRowLabel = "" + titleAnnotation = target !== null ? formatTime(target, grain, locale) : undefined + } else { + subtitle = + row.startTime !== null && row.endTime !== null + ? formatTimeRange(row.startTime, row.endTime, grain, locale) + : undefined + tooltipRowLabel = ctx.columns[row.startColumn]?.name ?? "" + titleAnnotation = undefined + } + + const tooltipRows: TooltipRow[] = [ + { + seriesKey: row.key, + label: tooltipRowLabel, + swatch: row.colour, + valueText: rangeText, + emphasized: true, + ...(notice !== undefined ? { notice } : {}), + }, + ] + targets.push({ + kind: "series", + seriesKey: row.key, + shape: { x: plotArea.x, y: band.start, width: plotArea.width, height: band.width }, + tooltip: { + title: row.label, + ...(titleAnnotation !== undefined ? { titleAnnotation } : {}), + ...(subtitle !== undefined ? { subtitle } : {}), + rows: tooltipRows, + footers: buildFooters(flags, grain, locale), + }, + }) + + outSeries.push({ + key: row.key, + label: row.label, + colour: row.colour, + entity: row.entity, + column: row.startColumn, + points: [row.start, row.end], + }) + }) + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets }, + legendItems: [], + greyedLegendKeys: [], + needsLegendFallback: false, + empty: false, + valueDomain: spec.domain, + diagnostics, + } +} + +/** Endpoint data label ("$24.1B"): the shared label-verbosity formatter. */ +function tooltipLabelValue(ctx: LayoutContext, column: string, value: number): string { + return labelValueText(ctx, column, value, false) +} diff --git a/packages/charts2/src/core/layout/charts/line.test.ts b/packages/charts2/src/core/layout/charts/line.test.ts index 309a8df3950..dc3a890d0cf 100644 --- a/packages/charts2/src/core/layout/charts/line.test.ts +++ b/packages/charts2/src/core/layout/charts/line.test.ts @@ -54,7 +54,7 @@ describe("line chart hover model (spec 06/11)", () => { selectedEntities: ["Nova Scotia", "Ontario", "Alberta"], }) const layer = layoutLineChart(ctx, AREA, OPTS) - expect(layer.hover.targets.length).toBe(6) + expect(layer.hover.targets.filter((t) => t.kind === "time").length).toBe(6) expect(layer.hover.timeGuide).toBeDefined() const first = layer.hover.targets[0] expect(first.kind).toBe("time") @@ -63,6 +63,35 @@ describe("line chart hover model (spec 06/11)", () => { expect(first.tooltip.rows.map((r) => r.label)).toEqual(["Ontario", "Alberta", "Nova Scotia"]) }) + it("emits a series hit target per end label so hovering the label focuses the line (spec 07 §3)", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Nova Scotia", "Ontario", "Alberta"], + }) + const layer = layoutLineChart(ctx, AREA, OPTS) + const seriesTargets = layer.hover.targets.filter((t) => t.kind === "series") + expect(seriesTargets.length).toBeGreaterThan(0) + for (const target of seriesTargets) { + if (target.kind !== "series") continue + expect(layer.series.some((s) => s.key === target.seriesKey)).toBe(true) + // Labels sit in the right-reserve margin, past the plot's right edge. + expect(target.shape.x).toBeGreaterThanOrEqual(layer.plotArea.x + layer.plotArea.width) + expect(target.shape.width).toBeGreaterThan(0) + expect(target.shape.height).toBeGreaterThan(0) + } + // Ontario has the top value, so its label is always placed. + expect(seriesTargets.some((t) => t.kind === "series" && t.seriesKey === "Ontario")).toBe(true) + }) + + it("emits no series hit targets when the legend is reserved (end labels hidden)", () => { + const ctx = ctxFor("provincial-budgets", { + y: ["total_spending"], + selectedEntities: ["Nova Scotia", "Ontario", "Alberta"], + }) + const layer = layoutLineChart(ctx, AREA, { ...OPTS, legendReserved: true }) + expect(layer.hover.targets.every((t) => t.kind === "time")).toBe(true) + }) + it("reports missing values as 'No data' rows, never zero", () => { const ctx = ctxFor("provincial-budgets", { y: ["program_spending"], diff --git a/packages/charts2/src/core/layout/charts/line.ts b/packages/charts2/src/core/layout/charts/line.ts index 27bb07f445c..209781dbe33 100644 --- a/packages/charts2/src/core/layout/charts/line.ts +++ b/packages/charts2/src/core/layout/charts/line.ts @@ -226,6 +226,10 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer } // --- End-of-line labels ---------------------------------------------------- + // Each end label is also a series hover/focus hit target (spec 07 §3): + // pointing at a line's right-side label emphasizes it and dims the rest. + const subtitle = builtResult.strategy === "entity" ? metricSubtitle(ctx, slug) : undefined + const labelTargets: HitTarget[] = [] let needsLegendFallback = false if (showLabels) { const candidates: LabelCandidate[] = [] @@ -247,7 +251,9 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer const { placed, dropped } = declutterLabels(candidates, plotArea.y, plotArea.y + plotArea.height) for (const label of placed) { const metrics = measurer.measure(label.text, labelFont) - const colour = series.find((s) => s.key === label.seriesKey)?.colour ?? theme.chrome.tickLabel + const s = series.find((entry) => entry.key === label.seriesKey) + const colour = s?.colour ?? theme.chrome.tickLabel + const labelX = plotArea.x + plotArea.width + LABEL_GAP nodes.push( textNode({ key: `label/${label.seriesKey}`, @@ -255,13 +261,43 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer text: label.text, font: labelFont, anchor: "start", - x: plotArea.x + plotArea.width + LABEL_GAP, + x: labelX, baselineY: label.y + metrics.ascent, colour, measurer, seriesKey: label.seriesKey, }), ) + if (s !== undefined) { + const drawable = s.points.filter((p) => !logScale || p.value > 0) + const last = drawable[drawable.length - 1] + if (last !== undefined) { + labelTargets.push({ + kind: "series", + seriesKey: s.key, + shape: { + x: labelX, + y: label.y, + width: metrics.width, + height: metrics.ascent + metrics.descent, + }, + tooltip: { + title: s.label, + ...(subtitle !== undefined ? { subtitle } : {}), + rows: [ + { + seriesKey: s.key, + label: s.label, + swatch: s.colour, + valueText: tooltipValueText(ctx, s.column ?? slug, last.value, relative), + emphasized: true, + }, + ], + footers: [], + }, + }) + } + } } if (dropped.length > 0) needsLegendFallback = true } else if (ctx.definition.hideSeriesLabels && !opts.legendReserved) { @@ -285,7 +321,6 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer // --- Hover ------------------------------------------------------------------- const targets: HitTarget[] = [] - const subtitle = builtResult.strategy === "entity" ? metricSubtitle(ctx, slug) : undefined for (const time of ctx.times) { const flags = collectFooterFlags() const present: { row: TooltipRow; value: number }[] = [] @@ -325,6 +360,10 @@ export function layoutLineChart(ctx: LayoutContext, area: Rect, opts: ChartLayer }) } + // Series (label) targets after the time strips; SceneSVG renders series + // shapes over the time strips, and they sit in the right-reserve margin. + targets.push(...labelTargets) + return { plotArea, nodes, diff --git a/packages/charts2/src/core/layout/charts/marimekko.test.ts b/packages/charts2/src/core/layout/charts/marimekko.test.ts new file mode 100644 index 00000000000..080ddbc6c80 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/marimekko.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect, SceneNode } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutMarimekko } from "./marimekko.ts" +import type { ChartLayer, ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: true, thumbnail: false, fontScale: 1 } +const ALL_PROVINCES = ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"] + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +/** Column width from a segment rect (all segments in a column share its width). */ +function columnWidth(layer: ChartLayer, entity: string, slug: string): number { + const node = layer.nodes.find((n) => n.key === `series/${entity}/${slug}/seg`) + if (node === undefined || node.kind !== "rect") throw new Error(`no segment for ${entity}/${slug}`) + return node.rect.width +} + +/** Entity order left-to-right, read from segment rects (always drawn). */ +function columnOrder(layer: ChartLayer, slug: string): string[] { + return layer.nodes + .filter((n): n is Extract => n.kind === "rect" && n.key.endsWith(`/${slug}/seg`)) + .map((n) => ({ entity: n.key.slice("series/".length, -`/${slug}/seg`.length), x: n.rect.x })) + .sort((a, b) => a.x - b.x) + .map((v) => v.entity) +} + +/** Entity labels actually placed beneath columns. */ +function labelOrder(layer: ChartLayer): string[] { + return layer.nodes + .filter((n): n is Extract => n.kind === "text" && n.key.startsWith("label/")) + .map((n) => ({ entity: n.key.slice("label/".length), x: n.position.x })) + .sort((a, b) => a.x - b.x) + .map((v) => v.entity) +} + +describe("marimekko column widths (spec 19)", () => { + const raw = { + y: ["program_spending", "debt_charges"], + x: "total_spending", + selectedEntities: ALL_PROVINCES, + time: "2023-24", + types: ["marimekko"], + } + // total_spending 2023-24: On 204.3, Qc 156.1, BC 79.5, Ab 68.3, NS 15.4 → 523.6. + const TOTAL_X = 204.3 + 156.1 + 79.5 + 68.3 + 15.4 + + it("makes a column's pixel share equal its x-value share", () => { + const layer = layoutMarimekko(ctxFor("provincial-budgets", raw), AREA, OPTS) + const widths = new Map(ALL_PROVINCES.map((e) => [e, columnWidth(layer, e, "program_spending")])) + const sum = [...widths.values()].reduce((a, b) => a + b, 0) + expect((widths.get("Ontario") ?? 0) / sum).toBeCloseTo(204.3 / TOTAL_X, 4) + expect((widths.get("Nova Scotia") ?? 0) / sum).toBeCloseTo(15.4 / TOTAL_X, 4) + }) + + it("enforces a minimum width for a tiny column, inflating it beyond its share", () => { + const narrow: Rect = { x: 0, y: 0, width: 120, height: 200 } + const layer = layoutMarimekko(ctxFor("provincial-budgets", raw), narrow, OPTS) + const nsWidth = columnWidth(layer, "Nova Scotia", "program_spending") + const onWidth = columnWidth(layer, "Ontario", "program_spending") + expect(nsWidth).toBeCloseTo(4, 5) // pinned to MIN_COL_WIDTH + // Min enforcement breaks proportionality in the small column's favour. + expect(nsWidth / onWidth).toBeGreaterThan(15.4 / 204.3) + }) + + it("uses equal widths when x is unbound", () => { + const layer = layoutMarimekko( + ctxFor("provincial-budgets", { ...raw, x: undefined }), + AREA, + OPTS, + ) + const widths = ALL_PROVINCES.map((e) => columnWidth(layer, e, "program_spending")) + expect(Math.max(...widths) - Math.min(...widths)).toBeLessThan(0.001) + }) +}) + +describe("marimekko sorting (spec 19)", () => { + const raw = { + y: ["program_spending", "debt_charges"], + x: "total_spending", + selectedEntities: ALL_PROVINCES, + time: "2023-24", + types: ["marimekko"], + } + + it("orders columns by width descending by default", () => { + const layer = layoutMarimekko(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(columnOrder(layer, "program_spending")).toEqual(["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"]) + }) + + it("orders columns by name when sorted by name", () => { + const layer = layoutMarimekko( + ctxFor("provincial-budgets", { ...raw, sort: { by: "name", order: "asc" } }), + AREA, + OPTS, + ) + expect(columnOrder(layer, "program_spending")).toEqual(["Alberta", "British Columbia", "Nova Scotia", "Ontario", "Quebec"]) + }) + + it("orders columns by y-total when sorted by total", () => { + const layer = layoutMarimekko( + ctxFor("provincial-budgets", { ...raw, sort: { by: "total", order: "asc" } }), + AREA, + OPTS, + ) + expect(columnOrder(layer, "program_spending")).toEqual(["Nova Scotia", "Alberta", "British Columbia", "Quebec", "Ontario"]) + }) +}) + +describe("marimekko segments (spec 19)", () => { + const raw = { + y: ["program_spending", "debt_charges"], + x: "total_spending", + selectedEntities: ALL_PROVINCES, + time: "2023-24", + types: ["marimekko"], + stackMode: "relative", + } + + it("stacks metrics in order and sums each column to 100% in relative mode", () => { + const layer = layoutMarimekko(ctxFor("provincial-budgets", raw), AREA, OPTS) + const program = layer.series.find((s) => s.key === "program_spending") + const debt = layer.series.find((s) => s.key === "debt_charges") + // Metric order preserved. + expect(layer.series.map((s) => s.key)).toEqual(["program_spending", "debt_charges"]) + // debt stacks on top of program (first metric at offset 0). + expect(program?.points[0].valueOffset).toBe(0) + expect(debt?.points[0].valueOffset).toBeCloseTo(program?.points[0].value ?? -1, 6) + // Each column sums to 100%. + for (let i = 0; i < ALL_PROVINCES.length; i++) { + const sum = layer.series.reduce((acc, s) => acc + (s.points[i]?.value ?? 0), 0) + expect(sum).toBeCloseTo(100, 6) + } + }) +}) + +describe("marimekko entities without y data (spec 19)", () => { + // At 2024-25 Quebec has no program_spending (row 12) but keeps total_spending. + const raw = { + y: ["program_spending"], + x: "total_spending", + selectedEntities: ["Ontario", "Quebec", "Alberta"], + time: "2024-25", + types: ["marimekko"], + } + + it("excludes an entity missing all y metrics and reports it", () => { + const layer = layoutMarimekko(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.nodes.some((n) => n.key.startsWith("series/Quebec/"))).toBe(false) + expect(layer.nodes.some((n) => n.key === "series/Ontario/program_spending/seg")).toBe(true) + expect(layer.diagnostics.some((d) => d.code === "entities-excluded-no-data")).toBe(true) + }) + + it("groups no-data entities into the right-edge area when showNoDataArea", () => { + const layer = layoutMarimekko( + ctxFor("provincial-budgets", { ...raw, showNoDataArea: true }), + AREA, + OPTS, + ) + expect(layer.nodes.some((n) => n.key === "nodata/area")).toBe(true) + expect(layer.nodes.some((n) => n.key.startsWith("series/Quebec/"))).toBe(false) + expect(layer.diagnostics.some((d) => d.code === "no-data-area")).toBe(true) + }) +}) + +describe("marimekko legend (spec 19)", () => { + it("provides one legend entry per metric, in metric order", () => { + const layer = layoutMarimekko( + ctxFor("provincial-budgets", { + y: ["program_spending", "debt_charges"], + x: "total_spending", + selectedEntities: ALL_PROVINCES, + time: "2023-24", + types: ["marimekko"], + }), + AREA, + OPTS, + ) + expect(layer.legendItems.map((i) => i.seriesKey)).toEqual(["program_spending", "debt_charges"]) + expect(layer.legendItems.map((i) => i.label)).toEqual(["Program spending", "Debt charges"]) + }) +}) + +describe("marimekko label declutter (spec 19)", () => { + it("keeps the widest column's label and drops labels that do not fit", () => { + const narrow: Rect = { x: 0, y: 0, width: 120, height: 200 } + const layer = layoutMarimekko( + ctxFor("provincial-budgets", { + y: ["program_spending", "debt_charges"], + x: "total_spending", + selectedEntities: ALL_PROVINCES, + time: "2023-24", + types: ["marimekko"], + }), + narrow, + OPTS, + ) + const labels = labelOrder(layer) + expect(labels).toContain("Ontario") // widest column keeps its label + expect(labels).not.toContain("Nova Scotia") // tiniest column is dropped + expect(labels.length).toBeLessThan(ALL_PROVINCES.length) + }) +}) diff --git a/packages/charts2/src/core/layout/charts/marimekko.ts b/packages/charts2/src/core/layout/charts/marimekko.ts new file mode 100644 index 00000000000..e873f9360eb --- /dev/null +++ b/packages/charts2/src/core/layout/charts/marimekko.ts @@ -0,0 +1,557 @@ +/** + * Marimekko chart layout (spec 19). + * + * One vertical column per entity at a single target time. Column WIDTH is + * proportional to the entity's `x` metric (equal widths when `x` is unbound), + * with a minimum visible width enforced. Within each column the `y` metrics + * stack in metric order — this is a stacked-discrete-bar turned on its side, + * so segment offsets come from the same both-directions stacking helper. + * + * Height encodes value: relative mode normalizes each column to 100% (the + * natural marimekko mode); absolute mode shares one value axis so column + * heights differ. Entities lacking any `y` value are grouped into a right-edge + * no-data area when `showNoDataArea`, otherwise excluded and reported. Text + * nodes never rotate, so entity labels sit horizontally beneath the columns, + * truncated to the column width and decluttered widest-first. + */ + +import { assignColours, createColourState } from "../../color/categoricalAssigner.ts" +import { resolveValue } from "../../data/derived.ts" +import { formatValue } from "../../format/number.ts" +import { formatTime } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint, TooltipRow } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { Diagnostic, ResolvedValue, SortConfig } from "../../types.ts" +import { horizontalValueAxisNodes, prepareValueAxis, verticalValueAxisNodes, PLOT_TOP_PAD, TICK_PADDING } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { createValueScale } from "../scales.ts" +import { stackSeriesInBothDirections, type StackedSeries } from "../stacking.ts" +import { + buildFooters, + collectFooterFlags, + compareStrings, + emptyLayer, + missingRow, + noteFooterFlags, + noticeFor, + seriesLabelFont, + strings, + textNode, + tickFont, + tooltipValueText, + metaFor, + RELATIVE_META, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +const MIN_COL_WIDTH = 4 +const COL_GAP = 2 +const LABEL_CAP = 20 +/** Columns narrower than this get no entity label (nothing legible fits). */ +const MIN_LABEL_WIDTH = 22 + +interface Cell { + slug: string + /** Absolute (pre-relative) resolved point; undefined when missing. */ + point?: SeriesPoint + /** Display value (share within the column in relative mode); 0 when missing. */ + value: number + /** Absolute pre-relative value (for share-of-column tooltip text). */ + absValue: number + missing: boolean +} + +interface Column { + entity: string + cells: Cell[] + /** Net display total (100 in relative mode). */ + total: number + /** Absolute pre-relative total (drives share-of-column and absolute height). */ + absTotal: number + /** Column-width metric value; undefined when `x` is unbound. */ + xValue?: number + /** Layout weight: the x value (clamped ≥ 0) or 1 when equal-width. */ + weight: number + partial: boolean + /** Pixel geometry, assigned during layout. */ + left: number + pixelWidth: number +} + +function toPoint(resolved: ResolvedValue, time: number | null): SeriesPoint | undefined { + if (resolved.status !== "value" || !Number.isFinite(resolved.value)) return undefined + return { + time, + value: resolved.value, + sourceTime: resolved.sourceTime, + ...(resolved.projected ? { projected: true } : {}), + ...(resolved.interpolated ? { interpolated: true } : {}), + } +} + +/** + * Split `available` px across `weights`, proportional to weight, but never + * below `minWidth`. Columns that fall below the floor are pinned to it and the + * rest re-share the remainder (iterated to a fixed point). + */ +function distributeWidths(weights: readonly number[], available: number, minWidth: number): number[] { + const n = weights.length + const widths = new Array(n).fill(0) + if (n === 0 || available <= 0) return widths + if (n * minWidth >= available) return widths.map(() => available / n) + + const fixed = new Array(n).fill(false) + for (;;) { + const usedByFixed = widths.reduce((sum, w, i) => (fixed[i] ? sum + w : sum), 0) + const remaining = available - usedByFixed + const freeWeight = weights.reduce((sum, w, i) => (fixed[i] ? sum : sum + Math.max(0, w)), 0) + const freeCount = fixed.reduce((sum, f) => (f ? sum : sum + 1), 0) + let clampedAny = false + for (let i = 0; i < n; i++) { + if (fixed[i]) continue + const w = freeWeight > 0 ? (Math.max(0, weights[i]) / freeWeight) * remaining : remaining / freeCount + if (w < minWidth) { + widths[i] = minWidth + fixed[i] = true + clampedAny = true + } + } + if (!clampedAny) { + for (let i = 0; i < n; i++) { + if (fixed[i]) continue + widths[i] = freeWeight > 0 ? (Math.max(0, weights[i]) / freeWeight) * remaining : remaining / freeCount + } + return widths + } + } +} + +export function layoutMarimekko(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const relative = ctx.stackMode === "relative" + const target = ctx.window?.end ?? null + const t = strings(locale) + + const diagnostics: Diagnostic[] = [] + const slugs = ctx.definition.y.filter((slug) => ctx.dataset.columns.has(slug)) + if (slugs.length === 0) return emptyLayer(area, diagnostics) + + const xSlug = ctx.definition.x + const xBound = xSlug !== undefined && ctx.dataset.columns.has(xSlug) + + // Metric → colour (column colour fixed, rest from the palette). + const fixed = new Map() + for (const slug of slugs) { + const colour = ctx.columns[slug]?.colour + if (colour !== undefined) fixed.set(slug, colour) + } + const colours = assignColours(createColourState(ctx.theme.palette.categorical), slugs, fixed) + const labelOf = (slug: string): string => ctx.columns[slug]?.name ?? slug + + // --- Resolve every entity's cells + width ----------------------------------- + const columns: Column[] = [] + const noDataEntities: string[] = [] + for (const entity of ctx.entities) { + const cells: Cell[] = slugs.map((slug) => { + const resolved = resolveValue(ctx.dataset, slug, entity, target, ctx.definition.bindings?.[slug]) + const point = toPoint(resolved, target) + return { + slug, + value: point?.value ?? 0, + absValue: point?.value ?? 0, + missing: point === undefined, + ...(point !== undefined ? { point } : {}), + } + }) + const presentCells = cells.filter((cell) => !cell.missing) + + let xValue: number | undefined + if (xBound && xSlug !== undefined) { + const resolvedX = resolveValue(ctx.dataset, xSlug, entity, target, ctx.definition.bindings?.[xSlug]) + if (resolvedX.status === "value" && Number.isFinite(resolvedX.value)) xValue = resolvedX.value + } + + if (presentCells.length === 0) { + // No composition to draw: group into the no-data area or drop it. + noDataEntities.push(entity) + continue + } + if (xBound && xValue === undefined) { + diagnostics.push({ + severity: "warning", + code: "entity-missing-width", + message: `Entity "${entity}" excluded: the width metric is missing at the target time`, + context: { entity }, + }) + continue + } + + const absTotal = presentCells.reduce((sum, cell) => sum + cell.value, 0) + columns.push({ + entity, + cells, + total: absTotal, + absTotal, + weight: xBound ? Math.max(0, xValue ?? 0) : 1, + partial: presentCells.length < cells.length, + left: 0, + pixelWidth: 0, + ...(xValue !== undefined ? { xValue } : {}), + }) + } + + const showNoData = ctx.definition.showNoDataArea === true && noDataEntities.length > 0 + if (!showNoData && noDataEntities.length > 0) { + diagnostics.push({ + severity: "warning", + code: "entities-excluded-no-data", + message: `${noDataEntities.length} entit${noDataEntities.length === 1 ? "y" : "ies"} excluded: no data for the selected metrics at the target time`, + context: { entities: noDataEntities.join(", ") }, + }) + } + if (showNoData) { + diagnostics.push({ + severity: "warning", + code: "no-data-area", + message: `${noDataEntities.length} entit${noDataEntities.length === 1 ? "y" : "ies"} grouped into the no-data area`, + context: { entities: noDataEntities.join(", ") }, + }) + } + + if (columns.length === 0) return emptyLayer(area, diagnostics) + + // Relative mode: each column normalizes by its own absolute total (spec 19). + if (relative) { + for (const col of columns) { + const absSum = col.cells.reduce((sum, cell) => sum + Math.abs(cell.value), 0) + for (const cell of col.cells) cell.value = absSum > 0 ? (cell.value / absSum) * 100 : 0 + col.total = col.cells.reduce((sum, cell) => sum + (cell.missing ? 0 : cell.value), 0) + } + } + + // --- Sort (default: width descending; also by name / by y-total) ------------- + const sort: SortConfig | undefined = ctx.definition.sort + const direction = (sort?.order ?? "desc") === "asc" ? 1 : -1 + const widthKey = (col: Column): number => (xBound ? (col.xValue ?? 0) : col.absTotal) + switch (sort?.by) { + case "name": + columns.sort((a, b) => direction * compareStrings(a.entity, b.entity)) + break + case "column": { + const slug = sort.column ?? slugs[0] + const valueOf = (col: Column): number => { + const cell = col.cells.find((c) => c.slug === slug) + return cell !== undefined && !cell.missing ? cell.value : Number.NEGATIVE_INFINITY + } + columns.sort((a, b) => direction * (valueOf(a) - valueOf(b))) + break + } + case "total": + case "change": + columns.sort((a, b) => direction * (a.absTotal - b.absTotal)) + break + case "custom": + break + default: + // No explicit sort: order by column width (spec 19 default). + columns.sort((a, b) => direction * (widthKey(a) - widthKey(b))) + break + } + + // --- Per-column stack offsets (metric order, both-directions) ---------------- + const stackedInput: StackedSeries[] = slugs.map((slug) => ({ + seriesKey: slug, + points: columns.map((col, index) => { + const cell = col.cells.find((c) => c.slug === slug) + return { + position: index, + time: target ?? 0, + value: cell !== undefined && !cell.missing ? cell.value : 0, + valueOffset: 0, + missing: cell === undefined || cell.missing, + } + }), + })) + const stacked = stackSeriesInBothDirections(stackedInput) + + // --- Value (height) axis + plot geometry ------------------------------------- + const axisFont = tickFont(scale) + const labelFont = seriesLabelFont(scale) + const extents = stacked.flatMap((s) => s.points.map((p) => p.value + p.valueOffset)) + const spec = prepareValueAxis({ + values: relative ? [0, 100] : [0, ...extents], + markType: "bar", + scaleType: "linear", + config: ctx.definition.yAxis, + pixelLength: Math.max(10, area.height), + font: axisFont, + meta: relative ? RELATIVE_META : metaFor(ctx, slugs[0]), + locale, + measurer, + }) + diagnostics.push(...spec.diagnostics) + + const hideTickLabels = ctx.definition.yAxis?.hideTickLabels === true + const yAxisWidth = hideTickLabels ? 0 : spec.maxLabelWidth + TICK_PADDING + + const labelSample = measurer.measure("Ag", labelFont) + const labelBandHeight = labelSample.ascent + labelSample.descent + PLOT_TOP_PAD + const axisSample = measurer.measure("0", axisFont) + const widthAxisHeight = xBound ? axisSample.ascent + axisSample.descent + PLOT_TOP_PAD + 2 : 0 + + const plotArea: Rect = { + x: area.x + yAxisWidth, + y: area.y + PLOT_TOP_PAD, + width: Math.max(10, area.width - yAxisWidth), + height: Math.max(10, area.height - PLOT_TOP_PAD - labelBandHeight - widthAxisHeight), + } + + const yScale = createValueScale("linear", spec.domain, [plotArea.y + plotArea.height, plotArea.y]) + const nodes: SceneNode[] = verticalValueAxisNodes(spec, yScale, plotArea, Math.max(0, area.y), { + theme, + font: axisFont, + hideGridlines: ctx.definition.yAxis?.hideGridlines, + hideTickLabels, + }) + + // --- Column pixel widths ------------------------------------------------------ + const nColumns = columns.length + const noDataWidth = showNoData ? Math.min(Math.max(plotArea.width * 0.1, MIN_COL_WIDTH), 60) : 0 + const gapCount = nColumns - 1 + (showNoData ? 1 : 0) + const availableForColumns = Math.max(MIN_COL_WIDTH * nColumns, plotArea.width - noDataWidth - gapCount * COL_GAP) + const widths = distributeWidths( + columns.map((col) => col.weight), + availableForColumns, + MIN_COL_WIDTH, + ) + let cursor = plotArea.x + columns.forEach((col, index) => { + col.left = cursor + col.pixelWidth = widths[index] + cursor += col.pixelWidth + COL_GAP + }) + + // --- Cumulative width axis (x-unit ticks), only when width-bound ------------- + if (xBound && xSlug !== undefined) { + const totalX = columns.reduce((sum, col) => sum + Math.max(0, col.xValue ?? 0), 0) + const widthSpec = prepareValueAxis({ + values: [0, totalX], + markType: "bar", + scaleType: "linear", + pixelLength: plotArea.width, + font: axisFont, + meta: metaFor(ctx, xSlug), + locale, + measurer, + }) + const widthScale = createValueScale("linear", widthSpec.domain, [plotArea.x, plotArea.x + plotArea.width]) + const widthAxisPlot: Rect = { + x: plotArea.x, + y: plotArea.y, + width: plotArea.width, + height: plotArea.height + labelBandHeight, + } + nodes.push( + ...horizontalValueAxisNodes(widthSpec, widthScale, widthAxisPlot, area, { + theme, + font: axisFont, + hideGridlines: true, + }), + ) + } + + // --- Segments + hover --------------------------------------------------------- + const targets: HitTarget[] = [] + + const tooltipRowsFor = (col: Column, emphasizedSlug: string): TooltipRow[] => + col.cells.map((cell) => { + if (cell.missing || cell.point === undefined) { + return missingRow(cell.slug, labelOf(cell.slug), colours.get(cell.slug) ?? theme.palette.noData, locale) + } + const absText = tooltipValueText(ctx, cell.slug, cell.absValue, false) + const share = col.absTotal !== 0 ? (cell.absValue / col.absTotal) * 100 : 0 + const shareText = formatValue(share, RELATIVE_META, { locale, verbosity: "long" }) + const notice = noticeFor(cell.point, target) + return { + seriesKey: cell.slug, + label: labelOf(cell.slug), + swatch: colours.get(cell.slug) ?? theme.palette.noData, + valueText: `${absText} (${shareText})`, + emphasized: cell.slug === emphasizedSlug, + ...(notice !== undefined ? { notice } : {}), + } + }) + + columns.forEach((col, colIndex) => { + for (const series of stacked) { + const point = series.points[colIndex] + if (point.missing === true || point.value === 0) continue + const y1 = yScale.place(point.valueOffset) + const y2 = yScale.place(point.value + point.valueOffset) + const cell = col.cells.find((c) => c.slug === series.seriesKey) + const segmentRect: Rect = { + x: col.left, + y: Math.min(y1, y2), + width: Math.max(col.pixelWidth, 0.5), + height: Math.max(Math.abs(y2 - y1), 0.5), + } + nodes.push({ + key: `series/${col.entity}/${series.seriesKey}/seg`, + seriesKey: series.seriesKey, + role: "mark", + kind: "rect", + rect: segmentRect, + style: { + fill: colours.get(series.seriesKey) ?? theme.palette.noData, + ...(cell?.point?.projected === true ? { patternId: "projection", opacity: 0.85 } : {}), + }, + }) + + // Hover: one target per segment, entity-titled. + const flags = collectFooterFlags() + for (const c of col.cells) noteFooterFlags(flags, c.point, target) + const widthSubtitle = + xBound && xSlug !== undefined && col.xValue !== undefined + ? `${labelOf(xSlug)}: ${tooltipValueText(ctx, xSlug, col.xValue, false)}` + : undefined + targets.push({ + kind: "series", + seriesKey: series.seriesKey, + shape: segmentRect, + tooltip: { + title: col.entity, + ...(target !== null ? { titleAnnotation: formatTime(target, grain, locale) } : {}), + ...(widthSubtitle !== undefined ? { subtitle: widthSubtitle } : {}), + rows: tooltipRowsFor(col, series.seriesKey), + footers: buildFooters(flags, grain, locale), + }, + }) + } + }) + + // --- No-data area at the right edge ------------------------------------------ + if (showNoData) { + const noDataLeft = cursor + const areaRect: Rect = { + x: noDataLeft, + y: plotArea.y, + width: Math.max(noDataWidth, 0.5), + height: plotArea.height, + } + nodes.push({ + key: "nodata/area", + role: "mark", + kind: "rect", + rect: areaRect, + style: { fill: theme.palette.noData, opacity: 0.5 }, + }) + const noDataLabel = truncateWithEllipsis(t.noData, labelFont, Math.max(10, noDataWidth), measurer) + if (noDataLabel !== "" && noDataLabel !== "…") { + const metrics = measurer.measure(noDataLabel, labelFont) + nodes.push( + textNode({ + key: "nodata/label", + role: "label", + text: noDataLabel, + font: labelFont, + anchor: "middle", + x: noDataLeft + noDataWidth / 2, + baselineY: plotArea.y + plotArea.height + PLOT_TOP_PAD + metrics.ascent, + colour: theme.chrome.tickLabel, + measurer, + }), + ) + } + targets.push({ + kind: "series", + seriesKey: "nodata", + shape: areaRect, + tooltip: { + title: t.noData, + rows: noDataEntities.map((entity) => ({ + seriesKey: entity, + label: entity, + swatch: theme.palette.noData, + valueText: t.noData, + emphasized: false, + notice: "missing" as const, + })), + footers: [], + }, + }) + } + + // --- Entity labels beneath columns, decluttered widest-first ----------------- + const labelBaseline = plotArea.y + plotArea.height + PLOT_TOP_PAD + labelSample.ascent + const placed: { left: number; right: number }[] = [] + const byPriority = columns.map((col, index) => ({ col, index })).sort((a, b) => b.col.pixelWidth - a.col.pixelWidth) + for (const { col } of byPriority) { + if (placed.length >= LABEL_CAP) break + if (col.pixelWidth < MIN_LABEL_WIDTH) continue + const text = truncateWithEllipsis(col.entity, labelFont, col.pixelWidth, measurer) + if (text === "" || text === "…") continue + const metrics = measurer.measure(text, labelFont) + const centerX = col.left + col.pixelWidth / 2 + const left = centerX - metrics.width / 2 + const right = centerX + metrics.width / 2 + const collides = placed.some((p) => left - 2 < p.right && right + 2 > p.left) + if (collides) continue + placed.push({ left, right }) + nodes.push( + textNode({ + key: `label/${col.entity}`, + role: "label", + text, + font: labelFont, + anchor: "middle", + x: centerX, + baselineY: labelBaseline, + colour: theme.chrome.tickLabel, + measurer, + }), + ) + } + + // --- Series models (one per metric, points in column order) ------------------ + const zeroThroughout = new Set() + const outSeries: SeriesModel[] = stacked.map((series) => { + const anyNonZero = series.points.some((p) => p.missing !== true && p.value !== 0) + if (!anyNonZero) zeroThroughout.add(series.seriesKey) + return { + key: series.seriesKey, + label: labelOf(series.seriesKey), + colour: colours.get(series.seriesKey) ?? theme.palette.noData, + column: series.seriesKey, + points: series.points.map((p, index) => { + const col = columns[index] + const cell = col?.cells.find((c) => c.slug === series.seriesKey) + return { + time: target, + value: p.value, + valueOffset: p.valueOffset, + ...(cell?.point?.sourceTime !== undefined ? { sourceTime: cell.point.sourceTime } : {}), + ...(cell?.point?.projected === true ? { projected: true } : {}), + } + }), + } + }) + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets }, + legendItems: slugs.map((slug) => ({ + seriesKey: slug, + label: labelOf(slug), + swatch: colours.get(slug) ?? theme.palette.noData, + })), + greyedLegendKeys: [...zeroThroughout], + needsLegendFallback: false, + empty: false, + valueDomain: spec.domain, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/charts/scatter.test.ts b/packages/charts2/src/core/layout/charts/scatter.test.ts new file mode 100644 index 00000000000..bc7ca8e4e0f --- /dev/null +++ b/packages/charts2/src/core/layout/charts/scatter.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest" + +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutScatter } from "./scatter.ts" +import type { ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } + +const ALL_PROVINCES = ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"] + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("scatter requires an x metric (spec 18)", () => { + it("returns an empty layer with an error diagnostic when x is missing", () => { + const layer = layoutScatter(ctxFor("provincial-budgets", { y: ["debt_charges"], types: ["scatter"] }), AREA, OPTS) + expect(layer.empty).toBe(true) + expect(layer.diagnostics).toContainEqual( + expect.objectContaining({ severity: "error", code: "scatter-missing-x" }), + ) + }) +}) + +describe("scatter snapshot (spec 18)", () => { + const raw = { + x: "program_spending", + y: ["debt_charges"], + types: ["scatter"], + time: "2023-24", + selectedEntities: ALL_PROVINCES, + } + + it("renders one point per entity with a matched pair at the target time", () => { + const layer = layoutScatter(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.empty).toBe(false) + const points = layer.nodes.filter((n) => n.kind === "point") + expect(points.length).toBe(5) + expect(new Set(points.map((n) => n.key))).toEqual( + new Set(ALL_PROVINCES.map((e) => `point/${e}`)), + ) + expect(layer.series.length).toBe(5) + expect(layer.valueDomain).toBeDefined() + }) + + it("sets a series hit target per point", () => { + const layer = layoutScatter(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.hover.targets.length).toBe(5) + expect(layer.hover.targets.every((t) => t.kind === "series")).toBe(true) + }) +}) + +describe("scatter excludes entities without a matched pair (spec 18)", () => { + it("drops entities missing x or y and reports each via a diagnostic", () => { + // Quebec 2024-25 has no program_spending (x) and no debt_charges; + // debt_charges borrows via tolerance but program_spending does not. + const layer = layoutScatter( + ctxFor("provincial-budgets", { + x: "program_spending", + y: ["debt_charges"], + types: ["scatter"], + time: "2024-25", + selectedEntities: ALL_PROVINCES, + }), + AREA, + OPTS, + ) + expect(layer.series.map((s) => s.entity)).not.toContain("Quebec") + expect(layer.diagnostics).toContainEqual( + expect.objectContaining({ code: "scatter-missing-pair", context: { entity: "Quebec" } }), + ) + }) +}) + +describe("scatter size scaling (spec 18)", () => { + it("maps the min size value to the min radius and the max to the max radius", () => { + const layer = layoutScatter( + ctxFor("provincial-budgets", { + x: "program_spending", + y: ["debt_charges"], + sizeMetric: "total_spending", + types: ["scatter"], + time: "2023-24", + selectedEntities: ALL_PROVINCES, + }), + AREA, + OPTS, + ) + // total_spending 2023-24: Ontario 204.3 (max), Nova Scotia 15.4 (min). + const ontario = layer.nodes.find((n) => n.key === "point/Ontario") + const novaScotia = layer.nodes.find((n) => n.key === "point/Nova Scotia") + expect(ontario?.kind).toBe("point") + expect(novaScotia?.kind).toBe("point") + if (ontario?.kind !== "point" || novaScotia?.kind !== "point") return + // fontScale 1 → minRadius 3, maxRadius 18. + expect(ontario.radius).toBeCloseTo(18, 5) + expect(novaScotia.radius).toBeCloseTo(3, 5) + // Every other point sits between the extremes. + for (const node of layer.nodes) { + if (node.kind === "point") { + expect(node.radius).toBeGreaterThanOrEqual(3 - 1e-6) + expect(node.radius).toBeLessThanOrEqual(18 + 1e-6) + } + } + }) +}) + +describe("scatter log axis (spec 18)", () => { + it("excludes non-positive values on a log axis and reports a diagnostic", () => { + // pathological "negatives" is all-negative; a log y-axis excludes them. + const layer = layoutScatter( + ctxFor("pathological", { + x: "spending", + y: ["negatives"], + types: ["scatter"], + time: 2021, + selectedEntities: ["Québec", "Lonely Station"], + yAxis: { scale: "log" }, + }), + AREA, + OPTS, + ) + expect(layer.diagnostics).toContainEqual( + expect.objectContaining({ code: "scatter-log-excluded", context: expect.objectContaining({ axis: "y" }) }), + ) + expect(layer.empty).toBe(true) + }) +}) + +describe("scatter tooltip (spec 18)", () => { + it("includes formatted x and y values", () => { + const layer = layoutScatter( + ctxFor("provincial-budgets", { + x: "program_spending", + y: ["debt_charges"], + types: ["scatter"], + time: "2023-24", + selectedEntities: ["Ontario"], + }), + AREA, + OPTS, + ) + const target = layer.hover.targets[0] + expect(target.kind).toBe("series") + if (target.kind !== "series") return + expect(target.tooltip.title).toBe("Ontario") + const labels = target.tooltip.rows.map((r) => r.label) + expect(labels).toContain("Program spending") + expect(labels).toContain("Debt charges") + for (const row of target.tooltip.rows) { + expect(row.valueText).toMatch(/\d/) + } + }) +}) diff --git a/packages/charts2/src/core/layout/charts/scatter.ts b/packages/charts2/src/core/layout/charts/scatter.ts new file mode 100644 index 00000000000..4bf3686eeb6 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/scatter.ts @@ -0,0 +1,411 @@ +/** + * Scatter chart layout (spec 18). + * + * Snapshot mode (MVP): one point per entity at the target time, plotted on two + * independent value axes (x from `definition.x`, y from `definition.y[0]`). + * Optional point-size scaling (sqrt) from `sizeMetric` and categorical colour + * binning from `colourMetric`. Entity labels are placed for a capped, priority + * ordered subset with a simple bounding-box declutter. Trails (connected + * scatter over a time range) are NOT implemented — a range selection renders as + * a snapshot at the window end and emits a diagnostic. + */ + +import { resolveValue } from "../../data/derived.ts" +import { formatTime } from "../../format/timeLabels.ts" +import { assignColours, createColourState } from "../../color/categoricalAssigner.ts" +import type { HitTarget, LegendItem, Rect, SceneNode, SeriesModel, SeriesPoint, TooltipRow } from "../../scene/nodes.ts" +import type { Diagnostic, HexColour, ResolvedValue, TimeOrdinal } from "../../types.ts" +import { horizontalValueAxisNodes, prepareValueAxis, verticalValueAxisNodes, PLOT_TOP_PAD, TICK_PADDING } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { createValueScale } from "../scales.ts" +import { + buildFooters, + centeredBaseline, + collectFooterFlags, + emptyLayer, + metaFor, + metricSubtitle, + noteFooterFlags, + noticeFor, + seriesLabelFont, + textNode, + tickFont, + tooltipValueText, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +const LABEL_GAP = 4 +const LABEL_CAP = 20 + +interface ScatterPoint { + entity: string + xValue: number + yValue: number + x: ResolvedValue & { status: "value" } + y: ResolvedValue & { status: "value" } + sizeValue?: number + colourValue?: number +} + +function metricName(ctx: LayoutContext, slug: string): string { + return ctx.columns[slug]?.name ?? slug +} + +function toSeriesPoint(resolved: ResolvedValue & { status: "value" }, time: TimeOrdinal | null): SeriesPoint { + return { + time, + value: resolved.value, + ...(resolved.sourceTime !== undefined ? { sourceTime: resolved.sourceTime } : {}), + ...(resolved.projected === true ? { projected: true } : {}), + ...(resolved.interpolated === true ? { interpolated: true } : {}), + } +} + +function boxesOverlap(a: Rect, b: Rect): boolean { + return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y +} + +export function layoutScatter(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + const diagnostics: Diagnostic[] = [] + + // --- x metric is required ------------------------------------------------ + const xSlug = ctx.definition.x + if (xSlug === undefined) { + return emptyLayer(area, [ + { severity: "error", code: "scatter-missing-x", message: "Scatter charts require an x metric" }, + ]) + } + const ySlug = ctx.definition.y[0] + const sizeSlug = ctx.definition.sizeMetric + const colourSlug = ctx.definition.colourMetric + + // Snapshot at the window end (null for grain "none"). Trails deferred. + const target: TimeOrdinal | null = grain === "none" ? null : (ctx.window?.end ?? null) + if (grain !== "none" && ctx.window !== null && ctx.window.start !== ctx.window.end) { + diagnostics.push({ + severity: "warning", + code: "scatter-range-snapshot", + message: "Scatter renders the selected range as a snapshot at the window end (trails not implemented)", + }) + } + + // --- Resolve one (x, y) pair per entity ---------------------------------- + const resolved: ScatterPoint[] = [] + for (const entity of ctx.entities) { + const x = resolveValue(ctx.dataset, xSlug, entity, target, ctx.definition.bindings?.[xSlug]) + const y = resolveValue(ctx.dataset, ySlug, entity, target, ctx.definition.bindings?.[ySlug]) + if (x.status !== "value" || y.status !== "value") { + diagnostics.push({ + severity: "warning", + code: "scatter-missing-pair", + message: `${entity} is missing an x or y value and was excluded`, + context: { entity }, + }) + continue + } + const point: ScatterPoint = { entity, xValue: x.value, yValue: y.value, x, y } + if (sizeSlug !== undefined) { + const s = resolveValue(ctx.dataset, sizeSlug, entity, target, ctx.definition.bindings?.[sizeSlug]) + if (s.status === "value") point.sizeValue = s.value + } + if (colourSlug !== undefined) { + const c = resolveValue(ctx.dataset, colourSlug, entity, target, ctx.definition.bindings?.[colourSlug]) + if (c.status === "value") point.colourValue = c.value + } + resolved.push(point) + } + + // --- Log axes drop non-positive values on that axis (reported) ----------- + const xScaleType = ctx.definition.xAxis?.scale ?? "linear" + const yScaleType = ctx.scaleType + const renderable: ScatterPoint[] = [] + let xExcluded = 0 + let yExcluded = 0 + for (const point of resolved) { + if (xScaleType === "log" && point.xValue <= 0) { + xExcluded++ + continue + } + if (yScaleType === "log" && point.yValue <= 0) { + yExcluded++ + continue + } + renderable.push(point) + } + if (xExcluded > 0) { + diagnostics.push({ + severity: "warning", + code: "scatter-log-excluded", + message: `${xExcluded} non-positive x value${xExcluded === 1 ? "" : "s"} excluded from the log axis`, + context: { axis: "x", count: xExcluded }, + }) + } + if (yExcluded > 0) { + diagnostics.push({ + severity: "warning", + code: "scatter-log-excluded", + message: `${yExcluded} non-positive y value${yExcluded === 1 ? "" : "s"} excluded from the log axis`, + context: { axis: "y", count: yExcluded }, + }) + } + + if (renderable.length === 0) return emptyLayer(area, diagnostics) + + // --- Axes (both value axes) ---------------------------------------------- + const yFont = tickFont(scale) + const xFont = tickFont(scale) + const sample = measurer.measure("0", xFont) + const xAxisHeight = sample.ascent + sample.descent + PLOT_TOP_PAD + 2 + const provHeight = Math.max(10, area.height - xAxisHeight - PLOT_TOP_PAD) + + const ySpec = prepareValueAxis({ + values: renderable.map((p) => p.yValue), + markType: "line", + scaleType: yScaleType, + config: ctx.definition.yAxis, + pixelLength: provHeight, + font: yFont, + meta: metaFor(ctx, ySlug), + locale, + measurer, + }) + diagnostics.push(...ySpec.diagnostics) + const yAxisWidth = ctx.definition.yAxis?.hideTickLabels === true ? 0 : ySpec.maxLabelWidth + TICK_PADDING + + const plotArea: Rect = { + x: area.x + yAxisWidth, + y: area.y + PLOT_TOP_PAD, + width: Math.max(10, area.width - yAxisWidth), + height: Math.max(10, area.height - PLOT_TOP_PAD - xAxisHeight), + } + + const xSpec = prepareValueAxis({ + values: renderable.map((p) => p.xValue), + markType: "line", + scaleType: xScaleType, + config: ctx.definition.xAxis, + pixelLength: plotArea.width, + font: xFont, + meta: metaFor(ctx, xSlug), + locale, + measurer, + }) + diagnostics.push(...xSpec.diagnostics) + + const yScale = createValueScale(yScaleType, ySpec.domain, [plotArea.y + plotArea.height, plotArea.y]) + const xScale = createValueScale(xScaleType, xSpec.domain, [plotArea.x, plotArea.x + plotArea.width]) + + const nodes: SceneNode[] = [ + ...verticalValueAxisNodes(ySpec, yScale, plotArea, Math.max(0, area.y - PLOT_TOP_PAD), { + theme, + font: yFont, + hideGridlines: ctx.definition.yAxis?.hideGridlines, + hideTickLabels: ctx.definition.yAxis?.hideTickLabels, + }), + ...horizontalValueAxisNodes(xSpec, xScale, plotArea, area, { + theme, + font: xFont, + hideGridlines: ctx.definition.xAxis?.hideGridlines, + hideTickLabels: ctx.definition.xAxis?.hideTickLabels, + }), + ] + + // --- Colour: categorical bins, else theme primary ------------------------ + const primary: HexColour = theme.palette.categorical[0] + const colourByEntity = new Map() + const legendItems: LegendItem[] = [] + if (colourSlug !== undefined) { + const colType = ctx.columns[colourSlug]?.type + if (colType === "categorical" || colType === "ordinal") { + const distinct = [...new Set(renderable.map((p) => p.colourValue).filter((v): v is number => v !== undefined))].sort( + (a, b) => a - b, + ) + const binKeys = distinct.map((v) => String(v)) + const state = createColourState(theme.palette.categorical) + const assigned = assignColours(state, binKeys) + const binColour = new Map() + distinct.forEach((v, i) => { + const colour = assigned.get(binKeys[i]) ?? primary + binColour.set(v, colour) + legendItems.push({ + seriesKey: binKeys[i], + label: tooltipValueText(ctx, colourSlug, v, false), + swatch: colour, + }) + }) + for (const point of renderable) { + colourByEntity.set(point.entity, point.colourValue !== undefined ? (binColour.get(point.colourValue) ?? primary) : primary) + } + } else { + diagnostics.push({ + severity: "warning", + code: "scatter-numeric-colour-unsupported", + message: "Numeric colour metric is rendered with the theme primary colour (continuous ramp not implemented)", + }) + } + } + const colourOf = (entity: string): HexColour => colourByEntity.get(entity) ?? primary + + // --- Point radius: uniform, or sqrt scaling over the size domain --------- + const uniformRadius = 5 * scale + const minRadius = 3 * scale + const maxRadius = 18 * scale + let radiusOf = (_point: ScatterPoint): number => uniformRadius + if (sizeSlug !== undefined) { + const sizeValues = renderable.map((p) => p.sizeValue).filter((v): v is number => v !== undefined) + if (sizeValues.length > 0) { + const sMin = Math.min(...sizeValues) + const sMax = Math.max(...sizeValues) + radiusOf = (point: ScatterPoint): number => { + if (point.sizeValue === undefined) return minRadius + if (sMax === sMin) return uniformRadius + const t = (point.sizeValue - sMin) / (sMax - sMin) + // Area-proportional (sqrt) interpolation: t=0 → minRadius, t=1 → maxRadius. + return Math.sqrt(minRadius * minRadius + t * (maxRadius * maxRadius - minRadius * minRadius)) + } + } + } + + // --- Marks, hover, series ------------------------------------------------ + const outSeries: SeriesModel[] = [] + const targets: HitTarget[] = [] + const xName = metricName(ctx, xSlug) + const yName = metricName(ctx, ySlug) + const subtitle = metricSubtitle(ctx, ySlug) + + for (const point of renderable) { + const cx = xScale.place(point.xValue) + const cy = yScale.place(point.yValue) + const radius = radiusOf(point) + const colour = colourOf(point.entity) + const projected = point.x.projected === true || point.y.projected === true + nodes.push({ + key: `point/${point.entity}`, + seriesKey: point.entity, + role: "mark", + kind: "point", + center: { x: cx, y: cy }, + radius, + style: { fill: colour, ...(projected ? { opacity: 0.85 } : {}) }, + }) + + // Hover: small box around the point. + const flags = collectFooterFlags() + noteFooterFlags(flags, toSeriesPoint(point.x, target), target) + noteFooterFlags(flags, toSeriesPoint(point.y, target), target) + const xNotice = noticeFor(point.x, target) + const yNotice = noticeFor(point.y, target) + const rows: TooltipRow[] = [ + { + seriesKey: point.entity, + label: xName, + swatch: colour, + valueText: tooltipValueText(ctx, xSlug, point.xValue, false), + emphasized: true, + ...(xNotice !== undefined ? { notice: xNotice } : {}), + }, + { + seriesKey: point.entity, + label: yName, + swatch: colour, + valueText: tooltipValueText(ctx, ySlug, point.yValue, false), + emphasized: true, + ...(yNotice !== undefined ? { notice: yNotice } : {}), + }, + ] + if (sizeSlug !== undefined && point.sizeValue !== undefined) { + rows.push({ + seriesKey: point.entity, + label: metricName(ctx, sizeSlug), + swatch: colour, + valueText: tooltipValueText(ctx, sizeSlug, point.sizeValue, false), + emphasized: false, + }) + } + if (colourSlug !== undefined && point.colourValue !== undefined) { + rows.push({ + seriesKey: point.entity, + label: metricName(ctx, colourSlug), + swatch: colour, + valueText: tooltipValueText(ctx, colourSlug, point.colourValue, false), + emphasized: false, + }) + } + const hit = Math.max(radius, 6) + targets.push({ + kind: "series", + seriesKey: point.entity, + shape: { x: cx - hit, y: cy - hit, width: hit * 2, height: hit * 2 }, + tooltip: { + title: point.entity, + ...(target !== null ? { titleAnnotation: formatTime(target, grain, locale) } : {}), + ...(subtitle !== undefined ? { subtitle } : {}), + rows, + footers: buildFooters(flags, grain, locale), + }, + }) + + outSeries.push({ + key: point.entity, + label: point.entity, + colour, + entity: point.entity, + points: [toSeriesPoint(point.y, target)], + }) + } + + // --- Entity labels: priority (focus > size), capped, decluttered --------- + const labelFont = seriesLabelFont(scale) + const focusSet = new Set(ctx.definition.focusedSeries ?? []) + const ranked = [...renderable].sort((a, b) => { + const fa = focusSet.has(a.entity) ? 1 : 0 + const fb = focusSet.has(b.entity) ? 1 : 0 + if (fa !== fb) return fb - fa + const sa = a.sizeValue ?? a.yValue + const sb = b.sizeValue ?? b.yValue + return sb - sa + }) + const placed: Rect[] = [] + for (const point of ranked) { + if (placed.length >= LABEL_CAP) break + const metrics = measurer.measure(point.entity, labelFont) + const radius = radiusOf(point) + const cx = xScale.place(point.xValue) + const cy = yScale.place(point.yValue) + const labelX = cx + radius + LABEL_GAP + const height = metrics.ascent + metrics.descent + const box: Rect = { x: labelX, y: cy - height / 2, width: metrics.width, height } + if (placed.some((other) => boxesOverlap(other, box))) continue + placed.push(box) + nodes.push( + textNode({ + key: `label/${point.entity}`, + role: "label", + text: point.entity, + font: labelFont, + anchor: "start", + x: labelX, + baselineY: centeredBaseline(cy, metrics), + colour: theme.chrome.tickLabel, + measurer, + seriesKey: point.entity, + }), + ) + } + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets }, + legendItems, + greyedLegendKeys: [], + needsLegendFallback: legendItems.length > 0 && !opts.legendReserved, + empty: false, + valueDomain: ySpec.domain, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/charts/shared.ts b/packages/charts2/src/core/layout/charts/shared.ts index c0b0024d231..e6ba1bb69c7 100644 --- a/packages/charts2/src/core/layout/charts/shared.ts +++ b/packages/charts2/src/core/layout/charts/shared.ts @@ -82,8 +82,8 @@ function font(family: FontSpec["family"], sizePx: number, weight: FontSpec["weig return { family, sizePx: round2(sizePx), weight } } -export const titleFont = (scale: number): FontSpec => font("heading", 20 * scale, 700) -export const subtitleFont = (scale: number): FontSpec => font("body", 13 * scale, 400) +export const titleFont = (scale: number): FontSpec => font("heading", 24 * scale, 700) +export const subtitleFont = (scale: number): FontSpec => font("body", 15 * scale, 400) export const tickFont = (scale: number): FontSpec => font("body", 12 * scale, 400) export const seriesLabelFont = (scale: number): FontSpec => font("body", 12 * scale, 400) export const valueLabelFont = (scale: number): FontSpec => font("body", 12 * scale, 400) diff --git a/packages/charts2/src/core/layout/charts/slope.test.ts b/packages/charts2/src/core/layout/charts/slope.test.ts new file mode 100644 index 00000000000..6fa5c51f1e7 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/slope.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest" + +import { buildDataset } from "../../data/dataset.ts" +import { parseManifest } from "../../data/manifest.ts" +import { parseCsv } from "../../data/parse.ts" +import { loadFixtureDataset, type FixtureName } from "../../../fixtures/index.ts" +import { parseDefinition } from "../../definition/schema.ts" +import { defaultMeasurer } from "../../text/createMeasurer.ts" +import { buildCanadaTheme } from "../../theme/themes.ts" +import type { Rect } from "../../scene/nodes.ts" +import type { ChartDefinition, ViewState } from "../../types.ts" +import { buildContext, type LayoutContext } from "../context.ts" +import { layoutSlope } from "./slope.ts" +import { seriesLabelFont, type ChartLayerOptions } from "./shared.ts" + +const AREA: Rect = { x: 0, y: 0, width: 800, height: 500 } +const OPTS: ChartLayerOptions = { legendReserved: false, thumbnail: false, fontScale: 1 } + +function definitionFor(raw: Record): ChartDefinition { + const { definition } = parseDefinition({ title: "Test chart", data: "fixture", ...raw }) + if (definition === null) throw new Error("test definition failed to parse") + return definition +} + +function ctxFor(fixture: FixtureName, raw: Record, view?: ViewState): LayoutContext { + const { dataset } = loadFixtureDataset(fixture) + return buildContext({ definition: definitionFor(raw), dataset, view, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +// A synthetic dataset exercising a zero start, a flat series, a rising series, +// and a sign-crossing series — none of which the committed fixtures provide. +const SYNTH_CSV = `entity,time,metric +Zeroland,2000,0 +Zeroland,2010,50 +Flatland,2000,42 +Flatland,2010,42 +Riser,2000,10 +Riser,2010,30 +Faller,2000,20 +Faller,2010,-10 +` + +const SYNTH_MANIFEST = { + name: "synthetic-slope", + title: "Synthetic slope", + timeGrain: "year", + entity: { label: "place", labelPlural: "places" }, + columns: { metric: { name: "Metric", type: "numeric" } }, + sources: [{ name: "Synthetic test data" }], +} + +function synthCtx(raw: Record): LayoutContext { + const { manifest } = parseManifest(SYNTH_MANIFEST) + if (manifest === null) throw new Error("synthetic manifest failed to parse") + const parsed = parseCsv(SYNTH_CSV, manifest) + const { dataset } = buildDataset(manifest, parsed.rows) + return buildContext({ definition: definitionFor(raw), dataset, theme: buildCanadaTheme, measurer: defaultMeasurer }) +} + +describe("slope marks (spec 12)", () => { + const raw = { + y: ["total_spending"], + types: ["slope"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + } + + it("renders one slope line and two endpoint dots per renderable series", () => { + const layer = layoutSlope(ctxFor("provincial-budgets", raw), AREA, OPTS) + expect(layer.empty).toBe(false) + expect(layer.series.length).toBe(5) + for (const series of layer.series) expect(series.points.length).toBe(2) + + const slopeLines = layer.nodes.filter((n) => n.kind === "line" && n.key.endsWith("/slope")) + // Coloured endpoint dots only (each also has a background halo dot beneath). + const dots = layer.nodes.filter((n) => n.kind === "point" && !n.key.endsWith("-halo")) + expect(slopeLines.length).toBe(5) + expect(dots.length).toBe(10) + expect(layer.valueDomain).toBeDefined() + }) + + it("returns an empty layer when there is no time window", () => { + const layer = layoutSlope(ctxFor("population-snapshot", { y: ["population"], types: ["slope"] }), AREA, OPTS) + expect(layer.empty).toBe(true) + }) +}) + +describe("slope endpoint filtering (spec 12)", () => { + it("excludes a series missing an endpoint and emits a diagnostic", () => { + // Quebec has no program_spending in the final fiscal year (the end + // endpoint), so it drops out; Nova Scotia is missing only an interior + // year and still renders. + const layer = layoutSlope( + ctxFor("provincial-budgets", { + y: ["program_spending"], + types: ["slope"], + selectedEntities: ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"], + }), + AREA, + OPTS, + ) + const labels = layer.series.map((s) => s.label) + expect(labels).not.toContain("Quebec") + expect(labels).toContain("Nova Scotia") + expect(labels.length).toBe(4) + + const diag = layer.diagnostics.find((d) => d.code === "slope-incomplete-endpoints") + expect(diag).toBeDefined() + expect(diag?.context?.series).toBe("Quebec") + }) +}) + +describe("slope tooltip (spec 12) — OWID format", () => { + it("shows a single start→end range row with a trend arrow and a time-range subtitle", () => { + const layer = layoutSlope( + ctxFor("provincial-budgets", { y: ["total_spending"], types: ["slope"], selectedEntities: ["Ontario"] }), + AREA, + OPTS, + ) + const target = layer.hover.targets.find((t) => t.kind === "series" && t.seriesKey === "Ontario") + expect(target?.kind).toBe("series") + if (target?.kind !== "series") return + // OWID: one row (not start/end/Δ) plus a time-range subtitle. + expect(target.tooltip.rows).toHaveLength(1) + expect(target.tooltip.subtitle).toBeTruthy() + const row = target.tooltip.rows[0] + // Ontario rises → up arrow, both endpoint values present, emphasized. + expect(row.valueText).toContain("↑") + expect(row.emphasized).toBe(true) + }) + + it("uses ↑ / ↓ / → for rising, falling, and flat series", () => { + const layer = layoutSlope( + synthCtx({ y: ["metric"], types: ["slope"], selectedEntities: ["Riser", "Faller", "Flatland"] }), + AREA, + OPTS, + ) + const rangeFor = (key: string): string => { + const t = layer.hover.targets.find((x) => x.kind === "series" && x.seriesKey === key) + if (t?.kind !== "series") throw new Error(`missing target ${key}`) + return t.tooltip.rows[0].valueText + } + expect(rangeFor("Riser")).toContain("↑") + expect(rangeFor("Faller")).toContain("↓") + expect(rangeFor("Flatland")).toContain("→") + }) +}) + +describe("slope edge cases (spec 12)", () => { + it("renders a flat series as a horizontal line", () => { + const layer = layoutSlope(synthCtx({ y: ["metric"], types: ["slope"], selectedEntities: ["Flatland"] }), AREA, OPTS) + const line = layer.nodes.find((n) => n.kind === "line" && n.key === "series/Flatland/slope") + expect(line?.kind).toBe("line") + if (line?.kind !== "line") return + const [start, end] = line.segments[0] + expect(start.y).toBeCloseTo(end.y, 5) + expect(start.x).not.toBeCloseTo(end.x, 1) + }) + + it("draws a zero line when the value domain spans zero", () => { + const layer = layoutSlope( + synthCtx({ y: ["metric"], types: ["slope"], selectedEntities: ["Riser", "Faller"] }), + AREA, + OPTS, + ) + expect(layer.valueDomain?.[0]).toBeLessThan(0) + expect(layer.valueDomain?.[1]).toBeGreaterThan(0) + expect(layer.nodes.some((n) => n.key === "slope/zero-line")).toBe(true) + }) +}) + +describe("slope label collision (spec 12)", () => { + it("keeps ≥10 labels non-overlapping and adjacent to their lines", () => { + const layer = layoutSlope( + ctxFor("federal-departments", { + y: ["spending"], + types: ["slope"], + selectedEntities: [ + "National Defence", + "Employment and Social Development Canada", + "Indigenous Services Canada", + "Health Canada", + "Innovation, Science and Economic Development Canada", + "Global Affairs Canada", + "Public Safety Canada", + "Transport Canada", + "Environment and Climate Change Canada", + "Agriculture and Agri-Food Canada", + "Canada Revenue Agency", + "Fisheries and Oceans Canada", + ], + }), + AREA, + OPTS, + ) + expect(layer.series.length).toBeGreaterThanOrEqual(10) + + // Right-side (end) labels only; time-head labels are role "annotation". + const endLabels = layer.nodes.filter( + (n) => n.kind === "text" && n.role === "label" && n.key.startsWith("label/") && n.key.endsWith("/end"), + ) + expect(endLabels.length).toBe(layer.series.length) + + const font = seriesLabelFont(OPTS.fontScale) + const lineHeight = (() => { + const m = defaultMeasurer.measure("Ag", font) + return m.ascent + m.descent + })() + + const endValue = new Map(layer.series.map((s) => [s.key, s.points[1].value])) + const sorted = endLabels + .map((n) => (n.kind === "text" ? { key: n.seriesKey ?? "", y: n.position.y } : { key: "", y: 0 })) + .sort((a, b) => a.y - b.y) + + // No two labels overlap vertically. + for (let i = 1; i < sorted.length; i++) { + expect(sorted[i].y - sorted[i - 1].y).toBeGreaterThanOrEqual(lineHeight - 0.5) + } + // Adjacency: top-to-bottom label order tracks descending end value + // (larger value sits higher on the axis), so labels attribute to lines. + for (let i = 1; i < sorted.length; i++) { + const prev = endValue.get(sorted[i - 1].key) ?? Number.NEGATIVE_INFINITY + const cur = endValue.get(sorted[i].key) ?? Number.NEGATIVE_INFINITY + expect(prev).toBeGreaterThanOrEqual(cur - 1e-6) + } + }) +}) diff --git a/packages/charts2/src/core/layout/charts/slope.ts b/packages/charts2/src/core/layout/charts/slope.ts new file mode 100644 index 00000000000..19f410249f7 --- /dev/null +++ b/packages/charts2/src/core/layout/charts/slope.ts @@ -0,0 +1,427 @@ +/** + * Slope chart layout (spec 12). + * + * Compares each series' change between exactly two times (the window's two + * handles). Follows the OWID slope layout: the value axis is hidden (no + * gutter, no gridlines); two full-height column lines frame the start/end + * positions with the year labels below them; each series is a straight line + * from its start to end value with a dot at each endpoint, drawn on a + * background-coloured halo so crossing slopes stay separable. Endpoint labels + * show the value on the left and the series name (+ value) on the right, in + * wide outer margins, collision-resolved by a greedy vertical spread. The + * slope area is sized tall-and-narrow by aspect ratio rather than filling the + * width. Only series present at BOTH endpoints render; incomplete series raise + * a warning. A dashed zero line is drawn when the value domain spans zero. + */ + +import { formatTime, formatTimeRange } from "../../format/timeLabels.ts" +import type { HitTarget, Rect, SceneNode, SeriesModel, SeriesPoint, Vec2 } from "../../scene/nodes.ts" +import { truncateWithEllipsis } from "../../text/truncate.ts" +import type { AxisConfig } from "../../types.ts" +import { prepareValueAxis, PLOT_TOP_PAD } from "../axis.ts" +import type { LayoutContext } from "../context.ts" +import { createValueScale } from "../scales.ts" +import { buildSeriesModels } from "../series.ts" +import { + buildFooters, + collectFooterFlags, + emptyLayer, + labelValueText, + legendItemsFor, + metaFor, + noteFooterFlags, + noticeFor, + pointByTime, + seriesLabelFont, + textNode, + tickFont, + tooltipValueText, + type ChartLayer, + type ChartLayerOptions, +} from "./shared.ts" + +const LABEL_GAP = 4 +const LABEL_MIN_GAP = 2 +/** Trend arrows for the tooltip range (OWID calculateTrendDirection). */ +const TREND_ARROW = { up: "↑", down: "↓", flat: "→" } as const +/** OWID frames the slopes tall-and-narrow: the column span is capped to this + * fraction of the plot height, then centred, so the years sit in wide margins. */ +const IDEAL_ASPECT = 0.9 +/** Below this width, skip aspect capping and use the full available span. */ +const NARROW_WIDTH = 320 +/** Gap below the plot reserved for the two column (year) labels. */ +const TIME_LABEL_PAD = 8 + +interface SlopeRow { + series: SeriesModel + startPoint: SeriesPoint + endPoint: SeriesPoint + slug: string + startValue: number + endValue: number + leftText: string + rightText: string +} + +/** + * Greedy vertical spread: place non-overlapping label boxes as close to their + * target tops as possible, pushing later boxes down, then shift the whole set + * up if it overruns the bottom. Ordering is preserved (each label stays + * adjacent to its line). Returns tops aligned to the input order. + */ +function spreadTops(targetTops: readonly number[], heights: readonly number[], bottom: number): number[] { + const n = targetTops.length + const order = [...Array(n).keys()].sort((a, b) => targetTops[a] - targetTops[b]) + const tops = new Array(n) + let cursor = Number.NEGATIVE_INFINITY + for (const idx of order) { + const y = Math.max(targetTops[idx], cursor) + tops[idx] = y + cursor = y + heights[idx] + LABEL_MIN_GAP + } + const lastBottom = cursor - LABEL_MIN_GAP + if (lastBottom > bottom) { + const shift = lastBottom - bottom + for (let i = 0; i < n; i++) tops[i] -= shift + } + return tops +} + +export function layoutSlope(ctx: LayoutContext, area: Rect, opts: ChartLayerOptions): ChartLayer { + const { theme, measurer, locale, grain } = ctx + const scale = opts.fontScale + + const builtResult = buildSeriesModels(ctx, "slope") + const diagnostics = [...builtResult.diagnostics] + + if (ctx.window === null) return emptyLayer(area, diagnostics) + const startTime = ctx.window.start + const endTime = ctx.window.end + const slug = ctx.definition.y[0] + + // --- Endpoint filtering: keep only series with values at BOTH ends ------- + const labelFont = seriesLabelFont(scale) + const maxNameWidth = Math.max(30, area.width * 0.25) + const rows: SlopeRow[] = [] + for (const series of builtResult.series) { + const byTime = pointByTime(series) + const startPoint = byTime.get(startTime) + const endPoint = byTime.get(endTime) + if (startPoint === undefined || endPoint === undefined) { + diagnostics.push({ + severity: "warning", + code: "slope-incomplete-endpoints", + message: `Series "${series.label}" is missing a value at one of the endpoints and is not shown`, + context: { series: series.key }, + }) + continue + } + const slugR = series.column ?? slug + const startValueText = labelValueText(ctx, slugR, startPoint.value, false) + const endValueText = labelValueText(ctx, slugR, endPoint.value, false) + const name = truncateWithEllipsis(series.label, labelFont, maxNameWidth, measurer) + rows.push({ + series, + startPoint, + endPoint, + slug: slugR, + startValue: startPoint.value, + endValue: endPoint.value, + // OWID convention: value only on the left, series name (+ value) on + // the right where the series is identified. + leftText: startValueText, + rightText: `${name} ${endValueText}`, + }) + } + + if (rows.length < 1) return emptyLayer(area, diagnostics) + + if (ctx.definition.trendColouring === true) { + // The theme exposes no semantic direction colours (positive/negative); + // fall back to each series' identity colour and record the gap. + diagnostics.push({ + severity: "warning", + code: "slope-trend-colouring-unavailable", + message: "trendColouring requested but the theme provides no semantic direction colours; using series colours", + }) + } + + const logScale = ctx.scaleType === "log" + + // --- Vertical framing: PLOT_TOP_PAD for dots that overflow the top, and a + // bottom strip for the two column (year) labels (OWID puts years below). + const timeFont = tickFont(scale) + const startTimeText = formatTime(startTime, grain, locale) + const endTimeText = formatTime(endTime, grain, locale) + const timeMetrics = measurer.measure(startTimeText, timeFont) + const bottomStrip = timeMetrics.ascent + timeMetrics.descent + TIME_LABEL_PAD + const plotTop = area.y + PLOT_TOP_PAD + const plotHeight = Math.max(10, area.height - PLOT_TOP_PAD - bottomStrip) + + // --- Shared value axis over all endpoint values -------------------------- + const axisFont = tickFont(scale) + // Release the forced-zero baseline (slope uses the data extent); a manual + // yAxis.min still wins. + const axisConfig: AxisConfig = { ...ctx.definition.yAxis, min: ctx.definition.yAxis?.min ?? "auto" } + const spec = prepareValueAxis({ + values: rows.flatMap((r) => [r.startValue, r.endValue]), + markType: "line", + scaleType: logScale ? "log" : "linear", + config: axisConfig, + pixelLength: plotHeight, + font: axisFont, + meta: metaFor(ctx, slug), + locale, + measurer, + }) + diagnostics.push(...spec.diagnostics) + + // OWID hides the value axis: no gutter, no horizontal gridlines. The plot + // spans the full width and the endpoint value labels stand in for ticks. + const plotArea: Rect = { x: area.x, y: plotTop, width: area.width, height: plotHeight } + const yScale = createValueScale(logScale ? "log" : "linear", spec.domain, [ + plotArea.y + plotArea.height, + plotArea.y, + ]) + const plotBottom = plotArea.y + plotArea.height + + // --- Horizontal reserve for the endpoint labels -------------------------- + const dotRadius = rows.length === 1 ? 4 : 3.5 + const rawLeft = Math.max(0, ...rows.map((r) => measurer.measure(r.leftText, labelFont).width)) + const rawRight = Math.max(0, ...rows.map((r) => measurer.measure(r.rightText, labelFont).width)) + const leftReserve = Math.min(rawLeft + LABEL_GAP + dotRadius, plotArea.width * 0.3) + const rightReserve = Math.min(rawRight + LABEL_GAP + dotRadius, plotArea.width * 0.3) + const innerWidth = Math.max(10, plotArea.width - leftReserve - rightReserve) + // Cap the column span to IDEAL_ASPECT × height and centre it, so the slopes + // stay tall and narrow with the years/labels framed in wide outer margins. + const span = plotArea.width < NARROW_WIDTH ? innerWidth : Math.min(innerWidth, IDEAL_ASPECT * plotHeight) + const leftX = plotArea.x + leftReserve + (innerWidth - span) / 2 + const rightX = leftX + span + + // --- Column lines: a full-height vertical frames each column -------------- + const nodes: SceneNode[] = [] + for (const [side, x] of [["start", leftX] as const, ["end", rightX] as const]) { + nodes.push({ + key: `slope/column/${side}`, + role: "axis", + kind: "rule", + from: { x, y: plotArea.y }, + to: { x, y: plotBottom }, + style: { stroke: theme.chrome.axisLine, strokeWidth: 1, opacity: 1 }, + }) + } + + // Zero line (dashed, light) when the domain spans zero (spec 12). + if (spec.domain[0] < 0 && spec.domain[1] > 0) { + const zeroY = yScale.place(0) + nodes.push({ + key: "slope/zero-line", + role: "grid", + kind: "rule", + from: { x: leftX, y: zeroY }, + to: { x: rightX, y: zeroY }, + style: { stroke: theme.chrome.axisLine, strokeWidth: 1, dash: [3, 2], opacity: 0.6 }, + }) + } + + // --- Column (year) labels below the plot --------------------------------- + const timeBaseline = plotBottom + TIME_LABEL_PAD + timeMetrics.ascent + nodes.push( + textNode({ + key: "annotation/time/start", + role: "annotation", + text: startTimeText, + font: timeFont, + anchor: "middle", + x: leftX, + baselineY: timeBaseline, + colour: theme.chrome.tickLabel, + measurer, + }), + textNode({ + key: "annotation/time/end", + role: "annotation", + text: endTimeText, + font: timeFont, + anchor: "middle", + x: rightX, + baselineY: timeBaseline, + colour: theme.chrome.tickLabel, + measurer, + }), + ) + + // --- Slopes + endpoint dots, each on a background-coloured halo ------------ + // Interleaved in series order: a later slope's halo masks earlier slopes at + // crossings, keeping them visually separable (OWID's foreground outline). + const strokeWidth = rows.length === 1 ? 2 : 1.5 + const haloWidth = strokeWidth + 2 + const halo = theme.chrome.background + for (const row of rows) { + const left: Vec2 = { x: leftX, y: yScale.place(row.startValue) } + const right: Vec2 = { x: rightX, y: yScale.place(row.endValue) } + nodes.push( + { + key: `series/${row.series.key}/slope-halo`, + seriesKey: row.series.key, + role: "mark", + kind: "line", + segments: [[left, right]], + style: { stroke: halo, strokeWidth: haloWidth, lineCap: "round" }, + }, + { + key: `point/${row.series.key}/start-halo`, + seriesKey: row.series.key, + role: "mark", + kind: "point", + center: left, + radius: dotRadius + 1, + style: { fill: halo }, + }, + { + key: `point/${row.series.key}/end-halo`, + seriesKey: row.series.key, + role: "mark", + kind: "point", + center: right, + radius: dotRadius + 1, + style: { fill: halo }, + }, + { + key: `series/${row.series.key}/slope`, + seriesKey: row.series.key, + role: "mark", + kind: "line", + segments: [[left, right]], + style: { stroke: row.series.colour, strokeWidth, lineCap: "round" }, + }, + { + key: `point/${row.series.key}/start`, + seriesKey: row.series.key, + role: "mark", + kind: "point", + center: left, + radius: dotRadius, + style: { fill: row.series.colour }, + }, + { + key: `point/${row.series.key}/end`, + seriesKey: row.series.key, + role: "mark", + kind: "point", + center: right, + radius: dotRadius, + style: { fill: row.series.colour }, + }, + ) + } + + // --- Endpoint labels, collision-resolved per side ------------------------ + const leftMetrics = rows.map((r) => measurer.measure(r.leftText, labelFont)) + const rightMetrics = rows.map((r) => measurer.measure(r.rightText, labelFont)) + const leftHeights = leftMetrics.map((m) => m.ascent + m.descent) + const rightHeights = rightMetrics.map((m) => m.ascent + m.descent) + const leftTargets = rows.map((r, i) => yScale.place(r.startValue) - leftHeights[i] / 2) + const rightTargets = rows.map((r, i) => yScale.place(r.endValue) - rightHeights[i] / 2) + const leftTops = spreadTops(leftTargets, leftHeights, plotBottom) + const rightTops = spreadTops(rightTargets, rightHeights, plotBottom) + + rows.forEach((row, i) => { + nodes.push( + textNode({ + key: `label/${row.series.key}/start`, + role: "label", + text: row.leftText, + font: labelFont, + anchor: "end", + x: leftX - LABEL_GAP - dotRadius, + baselineY: leftTops[i] + leftMetrics[i].ascent, + colour: row.series.colour, + measurer, + seriesKey: row.series.key, + }), + textNode({ + key: `label/${row.series.key}/end`, + role: "label", + text: row.rightText, + font: labelFont, + anchor: "start", + x: rightX + LABEL_GAP + dotRadius, + baselineY: rightTops[i] + rightMetrics[i].ascent, + colour: row.series.colour, + measurer, + seriesKey: row.series.key, + }), + ) + }) + + // --- Hover: OWID slope format — title = series, subtitle = the time range, + // a single "start → end" row with an up/down/flat trend arrow. --------- + const metricLabel = builtResult.strategy === "entity" ? (ctx.columns[slug]?.name ?? "") : "" + const timeRangeText = formatTimeRange(startTime, endTime, grain, locale) + const targets: HitTarget[] = [] + const outSeries: SeriesModel[] = [] + for (const row of rows) { + const yStart = yScale.place(row.startValue) + const yEnd = yScale.place(row.endValue) + const pad = dotRadius + 2 + const top = Math.min(yStart, yEnd) - pad + const height = Math.abs(yEnd - yStart) + pad * 2 + + const flags = collectFooterFlags() + noteFooterFlags(flags, row.startPoint, startTime) + noteFooterFlags(flags, row.endPoint, endTime) + + const trend = row.endValue > row.startValue ? "up" : row.endValue < row.startValue ? "down" : "flat" + const startText = tooltipValueText(ctx, row.slug, row.startValue, false) + const endText = tooltipValueText(ctx, row.slug, row.endValue, false) + const rangeText = `${startText} ${TREND_ARROW[trend]} ${endText}` + // The single row carries the stronger endpoint notice; footers detail it. + const startNotice = noticeFor(row.startPoint, startTime) + const endNotice = noticeFor(row.endPoint, endTime) + const notice = + startNotice === "projected" || endNotice === "projected" ? "projected" : (startNotice ?? endNotice) + + targets.push({ + kind: "series", + seriesKey: row.series.key, + shape: { x: leftX, y: top, width: Math.max(1, rightX - leftX), height: Math.max(8, height) }, + tooltip: { + title: row.series.label, + subtitle: timeRangeText, + rows: [ + { + seriesKey: row.series.key, + label: metricLabel, + swatch: row.series.colour, + valueText: rangeText, + emphasized: true, + ...(notice !== undefined ? { notice } : {}), + }, + ], + footers: buildFooters(flags, grain, locale), + }, + }) + + outSeries.push({ + ...row.series, + points: [ + { ...row.startPoint, time: startTime }, + { ...row.endPoint, time: endTime }, + ], + }) + } + + return { + plotArea, + nodes, + series: outSeries, + hover: { targets }, + legendItems: legendItemsFor(outSeries), + greyedLegendKeys: [], + needsLegendFallback: false, + empty: false, + valueDomain: spec.domain, + diagnostics, + } +} diff --git a/packages/charts2/src/core/layout/chrome.test.ts b/packages/charts2/src/core/layout/chrome.test.ts index 27520b72150..23fcfb18d19 100644 --- a/packages/charts2/src/core/layout/chrome.test.ts +++ b/packages/charts2/src/core/layout/chrome.test.ts @@ -6,6 +6,7 @@ import { loadFixtureDataset } from "../../fixtures/index.ts" import { defaultMeasurer } from "../text/createMeasurer.ts" import { BUILD_CANADA_SQUARE_LOGO_DATA_URI, CANADA_SPENDS_LOGO_DATA_URI } from "../theme/logos.ts" import { buildCanadaTheme, canadaSpendsTheme } from "../theme/themes.ts" +import { subtitleFont, tickFont } from "./charts/shared.ts" import { chartTitleText, layoutChrome } from "./chrome.ts" function definitionFor(raw: Record): ChartDefinition { @@ -124,10 +125,10 @@ describe("chrome logo", () => { expect(logo).toMatchObject({ kind: "image", role: "chrome", - rect: { x: 786.4, y: 16, width: 47.6, height: 47.6 }, + rect: { x: 779.2, y: 16, width: 54.8, height: 54.8 }, href: BUILD_CANADA_SQUARE_LOGO_DATA_URI, }) - expect(layout.contentArea.y).toBe(71.6) + expect(layout.contentArea.y).toBe(78.8) }) it("uses the Canada Spends logo for the Canada Spends theme", () => { @@ -136,10 +137,10 @@ describe("chrome logo", () => { expect(logo).toMatchObject({ kind: "image", role: "chrome", - rect: { x: 679.0315789473684, y: 16, width: 154.96842105263158, height: 47.6 }, + rect: { x: 655.5909774436091, y: 16, width: 178.40902255639097, height: 54.8 }, href: CANADA_SPENDS_LOGO_DATA_URI, }) - expect(layout.contentArea.y).toBe(71.6) + expect(layout.contentArea.y).toBe(78.8) }) it("does not render powered-by attribution text", () => { @@ -148,3 +149,9 @@ describe("chrome logo", () => { expect(layout.nodes.some((node) => node.kind === "text" && node.text.startsWith("Powered by"))).toBe(false) }) }) + +describe("chrome typography", () => { + it("sets subtitle text larger than chart axis text", () => { + expect(subtitleFont(1).sizePx).toBeGreaterThan(tickFont(1).sizePx) + }) +}) diff --git a/packages/charts2/src/core/layout/index.ts b/packages/charts2/src/core/layout/index.ts index 9c5e8f9e427..b74d7b68bf3 100644 --- a/packages/charts2/src/core/layout/index.ts +++ b/packages/charts2/src/core/layout/index.ts @@ -3,8 +3,12 @@ export * from "./axis.ts" export * from "./charts/discreteBar.ts" +export * from "./charts/dumbbell.ts" export * from "./charts/line.ts" +export * from "./charts/marimekko.ts" +export * from "./charts/scatter.ts" export * from "./charts/shared.ts" +export * from "./charts/slope.ts" export * from "./charts/stackedArea.ts" export * from "./charts/stackedBar.ts" export * from "./charts/stackedDiscreteBar.ts" diff --git a/packages/charts2/src/core/layout/layoutChart.test.ts b/packages/charts2/src/core/layout/layoutChart.test.ts index 92c010f034c..e603134ba8d 100644 --- a/packages/charts2/src/core/layout/layoutChart.test.ts +++ b/packages/charts2/src/core/layout/layoutChart.test.ts @@ -284,7 +284,7 @@ describe("layoutChart behaviours", () => { it("precomputes tooltip models on hover targets (M9 consumes them as data)", () => { const scene = sceneFor("government-debt", { y: DEBT_Y, types: ["line"] }, { width: 850, height: 600 }) - expect(scene.hover.targets.length).toBe(5) + expect(scene.hover.targets.filter((t) => t.kind === "time").length).toBe(5) const target = scene.hover.targets[0] if (target.kind !== "time") return expect(target.tooltip.rows.length).toBe(3) diff --git a/packages/charts2/src/core/layout/layoutChart.ts b/packages/charts2/src/core/layout/layoutChart.ts index cacacb7b4b4..195b39aa448 100644 --- a/packages/charts2/src/core/layout/layoutChart.ts +++ b/packages/charts2/src/core/layout/layoutChart.ts @@ -19,7 +19,11 @@ import type { Theme } from "../theme/types.ts" import { truncateWithEllipsis } from "../text/truncate.ts" import type { ChartDefinition, ChartType, Dataset, Diagnostic, ViewState } from "../types.ts" import { layoutDiscreteBar } from "./charts/discreteBar.ts" +import { layoutDumbbell } from "./charts/dumbbell.ts" import { layoutLineChart } from "./charts/line.ts" +import { layoutMarimekko } from "./charts/marimekko.ts" +import { layoutScatter } from "./charts/scatter.ts" +import { layoutSlope } from "./charts/slope.ts" import { layoutStackedArea } from "./charts/stackedArea.ts" import { layoutStackedBar } from "./charts/stackedBar.ts" import { layoutStackedDiscreteBar } from "./charts/stackedDiscreteBar.ts" @@ -59,12 +63,16 @@ const CHART_LAYOUTS: Record = { "stacked-area": layoutStackedArea, "stacked-bar": layoutStackedBar, "stacked-discrete-bar": layoutStackedDiscreteBar, + slope: layoutSlope, + dumbbell: layoutDumbbell, + scatter: layoutScatter, + marimekko: layoutMarimekko, } /** Spec 05 §1: when a legend is planned before the chart is laid out. */ function legendPlanned(chartType: ChartType, definition: ChartDefinition, mode: ChromeMode): boolean { if (definition.hideLegend || mode === "thumbnail" || mode === "none") return false - if (chartType === "stacked-bar" || chartType === "stacked-discrete-bar") return true + if (chartType === "stacked-bar" || chartType === "stacked-discrete-bar" || chartType === "marimekko") return true if (definition.hideSeriesLabels && (chartType === "line" || chartType === "stacked-area")) return true return false } diff --git a/packages/charts2/src/core/text/metricsTables.ts b/packages/charts2/src/core/text/metricsTables.ts index bd946bc910f..1aa00c9e314 100644 --- a/packages/charts2/src/core/text/metricsTables.ts +++ b/packages/charts2/src/core/text/metricsTables.ts @@ -7,9 +7,9 @@ * FontRole yet. */ -import financierTextRegular from "../../fonts/metrics/financier-text-regular.json" -import foundersGroteskMonoRegular from "../../fonts/metrics/founders-grotesk-mono-regular.json" -import soehneKraftig from "../../fonts/metrics/soehne-kraftig.json" +import financierTextRegular from "../../fonts/metrics/financier-text-regular.json" with { type: "json" } +import foundersGroteskMonoRegular from "../../fonts/metrics/founders-grotesk-mono-regular.json" with { type: "json" } +import soehneKraftig from "../../fonts/metrics/soehne-kraftig.json" with { type: "json" } import type { FontMetricsTable, FontRole } from "./measurer.ts" export const headingTable: FontMetricsTable = soehneKraftig diff --git a/packages/charts2/src/core/theme/themes.test.ts b/packages/charts2/src/core/theme/themes.test.ts index 9fd7f755881..c9897a2efa7 100644 --- a/packages/charts2/src/core/theme/themes.test.ts +++ b/packages/charts2/src/core/theme/themes.test.ts @@ -1,4 +1,5 @@ -import { auburn, lake } from "@buildcanada/colours" +import { auburn } from "@buildcanada/colours/styles/colours/auburn.js" +import { lake } from "@buildcanada/colours/styles/colours/lake.js" import { describe, expect, it } from "vitest" import type { Theme } from "./types.ts" @@ -36,6 +37,10 @@ describe("themes", () => { expect(canadaSpendsTheme.branding.logo).toBe("canada-spends") }) + it("uses the same chart background across brands", () => { + expect(canadaSpendsTheme.chrome.background).toBe(buildCanadaTheme.chrome.background) + }) + it("noData is a reserved neutral, never in the categorical palette", () => { for (const theme of [buildCanadaTheme, canadaSpendsTheme]) { expect(theme.palette.categorical).not.toContain(theme.palette.noData) diff --git a/packages/charts2/src/core/theme/themes.ts b/packages/charts2/src/core/theme/themes.ts index 290226f7a9a..86836b4f1b3 100644 --- a/packages/charts2/src/core/theme/themes.ts +++ b/packages/charts2/src/core/theme/themes.ts @@ -10,7 +10,11 @@ * @buildcanada/colours by identity. */ -import { auburn, charcoal, lake, linen, nickel } from "@buildcanada/colours" +import { auburn } from "@buildcanada/colours/styles/colours/auburn.js" +import { charcoal } from "@buildcanada/colours/styles/colours/charcoal.js" +import { lake } from "@buildcanada/colours/styles/colours/lake.js" +import { linen } from "@buildcanada/colours/styles/colours/linen.js" +import { nickel } from "@buildcanada/colours/styles/colours/nickel.js" import type { HexColour } from "../types.ts" import type { Theme } from "./types.ts" @@ -78,7 +82,7 @@ export const buildCanadaTheme: Theme = { palette: { categorical: grapherDistinctLinesPalette, noData: nickel["300"], - dimOpacity: 0.35, + dimOpacity: 0.2, sequentialScale: lake, }, branding: { @@ -106,7 +110,7 @@ export const canadaSpendsTheme: Theme = { palette: { categorical: grapherDistinctPalette, noData: nickel["300"], - dimOpacity: 0.35, + dimOpacity: 0.2, sequentialScale: auburn, }, branding: { @@ -114,7 +118,7 @@ export const canadaSpendsTheme: Theme = { }, typography, chrome: { - background: "#ffffff", + background: linen["50"], gridline: charcoal["200"], axisLine: charcoal["400"], tickLabel: charcoal["600"], diff --git a/packages/charts2/src/core/types.ts b/packages/charts2/src/core/types.ts index 8c030550a28..86df14f6a2b 100644 --- a/packages/charts2/src/core/types.ts +++ b/packages/charts2/src/core/types.ts @@ -188,6 +188,16 @@ export type ChartType = | "stacked-area" | "stacked-bar" | "stacked-discrete-bar" + | "slope" + | "dumbbell" + | "scatter" + | "marimekko" + +/** Dumbbell connector style (spec 17). */ +export type ConnectorStyle = "arrow" | "line" + +/** Value-label mode for two-endpoint charts (spec 17). */ +export type ValueLabelMode = "absolute" | "change" | "percentChange" | "none" export type Tab = ChartType | "table" @@ -246,6 +256,21 @@ export interface ChartDefinition { data: string /** Metric column slugs (≥1). */ y: string[] + /** X-axis metric column slug. Required for scatter (spec 18); optional + * column-width metric for marimekko (spec 19). Ignored by other types. */ + x?: string + /** Scatter point-size metric (spec 18). Uniform radius when absent. */ + sizeMetric?: string + /** Scatter point-colour metric (spec 18). Theme primary when absent. */ + colourMetric?: string + /** Dumbbell connector style (spec 17). Default "arrow". */ + connector?: ConnectorStyle + /** Dumbbell endpoint value labels (spec 17). Default "absolute". */ + valueLabelMode?: ValueLabelMode + /** Colour slope/dumbbell marks by direction (rising/falling/flat), specs 12/17. */ + trendColouring?: boolean + /** Marimekko: group entities lacking y data into a right-edge area (spec 19). */ + showNoDataArea?: boolean /** Dimension filters for long-format datasets. */ filter?: Record /** Per-binding overrides of column metadata. */ diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg index 5627b9cb340..878f13371ab 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k +Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg index e239ff433d0..52208596726 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Population by province andterritory02M4M6M8M12M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k +Population byprovince and territory02M4M6M8M12M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg index 34cbc84c172..7f75bf173b5 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k +Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MOntario16MQuebec9MBritish Columbia6MAlberta5MManitoba1MSaskatchewan1MNova Scotia1MNew Brunswick832k diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg index 810e9959e8f..52415d0517f 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--negatives--850x600.svg @@ -1,2 +1,2 @@ -Net balance by place, 2021Source: Synthetic test data−10−8−6−4−20Î.-P.-É.−1Québec−6Lonely Station−9 +Net balance by place, 2021Source: Synthetic test data−10−8−6−4−20Î.-P.-É.−1Québec−6Lonely Station−9 diff --git a/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg b/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg index a2c9051449a..bbf5db3efac 100644 --- a/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/discrete-bar--sort-name--850x600.svg @@ -1,2 +1,2 @@ -Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MAlberta5MBritish Columbia6MManitoba1MNew Brunswick832kNova Scotia1MOntario16MQuebec9MSaskatchewan1M +Population by province and territorySource: Statistics Canada02M4M6M8M10M12M14M16MAlberta5MBritish Columbia6MManitoba1MNew Brunswick832kNova Scotia1MOntario16MQuebec9MSaskatchewan1M diff --git a/packages/charts2/src/corpus/__golden__/dumbbell--change-labels--850x600.svg b/packages/charts2/src/corpus/__golden__/dumbbell--change-labels--850x600.svg new file mode 100644 index 00000000000..bd94f18173c --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/dumbbell--change-labels--850x600.svg @@ -0,0 +1,2 @@ + +Program spending vs debt charges, 2023–24By province, 2023-24Source: Provincial public accounts$0.0$50.0$100.0$150.0$200.0Ontario−$177.7Quebec−$137.5British Columbia−$72.7Alberta−$62.1Nova Scotia−$14.0 diff --git a/packages/charts2/src/corpus/__golden__/dumbbell--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/dumbbell--default--1200x600.svg new file mode 100644 index 00000000000..528a9f339c9 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/dumbbell--default--1200x600.svg @@ -0,0 +1,2 @@ + +Program spending vs debt charges, 2023–24By province, 2023-24Source: Provincial public accounts$0.0$50.0$100.0$150.0$200.0Ontario$191.0$13.3Quebec$146.8$9.3British Columbia$76.1$3.4Alberta$65.2$3.1Nova Scotia$14.7$0.7 diff --git a/packages/charts2/src/corpus/__golden__/dumbbell--default--300x160.svg b/packages/charts2/src/corpus/__golden__/dumbbell--default--300x160.svg new file mode 100644 index 00000000000..55653f2134d --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/dumbbell--default--300x160.svg @@ -0,0 +1,2 @@ + +Program spending vsdebt charges, 2023–24$0.0$50.0$150.0Ontario$191.0$13.3Quebec$146.8$9.3British Columbia$76.1$3.4Alberta$65.2$3.1Nova Scotia$14.7$0.7 diff --git a/packages/charts2/src/corpus/__golden__/dumbbell--default--850x600.svg b/packages/charts2/src/corpus/__golden__/dumbbell--default--850x600.svg new file mode 100644 index 00000000000..670556fcaae --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/dumbbell--default--850x600.svg @@ -0,0 +1,2 @@ + +Program spending vs debt charges, 2023–24By province, 2023-24Source: Provincial public accounts$0.0$50.0$100.0$150.0$200.0Ontario$191.0$13.3Quebec$146.8$9.3British Columbia$76.1$3.4Alberta$65.2$3.1Nova Scotia$14.7$0.7 diff --git a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg index 6704929f9dd..87da16d2323 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--1200x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--default--300x160.svg b/packages/charts2/src/corpus/__golden__/line--default--300x160.svg index f64da24c4aa..292bd57433d 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--300x160.svg @@ -1,2 +1,2 @@ -Provincial budget spending,2019–20 to 2024–25$0.0$100.0$200.02019–202022–232024–25OntarioQuebec +Provincial budget spending,2019–20 to 2024–25$0.0$100.0$200.02019–202022–232024–25OntarioQuebec diff --git a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg index c75a704f263..e44c2150fba 100644 --- a/packages/charts2/src/corpus/__golden__/line--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--default--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg index 6560bd5c32f..9bb2e0c6d2c 100644 --- a/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--fr--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial budget spending, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0,0 $50,0 $100,0 $150,0 $200,0 $250,0 $2019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg index 42cf85e33e5..35633a347ae 100644 --- a/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--many-entities--1200x600.svg @@ -1,2 +1,2 @@ -Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of Canada$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada +Federal departmental spending, 2019–20 to 2023–24Source: Public Accounts of Canada$0.0$20.0$40.0$60.0$80.0$100.0$120.0$140.0$160.02019–202020–212021–222022–232023–24Crown-Indigenous Relations and Northern Affairs Ca…Natural Resources CanadaVeterans Affairs CanadaFisheries and Oceans CanadaCanada Revenue AgencyAgriculture and Agri-Food CanadaEnvironment and Climate Change CanadaTransport Canada diff --git a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg index 7d64fcb5879..2c4faccf1c7 100644 --- a/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--missing-data--850x600.svg @@ -1,2 +1,2 @@ -Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Provincial program spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.02019–202020–212021–222022–232023–242024–25OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg index e06f90c0777..f1fed3b1f76 100644 --- a/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta +Change in Provincial budget spending, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts0%+10%+20%+30%+40%+50%2019–202020–212021–222022–232023–242024–25Nova ScotiaBritish ColumbiaQuebecOntarioAlberta diff --git a/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg b/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg index 283fc2bf8a5..8cb05bfd01a 100644 --- a/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/line--single-time--850x600.svg @@ -1,2 +1,2 @@ -Provincial budget spending, 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5Quebec$161.0British Columbia$84.2Alberta$71.2Nova Scotia$16.5 +Provincial budget spending, 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5Quebec$161.0British Columbia$84.2Alberta$71.2Nova Scotia$16.5 diff --git a/packages/charts2/src/corpus/__golden__/marimekko--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/marimekko--default--1200x600.svg new file mode 100644 index 00000000000..d4b437bdf84 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/marimekko--default--1200x600.svg @@ -0,0 +1,2 @@ + +Spending composition, sized by budget, 2023–24Width = total spending; 2023-24Source: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$0.0$100.0$200.0$300.0$400.0$500.0OntarioQuebecBritish ColumbiaAlbertaNov… diff --git a/packages/charts2/src/corpus/__golden__/marimekko--default--300x160.svg b/packages/charts2/src/corpus/__golden__/marimekko--default--300x160.svg new file mode 100644 index 00000000000..b5bc5b6dac0 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/marimekko--default--300x160.svg @@ -0,0 +1,2 @@ + +Spending composition,sized by budget, 2023–24$0.0$50.0$100.0$150.0$200.0$0.0$100.0$200.0$300.0$400.0$500.0OntarioQuebecBritis…Albe… diff --git a/packages/charts2/src/corpus/__golden__/marimekko--default--850x600.svg b/packages/charts2/src/corpus/__golden__/marimekko--default--850x600.svg new file mode 100644 index 00000000000..5423475d3be --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/marimekko--default--850x600.svg @@ -0,0 +1,2 @@ + +Spending composition, sized by budget, 2023–24Width = total spending; 2023-24Source: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$0.0$100.0$200.0$300.0$400.0$500.0OntarioQuebecBritish ColumbiaAlbertaN… diff --git a/packages/charts2/src/corpus/__golden__/marimekko--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/marimekko--relative--850x600.svg new file mode 100644 index 00000000000..272dc14b1d6 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/marimekko--relative--850x600.svg @@ -0,0 +1,2 @@ + +Change in Spending composition, sized by budget, 2023–24Width = total spending; 2023-24Source: Provincial public accountsProgram spendingDebt charges0%20%40%60%80%100%$0.0$100.0$200.0$300.0$400.0$500.0OntarioQuebecBritish ColumbiaAlbertaN… diff --git a/packages/charts2/src/corpus/__golden__/scatter--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/scatter--default--1200x600.svg new file mode 100644 index 00000000000..25dc760cef3 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/scatter--default--1200x600.svg @@ -0,0 +1,2 @@ + +Debt charges vs program spending, 2023–24By province, 2023-24Source: Provincial public accounts$0.0$2.0$4.0$6.0$8.0$10.0$12.0$14.0$0.0$50.0$100.0$150.0$200.0OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/scatter--default--300x160.svg b/packages/charts2/src/corpus/__golden__/scatter--default--300x160.svg new file mode 100644 index 00000000000..ea7dcd41a29 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/scatter--default--300x160.svg @@ -0,0 +1,2 @@ + +Debt charges vs programspending, 2023–24$0.0$5.0$10.0$15.0$0.0$50.0$100.0$150.0$200.0OntarioQuebecBritish ColumbiaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/scatter--default--850x600.svg b/packages/charts2/src/corpus/__golden__/scatter--default--850x600.svg new file mode 100644 index 00000000000..fc4f922ab3d --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/scatter--default--850x600.svg @@ -0,0 +1,2 @@ + +Debt charges vs program spending, 2023–24By province, 2023-24Source: Provincial public accounts$0.0$2.0$4.0$6.0$8.0$10.0$12.0$14.0$0.0$50.0$100.0$150.0$200.0OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/__golden__/slope--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/slope--default--1200x600.svg new file mode 100644 index 00000000000..ed1eb6e3b12 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/slope--default--1200x600.svg @@ -0,0 +1,2 @@ + +Provincial spending, first vs latest year, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts2019–202024–25$165.1Ontario $214.5$118.6Quebec $161.0$58.5British Columbia $84.2$58.7Alberta $71.2$11.3Nova Scotia $16.5 diff --git a/packages/charts2/src/corpus/__golden__/slope--default--300x160.svg b/packages/charts2/src/corpus/__golden__/slope--default--300x160.svg new file mode 100644 index 00000000000..c8aa1a1cf7c --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/slope--default--300x160.svg @@ -0,0 +1,2 @@ + +Provincial spending, first vslatest year, 2019–20 to 2024–252019–202024–25$165.1Ontario $214.5$118.6Quebec $161.0$58.5British Colu… $84.2$58.7Alberta $71.2$11.3Nova Scotia $16.5 diff --git a/packages/charts2/src/corpus/__golden__/slope--default--850x600.svg b/packages/charts2/src/corpus/__golden__/slope--default--850x600.svg new file mode 100644 index 00000000000..22f341decca --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/slope--default--850x600.svg @@ -0,0 +1,2 @@ + +Provincial spending, first vs latest year, 2019–20 to 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts2019–202024–25$165.1Ontario $214.5$118.6Quebec $161.0$58.5British Columbia $84.2$58.7Alberta $71.2$11.3Nova Scotia $16.5 diff --git a/packages/charts2/src/corpus/__golden__/slope--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/slope--fr--850x600.svg new file mode 100644 index 00000000000..871d553ea68 --- /dev/null +++ b/packages/charts2/src/corpus/__golden__/slope--fr--850x600.svg @@ -0,0 +1,2 @@ + +Provincial spending, first vs latest year, de 2019–20 à 2024–25Total budgetary expenditure, public accounts basisSource: Provincial public accounts2019–202024–25165,1 $Ontario 214,5 $118,6 $Quebec 161,0 $58,5 $British Columbia 84,2 $58,7 $Alberta 71,2 $11,3 $Nova Scotia 16,5 $ diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg index b5f0ecedfd6..c99cccb3ce3 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--1200x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg index 1765e429209..21085ccff69 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--300x160.svg @@ -1,2 +1,2 @@ -Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202023–24Provincial de…Federal debt +Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202023–24Provincial de…Federal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg index 772b7f9cac8..e7a1990b751 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--default--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg index 171d10b497f..74e6be4c219 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--fr--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Government debt as a share of GDP, Canada, de 2019–20 à 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0,0 %20,0 %40,0 %60,0 %80,0 %100,0 %2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg index dda83a9c165..15921890631 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-area--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt +Change in Government debt as a share of GDP, Canada, 2019–20 to2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tables0%20%40%60%80%100%2019–202020–212021–222022–232023–24Municipal debtProvincial debtFederal debt diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg index eaafc00ec1b..f40d1cf2b09 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg index a7a585fc131..f42ba1bd6fd 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202021–222023–24 +Government debt as a share ofGDP, Canada, 2019–20 to 2023–240.0%50.0%100.0%2019–202021–222023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg index dfd0ac3e314..2a3b07755a5 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 +Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0.0%20.0%40.0%60.0%80.0%100.0%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg index ab5020746dc..6ec6fb1495d 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-bar--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Government debt as a share of GDP, Canada, 2019–20 to 2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 +Change in Government debt as a share of GDP, Canada, 2019–20 to2023–24Federal, provincial, and municipal debt divided by nominal GDPSource: Fiscal reference tablesFederal debtProvincial debtMunicipal debt0%+20%+40%+60%+80%+100%2019–202020–212021–222022–232023–24 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg index 196c665dc15..4907bef2903 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--1200x600.svg @@ -1,2 +1,2 @@ -Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 +Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg index d8cfde6a768..13b6133d038 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--300x160.svg @@ -1,2 +1,2 @@ -Provincial spending composition,2019–20 to 2024–25$0.0$50.0$150.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 +Provincial spending composition,2019–20 to 2024–25$0.0$50.0$150.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg index 432e936e89d..dff386a40f5 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--default--850x600.svg @@ -1,2 +1,2 @@ -Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 +Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0$250.0Ontario$214.5British Columbia$83.9Alberta$71.2Nova Scotia$16.5Quebec$9.3 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg index 41d9dd03839..c2d0694ea56 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--missing-data--850x600.svg @@ -1,2 +1,2 @@ -Provincial spending composition, 2022–23Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0Ontario$192.9Quebec$147.3British Columbia$73.6Alberta$64.3Nova Scotia$0.7 +Provincial spending composition, 2022–23Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges$0.0$50.0$100.0$150.0$200.0Ontario$192.9Quebec$147.3British Columbia$73.6Alberta$64.3Nova Scotia$0.7 diff --git a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg index f3007b27176..f2a5c2301ff 100644 --- a/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg +++ b/packages/charts2/src/corpus/__golden__/stacked-discrete-bar--relative--850x600.svg @@ -1,2 +1,2 @@ -Change in Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges0%+20%+40%+60%+80%+100%OntarioQuebecBritish ColumbiaAlbertaNova Scotia +Change in Provincial spending composition, 2019–20 to 2024–25Program spending and debt charges by provinceSource: Provincial public accountsProgram spendingDebt charges0%+20%+40%+60%+80%+100%OntarioQuebecBritish ColumbiaAlbertaNova Scotia diff --git a/packages/charts2/src/corpus/corpus.ts b/packages/charts2/src/corpus/corpus.ts index c0897973323..c0535951fb3 100644 --- a/packages/charts2/src/corpus/corpus.ts +++ b/packages/charts2/src/corpus/corpus.ts @@ -144,6 +144,57 @@ const stackedDiscreteBudgets = { sourceText: "Provincial public accounts", } +const budgetEntities = ["Ontario", "Quebec", "British Columbia", "Alberta", "Nova Scotia"] + +// Slope: one metric across the window's two endpoints (spec 12). +const slopeBudgets = { + title: "Provincial spending, first vs latest year", + subtitle: "Total budgetary expenditure, public accounts basis", + data: "provincial-budgets", + y: ["total_spending"], + types: ["slope"], + selectedEntities: budgetEntities, + sourceText: "Provincial public accounts", +} + +// Dumbbell: two metrics at one time, one row per entity (spec 17). +const dumbbellBudgets = { + title: "Program spending vs debt charges", + subtitle: "By province, 2023-24", + data: "provincial-budgets", + y: ["program_spending", "debt_charges"], + types: ["dumbbell"], + time: "2023-24", + selectedEntities: budgetEntities, + sourceText: "Provincial public accounts", +} + +// Scatter: x vs y metric, one point per entity at a target time (spec 18). +const scatterBudgets = { + title: "Debt charges vs program spending", + subtitle: "By province, 2023-24", + data: "provincial-budgets", + x: "program_spending", + y: ["debt_charges"], + types: ["scatter"], + time: "2023-24", + selectedEntities: budgetEntities, + sourceText: "Provincial public accounts", +} + +// Marimekko: stacked metrics with column widths from an x metric (spec 19). +const marimekkoBudgets = { + title: "Spending composition, sized by budget", + subtitle: "Width = total spending; 2023-24", + data: "provincial-budgets", + y: ["program_spending", "debt_charges"], + x: "total_spending", + types: ["marimekko"], + time: "2023-24", + selectedEntities: budgetEntities, + sourceText: "Provincial public accounts", +} + // --------------------------------------------------------------------------- // The corpus // --------------------------------------------------------------------------- @@ -297,6 +348,47 @@ export const corpusCases: readonly CorpusCase[] = [ raw: { ...stackedDiscreteBudgets, time: "2022-23" }, size: DEFAULT, }), + + // --- slope (provincial-budgets, two-endpoint window) -------------------- + makeCase({ type: "slope", state: "default", fixture: "provincial-budgets", raw: slopeBudgets, size: THUMBNAIL }), + makeCase({ type: "slope", state: "default", fixture: "provincial-budgets", raw: slopeBudgets, size: DEFAULT }), + makeCase({ type: "slope", state: "default", fixture: "provincial-budgets", raw: slopeBudgets, size: WIDE }), + makeCase({ + type: "slope", + state: "fr", + fixture: "provincial-budgets", + raw: { ...slopeBudgets, locale: "fr" }, + size: DEFAULT, + }), + + // --- dumbbell (provincial-budgets, two-metric) -------------------------- + makeCase({ type: "dumbbell", state: "default", fixture: "provincial-budgets", raw: dumbbellBudgets, size: THUMBNAIL }), + makeCase({ type: "dumbbell", state: "default", fixture: "provincial-budgets", raw: dumbbellBudgets, size: DEFAULT }), + makeCase({ type: "dumbbell", state: "default", fixture: "provincial-budgets", raw: dumbbellBudgets, size: WIDE }), + makeCase({ + type: "dumbbell", + state: "change-labels", + fixture: "provincial-budgets", + raw: { ...dumbbellBudgets, valueLabelMode: "change", connector: "line" }, + size: DEFAULT, + }), + + // --- scatter (provincial-budgets, snapshot) ----------------------------- + makeCase({ type: "scatter", state: "default", fixture: "provincial-budgets", raw: scatterBudgets, size: THUMBNAIL }), + makeCase({ type: "scatter", state: "default", fixture: "provincial-budgets", raw: scatterBudgets, size: DEFAULT }), + makeCase({ type: "scatter", state: "default", fixture: "provincial-budgets", raw: scatterBudgets, size: WIDE }), + + // --- marimekko (provincial-budgets, width = total spending) ------------- + makeCase({ type: "marimekko", state: "default", fixture: "provincial-budgets", raw: marimekkoBudgets, size: THUMBNAIL }), + makeCase({ type: "marimekko", state: "default", fixture: "provincial-budgets", raw: marimekkoBudgets, size: DEFAULT }), + makeCase({ type: "marimekko", state: "default", fixture: "provincial-budgets", raw: marimekkoBudgets, size: WIDE }), + makeCase({ + type: "marimekko", + state: "relative", + fixture: "provincial-budgets", + raw: { ...marimekkoBudgets, stackMode: "relative" }, + size: DEFAULT, + }), ] // --------------------------------------------------------------------------- diff --git a/packages/charts2/src/react/Chart.test.tsx b/packages/charts2/src/react/Chart.test.tsx index 4c79975e7ed..0c5eafacc0a 100644 --- a/packages/charts2/src/react/Chart.test.tsx +++ b/packages/charts2/src/react/Chart.test.tsx @@ -136,4 +136,23 @@ describe("Chart", () => { // Focus and escape NEVER call layoutChart. expect(layoutSpy.mock.calls.length).toBe(layoutCallsAfterMount) }) + + it("focusedSeries prop seeds focus emphasis on mount, without interaction", () => { + const dim = buildCanadaTheme.palette.dimOpacity.toString() + const { container } = render( + , + ) + // Non-focused series are dimmed to the theme dim immediately (no hover/click). + expect(container.querySelectorAll(`[opacity="${dim}"]`).length).toBeGreaterThan(0) + }) }) diff --git a/packages/charts2/src/react/Chart.tsx b/packages/charts2/src/react/Chart.tsx index 70a9ecdd0c4..8f368165ec1 100644 --- a/packages/charts2/src/react/Chart.tsx +++ b/packages/charts2/src/react/Chart.tsx @@ -17,7 +17,7 @@ import { layoutChart } from "../core/layout/layoutChart.ts" import type { HitTarget, TooltipModel, Vec2 } from "../core/scene/nodes.ts" import { getTheme } from "../core/theme/registry.ts" import type { Theme } from "../core/theme/types.ts" -import type { ChartDefinition, Dataset, ViewState } from "../core/types.ts" +import type { ChartDefinition, Dataset, SeriesKey, ViewState } from "../core/types.ts" import { emphasisFor, emphasisReducer, type EmphasisState } from "./interaction/emphasisReducer.ts" import { useUrlState } from "./interaction/useUrlState.ts" import { SceneSVG } from "./SceneSVG.tsx" @@ -42,6 +42,10 @@ export interface ChartProps { height?: number /** Tooltip render prop — the chrome Tooltip component plugs in here. */ renderTooltip?: (args: RenderTooltipArgs) => ReactNode + /** Force an initial focus set (spec 07 §3), taking precedence over the + * view's `focus` and the definition's `focusedSeries`. Hover and Escape + * still apply on top. */ + focusedSeries?: SeriesKey[] } /** SSR/first-paint size before the container has been measured. */ @@ -60,6 +64,7 @@ export function Chart({ width, height, renderTooltip, + focusedSeries, }: ChartProps): ReactNode { const grain = dataset.manifest.timeGrain @@ -101,7 +106,7 @@ export function Chart({ null, (): EmphasisState => ({ hover: null, - focus: new Set(view.focus ?? definition.focusedSeries ?? []), + focus: new Set(focusedSeries ?? view.focus ?? definition.focusedSeries ?? []), }), ) diff --git a/packages/charts2/src/react/SceneSVG.test.tsx b/packages/charts2/src/react/SceneSVG.test.tsx index d054d4a3fcd..d33d7d23426 100644 --- a/packages/charts2/src/react/SceneSVG.test.tsx +++ b/packages/charts2/src/react/SceneSVG.test.tsx @@ -217,6 +217,31 @@ describe("SceneSVG", () => { expect(markup).toMatch(/]*opacity)/) }) + it("hides non-emphasized line markers outright, keeping the emphasized ones (spec 07 §3)", () => { + const base = minimalScene() + const betaPoint: SceneNode = { + kind: "point", + key: "series/beta/point", + seriesKey: "beta", + role: "mark", + center: { x: 70, y: 50 }, + radius: 3, + style: { fill: "#334455" }, + } + const markup = renderToStaticMarkup( + , + ) + // alpha's marker (emphasized) stays… + expect(markup).toContain('cx="50" cy="40.5"') + // …beta's marker (non-emphasized) is gone, not merely dimmed. + expect(markup).not.toContain('cx="70"') + }) + it("is a pass-through when emphasis is idle", () => { const scene = minimalScene() const idle = renderToStaticMarkup( diff --git a/packages/charts2/src/react/SceneSVG.tsx b/packages/charts2/src/react/SceneSVG.tsx index f38d60a1eeb..399734c00c6 100644 --- a/packages/charts2/src/react/SceneSVG.tsx +++ b/packages/charts2/src/react/SceneSVG.tsx @@ -174,7 +174,21 @@ function dimFor(node: SceneNode, ctx: RenderContext, ancestorDimmed: boolean): n return ctx.emphasis.keys.has(node.seriesKey) ? 1 : ctx.dimOpacity } +/** Focus visual (spec 07 §3): data-point markers on non-emphasized series are + * hidden outright, not merely dimmed. Scoped to `mark` `point` nodes, so bars + * (rect) and areas are unaffected; only line/scatter markers ever disappear. */ +function markerHidden(node: SceneNode, ctx: RenderContext): boolean { + return ( + ctx.emphasis.mode === "emphasis" && + node.kind === "point" && + node.role === "mark" && + node.seriesKey !== undefined && + !ctx.emphasis.keys.has(node.seriesKey) + ) +} + function renderNode(node: SceneNode, ctx: RenderContext, ancestorDimmed: boolean): ReactNode { + if (markerHidden(node, ctx)) return null const dim = dimFor(node, ctx, ancestorDimmed) const childDimmed = ancestorDimmed || dim !== 1 diff --git a/packages/charts2/src/react/chrome/DataTable.test.tsx b/packages/charts2/src/react/chrome/DataTable.test.tsx index 318547921b7..bd0c6e59936 100644 --- a/packages/charts2/src/react/chrome/DataTable.test.tsx +++ b/packages/charts2/src/react/chrome/DataTable.test.tsx @@ -133,6 +133,16 @@ describe("DataTable annotations (spec 22 §2)", () => { }) describe("DataTable scope, sort, and search (spec 22 §3)", () => { + it("renders search with the Build Canada TextField primitive", () => { + const { container } = renderTable() + expect(container.querySelector(".bc-textfield.bcds2-data-table__search")).not.toBeNull() + }) + + it("renders sortable table headers with the shared Button primitive", () => { + const { container } = renderTable() + expect(container.querySelector(".bc-btn.bcds2-data-table__sort-button")).not.toBeNull() + }) + it("applies the sort prop to row order", () => { const { container } = renderTable({ sort: { column: "total_spending", order: "desc" } }) const names = [...container.querySelectorAll("tbody th")].map((th) => th.textContent) diff --git a/packages/charts2/src/react/chrome/DataTable.tsx b/packages/charts2/src/react/chrome/DataTable.tsx index 42684604f43..e3183729399 100644 --- a/packages/charts2/src/react/chrome/DataTable.tsx +++ b/packages/charts2/src/react/chrome/DataTable.tsx @@ -12,6 +12,7 @@ import { useMemo } from "react" import type { ReactNode } from "react" +import { Button, SegmentedControl, TextField } from "@buildcanada/components" import { resolveValue } from "../../core/data/derived.ts" import { snapToAvailable } from "../../core/data/time.ts" import { formatChange, formatValue } from "../../core/format/number.ts" @@ -163,10 +164,12 @@ export function DataTable({ const isActive = sort.column === columnId const nextOrder: SortOrder = isActive ? (sort.order === "asc" ? "desc" : "asc") : columnId === "entity" ? "asc" : "desc" return ( - + ) } @@ -259,28 +262,21 @@ export function DataTable({ return (
-
- - -
- onScopeChange(value as DataTableScope)} + items={[ + { value: "selected", label: "Selected" }, + { value: "all", label: "All" }, + ]} + /> + onSearchChange(event.target.value)} diff --git a/packages/charts2/src/react/chrome/EntitySelector.test.tsx b/packages/charts2/src/react/chrome/EntitySelector.test.tsx index 837e336e496..ecb7bbc65f0 100644 --- a/packages/charts2/src/react/chrome/EntitySelector.test.tsx +++ b/packages/charts2/src/react/chrome/EntitySelector.test.tsx @@ -12,10 +12,31 @@ const pathological = loadFixtureDataset("pathological").dataset const federal = loadFixtureDataset("federal-departments").dataset function rowNames(container: HTMLElement): string[] { - return [...container.querySelectorAll(".bcds2-entity-selector__name")].map((el) => el.textContent ?? "") + return [...container.querySelectorAll(".bcds2-entity-selector__row")].map((row) => { + const name = row.querySelector(".bcds2-entity-selector__name, .bc-checkbox__text") + return name?.textContent ?? "" + }) } describe("EntitySelector search (spec 07 §2)", () => { + it("renders search with the Build Canada TextField primitive", () => { + const { container } = render( + undefined} locale="en" />, + ) + expect(container.querySelector(".bc-textfield.bcds2-entity-selector__search")).not.toBeNull() + }) + + it("renders multi-select controls with shared Button and Checkbox primitives", () => { + const { container, getByLabelText } = render( + undefined} locale="en" />, + ) + expect(container.querySelector(".bc-btn.bcds2-entity-selector__order")).not.toBeNull() + expect(container.querySelectorAll(".bc-btn.bcds2-entity-selector__action").length).toBe(2) + expect(container.querySelector(".bc-checkbox.bcds2-entity-selector__group-checkbox")).not.toBeNull() + expect(container.querySelector(".bc-checkbox.bcds2-entity-selector__row-checkbox")).not.toBeNull() + expect(getByLabelText("Sorted ascending")).not.toBeNull() + }) + it("finds accented entities from unaccented queries (quebec → Québec)", () => { const { container, getByLabelText } = render( undefined} locale="en" />, @@ -55,7 +76,7 @@ describe("EntitySelector groups (spec 07 §2)", () => { const first = render( , ) - fireEvent.click(first.getByLabelText("Select all in Social")) + fireEvent.click(first.getByLabelText("Social")) expect(onChange).toHaveBeenCalledWith(social) first.unmount() @@ -63,7 +84,7 @@ describe("EntitySelector groups (spec 07 §2)", () => { const second = render( , ) - fireEvent.click(second.getByLabelText("Select all in Social")) + fireEvent.click(second.getByLabelText("Social")) expect(onChange).toHaveBeenCalledWith([]) }) }) @@ -123,10 +144,9 @@ describe("EntitySelector selection modes (spec 07 §1)", () => { const { container } = render( , ) - const rows = [...container.querySelectorAll(".bcds2-entity-selector__row")] - const lonely = rows.find((row) => row.textContent?.includes("Lonely Station")) - expect(lonely?.querySelector("input")?.getAttribute("type")).toBe("radio") - fireEvent.click(lonely!.querySelector("input")!) + expect(container.querySelector(".bc-radio-group")).not.toBeNull() + const lonely = container.querySelector('input[type="radio"][value="Lonely Station"]') as HTMLInputElement + fireEvent.click(lonely) expect(onChange).toHaveBeenCalledWith(["Lonely Station"]) }) diff --git a/packages/charts2/src/react/chrome/EntitySelector.tsx b/packages/charts2/src/react/chrome/EntitySelector.tsx index fdb149ee063..25d330956a3 100644 --- a/packages/charts2/src/react/chrome/EntitySelector.tsx +++ b/packages/charts2/src/react/chrome/EntitySelector.tsx @@ -7,6 +7,7 @@ */ import { useMemo, useState } from "react" +import { Button, Checkbox, IconButton, RadioGroup, Select, TextField } from "@buildcanada/components" import { resolveValue } from "../../core/data/derived.ts" import { formatValue } from "../../core/format/number.ts" import type { Dataset, EntityMeta, Locale, SortOrder } from "../../core/types.ts" @@ -169,42 +170,56 @@ export function EntitySelector({ dataset, selected, mode, onChange, sortColumns return (
- setQuery(event.target.value)} />
- - + variant="outline-charcoal" + size="sm" + icon={} + /> {mode === "multi" && ( - - + + )}
@@ -216,40 +231,63 @@ export function EntitySelector({ dataset, selected, mode, onChange, sortColumns
{group.name !== null && (mode === "multi" ? ( - +
) : (
{group.name}
))} - {group.rows.map((row) => ( - - ))} + {mode === "single" ? ( + toggleEntity(value)} + options={group.rows.map((row) => ({ + value: row.name, + label: ( + + {row.name} + {!row.hasData && no data} + {row.valueText !== null && {row.valueText}} + + ), + }))} + /> + ) : ( + group.rows.map((row) => { + const rowClass = row.hasData + ? "bcds2-entity-selector__row" + : "bcds2-entity-selector__row bcds2-entity-selector__row--no-data" + return ( +
+ toggleEntity(row.name)} + /> + {!row.hasData && no data} + {row.valueText !== null && {row.valueText}} +
+ ) + }) + )}
) })} diff --git a/packages/charts2/src/react/chrome/SettingsMenu.test.tsx b/packages/charts2/src/react/chrome/SettingsMenu.test.tsx index 919fcfa4b69..dd22f47f3fa 100644 --- a/packages/charts2/src/react/chrome/SettingsMenu.test.tsx +++ b/packages/charts2/src/react/chrome/SettingsMenu.test.tsx @@ -32,6 +32,7 @@ describe("SettingsMenu (spec 10 §4)", () => { fireEvent.click(getByLabelText("Settings")) expect(container.querySelector(".bcds2-settings__popover")).not.toBeNull() + expect(container.querySelector(".bc-checkbox")).not.toBeNull() expect(getByText("Relative")).not.toBeNull() expect(getByText("Y-axis scale")).not.toBeNull() expect(container.querySelectorAll(".bcds2-settings__item").length).toBe(2) diff --git a/packages/charts2/src/react/chrome/SettingsMenu.tsx b/packages/charts2/src/react/chrome/SettingsMenu.tsx index 6c946f33405..8d9aae616fa 100644 --- a/packages/charts2/src/react/chrome/SettingsMenu.tsx +++ b/packages/charts2/src/react/chrome/SettingsMenu.tsx @@ -1,10 +1,10 @@ /** * Settings menu (spec 10 §4): a gear button opening a popover that lists * only the items the caller passes — relevance to the current view is the - * caller's decision. Closes on Escape and on outside click. + * caller's decision. */ -import { useEffect, useRef, useState } from "react" +import { Checkbox, IconButton, Popover, RadioGroup } from "@buildcanada/components" export type SettingsItem = | { @@ -30,79 +30,55 @@ export interface SettingsMenuProps { } export function SettingsMenu({ items, label = "Settings" }: SettingsMenuProps) { - const [open, setOpen] = useState(false) - const rootRef = useRef(null) - - useEffect(() => { - if (!open) return - function handlePointerDown(event: PointerEvent): void { - const root = rootRef.current - if (root !== null && event.target instanceof Node && root.contains(event.target)) return - setOpen(false) - } - function handleKeyDown(event: KeyboardEvent): void { - if (event.key === "Escape") setOpen(false) - } - document.addEventListener("pointerdown", handlePointerDown) - document.addEventListener("keydown", handleKeyDown) - return () => { - document.removeEventListener("pointerdown", handlePointerDown) - document.removeEventListener("keydown", handleKeyDown) - } - }, [open]) - return ( -
- - {open && ( -
- {items.map((item) => - item.kind === "toggle" ? ( - - ) : ( -
- {item.label} - {item.options.map((option) => ( - - ))} -
- ), - )} -
- )} -
+ ) } diff --git a/packages/charts2/src/react/chrome/Tabs.tsx b/packages/charts2/src/react/chrome/Tabs.tsx index bb1dabd48ee..413d70f3931 100644 --- a/packages/charts2/src/react/chrome/Tabs.tsx +++ b/packages/charts2/src/react/chrome/Tabs.tsx @@ -4,8 +4,7 @@ * tab index. The caller owns the active tab and tab list. */ -import { useRef } from "react" -import type { KeyboardEvent as ReactKeyboardEvent } from "react" +import { SegmentedControl } from "@buildcanada/components" import type { Tab } from "../../core/types.ts" const DEFAULT_LABELS: Record = { @@ -14,6 +13,10 @@ const DEFAULT_LABELS: Record = { "stacked-area": "Stacked area", "stacked-bar": "Stacked bar", "stacked-discrete-bar": "Stacked bar", + "slope": "Slope", + "dumbbell": "Dumbbell", + "scatter": "Scatter", + "marimekko": "Marimekko", "table": "Table", } @@ -26,55 +29,17 @@ export interface TabsProps { } export function Tabs({ tabs, active, onChange, labels }: TabsProps) { - const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) - - function activate(index: number): void { - if (tabs.length === 0) return - const wrapped = (index + tabs.length) % tabs.length - onChange(tabs[wrapped]) - buttonRefs.current[wrapped]?.focus() - } - - function handleKeyDown(event: ReactKeyboardEvent, index: number): void { - switch (event.key) { - case "ArrowRight": - event.preventDefault() - activate(index + 1) - break - case "ArrowLeft": - event.preventDefault() - activate(index - 1) - break - case "Home": - event.preventDefault() - activate(0) - break - case "End": - event.preventDefault() - activate(tabs.length - 1) - break - } - } - return ( -
- {tabs.map((tab, index) => ( - - ))} -
+ onChange(value as Tab)} + items={tabs.map((tab) => ({ + value: tab, + label: labels?.[tab] ?? DEFAULT_LABELS[tab], + }))} + /> ) } diff --git a/packages/charts2/src/react/chrome/Timeline.tsx b/packages/charts2/src/react/chrome/Timeline.tsx index 2742736c064..94a9c46ee31 100644 --- a/packages/charts2/src/react/chrome/Timeline.tsx +++ b/packages/charts2/src/react/chrome/Timeline.tsx @@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from "react" import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react" +import { IconButton } from "@buildcanada/components" import { snapToAvailable } from "../../core/data/time.ts" import { formatTime, formatTimeRange } from "../../core/format/timeLabels.ts" import type { Locale, TimeBound, TimeGrain, TimeOrdinal, TimeSelection } from "../../core/types.ts" @@ -325,21 +326,23 @@ export function Timeline({ times, grain, locale, selection, mode, onChange, play return (
{playable && ( - + variant="outline-charcoal" + size="sm" + icon={ + + } + /> )}