graphein 0.7.2 → 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;
@@ -2482,8 +2489,9 @@ declare function fontString(size: number, family: string, weight?: number | stri
2482
2489
  *
2483
2490
  * A dashboard owns a shared dataset and a shared selection store, lays its views
2484
2491
  * out on a responsive grid, and *auto-wires* cross-interaction: slicers filter
2485
- * the charts, and clicking a chart cross-highlights the other charts that share
2486
- * the selected field. Everything is plain JSON — no callbacks — so a dashboard
2492
+ * the charts, and clicking a chart cross-filters the other views while the
2493
+ * source dims its own unpicked marks (Power BI–style).
2494
+ * Everything is plain JSON — no callbacks — so a dashboard
2487
2495
  * round-trips through `JSON.stringify` just like a chart.
2488
2496
  */
2489
2497
 
@@ -2607,8 +2615,9 @@ interface DashboardSpec {
2607
2615
  views: DashboardView[];
2608
2616
  /**
2609
2617
  * Cross-interaction policy:
2610
- * - 'auto' (default): slicers filter charts; chart clicks cross-highlight
2611
- * charts sharing the selected field.
2618
+ * - 'auto' (default): every source cross-filters the page — slicers and chart
2619
+ * clicks both subset rows in the other views; the clicked chart dims its own
2620
+ * unpicked marks instead of hiding them.
2612
2621
  * - 'none': views are laid out but not linked.
2613
2622
  * - an array of {@link InteractionLink}: explicit wiring (replaces auto).
2614
2623
  */
@@ -2875,6 +2884,12 @@ interface AxisInput {
2875
2884
  * false/omitted for them.
2876
2885
  */
2877
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;
2878
2893
  }
2879
2894
  interface FrameInput {
2880
2895
  width: number;
@@ -2907,6 +2922,8 @@ interface Frame {
2907
2922
  legendRect?: Rect;
2908
2923
  legendItems?: PositionedLegendItem[];
2909
2924
  legendPosition?: LegendPosition;
2925
+ /** Resolved x tick-label rotation in degrees (0 = horizontal). */
2926
+ xLabelAngle: number;
2910
2927
  }
2911
2928
  declare const TICK_SIZE = 6;
2912
2929
  /** Reserve space for every region and return the plot rect + positioned chrome. */
@@ -3682,12 +3699,12 @@ declare function render(target: HTMLElement | string, spec: ChartSpec, options?:
3682
3699
  * store) so it unit-tests in node and the runtime stays a thin shell.
3683
3700
  *
3684
3701
  * Rules (interactions: 'auto'):
3685
- * - A **slicer** publishes a value on its field/param; every chart that *uses*
3686
- * that field gets a `filter` clause for it (cross-filter).
3687
- * - A **chart** click publishes a point selection on its key field(s); every
3688
- * chart that uses *all* those fields (including itself) gets that param added
3689
- * to its `highlight` union (cross-highlight). Matching on shared fields means
3690
- * emphasis is always well-defined — a chart never dims itself to nothing.
3702
+ * - A **slicer** publishes a value on its field/param; every other non-slicer
3703
+ * view gets a `filter` clause for it (page-level cross-filter).
3704
+ * - A **chart** click publishes a point selection on its key field(s). The
3705
+ * source emphasizes itself (`highlight` on its own param, so it dims the
3706
+ * unpicked marks rather than hiding them), and **every other view** gets a
3707
+ * `filter` clause for that param (Power BI–style cross-filter of the page).
3691
3708
  */
3692
3709
 
3693
3710
  declare function isSlicerType(type: ChartSpec['type']): boolean;
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;
@@ -16401,11 +16440,6 @@ function addFilterParam(spec, paramName) {
16401
16440
  filter.push({ param: paramName });
16402
16441
  return { ...spec, filter };
16403
16442
  }
16404
- function usesAll(spec, fields) {
16405
- if (fields.length === 0) return false;
16406
- const have = specFields(spec);
16407
- return fields.every((f) => have.has(f));
16408
- }
16409
16443
  function autoParamName(view) {
16410
16444
  const existing = view.spec.params?.[0]?.name;
16411
16445
  return existing ?? `__sel__${view.id}`;
@@ -16441,16 +16475,12 @@ function wireViews(views, interactions) {
16441
16475
  name: src.param,
16442
16476
  select: { type: "point", on: "click", fields: src.fields }
16443
16477
  });
16478
+ out[src.view].spec = addHighlight(out[src.view].spec, src.param);
16444
16479
  }
16445
16480
  out.forEach((v, j) => {
16446
16481
  if (isSlicerType(v.spec.type)) return;
16447
- if (src.as === "filter") {
16448
- if (j === src.view) return;
16449
- out[j].spec = addFilterParam(out[j].spec, src.param);
16450
- } else {
16451
- if (!usesAll(v.spec, src.fields)) return;
16452
- out[j].spec = addHighlight(out[j].spec, src.param);
16453
- }
16482
+ if (j === src.view) return;
16483
+ out[j].spec = addFilterParam(out[j].spec, src.param);
16454
16484
  });
16455
16485
  }
16456
16486
  return out;