@opendata-ai/openchart-vanilla 7.2.1 → 7.2.3

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-vanilla",
3
- "version": "7.2.1",
3
+ "version": "7.2.3",
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.1",
54
- "@opendata-ai/openchart-engine": "7.2.1",
53
+ "@opendata-ai/openchart-core": "7.2.3",
54
+ "@opendata-ai/openchart-engine": "7.2.3",
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
+ });
@@ -86,7 +86,15 @@ function makeTheme(isDark = false): ResolvedTheme {
86
86
  fonts: {
87
87
  family: 'Inter, sans-serif',
88
88
  mono: 'monospace',
89
- sizes: { title: 18, subtitle: 14, body: 12, small: 10, axisTick: 11 },
89
+ sizes: {
90
+ title: 18,
91
+ subtitle: 14,
92
+ body: 12,
93
+ small: 10,
94
+ axisTick: 11,
95
+ metricLabel: 10,
96
+ metricValue: 22,
97
+ },
90
98
  weights: { normal: 400, medium: 500, semibold: 600, bold: 700 },
91
99
  },
92
100
  spacing: {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { AxisLayout, ChartLayout } from '@opendata-ai/openchart-core';
6
6
  import {
7
- AXIS_TITLE_GAP,
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 dynamicOffset = TICK_LABEL_OFFSET + maxTickLabelWidth + AXIS_TITLE_GAP;
284
- const titleOffset = Math.max(dynamicOffset, getAxisTitleOffset(layout.dimensions.width));
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,
@@ -7,23 +7,34 @@
7
7
  import type { ChartLayout } from '@opendata-ai/openchart-core';
8
8
  import { createSVGElement, setAttrs } from './svg-dom';
9
9
 
10
+ // Delta and secondary text run at a fraction of the primary value size. This
11
+ // preserves the editorial mock's proportion (12px delta against a 22px value)
12
+ // at any theme-driven value size, so scaling metricValue scales the whole cell.
13
+ const DELTA_SIZE_RATIO = 12 / 22;
14
+
10
15
  export function renderMetrics(parent: SVGElement, layout: ChartLayout): void {
11
16
  const bar = layout.metrics;
12
17
  if (!bar || bar.cells.length === 0) return;
13
18
 
19
+ // Font sizes flow from the theme. Set them inline so they override the CSS
20
+ // defaults (.oc-metric-* in chrome.css) when a chart customizes the sizes.
21
+ const labelSize = layout.theme.fonts.sizes.metricLabel;
22
+ const valueSize = layout.theme.fonts.sizes.metricValue;
23
+ const deltaSize = Math.round(valueSize * DELTA_SIZE_RATIO);
24
+
14
25
  const g = createSVGElement('g');
15
26
  g.setAttribute('class', 'oc-metrics');
16
27
 
17
28
  for (const cell of bar.cells) {
18
29
  const label = createSVGElement('text');
19
30
  label.setAttribute('class', 'oc-metric-label');
20
- setAttrs(label, { x: cell.x, y: cell.labelY });
31
+ setAttrs(label, { x: cell.x, y: cell.labelY, 'font-size': labelSize });
21
32
  label.textContent = cell.metric.label.toUpperCase();
22
33
  g.appendChild(label);
23
34
 
24
35
  const value = createSVGElement('text');
25
36
  value.setAttribute('class', 'oc-metric-value');
26
- setAttrs(value, { x: cell.x, y: cell.valueY });
37
+ setAttrs(value, { x: cell.x, y: cell.valueY, 'font-size': valueSize });
27
38
  value.textContent = cell.metric.value;
28
39
 
29
40
  if (cell.metric.delta) {
@@ -31,6 +42,7 @@ export function renderMetrics(parent: SVGElement, layout: ChartLayout): void {
31
42
  const tone = cell.metric.deltaTone ?? 'up';
32
43
  delta.setAttribute('class', tone === 'down' ? 'oc-metric-delta-down' : 'oc-metric-delta-up');
33
44
  delta.setAttribute('dx', '8');
45
+ delta.setAttribute('font-size', String(deltaSize));
34
46
  delta.textContent = cell.metric.delta;
35
47
  value.appendChild(delta);
36
48
  }
@@ -39,6 +51,7 @@ export function renderMetrics(parent: SVGElement, layout: ChartLayout): void {
39
51
  const secondary = createSVGElement('tspan');
40
52
  secondary.setAttribute('class', 'oc-metric-secondary');
41
53
  secondary.setAttribute('dx', '6');
54
+ secondary.setAttribute('font-size', String(deltaSize));
42
55
  secondary.textContent = cell.metric.secondary;
43
56
  value.appendChild(secondary);
44
57
  }