@opendata-ai/openchart-engine 6.24.0 → 6.24.1

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": "6.24.0",
3
+ "version": "6.24.1",
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": "6.24.0",
51
+ "@opendata-ai/openchart-core": "6.24.1",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -354,6 +354,17 @@ describe('computeLegend', () => {
354
354
  expect(legend.entries).toHaveLength(3);
355
355
  });
356
356
 
357
+ it('preserves legend when any legend config is present (e.g. position)', () => {
358
+ const spec: NormalizedChartSpec = {
359
+ ...lineWithLabels,
360
+ legend: { position: 'top' },
361
+ hiddenSeries: [],
362
+ seriesStyles: {},
363
+ };
364
+ const legend = computeLegend(spec, fullStrategy, theme, chartArea);
365
+ expect(legend.entries).toHaveLength(3);
366
+ });
367
+
357
368
  it('preserves legend when labels density is none', () => {
358
369
  const spec: NormalizedChartSpec = {
359
370
  ...lineWithLabels,
@@ -45,14 +45,15 @@ export function computeAnnotations(
45
45
  obstacles: Rect[] = [],
46
46
  svgDimensions?: { width: number; height: number },
47
47
  ): ResolvedAnnotation[] {
48
- // At compact breakpoints, skip all annotations
49
- if (strategy.annotationPosition === 'tooltip-only') {
50
- return [];
51
- }
48
+ const isCompact = strategy.annotationPosition === 'tooltip-only';
52
49
 
53
50
  const annotations: ResolvedAnnotation[] = [];
54
51
 
55
52
  for (const annotation of spec.annotations) {
53
+ // At compact breakpoints, skip annotations unless they opt out with responsive: false
54
+ if (isCompact && annotation.responsive !== false) {
55
+ continue;
56
+ }
56
57
  let resolved: ResolvedAnnotation | null = null;
57
58
 
58
59
  switch (annotation.type) {
@@ -43,9 +43,11 @@ export function resolveRefLineAnnotation(
43
43
  return null;
44
44
  }
45
45
 
46
- // Determine dash pattern from style
46
+ // Determine dash pattern: strokeDash array wins, then style string
47
47
  let strokeDasharray: string | undefined;
48
- if (annotation.style === 'dashed' || annotation.style === undefined) {
48
+ if (annotation.strokeDash && annotation.strokeDash.length > 0) {
49
+ strokeDasharray = annotation.strokeDash.join(' ');
50
+ } else if (annotation.style === 'dashed' || annotation.style === undefined) {
49
51
  strokeDasharray = DEFAULT_REFLINE_DASH;
50
52
  } else if (annotation.style === 'dotted') {
51
53
  strokeDasharray = '2 2';
@@ -20,6 +20,7 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
+ findAccessibleColor,
23
24
  getRepresentativeColor,
24
25
  resolveCollisions,
25
26
  } from '@opendata-ai/openchart-core';
@@ -137,6 +138,8 @@ export function computeBarLabels(
137
138
 
138
139
  // Determine if label goes inside or outside the bar
139
140
  const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
141
+ const isNegative = Number.isFinite(rawNum) ? rawNum < 0 : false;
142
+ const bgColor = getRepresentativeColor(mark.fill);
140
143
 
141
144
  let anchorX: number;
142
145
  let fill: string;
@@ -145,18 +148,32 @@ export function computeBarLabels(
145
148
  if (isStacked && isInside) {
146
149
  // Stacked: centered within segment
147
150
  anchorX = mark.x + mark.width / 2;
148
- fill = '#ffffff';
151
+ fill = findAccessibleColor('#ffffff', bgColor, 4.5);
149
152
  textAnchor = 'middle';
150
153
  } else if (isInside) {
151
- // Simple: right-aligned within bar
152
- anchorX = mark.x + mark.width - LABEL_PADDING;
153
- fill = '#ffffff';
154
- textAnchor = 'end';
154
+ if (isNegative) {
155
+ // Negative bar: left-aligned within bar (bar extends leftward)
156
+ anchorX = mark.x + LABEL_PADDING;
157
+ fill = findAccessibleColor('#ffffff', bgColor, 4.5);
158
+ textAnchor = 'start';
159
+ } else {
160
+ // Positive bar: right-aligned within bar
161
+ anchorX = mark.x + mark.width - LABEL_PADDING;
162
+ fill = findAccessibleColor('#ffffff', bgColor, 4.5);
163
+ textAnchor = 'end';
164
+ }
155
165
  } else {
156
- // Outside: just past the bar's right edge
157
- anchorX = mark.x + mark.width + LABEL_PADDING;
158
- fill = getRepresentativeColor(mark.fill);
159
- textAnchor = 'start';
166
+ if (isNegative) {
167
+ // Outside negative bar: just past the bar's left edge
168
+ anchorX = mark.x - LABEL_PADDING;
169
+ fill = getRepresentativeColor(mark.fill);
170
+ textAnchor = 'end';
171
+ } else {
172
+ // Outside positive bar: just past the bar's right edge
173
+ anchorX = mark.x + mark.width + LABEL_PADDING;
174
+ fill = getRepresentativeColor(mark.fill);
175
+ textAnchor = 'start';
176
+ }
160
177
  }
161
178
 
162
179
  // anchorY = bar vertical center. With dominant-baseline: central,
@@ -477,6 +477,34 @@ describe('computeAreaMarks', () => {
477
477
  expect(marks[0].fillOpacity).toBeLessThanOrEqual(1);
478
478
  });
479
479
 
480
+ it('area with y2 encoding uses y2 field as bottom boundary instead of baseline', () => {
481
+ const spec: NormalizedChartSpec = {
482
+ ...makeSingleSeriesSpec(),
483
+ data: [
484
+ { date: '2020-01-01', value: 80, value_low: 60 },
485
+ { date: '2021-01-01', value: 90, value_low: 70 },
486
+ { date: '2022-01-01', value: 85, value_low: 65 },
487
+ ],
488
+ encoding: {
489
+ x: { field: 'date', type: 'temporal' },
490
+ y: { field: 'value', type: 'quantitative' },
491
+ y2: { field: 'value_low', type: 'quantitative' },
492
+ },
493
+ };
494
+ const scales = computeScales(spec, chartArea, spec.data);
495
+ const marks = computeAreaMarks(spec, scales, chartArea);
496
+
497
+ expect(marks).toHaveLength(1);
498
+ // Bottom points should NOT all be at the same baseline y coordinate
499
+ const bottomYValues = marks[0].bottomPoints.map((p) => p.y);
500
+ const allSame = bottomYValues.every((y) => y === bottomYValues[0]);
501
+ expect(allSame).toBe(false);
502
+ // Each bottom point should be between the top point and the chart bottom
503
+ for (let i = 0; i < marks[0].topPoints.length; i++) {
504
+ expect(marks[0].bottomPoints[i].y).toBeGreaterThan(marks[0].topPoints[i].y); // SVG coords: larger y = lower on screen
505
+ }
506
+ });
507
+
480
508
  it('stacked areas: produces multiple AreaMarks for multi-series', () => {
481
509
  const spec = makeMultiSeriesSpec();
482
510
  const scales = computeScales(spec, chartArea, spec.data);
@@ -84,16 +84,24 @@ function computeSingleArea(
84
84
  // Compute points, filtering out null values
85
85
  const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
86
86
 
87
+ // Check for y2 channel (band between y and y2)
88
+ const y2Channel = (encoding as Encoding & { y2?: { field: string; type: string } }).y2;
89
+
87
90
  for (const row of sortedRows) {
88
91
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
89
92
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
90
93
 
91
94
  if (xVal === null || yVal === null) continue;
92
95
 
96
+ const yBottomVal =
97
+ y2Channel && row[y2Channel.field] != null
98
+ ? scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field])
99
+ : null;
100
+
93
101
  validPoints.push({
94
102
  x: xVal,
95
103
  yTop: yVal,
96
- yBottom: baselineY,
104
+ yBottom: yBottomVal ?? baselineY,
97
105
  row,
98
106
  });
99
107
  }
@@ -127,6 +135,8 @@ function computeSingleArea(
127
135
 
128
136
  const aria: MarkAria = { label: ariaLabel };
129
137
 
138
+ const fillOpacity = y2Channel ? 0.25 : DEFAULT_FILL_OPACITY;
139
+
130
140
  marks.push({
131
141
  type: 'area',
132
142
  topPoints,
@@ -134,7 +144,7 @@ function computeSingleArea(
134
144
  path: pathStr,
135
145
  topPath: topPathStr,
136
146
  fill: color,
137
- fillOpacity: DEFAULT_FILL_OPACITY,
147
+ fillOpacity: fillOpacity,
138
148
  stroke: getRepresentativeColor(color),
139
149
  strokeWidth: 2,
140
150
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
@@ -174,8 +174,10 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
174
174
  fill: ann.fill ?? '#000000',
175
175
  };
176
176
  case 'refline':
177
+ case 'rule':
177
178
  return {
178
179
  ...ann,
180
+ type: 'refline' as const,
179
181
  style: ann.style ?? 'dashed',
180
182
  strokeWidth: ann.strokeWidth ?? 1,
181
183
  stroke: ann.stroke ?? '#666666',
@@ -168,9 +168,14 @@ export function computeDimensions(
168
168
  };
169
169
 
170
170
  // Dynamic right margin for line/area end-of-line labels.
171
- // Only reserve space when labels will actually render (density != 'none').
171
+ // Only reserve space when labels will actually render.
172
172
  const labelDensity = spec.labels.density;
173
- if ((spec.markType === 'line' || spec.markType === 'area') && labelDensity !== 'none') {
173
+ const labelsHiddenByStrategy = strategy?.labelMode === 'none';
174
+ if (
175
+ (spec.markType === 'line' || spec.markType === 'area') &&
176
+ labelDensity !== 'none' &&
177
+ !labelsHiddenByStrategy
178
+ ) {
174
179
  // Estimate label width from longest series name (color encoding domain)
175
180
  const colorEnc = encoding.color;
176
181
  const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
@@ -83,21 +83,25 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
83
83
  ]
84
84
  : dataValues;
85
85
 
86
- return uniqueValues.map((value, i) => {
87
- // When explicit domain+range are provided, look up the color by domain index
88
- // so legend colors match the mark colors exactly.
89
- let colorIndex = i;
90
- if (explicitDomain && explicitRange) {
91
- const domainIdx = explicitDomain.indexOf(value);
92
- if (domainIdx >= 0) colorIndex = domainIdx;
93
- }
94
- return {
95
- label: value,
96
- color: palette[colorIndex % palette.length],
97
- shape,
98
- active: true,
99
- };
100
- });
86
+ const excludeSet = new Set(spec.legend?.exclude ?? []);
87
+
88
+ return uniqueValues
89
+ .map((value, i) => {
90
+ // When explicit domain+range are provided, look up the color by domain index
91
+ // so legend colors match the mark colors exactly.
92
+ let colorIndex = i;
93
+ if (explicitDomain && explicitRange) {
94
+ const domainIdx = explicitDomain.indexOf(value);
95
+ if (domainIdx >= 0) colorIndex = domainIdx;
96
+ }
97
+ return {
98
+ label: value,
99
+ color: palette[colorIndex % palette.length],
100
+ shape,
101
+ active: true,
102
+ };
103
+ })
104
+ .filter((entry) => !excludeSet.has(entry.label));
101
105
  }
102
106
 
103
107
  /**
@@ -162,12 +166,22 @@ export function computeLegend(
162
166
 
163
167
  // Auto-suppress legend when endpoint labels identify series on line/area charts.
164
168
  // Guards: keep legend at compact breakpoints (labels hidden), for stacked areas
165
- // (endpoint labels overlap), and when user explicitly forces legend on.
169
+ // (endpoint labels overlap), and when user has configured any legend property
170
+ // (position, columns, maxRows, etc.) — any explicit legend config signals intent
171
+ // to show a legend, not just show: true.
166
172
  const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
167
173
  const hasLabels = spec.labels.density !== 'none';
168
174
  const labelsWillRender = strategy.labelMode !== 'none';
169
175
  const hasColorEncoding = spec.encoding.color != null;
170
- const legendNotForced = spec.legend?.show !== true;
176
+ // Legend is "forced" when the user set show: true OR specified any legend config
177
+ // other than show: false. Vega-Lite convention: legend is shown by default for
178
+ // multi-series charts; auto-suppression only fires when no legend config is present.
179
+ const userConfiguredLegend =
180
+ spec.legend != null &&
181
+ Object.keys(spec.legend).some(
182
+ (k) => k !== 'show' || spec.legend![k as keyof typeof spec.legend] !== false,
183
+ );
184
+ const legendNotForced = !userConfiguredLegend;
171
185
 
172
186
  if (isLineOrArea && hasLabels && labelsWillRender && hasColorEncoding && legendNotForced) {
173
187
  const isArea = spec.markType === 'area';