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 +27 -10
- package/dist/index.js +48 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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-
|
|
2486
|
-
*
|
|
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):
|
|
2611
|
-
*
|
|
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
|
|
3686
|
-
*
|
|
3687
|
-
* - A **chart** click publishes a point selection on its key field(s)
|
|
3688
|
-
*
|
|
3689
|
-
*
|
|
3690
|
-
*
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
16448
|
-
|
|
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;
|