@cfasim-ui/docs 0.4.6 → 0.4.8
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 +37 -2
- package/charts/BarChart/BarChart.vue +60 -17
- package/charts/ChartMenu/download.ts +68 -3
- package/charts/ChoroplethMap/ChoroplethMap.md +1 -1
- package/charts/ChoroplethMap/ChoroplethMap.vue +8 -6
- package/charts/DataTable/DataTable.md +48 -0
- package/charts/DataTable/DataTable.vue +28 -1
- package/charts/LineChart/LineChart.md +37 -3
- package/charts/LineChart/LineChart.vue +83 -67
- package/charts/_shared/ChartAnnotations.vue +54 -34
- package/charts/_shared/chartProps.ts +6 -4
- package/charts/_shared/index.ts +7 -0
- package/charts/_shared/scale.ts +86 -0
- package/charts/_shared/useChartFoundation.ts +5 -4
- package/components/NumberInput/NumberInput.md +22 -7
- package/components/NumberInput/NumberInput.vue +18 -4
- package/index.json +1 -1
- package/package.json +1 -1
- package/shared/formatNumber.ts +146 -0
- package/shared/index.ts +2 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from "vue";
|
|
3
|
+
import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
|
|
3
4
|
import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
4
5
|
import {
|
|
5
6
|
snap,
|
|
6
7
|
formatTick,
|
|
7
8
|
computeTickValues,
|
|
9
|
+
computeLogTickValues,
|
|
10
|
+
scaleFraction,
|
|
11
|
+
clampExtentForScale,
|
|
8
12
|
seriesToCsv,
|
|
9
13
|
useChartFoundation,
|
|
10
14
|
makeTooltipValueFormatter,
|
|
@@ -100,6 +104,13 @@ interface LineChartProps extends ChartCommonProps {
|
|
|
100
104
|
areaSections?: AreaSection[];
|
|
101
105
|
lineOpacity?: number;
|
|
102
106
|
yMin?: number;
|
|
107
|
+
/**
|
|
108
|
+
* Scale type for the y axis. `"linear"` (default) maps values directly
|
|
109
|
+
* to pixels; `"log"` uses a base-10 log mapping. On a log axis,
|
|
110
|
+
* non-positive values collapse to the visible minimum, and `yMin <= 0`
|
|
111
|
+
* is ignored.
|
|
112
|
+
*/
|
|
113
|
+
yScaleType?: "linear" | "log";
|
|
103
114
|
/**
|
|
104
115
|
* Offset applied to index-based x values (e.g. `xMin: 10` starts the
|
|
105
116
|
* x axis at 10 instead of 0). Ignored when any series or area has
|
|
@@ -119,10 +130,18 @@ interface LineChartProps extends ChartCommonProps {
|
|
|
119
130
|
* omitted, ticks are chosen automatically.
|
|
120
131
|
*/
|
|
121
132
|
yTicks?: number | number[];
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Formatter for x-axis tick labels. Accepts a preset name, a printf-style
|
|
135
|
+
* format string, or a function. The two-arg function form `(value, index)`
|
|
136
|
+
* is also supported for index-based labels. See `formatNumber` in
|
|
137
|
+
* `@cfasim-ui/shared`.
|
|
138
|
+
*/
|
|
139
|
+
xTickFormat?: NumberFormat | ((value: number, index: number) => string);
|
|
140
|
+
/**
|
|
141
|
+
* Formatter for y-axis tick labels. Accepts a preset name, a printf-style
|
|
142
|
+
* format string, or a function. See `formatNumber` in `@cfasim-ui/shared`.
|
|
143
|
+
*/
|
|
144
|
+
yTickFormat?: NumberFormat;
|
|
126
145
|
/**
|
|
127
146
|
* @deprecated Use `xTickFormat` (e.g. `(_, i) => labels[i]`) together
|
|
128
147
|
* with `xTicks` for explicit control. Still honored for tooltip x-labels
|
|
@@ -137,6 +156,7 @@ const props = withDefaults(defineProps<LineChartProps>(), {
|
|
|
137
156
|
lineOpacity: 1,
|
|
138
157
|
menu: true,
|
|
139
158
|
tooltipClamp: "chart",
|
|
159
|
+
yScaleType: "linear",
|
|
140
160
|
});
|
|
141
161
|
|
|
142
162
|
const emit = defineEmits<{
|
|
@@ -254,36 +274,42 @@ function xPixel(v: number): number {
|
|
|
254
274
|
const extent = computed(() => {
|
|
255
275
|
let min = Infinity;
|
|
256
276
|
let max = -Infinity;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
277
|
+
let smallestPositive = Infinity;
|
|
278
|
+
const visit = (v: number) => {
|
|
279
|
+
if (!isFinite(v)) return;
|
|
280
|
+
if (v < min) min = v;
|
|
281
|
+
if (v > max) max = v;
|
|
282
|
+
if (v > 0 && v < smallestPositive) smallestPositive = v;
|
|
283
|
+
};
|
|
284
|
+
for (const s of allSeries.value) for (const v of s.data) visit(v);
|
|
264
285
|
for (const a of allAreas.value) {
|
|
265
|
-
for (const v of a.upper)
|
|
266
|
-
|
|
267
|
-
if (v < min) min = v;
|
|
268
|
-
if (v > max) max = v;
|
|
269
|
-
}
|
|
270
|
-
for (const v of a.lower) {
|
|
271
|
-
if (!isFinite(v)) continue;
|
|
272
|
-
if (v < min) min = v;
|
|
273
|
-
if (v > max) max = v;
|
|
274
|
-
}
|
|
286
|
+
for (const v of a.upper) visit(v);
|
|
287
|
+
for (const v of a.lower) visit(v);
|
|
275
288
|
}
|
|
276
289
|
if (!isFinite(min)) return { min: 0, max: 0, range: 1 };
|
|
277
290
|
if (props.yMin != null && props.yMin < min) min = props.yMin;
|
|
278
|
-
|
|
291
|
+
const clamped = clampExtentForScale(
|
|
292
|
+
min,
|
|
293
|
+
max,
|
|
294
|
+
props.yScaleType,
|
|
295
|
+
smallestPositive,
|
|
296
|
+
);
|
|
297
|
+
return {
|
|
298
|
+
min: clamped.min,
|
|
299
|
+
max: clamped.max,
|
|
300
|
+
range: clamped.max - clamped.min || 1,
|
|
301
|
+
};
|
|
279
302
|
});
|
|
280
303
|
|
|
304
|
+
function yPixel(v: number): number {
|
|
305
|
+
const { min, max } = extent.value;
|
|
306
|
+
const py = padding.value.top + innerH.value;
|
|
307
|
+
return py - scaleFraction(v, min, max, props.yScaleType) * innerH.value;
|
|
308
|
+
}
|
|
309
|
+
|
|
281
310
|
function toPath(s: ResolvedSeries): string {
|
|
282
311
|
const data = s.data;
|
|
283
312
|
if (data.length === 0) return "";
|
|
284
|
-
const { min, range } = extent.value;
|
|
285
|
-
const yScale = innerH.value / range;
|
|
286
|
-
const py = padding.value.top + innerH.value;
|
|
287
313
|
let d = "";
|
|
288
314
|
let inSegment = false;
|
|
289
315
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -293,7 +319,7 @@ function toPath(s: ResolvedSeries): string {
|
|
|
293
319
|
continue;
|
|
294
320
|
}
|
|
295
321
|
const x = xPixel(xv);
|
|
296
|
-
const y =
|
|
322
|
+
const y = yPixel(data[i]);
|
|
297
323
|
d += inSegment ? `L${x},${y}` : `M${x},${y}`;
|
|
298
324
|
inSegment = true;
|
|
299
325
|
}
|
|
@@ -302,14 +328,11 @@ function toPath(s: ResolvedSeries): string {
|
|
|
302
328
|
|
|
303
329
|
function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
|
|
304
330
|
const data = s.data;
|
|
305
|
-
const { min, range } = extent.value;
|
|
306
|
-
const yScale = innerH.value / range;
|
|
307
|
-
const py = padding.value.top + innerH.value;
|
|
308
331
|
const pts: { x: number; y: number }[] = [];
|
|
309
332
|
for (let i = 0; i < data.length; i++) {
|
|
310
333
|
const xv = seriesXAt(s, i);
|
|
311
334
|
if (!isFinite(data[i]) || !isFinite(xv)) continue;
|
|
312
|
-
pts.push({ x: xPixel(xv), y:
|
|
335
|
+
pts.push({ x: xPixel(xv), y: yPixel(data[i]) });
|
|
313
336
|
}
|
|
314
337
|
return pts;
|
|
315
338
|
}
|
|
@@ -317,10 +340,6 @@ function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
|
|
|
317
340
|
function toAreaPath(a: Area): string {
|
|
318
341
|
const len = Math.min(a.upper.length, a.lower.length);
|
|
319
342
|
if (len === 0) return "";
|
|
320
|
-
const { min, range } = extent.value;
|
|
321
|
-
const yScale = innerH.value / range;
|
|
322
|
-
const py = padding.value.top + innerH.value;
|
|
323
|
-
const y = (v: number) => py - (v - min) * yScale;
|
|
324
343
|
// Collect contiguous segments where both upper/lower and x are finite
|
|
325
344
|
const segments: number[][] = [];
|
|
326
345
|
let seg: number[] = [];
|
|
@@ -339,11 +358,11 @@ function toAreaPath(a: Area): string {
|
|
|
339
358
|
if (seg.length) segments.push(seg);
|
|
340
359
|
let d = "";
|
|
341
360
|
for (const s of segments) {
|
|
342
|
-
d += `M${xPixel(areaXAt(a, s[0]))},${
|
|
361
|
+
d += `M${xPixel(areaXAt(a, s[0]))},${yPixel(a.upper[s[0]])}`;
|
|
343
362
|
for (let j = 1; j < s.length; j++)
|
|
344
|
-
d += `L${xPixel(areaXAt(a, s[j]))},${
|
|
363
|
+
d += `L${xPixel(areaXAt(a, s[j]))},${yPixel(a.upper[s[j]])}`;
|
|
345
364
|
for (let j = s.length - 1; j >= 0; j--)
|
|
346
|
-
d += `L${xPixel(areaXAt(a, s[j]))},${
|
|
365
|
+
d += `L${xPixel(areaXAt(a, s[j]))},${yPixel(a.lower[s[j]])}`;
|
|
347
366
|
d += "Z";
|
|
348
367
|
}
|
|
349
368
|
return d;
|
|
@@ -376,18 +395,15 @@ function toSectionPath(section: AreaSection, closed = true): string {
|
|
|
376
395
|
|
|
377
396
|
const s = allSeries.value[section.seriesIndex];
|
|
378
397
|
if (!s) return "";
|
|
379
|
-
const { min, range } = extent.value;
|
|
380
|
-
const yScale = innerH.value / range;
|
|
381
|
-
const y = (v: number) => py - (v - min) * yScale;
|
|
382
398
|
|
|
383
399
|
const start = Math.max(0, section.startIndex);
|
|
384
400
|
const end = Math.min(s.data.length - 1, section.endIndex);
|
|
385
401
|
if (start > end) return "";
|
|
386
402
|
|
|
387
|
-
let d = `M${xPixel(seriesXAt(s, start))},${
|
|
403
|
+
let d = `M${xPixel(seriesXAt(s, start))},${yPixel(s.data[start])}`;
|
|
388
404
|
for (let i = start + 1; i <= end; i++) {
|
|
389
405
|
if (!isFinite(s.data[i])) continue;
|
|
390
|
-
d += `L${xPixel(seriesXAt(s, i))},${
|
|
406
|
+
d += `L${xPixel(seriesXAt(s, i))},${yPixel(s.data[i])}`;
|
|
391
407
|
}
|
|
392
408
|
if (closed) {
|
|
393
409
|
d += `L${xPixel(seriesXAt(s, end))},${py}`;
|
|
@@ -531,26 +547,25 @@ const sectionLabelBaseY = computed(
|
|
|
531
547
|
|
|
532
548
|
const yTickItems = computed(() => {
|
|
533
549
|
const { min, max } = extent.value;
|
|
534
|
-
const toY = (v: number) =>
|
|
535
|
-
snap(
|
|
536
|
-
padding.value.top +
|
|
537
|
-
innerH.value -
|
|
538
|
-
((v - min) / extent.value.range) * innerH.value,
|
|
539
|
-
);
|
|
540
550
|
const fmt = (v: number) =>
|
|
541
|
-
props.yTickFormat
|
|
551
|
+
props.yTickFormat !== undefined
|
|
552
|
+
? formatNumber(v, props.yTickFormat)
|
|
553
|
+
: formatTick(v);
|
|
542
554
|
|
|
543
555
|
if (min === max) {
|
|
544
556
|
return [{ value: fmt(min), y: snap(padding.value.top + innerH.value / 2) }];
|
|
545
557
|
}
|
|
546
558
|
|
|
547
|
-
const values =
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
559
|
+
const values =
|
|
560
|
+
props.yScaleType === "log"
|
|
561
|
+
? computeLogTickValues({ min, max, ticks: props.yTicks })
|
|
562
|
+
: computeTickValues({
|
|
563
|
+
min,
|
|
564
|
+
max,
|
|
565
|
+
ticks: props.yTicks,
|
|
566
|
+
targetTickCount: innerH.value / 50,
|
|
567
|
+
});
|
|
568
|
+
return values.map((v) => ({ value: fmt(v), y: snap(yPixel(v)) }));
|
|
554
569
|
});
|
|
555
570
|
|
|
556
571
|
const xTickItems = computed(() => {
|
|
@@ -562,7 +577,12 @@ const xTickItems = computed(() => {
|
|
|
562
577
|
// Tick values are in data-space; display labels add `xDisplayOffset`.
|
|
563
578
|
const fmt = (v: number, i: number) => {
|
|
564
579
|
const display = v + offset;
|
|
565
|
-
|
|
580
|
+
const xf = props.xTickFormat;
|
|
581
|
+
if (xf !== undefined) {
|
|
582
|
+
return typeof xf === "function"
|
|
583
|
+
? xf(display, i)
|
|
584
|
+
: formatNumber(display, xf);
|
|
585
|
+
}
|
|
566
586
|
if (
|
|
567
587
|
!hasExplicitX.value &&
|
|
568
588
|
props.xLabels &&
|
|
@@ -653,9 +673,6 @@ function nearestIndex(s: ResolvedSeries, targetX: number): number | null {
|
|
|
653
673
|
const hoverDots = computed(() => {
|
|
654
674
|
const targetX = hoverDataX.value;
|
|
655
675
|
if (targetX === null) return [];
|
|
656
|
-
const { min, range } = extent.value;
|
|
657
|
-
const yScale = innerH.value / range;
|
|
658
|
-
const py = padding.value.top + innerH.value;
|
|
659
676
|
const dots: { x: number; y: number; color: string }[] = [];
|
|
660
677
|
for (const s of allSeries.value) {
|
|
661
678
|
const nIdx = nearestIndex(s, targetX);
|
|
@@ -664,7 +681,7 @@ const hoverDots = computed(() => {
|
|
|
664
681
|
if (!isFinite(yv)) continue;
|
|
665
682
|
dots.push({
|
|
666
683
|
x: xPixel(seriesXAt(s, nIdx)),
|
|
667
|
-
y:
|
|
684
|
+
y: yPixel(yv),
|
|
668
685
|
color: s.color ?? "currentColor",
|
|
669
686
|
});
|
|
670
687
|
}
|
|
@@ -678,8 +695,10 @@ const hoverSlotProps = computed(() => {
|
|
|
678
695
|
const offset = xDisplayOffset.value;
|
|
679
696
|
const displayX = targetX + offset;
|
|
680
697
|
let xLabel: string | undefined;
|
|
681
|
-
|
|
682
|
-
|
|
698
|
+
const xf = props.xTickFormat;
|
|
699
|
+
if (xf !== undefined) {
|
|
700
|
+
xLabel =
|
|
701
|
+
typeof xf === "function" ? xf(displayX, idx) : formatNumber(displayX, xf);
|
|
683
702
|
} else if (!hasExplicitX.value) {
|
|
684
703
|
xLabel = props.xLabels?.[idx];
|
|
685
704
|
} else {
|
|
@@ -706,10 +725,7 @@ function projectAnnotation(
|
|
|
706
725
|
): { x: number; y: number } | null {
|
|
707
726
|
if (!isFinite(x) || !isFinite(y)) return null;
|
|
708
727
|
const internalX = x - xDisplayOffset.value;
|
|
709
|
-
|
|
710
|
-
const py =
|
|
711
|
-
padding.value.top + innerH.value - (y - min) * (innerH.value / range);
|
|
712
|
-
return { x: xPixel(internalX), y: py };
|
|
728
|
+
return { x: xPixel(internalX), y: yPixel(y) };
|
|
713
729
|
}
|
|
714
730
|
|
|
715
731
|
function indexFromPointer(clientX: number): number | null {
|
|
@@ -39,10 +39,6 @@ const LINE_HEIGHT_RATIO = 1.2;
|
|
|
39
39
|
// of the first text line (between baseline and cap-height). Lands on the
|
|
40
40
|
// x-height middle for most fonts.
|
|
41
41
|
const FIRST_LINE_CENTER_RATIO = 0.35;
|
|
42
|
-
// Nudge the start of the curve a few pixels in the offset direction so
|
|
43
|
-
// it doesn't sit directly on top of axis lines or gridlines at the
|
|
44
|
-
// anchor.
|
|
45
|
-
const START_NUDGE_PX = 3;
|
|
46
42
|
|
|
47
43
|
interface TextRun {
|
|
48
44
|
text: string;
|
|
@@ -66,6 +62,10 @@ interface RenderedAnnotation {
|
|
|
66
62
|
lineWidth: number;
|
|
67
63
|
lineDash?: string;
|
|
68
64
|
arrow: boolean;
|
|
65
|
+
// Inline arrow geometry. Rendered as a triangle with explicit fill so it
|
|
66
|
+
// renders correctly in Safari (which doesn't support `context-stroke` on
|
|
67
|
+
// SVG markers). Present only when an arrow should be drawn.
|
|
68
|
+
arrowTip?: { x: number; y: number; angle: number };
|
|
69
69
|
rule?: { x1: number; y1: number; x2: number; y2: number };
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -138,10 +138,12 @@ const items = computed<RenderedAnnotation[]>(() => {
|
|
|
138
138
|
|
|
139
139
|
let rule: RenderedAnnotation["rule"];
|
|
140
140
|
let pointerPath = "";
|
|
141
|
+
let arrowTip: RenderedAnnotation["arrowTip"];
|
|
142
|
+
const wantArrow = !isRule && (a.arrow ?? true);
|
|
141
143
|
if (isRule && props.bounds) {
|
|
142
144
|
rule = computeRule(pointer, projected.x, projected.y, props.bounds);
|
|
143
145
|
} else {
|
|
144
|
-
|
|
146
|
+
const built = buildPointerPath(
|
|
145
147
|
projected.x,
|
|
146
148
|
projected.y,
|
|
147
149
|
labelX,
|
|
@@ -149,6 +151,8 @@ const items = computed<RenderedAnnotation[]>(() => {
|
|
|
149
151
|
fontSize,
|
|
150
152
|
pointer as "curved" | "straight" | "none",
|
|
151
153
|
);
|
|
154
|
+
pointerPath = built.path;
|
|
155
|
+
if (wantArrow && built.arrow) arrowTip = built.arrow;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
out.push({
|
|
@@ -166,7 +170,8 @@ const items = computed<RenderedAnnotation[]>(() => {
|
|
|
166
170
|
lineColor,
|
|
167
171
|
lineWidth,
|
|
168
172
|
lineDash,
|
|
169
|
-
arrow:
|
|
173
|
+
arrow: wantArrow,
|
|
174
|
+
arrowTip,
|
|
170
175
|
rule,
|
|
171
176
|
});
|
|
172
177
|
}
|
|
@@ -216,6 +221,15 @@ function computeRule(
|
|
|
216
221
|
* label so the endpoint reads as pointing at the first line — not at
|
|
217
222
|
* the bottom of a multi-line block.
|
|
218
223
|
*/
|
|
224
|
+
interface PointerGeom {
|
|
225
|
+
path: string;
|
|
226
|
+
// Tip position and rotation (degrees) for the inline arrow head. The
|
|
227
|
+
// arrow points opposite the path's start tangent — matching the look of
|
|
228
|
+
// `marker-start` with `orient="auto-start-reverse"`, but rendered as a
|
|
229
|
+
// plain triangle so the color works in Safari.
|
|
230
|
+
arrow?: { x: number; y: number; angle: number };
|
|
231
|
+
}
|
|
232
|
+
|
|
219
233
|
function buildPointerPath(
|
|
220
234
|
ax: number,
|
|
221
235
|
ay: number,
|
|
@@ -223,8 +237,8 @@ function buildPointerPath(
|
|
|
223
237
|
ly: number,
|
|
224
238
|
fontSize: number,
|
|
225
239
|
pointer: "curved" | "straight" | "none",
|
|
226
|
-
):
|
|
227
|
-
if (pointer === "none") return "";
|
|
240
|
+
): PointerGeom {
|
|
241
|
+
if (pointer === "none") return { path: "" };
|
|
228
242
|
const dx = lx - ax;
|
|
229
243
|
const dy = ly - ay;
|
|
230
244
|
|
|
@@ -243,53 +257,49 @@ function buildPointerPath(
|
|
|
243
257
|
const segDx = lx - ax;
|
|
244
258
|
const segDy = ey - ay;
|
|
245
259
|
const len = Math.hypot(segDx, segDy);
|
|
246
|
-
if (len <= ANCHOR_GAP_PX + LABEL_GAP_PX) return "";
|
|
260
|
+
if (len <= ANCHOR_GAP_PX + LABEL_GAP_PX) return { path: "" };
|
|
247
261
|
const ux = segDx / len;
|
|
248
262
|
const uy = segDy / len;
|
|
249
263
|
const sx = ax + ux * ANCHOR_GAP_PX;
|
|
250
264
|
const sy = ay + uy * ANCHOR_GAP_PX;
|
|
251
265
|
const ex = lx - ux * LABEL_GAP_PX;
|
|
252
266
|
const eyClamped = ey - uy * LABEL_GAP_PX;
|
|
253
|
-
|
|
267
|
+
// Arrow points back toward the anchor (opposite of (ux, uy)).
|
|
268
|
+
const angle = (Math.atan2(-uy, -ux) * 180) / Math.PI;
|
|
269
|
+
return {
|
|
270
|
+
path: `M${sx},${sy} L${ex},${eyClamped}`,
|
|
271
|
+
arrow: { x: sx, y: sy, angle },
|
|
272
|
+
};
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
const adjDy = targetY - ay;
|
|
257
276
|
|
|
258
277
|
// Skip the curve if one dimension is too small to clear its gap.
|
|
259
278
|
if (Math.abs(adjDy) <= ANCHOR_GAP_PX || Math.abs(dx) <= LABEL_GAP_PX) {
|
|
260
|
-
return "";
|
|
279
|
+
return { path: "" };
|
|
261
280
|
}
|
|
262
281
|
|
|
263
282
|
const xDir = Math.sign(dx);
|
|
264
283
|
const yDir = Math.sign(adjDy);
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
const sx = ax
|
|
284
|
+
// Start the curve directly above/below the anchor so the arrow head
|
|
285
|
+
// lines up with the data point rather than sitting off to one side.
|
|
286
|
+
const sx = ax;
|
|
268
287
|
const sy = ay + yDir * ANCHOR_GAP_PX;
|
|
269
288
|
const ex = lx - xDir * LABEL_GAP_PX;
|
|
270
289
|
const ey = targetY;
|
|
271
|
-
// Control sits at (sx, targetY) so the curve emerges from the
|
|
272
|
-
//
|
|
273
|
-
// a clean quarter-arc shape.
|
|
274
|
-
|
|
290
|
+
// Control sits at (sx, targetY) so the curve emerges from the start
|
|
291
|
+
// tangent vertically and lands on the label horizontally —
|
|
292
|
+
// a clean quarter-arc shape. The start tangent is (0, yDir), so the
|
|
293
|
+
// arrow points (0, -yDir) — straight up when yDir=1, down when yDir=-1.
|
|
294
|
+
const angle = yDir > 0 ? -90 : 90;
|
|
295
|
+
return {
|
|
296
|
+
path: `M${sx},${sy} Q${sx},${targetY} ${ex},${ey}`,
|
|
297
|
+
arrow: { x: sx, y: sy, angle },
|
|
298
|
+
};
|
|
275
299
|
}
|
|
276
300
|
</script>
|
|
277
301
|
|
|
278
302
|
<template>
|
|
279
|
-
<defs>
|
|
280
|
-
<marker
|
|
281
|
-
id="chart-annotation-arrow"
|
|
282
|
-
viewBox="0 0 8 8"
|
|
283
|
-
refX="7"
|
|
284
|
-
refY="4"
|
|
285
|
-
markerWidth="6"
|
|
286
|
-
markerHeight="6"
|
|
287
|
-
orient="auto-start-reverse"
|
|
288
|
-
markerUnits="userSpaceOnUse"
|
|
289
|
-
>
|
|
290
|
-
<path d="M0,0 L8,4 L0,8 Z" fill="context-stroke" />
|
|
291
|
-
</marker>
|
|
292
|
-
</defs>
|
|
293
303
|
<g class="chart-annotations" pointer-events="none">
|
|
294
304
|
<template v-for="(item, i) in items" :key="i">
|
|
295
305
|
<line
|
|
@@ -308,11 +318,21 @@ function buildPointerPath(
|
|
|
308
318
|
:d="item.pointerPath"
|
|
309
319
|
fill="none"
|
|
310
320
|
:stroke="item.lineColor"
|
|
311
|
-
:style="{ color: item.lineColor }"
|
|
312
321
|
:stroke-width="item.lineWidth"
|
|
313
322
|
:stroke-dasharray="item.lineDash"
|
|
314
323
|
stroke-linecap="round"
|
|
315
|
-
|
|
324
|
+
/>
|
|
325
|
+
<!--
|
|
326
|
+
Inline arrow head. Drawn as an explicit triangle (not via
|
|
327
|
+
`<marker>`) because Safari does not implement `context-stroke` on
|
|
328
|
+
marker fills, so a shared marker rendered as black instead of the
|
|
329
|
+
line color.
|
|
330
|
+
-->
|
|
331
|
+
<polygon
|
|
332
|
+
v-if="item.arrowTip"
|
|
333
|
+
points="0,0 -6,-3 -6,3"
|
|
334
|
+
:fill="item.lineColor"
|
|
335
|
+
:transform="`translate(${item.arrowTip.x} ${item.arrowTip.y}) rotate(${item.arrowTip.angle})`"
|
|
316
336
|
/>
|
|
317
337
|
<text
|
|
318
338
|
:x="item.textX"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* intersection (e.g. `defineProps<ChartCommonProps & MyExtraProps>()`).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { NumberFormat } from "@cfasim-ui/shared";
|
|
7
8
|
import type { ChartAnnotation } from "./annotations.js";
|
|
8
9
|
import type { ChartPadding } from "./useChartPadding.js";
|
|
9
10
|
|
|
@@ -30,11 +31,12 @@ export interface ChartCommonProps {
|
|
|
30
31
|
/** Boundary for tooltip flip/clamp. Default: `"chart"`. */
|
|
31
32
|
tooltipClamp?: "none" | "chart" | "window";
|
|
32
33
|
/**
|
|
33
|
-
* Formatter for numeric values shown in the default tooltip.
|
|
34
|
-
*
|
|
35
|
-
* tick formatter, then
|
|
34
|
+
* Formatter for numeric values shown in the default tooltip. Accepts a
|
|
35
|
+
* preset name, a printf-style format string, or a function. When
|
|
36
|
+
* omitted, the chart falls back to its value-axis tick formatter, then
|
|
37
|
+
* `formatTick`. See `formatNumber` in `@cfasim-ui/shared`.
|
|
36
38
|
*/
|
|
37
|
-
tooltipValueFormat?:
|
|
39
|
+
tooltipValueFormat?: NumberFormat;
|
|
38
40
|
/**
|
|
39
41
|
* Custom CSV content (string or function) for the Download CSV menu
|
|
40
42
|
* item. When omitted, CSV is generated from the chart's series.
|
package/charts/_shared/index.ts
CHANGED
|
@@ -6,6 +6,13 @@ export {
|
|
|
6
6
|
type ChartData,
|
|
7
7
|
} from "./axes.js";
|
|
8
8
|
export { computeTickValues, type TickValueOptions } from "./computeTicks.js";
|
|
9
|
+
export {
|
|
10
|
+
scaleFraction,
|
|
11
|
+
clampExtentForScale,
|
|
12
|
+
computeLogTickValues,
|
|
13
|
+
LOG_FLOOR,
|
|
14
|
+
type ScaleType,
|
|
15
|
+
} from "./scale.js";
|
|
9
16
|
export { useChartSize, type ChartSizeOptions } from "./useChartSize.js";
|
|
10
17
|
export {
|
|
11
18
|
useChartPadding,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear and log scale helpers shared by chart components. The chart
|
|
3
|
+
* computes a `[min, max]` data extent then maps values to pixels via
|
|
4
|
+
* `scaleFraction`; log mode clamps `min` to a positive floor so we
|
|
5
|
+
* never take `log10(0)` or `log10(-x)`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ScaleType = "linear" | "log";
|
|
9
|
+
|
|
10
|
+
/** Default floor used when a log-scale extent contains no positive data. */
|
|
11
|
+
export const LOG_FLOOR = 1;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Project a value onto a [0, 1] fraction of the axis range. The caller
|
|
15
|
+
* multiplies by the pixel range and adds the axis origin. On log
|
|
16
|
+
* scales, non-positive values collapse to the visible minimum so they
|
|
17
|
+
* sit on the axis floor instead of producing -Infinity.
|
|
18
|
+
*/
|
|
19
|
+
export function scaleFraction(
|
|
20
|
+
v: number,
|
|
21
|
+
min: number,
|
|
22
|
+
max: number,
|
|
23
|
+
type: ScaleType,
|
|
24
|
+
): number {
|
|
25
|
+
if (type === "log") {
|
|
26
|
+
const lmin = Math.log10(min);
|
|
27
|
+
const lmax = Math.log10(max);
|
|
28
|
+
const range = lmax - lmin || 1;
|
|
29
|
+
const safe = v > 0 ? v : min;
|
|
30
|
+
return (Math.log10(safe) - lmin) / range;
|
|
31
|
+
}
|
|
32
|
+
const range = max - min || 1;
|
|
33
|
+
return (v - min) / range;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clamp the lower bound of a data extent so it's safe for log scale.
|
|
38
|
+
* For linear scales the inputs are returned unchanged. For log scales,
|
|
39
|
+
* `min` is raised to the smallest positive value in the data (or
|
|
40
|
+
* `LOG_FLOOR` when no positive values exist), and `max` is also
|
|
41
|
+
* floored to that value so a degenerate axis still renders.
|
|
42
|
+
*/
|
|
43
|
+
export function clampExtentForScale(
|
|
44
|
+
min: number,
|
|
45
|
+
max: number,
|
|
46
|
+
type: ScaleType,
|
|
47
|
+
smallestPositive: number,
|
|
48
|
+
): { min: number; max: number } {
|
|
49
|
+
if (type !== "log") return { min, max };
|
|
50
|
+
const floor =
|
|
51
|
+
smallestPositive > 0 && isFinite(smallestPositive)
|
|
52
|
+
? smallestPositive
|
|
53
|
+
: LOG_FLOOR;
|
|
54
|
+
const lo = min > 0 ? min : floor;
|
|
55
|
+
const hi = max > 0 ? Math.max(max, lo) : lo;
|
|
56
|
+
return { min: lo, max: hi };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate tick values for a log axis. By default returns powers of 10
|
|
61
|
+
* inside `[min, max]`. Pass `ticks` as an explicit array to override;
|
|
62
|
+
* numeric `ticks` (linear interval) is ignored on a log axis since it
|
|
63
|
+
* would produce a swarm of densely-packed labels — use an array for
|
|
64
|
+
* non-default tick placement.
|
|
65
|
+
*/
|
|
66
|
+
export function computeLogTickValues(opts: {
|
|
67
|
+
min: number;
|
|
68
|
+
max: number;
|
|
69
|
+
ticks?: number | number[];
|
|
70
|
+
}): number[] {
|
|
71
|
+
const { min, max, ticks } = opts;
|
|
72
|
+
if (!(min > 0) || !(max > 0) || min === max) return [];
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(ticks)) {
|
|
75
|
+
return ticks.filter((v) => v > 0 && v >= min && v <= max);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lo = Math.floor(Math.log10(min));
|
|
79
|
+
const hi = Math.ceil(Math.log10(max));
|
|
80
|
+
const out: number[] = [];
|
|
81
|
+
for (let e = lo; e <= hi; e++) {
|
|
82
|
+
const v = Math.pow(10, e);
|
|
83
|
+
if (v >= min && v <= max) out.push(v);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed } from "vue";
|
|
2
|
+
import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
|
|
2
3
|
import { formatTick } from "./axes.js";
|
|
3
4
|
import { useChartSize } from "./useChartSize.js";
|
|
4
5
|
import { useChartPadding, type ChartPadding } from "./useChartPadding.js";
|
|
@@ -111,14 +112,14 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
111
112
|
* `formatTick`. Both chart components use the same precedence order.
|
|
112
113
|
*/
|
|
113
114
|
export function makeTooltipValueFormatter(
|
|
114
|
-
tooltipFormat: () =>
|
|
115
|
-
axisFormat: () =>
|
|
115
|
+
tooltipFormat: () => NumberFormat | undefined,
|
|
116
|
+
axisFormat: () => NumberFormat | undefined,
|
|
116
117
|
): (v: number) => string {
|
|
117
118
|
return (v: number) => {
|
|
118
119
|
const tf = tooltipFormat();
|
|
119
|
-
if (tf) return
|
|
120
|
+
if (tf !== undefined) return formatNumber(v, tf);
|
|
120
121
|
const af = axisFormat();
|
|
121
|
-
if (af) return
|
|
122
|
+
if (af !== undefined) return formatNumber(v, af);
|
|
122
123
|
return formatTick(v);
|
|
123
124
|
};
|
|
124
125
|
}
|
|
@@ -232,12 +232,24 @@ Range mode works with `percent` and `live` as well:
|
|
|
232
232
|
</template>
|
|
233
233
|
</ComponentDemo>
|
|
234
234
|
|
|
235
|
-
### Custom
|
|
235
|
+
### Custom display format
|
|
236
236
|
|
|
237
|
-
Pass `
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
Pass `format` to control how the value is displayed in the text input and
|
|
238
|
+
in slider thumb/min/max labels. Accepts a
|
|
239
|
+
[`NumberFormat`](../charts/data-table.md#columnconfig) — a preset name
|
|
240
|
+
(optionally with a `:N` digits suffix, e.g. `"percent:1"`), a printf-style
|
|
241
|
+
format string (`"%.2f"`), or a `(value: number) => string` function. The
|
|
242
|
+
internal model stays a number — only the displayed text changes.
|
|
243
|
+
|
|
244
|
+
When unset, the default formatting follows the `percent` and `decimals`
|
|
245
|
+
props. When set, `format` overrides both. Formats that add suffixes or
|
|
246
|
+
scale the value (e.g. `"percent:1"` → `"12.3%"`) may not round-trip
|
|
247
|
+
through the text input — pair them with `percent: true` for value scaling
|
|
248
|
+
and use `format` for display shaping.
|
|
249
|
+
|
|
250
|
+
The older `slider-display` prop (a `(value: number) => string` function
|
|
251
|
+
that only affected slider thumb/min/max labels) is **deprecated** but
|
|
252
|
+
still honored when `format` is unset. Prefer `format` for new code.
|
|
241
253
|
|
|
242
254
|
<ComponentDemo>
|
|
243
255
|
<div style="width: 300px">
|
|
@@ -247,7 +259,7 @@ sliders and ranges; the regular text input is unaffected.
|
|
|
247
259
|
:min="dateStart"
|
|
248
260
|
:max="dateEnd"
|
|
249
261
|
:step="dayMs"
|
|
250
|
-
:
|
|
262
|
+
:format="formatDate"
|
|
251
263
|
/>
|
|
252
264
|
</div>
|
|
253
265
|
|
|
@@ -270,7 +282,7 @@ const formatDate = (ms) =>
|
|
|
270
282
|
:min="dateStart"
|
|
271
283
|
:max="dateEnd"
|
|
272
284
|
:step="dayMs"
|
|
273
|
-
:
|
|
285
|
+
:format="formatDate"
|
|
274
286
|
/>
|
|
275
287
|
```
|
|
276
288
|
|
|
@@ -466,5 +478,8 @@ the input visually.
|
|
|
466
478
|
| `numberType` | `"integer" \| "float"` | No | — |
|
|
467
479
|
| `required` | `boolean` | No | — |
|
|
468
480
|
| `decimals` | `number` | No | — |
|
|
481
|
+
| `percent` | `1"`) may not round-trip through the text input — use
|
|
482
|
+
// `percent: true` for value scaling and `format` for display shaping.
|
|
483
|
+
format?: NumberFormat` | Yes | — |
|
|
469
484
|
| `sliderDisplay` | `(value: number) => string` | No | — |
|
|
470
485
|
|