Skip to content

Add annotation modifier #89

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/LiveViewNativeCharts/AxisContent/AxisMarks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,9 @@ extension Calendar.Component: AttributeDecodable {
case "nanosecond": self = .nanosecond
case "calendar": self = .calendar
case "time_zone": self = .timeZone
#if swift(>=5.9)
case "is_leap_month": self = .isLeapMonth
#endif
default: throw AttributeDecodingError.badValue(Self.self)
}
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/LiveViewNativeCharts/ChartContentBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct ChartContentBuilder: ContentBuilder {
}

enum ModifierType: String, Decodable {
case annotation
case alignsMarkStylesWithPlotArea = "aligns_mark_styles_with_plot_area"
case cornerRadius = "corner_radius"
case foregroundStyle = "foreground_style"
Expand Down Expand Up @@ -72,6 +73,8 @@ struct ChartContentBuilder: ContentBuilder {
registry _: R.Type
) throws -> any ContentModifier<Self> {
switch type {
case .annotation:
return try AnnotationModifier(from: decoder)
case .alignsMarkStylesWithPlotArea:
return try AlignsMarkStylesWithPlotAreaModifier(from: decoder)
case .cornerRadius:
Expand Down
3 changes: 3 additions & 0 deletions Sources/LiveViewNativeCharts/ChartsRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public struct ChartsRegistry<Root: RootRegistry>: CustomRegistry {
}

public enum ModifierType: String {
case chartLegend = "chart_legend"
case chartBackground = "chart_background"
case chartOverlay = "chart_overlay"
case chartXAxis = "chart_x_axis"
Expand All @@ -33,6 +34,8 @@ public struct ChartsRegistry<Root: RootRegistry>: CustomRegistry {

public static func decodeModifier(_ type: ModifierType, from decoder: Decoder) throws -> some ViewModifier {
switch type {
case .chartLegend:
try ChartLegendModifier<Root>(from: decoder)
case .chartBackground:
try ChartBackgroundModifier<Root>(from: decoder)
case .chartOverlay:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# ``LiveViewNativeCharts/AnnotationModifier``

@Metadata {
@DocumentationExtension(mergeBehavior: append)
@DisplayName("annotation", style: symbol)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@
### Axis Modifiers
- ``ChartXAxisModifier``
- ``ChartYAxisModifier``
### Annotating Marks
- ``AnnotationModifier``
### Legend Modifiers
- ``ChartLegendModifier``
### Background Modifiers
- ``ChartBackgroundModifier``
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# ``LiveViewNativeCharts/ChartLegendModifier``

@Metadata {
@DocumentationExtension(mergeBehavior: append)
@DisplayName("chart_legend", style: symbol)
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ Each mark supports different values. These attributes are detailed in their docu
### Modifiers
- ``ForegroundStyleModifier``
- ``OffsetModifier``
- ``SymbolModifier``
207 changes: 207 additions & 0 deletions Sources/LiveViewNativeCharts/Modifiers/AnnotationModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//
// AnnotationModifier.swift
//
//
// Created by Carson.Katri on 6/20/23.
//

import Charts
import SwiftUI
import LiveViewNative

/// Annotate a chart element with custom Views.
///
/// Place an annotation on a mark to annotate it.
/// Provide a key name to the ``content`` argument, and create a nested element with the `template` attribute set to the same key.
///
/// ```html
/// <BarMark
/// ...
/// modifiers={@native |> annotation(content: :icon)}
/// >
/// <Image template={:icon} system-name="flag.fill" />
/// </BarMark>
/// ```
///
/// ## Arguments
/// * ``position``
/// * ``alignment``
/// * ``spacing``
/// * ``content``
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
struct AnnotationModifier: ContentModifier {
typealias Builder = ChartContentBuilder

/// The position of the annotation with respect to the annotated item. Defaults to `automatic`.
///
/// See ``LiveViewNativeCharts/Charts/AnnotationPosition`` for a list of possible values.
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
private let position: AnnotationPosition

/// The alignment of the annotation with respect to the annotated item. Defaults to `center`.
///
/// See ``LiveViewNative/SwiftUI/Alignment`` for a list of possible values.
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
private let alignment: Alignment

/// The space between the annotation and the annotated item.
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
private let spacing: CGFloat?

/// `overflow_resolution`, the method used to resolve annotations that do not fit within the chart. Defaults to `automatic`.
///
/// See ``OverflowResolution`` for more details.
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
private let overflowResolution: OverflowResolution?

/// The key name of the content to place in the annotation.
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
private let content: String

func apply<R: RootRegistry>(
to content: Builder.Content,
on element: ElementNode,
in context: Builder.Context<R>
) -> Builder.Content {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *),
let overflowResolution = overflowResolution?.value
{
#if swift(>=5.9)
return content
.annotation(
position: position,
alignment: alignment,
spacing: spacing,
overflowResolution: overflowResolution
) {
Builder.buildChildViews(of: element, forTemplate: self.content, in: context)
}
#else
let _ = overflowResolution
return content
#endif
} else {
return content
.annotation(
position: position,
alignment: alignment,
spacing: spacing
) {
Builder.buildChildViews(of: element, forTemplate: self.content, in: context)
}
}
}
}

/// Resolves an annotation that overflows the chart on the `x`/`y` axes.
///
/// Use a tuple where the first element is the `x` strategy is the first element, and the `y` strategy is the second element.
/// Both elements should contain a ``LiveViewNativeCharts/Charts/AnnotationOverflowResolution/Strategy`` type.
///
/// ```elixir
/// {:automatic, :fit}
/// ```
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
struct OverflowResolution: Decodable {
let _value: Any

#if swift(>=5.9)
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
var value: AnnotationOverflowResolution {
_value as! AnnotationOverflowResolution
}
#else
var value: Any { fatalError() }
#endif

init(from decoder: Decoder) throws {
#if swift(>=5.9)
var container = try decoder.unkeyedContainer()
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
let x = try container.decode(AnnotationOverflowResolution.Strategy.self)
let y = try container.decode(AnnotationOverflowResolution.Strategy.self)
self._value = AnnotationOverflowResolution(x: x, y: y)
} else {
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "AnnotationOverflowResolution is only available on iOS 17, macOS 14, tvOS 17, and watchOS 10 or higher."))
}
#else
fatalError()
#endif
}
}

#if swift(>=5.9)
/// The strategy used to resolve an overflowing annotation.
///
/// Possible values:
/// * `automatic`
/// * `disabled`
/// * `fit`, `{:fit, boundary}` - See ``LiveViewNativeCharts/Charts/AnnotationOverflowResolution/Boundary`` for a list of possible values.
/// * `pad_scale`
@_documentation(visibility: public)
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension AnnotationOverflowResolution.Strategy: Decodable {
public init(from decoder: Decoder) throws {
if var container = try? decoder.unkeyedContainer() {
switch try container.decode(String.self) {
case "fit":
self = .fit(to: try container.decode(AnnotationOverflowResolution.Boundary.self))
case let `default`:
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown strategy '\(`default`)'"))
}
} else {
let container = try decoder.singleValueContainer()
switch try container.decode(String.self) {
case "automatic":
self = .automatic
case "disabled":
self = .disabled
case "fit":
self = .fit
case "pad_scale":
self = .padScale
case let `default`:
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown strategy '\(`default`)'"))
}
}
}
}

/// The boundary to fit to.
///
/// Possible values:
/// * `automatic`
/// * `chart`
/// * `plot`
@_documentation(visibility: public)
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension AnnotationOverflowResolution.Boundary: Decodable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
switch try container.decode(String.self) {
case "automatic":
self = .automatic
case "chart":
self = .chart
case "plot":
self = .plot
case let `default`:
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Unknown boundary '\(`default`)'"))
}
}
}
#endif
Loading