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),