Skip to content

Commit 867ba8e

Browse files
committed
Add annotation modifier
1 parent 9fde1bc commit 867ba8e

File tree

7 files changed

+302
-0
lines changed

7 files changed

+302
-0
lines changed

Sources/LiveViewNativeCharts/ChartContentBuilder.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ struct ChartContentBuilder: ContentBuilder {
2525
}
2626

2727
enum ModifierType: String, Decodable {
28+
case annotation
2829
case foregroundStyle = "foreground_style"
2930
case offset
3031
case symbol
@@ -67,6 +68,8 @@ struct ChartContentBuilder: ContentBuilder {
6768
registry _: R.Type
6869
) throws -> any ContentModifier<Self> {
6970
switch type {
71+
case .annotation:
72+
return try AnnotationModifier(from: decoder)
7073
case .foregroundStyle:
7174
return try ForegroundStyleModifier(from: decoder)
7275
case .offset:
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# ``LiveViewNativeCharts/AnnotationModifier``
2+
3+
@Metadata {
4+
@DocumentationExtension(mergeBehavior: append)
5+
@DisplayName("annotation", style: symbol)
6+
}

Sources/LiveViewNativeCharts/LiveViewNativeCharts.docc/Extensions/Chart.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@
99
### Axis Modifiers
1010
- ``ChartXAxisModifier``
1111
- ``ChartYAxisModifier``
12+
### Annotating Marks
13+
- ``AnnotationModifier``
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//
2+
// AnnotationModifier.swift
3+
//
4+
//
5+
// Created by Carson.Katri on 6/20/23.
6+
//
7+
8+
import Charts
9+
import SwiftUI
10+
import LiveViewNative
11+
12+
/// Annotate a chart element with custom Views.
13+
///
14+
/// Place an annotation on a mark to annotate it.
15+
/// Provide a key name to the ``content`` argument, and create a nested element with the `template` attribute set to the same key.
16+
///
17+
/// ```html
18+
/// <BarMark
19+
/// ...
20+
/// modifiers={@native |> annotation(content: :icon)}
21+
/// >
22+
/// <Image template={:icon} system-name="flag.fill" />
23+
/// </BarMark>
24+
/// ```
25+
///
26+
/// ## Arguments
27+
/// * ``position``
28+
/// * ``alignment``
29+
/// * ``spacing``
30+
/// * ``content``
31+
#if swift(>=5.8)
32+
@_documentation(visibility: public)
33+
#endif
34+
struct AnnotationModifier: ContentModifier {
35+
typealias Builder = ChartContentBuilder
36+
37+
/// The position of the annotation with respect to the annotated item. Defaults to `automatic`.
38+
///
39+
/// See ``LiveViewNativeCharts/Charts/AnnotationPosition`` for a list of possible values.
40+
#if swift(>=5.8)
41+
@_documentation(visibility: public)
42+
#endif
43+
private let position: AnnotationPosition
44+
45+
/// The alignment of the annotation with respect to the annotated item. Defaults to `center`.
46+
///
47+
/// See ``LiveViewNative/SwiftUI/Alignment`` for a list of possible values.
48+
#if swift(>=5.8)
49+
@_documentation(visibility: public)
50+
#endif
51+
private let alignment: Alignment
52+
53+
/// The space between the annotation and the annotated item.
54+
#if swift(>=5.8)
55+
@_documentation(visibility: public)
56+
#endif
57+
private let spacing: CGFloat?
58+
59+
/// `overflow_resolution`, the method used to resolve annotations that do not fit within the chart. Defaults to `automatic`.
60+
///
61+
/// See ``OverflowResolution`` for more details.
62+
#if swift(>=5.8)
63+
@_documentation(visibility: public)
64+
#endif
65+
private let overflowResolution: OverflowResolution?
66+
67+
/// The key name of the content to place in the annotation.
68+
#if swift(>=5.8)
69+
@_documentation(visibility: public)
70+
#endif
71+
private let content: String
72+
73+
func apply<R: RootRegistry>(
74+
to content: Builder.Content,
75+
on element: ElementNode,
76+
in context: Builder.Context<R>
77+
) -> Builder.Content {
78+
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *),
79+
let overflowResolution = overflowResolution?.value
80+
{
81+
#if swift(>=5.9)
82+
content
83+
.annotation(
84+
position: position,
85+
alignment: alignment,
86+
spacing: spacing,
87+
overflowResolution: overflowResolution
88+
) {
89+
Builder.buildChildViews(of: element, forTemplate: self.content, in: context)
90+
}
91+
#endif
92+
} else {
93+
content
94+
.annotation(
95+
position: position,
96+
alignment: alignment,
97+
spacing: spacing
98+
) {
99+
Builder.buildChildViews(of: element, forTemplate: self.content, in: context)
100+
}
101+
}
102+
}
103+
}
104+
105+
/// Resolves an annotation that overflows the chart on the `x`/`y` axes.
106+
///
107+
/// Use a tuple where the first element is the `x` strategy is the first element, and the `y` strategy is the second element.
108+
/// Both elements should contain a ``LiveViewNativeCharts/Charts/AnnotationOverflowResolution/Strategy`` type.
109+
///
110+
/// ```elixir
111+
/// {:automatic, :fit}
112+
/// ```
113+
#if swift(>=5.8)
114+
@_documentation(visibility: public)
115+
#endif
116+
struct OverflowResolution: Decodable {
117+
let _value: Any
118+
119+
#if swift(>=5.9)
120+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
121+
var value: AnnotationOverflowResolution {
122+
_value as! AnnotationOverflowResolution
123+
}
124+
#else
125+
var value: Any { fatalError() }
126+
#endif
127+
128+
init(from decoder: Decoder) throws {
129+
var container = try decoder.unkeyedContainer()
130+
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
131+
let x = try container.decode(AnnotationOverflowResolution.Strategy.self)
132+
let y = try container.decode(AnnotationOverflowResolution.Strategy.self)
133+
self._value = AnnotationOverflowResolution(x: x, y: y)
134+
} else {
135+
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "AnnotationOverflowResolution is only available on iOS 17, macOS 14, tvOS 17, and watchOS 10 or higher."))
136+
}
137+
}
138+
}
139+
140+
#if swift(>=5.9)
141+
/// The strategy used to resolve an overflowing annotation.
142+
///
143+
/// Possible values:
144+
/// * `automatic`
145+
/// * `disabled`
146+
/// * `fit`, `{:fit, boundary}` - See ``LiveViewNativeCharts/Charts/AnnotationOverflowResolution/Boundary`` for a list of possible values.
147+
/// * `pad_scale`
148+
@_documentation(visibility: public)
149+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
150+
extension AnnotationOverflowResolution.Strategy: Decodable {
151+
public init(from decoder: Decoder) throws {
152+
if var container = try? decoder.unkeyedContainer() {
153+
switch try container.decode(String.self) {
154+
case "fit":
155+
self = .fit(to: try container.decode(AnnotationOverflowResolution.Boundary.self))
156+
case let `default`:
157+
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown strategy '\(`default`)'"))
158+
}
159+
} else {
160+
let container = try decoder.singleValueContainer()
161+
switch try container.decode(String.self) {
162+
case "automatic":
163+
self = .automatic
164+
case "disabled":
165+
self = .disabled
166+
case "fit":
167+
self = .fit
168+
case "pad_scale":
169+
self = .padScale
170+
case let `default`:
171+
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown strategy '\(`default`)'"))
172+
}
173+
}
174+
}
175+
}
176+
177+
/// The boundary to fit to.
178+
///
179+
/// Possible values:
180+
/// * `automatic`
181+
/// * `chart`
182+
/// * `plot`
183+
@_documentation(visibility: public)
184+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
185+
extension AnnotationOverflowResolution.Boundary: Decodable {
186+
public init(from decoder: Decoder) throws {
187+
let container = try decoder.singleValueContainer()
188+
switch try container.decode(String.self) {
189+
case "automatic":
190+
self = .automatic
191+
case "chart":
192+
self = .chart
193+
case "plot":
194+
self = .plot
195+
case let `default`:
196+
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown boundary '\(`default`)'"))
197+
}
198+
}
199+
}
200+
#endif
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// AnnotationPosition.swift
3+
//
4+
//
5+
// Created by Carson Katri on 6/20/23.
6+
//
7+
8+
import Charts
9+
10+
/// The placement of an annotation relative to the annotated item.
11+
///
12+
/// Possible values:
13+
/// * `automatic`
14+
/// * `overlay`
15+
/// * `top`
16+
/// * `bottom`
17+
/// * `leading`
18+
/// * `trailing`
19+
/// * `top_leading`
20+
/// * `top_trailing`
21+
/// * `bottom_leading`
22+
/// * `bottom_trailing`
23+
#if swift(>=5.8)
24+
@_documentation(visibility: public)
25+
#endif
26+
extension AnnotationPosition: Decodable {
27+
public init(from decoder: Decoder) throws {
28+
let container = try decoder.singleValueContainer()
29+
switch try container.decode(String.self) {
30+
case "automatic": self = .automatic
31+
case "overlay": self = .overlay
32+
case "top": self = .top
33+
case "bottom": self = .bottom
34+
case "leading": self = .leading
35+
case "trailing": self = .trailing
36+
case "top_leading": self = .topLeading
37+
case "top_trailing": self = .topTrailing
38+
case "bottom_leading": self = .bottomLeading
39+
case "bottom_trailing": self = .bottomTrailing
40+
case let `default`:
41+
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown annotation position '\(`default`)'"))
42+
}
43+
}
44+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule LiveViewNativeSwiftUiCharts.Modifiers.Annotation do
2+
use LiveViewNativePlatform.Modifier
3+
4+
alias LiveViewNativeSwiftUiCharts.Types.AnnotationOverflowResolution
5+
alias LiveViewNativeSwiftUi.Types.KeyName
6+
7+
modifier_schema "annotation" do
8+
field :position, Ecto.Enum, values: ~w(
9+
automatic
10+
overlay
11+
top
12+
bottom
13+
leading
14+
trailing
15+
top_leading
16+
top_trailing
17+
bottom_leading
18+
bottom_trailing
19+
)a, default: :automatic
20+
field :alignment, Ecto.Enum, values: ~w(
21+
bottom
22+
bottom_leading
23+
bottom_trailing
24+
center
25+
leading
26+
leading_last_text_baseline
27+
top
28+
top_leading
29+
top_trailing
30+
trailing
31+
trailing_first_text_baseline
32+
)a, default: :center
33+
field :spacing, :float
34+
field :overflow_resolution, AnnotationOverflowResolution
35+
field :content, KeyName
36+
end
37+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule LiveViewNativeSwiftUiCharts.Types.AnnotationOverflowResolution do
2+
use LiveViewNativePlatform.Modifier.Type
3+
def type, do: :array
4+
5+
def cast({x, y}), do: {:ok, [cast_strategy(x), cast_strategy(y)]}
6+
def cast(_), do: :error
7+
8+
def cast_strategy(type) when is_atom(type), do: type
9+
def cast_strategy({:fit = type, boundary}), do: [type, boundary]
10+
end

0 commit comments

Comments
 (0)