diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..7f54e512
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,34 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+
+## Description
+
+
+## Expected Behavior
+
+
+## Actual Behavior
+
+
+## Possible Fix
+
+
+## Steps to Reproduce
+
+
+
+## Your Environment
+
+* Version of this package used:
+* Device/Simulator:
+* Operating System and version:
+* Link to your project:
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..d4b1ee3d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Ask for a new feature
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+## Detailed Description
+
+
+## Context
+
+
+
+## Possible Implementation
+
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/v2Ticket.md b/.github/ISSUE_TEMPLATE/v2Ticket.md
new file mode 100644
index 00000000..ac9ec0cb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/v2Ticket.md
@@ -0,0 +1,11 @@
+---
+name: v2 ticket
+about: Create tasks for the upcoming new version
+title: ''
+labels: v2
+assignees: ''
+
+---
+# v2 ticket
+
+## Ticket description:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..5eabcf1b
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,29 @@
+
+
+## Description
+
+
+## Motivation and Context
+
+
+
+## How Has This Been Tested?
+
+
+
+
+## Screenshots (if appropriate):
+
+## Types of changes
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to change)
+- [ ] Non-functional change (Updating Documentation, CI automation, etc..)
+
+## Checklist:
+
+
+- [ ] My code follows the code style of this project.
+- [ ] My change requires a change to the documentation.
+- [ ] I have updated the documentation accordingly.
diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml
new file mode 100644
index 00000000..1e6268cc
--- /dev/null
+++ b/.github/workflows/swift.yml
@@ -0,0 +1,34 @@
+name: Swift
+
+on:
+ push:
+ branches: [ master, new-version ]
+ pull_request:
+
+jobs:
+ build-and-test:
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode.app
+
+ - name: Build
+ run: swift build
+
+ - name: Test
+ run: swift test
+
+ - name: Build Showcase App
+ run: |
+ cd Examples/SwiftUIChartsShowcase
+ xcodebuild \
+ -project SwiftUIChartsShowcase.xcodeproj \
+ -scheme SwiftUIChartsShowcase \
+ -sdk iphonesimulator \
+ -destination "generic/platform=iOS Simulator" \
+ CODE_SIGNING_ALLOWED=NO \
+ build
diff --git a/.gitignore b/.gitignore
index 02c08753..1a3fb064 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
/.build
/Packages
/*.xcodeproj
+**/*.xcodeproj/project.xcworkspace/xcuserdata/
+**/*.xcworkspace/xcuserdata/
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 00000000..60e30109
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,64 @@
+disabled_rules:
+- explicit_acl
+- trailing_whitespace
+- force_cast
+- unused_closure_parameter
+- multiple_closures_with_trailing_closure
+opt_in_rules:
+- anyobject_protocol
+- array_init
+- attributes
+- collection_alignment
+- colon
+- conditional_returns_on_newline
+- convenience_type
+- empty_count
+- empty_string
+- empty_collection_literal
+- enum_case_associated_values_count
+- function_default_parameter_at_end
+- fatal_error_message
+- file_name
+- first_where
+- modifier_order
+- toggle_bool
+- unused_private_declaration
+- yoda_condition
+excluded:
+- Carthage
+- Pods
+- SwiftLint/Common/3rdPartyLib
+identifier_name:
+ excluded:
+ - a
+ - b
+ - c
+ - i
+ - id
+ - t
+ - to
+ - x
+ - y
+line_length:
+ warning: 150
+ error: 200
+ ignores_function_declarations: true
+ ignores_comments: true
+ ignores_urls: true
+function_body_length:
+ warning: 300
+ error: 500
+function_parameter_count:
+ warning: 6
+ error: 8
+type_body_length:
+ warning: 300
+ error: 400
+file_length:
+ warning: 500
+ error: 1200
+ ignore_comment_only_lines: true
+cyclomatic_complexity:
+ warning: 15
+ error: 21
+reporter: "xcode"
diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate
index aae2473e..057e951c 100644
Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 00000000..aae6dfe7
Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 00000000..a5f64f0a
Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 00000000..a0f26bbb
--- /dev/null
+++ b/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ SchemeUserState
+
+ SwiftUICharts.xcscheme_^#shared#^_
+
+ orderHint
+ 2
+
+
+
+
diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
new file mode 100644
index 00000000..867e4fe8
--- /dev/null
+++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -0,0 +1,6 @@
+
+
+
diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 00000000..1be8e537
--- /dev/null
+++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ SchemeUserState
+
+ SwiftUICharts.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+
+ SuppressBuildableAutocreation
+
+ SwiftUICharts
+
+ primary
+
+
+ SwiftUIChartsTests
+
+ primary
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..ffd6c6c3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,33 @@
+# Changelog
+
+## 2.0.0
+
+### Added
+
+- Full SwiftUI-idiomatic modifier API:
+ - `chartData`, `chartXRange`, `chartYRange`
+ - `chartGridLines`, `chartGridStroke`, `chartGridBaseline`
+ - `chartXAxisLabels`, `chartYAxisLabels`, `chartAxisFont`, `chartAxisColor`
+ - `chartLineWidth`, `chartLineBackground`, `chartLineMarks`, `chartLineStyle`, `chartLineAnimation`
+ - `chartInteractionValue`
+ - `chartSelectionHandler`
+ - `chartPerformance`
+- Immutable chart configuration structs and environment-key-based composition.
+- Updated docs/examples and generated showcase app.
+- Dynamic streaming data source support via `ChartStreamingDataSource`.
+- Unified X-axis alignment strategy shared across chart layers.
+- Apple privacy manifest for SDK distribution (`PrivacyInfo.xcprivacy`).
+
+### Changed
+
+- Major clean break from mutating chain APIs.
+- Chart style/data/range/axis/grid/line configs are now modifier-driven and value-based.
+
+### Fixed
+
+- Removed required `@EnvironmentObject` interaction dependency for basic chart rendering paths.
+- Existing regression and smoke tests remain green after API shift.
+
+### Migration
+
+- See `MIGRATION.md` for full old-to-new mapping.
diff --git a/Examples/SwiftUIChartsShowcase/README.md b/Examples/SwiftUIChartsShowcase/README.md
new file mode 100644
index 00000000..ef33ba81
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/README.md
@@ -0,0 +1,18 @@
+# SwiftUICharts Showcase App
+
+This iOS app demonstrates the current composable API features of `SwiftUICharts`:
+
+- Line chart with marks, ranges, style, background fill, and animation
+- Grid and axis labels
+- Multiple overlaid line charts
+- Mixed bar + line chart in one frame
+- Interactive bar chart with shared `ChartValue` + `ChartLabel`
+- Pie and rings charts
+- Card-based composition with `CardView`
+
+## Open in Xcode
+
+1. `cd Examples/SwiftUIChartsShowcase`
+2. `xcodegen generate`
+3. Open `SwiftUIChartsShowcase.xcodeproj`
+4. Run the `SwiftUIChartsShowcase` scheme on an iOS simulator
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.pbxproj b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..06a1eba3
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.pbxproj
@@ -0,0 +1,341 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0A17A65BC7246EF894FB4C46 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8FEFD8F7975122CADED38BF /* AppDelegate.swift */; };
+ 1D4CE1A2A00E43B69F8E917B /* ShowcaseDynamicLabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D307985C5742659FBD2048 /* ShowcaseDynamicLabView.swift */; };
+ 282BC43F66ABF75981A2CA5E /* ShowcaseHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472650295568456DC645EC4F /* ShowcaseHomeView.swift */; };
+ 5C46F84CBBE811CF1BACDC53 /* SwiftUICharts in Frameworks */ = {isa = PBXBuildFile; productRef = C3965A0A2A9FEBF45BC880B8 /* SwiftUICharts */; };
+ 78F8536EADC5FE60A9B6D6B6 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4C0A162EAAD03257FCC320 /* SceneDelegate.swift */; };
+ 8BC2B530A7D14FACB535629E /* ShowcaseTabContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812FA90A31E14920A08CC991 /* ShowcaseTabContainerView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 07C74870F224E2F5213D5F81 /* ChartView */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ChartView; path = ../..; sourceTree = SOURCE_ROOT; };
+ 09A022B6E881286CD3B96CB5 /* SwiftUIChartsShowcase.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = SwiftUIChartsShowcase.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 472650295568456DC645EC4F /* ShowcaseHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowcaseHomeView.swift; sourceTree = ""; };
+ 4EC45D31E6FCC4FF27B4F3B7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
+ 812FA90A31E14920A08CC991 /* ShowcaseTabContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowcaseTabContainerView.swift; sourceTree = ""; };
+ 89D307985C5742659FBD2048 /* ShowcaseDynamicLabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowcaseDynamicLabView.swift; sourceTree = ""; };
+ CA4C0A162EAAD03257FCC320 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ F8FEFD8F7975122CADED38BF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ F9428672158235A7E14E3CBE /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5C46F84CBBE811CF1BACDC53 /* SwiftUICharts in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 58E5FB23E95DB74437A4AAC4 /* Packages */ = {
+ isa = PBXGroup;
+ children = (
+ 07C74870F224E2F5213D5F81 /* ChartView */,
+ );
+ name = Packages;
+ sourceTree = "";
+ };
+ A30E7F93B90AC7C66A7B151C = {
+ isa = PBXGroup;
+ children = (
+ 58E5FB23E95DB74437A4AAC4 /* Packages */,
+ C75B23F4C6E0261F5CF362F1 /* SwiftUIChartsShowcaseApp */,
+ A54955542983F1195821FBA1 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A54955542983F1195821FBA1 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 09A022B6E881286CD3B96CB5 /* SwiftUIChartsShowcase.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ C75B23F4C6E0261F5CF362F1 /* SwiftUIChartsShowcaseApp */ = {
+ isa = PBXGroup;
+ children = (
+ F8FEFD8F7975122CADED38BF /* AppDelegate.swift */,
+ 4EC45D31E6FCC4FF27B4F3B7 /* Info.plist */,
+ CA4C0A162EAAD03257FCC320 /* SceneDelegate.swift */,
+ 472650295568456DC645EC4F /* ShowcaseHomeView.swift */,
+ 812FA90A31E14920A08CC991 /* ShowcaseTabContainerView.swift */,
+ 89D307985C5742659FBD2048 /* ShowcaseDynamicLabView.swift */,
+ );
+ path = SwiftUIChartsShowcaseApp;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 1157F58C037245E8EAA47268 /* SwiftUIChartsShowcase */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = BAC9D2E2CC2F3EFE9AE1C7D7 /* Build configuration list for PBXNativeTarget "SwiftUIChartsShowcase" */;
+ buildPhases = (
+ 2FEC21B48626CFA9E502C36C /* Sources */,
+ F9428672158235A7E14E3CBE /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SwiftUIChartsShowcase;
+ packageProductDependencies = (
+ C3965A0A2A9FEBF45BC880B8 /* SwiftUICharts */,
+ );
+ productName = SwiftUIChartsShowcase;
+ productReference = 09A022B6E881286CD3B96CB5 /* SwiftUIChartsShowcase.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 8B7A7FE05FB51DACF82D3B42 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ };
+ };
+ buildConfigurationList = E4A630DF3DA41D75EA5D748D /* Build configuration list for PBXProject "SwiftUIChartsShowcase" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = A30E7F93B90AC7C66A7B151C;
+ packageReferences = (
+ 71626CD6A68405D598C36A30 /* XCLocalSwiftPackageReference "../.." */,
+ );
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 1157F58C037245E8EAA47268 /* SwiftUIChartsShowcase */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 2FEC21B48626CFA9E502C36C /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0A17A65BC7246EF894FB4C46 /* AppDelegate.swift in Sources */,
+ 78F8536EADC5FE60A9B6D6B6 /* SceneDelegate.swift in Sources */,
+ 282BC43F66ABF75981A2CA5E /* ShowcaseHomeView.swift in Sources */,
+ 8BC2B530A7D14FACB535629E /* ShowcaseTabContainerView.swift in Sources */,
+ 1D4CE1A2A00E43B69F8E917B /* ShowcaseDynamicLabView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 53F29F46DD197EDB34FB3BBE /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ INFOPLIST_FILE = SwiftUIChartsShowcaseApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.appear.swiftuicharts.showcase;
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ B63534D891123660CA98BC1E /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ INFOPLIST_FILE = SwiftUIChartsShowcaseApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.appear.swiftuicharts.showcase;
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ B94BEAEF1BE98CCEA81C480D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ CC0F00DD9662CAB69A592EDC /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ BAC9D2E2CC2F3EFE9AE1C7D7 /* Build configuration list for PBXNativeTarget "SwiftUIChartsShowcase" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B63534D891123660CA98BC1E /* Debug */,
+ 53F29F46DD197EDB34FB3BBE /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ E4A630DF3DA41D75EA5D748D /* Build configuration list for PBXProject "SwiftUIChartsShowcase" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ CC0F00DD9662CAB69A592EDC /* Debug */,
+ B94BEAEF1BE98CCEA81C480D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 71626CD6A68405D598C36A30 /* XCLocalSwiftPackageReference "../.." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../..;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ C3965A0A2A9FEBF45BC880B8 /* SwiftUICharts */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = SwiftUICharts;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 8B7A7FE05FB51DACF82D3B42 /* Project object */;
+}
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/xcshareddata/xcschemes/SwiftUIChartsShowcase.xcscheme b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/xcshareddata/xcschemes/SwiftUIChartsShowcase.xcscheme
new file mode 100644
index 00000000..d43aa136
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/xcshareddata/xcschemes/SwiftUIChartsShowcase.xcscheme
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/AppDelegate.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/AppDelegate.swift
new file mode 100644
index 00000000..acacd5c9
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/AppDelegate.swift
@@ -0,0 +1,16 @@
+import UIKit
+
+@UIApplicationMain
+final class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ true
+ }
+
+ func application(_ application: UIApplication,
+ configurationForConnecting connectingSceneSession: UISceneSession,
+ options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
+ }
+}
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/Info.plist b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/Info.plist
new file mode 100644
index 00000000..e46a7097
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/Info.plist
@@ -0,0 +1,51 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+
+
+
+
+ UILaunchStoryboardName
+
+ UIMainStoryboardFile
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/SceneDelegate.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/SceneDelegate.swift
new file mode 100644
index 00000000..14966b1a
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/SceneDelegate.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+import UIKit
+
+final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+ var window: UIWindow?
+
+ func scene(_ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions) {
+ guard let windowScene = scene as? UIWindowScene else {
+ return
+ }
+
+ let window = UIWindow(windowScene: windowScene)
+ window.rootViewController = UIHostingController(rootView: ShowcaseTabContainerView())
+ self.window = window
+ window.makeKeyAndVisible()
+ }
+}
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseDynamicLabView.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseDynamicLabView.swift
new file mode 100644
index 00000000..8139c0c8
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseDynamicLabView.swift
@@ -0,0 +1,125 @@
+import SwiftUI
+import SwiftUICharts
+import UIKit
+
+struct ShowcaseDynamicLabView: View {
+ @ObservedObject private var stream = ChartStreamingDataSource(initialValues: [28, 31, 30, 35, 33, 36, 34, 37],
+ windowSize: 8,
+ autoScroll: true)
+ @State private var timer: Timer?
+ @State private var callbackText = "Touch bars to receive callback events."
+
+ private var pageBackgroundColor: Color { Color(UIColor.systemGroupedBackground) }
+ private var cardBackgroundColor: Color { Color(UIColor.secondarySystemGroupedBackground) }
+ private var chartSurfaceColor: Color { Color(UIColor.secondarySystemBackground) }
+
+ var body: some View {
+ NavigationView {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ liveStreamSection
+ callbackSection
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .navigationBarTitle("Dynamic Data Lab", displayMode: .inline)
+ .background(pageBackgroundColor)
+ }
+ .onAppear(perform: startFeed)
+ .onDisappear(perform: stopFeed)
+ }
+
+ private var liveStreamSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Streaming Feed (Mock Dynamic)")
+ .font(.headline)
+ Text("A timer appends points continuously to emulate live network updates.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData(stream)
+ .chartYRange(stream.suggestedYRange)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.green, .blue)))
+ .chartLineMarks(true, color: ColorGradient(.green, .blue))
+ }
+ .chartGridLines(horizontal: 5, vertical: max(2, stream.values.count))
+ }
+ .chartXAxisLabels(stream.xLabels)
+ .chartYAxisAutoTicks(5, format: .number)
+ .chartAxisFont(.caption)
+ .chartAxisColor(.secondary)
+ .frame(height: 240)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var callbackSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Selection Callback Output")
+ .font(.headline)
+ Text(callbackText)
+ .font(.caption.monospacedDigit())
+ .foregroundColor(.secondary)
+
+ AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData(stream.values)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: [
+ ColorGradient(.orange, .red),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.green, .yellow)
+ ]))
+ }
+ .chartGridLines(horizontal: 4, vertical: 0)
+ }
+ .chartXAxisLabels(stream.xLabels)
+ .chartYAxisAutoTicks(4, format: .number)
+ .chartAxisFont(.caption)
+ .chartAxisColor(.secondary)
+ .chartSelectionHandler { event in
+ guard event.isActive,
+ let value = event.value,
+ let index = event.index else {
+ callbackText = "No active selection"
+ return
+ }
+
+ callbackText = "Slot \(index + 1): \(String(format: "%.2f", value))"
+ }
+ .frame(height: 230)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private func startFeed() {
+ guard timer == nil else { return }
+
+ let next = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { _ in
+ let drift = Double.random(in: -2.0...2.0)
+ let value = min(45, max(20, stream.latestValue + drift))
+ stream.append(value)
+ }
+ next.tolerance = 0.25
+ timer = next
+ }
+
+ private func stopFeed() {
+ timer?.invalidate()
+ timer = nil
+ }
+}
+
+struct ShowcaseDynamicLabView_Previews: PreviewProvider {
+ static var previews: some View {
+ ShowcaseDynamicLabView()
+ }
+}
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseHomeView.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseHomeView.swift
new file mode 100644
index 00000000..f8af1bf0
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseHomeView.swift
@@ -0,0 +1,538 @@
+import SwiftUI
+import SwiftUICharts
+import UIKit
+
+struct ShowcaseHomeView: View {
+ private let sharedBarValue = ChartValue()
+ private let lineSelectionValue = ChartValue()
+ @ObservedObject private var streamingSource = ChartStreamingDataSource(initialValues: [18, 23, 20, 27, 29, 24, 28, 31],
+ windowSize: 8,
+ autoScroll: true)
+ @State private var hiddenSeries: Set = []
+ @State private var streamTimer: Timer?
+ @State private var highContrastEnabled = false
+ @State private var performanceModeEnabled = true
+ @State private var callbackSelectionText = "Drag bars to receive callback events"
+ private let denseSeries: [(Double, Double)] = ShowcaseHomeView.makeDenseSeries()
+ private var pageBackgroundColor: Color { Color(UIColor.systemGroupedBackground) }
+ private var cardBackgroundColor: Color { Color(UIColor.secondarySystemGroupedBackground) }
+ private var chartSurfaceColor: Color { Color(UIColor.secondarySystemBackground) }
+ private var axisColor: Color { .secondary }
+ private var ringsBackgroundGradient: ColorGradient {
+ ColorGradient(chartSurfaceColor, Color(UIColor.tertiarySystemBackground))
+ }
+
+ var body: some View {
+ NavigationView {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ headline
+ lineInteractionSection
+ dynamicDataSection
+ lineChartSection
+ accessibilitySection
+ axisEngineSection
+ performanceSection
+ selectionCallbackSection
+ overlayLineSection
+ legendControlSection
+ mixedChartSection
+ interactiveBarCard
+ pieAndRingsSection
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .navigationBarTitle("SwiftUICharts Showcase", displayMode: .inline)
+ .background(pageBackgroundColor)
+ }
+ .onAppear(perform: startStreamingSimulation)
+ .onDisappear(perform: stopStreamingSimulation)
+ }
+
+ private var headline: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Composable chart examples")
+ .font(.title)
+ .bold()
+ Text("Demonstrates line, bar, pie, and rings charts with modifier-based data, style, axis, grid, and interaction APIs.")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private var lineChartSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Line Chart + Grid + Axis + Marks + Range")
+ .font(.headline)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineWidth(3)
+ .chartLineBackground(ColorGradient(.blue.opacity(0.2), .clear))
+ .chartLineMarks(true, color: ColorGradient(.blue, .purple))
+ .chartLineStyle(.curved)
+ .chartLineAnimation(true)
+ .chartData([12, 34, 23, 18, 36, 22, 26])
+ .chartYRange(10...40)
+ .chartXRange(0...6)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.blue, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 6)
+ }
+ .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6)
+ .chartYAxisLabels([(0, "10"), (1, "20"), (2, "30"), (3, "40")], range: 0...3)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var axisEngineSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Auto Tick Engine (Date + Collision + Rotation)")
+ .font(.headline)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineWidth(3)
+ .chartLineMarks(true)
+ .chartData(weekTimeSeries)
+ .chartYRange(10...40)
+ .chartXRange(weekTimeRange)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.green, .blue)))
+ }
+ .chartGridLines(horizontal: 4, vertical: 6)
+ }
+ .chartXAxisAutoTicks(6, format: .shortDate)
+ .chartYAxisAutoTicks(4, format: .number)
+ .chartXAxisLabelRotation(.degrees(-24))
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 230)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var performanceSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Large Dataset Performance Mode")
+ .font(.headline)
+ Spacer(minLength: 8)
+ Toggle("Performance", isOn: $performanceModeEnabled)
+ .labelsHidden()
+ }
+
+ Text("2000 points rendered with optional downsampling + simplified line style.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData(denseSeries)
+ .chartYRange(-2...2)
+ .chartXRange(0...Double(max(0, denseSeries.count - 1)))
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.purple, .blue)))
+ .chartPerformance(performanceModeEnabled
+ ? .automatic(threshold: 600, maxPoints: 180, simplifyLineStyle: true)
+ : .none)
+ }
+ .chartGridLines(horizontal: 4, vertical: 6)
+ }
+ .chartXAxisAutoTicks(6, format: .number)
+ .chartYAxisAutoTicks(5, format: .number)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var accessibilitySection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Accessibility + High Contrast")
+ .font(.headline)
+ Spacer(minLength: 8)
+ Toggle("High Contrast", isOn: $highContrastEnabled)
+ .labelsHidden()
+ }
+
+ Text("VoiceOver labels are available for bars, points, slices, and rings.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([8, 14, 11, 17, 15, 19, 16])
+ .chartStyle(highContrastEnabled ? .highContrast : ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.orange, .red)))
+ }
+ .chartGridLines(horizontal: 4, vertical: 0)
+ }
+ .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6)
+ .chartYAxisAutoTicks(4, format: .number)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 210)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var selectionCallbackSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Selection Callback (No ChartValue)")
+ .font(.headline)
+
+ Text(callbackSelectionText)
+ .font(.caption.monospacedDigit())
+ .foregroundColor(.secondary)
+
+ AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([11, 17, 15, 20, 16, 14, 19])
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: [
+ ColorGradient(.red, .orange),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.green, .yellow)
+ ]))
+ }
+ .chartGridLines(horizontal: 4, vertical: 0)
+ }
+ .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6)
+ .chartYAxisAutoTicks(4, format: .number)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .chartSelectionHandler { event in
+ guard event.isActive,
+ let value = event.value,
+ let index = event.index else {
+ callbackSelectionText = "No active selection"
+ return
+ }
+
+ callbackSelectionText = "Selected index \(index + 1): \(String(format: "%.1f", value))"
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var lineInteractionSection: some View {
+ CardView {
+ ChartLabel("Line Selection", type: .title)
+ ChartLabel("Drag to inspect points", type: .legend, format: "%.1f")
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineWidth(3)
+ .chartLineMarks(true, color: ColorGradient(.pink, .purple))
+ .chartLineStyle(.curved)
+ .chartData([14, 18, 12, 26, 22, 30, 24])
+ .chartYRange(10...35)
+ .chartXRange(0...6)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.pink, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 6)
+ }
+ .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 170)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 280)
+ .padding(6)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.pink, .purple)))
+ .chartInteractionValue(lineSelectionValue)
+ }
+
+ private var dynamicDataSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Streaming Data Source")
+ .font(.headline)
+ Spacer(minLength: 8)
+ Text(String(format: "%.1f", streamingSource.latestValue))
+ .font(.subheadline.monospacedDigit())
+ .foregroundColor(.secondary)
+ }
+ Text("First-class streaming helper with append/window/auto-scroll.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineWidth(3)
+ .chartLineMarks(true, color: ColorGradient(.green, .blue))
+ .chartLineStyle(.curved)
+ .chartLineAnimation(true)
+ .chartData(streamingSource)
+ .chartYRange(streamingSource.suggestedYRange)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.green, .blue)))
+ }
+ .chartGridLines(horizontal: 5, vertical: max(2, streamingSource.values.count))
+ }
+ .chartXAxisLabels(streamingSource.xLabels)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var overlayLineSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Multiple Line Charts In One Frame")
+ .font(.headline)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineMarks(true)
+ .chartLineStyle(.curved)
+ .chartData([3, 5, 4, 1, 0, 2, 4])
+ .chartYRange(0...8)
+ .chartXRange(0...6)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.orange, .red)))
+
+ LineChart()
+ .chartLineMarks(true)
+ .chartLineStyle(.straight)
+ .chartLineAnimation(false)
+ .chartData([4, 1, 0, 2, 6, 3, 5])
+ .chartYRange(0...8)
+ .chartXRange(0...6)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.green, .yellow)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 6)
+ }
+ .chartXAxisLabels([(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7")], range: 0...6)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var mixedChartSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Mixed Bar + Line Chart")
+ .font(.headline)
+
+ AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([2, 4, 1, 3, 5])
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.orange, .red)))
+
+ LineChart()
+ .chartLineMarks(true)
+ .chartData([2, 4, 1, 3, 5])
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.blue, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 5)
+ }
+ .chartXAxisLabels([(0, "A"), (1, "B"), (2, "C"), (3, "D"), (4, "E")], range: 0...4)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var legendControlSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Legend + Series Visibility")
+ .font(.headline)
+
+ ChartLegend(items: [
+ ChartLegendItem(id: "sales", title: "Sales", color: ColorGradient(.orange, .red)),
+ ChartLegendItem(id: "forecast", title: "Forecast", color: ColorGradient(.blue, .purple))
+ ], hiddenSeries: $hiddenSeries)
+ .padding(.horizontal, 8)
+
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartSeriesID("sales")
+ .chartLineMarks(true)
+ .chartData([2, 4, 3, 5, 4, 6, 7])
+ .chartYRange(0...8)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.orange, .red)))
+
+ LineChart()
+ .chartSeriesID("forecast")
+ .chartLineMarks(true)
+ .chartData([1, 3, 4, 4, 5, 5, 6])
+ .chartYRange(0...8)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.blue, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 6)
+ }
+ .chartHiddenSeries(hiddenSeries)
+ .chartXAxisLabels([(0, "Mon"), (1, "Tue"), (2, "Wed"), (3, "Thu"), (4, "Fri"), (5, "Sat"), (6, "Sun")], range: 0...6)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 220)
+ .padding(12)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+
+ private var interactiveBarCard: some View {
+ CardView {
+ ChartLabel("Weekly Sales", type: .title)
+ ChartLabel("Drag bars to inspect values", type: .legend, format: "%.0f")
+
+ AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([14, 22, 18, 31, 26, 19, 24])
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: [
+ ColorGradient(.red, .orange),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.green, .yellow)
+ ]))
+ }
+ .chartGridLines(horizontal: 5, vertical: 0)
+ }
+ .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6)
+ .chartAxisColor(axisColor)
+ .chartAxisFont(.caption)
+ .frame(maxWidth: .infinity)
+ .frame(height: 170)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 280)
+ .padding(6)
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: ColorGradient(.blue, .purple)))
+ .chartInteractionValue(sharedBarValue)
+ }
+
+ private var pieAndRingsSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Pie and Rings Charts")
+ .font(.headline)
+
+ HStack(spacing: 12) {
+ PieChart()
+ .chartData([34, 23, 12])
+ .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor,
+ foregroundColor: [
+ ColorGradient(.red, .orange),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.green, .yellow)
+ ]))
+ .frame(maxWidth: .infinity)
+ .frame(height: 180)
+ .padding(10)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+
+ RingsChart()
+ .chartData([25, 50, 75, 90])
+ .chartStyle(ChartStyle(backgroundColor: ringsBackgroundGradient,
+ foregroundColor: [
+ ColorGradient(.purple, .blue),
+ ColorGradient(.orange, .red),
+ ColorGradient(.green, .yellow),
+ ColorGradient(.pink, .purple)
+ ]))
+ .frame(maxWidth: .infinity)
+ .frame(height: 180)
+ .padding(10)
+ .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor))
+ }
+ }
+ }
+
+ private var weekTimeSeries: [(Double, Double)] {
+ let day: TimeInterval = 24 * 60 * 60
+ let start = Date(timeIntervalSince1970: 1_704_067_200).timeIntervalSince1970
+ let points: [Double] = [12, 16, 21, 19, 28, 24, 31]
+ return points.enumerated().map { index, value in
+ (start + (Double(index) * day), value)
+ }
+ }
+
+ private var weekTimeRange: ClosedRange? {
+ guard let start = weekTimeSeries.first?.0, let end = weekTimeSeries.last?.0 else { return nil }
+ return start...end
+ }
+
+ private static func makeDenseSeries() -> [(Double, Double)] {
+ (0..<2_000).map { index in
+ let x = Double(index)
+ let y = sin(x / 45.0) + (cos(x / 12.0) * 0.25)
+ return (x, y)
+ }
+ }
+}
+
+struct ShowcaseHomeView_Previews: PreviewProvider {
+ static var previews: some View {
+ ShowcaseHomeView()
+ }
+}
+
+private extension ShowcaseHomeView {
+ func startStreamingSimulation() {
+ guard streamTimer == nil else { return }
+
+ let timer = Timer.scheduledTimer(withTimeInterval: 1.8, repeats: true) { _ in
+ let current = streamingSource.latestValue
+ let delta = Double.random(in: -4.5...4.5)
+ let next = min(45, max(10, current + delta))
+ streamingSource.append(next)
+ }
+ timer.tolerance = 0.35
+ streamTimer = timer
+ }
+
+ func stopStreamingSimulation() {
+ streamTimer?.invalidate()
+ streamTimer = nil
+ }
+}
diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseTabContainerView.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseTabContainerView.swift
new file mode 100644
index 00000000..8ea87965
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseTabContainerView.swift
@@ -0,0 +1,27 @@
+import SwiftUI
+
+struct ShowcaseTabContainerView: View {
+ var body: some View {
+ TabView {
+ ShowcaseHomeView()
+ .tabItem {
+ Image(systemName: "chart.xyaxis.line")
+ Text("Showcase")
+ }
+ .tag(0)
+
+ ShowcaseDynamicLabView()
+ .tabItem {
+ Image(systemName: "waveform.path.ecg")
+ Text("Dynamic")
+ }
+ .tag(1)
+ }
+ }
+}
+
+struct ShowcaseTabContainerView_Previews: PreviewProvider {
+ static var previews: some View {
+ ShowcaseTabContainerView()
+ }
+}
diff --git a/Examples/SwiftUIChartsShowcase/project.yml b/Examples/SwiftUIChartsShowcase/project.yml
new file mode 100644
index 00000000..1405fcb9
--- /dev/null
+++ b/Examples/SwiftUIChartsShowcase/project.yml
@@ -0,0 +1,31 @@
+name: SwiftUIChartsShowcase
+options:
+ bundleIdPrefix: com.appear
+settings:
+ base:
+ IPHONEOS_DEPLOYMENT_TARGET: 13.0
+targets:
+ SwiftUIChartsShowcase:
+ type: application
+ platform: iOS
+ deploymentTarget: "13.0"
+ sources:
+ - path: SwiftUIChartsShowcaseApp
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.appear.swiftuicharts.showcase
+ INFOPLIST_FILE: SwiftUIChartsShowcaseApp/Info.plist
+ SWIFT_VERSION: 5.0
+ dependencies:
+ - package: SwiftUICharts
+ product: SwiftUICharts
+packages:
+ SwiftUICharts:
+ path: ../..
+schemes:
+ SwiftUIChartsShowcase:
+ build:
+ targets:
+ SwiftUIChartsShowcase: all
+ run:
+ config: Debug
diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 00000000..8bc25fb7
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,150 @@
+# SwiftUICharts Migration Guide
+
+## Target release
+
+This guide targets `2.0.0`.
+
+`2.0.0` is a major breaking release and phases out the old mutable chain API in favor of composable modifier-based configuration.
+
+## Version direction
+
+This release moves to a strict SwiftUI-idiomatic modifier API.
+
+- Immutable configuration
+- `ViewModifier` composition
+- Environment keys instead of mutable reference state in view structs
+
+## Migration checklist
+
+1. Replace legacy chart types with `LineChart`, `BarChart`, `PieChart`, `RingsChart`.
+2. Replace old chain methods with `chart...` modifiers.
+3. Migrate interaction:
+ - shared value model: `chartInteractionValue(_:)`
+ - callback model: `chartSelectionHandler(_:)`
+4. Validate axis label/range behavior under the unified X-axis alignment model.
+5. Run `swift test` and build the showcase app to verify rendering parity.
+
+## Old -> new mapping
+
+### Previous chart data/range chains
+
+| Old | New |
+| --- | --- |
+| `.data([Double])` | `.chartData([Double])` |
+| `.data([(Double, Double)])` | `.chartData([(Double, Double)])` |
+| `.rangeX(...)` | `.chartXRange(...)` |
+| `.rangeY(...)` | `.chartYRange(...)` |
+
+### Grid chains
+
+| Old | New |
+| --- | --- |
+| `.setNumberOfHorizontalLines(h)` + `.setNumberOfVerticalLines(v)` | `.chartGridLines(horizontal: h, vertical: v)` |
+| `.setStoreStyle(style)` + `.setColor(color)` | `.chartGridStroke(style: style, color: color)` |
+| `.showBaseLine(show, with: style)` | `.chartGridBaseline(show, style: style)` |
+
+### Axis chains
+
+| Old | New |
+| --- | --- |
+| `.setAxisXLabels([String])` | `.chartXAxisLabels([String])` |
+| `.setAxisXLabels([(Double, String)], range:)` | `.chartXAxisLabels([(Double, String)], range:)` |
+| `.setAxisYLabels([String], position:)` | `.chartYAxisLabels([String], position:)` |
+| `.setAxisYLabels([(Double, String)], range:, position:)` | `.chartYAxisLabels([(Double, String)], range:, position:)` |
+| `.setFont(font)` | `.chartAxisFont(font)` |
+| `.setColor(color)` | `.chartAxisColor(color)` |
+
+### Line-specific chains
+
+| Old | New |
+| --- | --- |
+| `.setLineWidth(width:)` | `.chartLineWidth(...)` |
+| `.setBackground(colorGradient:)` | `.chartLineBackground(...)` |
+| `.showChartMarks(_, with:)` | `.chartLineMarks(_, color:)` |
+| `.setLineStyle(to:)` | `.chartLineStyle(...)` |
+| `.withAnimation(_)` | `.chartLineAnimation(...)` |
+
+### Interaction wiring
+
+| Old | New |
+| --- | --- |
+| `.chartValue(...)` on chart views | `.chartInteractionValue(...)` on any parent container |
+| `@EnvironmentObject ChartValue` requirement | optional environment interaction value |
+| N/A | `.chartSelectionHandler { event in ... }` callback-based selection |
+
+### Legacy public type replacements
+
+| Legacy type | Replacement |
+| --- | --- |
+| `LineChartView` | `LineChart` with modifiers |
+| `BarChartView` | `BarChart` with modifiers |
+| `PieChartView` | `PieChart` with modifiers |
+| `MultiLineChartView` | multiple `LineChart` overlays |
+| `LineView` | `CardView` + `LineChart` + modifiers |
+| `GradientColor` | `ColorGradient` |
+| `GradientColors` | explicit `ColorGradient` values |
+| `Colors` | `ChartColors` + `Color` |
+| `Styles` | explicit `ChartStyle` initialization |
+| `ChartForm` | SwiftUI layout (`frame`, stacks, spacing) |
+| `MultiLineChartData` | multiple `chartData`-configured line layers |
+| `MagnifierRect` | no direct replacement |
+| `TestData` | app/test-local fixtures |
+
+## Example migration
+
+Before:
+
+```swift
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ .showChartMarks(true)
+ .data([8, 23, 54, 32])
+ .rangeY(0...60)
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: ColorGradient(.orange, .red)))
+ }
+ .setNumberOfHorizontalLines(5)
+ .setNumberOfVerticalLines(4)
+}
+.setAxisXLabels(["Q1", "Q2", "Q3", "Q4"])
+```
+
+After:
+
+```swift
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineMarks(true)
+ .chartData([8, 23, 54, 32])
+ .chartYRange(0...60)
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: ColorGradient(.orange, .red)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 4)
+}
+.chartXAxisLabels(["Q1", "Q2", "Q3", "Q4"])
+```
+
+## Advanced migration examples
+
+### Dynamic streaming data
+
+```swift
+@ObservedObject private var stream = ChartStreamingDataSource(initialValues: [12, 14, 18, 16],
+ windowSize: 8,
+ autoScroll: true)
+
+LineChart()
+ .chartData(stream)
+ .chartYRange(stream.suggestedYRange)
+```
+
+### Performance mode for large datasets
+
+```swift
+LineChart()
+ .chartData(largeSeries)
+ .chartPerformance(.automatic(threshold: 600, maxPoints: 180, simplifyLineStyle: true))
+```
diff --git a/Package.swift b/Package.swift
index ffd10e06..5f25255e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version:5.1
+// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -6,13 +6,13 @@ import PackageDescription
let package = Package(
name: "SwiftUICharts",
platforms: [
- .iOS(.v13),.watchOS(.v6)
+ .iOS(.v13), .watchOS(.v6), .macOS(.v10_15)
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "SwiftUICharts",
- targets: ["SwiftUICharts"]),
+ targets: ["SwiftUICharts"])
],
dependencies: [
// Dependencies declare other packages that this package depends on.
@@ -23,9 +23,12 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "SwiftUICharts",
- dependencies: []),
+ dependencies: [],
+ resources: [
+ .process("PrivacyInfo.xcprivacy")
+ ]),
.testTarget(
name: "SwiftUIChartsTests",
- dependencies: ["SwiftUICharts"]),
+ dependencies: ["SwiftUICharts"])
]
)
diff --git a/README.md b/README.md
index 23f773a2..f9728d5d 100644
--- a/README.md
+++ b/README.md
@@ -1,116 +1,187 @@
# SwiftUICharts
-Swift package for displaying charts effortlessly.
+SwiftUICharts is a composable, SwiftUI-native chart library for iOS 13+.
-
+`2.0.0` is a major release focused on immutable configuration, environment-driven composition, and modifier-based APIs.
-It supports:
-* Line charts
-* Bar charts
-* Pie charts
+
+
+
-### Installation:
+## AI Agent Quick Context
-It requires iOS 13 and xCode 11!
+Use this section when an AI agent generates code against this package.
-In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView`
+### API contract
-### Usage:
+- Use only `2.x` composable APIs.
+- Do not use legacy `1.x` types or mutating chains.
+- Build charts with `ViewModifier` composition.
+- Keep data source semantics explicit:
+ - `chartData([Double])` = categorical slots
+ - `chartData([(Double, Double)])` = numeric/continuous domain
-import the package in the file you would like to use it: `import SwiftUICharts`
+### Use these APIs
-You can display a Chart by adding a chart view to your parent view:
+| Task | API |
+| --- | --- |
+| Set data | `chartData(...)` |
+| Set ranges | `chartXRange(...)`, `chartYRange(...)` |
+| Style chart | `chartStyle(...)` |
+| Grid config | `chartGridLines`, `chartGridStroke`, `chartGridBaseline` |
+| Axis labels/ticks | `chartXAxisLabels`, `chartYAxisLabels`, `chartXAxisAutoTicks`, `chartYAxisAutoTicks` |
+| Line tuning | `chartLineWidth`, `chartLineStyle`, `chartLineMarks`, `chartLineAnimation` |
+| Shared interaction | `chartInteractionValue(...)` |
+| Callback interaction | `chartSelectionHandler { event in ... }` |
+| Streaming data | `ChartStreamingDataSource` + `chartData(stream)` |
+| Large datasets | `chartPerformance(...)` |
-### Demo
+### Avoid these legacy APIs
-Added an example project, with **iOS, watchOS** target: https://github.com/AppPear/ChartViewDemo
+- `LineChartView`, `BarChartView`, `PieChartView`, `MultiLineChartView`
+- `.data(...)`, `.rangeX(...)`, `.rangeY(...)`
+- `.setAxisXLabels(...)`, `.setNumberOfHorizontalLines(...)`
+- `.showChartMarks(...)` (old form)
-## Line charts
-
+## Installation
-**Line chart is interactive, so you can drag across to reveal the data points**
+Add with Swift Package Manager:
-You can add a line chart with the following code:
+`https://github.com/AppPear/ChartView`
+
+For this release, use tag `2.0.0` (or `from: "2.0.0"` up to next major).
+
+## 30-Second Quick Start
```swift
- LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
+import SwiftUI
+import SwiftUICharts
+
+struct DemoView: View {
+ var body: some View {
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData([12, 34, 23, 18, 36, 22, 26])
+ .chartYRange(10...40)
+ .chartLineMarks(true, color: ColorGradient(.blue, .purple))
+ .chartStyle(
+ ChartStyle(
+ backgroundColor: .white,
+ foregroundColor: ColorGradient(.blue, .purple)
+ )
+ )
+ }
+ .chartGridLines(horizontal: 5, vertical: 6)
+ }
+ .chartXAxisLabels(["M", "T", "W", "T", "F", "S", "S"])
+ .chartAxisColor(.secondary)
+ .chartAxisFont(.caption)
+ .frame(height: 220)
+ .padding()
+ }
+}
```
+## Feature Recipes
-## Bar charts
-
+### 1) Mixed bar + line
-**Bar chart is interactive, so you can drag across to reveal the data points**
+```swift
+AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([2, 4, 1, 3, 5])
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: ColorGradient(.orange, .red)))
+
+ LineChart()
+ .chartData([2, 4, 1, 3, 5])
+ .chartLineMarks(true)
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: ColorGradient(.blue, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 5)
+}
+.chartXAxisLabels([(0, "A"), (1, "B"), (2, "C"), (3, "D"), (4, "E")], range: 0...4)
+```
-You can add a bar chart with the following code:
+### 2) Shared interaction state
```swift
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
-```
+let selected = ChartValue()
-You can add different formats:
-* Small `Form.small`
-* Medium `Form.medium`
-* Large `Form.large`
+VStack(alignment: .leading) {
+ ChartLabel("Weekly Sales", type: .title)
+ ChartLabel("Drag bars", type: .legend, format: "%.1f")
- ```swift
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small)
- ```
-
- ### You can customize styling of the chart with a ChartStyle object:
+ BarChart().chartData([14, 22, 18, 31, 26, 19, 24])
+}
+.chartInteractionValue(selected)
+```
-Customizable:
-* background color
-* accent color
-* second gradient color
-* text color
-* legend text color
+### 3) Callback-based interaction
```swift
- let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: Form.medium, textColor: Color.white, legendTextColor: Color.white )
- ...
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle)
+BarChart()
+ .chartData([8, 11, 13, 9, 12])
+ .chartSelectionHandler { event in
+ guard event.isActive,
+ let value = event.value,
+ let index = event.index else { return }
+ print("selected", index, value)
+ }
```
-You can access built-in styles:
+### 4) Dynamic streaming data
+
```swift
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: Styles.barChartMidnightGreen)
+@ObservedObject private var stream = ChartStreamingDataSource(
+ initialValues: [18, 23, 20, 27, 29, 24],
+ windowSize: 6,
+ autoScroll: true
+)
+
+LineChart()
+ .chartData(stream)
+ .chartYRange(stream.suggestedYRange)
```
-#### All styles available as a preset:
-* barChartStyleOrangeLight
-* barChartStyleOrangeDark
-* barChartStyleNeonBlueLight
-* barChartStyleNeonBlueDark
-* barChartMidnightGreenLight
-* barChartMidnightGreenDark
-
+### 5) Performance mode for large datasets
-
+```swift
+LineChart()
+ .chartData(largeSeries)
+ .chartPerformance(.automatic(threshold: 600,
+ maxPoints: 180,
+ simplifyLineStyle: true))
+```
+## Migration
-### You can customize the size of the chart with a Form object:
+This is a major breaking release.
-**Form**
-* `.small`
-* `.medium`
-* `.large`
-* `.detail`
+- Full migration guide: [MIGRATION.md](./MIGRATION.md)
+- Quick examples: [example.md](./example.md)
-```swift
-BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small)
-```
+## Documentation Map
-### WatchOS support for Bar charts:
+- Wiki index: [docs/wiki/README.md](./docs/wiki/README.md)
+- Getting started: [docs/wiki/01-getting-started.md](./docs/wiki/01-getting-started.md)
+- Modifiers: [docs/wiki/02-composable-modifiers.md](./docs/wiki/02-composable-modifiers.md)
+- Interaction: [docs/wiki/03-interaction-and-selection.md](./docs/wiki/03-interaction-and-selection.md)
+- Streaming: [docs/wiki/04-dynamic-and-streaming-data.md](./docs/wiki/04-dynamic-and-streaming-data.md)
+- Performance: [docs/wiki/05-performance-and-large-datasets.md](./docs/wiki/05-performance-and-large-datasets.md)
+- Axis alignment: [docs/wiki/06-unified-axis-and-label-alignment.md](./docs/wiki/06-unified-axis-and-label-alignment.md)
+- Migration summary: [docs/wiki/07-migration-from-1x.md](./docs/wiki/07-migration-from-1x.md)
-
+## Example App
-## Pie charts
-
+The showcase app demonstrates all major features:
-You can add a line chart with the following code:
+`Examples/SwiftUIChartsShowcase`
-```swift
- PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional
-```
+## Release Notes
+- Changelog: [CHANGELOG.md](./CHANGELOG.md)
+- Includes Apple privacy manifest: `Sources/SwiftUICharts/PrivacyInfo.xcprivacy`
diff --git a/Resources/barchartcard.png b/Resources/barchartcard.png
new file mode 100644
index 00000000..2e816179
Binary files /dev/null and b/Resources/barchartcard.png differ
diff --git a/Resources/barvid2.gif b/Resources/barvid2.gif
new file mode 100644
index 00000000..3e8499a6
Binary files /dev/null and b/Resources/barvid2.gif differ
diff --git a/Resources/chartpic1.png b/Resources/chartpic1.png
new file mode 100644
index 00000000..62608829
Binary files /dev/null and b/Resources/chartpic1.png differ
diff --git a/Resources/chartpic2.png b/Resources/chartpic2.png
new file mode 100644
index 00000000..939cb8fb
Binary files /dev/null and b/Resources/chartpic2.png differ
diff --git a/Resources/chartpic3.png b/Resources/chartpic3.png
new file mode 100644
index 00000000..1bd0d5e7
Binary files /dev/null and b/Resources/chartpic3.png differ
diff --git a/Resources/chartpic4.png b/Resources/chartpic4.png
new file mode 100644
index 00000000..1aa7a358
Binary files /dev/null and b/Resources/chartpic4.png differ
diff --git a/Resources/chartpic5.png b/Resources/chartpic5.png
new file mode 100644
index 00000000..d1e06b88
Binary files /dev/null and b/Resources/chartpic5.png differ
diff --git a/Resources/chartpic6.png b/Resources/chartpic6.png
new file mode 100644
index 00000000..1d16a59e
Binary files /dev/null and b/Resources/chartpic6.png differ
diff --git a/Resources/chartpic7.png b/Resources/chartpic7.png
new file mode 100644
index 00000000..aee3f227
Binary files /dev/null and b/Resources/chartpic7.png differ
diff --git a/Resources/linechartcard.png b/Resources/linechartcard.png
new file mode 100644
index 00000000..d7c49156
Binary files /dev/null and b/Resources/linechartcard.png differ
diff --git a/Resources/linevid2.gif b/Resources/linevid2.gif
new file mode 100644
index 00000000..8bc8a650
Binary files /dev/null and b/Resources/linevid2.gif differ
diff --git a/Resources/piechartcard.png b/Resources/piechartcard.png
new file mode 100644
index 00000000..0cc1e751
Binary files /dev/null and b/Resources/piechartcard.png differ
diff --git a/Resources/pievid2.gif b/Resources/pievid2.gif
new file mode 100644
index 00000000..a30279bb
Binary files /dev/null and b/Resources/pievid2.gif differ
diff --git a/Resources/ringchart1.png b/Resources/ringchart1.png
new file mode 100644
index 00000000..da2b200e
Binary files /dev/null and b/Resources/ringchart1.png differ
diff --git a/Sources/SwiftUICharts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/BarChart/BarChartCell.swift
deleted file mode 100644
index 1414ddb8..00000000
--- a/Sources/SwiftUICharts/BarChart/BarChartCell.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-//
-// ChartCell.swift
-// ChartView
-//
-// Created by András Samu on 2019. 06. 12..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct BarChartCell : View {
- var value: Double
- var index: Int = 0
- var width: Float
- var numberOfDataPoints: Int
- var cellWidth: Double {
- return Double(width)/(Double(numberOfDataPoints) * 1.5)
- }
- var accentColor: Color
- var secondGradientAccentColor: Color?
- var gradientColors:[Color] {
- if (secondGradientAccentColor != nil) {
- return [secondGradientAccentColor!, accentColor]
- }
- return [accentColor, accentColor]
- }
- @State var scaleValue: Double = 0
- @Binding var touchLocation: CGFloat
- public var body: some View {
- ZStack {
- RoundedRectangle(cornerRadius: 4)
- .fill(LinearGradient(gradient: Gradient(colors: gradientColors), startPoint: .bottom, endPoint: .top))
- }
- .frame(width: CGFloat(self.cellWidth))
- .scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom)
- .onAppear(){
- self.scaleValue = self.value
- }
- .animation(Animation.spring().delay(self.touchLocation < 0 ? Double(self.index) * 0.04 : 0))
- }
-}
-
-#if DEBUG
-struct ChartCell_Previews : PreviewProvider {
- static var previews: some View {
- BarChartCell(value: Double(0.75), width: 320, numberOfDataPoints: 12, accentColor: Colors.OrangeStart, secondGradientAccentColor: nil, touchLocation: .constant(-1))
- }
-}
-#endif
diff --git a/Sources/SwiftUICharts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/BarChart/BarChartRow.swift
deleted file mode 100644
index 242f4efd..00000000
--- a/Sources/SwiftUICharts/BarChart/BarChartRow.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-//
-// ChartRow.swift
-// ChartView
-//
-// Created by András Samu on 2019. 06. 12..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct BarChartRow : View {
- var data: [Int]
- var accentColor: Color
- var secondGradientAccentColor: Color?
- var maxValue: Int {
- data.max() ?? 0
- }
- @Binding var touchLocation: CGFloat
- public var body: some View {
- GeometryReader { geometry in
- HStack(alignment: .bottom, spacing: (geometry.frame(in: .local).width-22)/CGFloat(self.data.count * 3)){
- ForEach(0.. CGFloat(i)/CGFloat(self.data.count) && self.touchLocation < CGFloat(i+1)/CGFloat(self.data.count) ? CGSize(width: 1.4, height: 1.1) : CGSize(width: 1, height: 1), anchor: .bottom)
-
- }
- }
- .padding([.top, .leading, .trailing], 10)
- }
- }
-
- func normalizedValue(index: Int) -> Double {
- return Double(self.data[index])/Double(self.maxValue)
- }
-}
-
-#if DEBUG
-struct ChartRow_Previews : PreviewProvider {
- static var previews: some View {
- BarChartRow(data: [8,23,54,32,12,37,7], accentColor: Colors.OrangeStart, touchLocation: .constant(-1))
- }
-}
-#endif
diff --git a/Sources/SwiftUICharts/BarChart/BarChartView.swift b/Sources/SwiftUICharts/BarChart/BarChartView.swift
deleted file mode 100644
index d6013c7e..00000000
--- a/Sources/SwiftUICharts/BarChart/BarChartView.swift
+++ /dev/null
@@ -1,106 +0,0 @@
-//
-// ChartView.swift
-// ChartView
-//
-// Created by András Samu on 2019. 06. 12..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct BarChartView : View {
- public var data: [Int]
- public var title: String
- public var legend: String?
- public var style: ChartStyle
- public var formSize:CGSize
-// let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
-
- @State private var touchLocation: CGFloat = -1.0
- @State private var showValue: Bool = false
- @State private var currentValue: Int = 0 {
- didSet{
- if(oldValue != self.currentValue && self.showValue) {
-// selectionFeedbackGenerator.selectionChanged()
- HapticFeedback.playSelection()
- }
- }
- }
- var isFullWidth:Bool {
- return self.formSize == Form.large
- }
- public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = Form.medium){
- self.data = data
- self.title = title
- self.legend = legend
- self.style = style
- self.formSize = form!
- }
-
- public var body: some View {
- ZStack{
- Rectangle()
- .fill(self.style.backgroundColor)
- .cornerRadius(20)
- .shadow(color: Color.gray, radius: 8 )
- VStack(alignment: .leading){
- HStack{
- if(!showValue){
- Text(self.title)
- .font(.headline)
- .foregroundColor(self.style.textColor)
- }else{
- Text("\(self.currentValue)")
- .font(.headline)
- .foregroundColor(self.style.textColor)
- }
- if(self.formSize == Form.large && self.legend != nil && !showValue) {
- Text(self.legend!)
- .font(.callout)
- .foregroundColor(self.style.accentColor)
- .transition(.opacity)
- .animation(.easeOut)
- }
- Spacer()
- Image(systemName: "waveform.path.ecg")
- .imageScale(.large)
- .foregroundColor(self.style.legendTextColor)
- }.padding()
- BarChartRow(data: data, accentColor: self.style.accentColor, secondGradientAccentColor: self.style.secondGradientColor, touchLocation: self.$touchLocation)
- if self.legend != nil && self.formSize == Form.medium {
- Text(self.legend!)
- .font(.headline)
- .foregroundColor(self.style.legendTextColor)
- .padding()
- }
-
- }
- }.frame(minWidth:self.formSize.width, maxWidth: self.isFullWidth ? .infinity : self.formSize.width, minHeight:self.formSize.height, maxHeight:self.formSize.height)
- .gesture(DragGesture()
- .onChanged({ value in
- self.touchLocation = value.location.x/self.formSize.width
- self.showValue = true
- self.currentValue = self.getCurrentValue()
- })
- .onEnded({ value in
- self.showValue = false
- self.touchLocation = -1
- })
- )
- .gesture(TapGesture()
- )
- }
-
- func getCurrentValue()-> Int{
- let index = max(0,min(self.data.count-1,Int(floor((self.touchLocation*self.formSize.width)/(self.formSize.width/CGFloat(self.data.count))))))
- return self.data[index]
- }
-}
-
-#if DEBUG
-struct ChartView_Previews : PreviewProvider {
- static var previews: some View {
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary")
- }
-}
-#endif
diff --git a/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift b/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift
new file mode 100644
index 00000000..a90b6796
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift
@@ -0,0 +1,315 @@
+import SwiftUI
+
+public struct AxisLabels: View {
+ @Environment(\.chartAxisConfig) private var axisConfig
+
+ @State private var preferredDataPoints: [(Double, Double)] = []
+ @State private var preferredXRange: ClosedRange?
+ @State private var preferredYRange: ClosedRange?
+ @State private var preferredXDomainMode: ChartXDomainMode = .numeric
+
+ private let yAxisWidth: CGFloat = 42
+ private let xAxisHeight: CGFloat = 24
+
+ let content: () -> Content
+
+ public init(@ViewBuilder content: @escaping () -> Content) {
+ self.content = content
+ }
+
+ private var visibleXValues: [Double] {
+ preferredDataPoints
+ .filter { preferredXRange?.contains($0.0) ?? true }
+ .map(\.0)
+ }
+
+ private var visibleYValues: [Double] {
+ preferredDataPoints
+ .filter { preferredXRange?.contains($0.0) ?? true }
+ .map(\.1)
+ }
+
+ private var xRangeForScale: ClosedRange? {
+ preferredXRange ?? axisConfig.axisXRange
+ }
+
+ private var xDomainModeForScale: ChartXDomainMode {
+ preferredDataPoints.isEmpty ? axisConfig.axisXDomainMode : preferredXDomainMode
+ }
+
+ private var resolvedXAxisLabels: [ChartXAxisLabel] {
+ if !axisConfig.axisXLabels.isEmpty {
+ return axisConfig.axisXLabels
+ }
+
+ guard let autoCount = axisConfig.axisXAutoTickCount else { return [] }
+ return autoGeneratedXAxisLabels(count: autoCount)
+ }
+
+ private var resolvedYAxisLabels: [String] {
+ if !axisConfig.axisYLabels.isEmpty {
+ return axisConfig.axisYLabels
+ }
+
+ guard let autoCount = axisConfig.axisYAutoTickCount else { return [] }
+ return autoGeneratedYAxisLabels(count: autoCount)
+ }
+
+ private var hasYLabels: Bool {
+ !resolvedYAxisLabels.isEmpty
+ }
+
+ private var hasXLabels: Bool {
+ !resolvedXAxisLabels.isEmpty
+ }
+
+ private var effectiveYAxisWidth: CGFloat {
+ hasYLabels ? yAxisWidth : 0
+ }
+
+ private var effectiveXAxisHeight: CGFloat {
+ hasXLabels ? xAxisHeight : 0
+ }
+
+ private var leftAxisGutter: CGFloat {
+ axisConfig.axisLabelsYPosition == .leading ? effectiveYAxisWidth : 0
+ }
+
+ private var rightAxisGutter: CGFloat {
+ axisConfig.axisLabelsYPosition == .trailing ? effectiveYAxisWidth : 0
+ }
+
+ private var xScale: ChartXScale {
+ let scaleValues = visibleXValues.isEmpty ? resolvedXAxisLabels.map(\.value) : visibleXValues
+ return ChartXScale(values: scaleValues,
+ rangeX: xRangeForScale,
+ mode: xDomainModeForScale,
+ slotCountHint: max(scaleValues.count, resolvedXAxisLabels.count))
+ }
+
+ var yAxis: some View {
+ VStack(spacing: 0) {
+ ForEach(Array(resolvedYAxisLabels.reversed().enumerated()), id: \.offset) { index, axisYData in
+ Text(axisYData)
+ .font(axisConfig.axisFont)
+ .foregroundColor(axisConfig.axisFontColor)
+ .frame(maxWidth: .infinity,
+ alignment: axisConfig.axisLabelsYPosition == .leading ? .trailing : .leading)
+
+ if index < resolvedYAxisLabels.count - 1 {
+ Spacer(minLength: 0)
+ }
+ }
+ }
+ .padding(.horizontal, 4)
+ }
+
+ var xAxis: some View {
+ GeometryReader { geometry in
+ let safeSize = geometry.size.sanitized
+ let width = safeSize.width
+ let labels = visibleXAxisLabels(width: width)
+
+ ZStack(alignment: .topLeading) {
+ ForEach(Array(labels.enumerated()), id: \.offset) { _, xLabel in
+ positionedXLabel(xLabel, width: width)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: xAxisHeight, alignment: .top)
+ }
+
+ public var body: some View {
+ GeometryReader { geometry in
+ let safeSize = geometry.size.sanitized
+ let axisHeight = effectiveXAxisHeight
+ let chartHeight = max(0, safeSize.height - axisHeight)
+
+ VStack(spacing: 0) {
+ HStack(spacing: 0) {
+ if leftAxisGutter > 0 {
+ yAxis
+ .frame(width: leftAxisGutter, height: chartHeight, alignment: .trailing)
+ }
+
+ content()
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+
+ if rightAxisGutter > 0 {
+ yAxis
+ .frame(width: rightAxisGutter, height: chartHeight, alignment: .leading)
+ }
+ }
+ .frame(height: chartHeight, alignment: .top)
+
+ if axisHeight > 0 {
+ HStack(spacing: 0) {
+ if leftAxisGutter > 0 {
+ Color.clear.frame(width: leftAxisGutter, height: axisHeight)
+ }
+
+ xAxis
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+
+ if rightAxisGutter > 0 {
+ Color.clear.frame(width: rightAxisGutter, height: axisHeight)
+ }
+ }
+ .frame(height: axisHeight, alignment: .top)
+ }
+ }
+ .frame(width: safeSize.width, height: safeSize.height, alignment: .topLeading)
+ }
+ .onPreferenceChange(ChartDataPointsPreferenceKey.self) { snapshot in
+ preferredDataPoints = snapshot.points
+ }
+ .onPreferenceChange(ChartXRangePreferenceKey.self) { range in
+ preferredXRange = range
+ }
+ .onPreferenceChange(ChartYRangePreferenceKey.self) { range in
+ preferredYRange = range
+ }
+ .onPreferenceChange(ChartXDomainModePreferenceKey.self) { mode in
+ preferredXDomainMode = mode
+ }
+ }
+
+ private func visibleXAxisLabels(width: CGFloat) -> [ChartXAxisLabel] {
+ let labels = resolvedXAxisLabels.sorted(by: { $0.value < $1.value })
+ guard labels.count > 2, width > 0 else { return labels }
+
+ let rotationFactor = abs(axisConfig.axisXLabelRotation.degrees) > 0 ? 1.4 : 1.0
+ let minimumSpacing = 28.0 * rotationFactor
+ let minNormalizedDistance = minimumSpacing / width
+ var filtered: [ChartXAxisLabel] = []
+ var lastPlacedX = -Double.greatestFiniteMagnitude
+
+ for (index, label) in labels.enumerated() {
+ let x = xScale.normalizedX(for: label.value)
+ if index == 0 || index == labels.count - 1 || (x - lastPlacedX) >= minNormalizedDistance {
+ filtered.append(label)
+ lastPlacedX = x
+ }
+ }
+
+ return filtered
+ }
+
+ @ViewBuilder
+ private func positionedXLabel(_ xLabel: ChartXAxisLabel, width: CGFloat) -> some View {
+ let normalized = xScale.normalizedX(for: xLabel.value)
+ let clamped = min(1.0, max(0.0, normalized))
+ let label = axisLabelText(xLabel.title)
+
+ if clamped <= 0.001 {
+ label
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ } else if clamped >= 0.999 {
+ label
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
+ } else {
+ label
+ .position(x: width * CGFloat(clamped), y: xAxisHeight / 2)
+ }
+ }
+
+ private func axisLabelText(_ title: String) -> some View {
+ Text(title)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ .font(axisConfig.axisFont)
+ .foregroundColor(axisConfig.axisFontColor)
+ .rotationEffect(axisConfig.axisXLabelRotation, anchor: .top)
+ }
+
+ private func autoGeneratedXAxisLabels(count: Int) -> [ChartXAxisLabel] {
+ guard count > 1 else { return [] }
+
+ switch xDomainModeForScale {
+ case .categorical:
+ let values: [Double]
+ if let range = xRangeForScale {
+ let lower = Int(floor(range.lowerBound))
+ let upper = Int(ceil(range.upperBound))
+ values = Array(lower...upper).map(Double.init)
+ } else {
+ values = Array(Set(visibleXValues)).sorted()
+ }
+
+ guard !values.isEmpty else { return [] }
+ return sampled(values, targetCount: count).map {
+ ChartXAxisLabel(value: $0, title: formatAxisTick($0, format: axisConfig.axisXTickFormat))
+ }
+
+ case .numeric:
+ let bounds: ClosedRange?
+ if let range = xRangeForScale {
+ bounds = range
+ } else if let minValue = visibleXValues.min(), let maxValue = visibleXValues.max(), minValue != maxValue {
+ bounds = minValue...maxValue
+ } else {
+ bounds = nil
+ }
+
+ guard let range = bounds else { return [] }
+ let span = range.upperBound - range.lowerBound
+ guard span.isFinite, span > 0 else { return [] }
+ let step = span / Double(count - 1)
+ return (0.. [String] {
+ guard count > 1 else { return [] }
+
+ let bounds: ClosedRange?
+ if let preferredYRange = preferredYRange {
+ bounds = preferredYRange
+ } else if let minValue = visibleYValues.min(), let maxValue = visibleYValues.max(), minValue != maxValue {
+ bounds = minValue...maxValue
+ } else {
+ bounds = nil
+ }
+
+ guard let range = bounds else { return [] }
+ let span = range.upperBound - range.lowerBound
+ guard span.isFinite, span > 0 else { return [] }
+ let step = span / Double(count - 1)
+ return (0.. [Double] {
+ guard values.count > targetCount, targetCount > 1 else { return values }
+
+ let stride = Double(values.count - 1) / Double(targetCount - 1)
+ var result: [Double] = []
+ for index in 0.. String {
+ switch format {
+ case .number:
+ if abs(value.rounded() - value) < 0.001 {
+ return String(Int(value.rounded()))
+ }
+ return String(format: "%.1f", value)
+ case .shortDate:
+ let formatter = DateFormatter()
+ formatter.dateStyle = .short
+ formatter.timeStyle = .none
+ return formatter.string(from: Date(timeIntervalSince1970: value))
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift
new file mode 100644
index 00000000..66735d7b
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift
@@ -0,0 +1,11 @@
+import Foundation
+
+public enum AxisLabelsYPosition {
+ case leading
+ case trailing
+}
+
+public enum AxisLabelsXPosition {
+ case top
+ case bottom
+}
diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift
new file mode 100644
index 00000000..61535a81
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift
@@ -0,0 +1,4 @@
+import SwiftUI
+
+@available(*, deprecated, message: "Use chartAxis* modifiers and ChartAxisConfig")
+public typealias AxisLabelsStyle = ChartAxisConfig
diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift
new file mode 100644
index 00000000..00d38bef
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift
@@ -0,0 +1,4 @@
+import SwiftUI
+
+@available(*, deprecated, message: "Use chartAxis* modifiers and ChartAxisConfig")
+public typealias AxisLabelsData = ChartAxisConfig
diff --git a/Sources/SwiftUICharts/Base/CardView/CardView.swift b/Sources/SwiftUICharts/Base/CardView/CardView.swift
new file mode 100644
index 00000000..2f9f11a3
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/CardView/CardView.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+/// View containing data and chart content.
+public struct CardView: View {
+ @Environment(\.colorScheme) private var colorScheme
+
+ let content: () -> Content
+
+ private var showShadow: Bool
+
+ public init(showShadow: Bool = true, @ViewBuilder content: @escaping () -> Content) {
+ self.showShadow = showShadow
+ self.content = content
+ }
+
+ public var body: some View {
+ ZStack {
+ if showShadow {
+ RoundedRectangle(cornerRadius: 20)
+ .fill(cardBackgroundColor)
+ .shadow(color: shadowColor, radius: 8, x: 0, y: 2)
+ }
+ VStack(alignment: .leading) {
+ content()
+ }
+ .clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0))
+ }
+ }
+
+ private var cardBackgroundColor: Color {
+ colorScheme == .dark ? Color.white.opacity(0.08) : Color.white
+ }
+
+ private var shadowColor: Color {
+ colorScheme == .dark ? Color.black.opacity(0.45) : Color.black.opacity(0.12)
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Chart/ChartBase.swift b/Sources/SwiftUICharts/Base/Chart/ChartBase.swift
new file mode 100644
index 00000000..39d2ebca
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Chart/ChartBase.swift
@@ -0,0 +1,4 @@
+import SwiftUI
+
+@available(*, deprecated, message: "Use View-based chart modifiers (chartData, chartXRange, chartYRange) with chart views.")
+public protocol ChartBase: View {}
diff --git a/Sources/SwiftUICharts/Base/Chart/ChartData.swift b/Sources/SwiftUICharts/Base/Chart/ChartData.swift
new file mode 100644
index 00000000..194400bb
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Chart/ChartData.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+
+/// Value-backed data model for chart rendering.
+public struct ChartData {
+ public var data: [(Double, Double)]
+ public var rangeY: ClosedRange?
+ public var rangeX: ClosedRange?
+ public var xDomainMode: ChartXDomainMode
+
+ var points: [Double] {
+ data.filter { rangeX?.contains($0.0) ?? true }.map { $0.1 }
+ }
+
+ var values: [Double] {
+ data.filter { rangeX?.contains($0.0) ?? true }.map { $0.0 }
+ }
+
+ var normalisedPoints: [Double] {
+ let absolutePoints = points.map { abs($0) }
+ var maxPoint = absolutePoints.max()
+ if let rangeY = rangeY {
+ maxPoint = Double(rangeY.overreach)
+ return points.map { ($0 - rangeY.lowerBound) / (maxPoint ?? 1.0) }
+ }
+
+ return points.map { $0 / (maxPoint ?? 1.0) }
+ }
+
+ var normalisedValues: [Double] {
+ let xScale = ChartXScale(values: values,
+ rangeX: rangeX,
+ mode: xDomainMode,
+ slotCountHint: values.count)
+ return values.map { xScale.normalizedX(for: $0) }
+ }
+
+ var normalisedData: [(Double, Double)] {
+ Array(zip(normalisedValues, normalisedPoints))
+ }
+
+ var normalisedYRange: Double {
+ rangeY == nil ? (normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0) : 1
+ }
+
+ var normalisedXRange: Double {
+ rangeX == nil ? (normalisedValues.max() ?? 0.0) - (normalisedValues.min() ?? 0.0) : 1
+ }
+
+ var isInNegativeDomain: Bool {
+ if let rangeY = rangeY {
+ return rangeY.lowerBound < 0
+ }
+
+ return (points.min() ?? 0.0) < 0
+ }
+
+ public init(_ data: [Double],
+ rangeY: ClosedRange? = nil,
+ rangeX: ClosedRange? = nil,
+ xDomainMode: ChartXDomainMode = .categorical) {
+ self.data = data.enumerated().map { (index, value) in (Double(index), value) }
+ self.rangeY = rangeY
+ self.rangeX = rangeX
+ self.xDomainMode = xDomainMode
+ }
+
+ public init(_ data: [(Double, Double)],
+ rangeY: ClosedRange? = nil,
+ rangeX: ClosedRange? = nil,
+ xDomainMode: ChartXDomainMode = .numeric) {
+ self.data = data
+ self.rangeY = rangeY
+ self.rangeX = rangeX
+ self.xDomainMode = xDomainMode
+ }
+
+ public init(xDomainMode: ChartXDomainMode = .numeric) {
+ self.data = []
+ self.rangeY = nil
+ self.rangeX = nil
+ self.xDomainMode = xDomainMode
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Chart/ChartDownsampler.swift b/Sources/SwiftUICharts/Base/Chart/ChartDownsampler.swift
new file mode 100644
index 00000000..ce7443af
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Chart/ChartDownsampler.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+enum ChartDownsampler {
+ static func reduced(_ data: [(Double, Double)], maxPoints: Int) -> [(Double, Double)] {
+ let limit = max(2, maxPoints)
+ guard data.count > limit else { return data }
+
+ let stride = Double(data.count - 1) / Double(limit - 1)
+ var reduced: [(Double, Double)] = []
+ reduced.reserveCapacity(limit)
+
+ for index in 0.. Void
diff --git a/Sources/SwiftUICharts/Base/Chart/ChartStreamingDataSource.swift b/Sources/SwiftUICharts/Base/Chart/ChartStreamingDataSource.swift
new file mode 100644
index 00000000..27214150
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Chart/ChartStreamingDataSource.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+public final class ChartStreamingDataSource: ObservableObject {
+ @Published public private(set) var values: [Double]
+
+ public var windowSize: Int
+ public var autoScroll: Bool
+
+ private var startingIndex: Int
+
+ public var latestValue: Double {
+ values.last ?? 0
+ }
+
+ public var xLabels: [String] {
+ guard !values.isEmpty else { return [] }
+ return (startingIndex..<(startingIndex + values.count)).map(String.init)
+ }
+
+ public var suggestedYRange: ClosedRange {
+ let minValue = values.min() ?? 0
+ let maxValue = values.max() ?? 1
+ let span = max(1, maxValue - minValue)
+ let padding = max(1, span * 0.15)
+ return (minValue - padding)...(maxValue + padding)
+ }
+
+ public init(initialValues: [Double] = [],
+ windowSize: Int = 20,
+ autoScroll: Bool = true,
+ startingIndex: Int = 1) {
+ self.values = initialValues
+ self.windowSize = max(1, windowSize)
+ self.autoScroll = autoScroll
+ self.startingIndex = max(0, startingIndex)
+ normalizeWindow()
+ }
+
+ public func append(_ value: Double) {
+ values.append(value)
+ normalizeWindow()
+ }
+
+ public func append(contentsOf newValues: [Double]) {
+ values.append(contentsOf: newValues)
+ normalizeWindow()
+ }
+
+ public func reset(_ newValues: [Double], startingIndex: Int = 1) {
+ values = newValues
+ self.startingIndex = max(0, startingIndex)
+ normalizeWindow()
+ }
+
+ private func normalizeWindow() {
+ guard autoScroll else { return }
+
+ let overflow = max(0, values.count - windowSize)
+ if overflow > 0 {
+ values.removeFirst(overflow)
+ startingIndex += overflow
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Chart/ChartValue.swift b/Sources/SwiftUICharts/Base/Chart/ChartValue.swift
new file mode 100644
index 00000000..9b223aa8
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Chart/ChartValue.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+/// Representation of a single data point in a chart that is being observed.
+public final class ChartValue: ObservableObject {
+ @Published public var currentValue: Double = 0
+ @Published public var interactionInProgress: Bool = false
+
+ public init() {
+ // no-op
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Chart/ChartXScale.swift b/Sources/SwiftUICharts/Base/Chart/ChartXScale.swift
new file mode 100644
index 00000000..70282ce3
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Chart/ChartXScale.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+struct ChartXScale {
+ private let values: [Double]
+ private let rangeX: ClosedRange?
+ private let mode: ChartXDomainMode
+ private let slotCountHint: Int
+
+ init(values: [Double],
+ rangeX: ClosedRange?,
+ mode: ChartXDomainMode,
+ slotCountHint: Int = 0) {
+ self.values = values
+ self.rangeX = rangeX
+ self.mode = mode
+ self.slotCountHint = slotCountHint
+ }
+
+ func normalizedX(for value: Double) -> Double {
+ let normalized: Double
+ switch mode {
+ case .numeric:
+ normalized = normalizeNumeric(value)
+ case .categorical:
+ normalized = normalizeCategorical(value)
+ }
+ return min(1.0, max(0.0, normalized))
+ }
+
+ private func normalizeNumeric(_ value: Double) -> Double {
+ if let range = rangeX {
+ let overreach = range.overreach
+ guard overreach.isFinite, overreach > 0 else { return 0.5 }
+ return (value - range.lowerBound) / overreach
+ }
+
+ guard let minValue = values.min(),
+ let maxValue = values.max() else { return 0.5 }
+ let span = maxValue - minValue
+ guard span.isFinite, span > 0 else { return 0.5 }
+ return (value - minValue) / span
+ }
+
+ private func normalizeCategorical(_ value: Double) -> Double {
+ let lowerBound: Double
+ if let range = rangeX {
+ lowerBound = range.lowerBound
+ } else {
+ lowerBound = values.min() ?? 0
+ }
+
+ let slotCount: Int
+ if let range = rangeX {
+ let derived = Int(max(1, floor(range.overreach) + 1))
+ slotCount = max(1, derived)
+ } else if slotCountHint > 0 {
+ slotCount = slotCountHint
+ } else {
+ slotCount = max(1, values.count)
+ }
+
+ return (value - lowerBound + 0.5) / Double(slotCount)
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift b/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift
new file mode 100644
index 00000000..ea8357f1
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+public struct ViewGeometry: View where T: PreferenceKey {
+ public var body: some View {
+ GeometryReader { geometry in
+ Color.clear
+ .preference(key: T.self, value: [ViewSizeData(size: geometry.size)] as! T.Value)
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift b/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift
new file mode 100644
index 00000000..b6671040
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+public protocol ViewPreferenceKey: PreferenceKey where Value == [ViewSizeData] {}
+
+public extension ViewPreferenceKey {
+ static var defaultValue: [ViewSizeData] {
+ []
+ }
+
+ static func reduce(value: inout [ViewSizeData], nextValue: () -> [ViewSizeData]) {
+ value.append(contentsOf: nextValue())
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift b/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift
new file mode 100644
index 00000000..9a53cec3
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+public struct ViewSizeData: Identifiable, Equatable, Hashable {
+ public let id: UUID = UUID()
+ public let size: CGSize
+
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ return lhs.id == rhs.id
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Config/ChartAxisConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartAxisConfig.swift
new file mode 100644
index 00000000..e3e9f20d
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Config/ChartAxisConfig.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+public enum ChartAxisTickFormat {
+ case number
+ case shortDate
+}
+
+public struct ChartXAxisLabel: Equatable {
+ public let value: Double
+ public let title: String
+
+ public init(value: Double, title: String) {
+ self.value = value
+ self.title = title
+ }
+}
+
+public struct ChartAxisConfig {
+ public var axisYLabels: [String]
+ public var axisXLabels: [ChartXAxisLabel]
+ public var axisXRange: ClosedRange?
+ public var axisXDomainMode: ChartXDomainMode
+ public var axisXAutoTickCount: Int?
+ public var axisYAutoTickCount: Int?
+ public var axisXTickFormat: ChartAxisTickFormat
+ public var axisYTickFormat: ChartAxisTickFormat
+ public var axisXLabelRotation: Angle
+ public var axisFont: Font
+ public var axisFontColor: Color
+ public var axisLabelsYPosition: AxisLabelsYPosition
+
+ public init(axisYLabels: [String] = [],
+ axisXLabels: [ChartXAxisLabel] = [],
+ axisXRange: ClosedRange? = nil,
+ axisXDomainMode: ChartXDomainMode = .categorical,
+ axisXAutoTickCount: Int? = nil,
+ axisYAutoTickCount: Int? = nil,
+ axisXTickFormat: ChartAxisTickFormat = .number,
+ axisYTickFormat: ChartAxisTickFormat = .number,
+ axisXLabelRotation: Angle = .degrees(0),
+ axisFont: Font = .callout,
+ axisFontColor: Color = .primary,
+ axisLabelsYPosition: AxisLabelsYPosition = .leading) {
+ self.axisYLabels = axisYLabels
+ self.axisXLabels = axisXLabels
+ self.axisXRange = axisXRange
+ self.axisXDomainMode = axisXDomainMode
+ self.axisXAutoTickCount = axisXAutoTickCount
+ self.axisYAutoTickCount = axisYAutoTickCount
+ self.axisXTickFormat = axisXTickFormat
+ self.axisYTickFormat = axisYTickFormat
+ self.axisXLabelRotation = axisXLabelRotation
+ self.axisFont = axisFont
+ self.axisFontColor = axisFontColor
+ self.axisLabelsYPosition = axisLabelsYPosition
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Config/ChartGridConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartGridConfig.swift
new file mode 100644
index 00000000..e3a462ae
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Config/ChartGridConfig.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+public struct ChartGridConfig {
+ public var numberOfHorizontalLines: Int
+ public var numberOfVerticalLines: Int
+ public var strokeStyle: StrokeStyle
+ public var color: Color
+ public var showBaseLine: Bool
+ public var baseStrokeStyle: StrokeStyle
+
+ public init(numberOfHorizontalLines: Int = 3,
+ numberOfVerticalLines: Int = 3,
+ strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, 10]),
+ color: Color = Color.secondary.opacity(0.35),
+ showBaseLine: Bool = true,
+ baseStrokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5, 0])) {
+ self.numberOfHorizontalLines = numberOfHorizontalLines
+ self.numberOfVerticalLines = numberOfVerticalLines
+ self.strokeStyle = strokeStyle
+ self.color = color
+ self.showBaseLine = showBaseLine
+ self.baseStrokeStyle = baseStrokeStyle
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Config/ChartLineConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartLineConfig.swift
new file mode 100644
index 00000000..ae2bfc28
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Config/ChartLineConfig.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+public struct ChartLineConfig {
+ public var lineWidth: CGFloat
+ public var backgroundGradient: ColorGradient?
+ public var showChartMarks: Bool
+ public var customChartMarksColors: ColorGradient?
+ public var lineStyle: LineStyle
+ public var animationEnabled: Bool
+
+ public init(lineWidth: CGFloat = 2.0,
+ backgroundGradient: ColorGradient? = nil,
+ showChartMarks: Bool = true,
+ customChartMarksColors: ColorGradient? = nil,
+ lineStyle: LineStyle = .curved,
+ animationEnabled: Bool = true) {
+ self.lineWidth = lineWidth
+ self.backgroundGradient = backgroundGradient
+ self.showChartMarks = showChartMarks
+ self.customChartMarksColors = customChartMarksColors
+ self.lineStyle = lineStyle
+ self.animationEnabled = animationEnabled
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Config/ChartPerformanceConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartPerformanceConfig.swift
new file mode 100644
index 00000000..83d03f83
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Config/ChartPerformanceConfig.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+public enum ChartPerformanceMode {
+ case none
+ case automatic(threshold: Int, maxPoints: Int, simplifyLineStyle: Bool)
+ case downsample(maxPoints: Int, simplifyLineStyle: Bool)
+}
+
+public struct ChartPerformanceConfig {
+ public var mode: ChartPerformanceMode
+
+ public init(mode: ChartPerformanceMode = .none) {
+ self.mode = mode
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Config/ChartSeriesConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartSeriesConfig.swift
new file mode 100644
index 00000000..8705320c
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Config/ChartSeriesConfig.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+public struct ChartSeriesConfig {
+ public var seriesID: String?
+ public var hiddenSeriesIDs: Set
+
+ public init(seriesID: String? = nil,
+ hiddenSeriesIDs: Set = []) {
+ self.seriesID = seriesID
+ self.hiddenSeriesIDs = hiddenSeriesIDs
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Environment/ChartEnvironmentKeys.swift b/Sources/SwiftUICharts/Base/Environment/ChartEnvironmentKeys.swift
new file mode 100644
index 00000000..2199ba6e
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Environment/ChartEnvironmentKeys.swift
@@ -0,0 +1,116 @@
+import SwiftUI
+
+public enum ChartXDomainMode {
+ case categorical
+ case numeric
+}
+
+private struct ChartDataPointsKey: EnvironmentKey {
+ static let defaultValue: [(Double, Double)] = []
+}
+
+private struct ChartXDomainModeKey: EnvironmentKey {
+ static let defaultValue: ChartXDomainMode = .numeric
+}
+
+private struct ChartXRangeKey: EnvironmentKey {
+ static let defaultValue: ClosedRange? = nil
+}
+
+private struct ChartYRangeKey: EnvironmentKey {
+ static let defaultValue: ClosedRange? = nil
+}
+
+private struct ChartStyleKey: EnvironmentKey {
+ static let defaultValue = ChartStyle(backgroundColor: Color.primary.opacity(0.04), foregroundColor: .orangeBright)
+}
+
+private struct ChartInteractionValueKey: EnvironmentKey {
+ static let defaultValue: ChartValue? = nil
+}
+
+private struct ChartSelectionHandlerKey: EnvironmentKey {
+ static let defaultValue: ChartSelectionHandler? = nil
+}
+
+private struct ChartGridConfigKey: EnvironmentKey {
+ static let defaultValue = ChartGridConfig()
+}
+
+private struct ChartAxisConfigKey: EnvironmentKey {
+ static let defaultValue = ChartAxisConfig()
+}
+
+private struct ChartLineConfigKey: EnvironmentKey {
+ static let defaultValue = ChartLineConfig()
+}
+
+private struct ChartSeriesConfigKey: EnvironmentKey {
+ static let defaultValue = ChartSeriesConfig()
+}
+
+private struct ChartPerformanceConfigKey: EnvironmentKey {
+ static let defaultValue = ChartPerformanceConfig()
+}
+
+public extension EnvironmentValues {
+ var chartDataPoints: [(Double, Double)] {
+ get { self[ChartDataPointsKey.self] }
+ set { self[ChartDataPointsKey.self] = newValue }
+ }
+
+ var chartXDomainMode: ChartXDomainMode {
+ get { self[ChartXDomainModeKey.self] }
+ set { self[ChartXDomainModeKey.self] = newValue }
+ }
+
+ var chartXRange: ClosedRange? {
+ get { self[ChartXRangeKey.self] }
+ set { self[ChartXRangeKey.self] = newValue }
+ }
+
+ var chartYRange: ClosedRange? {
+ get { self[ChartYRangeKey.self] }
+ set { self[ChartYRangeKey.self] = newValue }
+ }
+
+ var chartStyle: ChartStyle {
+ get { self[ChartStyleKey.self] }
+ set { self[ChartStyleKey.self] = newValue }
+ }
+
+ var chartInteractionValue: ChartValue? {
+ get { self[ChartInteractionValueKey.self] }
+ set { self[ChartInteractionValueKey.self] = newValue }
+ }
+
+ var chartSelectionHandler: ChartSelectionHandler? {
+ get { self[ChartSelectionHandlerKey.self] }
+ set { self[ChartSelectionHandlerKey.self] = newValue }
+ }
+
+ var chartGridConfig: ChartGridConfig {
+ get { self[ChartGridConfigKey.self] }
+ set { self[ChartGridConfigKey.self] = newValue }
+ }
+
+ var chartAxisConfig: ChartAxisConfig {
+ get { self[ChartAxisConfigKey.self] }
+ set { self[ChartAxisConfigKey.self] = newValue }
+ }
+
+ var chartLineConfig: ChartLineConfig {
+ get { self[ChartLineConfigKey.self] }
+ set { self[ChartLineConfigKey.self] = newValue }
+ }
+
+ var chartSeriesConfig: ChartSeriesConfig {
+ get { self[ChartSeriesConfigKey.self] }
+ set { self[ChartSeriesConfigKey.self] = newValue }
+ }
+
+ var chartPerformanceConfig: ChartPerformanceConfig {
+ get { self[ChartPerformanceConfigKey.self] }
+ set { self[ChartPerformanceConfigKey.self] = newValue }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Environment/ChartPreferenceKeys.swift b/Sources/SwiftUICharts/Base/Environment/ChartPreferenceKeys.swift
new file mode 100644
index 00000000..c968fab7
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Environment/ChartPreferenceKeys.swift
@@ -0,0 +1,56 @@
+import SwiftUI
+
+struct ChartDataPointsSnapshot: Equatable {
+ let points: [(Double, Double)]
+
+ static func == (lhs: ChartDataPointsSnapshot, rhs: ChartDataPointsSnapshot) -> Bool {
+ guard lhs.points.count == rhs.points.count else { return false }
+ return zip(lhs.points, rhs.points).allSatisfy { lhsPoint, rhsPoint in
+ lhsPoint.0 == rhsPoint.0 && lhsPoint.1 == rhsPoint.1
+ }
+ }
+}
+
+struct ChartDataPointsPreferenceKey: PreferenceKey {
+ static var defaultValue: ChartDataPointsSnapshot = ChartDataPointsSnapshot(points: [])
+
+ static func reduce(value: inout ChartDataPointsSnapshot, nextValue: () -> ChartDataPointsSnapshot) {
+ let next = nextValue()
+ if next.points.count >= value.points.count {
+ value = next
+ }
+ }
+}
+
+struct ChartXRangePreferenceKey: PreferenceKey {
+ static var defaultValue: ClosedRange? = nil
+
+ static func reduce(value: inout ClosedRange?, nextValue: () -> ClosedRange?) {
+ if let next = nextValue() {
+ value = next
+ }
+ }
+}
+
+struct ChartYRangePreferenceKey: PreferenceKey {
+ static var defaultValue: ClosedRange? = nil
+
+ static func reduce(value: inout ClosedRange?, nextValue: () -> ClosedRange?) {
+ if let next = nextValue() {
+ value = next
+ }
+ }
+}
+
+struct ChartXDomainModePreferenceKey: PreferenceKey {
+ static var defaultValue: ChartXDomainMode = .numeric
+
+ static func reduce(value: inout ChartXDomainMode, nextValue: () -> ChartXDomainMode) {
+ let next = nextValue()
+ if value == .categorical || next == .categorical {
+ value = .categorical
+ } else {
+ value = .numeric
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift
new file mode 100644
index 00000000..1e4bedd7
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+extension Array where Element == ColorGradient {
+
+ /// <#Description#>
+ /// - Parameter index: offset in data table
+ /// - Returns: <#description#>
+ func rotate(for index: Int) -> ColorGradient {
+ if self.isEmpty {
+ return ColorGradient.orangeBright
+ }
+
+ if self.count <= index {
+ return self[index % self.count]
+ }
+
+ return self[index]
+ }
+}
+
+extension Collection {
+ /// Returns the element at the specified index if it is within bounds, otherwise nil.
+ subscript (safe index: Index) -> Element? {
+ return indices.contains(index) ? self[index] : nil
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift
new file mode 100644
index 00000000..afaa7512
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+
+extension CGPoint {
+
+ /// Calculate X and Y delta for each data point, based on data min/max and enclosing frame.
+ /// - Parameters:
+ /// - frame: Rectangle of enclosing frame
+ /// - data: array of `Double`
+ /// - Returns: X and Y delta as a `CGPoint`
+ static func getStep(frame: CGRect, data: [Double]) -> CGPoint {
+ guard data.count > 1 else {
+ return .zero
+ }
+
+ guard let minPoint = data.min(), let maxPoint = data.max(), minPoint != maxPoint else {
+ return .zero
+ }
+
+ let padding: CGFloat = 0
+ let stepWidth = frame.size.width / CGFloat(data.count - 1)
+ let stepHeight: CGFloat
+
+ if minPoint <= 0 {
+ stepHeight = (frame.size.height - padding) / CGFloat(maxPoint - minPoint)
+ } else {
+ stepHeight = (frame.size.height - padding) / CGFloat(maxPoint + minPoint)
+ }
+
+ return CGPoint(x: stepWidth, y: stepHeight)
+ }
+
+ func denormalize(with geometry: GeometryProxy) -> CGPoint {
+ let frame = geometry.frame(in: .local).sanitized
+ let width = frame.width
+ let height = frame.height
+ return CGPoint(x: self.x * width, y: self.y * height)
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift
new file mode 100644
index 00000000..d45f5742
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift
@@ -0,0 +1,27 @@
+import Foundation
+import SwiftUI
+
+extension CGRect {
+
+ /// Midpoint of rectangle
+ /// - Returns: the coordinate for a rectangle center
+ public var mid: CGPoint {
+ return CGPoint(x: self.midX, y: self.midY)
+ }
+
+ /// Returns a rectangle with finite origin and non-negative finite size.
+ public var sanitized: CGRect {
+ CGRect(x: origin.x.isFinite ? origin.x : 0,
+ y: origin.y.isFinite ? origin.y : 0,
+ width: max(0, size.width.isFinite ? size.width : 0),
+ height: max(0, size.height.isFinite ? size.height : 0))
+ }
+}
+
+extension CGSize {
+ /// Returns a size with non-negative finite width and height.
+ public var sanitized: CGSize {
+ CGSize(width: max(0, width.isFinite ? width : 0),
+ height: max(0, height.isFinite ? height : 0))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift
new file mode 100644
index 00000000..6f3bd22f
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+extension Color {
+ /// Create a `Color` from a hexadecimal representation
+ /// - Parameter hexString: 3, 6, or 8-character string, with optional (ignored) punctuation such as "#"
+ init(hexString: String) {
+ let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var int = UInt64()
+ Scanner(string: hex).scanHexInt64(&int)
+ let red, green, blue: UInt64
+ switch hex.count {
+ case 3: // RGB (12-bit)
+ (red, green, blue) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
+ case 6: // RGB (24-bit)
+ (red, green, blue) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
+ case 8: // ARGB (32-bit)
+ // FIXME: I think we need an an alpha value on this one. See link below.
+ // https://stackoverflow.com/a/56874327/4475605
+ (red, green, blue) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
+ default:
+ (red, green, blue) = (0, 0, 0)
+ }
+ self.init(red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255)
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift
new file mode 100644
index 00000000..5120626f
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift
@@ -0,0 +1,442 @@
+import SwiftUI
+
+extension Path {
+ private static func sanitizedRect(_ rect: CGRect) -> CGRect {
+ rect.sanitized
+ }
+
+ func trimmedPath(for percent: CGFloat) -> Path {
+ let boundsDistance: CGFloat = 0.001
+ let completion: CGFloat = 1 - boundsDistance
+
+ let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
+
+ // Start/end points centered around given percentage, but capped if right at the very end
+ let start = pct > completion ? completion : pct - boundsDistance
+ let end = pct > completion ? 1 : pct + boundsDistance
+ return trimmedPath(from: start, to: end)
+ }
+
+ func point(for percent: CGFloat) -> CGPoint {
+ let path = trimmedPath(for: percent)
+ return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY)
+ }
+
+ func point(to maxX: CGFloat) -> CGPoint {
+ let total = length
+ let sub = length(to: maxX)
+ let percent = sub / total
+ return point(for: percent)
+ }
+
+ var length: CGFloat {
+ var ret: CGFloat = 0.0
+ var start: CGPoint?
+ var point = CGPoint.zero
+
+ forEach { ele in
+ switch ele {
+ case .move(let to):
+ if start == nil {
+ start = to
+ }
+ point = to
+ case .line(let to):
+ ret += point.line(to: to)
+ point = to
+ case .quadCurve(let to, let control):
+ ret += point.quadCurve(to: to, control: control)
+ point = to
+ case .curve(let to, let control1, let control2):
+ ret += point.curve(to: to, control1: control1, control2: control2)
+ point = to
+ case .closeSubpath:
+ if let to = start {
+ ret += point.line(to: to)
+ point = to
+ }
+ start = nil
+ }
+ }
+ return ret
+ }
+
+ func length(to maxX: CGFloat) -> CGFloat {
+ var ret: CGFloat = 0.0
+ var start: CGPoint?
+ var point = CGPoint.zero
+ var finished = false
+
+ forEach { ele in
+ if finished {
+ return
+ }
+ switch ele {
+ case .move(let to):
+ if to.x > maxX {
+ finished = true
+ return
+ }
+ if start == nil {
+ start = to
+ }
+ point = to
+ case .line(let to):
+ if to.x > maxX {
+ finished = true
+ ret += point.line(to: to, x: maxX)
+ return
+ }
+ ret += point.line(to: to)
+ point = to
+ case .quadCurve(let to, let control):
+ if to.x > maxX {
+ finished = true
+ ret += point.quadCurve(to: to, control: control, x: maxX)
+ return
+ }
+ ret += point.quadCurve(to: to, control: control)
+ point = to
+ case .curve(let to, let control1, let control2):
+ if to.x > maxX {
+ finished = true
+ ret += point.curve(to: to, control1: control1, control2: control2, x: maxX)
+ return
+ }
+ ret += point.curve(to: to, control1: control1, control2: control2)
+ point = to
+ case .closeSubpath:
+ fatalError("Can't include closeSubpath")
+ }
+ }
+ return ret
+ }
+
+ static func quadCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path {
+ var path = Path()
+ if points.count < 2 {
+ return path
+ }
+ let offset = globalOffset ?? points.min()!
+ // guard let offset = points.min() else { return path }
+ var point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
+ path.move(to: point1)
+ for pointIndex in 1.. Path {
+ var path = Path()
+ if data.count < 2 {
+ return path
+ }
+ let rect = sanitizedRect(rect)
+
+ let convertedXValues = data.map { CGFloat($0.0) * rect.width }
+ let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
+
+ var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
+ path.move(to: point1)
+ for pointIndex in 1.. Path {
+ var path = Path()
+ let filteredData = data.filter { $0.1 <= 1 && $0.1 >= 0 }
+
+ if filteredData.count < 1 {
+ return path
+ }
+ let rect = sanitizedRect(rect)
+
+ let convertedXValues = filteredData.map { CGFloat($0.0) * rect.width }
+ let convertedYPoints = filteredData.map { CGFloat($0.1) * rect.height }
+
+ let markerSize = CGSize(width: 8, height: 8)
+ for pointIndex in 0.. Path {
+ var path = Path()
+ let rect = sanitizedRect(rect)
+
+ if numberOfHorizontalLines > 1 {
+ for index in 0.. 1 {
+ for index in 0.. Path {
+ var path = Path()
+ if data.count < 2 {
+ return path
+ }
+ let rect = sanitizedRect(rect)
+
+ let convertedXValues = data.map { CGFloat($0.0) * rect.width }
+ let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
+
+ path.move(to: CGPoint(x: convertedXValues[0], y: 0))
+ var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
+ path.addLine(to: point1)
+ for pointIndex in 1.. Path {
+ var path = Path()
+ if data.count < 2 {
+ return path
+ }
+ let rect = sanitizedRect(rect)
+
+ let convertedXValues = data.map { CGFloat($0.0) * rect.width }
+ let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
+
+ let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
+ path.move(to: point1)
+ for pointIndex in 1.. Path {
+ var path = Path()
+ if data.count < 2 {
+ return path
+ }
+ let rect = sanitizedRect(rect)
+
+ let convertedXValues = data.map { CGFloat($0.0) * rect.width }
+ let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
+ path.move(to: .zero)
+
+ let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
+ path.addLine(to: point1)
+
+ for pointIndex in 1.. CGPoint {
+ let a = (to.y - self.y) / (to.x - self.x)
+ let y = self.y + (x - self.x) * a
+ return CGPoint(x: x, y: y)
+ }
+
+ func line(to: CGPoint) -> CGFloat {
+ dist(to: to)
+ }
+
+ func line(to: CGPoint, x: CGFloat) -> CGFloat {
+ dist(to: point(to: to, x: x))
+ }
+
+ func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat {
+ var dist: CGFloat = 0
+ let steps: CGFloat = 100
+
+ for i in 0.. CGFloat {
+ var dist: CGFloat = 0
+ let steps: CGFloat = 100
+
+ for i in 0..= x {
+ return dist
+ } else if b.x > x {
+ dist += a.line(to: b, x: x)
+ return dist
+ } else if b.x == x {
+ dist += a.line(to: b)
+ return dist
+ }
+
+ dist += a.line(to: b)
+ }
+ return dist
+ }
+
+ func point(to: CGPoint, t: CGFloat, control: CGPoint) -> CGPoint {
+ let x = CGPoint.value(x: self.x, y: to.x, t: t, c: control.x)
+ let y = CGPoint.value(x: self.y, y: to.y, t: t, c: control.y)
+
+ return CGPoint(x: x, y: y)
+ }
+
+ func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat {
+ var dist: CGFloat = 0
+ let steps: CGFloat = 100
+
+ for i in 0.. CGFloat {
+ var dist: CGFloat = 0
+ let steps: CGFloat = 100
+
+ for i in 0..= x {
+ return dist
+ } else if b.x > x {
+ dist += a.line(to: b, x: x)
+ return dist
+ } else if b.x == x {
+ dist += a.line(to: b)
+ return dist
+ }
+
+ dist += a.line(to: b)
+ }
+
+ return dist
+ }
+
+ func point(to: CGPoint, t: CGFloat, control1: CGPoint, control2: CGPoint) -> CGPoint {
+ let x = CGPoint.value(x: self.x, y: to.x, t: t, control1: control1.x, control2: control2.x)
+ let y = CGPoint.value(x: self.y, y: to.y, t: t, control1: control1.y, control2: control2.x)
+
+ return CGPoint(x: x, y: y)
+ }
+
+ static func value(x: CGFloat, y: CGFloat, t: CGFloat, c: CGFloat) -> CGFloat {
+ var value: CGFloat = 0.0
+ // (1-t)^2 * p0 + 2 * (1-t) * t * c1 + t^2 * p1
+ value += pow(1-t, 2) * x
+ value += 2 * (1-t) * t * c
+ value += pow(t, 2) * y
+ return value
+ }
+
+ static func value(x: CGFloat, y: CGFloat, t: CGFloat, control1: CGFloat, control2: CGFloat) -> CGFloat {
+ var value: CGFloat = 0.0
+ // (1-t)^3 * p0 + 3 * (1-t)^2 * t * c1 + 3 * (1-t) * t^2 * c2 + t^3 * p1
+ value += pow(1-t, 3) * x
+ value += 3 * pow(1-t, 2) * t * control1
+ value += 3 * (1-t) * pow(t, 2) * control2
+ value += pow(t, 3) * y
+ return value
+ }
+
+ static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
+ return CGPoint(
+ x: point1.x + (point2.x - point1.x) / 2,
+ y: point1.y + (point2.y - point1.y) / 2
+ )
+ }
+
+ func dist(to: CGPoint) -> CGFloat {
+ return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2)))
+ }
+
+ static func midPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
+ return CGPoint(
+ x: (firstPoint.x + secondPoint.x) / 2,
+ y: (firstPoint.y + secondPoint.y) / 2)
+ }
+
+ static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
+ var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint)
+ let diffY = abs(secondPoint.y - controlPoint.y)
+
+ if firstPoint.y < secondPoint.y {
+ controlPoint.y += diffY
+ } else if firstPoint.y > secondPoint.y {
+ controlPoint.y -= diffY
+ }
+ return controlPoint
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift
new file mode 100644
index 00000000..547a0435
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+public extension ClosedRange where Bound: AdditiveArithmetic {
+ var overreach: Bound {
+ self.upperBound - self.lowerBound
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift
new file mode 100644
index 00000000..5624e0db
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+extension Shape {
+ func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
+ self
+ .stroke(strokeStyle, lineWidth: lineWidth)
+ .background(self.fill(fillStyle))
+ }
+}
+
+extension InsettableShape {
+ func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
+ self
+ .strokeBorder(strokeStyle, lineWidth: lineWidth)
+ .background(self.fill(fillStyle))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift
new file mode 100644
index 00000000..7cb2e0fa
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+
+extension View {
+ func toStandardCoordinateSystem() -> some View {
+ self
+ .rotationEffect(.degrees(180), anchor: .center)
+ .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift b/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift
new file mode 100644
index 00000000..e93ff937
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+public struct ChartGrid: View {
+ let content: () -> Content
+
+ @Environment(\.chartGridConfig) private var gridConfig
+
+ public init(@ViewBuilder content: @escaping () -> Content) {
+ self.content = content
+ }
+
+ public var body: some View {
+ ZStack {
+ ChartGridShape(numberOfHorizontalLines: gridConfig.numberOfHorizontalLines,
+ numberOfVerticalLines: gridConfig.numberOfVerticalLines)
+ .stroke(gridConfig.color, style: gridConfig.strokeStyle)
+ if gridConfig.showBaseLine {
+ ChartGridBaseShape()
+ .stroke(gridConfig.color, style: gridConfig.baseStrokeStyle)
+ .rotationEffect(.degrees(180), anchor: .center)
+ }
+ content()
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift b/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift
new file mode 100644
index 00000000..57ca9669
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+struct ChartGridBaseShape: Shape {
+ func path(in rect: CGRect) -> Path {
+ let rect = rect.sanitized
+ var path = Path()
+ path.move(to: CGPoint(x: 0, y: 0))
+ path.addLine(to: CGPoint(x: rect.width, y: 0))
+ return path
+ }
+}
+
+struct ChartGridBaseShape_Previews: PreviewProvider {
+ static var previews: some View {
+ ChartGridBaseShape()
+ .stroke()
+ .rotationEffect(.degrees(180), anchor: .center)
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift b/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift
new file mode 100644
index 00000000..ccb6e18b
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+struct ChartGridShape: Shape {
+ var numberOfHorizontalLines: Int
+ var numberOfVerticalLines: Int
+
+ func path(in rect: CGRect) -> Path {
+ let path = Path.drawGridLines(numberOfHorizontalLines: numberOfHorizontalLines,
+ numberOfVerticalLines: numberOfVerticalLines,
+ in: rect)
+ return path
+ }
+}
+
+struct ChartGridShape_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ ChartGridShape(numberOfHorizontalLines: 5, numberOfVerticalLines: 0)
+ .stroke()
+ .toStandardCoordinateSystem()
+
+ ChartGridShape(numberOfHorizontalLines: 4, numberOfVerticalLines: 4)
+ .stroke()
+ .toStandardCoordinateSystem()
+ }
+ .padding()
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift b/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift
new file mode 100644
index 00000000..f1e68d56
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift
@@ -0,0 +1,4 @@
+import SwiftUI
+
+@available(*, deprecated, renamed: "ChartGridConfig")
+public typealias GridOptions = ChartGridConfig
diff --git a/Sources/SwiftUICharts/Base/Label/ChartLabel.swift b/Sources/SwiftUICharts/Base/Label/ChartLabel.swift
new file mode 100644
index 00000000..870e38fc
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Label/ChartLabel.swift
@@ -0,0 +1,132 @@
+import SwiftUI
+
+/// What kind of label - this affects color, size, position of the label.
+public enum ChartLabelType {
+ case title
+ case subTitle
+ case largeTitle
+ case custom(size: CGFloat, padding: EdgeInsets, color: Color)
+ case legend
+}
+
+/// A chart may contain any number of labels in pre-set positions based on their `ChartLabelType`.
+public struct ChartLabel: View {
+ @Environment(\.chartInteractionValue) private var chartValue
+
+ private var title: String
+ private var format: String
+ private let labelType: ChartLabelType
+
+ public init(_ title: String,
+ type: ChartLabelType = .title,
+ format: String = "%.01f") {
+ self.title = title
+ self.labelType = type
+ self.format = format
+ }
+
+ public var body: some View {
+ if let chartValue = chartValue {
+ ChartLabelObservedValue(title: title,
+ format: format,
+ labelSize: labelSize,
+ labelPadding: labelPadding,
+ labelColor: labelColor,
+ chartValue: chartValue)
+ } else {
+ HStack {
+ Text(title)
+ .font(.system(size: labelSize))
+ .bold()
+ .foregroundColor(labelColor)
+ .padding(labelPadding)
+ Spacer()
+ }
+ }
+ }
+
+ private var labelSize: CGFloat {
+ switch labelType {
+ case .title:
+ return 32.0
+ case .legend:
+ return 14.0
+ case .subTitle:
+ return 24.0
+ case .largeTitle:
+ return 38.0
+ case .custom(let size, _, _):
+ return size
+ }
+ }
+
+ private var labelPadding: EdgeInsets {
+ switch labelType {
+ case .title:
+ return EdgeInsets(top: 16.0, leading: 0, bottom: 0.0, trailing: 8.0)
+ case .legend:
+ return EdgeInsets(top: 4.0, leading: 0, bottom: 0.0, trailing: 8.0)
+ case .subTitle:
+ return EdgeInsets(top: 8.0, leading: 0, bottom: 0.0, trailing: 8.0)
+ case .largeTitle:
+ return EdgeInsets(top: 24.0, leading: 0, bottom: 0.0, trailing: 8.0)
+ case .custom(_, let padding, _):
+ return padding
+ }
+ }
+
+ private var labelColor: Color {
+ switch labelType {
+ case .title:
+ return .primary
+ case .legend:
+ return .secondary
+ case .subTitle:
+ return .primary
+ case .largeTitle:
+ return .primary
+ case .custom(_, _, let color):
+ return color
+ }
+ }
+}
+
+private struct ChartLabelObservedValue: View {
+ @ObservedObject var chartValue: ChartValue
+
+ let title: String
+ let format: String
+ let labelSize: CGFloat
+ let labelPadding: EdgeInsets
+ let labelColor: Color
+
+ init(title: String,
+ format: String,
+ labelSize: CGFloat,
+ labelPadding: EdgeInsets,
+ labelColor: Color,
+ chartValue: ChartValue) {
+ self.title = title
+ self.format = format
+ self.labelSize = labelSize
+ self.labelPadding = labelPadding
+ self.labelColor = labelColor
+ self.chartValue = chartValue
+ }
+
+ var body: some View {
+ HStack {
+ Text(chartValue.interactionInProgress
+ ? String(format: format, chartValue.currentValue)
+ : title)
+ .font(.system(size: labelSize))
+ .bold()
+ .foregroundColor(labelColor)
+ .padding(labelPadding)
+
+ if !chartValue.interactionInProgress {
+ Spacer()
+ }
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Label/ChartLegend.swift b/Sources/SwiftUICharts/Base/Label/ChartLegend.swift
new file mode 100644
index 00000000..5946402b
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Label/ChartLegend.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+
+public struct ChartLegendItem: Identifiable {
+ public let id: String
+ public let title: String
+ public let color: ColorGradient
+
+ public init(id: String, title: String, color: ColorGradient) {
+ self.id = id
+ self.title = title
+ self.color = color
+ }
+}
+
+public struct ChartLegend: View {
+ private let items: [ChartLegendItem]
+ @Binding private var hiddenSeries: Set
+
+ public init(items: [ChartLegendItem], hiddenSeries: Binding>) {
+ self.items = items
+ self._hiddenSeries = hiddenSeries
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(items) { item in
+ Button(action: { toggle(item.id) }) {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(item.color.linearGradient(from: .topLeading, to: .bottomTrailing))
+ .frame(width: 10, height: 10)
+ .opacity(isHidden(item.id) ? 0.25 : 1)
+
+ Text(item.title)
+ .font(.caption)
+ .foregroundColor(.primary)
+ .strikethrough(isHidden(item.id), color: .secondary)
+
+ Spacer(minLength: 0)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PlainButtonStyle())
+ .accessibility(label: Text(isHidden(item.id) ? "Show \(item.title)" : "Hide \(item.title)"))
+ }
+ }
+ }
+
+ private func isHidden(_ id: String) -> Bool {
+ hiddenSeries.contains(id)
+ }
+
+ private func toggle(_ id: String) {
+ if hiddenSeries.contains(id) {
+ hiddenSeries.remove(id)
+ } else {
+ hiddenSeries.insert(id)
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartAxisModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartAxisModifiers.swift
new file mode 100644
index 00000000..ed9dfc48
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartAxisModifiers.swift
@@ -0,0 +1,189 @@
+import SwiftUI
+
+private struct ChartXAxisLabelsModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let labels: [ChartXAxisLabel]
+ let range: ClosedRange?
+ let mode: ChartXDomainMode
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisXLabels = labels
+ updated.axisXRange = range
+ updated.axisXDomainMode = mode
+ updated.axisXAutoTickCount = nil
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+private struct ChartYAxisLabelsModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let labels: [String]
+ let position: AxisLabelsYPosition
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisYLabels = labels
+ updated.axisLabelsYPosition = position
+ updated.axisYAutoTickCount = nil
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+private struct ChartXAxisAutoTicksModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let count: Int
+ let format: ChartAxisTickFormat
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisXAutoTickCount = max(2, count)
+ updated.axisXTickFormat = format
+ updated.axisXLabels = []
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+private struct ChartYAxisAutoTicksModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let count: Int
+ let format: ChartAxisTickFormat
+ let position: AxisLabelsYPosition
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisYAutoTickCount = max(2, count)
+ updated.axisYTickFormat = format
+ updated.axisYLabels = []
+ updated.axisLabelsYPosition = position
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+private struct ChartXAxisLabelRotationModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let angle: Angle
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisXLabelRotation = angle
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+private struct ChartAxisFontModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let font: Font
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisFont = font
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+private struct ChartAxisColorModifier: ViewModifier {
+ @Environment(\.chartAxisConfig) private var currentConfig
+
+ let color: Color
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.axisFontColor = color
+ return content.environment(\.chartAxisConfig, updated)
+ }
+}
+
+public extension View {
+ func chartXAxisLabels(_ labels: [String]) -> some View {
+ modifier(ChartXAxisLabelsModifier(labels: ChartAxisLabelMapper.mapXAxis(labels),
+ range: labels.isEmpty ? nil : 0...Double(labels.count - 1),
+ mode: .categorical))
+ }
+
+ func chartXAxisLabels(_ labels: [(Double, String)], range: ClosedRange) -> some View {
+ let mapped = ChartAxisLabelMapper.mapXAxis(labels, in: range)
+ return modifier(ChartXAxisLabelsModifier(labels: mapped,
+ range: Double(range.lowerBound)...Double(range.upperBound),
+ mode: .numeric))
+ }
+
+ func chartYAxisLabels(_ labels: [String],
+ position: AxisLabelsYPosition = .leading) -> some View {
+ modifier(ChartYAxisLabelsModifier(labels: labels, position: position))
+ }
+
+ func chartYAxisLabels(_ labels: [(Double, String)],
+ range: ClosedRange,
+ position: AxisLabelsYPosition = .leading) -> some View {
+ modifier(ChartYAxisLabelsModifier(labels: ChartAxisLabelMapper.mapYAxis(labels, in: range), position: position))
+ }
+
+ func chartAxisFont(_ font: Font) -> some View {
+ modifier(ChartAxisFontModifier(font: font))
+ }
+
+ func chartAxisColor(_ color: Color) -> some View {
+ modifier(ChartAxisColorModifier(color: color))
+ }
+
+ func chartXAxisAutoTicks(_ count: Int = 5,
+ format: ChartAxisTickFormat = .number) -> some View {
+ modifier(ChartXAxisAutoTicksModifier(count: count, format: format))
+ }
+
+ func chartYAxisAutoTicks(_ count: Int = 5,
+ format: ChartAxisTickFormat = .number,
+ position: AxisLabelsYPosition = .leading) -> some View {
+ modifier(ChartYAxisAutoTicksModifier(count: count,
+ format: format,
+ position: position))
+ }
+
+ func chartXAxisLabelRotation(_ angle: Angle) -> some View {
+ modifier(ChartXAxisLabelRotationModifier(angle: angle))
+ }
+}
+
+enum ChartAxisLabelMapper {
+ static func mapXAxis(_ labels: [String]) -> [ChartXAxisLabel] {
+ labels.enumerated().map { index, label in
+ ChartXAxisLabel(value: Double(index), title: label)
+ }
+ }
+
+ static func mapXAxis(_ labels: [(Double, String)], in range: ClosedRange) -> [ChartXAxisLabel] {
+ var labelsByValue: [Double: String] = [:]
+ for (value, title) in labels {
+ labelsByValue[value] = title
+ }
+
+ for value in range {
+ labelsByValue[Double(value)] = labelsByValue[Double(value)] ?? ""
+ }
+
+ return labelsByValue
+ .sorted(by: { $0.key < $1.key })
+ .map { ChartXAxisLabel(value: $0.key, title: $0.value) }
+ }
+
+ static func mapYAxis(_ labels: [(Double, String)], in range: ClosedRange) -> [String] {
+ let count = max(0, range.overreach + 1)
+ guard count > 0 else { return [] }
+
+ var labelArray = Array(repeating: "", count: count)
+ for (value, label) in labels {
+ let index = Int(value) - range.lowerBound
+ if index >= 0, index < labelArray.count {
+ labelArray[index] = label
+ }
+ }
+ return labelArray
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartDataModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartDataModifiers.swift
new file mode 100644
index 00000000..e22958d6
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartDataModifiers.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+private struct ChartDataValuesModifier: ViewModifier {
+ let points: [(Double, Double)]
+ let xDomainMode: ChartXDomainMode
+
+ func body(content: Content) -> some View {
+ content
+ .environment(\.chartDataPoints, points)
+ .environment(\.chartXDomainMode, xDomainMode)
+ .preference(key: ChartDataPointsPreferenceKey.self, value: ChartDataPointsSnapshot(points: points))
+ .preference(key: ChartXDomainModePreferenceKey.self, value: xDomainMode)
+ }
+}
+
+private struct ChartXRangeModifier: ViewModifier {
+ let range: ClosedRange?
+
+ func body(content: Content) -> some View {
+ content
+ .environment(\.chartXRange, range)
+ .preference(key: ChartXRangePreferenceKey.self, value: range)
+ }
+}
+
+private struct ChartYRangeModifier: ViewModifier {
+ let range: ClosedRange?
+
+ func body(content: Content) -> some View {
+ content
+ .environment(\.chartYRange, range)
+ .preference(key: ChartYRangePreferenceKey.self, value: range)
+ }
+}
+
+public extension View {
+ func chartData(_ stream: ChartStreamingDataSource) -> some View {
+ chartData(stream.values)
+ }
+
+ func chartData(_ points: [Double]) -> some View {
+ let indexed = points.enumerated().map { (index, value) in (Double(index), value) }
+ return modifier(ChartDataValuesModifier(points: indexed, xDomainMode: .categorical))
+ }
+
+ func chartData(_ points: [(Double, Double)]) -> some View {
+ modifier(ChartDataValuesModifier(points: points, xDomainMode: .numeric))
+ }
+
+ func chartXRange(_ range: ClosedRange?) -> some View {
+ modifier(ChartXRangeModifier(range: range))
+ }
+
+ func chartYRange(_ range: ClosedRange?) -> some View {
+ modifier(ChartYRangeModifier(range: range))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartGridModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartGridModifiers.swift
new file mode 100644
index 00000000..dc179321
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartGridModifiers.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+private struct ChartGridLinesModifier: ViewModifier {
+ @Environment(\.chartGridConfig) private var currentConfig
+
+ let horizontal: Int
+ let vertical: Int
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.numberOfHorizontalLines = horizontal
+ updated.numberOfVerticalLines = vertical
+ return content.environment(\.chartGridConfig, updated)
+ }
+}
+
+private struct ChartGridStrokeModifier: ViewModifier {
+ @Environment(\.chartGridConfig) private var currentConfig
+
+ let style: StrokeStyle
+ let color: Color
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.strokeStyle = style
+ updated.color = color
+ return content.environment(\.chartGridConfig, updated)
+ }
+}
+
+private struct ChartGridBaselineModifier: ViewModifier {
+ @Environment(\.chartGridConfig) private var currentConfig
+
+ let visible: Bool
+ let style: StrokeStyle?
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.showBaseLine = visible
+ if let style = style {
+ updated.baseStrokeStyle = style
+ }
+ return content.environment(\.chartGridConfig, updated)
+ }
+}
+
+public extension View {
+ func chartGridLines(horizontal: Int, vertical: Int) -> some View {
+ modifier(ChartGridLinesModifier(horizontal: horizontal, vertical: vertical))
+ }
+
+ func chartGridStroke(style: StrokeStyle, color: Color) -> some View {
+ modifier(ChartGridStrokeModifier(style: style, color: color))
+ }
+
+ func chartGridBaseline(_ visible: Bool, style: StrokeStyle? = nil) -> some View {
+ modifier(ChartGridBaselineModifier(visible: visible, style: style))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartInteractionModifier.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartInteractionModifier.swift
new file mode 100644
index 00000000..8db88a1e
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartInteractionModifier.swift
@@ -0,0 +1,27 @@
+import SwiftUI
+
+private struct ChartInteractionModifier: ViewModifier {
+ let value: ChartValue?
+
+ func body(content: Content) -> some View {
+ content.environment(\.chartInteractionValue, value)
+ }
+}
+
+private struct ChartSelectionHandlerModifier: ViewModifier {
+ let handler: ChartSelectionHandler?
+
+ func body(content: Content) -> some View {
+ content.environment(\.chartSelectionHandler, handler)
+ }
+}
+
+public extension View {
+ func chartInteractionValue(_ value: ChartValue?) -> some View {
+ modifier(ChartInteractionModifier(value: value))
+ }
+
+ func chartSelectionHandler(_ handler: @escaping ChartSelectionHandler) -> some View {
+ modifier(ChartSelectionHandlerModifier(handler: handler))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartLineModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartLineModifiers.swift
new file mode 100644
index 00000000..ffd0a3e6
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartLineModifiers.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+private struct ChartLineWidthModifier: ViewModifier {
+ @Environment(\.chartLineConfig) private var currentConfig
+
+ let width: CGFloat
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.lineWidth = width
+ return content.environment(\.chartLineConfig, updated)
+ }
+}
+
+private struct ChartLineBackgroundModifier: ViewModifier {
+ @Environment(\.chartLineConfig) private var currentConfig
+
+ let gradient: ColorGradient?
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.backgroundGradient = gradient
+ return content.environment(\.chartLineConfig, updated)
+ }
+}
+
+private struct ChartLineMarksModifier: ViewModifier {
+ @Environment(\.chartLineConfig) private var currentConfig
+
+ let visible: Bool
+ let color: ColorGradient?
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.showChartMarks = visible
+ updated.customChartMarksColors = color
+ return content.environment(\.chartLineConfig, updated)
+ }
+}
+
+private struct ChartLineStyleModifier: ViewModifier {
+ @Environment(\.chartLineConfig) private var currentConfig
+
+ let style: LineStyle
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.lineStyle = style
+ return content.environment(\.chartLineConfig, updated)
+ }
+}
+
+private struct ChartLineAnimationModifier: ViewModifier {
+ @Environment(\.chartLineConfig) private var currentConfig
+
+ let enabled: Bool
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.animationEnabled = enabled
+ return content.environment(\.chartLineConfig, updated)
+ }
+}
+
+public extension View {
+ func chartLineWidth(_ width: CGFloat) -> some View {
+ modifier(ChartLineWidthModifier(width: width))
+ }
+
+ func chartLineBackground(_ gradient: ColorGradient?) -> some View {
+ modifier(ChartLineBackgroundModifier(gradient: gradient))
+ }
+
+ func chartLineMarks(_ visible: Bool, color: ColorGradient? = nil) -> some View {
+ modifier(ChartLineMarksModifier(visible: visible, color: color))
+ }
+
+ func chartLineStyle(_ style: LineStyle) -> some View {
+ modifier(ChartLineStyleModifier(style: style))
+ }
+
+ func chartLineAnimation(_ enabled: Bool) -> some View {
+ modifier(ChartLineAnimationModifier(enabled: enabled))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartPerformanceModifier.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartPerformanceModifier.swift
new file mode 100644
index 00000000..36eb929c
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartPerformanceModifier.swift
@@ -0,0 +1,15 @@
+import SwiftUI
+
+private struct ChartPerformanceModifier: ViewModifier {
+ let mode: ChartPerformanceMode
+
+ func body(content: Content) -> some View {
+ content.environment(\.chartPerformanceConfig, ChartPerformanceConfig(mode: mode))
+ }
+}
+
+public extension View {
+ func chartPerformance(_ mode: ChartPerformanceMode) -> some View {
+ modifier(ChartPerformanceModifier(mode: mode))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartSeriesModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartSeriesModifiers.swift
new file mode 100644
index 00000000..c003d8d5
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartSeriesModifiers.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+
+private struct ChartSeriesIDModifier: ViewModifier {
+ @Environment(\.chartSeriesConfig) private var currentConfig
+
+ let seriesID: String?
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.seriesID = seriesID
+ return content.environment(\.chartSeriesConfig, updated)
+ }
+}
+
+private struct ChartHiddenSeriesModifier: ViewModifier {
+ @Environment(\.chartSeriesConfig) private var currentConfig
+
+ let hiddenSeries: Set
+
+ func body(content: Content) -> some View {
+ var updated = currentConfig
+ updated.hiddenSeriesIDs = hiddenSeries
+ return content.environment(\.chartSeriesConfig, updated)
+ }
+}
+
+public extension View {
+ func chartSeriesID(_ id: String?) -> some View {
+ modifier(ChartSeriesIDModifier(seriesID: id))
+ }
+
+ func chartHiddenSeries(_ ids: Set) -> some View {
+ modifier(ChartHiddenSeriesModifier(hiddenSeries: ids))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartStyleModifier.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartStyleModifier.swift
new file mode 100644
index 00000000..d2d35bca
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Modifiers/ChartStyleModifier.swift
@@ -0,0 +1,15 @@
+import SwiftUI
+
+private struct ChartStyleModifier: ViewModifier {
+ let style: ChartStyle
+
+ func body(content: Content) -> some View {
+ content.environment(\.chartStyle, style)
+ }
+}
+
+public extension View {
+ func chartStyle(_ style: ChartStyle) -> some View {
+ modifier(ChartStyleModifier(style: style))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Style/ChartStyle.swift b/Sources/SwiftUICharts/Base/Style/ChartStyle.swift
new file mode 100644
index 00000000..451ad18e
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Style/ChartStyle.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+
+public struct ChartStyle {
+ public let backgroundColor: ColorGradient
+ public let foregroundColor: [ColorGradient]
+
+ public init(backgroundColor: Color, foregroundColor: [ColorGradient]) {
+ self.backgroundColor = ColorGradient(backgroundColor)
+ self.foregroundColor = foregroundColor
+ }
+
+ public init(backgroundColor: Color, foregroundColor: ColorGradient) {
+ self.backgroundColor = ColorGradient(backgroundColor)
+ self.foregroundColor = [foregroundColor]
+ }
+
+ public init(backgroundColor: ColorGradient, foregroundColor: ColorGradient) {
+ self.backgroundColor = backgroundColor
+ self.foregroundColor = [foregroundColor]
+ }
+
+ public init(backgroundColor: ColorGradient, foregroundColor: [ColorGradient]) {
+ self.backgroundColor = backgroundColor
+ self.foregroundColor = foregroundColor
+ }
+}
+
+public extension ChartStyle {
+ static var highContrast: ChartStyle {
+ ChartStyle(backgroundColor: Color.primary.opacity(0.12),
+ foregroundColor: [
+ ColorGradient(.yellow, .orange),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.green, .yellow),
+ ColorGradient(.pink, .purple)
+ ])
+ }
+
+ static var highContrastMono: ChartStyle {
+ ChartStyle(backgroundColor: Color.primary.opacity(0.15),
+ foregroundColor: ColorGradient(.white, .primary))
+ }
+}
diff --git a/Sources/SwiftUICharts/Base/Style/ColorGradient.swift b/Sources/SwiftUICharts/Base/Style/ColorGradient.swift
new file mode 100644
index 00000000..6625428e
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Style/ColorGradient.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+public struct ColorGradient: Equatable {
+ public let startColor: Color
+ public let endColor: Color
+
+ public init(_ color: Color) {
+ self.startColor = color
+ self.endColor = color
+ }
+
+ public init(_ startColor: Color, _ endColor: Color) {
+ self.startColor = startColor
+ self.endColor = endColor
+ }
+
+ public var gradient: Gradient {
+ return Gradient(colors: [startColor, endColor])
+ }
+}
+
+extension ColorGradient {
+ public func linearGradient(from startPoint: UnitPoint, to endPoint: UnitPoint) -> LinearGradient {
+ return LinearGradient(gradient: self.gradient, startPoint: startPoint, endPoint: endPoint)
+ }
+}
+
+extension ColorGradient {
+ public static let orangeBright = ColorGradient(ChartColors.orangeBright)
+ public static let redBlack = ColorGradient(.red, .black)
+ public static let greenRed = ColorGradient(.green, .red)
+ public static let whiteBlack = ColorGradient(.white, .black)
+}
diff --git a/Sources/SwiftUICharts/Base/Style/Colors.swift b/Sources/SwiftUICharts/Base/Style/Colors.swift
new file mode 100644
index 00000000..a230e901
--- /dev/null
+++ b/Sources/SwiftUICharts/Base/Style/Colors.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+
+public enum ChartColors {
+ public static let orangeBright = Color(hexString: "#FF782C")
+ public static let orangeDark = Color(hexString: "#EC2301")
+
+ public static let legendColor: Color = Color(hexString: "#E8E7EA")
+ public static let indicatorKnob: Color = Color(hexString: "#FF57A6")
+}
diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift
new file mode 100644
index 00000000..d5cc07c0
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+public struct BarChart: View {
+ @Environment(\.chartDataPoints) private var points
+ @Environment(\.chartYRange) private var rangeY
+ @Environment(\.chartXRange) private var rangeX
+ @Environment(\.chartStyle) private var style
+ @Environment(\.chartSeriesConfig) private var seriesConfig
+
+ public init() {}
+
+ public var body: some View {
+ if isSeriesVisible {
+ BarChartRow(chartData: ChartData(points, rangeY: rangeY, rangeX: rangeX), style: style)
+ }
+ }
+
+ private var isSeriesVisible: Bool {
+ guard let seriesID = seriesConfig.seriesID else { return true }
+ return !seriesConfig.hiddenSeriesIDs.contains(seriesID)
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift
new file mode 100644
index 00000000..13153557
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift
@@ -0,0 +1,52 @@
+import SwiftUI
+
+public struct BarChartCell: View {
+ var value: Double
+ var index: Int = 0
+ var gradientColor: ColorGradient
+ var touchLocation: CGFloat
+
+ @State private var didCellAppear: Bool = false
+
+ public init( value: Double,
+ index: Int = 0,
+ gradientColor: ColorGradient,
+ touchLocation: CGFloat) {
+ self.value = value
+ self.index = index
+ self.gradientColor = gradientColor
+ self.touchLocation = touchLocation
+ }
+
+ public var body: some View {
+ BarChartCellShape(value: didCellAppear ? value : 0.0)
+ .fill(gradientColor.linearGradient(from: .bottom, to: .top)) .onAppear {
+ self.didCellAppear = true
+ }
+ .onDisappear {
+ self.didCellAppear = false
+ }
+ .transition(.slide)
+ .animation(Animation.spring().delay(self.touchLocation < 0 || !didCellAppear ? Double(self.index) * 0.04 : 0))
+ }
+}
+
+struct BarChartCell_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ Group {
+ BarChartCell(value: 0, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
+
+ BarChartCell(value: 0.5, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
+ BarChartCell(value: 0.75, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
+ BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
+ }
+
+ Group {
+ BarChartCell(value: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
+ BarChartCell(value: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
+ BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
+ }.environment(\.colorScheme, .dark)
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift
new file mode 100644
index 00000000..268f4229
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+struct BarChartCellShape: Shape, Animatable {
+ var value: Double
+ var cornerRadius: CGFloat = 6.0
+
+ var animatableData: CGFloat {
+ get { CGFloat(value) }
+ set { value = Double(newValue) }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let adjustedOriginY = rect.height - (rect.height * CGFloat(value))
+ var path = Path()
+ path.move(to: CGPoint(x: 0.0 , y: rect.height))
+ path.addLine(to: CGPoint(x: 0.0, y: adjustedOriginY + cornerRadius))
+ path.addArc(center: CGPoint(x: cornerRadius, y: adjustedOriginY + cornerRadius),
+ radius: cornerRadius,
+ startAngle: Angle(radians: Double.pi),
+ endAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2),
+ clockwise: value < 0 ? true : false)
+ path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: value < 0 ? adjustedOriginY + 2 * cornerRadius : adjustedOriginY))
+ path.addArc(center: CGPoint(x: rect.width - cornerRadius, y: adjustedOriginY + cornerRadius),
+ radius: cornerRadius,
+ startAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2),
+ endAngle: Angle(radians: 0),
+ clockwise: value < 0 ? true : false)
+ path.addLine(to: CGPoint(x: rect.width, y: rect.height))
+ path.closeSubpath()
+
+ return path
+ }
+}
+
+struct BarChartCellShape_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ BarChartCellShape(value: 0.75)
+ .fill(Color.red)
+
+ BarChartCellShape(value: 0.3)
+ .fill(Color.blue)
+
+ BarChartCellShape(value: -0.3)
+ .fill(Color.blue)
+ .offset(x: 0, y: -600)
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift
new file mode 100644
index 00000000..a68d9118
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift
@@ -0,0 +1,101 @@
+import SwiftUI
+
+public struct BarChartRow: View {
+ @Environment(\.chartInteractionValue) private var chartValue
+ @Environment(\.chartSelectionHandler) private var selectionHandler
+
+ var chartData: ChartData
+ @State private var touchLocation: CGFloat = -1.0
+
+ var style: ChartStyle
+
+ public var body: some View {
+ GeometryReader { geometry in
+ let localFrame = geometry.frame(in: .local).sanitized
+ let safeWidth = localFrame.width
+ let safeHeight = localFrame.height
+ let barCount = max(1, chartData.data.count)
+ let slotWidth = safeWidth / CGFloat(barCount)
+ let barWidthRatio: CGFloat = 0.72
+
+ HStack(alignment: .bottom, spacing: 0) {
+ ForEach(0.. 0 else { return }
+ touchLocation = value.location.x / width
+ if let selected = getCurrentSelection(width: width) {
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: selected.value,
+ index: selected.index,
+ isActive: true)
+ } else {
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: nil,
+ index: nil,
+ isActive: false)
+ }
+ })
+ .onEnded({ _ in
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: nil,
+ index: nil,
+ isActive: false)
+ touchLocation = -1
+ })
+ )
+ }
+ }
+
+ func getScaleSize(touchLocation: CGFloat, index: Int) -> CGSize {
+ if touchLocation > CGFloat(index) / CGFloat(max(1, chartData.data.count)) &&
+ touchLocation < CGFloat(index + 1) / CGFloat(max(1, chartData.data.count)) {
+ return CGSize(width: 1.4, height: 1.1)
+ }
+ return CGSize(width: 1, height: 1)
+ }
+
+ func getCurrentSelection(width: CGFloat) -> (index: Int, value: Double)? {
+ guard !chartData.data.isEmpty else { return nil }
+ guard width.isFinite, width > 0 else { return nil }
+ let denominator = width / CGFloat(chartData.data.count)
+ guard denominator > 0, denominator.isFinite else { return nil }
+ let index = max(0, min(chartData.data.count - 1, Int(floor((touchLocation * width) / denominator))))
+ return (index, chartData.points[index])
+ }
+
+ private func formatted(_ value: Double) -> String {
+ if abs(value.rounded() - value) < 0.001 {
+ return String(Int(value.rounded()))
+ }
+ return String(format: "%.2f", value)
+ }
+}
+
+struct BarChartRow_Previews: PreviewProvider {
+ static let chartData = ChartData([6, 2, 5, 8, 6])
+ static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright)
+
+ static var previews: some View {
+ BarChartRow(chartData: chartData, style: chartStyle)
+ }
+}
diff --git a/Sources/SwiftUICharts/LineChart/IndicatorPoint.swift b/Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift
similarity index 52%
rename from Sources/SwiftUICharts/LineChart/IndicatorPoint.swift
rename to Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift
index 2e8667da..69d8d88c 100644
--- a/Sources/SwiftUICharts/LineChart/IndicatorPoint.swift
+++ b/Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift
@@ -1,23 +1,15 @@
-//
-// IndicatorPoint.swift
-// LineChart
-//
-// Created by András Samu on 2019. 09. 03..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
import SwiftUI
struct IndicatorPoint: View {
- var body: some View {
- ZStack{
+ public var body: some View {
+ ZStack {
Circle()
- .fill(Colors.IndicatorKnob)
+ .fill(ChartColors.indicatorKnob)
Circle()
.stroke(Color.white, style: StrokeStyle(lineWidth: 4))
}
.frame(width: 14, height: 14)
- .shadow(color: Colors.LegendColor, radius: 6, x: 0, y: 6)
+ .shadow(color: ChartColors.legendColor, radius: 6, x: 0, y: 6)
}
}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/Line.swift b/Sources/SwiftUICharts/Charts/LineChart/Line.swift
new file mode 100644
index 00000000..50e769c2
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/Line.swift
@@ -0,0 +1,181 @@
+import SwiftUI
+
+/// A single line of data, a view in a `LineChart`
+public struct Line: View {
+ @Environment(\.chartInteractionValue) private var chartValue
+ @Environment(\.chartSelectionHandler) private var selectionHandler
+
+ var chartData: ChartData
+ var chartProperties: ChartLineConfig
+
+ var style: ChartStyle
+
+ @State private var didCellAppear: Bool = false
+ @State private var touchLocation: CGFloat = -1
+
+ public init(chartData: ChartData,
+ style: ChartStyle,
+ chartProperties: ChartLineConfig) {
+ self.chartData = chartData
+ self.style = style
+ self.chartProperties = chartProperties
+ }
+
+ public var body: some View {
+ GeometryReader { geometry in
+ let safeFrame = geometry.frame(in: .local).sanitized
+ ZStack {
+ if didCellAppear, let backgroundColor = chartProperties.backgroundGradient {
+ LineBackgroundShapeView(chartData: chartData,
+ geometry: geometry,
+ backgroundColor: backgroundColor)
+ }
+ lineShapeView(geometry: geometry)
+ selectionOverlay(size: safeFrame.size)
+ accessibilityOverlay(size: safeFrame.size)
+ }
+ .frame(width: safeFrame.width, height: safeFrame.height, alignment: .topLeading)
+ .contentShape(Rectangle())
+ .gesture(DragGesture(minimumDistance: 0)
+ .onChanged({ value in
+ guard safeFrame.width > 0 else { return }
+ touchLocation = max(0, min(1, value.location.x / safeFrame.width))
+ publishSelectionState(active: true)
+ })
+ .onEnded({ _ in
+ publishSelectionState(active: false)
+ touchLocation = -1
+ }))
+ .onAppear {
+ didCellAppear = true
+ }
+ .onDisappear {
+ didCellAppear = false
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func lineShapeView(geometry: GeometryProxy) -> some View {
+ if chartProperties.animationEnabled {
+ LineShapeView(chartData: chartData,
+ chartProperties: chartProperties,
+ geometry: geometry,
+ style: style,
+ trimTo: didCellAppear ? 1.0 : 0.0)
+ .animation(Animation.easeIn(duration: 0.75))
+ } else {
+ LineShapeView(chartData: chartData,
+ chartProperties: chartProperties,
+ geometry: geometry,
+ style: style,
+ trimTo: 1.0)
+ }
+ }
+
+ @ViewBuilder
+ private func selectionOverlay(size: CGSize) -> some View {
+ if chartValue != nil,
+ touchLocation >= 0,
+ let selectedPoint = selectedChartPoint(size: size) {
+ ZStack {
+ Path { path in
+ path.move(to: CGPoint(x: selectedPoint.x, y: 0))
+ path.addLine(to: CGPoint(x: selectedPoint.x, y: size.height))
+ }
+ .stroke(style.foregroundColor.rotate(for: selectedPoint.index).startColor.opacity(0.28),
+ style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
+
+ IndicatorPoint()
+ .position(x: selectedPoint.x, y: selectedPoint.y)
+ }
+ .toStandardCoordinateSystem()
+ }
+ }
+
+ @ViewBuilder
+ private func accessibilityOverlay(size: CGSize) -> some View {
+ if !chartData.normalisedData.isEmpty {
+ ZStack {
+ ForEach(Array(chartData.normalisedData.enumerated()), id: \.offset) { index, point in
+ Circle()
+ .fill(Color.clear)
+ .frame(width: 30, height: 30)
+ .position(x: CGFloat(point.0) * size.width,
+ y: CGFloat(point.1) * size.height)
+ .accessibilityElement(children: .ignore)
+ .accessibility(label: Text("Point \(index + 1), value \(formatted(chartData.points[index]))"))
+ }
+ }
+ .toStandardCoordinateSystem()
+ .allowsHitTesting(false)
+ }
+ }
+
+ private func selectedIndex() -> Int? {
+ guard !chartData.normalisedData.isEmpty, touchLocation >= 0 else { return nil }
+
+ return chartData.normalisedData.enumerated().min {
+ abs($0.element.0 - Double(touchLocation)) < abs($1.element.0 - Double(touchLocation))
+ }?.offset
+ }
+
+ private func selectedChartPoint(size: CGSize) -> (index: Int, x: CGFloat, y: CGFloat)? {
+ guard let index = selectedIndex(),
+ index < chartData.normalisedData.count else {
+ return nil
+ }
+
+ let point = chartData.normalisedData[index]
+ return (index, CGFloat(point.0) * size.width, CGFloat(point.1) * size.height)
+ }
+
+ private func publishSelectionState(active: Bool) {
+ guard active else {
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: nil,
+ index: nil,
+ isActive: false)
+ return
+ }
+
+ guard let index = selectedIndex(), index < chartData.points.count else {
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: nil,
+ index: nil,
+ isActive: false)
+ return
+ }
+
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: chartData.points[index],
+ index: index,
+ isActive: true)
+ }
+
+ private func formatted(_ value: Double) -> String {
+ if abs(value.rounded() - value) < 0.001 {
+ return String(Int(value.rounded()))
+ }
+ return String(format: "%.2f", value)
+ }
+}
+
+struct Line_Previews: PreviewProvider {
+ static let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black))
+ static let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red))
+
+ static var previews: some View {
+ Group {
+ Line(chartData: ChartData([8, 23, 32, 7, 23, -4]),
+ style: blackLineStyle,
+ chartProperties: ChartLineConfig())
+ Line(chartData: ChartData([8, 23, 32, 7, 23, 43]),
+ style: redLineStyle,
+ chartProperties: ChartLineConfig())
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift
new file mode 100644
index 00000000..3b57733e
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift
@@ -0,0 +1,27 @@
+import SwiftUI
+
+struct LineBackgroundShape: Shape {
+ var data: [(Double, Double)]
+ func path(in rect: CGRect) -> Path {
+ let path = Path.quadClosedCurvedPathWithPoints(data: data, in: rect)
+ return path
+ }
+}
+
+struct LineBackgroundShape_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ GeometryReader { geometry in
+ LineBackgroundShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)])
+ .fill(Color.red)
+ .toStandardCoordinateSystem()
+ }
+ GeometryReader { geometry in
+ LineBackgroundShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
+ .fill(Color.blue)
+ .toStandardCoordinateSystem()
+ }
+ }
+ }
+}
+
diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift
new file mode 100644
index 00000000..2a4f45eb
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+
+struct LineBackgroundShapeView: View {
+ var chartData: ChartData
+ var geometry: GeometryProxy
+ var backgroundColor: ColorGradient
+
+ var body: some View {
+ LineBackgroundShape(data: chartData.normalisedData)
+ .fill(LinearGradient(gradient: Gradient(colors: [backgroundColor.startColor,
+ backgroundColor.endColor]),
+ startPoint: .bottom,
+ endPoint: .top))
+ .toStandardCoordinateSystem()
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift b/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift
new file mode 100644
index 00000000..1d933ed3
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift
@@ -0,0 +1,77 @@
+import SwiftUI
+
+public struct LineChart: View {
+ @Environment(\.chartDataPoints) private var points
+ @Environment(\.chartYRange) private var rangeY
+ @Environment(\.chartXRange) private var rangeX
+ @Environment(\.chartXDomainMode) private var xDomainMode
+ @Environment(\.chartStyle) private var style
+ @Environment(\.chartLineConfig) private var lineConfig
+ @Environment(\.chartSeriesConfig) private var seriesConfig
+ @Environment(\.chartPerformanceConfig) private var performanceConfig
+
+ public init() {}
+
+ public var body: some View {
+ if isSeriesVisible {
+ Line(chartData: ChartData(performanceAdjustedPoints,
+ rangeY: rangeY,
+ rangeX: rangeX,
+ xDomainMode: xDomainMode),
+ style: style,
+ chartProperties: performanceAdjustedLineConfig)
+ }
+ }
+
+ private var isSeriesVisible: Bool {
+ guard let seriesID = seriesConfig.seriesID else { return true }
+ return !seriesConfig.hiddenSeriesIDs.contains(seriesID)
+ }
+
+ private var performanceAdjustedPoints: [(Double, Double)] {
+ let downsampled: [(Double, Double)]
+
+ switch performanceConfig.mode {
+ case .none:
+ downsampled = points
+ case .automatic(let threshold, let maxPoints, _):
+ if points.count > max(2, threshold) {
+ downsampled = ChartDownsampler.reduced(points, maxPoints: maxPoints)
+ } else {
+ downsampled = points
+ }
+ case .downsample(let maxPoints, _):
+ downsampled = ChartDownsampler.reduced(points, maxPoints: maxPoints)
+ }
+
+ if xDomainMode == .categorical {
+ return downsampled.enumerated().map { index, value in
+ (Double(index), value.1)
+ }
+ }
+
+ return downsampled
+ }
+
+ private var performanceAdjustedLineConfig: ChartLineConfig {
+ var updated = lineConfig
+
+ let shouldSimplify: Bool
+ switch performanceConfig.mode {
+ case .none:
+ shouldSimplify = false
+ case .automatic(let threshold, _, let simplify):
+ shouldSimplify = simplify && points.count > max(2, threshold)
+ case .downsample(_, let simplify):
+ shouldSimplify = simplify
+ }
+
+ if shouldSimplify {
+ updated.lineStyle = .straight
+ updated.showChartMarks = false
+ updated.animationEnabled = false
+ }
+
+ return updated
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift b/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift
new file mode 100644
index 00000000..e22be2f4
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct LineShape: Shape {
+ var data: [(Double, Double)]
+ var lineStyle: LineStyle = .curved
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ switch lineStyle {
+ case .curved:
+ path = Path.quadCurvedPathWithPoints(data: data, in: rect)
+ case .straight:
+ path = Path.linePathWithPoints(data: data, in: rect)
+ }
+ return path
+ }
+}
+
+struct LineShape_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ LineShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
+ .stroke()
+ .toStandardCoordinateSystem()
+
+ LineShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)], lineStyle: .straight)
+ .stroke()
+ .toStandardCoordinateSystem()
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift b/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift
new file mode 100644
index 00000000..bebf7616
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+
+struct LineShapeView: View, Animatable {
+ var chartData: ChartData
+ var chartProperties: ChartLineConfig
+
+ var geometry: GeometryProxy
+ var style: ChartStyle
+ var trimTo: Double = 0
+
+ var animatableData: CGFloat {
+ get { CGFloat(trimTo) }
+ set { trimTo = Double(newValue) }
+ }
+
+ var chartMarkColor: LinearGradient {
+ if let customColor = chartProperties.customChartMarksColors {
+ return customColor.linearGradient(from: .leading, to: .trailing)
+ }
+
+ return LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient,
+ startPoint: .leading,
+ endPoint: .trailing)
+ }
+
+ var body: some View {
+ ZStack {
+ LineShape(data: chartData.normalisedData, lineStyle: chartProperties.lineStyle)
+ .trim(from: 0, to: CGFloat(trimTo))
+ .stroke(LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient,
+ startPoint: .leading,
+ endPoint: .trailing),
+ style: StrokeStyle(lineWidth: chartProperties.lineWidth, lineJoin: .round))
+ .toStandardCoordinateSystem()
+ .clipped()
+ if chartProperties.showChartMarks {
+ MarkerShape(data: chartData.normalisedData)
+ .trim(from: 0, to: CGFloat(trimTo))
+ .fill(style.backgroundColor.startColor.opacity(0.95),
+ strokeBorder: chartMarkColor,
+ lineWidth: chartProperties.lineWidth)
+ .toStandardCoordinateSystem()
+ }
+ }
+ }
+}
+
+struct LineShapeView_Previews: PreviewProvider {
+ static let chartData = ChartData([6, 8, 6], rangeY: 6...10)
+ static let chartDataOutOfRange = ChartData([-1, 8, 6, 12, 3], rangeY: -5...15)
+ static let chartDataOutOfRange2 = ChartData([6, 6, 8, 5], rangeY: 5...10)
+
+ static let chartStyle = ChartStyle(backgroundColor: Color.white,
+ foregroundColor: [ColorGradient(Color.orange, Color.red)])
+
+ static var previews: some View {
+ Group {
+ GeometryReader { geometry in
+ LineShapeView(chartData: chartData,
+ chartProperties: ChartLineConfig(),
+ geometry: geometry,
+ style: chartStyle,
+ trimTo: 1.0)
+ }
+ GeometryReader { geometry in
+ LineShapeView(chartData: chartDataOutOfRange,
+ chartProperties: ChartLineConfig(),
+ geometry: geometry,
+ style: chartStyle,
+ trimTo: 1.0)
+ }
+ GeometryReader { geometry in
+ LineShapeView(chartData: chartDataOutOfRange2,
+ chartProperties: ChartLineConfig(),
+ geometry: geometry,
+ style: chartStyle,
+ trimTo: 1.0)
+ }
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift b/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift
new file mode 100644
index 00000000..0207b896
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct MarkerShape: Shape {
+ var data: [(Double, Double)]
+ func path(in rect: CGRect) -> Path {
+ let path = Path.drawChartMarkers(data: data, in: rect)
+ return path
+ }
+}
+
+struct MarkerShape_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ MarkerShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
+ .stroke()
+ .toStandardCoordinateSystem()
+
+ MarkerShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)])
+ .stroke()
+ .toStandardCoordinateSystem()
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift b/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift
new file mode 100644
index 00000000..c6684624
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift
@@ -0,0 +1,4 @@
+import SwiftUI
+
+@available(*, deprecated, renamed: "ChartLineConfig")
+public typealias LineChartProperties = ChartLineConfig
diff --git a/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift b/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift
new file mode 100644
index 00000000..1612e8f2
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift
@@ -0,0 +1,6 @@
+import Foundation
+
+public enum LineStyle: Sendable {
+ case curved
+ case straight
+}
diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift
new file mode 100644
index 00000000..0e1fcc57
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+/// A type of chart that displays a slice of "pie" for each data point
+public struct PieChart: View {
+ @Environment(\.chartDataPoints) private var points
+ @Environment(\.chartYRange) private var rangeY
+ @Environment(\.chartXRange) private var rangeX
+ @Environment(\.chartStyle) private var style
+ @Environment(\.chartSeriesConfig) private var seriesConfig
+
+ public init() {}
+
+ public var body: some View {
+ if isSeriesVisible {
+ PieChartRow(chartData: ChartData(points, rangeY: rangeY, rangeX: rangeX), style: style)
+ .aspectRatio(1, contentMode: .fit)
+ }
+ }
+
+ private var isSeriesVisible: Bool {
+ guard let seriesID = seriesConfig.seriesID else { return true }
+ return !seriesConfig.hiddenSeriesIDs.contains(seriesID)
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift
new file mode 100644
index 00000000..465f6992
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift
@@ -0,0 +1,119 @@
+import SwiftUI
+
+/// One slice of a `PieChartRow`
+struct PieSlice: Identifiable {
+ var id = UUID()
+ var startDeg: Double
+ var endDeg: Double
+ var value: Double
+}
+
+/// A single row of data, a view in a `PieChart`
+public struct PieChartCell: View {
+ @State private var show: Bool = false
+ var rect: CGRect
+ private var safeRect: CGRect {
+ rect.sanitized
+ }
+ var radius: CGFloat {
+ return min(safeRect.width, safeRect.height)/2
+ }
+ var startDeg: Double
+ var endDeg: Double
+
+ /// Path representing this slice
+ var path: Path {
+ var path = Path()
+ path.addArc(
+ center: safeRect.mid,
+ radius: self.radius,
+ startAngle: Angle(degrees: self.startDeg),
+ endAngle: Angle(degrees: self.endDeg),
+ clockwise: false)
+ path.addLine(to: safeRect.mid)
+ path.closeSubpath()
+ return path
+ }
+ var index: Int
+
+ // Section line border color
+ var backgroundColor: Color
+
+ // Section color
+ var accentColor: ColorGradient
+
+ /// The content and behavior of the `PieChartCell`.
+ ///
+ /// Fills and strokes with 2-pixel line (unless start/end degrees not yet set). Animates by scaling up to 100% when first appears.
+ public var body: some View {
+ Group {
+ path
+ .fill(self.accentColor.linearGradient(from: .bottom, to: .top))
+ .overlay(path.stroke(self.backgroundColor, lineWidth: (startDeg == 0 && endDeg == 0 ? 0 : 2)))
+ .scaleEffect(self.show ? 1 : 0)
+ .animation(Animation.spring().delay(Double(self.index) * 0.04))
+ .onAppear {
+ self.show = true
+ }
+
+ }
+ }
+}
+
+struct PieChartCell_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+
+ GeometryReader { geometry in
+ PieChartCell(
+ rect: geometry.frame(in: .local),
+ startDeg: 00.0,
+ endDeg: 90.0,
+ index: 0,
+ backgroundColor: Color.red,
+ accentColor: ColorGradient.greenRed)
+ }.frame(width: 100, height: 100)
+
+ GeometryReader { geometry in
+ PieChartCell(
+ rect: geometry.frame(in: .local),
+ startDeg: 0.0,
+ endDeg: 90.0,
+ index: 0,
+ backgroundColor: Color.green,
+ accentColor: ColorGradient.redBlack)
+ }.frame(width: 100, height: 100)
+
+ GeometryReader { geometry in
+ PieChartCell(
+ rect: geometry.frame(in: .local),
+ startDeg: 100.0,
+ endDeg: 135.0,
+ index: 0,
+ backgroundColor: Color.black,
+ accentColor: ColorGradient.whiteBlack)
+ }.frame(width: 100, height: 100)
+
+ GeometryReader { geometry in
+ PieChartCell(
+ rect: geometry.frame(in: .local),
+ startDeg: 185.0,
+ endDeg: 290.0,
+ index: 1,
+ backgroundColor: Color.purple,
+ accentColor: ColorGradient(.purple))
+ }.frame(width: 100, height: 100)
+
+ GeometryReader { geometry in
+ PieChartCell(
+ rect: geometry.frame(in: .local),
+ startDeg: 0,
+ endDeg: 0,
+ index: 0,
+ backgroundColor: Color.purple,
+ accentColor: ColorGradient(.purple))
+ }.frame(width: 100, height: 100)
+
+ }.previewLayout(.fixed(width: 125, height: 125))
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift
new file mode 100644
index 00000000..5dd0d4e0
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+func isPointInCircle(point: CGPoint, circleRect: CGRect) -> Bool {
+ let circleRect = circleRect.sanitized
+ let r = min(circleRect.width, circleRect.height) / 2
+ guard r > 0 else { return false }
+ let center = CGPoint(x: circleRect.midX, y: circleRect.midY)
+ let dx = point.x - center.x
+ let dy = point.y - center.y
+ let distance = sqrt(dx * dx + dy * dy)
+ return distance <= r
+}
+
+func degree(for point: CGPoint, inCircleRect circleRect: CGRect) -> Double {
+ let circleRect = circleRect.sanitized
+ let center = CGPoint(x: circleRect.midX, y: circleRect.midY)
+ let dx = point.x - center.x
+ let dy = point.y - center.y
+ guard dx != 0 else {
+ return dy >= 0 ? 90 : 270
+ }
+ let acuteDegree = Double(atan(dy / dx)) * (180 / .pi)
+
+ let isInBottomRight = dx >= 0 && dy >= 0
+ let isInBottomLeft = dx <= 0 && dy >= 0
+ let isInTopLeft = dx <= 0 && dy <= 0
+ let isInTopRight = dx >= 0 && dy <= 0
+
+ if isInBottomRight {
+ return acuteDegree
+ } else if isInBottomLeft {
+ return 180 - abs(acuteDegree)
+ } else if isInTopLeft {
+ return 180 + abs(acuteDegree)
+ } else if isInTopRight {
+ return 360 - abs(acuteDegree)
+ }
+
+ return 0
+}
diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift
new file mode 100644
index 00000000..516c2309
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+/// A single "row" (slice) of data, a view in a `PieChart`
+public struct PieChartRow: View {
+ @Environment(\.chartInteractionValue) private var chartValue
+ @Environment(\.chartSelectionHandler) private var selectionHandler
+
+ var chartData: ChartData
+ var style: ChartStyle
+
+ var slices: [PieSlice] {
+ var tempSlices: [PieSlice] = []
+ var lastEndDeg: Double = 0
+ let maxValue: Double = chartData.points.reduce(0, +)
+
+ for slice in chartData.points {
+ let normalized: Double = Double(slice) / (maxValue == 0 ? 1 : maxValue)
+ let startDeg = lastEndDeg
+ let endDeg = lastEndDeg + (normalized * 360)
+ lastEndDeg = endDeg
+ tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice))
+ }
+
+ return tempSlices
+ }
+
+ @State private var currentTouchedIndex = -1 {
+ didSet {
+ guard oldValue != currentTouchedIndex else {
+ return
+ }
+
+ if currentTouchedIndex == -1 {
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: nil,
+ index: nil,
+ isActive: false)
+ } else {
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: selectionHandler,
+ value: slices[currentTouchedIndex].value,
+ index: currentTouchedIndex,
+ isActive: true)
+ }
+ }
+ }
+
+ public var body: some View {
+ GeometryReader { geometry in
+ let rect = geometry.frame(in: .local).sanitized
+ let total = max(0.0001, chartData.points.reduce(0, +))
+ ZStack {
+ ForEach(Array(slices.indices), id: \.self) { index in
+ PieChartCell(rect: rect,
+ startDeg: slices[index].startDeg,
+ endDeg: slices[index].endDeg,
+ index: index,
+ backgroundColor: style.backgroundColor.startColor,
+ accentColor: style.foregroundColor.rotate(for: index))
+ .scaleEffect(currentTouchedIndex == index ? 1.1 : 1)
+ .animation(Animation.spring())
+ .accessibilityElement(children: .ignore)
+ .accessibility(label: Text("Slice \(index + 1), value \(formatted(slices[index].value)), \(formatted((slices[index].value / total) * 100)) percent"))
+ }
+ }
+ .frame(width: rect.width, height: rect.height, alignment: .topLeading)
+ .gesture(DragGesture()
+ .onChanged({ value in
+ let isTouchInPie = isPointInCircle(point: value.location, circleRect: rect)
+ if isTouchInPie {
+ let touchDegree = degree(for: value.location, inCircleRect: rect)
+ currentTouchedIndex = slices.firstIndex(where: { $0.startDeg < touchDegree && $0.endDeg > touchDegree }) ?? -1
+ } else {
+ currentTouchedIndex = -1
+ }
+ })
+ .onEnded({ _ in
+ currentTouchedIndex = -1
+ })
+ )
+ }
+ }
+
+ private func formatted(_ value: Double) -> String {
+ if abs(value.rounded() - value) < 0.001 {
+ return String(Int(value.rounded()))
+ }
+ return String(format: "%.1f", value)
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift b/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift
new file mode 100644
index 00000000..c42599c1
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift
@@ -0,0 +1,189 @@
+//
+// Ring.swift
+// ChartViewV2Demo
+//
+// Created by Dan Wood on 8/20/20.
+// Based on article and playground code by Frank Jia
+// https://medium.com/@frankjia/creating-activity-rings-in-swiftui-11ef7d336676
+
+import SwiftUI
+
+
+extension Double {
+ func toRadians() -> Double {
+ return self * Double.pi / 180
+ }
+ func toCGFloat() -> CGFloat {
+ return CGFloat(self)
+ }
+}
+
+struct RingShape: Shape {
+ /// Helper function to convert percent values to angles in degrees
+ /// - Parameters:
+ /// - percent: percent, greater than 100 is OK
+ /// - startAngle: angle to add after converting
+ /// - Returns: angle in degrees
+ static func percentToAngle(percent: Double, startAngle: Double) -> Double {
+ (percent / 100 * 360) + startAngle
+ }
+ private var percent: Double
+ private var startAngle: Double
+ private let drawnClockwise: Bool
+
+ // This allows animations to run smoothly for percent values
+ var animatableData: Double {
+ get {
+ return percent
+ }
+ set {
+ percent = newValue
+ }
+ }
+
+ init(percent: Double = 100, startAngle: Double = -90, drawnClockwise: Bool = false) {
+ self.percent = percent
+ self.startAngle = startAngle
+ self.drawnClockwise = drawnClockwise
+ }
+
+ /// This draws a simple arc from the start angle to the end angle
+ ///
+ /// - Parameter rect: The frame of reference for describing this shape.
+ /// - Returns: A path that describes this shape.
+ func path(in rect: CGRect) -> Path {
+ let width = rect.width
+ let height = rect.height
+ let radius = min(width, height) / 2
+ let center = CGPoint(x: width / 2, y: height / 2)
+ let endAngle = Angle(degrees: RingShape.percentToAngle(percent: self.percent, startAngle: self.startAngle))
+ return Path { path in
+ path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startAngle), endAngle: endAngle, clockwise: drawnClockwise)
+ }
+ }
+}
+
+struct Ring: View {
+
+ private static let ShadowColor: Color = Color.black.opacity(0.2)
+ private static let ShadowRadius: CGFloat = 5
+ private static let ShadowOffsetMultiplier: CGFloat = ShadowRadius + 2
+
+ private let ringWidth: CGFloat
+ private let percent: Double
+ private let foregroundColor: ColorGradient
+ private let startAngle: Double = -90
+
+ private let touchLocation: CGFloat
+
+
+
+ private var gradientStartAngle: Double {
+ self.percent >= 100 ? relativePercentageAngle - 360 : startAngle
+ }
+ private var absolutePercentageAngle: Double {
+ RingShape.percentToAngle(percent: self.percent, startAngle: 0)
+ }
+ private var relativePercentageAngle: Double {
+ // Take into account the startAngle
+ absolutePercentageAngle + startAngle
+ }
+ private var lastGradientColor: Color {
+ self.foregroundColor.endColor
+ }
+
+ private var ringGradient: AngularGradient {
+ AngularGradient(
+ gradient: self.foregroundColor.gradient,
+ center: .center,
+ startAngle: Angle(degrees: self.gradientStartAngle),
+ endAngle: Angle(degrees: relativePercentageAngle)
+ )
+ }
+
+ init(ringWidth: CGFloat, percent: Double, foregroundColor: ColorGradient, touchLocation:CGFloat) {
+ self.ringWidth = ringWidth
+ self.percent = percent
+ self.foregroundColor = foregroundColor
+ self.touchLocation = touchLocation
+ }
+
+ var body: some View {
+ GeometryReader { geometry in
+ let safeFrame = geometry.size.sanitized
+ ZStack {
+ // Background for the ring. Use the final color with reduced opacity
+ RingShape()
+ .stroke(style: StrokeStyle(lineWidth: self.ringWidth))
+ .fill(lastGradientColor.opacity(0.142857))
+ // Foreground
+ RingShape(percent: self.percent, startAngle: self.startAngle)
+ .stroke(style: StrokeStyle(lineWidth: self.ringWidth, lineCap: .round))
+ .fill(self.ringGradient)
+ // End of ring with drop shadow
+ if self.getShowShadow(frame: safeFrame) {
+ Circle()
+ .fill(self.lastGradientColor)
+ .frame(width: self.ringWidth, height: self.ringWidth, alignment: .center)
+ .offset(x: self.getEndCircleLocation(frame: safeFrame).0,
+ y: self.getEndCircleLocation(frame: safeFrame).1)
+ .shadow(color: Ring.ShadowColor,
+ radius: Ring.ShadowRadius,
+ x: self.getEndCircleShadowOffset().0,
+ y: self.getEndCircleShadowOffset().1)
+ }
+ }
+ .frame(width: safeFrame.width, height: safeFrame.height, alignment: .topLeading)
+ }
+ // Padding to ensure that the entire ring fits within the view size allocated
+ .padding(self.ringWidth / 2)
+ }
+
+ private func getEndCircleLocation(frame: CGSize) -> (CGFloat, CGFloat) {
+ // Get angle of the end circle with respect to the start angle
+ let angleOfEndInRadians: Double = relativePercentageAngle.toRadians()
+ let offsetRadius = min(frame.width, frame.height) / 2
+ return (offsetRadius * cos(angleOfEndInRadians).toCGFloat(), offsetRadius * sin(angleOfEndInRadians).toCGFloat())
+ }
+
+ private func getEndCircleShadowOffset() -> (CGFloat, CGFloat) {
+ let angleForOffset = absolutePercentageAngle + (self.startAngle + 90)
+ let angleForOffsetInRadians = angleForOffset.toRadians()
+ let relativeXOffset = cos(angleForOffsetInRadians)
+ let relativeYOffset = sin(angleForOffsetInRadians)
+ let xOffset = relativeXOffset.toCGFloat() * Ring.ShadowOffsetMultiplier
+ let yOffset = relativeYOffset.toCGFloat() * Ring.ShadowOffsetMultiplier
+ return (xOffset, yOffset)
+ }
+
+ private func getShowShadow(frame: CGSize) -> Bool {
+ if self.percent >= 100 {
+ return true
+ }
+ let circleRadius = min(frame.width, frame.height) / 2
+ let remainingAngleInRadians = (360 - absolutePercentageAngle).toRadians().toCGFloat()
+
+ return circleRadius * remainingAngleInRadians <= self.ringWidth
+ }
+}
+
+struct Ring_Previews: PreviewProvider {
+ static var previews: some View {
+ VStack {
+ Ring(
+ ringWidth: 50, percent: 5 ,
+ foregroundColor: ColorGradient(.green, .blue), touchLocation: -1.0
+ )
+ .frame(width: 200, height: 200)
+
+ Ring(
+ ringWidth: 20, percent: 110 ,
+ foregroundColor: ColorGradient(.red, .blue), touchLocation: -1.0
+ )
+ .frame(width: 200, height: 200)
+
+
+
+ }
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift b/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift
new file mode 100644
index 00000000..032a712e
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+public struct RingsChart: View {
+ @Environment(\.chartDataPoints) private var points
+ @Environment(\.chartYRange) private var rangeY
+ @Environment(\.chartXRange) private var rangeX
+ @Environment(\.chartStyle) private var style
+ @Environment(\.chartSeriesConfig) private var seriesConfig
+
+ public init() {}
+
+ public var body: some View {
+ if isSeriesVisible {
+ RingsChartRow(width: 10.0,
+ spacing: 5.0,
+ chartData: ChartData(points, rangeY: rangeY, rangeX: rangeX),
+ style: style)
+ .aspectRatio(1, contentMode: .fit)
+ }
+ }
+
+ private var isSeriesVisible: Bool {
+ guard let seriesID = seriesConfig.seriesID else { return true }
+ return !seriesConfig.hiddenSeriesIDs.contains(seriesID)
+ }
+}
diff --git a/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift b/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift
new file mode 100644
index 00000000..8ea9bd81
--- /dev/null
+++ b/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift
@@ -0,0 +1,123 @@
+import SwiftUI
+
+public struct RingsChartRow: View {
+ var width: CGFloat
+ var spacing: CGFloat
+
+ @Environment(\.chartInteractionValue) private var chartValue
+ @Environment(\.chartSelectionHandler) private var selectionHandler
+ var chartData: ChartData
+ @State var touchRadius: CGFloat = -1.0
+
+ var style: ChartStyle
+
+ public var body: some View {
+ GeometryReader { geometry in
+ let safeSize = geometry.size.sanitized
+ ZStack {
+ Circle()
+ .fill(RadialGradient(gradient: style.backgroundColor.gradient,
+ center: .center,
+ startRadius: min(safeSize.width, safeSize.height) / 2.0,
+ endRadius: 1.0))
+
+ ForEach(0.. Bool {
+ let radius = min(size.width, size.height) / 2.0
+ return index == touchedCircleIndex(maxRadius: radius)
+ }
+
+ func touchedCircleIndex(maxRadius: CGFloat) -> Int? {
+ guard !chartData.data.isEmpty else { return nil }
+
+ let radialDistanceFromEdge = (maxRadius + spacing / 2) - touchRadius
+ guard radialDistanceFromEdge >= 0 else { return nil }
+
+ let touchIndex = Int(floor(radialDistanceFromEdge / (width + spacing)))
+
+ if touchIndex >= chartData.data.count { return nil }
+
+ return touchIndex
+ }
+
+ func getCurrentSelection(maxRadius: CGFloat) -> (index: Int, value: Double)? {
+ guard let index = touchedCircleIndex(maxRadius: maxRadius) else { return nil }
+ return (index, chartData.points[index])
+ }
+
+ private func formatted(_ value: Double) -> String {
+ if abs(value.rounded() - value) < 0.001 {
+ return String(Int(value.rounded()))
+ }
+ return String(format: "%.1f", value)
+ }
+}
+
+struct RingsChartRow_Previews: PreviewProvider {
+ static var previews: some View {
+ let multiStyle = ChartStyle(backgroundColor: ColorGradient(Color.black.opacity(0.05), Color.white),
+ foregroundColor: [ColorGradient(.purple, .blue),
+ ColorGradient(.orange, .red),
+ ColorGradient(.green, .yellow)])
+
+ return RingsChartRow(width: 20.0,
+ spacing: 10.0,
+ chartData: ChartData([25, 50, 75, 100, 125]),
+ style: multiStyle)
+ .frame(width: 300, height: 400)
+ }
+}
diff --git a/Sources/SwiftUICharts/Helpers.swift b/Sources/SwiftUICharts/Helpers.swift
deleted file mode 100644
index 9f7e9293..00000000
--- a/Sources/SwiftUICharts/Helpers.swift
+++ /dev/null
@@ -1,176 +0,0 @@
-//
-// File.swift
-//
-//
-// Created by András Samu on 2019. 07. 19..
-//
-
-import Foundation
-import SwiftUI
-
-public struct Colors {
- public static let color1:Color = Color(hexString: "#E2FAE7")
- public static let color1Accent:Color = Color(hexString: "#72BF82")
- public static let color2:Color = Color(hexString: "#EEF1FF")
- public static let color2Accent:Color = Color(hexString: "#4266E8")
- public static let color3:Color = Color(hexString: "#FCECEA")
- public static let color3Accent:Color = Color(hexString: "#E1614C")
- public static let OrangeStart:Color = Color(hexString: "#FF782C")
- public static let OrangeEnd:Color = Color(hexString: "#EC2301")
- public static let LegendText:Color = Color(hexString: "#A7A6A8")
- public static let LegendColor:Color = Color(hexString: "#E8E7EA")
- public static let IndicatorKnob:Color = Color(hexString: "#FF57A6")
- public static let GradientUpperBlue:Color = Color(hexString: "#C2E8FF")
- public static let GradinetUpperBlue1:Color = Color(hexString: "#A8E1FF")
- public static let GradientPurple:Color = Color(hexString: "#7B75FF")
- public static let GradientNeonBlue:Color = Color(hexString: "#6FEAFF")
- public static let GradientLowerBlue:Color = Color(hexString: "#F1F9FF")
- public static let DarkPurple:Color = Color(hexString: "#1B205E")
- public static let BorderBlue:Color = Color(hexString: "#4EBCFF")
-}
-
-public struct Styles {
- public static let lineChartStyleOne = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.black,
- legendTextColor: Color.gray)
-
- public static let barChartStyleOrangeLight = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.black,
- legendTextColor: Color.gray)
-
- public static let barChartStyleOrangeDark = ChartStyle(
- backgroundColor: Color.black,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.white,
- legendTextColor: Color.gray)
-
- public static let barChartStyleNeonBlueLight = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.GradientNeonBlue,
- secondGradientColor: Colors.GradientPurple,
- textColor: Color.black,
- legendTextColor: Color.gray)
-
- public static let barChartStyleNeonBlueDark = ChartStyle(
- backgroundColor: Color.black,
- accentColor: Colors.GradientNeonBlue,
- secondGradientColor: Colors.GradientPurple,
- textColor: Color.white,
- legendTextColor: Color.gray)
-
- public static let barChartMidnightGreenDark = ChartStyle(
- backgroundColor: Color(hexString: "#36534D"), //3B5147, 313D34
- accentColor: Color(hexString: "#FFD603"),
- secondGradientColor: Color(hexString: "#FFCA04"),
- textColor: Color.white,
- legendTextColor: Color(hexString: "#D2E5E1"))
-
- public static let barChartMidnightGreenLight = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Color(hexString: "#84A094"), //84A094 , 698378
- secondGradientColor: Color(hexString: "#50675D"),
- textColor: Color.black,
- legendTextColor:Color.gray)
-
- public static let pieChartStyleOne = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.black,
- legendTextColor: Color.gray)
-}
-
-public struct Form {
- #if os(watchOS)
- public static let small = CGSize(width:120, height:90)
- public static let medium = CGSize(width:120, height:160)
- public static let large = CGSize(width:180, height:90)
- public static let detail = CGSize(width:180, height:160)
- #else
- public static let small = CGSize(width:180, height:120)
- public static let medium = CGSize(width:180, height:240)
- public static let large = CGSize(width:360, height:120)
- public static let detail = CGSize(width:180, height:120)
- #endif
-
-
-}
-
-public struct ChartStyle {
- public var backgroundColor: Color
- public var accentColor: Color
- public var secondGradientColor: Color
- public var textColor: Color
- public var legendTextColor: Color
-
- public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color){
- self.backgroundColor = backgroundColor
- self.accentColor = accentColor
- self.secondGradientColor = secondGradientColor
- self.textColor = textColor
- self.legendTextColor = legendTextColor
- }
-
- public init(formSize: CGSize){
- self.backgroundColor = Color.white
- self.accentColor = Colors.OrangeStart
- self.secondGradientColor = Colors.OrangeEnd
- self.legendTextColor = Color.gray
- self.textColor = Color.black
- }
-}
-
-class ChartData: ObservableObject {
- @Published var points: [Int] = [Int]()
- @Published var currentPoint: Int? = nil
-
- init(points:[Int]) {
- self.points = points
- }
-}
-
-class TestData{
- static public var data:ChartData = ChartData(points: [37,72,51,22,39,47,66,85,50])
-}
-
-extension Color {
- init(hexString: String) {
- let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
- var int = UInt64()
- Scanner(string: hex).scanHexInt64(&int)
- let r, g, b: UInt64
- switch hex.count {
- case 3: // RGB (12-bit)
- (r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
- case 6: // RGB (24-bit)
- (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
- case 8: // ARGB (32-bit)
- (r, g, b) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
- default:
- (r, g, b) = (0, 0, 0)
- }
- self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
- }
-}
-
-class HapticFeedback {
- #if os(watchOS)
- //watchOS implementation
- static func playSelection() -> Void {
- WKInterfaceDevice.current().play(.click)
- }
- #else
- //iOS implementation
- let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
- static func playSelection() -> Void {
- UISelectionFeedbackGenerator().selectionChanged()
- }
- #endif
-}
diff --git a/Sources/SwiftUICharts/LineChart/Legend.swift b/Sources/SwiftUICharts/LineChart/Legend.swift
deleted file mode 100644
index a706c0d5..00000000
--- a/Sources/SwiftUICharts/LineChart/Legend.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-//
-// Legend.swift
-// LineChart
-//
-// Created by András Samu on 2019. 09. 02..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-struct Legend: View {
- @ObservedObject var data: ChartData
- @Binding var frame: CGRect
- @Binding var hideHorizontalLines: Bool
-
- var stepWidth: CGFloat {
- return frame.size.width / CGFloat(data.points.count-1)
- }
- var stepHeight: CGFloat {
- return frame.size.height / CGFloat(data.points.max()! + data.points.min()!)
- }
-
- var body: some View {
- ZStack(alignment: .topLeading){
- ForEach((0...4), id: \.self) { height in
- HStack(alignment: .center){
- Text("\(self.getYLegend()![height])").offset(x: 0, y: (self.frame.height-CGFloat(self.getYLegend()![height])*self.stepHeight)-(self.frame.height/2))
- .foregroundColor(Colors.LegendText)
- .font(.caption)
- self.line(atHeight: CGFloat(self.getYLegend()![height]), width: self.frame.width)
- .stroke(Colors.LegendColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5,height == 0 ? 0 : 10]))
- .opacity((self.hideHorizontalLines && height != 0) ? 0 : 1)
- .rotationEffect(.degrees(180), anchor: .center)
- .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
- .animation(.easeOut(duration: 0.2))
- .clipped()
- }
-
- }
-
- }
- }
-
- func line(atHeight: CGFloat, width: CGFloat) -> Path {
- var hLine = Path()
- hLine.move(to: CGPoint(x:5, y: atHeight*stepHeight))
- hLine.addLine(to: CGPoint(x: width, y: atHeight*stepHeight))
- return hLine
- }
-
- func getYLegend() -> [Int]? {
- guard let max = data.points.max() else { return nil }
- guard let min = data.points.min() else { return nil }
- if(min > 0){
- let upperBound = ((max/10)+1) * 10
- let step = upperBound/4
- return [step * 0,step * 1, step * 2, step * 3, step * 4]
- }
-
- return nil
- }
-}
-
-struct Legend_Previews: PreviewProvider {
- static var previews: some View {
- GeometryReader{ geometry in
- Legend(data: TestData.data, frame: .constant(geometry.frame(in: .local)), hideHorizontalLines: .constant(false))
- }.frame(width: 320, height: 200)
- }
-}
diff --git a/Sources/SwiftUICharts/LineChart/Line.swift b/Sources/SwiftUICharts/LineChart/Line.swift
deleted file mode 100644
index 363c4085..00000000
--- a/Sources/SwiftUICharts/LineChart/Line.swift
+++ /dev/null
@@ -1,159 +0,0 @@
-//
-// Line.swift
-// LineChart
-//
-// Created by András Samu on 2019. 08. 30..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-struct Line: View {
- @ObservedObject var data: ChartData
- @Binding var frame: CGRect
- @Binding var touchLocation: CGPoint
- @Binding var showIndicator: Bool
- @State private var showFull: Bool = false
- @State var showBackground: Bool = true
-
- var stepWidth: CGFloat {
- return frame.size.width / CGFloat(data.points.count-1)
- }
- var stepHeight: CGFloat {
- return frame.size.height / CGFloat(data.points.max()! + data.points.min()!)
- }
- var path: Path {
- return Path.quadCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight))
- }
- var closedPath: Path {
- return Path.quadClosedCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight))
- }
-
- var body: some View {
- ZStack {
- if(self.showFull && self.showBackground){
- self.closedPath
- .fill(LinearGradient(gradient: Gradient(colors: [Colors.GradientUpperBlue, .white]), startPoint: .bottom, endPoint: .top))
- .rotationEffect(.degrees(180), anchor: .center)
- .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
- .transition(.opacity)
- .animation(.easeIn(duration: 1.6))
- }
- self.path
- .trim(from: 0, to: self.showFull ? 1:0)
- .stroke(LinearGradient(gradient: Gradient(colors: [Colors.GradientPurple, Colors.GradientNeonBlue]), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3))
- .rotationEffect(.degrees(180), anchor: .center)
- .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
- .animation(.easeOut(duration: 1.2))
- .onAppear(){
- self.showFull.toggle()
- }.drawingGroup()
- if(self.showIndicator) {
- IndicatorPoint()
- .position(self.getClosestPointOnPath(touchLocation: self.touchLocation))
- .rotationEffect(.degrees(180), anchor: .center)
- .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
- }
- }
- }
-
- func getClosestPointOnPath(touchLocation: CGPoint) -> CGPoint {
- let percentage:CGFloat = min(max(touchLocation.x,0)/self.frame.width,1)
- let closest = self.path.percentPoint(percentage)
- return closest
- }
-
-}
-
-extension CGPoint {
- static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
- return CGPoint(
- x: point1.x + (point2.x - point1.x) / 2,
- y: point1.y + (point2.y - point1.y) / 2
- )
- }
-
- func dist(to: CGPoint) -> CGFloat {
- return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2)))
- }
-
- static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint {
- return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2)
- }
-
- static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint {
- var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2)
- let diffY = abs(p2.y - controlPoint.y)
-
- if (p1.y < p2.y){
- controlPoint.y += diffY
- } else if (p1.y > p2.y) {
- controlPoint.y -= diffY
- }
- return controlPoint
- }
-}
-extension Path {
- static func quadCurvedPathWithPoints(points:[Int], step:CGPoint) -> Path {
- var path = Path()
- var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y)
- path.move(to: p1)
- if(points.count < 2){
- path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1])))
- return path
- }
- for pointIndex in 1.. Path {
- var path = Path()
- path.move(to: .zero)
- var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y)
- path.addLine(to: p1)
- if(points.count < 2){
- path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1])))
- return path
- }
- for pointIndex in 1.. CGPoint {
- // percent difference between points
- let diff: CGFloat = 0.001
- let comp: CGFloat = 1 - diff
-
- // handle limits
- let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
-
- let f = pct > comp ? comp : pct
- let t = pct > comp ? 1 : pct + diff
- let tp = self.trimmedPath(from: f, to: t)
-
- return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
- }
-
-}
-
-struct Line_Previews: PreviewProvider {
- static var previews: some View {
- GeometryReader{ geometry in
- Line(data: TestData.data, frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 300, y: 12)), showIndicator: .constant(true))
- }.frame(width: 320, height: 160)
- }
-}
diff --git a/Sources/SwiftUICharts/LineChart/LineChartView.swift b/Sources/SwiftUICharts/LineChart/LineChartView.swift
deleted file mode 100644
index 3cd0580c..00000000
--- a/Sources/SwiftUICharts/LineChart/LineChartView.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-//
-// LineCard.swift
-// LineChart
-//
-// Created by András Samu on 2019. 08. 31..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct LineChartView: View {
-// let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
- @ObservedObject var data:ChartData
- public var title: String
- public var legend: String?
- public var style: ChartStyle
- public var formSize:CGSize
- @State private var touchLocation:CGPoint = .zero
- @State private var showIndicatorDot: Bool = false
- @State private var currentValue: Int = 2 {
- didSet{
- if (oldValue != self.currentValue && showIndicatorDot) {
-// selectionFeedbackGenerator.selectionChanged()
- HapticFeedback.playSelection()
- }
-
- }
- }
- let frame = CGSize(width: 180, height: 120)
-
- public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.lineChartStyleOne, form: CGSize? = Form.medium){
- self.data = ChartData(points: data)
- self.title = title
- self.legend = legend
- self.style = style
- self.formSize = form!
- }
-
- public var body: some View {
- ZStack(alignment: .center){
- RoundedRectangle(cornerRadius: 20).fill(self.style.backgroundColor).frame(width: frame.width, height: 240, alignment: .center).shadow(radius: 8)
- VStack(alignment: .leading){
- if(!self.showIndicatorDot){
- VStack(alignment: .leading, spacing: 8){
- Text(self.title).font(.title).bold().foregroundColor(self.style.textColor)
- if (self.legend != nil){
- Text(self.legend!).font(.callout).foregroundColor(self.style.legendTextColor)
- }
- HStack {
- Image(systemName: "arrow.up")
- Text("14%")
- }
- }
- .transition(.opacity)
- .animation(.easeIn(duration: 0.1))
- .padding([.leading, .top])
- }else{
- HStack{
- Spacer()
- Text("\(self.currentValue)")
- .font(.system(size: 41, weight: .bold, design: .default))
- .offset(x: 0, y: 30)
- Spacer()
- }
- .transition(.scale)
- .animation(.spring())
-
- }
- Spacer()
- GeometryReader{ geometry in
- Line(data: self.data, frame: .constant(geometry.frame(in: .local)), touchLocation: self.$touchLocation, showIndicator: self.$showIndicatorDot)
- }
- .frame(width: frame.width, height: frame.height)
- .clipShape(RoundedRectangle(cornerRadius: 20))
- .offset(x: 0, y: 0)
- }.frame(width: self.formSize.width, height: self.formSize.height)
- }
- .gesture(DragGesture()
- .onChanged({ value in
- self.touchLocation = value.location
- self.showIndicatorDot = true
- self.getClosestDataPoint(toPoint: value.location, width:self.frame.width, height: self.frame.height)
- })
- .onEnded({ value in
- self.showIndicatorDot = false
- })
- )
- }
-
- func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint {
- let stepWidth: CGFloat = width / CGFloat(data.points.count-1)
- let stepHeight: CGFloat = height / CGFloat(data.points.max()! + data.points.min()!)
-
- let index:Int = Int(round((toPoint.x)/stepWidth))
- if (index >= 0 && index < data.points.count){
- self.currentValue = self.data.points[index]
- return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(self.data.points[index])*stepHeight)
- }
- return .zero
- }
-}
-
-struct WidgetView_Previews: PreviewProvider {
- static var previews: some View {
- Group {
- LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Basic")
- .environment(\.colorScheme, .light)
- }
- }
-}
diff --git a/Sources/SwiftUICharts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/PieChart/PieChartCell.swift
deleted file mode 100644
index 352ce6b7..00000000
--- a/Sources/SwiftUICharts/PieChart/PieChartCell.swift
+++ /dev/null
@@ -1,65 +0,0 @@
-//
-// PieChartCell.swift
-// ChartView
-//
-// Created by András Samu on 2019. 06. 12..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-struct PieSlice: Identifiable {
- var id = UUID()
- var startDeg: Double
- var endDeg: Double
- var value: Int
- var normalizedValue: Double
-}
-
-public struct PieChartCell : View {
- @State private var show:Bool = false
- var rect: CGRect
- var radius: CGFloat {
- return min(rect.width, rect.height)/2
- }
- var startDeg: Double
- var endDeg: Double
- var path: Path {
- var path = Path()
- path.addArc(center:rect.mid , radius:self.radius, startAngle: Angle(degrees: self.startDeg), endAngle: Angle(degrees: self.endDeg), clockwise: false)
- path.addLine(to: rect.mid)
- path.closeSubpath()
- return path
- }
- var index: Int
- var backgroundColor:Color
- var accentColor:Color
- public var body: some View {
- path
- .fill()
- .foregroundColor(self.accentColor)
- .overlay(path.stroke(self.backgroundColor, lineWidth: 2))
- .scaleEffect(self.show ? 1 : 0)
- .animation(Animation.spring().delay(Double(self.index) * 0.04))
- .onAppear(){
- self.show = true
- }
- }
-}
-
-extension CGRect {
- var mid: CGPoint {
- return CGPoint(x:self.midX, y: self.midY)
- }
-}
-
-#if DEBUG
-struct PieChartCell_Previews : PreviewProvider {
- static var previews: some View {
- GeometryReader { geometry in
- PieChartCell(rect: geometry.frame(in: .local),startDeg: 0.0,endDeg: 90.0, index: 0, backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0))
- }.frame(width:100, height:100)
-
- }
-}
-#endif
diff --git a/Sources/SwiftUICharts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/PieChart/PieChartRow.swift
deleted file mode 100644
index 354ebfff..00000000
--- a/Sources/SwiftUICharts/PieChart/PieChartRow.swift
+++ /dev/null
@@ -1,46 +0,0 @@
-//
-// PieChartRow.swift
-// ChartView
-//
-// Created by András Samu on 2019. 06. 12..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct PieChartRow : View {
- var data: [Int]
- var backgroundColor: Color
- var accentColor: Color
- var slices: [PieSlice] {
- var tempSlices:[PieSlice] = []
- var lastEndDeg:Double = 0
- let maxValue = data.reduce(0, +)
- for slice in data {
- let normalized:Double = Double(slice)/Double(maxValue)
- let startDeg = lastEndDeg
- let endDeg = lastEndDeg + (normalized * 360)
- lastEndDeg = endDeg
- tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice, normalizedValue: normalized))
- }
- return tempSlices
- }
- public var body: some View {
- GeometryReader { geometry in
- ZStack{
- ForEach(0..
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyAccessedAPITypes
+
+
+
diff --git a/Tests/SwiftUIChartsTests/ArrayExtensionTests.swift b/Tests/SwiftUIChartsTests/ArrayExtensionTests.swift
new file mode 100644
index 00000000..f76c00e2
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/ArrayExtensionTests.swift
@@ -0,0 +1,37 @@
+@testable import SwiftUICharts
+import XCTest
+
+class ArrayExtensionTests: XCTestCase {
+
+ func testArrayRotatingIndexEmpty() {
+ let colors = [ColorGradient]()
+ XCTAssertEqual(colors.rotate(for: 0), ColorGradient.orangeBright)
+ }
+
+ func testArrayRotatingIndexOneValue() {
+ let colors = [ColorGradient.greenRed]
+
+ XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed)
+ XCTAssertEqual(colors.rotate(for: 1), ColorGradient.greenRed)
+ XCTAssertEqual(colors.rotate(for: 2), ColorGradient.greenRed)
+ }
+
+ func testArrayRotatingIndexLessValues() {
+ let colors = [ColorGradient.greenRed, ColorGradient.whiteBlack]
+
+ XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed)
+ XCTAssertEqual(colors.rotate(for: 1), ColorGradient.whiteBlack)
+ XCTAssertEqual(colors.rotate(for: 2), ColorGradient.greenRed)
+ XCTAssertEqual(colors.rotate(for: 3), ColorGradient.whiteBlack)
+ XCTAssertEqual(colors.rotate(for: 4), ColorGradient.greenRed)
+ }
+
+ func testArrayRotatingIndexMoreValues() {
+ let colors = [ColorGradient.greenRed, ColorGradient.whiteBlack, ColorGradient.orangeBright]
+
+ XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed)
+ XCTAssertEqual(colors.rotate(for: 1), ColorGradient.whiteBlack)
+
+ }
+
+}
diff --git a/Tests/SwiftUIChartsTests/CGPointExtensionTests.swift b/Tests/SwiftUIChartsTests/CGPointExtensionTests.swift
new file mode 100644
index 00000000..7a01359d
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/CGPointExtensionTests.swift
@@ -0,0 +1,38 @@
+//
+// CGPointExtensionTests.swift
+// SwiftUIChartsTests
+//
+// Created by Adrian Bolinger on 5/24/20.
+//
+
+@testable import SwiftUICharts
+import XCTest
+
+class CGPointExtensionTests: XCTestCase {
+ static let twentyElementArray: [Double] = Array(repeating: Double.random(in: 1...100), count: 20)
+
+ func testGetStepWithOneElementArray() {
+ let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
+ let oneElementArray: [Double] = [0.0]
+
+ XCTAssertEqual(CGPoint.getStep(frame: frame, data: oneElementArray), .zero)
+ }
+
+ func testGetStepWithMultiElementArrayWithNegativeValues() {
+ let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
+ let multiElementArray: [Double] = [-5.0, 0.0, 5.0]
+ XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), CGPoint(x: 150.0, y: 30.0))
+ }
+
+ func testGetStepWithMultiElementArrayWithPositiveValues() {
+ let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
+ let multiElementArray: [Double] = [5.0, 10.0, 15.0]
+ XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), CGPoint(x: 150.0, y: 15.0))
+ }
+
+ func testGetStepWithAllEqualValues() {
+ let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
+ let multiElementArray: [Double] = [5.0, 5.0, 5.0]
+ XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), .zero)
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/ChartSelectionDispatcherTests.swift b/Tests/SwiftUIChartsTests/ChartSelectionDispatcherTests.swift
new file mode 100644
index 00000000..6928b510
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/ChartSelectionDispatcherTests.swift
@@ -0,0 +1,45 @@
+@testable import SwiftUICharts
+import XCTest
+
+final class ChartSelectionDispatcherTests: XCTestCase {
+
+ func testPublishUpdatesChartValueAndEmitsCallback() {
+ let chartValue = ChartValue()
+ var received: ChartSelectionEvent?
+
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: { event in
+ received = event
+ },
+ value: 42,
+ index: 3,
+ isActive: true)
+
+ XCTAssertTrue(chartValue.interactionInProgress)
+ XCTAssertEqual(chartValue.currentValue, 42)
+ XCTAssertEqual(received?.value, 42)
+ XCTAssertEqual(received?.index, 3)
+ XCTAssertEqual(received?.isActive, true)
+ }
+
+ func testPublishInactiveClearsInteractionState() {
+ let chartValue = ChartValue()
+ chartValue.currentValue = 10
+ chartValue.interactionInProgress = true
+ var received: ChartSelectionEvent?
+
+ ChartSelectionDispatcher.publish(chartValue: chartValue,
+ handler: { event in
+ received = event
+ },
+ value: nil,
+ index: nil,
+ isActive: false)
+
+ XCTAssertFalse(chartValue.interactionInProgress)
+ XCTAssertEqual(chartValue.currentValue, 10)
+ XCTAssertNil(received?.value)
+ XCTAssertNil(received?.index)
+ XCTAssertEqual(received?.isActive, false)
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/ChartXAlignmentTests.swift b/Tests/SwiftUIChartsTests/ChartXAlignmentTests.swift
new file mode 100644
index 00000000..c67f09c5
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/ChartXAlignmentTests.swift
@@ -0,0 +1,87 @@
+@testable import SwiftUICharts
+import SwiftUI
+import XCTest
+
+final class ChartXScaleTests: XCTestCase {
+
+ func testCategoricalPositionsUseCenteredSlots() {
+ let scale = ChartXScale(values: [0, 1, 2, 3, 4, 5, 6],
+ rangeX: nil,
+ mode: .categorical,
+ slotCountHint: 7)
+
+ let expected: [Double] = [1, 3, 5, 7, 9, 11, 13].map { $0 / 14.0 }
+ let actual = (0...6).map { scale.normalizedX(for: Double($0)) }
+
+ for (a, e) in zip(actual, expected) {
+ XCTAssertEqual(a, e, accuracy: 0.0001)
+ }
+ }
+
+ func testNumericPositionsUseEdgeMappingWithinRange() {
+ let scale = ChartXScale(values: [10, 20, 30], rangeX: 10...30, mode: .numeric)
+
+ XCTAssertEqual(scale.normalizedX(for: 10), 0, accuracy: 0.0001)
+ XCTAssertEqual(scale.normalizedX(for: 20), 0.5, accuracy: 0.0001)
+ XCTAssertEqual(scale.normalizedX(for: 30), 1, accuracy: 0.0001)
+ }
+
+ func testSingleCategoricalValueCentersAtHalf() {
+ let scale = ChartXScale(values: [0], rangeX: nil, mode: .categorical, slotCountHint: 1)
+ XCTAssertEqual(scale.normalizedX(for: 0), 0.5, accuracy: 0.0001)
+ }
+
+ func testNormalizedXIsClamped() {
+ let scale = ChartXScale(values: [10, 20, 30], rangeX: 10...30, mode: .numeric)
+ XCTAssertEqual(scale.normalizedX(for: -100), 0, accuracy: 0.0001)
+ XCTAssertEqual(scale.normalizedX(for: 100), 1, accuracy: 0.0001)
+ }
+}
+
+final class ChartDataXNormalizationTests: XCTestCase {
+
+ func testArrayDataUsesCategoricalCenterMapping() {
+ let data = ChartData([10, 20, 30], xDomainMode: .categorical)
+ let expected = [1.0 / 6.0, 3.0 / 6.0, 5.0 / 6.0]
+
+ for (a, e) in zip(data.normalisedValues, expected) {
+ XCTAssertEqual(a, e, accuracy: 0.0001)
+ }
+ }
+
+ func testArrayDataWithRangeRemainsCenteredWithinVisibleSlots() {
+ let data = ChartData([11, 12, 13], rangeX: 1...2, xDomainMode: .categorical)
+ XCTAssertEqual(data.normalisedValues.count, 2)
+ XCTAssertEqual(data.normalisedValues[0], 0.25, accuracy: 0.0001)
+ XCTAssertEqual(data.normalisedValues[1], 0.75, accuracy: 0.0001)
+ }
+
+ func testTupleDataUsesNumericMapping() {
+ let data = ChartData([(10, 1), (20, 2), (30, 3)], rangeX: 10...30, xDomainMode: .numeric)
+ XCTAssertEqual(data.normalisedValues[0], 0, accuracy: 0.0001)
+ XCTAssertEqual(data.normalisedValues[1], 0.5, accuracy: 0.0001)
+ XCTAssertEqual(data.normalisedValues[2], 1, accuracy: 0.0001)
+ }
+}
+
+final class ChartAxisLabelMapperTests: XCTestCase {
+
+ func testStringLabelsMapToIndexedAnchors() {
+ let mapped = ChartAxisLabelMapper.mapXAxis(["A", "B", "C"])
+ XCTAssertEqual(mapped, [
+ ChartXAxisLabel(value: 0, title: "A"),
+ ChartXAxisLabel(value: 1, title: "B"),
+ ChartXAxisLabel(value: 2, title: "C")
+ ])
+ }
+
+ func testTupleLabelsPreserveValuesAndFillMissingRangeEntries() {
+ let mapped = ChartAxisLabelMapper.mapXAxis([(1, "A"), (2.5, "B2"), (3, "C")], in: 1...3)
+ XCTAssertEqual(mapped, [
+ ChartXAxisLabel(value: 1, title: "A"),
+ ChartXAxisLabel(value: 2, title: ""),
+ ChartXAxisLabel(value: 2.5, title: "B2"),
+ ChartXAxisLabel(value: 3, title: "C")
+ ])
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/ColorExtensionTests.swift b/Tests/SwiftUIChartsTests/ColorExtensionTests.swift
new file mode 100644
index 00000000..2a016ed2
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/ColorExtensionTests.swift
@@ -0,0 +1,56 @@
+//
+// ColorExtensionTests.swift
+// SwiftUIChartsTests
+//
+// Created by Adrian Bolinger on 5/24/20.
+//
+
+@testable import SwiftUICharts
+import SwiftUI
+import XCTest
+
+class ColorExtensionTests: XCTestCase {
+ func testTwentyFourBitRGBColors() {
+ let actualWhite = Color(hexString: "FFFFFF")
+ let expectedWhite = Color(red: 1, green: 1, blue: 1)
+ XCTAssertEqual(actualWhite, expectedWhite)
+
+ let actualBlack = Color(hexString: "000000")
+ let expectedBlack = Color(red: 0, green: 0, blue: 0)
+ XCTAssertEqual(actualBlack, expectedBlack)
+
+ let actualRed = Color(hexString: "FF0000")
+ let expectedRed = Color(red: 255/255, green: 0, blue: 0)
+ XCTAssertEqual(actualRed, expectedRed)
+
+ let actualGreen = Color(hexString: "00FF00")
+ let expectedGreen = Color(red: 0, green: 1, blue: 0)
+ XCTAssertEqual(actualGreen, expectedGreen)
+
+ let actualBlue = Color(hexString: "0000FF")
+ let expectedBlue = Color(red: 0, green: 0, blue: 1)
+ XCTAssertEqual(actualBlue, expectedBlue)
+ }
+
+ func testTwelveBitRGBColors() {
+ let actualWhite = Color(hexString: "FFF")
+ let expectedWhite = Color(red: 1, green: 1, blue: 1)
+ XCTAssertEqual(actualWhite, expectedWhite)
+
+ let actualBlack = Color(hexString: "000")
+ let expectedBlack = Color(red: 0, green: 0, blue: 0)
+ XCTAssertEqual(actualBlack, expectedBlack)
+
+ let actualRed = Color(hexString: "F00")
+ let expectedRed = Color(red: 255/255, green: 0, blue: 0)
+ XCTAssertEqual(actualRed, expectedRed)
+
+ let actualGreen = Color(hexString: "0F0")
+ let expectedGreen = Color(red: 0, green: 1, blue: 0)
+ XCTAssertEqual(actualGreen, expectedGreen)
+
+ let actualBlue = Color(hexString: "00F")
+ let expectedBlue = Color(red: 0, green: 0, blue: 1)
+ XCTAssertEqual(actualBlue, expectedBlue)
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/ComposableUsageSmokeTests.swift b/Tests/SwiftUIChartsTests/ComposableUsageSmokeTests.swift
new file mode 100644
index 00000000..482d69fa
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/ComposableUsageSmokeTests.swift
@@ -0,0 +1,219 @@
+@testable import SwiftUICharts
+import SwiftUI
+import XCTest
+
+#if canImport(AppKit)
+import AppKit
+#endif
+
+final class ComposableUsageSmokeTests: XCTestCase {
+
+ func testBarChartRendersWithOptionalInteractionValue() {
+ let view = BarChart()
+ .chartData([12, 34, 23])
+ .chartStyle(sampleStyle)
+ .frame(width: 240, height: 140)
+
+ assertCanRender(view)
+ }
+
+ func testPieChartRendersWithOptionalInteractionValue() {
+ let view = PieChart()
+ .chartData([34, 23, 12])
+ .chartStyle(sampleStyle)
+ .frame(width: 240, height: 240)
+
+ assertCanRender(view)
+ }
+
+ func testRingsChartRendersWithOptionalInteractionValue() {
+ let view = RingsChart()
+ .chartData([25, 50, 75])
+ .chartStyle(sampleStyle)
+ .frame(width: 240, height: 240)
+
+ assertCanRender(view)
+ }
+
+ func testPieAndRingsRenderInHeightOnlyHStackWithoutLayoutCrash() {
+ let view = HStack(spacing: 12) {
+ PieChart()
+ .chartData([34, 23, 12])
+ .chartStyle(sampleStyle)
+ .frame(height: 180)
+
+ RingsChart()
+ .chartData([25, 50, 75, 90])
+ .chartStyle(sampleStyle)
+ .frame(height: 180)
+ }
+
+ assertCanRender(view)
+ }
+
+ func testChartLabelCanShareExternalChartValue() {
+ let sharedChartValue = ChartValue()
+
+ let view = VStack(alignment: .leading) {
+ ChartLabel("Sales", type: .title)
+ BarChart()
+ .chartData([12, 34, 23])
+ .chartStyle(sampleStyle)
+ .frame(height: 140)
+ }
+ .frame(width: 280, height: 220)
+ .chartInteractionValue(sharedChartValue)
+
+ assertCanRender(view)
+ }
+
+ func testMixedBarAndLineWithAxisLabelsRenders() {
+ let view = AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([2, 4, 1, 3, 5])
+ .chartStyle(self.sampleStyle)
+ LineChart()
+ .chartLineMarks(true)
+ .chartData([2, 4, 1, 3, 5])
+ .chartStyle(self.sampleStyle)
+ }
+ .chartGridLines(horizontal: 5, vertical: 5)
+ }
+ .chartXAxisLabels([(0, "A"), (1, "B"), (2, "C"), (3, "D"), (4, "E")], range: 0...4)
+ .chartAxisColor(.gray)
+ .chartAxisFont(.caption)
+ .frame(width: 280, height: 220)
+
+ assertCanRender(view)
+ }
+
+ func testLegendAndSeriesVisibilityModifiersCompile() {
+ let hidden: Set = ["forecast"]
+ let view = VStack(alignment: .leading, spacing: 8) {
+ ChartLegend(items: [
+ ChartLegendItem(id: "sales", title: "Sales", color: ColorGradient(.orange, .red)),
+ ChartLegendItem(id: "forecast", title: "Forecast", color: ColorGradient(.blue, .purple))
+ ], hiddenSeries: .constant(hidden))
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartSeriesID("sales")
+ .chartData([2, 4, 3, 5, 4])
+ .chartStyle(self.sampleStyle)
+ LineChart()
+ .chartSeriesID("forecast")
+ .chartData([1, 2, 4, 4, 5])
+ .chartStyle(self.sampleStyle)
+ }
+ }
+ .chartHiddenSeries(hidden)
+ }
+ .frame(width: 280, height: 260)
+
+ assertCanRender(view)
+ }
+
+ func testStreamingDataSourceOverloadCompiles() {
+ let stream = ChartStreamingDataSource(initialValues: [12, 14, 18, 16],
+ windowSize: 4,
+ autoScroll: true)
+ stream.append(20)
+
+ let view = LineChart()
+ .chartData(stream)
+ .chartYRange(stream.suggestedYRange)
+ .chartStyle(sampleStyle)
+ .frame(width: 280, height: 180)
+
+ assertCanRender(view)
+ }
+
+ func testAutoTickAndRotationModifiersCompile() {
+ let timestamps: [(Double, Double)] = [
+ (1_704_067_200, 10), (1_704_153_600, 14), (1_704_240_000, 18), (1_704_326_400, 20)
+ ]
+
+ let view = AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData(timestamps)
+ .chartYRange(8...24)
+ .chartXRange(1_704_067_200...1_704_326_400)
+ .chartStyle(self.sampleStyle)
+ }
+ }
+ .chartXAxisAutoTicks(4, format: .shortDate)
+ .chartYAxisAutoTicks(4, format: .number)
+ .chartXAxisLabelRotation(.degrees(-22))
+ .frame(width: 300, height: 220)
+
+ assertCanRender(view)
+ }
+
+ func testHighContrastStylePresetCompiles() {
+ let view = AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([8, 14, 11, 17])
+ .chartStyle(.highContrast)
+ }
+ }
+ .chartXAxisLabels(["Q1", "Q2", "Q3", "Q4"])
+ .frame(width: 280, height: 220)
+
+ assertCanRender(view)
+ }
+
+ func testPerformanceModeModifierCompiles() {
+ let data = (0..<1200).map { index -> (Double, Double) in
+ let x = Double(index)
+ return (x, sin(x / 30.0))
+ }
+
+ let view = LineChart()
+ .chartData(data)
+ .chartPerformance(.automatic(threshold: 500, maxPoints: 120, simplifyLineStyle: true))
+ .chartStyle(sampleStyle)
+ .frame(width: 300, height: 180)
+
+ assertCanRender(view)
+ }
+
+ func testSelectionHandlerModifierCompiles() {
+ let view = AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([8, 11, 13, 9, 12])
+ .chartStyle(self.sampleStyle)
+ }
+ }
+ .chartXAxisLabels(["M", "T", "W", "T", "F"])
+ .chartSelectionHandler { event in
+ _ = event.value
+ _ = event.index
+ _ = event.isActive
+ }
+ .frame(width: 300, height: 220)
+
+ assertCanRender(view)
+ }
+
+ private var sampleStyle: ChartStyle {
+ ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red))
+ }
+
+ private func assertCanRender(_ view: Content,
+ file: StaticString = #filePath,
+ line: UInt = #line) {
+ #if canImport(AppKit)
+ let hostingView = NSHostingView(rootView: view)
+ hostingView.layoutSubtreeIfNeeded()
+ _ = hostingView.fittingSize
+ #else
+ _ = view
+ #endif
+
+ XCTAssertTrue(true, file: file, line: line)
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/DocumentationIntegrityTests.swift b/Tests/SwiftUIChartsTests/DocumentationIntegrityTests.swift
new file mode 100644
index 00000000..c5b1156d
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/DocumentationIntegrityTests.swift
@@ -0,0 +1,49 @@
+import Foundation
+import XCTest
+
+final class DocumentationIntegrityTests: XCTestCase {
+
+ func testExampleFileExists() {
+ let examplePath = repositoryRoot.appendingPathComponent("example.md").path
+ XCTAssertTrue(FileManager.default.fileExists(atPath: examplePath))
+ }
+
+ func testReadmeLinksToExampleFile() throws {
+ let readme = try String(contentsOf: repositoryRoot.appendingPathComponent("README.md"), encoding: .utf8)
+ XCTAssertTrue(readme.contains("./example.md"))
+ }
+
+ func testReferencedReadmeResourcesExist() throws {
+ let readme = try String(contentsOf: repositoryRoot.appendingPathComponent("README.md"), encoding: .utf8)
+ let referencedResources = extractResourcePaths(from: readme)
+
+ XCTAssertFalse(referencedResources.isEmpty)
+
+ for resourcePath in referencedResources {
+ let fullPath = repositoryRoot.appendingPathComponent(resourcePath).path
+ XCTAssertTrue(FileManager.default.fileExists(atPath: fullPath), "Missing resource: \(resourcePath)")
+ }
+ }
+
+ private var repositoryRoot: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ }
+
+ private func extractResourcePaths(from content: String) -> Set {
+ guard let regex = try? NSRegularExpression(pattern: #"Resources/[A-Za-z0-9_.-]+"#) else {
+ return []
+ }
+
+ let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content))
+
+ return Set(matches.compactMap { match in
+ guard let range = Range(match.range, in: content) else {
+ return nil
+ }
+ return String(content[range])
+ })
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/MigrationGuideTests.swift b/Tests/SwiftUIChartsTests/MigrationGuideTests.swift
new file mode 100644
index 00000000..575d6383
--- /dev/null
+++ b/Tests/SwiftUIChartsTests/MigrationGuideTests.swift
@@ -0,0 +1,41 @@
+import Foundation
+import XCTest
+
+final class MigrationGuideTests: XCTestCase {
+
+ func testMigrationGuideExists() {
+ let migrationPath = repositoryRoot.appendingPathComponent("MIGRATION.md").path
+ XCTAssertTrue(FileManager.default.fileExists(atPath: migrationPath))
+ }
+
+ func testMigrationGuideCoversRemovedPublicTypes() throws {
+ let migration = try String(contentsOf: repositoryRoot.appendingPathComponent("MIGRATION.md"), encoding: .utf8)
+
+ let requiredSymbols = [
+ "LineChartView",
+ "BarChartView",
+ "PieChartView",
+ "MultiLineChartView",
+ "LineView",
+ "GradientColor",
+ "GradientColors",
+ "Colors",
+ "Styles",
+ "ChartForm",
+ "MultiLineChartData",
+ "MagnifierRect",
+ "TestData"
+ ]
+
+ for symbol in requiredSymbols {
+ XCTAssertTrue(migration.contains(symbol), "Missing migration mapping for \(symbol)")
+ }
+ }
+
+ private var repositoryRoot: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ }
+}
diff --git a/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift b/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift
index 0171d836..dc7dc53a 100644
--- a/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift
+++ b/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift
@@ -9,6 +9,6 @@ final class SwiftUIChartsTests: XCTestCase {
}
static var allTests = [
- ("testExample", testExample),
+ ("testExample", testExample)
]
}
diff --git a/Tests/SwiftUIChartsTests/XCTestManifests.swift b/Tests/SwiftUIChartsTests/XCTestManifests.swift
index a3999a87..e80e5857 100644
--- a/Tests/SwiftUIChartsTests/XCTestManifests.swift
+++ b/Tests/SwiftUIChartsTests/XCTestManifests.swift
@@ -3,7 +3,7 @@ import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
- testCase(SwiftUIChartsTests.allTests),
+ testCase(SwiftUIChartsTests.allTests)
]
}
#endif
diff --git a/docs/wiki/01-getting-started.md b/docs/wiki/01-getting-started.md
new file mode 100644
index 00000000..1b82a067
--- /dev/null
+++ b/docs/wiki/01-getting-started.md
@@ -0,0 +1,57 @@
+# Getting Started
+
+## Install
+
+Add the package in Xcode using Swift Package Manager:
+
+`https://github.com/AppPear/ChartView`
+
+For version `2.0.0`, pin to tag `2.0.0` or use a `from: "2.0.0"` rule.
+
+## Minimal line chart
+
+```swift
+import SwiftUI
+import SwiftUICharts
+
+struct ContentView: View {
+ var body: some View {
+ LineChart()
+ .chartData([3, 5, 4, 8, 6, 9, 7])
+ .chartStyle(
+ ChartStyle(
+ backgroundColor: .white,
+ foregroundColor: ColorGradient(.blue, .purple)
+ )
+ )
+ .frame(height: 180)
+ .padding()
+ }
+}
+```
+
+## Compose grid + axis
+
+```swift
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData([12, 22, 18, 26, 24, 30, 28])
+ .chartYRange(10...32)
+ .chartStyle(
+ ChartStyle(
+ backgroundColor: .white,
+ foregroundColor: ColorGradient(.green, .blue)
+ )
+ )
+ }
+ .chartGridLines(horizontal: 5, vertical: 6)
+}
+.chartXAxisLabels(["M", "T", "W", "T", "F", "S", "S"])
+.chartAxisColor(.secondary)
+.chartAxisFont(.caption)
+```
+
+## Core rule
+
+In `2.x`, always use `chart...` modifiers. Legacy `1.x` mutating chains are removed.
diff --git a/docs/wiki/02-composable-modifiers.md b/docs/wiki/02-composable-modifiers.md
new file mode 100644
index 00000000..25742141
--- /dev/null
+++ b/docs/wiki/02-composable-modifiers.md
@@ -0,0 +1,63 @@
+# Composable Modifiers
+
+The `2.x` API is environment-driven and modifier-first.
+
+## Data and range
+
+```swift
+LineChart()
+ .chartData([Double]) // categorical
+ .chartData([(Double, Double)]) // numeric
+ .chartXRange(0...10) // optional
+ .chartYRange(0...100) // optional
+```
+
+## Grid
+
+```swift
+ChartGrid {
+ LineChart()
+}
+.chartGridLines(horizontal: 5, vertical: 6)
+.chartGridStroke(style: StrokeStyle(lineWidth: 1, dash: [6]), color: .gray)
+.chartGridBaseline(true, style: StrokeStyle(lineWidth: 1))
+```
+
+## Axis
+
+```swift
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ }
+}
+.chartXAxisLabels(["Q1", "Q2", "Q3", "Q4"])
+.chartYAxisLabels(["0", "50", "100"])
+.chartXAxisAutoTicks(6, format: .number)
+.chartYAxisAutoTicks(5, format: .number)
+.chartXAxisLabelRotation(.degrees(-20))
+.chartAxisFont(.caption)
+.chartAxisColor(.secondary)
+```
+
+## Line options
+
+```swift
+LineChart()
+ .chartLineWidth(3)
+ .chartLineBackground(ColorGradient(.blue.opacity(0.2), .clear))
+ .chartLineMarks(true, color: ColorGradient(.blue, .purple))
+ .chartLineStyle(.curved) // or .straight
+ .chartLineAnimation(true)
+```
+
+## Composition pattern
+
+Recommended build order:
+
+1. chart primitive (`LineChart`, `BarChart`, `PieChart`, `RingsChart`)
+2. data + ranges (`chartData`, `chartXRange`, `chartYRange`)
+3. style + chart-specific options
+4. wrap in `ChartGrid`
+5. wrap in `AxisLabels`
+6. apply axis/grid interaction modifiers at container level
diff --git a/docs/wiki/03-interaction-and-selection.md b/docs/wiki/03-interaction-and-selection.md
new file mode 100644
index 00000000..49a0d2da
--- /dev/null
+++ b/docs/wiki/03-interaction-and-selection.md
@@ -0,0 +1,59 @@
+# Interaction and Selection
+
+`2.x` supports two interaction styles:
+
+1. shared observable value (`ChartValue`)
+2. callback events (`chartSelectionHandler`)
+
+## Shared interaction value
+
+Use when multiple views should react to the same selection state.
+
+```swift
+let selected = ChartValue()
+
+VStack(alignment: .leading) {
+ ChartLabel("Weekly Sales", type: .title)
+ ChartLabel("Drag to inspect values", type: .legend, format: "%.1f")
+
+ BarChart()
+ .chartData([12, 18, 16, 24, 21, 19, 22])
+}
+.chartInteractionValue(selected)
+```
+
+## Callback-based interaction
+
+Use when you prefer event handling and do not want shared object state.
+
+```swift
+@State private var selectionText = "No selection"
+
+BarChart()
+ .chartData([8, 12, 10, 14, 9])
+ .chartSelectionHandler { event in
+ guard event.isActive,
+ let value = event.value,
+ let index = event.index else {
+ selectionText = "No selection"
+ return
+ }
+ selectionText = "Selected index \(index): \(value)"
+ }
+```
+
+## `ChartSelectionEvent`
+
+```swift
+public struct ChartSelectionEvent {
+ public let value: Double?
+ public let index: Int?
+ public let isActive: Bool
+}
+```
+
+## Notes
+
+- Basic rendering does not require `ChartValue`.
+- Interaction is available on line, bar, pie, and rings charts.
+- `ChartLabel` displays static text unless `chartInteractionValue` is present.
diff --git a/docs/wiki/04-dynamic-and-streaming-data.md b/docs/wiki/04-dynamic-and-streaming-data.md
new file mode 100644
index 00000000..91dea3cb
--- /dev/null
+++ b/docs/wiki/04-dynamic-and-streaming-data.md
@@ -0,0 +1,57 @@
+# Dynamic and Streaming Data
+
+Use `ChartStreamingDataSource` for live or mock dynamic feeds.
+
+## Basic setup
+
+```swift
+@ObservedObject private var stream = ChartStreamingDataSource(
+ initialValues: [18, 21, 20, 24, 23, 26],
+ windowSize: 6,
+ autoScroll: true
+)
+```
+
+## Bind to a chart
+
+```swift
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData(stream)
+ .chartYRange(stream.suggestedYRange)
+ .chartStyle(
+ ChartStyle(
+ backgroundColor: .white,
+ foregroundColor: ColorGradient(.green, .blue)
+ )
+ )
+ }
+ .chartGridLines(horizontal: 5, vertical: max(2, stream.values.count))
+}
+.chartXAxisLabels(stream.xLabels)
+```
+
+## Update the stream
+
+```swift
+stream.append(27)
+stream.append(contentsOf: [26, 29, 31])
+stream.reset([15, 17, 16], startingIndex: 1)
+```
+
+## Source behavior
+
+- `windowSize`: max visible points (with `autoScroll == true`)
+- `autoScroll`: trims oldest points after overflow
+- `xLabels`: generated from current visible window indices
+- `suggestedYRange`: dynamic padded range based on current values
+
+## Internet-backed flow
+
+Typical pattern:
+
+1. fetch values from API
+2. map response to `[Double]`
+3. call `stream.append(...)` or `stream.reset(...)`
+4. chart updates automatically through `ObservableObject`
diff --git a/docs/wiki/05-performance-and-large-datasets.md b/docs/wiki/05-performance-and-large-datasets.md
new file mode 100644
index 00000000..8a909947
--- /dev/null
+++ b/docs/wiki/05-performance-and-large-datasets.md
@@ -0,0 +1,45 @@
+# Performance and Large Datasets
+
+`LineChart` supports built-in downsampling through `chartPerformance(_:)`.
+
+## Modes
+
+```swift
+public enum ChartPerformanceMode {
+ case none
+ case automatic(threshold: Int, maxPoints: Int, simplifyLineStyle: Bool)
+ case downsample(maxPoints: Int, simplifyLineStyle: Bool)
+}
+```
+
+## Recommended setup
+
+```swift
+LineChart()
+ .chartData(largeSeries) // e.g. 2000+ points
+ .chartPerformance(.automatic(
+ threshold: 600,
+ maxPoints: 180,
+ simplifyLineStyle: true
+ ))
+```
+
+## What simplification changes
+
+When `simplifyLineStyle` is enabled:
+
+- line style switches to straight
+- chart marks are disabled
+- line animation is disabled
+
+This reduces render overhead for dense data.
+
+## For categorical data
+
+For `chartData([Double])`, downsampled values are remapped to categorical slot indices to keep axis alignment stable.
+
+## Validation checklist
+
+1. Compare visual shape with and without downsampling.
+2. Ensure tooltips/selection still target expected points.
+3. Tune `threshold` and `maxPoints` for your dataset size and UI refresh rate.
diff --git a/docs/wiki/06-unified-axis-and-label-alignment.md b/docs/wiki/06-unified-axis-and-label-alignment.md
new file mode 100644
index 00000000..c4e9e4e6
--- /dev/null
+++ b/docs/wiki/06-unified-axis-and-label-alignment.md
@@ -0,0 +1,52 @@
+# Unified Axis and Label Alignment
+
+`2.x` uses a shared X-positioning pipeline across:
+
+- chart rendering (`LineChart`, `BarChart`)
+- axis labels (`AxisLabels`)
+
+This removes drift where labels and marks used different spacing logic.
+
+## Domain behavior
+
+### Categorical data
+
+```swift
+chartData([Double])
+```
+
+- interpreted as category slots
+- points are centered in slots
+- labels map to slot-centered anchors
+
+### Numeric data
+
+```swift
+chartData([(Double, Double)])
+```
+
+- interpreted as continuous numeric domain
+- points map by numeric x-values and optional `chartXRange`
+
+## Label APIs
+
+```swift
+.chartXAxisLabels(["M", "T", "W", "T", "F", "S", "S"])
+.chartXAxisLabels([(0, "A"), (1, "B"), (2, "C")], range: 0...2)
+```
+
+## Auto ticks
+
+```swift
+.chartXAxisAutoTicks(6, format: .number)
+.chartYAxisAutoTicks(5, format: .number)
+.chartXAxisLabelRotation(.degrees(-20))
+```
+
+## Mixed bar + line guidance
+
+For aligned mixed charts:
+
+1. use the same data domain semantics for both layers
+2. keep explicit `chartXRange` consistent when using numeric tuples
+3. apply axis labels in the same container (`AxisLabels`) that wraps both layers
diff --git a/docs/wiki/07-migration-from-1x.md b/docs/wiki/07-migration-from-1x.md
new file mode 100644
index 00000000..2fa56e53
--- /dev/null
+++ b/docs/wiki/07-migration-from-1x.md
@@ -0,0 +1,42 @@
+# Migration from 1.x
+
+This page summarizes migration to `2.0.0`.
+
+## Breaking change summary
+
+- Legacy chart view types are replaced by composable chart primitives.
+- Legacy mutating method chains are replaced by modifier APIs.
+- Interaction no longer requires `@EnvironmentObject`.
+
+## Replace these first
+
+| 1.x | 2.x |
+| --- | --- |
+| `LineChartView` | `LineChart` |
+| `BarChartView` | `BarChart` |
+| `PieChartView` | `PieChart` |
+| `MultiLineChartView` | multiple `LineChart` layers |
+
+## Replace common method chains
+
+| 1.x | 2.x |
+| --- | --- |
+| `.data(...)` | `.chartData(...)` |
+| `.rangeX(...)` | `.chartXRange(...)` |
+| `.rangeY(...)` | `.chartYRange(...)` |
+| `.setAxisXLabels(...)` | `.chartXAxisLabels(...)` |
+| `.setNumberOfHorizontalLines(...)` | `.chartGridLines(...)` |
+| `.showChartMarks(...)` | `.chartLineMarks(...)` |
+
+## Interaction migration
+
+Pick one:
+
+1. shared state:
+ - `.chartInteractionValue(ChartValue())`
+2. callback style:
+ - `.chartSelectionHandler { event in ... }`
+
+## Full mapping
+
+See [MIGRATION.md](../../MIGRATION.md) for complete type + method mapping.
diff --git a/docs/wiki/README.md b/docs/wiki/README.md
new file mode 100644
index 00000000..9dfc36c0
--- /dev/null
+++ b/docs/wiki/README.md
@@ -0,0 +1,22 @@
+# SwiftUICharts Wiki
+
+This wiki documents the `2.x` composable API.
+
+## Pages
+
+1. [Getting Started](./01-getting-started.md)
+2. [Composable Modifiers](./02-composable-modifiers.md)
+3. [Interaction and Selection](./03-interaction-and-selection.md)
+4. [Dynamic and Streaming Data](./04-dynamic-and-streaming-data.md)
+5. [Performance for Large Datasets](./05-performance-and-large-datasets.md)
+6. [Unified Axis and Label Alignment](./06-unified-axis-and-label-alignment.md)
+7. [Migration from 1.x](./07-migration-from-1x.md)
+
+## Version scope
+
+This wiki targets:
+
+- `2.0.0` and newer in the `2.x` line
+- Composable modifier-based API only
+
+For old APIs, see [MIGRATION.md](../../MIGRATION.md).
diff --git a/example.md b/example.md
new file mode 100644
index 00000000..db8ef99f
--- /dev/null
+++ b/example.md
@@ -0,0 +1,170 @@
+# SwiftUICharts
+
+### Example codes (modifier-based composable API)
+
+## Notes for 2.0.0 usage
+
+- Use modifier APIs only (`chartData`, `chartXRange`, `chartYRange`, `chartGridLines`, `chartXAxisLabels`, etc.).
+- Use `chartInteractionValue(_:)` when you want shared interaction state.
+- Use `chartSelectionHandler(_:)` when you prefer callback-based interaction events.
+- Use `ChartStreamingDataSource` for dynamic feeds and `chartPerformance(_:)` for large datasets.
+
+
+
+
+
+```swift
+import SwiftUI
+import SwiftUICharts
+
+struct DemoView: View {
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Sneakers sold")
+ .font(.title)
+ Text("Last week")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ .padding(.bottom, 8.0)
+ HStack {
+ AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineMarks(true)
+ .chartYRange(10...40)
+ .chartData([12, 34, 23, 18, 36, 22, 26])
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: ColorGradient(.blue, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 0)
+ }
+ .chartXAxisLabels([(1, "M"), (2, "T"), (3, "W"), (4, "T"), (5, "F"), (6, "S"), (7, "S")],
+ range: 1...7)
+ .chartAxisColor(.gray)
+ .chartAxisFont(.caption)
+
+ VStack(alignment: .leading, spacing: 8.0) {
+ Text("Highest revenue:")
+ .font(.callout)
+ Text("Tuesday")
+ .font(.subheadline)
+ .bold()
+
+ Text("Most sales:")
+ .font(.callout)
+ Text("Friday")
+ .font(.subheadline)
+ .bold()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(16.0)
+ .background(RoundedRectangle(cornerRadius: 20)
+ .fill(.white)
+ .shadow(radius: 8.0))
+ .padding(32)
+ .frame(width: 450, height: 350)
+ }
+}
+```
+
+
+
+
+
+```swift
+import SwiftUI
+import SwiftUICharts
+
+struct DemoView: View {
+ let chartValue = ChartValue()
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Sneaker brands")
+ .font(.title)
+ Text("By popularity")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ .padding(.bottom, 8.0)
+ HStack {
+ AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([34, 23, 12])
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: [ColorGradient(.red, .orange),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.green, .yellow)]))
+ }
+ .chartGridLines(horizontal: 5, vertical: 0)
+ }
+ .chartYAxisLabels([(1, "0"), (2, "100"), (3, "200")], range: 1...3)
+ .chartAxisColor(.gray)
+ .chartAxisFont(.caption)
+
+ VStack(alignment: .leading, spacing: 8.0) {
+ Text("Current")
+ .font(.callout)
+ ChartLabel("Sales", type: .legend, format: "%.0f")
+ .chartInteractionValue(chartValue)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .chartInteractionValue(chartValue)
+ .padding(16.0)
+ .background(RoundedRectangle(cornerRadius: 20)
+ .fill(.white)
+ .shadow(radius: 8.0))
+ .padding(32)
+ .frame(width: 450, height: 350)
+ }
+}
+```
+
+
+
+
+
+```swift
+import SwiftUI
+import SwiftUICharts
+
+struct DemoView: View {
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Sneaker brands")
+ .font(.title)
+ Text("By popularity")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ .padding(.bottom, 8.0)
+ HStack {
+ PieChart()
+ .chartData([34, 23, 12])
+ .chartStyle(ChartStyle(backgroundColor: .white,
+ foregroundColor: [ColorGradient(.red, .orange),
+ ColorGradient(.blue, .purple),
+ ColorGradient(.yellow, .green)]))
+
+ VStack(alignment: .leading, spacing: 8.0) {
+ Text("Legend")
+ .font(.callout)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(16.0)
+ .background(RoundedRectangle(cornerRadius: 20)
+ .fill(.white)
+ .shadow(radius: 8.0))
+ .padding(32)
+ .frame(width: 450, height: 350)
+ }
+}
+```
diff --git a/midnightgreen.gif b/midnightgreen.gif
deleted file mode 100644
index 7fc8f746..00000000
Binary files a/midnightgreen.gif and /dev/null differ
diff --git a/showcase1.gif b/showcase1.gif
deleted file mode 100644
index 52bba15a..00000000
Binary files a/showcase1.gif and /dev/null differ
diff --git a/showcase2.gif b/showcase2.gif
deleted file mode 100644
index 81a85c64..00000000
Binary files a/showcase2.gif and /dev/null differ
diff --git a/showcase3.gif b/showcase3.gif
deleted file mode 100644
index 497fed2a..00000000
Binary files a/showcase3.gif and /dev/null differ
diff --git a/showcase4.png b/showcase4.png
deleted file mode 100644
index a58c861f..00000000
Binary files a/showcase4.png and /dev/null differ
diff --git a/showcase5.png b/showcase5.png
deleted file mode 100644
index 319cd116..00000000
Binary files a/showcase5.png and /dev/null differ
diff --git a/watchos1.png b/watchos1.png
deleted file mode 100644
index 65d42e5e..00000000
Binary files a/watchos1.png and /dev/null differ