@opendata-ai/openchart-engine 7.1.3 → 7.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.1.3",
3
+ "version": "7.1.4",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.1.3",
51
+ "@opendata-ai/openchart-core": "7.1.4",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -227,7 +227,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
227
227
  "connector": undefined,
228
228
  "style": {
229
229
  "dominantBaseline": "central",
230
- "fill": "#111111",
230
+ "fill": "#ffffff",
231
231
  "fontFamily": "system-ui, -apple-system, sans-serif",
232
232
  "fontSize": 11,
233
233
  "fontWeight": 600,
@@ -260,7 +260,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
260
260
  "connector": undefined,
261
261
  "style": {
262
262
  "dominantBaseline": "central",
263
- "fill": "#111111",
263
+ "fill": "#ffffff",
264
264
  "fontFamily": "system-ui, -apple-system, sans-serif",
265
265
  "fontSize": 11,
266
266
  "fontWeight": 600,
@@ -293,7 +293,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
293
293
  "connector": undefined,
294
294
  "style": {
295
295
  "dominantBaseline": "central",
296
- "fill": "#111111",
296
+ "fill": "#ffffff",
297
297
  "fontFamily": "system-ui, -apple-system, sans-serif",
298
298
  "fontSize": 11,
299
299
  "fontWeight": 600,
@@ -126,6 +126,84 @@ describe('computeDimensions', () => {
126
126
  expect(withLegend.chartArea.height).toBeLessThan(withoutLegend.chartArea.height);
127
127
  });
128
128
 
129
+ it('reserves enough left margin for y-axis title to clear tick labels', () => {
130
+ const specWithYTitle: NormalizedChartSpec = {
131
+ ...baseSpec,
132
+ encoding: {
133
+ x: { field: 'date', type: 'temporal' },
134
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Share of districts' } },
135
+ },
136
+ };
137
+ const specWithoutYTitle: NormalizedChartSpec = {
138
+ ...baseSpec,
139
+ encoding: {
140
+ x: { field: 'date', type: 'temporal' },
141
+ y: { field: 'value', type: 'quantitative' },
142
+ },
143
+ };
144
+
145
+ const dimsWithTitle = computeDimensions(
146
+ specWithYTitle,
147
+ { width: 600, height: 400 },
148
+ emptyLegend,
149
+ lightTheme,
150
+ );
151
+ const dimsWithoutTitle = computeDimensions(
152
+ specWithoutYTitle,
153
+ { width: 600, height: 400 },
154
+ emptyLegend,
155
+ lightTheme,
156
+ );
157
+
158
+ // A chart with a y-axis title needs more left margin than one without
159
+ expect(dimsWithTitle.margins.left).toBeGreaterThan(dimsWithoutTitle.margins.left);
160
+ // The difference should be at least enough for the rotated title glyph
161
+ // plus breathing room (halfGlyph ~7 + trailing pad 4 = 11px minimum)
162
+ expect(dimsWithTitle.margins.left - dimsWithoutTitle.margins.left).toBeGreaterThanOrEqual(11);
163
+ });
164
+
165
+ it('y-axis title margin scales with tick label width', () => {
166
+ const smallValues: NormalizedChartSpec = {
167
+ ...baseSpec,
168
+ data: [
169
+ { date: '2020-01-01', value: 5 },
170
+ { date: '2021-01-01', value: 9 },
171
+ ],
172
+ encoding: {
173
+ x: { field: 'date', type: 'temporal' },
174
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Count' } },
175
+ },
176
+ };
177
+ const largeValues: NormalizedChartSpec = {
178
+ ...baseSpec,
179
+ data: [
180
+ { date: '2020-01-01', value: 1_500_000 },
181
+ { date: '2021-01-01', value: 2_000_000 },
182
+ ],
183
+ encoding: {
184
+ x: { field: 'date', type: 'temporal' },
185
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Revenue ($)' } },
186
+ },
187
+ };
188
+
189
+ const dimsSmall = computeDimensions(
190
+ smallValues,
191
+ { width: 600, height: 400 },
192
+ emptyLegend,
193
+ lightTheme,
194
+ );
195
+ const dimsLarge = computeDimensions(
196
+ largeValues,
197
+ { width: 600, height: 400 },
198
+ emptyLegend,
199
+ lightTheme,
200
+ );
201
+
202
+ // Larger numeric values produce wider tick labels (e.g. "1.5M" vs "0.0"),
203
+ // so the y-axis title margin should grow to keep clearance
204
+ expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
205
+ });
206
+
129
207
  it('applies dark mode theme adaptation', () => {
130
208
  const lightDims = computeDimensions(
131
209
  baseSpec,
@@ -382,6 +460,36 @@ describe('computeDimensions', () => {
382
460
  });
383
461
  });
384
462
 
463
+ it('avoids doubling axisMargin and legendGap when top legend is present', () => {
464
+ const dimsWithTopLegend = computeDimensions(
465
+ baseSpec,
466
+ { width: 600, height: 400 },
467
+ topLegend,
468
+ lightTheme,
469
+ );
470
+ const dimsNoLegend = computeDimensions(
471
+ baseSpec,
472
+ { width: 600, height: 400 },
473
+ emptyLegend,
474
+ lightTheme,
475
+ );
476
+
477
+ // Without a top legend, the full topAxisGap (axisMargin + inlineTickOverhang)
478
+ // separates chrome from chart area. With a top legend, legendGap already
479
+ // provides separation, so only inlineTickOverhang is added (not the full
480
+ // topAxisGap). This means the chart area gains back ~axisMargin (6px)
481
+ // that would otherwise be redundant spacing.
482
+ //
483
+ // The top margin with legend includes: legendHeight(28) + legendGap(8)
484
+ // + inlineTickOverhang(17) instead of the no-legend topAxisGap(23).
485
+ // Net: margin delta = 28 + 8 + 17 - 23 = 30px.
486
+ // If axisMargin were doubling up: 28 + 8 + 23 - 23 = 36px.
487
+ const topMarginDelta = dimsWithTopLegend.margins.top - dimsNoLegend.margins.top;
488
+ expect(topMarginDelta).toBeLessThan(topLegend.bounds.height + legendGap(600) + 1);
489
+ // With a legend present, the chart area should still be shorter
490
+ expect(dimsWithTopLegend.chartArea.height).toBeLessThan(dimsNoLegend.chartArea.height);
491
+ });
492
+
385
493
  it('tightens legend gap on narrow viewports', () => {
386
494
  const wideDims = computeDimensions(
387
495
  baseSpec,
@@ -217,3 +217,75 @@ describe('computeBarLabels with Unicode minus (U+2212) in aria values', () => {
217
217
  expect(labels[1].text).toBe('\u22125%'); // −5%
218
218
  });
219
219
  });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Dark-mode inside-label color
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe('computeBarLabels inside-label color by mode', () => {
226
+ // Wide bar (>= 40px) so the label is placed inside and uses pickLabelColor.
227
+ function makeFilledMark(fill: string): RectMark {
228
+ return {
229
+ type: 'rect',
230
+ x: 0,
231
+ y: 0,
232
+ width: 200,
233
+ height: 25,
234
+ fill,
235
+ data: { category: 'A', value: 100 },
236
+ aria: { label: 'A: 100' },
237
+ };
238
+ }
239
+
240
+ it('mid-tone fill gets white inside-label in light mode, dark in dark mode', () => {
241
+ const slate = [makeFilledMark('#94a3b8')];
242
+ const light = computeBarLabels(
243
+ slate,
244
+ chartArea,
245
+ 'all',
246
+ undefined,
247
+ undefined,
248
+ undefined,
249
+ undefined,
250
+ false,
251
+ );
252
+ const dark = computeBarLabels(
253
+ slate,
254
+ chartArea,
255
+ 'all',
256
+ undefined,
257
+ undefined,
258
+ undefined,
259
+ undefined,
260
+ true,
261
+ );
262
+ expect(light[0].style.fill).toBe('#ffffff');
263
+ expect(dark[0].style.fill).toBe('#111111');
264
+ });
265
+
266
+ it('saturated fill keeps white inside-label in both modes', () => {
267
+ const red = [makeFilledMark('#c0392b')];
268
+ const light = computeBarLabels(
269
+ red,
270
+ chartArea,
271
+ 'all',
272
+ undefined,
273
+ undefined,
274
+ undefined,
275
+ undefined,
276
+ false,
277
+ );
278
+ const dark = computeBarLabels(
279
+ red,
280
+ chartArea,
281
+ 'all',
282
+ undefined,
283
+ undefined,
284
+ undefined,
285
+ undefined,
286
+ true,
287
+ );
288
+ expect(light[0].style.fill).toBe('#ffffff');
289
+ expect(dark[0].style.fill).toBe('#ffffff');
290
+ });
291
+ });
@@ -13,7 +13,7 @@ import { computeBarLabels } from './labels';
13
13
  // Bar chart renderer
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
16
+ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
17
17
  const marks = computeBarMarks(spec, scales, chartArea, strategy);
18
18
 
19
19
  // Compute and attach value labels (respects spec.labels.density)
@@ -27,6 +27,7 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
27
27
  spec.labels.prefix,
28
28
  valueField,
29
29
  spec.labels.color,
30
+ theme.isDark,
30
31
  );
31
32
  for (let i = 0; i < marks.length && i < labels.length; i++) {
32
33
  marks[i].label = labels[i];
@@ -95,6 +95,7 @@ export function computeBarLabels(
95
95
  labelPrefix?: string,
96
96
  valueField?: string,
97
97
  labelColor?: string,
98
+ darkMode = false,
98
99
  ): ResolvedLabel[] {
99
100
  const targetMarks = filterByDensity(marks, density);
100
101
 
@@ -150,18 +151,18 @@ export function computeBarLabels(
150
151
  if (isStacked && isInside) {
151
152
  // Stacked: centered within segment
152
153
  anchorX = mark.x + mark.width / 2;
153
- fill = pickLabelColor(bgColor);
154
+ fill = pickLabelColor(bgColor, darkMode);
154
155
  textAnchor = 'middle';
155
156
  } else if (isInside) {
156
157
  if (isNegative) {
157
158
  // Negative bar: left-aligned within bar (bar extends leftward)
158
159
  anchorX = mark.x + LABEL_PADDING;
159
- fill = pickLabelColor(bgColor);
160
+ fill = pickLabelColor(bgColor, darkMode);
160
161
  textAnchor = 'start';
161
162
  } else {
162
163
  // Positive bar: right-aligned within bar
163
164
  anchorX = mark.x + mark.width - LABEL_PADDING;
164
- fill = pickLabelColor(bgColor);
165
+ fill = pickLabelColor(bgColor, darkMode);
165
166
  textAnchor = 'end';
166
167
  }
167
168
  } else {
@@ -23,6 +23,7 @@ import type {
23
23
  ResolvedTheme,
24
24
  } from '@opendata-ai/openchart-core';
25
25
  import {
26
+ AXIS_TITLE_GAP,
26
27
  AXIS_TITLE_TRAILING_PAD,
27
28
  BREAKPOINT_COMPACT_MAX,
28
29
  computeChrome,
@@ -633,9 +634,8 @@ export function computeDimensions(
633
634
  );
634
635
  }
635
636
  // Mirror the renderer's dynamic offset formula:
636
- // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + 8px gap
637
- // titleOffset = max(dynamicOffset, AXIS_TITLE_OFFSET_COMPACT)
638
- const AXIS_TITLE_GAP = 8;
637
+ // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + AXIS_TITLE_GAP(14)
638
+ // titleOffset = max(dynamicOffset, getAxisTitleOffset(width))
639
639
  const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
640
640
  const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
641
641
  const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
@@ -658,6 +658,8 @@ export function computeDimensions(
658
658
  // here. The legend lands below the x-axis tick row (which is reserved via
659
659
  // `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
660
660
  // stacks underneath the legend band rather than colliding with it.
661
+ const hasTopLegend =
662
+ 'entries' in legendLayout && legendLayout.entries.length > 0 && legendLayout.position === 'top';
661
663
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
662
664
  const gap = legendGap(width);
663
665
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
@@ -669,9 +671,12 @@ export function computeDimensions(
669
671
  // above.
670
672
  }
671
673
 
672
- // Add topAxisGap after legend so it sits between the legend (or chrome
673
- // when there's no legend) and the chart area.
674
- margins.top += topAxisGap;
674
+ // topAxisGap sits between the legend (or chrome, if no legend) and the
675
+ // chart area. When a top legend is present, the legendGap already provides
676
+ // breathing room, so only the inlineTickOverhang is needed (the axisMargin
677
+ // component would double up with legendGap). Without a top legend, the
678
+ // full topAxisGap (axisMargin + inlineTickOverhang) applies.
679
+ margins.top += hasTopLegend ? inlineTickOverhang : topAxisGap;
675
680
 
676
681
  // Chart area is what's left after margins
677
682
  let chartArea: Rect = {
@@ -707,6 +712,7 @@ export function computeDimensions(
707
712
  // until resolveMetrics decides otherwise).
708
713
  const fallbackTopAxisGap =
709
714
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
715
+ const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
710
716
  const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
711
717
  const topDelta = margins.top - newTop;
712
718
  const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
@@ -721,7 +727,7 @@ export function computeDimensions(
721
727
  legendLayout.position === 'top'
722
728
  ? legendLayout.bounds.height + gap
723
729
  : 0) +
724
- fallbackTopAxisGap;
730
+ fallbackEffectiveAxisGap;
725
731
  margins.bottom = newBottom;
726
732
 
727
733
  chartArea = {
@@ -679,8 +679,17 @@ export function computeScales(
679
679
  xStackEnabled
680
680
  ) {
681
681
  if (encoding.x.stack === 'normalize') {
682
- // Normalize: domain is [0, 1]
683
- xChannel = { ...encoding.x, scale: { ...encoding.x.scale, domain: [0, 1], nice: false } };
682
+ // Normalize: domain is [0, 1], default to percentage axis
683
+ const existingAxis = encoding.x.axis;
684
+ const axis =
685
+ existingAxis === false || existingAxis?.format
686
+ ? existingAxis
687
+ : { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
688
+ xChannel = {
689
+ ...encoding.x,
690
+ scale: { ...encoding.x.scale, domain: [0, 1], nice: false },
691
+ axis,
692
+ };
684
693
  } else if (encoding.x.stack === 'center') {
685
694
  // Center: compute max half-sum for symmetric domain
686
695
  const yField = encoding.y?.field;
@@ -785,8 +794,17 @@ export function computeScales(
785
794
  }
786
795
  if ((isBarStacked || isAreaStacked) && encoding.color && encoding.y.type === 'quantitative') {
787
796
  if (encoding.y.stack === 'normalize') {
788
- // Normalize: domain is [0, 1] (VL convention)
789
- yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
797
+ // Normalize: domain is [0, 1] (VL convention), default to percentage axis
798
+ const existingAxis = encoding.y.axis;
799
+ const axis =
800
+ existingAxis === false || existingAxis?.format
801
+ ? existingAxis
802
+ : { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
803
+ yChannel = {
804
+ ...encoding.y,
805
+ scale: { ...encoding.y.scale, domain: [0, 1], nice: false },
806
+ axis,
807
+ };
790
808
  } else if (encoding.y.stack === 'center') {
791
809
  // Center: compute max half-sum for symmetric domain
792
810
  const xField = encoding.x?.field;