Skip to content

Commit 666792b

Browse files
committed
feat: responsive layout improvements for narrow viewports
Tighten spacing on small screens so charts maximize data area: - Consolidate legend gap logic into shared legendGap() helper - Extract COMPACT_WIDTH constant (420px) to eliminate magic numbers - Clamp y-axis label margins so long categories don't starve chart area - Truncate overflowing y-axis labels with ellipsis + title for a11y - Clamp dot chart baseline to scale range when domain excludes zero - Allow tick thinning even with explicit tickCount (D3 log scales overshoot) - Order legend entries by explicit domain for author-controlled truncation - Move applyTextStyle to inline styles so engine values override CSS defaults - Document mark properties and gradients in spec reference
1 parent 9fa3624 commit 666792b

18 files changed

Lines changed: 364 additions & 69 deletions

File tree

docs/spec-reference.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All types are importable from `@opendata-ai/openchart-core` or from the convenie
88

99
- [VizSpec](#vizspec) (top-level union type)
1010
- [ChartSpec](#chartspec) (line, area, bar, column, pie, donut, dot, scatter)
11+
- [Mark properties](#mark-properties) (fill, gradient, point, interpolate, opacity)
1112
- [LayerSpec](#layerspec) (overlay multiple chart types)
1213
- [Encoding](#encoding) (x, y, color, size, detail channels)
1314
- [Annotations](#annotations) (refline, text, range)
@@ -67,6 +68,65 @@ type DataRow = Record<string, unknown>;
6768

6869
A plain object with string keys. Values can be numbers, strings, dates, nulls, arrays (for sparklines), or booleans. The engine inspects values at runtime to validate encoding types.
6970

71+
### Mark properties
72+
73+
The `type` field on ChartSpec accepts either a string (`'line'`) or an object with additional mark configuration. In LayerSpec children, this field is called `mark`.
74+
75+
```ts
76+
// String shorthand
77+
{ type: 'area', data: [...], encoding: {...} }
78+
79+
// Object form with mark properties
80+
{
81+
type: { type: 'area', point: true, fill: { gradient: 'linear', ... } },
82+
data: [...],
83+
encoding: {...}
84+
}
85+
```
86+
87+
| Field | Type | Default | Applies to | Description |
88+
| -------------- | ----------------------- | ----------- | -------------- | ------------------------------------------------------------------------ |
89+
| `type` | `MarkType` | (required) | all | Mark type: `'bar'`, `'line'`, `'area'`, `'point'`, `'arc'`, `'text'`, etc. |
90+
| `point` | `boolean \| 'transparent'` | `false` | line, area | Show point markers at data positions. `true` = filled circles. |
91+
| `interpolate` | `string` | `'linear'` | line, area | Curve: `'linear'`, `'monotone'`, `'step'`, `'step-before'`, `'step-after'`, `'basis'`, `'cardinal'`, `'natural'`. |
92+
| `orient` | `'horizontal' \| 'vertical'` | auto | bar | Explicit orientation override. |
93+
| `innerRadius` | `number` | `0` | arc | Inner radius. >0 produces a donut. |
94+
| `outerRadius` | `number` | auto | arc | Outer radius. |
95+
| `cornerRadius` | `number` | `0` | bar, arc | Corner rounding in pixels. |
96+
| `filled` | `boolean` | `true` | all | Whether the mark is filled vs stroked only. |
97+
| `opacity` | `number` | `1` | all | Overall mark opacity (0-1). |
98+
| `fill` | `string \| GradientDef` | theme color | all | Fill color or gradient. See [Gradients](#gradients). |
99+
| `stroke` | `string` | `undefined` | all | Stroke color. |
100+
| `strokeWidth` | `number` | varies | all | Stroke width in pixels. |
101+
| `tooltip` | `boolean \| null` | `true` | all | Tooltip behavior. `null` disables tooltips. |
102+
| `clip` | `boolean` | `false` | all | Clip marks to the chart area. |
103+
104+
#### Gradients
105+
106+
The `fill` property accepts a `GradientDef` object for linear or radial gradients. Source: `core/src/types/spec.ts`.
107+
108+
```ts
109+
// Linear gradient (top-to-bottom opacity fade)
110+
fill: {
111+
gradient: 'linear',
112+
x1: 0, y1: 1, // start (bottom)
113+
x2: 0, y2: 0, // end (top)
114+
stops: [
115+
{ offset: 0, color: '#38bdf8', opacity: 0 },
116+
{ offset: 1, color: '#38bdf8', opacity: 1 },
117+
],
118+
}
119+
```
120+
121+
| Field | Type | Default | Description |
122+
| -------- | ---------------- | ------- | ---------------------------------------------- |
123+
| `gradient` | `'linear' \| 'radial'` | (required) | Gradient type. |
124+
| `stops` | `GradientStop[]` | (required) | Color stops with offset (0-1), color, and optional opacity. |
125+
| `x1`, `y1` | `number` | `0`, `0` | Start point in [0,1] normalized space. |
126+
| `x2`, `y2` | `number` | `0`, `1` | End point. Default is top-to-bottom. |
127+
128+
**Area chart fill behavior:** Single-series area charts apply a default `fillOpacity` of 0.15 to the area fill (the stroke is drawn at full opacity). Stacked areas use 0.7. The gradient stop `opacity` values multiply with this default, so a stop at `opacity: 1` with the area default produces an effective opacity of 0.15. Design gradient stops accordingly, or use a LayerSpec with separate line and area marks for full control.
129+
70130
---
71131

72132
## LayerSpec

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export {
5656
BRAND_FONT_SIZE,
5757
BRAND_MIN_WIDTH,
5858
BRAND_RESERVE_WIDTH,
59+
COMPACT_WIDTH,
5960
computeChrome,
6061
estimateTextWidth,
6162
wrapText,

packages/core/src/layout/chrome.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
BRAND_FONT_SIZE,
2727
BRAND_MIN_WIDTH,
2828
BRAND_RESERVE_WIDTH,
29+
COMPACT_WIDTH,
2930
estimateCharWidth,
3031
estimateTextHeight,
3132
} from './text-measure';
@@ -236,9 +237,12 @@ export function computeChrome(
236237
topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
237238
}
238239

239-
// Add chromeToChart gap if there are any top elements
240+
// Add chromeToChart gap if there are any top elements. Tighten on narrow
241+
// viewports so the subtitle doesn't float far above a legend or chart area.
240242
const hasTopChrome = titleNorm || subtitleNorm;
241-
const topHeight = hasTopChrome ? topY - pad + theme.spacing.chromeToChart - chromeGap : 0;
243+
const chromeToChart =
244+
width < COMPACT_WIDTH ? Math.min(theme.spacing.chromeToChart, 2) : theme.spacing.chromeToChart;
245+
const topHeight = hasTopChrome ? topY - pad + chromeToChart - chromeGap : 0;
242246

243247
// Bottom chrome text hidden in compact mode, but brand watermark still
244248
// renders for wide-enough charts. Reserve space so it doesn't overflow.

packages/core/src/layout/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
BRAND_FONT_SIZE,
88
BRAND_MIN_WIDTH,
99
BRAND_RESERVE_WIDTH,
10+
COMPACT_WIDTH,
1011
estimateCharWidth,
1112
estimateTextHeight,
1213
estimateTextWidth,

packages/core/src/layout/text-measure.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ export const BRAND_FONT_SIZE = 12;
7373
/** Minimum chart width to render the brand watermark (px). */
7474
export const BRAND_MIN_WIDTH = 120;
7575

76+
// ---------------------------------------------------------------------------
77+
// Responsive breakpoints
78+
// ---------------------------------------------------------------------------
79+
80+
/**
81+
* Width threshold below which layout tightens spacing (legend gaps, chrome
82+
* padding, label gaps) to maximize data area on small screens.
83+
*/
84+
export const COMPACT_WIDTH = 420;
85+
7686
/**
7787
* Estimate the rendered height of a text block.
7888
*

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ describe('text-aware tick density', () => {
418418
expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
419419
});
420420

421-
it('does not thin x-axis ticks when explicit tickCount is set', () => {
421+
it('still thins x-axis ticks when tickCount is set but D3 overshoots', () => {
422422
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
423423
const specWithTickCount: NormalizedChartSpec = {
424424
...lineSpec,
@@ -431,12 +431,33 @@ describe('text-aware tick density', () => {
431431
const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
432432
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
433433

434-
// With explicit tickCount, the engine should not thin
435-
// D3 may return fewer than 8 for this small dataset, but the point is
436-
// thinTicksUntilFit should not be called
434+
// tickCount is advisory for D3 - if it overshoots, thinning still applies
435+
// to prevent overlap. The result should still have ticks, just not more
436+
// than the narrow area can display without overlap.
437437
expect(axes.x!.ticks.length).toBeGreaterThan(0);
438438
});
439439

440+
it('does not thin x-axis ticks when explicit values are set', () => {
441+
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
442+
const specWithValues: NormalizedChartSpec = {
443+
...lineSpec,
444+
encoding: {
445+
x: {
446+
field: 'date',
447+
type: 'temporal',
448+
axis: { values: ['2020-01-01', '2021-01-01', '2022-01-01'] },
449+
},
450+
y: { field: 'value', type: 'quantitative' },
451+
},
452+
};
453+
454+
const scales = computeScales(specWithValues, narrowArea, specWithValues.data);
455+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
456+
457+
// Explicit values should be preserved exactly as specified
458+
expect(axes.x!.ticks.length).toBe(3);
459+
});
460+
440461
it('band scale shows all categories regardless of width', () => {
441462
const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
442463
const barSpec: NormalizedChartSpec = {

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,52 @@ describe('computeDimensions', () => {
265265
// Tooltip-only should NOT reserve extra margin (annotations are hidden)
266266
expect(dimsTooltipOnly.margins.right).toBe(dimsNoAnnotations.margins.right);
267267
});
268+
269+
it('clamps y-axis label margin on narrow containers to preserve chart area', () => {
270+
const longLabelSpec: NormalizedChartSpec = {
271+
...baseSpec,
272+
markType: 'bar',
273+
markDef: { type: 'bar' },
274+
data: [
275+
{
276+
category: 'This is a very long category label that would consume lots of space',
277+
value: 10,
278+
},
279+
{ category: 'Another extremely verbose category name', value: 20 },
280+
],
281+
encoding: {
282+
x: { field: 'value', type: 'quantitative' },
283+
y: { field: 'category', type: 'nominal' },
284+
},
285+
};
286+
287+
const narrowDims = computeDimensions(
288+
longLabelSpec,
289+
{ width: 350, height: 300 },
290+
emptyLegend,
291+
lightTheme,
292+
);
293+
294+
// On narrow viewports, left margin should be clamped so the chart area
295+
// retains at least ~45% of the container width
296+
expect(narrowDims.chartArea.width).toBeGreaterThanOrEqual(350 * 0.4);
297+
});
298+
299+
it('tightens legend gap on narrow viewports', () => {
300+
const wideDims = computeDimensions(
301+
baseSpec,
302+
{ width: 600, height: 400 },
303+
topLegend,
304+
lightTheme,
305+
);
306+
const narrowDims = computeDimensions(
307+
baseSpec,
308+
{ width: 360, height: 400 },
309+
topLegend,
310+
lightTheme,
311+
);
312+
313+
// Narrow viewport should have more chart height available (smaller legend gap)
314+
expect(narrowDims.chartArea.height).toBeGreaterThanOrEqual(wideDims.chartArea.height - 10);
315+
});
268316
});

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

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,30 @@ describe('computeLegend', () => {
276276
expect(deEntry.color).toBe('#00ff00');
277277
});
278278

279+
it('orders legend entries by explicit domain, not data order', () => {
280+
const specExplicit: NormalizedChartSpec = {
281+
...specWithColor,
282+
data: [
283+
{ date: '2020', value: 10, country: 'Germany' },
284+
{ date: '2021', value: 20, country: 'UK' },
285+
{ date: '2022', value: 30, country: 'US' },
286+
],
287+
encoding: {
288+
x: { field: 'date', type: 'temporal' },
289+
y: { field: 'value', type: 'quantitative' },
290+
color: {
291+
field: 'country',
292+
type: 'nominal',
293+
scale: {
294+
domain: ['US', 'UK', 'Germany'],
295+
},
296+
},
297+
},
298+
};
299+
const legend = computeLegend(specExplicit, compactStrategy, theme, chartArea);
300+
expect(legend.entries.map((e) => e.label)).toEqual(['US', 'UK', 'Germany']);
301+
});
302+
279303
it('uses correct swatch shape for chart type', () => {
280304
const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
281305
expect(lineLegend.entries[0].shape).toBe('line');
@@ -395,41 +419,44 @@ describe('computeLegend', () => {
395419
});
396420
});
397421

398-
// ---------------------------------------------------------------------------
399-
// Characterization test (refactor/v7-cohesion step 1):
400-
// Pins the 4px gap between a top-positioned legend and the chart area,
401-
// as enforced at packages/engine/src/compile.ts:331. Refactor step 4 will
402-
// consolidate legend row-wrapping geometry; this test guards the spacing
403-
// invariant through that change.
404-
// ---------------------------------------------------------------------------
405422
describe('top legend spacing', () => {
406-
it('places the legend exactly 4px above the chart area', () => {
407-
const spec = {
408-
mark: 'bar' as const,
409-
data: [
410-
{ name: 'A', value: 10, group: 'X' },
411-
{ name: 'A', value: 20, group: 'Y' },
412-
{ name: 'B', value: 30, group: 'X' },
413-
{ name: 'B', value: 25, group: 'Y' },
414-
],
415-
encoding: {
416-
x: { field: 'name', type: 'nominal' as const },
417-
y: { field: 'value', type: 'quantitative' as const },
418-
color: { field: 'group', type: 'nominal' as const },
419-
},
420-
legend: { position: 'top' as const },
421-
};
423+
const topLegendSpec = {
424+
mark: 'bar' as const,
425+
data: [
426+
{ name: 'A', value: 10, group: 'X' },
427+
{ name: 'A', value: 20, group: 'Y' },
428+
{ name: 'B', value: 30, group: 'X' },
429+
{ name: 'B', value: 25, group: 'Y' },
430+
],
431+
encoding: {
432+
x: { field: 'name', type: 'nominal' as const },
433+
y: { field: 'value', type: 'quantitative' as const },
434+
color: { field: 'group', type: 'nominal' as const },
435+
},
436+
legend: { position: 'top' as const },
437+
};
422438

423-
const layout = compileChart(spec, { width: 600, height: 400 });
439+
it('places the legend exactly 4px above the chart area at standard width', () => {
440+
const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
424441

425442
expect(layout.legend.position).toBe('top');
426443
expect(layout.legend.entries.length).toBeGreaterThan(0);
427444
expect(layout.legend.bounds.height).toBeGreaterThan(0);
428445

429446
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
430447
const gap = layout.area.y - legendBottom;
431-
// Pin value matches the literal at compile.ts:331 (legendArea.y -= legendLayout.bounds.height + 4)
432448
expect(gap).toBe(4);
433449
});
450+
451+
it('eliminates legend gap on narrow viewports (< 420px)', () => {
452+
const layout = compileChart(topLegendSpec, { width: 360, height: 400 });
453+
454+
expect(layout.legend.position).toBe('top');
455+
expect(layout.legend.entries.length).toBeGreaterThan(0);
456+
457+
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
458+
const gap = layout.area.y - legendBottom;
459+
expect(gap).toBe(0);
460+
});
434461
});
435462
});

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,37 @@ describe('computeDotMarks', () => {
264264
});
265265
});
266266

267+
describe('baseline clamping', () => {
268+
it('clamps stems to the plot area when domain does not include zero', () => {
269+
const spec: NormalizedChartSpec = {
270+
markType: 'circle',
271+
markDef: { type: 'circle' },
272+
data: [
273+
{ country: 'USA', score: 50 },
274+
{ country: 'UK', score: 80 },
275+
],
276+
encoding: {
277+
x: { field: 'score', type: 'quantitative', scale: { domain: [40, 100] } },
278+
y: { field: 'country', type: 'nominal' },
279+
},
280+
chrome: {},
281+
annotations: [],
282+
responsive: true,
283+
theme: {},
284+
darkMode: 'off',
285+
labels: { density: 'auto', format: '' },
286+
};
287+
const scales = computeScales(spec, chartArea, spec.data);
288+
const marks = computeDotMarks(spec, scales, chartArea, fullStrategy);
289+
290+
const stems = marks.filter((m): m is RectMark => m.type === 'rect');
291+
for (const stem of stems) {
292+
expect(stem.x).toBeGreaterThanOrEqual(chartArea.x);
293+
expect(stem.x + stem.width).toBeLessThanOrEqual(chartArea.x + chartArea.width);
294+
}
295+
});
296+
});
297+
267298
describe('edge cases', () => {
268299
it('returns empty array when no x encoding', () => {
269300
const spec: NormalizedChartSpec = {

packages/engine/src/charts/dot/compute.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ export function computeDotMarks(
6767
}
6868

6969
const bandwidth = yScale.bandwidth();
70-
const baseline = xScale(0);
70+
// Clamp baseline to the scale range so stems never extend past the plot area
71+
// (e.g., when domain doesn't include zero, xScale(0) would land outside).
72+
const [rangeStart, rangeEnd] = xScale.range();
73+
const rangeMin = Math.min(rangeStart, rangeEnd);
74+
const rangeMax = Math.max(rangeStart, rangeEnd);
75+
const baseline = Math.max(rangeMin, Math.min(rangeMax, xScale(0)));
7176
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
7277
const isSequentialColor = colorEnc?.type === 'quantitative';
7378
const colorField = isSequentialColor ? undefined : colorEnc?.field;

0 commit comments

Comments
 (0)