import type { SkFont } from "@shopify/react-native-skia"; import { measureFontTextWidth } from "../lib/measureFontTextWidth"; import { dotGlowRadialOutset, minPaddingRightForBadgeYAxisAlign, minPaddingRightForYAxisWithPulse, pulseRadialOutset, resolveAutoLeft, resolveAutoRight, resolvePadding, type ChartPadding, } from "../draw/line"; import type { BadgeMetrics, ChartInsets, LiveChartPalette } from "../types"; export interface ChartLayoutConfig { palette: LiveChartPalette; lineWidthOverride?: number; insetsOverride?: ChartInsets; yAxis: boolean; /** Float the y-axis over a full-width plot (no reserved right gutter). */ yAxisFloat?: boolean; badge: boolean; /** Badge pill geometry tokens. Omit for built-in defaults. */ badgeMetrics?: BadgeMetrics; /** * When true (default if omitted and `badge` is true), reserve the wide right gutter for the badge. * Set false when the badge is anchored left of the live dot (`position: "left"`). */ badgeUsesRightGutter?: boolean; /** When false, bottom inset shrinks (no x-axis labels). Default true. */ xAxis?: boolean; /** * Reserved volume-bar band height (px) below the candles. Added to the bottom * padding so the candle/price plot shrinks by this much; the x-axis offsets * back down by it so its labels stay pinned to the bottom (see XAxisOverlay). * `0`/omitted = no band. An explicit `insetsOverride.bottom` suppresses it. */ volumeBandHeight?: number; /** Skia font for measuring label width. When provided with formatValue + currentValue, padding auto-sizes. */ font?: SkFont; /** Worklet formatter — called on JS thread here to measure label width */ formatValue?: (v: number) => string; /** Current value read from SharedValue on JS thread — used to produce a sample label for measurement */ currentValue?: number; /** * Live dot pulse (LiveChart); floors top/right/bottom auto-padding so the ring * is not clipped. An explicit `insetsOverride` on a side wins over this floor. */ pulse?: { maxRadius: number; strokeWidth: number } | null; /** * Static live-dot glow; floors auto-padding so its blurred falloff is not * clipped. Explicit side insets still win. */ dotGlow?: { radius: number; blur: number } | null; /** * When false and the badge uses the right gutter, omit the tail and * round-cap inset from the right padding. */ badgeShowTail?: boolean; /** Multi-series dot radius — used to add spacing between dots and Y-axis labels. */ multiSeriesDotRadius?: number; /** When true, inline series labels sit right of dots and need extra right padding. */ multiSeriesValueLabel?: boolean; /** Measured width of the widest series label (e.g. "Maybe"). Used when `multiSeriesValueLabel` is true. */ multiSeriesMaxLabelWidth?: number; } export interface ChartLayoutResult { strokeWidth: number; padding: ChartPadding; } /** Right inset (px) for a floating y-axis — just keeps the plot off the edge. */ const FLOAT_AXIS_RIGHT_INSET = 6; export function resolveChartLayout( config: ChartLayoutConfig, ): ChartLayoutResult { const badgeUsesRightGutter = config.badge && (config.badgeUsesRightGutter ?? true); const showTail = config.badgeShowTail ?? true; const xAxis = config.xAxis ?? true; let measuredYAxisLabelWidth: number | undefined; // ── Right inset ────────────────────────────────────────────────────────── let rightPad: number; if (config.insetsOverride?.right != null) { rightPad = config.insetsOverride.right; } else if ( config.font && config.formatValue && config.currentValue != null && (badgeUsesRightGutter || !config.badge || config.yAxis) ) { const v = config.currentValue; const samples = [v, v / 10, v / 100, v * 10].map(config.formatValue); measuredYAxisLabelWidth = Math.max( ...samples.map((s) => measureFontTextWidth(config.font!, s)), ); const dotR = config.multiSeriesDotRadius ?? 0; const seriesLabelW = config.multiSeriesMaxLabelWidth ?? 0; rightPad = badgeUsesRightGutter ? minPaddingRightForBadgeYAxisAlign( config.font.getSize(), measuredYAxisLabelWidth, showTail, config.badgeMetrics, ) : config.multiSeriesValueLabel && config.yAxis ? Math.max( dotR + 8 + seriesLabelW + 8 + measuredYAxisLabelWidth + 8, 44, ) : config.multiSeriesValueLabel ? Math.max(dotR + 8 + seriesLabelW + 8, 44) : config.yAxis ? Math.max(measuredYAxisLabelWidth + 16 + dotR * 2, 44) : resolveAutoRight(false, false); } else { rightPad = resolveAutoRight( config.yAxis, badgeUsesRightGutter, showTail, config.badgeMetrics, ); } const pulseOutset = config.pulse ? pulseRadialOutset(config.pulse.maxRadius, config.pulse.strokeWidth) : 0; const glowOutset = config.dotGlow ? dotGlowRadialOutset(config.dotGlow.radius, config.dotGlow.blur) : 0; const dotEffectOutset = Math.max(pulseOutset, glowOutset); // A right-gutter badge uses its pill layout instead of the bare centered label // column, so the bare-axis effect/label floor must not override badge sizing. // The badge renders above the dot effects; the general outlet floor below // still prevents canvas-edge clipping. if ( dotEffectOutset > 0 && config.yAxis && !badgeUsesRightGutter && config.insetsOverride?.right == null ) { const labelW = measuredYAxisLabelWidth ?? 49; rightPad = Math.max( rightPad, minPaddingRightForYAxisWithPulse(dotEffectOutset, labelW), ); } // ── Left inset ─────────────────────────────────────────────────────────── let leftPad: number; if (config.insetsOverride?.left != null) { leftPad = config.insetsOverride.left; } else { leftPad = resolveAutoLeft(false); } const base = resolvePadding( config.insetsOverride, config.yAxis, badgeUsesRightGutter, false, xAxis, showTail, config.badgeMetrics, ); let padding: ChartPadding = { ...base, right: rightPad, left: leftPad }; if (dotEffectOutset > 0) { // The pulse/glow needs this much room or it clips at the canvas edge, so it // acts as a floor on the *auto* padding. An explicitly-provided inset always // wins, though — the caller opts into any clipping that causes (issue #128). // Every other auto-padding already respects an explicit inset (see the // right/left branches and resolvePadding); this is the last place that didn't. const ins = config.insetsOverride; padding = { ...padding, right: ins?.right != null ? padding.right : Math.max(padding.right, dotEffectOutset), top: ins?.top != null ? padding.top : Math.max(padding.top, dotEffectOutset), bottom: ins?.bottom != null ? padding.bottom : Math.max(padding.bottom, dotEffectOutset), }; } // Floating axis: collapse the right gutter LAST so it wins over the label, // pulse, and badge reservations above — the plot runs full-width with the price // axis (and the live-value badge) floating on top. An explicit right inset still // wins. (The live-dot pulse may clip at the right edge in this mode.) if (config.yAxisFloat && config.insetsOverride?.right == null) { padding = { ...padding, right: FLOAT_AXIS_RIGHT_INSET }; } // Volume band: reserve extra space at the bottom by ADDING the band height to // the (already finalized) bottom padding, so every price Y-projection shrinks // for free. The x-axis is shifted back down by the same amount (XAxisOverlay) // so its labels stay at the very bottom, below the band. An explicit bottom // inset opts out (the caller owns the full bottom space). if ( config.volumeBandHeight && config.volumeBandHeight > 0 && config.insetsOverride?.bottom == null ) { padding = { ...padding, bottom: padding.bottom + config.volumeBandHeight }; } return { strokeWidth: config.lineWidthOverride ?? config.palette.lineWidth, padding, }; }