diff --git a/.changeset/cuddly-bikes-decide.md b/.changeset/cuddly-bikes-decide.md
new file mode 100644
index 00000000..ba9f6191
--- /dev/null
+++ b/.changeset/cuddly-bikes-decide.md
@@ -0,0 +1,6 @@
+---
+"example": patch
+"victory-native": patch
+---
+
+Fix yLabel width calculation to better align x-scale
diff --git a/example/app/axis-configuration.tsx b/example/app/axis-configuration.tsx
new file mode 100644
index 00000000..7c648385
--- /dev/null
+++ b/example/app/axis-configuration.tsx
@@ -0,0 +1,346 @@
+import { useFont } from "@shopify/react-native-skia";
+import * as React from "react";
+import { useMemo } from "react";
+import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native";
+import {
+ CartesianChart,
+ Line,
+ Scatter,
+ type XAxisSide,
+ type YAxisSide,
+} from "victory-native";
+import type { AxisLabelPosition } from "lib/src/types";
+import { useDarkMode } from "react-native-dark";
+import { InputSlider } from "example/components/InputSlider";
+import { InputSegment } from "example/components/InputSegment";
+import {
+ optionsInitialState,
+ optionsReducer,
+} from "example/hooks/useOptionsReducer";
+import { InputColor } from "example/components/InputColor";
+import { InputText } from "example/components/InputText";
+import inter from "../assets/inter-medium.ttf";
+import { appColors } from "./consts/colors";
+import { InfoCard } from "../components/InfoCard";
+import { descriptionForRoute } from "./consts/routes";
+
+const parseTickValues = (tickString?: string) =>
+ tickString
+ ?.split(",")
+ .map((v) => parseFloat(v))
+ .filter((v) => !isNaN(v));
+
+const DATA = (ticksX: number[], ticksY: number[]) => {
+ const maxY = Math.max(...ticksY);
+ const minY = Math.min(...ticksY);
+ const maxX = Math.max(...ticksX);
+ const minX = Math.min(...ticksX);
+ const dX = maxX - minX;
+ const dY = maxY - minY;
+
+ return Array.from({ length: 10 }, (_, index) => ({
+ day: minX + (dX * index) / 10,
+ sales: Math.random() * dY + minY,
+ }));
+};
+
+export default function AxisConfiguration(props: { segment: string }) {
+ const description = descriptionForRoute(props.segment);
+ const isDark = useDarkMode();
+ const [
+ {
+ fontSize,
+ chartPadding,
+ strokeWidth,
+ xAxisSide,
+ yAxisSide,
+ xLabelOffset,
+ yLabelOffset,
+ xTickCount,
+ yTickCount,
+ xAxisLabelPosition,
+ yAxisLabelPosition,
+ scatterRadius,
+ colors,
+ domainPadding,
+ curveType,
+ customXLabel,
+ customYLabel,
+ xAxisValues,
+ yAxisValues,
+ },
+ dispatch,
+ ] = React.useReducer(optionsReducer, {
+ ...optionsInitialState,
+ domainPadding: 10,
+ chartPadding: 0,
+ strokeWidth: 2,
+ xAxisValues: "0,2,4,6,8",
+ yAxisValues: "-1,0,1,2,4,6,8",
+ colors: {
+ stroke: isDark ? "#fafafa" : "#71717a",
+ xLine: isDark ? "#71717a" : "#ffffff",
+ yLine: isDark ? "#aabbcc" : "#ddfa55",
+ frameLine: isDark ? "#444" : "#aaa",
+ xLabel: isDark ? appColors.text.dark : appColors.text.light,
+ yLabel: isDark ? appColors.text.dark : appColors.text.light,
+ scatter: "#a78bfa",
+ },
+ });
+ const font = useFont(inter, fontSize);
+ const ticksX = useMemo(
+ () => parseTickValues(xAxisValues) ?? [0, 10],
+ [xAxisValues],
+ );
+ const ticksY = useMemo(
+ () => parseTickValues(yAxisValues) ?? [0, 10],
+ [yAxisValues],
+ );
+
+ const data = useMemo(() => DATA(ticksX, ticksY), [ticksX, ticksY]);
+
+ return (
+
+
+ {
+ return customXLabel ? `${value} ${customXLabel}` : `${value}`;
+ },
+ formatYLabel: (value) => {
+ return customYLabel ? `${value} ${customYLabel}` : `${value}`;
+ },
+ }}
+ data={data}
+ domainPadding={domainPadding}
+ >
+ {({ points }) => (
+ <>
+
+
+ >
+ )}
+
+
+
+ {description}
+
+
+ dispatch({
+ type: "SET_X_AXIS_VALUES",
+ payload: val,
+ })
+ }
+ />
+ {/** Spacer */}
+
+
+ dispatch({
+ type: "SET_Y_AXIS_VALUES",
+ payload: val,
+ })
+ }
+ />
+
+
+
+ dispatch({ type: "SET_X_LABEL", payload: val })
+ }
+ />
+ {/** Spacer */}
+
+
+ dispatch({ type: "SET_Y_LABEL", payload: val })
+ }
+ />
+
+
+
+ dispatch({ type: "SET_CHART_PADDING", payload: val })
+ }
+ />
+ dispatch({ type: "SET_FONT_SIZE", payload: val })}
+ />
+
+ dispatch({ type: "SET_X_TICK_COUNT", payload: val })
+ }
+ />
+
+ dispatch({ type: "SET_X_LABEL_OFFSET", payload: val })
+ }
+ />
+
+ label="X Axis side"
+ onChange={(val) =>
+ dispatch({ type: "SET_X_AXIS_SIDE", payload: val })
+ }
+ value={xAxisSide}
+ values={["top", "bottom"]}
+ />
+
+
+ label="X Axis Label position"
+ onChange={(val) =>
+ dispatch({ type: "SET_X_AXIS_LABEL_POSITION", payload: val })
+ }
+ value={xAxisLabelPosition}
+ values={["inset", "outset"]}
+ />
+
+ dispatch({ type: "SET_COLORS", payload: { xLabel: val } })
+ }
+ />
+
+ dispatch({ type: "SET_Y_TICK_COUNT", payload: val })
+ }
+ />
+
+ dispatch({ type: "SET_Y_LABEL_OFFSET", payload: val })
+ }
+ />
+
+ label="Y Axis Label position"
+ onChange={(val) =>
+ dispatch({ type: "SET_Y_AXIS_LABEL_POSITION", payload: val })
+ }
+ value={yAxisLabelPosition}
+ values={["inset", "outset"]}
+ />
+
+ label="Y Axis side"
+ onChange={(val) =>
+ dispatch({ type: "SET_Y_AXIS_SIDE", payload: val })
+ }
+ value={yAxisSide}
+ values={["left", "right"]}
+ />
+
+ dispatch({ type: "SET_COLORS", payload: { yLabel: val } })
+ }
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeView: {
+ flex: 1,
+ backgroundColor: appColors.viewBackground.light,
+ $dark: {
+ backgroundColor: appColors.viewBackground.dark,
+ },
+ },
+ chart: {
+ flex: 1,
+ },
+ optionsScrollView: {
+ flex: 0.5,
+ backgroundColor: appColors.cardBackground.light,
+ $dark: {
+ backgroundColor: appColors.cardBackground.dark,
+ },
+ },
+ options: {
+ paddingHorizontal: 20,
+ paddingVertical: 15,
+ alignItems: "flex-start",
+ justifyContent: "flex-start",
+ },
+});
diff --git a/example/app/consts/routes.ts b/example/app/consts/routes.ts
index 6e549aa2..956ad6d9 100644
--- a/example/app/consts/routes.ts
+++ b/example/app/consts/routes.ts
@@ -58,6 +58,12 @@ export const ChartRoutes: {
"This chart shows off ordinal data and touch events. Tap different x axis points to see the highlighted dot move. The color changes based on interpolating the color from the transformed and range data.",
path: "/ordinal-data",
},
+ {
+ title: "Axis Configuration",
+ description:
+ "This shows off the various ways to configure custom axis rendering.",
+ path: "/axis-configuration",
+ },
{
title: "Custom Shaders",
description:
diff --git a/example/hooks/useOptionsReducer.ts b/example/hooks/useOptionsReducer.ts
index 578f338a..fd6f8652 100644
--- a/example/hooks/useOptionsReducer.ts
+++ b/example/hooks/useOptionsReducer.ts
@@ -19,6 +19,8 @@ type State = {
curveType: CurveType;
customXLabel: string | undefined;
customYLabel: string | undefined;
+ xAxisValues: string | undefined;
+ yAxisValues: string | undefined;
};
type Action =
@@ -38,7 +40,9 @@ type Action =
| { type: "SET_DOMAIN_PADDING"; payload: number }
| { type: "SET_CURVE_TYPE"; payload: CurveType }
| { type: "SET_X_LABEL"; payload: string }
- | { type: "SET_Y_LABEL"; payload: string };
+ | { type: "SET_Y_LABEL"; payload: string }
+ | { type: "SET_X_AXIS_VALUES"; payload: string | undefined }
+ | { type: "SET_Y_AXIS_VALUES"; payload: string | undefined };
export const optionsReducer = (state: State, action: Action): State => {
switch (action.type) {
@@ -76,6 +80,10 @@ export const optionsReducer = (state: State, action: Action): State => {
return { ...state, customXLabel: action.payload };
case "SET_Y_LABEL":
return { ...state, customYLabel: action.payload };
+ case "SET_X_AXIS_VALUES":
+ return { ...state, xAxisValues: action.payload };
+ case "SET_Y_AXIS_VALUES":
+ return { ...state, yAxisValues: action.payload };
default:
throw new Error(`Unhandled action type`);
@@ -100,4 +108,6 @@ export const optionsInitialState: State = {
curveType: "linear",
customXLabel: undefined,
customYLabel: undefined,
+ xAxisValues: undefined,
+ yAxisValues: undefined,
};
diff --git a/lib/src/cartesian/CartesianChart.tsx b/lib/src/cartesian/CartesianChart.tsx
index 9aca5a48..e6a0aabb 100644
--- a/lib/src/cartesian/CartesianChart.tsx
+++ b/lib/src/cartesian/CartesianChart.tsx
@@ -93,48 +93,69 @@ export function CartesianChart<
),
});
- const { xScale, yScale, chartBounds, isNumericalData, _tData } =
- React.useMemo(() => {
- const { xScale, yScale, isNumericalData, ..._tData } = transformInputData(
- {
- data,
- xKey,
- yKeys,
- axisOptions: axisOptions
- ? Object.assign({}, CartesianAxisDefaultProps, axisOptions)
- : undefined,
- outputWindow: {
- xMin: valueFromSidedNumber(padding, "left"),
- xMax: size.width - valueFromSidedNumber(padding, "right"),
- yMin: valueFromSidedNumber(padding, "top"),
- yMax: size.height - valueFromSidedNumber(padding, "bottom"),
- },
- domain,
- domainPadding,
- },
- );
- tData.value = _tData;
-
- const chartBounds = {
- left: xScale(xScale.domain().at(0) || 0),
- right: xScale(xScale.domain().at(-1) || 0),
- top: yScale(yScale.domain().at(0) || 0),
- bottom: yScale(yScale.domain().at(-1) || 0),
- };
-
- return { tData, xScale, yScale, chartBounds, isNumericalData, _tData };
- }, [
+ const {
+ xTicksNormalized,
+ yTicksNormalized,
+ xScale,
+ yScale,
+ chartBounds,
+ isNumericalData,
+ _tData,
+ } = React.useMemo(() => {
+ const {
+ xScale,
+ yScale,
+ isNumericalData,
+ xTicksNormalized,
+ yTicksNormalized,
+ ..._tData
+ } = transformInputData({
data,
xKey,
yKeys,
- axisOptions,
- padding,
- size.width,
- size.height,
+ axisOptions: axisOptions
+ ? Object.assign({}, CartesianAxisDefaultProps, axisOptions)
+ : undefined,
+ outputWindow: {
+ xMin: valueFromSidedNumber(padding, "left"),
+ xMax: size.width - valueFromSidedNumber(padding, "right"),
+ yMin: valueFromSidedNumber(padding, "top"),
+ yMax: size.height - valueFromSidedNumber(padding, "bottom"),
+ },
domain,
domainPadding,
+ });
+ tData.value = _tData;
+
+ const chartBounds = {
+ left: xScale(xScale.domain().at(0) || 0),
+ right: xScale(xScale.domain().at(-1) || 0),
+ top: yScale(yScale.domain().at(0) || 0),
+ bottom: yScale(yScale.domain().at(-1) || 0),
+ };
+
+ return {
+ xTicksNormalized,
+ yTicksNormalized,
tData,
- ]);
+ xScale,
+ yScale,
+ chartBounds,
+ isNumericalData,
+ _tData,
+ };
+ }, [
+ data,
+ xKey,
+ yKeys,
+ axisOptions,
+ padding,
+ size.width,
+ size.height,
+ domain,
+ domainPadding,
+ tData,
+ ]);
/**
* Pan gesture handling
@@ -356,6 +377,8 @@ export function CartesianChart<
xScale,
yScale,
isNumericalData,
+ xTicksNormalized,
+ yTicksNormalized,
ix: _tData.ix,
}}
/>
diff --git a/lib/src/cartesian/components/CartesianAxis.tsx b/lib/src/cartesian/components/CartesianAxis.tsx
index aef08d3f..1e265d84 100644
--- a/lib/src/cartesian/components/CartesianAxis.tsx
+++ b/lib/src/cartesian/components/CartesianAxis.tsx
@@ -8,7 +8,6 @@ import {
type Color,
} from "@shopify/react-native-skia";
import { StyleSheet } from "react-native";
-import { downsampleTicks } from "../../utils/tickHelpers";
import type {
ValueOf,
NumericalFields,
@@ -16,14 +15,16 @@ import type {
AxisProps,
InputFields,
} from "../../types";
+import { DEFAULT_TICK_COUNT } from "../../utils/tickHelpers";
export const CartesianAxis = <
RawData extends Record,
XK extends keyof InputFields,
YK extends keyof NumericalFields,
>({
- tickCount = 5,
- tickValues,
+ tickCount = DEFAULT_TICK_COUNT,
+ xTicksNormalized,
+ yTicksNormalized,
labelPosition = "outset",
labelOffset = { x: 2, y: 4 },
axisSide = { x: "bottom", y: "left" },
@@ -42,8 +43,6 @@ export const CartesianAxis = <
return {
xTicks: typeof tickCount === "number" ? tickCount : tickCount.x,
yTicks: typeof tickCount === "number" ? tickCount : tickCount.y,
- xTickValues: Array.isArray(tickValues) ? tickValues : tickValues?.x,
- yTickValues: Array.isArray(tickValues) ? tickValues : tickValues?.y,
xLabelOffset:
typeof labelOffset === "number" ? labelOffset : labelOffset.x,
yLabelOffset:
@@ -85,7 +84,6 @@ export const CartesianAxis = <
: lineWidth,
} as const;
}, [
- tickValues,
tickCount,
labelOffset,
axisSide.x,
@@ -98,8 +96,6 @@ export const CartesianAxis = <
const {
xTicks,
yTicks,
- xTickValues,
- yTickValues,
xAxisPosition,
yAxisPosition,
xLabelPosition,
@@ -119,10 +115,6 @@ export const CartesianAxis = <
const [x1r = 0, x2r = 0] = xScale.range();
const fontSize = font?.getSize() ?? 0;
- // Normalize yTicks values either via the d3 scaleLinear ticks() function or our custom downSample function
- const yTicksNormalized = yTickValues
- ? downsampleTicks(yTickValues, yTicks)
- : yScale.ticks(yTicks);
const yAxisNodes = yTicksNormalized.map((tick) => {
const contentY = formatYLabel(tick as never);
const labelWidth = font?.measureText?.(contentY).width ?? 0;
@@ -171,10 +163,6 @@ export const CartesianAxis = <
);
});
- // Normalize xTicks values either via the d3 scaleLinear ticks() function or our custom downSample function
- const xTicksNormalized = xTickValues
- ? downsampleTicks(xTickValues, xTicks)
- : xScale.ticks(xTicks);
const xAxisNodes = xTicksNormalized.map((tick) => {
const val = isNumericalData ? tick : ix[tick];
const contentX = formatXLabel(val as never);
diff --git a/lib/src/cartesian/utils/transformInputData.ts b/lib/src/cartesian/utils/transformInputData.ts
index 8b5f145a..7ab23781 100644
--- a/lib/src/cartesian/utils/transformInputData.ts
+++ b/lib/src/cartesian/utils/transformInputData.ts
@@ -1,5 +1,9 @@
import { type ScaleLinear } from "d3-scale";
-import { getDomainFromTicks } from "../../utils/tickHelpers";
+import {
+ DEFAULT_TICK_COUNT,
+ downsampleTicks,
+ getDomainFromTicks,
+} from "../../utils/tickHelpers";
import type {
AxisProps,
NumericalFields,
@@ -50,17 +54,27 @@ export const transformInputData = <
xScale: ScaleLinear;
yScale: ScaleLinear;
isNumericalData: boolean;
+ xTicksNormalized: number[];
+ yTicksNormalized: number[];
} => {
const data = [..._data];
const tickValues = axisOptions?.tickValues;
- const tickDomainsX =
+ const tickCount = axisOptions?.tickCount ?? DEFAULT_TICK_COUNT;
+
+ const xTickValues =
tickValues && typeof tickValues === "object" && "x" in tickValues
- ? getDomainFromTicks(tickValues.x)
- : getDomainFromTicks(tickValues);
- const tickDomainsY =
+ ? tickValues.x
+ : tickValues;
+ const yTickValues =
tickValues && typeof tickValues === "object" && "y" in tickValues
- ? getDomainFromTicks(tickValues.y)
- : getDomainFromTicks(tickValues);
+ ? tickValues.y
+ : tickValues;
+ const xTicks = typeof tickCount === "number" ? tickCount : tickCount.x;
+ const yTicks = typeof tickCount === "number" ? tickCount : tickCount.y;
+
+ const tickDomainsX = getDomainFromTicks(xTickValues);
+ const tickDomainsY = getDomainFromTicks(yTickValues);
+
const isNumericalData = data.every(
(datum) => typeof datum[xKey as keyof RawData] === "number",
);
@@ -171,11 +185,20 @@ export const transformInputData = <
);
});
- // Measure our top-most y-label if we have grid options so we can
- // compensate for it in our x-scale.
- const topYLabel =
- axisOptions?.formatYLabel?.(yScale.domain().at(0) as RawData[YK]) ||
- String(yScale.domain().at(0));
+ // Normalize yTicks values either via the d3 scaleLinear ticks() function or our custom downSample function
+ // Awkward doing this in the transformInputData function but must be done due to x-scale needing this data
+ const yTicksNormalized = yTickValues
+ ? downsampleTicks(yTickValues, yTicks)
+ : yScale.ticks(yTicks);
+ // Calculate all yTicks we're displaying, so we can properly compensate for it in our x-scale
+ const maxYLabel = Math.max(
+ ...yTicksNormalized.map(
+ (yTick) =>
+ axisOptions?.font?.measureText(
+ axisOptions?.formatYLabel?.(yTick as RawData[YK]) || String(yTick),
+ ).width ?? 0,
+ ),
+ );
// Generate our x-scale
// If user provides a domain, use that as our min / max
@@ -183,7 +206,7 @@ export const transformInputData = <
// Else, we find min / max of y values across all yKeys, and use that for y range instead.
const ixMin = asNumber(domain?.x?.[0] ?? tickDomainsX?.[0] ?? ixNum.at(0)),
ixMax = asNumber(domain?.x?.[1] ?? tickDomainsX?.[1] ?? ixNum.at(-1));
- const topYLabelWidth = axisOptions?.font?.measureText(topYLabel).width ?? 0;
+ const topYLabelWidth = maxYLabel;
// Determine our x-output range based on yAxis/label options
const oRange: [number, number] = (() => {
const yTickCount =
@@ -229,6 +252,13 @@ export const transformInputData = <
padEnd:
typeof domainPadding === "number" ? domainPadding : domainPadding?.right,
});
+
+ // Normalize xTicks values either via the d3 scaleLinear ticks() function or our custom downSample function
+ // For consistency we do it here, so we have both y and x ticks to pass to the axis generator
+ const xTicksNormalized = xTickValues
+ ? downsampleTicks(xTickValues, xTicks)
+ : xScale.ticks(xTicks);
+
const ox = ixNum.map((x) => xScale(x)!);
return {
@@ -238,5 +268,7 @@ export const transformInputData = <
xScale,
yScale,
isNumericalData,
+ xTicksNormalized,
+ yTicksNormalized,
};
};
diff --git a/lib/src/types.ts b/lib/src/types.ts
index b8a34d45..eb68be16 100644
--- a/lib/src/types.ts
+++ b/lib/src/types.ts
@@ -98,6 +98,8 @@ export type AxisProps<
XK extends keyof InputFields,
YK extends keyof NumericalFields,
> = {
+ xTicksNormalized: number[];
+ yTicksNormalized: number[];
xScale: ScaleLinear;
yScale: ScaleLinear;
font?: SkFont | null;
diff --git a/lib/src/utils/tickHelpers.ts b/lib/src/utils/tickHelpers.ts
index 530c890a..f24630b2 100644
--- a/lib/src/utils/tickHelpers.ts
+++ b/lib/src/utils/tickHelpers.ts
@@ -1,3 +1,5 @@
+export const DEFAULT_TICK_COUNT = 5;
+
function coerceNumArray(collection: Array) {
return collection.map((item, idx) =>
Number.isNaN(Number(item)) ? idx : (item as number),