import type { AreaDotsConfig, AxisLabelConfig, BadgeConfig, BadgeVariant, CandleGap, CandleGapBandStyle, CandleGapBridgeStyle, CandleGapKind, CandleGapLabelStyle, CandleGapsConfig, DegenOptions, FontConfig, FontWeight, GradientConfig, GridStyleConfig, LeftEdgeFadeConfig, LegendConfig, LegendStyle, LineStyleConfig, LiveChartMetrics, LiveChartMetricsOverride, LiveChartPoint, LoadingConfig, MarkerClusterConfig, DotConfig, DotGlowConfig, DotRingConfig, MultiSeriesDotConfig, PulseConfig, ReferenceLine, ScrubActionConfig, ScrubConfig, PerSeriesTooltipConfig, SelectionDotConfig, SelectionDotProps, SelectionDotRingConfig, ReturnToLiveConfig, ThresholdConfig, TimeScrollConfig, ThresholdLineConfig, TradeEvent, TransitionConfig, ValueLineConfig, VolumeConfig, XAxisConfig, YAxisConfig, ZoomConfig, } from "../types"; import type { ComponentType, ReactElement } from "react"; import type { SharedValue } from "react-native-reanimated"; import type { ResolvedMarkerCluster } from "../math/markerCluster"; import { BADGE_METRICS_DEFAULTS, CANDLE_METRICS_DEFAULTS, EMPTY_STATE_METRICS_DEFAULTS, FADE_EDGE_WIDTH, GRID_METRICS_DEFAULTS, LOADING_WAVE_AMPLITUDE, LOADING_WAVE_SPEED, MOTION_METRICS_DEFAULTS, RETURN_TO_LIVE_MS, SCRUB_CANDLE_DIM_FADE_MS, } from "../constants"; // ─── Resolved types (all fields required, no optionals) ────────────────────── export interface ResolvedValueLineConfig { strokeWidth: number; intervals: [number, number]; /** undefined → use palette.dashLine at render time */ color: string | undefined; } export interface ResolvedBadgeConfig { variant: BadgeVariant; tail: boolean; position: "right" | "left"; background: string | undefined; /** undefined → capsule (pillHeight / 2) at render time */ radius: number | undefined; /** undefined → no border */ borderColor: string | undefined; borderWidth: number; /** undefined → variant/theme text color at render time */ textColor: string | undefined; fontSize: number | undefined; fontFamily: string | undefined; fontWeight: FontWeight | undefined; offsetX: number; offsetY: number; /** Track the visible window's right-edge price while scrolled back. */ followViewEdge: boolean; } export interface ResolvedYAxisConfig { minGap: number; /** Multiplier used to choose and align representable dynamic nice intervals. */ intervalScale: number; /** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */ count: number; /** undefined → keep the default centered-gutter label placement. */ labelRightMargin: number | undefined; /** undefined → no gap; inert unless labelRightMargin is set. */ gridEndGap: number | undefined; /** Float the axis over a full-width plot (no reserved right gutter). */ float: boolean; } export interface ResolvedVolumeConfig { /** undefined → use palette.candleUp at render time. */ upColor: string | undefined; /** undefined → use palette.candleDown at render time. */ downColor: string | undefined; /** Reserved band height (px) — the tallest a bar can be. */ maxHeight: number; /** Corner radius (px) of bar tops. */ radius: number; /** Opacity (0..1) applied to the whole band. */ opacity: number; } export interface ResolvedCandleGapBridgeStyle { color: string | undefined; opacity: number; strokeWidth: number; strokeCap: "butt" | "round" | "square"; } export interface ResolvedCandleGapBandStyle { fillColor: string | undefined; fillOpacity: number; borderColor: string | undefined; borderOpacity: number; borderWidth: number; intervals: [number, number]; } export interface ResolvedCandleGapLabelStyle { color: string | undefined; position: "left" | "right"; } export interface ResolvedCandleGapStyle { bridge: ResolvedCandleGapBridgeStyle | null; band: ResolvedCandleGapBandStyle | null; label: ResolvedCandleGapLabelStyle | null; } export interface ResolvedCandleGapsConfig { gaps: CandleGap[]; styles: Record; } /** Resolved straight-line styling (connector, etc.). `color: undefined` → caller default. */ export interface ResolvedLineStyleConfig { color: string | undefined; strokeWidth: number; /** undefined → solid (no dash). */ intervals: [number, number] | undefined; } export interface ResolvedAxisLabelConfig { /** undefined → use the chart's `formatValue` at render time. */ format?: (v: number) => string; /** undefined → use the muted default label color at render time. */ color?: string; /** `"left"`/`"right"` pin to that edge; `"extrema"`(-`edge`) tracks the data point. */ position: "left" | "right" | "extrema" | "extrema-edge"; /** undefined → the built-in default text size (11). */ fontSize?: number; /** undefined → the platform `` default weight. */ fontWeight?: FontWeight; /** undefined → the platform `` default family. */ fontFamily?: string; /** Extrema dot color; undefined → use `color`. */ dotColor?: string; /** Extrema dot diameter (px); undefined → the built-in default (7). */ dotSize?: number; /** Extrema — whether to draw the marker dot. */ dot: boolean; /** `"extrema-edge"` connector line (dot → edge label); null → none. */ connector: ResolvedLineStyleConfig | null; /** When set, the built-in value label is replaced by this custom element. */ render?: () => ReactElement | null; } export interface ResolvedXAxisConfig { minGap: number; } export interface ResolvedScrubConfig { tooltip: boolean; /** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */ seriesTooltip: ResolvedPerSeriesTooltipConfig | null; /** Content affected by dimOpacity while scrubbing. */ dimTarget: "future" | "otherCandles"; /** Opacity of the content selected by dimTarget while scrubbing. */ dimOpacity: number; /** Candle-focus dim transition duration in milliseconds. */ dimFadeMs: number; /** undefined → palette.crosshairLine */ crosshairLineColor: string | undefined; /** Vertical crosshair line width in px. */ crosshairStrokeWidth: number; /** Extension past the plot's top and bottom edges in px. */ crosshairOvershoot: number; /** Fade the crosshair near the live edge. */ crosshairFade: boolean; /** Visible-crosshair fade distance near the live edge in px. */ crosshairFadeDistance: number; /** undefined preserves the existing Skia line-cap default. */ crosshairLineCap: "butt" | "round" | "square" | undefined; /** Dash intervals `[on, off, …]` for the crosshair line; undefined → solid. */ crosshairDash: number[] | undefined; /** undefined → palette.crosshairDim */ crosshairDimColor: string | undefined; /** undefined → palette.tooltipBg */ tooltipBackground: string | undefined; /** undefined → palette.tooltipText */ tooltipColor: string | undefined; /** undefined → palette.tooltipBorder */ tooltipBorderColor: string | undefined; /** Tooltip pill corner radius in px. */ tooltipBorderRadius: number; /** Where the tooltip pill sits relative to the scrub line. */ tooltipPlacement: "side" | "top" | "bottom" | "point"; /** Gap (px) between the tooltip and the plot edge it's pinned to. */ tooltipMargin: number; /** Show the value row in the default tooltip body. */ tooltipShowValue: boolean; /** Show the time row in the default tooltip body. */ tooltipShowTime: boolean; /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */ panGestureDelay: number; /** Fade markers + reference lines out while scrubbing. */ hideOverlaysOnScrub: boolean; /** Reject outside plain-scrub starts and clamp active scrub X to the plot. */ clampToPlot: boolean; /** Candle mode: quantize the scrub X to the hovered candle's center. */ snapToCandles: boolean; } export interface ResolvedPerSeriesTooltipConfig { alwaysShow: boolean; bucketSeconds: number | undefined; formatSeriesValue: ((value: number, seriesId: string) => string) | undefined; formatTimeRange: ((from: number, to: number) => string) | undefined; maxLabelChars: number; guideColor: string | undefined; guideWidth: number; guideDashPattern: number[] | undefined; timePillBackground: string | undefined; timePillColor: string | undefined; timePillBorderColor: string | undefined; timePillRadius: number; timePillPaddingX: number; timePillPaddingY: number; seriesPillBackground: string | undefined; seriesPillLabelColor: string | undefined; seriesPillValueColor: string | undefined; seriesPillBorderColor: string | undefined; seriesPillRadius: number; seriesPillPaddingX: number; seriesPillPaddingY: number; seriesPillDotSize: number; seriesPillDotGap: number; seriesPillLabelValueGap: number; intersectionDotSize: number; } export interface ResolvedScrubActionConfig { /** Glyph drawn in the action badge. */ icon: string; /** undefined → palette.badgeBg */ background: string | undefined; /** undefined → palette.badgeText */ iconColor: string | undefined; /** undefined → palette.crosshairLine */ lineColor: string | undefined; /** Show the price readout pill; false → icon-only badge. */ text: boolean; /** Show the date/time pill where the vertical line meets the x-axis. */ timeBadge: boolean; /** undefined → no rounding */ snap: number | undefined; dismissOnTapOutside: boolean; /** Clear the reticle once the action badge fires onScrubAction. */ dismissOnAction: boolean; } export interface ResolvedGradientConfig { /** undefined → use palette.fillTop (theme-aware) at render time */ topOpacity: number | undefined; /** undefined → use palette.fillBottom (transparent) at render time */ bottomOpacity: number | undefined; /** Explicit color stops (top → bottom); overrides the opacity stops. */ colors: string[] | undefined; /** Stop positions (0..1) matching `colors` length. */ positions: number[] | undefined; } export interface ResolvedAreaDotsConfig { spacing: number; size: number; /** undefined → derive a faint tint from the line/accent color at render time. */ color: string | undefined; opacity: number; } export interface ResolvedPulseConfig { interval: number; duration: number; maxRadius: number; opacity: number; strokeWidth: number; } export interface ResolvedReferenceLineConfig { strokeWidth: number; intervals: [number, number]; /** undefined → use palette.refLine at render time */ color: string | undefined; } export interface ResolvedGridStyleConfig { /** undefined → use palette.gridLine at render time */ color: string | undefined; strokeWidth: number; /** Empty array → solid stroke (no dash effect). */ intervals: number[]; opacity: number; } export interface ResolvedFontConfig { fontFamily: string; fontSize: number; fontWeight: FontWeight; } export interface ResolvedDegenConfig { scale: number; downMomentum: boolean; shake: boolean; shakeIntensity: number; shakeDurationSec: number; particleSlotCount: number; particleBurstDurationSec: number; burstParticleCount: number; drag: number; particleSizeMin: number; particleSizeMax: number; particleOpacity: number; spreadAngle: number; positionJitterX: number; positionJitterY: number; speedMin: number; speedMax: number; /** `null` = use palette.line at render time. */ colors: string[] | null; } export interface ResolvedTradeStreamConfig { maxCount: number; /** Horizontal offset from `padding.left` for the label text (default 8). */ labelOffsetX: number; } export interface ResolvedLeftEdgeFadeConfig { width: number; startColor: string; endColor: string; } // ─── Resolver functions ─────────────────────────────────────────────────────── /** * Uniform `boolean | Config` feature-flag resolver, shared by every toggle below. * - `false` → `null` (explicitly disabled) * - `undefined` → `defaultOn ? defaults : null` (the feature's default state) * - `true` → `defaults` * - object → defaults shallow-merged with the caller's overrides * * `defaultOn` makes each toggle's default explicit at the resolver. Toggles whose * default-on is owned by the component prop (e.g. `badge = true`) pass * `defaultOn: false` here, so a bare resolver call without that default stays off. */ function resolveToggle( prop: boolean | C | undefined, defaults: R, defaultOn: boolean, ): R | null { if (prop === false) return null; if (prop == null) return defaultOn ? defaults : null; if (prop === true) return defaults; return { ...defaults, ...prop } as R; } const VALUE_LINE_DEFAULTS: ResolvedValueLineConfig = { strokeWidth: 1, intervals: [4, 4], color: undefined, }; /** * Resolves `valueLine` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. */ export function resolveValueLine( prop: boolean | ValueLineConfig | undefined, ): ResolvedValueLineConfig | null { return resolveToggle(prop, VALUE_LINE_DEFAULTS, false); } export interface ResolvedThresholdLineConfig { /** undefined → no label. */ label: string | undefined; /** Label side; `"left"` sits inside the plot (clear of the y-axis gutter). */ labelPosition: "left" | "right"; /** Series badge value/Y source; `"last"` preserves the live-edge default. */ labelAnchor: "first" | "last"; /** undefined → use palette.refLine (line) / palette.refLabel (label) at render time. */ color: string | undefined; intervals: [number, number]; strokeWidth: number; showValue: boolean; /** undefined → fall back to `color`, then palette.refLabel at render time. */ labelColor: string | undefined; } export interface ResolvedThresholdConfig { /** * The split value (Y-axis units). A `SharedValue` for a single live * benchmark (read on the UI thread each frame), or a `LiveChartPoint[]` for a * time-varying threshold the split follows point-for-point. `undefined` when * {@link series} carries the threshold instead. */ value: SharedValue | LiveChartPoint[] | undefined; /** Live time-varying threshold (`SharedValue`); wins over `value`. */ series: SharedValue | null; /** undefined → use palette.candleUp (up-green) at render time. */ aboveColor: string | undefined; /** undefined → use palette.candleDown (down-red) at render time. */ belowColor: string | undefined; fill: boolean; /** Band opacity (0–1); {@link THRESHOLD_FILL_OPACITY_DEFAULT} unless tuned. */ fillOpacity: number; /** Fold the threshold into the Y-axis range fit. */ includeInRange: boolean; /** Series forms: extend flat before the first point to the visible window's start. */ extendToStart: boolean; /** Series forms: extend flat past the last point to "now". */ extendToNow: boolean; line: ResolvedThresholdLineConfig | null; } const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = { label: undefined, labelPosition: "left", labelAnchor: "last", color: undefined, intervals: [4, 4], strokeWidth: 1, showValue: false, labelColor: undefined, }; /** Default threshold band opacity — matches reference-line bands. */ export const THRESHOLD_FILL_OPACITY_DEFAULT = 0.16; /** * Resolves the `threshold.line` sub-prop to a config or null (no marker line). * `true` → dashed defaults, object → merged, falsy/undefined → null. */ export function resolveThresholdLine( prop: boolean | ThresholdLineConfig | undefined, ): ResolvedThresholdLineConfig | null { return resolveToggle(prop, THRESHOLD_LINE_DEFAULTS, false); } /** * Resolves the `threshold` prop to a fully-typed config or null (disabled). * Presence-gated (like `referenceLines`/`markers`): a config object with a * `value` or `series` enables it — there is no boolean form. */ export function resolveThreshold( prop: ThresholdConfig | undefined, ): ResolvedThresholdConfig | null { if (!prop || (prop.value == null && prop.series == null)) return null; return { value: prop.value, series: prop.series ?? null, aboveColor: prop.aboveColor, belowColor: prop.belowColor, fill: !!prop.fill, fillOpacity: (typeof prop.fill === "object" ? prop.fill.opacity : undefined) ?? THRESHOLD_FILL_OPACITY_DEFAULT, includeInRange: prop.includeInRange ?? false, extendToStart: prop.extendToStart ?? true, extendToNow: prop.extendToNow ?? true, line: resolveThresholdLine(prop.line), }; } const BADGE_DEFAULTS: ResolvedBadgeConfig = { variant: "default", tail: true, position: "right", background: undefined, radius: undefined, borderColor: undefined, borderWidth: 1, textColor: undefined, fontSize: undefined, fontFamily: undefined, fontWeight: undefined, offsetX: 0, offsetY: 0, followViewEdge: false, }; /** * Resolves `badge` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. */ export function resolveBadge( prop: boolean | BadgeConfig | undefined, ): ResolvedBadgeConfig | null { return resolveToggle(prop, BADGE_DEFAULTS, false); } const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = { minGap: 36, intervalScale: 1, count: 0, labelRightMargin: undefined, gridEndGap: undefined, float: false, }; /** * Resolves `yAxis` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. * `count` is normalized to a non-negative integer (the grid math clamps the * upper bound to the label pool size). */ export function resolveYAxis( prop: boolean | YAxisConfig | undefined, ): ResolvedYAxisConfig | null { const resolved = resolveToggle(prop, Y_AXIS_DEFAULTS, false); if (resolved === null) return null; return { ...resolved, count: Math.max(0, Math.floor(resolved.count)), intervalScale: Number.isFinite(resolved.intervalScale) && resolved.intervalScale > 0 ? resolved.intervalScale : 1, }; } const VOLUME_DEFAULTS: ResolvedVolumeConfig = { upColor: undefined, downColor: undefined, maxHeight: 48, radius: 2, opacity: 0.6, }; /** * Resolves the `volume` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. Colors left * `undefined` fall back to the candle palette at render time. */ export function resolveVolume( prop: boolean | VolumeConfig | undefined, ): ResolvedVolumeConfig | null { return resolveToggle(prop, VOLUME_DEFAULTS, false); } const CANDLE_GAP_BRIDGE_DEFAULTS: ResolvedCandleGapBridgeStyle = { color: undefined, opacity: 0.7, strokeWidth: 2, strokeCap: "round", }; const CANDLE_GAP_BAND_DEFAULTS: ResolvedCandleGapBandStyle = { fillColor: undefined, fillOpacity: 0.12, borderColor: undefined, borderOpacity: 1, borderWidth: 2, intervals: [4, 4], }; const CANDLE_GAP_LABEL_DEFAULTS: ResolvedCandleGapLabelStyle = { color: undefined, position: "left", }; function clampOpacity(value: number): number { return value < 0 ? 0 : value > 1 ? 1 : value; } const CANDLE_GAP_STYLE_DEFAULTS: Record = { "no-trades": { bridge: CANDLE_GAP_BRIDGE_DEFAULTS, band: null, label: null, }, unavailable: { bridge: { ...CANDLE_GAP_BRIDGE_DEFAULTS, opacity: 0.55 }, band: CANDLE_GAP_BAND_DEFAULTS, label: CANDLE_GAP_LABEL_DEFAULTS, }, unknown: { bridge: null, band: { ...CANDLE_GAP_BAND_DEFAULTS, fillOpacity: 0.08, borderWidth: 1, }, label: CANDLE_GAP_LABEL_DEFAULTS, }, }; function resolveCandleGapBridge( override: false | CandleGapBridgeStyle | undefined, semanticDefault: ResolvedCandleGapBridgeStyle | null, ): ResolvedCandleGapBridgeStyle | null { if (override === false) return null; if (override === undefined) { return semanticDefault ? { ...semanticDefault } : null; } const base = semanticDefault ?? CANDLE_GAP_BRIDGE_DEFAULTS; const strokeCap = override.strokeCap; return { color: override.color ?? base.color, opacity: clampOpacity(override.opacity ?? base.opacity), strokeWidth: Math.max(0, override.strokeWidth ?? base.strokeWidth), strokeCap: strokeCap === "butt" || strokeCap === "round" || strokeCap === "square" ? strokeCap : base.strokeCap, }; } function resolveCandleGapBand( override: false | CandleGapBandStyle | undefined, semanticDefault: ResolvedCandleGapBandStyle | null, ): ResolvedCandleGapBandStyle | null { if (override === false) return null; if (override === undefined) { return semanticDefault ? { ...semanticDefault } : null; } const base = semanticDefault ?? CANDLE_GAP_BAND_DEFAULTS; return { fillColor: override.fillColor ?? base.fillColor, fillOpacity: clampOpacity(override.fillOpacity ?? base.fillOpacity), borderColor: override.borderColor ?? base.borderColor, borderOpacity: clampOpacity(override.borderOpacity ?? base.borderOpacity), borderWidth: Math.max(0, override.borderWidth ?? base.borderWidth), intervals: override.intervals ?? base.intervals, }; } function resolveCandleGapLabel( override: false | CandleGapLabelStyle | undefined, semanticDefault: ResolvedCandleGapLabelStyle | null, ): ResolvedCandleGapLabelStyle | null { if (override === false) return null; if (override === undefined) { return semanticDefault ? { ...semanticDefault } : null; } const base = semanticDefault ?? CANDLE_GAP_LABEL_DEFAULTS; return { color: override.color ?? base.color, position: override.position === "left" || override.position === "right" ? override.position : base.position, }; } /** Resolve and defensively normalize explicit candle-gap metadata. */ export function resolveCandleGaps( prop: CandleGap[] | CandleGapsConfig | undefined, ): ResolvedCandleGapsConfig | null { if (prop == null) return null; const gaps = (Array.isArray(prop) ? prop : prop.gaps) .filter( (gap) => Number.isFinite(gap.from) && Number.isFinite(gap.to) && gap.to > gap.from && (gap.kind === "no-trades" || gap.kind === "unavailable" || gap.kind === "unknown"), ) .slice() .sort((a, b) => a.from - b.from || a.to - b.to); if (gaps.length === 0) return null; const overrides = Array.isArray(prop) ? undefined : prop.styles; const resolveStyle = (kind: CandleGapKind): ResolvedCandleGapStyle => { const base = CANDLE_GAP_STYLE_DEFAULTS[kind]; const override = overrides?.[kind]; return { bridge: resolveCandleGapBridge(override?.bridge, base.bridge), band: resolveCandleGapBand(override?.band, base.band), label: resolveCandleGapLabel(override?.label, base.label), }; }; return { gaps, styles: { "no-trades": resolveStyle("no-trades"), unavailable: resolveStyle("unavailable"), unknown: resolveStyle("unknown"), }, }; } export interface ResolvedZoomConfig { /** Tightest window (max zoom-in), seconds. `undefined` → `timeWindow / 8`. */ minTimeWindow: number | undefined; /** Widest window (max zoom-out), seconds. `undefined` → full data span. */ maxTimeWindow: number | undefined; } const ZOOM_DEFAULTS: ResolvedZoomConfig = { minTimeWindow: undefined, maxTimeWindow: undefined, }; /** * Resolves the `zoom` prop to a config or null (disabled). `true` → defaults * (bounds derived at gesture time), object → merged, falsy → null. */ export function resolveZoom( prop: boolean | ZoomConfig | undefined, ): ResolvedZoomConfig | null { return resolveToggle(prop, ZOOM_DEFAULTS, false); } /** * Resolves `timeScroll.returnToLive` to the "return to live" glide duration in ms, * where `0` means an instant snap (no animation): * - `undefined` / `true` → default {@link RETURN_TO_LIVE_MS} * - `false` → `0` (instant) * - `{ duration }` → that duration (a non-positive value collapses to `0`) * * See {@link ReturnToLiveConfig} / #164. */ export function resolveReturnToLiveMs( prop: boolean | ReturnToLiveConfig | undefined, ): number { if (prop === false) return 0; if (prop == null || prop === true) return RETURN_TO_LIVE_MS; const d = prop.duration; if (d == null) return RETURN_TO_LIVE_MS; return d > 0 ? d : 0; } /** * Overscroll ceiling — `overscroll: 1` would let the window scroll fully past * the data (an all-blank plot), so the fraction clamps just short of it. */ const MAX_OVERSCROLL = 0.99; /** * Resolves `timeScroll.overscroll` to a clamped `[0, 1)` fraction of the * visible window that pan / fling / pinch may travel past the data bounds. * `0` (booleans, omitted, or non-positive values) keeps the classic hard * stops at the oldest data and the live edge. See {@link TimeScrollConfig}. */ export function resolveOverscroll( prop: boolean | TimeScrollConfig | undefined, ): number { if (prop == null || typeof prop === "boolean") return 0; const v = prop.overscroll; if (v == null || !(v > 0)) return 0; return v < MAX_OVERSCROLL ? v : MAX_OVERSCROLL; } /** * Resolves `timeScroll.fling`: `false` disables release inertia so the window * stops dead where the finger lifts. Booleans / omitted keep the default (on). * See {@link TimeScrollConfig}. */ export function resolveFling( prop: boolean | TimeScrollConfig | undefined, ): boolean { if (prop == null || typeof prop === "boolean") return true; return prop.fling ?? true; } /** * Resolved transition durations. `undefined` for a field means "use the * component's built-in default" (so we don't duplicate the default constants * here); a number is an explicit duration in ms (clamped to ≥ 0). */ export interface ResolvedTransitionConfig { reveal: number | undefined; mode: number | undefined; /** Candle-width lerp speed (0–1); `undefined` = use the built-in default. */ candleLerpSpeed: number | undefined; } const clampMs = (v: number | undefined): number | undefined => v == null ? undefined : v > 0 ? v : 0; /** Clamp a per-frame lerp speed to `[0, 1]`; `undefined` → built-in default. */ const clampSpeed = (v: number | undefined): number | undefined => v == null ? undefined : v < 0 ? 0 : v > 1 ? 1 : v; /** * Resolves the `transitions` prop. `false` → all transitions instant (durations * `0`, candle width snaps with speed `1`); `true` / omitted → defaults (all * `undefined` = use the built-in durations / speed); an object → per-transition * overrides (an omitted field keeps its default). */ export function resolveTransitions( prop: boolean | TransitionConfig | undefined, ): ResolvedTransitionConfig { if (prop === false) return { reveal: 0, mode: 0, candleLerpSpeed: 1 }; if (prop == null || prop === true) return { reveal: undefined, mode: undefined, candleLerpSpeed: undefined }; return { reveal: clampMs(prop.reveal), mode: clampMs(prop.mode), candleLerpSpeed: clampSpeed(prop.candleLerpSpeed), }; } const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = { format: undefined, color: undefined, position: "right", fontSize: undefined, fontWeight: undefined, fontFamily: undefined, dotColor: undefined, dotSize: undefined, dot: true, // Always overwritten by resolveAxisLabel (per-position default-on); placeholder. connector: null, render: undefined, }; /** Dashed by default — a subtle guide tying the extrema dot to its edge label. */ const CONNECTOR_DEFAULTS: ResolvedLineStyleConfig = { color: undefined, strokeWidth: 1, intervals: [2, 3], }; /** * Resolves the extrema-label `connector` sub-prop to a line style or null. * `defaultOn` (true in `"extrema-edge"` mode) means an unset connector draws the * dashed default; `false` → null; an object → merged; a `LineStyleConfig` is * normalized (its `intervals` may be omitted for a solid line). */ export function resolveConnector( prop: boolean | LineStyleConfig | undefined, defaultOn: boolean, ): ResolvedLineStyleConfig | null { if (prop === false) return null; if (prop == null) return defaultOn ? CONNECTOR_DEFAULTS : null; if (prop === true) return CONNECTOR_DEFAULTS; return { color: prop.color, strokeWidth: prop.strokeWidth ?? CONNECTOR_DEFAULTS.strokeWidth, // An explicit object opts into its own dash (or solid when omitted). intervals: prop.intervals, }; } /** * Resolves a `topLabel` / `bottomLabel` prop to a fully-typed config or null * (no label). Opt-in, so `undefined`/`false` → null; `true` → the built-in * value label with defaults; object → configured built-in (or a custom `render`). * The `connector` defaults on (dashed) in `"extrema-edge"` mode, else off. */ export function resolveAxisLabel( prop: boolean | AxisLabelConfig | undefined, ): ResolvedAxisLabelConfig | null { const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false); if (!resolved) return null; const connectorProp = typeof prop === "object" ? prop.connector : undefined; return { ...resolved, connector: resolveConnector( connectorProp, resolved.position === "extrema-edge", ), }; } const X_AXIS_DEFAULTS: ResolvedXAxisConfig = { minGap: 60, }; /** * Resolves `xAxis` prop to a fully-typed config or null (disabled). * Defaults to enabled (`true`) so a bare `undefined` also returns the defaults. */ export function resolveXAxis( prop: boolean | XAxisConfig | undefined, ): ResolvedXAxisConfig | null { return resolveToggle(prop, X_AXIS_DEFAULTS, true); } const SCRUB_DEFAULTS: ResolvedScrubConfig = { tooltip: true, seriesTooltip: null, dimTarget: "future", dimOpacity: 0.3, dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS, crosshairLineColor: undefined, crosshairStrokeWidth: 1, crosshairOvershoot: 0, crosshairFade: true, crosshairFadeDistance: 4, crosshairLineCap: undefined, crosshairDash: undefined, crosshairDimColor: undefined, tooltipBackground: undefined, tooltipColor: undefined, tooltipBorderColor: undefined, tooltipBorderRadius: 5, tooltipPlacement: "side", tooltipMargin: 8, tooltipShowValue: true, tooltipShowTime: true, panGestureDelay: 0, hideOverlaysOnScrub: false, clampToPlot: false, snapToCandles: false, }; const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = { alwaysShow: false, bucketSeconds: undefined, formatSeriesValue: undefined, formatTimeRange: undefined, maxLabelChars: 14, guideColor: undefined, guideWidth: 1, guideDashPattern: [3, 3], timePillBackground: undefined, timePillColor: undefined, timePillBorderColor: undefined, timePillRadius: 6, timePillPaddingX: 8, timePillPaddingY: 4, seriesPillBackground: undefined, seriesPillLabelColor: undefined, seriesPillValueColor: undefined, seriesPillBorderColor: undefined, seriesPillRadius: 6, seriesPillPaddingX: 8, seriesPillPaddingY: 4, seriesPillDotSize: 8, seriesPillDotGap: 6, seriesPillLabelValueGap: 6, intersectionDotSize: 8, }; function resolvePerSeriesTooltip( prop: boolean | PerSeriesTooltipConfig | undefined, ): ResolvedPerSeriesTooltipConfig | null { const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false); if (!resolved) return null; const dash = typeof prop === "object" ? prop.guideDashPattern : undefined; if (dash !== undefined) { resolved.guideDashPattern = dash === true ? [3, 3] : dash || undefined; } resolved.maxLabelChars = Math.max(1, Math.floor(resolved.maxLabelChars)); resolved.bucketSeconds = resolved.bucketSeconds !== undefined && resolved.bucketSeconds > 0 ? resolved.bucketSeconds : undefined; return resolved; } /** * Resolves `scrub` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. */ export function resolveScrub( prop: boolean | ScrubConfig | undefined, ): ResolvedScrubConfig | null { const resolved = resolveToggle(prop, SCRUB_DEFAULTS, false); if (resolved) { // Normalize the dash shorthand: `true` → a default dash, an array passes // through, anything falsy → solid (undefined). const dash = typeof prop === "object" ? prop.crosshairDash : undefined; resolved.crosshairDash = dash === true ? [4, 4] : dash || undefined; const seriesTooltip = typeof prop === "object" ? prop.seriesTooltip : undefined; resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip); // `resolveToggle` shallow-merges object props, so an explicitly undefined // optional field replaces its default. Restore the public defaults before // normalizing; otherwise the numeric clamps produce NaN and an undefined // dimTarget selects neither candle scrub rendering path. resolved.dimTarget = resolved.dimTarget ?? SCRUB_DEFAULTS.dimTarget; resolved.dimOpacity = Math.max( 0, Math.min(1, resolved.dimOpacity ?? SCRUB_DEFAULTS.dimOpacity), ); resolved.dimFadeMs = Math.max( 0, resolved.dimFadeMs ?? SCRUB_DEFAULTS.dimFadeMs, ); resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot); resolved.crosshairFadeDistance = Math.max( 0, resolved.crosshairFadeDistance, ); } return resolved; } export interface ResolvedLoadingConfig { /** undefined → palette.gridLine */ color: string | undefined; /** undefined → the chart's line strokeWidth */ strokeWidth: number | undefined; /** Base breathing-wave amplitude (px). */ amplitude: number; /** Breathing-wave speed multiplier. */ speed: number; /** Draw the skeleton Y-axis label placeholders. */ axisLabels: boolean; } const LOADING_DEFAULTS: ResolvedLoadingConfig = { color: undefined, strokeWidth: undefined, amplitude: LOADING_WAVE_AMPLITUDE, speed: LOADING_WAVE_SPEED, axisLabels: true, }; /** * Resolves the `loading` prop to a fully-typed config or null (not loading). * `true` → defaults (loading on, built-in look), object → merged with defaults * (loading on, restyled), `false` / omitted → null. So a non-null result is the * "is loading" flag and carries the resolved styling. */ export function resolveLoading( prop: boolean | LoadingConfig | undefined, ): ResolvedLoadingConfig | null { return resolveToggle(prop, LOADING_DEFAULTS, false); } const SCRUB_ACTION_DEFAULTS: ResolvedScrubActionConfig = { icon: "+", background: undefined, iconColor: undefined, lineColor: undefined, text: true, timeBadge: false, snap: undefined, dismissOnTapOutside: false, dismissOnAction: false, }; /** * Resolves `scrubAction` prop to a fully-typed config or null (disabled). * Opt-in: `false`/`undefined` → null; `true` → defaults; object → merged. */ export function resolveScrubAction( prop: boolean | ScrubActionConfig | undefined, ): ResolvedScrubActionConfig | null { return resolveToggle(prop, SCRUB_ACTION_DEFAULTS, false); } /** Spacing + collapse-threshold defaults for `markerCluster: "stacked"`. */ const MARKER_CLUSTER_GAP = 2; const MARKER_CLUSTER_MAX_BEFORE_GROUP = 5; const MARKER_CLUSTER_OVERLAP = 0.75; /** * Resolves the `markerCluster` prop (a `"anchored"`/`"stacked"` shorthand or a * {@link MarkerClusterConfig} object) to a full config. Always returns a config * (never null) — `"anchored"` (the default) is a valid mode the cluster pass * treats as "side offsets only, no bucketing". The object form implies * `"stacked"` unless `mode` is set. */ export function resolveMarkerCluster( prop: "anchored" | "stacked" | MarkerClusterConfig | undefined, ): ResolvedMarkerCluster { if (typeof prop === "object") { return { mode: prop.mode ?? "stacked", direction: prop.direction ?? "horizontal", overlap: clamp01(prop.overlap ?? MARKER_CLUSTER_OVERLAP), gap: MARKER_CLUSTER_GAP, maxBeforeGroup: prop.maxBeforeGroup ?? MARKER_CLUSTER_MAX_BEFORE_GROUP, maxVisible: prop.maxVisible ?? Number.MAX_SAFE_INTEGER, groupBadge: prop.groupBadge ?? "count", showGroupCount: prop.showGroupCount ?? false, }; } return { mode: prop === "stacked" ? "stacked" : "anchored", direction: "horizontal", overlap: MARKER_CLUSTER_OVERLAP, gap: MARKER_CLUSTER_GAP, maxBeforeGroup: MARKER_CLUSTER_MAX_BEFORE_GROUP, maxVisible: Number.MAX_SAFE_INTEGER, groupBadge: "count", showGroupCount: false, }; } /** Clamp to [0, 0.95] so a glyph never fully covers its neighbor (overlap 1). */ function clamp01(v: number): number { return v < 0 ? 0 : v > 0.95 ? 0.95 : v; } const GRADIENT_DEFAULTS: ResolvedGradientConfig = { topOpacity: undefined, bottomOpacity: undefined, colors: undefined, positions: undefined, }; /** * Resolves `gradient` prop to a fully-typed config or null (disabled). * `true` → defaults (use palette colors), object → merged with defaults, falsy → null. */ export function resolveGradient( prop: boolean | GradientConfig | undefined, ): ResolvedGradientConfig | null { return resolveToggle(prop, GRADIENT_DEFAULTS, false); } const AREA_DOTS_DEFAULTS: ResolvedAreaDotsConfig = { spacing: 12, size: 1.6, color: undefined, opacity: 1, }; /** * Resolves `areaDots` prop to a fully-typed config or null (disabled). * `true` → defaults (palette-derived color), object → merged with defaults, * falsy/omitted → null. Default OFF (unlike `gradient`). */ export function resolveAreaDots( prop: boolean | AreaDotsConfig | undefined, ): ResolvedAreaDotsConfig | null { return resolveToggle(prop, AREA_DOTS_DEFAULTS, false); } /** Fallback when no theme background is passed (e.g. unit tests). */ const LEFT_EDGE_FADE_COLOR_FALLBACK: Pick< ResolvedLeftEdgeFadeConfig, "startColor" | "endColor" > = { startColor: "rgba(0, 0, 0, 1)", endColor: "rgba(0, 0, 0, 0)", }; /** * Resolves `leftEdgeFade` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. * * Pass `colorDefaults` from `leftEdgeFadeColorsFromBgRgb(palette.bgRgb)` so default * stops match the chart background; omit for black alpha-gradient fallback. */ export function resolveLeftEdgeFade( prop: boolean | LeftEdgeFadeConfig | undefined, colorDefaults: { startColor: string; endColor: string; } = LEFT_EDGE_FADE_COLOR_FALLBACK, ): ResolvedLeftEdgeFadeConfig | null { if (!prop) return null; const base: ResolvedLeftEdgeFadeConfig = { width: FADE_EDGE_WIDTH, ...colorDefaults, }; if (prop === true) return base; return { ...base, ...prop }; } const PULSE_DEFAULTS: ResolvedPulseConfig = { interval: 1500, duration: 900, maxRadius: 21, opacity: 0.35, strokeWidth: 1.5, }; /** * Resolves `pulse` prop to a fully-typed config or null (disabled). * `true` → defaults, object → merged with defaults, falsy → null. */ export function resolvePulse( prop: boolean | PulseConfig | undefined, ): ResolvedPulseConfig | null { return resolveToggle(prop, PULSE_DEFAULTS, false); } const REFERENCE_LINE_VISUAL_DEFAULTS = { strokeWidth: 1, intervals: [4, 4] as [number, number], color: undefined as string | undefined, }; /** * Resolves the `font` prop into a fully-typed config ready for `matchFont`. * `defaultFamily` is the platform-specific fallback resolved by the caller. */ export function resolveFontConfig( config: FontConfig | undefined, defaultFamily: string, defaultSize: number, ): ResolvedFontConfig { return { fontFamily: config?.fontFamily ?? defaultFamily, fontSize: config?.fontSize ?? defaultSize, fontWeight: config?.fontWeight ?? "500", }; } /** * Extracts the visual rendering config from a `ReferenceLine` prop. * Returns null when no reference line is set. */ export function resolveReferenceLineConfig( rl: ReferenceLine | undefined, ): ResolvedReferenceLineConfig | null { if (!rl) return null; return { strokeWidth: rl.strokeWidth ?? REFERENCE_LINE_VISUAL_DEFAULTS.strokeWidth, intervals: rl.intervals ?? REFERENCE_LINE_VISUAL_DEFAULTS.intervals, color: rl.color, }; } const GRID_STYLE_DEFAULTS: ResolvedGridStyleConfig = { color: undefined, strokeWidth: 1, intervals: [], opacity: 1, }; /** * Resolves the `gridStyle` prop into a fully-typed config. Always returns a * config (the grid always needs concrete defaults); omitted fields fall back to * the legacy solid 1px grid line. */ export function resolveGridStyle( prop: GridStyleConfig | undefined, ): ResolvedGridStyleConfig { if (!prop) return GRID_STYLE_DEFAULTS; return { color: prop.color, strokeWidth: prop.strokeWidth ?? GRID_STYLE_DEFAULTS.strokeWidth, intervals: prop.intervals ?? GRID_STYLE_DEFAULTS.intervals, opacity: prop.opacity ?? GRID_STYLE_DEFAULTS.opacity, }; } const DEGEN_SHAKE_DURATION_DEFAULT_SEC = 0.45; const DEGEN_PARTICLE_SLOT_DEFAULT = 60; const DEGEN_PARTICLE_BURST_DURATION_DEFAULT_SEC = 1.0; const DEGEN_BURST_PARTICLE_DEFAULT = 20; function clampDegenParticleSlotCount(n: number): number { return Math.max(4, Math.min(80, Math.round(n))); } function clampDegenParticleBurstDurationSec(n: number): number { return Math.max(0.05, Math.min(5, n)); } function clampDegenBurstParticleCount(n: number, slotCount: number): number { return Math.max(1, Math.min(slotCount, Math.round(n))); } const DEGEN_DEFAULTS: ResolvedDegenConfig = { scale: 1, downMomentum: false, shake: true, shakeIntensity: 1, shakeDurationSec: DEGEN_SHAKE_DURATION_DEFAULT_SEC, particleSlotCount: DEGEN_PARTICLE_SLOT_DEFAULT, particleBurstDurationSec: DEGEN_PARTICLE_BURST_DURATION_DEFAULT_SEC, burstParticleCount: DEGEN_BURST_PARTICLE_DEFAULT, drag: 0.95, particleSizeMin: 1, particleSizeMax: 2.2, particleOpacity: 0.55, spreadAngle: Math.PI * 1.2, positionJitterX: 24, positionJitterY: 8, speedMin: 60, speedMax: 160, colors: null, }; function resolveDegenColors( input: string | string[] | undefined, ): string[] | null { if (!input) return null; if (typeof input === "string") return [input]; return input.length > 0 ? input : null; } /** * Resolves `degen` prop to a fully-typed config or null (disabled). */ export function resolveDegen( prop: boolean | DegenOptions | undefined, ): ResolvedDegenConfig | null { if (!prop) return null; if (prop === true) return DEGEN_DEFAULTS; const slots = clampDegenParticleSlotCount( prop.particleSlotCount ?? DEGEN_DEFAULTS.particleSlotCount, ); return { scale: prop.scale ?? DEGEN_DEFAULTS.scale, downMomentum: prop.downMomentum ?? DEGEN_DEFAULTS.downMomentum, shake: prop.shake ?? DEGEN_DEFAULTS.shake, shakeIntensity: prop.shakeIntensity ?? DEGEN_DEFAULTS.shakeIntensity, shakeDurationSec: prop.shakeDurationSec ?? DEGEN_DEFAULTS.shakeDurationSec, particleSlotCount: slots, particleBurstDurationSec: clampDegenParticleBurstDurationSec( prop.particleBurstDurationSec ?? DEGEN_DEFAULTS.particleBurstDurationSec, ), burstParticleCount: clampDegenBurstParticleCount( prop.burstParticleCount ?? DEGEN_DEFAULTS.burstParticleCount, slots, ), drag: Math.max(0, Math.min(1, prop.drag ?? DEGEN_DEFAULTS.drag)), particleSizeMin: Math.max( 0.1, prop.particleSizeMin ?? DEGEN_DEFAULTS.particleSizeMin, ), particleSizeMax: Math.max( 0.1, prop.particleSizeMax ?? DEGEN_DEFAULTS.particleSizeMax, ), particleOpacity: Math.max( 0, Math.min(1, prop.particleOpacity ?? DEGEN_DEFAULTS.particleOpacity), ), spreadAngle: prop.spreadAngle ?? DEGEN_DEFAULTS.spreadAngle, positionJitterX: Math.max( 0, prop.positionJitterX ?? DEGEN_DEFAULTS.positionJitterX, ), positionJitterY: Math.max( 0, prop.positionJitterY ?? DEGEN_DEFAULTS.positionJitterY, ), speedMin: Math.max(0, prop.speedMin ?? DEGEN_DEFAULTS.speedMin), speedMax: Math.max(0, prop.speedMax ?? DEGEN_DEFAULTS.speedMax), colors: resolveDegenColors(prop.colors), }; } const TRADE_STREAM_DEFAULTS: ResolvedTradeStreamConfig = { maxCount: 50, labelOffsetX: 8, }; /** * Whether trade stream markers should run, and cap for mapped trades per frame. * Extend `options` when display options are added to props. */ export function resolveTradeStream( stream: SharedValue | undefined, options?: boolean | { maxCount?: number; labelOffsetX?: number }, ): ResolvedTradeStreamConfig | null { if (stream === undefined) return null; if (options === false) return null; if (options === undefined || options === true) return TRADE_STREAM_DEFAULTS; return { maxCount: options.maxCount ?? TRADE_STREAM_DEFAULTS.maxCount, labelOffsetX: options.labelOffsetX ?? TRADE_STREAM_DEFAULTS.labelOffsetX, }; } // ─── Dot (shared) ───────────────────────────────────────────────────────────── /** Resolved static dot glow. `color: undefined` means "use the dot color". */ export interface ResolvedDotGlowConfig { color: string | undefined; radius: number; blur: number; opacity: number; } const DOT_GLOW_DEFAULTS: ResolvedDotGlowConfig = { color: undefined, radius: 7, blur: 5, opacity: 0.18, }; /** `undefined`/`false` → off; `true` → restrained defaults; object → merged. */ export function resolveDotGlow( prop: boolean | DotGlowConfig | undefined, ): ResolvedDotGlowConfig | null { if (!prop) return null; const config = prop === true ? DOT_GLOW_DEFAULTS : prop; return { color: config.color, radius: Math.max(0, config.radius ?? DOT_GLOW_DEFAULTS.radius), blur: Math.max(0, config.blur ?? DOT_GLOW_DEFAULTS.blur), opacity: Math.max( 0, Math.min(1, config.opacity ?? DOT_GLOW_DEFAULTS.opacity), ), }; } /** Resolved backing ring. `color: undefined` means "use the theme `badgeOuterBg`". */ export interface ResolvedDotRingConfig { color: string | undefined; width: number; } const RING_DEFAULTS: ResolvedDotRingConfig = { color: undefined, width: 2.5, }; /** `undefined`/`true` → haloed defaults; `false` → null (flat circle). */ export function resolveDotRing( prop: boolean | DotRingConfig | undefined, ): ResolvedDotRingConfig | null { return resolveToggle(prop, RING_DEFAULTS, true); } /** Shared, fully-resolved dot styling (single- and multi-series). */ export interface ResolvedDotConfig { radius: number; ring: ResolvedDotRingConfig | null; glow: ResolvedDotGlowConfig | null; show: boolean; color: string | undefined; trackWhileParked: boolean; } const DOT_DEFAULTS: ResolvedDotConfig = { radius: 3.5, ring: RING_DEFAULTS, glow: null, show: true, color: undefined, trackWhileParked: false, }; /** * Resolves the `dot` prop. Always returns a config (the live dot's geometry is * read unconditionally); visibility rides on `show`. * - `false` → defaults with `show: false` (the canonical "hide the dot") * - `undefined`/`true` → shown defaults * - object → merged with defaults (honoring an explicit `show`) */ export function resolveDot( prop: boolean | DotConfig | undefined, ): ResolvedDotConfig { if (prop === false) return { ...DOT_DEFAULTS, show: false }; if (prop == null || prop === true) return DOT_DEFAULTS; return { radius: prop.radius ?? DOT_DEFAULTS.radius, ring: resolveDotRing(prop.ring), glow: resolveDotGlow(prop.glow), show: prop.show ?? DOT_DEFAULTS.show, color: prop.color, trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked, }; } // ─── Multi-series dot ───────────────────────────────────────────────────────── export interface ResolvedMultiSeriesDotConfig extends ResolvedDotConfig { pulse: ResolvedPulseConfig | null; valueLine: ResolvedValueLineConfig | null; valueLabel: boolean; } export function resolveMultiSeriesDot( prop: boolean | MultiSeriesDotConfig | undefined, ): ResolvedMultiSeriesDotConfig { const cfg = typeof prop === "object" && prop !== null ? prop : undefined; return { ...resolveDot(prop), pulse: resolvePulse(cfg?.pulse ?? true), valueLine: resolveValueLine(cfg?.valueLine), valueLabel: cfg?.valueLabel ?? true, }; } // ─── Selection dot ────────────────────────────────────────────────────────── /** Resolved selection-dot ring. `color: undefined` → use the dot color. */ export interface ResolvedSelectionDotRingConfig { color: string | undefined; width: number; } const SELECTION_DOT_RING_DEFAULTS: ResolvedSelectionDotRingConfig = { color: undefined, width: 2, }; /** `undefined`/`true` → ring defaults; `false` → null (no ring). */ export function resolveSelectionDotRing( prop: boolean | SelectionDotRingConfig | undefined, ): ResolvedSelectionDotRingConfig | null { return resolveToggle(prop, SELECTION_DOT_RING_DEFAULTS, true); } /** Fully-resolved selection-dot styling. */ export interface ResolvedSelectionDotConfig { size: number; /** undefined → use the line / leading-series color at render time. */ color: string | undefined; ring: ResolvedSelectionDotRingConfig | null; /** When set, the built-in size/color/ring knobs are ignored. */ component?: ComponentType; } const SELECTION_DOT_SIZE_DEFAULT = 4; const SELECTION_DOT_DEFAULTS: ResolvedSelectionDotConfig = { size: SELECTION_DOT_SIZE_DEFAULT, color: undefined, ring: SELECTION_DOT_RING_DEFAULTS, }; /** * Resolves the `selectionDot` prop to a fully-typed config or null (hidden). * Defaults to ON, so `undefined`/`true` yield the built-in dot. * - `false` → `null` (no dot) * - `undefined`/`true` → built-in dot with defaults * - object → configured built-in dot, or the custom `component` when set * (the size/color/ring knobs are still resolved but ignored by the slot) */ export function resolveSelectionDot( prop: boolean | SelectionDotConfig | undefined, ): ResolvedSelectionDotConfig | null { if (prop === false) return null; if (prop == null || prop === true) return SELECTION_DOT_DEFAULTS; return { size: prop.size ?? SELECTION_DOT_DEFAULTS.size, color: prop.color, ring: resolveSelectionDotRing(prop.ring), component: prop.component, }; } // ─── Legend ─────────────────────────────────────────────────────────────────── export interface ResolvedLegendConfig { visible: boolean; compact: boolean; position: "top" | "bottom"; /** Raw style overrides; the chip row applies its own fallbacks. */ style: LegendStyle | undefined; } const LEGEND_DEFAULTS: ResolvedLegendConfig = { visible: true, compact: false, position: "top", style: undefined, }; export function resolveLegend( prop: boolean | LegendConfig | undefined, ): ResolvedLegendConfig { if (prop === false) return { ...LEGEND_DEFAULTS, visible: false }; if (prop === undefined || prop === true) { return { ...LEGEND_DEFAULTS, compact: false }; } return { visible: prop.visible ?? LEGEND_DEFAULTS.visible, compact: prop.compact ?? LEGEND_DEFAULTS.compact, position: prop.position ?? LEGEND_DEFAULTS.position, style: prop.style, }; } // ─── Metrics (sizing & motion tokens) ───────────────────────────────────────── /** Canonical resolved metrics — every namespace/field present. */ const METRICS_DEFAULTS: LiveChartMetrics = { badge: BADGE_METRICS_DEFAULTS, candle: CANDLE_METRICS_DEFAULTS, grid: GRID_METRICS_DEFAULTS, motion: MOTION_METRICS_DEFAULTS, emptyState: EMPTY_STATE_METRICS_DEFAULTS, }; /** * Resolve the `metrics` prop into a fully-typed config. Per-namespace shallow * merge over the defaults — only the keys the caller sets are replaced, the same * model as `applyPaletteOverride`. Returns the shared defaults object when no * override is supplied (treated read-only by consumers). */ export function resolveMetrics( prop: LiveChartMetricsOverride | undefined, ): LiveChartMetrics { if (!prop) return METRICS_DEFAULTS; return { badge: { ...METRICS_DEFAULTS.badge, ...prop.badge }, candle: { ...METRICS_DEFAULTS.candle, ...prop.candle }, grid: { ...METRICS_DEFAULTS.grid, ...prop.grid }, motion: { ...METRICS_DEFAULTS.motion, ...prop.motion }, emptyState: { ...METRICS_DEFAULTS.emptyState, ...prop.emptyState }, }; }