@cfasim-ui/docs 0.4.16 → 0.5.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/charts/BarChart/BarChart.md +133 -0
- package/charts/BarChart/BarChart.vue +477 -9
- package/charts/DataTable/DataTable.md +55 -0
- package/charts/DataTable/DataTable.vue +40 -1
- package/charts/_shared/contrast.ts +100 -0
- package/charts/_shared/index.ts +7 -0
- package/charts/env.d.ts +4 -0
- package/components/Icon/Icon.md +122 -1
- package/components/Icon/Icon.vue +43 -51
- package/components/Icon/defaultIcons.ts +92 -0
- package/components/Icon/github.svg +1 -0
- package/components/Icon/registry.ts +68 -0
- package/components/ParamEditor/ParamEditorImpl.vue +8 -5
- package/components/index.ts +3 -0
- package/components/svg.d.ts +5 -0
- package/index.json +1 -1
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
TICK_LABEL_FONT_SIZE,
|
|
22
22
|
TICK_LABEL_OPACITY,
|
|
23
23
|
LEGEND_FONT_SIZE,
|
|
24
|
+
pickContrastColor,
|
|
24
25
|
resolveLabelStyle,
|
|
25
26
|
parseDate,
|
|
26
27
|
isAllDates,
|
|
@@ -34,6 +35,8 @@ import {
|
|
|
34
35
|
type ChartTooltipValue,
|
|
35
36
|
type BlendMode,
|
|
36
37
|
type LineMarkStyle,
|
|
38
|
+
type LabelStyle,
|
|
39
|
+
type ChartPadding,
|
|
37
40
|
} from "../_shared/index.js";
|
|
38
41
|
|
|
39
42
|
export type BarChartData = ChartData;
|
|
@@ -96,6 +99,66 @@ export interface BarSummaryLine extends LineMarkStyle {
|
|
|
96
99
|
valueMax?: number;
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
/** Context passed to a `barLabels.format` function. */
|
|
103
|
+
export interface BarLabelContext {
|
|
104
|
+
value: number;
|
|
105
|
+
categoryIndex: number;
|
|
106
|
+
seriesIndex: number;
|
|
107
|
+
/** The resolved category label string. */
|
|
108
|
+
category: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Styling and behavior for value labels drawn directly on the bars (an
|
|
113
|
+
* alternative to reading values off the value axis — see `valueAxis`).
|
|
114
|
+
* Most useful with `layout="stacked"`, where each segment carries its own
|
|
115
|
+
* value. Extends `LabelStyle` (`fontSize` / `fontWeight`).
|
|
116
|
+
*/
|
|
117
|
+
export interface BarLabelStyle extends LabelStyle {
|
|
118
|
+
/**
|
|
119
|
+
* How to turn a bar's numeric value into label text. A `NumberFormat`
|
|
120
|
+
* (preset/printf/function), or a function receiving `(value, context)`
|
|
121
|
+
* for fully custom strings (e.g. `">99%"`). Return `""` to omit a
|
|
122
|
+
* label. When unset, falls back to `valueTickFormat`, then a default.
|
|
123
|
+
*/
|
|
124
|
+
format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
|
|
125
|
+
/**
|
|
126
|
+
* Label text color. `"auto"` (default) picks white or near-black per
|
|
127
|
+
* segment by the fill's luminance for readable contrast; outside-placed
|
|
128
|
+
* labels use the chart's text color. Any other CSS color is used as-is
|
|
129
|
+
* for every label.
|
|
130
|
+
*/
|
|
131
|
+
color?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Where to draw each label. `"auto"` (default) centers it inside the
|
|
134
|
+
* segment when the text fits, otherwise just past the segment's
|
|
135
|
+
* high-value edge. `"inside"` / `"outside"` force one placement.
|
|
136
|
+
*/
|
|
137
|
+
placement?: "auto" | "inside" | "outside";
|
|
138
|
+
/**
|
|
139
|
+
* Alignment of an *inside* label along the value axis. `"center"`
|
|
140
|
+
* (default) centers it in its segment; `"start"` pins it to the
|
|
141
|
+
* segment's low-value edge (left edge for horizontal bars); `"end"`
|
|
142
|
+
* pins it to the high-value edge. Outside/overflow labels ignore this
|
|
143
|
+
* and always sit just past the high-value edge.
|
|
144
|
+
*/
|
|
145
|
+
align?: "start" | "center" | "end";
|
|
146
|
+
/**
|
|
147
|
+
* How to handle labels that would collide along the value axis (common
|
|
148
|
+
* with many small adjacent segments). `"shift"` (default) nudges them
|
|
149
|
+
* apart toward higher values; `"hide"` drops the colliding label. Either
|
|
150
|
+
* way, a label pushed past the chart edge is hidden.
|
|
151
|
+
*/
|
|
152
|
+
overlap?: "shift" | "hide";
|
|
153
|
+
/**
|
|
154
|
+
* Segments smaller than this many pixels (along the value axis) never
|
|
155
|
+
* get an *inside* label — they fall back to outside placement (or hide,
|
|
156
|
+
* per `overlap`). Use to suppress labels crammed into thin slivers.
|
|
157
|
+
* Default 0 (size alone never forces a label out; text fit still does).
|
|
158
|
+
*/
|
|
159
|
+
minSize?: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
interface BarChartProps extends ChartCommonProps {
|
|
100
163
|
/** Single-series values. Equivalent to `y`. */
|
|
101
164
|
data?: BarChartData;
|
|
@@ -151,6 +214,15 @@ interface BarChartProps extends ChartCommonProps {
|
|
|
151
214
|
valueTickFormat?: NumberFormat;
|
|
152
215
|
/** Formatter for category-axis labels. Receives the resolved category string. */
|
|
153
216
|
categoryFormat?: (label: string, index: number) => string;
|
|
217
|
+
/**
|
|
218
|
+
* Alignment of the category labels in horizontal orientation. `"end"`
|
|
219
|
+
* (default) right-aligns them against the plot's left edge; `"start"`
|
|
220
|
+
* left-aligns them at the chart's left margin; `"center"` centers them
|
|
221
|
+
* in the margin. The `categoryHeader`, if set, follows the same
|
|
222
|
+
* alignment. Ignored in vertical orientation (labels stay centered
|
|
223
|
+
* under each column).
|
|
224
|
+
*/
|
|
225
|
+
categoryAlign?: "start" | "center" | "end";
|
|
154
226
|
/**
|
|
155
227
|
* Date-tick formatter for date-mode category labels (auto-detected
|
|
156
228
|
* when every entry of `categories` parses as a date). Ignored unless
|
|
@@ -172,6 +244,33 @@ interface BarChartProps extends ChartCommonProps {
|
|
|
172
244
|
*/
|
|
173
245
|
groupGap?: number;
|
|
174
246
|
valueGrid?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* Draw the value axis (its line and tick labels). Default true. Set
|
|
249
|
+
* false for a clean "value-on-bar" look where each segment is labeled
|
|
250
|
+
* via `barLabels` and there's no axis to read against.
|
|
251
|
+
*/
|
|
252
|
+
valueAxis?: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Draw the numeric value directly on each bar instead of (or alongside)
|
|
255
|
+
* reading it off the value axis. `true` uses defaults; pass a
|
|
256
|
+
* `BarLabelStyle` to customize formatting, color, placement, and
|
|
257
|
+
* overlap handling. Most useful with `layout="stacked"`.
|
|
258
|
+
*/
|
|
259
|
+
barLabels?: boolean | BarLabelStyle;
|
|
260
|
+
/**
|
|
261
|
+
* Header drawn above the category labels (e.g. "Scenario"). Aligned to
|
|
262
|
+
* match the category labels — right-edge-aligned in horizontal
|
|
263
|
+
* orientation, centered under each column in vertical. Reserves a row
|
|
264
|
+
* of space above the plot.
|
|
265
|
+
*/
|
|
266
|
+
categoryHeader?: string;
|
|
267
|
+
/**
|
|
268
|
+
* Header drawn above the bars / value area (e.g. "Proportion of
|
|
269
|
+
* simulations"), left-aligned to where the bars start. Pairs with
|
|
270
|
+
* `categoryHeader` to label both columns of a value-on-bar chart.
|
|
271
|
+
* Reserves a row of space above the plot.
|
|
272
|
+
*/
|
|
273
|
+
valueHeader?: string;
|
|
175
274
|
}
|
|
176
275
|
|
|
177
276
|
const props = withDefaults(defineProps<BarChartProps>(), {
|
|
@@ -184,6 +283,7 @@ const props = withDefaults(defineProps<BarChartProps>(), {
|
|
|
184
283
|
tooltipClamp: "chart",
|
|
185
284
|
valueGrid: true,
|
|
186
285
|
valueScaleType: "linear",
|
|
286
|
+
valueAxis: true,
|
|
187
287
|
});
|
|
188
288
|
|
|
189
289
|
const emit = defineEmits<{
|
|
@@ -861,6 +961,27 @@ function pointerToIndex(clientX: number, clientY: number): number | null {
|
|
|
861
961
|
return Math.max(0, Math.min(n - 1, Math.floor(local / slotSize.value)));
|
|
862
962
|
}
|
|
863
963
|
|
|
964
|
+
/** Height reserved above the plot for the category/value column headers. */
|
|
965
|
+
const HEADER_ROW_HEIGHT = 22;
|
|
966
|
+
|
|
967
|
+
const hasHeaders = computed(
|
|
968
|
+
() => !!(props.categoryHeader || props.valueHeader),
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* `chartPadding` with extra top room reserved for the column headers when
|
|
973
|
+
* either is set, so they don't overlap the legend or the plot.
|
|
974
|
+
*/
|
|
975
|
+
const effectiveChartPadding = computed<ChartPadding | undefined>(() => {
|
|
976
|
+
const extraTop = hasHeaders.value ? HEADER_ROW_HEIGHT : 0;
|
|
977
|
+
const base = props.chartPadding;
|
|
978
|
+
if (!extraTop) return base;
|
|
979
|
+
if (base == null) return { top: extraTop };
|
|
980
|
+
if (typeof base === "number")
|
|
981
|
+
return { top: base + extraTop, right: base, bottom: base, left: base };
|
|
982
|
+
return { ...base, top: (base.top ?? 0) + extraTop };
|
|
983
|
+
});
|
|
984
|
+
|
|
864
985
|
const {
|
|
865
986
|
containerRef,
|
|
866
987
|
svgRef,
|
|
@@ -897,7 +1018,7 @@ const {
|
|
|
897
1018
|
filename: () => props.filename,
|
|
898
1019
|
downloadLink: () => props.downloadLink,
|
|
899
1020
|
downloadButton: () => props.downloadButton,
|
|
900
|
-
chartPadding: () =>
|
|
1021
|
+
chartPadding: () => effectiveChartPadding.value,
|
|
901
1022
|
inlineLegendLabels: () => inlineLegendLabels.value,
|
|
902
1023
|
hasTooltipSlot: () => hasTooltipSlot.value,
|
|
903
1024
|
getCsv: toCsv,
|
|
@@ -921,6 +1042,21 @@ const legendResolved = computed(() =>
|
|
|
921
1042
|
resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
|
|
922
1043
|
);
|
|
923
1044
|
|
|
1045
|
+
/** Small inset from the chart's left edge for start-aligned category labels. */
|
|
1046
|
+
const CATEGORY_LABEL_INSET = 2;
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Left x-anchor for the title and inline legend. When the category labels
|
|
1050
|
+
* are start-aligned (horizontal), the title/legend align to that same left
|
|
1051
|
+
* edge so the whole header column lines up; otherwise they sit at the
|
|
1052
|
+
* plot's left edge.
|
|
1053
|
+
*/
|
|
1054
|
+
const headerLeftX = computed(() =>
|
|
1055
|
+
props.categoryAlign === "start" && !isVertical.value
|
|
1056
|
+
? CATEGORY_LABEL_INSET
|
|
1057
|
+
: padding.value.left,
|
|
1058
|
+
);
|
|
1059
|
+
|
|
924
1060
|
/** Resolved title style with defaults applied. */
|
|
925
1061
|
const titleResolved = computed(() => {
|
|
926
1062
|
const s = props.titleStyle;
|
|
@@ -928,7 +1064,7 @@ const titleResolved = computed(() => {
|
|
|
928
1064
|
const b = bounds.value;
|
|
929
1065
|
const x =
|
|
930
1066
|
align === "left"
|
|
931
|
-
?
|
|
1067
|
+
? headerLeftX.value
|
|
932
1068
|
: align === "right"
|
|
933
1069
|
? b.right
|
|
934
1070
|
: b.left + b.width / 2;
|
|
@@ -1007,7 +1143,7 @@ const hoverBand = computed(() => {
|
|
|
1007
1143
|
*/
|
|
1008
1144
|
const positionedLegendItems = computed(() => {
|
|
1009
1145
|
const positions = inlineLegendLayout.value.positions;
|
|
1010
|
-
const pad =
|
|
1146
|
+
const pad = headerLeftX.value;
|
|
1011
1147
|
const baseY = legendY.value;
|
|
1012
1148
|
return inlineLegendItems.value.map((item, i) => {
|
|
1013
1149
|
const pos = positions[i];
|
|
@@ -1018,6 +1154,306 @@ const positionedLegendItems = computed(() => {
|
|
|
1018
1154
|
};
|
|
1019
1155
|
});
|
|
1020
1156
|
});
|
|
1157
|
+
|
|
1158
|
+
const BAR_LABEL_FONT_SIZE = 11;
|
|
1159
|
+
/** Estimated glyph width as a fraction of font size (matches axis logic). */
|
|
1160
|
+
const BAR_LABEL_CHAR_WIDTH = 0.6;
|
|
1161
|
+
/** Inset of an inside label / offset of an outside label from the edge. */
|
|
1162
|
+
const BAR_LABEL_PAD = 4;
|
|
1163
|
+
/** Minimum gap between two labels when decluttering. */
|
|
1164
|
+
const BAR_LABEL_GAP = 4;
|
|
1165
|
+
|
|
1166
|
+
interface ResolvedBarLabels {
|
|
1167
|
+
format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
|
|
1168
|
+
color: string;
|
|
1169
|
+
placement: "auto" | "inside" | "outside";
|
|
1170
|
+
align: "start" | "center" | "end";
|
|
1171
|
+
overlap: "shift" | "hide";
|
|
1172
|
+
minSize: number;
|
|
1173
|
+
fontSize: number;
|
|
1174
|
+
fontWeight: number | string | undefined;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const barLabelOptions = computed<ResolvedBarLabels | null>(() => {
|
|
1178
|
+
const b = props.barLabels;
|
|
1179
|
+
if (!b) return null;
|
|
1180
|
+
const o = b === true ? {} : b;
|
|
1181
|
+
return {
|
|
1182
|
+
format: o.format,
|
|
1183
|
+
color: o.color ?? "auto",
|
|
1184
|
+
placement: o.placement ?? "auto",
|
|
1185
|
+
align: o.align ?? "center",
|
|
1186
|
+
overlap: o.overlap ?? "shift",
|
|
1187
|
+
minSize: o.minSize ?? 0,
|
|
1188
|
+
fontSize: o.fontSize ?? BAR_LABEL_FONT_SIZE,
|
|
1189
|
+
fontWeight: o.fontWeight,
|
|
1190
|
+
};
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
function formatBarLabel(value: number, ctx: BarLabelContext): string {
|
|
1194
|
+
const f = barLabelOptions.value?.format;
|
|
1195
|
+
if (typeof f === "function") return f(value, ctx);
|
|
1196
|
+
if (f !== undefined) return formatNumber(value, f);
|
|
1197
|
+
if (props.valueTickFormat !== undefined)
|
|
1198
|
+
return formatNumber(value, props.valueTickFormat);
|
|
1199
|
+
return formatTick(value);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Fill color of the bar covering pixel (px, py) within category `cat`, or
|
|
1204
|
+
* null when no bar sits there (the chart background). Used to choose a
|
|
1205
|
+
* readable color for a label that overflows onto a neighboring segment.
|
|
1206
|
+
*/
|
|
1207
|
+
function barColorAt(cat: number, px: number, py: number): string | null {
|
|
1208
|
+
// Iterate in reverse so the topmost (last-drawn) bar wins under overlap.
|
|
1209
|
+
for (let i = bars.value.length - 1; i >= 0; i--) {
|
|
1210
|
+
const b = bars.value[i];
|
|
1211
|
+
if (b.categoryIndex !== cat) continue;
|
|
1212
|
+
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h)
|
|
1213
|
+
return b.color;
|
|
1214
|
+
}
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
interface BarLabelItem {
|
|
1219
|
+
key: string;
|
|
1220
|
+
x: number;
|
|
1221
|
+
y: number;
|
|
1222
|
+
text: string;
|
|
1223
|
+
anchor: "start" | "middle" | "end";
|
|
1224
|
+
fill: string;
|
|
1225
|
+
fontSize: number;
|
|
1226
|
+
fontWeight: number | string | undefined;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const barLabelItems = computed<BarLabelItem[]>(() => {
|
|
1230
|
+
const opts = barLabelOptions.value;
|
|
1231
|
+
if (!opts) return [];
|
|
1232
|
+
const vertical = isVertical.value;
|
|
1233
|
+
const fontSize = opts.fontSize;
|
|
1234
|
+
|
|
1235
|
+
// Build a draft label per bar, computing its placement (inside vs
|
|
1236
|
+
// outside) and a 1D extent [lo, hi] along the value axis in a
|
|
1237
|
+
// "value-increasing" coordinate `u` (rightward when horizontal, upward
|
|
1238
|
+
// when vertical). Collision resolution then runs per category in `u`.
|
|
1239
|
+
interface Draft {
|
|
1240
|
+
bar: (typeof bars.value)[number];
|
|
1241
|
+
text: string;
|
|
1242
|
+
textLen: number;
|
|
1243
|
+
inside: boolean;
|
|
1244
|
+
cross: number; // fixed coordinate on the categorical axis
|
|
1245
|
+
lo: number; // low-value edge of the text in u
|
|
1246
|
+
hi: number; // high-value edge of the text in u
|
|
1247
|
+
// Horizontal text-anchor for this label (vertical bars always center).
|
|
1248
|
+
svgAnchor: "start" | "middle" | "end";
|
|
1249
|
+
hidden: boolean;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const drafts: Draft[] = [];
|
|
1253
|
+
for (const bar of bars.value) {
|
|
1254
|
+
const text = formatBarLabel(bar.value, {
|
|
1255
|
+
value: bar.value,
|
|
1256
|
+
categoryIndex: bar.categoryIndex,
|
|
1257
|
+
seriesIndex: bar.seriesIndex,
|
|
1258
|
+
category:
|
|
1259
|
+
categoryLabels.value[bar.categoryIndex] ?? String(bar.categoryIndex),
|
|
1260
|
+
});
|
|
1261
|
+
if (!text) continue;
|
|
1262
|
+
|
|
1263
|
+
const segSize = vertical ? bar.h : bar.w;
|
|
1264
|
+
const textLen = text.length * fontSize * BAR_LABEL_CHAR_WIDTH;
|
|
1265
|
+
const fits =
|
|
1266
|
+
segSize >= textLen + 2 * BAR_LABEL_PAD && segSize >= opts.minSize;
|
|
1267
|
+
const inside =
|
|
1268
|
+
opts.placement === "inside"
|
|
1269
|
+
? true
|
|
1270
|
+
: opts.placement === "outside"
|
|
1271
|
+
? false
|
|
1272
|
+
: fits;
|
|
1273
|
+
|
|
1274
|
+
// `u` increases with value: rightward (x) for horizontal bars, upward
|
|
1275
|
+
// (-y) for vertical bars. The label occupies a span of `len` in u —
|
|
1276
|
+
// its text width when horizontal, its line height when vertical.
|
|
1277
|
+
let lo: number;
|
|
1278
|
+
let hi: number;
|
|
1279
|
+
let cross: number;
|
|
1280
|
+
let svgAnchor: "start" | "middle" | "end" = "middle";
|
|
1281
|
+
const segHiU = vertical ? -bar.y : bar.x + bar.w; // high-value edge
|
|
1282
|
+
const segLoU = vertical ? -(bar.y + bar.h) : bar.x; // low-value edge
|
|
1283
|
+
const len = vertical ? fontSize : textLen;
|
|
1284
|
+
cross = vertical ? bar.x + bar.w / 2 : bar.y + bar.h / 2;
|
|
1285
|
+
if (!inside) {
|
|
1286
|
+
// Just past the high-value edge, growing outward.
|
|
1287
|
+
lo = segHiU + BAR_LABEL_PAD;
|
|
1288
|
+
hi = lo + len;
|
|
1289
|
+
svgAnchor = "start";
|
|
1290
|
+
} else if (opts.align === "start") {
|
|
1291
|
+
lo = segLoU + BAR_LABEL_PAD;
|
|
1292
|
+
hi = lo + len;
|
|
1293
|
+
svgAnchor = "start";
|
|
1294
|
+
} else if (opts.align === "end") {
|
|
1295
|
+
hi = segHiU - BAR_LABEL_PAD;
|
|
1296
|
+
lo = hi - len;
|
|
1297
|
+
svgAnchor = "end";
|
|
1298
|
+
} else {
|
|
1299
|
+
const c = (segHiU + segLoU) / 2;
|
|
1300
|
+
lo = c - len / 2;
|
|
1301
|
+
hi = c + len / 2;
|
|
1302
|
+
svgAnchor = "middle";
|
|
1303
|
+
}
|
|
1304
|
+
drafts.push({
|
|
1305
|
+
bar,
|
|
1306
|
+
text,
|
|
1307
|
+
textLen,
|
|
1308
|
+
inside,
|
|
1309
|
+
cross,
|
|
1310
|
+
lo,
|
|
1311
|
+
hi,
|
|
1312
|
+
svgAnchor,
|
|
1313
|
+
hidden: false,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Declutter per category along `u`: sweep low→high, pushing or hiding
|
|
1318
|
+
// labels that would overlap the previous one. Bounds keep a pushed label
|
|
1319
|
+
// from sliding off the chart.
|
|
1320
|
+
const uMax = vertical ? -padding.value.top : width.value - 2;
|
|
1321
|
+
const byCategory = new Map<number, Draft[]>();
|
|
1322
|
+
for (const d of drafts) {
|
|
1323
|
+
const arr = byCategory.get(d.bar.categoryIndex);
|
|
1324
|
+
if (arr) arr.push(d);
|
|
1325
|
+
else byCategory.set(d.bar.categoryIndex, [d]);
|
|
1326
|
+
}
|
|
1327
|
+
for (const group of byCategory.values()) {
|
|
1328
|
+
group.sort((a, b) => a.lo - b.lo);
|
|
1329
|
+
let cursor = -Infinity;
|
|
1330
|
+
for (const d of group) {
|
|
1331
|
+
if (d.lo < cursor + BAR_LABEL_GAP) {
|
|
1332
|
+
if (opts.overlap === "hide") {
|
|
1333
|
+
d.hidden = true;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
const shift = cursor + BAR_LABEL_GAP - d.lo;
|
|
1337
|
+
d.lo += shift;
|
|
1338
|
+
d.hi += shift;
|
|
1339
|
+
d.inside = false; // a shifted label no longer sits in its segment
|
|
1340
|
+
d.svgAnchor = "start";
|
|
1341
|
+
}
|
|
1342
|
+
if (d.hi > uMax) {
|
|
1343
|
+
d.hidden = true;
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
cursor = d.hi;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const auto = opts.color === "auto";
|
|
1351
|
+
const items: BarLabelItem[] = [];
|
|
1352
|
+
for (const d of drafts) {
|
|
1353
|
+
if (d.hidden) continue;
|
|
1354
|
+
let x: number;
|
|
1355
|
+
let y: number;
|
|
1356
|
+
let anchor: "start" | "middle" | "end";
|
|
1357
|
+
if (vertical) {
|
|
1358
|
+
// Vertical bars center the text horizontally; align shifted the
|
|
1359
|
+
// [lo, hi] band along the value (y) axis, so render at its center.
|
|
1360
|
+
x = d.cross;
|
|
1361
|
+
y = -((d.lo + d.hi) / 2);
|
|
1362
|
+
anchor = "middle";
|
|
1363
|
+
} else {
|
|
1364
|
+
anchor = d.svgAnchor;
|
|
1365
|
+
x =
|
|
1366
|
+
anchor === "start" ? d.lo : anchor === "end" ? d.hi : (d.lo + d.hi) / 2;
|
|
1367
|
+
y = d.cross;
|
|
1368
|
+
}
|
|
1369
|
+
// Pick contrast against whatever the label actually sits on. Inside
|
|
1370
|
+
// labels are on their own segment; an outside/shifted label commonly
|
|
1371
|
+
// lands on a neighboring segment of the same stack — use that
|
|
1372
|
+
// segment's fill, falling back to the chart background (currentColor)
|
|
1373
|
+
// only when the label clears all bars.
|
|
1374
|
+
let fill: string;
|
|
1375
|
+
if (!auto) {
|
|
1376
|
+
fill = opts.color;
|
|
1377
|
+
} else if (d.inside) {
|
|
1378
|
+
fill = pickContrastColor(d.bar.color);
|
|
1379
|
+
} else {
|
|
1380
|
+
const cx = vertical ? d.cross : (d.lo + d.hi) / 2;
|
|
1381
|
+
const cy = vertical ? -((d.lo + d.hi) / 2) : d.cross;
|
|
1382
|
+
const under = barColorAt(d.bar.categoryIndex, cx, cy);
|
|
1383
|
+
fill = under ? pickContrastColor(under) : "currentColor";
|
|
1384
|
+
}
|
|
1385
|
+
items.push({
|
|
1386
|
+
key: `${d.bar.categoryIndex}-${d.bar.seriesIndex}`,
|
|
1387
|
+
x,
|
|
1388
|
+
y,
|
|
1389
|
+
text: d.text,
|
|
1390
|
+
anchor,
|
|
1391
|
+
fill,
|
|
1392
|
+
fontSize,
|
|
1393
|
+
fontWeight: opts.fontWeight,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
return items;
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Anchor + x for the category labels (and matching `categoryHeader`) in
|
|
1401
|
+
* horizontal orientation, driven by `categoryAlign`. Vertical orientation
|
|
1402
|
+
* doesn't use this (labels stay centered under each column).
|
|
1403
|
+
*/
|
|
1404
|
+
const categoryLabelLayout = computed<{
|
|
1405
|
+
anchor: "start" | "middle" | "end";
|
|
1406
|
+
x: number;
|
|
1407
|
+
}>(() => {
|
|
1408
|
+
const right = padding.value.left - 6;
|
|
1409
|
+
switch (props.categoryAlign ?? "end") {
|
|
1410
|
+
case "start":
|
|
1411
|
+
return { anchor: "start", x: CATEGORY_LABEL_INSET };
|
|
1412
|
+
case "center":
|
|
1413
|
+
return { anchor: "middle", x: (CATEGORY_LABEL_INSET + right) / 2 };
|
|
1414
|
+
default:
|
|
1415
|
+
return { anchor: "end", x: right };
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
interface ColumnHeader {
|
|
1420
|
+
key: string;
|
|
1421
|
+
text: string;
|
|
1422
|
+
x: number;
|
|
1423
|
+
y: number;
|
|
1424
|
+
anchor: "start" | "middle" | "end";
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Positioned column headers (`categoryHeader` / `valueHeader`), drawn in
|
|
1429
|
+
* the reserved row just above the plot. The category header is aligned to
|
|
1430
|
+
* match the category labels; the value header starts where the bars do.
|
|
1431
|
+
*/
|
|
1432
|
+
const columnHeaders = computed<ColumnHeader[]>(() => {
|
|
1433
|
+
const out: ColumnHeader[] = [];
|
|
1434
|
+
const y = padding.value.top - 7;
|
|
1435
|
+
if (props.valueHeader) {
|
|
1436
|
+
out.push({
|
|
1437
|
+
key: "vh",
|
|
1438
|
+
text: props.valueHeader,
|
|
1439
|
+
x: padding.value.left,
|
|
1440
|
+
y,
|
|
1441
|
+
anchor: "start",
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
if (props.categoryHeader) {
|
|
1445
|
+
const horizontal = !isVertical.value;
|
|
1446
|
+
const cat = categoryLabelLayout.value;
|
|
1447
|
+
out.push({
|
|
1448
|
+
key: "ch",
|
|
1449
|
+
text: props.categoryHeader,
|
|
1450
|
+
x: horizontal ? cat.x : padding.value.left,
|
|
1451
|
+
y,
|
|
1452
|
+
anchor: horizontal ? cat.anchor : "start",
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
return out;
|
|
1456
|
+
});
|
|
1021
1457
|
</script>
|
|
1022
1458
|
|
|
1023
1459
|
<template>
|
|
@@ -1050,6 +1486,20 @@ const positionedLegendItems = computed(() => {
|
|
|
1050
1486
|
{{ line }}
|
|
1051
1487
|
</tspan>
|
|
1052
1488
|
</text>
|
|
1489
|
+
<!-- column headers (category / value), in the reserved row above the plot -->
|
|
1490
|
+
<text
|
|
1491
|
+
v-for="h in columnHeaders"
|
|
1492
|
+
:key="h.key"
|
|
1493
|
+
:data-testid="h.key === 'ch' ? 'category-header' : 'value-header'"
|
|
1494
|
+
:x="h.x"
|
|
1495
|
+
:y="h.y"
|
|
1496
|
+
:text-anchor="h.anchor"
|
|
1497
|
+
:font-size="axisLabelResolved.fontSize"
|
|
1498
|
+
:fill="axisLabelResolved.fill"
|
|
1499
|
+
font-weight="600"
|
|
1500
|
+
>
|
|
1501
|
+
{{ h.text }}
|
|
1502
|
+
</text>
|
|
1053
1503
|
<!-- inline legend -->
|
|
1054
1504
|
<g v-if="positionedLegendItems.length > 0">
|
|
1055
1505
|
<template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
|
|
@@ -1082,8 +1532,9 @@ const positionedLegendItems = computed(() => {
|
|
|
1082
1532
|
</text>
|
|
1083
1533
|
</template>
|
|
1084
1534
|
</g>
|
|
1085
|
-
<!-- axes -->
|
|
1535
|
+
<!-- axes (the value axis line is suppressed when valueAxis is false) -->
|
|
1086
1536
|
<line
|
|
1537
|
+
v-if="!isVertical || valueAxis"
|
|
1087
1538
|
:x1="snap(padding.left)"
|
|
1088
1539
|
:y1="snap(padding.top)"
|
|
1089
1540
|
:x2="snap(padding.left)"
|
|
@@ -1092,6 +1543,7 @@ const positionedLegendItems = computed(() => {
|
|
|
1092
1543
|
stroke-opacity="0.3"
|
|
1093
1544
|
/>
|
|
1094
1545
|
<line
|
|
1546
|
+
v-if="isVertical || valueAxis"
|
|
1095
1547
|
:x1="snap(padding.left)"
|
|
1096
1548
|
:y1="snap(padding.top + innerH)"
|
|
1097
1549
|
:x2="snap(padding.left + innerW)"
|
|
@@ -1100,7 +1552,7 @@ const positionedLegendItems = computed(() => {
|
|
|
1100
1552
|
stroke-opacity="0.3"
|
|
1101
1553
|
/>
|
|
1102
1554
|
<!-- value grid lines -->
|
|
1103
|
-
<template v-if="valueGrid">
|
|
1555
|
+
<template v-if="valueGrid && valueAxis">
|
|
1104
1556
|
<line
|
|
1105
1557
|
v-for="(tick, i) in valueTickItems"
|
|
1106
1558
|
:key="'vg' + i"
|
|
@@ -1124,7 +1576,7 @@ const positionedLegendItems = computed(() => {
|
|
|
1124
1576
|
pointer-events="none"
|
|
1125
1577
|
/>
|
|
1126
1578
|
<!-- value tick labels -->
|
|
1127
|
-
<template v-if="isVertical">
|
|
1579
|
+
<template v-if="valueAxis && isVertical">
|
|
1128
1580
|
<text
|
|
1129
1581
|
v-for="(tick, i) in valueTickItems"
|
|
1130
1582
|
:key="'vt' + i"
|
|
@@ -1141,7 +1593,7 @@ const positionedLegendItems = computed(() => {
|
|
|
1141
1593
|
{{ tick.value }}
|
|
1142
1594
|
</text>
|
|
1143
1595
|
</template>
|
|
1144
|
-
<template v-else>
|
|
1596
|
+
<template v-else-if="valueAxis">
|
|
1145
1597
|
<text
|
|
1146
1598
|
v-for="(tick, i) in valueTickItems"
|
|
1147
1599
|
:key="'vt' + i"
|
|
@@ -1192,9 +1644,9 @@ const positionedLegendItems = computed(() => {
|
|
|
1192
1644
|
v-for="(tick, i) in categoryTickItems"
|
|
1193
1645
|
:key="'ct' + i"
|
|
1194
1646
|
data-testid="category-tick"
|
|
1195
|
-
:x="
|
|
1647
|
+
:x="categoryLabelLayout.x"
|
|
1196
1648
|
:y="tick.pos"
|
|
1197
|
-
text-anchor="
|
|
1649
|
+
:text-anchor="categoryLabelLayout.anchor"
|
|
1198
1650
|
dominant-baseline="middle"
|
|
1199
1651
|
:font-size="tickLabelResolved.fontSize"
|
|
1200
1652
|
:fill="tickLabelResolved.fill"
|
|
@@ -1231,6 +1683,22 @@ const positionedLegendItems = computed(() => {
|
|
|
1231
1683
|
:fill-opacity="bar.opacity"
|
|
1232
1684
|
:style="bar.blendMode ? { mixBlendMode: bar.blendMode } : undefined"
|
|
1233
1685
|
/>
|
|
1686
|
+
<!-- value-on-bar labels -->
|
|
1687
|
+
<text
|
|
1688
|
+
v-for="item in barLabelItems"
|
|
1689
|
+
:key="'blbl' + item.key"
|
|
1690
|
+
data-testid="bar-label"
|
|
1691
|
+
:x="item.x"
|
|
1692
|
+
:y="item.y"
|
|
1693
|
+
:text-anchor="item.anchor"
|
|
1694
|
+
dominant-baseline="middle"
|
|
1695
|
+
:font-size="item.fontSize"
|
|
1696
|
+
:font-weight="item.fontWeight"
|
|
1697
|
+
:fill="item.fill"
|
|
1698
|
+
pointer-events="none"
|
|
1699
|
+
>
|
|
1700
|
+
{{ item.text }}
|
|
1701
|
+
</text>
|
|
1234
1702
|
<!-- summary lines (drawn above bars, below annotations) -->
|
|
1235
1703
|
<template v-for="(line, i) in summaryLinesResolved" :key="'sl' + i">
|
|
1236
1704
|
<path
|
|
@@ -124,6 +124,56 @@ e.g. `"percent:1"`), a printf-style format string, or a function
|
|
|
124
124
|
</template>
|
|
125
125
|
</ComponentDemo>
|
|
126
126
|
|
|
127
|
+
### Wrapping long content
|
|
128
|
+
|
|
129
|
+
By default, cell content stays on one line and truncates with an ellipsis
|
|
130
|
+
when it's wider than the column — so a long string in one column can't
|
|
131
|
+
spill into the next. Set `wrap: true` on a column to let it grow
|
|
132
|
+
vertically instead.
|
|
133
|
+
|
|
134
|
+
<ComponentDemo>
|
|
135
|
+
<DataTable
|
|
136
|
+
:data="{
|
|
137
|
+
drug: ['Compound A', 'Compound B', 'Compound C'],
|
|
138
|
+
note: [
|
|
139
|
+
'Well tolerated at all tested doses; no adverse events reported.',
|
|
140
|
+
'Mild GI symptoms in 4% of participants; resolved without intervention.',
|
|
141
|
+
'Discontinued at week 6 after liver-enzyme elevation in two participants.',
|
|
142
|
+
],
|
|
143
|
+
}"
|
|
144
|
+
:column-config="{
|
|
145
|
+
drug: { width: 'small' },
|
|
146
|
+
note: { wrap: true, width: 'large' },
|
|
147
|
+
}"
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<template #code>
|
|
151
|
+
|
|
152
|
+
```vue
|
|
153
|
+
<DataTable
|
|
154
|
+
:data="{
|
|
155
|
+
drug: ['Compound A', 'Compound B', 'Compound C'],
|
|
156
|
+
note: [
|
|
157
|
+
'Well tolerated at all tested doses; no adverse events reported.',
|
|
158
|
+
'Mild GI symptoms in 4% of participants; resolved without intervention.',
|
|
159
|
+
'Discontinued at week 6 after liver-enzyme elevation in two participants.',
|
|
160
|
+
],
|
|
161
|
+
}"
|
|
162
|
+
:column-config="{
|
|
163
|
+
drug: { width: 'small' },
|
|
164
|
+
note: { wrap: true, width: 'large' },
|
|
165
|
+
}"
|
|
166
|
+
/>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
</template>
|
|
170
|
+
</ComponentDemo>
|
|
171
|
+
|
|
172
|
+
Use `columnClass` to attach a class to both the header and every body
|
|
173
|
+
cell in a column — handy when whole-column styling (background, border,
|
|
174
|
+
font weight) should match across the header and data. `cellClass` keeps
|
|
175
|
+
its meaning (body cells only).
|
|
176
|
+
|
|
127
177
|
### Full width
|
|
128
178
|
|
|
129
179
|
By default the table sizes to its content (columns default to a fixed
|
|
@@ -315,7 +365,12 @@ interface ColumnConfig {
|
|
|
315
365
|
label?: string;
|
|
316
366
|
width?: "small" | "medium" | "large" | number;
|
|
317
367
|
align?: "left" | "center" | "right";
|
|
368
|
+
/** Class applied to body `<td>` cells only. */
|
|
318
369
|
cellClass?: string;
|
|
370
|
+
/** Class applied to both the header `<th>` and body `<td>` cells. */
|
|
371
|
+
columnClass?: string;
|
|
372
|
+
/** Allow cell contents to wrap. Default `false` (nowrap + ellipsis). */
|
|
373
|
+
wrap?: boolean;
|
|
319
374
|
format?: NumberFormat | ((value: CellValue, row: number) => string);
|
|
320
375
|
}
|
|
321
376
|
|