rn-native-ios-charts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/docs/demo.gif +0 -0
- package/docs/demo.mp4 +0 -0
- package/expo-module.config.json +6 -0
- package/ios/ChartConfig.swift +70 -0
- package/ios/ChartDataPoint.swift +25 -0
- package/ios/ChartGradient.swift +33 -0
- package/ios/ChartMark.swift +64 -0
- package/ios/ChartView.swift +618 -0
- package/ios/NativeIosCharts.podspec +35 -0
- package/ios/NativeIosChartsModule.swift +37 -0
- package/package.json +50 -0
- package/src/AreaChart.tsx +68 -0
- package/src/BarChart.tsx +74 -0
- package/src/Chart.tsx +50 -0
- package/src/LineChart.tsx +104 -0
- package/src/PieChart.tsx +77 -0
- package/src/RangeBarChart.tsx +76 -0
- package/src/ScatterChart.tsx +73 -0
- package/src/index.ts +43 -0
- package/src/support.ts +30 -0
- package/src/types.ts +213 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import Charts
|
|
3
|
+
import ExpoModulesCore
|
|
4
|
+
|
|
5
|
+
internal final class ChartViewProps: ObservableObject {
|
|
6
|
+
@Published var marks: [ChartMark] = []
|
|
7
|
+
@Published var xAxis: ChartAxisConfig = ChartAxisConfig()
|
|
8
|
+
@Published var yAxis: ChartAxisConfig = ChartAxisConfig()
|
|
9
|
+
@Published var legend: ChartLegendConfig = ChartLegendConfig()
|
|
10
|
+
@Published var centerLabel: ChartCenterLabel = ChartCenterLabel()
|
|
11
|
+
@Published var tooltip: ChartTooltipConfig = ChartTooltipConfig()
|
|
12
|
+
@Published var animate: Bool = true
|
|
13
|
+
|
|
14
|
+
/// Closure invoked when the user selects a point via the scrubber
|
|
15
|
+
/// (or taps a pie sector). Wired to the Expo `onSelect` event.
|
|
16
|
+
/// `nil` payload = selection cleared.
|
|
17
|
+
var onSelect: (([String: Any]?) -> Void)?
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// One ExpoView that hosts a SwiftUI Chart, configurable to render
|
|
21
|
+
/// any combination of bar / line / area / point / rectangle / rule /
|
|
22
|
+
/// sector marks. Designed to be the single native primitive every
|
|
23
|
+
/// chart type ships through.
|
|
24
|
+
///
|
|
25
|
+
/// SwiftUI Charts' unified API (`Chart {}`, `SectorMark`,
|
|
26
|
+
/// `chartBackground`) is iOS 17+. The host view installs cleanly on
|
|
27
|
+
/// iOS 15.1+ so this pod can be added to any modern Expo project,
|
|
28
|
+
/// but on iOS 16 and earlier the chart renders nothing — matching
|
|
29
|
+
/// the JS-side no-op on non-iOS platforms.
|
|
30
|
+
internal final class ChartView: ExpoView {
|
|
31
|
+
let props = ChartViewProps()
|
|
32
|
+
/// JS `onSelect` event — fired when the user picks a point via
|
|
33
|
+
/// the scrubber, taps a pie sector, or releases (clears selection).
|
|
34
|
+
let onSelect = EventDispatcher()
|
|
35
|
+
private let hostingController: UIViewController
|
|
36
|
+
|
|
37
|
+
required init(appContext: AppContext? = nil) {
|
|
38
|
+
if #available(iOS 17.0, *) {
|
|
39
|
+
self.hostingController = UIHostingController(
|
|
40
|
+
rootView: ChartHostView(props: props)
|
|
41
|
+
)
|
|
42
|
+
} else {
|
|
43
|
+
// No-op host on iOS < 17. Keeps the view tree valid without
|
|
44
|
+
// pulling in any iOS-17-only SwiftUI types.
|
|
45
|
+
self.hostingController = UIHostingController(rootView: EmptyView())
|
|
46
|
+
}
|
|
47
|
+
super.init(appContext: appContext)
|
|
48
|
+
|
|
49
|
+
// Bridge the props' Swift closure to the JS event dispatcher.
|
|
50
|
+
// SwiftUI side calls `props.onSelect?(payload)`; we forward it.
|
|
51
|
+
props.onSelect = { [weak self] payload in
|
|
52
|
+
guard let self else { return }
|
|
53
|
+
self.onSelect(payload ?? [:])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
hostingController.view.backgroundColor = .clear
|
|
57
|
+
addSubview(hostingController.view)
|
|
58
|
+
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
59
|
+
NSLayoutConstraint.activate([
|
|
60
|
+
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
|
|
61
|
+
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
62
|
+
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
63
|
+
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
64
|
+
])
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// MARK: - SwiftUI implementation
|
|
69
|
+
|
|
70
|
+
@available(iOS 17.0, *)
|
|
71
|
+
private struct ChartHostView: View {
|
|
72
|
+
@ObservedObject var props: ChartViewProps
|
|
73
|
+
|
|
74
|
+
// Native selection bindings. SwiftUI Charts snaps to nearest datum
|
|
75
|
+
// for both, so we don't have to do hit-testing ourselves.
|
|
76
|
+
// - `selectedX` fires for cartesian marks (bar / line / area /
|
|
77
|
+
// point / rectangle).
|
|
78
|
+
// - `selectedAngleY` fires for `sector` marks (pie / donut).
|
|
79
|
+
@State private var selectedX: String?
|
|
80
|
+
@State private var selectedAngleY: Double?
|
|
81
|
+
|
|
82
|
+
var body: some View {
|
|
83
|
+
Chart {
|
|
84
|
+
ForEach(Array(props.marks.enumerated()), id: \.offset) { markIndex, mark in
|
|
85
|
+
renderMark(mark, markIndex: markIndex)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
.chartLegend(legendVisibility)
|
|
89
|
+
.chartXAxis(props.xAxis.hidden ? .hidden : .automatic)
|
|
90
|
+
.chartYAxis(props.yAxis.hidden ? .hidden : .automatic)
|
|
91
|
+
.conditionalChartXScale(domain: scaleDomain(props.xAxis))
|
|
92
|
+
.conditionalChartYScale(domain: scaleDomain(props.yAxis))
|
|
93
|
+
.chartBackground { proxy in
|
|
94
|
+
centerLabelView(proxy: proxy)
|
|
95
|
+
}
|
|
96
|
+
.chartXSelection(value: $selectedX)
|
|
97
|
+
.chartAngleSelection(value: $selectedAngleY)
|
|
98
|
+
.chartOverlay { proxy in
|
|
99
|
+
tooltipOverlay(proxy: proxy)
|
|
100
|
+
}
|
|
101
|
+
.onChange(of: selectedX) { _, _ in emitSelect() }
|
|
102
|
+
.onChange(of: selectedAngleY) { _, _ in emitSelect() }
|
|
103
|
+
.animation(
|
|
104
|
+
props.animate ? .easeInOut(duration: 0.4) : nil,
|
|
105
|
+
value: marksFingerprint
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MARK: - Tooltip overlay
|
|
110
|
+
|
|
111
|
+
@ViewBuilder
|
|
112
|
+
private func tooltipOverlay(proxy: ChartProxy) -> some View {
|
|
113
|
+
if props.tooltip.enabled, let x = selectedX, let active = findActivePoint(x: x) {
|
|
114
|
+
GeometryReader { geo in
|
|
115
|
+
if let plotFrame = proxy.plotFrame {
|
|
116
|
+
let plot = geo[plotFrame]
|
|
117
|
+
// Translate data → screen coords inside the plot frame.
|
|
118
|
+
if let xRel = proxy.position(forX: x) {
|
|
119
|
+
let xAbs = xRel + plot.minX
|
|
120
|
+
// Y coordinate of the active datum. `position(forY:)` is
|
|
121
|
+
// optional because numeric Y axes can be auto-scaled.
|
|
122
|
+
let yAbs: CGFloat? = proxy.position(forY: active.y).map {
|
|
123
|
+
$0 + plot.minY
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ZStack(alignment: .topLeading) {
|
|
127
|
+
if props.tooltip.showRule {
|
|
128
|
+
Path { path in
|
|
129
|
+
path.move(to: CGPoint(x: xAbs, y: plot.minY))
|
|
130
|
+
path.addLine(to: CGPoint(x: xAbs, y: plot.maxY))
|
|
131
|
+
}
|
|
132
|
+
.stroke(
|
|
133
|
+
Color(props.tooltip.borderColor ?? UIColor.tertiaryLabel),
|
|
134
|
+
style: StrokeStyle(lineWidth: 1, dash: [3, 3])
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
if props.tooltip.showDot, let y = yAbs {
|
|
138
|
+
Circle()
|
|
139
|
+
.fill(Color(activeColor(active) ?? UIColor.label))
|
|
140
|
+
.frame(width: 10, height: 10)
|
|
141
|
+
.overlay(
|
|
142
|
+
Circle()
|
|
143
|
+
.stroke(Color(props.tooltip.backgroundColor ?? UIColor.systemBackground), lineWidth: 2)
|
|
144
|
+
)
|
|
145
|
+
.position(x: xAbs, y: y)
|
|
146
|
+
}
|
|
147
|
+
calloutView(point: active)
|
|
148
|
+
.fixedSize()
|
|
149
|
+
.modifier(CalloutPlacement(
|
|
150
|
+
xAbs: xAbs,
|
|
151
|
+
yAbs: yAbs ?? (plot.minY + 16),
|
|
152
|
+
plot: plot
|
|
153
|
+
))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@ViewBuilder
|
|
162
|
+
private func calloutView(point: ChartDataPoint) -> some View {
|
|
163
|
+
let bg = Color(props.tooltip.backgroundColor ?? UIColor.systemBackground)
|
|
164
|
+
let fg = Color(props.tooltip.textColor ?? UIColor.label)
|
|
165
|
+
let border = Color(props.tooltip.borderColor ?? UIColor.separator)
|
|
166
|
+
|
|
167
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
168
|
+
if props.tooltip.showTitle {
|
|
169
|
+
Text(point.x)
|
|
170
|
+
.font(.system(size: 11))
|
|
171
|
+
.foregroundColor(fg.opacity(0.6))
|
|
172
|
+
}
|
|
173
|
+
Text(formatValue(point.y))
|
|
174
|
+
.font(.system(size: 13, weight: .semibold))
|
|
175
|
+
.foregroundColor(fg)
|
|
176
|
+
}
|
|
177
|
+
.padding(.horizontal, 8)
|
|
178
|
+
.padding(.vertical, 6)
|
|
179
|
+
.background(
|
|
180
|
+
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
181
|
+
.fill(bg)
|
|
182
|
+
.overlay(
|
|
183
|
+
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
184
|
+
.stroke(border, lineWidth: 1)
|
|
185
|
+
)
|
|
186
|
+
.shadow(color: Color.black.opacity(0.12), radius: 8, x: 0, y: 2)
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func findActivePoint(x: String) -> ChartDataPoint? {
|
|
191
|
+
// Prefer non-rule, non-sector marks (those have meaningful Y at X).
|
|
192
|
+
for mark in props.marks where mark.type != "rule" && mark.type != "sector" {
|
|
193
|
+
if let hit = mark.data.first(where: { $0.x == x }) {
|
|
194
|
+
return hit
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Fallback: scan everything.
|
|
198
|
+
for mark in props.marks {
|
|
199
|
+
if let hit = mark.data.first(where: { $0.x == x }) {
|
|
200
|
+
return hit
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func activeColor(_ point: ChartDataPoint) -> UIColor? {
|
|
207
|
+
if let c = point.color { return c }
|
|
208
|
+
for mark in props.marks {
|
|
209
|
+
if mark.data.contains(where: { $0.x == point.x }), let mc = mark.color {
|
|
210
|
+
return mc
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return nil
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private func formatValue(_ y: Double) -> String {
|
|
217
|
+
let formatter = NumberFormatter()
|
|
218
|
+
formatter.numberStyle = .decimal
|
|
219
|
+
formatter.minimumFractionDigits = props.tooltip.valueDecimals
|
|
220
|
+
formatter.maximumFractionDigits = props.tooltip.valueDecimals
|
|
221
|
+
let num = formatter.string(from: NSNumber(value: y)) ?? String(y)
|
|
222
|
+
return "\(props.tooltip.valuePrefix)\(num)\(props.tooltip.valueSuffix)"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Dispatches the JS `onSelect` event with the currently-selected
|
|
226
|
+
/// point (or `nil` if cleared).
|
|
227
|
+
private func emitSelect() {
|
|
228
|
+
if let x = selectedX, let p = findActivePoint(x: x) {
|
|
229
|
+
props.onSelect?([
|
|
230
|
+
"x": p.x,
|
|
231
|
+
"y": p.y,
|
|
232
|
+
])
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
if let ay = selectedAngleY,
|
|
236
|
+
let p = props.marks.first(where: { $0.type == "sector" })?
|
|
237
|
+
.data.first(where: { abs($0.y - ay) < 0.0001 }) {
|
|
238
|
+
props.onSelect?([
|
|
239
|
+
"x": p.x,
|
|
240
|
+
"y": p.y,
|
|
241
|
+
])
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
props.onSelect?(nil)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// MARK: - Mark dispatch
|
|
248
|
+
|
|
249
|
+
@ChartContentBuilder
|
|
250
|
+
private func renderMark(_ mark: ChartMark, markIndex: Int) -> some ChartContent {
|
|
251
|
+
ForEach(Array(mark.data.enumerated()), id: \.offset) { idx, point in
|
|
252
|
+
buildMark(mark: mark, point: point, markIndex: markIndex, pointIndex: idx)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@ChartContentBuilder
|
|
257
|
+
private func buildMark(
|
|
258
|
+
mark: ChartMark,
|
|
259
|
+
point: ChartDataPoint,
|
|
260
|
+
markIndex: Int,
|
|
261
|
+
pointIndex: Int
|
|
262
|
+
) -> some ChartContent {
|
|
263
|
+
switch mark.type {
|
|
264
|
+
case "bar":
|
|
265
|
+
barMark(mark: mark, point: point)
|
|
266
|
+
case "line":
|
|
267
|
+
lineMark(mark: mark, point: point)
|
|
268
|
+
if mark.showPoints {
|
|
269
|
+
pointMark(mark: mark, point: point)
|
|
270
|
+
}
|
|
271
|
+
case "area":
|
|
272
|
+
areaMark(mark: mark, point: point)
|
|
273
|
+
case "point":
|
|
274
|
+
pointMark(mark: mark, point: point)
|
|
275
|
+
case "rectangle":
|
|
276
|
+
rectangleMark(mark: mark, point: point)
|
|
277
|
+
case "rule":
|
|
278
|
+
ruleMark(mark: mark, point: point)
|
|
279
|
+
case "sector":
|
|
280
|
+
sectorMark(mark: mark, point: point)
|
|
281
|
+
default:
|
|
282
|
+
lineMark(mark: mark, point: point)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// MARK: - Individual marks
|
|
287
|
+
|
|
288
|
+
@ChartContentBuilder
|
|
289
|
+
private func barMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
290
|
+
BarMark(
|
|
291
|
+
x: .value("X", point.x),
|
|
292
|
+
y: .value("Y", point.y),
|
|
293
|
+
width: mark.barWidth > 0 ? .fixed(mark.barWidth) : .automatic
|
|
294
|
+
)
|
|
295
|
+
.cornerRadius(mark.cornerRadius)
|
|
296
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
297
|
+
.opacity(mark.opacity)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@ChartContentBuilder
|
|
301
|
+
private func lineMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
302
|
+
LineMark(
|
|
303
|
+
x: .value("X", point.x),
|
|
304
|
+
y: .value("Y", point.y),
|
|
305
|
+
series: .value("Series", point.category ?? "default")
|
|
306
|
+
)
|
|
307
|
+
.interpolationMethod(interpolationMethod(mark.interpolation))
|
|
308
|
+
.lineStyle(StrokeStyle(
|
|
309
|
+
lineWidth: mark.lineWidth,
|
|
310
|
+
lineCap: lineCap(mark.lineCap),
|
|
311
|
+
dash: mark.dashArray.map { CGFloat($0) }
|
|
312
|
+
))
|
|
313
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
314
|
+
.opacity(mark.opacity)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@ChartContentBuilder
|
|
318
|
+
private func areaMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
319
|
+
AreaMark(
|
|
320
|
+
x: .value("X", point.x),
|
|
321
|
+
y: .value("Y", point.y),
|
|
322
|
+
series: .value("Series", point.category ?? "default")
|
|
323
|
+
)
|
|
324
|
+
.interpolationMethod(interpolationMethod(mark.interpolation))
|
|
325
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
326
|
+
.opacity(mark.opacity)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@ChartContentBuilder
|
|
330
|
+
private func pointMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
331
|
+
PointMark(
|
|
332
|
+
x: .value("X", point.x),
|
|
333
|
+
y: .value("Y", point.y)
|
|
334
|
+
)
|
|
335
|
+
.symbol(symbolShape(mark.symbol))
|
|
336
|
+
.symbolSize(mark.symbolSize)
|
|
337
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
338
|
+
.opacity(mark.opacity)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@ChartContentBuilder
|
|
342
|
+
private func rectangleMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
343
|
+
RectangleMark(
|
|
344
|
+
x: .value("X", point.x),
|
|
345
|
+
yStart: .value("Y", point.y),
|
|
346
|
+
yEnd: .value("YEnd", point.yEnd ?? point.y)
|
|
347
|
+
)
|
|
348
|
+
.cornerRadius(mark.cornerRadius)
|
|
349
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
350
|
+
.opacity(mark.opacity)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
@ChartContentBuilder
|
|
354
|
+
private func ruleMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
355
|
+
if mark.orientation == "vertical" {
|
|
356
|
+
RuleMark(x: .value("X", point.x))
|
|
357
|
+
.lineStyle(StrokeStyle(
|
|
358
|
+
lineWidth: mark.lineWidth,
|
|
359
|
+
lineCap: lineCap(mark.lineCap),
|
|
360
|
+
dash: mark.dashArray.map { CGFloat($0) }
|
|
361
|
+
))
|
|
362
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
363
|
+
.opacity(mark.opacity)
|
|
364
|
+
} else {
|
|
365
|
+
RuleMark(y: .value("Y", point.y))
|
|
366
|
+
.lineStyle(StrokeStyle(
|
|
367
|
+
lineWidth: mark.lineWidth,
|
|
368
|
+
lineCap: lineCap(mark.lineCap),
|
|
369
|
+
dash: mark.dashArray.map { CGFloat($0) }
|
|
370
|
+
))
|
|
371
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
372
|
+
.opacity(mark.opacity)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@ChartContentBuilder
|
|
377
|
+
private func sectorMark(mark: ChartMark, point: ChartDataPoint) -> some ChartContent {
|
|
378
|
+
SectorMark(
|
|
379
|
+
angle: .value("Value", point.y),
|
|
380
|
+
innerRadius: .ratio(mark.innerRadius),
|
|
381
|
+
outerRadius: mark.outerRadius > 0 ? .ratio(mark.outerRadius) : .inset(0),
|
|
382
|
+
angularInset: mark.angularInset
|
|
383
|
+
)
|
|
384
|
+
.cornerRadius(mark.cornerRadius)
|
|
385
|
+
.foregroundStyle(resolveFill(mark: mark, point: point))
|
|
386
|
+
.opacity(mark.opacity)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// MARK: - Fill resolution
|
|
390
|
+
|
|
391
|
+
private func resolveFill(mark: ChartMark, point: ChartDataPoint) -> AnyShapeStyle {
|
|
392
|
+
// Priority: per-point color > gradient > mark color > system default.
|
|
393
|
+
if let pc = point.color {
|
|
394
|
+
return AnyShapeStyle(Color(pc))
|
|
395
|
+
}
|
|
396
|
+
if let grad = mark.gradient {
|
|
397
|
+
return AnyShapeStyle(buildGradient(grad, baseColor: mark.color))
|
|
398
|
+
}
|
|
399
|
+
if let mc = mark.color {
|
|
400
|
+
return AnyShapeStyle(Color(mc))
|
|
401
|
+
}
|
|
402
|
+
return AnyShapeStyle(Color.accentColor)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private func buildGradient(
|
|
406
|
+
_ grad: ChartGradient, baseColor: UIColor?
|
|
407
|
+
) -> LinearGradient {
|
|
408
|
+
let base = baseColor.map { Color($0) } ?? Color.accentColor
|
|
409
|
+
let stops: [Gradient.Stop]
|
|
410
|
+
if !grad.stops.isEmpty {
|
|
411
|
+
stops = grad.stops.map { s in
|
|
412
|
+
Gradient.Stop(
|
|
413
|
+
color: (s.color.map { Color($0) } ?? base).opacity(s.opacity),
|
|
414
|
+
location: CGFloat(s.offset)
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
stops = [
|
|
419
|
+
Gradient.Stop(
|
|
420
|
+
color: base.opacity(grad.startOpacity), location: 0),
|
|
421
|
+
Gradient.Stop(
|
|
422
|
+
color: base.opacity(grad.endOpacity), location: 1),
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
return LinearGradient(
|
|
426
|
+
gradient: Gradient(stops: stops),
|
|
427
|
+
startPoint: UnitPoint(x: grad.startX, y: grad.startY),
|
|
428
|
+
endPoint: UnitPoint(x: grad.endX, y: grad.endY)
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// MARK: - Center label overlay
|
|
433
|
+
|
|
434
|
+
@ViewBuilder
|
|
435
|
+
private func centerLabelView(proxy: ChartProxy) -> some View {
|
|
436
|
+
if props.centerLabel.value != nil || props.centerLabel.label != nil {
|
|
437
|
+
GeometryReader { geo in
|
|
438
|
+
if let plotFrame = proxy.plotFrame {
|
|
439
|
+
let f = geo[plotFrame]
|
|
440
|
+
VStack(spacing: 2) {
|
|
441
|
+
if let v = props.centerLabel.value {
|
|
442
|
+
Text(v)
|
|
443
|
+
.font(.system(
|
|
444
|
+
size: CGFloat(props.centerLabel.valueFontSize),
|
|
445
|
+
weight: .semibold
|
|
446
|
+
))
|
|
447
|
+
.foregroundColor(
|
|
448
|
+
Color(props.centerLabel.valueColor ?? UIColor.label)
|
|
449
|
+
)
|
|
450
|
+
.lineLimit(1)
|
|
451
|
+
.minimumScaleFactor(0.5)
|
|
452
|
+
}
|
|
453
|
+
if let l = props.centerLabel.label {
|
|
454
|
+
Text(l)
|
|
455
|
+
.font(.system(size: CGFloat(props.centerLabel.labelFontSize)))
|
|
456
|
+
.foregroundColor(
|
|
457
|
+
Color(props.centerLabel.labelColor ?? UIColor.secondaryLabel)
|
|
458
|
+
)
|
|
459
|
+
.lineLimit(1)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
.frame(width: f.width, height: f.height)
|
|
463
|
+
.position(x: f.midX, y: f.midY)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// MARK: - Helpers
|
|
470
|
+
|
|
471
|
+
private var legendVisibility: Visibility {
|
|
472
|
+
props.legend.hidden ? .hidden : .visible
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private func scaleDomain(_ axis: ChartAxisConfig) -> ClosedRange<Double>? {
|
|
476
|
+
guard let lo = axis.domainMin, let hi = axis.domainMax, hi > lo else {
|
|
477
|
+
return nil
|
|
478
|
+
}
|
|
479
|
+
return lo...hi
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Used as the trigger for `animation(_, value:)`. Stable hash of
|
|
483
|
+
// every y so SwiftUI knows when to re-animate the chart.
|
|
484
|
+
private var marksFingerprint: [Double] {
|
|
485
|
+
props.marks.flatMap { m in m.data.map(\.y) }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private func interpolationMethod(_ v: String) -> InterpolationMethod {
|
|
489
|
+
switch v {
|
|
490
|
+
case "catmullRom": return .catmullRom
|
|
491
|
+
case "monotone": return .monotone
|
|
492
|
+
case "stepStart": return .stepStart
|
|
493
|
+
case "stepEnd": return .stepEnd
|
|
494
|
+
case "stepCenter": return .stepCenter
|
|
495
|
+
default: return .linear
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private func lineCap(_ v: String) -> CGLineCap {
|
|
500
|
+
switch v {
|
|
501
|
+
case "butt": return .butt
|
|
502
|
+
case "square": return .square
|
|
503
|
+
default: return .round
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private func symbolShape(_ v: String) -> some ChartSymbolShape {
|
|
508
|
+
switch v {
|
|
509
|
+
case "square": return AnyChartSymbolShape(BasicChartSymbolShape.square)
|
|
510
|
+
case "triangle": return AnyChartSymbolShape(BasicChartSymbolShape.triangle)
|
|
511
|
+
case "diamond": return AnyChartSymbolShape(BasicChartSymbolShape.diamond)
|
|
512
|
+
case "pentagon": return AnyChartSymbolShape(BasicChartSymbolShape.pentagon)
|
|
513
|
+
case "plus": return AnyChartSymbolShape(BasicChartSymbolShape.plus)
|
|
514
|
+
case "cross": return AnyChartSymbolShape(BasicChartSymbolShape.cross)
|
|
515
|
+
case "asterisk": return AnyChartSymbolShape(BasicChartSymbolShape.asterisk)
|
|
516
|
+
default: return AnyChartSymbolShape(BasicChartSymbolShape.circle)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/// Conditional axis-scale modifiers. SwiftUI's `chartXScale(domain:)`
|
|
522
|
+
/// and `chartYScale(domain:)` require a non-optional `ClosedRange`,
|
|
523
|
+
/// so we can't just pass the optional result of `scaleDomain` —
|
|
524
|
+
/// these helpers apply the modifier only when the domain is set,
|
|
525
|
+
/// otherwise return the view unmodified.
|
|
526
|
+
@available(iOS 17.0, *)
|
|
527
|
+
private extension View {
|
|
528
|
+
@ViewBuilder
|
|
529
|
+
func conditionalChartXScale(domain: ClosedRange<Double>?) -> some View {
|
|
530
|
+
if let domain {
|
|
531
|
+
self.chartXScale(domain: domain)
|
|
532
|
+
} else {
|
|
533
|
+
self
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
@ViewBuilder
|
|
538
|
+
func conditionalChartYScale(domain: ClosedRange<Double>?) -> some View {
|
|
539
|
+
if let domain {
|
|
540
|
+
self.chartYScale(domain: domain)
|
|
541
|
+
} else {
|
|
542
|
+
self
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/// Positions a callout above the active datum and clamps it inside
|
|
548
|
+
/// the plot frame so it never overflows the chart's bounds. Uses
|
|
549
|
+
/// `alignmentGuide` so the callout is anchored bottom-center on
|
|
550
|
+
/// (xAbs, yAbs - 12) — i.e. 12pt above the dot.
|
|
551
|
+
@available(iOS 17.0, *)
|
|
552
|
+
private struct CalloutPlacement: ViewModifier {
|
|
553
|
+
let xAbs: CGFloat
|
|
554
|
+
let yAbs: CGFloat
|
|
555
|
+
let plot: CGRect
|
|
556
|
+
|
|
557
|
+
func body(content: Content) -> some View {
|
|
558
|
+
content
|
|
559
|
+
.background(
|
|
560
|
+
// Invisible probe to read the callout's own size so we can
|
|
561
|
+
// clamp it to the plot edges.
|
|
562
|
+
GeometryReader { geo in
|
|
563
|
+
Color.clear.preference(
|
|
564
|
+
key: CalloutSizeKey.self,
|
|
565
|
+
value: geo.size
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
.modifier(CalloutPositioner(xAbs: xAbs, yAbs: yAbs, plot: plot))
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
@available(iOS 17.0, *)
|
|
574
|
+
private struct CalloutSizeKey: PreferenceKey {
|
|
575
|
+
static var defaultValue: CGSize = .zero
|
|
576
|
+
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
|
577
|
+
value = nextValue()
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
@available(iOS 17.0, *)
|
|
582
|
+
private struct CalloutPositioner: ViewModifier {
|
|
583
|
+
let xAbs: CGFloat
|
|
584
|
+
let yAbs: CGFloat
|
|
585
|
+
let plot: CGRect
|
|
586
|
+
|
|
587
|
+
@State private var size: CGSize = .zero
|
|
588
|
+
|
|
589
|
+
func body(content: Content) -> some View {
|
|
590
|
+
let half = size.width / 2
|
|
591
|
+
let clampedX = min(max(xAbs, plot.minX + half + 4), plot.maxX - half - 4)
|
|
592
|
+
// Prefer above the dot; if too close to the top, drop below.
|
|
593
|
+
let aboveY = yAbs - size.height / 2 - 14
|
|
594
|
+
let belowY = yAbs + size.height / 2 + 14
|
|
595
|
+
let y = aboveY < plot.minY + size.height / 2
|
|
596
|
+
? belowY
|
|
597
|
+
: aboveY
|
|
598
|
+
|
|
599
|
+
return content
|
|
600
|
+
.onPreferenceChange(CalloutSizeKey.self) { size = $0 }
|
|
601
|
+
.position(x: clampedX, y: y)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Type-erased wrapper for the various BasicChartSymbolShape values
|
|
606
|
+
/// so a `switch` can return a single concrete type.
|
|
607
|
+
@available(iOS 17.0, *)
|
|
608
|
+
private struct AnyChartSymbolShape: ChartSymbolShape {
|
|
609
|
+
private let _path: (CGRect) -> Path
|
|
610
|
+
let perceptualUnitRect: CGRect
|
|
611
|
+
|
|
612
|
+
init<S: ChartSymbolShape>(_ shape: S) {
|
|
613
|
+
self._path = { rect in shape.path(in: rect) }
|
|
614
|
+
self.perceptualUnitRect = shape.perceptualUnitRect
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
func path(in rect: CGRect) -> Path { _path(rect) }
|
|
618
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
# npm uses `git+https://...` for repository urls, but cocoapods wants
|
|
6
|
+
# plain `https://...`. Strip the prefix so the podspec is valid.
|
|
7
|
+
clean_url = package['repository']['url'].sub(/^git\+/, '').sub(/\.git$/, '')
|
|
8
|
+
|
|
9
|
+
Pod::Spec.new do |s|
|
|
10
|
+
s.name = 'NativeIosCharts'
|
|
11
|
+
s.version = package['version']
|
|
12
|
+
s.summary = package['description']
|
|
13
|
+
s.description = package['description']
|
|
14
|
+
s.license = package['license']
|
|
15
|
+
s.author = ''
|
|
16
|
+
s.homepage = clean_url
|
|
17
|
+
# Podspec minimum is set low so the pod installs cleanly in any
|
|
18
|
+
# iOS-15+ Expo project. The actual SwiftUI Charts code is gated
|
|
19
|
+
# with `@available(iOS 17.0, *)` — on older iOS the native view
|
|
20
|
+
# renders a transparent placeholder, matching the JS-side no-op on
|
|
21
|
+
# non-iOS platforms.
|
|
22
|
+
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
|
23
|
+
s.swift_version = '5.9'
|
|
24
|
+
s.source = { git: "#{clean_url}.git", tag: s.version.to_s }
|
|
25
|
+
s.static_framework = true
|
|
26
|
+
|
|
27
|
+
s.dependency 'ExpoModulesCore'
|
|
28
|
+
|
|
29
|
+
s.pod_target_xcconfig = {
|
|
30
|
+
'DEFINES_MODULE' => 'YES',
|
|
31
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
s.source_files = "**/*.{h,m,swift}"
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
/// Top-level Expo module registration. Exposes a single generic
|
|
4
|
+
/// `ChartView` that handles every SwiftUI Charts mark type — JS-side
|
|
5
|
+
/// convenience wrappers (`PieChart`, `LineChart`, `BarChart`, …) all
|
|
6
|
+
/// shape their props into the same `marks: [Mark]` config.
|
|
7
|
+
public class NativeIosChartsModule: Module {
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
Name("NativeIosCharts")
|
|
10
|
+
|
|
11
|
+
View(ChartView.self) {
|
|
12
|
+
Events("onSelect")
|
|
13
|
+
|
|
14
|
+
Prop("marks") { (view: ChartView, marks: [ChartMark]) in
|
|
15
|
+
view.props.marks = marks
|
|
16
|
+
}
|
|
17
|
+
Prop("xAxis") { (view: ChartView, axis: ChartAxisConfig) in
|
|
18
|
+
view.props.xAxis = axis
|
|
19
|
+
}
|
|
20
|
+
Prop("yAxis") { (view: ChartView, axis: ChartAxisConfig) in
|
|
21
|
+
view.props.yAxis = axis
|
|
22
|
+
}
|
|
23
|
+
Prop("legend") { (view: ChartView, legend: ChartLegendConfig) in
|
|
24
|
+
view.props.legend = legend
|
|
25
|
+
}
|
|
26
|
+
Prop("centerLabel") { (view: ChartView, label: ChartCenterLabel) in
|
|
27
|
+
view.props.centerLabel = label
|
|
28
|
+
}
|
|
29
|
+
Prop("tooltip") { (view: ChartView, config: ChartTooltipConfig) in
|
|
30
|
+
view.props.tooltip = config
|
|
31
|
+
}
|
|
32
|
+
Prop("animate") { (view: ChartView, value: Bool) in
|
|
33
|
+
view.props.animate = value
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|