@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.
@@ -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
- /** Formatter for x-axis tick labels. Receives the raw numeric value. */
123
- xTickFormat?: (value: number, index: number) => string;
124
- /** Formatter for y-axis tick labels. Receives the raw numeric value. */
125
- yTickFormat?: (value: number) => string;
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
- for (const s of allSeries.value) {
258
- for (const v of s.data) {
259
- if (!isFinite(v)) continue;
260
- if (v < min) min = v;
261
- if (v > max) max = v;
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
- if (!isFinite(v)) continue;
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
- return { min, max, range: max - min || 1 };
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 = py - (data[i] - min) * yScale;
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: py - (data[i] - min) * yScale });
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]))},${y(a.upper[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]))},${y(a.upper[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]))},${y(a.lower[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))},${y(s.data[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))},${y(s.data[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 ? props.yTickFormat(v) : formatTick(v);
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 = computeTickValues({
548
- min,
549
- max,
550
- ticks: props.yTicks,
551
- targetTickCount: innerH.value / 50,
552
- });
553
- return values.map((v) => ({ value: fmt(v), y: toY(v) }));
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
- if (props.xTickFormat) return props.xTickFormat(display, i);
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: py - (yv - min) * yScale,
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
- if (props.xTickFormat) {
682
- xLabel = props.xTickFormat(displayX, idx);
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
- const { min, range } = extent.value;
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
- pointerPath = buildPointerPath(
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: !isRule && (a.arrow ?? true),
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
- ): string {
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
- return `M${sx},${sy} L${ex},${eyClamped}`;
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
- // Nudge the start horizontally toward the label so the line doesn't
266
- // sit on top of axis/grid lines passing through the anchor.
267
- const sx = ax + xDir * START_NUDGE_PX;
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 nudged
272
- // start tangent vertically and lands on the label horizontally —
273
- // a clean quarter-arc shape.
274
- return `M${sx},${sy} Q${sx},${targetY} ${ex},${ey}`;
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
- :marker-start="item.arrow ? 'url(#chart-annotation-arrow)' : undefined"
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. Receives
34
- * the raw value. When omitted, the chart falls back to its value-axis
35
- * tick formatter, then `formatTick`.
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?: (value: number) => string;
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.
@@ -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: () => ((v: number) => string) | undefined,
115
- axisFormat: () => ((v: number) => string) | undefined,
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 tf(v);
120
+ if (tf !== undefined) return formatNumber(v, tf);
120
121
  const af = axisFormat();
121
- if (af) return af(v);
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 slider display
235
+ ### Custom display format
236
236
 
237
- Pass `slider-display` (a `(value: number) => string` function) to format the
238
- thumb labels and the min/max labels however you like. The internal model is
239
- still a number only the displayed text changes. This applies to single
240
- sliders and ranges; the regular text input is unaffected.
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
- :slider-display="formatDate"
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
- :slider-display="formatDate"
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) =&gt; string` | No | — |
470
485