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.
@@ -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
+ }