diff --git a/.changeset/multiline-axis.md b/.changeset/multiline-axis.md new file mode 100644 index 00000000..098f2f61 --- /dev/null +++ b/.changeset/multiline-axis.md @@ -0,0 +1,6 @@ +--- +"example": patch +"victory-native": patch +--- + +Add multiline axis label support with proper spacing and layout handling. diff --git a/example/app/multiline-axis.tsx b/example/app/multiline-axis.tsx new file mode 100644 index 00000000..e5e7fd9c --- /dev/null +++ b/example/app/multiline-axis.tsx @@ -0,0 +1,137 @@ +import { useFont } from "@shopify/react-native-skia"; +import * as React from "react"; +import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native"; +import { CartesianChart, Line, Scatter } from "victory-native"; +import { useDarkMode } from "react-native-dark"; +import inter from "../assets/inter-medium.ttf"; +import { appColors } from "../consts/colors"; +import { InfoCard } from "../components/InfoCard"; +import { descriptionForRoute } from "../consts/routes"; + +const DATA = Array.from({ length: 6 }, (_, index) => ({ + month: index, + sales: Math.random() * 100 + 50, + profit: Math.random() * 50 + 25, +})); + +const MONTH_NAMES = [ + "January\n2024", + "February\n2024", + "March\n2024", + "April\n2024", + "May\n2024", + "June\n2024", +]; + +const METRIC_LABELS = [ + "Low\nPerformance", + "Below\nAverage", + "Average\nPerformance", + "Good\nPerformance", + "Excellent\nPerformance", +]; + +export default function MultilineAxis(props: { segment: string }) { + const description = descriptionForRoute(props.segment); + const isDark = useDarkMode(); + const font = useFont(inter, 12); + + return ( + + + + MONTH_NAMES[value as number] || `Month ${value}`, + labelOffset: 8, + }} + yAxis={[ + { + font, + labelColor: isDark ? appColors.text.dark : appColors.text.light, + formatYLabel: (value) => { + const index = Math.floor((value as number) / 25); + return ( + METRIC_LABELS[Math.min(index, METRIC_LABELS.length - 1)] || + `${value}` + ); + }, + labelOffset: 8, + yKeys: ["sales", "profit"], + }, + ]} + > + {({ points }) => ( + <> + + + + + + )} + + + + + {description || + "This example demonstrates multiline axis labels. X-axis shows months with years on separate lines, while Y-axis shows performance categories with descriptive text on multiple lines. Use \\n in your formatXLabel or formatYLabel functions to create multiline labels."} + + + + ); +} + +const styles = StyleSheet.create({ + safeView: { + flex: 1, + backgroundColor: appColors.viewBackground.light, + $dark: { + backgroundColor: appColors.viewBackground.dark, + }, + }, + chart: { + flex: 1, + }, + optionsScrollView: { + flex: 0.3, + backgroundColor: appColors.cardBackground.light, + $dark: { + backgroundColor: appColors.cardBackground.dark, + }, + }, + options: { + paddingHorizontal: 20, + paddingVertical: 15, + alignItems: "flex-start", + justifyContent: "flex-start", + }, +}); diff --git a/example/consts/routes.ts b/example/consts/routes.ts index 896e1294..2ed5c8e8 100644 --- a/example/consts/routes.ts +++ b/example/consts/routes.ts @@ -163,6 +163,12 @@ export const ChartRoutes: { description: "This example demonstrates chart interactions using refs.", path: "/chart-refs", }, + { + title: "Multiline Axis Labels", + description: + "Example demonstrating multiline axis labels using both newline characters and string arrays.", + path: "/multiline-axis", + }, ]; if (__DEV__) { diff --git a/lib/src/cartesian/CartesianChart.tsx b/lib/src/cartesian/CartesianChart.tsx index 9b82b54f..96fd53ca 100644 --- a/lib/src/cartesian/CartesianChart.tsx +++ b/lib/src/cartesian/CartesianChart.tsx @@ -295,6 +295,7 @@ function CartesianChartContent< domainPadding, normalizedAxisProps, viewport, + axisOptions?.axisScales, ]); React.useEffect(() => { diff --git a/lib/src/cartesian/components/CartesianAxis.tsx b/lib/src/cartesian/components/CartesianAxis.tsx index 51127299..cff87ba1 100644 --- a/lib/src/cartesian/components/CartesianAxis.tsx +++ b/lib/src/cartesian/components/CartesianAxis.tsx @@ -121,24 +121,15 @@ export const CartesianAxis = < const yAxisNodes = yTicksNormalized.map((tick) => { const contentY = formatYLabel(tick as never); - const labelWidth = getFontGlyphWidth(contentY, font); + + // Handle both string and string array formats for multiline labels + const lines = Array.isArray(contentY) ? contentY : contentY.split("\n"); + + // Calculate width for each line and find the max width + const lineWidths = lines.map((line: string) => + getFontGlyphWidth(line, font), + ); const labelY = yScale(tick) + fontSize / 3; - const labelX = (() => { - // left, outset - if (yAxisPosition === "left" && yLabelPosition === "outset") { - return xScale(x1) - (labelWidth + yLabelOffset); - } - // left, inset - if (yAxisPosition === "left" && yLabelPosition === "inset") { - return xScale(x1) + yLabelOffset; - } - // right, outset - if (yAxisPosition === "right" && yLabelPosition === "outset") { - return xScale(x2) + yLabelOffset; - } - // right, inset - return xScale(x2) - (labelWidth + yLabelOffset); - })(); const canFitLabelContent = labelY > fontSize && labelY < yScale(y2); @@ -154,15 +145,55 @@ export const CartesianAxis = < ) : null} {font ? canFitLabelContent && ( - + <> + {lines.map((line: string, lineIndex: number) => { + // Calculate x position for each line based on line width + const lineWidth = lineWidths[lineIndex]; + const lineLabelX = (() => { + // left, outset + if ( + yAxisPosition === "left" && + yLabelPosition === "outset" + ) { + return xScale(x1) - ((lineWidth || 0) + yLabelOffset); + } + // left, inset + if ( + yAxisPosition === "left" && + yLabelPosition === "inset" + ) { + return xScale(x1) + yLabelOffset; + } + // right, outset + if ( + yAxisPosition === "right" && + yLabelPosition === "outset" + ) { + return xScale(x2) + yLabelOffset; + } + // right, inset + return xScale(x2) - ((lineWidth || 0) + yLabelOffset); + })(); + + // Calculate y position for each line + const lineY = labelY + lineIndex * fontSize; + + return ( + + ); + })} + ) : null} @@ -172,7 +203,15 @@ export const CartesianAxis = < const xAxisNodes = xTicksNormalized.map((tick) => { const val = isNumericalData ? tick : ix[tick]; const contentX = formatXLabel(val as never); - const labelWidth = getFontGlyphWidth(contentX, font); + + // Handle both string and string array formats for multiline labels + const lines = Array.isArray(contentX) ? contentX : contentX.split("\n"); + + // Calculate width for each line and find the max width + const lineWidths = lines.map((line: string) => + getFontGlyphWidth(line, font), + ); + const labelWidth = Math.max(...lineWidths, 0); const labelX = xScale(tick) - (labelWidth ?? 0) / 2; const canFitLabelContent = yAxisPosition === "left" ? labelX + labelWidth < x2r : x1r < labelX; @@ -205,13 +244,28 @@ export const CartesianAxis = < /> ) : null} {font && labelWidth && canFitLabelContent ? ( - + <> + {lines.map((line: string, lineIndex: number) => { + // Calculate x position for center alignment + const lineWidth = lineWidths[lineIndex]; + const lineLabelX = xScale(tick) - (lineWidth || 0) / 2; + // Calculate y position for each line + const lineY = labelY + lineIndex * fontSize; + + return ( + + ); + })} + ) : null} ); diff --git a/lib/src/cartesian/components/XAxis.tsx b/lib/src/cartesian/components/XAxis.tsx index 6d834722..4033c372 100644 --- a/lib/src/cartesian/components/XAxis.tsx +++ b/lib/src/cartesian/components/XAxis.tsx @@ -59,10 +59,18 @@ export const XAxis = < const val = isNumericalData ? tick : ix[tick]; const contentX = formatXLabel(val as never); - const labelWidth = - font - ?.getGlyphWidths?.(font.getGlyphIDs(contentX)) - .reduce((sum, value) => sum + value, 0) ?? 0; + + // Handle both string and string array formats for multiline labels + const lines = Array.isArray(contentX) ? contentX : contentX.split("\n"); + + // Calculate width for each line and find the max width + const lineWidths = lines.map( + (line: string) => + font + ?.getGlyphWidths?.(font.getGlyphIDs(line)) + .reduce((sum, value) => sum + value, 0) ?? 0, + ); + const labelWidth = Math.max(...lineWidths, 0); const labelX = xScale(tick) - (labelWidth ?? 0) / 2; const canFitLabelContent = xScale(tick) >= chartBounds.left && @@ -139,19 +147,30 @@ export const XAxis = < ) : null} {font && labelWidth && canFitLabelContent ? ( - + {lines.map((line: string, lineIndex: number) => { + // Calculate x position for center alignment + const lineWidth = lineWidths[lineIndex]; + const lineLabelX = xScale(tick) - (lineWidth || 0) / 2; + // Calculate y position for each line + const lineY = labelY + lineIndex * fontSize; + + return ( + + ); + })} ) : null} <> diff --git a/lib/src/cartesian/components/YAxis.tsx b/lib/src/cartesian/components/YAxis.tsx index 478f6f02..4390faae 100644 --- a/lib/src/cartesian/components/YAxis.tsx +++ b/lib/src/cartesian/components/YAxis.tsx @@ -33,27 +33,19 @@ export const YAxis = < const fontSize = font?.getSize() ?? 0; const yAxisNodes = yTicksNormalized.map((tick) => { const contentY = formatYLabel(tick as never); - const labelWidth = - font - ?.getGlyphWidths?.(font.getGlyphIDs(contentY)) - .reduce((sum, value) => sum + value, 0) ?? 0; + + // Handle both string and string array formats for multiline labels + const lines = Array.isArray(contentY) ? contentY : contentY.split("\n"); + + // Calculate width for each line and find the max width + const lineWidths = lines.map( + (line: string) => + font + ?.getGlyphWidths?.(font.getGlyphIDs(line)) + .reduce((sum, value) => sum + value, 0) ?? 0, + ); + const labelY = yScale(tick) + fontSize / 3; - const labelX = (() => { - // left, outset - if (axisSide === "left" && labelPosition === "outset") { - return chartBounds.left - (labelWidth + labelOffset); - } - // left, inset - if (axisSide === "left" && labelPosition === "inset") { - return chartBounds.left + labelOffset; - } - // right, outset - if (axisSide === "right" && labelPosition === "outset") { - return chartBounds.right + labelOffset; - } - // right, inset - return chartBounds.right - (labelWidth + labelOffset); - })(); const canFitLabelContent = labelY > fontSize && labelY < yScale(y2); @@ -73,13 +65,44 @@ export const YAxis = < ) : null} {font ? canFitLabelContent && ( - + <> + {lines.map((line: string, lineIndex: number) => { + // Calculate x position for each line based on line width + const lineWidth = lineWidths[lineIndex]; + const lineLabelX = (() => { + // left, outset + if (axisSide === "left" && labelPosition === "outset") { + return ( + chartBounds.left - ((lineWidth || 0) + labelOffset) + ); + } + // left, inset + if (axisSide === "left" && labelPosition === "inset") { + return chartBounds.left + labelOffset; + } + // right, outset + if (axisSide === "right" && labelPosition === "outset") { + return chartBounds.right + labelOffset; + } + // right, inset + return chartBounds.right - ((lineWidth || 0) + labelOffset); + })(); + + // Calculate y position for each line + const lineY = labelY + lineIndex * fontSize; + + return ( + + ); + })} + ) : null} diff --git a/lib/src/cartesian/utils/transformInputData.ts b/lib/src/cartesian/utils/transformInputData.ts index 8d4403c2..85acc5bd 100644 --- a/lib/src/cartesian/utils/transformInputData.ts +++ b/lib/src/cartesian/utils/transformInputData.ts @@ -133,11 +133,19 @@ export const transformInputData = < xTick as unknown as Parameters[0], ) : String(xTick); - const labelStr = String(labelValue); - if (!xAxis.font) return 0; - const glyphIDs = xAxis.font.getGlyphIDs(labelStr); - const widths = xAxis.font.getGlyphWidths?.(glyphIDs) ?? []; - return widths.reduce((sum, w) => sum + w, 0); + + // Handle multiline labels by finding the width of the longest line + const lines = Array.isArray(labelValue) + ? labelValue + : String(labelValue).split("\n"); + const lineWidths = lines.map((line: string) => { + if (!xAxis.font) return 0; + const glyphIDs = xAxis.font.getGlyphIDs(line); + const widths = xAxis.font.getGlyphWidths?.(glyphIDs) ?? []; + return widths.reduce((sum, w) => sum + w, 0); + }); + + return Math.max(...lineWidths, 0); }), ); @@ -268,16 +276,21 @@ export const transformInputData = < }); const maxYLabel = Math.max( - ...yTicksNormalized.map( - (yTick) => - yAxis?.font - ?.getGlyphWidths?.( - yAxis.font.getGlyphIDs( - yAxis?.formatYLabel?.(yTick as RawData[YK]) || String(yTick), - ), - ) - .reduce((sum, value) => sum + value, 0) ?? 0, - ), + ...yTicksNormalized.map((yTick) => { + const label = + yAxis?.formatYLabel?.(yTick as RawData[YK]) || String(yTick); + + // Handle multiline labels by finding the width of the longest line + const lines = Array.isArray(label) ? label : label.split("\n"); + const lineWidths = lines.map( + (line: string) => + yAxis?.font + ?.getGlyphWidths?.(yAxis.font.getGlyphIDs(line)) + .reduce((sum, value) => sum + value, 0) ?? 0, + ); + + return Math.max(...lineWidths, 0); + }), ); return { diff --git a/lib/src/types.ts b/lib/src/types.ts index a3a4c816..dcc874f7 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -146,8 +146,8 @@ export type AxisProps< | AxisLabelPosition | { x: AxisLabelPosition; y: AxisLabelPosition }; axisSide?: { x: XAxisSide; y: YAxisSide }; - formatXLabel?: (label: InputFields[XK]) => string; - formatYLabel?: (label: RawData[YK]) => string; + formatXLabel?: (label: InputFields[XK]) => string | string[]; + formatYLabel?: (label: RawData[YK]) => string | string[]; domain?: YAxisDomain; isNumericalData?: boolean; ix?: InputFields[XK][]; @@ -176,8 +176,8 @@ export type OptionalAxisProps< > = { tickValues?: number[] | { x: number[]; y: number[] }; font?: SkFont | null; - formatXLabel?: (label: InputFields[XK]) => string; - formatYLabel?: (label: RawData[YK]) => string; + formatXLabel?: (label: InputFields[XK]) => string | string[]; + formatYLabel?: (label: RawData[YK]) => string | string[]; }; type DashPathEffectProps = React.ComponentProps; @@ -189,7 +189,7 @@ export type XAxisInputProps< > = { axisSide?: XAxisSide; font?: SkFont | null; - formatXLabel?: (label: InputFields[XK]) => string; + formatXLabel?: (label: InputFields[XK]) => string | string[]; labelColor?: string; labelOffset?: number; labelPosition?: AxisLabelPosition; @@ -201,6 +201,11 @@ export type XAxisInputProps< yAxisSide?: YAxisSide; linePathEffect?: DashPathEffectComponent; enableRescaling?: boolean; + multiline?: { + enabled?: boolean; + maxLines?: number; + lineHeight?: number; + }; }; export type XAxisPropsWithDefaults< @@ -209,7 +214,12 @@ export type XAxisPropsWithDefaults< > = Required< Omit< XAxisInputProps, - "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate" + | "font" + | "tickValues" + | "linePathEffect" + | "enableRescaling" + | "labelRotate" + | "multiline" > > & Partial< @@ -220,6 +230,7 @@ export type XAxisPropsWithDefaults< | "linePathEffect" | "enableRescaling" | "labelRotate" + | "multiline" > >; @@ -241,7 +252,7 @@ export type YAxisInputProps< > = { axisSide?: YAxisSide; font?: SkFont | null; - formatYLabel?: (label: RawData[YK]) => string; + formatYLabel?: (label: RawData[YK]) => string | string[]; labelColor?: string; labelOffset?: number; labelPosition?: AxisLabelPosition; @@ -253,6 +264,11 @@ export type YAxisInputProps< domain?: YAxisDomain; linePathEffect?: DashPathEffectComponent; enableRescaling?: boolean; + multiline?: { + enabled?: boolean; + maxLines?: number; + lineHeight?: number; + }; }; export type YAxisPropsWithDefaults< @@ -261,13 +277,13 @@ export type YAxisPropsWithDefaults< > = Required< Omit< YAxisInputProps, - "font" | "tickValues" | "linePathEffect" | "enableRescaling" + "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "multiline" > > & Partial< Pick< YAxisInputProps, - "font" | "tickValues" | "linePathEffect" | "enableRescaling" + "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "multiline" > >;