diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt
index e883a51..a0a2d72 100644
--- a/android/src/main/java/voltra/generated/ShortNames.kt
+++ b/android/src/main/java/voltra/generated/ShortNames.kt
@@ -67,6 +67,7 @@ object ShortNames {
"fvar" to "fontVariant",
"fw" to "fontWeight",
"fgs" to "foregroundStyle",
+ "fss" to "foregroundStyleScale",
"f" to "frame",
"g" to "gap",
"gs" to "gaugeStyle",
@@ -83,6 +84,7 @@ object ShortNames {
"ly" to "layout",
"lp" to "layoutPriority",
"l" to "left",
+ "lgv" to "legendVisibility",
"ls" to "letterSpacing",
"lh" to "lineHeight",
"ll" to "lineLimit",
@@ -95,6 +97,7 @@ object ShortNames {
"mr" to "marginRight",
"mt" to "marginTop",
"mv" to "marginVertical",
+ "mrk" to "marks",
"me" to "maskElement",
"maxh" to "maxHeight",
"max" to "maximumValue",
@@ -163,6 +166,10 @@ object ShortNames {
"valig" to "verticalAlignment",
"wt" to "weight",
"w" to "width",
+ "xgs" to "xAxisGridStyle",
+ "xav" to "xAxisVisibility",
+ "ygs" to "yAxisGridStyle",
+ "yav" to "yAxisVisibility",
"zi" to "zIndex",
)
diff --git a/data/components.json b/data/components.json
index 23f2723..2901442 100644
--- a/data/components.json
+++ b/data/components.json
@@ -149,7 +149,14 @@
"scaleEffect": "sce",
"rotationEffect": "re",
"border": "bd",
- "clipped": "clip"
+ "clipped": "clip",
+ "foregroundStyleScale": "fss",
+ "legendVisibility": "lgv",
+ "marks": "mrk",
+ "xAxisGridStyle": "xgs",
+ "xAxisVisibility": "xav",
+ "yAxisGridStyle": "ygs",
+ "yAxisVisibility": "yav"
},
"styleProperties": [
"padding",
@@ -1238,6 +1245,56 @@
"swiftAvailability": "iOS 16.0, macOS 13.0",
"hasChildren": true,
"parameters": {}
+ },
+ {
+ "name": "Chart",
+ "description": "SwiftUI Charts component for data visualization",
+ "swiftAvailability": "iOS 16.0, macOS 13.0",
+ "hasChildren": true,
+ "parameters": {
+ "marks": {
+ "type": "array",
+ "optional": true,
+ "jsonEncoded": true,
+ "description": "Compact mark data encoded from children by toJSON"
+ },
+ "xAxisVisibility": {
+ "type": "string",
+ "optional": true,
+ "enum": ["automatic", "visible", "hidden"],
+ "description": "Show or hide the x-axis"
+ },
+ "xAxisGridStyle": {
+ "type": "object",
+ "optional": true,
+ "jsonEncoded": true,
+ "description": "Configure x-axis grid line style"
+ },
+ "yAxisVisibility": {
+ "type": "string",
+ "optional": true,
+ "enum": ["automatic", "visible", "hidden"],
+ "description": "Show or hide the y-axis"
+ },
+ "yAxisGridStyle": {
+ "type": "object",
+ "optional": true,
+ "jsonEncoded": true,
+ "description": "Configure y-axis grid line style"
+ },
+ "legendVisibility": {
+ "type": "string",
+ "optional": true,
+ "enum": ["automatic", "visible", "hidden"],
+ "description": "Show or hide the chart legend"
+ },
+ "foregroundStyleScale": {
+ "type": "object",
+ "optional": true,
+ "jsonEncoded": true,
+ "description": "Map of series name to color string"
+ }
+ }
}
]
}
diff --git a/example/app/testing-grounds/chart-playground.tsx b/example/app/testing-grounds/chart-playground.tsx
new file mode 100644
index 0000000..03d633a
--- /dev/null
+++ b/example/app/testing-grounds/chart-playground.tsx
@@ -0,0 +1,5 @@
+import ChartPlaygroundScreen from '~/screens/testing-grounds/chart-playground/ChartPlaygroundScreen'
+
+export default function ChartPlaygroundIndex() {
+ return
+}
diff --git a/example/screens/testing-grounds/TestingGroundsScreen.tsx b/example/screens/testing-grounds/TestingGroundsScreen.tsx
index 9e9ecba..d8a8a05 100644
--- a/example/screens/testing-grounds/TestingGroundsScreen.tsx
+++ b/example/screens/testing-grounds/TestingGroundsScreen.tsx
@@ -55,6 +55,13 @@ const TESTING_GROUNDS_SECTIONS = [
'Interactive playground for experimenting with flex layout properties. Test alignItems, justifyContent, flexDirection, spacing, and padding with live visual feedback.',
route: '/testing-grounds/flex-playground',
},
+ {
+ id: 'chart-playground',
+ title: 'Chart Playground',
+ description:
+ 'Explore all SwiftUI chart mark types: BarMark, LineMark, AreaMark, PointMark, RuleMark, and SectorMark. Randomize data to see animated transitions.',
+ route: '/testing-grounds/chart-playground',
+ },
{
id: 'image-preloading',
title: 'Image Preloading',
diff --git a/example/screens/testing-grounds/chart-playground/ChartPlaygroundScreen.tsx b/example/screens/testing-grounds/chart-playground/ChartPlaygroundScreen.tsx
new file mode 100644
index 0000000..73274b5
--- /dev/null
+++ b/example/screens/testing-grounds/chart-playground/ChartPlaygroundScreen.tsx
@@ -0,0 +1,339 @@
+import { Link } from 'expo-router'
+import React, { useCallback, useState } from 'react'
+import { ScrollView, StyleSheet, Text, View } from 'react-native'
+import { Voltra } from 'voltra'
+import { VoltraView } from 'voltra/client'
+
+import { Button } from '~/components/Button'
+import { Card } from '~/components/Card'
+
+// ─── data helpers ───────────────────────────────────────────────────────────
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
+
+const randomValue = (min: number, max: number) => Math.round(Math.random() * (max - min) + min)
+
+const randomBarData = () => MONTHS.map((m) => ({ x: m, y: randomValue(20, 120) }))
+
+const randomMultiSeriesData = () => [
+ ...MONTHS.map((m) => ({ x: m, y: randomValue(20, 100), series: 'A' })),
+ ...MONTHS.map((m) => ({ x: m, y: randomValue(20, 100), series: 'B' })),
+]
+
+const randomLineData = () => MONTHS.map((m) => ({ x: m, y: randomValue(30, 100) }))
+
+const randomAreaData = () => MONTHS.map((m) => ({ x: m, y: randomValue(10, 90) }))
+
+const randomPointData = () => Array.from({ length: 12 }, (_, i) => ({ x: randomValue(0, 100), y: randomValue(0, 100) }))
+
+const randomSectorData = () => {
+ const raw = [
+ { category: 'Work', value: randomValue(20, 50) },
+ { category: 'Sleep', value: randomValue(20, 40) },
+ { category: 'Leisure', value: randomValue(10, 30) },
+ { category: 'Exercise', value: randomValue(5, 20) },
+ ]
+ return raw
+}
+
+const randomRuleY = () => randomValue(30, 80)
+const randomRuleX = () => MONTHS[randomValue(0, MONTHS.length - 1)] ?? MONTHS[0]
+const randomPointRuleY = () => randomValue(0, 100)
+const randomPointRuleX = () => randomValue(0, 100)
+
+// ─── chart preview wrapper ───────────────────────────────────────────────────
+
+function ChartPreview({ children }: { children: React.ReactNode }) {
+ return {children}
+}
+
+// ─── screen ─────────────────────────────────────────────────────────────────
+
+export default function ChartPlaygroundScreen() {
+ const [barData, setBarData] = useState(randomBarData)
+ const [multiData, setMultiData] = useState(randomMultiSeriesData)
+ const [lineData, setLineData] = useState(randomLineData)
+ const [areaData, setAreaData] = useState(randomAreaData)
+ const [pointData, setPointData] = useState(randomPointData)
+ const [pointRuleY, setPointRuleY] = useState(randomPointRuleY)
+ const [pointRuleX, setPointRuleX] = useState(randomPointRuleX)
+ const [sectorData, setSectorData] = useState(randomSectorData)
+ const [ruleY, setRuleY] = useState(randomRuleY)
+ const [ruleX, setRuleX] = useState(randomRuleX)
+ const [comboBarData, setComboBarData] = useState(randomBarData)
+ const [comboLineData, setComboLineData] = useState(randomLineData)
+
+ const randomizeAll = useCallback(() => {
+ setBarData(randomBarData())
+ setMultiData(randomMultiSeriesData())
+ setLineData(randomLineData())
+ setAreaData(randomAreaData())
+ setPointData(randomPointData())
+ setPointRuleY(randomPointRuleY())
+ setPointRuleX(randomPointRuleX())
+ setSectorData(randomSectorData())
+ setRuleY(randomRuleY())
+ setRuleX(randomRuleX())
+ setComboBarData(randomBarData())
+ setComboLineData(randomLineData())
+ }, [])
+
+ return (
+
+
+ Chart Playground
+
+ All SwiftUI chart mark types powered by Voltra. Tap Randomize to animate between data sets.
+
+
+
+
+
+
+ {/* BarMark */}
+
+ BarMark
+ Single series bar chart with rounded corners.
+
+
+
+
+
+
+
+
+
+ {/* BarMark multi-series */}
+
+ BarMark — Multi-series
+
+ Two series (A & B) rendered as grouped bars using the supported `stacking` grouped mode.
+
+
+
+
+
+
+
+
+
+
+ {/* LineMark */}
+
+ LineMark
+ Smooth monotone line chart.
+
+
+
+
+
+
+
+
+
+ {/* AreaMark */}
+
+ AreaMark
+ Filled area chart — the classic stocks-app look.
+
+
+
+
+
+
+
+
+
+ {/* PointMark */}
+
+ PointMark
+
+ Scatter plot with numeric x and y axes plus both vertical and horizontal reference lines.
+
+
+
+
+
+
+
+
+
+
+
+ {/* RuleMark */}
+
+ RuleMark
+
+ Bar chart with both horizontal and vertical reference lines. When both `xValue` and `yValue` are set, both
+ lines render.
+
+
+
+
+
+
+
+
+
+
+
+ {/* SectorMark — pie */}
+
+ SectorMark — Pie
+ Pie chart built with SectorMark (iOS 17+).
+
+
+
+
+
+
+
+
+
+ {/* SectorMark — donut */}
+
+ SectorMark — Donut
+ Same data as above but with an inner radius to create a donut chart.
+
+
+
+
+
+
+
+
+
+ {/* Combo: Bar + Line */}
+
+ Combo — Bar + Line
+ Multiple mark types composited in one chart.
+
+
+
+
+
+
+
+
+
+
+ {/* Axis visibility */}
+
+ Hidden Axes
+ Chart with both axes hidden — clean minimal look.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingVertical: 24,
+ },
+ heading: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ backgroundColor: '#0F172A',
+ marginBottom: 8,
+ },
+ subheading: {
+ fontSize: 14,
+ lineHeight: 20,
+ color: '#CBD5F5',
+ marginBottom: 16,
+ },
+ randomizeRow: {
+ marginBottom: 8,
+ },
+ refreshRow: {
+ marginTop: 8,
+ alignItems: 'flex-end',
+ },
+ footer: {
+ marginTop: 24,
+ alignItems: 'center',
+ },
+})
diff --git a/ios/shared/ComponentTypeID.swift b/ios/shared/ComponentTypeID.swift
index 4a3353c..7582243 100644
--- a/ios/shared/ComponentTypeID.swift
+++ b/ios/shared/ComponentTypeID.swift
@@ -31,6 +31,7 @@ public enum ComponentTypeID: Int, Codable {
case MASK = 18
case LINK = 19
case VIEW = 20
+ case CHART = 21
/// Get the component name string for this ID
public var componentName: String {
@@ -77,6 +78,8 @@ public enum ComponentTypeID: Int, Codable {
return "Link"
case .VIEW:
return "View"
+ case .CHART:
+ return "Chart"
}
}
@@ -106,6 +109,7 @@ public enum ComponentTypeID: Int, Codable {
case "Mask": self = .MASK
case "Link": self = .LINK
case "View": self = .VIEW
+ case "Chart": self = .CHART
default:
return nil
}
diff --git a/ios/shared/ShortNames.swift b/ios/shared/ShortNames.swift
index b07bb57..4effc01 100644
--- a/ios/shared/ShortNames.swift
+++ b/ios/shared/ShortNames.swift
@@ -64,6 +64,7 @@ public enum ShortNames {
"fvar": "fontVariant",
"fw": "fontWeight",
"fgs": "foregroundStyle",
+ "fss": "foregroundStyleScale",
"f": "frame",
"g": "gap",
"gs": "gaugeStyle",
@@ -80,6 +81,7 @@ public enum ShortNames {
"ly": "layout",
"lp": "layoutPriority",
"l": "left",
+ "lgv": "legendVisibility",
"ls": "letterSpacing",
"lh": "lineHeight",
"ll": "lineLimit",
@@ -92,6 +94,7 @@ public enum ShortNames {
"mr": "marginRight",
"mt": "marginTop",
"mv": "marginVertical",
+ "mrk": "marks",
"me": "maskElement",
"maxh": "maxHeight",
"max": "maximumValue",
@@ -160,6 +163,10 @@ public enum ShortNames {
"valig": "verticalAlignment",
"wt": "weight",
"w": "width",
+ "xgs": "xAxisGridStyle",
+ "xav": "xAxisVisibility",
+ "ygs": "yAxisGridStyle",
+ "yav": "yAxisVisibility",
"zi": "zIndex",
]
diff --git a/ios/shared/VoltraNode.swift b/ios/shared/VoltraNode.swift
index ae330b3..93d5fa1 100644
--- a/ios/shared/VoltraNode.swift
+++ b/ios/shared/VoltraNode.swift
@@ -182,6 +182,13 @@ struct VoltraElementView: View {
case "Mask":
VoltraMask(element)
+ case "Chart":
+ if #available(iOS 16.0, macOS 13.0, *) {
+ VoltraChart(element)
+ } else {
+ EmptyView()
+ }
+
default:
EmptyView()
}
diff --git a/ios/ui/Generated/Parameters/ChartParameters.swift b/ios/ui/Generated/Parameters/ChartParameters.swift
new file mode 100644
index 0000000..1320a7c
--- /dev/null
+++ b/ios/ui/Generated/Parameters/ChartParameters.swift
@@ -0,0 +1,33 @@
+//
+// ChartParameters.swift
+
+// AUTO-GENERATED from data/components.json
+// DO NOT EDIT MANUALLY - Changes will be overwritten
+// Schema version: 1.0.0
+
+import Foundation
+
+/// Parameters for Chart component
+/// SwiftUI Charts component for data visualization
+public struct ChartParameters: ComponentParameters {
+ /// Compact mark data encoded from children by toJSON
+ public let marks: String?
+
+ /// Show or hide the x-axis
+ public let xAxisVisibility: String?
+
+ /// Configure x-axis grid line style
+ public let xAxisGridStyle: String?
+
+ /// Show or hide the y-axis
+ public let yAxisVisibility: String?
+
+ /// Configure y-axis grid line style
+ public let yAxisGridStyle: String?
+
+ /// Show or hide the chart legend
+ public let legendVisibility: String?
+
+ /// Map of series name to color string
+ public let foregroundStyleScale: String?
+}
diff --git a/ios/ui/Views/VoltraChart.swift b/ios/ui/Views/VoltraChart.swift
new file mode 100644
index 0000000..c5a8614
--- /dev/null
+++ b/ios/ui/Views/VoltraChart.swift
@@ -0,0 +1,808 @@
+import Charts
+import SwiftUI
+
+@available(iOS 16.0, macOS 13.0, *)
+public struct VoltraChart: VoltraView {
+ public typealias Parameters = ChartParameters
+
+ public let element: VoltraElement
+
+ public init(_ element: VoltraElement) {
+ self.element = element
+ }
+
+ // MARK: - Wire format
+
+ private struct WireMark {
+ let type: String
+ let data: [[JSONValue]]?
+ let props: [String: JSONValue]
+ }
+
+ private func parseMarks(from json: String) -> [WireMark] {
+ guard
+ let data = json.data(using: .utf8),
+ let outer = try? JSONSerialization.jsonObject(with: data) as? [[Any]]
+ else { return [] }
+
+ return outer.compactMap { row -> WireMark? in
+ guard row.count >= 3,
+ let type = row[0] as? String,
+ let rawProps = row[2] as? [String: Any]
+ else { return nil }
+
+ let pts: [[JSONValue]]? = (row[1] as? [[Any]])?.map { $0.compactMap { jsonValue(from: $0) } }
+ let props = rawProps.compactMapValues { jsonValue(from: $0) }
+ return WireMark(type: type, data: pts, props: props)
+ }
+ }
+
+ // MARK: - Helpers
+
+ /// Extract a Double from a JSONValue that may be .int or .double
+ private func num(_ v: JSONValue) -> Double? {
+ switch v {
+ case let .double(d): return d
+ case let .int(i): return Double(i)
+ default: return nil
+ }
+ }
+
+ private func bool(_ v: JSONValue) -> Bool? {
+ if case let .bool(value) = v { return value }
+ return nil
+ }
+
+ private func parseAxisGridStyle(from raw: String?) -> AxisGridStyle? {
+ guard let raw,
+ let data = raw.data(using: .utf8),
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return nil }
+
+ let props = dict.compactMapValues { jsonValue(from: $0) }
+ if props.isEmpty { return nil }
+
+ let visible = props["v"].flatMap(bool) ?? props["visible"].flatMap(bool)
+ let color = (props["c"] ?? props["color"]).flatMap {
+ if case let .string(value) = $0 { return JSColorParser.parse(value) }
+ return nil
+ }
+ let lineWidth = (props["lw"] ?? props["lineWidth"]).flatMap(num).map { CGFloat($0) }
+ let dash = ((props["d"] ?? props["dash"]).flatMap {
+ if case let .array(values) = $0 { return values.compactMap(num).map { CGFloat($0) } }
+ return nil
+ })
+
+ return AxisGridStyle(visible: visible, color: color, lineWidth: lineWidth, dash: dash)
+ }
+
+ private func wireColor(_ props: [String: JSONValue]) -> Color? {
+ guard case let .string(s)? = props["c"] else { return nil }
+ return JSColorParser.parse(s)
+ }
+
+ private func symbolShape(_ props: [String: JSONValue]) -> BasicChartSymbolShape? {
+ guard case let .string(s)? = props["sym"] else { return nil }
+ switch s {
+ case "circle": return .circle
+ case "square": return .square
+ case "triangle": return .triangle
+ case "diamond": return .diamond
+ case "pentagon": return .pentagon
+ case "cross": return .cross
+ case "plus": return .plus
+ case "asterisk": return .asterisk
+ default: return nil
+ }
+ }
+
+ private func interpolation(_ props: [String: JSONValue]) -> InterpolationMethod {
+ guard case let .string(s)? = props["itp"] else { return .linear }
+ switch s {
+ case "monotone": return .monotone
+ case "stepStart": return .stepStart
+ case "stepEnd": return .stepEnd
+ case "stepCenter": return .stepCenter
+ case "cardinal": return .cardinal
+ case "catmullRom": return .catmullRom
+ default: return .linear
+ }
+ }
+
+ private func visibility(_ raw: String?) -> Visibility {
+ switch raw {
+ case "visible": return .visible
+ case "hidden": return .hidden
+ default: return .automatic
+ }
+ }
+
+ private func parseLegendItems(from raw: String?) -> [ChartLegendItem] {
+ guard let raw,
+ let data = raw.data(using: .utf8),
+ let pairs = try? JSONSerialization.jsonObject(with: data) as? [[String]],
+ !pairs.isEmpty
+ else { return [] }
+
+ return pairs.compactMap { p -> ChartLegendItem? in
+ guard p.count >= 2, let color = JSColorParser.parse(p[1]) else { return nil }
+ return ChartLegendItem(label: p[0], swatch: color)
+ }
+ }
+
+ /// Unpack [x, y] or [x, y, series] tuples into typed values.
+ private func xy(_ pt: [JSONValue]) -> (xStr: String?, xNum: Double?, y: Double, series: String?) {
+ guard pt.count >= 2 else { return (nil, nil, 0, nil) }
+ let y: Double = {
+ switch pt[1] {
+ case let .double(v): return v
+ case let .int(v): return Double(v)
+ default: return 0
+ }
+ }()
+ let series: String? = pt.count >= 3 ? { if case let .string(s) = pt[2] { return s }; return nil }() : nil
+ switch pt[0] {
+ case let .string(x): return (x, nil, y, series)
+ case let .double(x): return (nil, x, y, series)
+ case let .int(x): return (nil, Double(x), y, series)
+ default: return (nil, nil, y, series)
+ }
+ }
+
+ // MARK: - body
+
+ public var body: some View {
+ let wireMarks = params.marks.flatMap { parseMarks(from: $0) } ?? []
+ let xAxisVis = visibility(params.xAxisVisibility)
+ let yAxisVis = visibility(params.yAxisVisibility)
+ let xAxisGridStyle = parseAxisGridStyle(from: params.xAxisGridStyle)
+ let yAxisGridStyle = parseAxisGridStyle(from: params.yAxisGridStyle)
+ let legendVis = visibility(params.legendVisibility)
+ let legendItems = parseLegendItems(from: params.foregroundStyleScale)
+
+ Chart {
+ buildAll(wireMarks)
+ }
+ .applyForegroundStyleScale(params.foregroundStyleScale)
+ .applyChartStyle(
+ element.style,
+ xAxisVisibility: xAxisVis,
+ yAxisVisibility: yAxisVis,
+ xAxisGridStyle: xAxisGridStyle,
+ yAxisGridStyle: yAxisGridStyle,
+ legendVisibility: legendVis,
+ legendItems: legendItems
+ )
+ }
+
+ // MARK: - ChartContent
+
+ @ChartContentBuilder
+ private func buildAll(_ marks: [WireMark]) -> some ChartContent {
+ ForEach(Array(marks.enumerated()), id: \.offset) { _, m in
+ buildMark(m)
+ }
+ }
+
+ @ChartContentBuilder
+ private func buildMark(_ m: WireMark) -> some ChartContent {
+ if m.type == "bar" { barContent(m) }
+ if m.type == "line" { lineContent(m) }
+ if m.type == "area" { areaContent(m) }
+ if m.type == "point" { pointContent(m) }
+ if m.type == "rule" { ruleContent(m) }
+ if #available(iOS 17.0, macOS 14.0, *), m.type == "sector" { sectorContent(m) }
+ }
+
+ // MARK: Bar
+
+ @ChartContentBuilder
+ private func barContent(_ m: WireMark) -> some ChartContent {
+ ForEach(Array((m.data ?? []).enumerated()), id: \.offset) { _, pt in
+ barPoint(pt, props: m.props)
+ }
+ }
+
+ @ChartContentBuilder
+ private func barPoint(_ pt: [JSONValue], props: [String: JSONValue]) -> some ChartContent {
+ let (xStr, xNum, y, series) = xy(pt)
+ let cr: CGFloat? = props["cr"].flatMap(num).map { CGFloat($0) }
+ let barWidth: MarkDimension = {
+ if let v = props["w"].flatMap(num) { return .fixed(CGFloat(v)) }
+ return .automatic
+ }()
+ let grouped: Bool = { if case let .string(s)? = props["stk"] { return s == "grouped" }; return false }()
+
+ if let x = xStr {
+ if let series {
+ if let cr {
+ if grouped {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr)
+ .foregroundStyle(by: .value("series", series))
+ .position(by: .value("series", series))
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr)
+ .foregroundStyle(by: .value("series", series))
+ }
+ } else {
+ if grouped {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth)
+ .foregroundStyle(by: .value("series", series))
+ .position(by: .value("series", series))
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth)
+ .foregroundStyle(by: .value("series", series))
+ }
+ }
+ } else if let c = wireColor(props) {
+ if let cr {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr).foregroundStyle(c)
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).foregroundStyle(c)
+ }
+ } else {
+ if let cr {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr)
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth)
+ }
+ }
+ } else if let x = xNum {
+ if let series {
+ if let cr {
+ if grouped {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr)
+ .foregroundStyle(by: .value("series", series))
+ .position(by: .value("series", series))
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr)
+ .foregroundStyle(by: .value("series", series))
+ }
+ } else {
+ if grouped {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth)
+ .foregroundStyle(by: .value("series", series))
+ .position(by: .value("series", series))
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth)
+ .foregroundStyle(by: .value("series", series))
+ }
+ }
+ } else if let c = wireColor(props) {
+ if let cr {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr).foregroundStyle(c)
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).foregroundStyle(c)
+ }
+ } else {
+ if let cr {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth).cornerRadius(cr)
+ } else {
+ Charts.BarMark(x: .value("x", x), y: .value("y", y), width: barWidth)
+ }
+ }
+ }
+ }
+
+ // MARK: Line
+
+ @ChartContentBuilder
+ private func lineContent(_ m: WireMark) -> some ChartContent {
+ ForEach(Array((m.data ?? []).enumerated()), id: \.offset) { _, pt in
+ linePoint(pt, props: m.props)
+ }
+ }
+
+ @ChartContentBuilder
+ private func linePoint(_ pt: [JSONValue], props: [String: JSONValue]) -> some ChartContent {
+ let (xStr, xNum, y, series) = xy(pt)
+ let itp = interpolation(props)
+ let lw: CGFloat? = props["lw"].flatMap(num).map { CGFloat($0) }
+ let stroke: StrokeStyle? = lw.map { StrokeStyle(lineWidth: $0) }
+ let sym = symbolShape(props)
+
+ if let x = xStr {
+ if let series {
+ if let stroke, let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).symbol(sym).foregroundStyle(by: .value("series", series))
+ } else if let stroke {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).foregroundStyle(by: .value("series", series))
+ } else if let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).symbol(sym).foregroundStyle(by: .value("series", series))
+ } else {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).foregroundStyle(by: .value("series", series))
+ }
+ } else if let c = wireColor(props) {
+ if let stroke, let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).symbol(sym).foregroundStyle(c)
+ } else if let stroke {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).foregroundStyle(c)
+ } else if let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).symbol(sym).foregroundStyle(c)
+ } else {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).foregroundStyle(c)
+ }
+ } else {
+ if let stroke, let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).symbol(sym)
+ } else if let stroke {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke)
+ } else if let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).symbol(sym)
+ } else {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp)
+ }
+ }
+ } else if let x = xNum {
+ if let series {
+ if let stroke, let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).symbol(sym).foregroundStyle(by: .value("series", series))
+ } else if let stroke {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).foregroundStyle(by: .value("series", series))
+ } else if let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).symbol(sym).foregroundStyle(by: .value("series", series))
+ } else {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).foregroundStyle(by: .value("series", series))
+ }
+ } else if let c = wireColor(props) {
+ if let stroke, let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).symbol(sym).foregroundStyle(c)
+ } else if let stroke {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).foregroundStyle(c)
+ } else if let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).symbol(sym).foregroundStyle(c)
+ } else {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).foregroundStyle(c)
+ }
+ } else {
+ if let stroke, let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke).symbol(sym)
+ } else if let stroke {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).lineStyle(stroke)
+ } else if let sym {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp).symbol(sym)
+ } else {
+ Charts.LineMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp)
+ }
+ }
+ }
+ }
+
+ // MARK: Area
+
+ @ChartContentBuilder
+ private func areaContent(_ m: WireMark) -> some ChartContent {
+ ForEach(Array((m.data ?? []).enumerated()), id: \.offset) { _, pt in
+ areaPoint(pt, props: m.props)
+ }
+ }
+
+ @ChartContentBuilder
+ private func areaPoint(_ pt: [JSONValue], props: [String: JSONValue]) -> some ChartContent {
+ let (xStr, xNum, y, series) = xy(pt)
+ let itp = interpolation(props)
+
+ if let x = xStr {
+ if let series {
+ Charts.AreaMark(x: .value("x", x), y: .value("y", y))
+ .interpolationMethod(itp).foregroundStyle(by: .value("series", series))
+ } else if let c = wireColor(props) {
+ Charts.AreaMark(x: .value("x", x), y: .value("y", y))
+ .interpolationMethod(itp).foregroundStyle(c)
+ } else {
+ Charts.AreaMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp)
+ }
+ } else if let x = xNum {
+ if let series {
+ Charts.AreaMark(x: .value("x", x), y: .value("y", y))
+ .interpolationMethod(itp).foregroundStyle(by: .value("series", series))
+ } else if let c = wireColor(props) {
+ Charts.AreaMark(x: .value("x", x), y: .value("y", y))
+ .interpolationMethod(itp).foregroundStyle(c)
+ } else {
+ Charts.AreaMark(x: .value("x", x), y: .value("y", y)).interpolationMethod(itp)
+ }
+ }
+ }
+
+ // MARK: Point
+
+ @ChartContentBuilder
+ private func pointContent(_ m: WireMark) -> some ChartContent {
+ ForEach(Array((m.data ?? []).enumerated()), id: \.offset) { _, pt in
+ pointPoint(pt, props: m.props)
+ }
+ }
+
+ @ChartContentBuilder
+ private func pointPoint(_ pt: [JSONValue], props: [String: JSONValue]) -> some ChartContent {
+ let (xStr, xNum, y, series) = xy(pt)
+ let sym = symbolShape(props)
+ let symSize: CGFloat? = props["syms"].flatMap(num).map { CGFloat($0) }
+
+ if let x = xStr {
+ if let series {
+ if let sym, let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).symbolSize(symSize).foregroundStyle(by: .value("series", series))
+ } else if let sym {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).foregroundStyle(by: .value("series", series))
+ } else if let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbolSize(symSize).foregroundStyle(by: .value("series", series))
+ } else {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).foregroundStyle(by: .value("series", series))
+ }
+ } else if let c = wireColor(props) {
+ if let sym, let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).symbolSize(symSize).foregroundStyle(c)
+ } else if let sym {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).foregroundStyle(c)
+ } else if let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbolSize(symSize).foregroundStyle(c)
+ } else {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).foregroundStyle(c)
+ }
+ } else {
+ if let sym, let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).symbolSize(symSize)
+ } else if let sym {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym)
+ } else if let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbolSize(symSize)
+ } else {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y))
+ }
+ }
+ } else if let x = xNum {
+ if let series {
+ if let sym, let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).symbolSize(symSize).foregroundStyle(by: .value("series", series))
+ } else if let sym {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).foregroundStyle(by: .value("series", series))
+ } else if let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbolSize(symSize).foregroundStyle(by: .value("series", series))
+ } else {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).foregroundStyle(by: .value("series", series))
+ }
+ } else if let c = wireColor(props) {
+ if let sym, let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).symbolSize(symSize).foregroundStyle(c)
+ } else if let sym {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).foregroundStyle(c)
+ } else if let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbolSize(symSize).foregroundStyle(c)
+ } else {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).foregroundStyle(c)
+ }
+ } else {
+ if let sym, let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym).symbolSize(symSize)
+ } else if let sym {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbol(sym)
+ } else if let symSize {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y)).symbolSize(symSize)
+ } else {
+ Charts.PointMark(x: .value("x", x), y: .value("y", y))
+ }
+ }
+ }
+ }
+
+ // MARK: Rule
+
+ @ChartContentBuilder
+ private func ruleContent(_ m: WireMark) -> some ChartContent {
+ let props = m.props
+ let lw: CGFloat? = props["lw"].flatMap(num).map { CGFloat($0) }
+ let stroke: StrokeStyle? = lw.map { StrokeStyle(lineWidth: $0) }
+ let c = wireColor(props)
+
+ if let yv = props["yv"].flatMap(num) {
+ if let c {
+ if let stroke {
+ Charts.RuleMark(y: .value("y", yv)).lineStyle(stroke).foregroundStyle(c)
+ } else {
+ Charts.RuleMark(y: .value("y", yv)).foregroundStyle(c)
+ }
+ } else if let stroke {
+ Charts.RuleMark(y: .value("y", yv)).lineStyle(stroke)
+ } else {
+ Charts.RuleMark(y: .value("y", yv))
+ }
+ }
+
+ if case let .string(xv)? = props["xv"] {
+ if let c {
+ if let stroke {
+ Charts.RuleMark(x: .value("x", xv)).lineStyle(stroke).foregroundStyle(c)
+ } else {
+ Charts.RuleMark(x: .value("x", xv)).foregroundStyle(c)
+ }
+ } else if let stroke {
+ Charts.RuleMark(x: .value("x", xv)).lineStyle(stroke)
+ } else {
+ Charts.RuleMark(x: .value("x", xv))
+ }
+ } else if let xv = props["xv"].flatMap(num) {
+ if let c {
+ if let stroke {
+ Charts.RuleMark(x: .value("x", xv)).lineStyle(stroke).foregroundStyle(c)
+ } else {
+ Charts.RuleMark(x: .value("x", xv)).foregroundStyle(c)
+ }
+ } else if let stroke {
+ Charts.RuleMark(x: .value("x", xv)).lineStyle(stroke)
+ } else {
+ Charts.RuleMark(x: .value("x", xv))
+ }
+ }
+ }
+
+ // MARK: Sector (iOS 17+)
+
+ @available(iOS 17.0, macOS 14.0, *)
+ @ChartContentBuilder
+ private func sectorContent(_ m: WireMark) -> some ChartContent {
+ ForEach(Array((m.data ?? []).enumerated()), id: \.offset) { _, pt in
+ sectorPoint(pt, props: m.props)
+ }
+ }
+
+ @available(iOS 17.0, macOS 14.0, *)
+ @ChartContentBuilder
+ private func sectorPoint(_ pt: [JSONValue], props: [String: JSONValue]) -> some ChartContent {
+ if pt.count >= 2,
+ let value = num(pt[0]),
+ case let .string(category) = pt[1]
+ {
+ let inner: MarkDimension = {
+ if let v = props["ir"].flatMap(num) { return .ratio(CGFloat(v)) }
+ return .automatic
+ }()
+ let outer: MarkDimension = {
+ if let v = props["or"].flatMap(num) { return .ratio(CGFloat(v)) }
+ return .automatic
+ }()
+ let inset: CGFloat = {
+ if let v = props["agin"].flatMap(num) { return CGFloat(v) }
+ return 0
+ }()
+
+ if let c = wireColor(props) {
+ Charts.SectorMark(
+ angle: .value("value", value),
+ innerRadius: inner, outerRadius: outer, angularInset: inset
+ )
+ .foregroundStyle(c)
+ } else {
+ Charts.SectorMark(
+ angle: .value("value", value),
+ innerRadius: inner, outerRadius: outer, angularInset: inset
+ )
+ .foregroundStyle(by: .value("category", category))
+ }
+ }
+ }
+}
+
+// MARK: - Any → JSONValue bridge
+
+//
+// JSONSerialization.data(withJSONObject:) throws an ObjC NSException (not a Swift error)
+// when the input is a top-level primitive (NSNumber, NSString, etc.), which try? cannot
+// catch. We avoid it entirely by directly pattern-matching the already-parsed Any values.
+
+private func jsonValue(from value: Any) -> JSONValue? {
+ switch value {
+ case is NSNull:
+ return .null
+ case let n as NSNumber:
+ // Distinguish Bool from numeric types (NSNumber encodes both)
+ if CFGetTypeID(n) == CFBooleanGetTypeID() {
+ return .bool(n.boolValue)
+ }
+ // Use integer when the value has no fractional part
+ let d = n.doubleValue
+ if d == d.rounded(), !d.isInfinite, let i = Int(exactly: d) {
+ return .int(i)
+ }
+ return .double(d)
+ case let s as String:
+ return .string(s)
+ case let arr as [Any]:
+ return .array(arr.compactMap { jsonValue(from: $0) })
+ case let dict as [String: Any]:
+ return .object(dict.compactMapValues { jsonValue(from: $0) })
+ default:
+ return nil
+ }
+}
+
+// MARK: - View helpers for chart-level modifiers
+
+@available(iOS 16.0, macOS 13.0, *)
+private struct ChartLegendItem: Identifiable {
+ let label: String
+ let swatch: Color
+ var id: String { label }
+}
+
+@available(iOS 16.0, macOS 13.0, *)
+private struct AxisGridStyle {
+ let visible: Bool?
+ let color: Color?
+ let lineWidth: CGFloat?
+ let dash: [CGFloat]?
+}
+
+@available(iOS 16.0, macOS 13.0, *)
+private extension View {
+ @ViewBuilder
+ func applyChartStyle(
+ _ optionalStyle: [String: JSONValue]?,
+ xAxisVisibility: Visibility,
+ yAxisVisibility: Visibility,
+ xAxisGridStyle: AxisGridStyle?,
+ yAxisGridStyle: AxisGridStyle?,
+ legendVisibility: Visibility,
+ legendItems: [ChartLegendItem]
+ ) -> some View {
+ if let optionalStyle {
+ let anyStyle = optionalStyle.mapValues { $0.toAny() }
+ let (layout, decoration, rendering, _) = StyleConverter.convert(anyStyle)
+ let styled = modifier(CompositeStyleModifier(
+ layout: layout,
+ decoration: decoration,
+ rendering: rendering,
+ contentAlignment: .topLeading
+ ))
+ if let color = JSColorParser.parse(anyStyle["color"]) {
+ styled
+ .applyChartXAxis(visibility: xAxisVisibility, labelColor: color, gridStyle: xAxisGridStyle)
+ .applyChartYAxis(visibility: yAxisVisibility, labelColor: color, gridStyle: yAxisGridStyle)
+ .applyChartLegend(visibility: legendVisibility, labelColor: color, items: legendItems)
+ .applyYAxisLeadingInset(visibility: yAxisVisibility)
+ .foregroundStyle(color)
+ .foregroundColor(color)
+ .tint(color)
+ } else {
+ styled
+ .applyChartXAxis(visibility: xAxisVisibility, labelColor: nil, gridStyle: xAxisGridStyle)
+ .applyChartYAxis(visibility: yAxisVisibility, labelColor: nil, gridStyle: yAxisGridStyle)
+ .applyChartLegend(visibility: legendVisibility, labelColor: nil, items: legendItems)
+ .applyYAxisLeadingInset(visibility: yAxisVisibility)
+ }
+ } else {
+ applyChartXAxis(visibility: xAxisVisibility, labelColor: nil, gridStyle: xAxisGridStyle)
+ .applyChartYAxis(visibility: yAxisVisibility, labelColor: nil, gridStyle: yAxisGridStyle)
+ .applyChartLegend(visibility: legendVisibility, labelColor: nil, items: legendItems)
+ .applyYAxisLeadingInset(visibility: yAxisVisibility)
+ }
+ }
+
+ @ViewBuilder
+ func applyYAxisLeadingInset(visibility: Visibility) -> some View {
+ if visibility == .hidden {
+ self
+ } else {
+ padding(.leading, 10)
+ }
+ }
+
+ @ViewBuilder
+ func applyChartLegend(visibility: Visibility, labelColor: Color?, items: [ChartLegendItem]) -> some View {
+ if visibility == .hidden {
+ chartLegend(.hidden)
+ } else {
+ chartLegend(position: .automatic, alignment: .center, spacing: 8) {
+ HStack(spacing: 12) {
+ ForEach(items) { item in
+ HStack(spacing: 6) {
+ Circle()
+ .fill(item.swatch)
+ .frame(width: 8, height: 8)
+ Text(item.label)
+ .foregroundStyle(labelColor ?? .primary)
+ }
+ }
+ }
+ }
+ .chartLegend(visibility)
+ }
+ }
+
+ func axisGridStrokeStyle(_ gridStyle: AxisGridStyle?) -> StrokeStyle? {
+ guard let gridStyle else { return nil }
+ guard gridStyle.lineWidth != nil || (gridStyle.dash?.isEmpty == false) else { return nil }
+ return StrokeStyle(lineWidth: gridStyle.lineWidth ?? 1, dash: gridStyle.dash ?? [])
+ }
+
+ @ViewBuilder
+ func applyChartXAxis(visibility: Visibility, labelColor: Color?, gridStyle: AxisGridStyle?) -> some View {
+ if visibility == .hidden {
+ chartXAxis(.hidden)
+ } else if gridStyle == nil, labelColor == nil {
+ chartXAxis(visibility)
+ } else {
+ chartXAxis {
+ AxisMarks {
+ if gridStyle?.visible != false {
+ if let stroke = axisGridStrokeStyle(gridStyle) {
+ if let color = gridStyle?.color {
+ AxisGridLine(stroke: stroke).foregroundStyle(color)
+ } else {
+ AxisGridLine(stroke: stroke)
+ }
+ } else if let color = gridStyle?.color {
+ AxisGridLine().foregroundStyle(color)
+ } else {
+ AxisGridLine()
+ }
+ }
+ AxisTick()
+ if let labelColor {
+ AxisValueLabel()
+ .foregroundStyle(labelColor)
+ } else {
+ AxisValueLabel()
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ func applyChartYAxis(visibility: Visibility, labelColor: Color?, gridStyle: AxisGridStyle?) -> some View {
+ if visibility == .hidden {
+ chartYAxis(.hidden)
+ } else if visibility != .visible, gridStyle == nil, labelColor == nil {
+ chartYAxis(visibility)
+ } else {
+ chartYAxis {
+ AxisMarks(position: .leading) {
+ if gridStyle?.visible != false {
+ if let stroke = axisGridStrokeStyle(gridStyle) {
+ if let color = gridStyle?.color {
+ AxisGridLine(stroke: stroke).foregroundStyle(color)
+ } else {
+ AxisGridLine(stroke: stroke)
+ }
+ } else if let color = gridStyle?.color {
+ AxisGridLine().foregroundStyle(color)
+ } else {
+ AxisGridLine()
+ }
+ }
+ AxisTick()
+ if let labelColor {
+ AxisValueLabel()
+ .foregroundStyle(labelColor)
+ } else {
+ AxisValueLabel()
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ func applyForegroundStyleScale(_ raw: String?) -> some View {
+ if let raw,
+ let data = raw.data(using: .utf8),
+ let pairs = try? JSONSerialization.jsonObject(with: data) as? [[String]],
+ !pairs.isEmpty
+ {
+ let domain = pairs.compactMap(\.first)
+ let range: [Color] = pairs.compactMap { p -> Color? in
+ guard p.count >= 2 else { return nil }
+ return JSColorParser.parse(p[1])
+ }
+ if domain.count == range.count {
+ chartForegroundStyleScale(domain: domain, range: range)
+ } else {
+ self
+ }
+ } else {
+ self
+ }
+ }
+}
diff --git a/plugin/src/ios-widget/files/infoPlist.ts b/plugin/src/ios-widget/files/infoPlist.ts
index 154db51..578faf9 100644
--- a/plugin/src/ios-widget/files/infoPlist.ts
+++ b/plugin/src/ios-widget/files/infoPlist.ts
@@ -10,7 +10,7 @@ import { logger } from '../../utils/logger'
* Version keys are written directly to the plist following the Expo pattern.
*/
function generateInfoPlistContent(targetName: string, version: string, buildNumber: string): string {
- return `
+ return `
@@ -51,7 +51,7 @@ function generateInfoPlistContent(targetName: string, version: string, buildNumb
* @param buildNumber - The build number (CFBundleVersion)
*/
export function generateInfoPlist(targetPath: string, targetName: string, version: string, buildNumber: string): void {
- const infoPlistPath = path.join(targetPath, 'Info.plist')
- fs.writeFileSync(infoPlistPath, generateInfoPlistContent(targetName, version, buildNumber))
- logger.info('Generated Info.plist')
+ const infoPlistPath = path.join(targetPath, 'Info.plist')
+ fs.writeFileSync(infoPlistPath, generateInfoPlistContent(targetName, version, buildNumber))
+ logger.info('Generated Info.plist')
}
diff --git a/plugin/src/ios-widget/index.ts b/plugin/src/ios-widget/index.ts
index 2ab953d..ed9c182 100644
--- a/plugin/src/ios-widget/index.ts
+++ b/plugin/src/ios-widget/index.ts
@@ -36,7 +36,8 @@ export interface WithIOSProps {
* so fonts must be registered before configureXcodeProject.
*/
export const withIOS: ConfigPlugin = (config, props) => {
- const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier, fonts, version, buildNumber } = props
+ const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier, fonts, version, buildNumber } =
+ props
const plugins: [ConfigPlugin, any][] = [
// 1. Add custom fonts if provided
diff --git a/src/jsx/AreaMark.tsx b/src/jsx/AreaMark.tsx
new file mode 100644
index 0000000..5835026
--- /dev/null
+++ b/src/jsx/AreaMark.tsx
@@ -0,0 +1,14 @@
+import { createElement } from 'react'
+
+import { VOLTRA_MARK_TAG } from './BarMark.js'
+import type { ChartDataPoint } from './chart-types.js'
+
+export type AreaMarkProps = {
+ data: ChartDataPoint[]
+ color?: string
+ interpolation?: 'linear' | 'monotone' | 'stepStart' | 'stepEnd' | 'stepCenter' | 'cardinal' | 'catmullRom'
+}
+
+export const AreaMark = (props: AreaMarkProps) => createElement('VoltraAreaMark', props as any)
+AreaMark.displayName = 'AreaMark'
+AreaMark[VOLTRA_MARK_TAG] = 'area' as const
diff --git a/src/jsx/BarMark.tsx b/src/jsx/BarMark.tsx
new file mode 100644
index 0000000..a35e4f3
--- /dev/null
+++ b/src/jsx/BarMark.tsx
@@ -0,0 +1,17 @@
+import { createElement } from 'react'
+
+import type { ChartDataPoint } from './chart-types.js'
+
+export const VOLTRA_MARK_TAG = Symbol.for('VOLTRA_MARK_TAG')
+
+export type BarMarkProps = {
+ data: ChartDataPoint[]
+ color?: string
+ stacking?: 'grouped'
+ cornerRadius?: number
+ width?: number
+}
+
+export const BarMark = (props: BarMarkProps) => createElement('VoltraBarMark', props as any)
+BarMark.displayName = 'BarMark'
+BarMark[VOLTRA_MARK_TAG] = 'bar' as const
diff --git a/src/jsx/Chart.tsx b/src/jsx/Chart.tsx
new file mode 100644
index 0000000..1cbec64
--- /dev/null
+++ b/src/jsx/Chart.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+
+import type { AreaMarkProps } from './AreaMark.js'
+import type { BarMarkProps } from './BarMark.js'
+import { VOLTRA_MARK_TAG } from './BarMark.js'
+import type { ChartDataPoint, SectorDataPoint } from './chart-types.js'
+import { createVoltraComponent } from './createVoltraComponent.js'
+import type { LineMarkProps } from './LineMark.js'
+import type { PointMarkProps } from './PointMark.js'
+import type { ChartProps as GeneratedChartProps } from './props/Chart.js'
+import type { RuleMarkProps } from './RuleMark.js'
+import type { SectorMarkProps } from './SectorMark.js'
+
+// ---- user-facing prop types ----
+
+type AxisGridStyle = {
+ visible?: boolean
+ color?: string
+ lineWidth?: number
+ dash?: number[]
+}
+
+export type ChartProps = Omit<
+ GeneratedChartProps,
+ 'marks' | 'foregroundStyleScale' | 'xAxisGridStyle' | 'yAxisGridStyle' | 'style'
+> & {
+ children?: React.ReactNode
+ foregroundStyleScale?: Record
+ xAxisGridStyle?: AxisGridStyle
+ yAxisGridStyle?: AxisGridStyle
+ style?: GeneratedChartProps['style'] & { color?: string }
+}
+
+// ---- wire encoding helpers ----
+
+type CompactDataPoint = [string | number, number] | [string | number, number, string]
+type CompactSectorPoint = [number, string]
+type MarkWire = [string, CompactDataPoint[] | CompactSectorPoint[] | null, Record]
+
+const encodeDataPoints = (data: ChartDataPoint[]): CompactDataPoint[] =>
+ data.map((pt) => (pt.series != null ? [pt.x, pt.y, pt.series] : [pt.x, pt.y]))
+
+const encodeSectorPoints = (data: SectorDataPoint[]): CompactSectorPoint[] => data.map((pt) => [pt.value, pt.category])
+
+const encodeAxisGridStyle = (style: AxisGridStyle): Record => {
+ const encoded: Record = {}
+ if (style.visible != null) encoded.v = style.visible
+ if (style.color != null) encoded.c = style.color
+ if (style.lineWidth != null) encoded.lw = style.lineWidth
+ if (style.dash != null) encoded.d = style.dash
+ return encoded
+}
+
+const encodeBarMark = (props: BarMarkProps): MarkWire => {
+ const p: Record = {}
+ if (props.color != null) p.c = props.color
+ if (props.stacking === 'grouped') p.stk = props.stacking
+ if (props.cornerRadius != null) p.cr = props.cornerRadius
+ if (props.width != null) p.w = props.width
+ return ['bar', encodeDataPoints(props.data), p]
+}
+
+const encodeLineMark = (props: LineMarkProps): MarkWire => {
+ const p: Record = {}
+ if (props.color != null) p.c = props.color
+ if (props.interpolation != null) p.itp = props.interpolation
+ if (props.lineWidth != null) p.lw = props.lineWidth
+ if (props.symbol != null) p.sym = props.symbol
+ return ['line', encodeDataPoints(props.data), p]
+}
+
+const encodeAreaMark = (props: AreaMarkProps): MarkWire => {
+ const p: Record = {}
+ if (props.color != null) p.c = props.color
+ if (props.interpolation != null) p.itp = props.interpolation
+ return ['area', encodeDataPoints(props.data), p]
+}
+
+const encodePointMark = (props: PointMarkProps): MarkWire => {
+ const p: Record = {}
+ if (props.color != null) p.c = props.color
+ if (props.symbol != null) p.sym = props.symbol
+ if (props.symbolSize != null) p.syms = props.symbolSize
+ return ['point', encodeDataPoints(props.data), p]
+}
+
+const encodeRuleMark = (props: RuleMarkProps): MarkWire => {
+ const p: Record = {}
+ if (props.color != null) p.c = props.color
+ if (props.lineWidth != null) p.lw = props.lineWidth
+ if (props.xValue != null) p.xv = props.xValue
+ if (props.yValue != null) p.yv = props.yValue
+ return ['rule', null, p]
+}
+
+const encodeSectorMark = (props: SectorMarkProps): MarkWire => {
+ const p: Record = {}
+ if (props.color != null) p.c = props.color
+ if (props.innerRadius != null) p.ir = props.innerRadius
+ if (props.outerRadius != null) p.or = props.outerRadius
+ if (props.angularInset != null) p.agin = props.angularInset
+ return ['sector', encodeSectorPoints(props.data), p]
+}
+
+const ENCODERS: Record MarkWire> = {
+ bar: encodeBarMark,
+ line: encodeLineMark,
+ area: encodeAreaMark,
+ point: encodePointMark,
+ rule: encodeRuleMark,
+ sector: encodeSectorMark,
+}
+
+// ---- component ----
+
+export const Chart = createVoltraComponent('Chart', {
+ toJSON: (props) => {
+ const {
+ children,
+ foregroundStyleScale,
+ xAxisGridStyle,
+ yAxisGridStyle,
+ chartScrollableAxes: _chartScrollableAxes,
+ ...rest
+ } = props as ChartProps & { chartScrollableAxes?: unknown }
+
+ // Flatten mark children into a compact JSON string
+ const marks: MarkWire[] = []
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) return
+ const type = child.type as any
+ const markType: string | undefined = type[VOLTRA_MARK_TAG]
+ if (!markType) return
+ const encoder = ENCODERS[markType]
+ if (encoder) marks.push(encoder(child.props as any))
+ })
+
+ const result: Record = { ...rest }
+ if (marks.length > 0) result.marks = JSON.stringify(marks)
+ if (foregroundStyleScale != null) {
+ result.foregroundStyleScale = JSON.stringify(Object.entries(foregroundStyleScale))
+ }
+ if (xAxisGridStyle != null) {
+ result.xAxisGridStyle = JSON.stringify(encodeAxisGridStyle(xAxisGridStyle))
+ }
+ if (yAxisGridStyle != null) {
+ result.yAxisGridStyle = JSON.stringify(encodeAxisGridStyle(yAxisGridStyle))
+ }
+
+ return result
+ },
+})
diff --git a/src/jsx/LineMark.tsx b/src/jsx/LineMark.tsx
new file mode 100644
index 0000000..7228a29
--- /dev/null
+++ b/src/jsx/LineMark.tsx
@@ -0,0 +1,15 @@
+import { createElement } from 'react'
+import type { ChartDataPoint } from './chart-types.js'
+import { VOLTRA_MARK_TAG } from './BarMark.js'
+
+export type LineMarkProps = {
+ data: ChartDataPoint[]
+ color?: string
+ interpolation?: 'linear' | 'monotone' | 'stepStart' | 'stepEnd' | 'stepCenter' | 'cardinal' | 'catmullRom'
+ lineWidth?: number
+ symbol?: string
+}
+
+export const LineMark = (props: LineMarkProps) => createElement('VoltraLineMark', props as any)
+LineMark.displayName = 'LineMark'
+LineMark[VOLTRA_MARK_TAG] = 'line' as const
diff --git a/src/jsx/PointMark.tsx b/src/jsx/PointMark.tsx
new file mode 100644
index 0000000..46be06a
--- /dev/null
+++ b/src/jsx/PointMark.tsx
@@ -0,0 +1,14 @@
+import { createElement } from 'react'
+import type { ChartDataPoint } from './chart-types.js'
+import { VOLTRA_MARK_TAG } from './BarMark.js'
+
+export type PointMarkProps = {
+ data: ChartDataPoint[]
+ color?: string
+ symbol?: string
+ symbolSize?: number
+}
+
+export const PointMark = (props: PointMarkProps) => createElement('VoltraPointMark', props as any)
+PointMark.displayName = 'PointMark'
+PointMark[VOLTRA_MARK_TAG] = 'point' as const
diff --git a/src/jsx/RuleMark.tsx b/src/jsx/RuleMark.tsx
new file mode 100644
index 0000000..d5631cb
--- /dev/null
+++ b/src/jsx/RuleMark.tsx
@@ -0,0 +1,13 @@
+import { createElement } from 'react'
+import { VOLTRA_MARK_TAG } from './BarMark.js'
+
+export type RuleMarkProps = {
+ xValue?: string | number
+ yValue?: number
+ color?: string
+ lineWidth?: number
+}
+
+export const RuleMark = (props: RuleMarkProps) => createElement('VoltraRuleMark', props as any)
+RuleMark.displayName = 'RuleMark'
+RuleMark[VOLTRA_MARK_TAG] = 'rule' as const
diff --git a/src/jsx/SectorMark.tsx b/src/jsx/SectorMark.tsx
new file mode 100644
index 0000000..68cc975
--- /dev/null
+++ b/src/jsx/SectorMark.tsx
@@ -0,0 +1,15 @@
+import { createElement } from 'react'
+import type { SectorDataPoint } from './chart-types.js'
+import { VOLTRA_MARK_TAG } from './BarMark.js'
+
+export type SectorMarkProps = {
+ data: SectorDataPoint[]
+ color?: string
+ innerRadius?: number
+ outerRadius?: number
+ angularInset?: number
+}
+
+export const SectorMark = (props: SectorMarkProps) => createElement('VoltraSectorMark', props as any)
+SectorMark.displayName = 'SectorMark'
+SectorMark[VOLTRA_MARK_TAG] = 'sector' as const
diff --git a/src/jsx/__tests__/Chart.node.test.tsx b/src/jsx/__tests__/Chart.node.test.tsx
new file mode 100644
index 0000000..4a97f1f
--- /dev/null
+++ b/src/jsx/__tests__/Chart.node.test.tsx
@@ -0,0 +1,82 @@
+import React from 'react'
+
+import { renderVoltraVariantToJson } from '../../renderer/renderer'
+import { AreaMark } from '../AreaMark'
+import { BarMark } from '../BarMark'
+import { Chart } from '../Chart'
+import { RuleMark } from '../RuleMark'
+
+const getChartMarks = (output: any): any[] => {
+ expect(output.p?.mrk).toBeDefined()
+ return JSON.parse(output.p.mrk)
+}
+
+describe('Chart serialization', () => {
+ test('does not serialize chartScrollableAxes', () => {
+ const output = renderVoltraVariantToJson(
+
+
+
+ )
+
+ expect(output.p?.chartScrollableAxes).toBeUndefined()
+ expect(output.p?.csa).toBeUndefined()
+ })
+
+ test('BarMark serializes grouped stacking only', () => {
+ const groupedOutput = renderVoltraVariantToJson(
+
+
+
+ )
+ const groupedMarks = getChartMarks(groupedOutput)
+ expect(groupedMarks[0][2].stk).toBe('grouped')
+
+ const defaultOutput = renderVoltraVariantToJson(
+
+
+
+ )
+ const defaultMarks = getChartMarks(defaultOutput)
+ expect(defaultMarks[0][2].stk).toBeUndefined()
+ })
+
+ test('AreaMark does not serialize stacking', () => {
+ const output = renderVoltraVariantToJson(
+
+
+
+ )
+
+ const marks = getChartMarks(output)
+ expect(marks[0][2].stk).toBeUndefined()
+ })
+
+ test('RuleMark serializes both x and y values when both are provided', () => {
+ const output = renderVoltraVariantToJson(
+
+
+
+ )
+
+ const marks = getChartMarks(output)
+ expect(marks[0][2].xv).toBe('Jan')
+ expect(marks[0][2].yv).toBe(75)
+ })
+
+ test('serializes axis grid styles', () => {
+ const output = renderVoltraVariantToJson(
+
+
+
+ )
+
+ expect(output.p?.xgs).toBeDefined()
+ expect(output.p?.ygs).toBeDefined()
+ expect(JSON.parse(output.p.xgs)).toEqual({ v: false, c: '#ff0000', lw: 2, d: [4, 2] })
+ expect(JSON.parse(output.p.ygs)).toEqual({ lw: 1.5 })
+ })
+})
diff --git a/src/jsx/chart-types.ts b/src/jsx/chart-types.ts
new file mode 100644
index 0000000..f4c48b4
--- /dev/null
+++ b/src/jsx/chart-types.ts
@@ -0,0 +1,2 @@
+export type ChartDataPoint = { x: string | number; y: number; series?: string }
+export type SectorDataPoint = { value: number; category: string }
diff --git a/src/jsx/primitives.ts b/src/jsx/primitives.ts
index d8eed6e..9a575dd 100644
--- a/src/jsx/primitives.ts
+++ b/src/jsx/primitives.ts
@@ -19,3 +19,11 @@ export * from './Toggle.js'
export * from './View.js'
export * from './VStack.js'
export * from './ZStack.js'
+export * from './Chart.js'
+export * from './BarMark.js'
+export * from './LineMark.js'
+export * from './AreaMark.js'
+export * from './PointMark.js'
+export * from './RuleMark.js'
+export * from './SectorMark.js'
+export * from './chart-types.js'
diff --git a/src/jsx/props/Chart.ts b/src/jsx/props/Chart.ts
new file mode 100644
index 0000000..4fd69cf
--- /dev/null
+++ b/src/jsx/props/Chart.ts
@@ -0,0 +1,22 @@
+// 🤖 AUTO-GENERATED from data/components.json
+// DO NOT EDIT MANUALLY - Changes will be overwritten
+// Schema version: 1.0.0
+
+import type { VoltraBaseProps } from '../baseProps'
+
+export type ChartProps = VoltraBaseProps & {
+ /** Compact mark data encoded from children by toJSON */
+ marks?: string
+ /** Show or hide the x-axis */
+ xAxisVisibility?: 'automatic' | 'visible' | 'hidden'
+ /** Configure x-axis grid line style */
+ xAxisGridStyle?: Record
+ /** Show or hide the y-axis */
+ yAxisVisibility?: 'automatic' | 'visible' | 'hidden'
+ /** Configure y-axis grid line style */
+ yAxisGridStyle?: Record
+ /** Show or hide the chart legend */
+ legendVisibility?: 'automatic' | 'visible' | 'hidden'
+ /** Map of series name to color string */
+ foregroundStyleScale?: Record
+}
diff --git a/src/payload/component-ids.ts b/src/payload/component-ids.ts
index b644832..e7e0fdf 100644
--- a/src/payload/component-ids.ts
+++ b/src/payload/component-ids.ts
@@ -29,6 +29,7 @@ export const COMPONENT_NAME_TO_ID: Record = {
Mask: 18,
Link: 19,
View: 20,
+ Chart: 21,
}
/**
@@ -56,6 +57,7 @@ export const COMPONENT_ID_TO_NAME: Record = {
18: 'Mask',
19: 'Link',
20: 'View',
+ 21: 'Chart',
}
/**
diff --git a/src/payload/short-names.ts b/src/payload/short-names.ts
index 5128a18..8c60dc9 100644
--- a/src/payload/short-names.ts
+++ b/src/payload/short-names.ts
@@ -60,6 +60,7 @@ export const NAME_TO_SHORT: Record = {
fontVariant: 'fvar',
fontWeight: 'fw',
foregroundStyle: 'fgs',
+ foregroundStyleScale: 'fss',
frame: 'f',
gap: 'g',
gaugeStyle: 'gs',
@@ -76,6 +77,7 @@ export const NAME_TO_SHORT: Record = {
layout: 'ly',
layoutPriority: 'lp',
left: 'l',
+ legendVisibility: 'lgv',
letterSpacing: 'ls',
lineHeight: 'lh',
lineLimit: 'll',
@@ -88,6 +90,7 @@ export const NAME_TO_SHORT: Record = {
marginRight: 'mr',
marginTop: 'mt',
marginVertical: 'mv',
+ marks: 'mrk',
maskElement: 'me',
maxHeight: 'maxh',
maximumValue: 'max',
@@ -156,6 +159,10 @@ export const NAME_TO_SHORT: Record = {
verticalAlignment: 'valig',
weight: 'wt',
width: 'w',
+ xAxisGridStyle: 'xgs',
+ xAxisVisibility: 'xav',
+ yAxisGridStyle: 'ygs',
+ yAxisVisibility: 'yav',
zIndex: 'zi',
}
@@ -215,6 +222,7 @@ export const SHORT_TO_NAME: Record = {
fvar: 'fontVariant',
fw: 'fontWeight',
fgs: 'foregroundStyle',
+ fss: 'foregroundStyleScale',
f: 'frame',
g: 'gap',
gs: 'gaugeStyle',
@@ -231,6 +239,7 @@ export const SHORT_TO_NAME: Record = {
ly: 'layout',
lp: 'layoutPriority',
l: 'left',
+ lgv: 'legendVisibility',
ls: 'letterSpacing',
lh: 'lineHeight',
ll: 'lineLimit',
@@ -243,6 +252,7 @@ export const SHORT_TO_NAME: Record = {
mr: 'marginRight',
mt: 'marginTop',
mv: 'marginVertical',
+ mrk: 'marks',
me: 'maskElement',
maxh: 'maxHeight',
max: 'maximumValue',
@@ -311,6 +321,10 @@ export const SHORT_TO_NAME: Record = {
valig: 'verticalAlignment',
wt: 'weight',
w: 'width',
+ xgs: 'xAxisGridStyle',
+ xav: 'xAxisVisibility',
+ ygs: 'yAxisGridStyle',
+ yav: 'yAxisVisibility',
zi: 'zIndex',
}
diff --git a/website/docs/ios/_meta.json b/website/docs/ios/_meta.json
index 1553a4b..c3a6d9f 100644
--- a/website/docs/ios/_meta.json
+++ b/website/docs/ios/_meta.json
@@ -14,6 +14,11 @@
"name": "components",
"label": "Components"
},
+ {
+ "type": "file",
+ "name": "charts",
+ "label": "Charts"
+ },
{
"type": "dir",
"name": "development",
diff --git a/website/docs/ios/charts.md b/website/docs/ios/charts.md
new file mode 100644
index 0000000..c8cfddb
--- /dev/null
+++ b/website/docs/ios/charts.md
@@ -0,0 +1,339 @@
+# Charts (iOS)
+
+Render native SwiftUI Charts in your Live Activities and Widgets. Compose charts declaratively with typed mark components — mix and match bars, lines, areas, points, rules, and sectors in a single chart.
+
+:::info
+Charts require iOS 16.0+. SectorMark (pie/donut) requires iOS 17.0+.
+:::
+
+:::warning
+Mark components (`BarMark`, `LineMark`, etc.) must be **direct children** of ``. They cannot be wrapped in custom components. For example, this will not work:
+
+```tsx
+// This won't work — marks are not direct children
+function MyMarks() {
+ return
+}
+
+
+
+
+```
+
+Instead, always place marks directly inside Chart:
+
+```tsx
+
+
+
+```
+:::
+
+## Basic Usage
+
+Wrap one or more mark components inside ``:
+
+```tsx
+
+
+
+```
+
+## Data Types
+
+All marks except `RuleMark` take a `data` prop. There are two data shapes depending on the mark type:
+
+```typescript
+// BarMark, LineMark, AreaMark, PointMark
+type ChartDataPoint = {
+ x: string | number // Categorical ("Jan") or numeric (42)
+ y: number
+ series?: string // Optional — groups data for multi-series charts
+}
+
+// SectorMark
+type SectorDataPoint = {
+ value: number // Proportional angular value
+ category: string // Sector label
+}
+```
+
+## Marks
+
+### BarMark
+
+Vertical bars.
+
+**Parameters:**
+
+- `data` (ChartDataPoint[], required): The data points.
+- `color` (string, optional): Fill color.
+- `cornerRadius` (number, optional): Rounded bar corners in points.
+- `width` (number, optional): Fixed bar width in points.
+- `stacking` (string, optional): `"grouped"` (side by side).
+
+```tsx
+
+
+
+```
+
+---
+
+### LineMark
+
+Line chart with optional curve smoothing and data point symbols.
+
+**Parameters:**
+
+- `data` (ChartDataPoint[], required): The data points.
+- `color` (string, optional): Line color.
+- `lineWidth` (number, optional): Stroke width in points.
+- `interpolation` (string, optional): Curve type — `"linear"`, `"monotone"`, `"catmullRom"`, `"cardinal"`, `"stepStart"`, `"stepCenter"`, or `"stepEnd"`.
+- `symbol` (string, optional): Symbol at each point — `"circle"`, `"square"`, `"triangle"`, `"diamond"`, or `"cross"`.
+
+```tsx
+
+
+
+```
+
+---
+
+### AreaMark
+
+Filled area chart — useful for visualizing volume or ranges.
+
+**Parameters:**
+
+- `data` (ChartDataPoint[], required): The data points.
+- `color` (string, optional): Fill color.
+- `interpolation` (string, optional): Same options as LineMark.
+
+```tsx
+
+
+
+```
+
+---
+
+### PointMark
+
+Scatter plot / data point markers. Works with both categorical (string) and numeric x values.
+
+**Parameters:**
+
+- `data` (ChartDataPoint[], required): The data points.
+- `color` (string, optional): Point color.
+- `symbol` (string, optional): Same options as LineMark.
+- `symbolSize` (number, optional): Symbol size in points.
+
+```tsx
+
+
+
+```
+
+---
+
+### RuleMark
+
+A horizontal or vertical reference line. Unlike other marks, RuleMark has no `data` array.
+
+**Parameters:**
+
+- `yValue` (number, optional): Draw a horizontal line at this y value.
+- `xValue` (string | number, optional): Draw a vertical line at this x value.
+- `color` (string, optional): Line color.
+- `lineWidth` (number, optional): Stroke width in points.
+
+If both `xValue` and `yValue` are provided, Voltra renders both lines.
+
+```tsx
+
+
+
+
+```
+
+---
+
+### SectorMark
+
+Pie and donut charts. Requires iOS 17+.
+
+**Parameters:**
+
+- `data` (SectorDataPoint[], required): Sector data with `value` and `category`.
+- `color` (string, optional): Fill color (overrides automatic coloring).
+- `innerRadius` (number, optional): Ratio from 0 to 1. `0` = pie chart, any value above `0` = donut chart.
+- `outerRadius` (number, optional): Ratio from 0 to 1.
+- `angularInset` (number, optional): Gap between sectors in degrees.
+
+```tsx
+// Pie chart
+
+
+
+
+// Donut chart
+
+
+
+```
+
+## Chart Props
+
+The `` container accepts these props in addition to the standard Voltra `style` prop:
+
+| Prop | Type | Description |
+|---|---|---|
+| `xAxisVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the x-axis |
+| `yAxisVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the y-axis |
+| `legendVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the legend |
+| `foregroundStyleScale` | `Record` | Map series names to colors |
+
+## Multi-Series Charts
+
+Add a `series` field to your data points to create grouped or stacked charts. Use `foregroundStyleScale` on the Chart to assign specific colors to each series:
+
+```tsx
+
+
+
+```
+
+Without `foregroundStyleScale`, SwiftUI assigns colors automatically.
+
+To show grouped bars (side by side), set `stacking="grouped"`:
+
+```tsx
+
+
+
+```
+
+## Combining Marks
+
+Mix different mark types in one chart. This is useful for overlaying a trend line on a bar chart, or adding a reference threshold:
+
+```tsx
+
+
+
+
+
+```
+
+## Sparkline / Minimal Style
+
+Hide axes and legend for a clean, compact visualization:
+
+```tsx
+
+
+
+```
+
+## Styling the Chart Container
+
+## Widget / Live Activity Notes
+
+In WidgetKit and Live Activity surfaces, gesture-driven chart scrolling is not applicable. Voltra charts are optimized for static rendering in those contexts.
+
+The `` component supports the full Voltra style system on its container — padding, background, borders, corner radius, shadows, and sizing all work:
+
+```tsx
+
+
+
+```