@opendata-ai/openchart-vanilla 7.2.1 → 7.2.2
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/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/svg-renderer.test.ts +98 -0
- package/src/renderers/axes.ts +15 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.2",
|
|
4
4
|
"description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@floating-ui/dom": "^1.7.6",
|
|
53
|
-
"@opendata-ai/openchart-core": "7.2.
|
|
54
|
-
"@opendata-ai/openchart-engine": "7.2.
|
|
53
|
+
"@opendata-ai/openchart-core": "7.2.2",
|
|
54
|
+
"@opendata-ai/openchart-engine": "7.2.2",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* and that chart furniture (chrome, axes, legend, gridlines) renders properly.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { AXIS_TITLE_GAP, estimateTextWidth, TICK_LABEL_OFFSET } from '@opendata-ai/openchart-core';
|
|
9
10
|
import type { ChartSpec, CompileOptions } from '@opendata-ai/openchart-engine';
|
|
10
11
|
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
11
12
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
@@ -531,6 +532,22 @@ describe('axis rendering', () => {
|
|
|
531
532
|
// The renderer draws an axis line for x-axis
|
|
532
533
|
expect(line).not.toBeNull();
|
|
533
534
|
});
|
|
535
|
+
|
|
536
|
+
it('x-tick labels hang below the axis line by the label padding (no hugging)', () => {
|
|
537
|
+
const { svg, layout } = renderSpec(lineSpec);
|
|
538
|
+
const xAxis = svg.querySelector('.oc-axis-x')!;
|
|
539
|
+
const axisLineY = Number(xAxis.querySelector('line')!.getAttribute('y2'));
|
|
540
|
+
const labels = Array.from(xAxis.querySelectorAll('text.oc-axis-tick'));
|
|
541
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
542
|
+
|
|
543
|
+
const pad = layout.theme.spacing.xAxisLabelPadding;
|
|
544
|
+
for (const label of labels) {
|
|
545
|
+
// Anchored at the top edge so the gap holds regardless of font size.
|
|
546
|
+
expect(label.getAttribute('dominant-baseline')).toBe('hanging');
|
|
547
|
+
// The label top sits a full padding gap below the axis line.
|
|
548
|
+
expect(Number(label.getAttribute('y'))).toBeCloseTo(axisLineY + pad, 1);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
534
551
|
});
|
|
535
552
|
|
|
536
553
|
// ---------------------------------------------------------------------------
|
|
@@ -782,3 +799,84 @@ describe('brand watermark', () => {
|
|
|
782
799
|
expect(brandLink).toBeNull();
|
|
783
800
|
});
|
|
784
801
|
});
|
|
802
|
+
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
// Y-axis title spacing (regression for the title overlapping the tick labels)
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
describe('left y-axis title spacing', () => {
|
|
808
|
+
// Builds a chart with a left y-axis title and reads back the real geometry to
|
|
809
|
+
// compute the horizontal clearance between the widest tick label's far (left)
|
|
810
|
+
// edge and the rotated title's near (right) edge. A non-negative clearance
|
|
811
|
+
// means they don't overlap; the bug was a negative one at large font sizes.
|
|
812
|
+
const yTitleSpec = (axisTitleSize: number, values: number[]): ChartSpec => ({
|
|
813
|
+
mark: { type: 'line' },
|
|
814
|
+
data: values.map((v, i) => ({ year: String(2018 + i), pct: v })),
|
|
815
|
+
encoding: {
|
|
816
|
+
x: { field: 'year', type: 'ordinal' },
|
|
817
|
+
y: {
|
|
818
|
+
field: 'pct',
|
|
819
|
+
type: 'quantitative',
|
|
820
|
+
axis: { title: 'Spring 2026 pass rate', format: '.0f%' },
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
chrome: { title: 'Pass rate' },
|
|
824
|
+
// The deck renders axis titles via theme.fonts.sizes.body; vary it to
|
|
825
|
+
// reproduce the large-font overlap.
|
|
826
|
+
theme: { fonts: { sizes: { body: axisTitleSize } } },
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Clearance = (widest tick label's left edge) - (title glyph's right edge).
|
|
830
|
+
// Tick labels: text-anchor=end at x = area.x - 6, so left edge = anchorX - width.
|
|
831
|
+
// Title: text-anchor=middle, rotated, center at the title's x attr; its glyph
|
|
832
|
+
// box extends fontSize/2 toward the labels (the +x direction).
|
|
833
|
+
const measureClearance = (axisTitleSize: number, values: number[]): number => {
|
|
834
|
+
const container = createContainer(700, 450);
|
|
835
|
+
const layout = compileChart(yTitleSpec(axisTitleSize, values), {
|
|
836
|
+
width: 700,
|
|
837
|
+
height: 450,
|
|
838
|
+
});
|
|
839
|
+
const svg = renderChartSVG(layout, container);
|
|
840
|
+
|
|
841
|
+
const title = svg.querySelector('.oc-axis-title') as SVGTextElement | null;
|
|
842
|
+
expect(title).not.toBeNull();
|
|
843
|
+
const titleCenterX = Number(title!.getAttribute('x'));
|
|
844
|
+
const titleNearEdge = titleCenterX + axisTitleSize / 2;
|
|
845
|
+
|
|
846
|
+
const tickStyle = layout.axes.y!.tickLabelStyle;
|
|
847
|
+
const tickAnchorX = layout.area.x - TICK_LABEL_OFFSET;
|
|
848
|
+
let widestLeftEdge = tickAnchorX;
|
|
849
|
+
for (const t of layout.axes.y!.ticks) {
|
|
850
|
+
const w = estimateTextWidth(t.label, tickStyle.fontSize, tickStyle.fontWeight ?? 400);
|
|
851
|
+
widestLeftEdge = Math.min(widestLeftEdge, tickAnchorX - w);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return widestLeftEdge - titleNearEdge;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const PASS_RATES = [38, 41, 46, 52, 49, 61]; // labels like "38%" ... "61%"
|
|
858
|
+
// Require real breathing room, not just non-overlap. At the deck font size the
|
|
859
|
+
// old code left only ~3.5px (glyphs visibly touching), which this floor rejects.
|
|
860
|
+
const MIN_CLEARANCE = AXIS_TITLE_GAP - 1;
|
|
861
|
+
|
|
862
|
+
it('keeps the title clear of the tick labels at the default font size', () => {
|
|
863
|
+
expect(measureClearance(13, PASS_RATES)).toBeGreaterThanOrEqual(MIN_CLEARANCE);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('keeps the title clear of the tick labels at a large (deck) font size', () => {
|
|
867
|
+
// The slide deck uses body=21 for axis titles. Under the old fixed gap this
|
|
868
|
+
// left only ~3.5px and the title glyphs touched the labels (the reported bug).
|
|
869
|
+
expect(measureClearance(21, PASS_RATES)).toBeGreaterThanOrEqual(MIN_CLEARANCE);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('does not lose clearance as the title font size grows', () => {
|
|
873
|
+
// The original bug: clearance shrank ~1px per font-size point as the title's
|
|
874
|
+
// half-glyph ate the fixed gap, so big titles overlapped (negative clearance
|
|
875
|
+
// at body=36). Now the half-glyph is folded into the offset, so clearance
|
|
876
|
+
// holds at ~AXIS_TITLE_GAP across the whole font range instead of collapsing.
|
|
877
|
+
const sizes = [13, 18, 21, 28, 36];
|
|
878
|
+
for (const s of sizes) {
|
|
879
|
+
expect(measureClearance(s, PASS_RATES)).toBeGreaterThanOrEqual(MIN_CLEARANCE);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
});
|
package/src/renderers/axes.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { AxisLayout, ChartLayout } from '@opendata-ai/openchart-core';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
axisTitleOffset,
|
|
8
8
|
estimateTextWidth,
|
|
9
9
|
getAxisTitleOffset,
|
|
10
10
|
TICK_LABEL_OFFSET,
|
|
@@ -86,10 +86,16 @@ function renderAxis(
|
|
|
86
86
|
});
|
|
87
87
|
} else {
|
|
88
88
|
const xLabelPad = axis.labelPadding ?? layout.theme.spacing.xAxisLabelPadding;
|
|
89
|
+
// Anchor at the text's top edge (hanging baseline) so xLabelPad is the
|
|
90
|
+
// literal gap between the axis line and the top of the label, regardless
|
|
91
|
+
// of font size. With the default alphabetic baseline the offset lands at
|
|
92
|
+
// the text baseline instead, so large fonts let the label top creep up
|
|
93
|
+
// and hug (or overlap) the axis line.
|
|
89
94
|
setAttrs(label, {
|
|
90
95
|
x: tick.position,
|
|
91
96
|
y: area.y + area.height + xLabelPad,
|
|
92
97
|
'text-anchor': 'middle',
|
|
98
|
+
'dominant-baseline': 'hanging',
|
|
93
99
|
});
|
|
94
100
|
}
|
|
95
101
|
|
|
@@ -272,6 +278,9 @@ function renderAxis(
|
|
|
272
278
|
} else {
|
|
273
279
|
// Rotated left y-axis label.
|
|
274
280
|
// Compute a dynamic offset so the title clears the widest tick label.
|
|
281
|
+
// The title is rotated and centered, so axisTitleOffset() adds its own
|
|
282
|
+
// half-glyph height on top of the gap (otherwise large title fonts overlap
|
|
283
|
+
// the tick labels — the gap is visible clearance, not center-to-edge).
|
|
275
284
|
const maxTickLabelWidth = axis.ticks.reduce((max, t) => {
|
|
276
285
|
const w = estimateTextWidth(
|
|
277
286
|
t.label,
|
|
@@ -280,8 +289,11 @@ function renderAxis(
|
|
|
280
289
|
);
|
|
281
290
|
return Math.max(max, w);
|
|
282
291
|
}, 0);
|
|
283
|
-
const
|
|
284
|
-
|
|
292
|
+
const titleOffset = axisTitleOffset(
|
|
293
|
+
maxTickLabelWidth,
|
|
294
|
+
axis.labelStyle.fontSize,
|
|
295
|
+
layout.dimensions.width,
|
|
296
|
+
);
|
|
285
297
|
setAttrs(axisLabel, {
|
|
286
298
|
x: area.x - titleOffset,
|
|
287
299
|
y: area.y + area.height / 2,
|