Skip to content

Commit e31736b

Browse files
committed
feat: area y2 bands, annotation responsiveness, bar label improvements
- Area marks support y2 encoding channel for confidence bands / ranges - Annotations gain responsive opt-out (responsive: false stays visible at compact breakpoints) - RefLine annotations accept 'rule' type alias and strokeDash array override - Bar labels position correctly for negative values (inside and outside) - Inside-bar labels use findAccessibleColor instead of hardcoded white - findAccessibleColor tries both lighten/darken directions for edge cases - Legend auto-suppression respects any explicit legend config, not just show: true - Layout skips label margin reservation when responsive strategy hides labels - LegendConfig.exclude type added for future series exclusion support
1 parent f02eaf0 commit e31736b

11 files changed

Lines changed: 169 additions & 61 deletions

File tree

packages/core/src/colors/contrast.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,30 +65,42 @@ export function findAccessibleColor(baseColor: string, bg: string, targetRatio =
6565
if (c == null) return baseColor;
6666

6767
const bgLum = relativeLuminance(bg);
68-
// Determine direction: darken if bg is light, lighten if bg is dark.
69-
const bgIsLight = bgLum > 0.5;
70-
71-
// Binary search for the lightness adjustment that hits the target ratio.
72-
let lo = 0;
73-
let hi = 1;
74-
let best = baseColor;
75-
76-
for (let i = 0; i < 20; i++) {
77-
const mid = (lo + hi) / 2;
78-
const adjusted = bgIsLight
79-
? rgb(c.r * (1 - mid), c.g * (1 - mid), c.b * (1 - mid))
80-
: rgb(c.r + (255 - c.r) * mid, c.g + (255 - c.g) * mid, c.b + (255 - c.b) * mid);
81-
82-
const hex = adjusted.formatHex();
83-
const ratio = contrastRatio(hex, bg);
84-
85-
if (ratio >= targetRatio) {
86-
best = hex;
87-
hi = mid; // try less adjustment
88-
} else {
89-
lo = mid; // need more adjustment
68+
const baseLum = relativeLuminance(baseColor);
69+
70+
// Try both directions: prefer the one matching bg luminance, but fall back
71+
// to the other if the base color is already at the extreme (e.g. white on
72+
// a medium-luminance background can't be lightened, so darken instead).
73+
const preferDarken = bgLum > 0.5;
74+
const directions = preferDarken ? [true, false] : [false, true];
75+
76+
for (const darken of directions) {
77+
// Skip impossible directions: can't lighten white or darken black.
78+
if (!darken && baseLum > 0.95) continue;
79+
if (darken && baseLum < 0.05) continue;
80+
81+
let lo = 0;
82+
let hi = 1;
83+
let best: string | null = null;
84+
85+
for (let i = 0; i < 20; i++) {
86+
const mid = (lo + hi) / 2;
87+
const adjusted = darken
88+
? rgb(c.r * (1 - mid), c.g * (1 - mid), c.b * (1 - mid))
89+
: rgb(c.r + (255 - c.r) * mid, c.g + (255 - c.g) * mid, c.b + (255 - c.b) * mid);
90+
91+
const hex = adjusted.formatHex();
92+
const ratio = contrastRatio(hex, bg);
93+
94+
if (ratio >= targetRatio) {
95+
best = hex;
96+
hi = mid;
97+
} else {
98+
lo = mid;
99+
}
90100
}
101+
102+
if (best) return best;
91103
}
92104

93-
return best;
105+
return baseColor;
94106
}

packages/core/src/types/spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ interface AnnotationBase {
523523
opacity?: number;
524524
/** Z-index for render ordering. Higher values render on top. */
525525
zIndex?: number;
526+
/** When false, the annotation is always shown even at compact breakpoints. Default true. */
527+
responsive?: boolean;
526528
}
527529

528530
/**
@@ -588,13 +590,15 @@ export interface RangeAnnotation extends AnnotationBase {
588590
* Useful for baselines (zero), targets, or thresholds.
589591
*/
590592
export interface RefLineAnnotation extends AnnotationBase {
591-
type: 'refline';
593+
type: 'refline' | 'rule';
592594
/** X-axis value for a vertical reference line. */
593595
x?: string | number;
594596
/** Y-axis value for a horizontal reference line. */
595597
y?: string | number;
596598
/** Line style. */
597599
style?: 'solid' | 'dashed' | 'dotted';
600+
/** Raw SVG dash pattern override, e.g. [4, 4]. Takes precedence over style. */
601+
strokeDash?: number[];
598602
/** Line width in pixels. */
599603
strokeWidth?: number;
600604
/** Pixel offset for the reference line label. */
@@ -715,6 +719,8 @@ export interface LegendConfig {
715719
symbolLimit?: number;
716720
/** Maximum number of rows for top-positioned legends before truncation. Defaults to 2. */
717721
maxRows?: number;
722+
/** Series names to exclude from the legend. Excluded series still render in the chart. */
723+
exclude?: string[];
718724
}
719725

720726
// ---------------------------------------------------------------------------
@@ -1408,7 +1414,7 @@ export function isRangeAnnotation(annotation: Annotation): annotation is RangeAn
14081414

14091415
/** Check if an annotation is a RefLineAnnotation. */
14101416
export function isRefLineAnnotation(annotation: Annotation): annotation is RefLineAnnotation {
1411-
return annotation.type === 'refline';
1417+
return annotation.type === 'refline' || annotation.type === 'rule';
14121418
}
14131419

14141420
// ---------------------------------------------------------------------------

packages/engine/src/__tests__/legend.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,17 @@ describe('computeLegend', () => {
354354
expect(legend.entries).toHaveLength(3);
355355
});
356356

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+
357368
it('preserves legend when labels density is none', () => {
358369
const spec: NormalizedChartSpec = {
359370
...lineWithLabels,

packages/engine/src/annotations/compute.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ export function computeAnnotations(
4545
obstacles: Rect[] = [],
4646
svgDimensions?: { width: number; height: number },
4747
): ResolvedAnnotation[] {
48-
// At compact breakpoints, skip all annotations
49-
if (strategy.annotationPosition === 'tooltip-only') {
50-
return [];
51-
}
48+
const isCompact = strategy.annotationPosition === 'tooltip-only';
5249

5350
const annotations: ResolvedAnnotation[] = [];
5451

5552
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+
}
5657
let resolved: ResolvedAnnotation | null = null;
5758

5859
switch (annotation.type) {

packages/engine/src/annotations/resolve-refline.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ export function resolveRefLineAnnotation(
4343
return null;
4444
}
4545

46-
// Determine dash pattern from style
46+
// Determine dash pattern: strokeDash array wins, then style string
4747
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) {
4951
strokeDasharray = DEFAULT_REFLINE_DASH;
5052
} else if (annotation.style === 'dotted') {
5153
strokeDasharray = '2 2';

packages/engine/src/charts/bar/labels.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
import {
2121
buildD3Formatter,
2222
estimateTextWidth,
23+
findAccessibleColor,
2324
getRepresentativeColor,
2425
resolveCollisions,
2526
} from '@opendata-ai/openchart-core';
@@ -137,6 +138,8 @@ export function computeBarLabels(
137138

138139
// Determine if label goes inside or outside the bar
139140
const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
141+
const isNegative = Number.isFinite(rawNum) ? rawNum < 0 : false;
142+
const bgColor = getRepresentativeColor(mark.fill);
140143

141144
let anchorX: number;
142145
let fill: string;
@@ -145,18 +148,32 @@ export function computeBarLabels(
145148
if (isStacked && isInside) {
146149
// Stacked: centered within segment
147150
anchorX = mark.x + mark.width / 2;
148-
fill = '#ffffff';
151+
fill = findAccessibleColor('#ffffff', bgColor, 4.5);
149152
textAnchor = 'middle';
150153
} 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+
}
155165
} 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+
}
160177
}
161178

162179
// anchorY = bar vertical center. With dominant-baseline: central,

packages/engine/src/charts/line/__tests__/compute.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,34 @@ describe('computeAreaMarks', () => {
477477
expect(marks[0].fillOpacity).toBeLessThanOrEqual(1);
478478
});
479479

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+
480508
it('stacked areas: produces multiple AreaMarks for multi-series', () => {
481509
const spec = makeMultiSeriesSpec();
482510
const scales = computeScales(spec, chartArea, spec.data);

packages/engine/src/charts/line/area.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,24 @@ function computeSingleArea(
8484
// Compute points, filtering out null values
8585
const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
8686

87+
// Check for y2 channel (band between y and y2)
88+
const y2Channel = (encoding as Encoding & { y2?: { field: string; type: string } }).y2;
89+
8790
for (const row of sortedRows) {
8891
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
8992
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
9093

9194
if (xVal === null || yVal === null) continue;
9295

96+
const yBottomVal =
97+
y2Channel && row[y2Channel.field] != null
98+
? scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field])
99+
: null;
100+
93101
validPoints.push({
94102
x: xVal,
95103
yTop: yVal,
96-
yBottom: baselineY,
104+
yBottom: yBottomVal ?? baselineY,
97105
row,
98106
});
99107
}
@@ -127,14 +135,16 @@ function computeSingleArea(
127135

128136
const aria: MarkAria = { label: ariaLabel };
129137

138+
const fillOpacity = y2Channel ? 0.25 : DEFAULT_FILL_OPACITY;
139+
130140
marks.push({
131141
type: 'area',
132142
topPoints,
133143
bottomPoints,
134144
path: pathStr,
135145
topPath: topPathStr,
136146
fill: color,
137-
fillOpacity: DEFAULT_FILL_OPACITY,
147+
fillOpacity: fillOpacity,
138148
stroke: getRepresentativeColor(color),
139149
strokeWidth: 2,
140150
seriesKey: seriesKey === '__default__' ? undefined : seriesKey,

packages/engine/src/compiler/normalize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
174174
fill: ann.fill ?? '#000000',
175175
};
176176
case 'refline':
177+
case 'rule':
177178
return {
178179
...ann,
180+
type: 'refline' as const,
179181
style: ann.style ?? 'dashed',
180182
strokeWidth: ann.strokeWidth ?? 1,
181183
stroke: ann.stroke ?? '#666666',

packages/engine/src/layout/dimensions.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,14 @@ export function computeDimensions(
168168
};
169169

170170
// 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.
172172
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+
) {
174179
// Estimate label width from longest series name (color encoding domain)
175180
const colorEnc = encoding.color;
176181
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;

0 commit comments

Comments
 (0)