@opendata-ai/openchart-engine 6.28.2 → 6.28.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": "6.28.2",
3
+ "version": "6.28.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": "6.28.2",
51
+ "@opendata-ai/openchart-core": "6.28.4",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -112,6 +112,28 @@ describe('computeHeatmapColors', () => {
112
112
  }
113
113
  });
114
114
 
115
+ it('preserves luminance ordering in dark mode with custom color stops', () => {
116
+ const col: ColumnConfig = {
117
+ key: 'value',
118
+ heatmap: { palette: ['#fca5a5', '#c44e52'], domain: [0, 100] },
119
+ };
120
+ const lightTheme = getTheme(false);
121
+ const darkTheme = getTheme(true);
122
+ const lightColors = computeHeatmapColors(data, col, lightTheme, false);
123
+ const darkColors = computeHeatmapColors(data, col, darkTheme, true);
124
+
125
+ // Low value should have a visually lighter/less intense background than high value
126
+ // in both modes. Extract the backgrounds and compare luminance ordering.
127
+ const lightLowBg = lightColors.get(0)!.backgroundColor!;
128
+ const lightHighBg = lightColors.get(4)!.backgroundColor!;
129
+ const darkLowBg = darkColors.get(0)!.backgroundColor!;
130
+ const darkHighBg = darkColors.get(4)!.backgroundColor!;
131
+
132
+ // The low-value cell and high-value cell should have different backgrounds in both modes
133
+ expect(lightLowBg).not.toBe(lightHighBg);
134
+ expect(darkLowBg).not.toBe(darkHighBg);
135
+ });
136
+
115
137
  it('supports array of color stops as palette', () => {
116
138
  const col: ColumnConfig = {
117
139
  key: 'value',
@@ -11,6 +11,17 @@ import { interpolateRgb } from 'd3-interpolate';
11
11
  import { scaleSequential } from 'd3-scale';
12
12
  import { accessibleTextColor } from './utils';
13
13
 
14
+ /** WCAG relative luminance from a hex color string. */
15
+ function relativeLuminance(hex: string): number {
16
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
17
+ if (!m) return 0;
18
+ const [r, g, b] = [m[1], m[2], m[3]].map((c) => {
19
+ const v = Number.parseInt(c, 16) / 255;
20
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
21
+ });
22
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23
+ }
24
+
14
25
  /**
15
26
  * Build an interpolator from an array of color stops.
16
27
  * Uses d3-interpolate for smooth color transitions.
@@ -100,7 +111,24 @@ export function computeHeatmapColors(
100
111
  if (darkMode) {
101
112
  const lightBg = '#ffffff';
102
113
  const darkBg = theme.colors.background;
114
+ const originalStops = stops;
103
115
  stops = stops.map((c) => adaptColorForDarkMode(c, lightBg, darkBg));
116
+
117
+ // adaptColorForDarkMode preserves contrast ratios independently per stop,
118
+ // which can invert the luminance ordering (light-to-dark becomes dark-to-light).
119
+ // Detect this and reverse the adapted stops to preserve the intended gradient direction.
120
+ if (originalStops.length >= 2) {
121
+ const origDirection = Math.sign(
122
+ relativeLuminance(originalStops[originalStops.length - 1]) -
123
+ relativeLuminance(originalStops[0]),
124
+ );
125
+ const adaptedDirection = Math.sign(
126
+ relativeLuminance(stops[stops.length - 1]) - relativeLuminance(stops[0]),
127
+ );
128
+ if (origDirection !== 0 && adaptedDirection !== 0 && origDirection !== adaptedDirection) {
129
+ stops = stops.slice().reverse();
130
+ }
131
+ }
104
132
  }
105
133
 
106
134
  const interpolator = interpolatorFromStops(stops);
@@ -297,18 +297,20 @@ describe('compileTileMap', () => {
297
297
  });
298
298
 
299
299
  describe('dimensions', () => {
300
- it('reflects the compile options', () => {
300
+ it('width matches compile options, height fits content', () => {
301
301
  const result = compileTileMap(basicSpec, defaultOptions);
302
302
 
303
303
  expect(result.width).toBe(600);
304
- expect(result.height).toBe(400);
304
+ expect(result.height).toBeGreaterThan(0);
305
+ expect(result.height).toBeLessThanOrEqual(400);
305
306
  });
306
307
 
307
308
  it('works with different container sizes', () => {
308
309
  const result = compileTileMap(basicSpec, { width: 800, height: 600 });
309
310
 
310
311
  expect(result.width).toBe(800);
311
- expect(result.height).toBe(600);
312
+ expect(result.height).toBeGreaterThan(0);
313
+ expect(result.height).toBeLessThanOrEqual(600);
312
314
  });
313
315
  });
314
316
 
@@ -329,6 +329,15 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
329
329
  // 15. Resolve animation
330
330
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
331
331
 
332
+ // Tight content height: tiles + legend + chrome + padding
333
+ const contentHeight =
334
+ tileGridOffsetY +
335
+ tilePositions.gridHeight +
336
+ legendGap +
337
+ legendTotalHeight +
338
+ chrome.bottomHeight +
339
+ padding;
340
+
332
341
  return {
333
342
  area: fullArea,
334
343
  chrome,
@@ -338,7 +347,7 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
338
347
  a11y,
339
348
  theme,
340
349
  width: options.width,
341
- height: options.height,
350
+ height: contentHeight,
342
351
  animation: resolvedAnimation,
343
352
  watermark,
344
353
  measureText: