@gnome-ui/react-native-charts
v1.1.0
Published
Data visualisation components for React Native, styled with Adwaita design tokens, rendered on Skia via Victory Native
Readme
@gnome-ui/react-native-charts
Data visualisation components for @gnome-ui/react-native, styled with GNOME Adwaita design tokens and rendered on Skia via Victory Native.
Status: all 23 components shipped.
LineChart,BarChart,AreaChart,PieChart,RadarChart,RadialBarChart,CloudChart,SparkLineChart,SparkAreaChart,SparkBarChart,ScatterChart,FunnelChart,ComposedChart,GaugeChart,TreeMap,SankeyChart,BulletChart,WaterfallChart,Heatmap,SparkGaugeChart,SparkPieChart,SparkBulletChart, andBoxPlot— this package now fully mirrors@gnome-ui/charts's 23-component roadmap for React Native, one chart at a time, on top of Victory Native (Skia + Reanimated) rather than Recharts (SVG-over-DOM), since RN has no DOM/SVG renderer to reuse directly. SeeROADMAP.mdfor the full per-component history.
Installation
npm install @gnome-ui/react-native-charts victory-native @shopify/react-native-skia react-native-reanimated react-native-gesture-handler react-native-workletsOr, in an Expo app, let expo install resolve SDK-compatible versions of the native peers:
npx expo install victory-native @shopify/react-native-skia react-native-reanimated react-native-gesture-handler react-native-worklets
npm install @gnome-ui/react-native-chartsRequires @gnome-ui/react-native (for theme tokens and useNumberFormatter) and react ≥ 19 as peer
dependencies.
Setup
Wrap your app's root in
GestureHandlerRootView— Victory Native'sCartesianChartusesreact-native-gesture-handlerinternally, even for a chart with no pan/zoom interaction:import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> {/* ... */} </GestureHandlerRootView> ); }In an Expo app on SDK 50+,
babel-preset-expoauto-detectsreact-native-reanimated/react-native-workletsinnode_modulesand wires their Babel plugin for you — nobabel.config.jschanges needed. Outside Expo, addreact-native-worklets/pluginto your Babel config'splugins(must be listed last).
Components
| Component | Description |
|-----------|-------------|
| LineChart | Multi-series line chart with dots, grid, axis labels, and legend |
| BarChart | Grouped/clustered bar chart for categorical comparisons |
| AreaChart | Filled area chart — flat tint or gradient fill, overlapping or stacked series |
| PieChart | Pie or donut chart with optional in-slice labels and legend |
| RadarChart | Spider/radar chart for multi-attribute comparisons across subjects |
| RadialBarChart | Concentric arc bars for multiple circular progress metrics |
| CloudChart | Word/tag cloud with value-proportional font sizing |
| SparkLineChart | Minimal inline line sparkline for embedding in cards and tables |
| SparkAreaChart | Minimal inline area sparkline with optional gradient fill |
| SparkBarChart | Minimal inline bar sparkline for compact trend display |
| ScatterChart | Scatter/bubble chart for correlation between two numeric variables; zKey encodes a third dimension as bubble size |
| FunnelChart | Funnel visualization for conversion rates and sales pipelines |
| ComposedChart | Mixed bar/line/area series sharing one x-axis |
| GaugeChart | Radial gauge for a single value against a min/max range, with optional color thresholds |
| TreeMap | Proportional-area rectangles for hierarchical/part-of-whole data, laid out with a squarified treemap algorithm |
| SankeyChart | Flow diagram for multi-stage funnels/allocations, laid out with a d3-sankey-style algorithm |
| BulletChart | Compact single-measure KPI indicator — qualitative range bands, a performance bar, and an optional target tick |
| WaterfallChart | Bridge chart showing how a sequence of increases/decreases moves a value from a starting point to an ending point |
| Heatmap | Grid of colored cells for a value across two categorical dimensions, with intensity-based coloring and an optional legend |
| SparkGaugeChart | Minimal inline circular progress ring for a single value against a min/max range, with optional color thresholds |
| SparkPieChart | Minimal inline pie or donut chart for a small breakdown of values |
| SparkBulletChart | Minimal inline bullet-chart track for a single measure against qualitative bands and an optional target |
| BoxPlot | Box-and-whisker plot for comparing distributions across groups — median, interquartile range, whiskers, and outliers |
Usage
import { LineChart } from '@gnome-ui/react-native-charts';
<LineChart
data={[
{ day: 'Mon', cpu: 42, memory: 68 },
{ day: 'Tue', cpu: 58, memory: 72 },
]}
series={[
{ dataKey: 'cpu', name: 'CPU %' },
{ dataKey: 'memory', name: 'Memory %' },
]}
xAxisKey="day"
showLegend
/>;See src/components/LineChart/README.md,
src/components/BarChart/README.md,
src/components/AreaChart/README.md,
src/components/PieChart/README.md,
src/components/RadarChart/README.md,
src/components/RadialBarChart/README.md,
src/components/CloudChart/README.md,
src/components/SparkLineChart/README.md,
src/components/SparkAreaChart/README.md,
src/components/SparkBarChart/README.md,
src/components/ScatterChart/README.md,
src/components/FunnelChart/README.md,
src/components/ComposedChart/README.md,
src/components/GaugeChart/README.md,
src/components/TreeMap/README.md,
src/components/SankeyChart/README.md,
src/components/BulletChart/README.md,
src/components/WaterfallChart/README.md,
src/components/Heatmap/README.md,
src/components/SparkGaugeChart/README.md,
src/components/SparkPieChart/README.md,
src/components/SparkBulletChart/README.md, and
src/components/BoxPlot/README.md for the full prop
reference of each.
Design notes
Shared chart internals live in
src/internal/(ChartContainer,ChartLegend,chartKeys,useChartFont) — extracted onceBarChartbecame a second consumer of the exact same axis-font/ palette/legend/generic-typing codeLineChartalready had. Not part of the public API; every new chart should compose these rather than re-deriving them.Series colors default to the GNOME Adwaita chart palette (
blue3,green4,orange3,purple3,red3,yellow5), read from the activeGnomeProvidertheme so they track color scheme and high-contrast switches — seegetChartPalette.Axis/tick labels use Skia's
matchFont({ fontSize: 12 })with the default"System"family, nottheme.fontFamily("Adwaita Sans") — confirmed on-device that Skia's font matcher renders no glyphs at all for a family name that isn't actually registered on the device, unlike RN's own<Text>, which silently falls back to the OS default font for an unknownfontFamily.No interactive (tap/press) tooltip yet. Victory Native's
useChartPressStaterequires the series' data keys to be known as TypeScript literal types at the call site, which doesn't fit this package's (and@gnome-ui/charts') deliberately dynamicseries: { dataKey: string }[]prop shape without a larger API redesign. Revisit once a second chart's needs clarify the right shape for it.AreaChart'sstackedmode passes an explicitdomain={{ y: [0, stackedMax] }}toCartesianChart— its own auto-domain is the extent of each series' raw values, not aware a stacked chart sums them, so without this the topmost stacked band renders visibly flat-clipped at the un-stacked ceiling (confirmed on-device). Gradient fills use awithAlpha()helper (src/internal/colorAlpha.ts) to turn a palette hex color into a#RRGGBBAAfade-to-transparent pair for Skia's<LinearGradient>— the same alpha-suffix trickChip/Highlightuse in@gnome-ui/react-native.PieChartis polar, not Cartesian — it composesPolarChart/Pie.Chartinstead ofCartesianChart, has no axes/grid, and itsdataprop is a flat{ label, value, color? }[]rather than thedata+seriessplit the other charts use. Slice labels render inside each slice (Victory Native'sPie.Label) rather than the web version's external leader-line labels — there's no leader-line primitive to build on, and a label-position swap is a reasonable platform-idiom adaptation, not a data/behavior change.RadarCharthas no Victory Native primitive at all and is hand-built directly on@shopify/react-native-skia's own low-level primitives (Canvas,Path,Line,Text,Skia.PathBuilder) — flagged to the user before starting rather than assumed, since it's a real architecture decision (same category as the original Victory Native choice for this whole package). Manages its ownCanvassizing via a plainView'sonLayout, since there's noCartesianChart/PolarChartwrapper to do it. Uses the modernSkia.PathBuilder.Make()API, not the older mutableSkia.Path.Make()(deprecated in this Skia version — confirmed via a real runtime warning, not assumed from docs).RadialBarChartalso has no Victory Native primitive — same situation asRadarChart, confirmed again with the user before starting rather than assumed. Hand-built onSkPathBuilder.addArc(a thick stroked arc per ring, not a filled annular sector). A real sweep-direction bug shipped and was caught by the on-device screenshot, not by any automated check — Skia'saddArctreats positive sweep as clockwise in its y-down coordinate space, so a negative sweep fromstartAngle: 180traced the bottom half instead of the intended top-half gauge, rendering as a barely-visible sliver clipped against the canvas edge. Any future arc-based chart in this package should double-check sweep direction algebraically against Skia's own doc comment before trusting a screenshot alone to catch a sign error like this.CloudChartneeded no Skia canvas at all, unlikeRadarChart/RadialBarChart— its web source has no real word-cloud packing algorithm, just flex-wrapped<span>s withfont-sizescaled linearly by value, laid out by the browser's own text flow. Ports directly to a plainflexWrap: 'wrap'RNViewofTexts. No hover-only hint (web's:hover { opacity: 0.7 }, no touch equivalent and noonPressin the source either).SparkLineChartstill reusesCartesianChart(unlikeCloudChart) purely for its scaling math, with every decorative axis/grid/frame part hidden (axisOptions={{ lineColor: 'transparent' }}, nofont). It fully controls its own normalized data shape internally (always a plainRecord<string, number>via a synthetic__xindex field), so it passes explicit generic type arguments toCartesianChartinstead of needing theInputKeys/NumericalKeysreplica pattern the bigger, consumer-shape-generic charts use. The web's hover-triggeredhighlightedmode is dropped — a sparkline's usual embedding size (e.g. inside a table cell) has no natural touch affordance, unlike this whole port's usual hover-to-long-press swap.resolveSparkSeries/normalizeSparkData/sparkAccessibilityProps(src/internal/sparkTypes.ts) are shared by the whole spark-chart family, extracted onceSparkAreaChartbecame a second consumer — same "second occurrence" call as the bigger charts' ownsrc/internal/split.SparkAreaChartlayersArea(fill) + a separateLine(stroke), same asAreaChart— Victory Native'sAreahardcodesstyle: "fill"and its props don't accept astyle/strokeWidthoverride at all (confirmed via a realtscerror, not assumed), so a second<Area style="stroke">doesn't typecheck.SparkBarCharthas no multi-seriesmode at all (singledataKey+coloronly), matching the web version exactly — unlike its two spark siblings. Uses Victory Native's standaloneBar, notBarGroup/BarGroup.Bar(whichBarChartneeds for grouping multiple series side by side), since there's only ever one series here.ScatterChartis the only chart here where each series owns its own independent point list (its ownxKey/yKey/zKeyfield names), not rows shared across series — every series' points are merged into one combined row array before handing it toCartesianChart, with each row only populating its own series' namespaced y (and z) field;Scatteritself skips any point whoseyisn't a number, so other series' rows render as gaps for free.CartesianChartsorts its data byxKeyinternally (confirmed fromtransformInputData's source) — any per-point data matched back in after the fact (this chart's bubble-sizezKeylookup) must match by each point's own rawxValue/yValue, never by array index into the array you originally built, since the sort reorders points relative to your own insertion order.xLabel/yLabelfrom the web version are dropped — they only ever fed Recharts' tooltip text, which this package has none of yet.FunnelCharthas no Victory Native primitive either, same category asRadarChart/RadialBarChart, and is hand-built directly onSkia.PathBuilder— one closed trapezoidPathper segment (moveTo/lineToaround the four corners,.close()), width tapering linearly by each item's share of the largest value. UsesChartContainerwithlegend={null}since the web source renders no legend for this chart. Shipped with zero bugs on the first on-device screenshot — the first hand-rolled-geometry chart in this package to do so, credited to already having theSkia.PathBuilder.Make()API and Skia's angle/coordinate conventions worked out from the two prior hand-rolled charts before writing a line of this one.ComposedChartis the first chart mixing multiple render primitives (bar/line/area) within oneseriesarray — every prior chart's series all shared one rendering primitive.seriesis split bytypeand rendered as: all bar-type series together inside oneBarGroup(it needs every bar as a direct child to compute width/offset), then area-type (Areafill + a separateLinestroke, same patternAreaChartuses), then line-type last — a deliberate, documented deviation from the array's own literal order, since bars can't be interleaved with other types without breakingBarGroup's internal width math. The first on-device screenshot surfaced a demo-data mistake, not a component bug: agrowthfield (values 8–30) sharing one y-axis withrevenue(4000–5600) rendered as an invisible hairline —CartesianChartshares one y-domain across everyyKey, with no per-series secondary axis, the same limitation the webComposedCharthas with its own singleYAxis. Any future composed-chart data needs every series kept within the same order of magnitude, or a series will visually vanish.GaugeChartis a single-ring simplification ofRadialBarChart's already-validated semicircle-gauge geometry — fourth chart in this package with no Victory Native primitive (same category asRadarChart/RadialBarChart/FunnelChart), reusingRadialBarChart's exactSkPathBuilder.addArcsweep-direction convention andstrokeCap="round"track/value pairing rather than re-deriving them. Addsthresholds(ascending value/color status bands) and a centered value+caption drawn as SkiaTextnodes directly on theCanvas, rather than an absolutely-positioned RN<Text>overlay. No RN theme token exists for the web's--gnome-dim-label-colorCSS var (confirmed by grepping the generated theme — zero hits) — approximated the caption's dimmed look withtheme.windowFgColorplus a Skiaopacity={0.55}node prop instead of inventing a new theme token for one component's caption text. Shipped with zero bugs on the on-device screenshot, continuing the patternFunnelChartstarted: once a hand-rolled Skia chart's underlying primitive (arc math, here) has been paid down by an earlier chart, a later chart reusing it in a simpler shape can reasonably ship clean.TreeMapis the first chart needing an actual layout algorithm, not just a hand-rolled Skia primitive — flagged to the user before starting, since it's a different category of decision than "no Victory Native primitive" (already settled forRadarChart/RadialBarChart/FunnelChart/GaugeChart). Ships a hand-rolled squarified treemap (Bruls/Huizing/van Wijk — the same algorithm Recharts' ownTreemapuses internally) as puresquarify/layoutRow/worstRationumber-only helpers, decoupled from Skia entirely;TreeMapitself only turns the resulting rects intoRoundedRect/Textnodes. Sortsdatadescending byvaluebefore layout — a deliberate deviation from array order, since squarify's aspect ratios degrade noticeably on unsorted input. ReusesGaugeChart'sopacitynode-prop trick for the dimmed secondary value line. Real bug found and fixed via the on-device screenshot: the show-label gate only checked the tile's ownwidth/heightagainst a fixed size threshold, never whether the label's own measured text width actually fit inside that tile — a tall-but-narrow tile (an ordinary outcome of squarify packing several small values into one row) could pass the size gate while still being narrower than its own text, visibly bleeding the label into the neighboring tile. Fixed by measuringfont.measureText(label).widthbefore deciding to draw it, hiding the label entirely once it doesn't fit rather than letting it overflow.SankeyChartis the second chart needing a real layout algorithm — flagged to the user before starting (same category asTreeMap, distinct from the "no Victory Native primitive" question already settled for the arc-based hand-rolled charts), who chose a full d3-sankey-style layout over a simplified no-relaxation version. Column assignment by longest-path depth (bounded relaxation over the DAG, not an explicit topological sort), node height proportional to throughput, then several passes alternatingrelaxRightToLeft/relaxLeftToRight— each pass pulls every node toward the weighted-center of the links tugging on it and resolves the resulting overlap — to straighten links and reduce crossings, the same technique d3-sankey (and the web version's underlying RechartsSankey) uses. Deliberately skips d3-sankey's own link-crossing-minimizing sort at each node (links stack in the inputlinksarray's own order) as a smaller, documented scope trim distinct from the relaxation algorithm itself. Links are cubic-BezierPaths colored by their source node via thewithAlpha()helper (AreaChart/SparkAreaChart's translucent-fill trick), standing in for the web version's CSScolor-mix(no Skia equivalent).BulletChartneeds noCanvas/Skia at all, the second chart here afterCloudChartto skip it entirely — its web source is already just absolutely-positioned percentage-width<div>s in a flex row (qualitative bands, a performance bar, an optional target tick), which RN's ownViewpercentageleft/width/top/bottomstyles port directly with no canvas or layout algorithm needed. ReusesGaugeChart's documented gap (no RN theme token for the web's--gnome-dim-label-color) for the dim target-value text, approximated withopacity: 0.55. Verification lesson, not a component bug: a demo where the performance bar's color matches one of its ownrangesbands (a realistic case, e.g. a red "critical" zone with a red bar) looked broken in a screenshot — the bar's thin (40%-height-inset) fill seemingly absent over the differently-colored earlier bands. It wasn't; sampling actual pixel RGB values at the bar's true vertical center (not the row's visual midpoint) confirmed correct rendering the whole time — a same-colored thin center stripe is genuinely hard for a human eye to resolve in a compressed screenshot preview. Sample real pixel values for any future thin/inset overlay verification against a same- or similar-colored background, rather than trusting a screenshot by eye alone.WaterfallChartis the first chart in this package to compose Victory Native'sStackedBar— a real primitive match found by reading its source (points/chartBounds/barOptions), not the "no Victory Native primitive, hand-roll on Skia" situationRadarChart/RadialBarChart/FunnelChart/GaugeCharthit. Each bar is two stacked series per category (an invisiblebaseand a coloredvalue, the same floating-bar recipe the web version's<Bar dataKey="base" fill="transparent">+<Bar dataKey="value">pair uses), colored per-bar (not per-series) viabarOptions'sdatumIndexclosing back over the original row array — safe here becausetransformInputDataonly sorts byxKeywhen every value is numeric, and this chart'sxKeyis always a string label. An explicitdomain={{ y: [yMin, yMax] }}covering both series' combined extent is required for the same reasonAreaChart'sstackedmode needs one: the auto-domain only look at each series' own raw values, with no idea the real bar top is their sum. Two real on-device bugs, neither in the component's own math: (1) Skia's color parser can't be assumed to support the"transparent"CSS keyword the way a browser does — used an explicit#00000000hex value for the invisible base segment instead, matching this whole package's existing "prefer alpha-suffixed hex over named colors for Skia" convention. (2) Victory Native's categorical x-axis defaults totickCount: 5and silently downsamples ticks past that — with 6 bridge steps, it didn't crowd the 6th label, it dropped one category's label entirely (confirmed viadownsampleTicks' even-spaced-index selection, not assumed). Harmless for a chart where an occasional skipped tick is fine (a long time series), but every step in a bridge is a named, meaningful data point — fixed with an explicittickCount: { x: data.length, y: 5 }override so every bar always gets its own label, regardless of category count.Heatmapneeded no chart primitive at all, the third chart here afterCloudChart/BulletChartto skip both Victory Native and a hand-rolled Skia canvas — its web source is already just a CSS grid of plain<div>s with no chart-drawing involved, which RN's ownView/Textport directly as a row-of-rows layout instead ofdisplay: grid(RN has no grid layout mode at all). Cell color intensity reuses thewithAlpha()helper (AreaChart/SparkAreaChart's translucent-fill trick) instead of the web's CSScolor-mix. The only Skia usage in the whole component is a tinyCanvasfor the optional legend ramp's gradient fill, since RNViewstyles have nolinear-gradientequivalent — this package already depends on Skia for exactly that gradient-fill need elsewhere, so it added no new dependency. A genuine RN-vs-web layout gap: the web version gets its row-label column's width for free from CSS Grid'sautotrack sizing (as wide as the longest row label); RN has no such per-track auto-sizing at all. Solved by measuring every row label's width up front with Skia's synchronousfont.measureText— the same techniqueTreeMap/GaugeChartalready use for layout decisions in this package — and using the widest one as a fixedViewwidth, rather than a two-passonLayoutmeasure-then-reflow. Real on-device bug in that measurement's first version: the row-label box was sized to exactly the measured text width and given its ownpaddingRight— RN'swidthis border-box, so the padding silently ate into the text's own available space, ellipsizing capital-letter-heavy labels first (Mon/Tue/Wed/Thu→M…/T…,Frionly surviving by coincidence). A first, wrong hypothesis (a Skia-vs-RN font-metric mismatch from settingtheme.fontFamilyon the labelText) was tested and ruled out before finding the real cause — reverting that font change alone fixed nothing, which is what isolated the padding math as the actual bug. Fixed by adding the padding to the measured width instead of trusting the box to absorb it — a lesson for any future fixed-widthViewthat also carries its own padding, in this package or otherwise. ReusedGaugeChart's/BulletChart's documented gap (no RN theme token for the web's--gnome-dim-label-color) for row/column/legend label text, sameopacity: 0.55approximation.SparkGaugeChartis a full-circle simplification ofGaugeChart's already-validated semicircle-gauge geometry — sameSkPathBuilder.addArcprimitive,strokeCap="round", and 0deg=east/positive-sweep-is-clockwise angle conventionGaugeChart/RadialBarChartalready paid down on-device, just sweeping the full 360deg from the top (START_ANGLE = 270) instead of 180deg from the side. UnlikeGaugeChart, it takes an explicit squaresize(matching the web version's ownwidth={size} height={size}) instead of filling a container height, so it needs noonLayout/canvas-size state at all — geometry is fully known from props up front. ExtractedGaugeChart's threshold-color-resolution algorithm (walk sorted thresholds, keep the last one<=value) intosrc/internal/resolveThresholdColor.tson this, its second real consumer — same "extract on second occurrence" call this package's othersrc/internal/helpers were built on. Track color reusestheme.light3directly (a real token match for the web's--gnome-light-3var, unlike the--gnome-dim-label-colorgap several other charts hit — no approximation needed here). Shipped with zero bugs on the first on-device screenshot, the arc geometry and sweep direction already fully de-risked byGaugeChart/RadialBarChartbefore this component existed.SparkPieChartreusesPieChart's exactPolarChart/Pie.Chartprimitive, but had to solve a real gapPieChartnever needed: the web version supportspaddingAngle(a gap in degrees between slices), andPie.Charthas no built-in equivalent at all. Victory Native does ship a trick for this (PieSliceAngularInset, a stroke drawn over each slice's radial edge in a given color) but it only looks like a gap when that color happens to match whatever surface the chart sits on — fragile for a small inline chart meant to be dropped into cards, list rows, or any arbitrary background. Used a different technique instead: interleave a synthetic, fully transparent "spacer" slice between every pair of real slices, with its ownvaluesolved algebraically (spacer = paddingAngle * total / (360 - gaps * paddingAngle)) so its rendered sweep comes out to exactlypaddingAngledegrees once mixed intoPie.Chart's shared value total — a real, transparent gap rather than a background-color illusion. ReusedWaterfallChart's own documented lesson (an explicit#00000000hex, never the"transparent"CSS keyword, for anything Skia needs to render as invisible) for the spacer's color. Confirmed correct on-device: the "no padding" demo's slices visibly touch with no seam, while every other demo shows a real gap — shipped with zero bugs on the first on-device screenshot.SparkBulletChartneeded no new geometry at all — it's the "spark" (compact, no label, no value text) member of the bullet-chart family, and its web source is close enough toBulletChart's own (the same absolutely-positioned percentage-width bands/bar/target-tick recipe, just without the label/value text around it) that the actual bands/bar/target rendering moved into a new sharedsrc/internal/BulletTrack.tsxon this,BulletChart's second real consumer — same "extract on second occurrence" call this package's other shared helpers were built on.BulletChartitself was refactored to composeBulletTrackrather than duplicating the logic, with no behavior change (its own test suite still passes unchanged).BulletTrackdeliberately takes sizing (flex: 1forBulletChart, sitting next to a label in a row;width: '100%'forSparkBulletChart, standing alone) as a caller-suppliedstyleprop rather than assuming one, since the two consumers need genuinely different layout contexts. Shipped with zero bugs on the first on-device screenshot — the underlying track rendering was already fully validated byBulletChartbefore this component existed.BoxPlotis the twenty-third and final component in this package's roadmap — same "no layout algorithm, no Victory Native/Skia primitive" situationBulletChart/Heatmapalready found, just with vertical instead of horizontal percentage positioning for the whiskers/box/ median/outliers, the sametop/heightpercentage techniqueBulletTrackalready validated on-device forleft/width. Portedpercentile/computeStats/resolveStats(the quartile and 1.5×IQR outlier-fence math) verbatim — pure number-crunching, no rendering concerns. A real layout gap, not from the percentage geometry itself: the web version's tick-label column and each column's own track get their vertical alignment "for free" from ordinary CSS box flow; RN has no equivalent, so the axis column's tick-track area is given its own bottom spacer with the exact same height as each data column's label row (LABEL_ROW_HEIGHT), guaranteeing by construction — not by relying on how percentagetopresolves against a padded containing block — that the axis's 0%/100% line up with the real track's max/min. Two real on-device bugs, both in the tick-label sizing, not the alignment fix above: aTextpinned with bothleft: 0andright: 8(mirroring the web's fixed 44px column too literally) forced a 36dp box that ellipsized longer formatted numbers ("334.56"rendered as"334...."); removingleftfixed that case but a still-wider formatted string ("331.28ms", from a customvalueFormatter) truncated again — RN clamped even an unconstrained absolutely-positionedText's measured width to its containing block's size in practice, unlike the web<span>, which has no width constraint at all and simply overflows left when it needs to. Fixed with a generous explicitwidth(120, well beyond the 44dp column) plustextAlign: 'right', so the digits stay anchored on their tick position regardless of how many characters any formatter produces. General lesson: don't assume an absolutely-positioned RNTextwith only one offset set (right, noleft/width) sizes itself to its own content the way a web<span>would — give it an explicit generouswidthinstead of relying on that assumption, especially for anything driven by a caller-supplied formatter.
