graphein 0.8.0 → 0.9.0

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.d.ts CHANGED
@@ -1114,6 +1114,13 @@ interface AxisConfig {
1114
1114
  format?: string;
1115
1115
  /** Show tick labels. */
1116
1116
  labels?: boolean;
1117
+ /**
1118
+ * Rotation of x-axis tick labels in degrees: `0` horizontal, `45` diagonal,
1119
+ * `90` vertical. Omit for **auto** — categorical (bar/column) labels rotate to
1120
+ * 45° when there isn't room to show them all horizontally, so every category
1121
+ * stays visible instead of being thinned out. (X axis only.)
1122
+ */
1123
+ labelAngle?: number;
1117
1124
  }
1118
1125
  interface AxesConfig {
1119
1126
  x?: AxisConfig;
@@ -2877,6 +2884,12 @@ interface AxisInput {
2877
2884
  * false/omitted for them.
2878
2885
  */
2879
2886
  edgeAnchored?: boolean;
2887
+ /**
2888
+ * Explicit tick-label rotation in degrees (0/45/90). When omitted, categorical
2889
+ * (non-edge-anchored) labels auto-rotate to 45° if they wouldn't fit; the
2890
+ * resolved angle is returned on {@link Frame.xLabelAngle}.
2891
+ */
2892
+ labelAngle?: number;
2880
2893
  }
2881
2894
  interface FrameInput {
2882
2895
  width: number;
@@ -2909,6 +2922,8 @@ interface Frame {
2909
2922
  legendRect?: Rect;
2910
2923
  legendItems?: PositionedLegendItem[];
2911
2924
  legendPosition?: LegendPosition;
2925
+ /** Resolved x tick-label rotation in degrees (0 = horizontal). */
2926
+ xLabelAngle: number;
2912
2927
  }
2913
2928
  declare const TICK_SIZE = 6;
2914
2929
  /** Reserve space for every region and return the plot rect + positioned chrome. */
package/dist/index.js CHANGED
@@ -3268,7 +3268,10 @@ function paintCanvasText(ctx, c) {
3268
3268
  }
3269
3269
  function overlayTextToCanvasCmd(o, font) {
3270
3270
  const t = o.transform ?? "";
3271
- const rotate = t.includes("rotate(-90deg)") ? -Math.PI / 2 : t.includes("rotate(90deg)") ? Math.PI / 2 : void 0;
3271
+ const rotate = t.includes("rotate(-90deg)") ? -Math.PI / 2 : t.includes("rotate(90deg)") ? Math.PI / 2 : (() => {
3272
+ const m = t.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
3273
+ return m ? parseFloat(m[1]) * Math.PI / 180 : void 0;
3274
+ })();
3272
3275
  let align = "left";
3273
3276
  let x = o.left;
3274
3277
  const y = o.top;
@@ -5797,6 +5800,15 @@ var LEGEND_ITEM_GAP = 16;
5797
5800
  var LEGEND_ROW_GAP = 6;
5798
5801
  var LEGEND_PAD = 4;
5799
5802
  var lineHeight = (size) => Math.round(size * 1.35);
5803
+ function resolveXLabelAngle(axis, available, font) {
5804
+ if (axis.labelAngle != null) return Math.max(0, Math.min(90, axis.labelAngle));
5805
+ const labels = axis.labels;
5806
+ if (axis.edgeAnchored || labels.length < 2 || available <= 0) return 0;
5807
+ const wfont = fontString(font.size.small, font.family, font.weight.normal);
5808
+ const widest = Math.max(...labels.map((l) => measureText(l, wfont).width));
5809
+ const needed = widest + 10;
5810
+ return available / labels.length >= needed ? 0 : 45;
5811
+ }
5800
5812
  function legendItemWidth(label, font) {
5801
5813
  const w = measureText(label, fontString(font.size.small, font.family, font.weight.normal)).width;
5802
5814
  return LEGEND_SWATCH + LEGEND_SWATCH_GAP + Math.ceil(w);
@@ -5857,7 +5869,8 @@ function computeFrame(input) {
5857
5869
  height,
5858
5870
  originX: ox,
5859
5871
  originY: oy,
5860
- plot: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }
5872
+ plot: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
5873
+ xLabelAngle: 0
5861
5874
  };
5862
5875
  if (input.title?.text) {
5863
5876
  const titleH = lineHeight(font.size.title);
@@ -5922,10 +5935,21 @@ function computeFrame(input) {
5922
5935
  }
5923
5936
  }
5924
5937
  if (input.xAxis?.show) {
5925
- let gutter = lineHeight(font.size.small) + TICK_SIZE + AXIS_LABEL_GAP;
5938
+ const angle = resolveXLabelAngle(input.xAxis, right - left, font);
5939
+ frame.xLabelAngle = angle;
5940
+ const labelH = lineHeight(font.size.small);
5941
+ let labelBand = labelH;
5942
+ if (angle > 0 && input.xAxis.labels.length) {
5943
+ const wfont = fontString(font.size.small, font.family, font.weight.normal);
5944
+ const widest = Math.max(...input.xAxis.labels.map((l) => measureText(l, wfont).width));
5945
+ const cappedW = Math.min(widest, (bottom - top) * 0.4);
5946
+ const rad = angle * Math.PI / 180;
5947
+ labelBand = Math.ceil(cappedW * Math.sin(rad) + labelH * Math.cos(rad));
5948
+ }
5949
+ let gutter = labelBand + TICK_SIZE + AXIS_LABEL_GAP;
5926
5950
  if (input.xAxis.title) gutter += lineHeight(font.size.base) + AXIS_TITLE_GAP;
5927
5951
  bottom -= gutter;
5928
- if (input.xAxis.edgeAnchored && input.xAxis.labels.length) {
5952
+ if (input.xAxis.edgeAnchored && angle === 0 && input.xAxis.labels.length) {
5929
5953
  const wfont = fontString(font.size.small, font.family, font.weight.normal);
5930
5954
  const labels = input.xAxis.labels;
5931
5955
  const firstHalf = Math.ceil(measureText(labels[0], wfont).width / 2);
@@ -5980,6 +6004,7 @@ function addText(surface, font, o) {
5980
6004
  el.style.textAlign = o.align ?? "left";
5981
6005
  }
5982
6006
  if (o.transform) el.style.transform = o.transform;
6007
+ if (o.transformOrigin) el.style.transformOrigin = o.transformOrigin;
5983
6008
  el.style.pointerEvents = "none";
5984
6009
  overlay.appendChild(el);
5985
6010
  }
@@ -6115,8 +6140,18 @@ function placeXLabels(ticks2, originX, frameWidth, font) {
6115
6140
  }
6116
6141
  return boxes.filter((_, i) => keep[i]);
6117
6142
  }
6143
+ function placeRotatedXLabels(ticks2, angle) {
6144
+ return ticks2.map((t) => ({
6145
+ text: t.label,
6146
+ pos: t.pos,
6147
+ left: t.pos,
6148
+ transform: `translateX(-100%) rotate(-${angle}deg)`
6149
+ }));
6150
+ }
6118
6151
  function resolveXLabels(model) {
6119
6152
  const f = model.tokens.font;
6153
+ const angle = model.frame.xLabelAngle;
6154
+ if (angle > 0) return placeRotatedXLabels(model.xTicks, angle);
6120
6155
  const smallFont = fontString(f.size.small, f.family, f.weight.normal);
6121
6156
  const thinned = thinXTicks(model, f.size.small * 0.58);
6122
6157
  return placeXLabels(thinned, model.frame.originX, model.frame.width, smallFont);
@@ -6142,6 +6177,7 @@ function drawOverlay(surface, model) {
6142
6177
  }
6143
6178
  if (model.spec.axes?.x?.labels !== false) {
6144
6179
  const top = plot.y + plot.height + TICK_SIZE + 3;
6180
+ const rotated = frame.xLabelAngle > 0;
6145
6181
  for (const p of resolveXLabels(model)) {
6146
6182
  addText(surface, smallFont, {
6147
6183
  left: p.left,
@@ -6149,7 +6185,9 @@ function drawOverlay(surface, model) {
6149
6185
  text: p.text,
6150
6186
  color: tokens.color.textMuted,
6151
6187
  size: f.size.small,
6152
- transform: p.transform
6188
+ align: rotated ? "right" : void 0,
6189
+ transform: p.transform,
6190
+ transformOrigin: rotated ? "100% 0%" : void 0
6153
6191
  });
6154
6192
  }
6155
6193
  }
@@ -6536,7 +6574,7 @@ function buildCartesianModel(spec, tokens, opts) {
6536
6574
  font: tokens.font,
6537
6575
  title: titleInput4,
6538
6576
  legend: legend.show && legendItems.length > 1 ? { items: legendItems, position: legend.position } : void 0,
6539
- xAxis: { show: xShow, labels: xLabels, title: resolveAxisTitle(enc.x, xAxisCfg), edgeAnchored: !categories },
6577
+ xAxis: { show: xShow, labels: xLabels, title: resolveAxisTitle(enc.x, xAxisCfg), edgeAnchored: !categories, labelAngle: xAxisCfg.labelAngle },
6540
6578
  yAxis: { show: yShow, labels: yLabels, title: resolveAxisTitle(enc.y, yAxisCfg) }
6541
6579
  });
6542
6580
  const plot = frame.plot;
@@ -15702,6 +15740,7 @@ function checkClippedMarks(model, add) {
15702
15740
  function checkAxisLabelOverlap(model, tokens, add) {
15703
15741
  const ticks2 = model.xTicks;
15704
15742
  if (ticks2.length < 2) return;
15743
+ if (model.frame.xLabelAngle > 0) return;
15705
15744
  const font = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
15706
15745
  const GAP3 = 4;
15707
15746
  let collisions = 0;