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/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
index 540c36e2..1be8e537 100644
--- a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,7 +7,20 @@
SwiftUICharts.xcscheme_^#shared#^_
orderHint
- 1
+ 0
+
+
+ SuppressBuildableAutocreation
+
+ SwiftUICharts
+
+ primary
+
+
+ SwiftUIChartsTests
+
+ primary
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..d2252fdc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,28 @@
+# Changelog
+
+## 2.0.0-beta.9
+
+### Added
+
+- Full SwiftUI-idiomatic modifier API:
+ - `chartData`, `chartXRange`, `chartYRange`
+ - `chartGridLines`, `chartGridStroke`, `chartGridBaseline`
+ - `chartXAxisLabels`, `chartYAxisLabels`, `chartAxisFont`, `chartAxisColor`
+ - `chartLineWidth`, `chartLineBackground`, `chartLineMarks`, `chartLineStyle`, `chartLineAnimation`
+ - `chartInteractionValue`
+- Immutable chart configuration structs and environment-key-based composition.
+- Updated docs/examples and generated showcase app.
+
+### 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..580943a8
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,111 @@
+# SwiftUICharts Migration Guide
+
+## 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
+
+## 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 |
+
+### 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"])
+```
diff --git a/Package.swift b/Package.swift
index ffd10e06..3339018c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -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.
@@ -26,6 +26,6 @@ let package = Package(
dependencies: []),
.testTarget(
name: "SwiftUIChartsTests",
- dependencies: ["SwiftUICharts"]),
+ dependencies: ["SwiftUICharts"])
]
)
diff --git a/README.md b/README.md
index 88198213..98b76141 100644
--- a/README.md
+++ b/README.md
@@ -1,192 +1,124 @@
# SwiftUICharts
-Swift package for displaying charts effortlessly.
+SwiftUICharts is an open-source chart library for SwiftUI with iOS 13 compatibility.
-
+This release uses a fully composable, SwiftUI-idiomatic API based on immutable configuration and `ViewModifier` chains.
-It supports:
-* Line charts
-* Bar charts
-* Pie charts
+
+
+
-### Installation:
+## Charts
-It requires iOS 13 and Xcode 11!
+- `LineChart`
+- `BarChart`
+- `PieChart`
+- `RingsChart`
-In Xcode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView`
+## Installation
-### Usage:
+Use Swift Package Manager in Xcode and add:
-import the package in the file you would like to use it: `import SwiftUICharts`
+`https://github.com/AppPear/ChartView`
-You can display a Chart by adding a chart view to your parent view:
+## Migration
-### Demo
+This is a major composable API release.
-Added an example project, with **iOS, watchOS** target: https://github.com/AppPear/ChartViewDemo
+- Previous chain APIs like `.data`, `.rangeX`, `.rangeY`, `.setAxisXLabels`, `.setNumberOfHorizontalLines`, and line-specific setters were replaced by typed chart modifiers.
+- Full old-to-new mapping: [MIGRATION.md](./MIGRATION.md)
-## Line charts
+## Quick Start
-**LineChartView with multiple lines!**
-First release of this feature, interaction is disabled for now, I'll figure it out how could be the best to interact with multiple lines with a single touch.
-
+**Simple line chart**
-Usage:
-```swift
-MultiLineChartView(data: [([8,32,11,23,40,28], GradientColors.green), ([90,99,78,111,70,60,77], GradientColors.purple), ([34,56,72,38,43,100,50], GradientColors.orngPink)], title: "Title")
-```
-Gradient colors are now under the `GradientColor` struct you can create your own gradient by `GradientColor(start: Color, end: Color)`
-
-Available preset gradients:
-* orange
-* blue
-* green
-* blu
-* bluPurpl
-* purple
-* prplPink
-* prplNeon
-* orngPink
-
-**Full screen view called LineView!!!**
-
-
-
-```swift
- LineView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Full screen") // legend is optional, use optional .padding()
-```
-
-Adopts to dark mode automatically
-
-
-
-You can add your custom darkmode style by specifying:
-
-```swift
-let myCustomStyle = ChartStyle(...)
-let myCutsomDarkModeStyle = ChartStyle(...)
-myCustomStyle.darkModeStyle = myCutsomDarkModeStyle
-```
-
-**Line chart is interactive, so you can drag across to reveal the data points**
-
-You can add a line chart with the following code:
-
-```swift
- LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
-```
-
-**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
-
-
-## Bar charts
-
-
-**[New feature] you can display labels also along values and points for each bar to descirbe your data better!**
-**Bar chart is interactive, so you can drag across to reveal the data points**
-
-You can add a bar chart with the following code:
-
-Labels and points:
-
-```swift
- BarChartView(data: ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)]), title: "Sales", legend: "Quarterly") // legend is optional
-```
-Only points:
+
+
+
```swift
- BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", legend: "Legendary") // legend is optional
+LineChart()
+ .chartData([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
+ .chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
```
-**ChartData** structure
-Stores values in data pairs (actually tuple): `(String,Double)`
-* you can have duplicate values
-* keeps the data order
-
-You can initialise ChartData multiple ways:
-* For integer values: `ChartData(points: [8,23,54,32,12,37,7,23,43])`
-* For floating point values: `ChartData(points: [2.34,3.14,4.56])`
-* For label,value pairs: `ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900)])`
+**Add background grid**
-
-You can add different formats:
-* Small `ChartForm.small`
-* Medium `ChartForm.medium`
-* Large `ChartForm.large`
-
-```swift
-BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", form: ChartForm.small)
-```
-
-For floating point numbers, you can set a custom specifier:
+
+
+
```swift
-BarChartView(data: ChartData(points:[1.23,2.43,3.37]) ,title: "A", valueSpecifier: "%.2f")
+ChartGrid {
+ LineChart()
+ .chartData([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
+ .chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
+}
+.chartGridLines(horizontal: 5, vertical: 4)
```
-For integers you can disable by passing: `valueSpecifier: "%.0f"`
-
-You can set your custom image in the upper right corner by passing in the initialiser: `cornerImage:Image(systemName: "waveform.path.ecg")`
+**Axis labels**
+
+
+
- **Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
-
- ### You can customize styling of the chart with a ChartStyle object:
-
-Customizable:
-* background color
-* accent color
-* second gradient color
-* text color
-* legend text color
-
-```swift
- let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: ChartForm.medium, textColor: Color.white, legendTextColor: Color.white )
- ...
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle)
-```
-
-You can access built-in styles:
```swift
- BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: Styles.barChartMidnightGreen)
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartData([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
+ .chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 4)
+}
+.chartXAxisLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
```
-#### All styles available as a preset:
-* barChartStyleOrangeLight
-* barChartStyleOrangeDark
-* barChartStyleNeonBlueLight
-* barChartStyleNeonBlueDark
-* barChartMidnightGreenLight
-* barChartMidnightGreenDark
-
+**Line config + ranges**
-
-
-
-### You can customize the size of the chart with a ChartForm object:
-
-**ChartForm**
-* `.small`
-* `.medium`
-* `.large`
-* `.detail`
+
+
+
```swift
-BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: ChartForm.small)
+AxisLabels {
+ ChartGrid {
+ LineChart()
+ .chartLineMarks(true)
+ .chartData([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
+ .chartYRange(0...10)
+ .chartXRange(0...5)
+ .chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 4)
+}
+.chartXAxisLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
```
-### WatchOS support for Bar charts:
-
-
-
-## Pie charts
-
+**Mix chart types**
-You can add a pie chart with the following code:
+
+
+
```swift
- PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional
+AxisLabels {
+ ChartGrid {
+ BarChart()
+ .chartData([2, 4, 1, 3])
+ .chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
+
+ LineChart()
+ .chartLineMarks(true)
+ .chartData([2, 4, 1, 3])
+ .chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.blue, .purple)))
+ }
+ .chartGridLines(horizontal: 5, vertical: 4)
+}
+.chartXAxisLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
```
-**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
+## Full Examples
+See [example.md](./example.md) and [`Examples/SwiftUIChartsShowcase`](./Examples/SwiftUIChartsShowcase) for complete showcase code.
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 a3500b7f..00000000
--- a/Sources/SwiftUICharts/BarChart/BarChartCell.swift
+++ /dev/null
@@ -1,44 +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 gradient: GradientColor?
-
- @State var scaleValue: Double = 0
- @Binding var touchLocation: CGFloat
- public var body: some View {
- ZStack {
- RoundedRectangle(cornerRadius: 4)
- .fill(LinearGradient(gradient: gradient?.getGradient() ?? GradientColor(start: accentColor, end: accentColor).getGradient(), 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, gradient: nil, touchLocation: .constant(-1))
- }
-}
-#endif
diff --git a/Sources/SwiftUICharts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/BarChart/BarChartRow.swift
deleted file mode 100644
index 59b6a6d5..00000000
--- a/Sources/SwiftUICharts/BarChart/BarChartRow.swift
+++ /dev/null
@@ -1,50 +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: [Double]
- var accentColor: Color
- var gradient: GradientColor?
- var maxValue: Double {
- 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)
- .animation(.spring())
-
- }
- }
- .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 541d8b5f..00000000
--- a/Sources/SwiftUICharts/BarChart/BarChartView.swift
+++ /dev/null
@@ -1,148 +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 {
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- private var data: ChartData
- public var title: String
- public var legend: String?
- public var style: ChartStyle
- public var darkModeStyle: ChartStyle
- public var formSize:CGSize
- public var dropShadow: Bool
- public var cornerImage: Image
- public var valueSpecifier:String
-
- @State private var touchLocation: CGFloat = -1.0
- @State private var showValue: Bool = false
- @State private var showLabelValue: Bool = false
- @State private var currentValue: Double = 0 {
- didSet{
- if(oldValue != self.currentValue && self.showValue) {
- HapticFeedback.playSelection()
- }
- }
- }
- var isFullWidth:Bool {
- return self.formSize == ChartForm.large
- }
- public init(data:ChartData, title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = ChartForm.medium, dropShadow: Bool? = true, cornerImage:Image? = Image(systemName: "waveform.path.ecg"), valueSpecifier: String? = "%.1f"){
- self.data = data
- self.title = title
- self.legend = legend
- self.style = style
- self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.barChartStyleOrangeDark
- self.formSize = form!
- self.dropShadow = dropShadow!
- self.cornerImage = cornerImage!
- self.valueSpecifier = valueSpecifier!
- }
-
- public var body: some View {
- ZStack{
- Rectangle()
- .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor)
- .cornerRadius(20)
- .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 8 : 0)
- VStack(alignment: .leading){
- HStack{
- if(!showValue){
- Text(self.title)
- .font(.headline)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor)
- }else{
- Text("\(self.currentValue, specifier: self.valueSpecifier)")
- .font(.headline)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor)
- }
- if(self.formSize == ChartForm.large && self.legend != nil && !showValue) {
- Text(self.legend!)
- .font(.callout)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.accentColor : self.style.accentColor)
- .transition(.opacity)
- .animation(.easeOut)
- }
- Spacer()
- self.cornerImage
- .imageScale(.large)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor)
- }.padding()
- BarChartRow(data: data.points.map{$0.1},
- accentColor: self.colorScheme == .dark ? self.darkModeStyle.accentColor : self.style.accentColor,
- gradient: self.colorScheme == .dark ? self.darkModeStyle.gradientColor : self.style.gradientColor,
- touchLocation: self.$touchLocation)
- if self.legend != nil && self.formSize == ChartForm.medium && !self.showLabelValue{
- Text(self.legend!)
- .font(.headline)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor)
- .padding()
- }else if (self.data.valuesGiven && self.getCurrentValue() != nil) {
- LabelView(arrowOffset: self.getArrowOffset(touchLocation: self.touchLocation),
- title: .constant(self.getCurrentValue()!.0))
- .offset(x: self.getLabelViewOffset(touchLocation: self.touchLocation), y: -6)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor)
- }
-
- }
- }.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()?.1 ?? 0
- if(self.data.valuesGiven && self.formSize == ChartForm.medium) {
- self.showLabelValue = true
- }
- })
- .onEnded({ value in
- self.showValue = false
- self.showLabelValue = false
- self.touchLocation = -1
- })
- )
- .gesture(TapGesture()
- )
- }
-
- func getArrowOffset(touchLocation:CGFloat) -> Binding {
- let realLoc = (self.touchLocation * self.formSize.width) - 50
- if realLoc < 10 {
- return .constant(realLoc - 10)
- }else if realLoc > self.formSize.width-110 {
- return .constant((self.formSize.width-110 - realLoc) * -1)
- } else {
- return .constant(0)
- }
- }
-
- func getLabelViewOffset(touchLocation:CGFloat) -> CGFloat {
- return min(self.formSize.width-110,max(10,(self.touchLocation * self.formSize.width) - 50))
- }
-
- func getCurrentValue() -> (String,Double)? {
- guard self.data.points.count > 0 else { return nil}
- let index = max(0,min(self.data.points.count-1,Int(floor((self.touchLocation*self.formSize.width)/(self.formSize.width/CGFloat(self.data.points.count))))))
- return self.data.points[index]
- }
-}
-
-#if DEBUG
-struct ChartView_Previews : PreviewProvider {
- static var previews: some View {
- BarChartView(data: TestData.values ,
- title: "Model 3 sales",
- legend: "Quarterly",
- valueSpecifier: "%.0f")
- }
-}
-#endif
diff --git a/Sources/SwiftUICharts/BarChart/LabelView.swift b/Sources/SwiftUICharts/BarChart/LabelView.swift
deleted file mode 100644
index f17ae7be..00000000
--- a/Sources/SwiftUICharts/BarChart/LabelView.swift
+++ /dev/null
@@ -1,46 +0,0 @@
-//
-// LabelView.swift
-// BarChart
-//
-// Created by Samu András on 2020. 01. 08..
-// Copyright © 2020. Samu András. All rights reserved.
-//
-
-import SwiftUI
-
-struct LabelView: View {
- @Binding var arrowOffset: CGFloat
- @Binding var title:String
- var body: some View {
- VStack{
- ArrowUp().fill(Color.white).frame(width: 20, height: 12, alignment: .center).shadow(color: Color.gray, radius: 8, x: 0, y: 0).offset(x: getArrowOffset(offset:self.arrowOffset), y: 12)
- ZStack{
- RoundedRectangle(cornerRadius: 8).frame(width: 100, height: 32, alignment: .center).foregroundColor(Color.white).shadow(radius: 8)
- Text(self.title).font(.caption).bold()
- ArrowUp().fill(Color.white).frame(width: 20, height: 12, alignment: .center).zIndex(999).offset(x: getArrowOffset(offset:self.arrowOffset), y: -20)
-
- }
- }
- }
-
- func getArrowOffset(offset: CGFloat) -> CGFloat {
- return max(-36,min(36, offset))
- }
-}
-
-struct ArrowUp: Shape {
- func path(in rect: CGRect) -> Path {
- var path = Path()
- path.move(to: CGPoint(x: 0, y: rect.height))
- path.addLine(to: CGPoint(x: rect.width/2, y: 0))
- path.addLine(to: CGPoint(x: rect.width, y: rect.height))
- path.closeSubpath()
- return path
- }
-}
-
-struct LabelView_Previews: PreviewProvider {
- static var previews: some View {
- LabelView(arrowOffset: .constant(0), title: .constant("Tesla model 3"))
- }
-}
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/LineChart/Path+QuadCurve.swift b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift
similarity index 52%
rename from Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift
rename to Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift
index 83cf114b..5120626f 100644
--- a/Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift
+++ b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift
@@ -1,37 +1,34 @@
-//
-// File.swift
-//
-//
-// Created by xspyhack on 2020/1/21.
-//
-
import SwiftUI
extension Path {
+ private static func sanitizedRect(_ rect: CGRect) -> CGRect {
+ rect.sanitized
+ }
+
func trimmedPath(for percent: CGFloat) -> Path {
- // percent difference between points
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?
@@ -63,7 +60,7 @@ extension Path {
}
return ret
}
-
+
func length(to maxX: CGFloat) -> CGFloat {
var ret: CGFloat = 0.0
var start: CGPoint?
@@ -114,78 +111,169 @@ extension Path {
}
return ret
}
-
- static func quadCurvedPathWithPoints(points:[Double], step:CGPoint, globalOffset: Double? = nil) -> Path {
+
+ static func quadCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path {
var path = Path()
- if (points.count < 2){
+ if points.count < 2 {
return path
}
let offset = globalOffset ?? points.min()!
-// guard let offset = points.min() else { return path }
- var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
- path.move(to: p1)
+ // 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 {
+
+ static func quadCurvedPathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
var path = Path()
- if (points.count < 2){
+ if data.count < 2 {
return path
}
- let offset = globalOffset ?? points.min()!
+ let rect = sanitizedRect(rect)
-// guard let offset = points.min() else { return path }
- path.move(to: .zero)
- var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
- path.addLine(to: p1)
- 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 {
+
+ static func linePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
var path = Path()
- if (points.count < 2){
+ if data.count < 2 {
return path
}
- guard let offset = points.min() else { return path }
- let p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
- path.move(to: p1)
- for pointIndex in 1.. Path {
+
+ static func closedLinePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
var path = Path()
- if (points.count < 2){
+ if data.count < 2 {
return path
}
- guard let offset = points.min() else { return path }
- var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
- path.move(to: p1)
- for pointIndex in 1.. 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
@@ -220,7 +308,7 @@ extension CGPoint {
}
return dist
}
-
+
func quadCurve(to: CGPoint, control: CGPoint, x: CGFloat) -> CGFloat {
var dist: CGFloat = 0
let steps: CGFloat = 100
@@ -245,14 +333,14 @@ extension CGPoint {
}
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
@@ -269,7 +357,7 @@ extension CGPoint {
return dist
}
-
+
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint, x: CGFloat) -> CGFloat {
var dist: CGFloat = 0
let steps: CGFloat = 100
@@ -296,14 +384,14 @@ extension CGPoint {
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, c1: control1.x, c2: control2.x)
- let y = CGPoint.value(x: self.y, y: to.y, t: t, c1: control1.y, c2: control2.x)
+ 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
@@ -312,42 +400,43 @@ extension CGPoint {
value += pow(t, 2) * y
return value
}
-
- static func value(x: CGFloat, y: CGFloat, t: CGFloat, c1: CGFloat, c2: CGFloat) -> CGFloat {
+
+ 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 * c1
- value += 3 * (1-t) * pow(t, 2) * c2
+ 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(p1:CGPoint, p2:CGPoint) -> CGPoint {
- return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.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(p1:CGPoint, p2:CGPoint) -> CGPoint {
- var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2)
- let diffY = abs(p2.y - controlPoint.y)
+
+ static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
+ var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint)
+ let diffY = abs(secondPoint.y - controlPoint.y)
- if (p1.y < p2.y){
+ if firstPoint.y < secondPoint.y {
controlPoint.y += diffY
- } else if (p1.y > p2.y) {
+ } 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 a79bce54..00000000
--- a/Sources/SwiftUICharts/Helpers.swift
+++ /dev/null
@@ -1,278 +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 OrangeEnd:Color = Color(hexString: "#FF782C")
- public static let OrangeStart:Color = Color(hexString: "#EC2301")
- public static let LegendText:Color = Color(hexString: "#A7A6A8")
- public static let LegendColor:Color = Color(hexString: "#E8E7EA")
- public static let LegendDarkColor:Color = Color(hexString: "#545454")
- 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 GradientColor {
- public let start: Color
- public let end: Color
-
- public init(start: Color, end: Color) {
- self.start = start
- self.end = end
- }
-
- public func getGradient() -> Gradient {
- return Gradient(colors: [start, end])
- }
-}
-
-public struct GradientColors {
- public static let orange = GradientColor(start: Colors.OrangeStart, end: Colors.OrangeEnd)
- public static let blue = GradientColor(start: Colors.GradientPurple, end: Colors.GradientNeonBlue)
- public static let green = GradientColor(start: Color(hexString: "0BCDF7"), end: Color(hexString: "A2FEAE"))
- public static let blu = GradientColor(start: Color(hexString: "0591FF"), end: Color(hexString: "29D9FE"))
- public static let bluPurpl = GradientColor(start: Color(hexString: "4ABBFB"), end: Color(hexString: "8C00FF"))
- public static let purple = GradientColor(start: Color(hexString: "741DF4"), end: Color(hexString: "C501B0"))
- public static let prplPink = GradientColor(start: Color(hexString: "BC05AF"), end: Color(hexString: "FF1378"))
- public static let prplNeon = GradientColor(start: Color(hexString: "FE019A"), end: Color(hexString: "FE0BF4"))
- public static let orngPink = GradientColor(start: Color(hexString: "FF8E2D"), end: Color(hexString: "FF4E7A"))
-}
-
-public struct Styles {
- public static let lineChartStyleOne = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.black,
- legendTextColor: Color.gray,
- dropShadowColor: Color.gray)
-
- public static let barChartStyleOrangeLight = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.black,
- legendTextColor: Color.gray,
- dropShadowColor: Color.gray)
-
- public static let barChartStyleOrangeDark = ChartStyle(
- backgroundColor: Color.black,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.white,
- legendTextColor: Color.gray,
- dropShadowColor: Color.gray)
-
- public static let barChartStyleNeonBlueLight = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.GradientNeonBlue,
- secondGradientColor: Colors.GradientPurple,
- textColor: Color.black,
- legendTextColor: Color.gray,
- dropShadowColor: Color.gray)
-
- public static let barChartStyleNeonBlueDark = ChartStyle(
- backgroundColor: Color.black,
- accentColor: Colors.GradientNeonBlue,
- secondGradientColor: Colors.GradientPurple,
- textColor: Color.white,
- legendTextColor: Color.gray,
- dropShadowColor: 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"),
- dropShadowColor: Color.gray)
-
- public static let barChartMidnightGreenLight = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Color(hexString: "#84A094"), //84A094 , 698378
- secondGradientColor: Color(hexString: "#50675D"),
- textColor: Color.black,
- legendTextColor:Color.gray,
- dropShadowColor: Color.gray)
-
- public static let pieChartStyleOne = ChartStyle(
- backgroundColor: Color.white,
- accentColor: Colors.OrangeEnd,
- secondGradientColor: Colors.OrangeStart,
- textColor: Color.black,
- legendTextColor: Color.gray,
- dropShadowColor: Color.gray)
-
- public static let lineViewDarkMode = ChartStyle(
- backgroundColor: Color.black,
- accentColor: Colors.OrangeStart,
- secondGradientColor: Colors.OrangeEnd,
- textColor: Color.white,
- legendTextColor: Color.white,
- dropShadowColor: Color.gray)
-}
-
-public struct ChartForm {
- #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 class ChartStyle {
- public var backgroundColor: Color
- public var accentColor: Color
- public var gradientColor: GradientColor
- public var textColor: Color
- public var legendTextColor: Color
- public var dropShadowColor: Color
- public weak var darkModeStyle: ChartStyle?
-
- public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color, dropShadowColor: Color){
- self.backgroundColor = backgroundColor
- self.accentColor = accentColor
- self.gradientColor = GradientColor(start: accentColor, end: secondGradientColor)
- self.textColor = textColor
- self.legendTextColor = legendTextColor
- self.dropShadowColor = dropShadowColor
- }
-
- public init(backgroundColor: Color, accentColor: Color, gradientColor: GradientColor, textColor: Color, legendTextColor: Color, dropShadowColor: Color){
- self.backgroundColor = backgroundColor
- self.accentColor = accentColor
- self.gradientColor = gradientColor
- self.textColor = textColor
- self.legendTextColor = legendTextColor
- self.dropShadowColor = dropShadowColor
- }
-
- public init(formSize: CGSize){
- self.backgroundColor = Color.white
- self.accentColor = Colors.OrangeStart
- self.gradientColor = GradientColors.orange
- self.legendTextColor = Color.gray
- self.textColor = Color.black
- self.dropShadowColor = Color.gray
- }
-}
-
-public class ChartData: ObservableObject, Identifiable {
- @Published var points: [(String,Double)]
- var valuesGiven: Bool = false
- var ID = UUID()
-
- public init(points:[N]) {
- self.points = points.map{("", Double($0))}
- }
- public init(values:[(String,N)]){
- self.points = values.map{($0.0, Double($0.1))}
- self.valuesGiven = true
- }
- public init(values:[(String,N)]){
- self.points = values.map{($0.0, Double($0.1))}
- self.valuesGiven = true
- }
- public init(numberValues:[(N,N)]){
- self.points = numberValues.map{(String($0.0), Double($0.1))}
- self.valuesGiven = true
- }
- public init(numberValues:[(N,N)]){
- self.points = numberValues.map{(String($0.0), Double($0.1))}
- self.valuesGiven = true
- }
-
- public func onlyPoints() -> [Double] {
- return self.points.map{ $0.1 }
- }
-}
-
-public class MultiLineChartData: ChartData {
- var gradient: GradientColor
-
- public init(points:[N], gradient: GradientColor) {
- self.gradient = gradient
- super.init(points: points)
- }
-
- public init(points:[N], color: Color) {
- self.gradient = GradientColor(start: color, end: color)
- super.init(points: points)
- }
-
- public func getGradient() -> GradientColor {
- return self.gradient
- }
-}
-
-public class TestData{
- static public var data:ChartData = ChartData(points: [37,72,51,22,39,47,66,85,50])
- static public var values:ChartData = ChartData(values: [("2017 Q3",220),
- ("2017 Q4",1550),
- ("2018 Q1",8180),
- ("2018 Q2",18440),
- ("2018 Q3",55840),
- ("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)])
-
-}
-
-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 b613cb06..00000000
--- a/Sources/SwiftUICharts/LineChart/Legend.swift
+++ /dev/null
@@ -1,99 +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
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- let padding:CGFloat = 3
-
- var stepWidth: CGFloat {
- if data.points.count < 2 {
- return 0
- }
- return frame.size.width / CGFloat(data.points.count-1)
- }
- var stepHeight: CGFloat {
- let points = self.data.onlyPoints()
- if let min = points.min(), let max = points.max(), min != max {
- if (min < 0){
- return (frame.size.height-padding) / CGFloat(max - min)
- }else{
- return (frame.size.height-padding) / CGFloat(max + min)
- }
- }
- return 0
- }
-
- var min: CGFloat {
- let points = self.data.onlyPoints()
- return CGFloat(points.min() ?? 0)
- }
-
- var body: some View {
- ZStack(alignment: .topLeading){
- ForEach((0...4), id: \.self) { height in
- HStack(alignment: .center){
- Text("\(self.getYLegendSafe(height: height), specifier: "%.2f")").offset(x: 0, y: self.getYposition(height: height) )
- .foregroundColor(Colors.LegendText)
- .font(.caption)
- self.line(atHeight: self.getYLegendSafe(height: height), width: self.frame.width)
- .stroke(self.colorScheme == .dark ? Colors.LegendDarkColor : 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 getYLegendSafe(height:Int)->CGFloat{
- if let legend = getYLegend() {
- return CGFloat(legend[height])
- }
- return 0
- }
-
- func getYposition(height: Int)-> CGFloat {
- if let legend = getYLegend() {
- return (self.frame.height-((CGFloat(legend[height]) - min)*self.stepHeight))-(self.frame.height/2)
- }
- return 0
-
- }
-
- func line(atHeight: CGFloat, width: CGFloat) -> Path {
- var hLine = Path()
- hLine.move(to: CGPoint(x:5, y: (atHeight-min)*stepHeight))
- hLine.addLine(to: CGPoint(x: width, y: (atHeight-min)*stepHeight))
- return hLine
- }
-
- func getYLegend() -> [Double]? {
- let points = self.data.onlyPoints()
- guard let max = points.max() else { return nil }
- guard let min = points.min() else { return nil }
- let step = Double(max - min)/4
- return [min+step * 0, min+step * 1, min+step * 2, min+step * 3, min+step * 4]
- }
-}
-
-struct Legend_Previews: PreviewProvider {
- static var previews: some View {
- GeometryReader{ geometry in
- Legend(data: ChartData(points: [0.2,0.4,1.4,4.5]), 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 e85a8c3a..00000000
--- a/Sources/SwiftUICharts/LineChart/Line.swift
+++ /dev/null
@@ -1,107 +0,0 @@
-//
-// Line.swift
-// LineChart
-//
-// Created by András Samu on 2019. 08. 30..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct Line: View {
- @ObservedObject var data: ChartData
- @Binding var frame: CGRect
- @Binding var touchLocation: CGPoint
- @Binding var showIndicator: Bool
- @Binding var minDataValue: Double?
- @Binding var maxDataValue: Double?
- @State private var showFull: Bool = false
- @State var showBackground: Bool = true
- var gradient: GradientColor = GradientColor(start: Colors.GradientPurple, end: Colors.GradientNeonBlue)
- var index:Int = 0
- let padding:CGFloat = 30
- var curvedLines: Bool = true
- var stepWidth: CGFloat {
- if data.points.count < 2 {
- return 0
- }
- return frame.size.width / CGFloat(data.points.count-1)
- }
- var stepHeight: CGFloat {
- var min: Double?
- var max: Double?
- let points = self.data.onlyPoints()
- if minDataValue != nil && maxDataValue != nil {
- min = minDataValue!
- max = maxDataValue!
- print(min,max)
- }else if let minPoint = points.min(), let maxPoint = points.max(), minPoint != maxPoint {
- min = minPoint
- max = maxPoint
- }else {
- return 0
- }
- if let min = min, let max = max, min != max {
- if (min <= 0){
- return (frame.size.height-padding) / CGFloat(max - min)
- }else{
- return (frame.size.height-padding) / CGFloat(max + min)
- }
- }
- return 0
- }
- var path: Path {
- let points = self.data.onlyPoints()
- return curvedLines ? Path.quadCurvedPathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight), globalOffset: minDataValue) : Path.linePathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight))
- }
- var closedPath: Path {
- let points = self.data.onlyPoints()
- return curvedLines ? Path.quadClosedCurvedPathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight), globalOffset: minDataValue) : Path.closedLinePathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight))
- }
-
- public 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.getGradient(), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3, lineJoin: .round))
- .rotationEffect(.degrees(180), anchor: .center)
- .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
- .animation(Animation.easeOut(duration: 1.2).delay(Double(self.index)*0.4))
- .onAppear {
- self.showFull = true
- }
- .onDisappear {
- self.showFull = false
- }
- .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 closest = self.path.point(to: touchLocation.x)
- return closest
- }
-
-}
-
-struct Line_Previews: PreviewProvider {
- static var previews: some View {
- GeometryReader{ geometry in
- Line(data: ChartData(points: [12,-230,10,54]), frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 100, y: 12)), showIndicator: .constant(true), minDataValue: .constant(nil), maxDataValue: .constant(nil))
- }.frame(width: 320, height: 160)
- }
-}
diff --git a/Sources/SwiftUICharts/LineChart/LineChartView.swift b/Sources/SwiftUICharts/LineChart/LineChartView.swift
deleted file mode 100644
index 2726f083..00000000
--- a/Sources/SwiftUICharts/LineChart/LineChartView.swift
+++ /dev/null
@@ -1,148 +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 {
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- @ObservedObject var data:ChartData
- public var title: String
- public var legend: String?
- public var style: ChartStyle
- public var darkModeStyle: ChartStyle
-
- public var formSize:CGSize
- public var dropShadow: Bool
- public var valueSpecifier:String
-
- @State private var touchLocation:CGPoint = .zero
- @State private var showIndicatorDot: Bool = false
- @State private var currentValue: Double = 2 {
- didSet{
- if (oldValue != self.currentValue && showIndicatorDot) {
- HapticFeedback.playSelection()
- }
-
- }
- }
- let frame = CGSize(width: 180, height: 120)
- private var rateValue: Int?
-
- public init(data: [Double],
- title: String,
- legend: String? = nil,
- style: ChartStyle = Styles.lineChartStyleOne,
- form: CGSize? = ChartForm.medium,
- rateValue: Int? = 14,
- dropShadow: Bool? = true,
- valueSpecifier: String? = "%.1f") {
-
- self.data = ChartData(points: data)
- self.title = title
- self.legend = legend
- self.style = style
- self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode
- self.formSize = form!
- self.dropShadow = dropShadow!
- self.valueSpecifier = valueSpecifier!
- self.rateValue = rateValue
- }
-
- public var body: some View {
- ZStack(alignment: .center){
- RoundedRectangle(cornerRadius: 20)
- .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor)
- .frame(width: frame.width, height: 240, alignment: .center)
- .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 8 : 0)
- VStack(alignment: .leading){
- if(!self.showIndicatorDot){
- VStack(alignment: .leading, spacing: 8){
- Text(self.title)
- .font(.title)
- .bold()
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor)
- if (self.legend != nil){
- Text(self.legend!)
- .font(.callout)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor :self.style.legendTextColor)
- }
- HStack {
-
- if (self.rateValue ?? 0 != 0)
- {
- if (self.rateValue ?? 0 >= 0){
- Image(systemName: "arrow.up")
- }else{
- Image(systemName: "arrow.down")
- }
- Text("\(self.rateValue!)%")
- }
- }
- }
- .transition(.opacity)
- .animation(.easeIn(duration: 0.1))
- .padding([.leading, .top])
- }else{
- HStack{
- Spacer()
- Text("\(self.currentValue, specifier: self.valueSpecifier)")
- .font(.system(size: 41, weight: .bold, design: .default))
- .offset(x: 0, y: 30)
- Spacer()
- }
- .transition(.scale)
- }
- Spacer()
- GeometryReader{ geometry in
- Line(data: self.data,
- frame: .constant(geometry.frame(in: .local)),
- touchLocation: self.$touchLocation,
- showIndicator: self.$showIndicatorDot,
- minDataValue: .constant(nil),
- maxDataValue: .constant(nil)
- )
- }
- .frame(width: frame.width, height: frame.height + 30)
- .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
- })
- )
- }
-
- @discardableResult func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint {
- let points = self.data.onlyPoints()
- let stepWidth: CGFloat = width / CGFloat(points.count-1)
- let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!)
-
- let index:Int = Int(round((toPoint.x)/stepWidth))
- if (index >= 0 && index < points.count){
- self.currentValue = points[index]
- return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(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/LineChart/LineView.swift b/Sources/SwiftUICharts/LineChart/LineView.swift
deleted file mode 100644
index c4313aaf..00000000
--- a/Sources/SwiftUICharts/LineChart/LineView.swift
+++ /dev/null
@@ -1,127 +0,0 @@
-//
-// LineView.swift
-// LineChart
-//
-// Created by András Samu on 2019. 09. 02..
-// Copyright © 2019. András Samu. All rights reserved.
-//
-
-import SwiftUI
-
-public struct LineView: View {
- @ObservedObject var data: ChartData
- public var title: String?
- public var legend: String?
- public var style: ChartStyle
- public var darkModeStyle: ChartStyle
- public var valueSpecifier:String
-
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- @State private var showLegend = false
- @State private var dragLocation:CGPoint = .zero
- @State private var indicatorLocation:CGPoint = .zero
- @State private var closestPoint: CGPoint = .zero
- @State private var opacity:Double = 0
- @State private var currentDataNumber: Double = 0
- @State private var hideHorizontalLines: Bool = false
-
- public init(data: [Double],
- title: String? = nil,
- legend: String? = nil,
- style: ChartStyle = Styles.lineChartStyleOne,
- valueSpecifier: String? = "%.1f") {
-
- self.data = ChartData(points: data)
- self.title = title
- self.legend = legend
- self.style = style
- self.valueSpecifier = valueSpecifier!
- self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode
- }
-
- public var body: some View {
- GeometryReader{ geometry in
- VStack(alignment: .leading, spacing: 8) {
- Group{
- if (self.title != nil){
- Text(self.title!)
- .font(.title)
- .bold().foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor)
- }
- if (self.legend != nil){
- Text(self.legend!)
- .font(.callout)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor)
- }
- }.offset(x: 0, y: 20)
- ZStack{
- GeometryReader{ reader in
- Rectangle()
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor)
- if(self.showLegend){
- Legend(data: self.data,
- frame: .constant(reader.frame(in: .local)), hideHorizontalLines: self.$hideHorizontalLines)
- .transition(.opacity)
- .animation(Animation.easeOut(duration: 1).delay(1))
- }
- Line(data: self.data,
- frame: .constant(CGRect(x: 0, y: 0, width: reader.frame(in: .local).width - 30, height: reader.frame(in: .local).height)),
- touchLocation: self.$indicatorLocation,
- showIndicator: self.$hideHorizontalLines,
- minDataValue: .constant(nil),
- maxDataValue: .constant(nil),
- showBackground: false,
- gradient: self.style.gradientColor
- )
- .offset(x: 30, y: 0)
- .onAppear(){
- self.showLegend = true
- }
- .onDisappear(){
- self.showLegend = false
- }
- }
- .frame(width: geometry.frame(in: .local).size.width, height: 240)
- .offset(x: 0, y: 40 )
- MagnifierRect(currentNumber: self.$currentDataNumber, valueSpecifier: self.valueSpecifier)
- .opacity(self.opacity)
- .offset(x: self.dragLocation.x - geometry.frame(in: .local).size.width/2, y: 36)
- }
- .frame(width: geometry.frame(in: .local).size.width, height: 240)
- .gesture(DragGesture()
- .onChanged({ value in
- self.dragLocation = value.location
- self.indicatorLocation = CGPoint(x: max(value.location.x-30,0), y: 32)
- self.opacity = 1
- self.closestPoint = self.getClosestDataPoint(toPoint: value.location, width: geometry.frame(in: .local).size.width-30, height: 240)
- self.hideHorizontalLines = true
- })
- .onEnded({ value in
- self.opacity = 0
- self.hideHorizontalLines = false
- })
- )
- }
- }
- }
-
- func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint {
- let points = self.data.onlyPoints()
- let stepWidth: CGFloat = width / CGFloat(points.count-1)
- let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!)
-
- let index:Int = Int(floor((toPoint.x-15)/stepWidth))
- if (index >= 0 && index < points.count){
- self.currentDataNumber = points[index]
- return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight)
- }
- return .zero
- }
-}
-
-struct LineView_Previews: PreviewProvider {
- static var previews: some View {
- LineView(data: [8,23,54,32,12,37,7,23,43], title: "Full chart", style: Styles.lineChartStyleOne)
- }
-}
-
diff --git a/Sources/SwiftUICharts/LineChart/MagnifierRect.swift b/Sources/SwiftUICharts/LineChart/MagnifierRect.swift
deleted file mode 100644
index 4d3fd869..00000000
--- a/Sources/SwiftUICharts/LineChart/MagnifierRect.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-// MagnifierRect.swift
-//
-//
-// Created by Samu András on 2020. 03. 04..
-//
-
-import SwiftUI
-
-public struct MagnifierRect: View {
- @Binding var currentNumber: Double
- var valueSpecifier:String
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- public var body: some View {
- ZStack{
- Text("\(self.currentNumber, specifier: valueSpecifier)")
- .font(.system(size: 18, weight: .bold))
- .offset(x: 0, y:-110)
- .foregroundColor(self.colorScheme == .dark ? Color.white : Color.black)
- if (self.colorScheme == .dark ){
- RoundedRectangle(cornerRadius: 16)
- .stroke(Color.white, lineWidth: self.colorScheme == .dark ? 2 : 0)
- .frame(width: 60, height: 260)
- }else{
- RoundedRectangle(cornerRadius: 16)
- .frame(width: 60, height: 280)
- .foregroundColor(Color.white)
- .shadow(color: Colors.LegendText, radius: 12, x: 0, y: 6 )
- .blendMode(.multiply)
- }
- }
- }
-}
diff --git a/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift b/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift
deleted file mode 100644
index 720da66d..00000000
--- a/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift
+++ /dev/null
@@ -1,163 +0,0 @@
-//
-// File.swift
-//
-//
-// Created by Samu András on 2020. 02. 19..
-//
-
-import SwiftUI
-
-public struct MultiLineChartView: View {
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- var data:[MultiLineChartData]
- public var title: String
- public var legend: String?
- public var style: ChartStyle
- public var darkModeStyle: ChartStyle
- public var formSize:CGSize
- public var dropShadow: Bool
- public var valueSpecifier:String
-
- @State private var touchLocation:CGPoint = .zero
- @State private var showIndicatorDot: Bool = false
- @State private var currentValue: Double = 2 {
- didSet{
- if (oldValue != self.currentValue && showIndicatorDot) {
- HapticFeedback.playSelection()
- }
-
- }
- }
-
- var globalMin:Double {
- if let min = data.flatMap({$0.onlyPoints()}).min() {
- return min
- }
- return 0
- }
-
- var globalMax:Double {
- if let max = data.flatMap({$0.onlyPoints()}).max() {
- return max
- }
- return 0
- }
-
- let frame = CGSize(width: 180, height: 120)
- private var rateValue: Int
-
- public init(data: [([Double], GradientColor)],
- title: String,
- legend: String? = nil,
- style: ChartStyle = Styles.lineChartStyleOne,
- form: CGSize? = ChartForm.medium,
- rateValue: Int? = 14,
- dropShadow: Bool? = true,
- valueSpecifier: String? = "%.1f") {
-
- self.data = data.map({ MultiLineChartData(points: $0.0, gradient: $0.1)})
- self.title = title
- self.legend = legend
- self.style = style
- self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode
- self.formSize = form!
- self.rateValue = rateValue!
- self.dropShadow = dropShadow!
- self.valueSpecifier = valueSpecifier!
- }
-
- public var body: some View {
- ZStack(alignment: .center){
- RoundedRectangle(cornerRadius: 20)
- .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor)
- .frame(width: frame.width, height: 240, alignment: .center)
- .shadow(radius: self.dropShadow ? 8 : 0)
- VStack(alignment: .leading){
- if(!self.showIndicatorDot){
- VStack(alignment: .leading, spacing: 8){
- Text(self.title)
- .font(.title)
- .bold()
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor)
- if (self.legend != nil){
- Text(self.legend!)
- .font(.callout)
- .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor)
- }
- HStack {
- if (self.rateValue >= 0){
- Image(systemName: "arrow.up")
- }else{
- Image(systemName: "arrow.down")
- }
- Text("\(self.rateValue)%")
- }
- }
- .transition(.opacity)
- .animation(.easeIn(duration: 0.1))
- .padding([.leading, .top])
- }else{
- HStack{
- Spacer()
- Text("\(self.currentValue, specifier: self.valueSpecifier)")
- .font(.system(size: 41, weight: .bold, design: .default))
- .offset(x: 0, y: 30)
- Spacer()
- }
- .transition(.scale)
- }
- Spacer()
- GeometryReader{ geometry in
- ZStack{
- ForEach(0.. CGPoint {
-// let points = self.data.onlyPoints()
-// let stepWidth: CGFloat = width / CGFloat(points.count-1)
-// let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!)
-//
-// let index:Int = Int(round((toPoint.x)/stepWidth))
-// if (index >= 0 && index < points.count){
-// self.currentValue = points[index]
-// return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight)
-// }
-// return .zero
-// }
-}
-
-struct MultiWidgetView_Previews: PreviewProvider {
- static var previews: some View {
- Group {
- MultiLineChartView(data: [([8,23,54,32,12,37,7,23,43], GradientColors.orange)], 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 f511165e..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: Double
- 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 dd690d02..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: [Double]
- 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.. = ["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/example.md b/example.md
new file mode 100644
index 00000000..cd64b238
--- /dev/null
+++ b/example.md
@@ -0,0 +1,163 @@
+# SwiftUICharts
+
+### Example codes (modifier-based composable API)
+
+
+
+
+
+```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)
+ }
+}
+```