@internetstiftelsen/charts 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -174,11 +174,11 @@ export class ChartGroup {
174
174
  writable: true,
175
175
  value: null
176
176
  });
177
- Object.defineProperty(this, "title", {
177
+ Object.defineProperty(this, "textComponents", {
178
178
  enumerable: true,
179
179
  configurable: true,
180
180
  writable: true,
181
- value: null
181
+ value: []
182
182
  });
183
183
  Object.defineProperty(this, "resizeObserver", {
184
184
  enumerable: true,
@@ -253,8 +253,8 @@ export class ChartGroup {
253
253
  return this;
254
254
  }
255
255
  addChild(component) {
256
- if (component.type === 'title') {
257
- this.title = component;
256
+ if (this.isTextComponent(component)) {
257
+ this.textComponents.push(component);
258
258
  if (this.container) {
259
259
  this.refresh();
260
260
  }
@@ -272,7 +272,10 @@ export class ChartGroup {
272
272
  }
273
273
  return this;
274
274
  }
275
- throw new Error('ChartGroup only supports Title and Legend via addChild()');
275
+ throw new Error('ChartGroup only supports Text, Title, and Legend via addChild()');
276
+ }
277
+ isTextComponent(component) {
278
+ return component.type === 'text' || component.type === 'title';
276
279
  }
277
280
  render(target) {
278
281
  this.container = this.resolveContainer(target);
@@ -285,17 +288,18 @@ export class ChartGroup {
285
288
  return null;
286
289
  }
287
290
  const container = this.container;
288
- const { width, renderedTitle, renderedLegend, layout, totalHeight } = this.prepareRenderState(container);
291
+ const { width, renderedTopText, renderedBottomText, renderedLegend, layout, totalHeight, } = this.prepareRenderState(container);
289
292
  this.isRendering = true;
290
293
  try {
291
294
  this.applyScaleSyncOverrides(width);
292
295
  container.innerHTML = '';
293
296
  const { root, chartLayer } = this.createRenderHosts(totalHeight, layout.chartHeight);
294
- this.appendRenderedSection(root, 'chart-group__title', renderedTitle);
297
+ this.appendRenderedTextSections(root, renderedTopText);
295
298
  root.appendChild(chartLayer);
296
299
  container.appendChild(root);
297
300
  this.renderLayoutItems(chartLayer, layout.items);
298
301
  this.appendRenderedSection(root, 'chart-group__legend', renderedLegend);
302
+ this.appendRenderedTextSections(root, renderedBottomText);
299
303
  this.readyPromise = this.createReadyPromise();
300
304
  this.syncLegendStateFromChildren();
301
305
  this.childYDomainSnapshot = this.serializeChildYDomains(width);
@@ -338,11 +342,20 @@ export class ChartGroup {
338
342
  onLegendChange(callback) {
339
343
  return this.legendState.subscribe(callback);
340
344
  }
345
+ resolveAccessibleLabel() {
346
+ return (this.textComponents
347
+ .find((component) => {
348
+ return (component.display &&
349
+ (component.type === 'title' ||
350
+ component.variant === 'title'));
351
+ })
352
+ ?.text.trim() || 'Chart group');
353
+ }
341
354
  async export(format, options) {
342
355
  const container = this.requireRenderedContainer(options);
343
356
  await this.whenReady();
344
357
  const width = options?.width ?? this.resolveContainerWidth(container);
345
- const { svg, height } = await this.exportSVG(width, options);
358
+ const { svg, height } = await this.exportSVG(format, width, options);
346
359
  const content = await this.resolveExportContent(format, svg, width, height, options);
347
360
  if (options?.download) {
348
361
  this.downloadContent(content, format, options);
@@ -701,11 +714,11 @@ export class ChartGroup {
701
714
  legendHost.appendChild(renderedLegend.svg);
702
715
  root.appendChild(legendHost);
703
716
  }
704
- renderTitleSvg(width) {
705
- if (!this.title || !this.title.display) {
717
+ renderTextSvg(width, component) {
718
+ if (!component.display) {
706
719
  return null;
707
720
  }
708
- const height = Math.max(1, this.title.getRequiredSpace().height);
721
+ const height = Math.max(1, component.getRequiredSpace(this.theme).height);
709
722
  const svg = create('svg')
710
723
  .attr('width', width)
711
724
  .attr('height', height)
@@ -714,14 +727,27 @@ export class ChartGroup {
714
727
  if (!svgNode) {
715
728
  return null;
716
729
  }
717
- this.title.render(svg, this.theme, width);
730
+ component.render(svg, this.theme, width);
718
731
  return {
732
+ component,
719
733
  height,
720
734
  svg: svgNode,
721
735
  };
722
736
  }
723
- renderLegendSvg(width) {
724
- if (!this.legend || this.legend.mode === 'hidden') {
737
+ renderTextSvgs(width, position, components = this.textComponents) {
738
+ return components
739
+ .filter((component) => {
740
+ return component.position === position;
741
+ })
742
+ .map((component) => {
743
+ return this.renderTextSvg(width, component);
744
+ })
745
+ .filter((section) => {
746
+ return section !== null;
747
+ });
748
+ }
749
+ renderLegendSvg(width, legend = this.legend) {
750
+ if (!legend || legend.mode === 'hidden') {
725
751
  return null;
726
752
  }
727
753
  const items = this.getLegendItemsForWidth(width);
@@ -750,10 +776,10 @@ export class ChartGroup {
750
776
  fill: item.color,
751
777
  };
752
778
  });
753
- this.legend.estimateLayoutSpace(series, this.theme, width, svgNode);
754
- const height = Math.max(1, this.legend.getMeasuredHeight());
779
+ legend.estimateLayoutSpace(series, this.theme, width, svgNode);
780
+ const height = Math.max(1, legend.getMeasuredHeight());
755
781
  svg.attr('height', height);
756
- this.legend.render(svg, series, this.theme, width);
782
+ legend.render(svg, series, this.theme, width);
757
783
  return {
758
784
  height,
759
785
  svg: svgNode,
@@ -775,15 +801,23 @@ export class ChartGroup {
775
801
  });
776
802
  this.resizeObserver.observe(this.container);
777
803
  }
778
- async exportSVG(width, options) {
804
+ async exportSVG(format, width, options) {
779
805
  this.applyScaleSyncOverrides(width);
780
- const exportLayoutState = this.resolveExportLayoutState(width);
806
+ const baseContext = this.createExportRenderContext(format, width, options);
807
+ const exportLayoutState = this.resolveExportLayoutState(width, baseContext);
781
808
  const layout = this.calculateLayout(width, exportLayoutState.chartAreaHeight, exportLayoutState.defaultChartHeightOverride);
782
809
  const childSvgs = await this.exportLayoutItems(layout.items, options);
783
- const totalHeight = exportLayoutState.titleHeight +
810
+ const totalHeight = exportLayoutState.topTextHeight +
784
811
  layout.chartHeight +
785
- exportLayoutState.legendHeight;
812
+ exportLayoutState.legendHeight +
813
+ exportLayoutState.bottomTextHeight;
786
814
  const exportSvg = this.composeExportSvg(width, totalHeight, layout.chartHeight, exportLayoutState, childSvgs);
815
+ this.runExportHooks({
816
+ ...baseContext,
817
+ height: totalHeight,
818
+ svg: exportSvg,
819
+ }, exportLayoutState);
820
+ this.syncAccessibleLabelFromSvg(exportSvg);
787
821
  return {
788
822
  svg: exportSvg.outerHTML,
789
823
  height: totalHeight,
@@ -805,21 +839,31 @@ export class ChartGroup {
805
839
  }
806
840
  prepareRenderState(container) {
807
841
  const width = this.resolveContainerWidth(container);
808
- const renderedTitle = this.renderTitleSvg(width);
842
+ const renderedTopText = this.renderTextSvgs(width, 'top');
843
+ const renderedBottomText = this.renderTextSvgs(width, 'bottom');
809
844
  const renderedLegend = this.renderLegendSvg(width);
810
- const chartAreaHeight = this.resolveChartAreaHeightConstraint(this.resolveTotalHeightConstraint(container.getBoundingClientRect()), renderedTitle?.height ?? 0, renderedLegend?.height ?? 0);
845
+ const topTextHeight = this.sumRenderedTextHeight(renderedTopText);
846
+ const bottomTextHeight = this.sumRenderedTextHeight(renderedBottomText);
847
+ const chartAreaHeight = this.resolveChartAreaHeightConstraint(this.resolveTotalHeightConstraint(container.getBoundingClientRect()), topTextHeight, (renderedLegend?.height ?? 0) + bottomTextHeight);
811
848
  const layout = this.calculateLayout(width, chartAreaHeight);
812
- const totalHeight = (renderedTitle?.height ?? 0) +
849
+ const totalHeight = topTextHeight +
813
850
  layout.chartHeight +
814
- (renderedLegend?.height ?? 0);
851
+ (renderedLegend?.height ?? 0) +
852
+ bottomTextHeight;
815
853
  return {
816
854
  width,
817
- renderedTitle,
855
+ renderedTopText,
856
+ renderedBottomText,
818
857
  renderedLegend,
819
858
  layout,
820
859
  totalHeight,
821
860
  };
822
861
  }
862
+ sumRenderedTextHeight(sections) {
863
+ return sections.reduce((total, section) => {
864
+ return total + section.height;
865
+ }, 0);
866
+ }
823
867
  createRenderHosts(totalHeight, chartHeight) {
824
868
  const root = document.createElement('div');
825
869
  root.className = 'chart-group';
@@ -842,6 +886,16 @@ export class ChartGroup {
842
886
  host.appendChild(section.svg);
843
887
  root.appendChild(host);
844
888
  }
889
+ appendRenderedTextSections(root, sections) {
890
+ sections.forEach((section) => {
891
+ this.appendRenderedSection(root, this.resolveTextSectionClassName(section.component), section);
892
+ });
893
+ }
894
+ resolveTextSectionClassName(component) {
895
+ return component.type === 'title'
896
+ ? 'chart-group__title'
897
+ : 'chart-group__text';
898
+ }
845
899
  renderLayoutItems(chartLayer, items) {
846
900
  items.forEach((item) => {
847
901
  const chartHost = this.createChartHost(item);
@@ -922,37 +976,103 @@ export class ChartGroup {
922
976
  margin: options?.pdfMargin ?? 0,
923
977
  });
924
978
  }
925
- resolveExportLayoutState(width) {
926
- const renderedTitle = this.renderTitleSvg(width);
927
- const renderedLegend = this.renderLegendSvg(width);
928
- const titleHeight = renderedTitle?.height ?? 0;
979
+ resolveExportLayoutState(width, context) {
980
+ const textComponents = this.textComponents
981
+ .map((component) => {
982
+ return this.createExportComponent(component, context);
983
+ })
984
+ .filter((component) => {
985
+ return component !== null;
986
+ });
987
+ const legend = this.createExportComponent(this.legend, context);
988
+ const renderedTopText = this.renderTextSvgs(width, 'top', textComponents);
989
+ const renderedBottomText = this.renderTextSvgs(width, 'bottom', textComponents);
990
+ const renderedLegend = this.renderLegendSvg(width, legend);
991
+ return this.createExportLayoutState(renderedTopText, renderedBottomText, renderedLegend);
992
+ }
993
+ createExportLayoutState(renderedTopText, renderedBottomText, renderedLegend) {
994
+ const topTextHeight = this.sumRenderedTextHeight(renderedTopText);
995
+ const bottomTextHeight = this.sumRenderedTextHeight(renderedBottomText);
929
996
  const legendHeight = renderedLegend?.height ?? 0;
930
997
  if (this.configuredHeight !== undefined) {
931
998
  return {
932
- renderedTitle,
999
+ renderedTopText,
1000
+ renderedBottomText,
933
1001
  renderedLegend,
934
- titleHeight,
1002
+ topTextHeight,
1003
+ bottomTextHeight,
935
1004
  legendHeight,
936
- chartAreaHeight: this.resolveChartAreaHeightConstraint(this.configuredHeight, titleHeight, legendHeight),
1005
+ chartAreaHeight: this.resolveChartAreaHeightConstraint(this.configuredHeight, topTextHeight, legendHeight + bottomTextHeight),
937
1006
  };
938
1007
  }
939
1008
  const defaultChartHeightOverride = this.resolveLiveChartHeightOverride();
940
1009
  return {
941
- renderedTitle,
1010
+ renderedTopText,
1011
+ renderedBottomText,
942
1012
  renderedLegend,
943
- titleHeight,
1013
+ topTextHeight,
1014
+ bottomTextHeight,
944
1015
  legendHeight,
945
1016
  defaultChartHeightOverride,
946
1017
  };
947
1018
  }
1019
+ createExportRenderContext(format, width, options) {
1020
+ return {
1021
+ format,
1022
+ options,
1023
+ width,
1024
+ height: this.resolveBaseExportHeight(width),
1025
+ };
1026
+ }
1027
+ resolveBaseExportHeight(width) {
1028
+ const state = this.createExportLayoutState(this.renderTextSvgs(width, 'top'), this.renderTextSvgs(width, 'bottom'), this.renderLegendSvg(width));
1029
+ const layout = this.calculateLayout(width, state.chartAreaHeight, state.defaultChartHeightOverride);
1030
+ return (state.topTextHeight +
1031
+ layout.chartHeight +
1032
+ state.legendHeight +
1033
+ state.bottomTextHeight);
1034
+ }
1035
+ createExportComponent(component, context) {
1036
+ if (!component) {
1037
+ return null;
1038
+ }
1039
+ const exportable = component;
1040
+ const currentConfig = exportable.getExportConfig?.() ?? {};
1041
+ const result = exportable.exportHooks?.beforeRender?.call(component, context, currentConfig);
1042
+ if (!result ||
1043
+ typeof result !== 'object' ||
1044
+ !exportable.createExportComponent) {
1045
+ return component;
1046
+ }
1047
+ return exportable.createExportComponent(result);
1048
+ }
1049
+ runExportHooks(context, state) {
1050
+ [
1051
+ ...state.renderedTopText.map((rendered) => {
1052
+ return { component: rendered.component, rendered };
1053
+ }),
1054
+ { component: this.legend, rendered: state.renderedLegend },
1055
+ ...state.renderedBottomText.map((rendered) => {
1056
+ return { component: rendered.component, rendered };
1057
+ }),
1058
+ ].forEach(({ component, rendered }) => {
1059
+ if (!component || !rendered) {
1060
+ return;
1061
+ }
1062
+ const exportable = component;
1063
+ exportable.exportHooks?.before?.call(component, context);
1064
+ });
1065
+ }
948
1066
  resolveLiveChartHeightOverride() {
949
1067
  if (!this.container) {
950
1068
  return undefined;
951
1069
  }
952
1070
  const liveWidth = this.resolveContainerWidth(this.container);
953
- const liveTitle = this.renderTitleSvg(liveWidth);
1071
+ const liveTopText = this.renderTextSvgs(liveWidth, 'top');
1072
+ const liveBottomText = this.renderTextSvgs(liveWidth, 'bottom');
954
1073
  const liveLegend = this.renderLegendSvg(liveWidth);
955
- const liveChartAreaHeight = this.resolveChartAreaHeightConstraint(this.resolveTotalHeightConstraint(this.container.getBoundingClientRect()), liveTitle?.height ?? 0, liveLegend?.height ?? 0);
1074
+ const liveChartAreaHeight = this.resolveChartAreaHeightConstraint(this.resolveTotalHeightConstraint(this.container.getBoundingClientRect()), this.sumRenderedTextHeight(liveTopText), (liveLegend?.height ?? 0) +
1075
+ this.sumRenderedTextHeight(liveBottomText));
956
1076
  if (liveChartAreaHeight === undefined) {
957
1077
  return undefined;
958
1078
  }
@@ -982,28 +1102,44 @@ export class ChartGroup {
982
1102
  exportSvg.setAttribute('width', String(width));
983
1103
  exportSvg.setAttribute('height', String(totalHeight));
984
1104
  exportSvg.setAttribute('role', 'img');
985
- exportSvg.setAttribute('aria-label', this.title?.text.trim() || 'Chart group');
986
- if (state.renderedTitle) {
987
- state.renderedTitle.svg.setAttribute('x', '0');
988
- state.renderedTitle.svg.setAttribute('y', '0');
989
- exportSvg.appendChild(document.importNode(state.renderedTitle.svg, true));
990
- }
1105
+ exportSvg.setAttribute('aria-label', this.resolveAccessibleLabel());
1106
+ let yOffset = 0;
1107
+ this.appendExportTextSections(exportSvg, state.renderedTopText, yOffset);
1108
+ yOffset += state.topTextHeight;
991
1109
  childSvgs.forEach((item) => {
992
1110
  const parsed = parser.parseFromString(item.svg, 'image/svg+xml');
993
1111
  const imported = document.importNode(parsed.documentElement, true);
994
1112
  imported.setAttribute('x', String(item.x));
995
- imported.setAttribute('y', String(state.titleHeight + item.y));
1113
+ imported.setAttribute('y', String(yOffset + item.y));
996
1114
  imported.setAttribute('width', String(item.width));
997
1115
  imported.setAttribute('height', String(item.height));
998
1116
  exportSvg.appendChild(imported);
999
1117
  });
1118
+ yOffset += chartHeight;
1000
1119
  if (state.renderedLegend) {
1001
1120
  state.renderedLegend.svg.setAttribute('x', '0');
1002
- state.renderedLegend.svg.setAttribute('y', String(state.titleHeight + chartHeight));
1121
+ state.renderedLegend.svg.setAttribute('y', String(yOffset));
1003
1122
  exportSvg.appendChild(document.importNode(state.renderedLegend.svg, true));
1123
+ yOffset += state.legendHeight;
1004
1124
  }
1125
+ this.appendExportTextSections(exportSvg, state.renderedBottomText, yOffset);
1005
1126
  return exportSvg;
1006
1127
  }
1128
+ syncAccessibleLabelFromSvg(svg) {
1129
+ const titleText = svg
1130
+ .querySelector('.title text, .text--title text')
1131
+ ?.textContent?.trim();
1132
+ svg.setAttribute('aria-label', titleText || this.resolveAccessibleLabel());
1133
+ }
1134
+ appendExportTextSections(exportSvg, sections, startY) {
1135
+ let yOffset = startY;
1136
+ sections.forEach((section) => {
1137
+ section.svg.setAttribute('x', '0');
1138
+ section.svg.setAttribute('y', String(yOffset));
1139
+ exportSvg.appendChild(document.importNode(section.svg, true));
1140
+ yOffset += section.height;
1141
+ });
1142
+ }
1007
1143
  validateResponsiveConfig(responsive) {
1008
1144
  const breakpoints = responsive?.breakpoints;
1009
1145
  if (!breakpoints) {
@@ -1,5 +1,5 @@
1
- import type { ExportHooks } from './types.js';
2
- export type ChartComponentType = 'line' | 'scatter' | 'area' | 'bar' | 'xAxis' | 'yAxis' | 'grid' | 'tooltip' | 'legend' | 'title' | 'donutCenterContent';
1
+ import type { ChartTheme, ExportHooks } from './types.js';
2
+ export type ChartComponentType = 'line' | 'scatter' | 'area' | 'bar' | 'xAxis' | 'yAxis' | 'grid' | 'tooltip' | 'legend' | 'text' | 'title' | 'donutCenterContent';
3
3
  export interface ChartComponentBase {
4
4
  type: ChartComponentType;
5
5
  }
@@ -13,7 +13,7 @@ export type ComponentSpace = {
13
13
  position: 'top' | 'bottom' | 'left' | 'right';
14
14
  };
15
15
  export interface LayoutAwareComponentBase extends ChartComponentBase {
16
- getRequiredSpace(): ComponentSpace;
16
+ getRequiredSpace(theme?: ChartTheme): ComponentSpace;
17
17
  }
18
18
  export interface LayoutAwareComponent<TConfig = unknown> extends ChartComponent<TConfig>, LayoutAwareComponentBase {
19
19
  }
@@ -13,6 +13,7 @@ export type NormalizedChartData = {
13
13
  data: DataItem[];
14
14
  schema: DataSchema;
15
15
  };
16
+ export declare function attachDataSchema<T extends ChartData>(data: T, schema: DataSchema): T;
16
17
  export declare function hasEmptyGroupNames(data: GroupedDataGroup[]): boolean;
17
18
  export declare function isGroupedData(data: unknown): data is GroupedDataGroup[];
18
19
  export declare function resolveDataSchema(data: ChartData): DataSchema;
@@ -3,9 +3,78 @@ export const GROUPED_CATEGORY_LABEL_KEY = '__iis_grouped_category_label__';
3
3
  export const GROUPED_GROUP_LABEL_KEY = '__iis_grouped_group_label__';
4
4
  export const GROUPED_GAP_TICK_PREFIX = '__iis_group_gap__';
5
5
  export const GROUPED_EMPTY_GROUP_ERROR = 'Grouped data requires non-empty group names';
6
+ const DATA_SCHEMA_KEY = Symbol('iisChartDataSchema');
7
+ const CATEGORY_KEY_NAMES = new Set(['category', 'kategori', 'label', 'name']);
8
+ export function attachDataSchema(data, schema) {
9
+ Object.defineProperty(data, DATA_SCHEMA_KEY, {
10
+ value: schema,
11
+ enumerable: false,
12
+ configurable: true,
13
+ });
14
+ return data;
15
+ }
6
16
  function isRecord(value) {
7
17
  return typeof value === 'object' && value !== null;
8
18
  }
19
+ function isBlankValue(value) {
20
+ if (value === null || value === undefined) {
21
+ return true;
22
+ }
23
+ return typeof value === 'string' && value.trim() === '';
24
+ }
25
+ function isNumericLikeValue(value) {
26
+ if (typeof value === 'number') {
27
+ return Number.isFinite(value);
28
+ }
29
+ if (typeof value !== 'string') {
30
+ return false;
31
+ }
32
+ const trimmed = value.trim();
33
+ return trimmed.length > 0 && Number.isFinite(parseFloat(trimmed));
34
+ }
35
+ function collectDataKeys(data) {
36
+ const seen = new Set();
37
+ const keys = [];
38
+ data.forEach((row) => {
39
+ Object.keys(row).forEach((key) => {
40
+ if (seen.has(key)) {
41
+ return;
42
+ }
43
+ seen.add(key);
44
+ keys.push(key);
45
+ });
46
+ });
47
+ return keys;
48
+ }
49
+ function hasOnlyNonNumericValues(data, key) {
50
+ let hasValue = false;
51
+ return (data.every((row) => {
52
+ const value = row[key];
53
+ if (isBlankValue(value)) {
54
+ return true;
55
+ }
56
+ hasValue = true;
57
+ return !isNumericLikeValue(value);
58
+ }) && hasValue);
59
+ }
60
+ function isNamedCategoryKey(key) {
61
+ return CATEGORY_KEY_NAMES.has(key.trim().toLowerCase());
62
+ }
63
+ function resolveCategoryKey(data, fallback) {
64
+ const keys = collectDataKeys(data);
65
+ const namedCategoryKey = keys.find(isNamedCategoryKey);
66
+ if (namedCategoryKey) {
67
+ return namedCategoryKey;
68
+ }
69
+ const nonNumericCategoryKey = keys.find((key) => hasOnlyNonNumericValues(data, key));
70
+ return nonNumericCategoryKey ?? keys[0] ?? fallback;
71
+ }
72
+ function resolveMetricKeys(data, categoryKey) {
73
+ return collectDataKeys(data).filter((key) => key !== categoryKey);
74
+ }
75
+ function getAttachedDataSchema(data) {
76
+ return data[DATA_SCHEMA_KEY] ?? null;
77
+ }
9
78
  function hasNonEmptyGroupName(value) {
10
79
  return typeof value === 'string' && value.trim().length > 0;
11
80
  }
@@ -38,56 +107,27 @@ function resolveFlatSchema(data) {
38
107
  metricKeys: [],
39
108
  };
40
109
  }
41
- const keys = Object.keys(data[0]);
42
- const categoryKey = keys[0] ?? 'column';
43
- const metricKeys = [];
44
- const seen = new Set();
45
- data.forEach((row) => {
46
- Object.keys(row).forEach((key) => {
47
- if (key === categoryKey || seen.has(key)) {
48
- return;
49
- }
50
- seen.add(key);
51
- metricKeys.push(key);
52
- });
53
- });
110
+ const categoryKey = resolveCategoryKey(data, 'column');
54
111
  return {
55
112
  grouped: false,
56
113
  categoryKey,
57
- metricKeys,
114
+ metricKeys: resolveMetricKeys(data, categoryKey),
58
115
  };
59
116
  }
60
117
  function resolveGroupedSchema(data) {
61
- let firstRow = null;
62
- for (const group of data) {
63
- if (group.data.length > 0) {
64
- firstRow = group.data[0];
65
- break;
66
- }
67
- }
68
- const categoryKey = firstRow
69
- ? (Object.keys(firstRow)[0] ?? 'category')
70
- : 'category';
71
- const metricKeys = [];
72
- const seen = new Set();
73
- data.forEach((group) => {
74
- group.data.forEach((row) => {
75
- Object.keys(row).forEach((key) => {
76
- if (key === categoryKey || seen.has(key)) {
77
- return;
78
- }
79
- seen.add(key);
80
- metricKeys.push(key);
81
- });
82
- });
83
- });
118
+ const rows = data.flatMap((group) => group.data);
119
+ const categoryKey = rows.length > 0 ? resolveCategoryKey(rows, 'category') : 'category';
84
120
  return {
85
121
  grouped: true,
86
122
  categoryKey,
87
- metricKeys,
123
+ metricKeys: resolveMetricKeys(rows, categoryKey),
88
124
  };
89
125
  }
90
126
  export function resolveDataSchema(data) {
127
+ const attachedSchema = getAttachedDataSchema(data);
128
+ if (attachedSchema) {
129
+ return attachedSchema;
130
+ }
91
131
  if (isGroupedData(data)) {
92
132
  return resolveGroupedSchema(data);
93
133
  }
@@ -95,7 +135,7 @@ export function resolveDataSchema(data) {
95
135
  }
96
136
  function normalizeGroupedData(data) {
97
137
  assertNonEmptyGroupNames(data);
98
- const schema = resolveGroupedSchema(data);
138
+ const schema = resolveDataSchema(data);
99
139
  const categoryKey = schema.categoryKey;
100
140
  const normalizedRows = data.flatMap((group, groupIndex) => {
101
141
  return group.data.map((row, rowIndex) => {
@@ -119,6 +159,6 @@ export function normalizeChartData(data) {
119
159
  }
120
160
  return {
121
161
  data,
122
- schema: resolveFlatSchema(data),
162
+ schema: resolveDataSchema(data),
123
163
  };
124
164
  }
@@ -1,4 +1,4 @@
1
- import { GROUPED_EMPTY_GROUP_ERROR, hasEmptyGroupNames, resolveDataSchema, } from './grouped-data.js';
1
+ import { GROUPED_EMPTY_GROUP_ERROR, attachDataSchema, hasEmptyGroupNames, resolveDataSchema, } from './grouped-data.js';
2
2
  export const GROUPED_STRING_EMPTY_INPUT_ERROR = 'Input string is empty';
3
3
  export const GROUPED_STRING_MISSING_HEADER_ERROR = 'Unable to resolve grouped header row from input string';
4
4
  export const GROUPED_STRING_FIRST_ROW_BLANK_GROUP_ERROR = 'First data row cannot have a blank group label';
@@ -212,7 +212,11 @@ function parseGroupedRows(headerRow, dataRows, options) {
212
212
  headers: ['', categoryKey, ...metricKeys],
213
213
  categoryKey,
214
214
  metricKeys,
215
- data: rebuilt.data,
215
+ data: attachDataSchema(rebuilt.data, {
216
+ grouped: true,
217
+ categoryKey,
218
+ metricKeys,
219
+ }),
216
220
  };
217
221
  }
218
222
  function rowHasCategoryOrMetricValues(row) {
@@ -258,7 +262,7 @@ function parseFlatRows(headerRow, dataRows, options) {
258
262
  return '';
259
263
  });
260
264
  const columns = toUniqueNames(columnCandidates, 'column');
261
- return dataRows
265
+ const rows = dataRows
262
266
  .filter((row) => rowHasMetricContent(row))
263
267
  .map((row) => {
264
268
  const item = {};
@@ -267,6 +271,11 @@ function parseFlatRows(headerRow, dataRows, options) {
267
271
  });
268
272
  return item;
269
273
  });
274
+ return attachDataSchema(rows, {
275
+ grouped: false,
276
+ categoryKey: columns[0] ?? options?.categoryKey?.trim() ?? 'category',
277
+ metricKeys: columns.slice(1),
278
+ });
270
279
  }
271
280
  export function parseGroupedTabularString(input, options) {
272
281
  const { headerRow, dataRows } = parseStringTable(input, options);
@@ -36,7 +36,7 @@ export class LayoutManager {
36
36
  let marginLeft = this.theme.margins.left;
37
37
  // Accumulate space requirements from components
38
38
  for (const component of components) {
39
- const space = component.getRequiredSpace();
39
+ const space = component.getRequiredSpace(this.theme);
40
40
  switch (space.position) {
41
41
  case 'top':
42
42
  marginTop += space.height;
@@ -88,14 +88,14 @@ export class LayoutManager {
88
88
  let leftOffset = 0;
89
89
  let rightOffset = 0;
90
90
  for (const component of components) {
91
- const space = component.getRequiredSpace();
91
+ const space = component.getRequiredSpace(this.theme);
92
92
  let x = 0;
93
93
  let y = 0;
94
94
  switch (space.position) {
95
95
  case 'top':
96
- // Position above plot area, stacking upward
96
+ // Position above plot area, stacking downward from the outer margin.
97
97
  x = 0;
98
- y = this.plotBounds.top - topOffset - space.height;
98
+ y = this.theme.margins.top + topOffset;
99
99
  topOffset += space.height;
100
100
  break;
101
101
  case 'bottom':