diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..7f54e512 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + + + + +## Description + + +## Expected Behavior + + +## Actual Behavior + + +## Possible Fix + + +## Steps to Reproduce + + + +## Your Environment + +* Version of this package used: +* Device/Simulator: +* Operating System and version: +* Link to your project: \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..d4b1ee3d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Ask for a new feature +title: '' +labels: '' +assignees: '' + +--- + + + +## Detailed Description + + +## Context + + + +## Possible Implementation + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/v2Ticket.md b/.github/ISSUE_TEMPLATE/v2Ticket.md new file mode 100644 index 00000000..ac9ec0cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v2Ticket.md @@ -0,0 +1,11 @@ +--- +name: v2 ticket +about: Create tasks for the upcoming new version +title: '' +labels: v2 +assignees: '' + +--- +# v2 ticket + +## Ticket description: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..5eabcf1b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ + + +## Description + + +## Motivation and Context + + + +## How Has This Been Tested? + + + + +## Screenshots (if appropriate): + +## Types of changes + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Non-functional change (Updating Documentation, CI automation, etc..) + +## Checklist: + + +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 00000000..1e6268cc --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,34 @@ +name: Swift + +on: + push: + branches: [ master, new-version ] + pull_request: + +jobs: + build-and-test: + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Build + run: swift build + + - name: Test + run: swift test + + - name: Build Showcase App + run: | + cd Examples/SwiftUIChartsShowcase + xcodebuild \ + -project SwiftUIChartsShowcase.xcodeproj \ + -scheme SwiftUIChartsShowcase \ + -sdk iphonesimulator \ + -destination "generic/platform=iOS Simulator" \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/.gitignore b/.gitignore index 02c08753..1a3fb064 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.build /Packages /*.xcodeproj +**/*.xcodeproj/project.xcworkspace/xcuserdata/ +**/*.xcworkspace/xcuserdata/ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..60e30109 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,64 @@ +disabled_rules: +- explicit_acl +- trailing_whitespace +- force_cast +- unused_closure_parameter +- multiple_closures_with_trailing_closure +opt_in_rules: +- anyobject_protocol +- array_init +- attributes +- collection_alignment +- colon +- conditional_returns_on_newline +- convenience_type +- empty_count +- empty_string +- empty_collection_literal +- enum_case_associated_values_count +- function_default_parameter_at_end +- fatal_error_message +- file_name +- first_where +- modifier_order +- toggle_bool +- unused_private_declaration +- yoda_condition +excluded: +- Carthage +- Pods +- SwiftLint/Common/3rdPartyLib +identifier_name: + excluded: + - a + - b + - c + - i + - id + - t + - to + - x + - y +line_length: + warning: 150 + error: 200 + ignores_function_declarations: true + ignores_comments: true + ignores_urls: true +function_body_length: + warning: 300 + error: 500 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 300 + error: 400 +file_length: + warning: 500 + error: 1200 + ignore_comment_only_lines: true +cyclomatic_complexity: + warning: 15 + error: 21 +reporter: "xcode" diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate index aae2473e..057e951c 100644 Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..aae6dfe7 Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..a5f64f0a Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..a0f26bbb --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + SwiftUICharts.xcscheme_^#shared#^_ + + orderHint + 2 + + + + diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 00000000..867e4fe8 --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..1be8e537 --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,27 @@ + + + + + SchemeUserState + + SwiftUICharts.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + SwiftUICharts + + primary + + + SwiftUIChartsTests + + primary + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ffd6c6c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## 2.0.0 + +### Added + +- Full SwiftUI-idiomatic modifier API: + - `chartData`, `chartXRange`, `chartYRange` + - `chartGridLines`, `chartGridStroke`, `chartGridBaseline` + - `chartXAxisLabels`, `chartYAxisLabels`, `chartAxisFont`, `chartAxisColor` + - `chartLineWidth`, `chartLineBackground`, `chartLineMarks`, `chartLineStyle`, `chartLineAnimation` + - `chartInteractionValue` + - `chartSelectionHandler` + - `chartPerformance` +- Immutable chart configuration structs and environment-key-based composition. +- Updated docs/examples and generated showcase app. +- Dynamic streaming data source support via `ChartStreamingDataSource`. +- Unified X-axis alignment strategy shared across chart layers. +- Apple privacy manifest for SDK distribution (`PrivacyInfo.xcprivacy`). + +### Changed + +- Major clean break from mutating chain APIs. +- Chart style/data/range/axis/grid/line configs are now modifier-driven and value-based. + +### Fixed + +- Removed required `@EnvironmentObject` interaction dependency for basic chart rendering paths. +- Existing regression and smoke tests remain green after API shift. + +### Migration + +- See `MIGRATION.md` for full old-to-new mapping. diff --git a/Examples/SwiftUIChartsShowcase/README.md b/Examples/SwiftUIChartsShowcase/README.md new file mode 100644 index 00000000..ef33ba81 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/README.md @@ -0,0 +1,18 @@ +# SwiftUICharts Showcase App + +This iOS app demonstrates the current composable API features of `SwiftUICharts`: + +- Line chart with marks, ranges, style, background fill, and animation +- Grid and axis labels +- Multiple overlaid line charts +- Mixed bar + line chart in one frame +- Interactive bar chart with shared `ChartValue` + `ChartLabel` +- Pie and rings charts +- Card-based composition with `CardView` + +## Open in Xcode + +1. `cd Examples/SwiftUIChartsShowcase` +2. `xcodegen generate` +3. Open `SwiftUIChartsShowcase.xcodeproj` +4. Run the `SwiftUIChartsShowcase` scheme on an iOS simulator diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.pbxproj b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.pbxproj new file mode 100644 index 00000000..06a1eba3 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.pbxproj @@ -0,0 +1,341 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0A17A65BC7246EF894FB4C46 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8FEFD8F7975122CADED38BF /* AppDelegate.swift */; }; + 1D4CE1A2A00E43B69F8E917B /* ShowcaseDynamicLabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D307985C5742659FBD2048 /* ShowcaseDynamicLabView.swift */; }; + 282BC43F66ABF75981A2CA5E /* ShowcaseHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472650295568456DC645EC4F /* ShowcaseHomeView.swift */; }; + 5C46F84CBBE811CF1BACDC53 /* SwiftUICharts in Frameworks */ = {isa = PBXBuildFile; productRef = C3965A0A2A9FEBF45BC880B8 /* SwiftUICharts */; }; + 78F8536EADC5FE60A9B6D6B6 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4C0A162EAAD03257FCC320 /* SceneDelegate.swift */; }; + 8BC2B530A7D14FACB535629E /* ShowcaseTabContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812FA90A31E14920A08CC991 /* ShowcaseTabContainerView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 07C74870F224E2F5213D5F81 /* ChartView */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ChartView; path = ../..; sourceTree = SOURCE_ROOT; }; + 09A022B6E881286CD3B96CB5 /* SwiftUIChartsShowcase.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = SwiftUIChartsShowcase.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 472650295568456DC645EC4F /* ShowcaseHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowcaseHomeView.swift; sourceTree = ""; }; + 4EC45D31E6FCC4FF27B4F3B7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 812FA90A31E14920A08CC991 /* ShowcaseTabContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowcaseTabContainerView.swift; sourceTree = ""; }; + 89D307985C5742659FBD2048 /* ShowcaseDynamicLabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowcaseDynamicLabView.swift; sourceTree = ""; }; + CA4C0A162EAAD03257FCC320 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + F8FEFD8F7975122CADED38BF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F9428672158235A7E14E3CBE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C46F84CBBE811CF1BACDC53 /* SwiftUICharts in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 58E5FB23E95DB74437A4AAC4 /* Packages */ = { + isa = PBXGroup; + children = ( + 07C74870F224E2F5213D5F81 /* ChartView */, + ); + name = Packages; + sourceTree = ""; + }; + A30E7F93B90AC7C66A7B151C = { + isa = PBXGroup; + children = ( + 58E5FB23E95DB74437A4AAC4 /* Packages */, + C75B23F4C6E0261F5CF362F1 /* SwiftUIChartsShowcaseApp */, + A54955542983F1195821FBA1 /* Products */, + ); + sourceTree = ""; + }; + A54955542983F1195821FBA1 /* Products */ = { + isa = PBXGroup; + children = ( + 09A022B6E881286CD3B96CB5 /* SwiftUIChartsShowcase.app */, + ); + name = Products; + sourceTree = ""; + }; + C75B23F4C6E0261F5CF362F1 /* SwiftUIChartsShowcaseApp */ = { + isa = PBXGroup; + children = ( + F8FEFD8F7975122CADED38BF /* AppDelegate.swift */, + 4EC45D31E6FCC4FF27B4F3B7 /* Info.plist */, + CA4C0A162EAAD03257FCC320 /* SceneDelegate.swift */, + 472650295568456DC645EC4F /* ShowcaseHomeView.swift */, + 812FA90A31E14920A08CC991 /* ShowcaseTabContainerView.swift */, + 89D307985C5742659FBD2048 /* ShowcaseDynamicLabView.swift */, + ); + path = SwiftUIChartsShowcaseApp; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1157F58C037245E8EAA47268 /* SwiftUIChartsShowcase */ = { + isa = PBXNativeTarget; + buildConfigurationList = BAC9D2E2CC2F3EFE9AE1C7D7 /* Build configuration list for PBXNativeTarget "SwiftUIChartsShowcase" */; + buildPhases = ( + 2FEC21B48626CFA9E502C36C /* Sources */, + F9428672158235A7E14E3CBE /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUIChartsShowcase; + packageProductDependencies = ( + C3965A0A2A9FEBF45BC880B8 /* SwiftUICharts */, + ); + productName = SwiftUIChartsShowcase; + productReference = 09A022B6E881286CD3B96CB5 /* SwiftUIChartsShowcase.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8B7A7FE05FB51DACF82D3B42 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = E4A630DF3DA41D75EA5D748D /* Build configuration list for PBXProject "SwiftUIChartsShowcase" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = A30E7F93B90AC7C66A7B151C; + packageReferences = ( + 71626CD6A68405D598C36A30 /* XCLocalSwiftPackageReference "../.." */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1157F58C037245E8EAA47268 /* SwiftUIChartsShowcase */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 2FEC21B48626CFA9E502C36C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0A17A65BC7246EF894FB4C46 /* AppDelegate.swift in Sources */, + 78F8536EADC5FE60A9B6D6B6 /* SceneDelegate.swift in Sources */, + 282BC43F66ABF75981A2CA5E /* ShowcaseHomeView.swift in Sources */, + 8BC2B530A7D14FACB535629E /* ShowcaseTabContainerView.swift in Sources */, + 1D4CE1A2A00E43B69F8E917B /* ShowcaseDynamicLabView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 53F29F46DD197EDB34FB3BBE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = SwiftUIChartsShowcaseApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appear.swiftuicharts.showcase; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B63534D891123660CA98BC1E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = SwiftUIChartsShowcaseApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appear.swiftuicharts.showcase; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B94BEAEF1BE98CCEA81C480D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + CC0F00DD9662CAB69A592EDC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BAC9D2E2CC2F3EFE9AE1C7D7 /* Build configuration list for PBXNativeTarget "SwiftUIChartsShowcase" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B63534D891123660CA98BC1E /* Debug */, + 53F29F46DD197EDB34FB3BBE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + E4A630DF3DA41D75EA5D748D /* Build configuration list for PBXProject "SwiftUIChartsShowcase" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC0F00DD9662CAB69A592EDC /* Debug */, + B94BEAEF1BE98CCEA81C480D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 71626CD6A68405D598C36A30 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C3965A0A2A9FEBF45BC880B8 /* SwiftUICharts */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftUICharts; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 8B7A7FE05FB51DACF82D3B42 /* Project object */; +} diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/xcshareddata/xcschemes/SwiftUIChartsShowcase.xcscheme b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/xcshareddata/xcschemes/SwiftUIChartsShowcase.xcscheme new file mode 100644 index 00000000..d43aa136 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcase.xcodeproj/xcshareddata/xcschemes/SwiftUIChartsShowcase.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/AppDelegate.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/AppDelegate.swift new file mode 100644 index 00000000..acacd5c9 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/AppDelegate.swift @@ -0,0 +1,16 @@ +import UIKit + +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + true + } + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/Info.plist b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/Info.plist new file mode 100644 index 00000000..e46a7097 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + + UIMainStoryboardFile + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/SceneDelegate.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/SceneDelegate.swift new file mode 100644 index 00000000..14966b1a --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/SceneDelegate.swift @@ -0,0 +1,19 @@ +import SwiftUI +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = UIHostingController(rootView: ShowcaseTabContainerView()) + self.window = window + window.makeKeyAndVisible() + } +} diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseDynamicLabView.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseDynamicLabView.swift new file mode 100644 index 00000000..8139c0c8 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseDynamicLabView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import SwiftUICharts +import UIKit + +struct ShowcaseDynamicLabView: View { + @ObservedObject private var stream = ChartStreamingDataSource(initialValues: [28, 31, 30, 35, 33, 36, 34, 37], + windowSize: 8, + autoScroll: true) + @State private var timer: Timer? + @State private var callbackText = "Touch bars to receive callback events." + + private var pageBackgroundColor: Color { Color(UIColor.systemGroupedBackground) } + private var cardBackgroundColor: Color { Color(UIColor.secondarySystemGroupedBackground) } + private var chartSurfaceColor: Color { Color(UIColor.secondarySystemBackground) } + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + liveStreamSection + callbackSection + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationBarTitle("Dynamic Data Lab", displayMode: .inline) + .background(pageBackgroundColor) + } + .onAppear(perform: startFeed) + .onDisappear(perform: stopFeed) + } + + private var liveStreamSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Streaming Feed (Mock Dynamic)") + .font(.headline) + Text("A timer appends points continuously to emulate live network updates.") + .font(.caption) + .foregroundColor(.secondary) + + AxisLabels { + ChartGrid { + LineChart() + .chartData(stream) + .chartYRange(stream.suggestedYRange) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.green, .blue))) + .chartLineMarks(true, color: ColorGradient(.green, .blue)) + } + .chartGridLines(horizontal: 5, vertical: max(2, stream.values.count)) + } + .chartXAxisLabels(stream.xLabels) + .chartYAxisAutoTicks(5, format: .number) + .chartAxisFont(.caption) + .chartAxisColor(.secondary) + .frame(height: 240) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var callbackSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Selection Callback Output") + .font(.headline) + Text(callbackText) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + + AxisLabels { + ChartGrid { + BarChart() + .chartData(stream.values) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: [ + ColorGradient(.orange, .red), + ColorGradient(.blue, .purple), + ColorGradient(.green, .yellow) + ])) + } + .chartGridLines(horizontal: 4, vertical: 0) + } + .chartXAxisLabels(stream.xLabels) + .chartYAxisAutoTicks(4, format: .number) + .chartAxisFont(.caption) + .chartAxisColor(.secondary) + .chartSelectionHandler { event in + guard event.isActive, + let value = event.value, + let index = event.index else { + callbackText = "No active selection" + return + } + + callbackText = "Slot \(index + 1): \(String(format: "%.2f", value))" + } + .frame(height: 230) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private func startFeed() { + guard timer == nil else { return } + + let next = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { _ in + let drift = Double.random(in: -2.0...2.0) + let value = min(45, max(20, stream.latestValue + drift)) + stream.append(value) + } + next.tolerance = 0.25 + timer = next + } + + private func stopFeed() { + timer?.invalidate() + timer = nil + } +} + +struct ShowcaseDynamicLabView_Previews: PreviewProvider { + static var previews: some View { + ShowcaseDynamicLabView() + } +} diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseHomeView.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseHomeView.swift new file mode 100644 index 00000000..f8af1bf0 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseHomeView.swift @@ -0,0 +1,538 @@ +import SwiftUI +import SwiftUICharts +import UIKit + +struct ShowcaseHomeView: View { + private let sharedBarValue = ChartValue() + private let lineSelectionValue = ChartValue() + @ObservedObject private var streamingSource = ChartStreamingDataSource(initialValues: [18, 23, 20, 27, 29, 24, 28, 31], + windowSize: 8, + autoScroll: true) + @State private var hiddenSeries: Set = [] + @State private var streamTimer: Timer? + @State private var highContrastEnabled = false + @State private var performanceModeEnabled = true + @State private var callbackSelectionText = "Drag bars to receive callback events" + private let denseSeries: [(Double, Double)] = ShowcaseHomeView.makeDenseSeries() + private var pageBackgroundColor: Color { Color(UIColor.systemGroupedBackground) } + private var cardBackgroundColor: Color { Color(UIColor.secondarySystemGroupedBackground) } + private var chartSurfaceColor: Color { Color(UIColor.secondarySystemBackground) } + private var axisColor: Color { .secondary } + private var ringsBackgroundGradient: ColorGradient { + ColorGradient(chartSurfaceColor, Color(UIColor.tertiarySystemBackground)) + } + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + headline + lineInteractionSection + dynamicDataSection + lineChartSection + accessibilitySection + axisEngineSection + performanceSection + selectionCallbackSection + overlayLineSection + legendControlSection + mixedChartSection + interactiveBarCard + pieAndRingsSection + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationBarTitle("SwiftUICharts Showcase", displayMode: .inline) + .background(pageBackgroundColor) + } + .onAppear(perform: startStreamingSimulation) + .onDisappear(perform: stopStreamingSimulation) + } + + private var headline: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Composable chart examples") + .font(.title) + .bold() + Text("Demonstrates line, bar, pie, and rings charts with modifier-based data, style, axis, grid, and interaction APIs.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + private var lineChartSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Line Chart + Grid + Axis + Marks + Range") + .font(.headline) + + AxisLabels { + ChartGrid { + LineChart() + .chartLineWidth(3) + .chartLineBackground(ColorGradient(.blue.opacity(0.2), .clear)) + .chartLineMarks(true, color: ColorGradient(.blue, .purple)) + .chartLineStyle(.curved) + .chartLineAnimation(true) + .chartData([12, 34, 23, 18, 36, 22, 26]) + .chartYRange(10...40) + .chartXRange(0...6) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.blue, .purple))) + } + .chartGridLines(horizontal: 5, vertical: 6) + } + .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6) + .chartYAxisLabels([(0, "10"), (1, "20"), (2, "30"), (3, "40")], range: 0...3) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var axisEngineSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Auto Tick Engine (Date + Collision + Rotation)") + .font(.headline) + + AxisLabels { + ChartGrid { + LineChart() + .chartLineWidth(3) + .chartLineMarks(true) + .chartData(weekTimeSeries) + .chartYRange(10...40) + .chartXRange(weekTimeRange) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.green, .blue))) + } + .chartGridLines(horizontal: 4, vertical: 6) + } + .chartXAxisAutoTicks(6, format: .shortDate) + .chartYAxisAutoTicks(4, format: .number) + .chartXAxisLabelRotation(.degrees(-24)) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 230) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var performanceSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Large Dataset Performance Mode") + .font(.headline) + Spacer(minLength: 8) + Toggle("Performance", isOn: $performanceModeEnabled) + .labelsHidden() + } + + Text("2000 points rendered with optional downsampling + simplified line style.") + .font(.caption) + .foregroundColor(.secondary) + + AxisLabels { + ChartGrid { + LineChart() + .chartData(denseSeries) + .chartYRange(-2...2) + .chartXRange(0...Double(max(0, denseSeries.count - 1))) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.purple, .blue))) + .chartPerformance(performanceModeEnabled + ? .automatic(threshold: 600, maxPoints: 180, simplifyLineStyle: true) + : .none) + } + .chartGridLines(horizontal: 4, vertical: 6) + } + .chartXAxisAutoTicks(6, format: .number) + .chartYAxisAutoTicks(5, format: .number) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var accessibilitySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Accessibility + High Contrast") + .font(.headline) + Spacer(minLength: 8) + Toggle("High Contrast", isOn: $highContrastEnabled) + .labelsHidden() + } + + Text("VoiceOver labels are available for bars, points, slices, and rings.") + .font(.caption) + .foregroundColor(.secondary) + + AxisLabels { + ChartGrid { + BarChart() + .chartData([8, 14, 11, 17, 15, 19, 16]) + .chartStyle(highContrastEnabled ? .highContrast : ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.orange, .red))) + } + .chartGridLines(horizontal: 4, vertical: 0) + } + .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6) + .chartYAxisAutoTicks(4, format: .number) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 210) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var selectionCallbackSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Selection Callback (No ChartValue)") + .font(.headline) + + Text(callbackSelectionText) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + + AxisLabels { + ChartGrid { + BarChart() + .chartData([11, 17, 15, 20, 16, 14, 19]) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: [ + ColorGradient(.red, .orange), + ColorGradient(.blue, .purple), + ColorGradient(.green, .yellow) + ])) + } + .chartGridLines(horizontal: 4, vertical: 0) + } + .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6) + .chartYAxisAutoTicks(4, format: .number) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .chartSelectionHandler { event in + guard event.isActive, + let value = event.value, + let index = event.index else { + callbackSelectionText = "No active selection" + return + } + + callbackSelectionText = "Selected index \(index + 1): \(String(format: "%.1f", value))" + } + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var lineInteractionSection: some View { + CardView { + ChartLabel("Line Selection", type: .title) + ChartLabel("Drag to inspect points", type: .legend, format: "%.1f") + + AxisLabels { + ChartGrid { + LineChart() + .chartLineWidth(3) + .chartLineMarks(true, color: ColorGradient(.pink, .purple)) + .chartLineStyle(.curved) + .chartData([14, 18, 12, 26, 22, 30, 24]) + .chartYRange(10...35) + .chartXRange(0...6) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.pink, .purple))) + } + .chartGridLines(horizontal: 5, vertical: 6) + } + .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 170) + } + .frame(maxWidth: .infinity) + .frame(height: 280) + .padding(6) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.pink, .purple))) + .chartInteractionValue(lineSelectionValue) + } + + private var dynamicDataSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Streaming Data Source") + .font(.headline) + Spacer(minLength: 8) + Text(String(format: "%.1f", streamingSource.latestValue)) + .font(.subheadline.monospacedDigit()) + .foregroundColor(.secondary) + } + Text("First-class streaming helper with append/window/auto-scroll.") + .font(.caption) + .foregroundColor(.secondary) + + AxisLabels { + ChartGrid { + LineChart() + .chartLineWidth(3) + .chartLineMarks(true, color: ColorGradient(.green, .blue)) + .chartLineStyle(.curved) + .chartLineAnimation(true) + .chartData(streamingSource) + .chartYRange(streamingSource.suggestedYRange) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.green, .blue))) + } + .chartGridLines(horizontal: 5, vertical: max(2, streamingSource.values.count)) + } + .chartXAxisLabels(streamingSource.xLabels) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var overlayLineSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Multiple Line Charts In One Frame") + .font(.headline) + + AxisLabels { + ChartGrid { + LineChart() + .chartLineMarks(true) + .chartLineStyle(.curved) + .chartData([3, 5, 4, 1, 0, 2, 4]) + .chartYRange(0...8) + .chartXRange(0...6) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.orange, .red))) + + LineChart() + .chartLineMarks(true) + .chartLineStyle(.straight) + .chartLineAnimation(false) + .chartData([4, 1, 0, 2, 6, 3, 5]) + .chartYRange(0...8) + .chartXRange(0...6) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.green, .yellow))) + } + .chartGridLines(horizontal: 5, vertical: 6) + } + .chartXAxisLabels([(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7")], range: 0...6) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var mixedChartSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Mixed Bar + Line Chart") + .font(.headline) + + AxisLabels { + ChartGrid { + BarChart() + .chartData([2, 4, 1, 3, 5]) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.orange, .red))) + + LineChart() + .chartLineMarks(true) + .chartData([2, 4, 1, 3, 5]) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.blue, .purple))) + } + .chartGridLines(horizontal: 5, vertical: 5) + } + .chartXAxisLabels([(0, "A"), (1, "B"), (2, "C"), (3, "D"), (4, "E")], range: 0...4) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var legendControlSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Legend + Series Visibility") + .font(.headline) + + ChartLegend(items: [ + ChartLegendItem(id: "sales", title: "Sales", color: ColorGradient(.orange, .red)), + ChartLegendItem(id: "forecast", title: "Forecast", color: ColorGradient(.blue, .purple)) + ], hiddenSeries: $hiddenSeries) + .padding(.horizontal, 8) + + AxisLabels { + ChartGrid { + LineChart() + .chartSeriesID("sales") + .chartLineMarks(true) + .chartData([2, 4, 3, 5, 4, 6, 7]) + .chartYRange(0...8) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.orange, .red))) + + LineChart() + .chartSeriesID("forecast") + .chartLineMarks(true) + .chartData([1, 3, 4, 4, 5, 5, 6]) + .chartYRange(0...8) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.blue, .purple))) + } + .chartGridLines(horizontal: 5, vertical: 6) + } + .chartHiddenSeries(hiddenSeries) + .chartXAxisLabels([(0, "Mon"), (1, "Tue"), (2, "Wed"), (3, "Thu"), (4, "Fri"), (5, "Sat"), (6, "Sun")], range: 0...6) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + + private var interactiveBarCard: some View { + CardView { + ChartLabel("Weekly Sales", type: .title) + ChartLabel("Drag bars to inspect values", type: .legend, format: "%.0f") + + AxisLabels { + ChartGrid { + BarChart() + .chartData([14, 22, 18, 31, 26, 19, 24]) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: [ + ColorGradient(.red, .orange), + ColorGradient(.blue, .purple), + ColorGradient(.green, .yellow) + ])) + } + .chartGridLines(horizontal: 5, vertical: 0) + } + .chartXAxisLabels([(0, "M"), (1, "T"), (2, "W"), (3, "T"), (4, "F"), (5, "S"), (6, "S")], range: 0...6) + .chartAxisColor(axisColor) + .chartAxisFont(.caption) + .frame(maxWidth: .infinity) + .frame(height: 170) + } + .frame(maxWidth: .infinity) + .frame(height: 280) + .padding(6) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: ColorGradient(.blue, .purple))) + .chartInteractionValue(sharedBarValue) + } + + private var pieAndRingsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Pie and Rings Charts") + .font(.headline) + + HStack(spacing: 12) { + PieChart() + .chartData([34, 23, 12]) + .chartStyle(ChartStyle(backgroundColor: chartSurfaceColor, + foregroundColor: [ + ColorGradient(.red, .orange), + ColorGradient(.blue, .purple), + ColorGradient(.green, .yellow) + ])) + .frame(maxWidth: .infinity) + .frame(height: 180) + .padding(10) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + + RingsChart() + .chartData([25, 50, 75, 90]) + .chartStyle(ChartStyle(backgroundColor: ringsBackgroundGradient, + foregroundColor: [ + ColorGradient(.purple, .blue), + ColorGradient(.orange, .red), + ColorGradient(.green, .yellow), + ColorGradient(.pink, .purple) + ])) + .frame(maxWidth: .infinity) + .frame(height: 180) + .padding(10) + .background(RoundedRectangle(cornerRadius: 14).fill(cardBackgroundColor)) + } + } + } + + private var weekTimeSeries: [(Double, Double)] { + let day: TimeInterval = 24 * 60 * 60 + let start = Date(timeIntervalSince1970: 1_704_067_200).timeIntervalSince1970 + let points: [Double] = [12, 16, 21, 19, 28, 24, 31] + return points.enumerated().map { index, value in + (start + (Double(index) * day), value) + } + } + + private var weekTimeRange: ClosedRange? { + guard let start = weekTimeSeries.first?.0, let end = weekTimeSeries.last?.0 else { return nil } + return start...end + } + + private static func makeDenseSeries() -> [(Double, Double)] { + (0..<2_000).map { index in + let x = Double(index) + let y = sin(x / 45.0) + (cos(x / 12.0) * 0.25) + return (x, y) + } + } +} + +struct ShowcaseHomeView_Previews: PreviewProvider { + static var previews: some View { + ShowcaseHomeView() + } +} + +private extension ShowcaseHomeView { + func startStreamingSimulation() { + guard streamTimer == nil else { return } + + let timer = Timer.scheduledTimer(withTimeInterval: 1.8, repeats: true) { _ in + let current = streamingSource.latestValue + let delta = Double.random(in: -4.5...4.5) + let next = min(45, max(10, current + delta)) + streamingSource.append(next) + } + timer.tolerance = 0.35 + streamTimer = timer + } + + func stopStreamingSimulation() { + streamTimer?.invalidate() + streamTimer = nil + } +} diff --git a/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseTabContainerView.swift b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseTabContainerView.swift new file mode 100644 index 00000000..8ea87965 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/SwiftUIChartsShowcaseApp/ShowcaseTabContainerView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct ShowcaseTabContainerView: View { + var body: some View { + TabView { + ShowcaseHomeView() + .tabItem { + Image(systemName: "chart.xyaxis.line") + Text("Showcase") + } + .tag(0) + + ShowcaseDynamicLabView() + .tabItem { + Image(systemName: "waveform.path.ecg") + Text("Dynamic") + } + .tag(1) + } + } +} + +struct ShowcaseTabContainerView_Previews: PreviewProvider { + static var previews: some View { + ShowcaseTabContainerView() + } +} diff --git a/Examples/SwiftUIChartsShowcase/project.yml b/Examples/SwiftUIChartsShowcase/project.yml new file mode 100644 index 00000000..1405fcb9 --- /dev/null +++ b/Examples/SwiftUIChartsShowcase/project.yml @@ -0,0 +1,31 @@ +name: SwiftUIChartsShowcase +options: + bundleIdPrefix: com.appear +settings: + base: + IPHONEOS_DEPLOYMENT_TARGET: 13.0 +targets: + SwiftUIChartsShowcase: + type: application + platform: iOS + deploymentTarget: "13.0" + sources: + - path: SwiftUIChartsShowcaseApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.appear.swiftuicharts.showcase + INFOPLIST_FILE: SwiftUIChartsShowcaseApp/Info.plist + SWIFT_VERSION: 5.0 + dependencies: + - package: SwiftUICharts + product: SwiftUICharts +packages: + SwiftUICharts: + path: ../.. +schemes: + SwiftUIChartsShowcase: + build: + targets: + SwiftUIChartsShowcase: all + run: + config: Debug diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..8bc25fb7 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,150 @@ +# SwiftUICharts Migration Guide + +## Target release + +This guide targets `2.0.0`. + +`2.0.0` is a major breaking release and phases out the old mutable chain API in favor of composable modifier-based configuration. + +## Version direction + +This release moves to a strict SwiftUI-idiomatic modifier API. + +- Immutable configuration +- `ViewModifier` composition +- Environment keys instead of mutable reference state in view structs + +## Migration checklist + +1. Replace legacy chart types with `LineChart`, `BarChart`, `PieChart`, `RingsChart`. +2. Replace old chain methods with `chart...` modifiers. +3. Migrate interaction: + - shared value model: `chartInteractionValue(_:)` + - callback model: `chartSelectionHandler(_:)` +4. Validate axis label/range behavior under the unified X-axis alignment model. +5. Run `swift test` and build the showcase app to verify rendering parity. + +## Old -> new mapping + +### Previous chart data/range chains + +| Old | New | +| --- | --- | +| `.data([Double])` | `.chartData([Double])` | +| `.data([(Double, Double)])` | `.chartData([(Double, Double)])` | +| `.rangeX(...)` | `.chartXRange(...)` | +| `.rangeY(...)` | `.chartYRange(...)` | + +### Grid chains + +| Old | New | +| --- | --- | +| `.setNumberOfHorizontalLines(h)` + `.setNumberOfVerticalLines(v)` | `.chartGridLines(horizontal: h, vertical: v)` | +| `.setStoreStyle(style)` + `.setColor(color)` | `.chartGridStroke(style: style, color: color)` | +| `.showBaseLine(show, with: style)` | `.chartGridBaseline(show, style: style)` | + +### Axis chains + +| Old | New | +| --- | --- | +| `.setAxisXLabels([String])` | `.chartXAxisLabels([String])` | +| `.setAxisXLabels([(Double, String)], range:)` | `.chartXAxisLabels([(Double, String)], range:)` | +| `.setAxisYLabels([String], position:)` | `.chartYAxisLabels([String], position:)` | +| `.setAxisYLabels([(Double, String)], range:, position:)` | `.chartYAxisLabels([(Double, String)], range:, position:)` | +| `.setFont(font)` | `.chartAxisFont(font)` | +| `.setColor(color)` | `.chartAxisColor(color)` | + +### Line-specific chains + +| Old | New | +| --- | --- | +| `.setLineWidth(width:)` | `.chartLineWidth(...)` | +| `.setBackground(colorGradient:)` | `.chartLineBackground(...)` | +| `.showChartMarks(_, with:)` | `.chartLineMarks(_, color:)` | +| `.setLineStyle(to:)` | `.chartLineStyle(...)` | +| `.withAnimation(_)` | `.chartLineAnimation(...)` | + +### Interaction wiring + +| Old | New | +| --- | --- | +| `.chartValue(...)` on chart views | `.chartInteractionValue(...)` on any parent container | +| `@EnvironmentObject ChartValue` requirement | optional environment interaction value | +| N/A | `.chartSelectionHandler { event in ... }` callback-based selection | + +### Legacy public type replacements + +| Legacy type | Replacement | +| --- | --- | +| `LineChartView` | `LineChart` with modifiers | +| `BarChartView` | `BarChart` with modifiers | +| `PieChartView` | `PieChart` with modifiers | +| `MultiLineChartView` | multiple `LineChart` overlays | +| `LineView` | `CardView` + `LineChart` + modifiers | +| `GradientColor` | `ColorGradient` | +| `GradientColors` | explicit `ColorGradient` values | +| `Colors` | `ChartColors` + `Color` | +| `Styles` | explicit `ChartStyle` initialization | +| `ChartForm` | SwiftUI layout (`frame`, stacks, spacing) | +| `MultiLineChartData` | multiple `chartData`-configured line layers | +| `MagnifierRect` | no direct replacement | +| `TestData` | app/test-local fixtures | + +## Example migration + +Before: + +```swift +AxisLabels { + ChartGrid { + LineChart() + .showChartMarks(true) + .data([8, 23, 54, 32]) + .rangeY(0...60) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: ColorGradient(.orange, .red))) + } + .setNumberOfHorizontalLines(5) + .setNumberOfVerticalLines(4) +} +.setAxisXLabels(["Q1", "Q2", "Q3", "Q4"]) +``` + +After: + +```swift +AxisLabels { + ChartGrid { + LineChart() + .chartLineMarks(true) + .chartData([8, 23, 54, 32]) + .chartYRange(0...60) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: ColorGradient(.orange, .red))) + } + .chartGridLines(horizontal: 5, vertical: 4) +} +.chartXAxisLabels(["Q1", "Q2", "Q3", "Q4"]) +``` + +## Advanced migration examples + +### Dynamic streaming data + +```swift +@ObservedObject private var stream = ChartStreamingDataSource(initialValues: [12, 14, 18, 16], + windowSize: 8, + autoScroll: true) + +LineChart() + .chartData(stream) + .chartYRange(stream.suggestedYRange) +``` + +### Performance mode for large datasets + +```swift +LineChart() + .chartData(largeSeries) + .chartPerformance(.automatic(threshold: 600, maxPoints: 180, simplifyLineStyle: true)) +``` diff --git a/Package.swift b/Package.swift index ffd10e06..5f25255e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,13 +6,13 @@ import PackageDescription let package = Package( name: "SwiftUICharts", platforms: [ - .iOS(.v13),.watchOS(.v6) + .iOS(.v13), .watchOS(.v6), .macOS(.v10_15) ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "SwiftUICharts", - targets: ["SwiftUICharts"]), + targets: ["SwiftUICharts"]) ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -23,9 +23,12 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "SwiftUICharts", - dependencies: []), + dependencies: [], + resources: [ + .process("PrivacyInfo.xcprivacy") + ]), .testTarget( name: "SwiftUIChartsTests", - dependencies: ["SwiftUICharts"]), + dependencies: ["SwiftUICharts"]) ] ) diff --git a/README.md b/README.md index 23f773a2..f9728d5d 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,187 @@ # SwiftUICharts -Swift package for displaying charts effortlessly. +SwiftUICharts is a composable, SwiftUI-native chart library for iOS 13+. -![SwiftUI Charts](./showcase1.gif "SwiftUI Charts") +`2.0.0` is a major release focused on immutable configuration, environment-driven composition, and modifier-based APIs. -It supports: -* Line charts -* Bar charts -* Pie charts +

+ +

-### Installation: +## AI Agent Quick Context -It requires iOS 13 and xCode 11! +Use this section when an AI agent generates code against this package. -In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView` +### API contract -### Usage: +- Use only `2.x` composable APIs. +- Do not use legacy `1.x` types or mutating chains. +- Build charts with `ViewModifier` composition. +- Keep data source semantics explicit: + - `chartData([Double])` = categorical slots + - `chartData([(Double, Double)])` = numeric/continuous domain -import the package in the file you would like to use it: `import SwiftUICharts` +### Use these APIs -You can display a Chart by adding a chart view to your parent view: +| Task | API | +| --- | --- | +| Set data | `chartData(...)` | +| Set ranges | `chartXRange(...)`, `chartYRange(...)` | +| Style chart | `chartStyle(...)` | +| Grid config | `chartGridLines`, `chartGridStroke`, `chartGridBaseline` | +| Axis labels/ticks | `chartXAxisLabels`, `chartYAxisLabels`, `chartXAxisAutoTicks`, `chartYAxisAutoTicks` | +| Line tuning | `chartLineWidth`, `chartLineStyle`, `chartLineMarks`, `chartLineAnimation` | +| Shared interaction | `chartInteractionValue(...)` | +| Callback interaction | `chartSelectionHandler { event in ... }` | +| Streaming data | `ChartStreamingDataSource` + `chartData(stream)` | +| Large datasets | `chartPerformance(...)` | -### Demo +### Avoid these legacy APIs -Added an example project, with **iOS, watchOS** target: https://github.com/AppPear/ChartViewDemo +- `LineChartView`, `BarChartView`, `PieChartView`, `MultiLineChartView` +- `.data(...)`, `.rangeX(...)`, `.rangeY(...)` +- `.setAxisXLabels(...)`, `.setNumberOfHorizontalLines(...)` +- `.showChartMarks(...)` (old form) -## Line charts -![Line Charts](./showcase3.gif "Line Charts") +## Installation -**Line chart is interactive, so you can drag across to reveal the data points** +Add with Swift Package Manager: -You can add a line chart with the following code: +`https://github.com/AppPear/ChartView` + +For this release, use tag `2.0.0` (or `from: "2.0.0"` up to next major). + +## 30-Second Quick Start ```swift - LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional +import SwiftUI +import SwiftUICharts + +struct DemoView: View { + var body: some View { + AxisLabels { + ChartGrid { + LineChart() + .chartData([12, 34, 23, 18, 36, 22, 26]) + .chartYRange(10...40) + .chartLineMarks(true, color: ColorGradient(.blue, .purple)) + .chartStyle( + ChartStyle( + backgroundColor: .white, + foregroundColor: ColorGradient(.blue, .purple) + ) + ) + } + .chartGridLines(horizontal: 5, vertical: 6) + } + .chartXAxisLabels(["M", "T", "W", "T", "F", "S", "S"]) + .chartAxisColor(.secondary) + .chartAxisFont(.caption) + .frame(height: 220) + .padding() + } +} ``` +## Feature Recipes -## Bar charts -![Bar Charts](./showcase2.gif "Bar Charts") +### 1) Mixed bar + line -**Bar chart is interactive, so you can drag across to reveal the data points** +```swift +AxisLabels { + ChartGrid { + BarChart() + .chartData([2, 4, 1, 3, 5]) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: ColorGradient(.orange, .red))) + + LineChart() + .chartData([2, 4, 1, 3, 5]) + .chartLineMarks(true) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: ColorGradient(.blue, .purple))) + } + .chartGridLines(horizontal: 5, vertical: 5) +} +.chartXAxisLabels([(0, "A"), (1, "B"), (2, "C"), (3, "D"), (4, "E")], range: 0...4) +``` -You can add a bar chart with the following code: +### 2) Shared interaction state ```swift - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional -``` +let selected = ChartValue() -You can add different formats: -* Small `Form.small` -* Medium `Form.medium` -* Large `Form.large` +VStack(alignment: .leading) { + ChartLabel("Weekly Sales", type: .title) + ChartLabel("Drag bars", type: .legend, format: "%.1f") - ```swift - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small) - ``` - - ### You can customize styling of the chart with a ChartStyle object: + BarChart().chartData([14, 22, 18, 31, 26, 19, 24]) +} +.chartInteractionValue(selected) +``` -Customizable: -* background color -* accent color -* second gradient color -* text color -* legend text color +### 3) Callback-based interaction ```swift - let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: Form.medium, textColor: Color.white, legendTextColor: Color.white ) - ... - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle) +BarChart() + .chartData([8, 11, 13, 9, 12]) + .chartSelectionHandler { event in + guard event.isActive, + let value = event.value, + let index = event.index else { return } + print("selected", index, value) + } ``` -You can access built-in styles: +### 4) Dynamic streaming data + ```swift - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: Styles.barChartMidnightGreen) +@ObservedObject private var stream = ChartStreamingDataSource( + initialValues: [18, 23, 20, 27, 29, 24], + windowSize: 6, + autoScroll: true +) + +LineChart() + .chartData(stream) + .chartYRange(stream.suggestedYRange) ``` -#### All styles available as a preset: -* barChartStyleOrangeLight -* barChartStyleOrangeDark -* barChartStyleNeonBlueLight -* barChartStyleNeonBlueDark -* barChartMidnightGreenLight -* barChartMidnightGreenDark -![Midnightgreen](./midnightgreen.gif "Midnightgreen") +### 5) Performance mode for large datasets -![Custom Charts](./showcase5.png "Custom Charts") +```swift +LineChart() + .chartData(largeSeries) + .chartPerformance(.automatic(threshold: 600, + maxPoints: 180, + simplifyLineStyle: true)) +``` +## Migration -### You can customize the size of the chart with a Form object: +This is a major breaking release. -**Form** -* `.small` -* `.medium` -* `.large` -* `.detail` +- Full migration guide: [MIGRATION.md](./MIGRATION.md) +- Quick examples: [example.md](./example.md) -```swift -BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small) -``` +## Documentation Map -### WatchOS support for Bar charts: +- Wiki index: [docs/wiki/README.md](./docs/wiki/README.md) +- Getting started: [docs/wiki/01-getting-started.md](./docs/wiki/01-getting-started.md) +- Modifiers: [docs/wiki/02-composable-modifiers.md](./docs/wiki/02-composable-modifiers.md) +- Interaction: [docs/wiki/03-interaction-and-selection.md](./docs/wiki/03-interaction-and-selection.md) +- Streaming: [docs/wiki/04-dynamic-and-streaming-data.md](./docs/wiki/04-dynamic-and-streaming-data.md) +- Performance: [docs/wiki/05-performance-and-large-datasets.md](./docs/wiki/05-performance-and-large-datasets.md) +- Axis alignment: [docs/wiki/06-unified-axis-and-label-alignment.md](./docs/wiki/06-unified-axis-and-label-alignment.md) +- Migration summary: [docs/wiki/07-migration-from-1x.md](./docs/wiki/07-migration-from-1x.md) -![Pie Charts](./watchos1.png "Pie Charts") +## Example App -## Pie charts -![Pie Charts](./showcase4.png "Pie Charts") +The showcase app demonstrates all major features: -You can add a line chart with the following code: +`Examples/SwiftUIChartsShowcase` -```swift - PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional -``` +## Release Notes +- Changelog: [CHANGELOG.md](./CHANGELOG.md) +- Includes Apple privacy manifest: `Sources/SwiftUICharts/PrivacyInfo.xcprivacy` diff --git a/Resources/barchartcard.png b/Resources/barchartcard.png new file mode 100644 index 00000000..2e816179 Binary files /dev/null and b/Resources/barchartcard.png differ diff --git a/Resources/barvid2.gif b/Resources/barvid2.gif new file mode 100644 index 00000000..3e8499a6 Binary files /dev/null and b/Resources/barvid2.gif differ diff --git a/Resources/chartpic1.png b/Resources/chartpic1.png new file mode 100644 index 00000000..62608829 Binary files /dev/null and b/Resources/chartpic1.png differ diff --git a/Resources/chartpic2.png b/Resources/chartpic2.png new file mode 100644 index 00000000..939cb8fb Binary files /dev/null and b/Resources/chartpic2.png differ diff --git a/Resources/chartpic3.png b/Resources/chartpic3.png new file mode 100644 index 00000000..1bd0d5e7 Binary files /dev/null and b/Resources/chartpic3.png differ diff --git a/Resources/chartpic4.png b/Resources/chartpic4.png new file mode 100644 index 00000000..1aa7a358 Binary files /dev/null and b/Resources/chartpic4.png differ diff --git a/Resources/chartpic5.png b/Resources/chartpic5.png new file mode 100644 index 00000000..d1e06b88 Binary files /dev/null and b/Resources/chartpic5.png differ diff --git a/Resources/chartpic6.png b/Resources/chartpic6.png new file mode 100644 index 00000000..1d16a59e Binary files /dev/null and b/Resources/chartpic6.png differ diff --git a/Resources/chartpic7.png b/Resources/chartpic7.png new file mode 100644 index 00000000..aee3f227 Binary files /dev/null and b/Resources/chartpic7.png differ diff --git a/Resources/linechartcard.png b/Resources/linechartcard.png new file mode 100644 index 00000000..d7c49156 Binary files /dev/null and b/Resources/linechartcard.png differ diff --git a/Resources/linevid2.gif b/Resources/linevid2.gif new file mode 100644 index 00000000..8bc8a650 Binary files /dev/null and b/Resources/linevid2.gif differ diff --git a/Resources/piechartcard.png b/Resources/piechartcard.png new file mode 100644 index 00000000..0cc1e751 Binary files /dev/null and b/Resources/piechartcard.png differ diff --git a/Resources/pievid2.gif b/Resources/pievid2.gif new file mode 100644 index 00000000..a30279bb Binary files /dev/null and b/Resources/pievid2.gif differ diff --git a/Resources/ringchart1.png b/Resources/ringchart1.png new file mode 100644 index 00000000..da2b200e Binary files /dev/null and b/Resources/ringchart1.png differ diff --git a/Sources/SwiftUICharts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/BarChart/BarChartCell.swift deleted file mode 100644 index 1414ddb8..00000000 --- a/Sources/SwiftUICharts/BarChart/BarChartCell.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ChartCell.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct BarChartCell : View { - var value: Double - var index: Int = 0 - var width: Float - var numberOfDataPoints: Int - var cellWidth: Double { - return Double(width)/(Double(numberOfDataPoints) * 1.5) - } - var accentColor: Color - var secondGradientAccentColor: Color? - var gradientColors:[Color] { - if (secondGradientAccentColor != nil) { - return [secondGradientAccentColor!, accentColor] - } - return [accentColor, accentColor] - } - @State var scaleValue: Double = 0 - @Binding var touchLocation: CGFloat - public var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 4) - .fill(LinearGradient(gradient: Gradient(colors: gradientColors), startPoint: .bottom, endPoint: .top)) - } - .frame(width: CGFloat(self.cellWidth)) - .scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom) - .onAppear(){ - self.scaleValue = self.value - } - .animation(Animation.spring().delay(self.touchLocation < 0 ? Double(self.index) * 0.04 : 0)) - } -} - -#if DEBUG -struct ChartCell_Previews : PreviewProvider { - static var previews: some View { - BarChartCell(value: Double(0.75), width: 320, numberOfDataPoints: 12, accentColor: Colors.OrangeStart, secondGradientAccentColor: nil, touchLocation: .constant(-1)) - } -} -#endif diff --git a/Sources/SwiftUICharts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/BarChart/BarChartRow.swift deleted file mode 100644 index 242f4efd..00000000 --- a/Sources/SwiftUICharts/BarChart/BarChartRow.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// ChartRow.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct BarChartRow : View { - var data: [Int] - var accentColor: Color - var secondGradientAccentColor: Color? - var maxValue: Int { - data.max() ?? 0 - } - @Binding var touchLocation: CGFloat - public var body: some View { - GeometryReader { geometry in - HStack(alignment: .bottom, spacing: (geometry.frame(in: .local).width-22)/CGFloat(self.data.count * 3)){ - ForEach(0.. CGFloat(i)/CGFloat(self.data.count) && self.touchLocation < CGFloat(i+1)/CGFloat(self.data.count) ? CGSize(width: 1.4, height: 1.1) : CGSize(width: 1, height: 1), anchor: .bottom) - - } - } - .padding([.top, .leading, .trailing], 10) - } - } - - func normalizedValue(index: Int) -> Double { - return Double(self.data[index])/Double(self.maxValue) - } -} - -#if DEBUG -struct ChartRow_Previews : PreviewProvider { - static var previews: some View { - BarChartRow(data: [8,23,54,32,12,37,7], accentColor: Colors.OrangeStart, touchLocation: .constant(-1)) - } -} -#endif diff --git a/Sources/SwiftUICharts/BarChart/BarChartView.swift b/Sources/SwiftUICharts/BarChart/BarChartView.swift deleted file mode 100644 index d6013c7e..00000000 --- a/Sources/SwiftUICharts/BarChart/BarChartView.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// ChartView.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct BarChartView : View { - public var data: [Int] - public var title: String - public var legend: String? - public var style: ChartStyle - public var formSize:CGSize -// let selectionFeedbackGenerator = UISelectionFeedbackGenerator() - - @State private var touchLocation: CGFloat = -1.0 - @State private var showValue: Bool = false - @State private var currentValue: Int = 0 { - didSet{ - if(oldValue != self.currentValue && self.showValue) { -// selectionFeedbackGenerator.selectionChanged() - HapticFeedback.playSelection() - } - } - } - var isFullWidth:Bool { - return self.formSize == Form.large - } - public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = Form.medium){ - self.data = data - self.title = title - self.legend = legend - self.style = style - self.formSize = form! - } - - public var body: some View { - ZStack{ - Rectangle() - .fill(self.style.backgroundColor) - .cornerRadius(20) - .shadow(color: Color.gray, radius: 8 ) - VStack(alignment: .leading){ - HStack{ - if(!showValue){ - Text(self.title) - .font(.headline) - .foregroundColor(self.style.textColor) - }else{ - Text("\(self.currentValue)") - .font(.headline) - .foregroundColor(self.style.textColor) - } - if(self.formSize == Form.large && self.legend != nil && !showValue) { - Text(self.legend!) - .font(.callout) - .foregroundColor(self.style.accentColor) - .transition(.opacity) - .animation(.easeOut) - } - Spacer() - Image(systemName: "waveform.path.ecg") - .imageScale(.large) - .foregroundColor(self.style.legendTextColor) - }.padding() - BarChartRow(data: data, accentColor: self.style.accentColor, secondGradientAccentColor: self.style.secondGradientColor, touchLocation: self.$touchLocation) - if self.legend != nil && self.formSize == Form.medium { - Text(self.legend!) - .font(.headline) - .foregroundColor(self.style.legendTextColor) - .padding() - } - - } - }.frame(minWidth:self.formSize.width, maxWidth: self.isFullWidth ? .infinity : self.formSize.width, minHeight:self.formSize.height, maxHeight:self.formSize.height) - .gesture(DragGesture() - .onChanged({ value in - self.touchLocation = value.location.x/self.formSize.width - self.showValue = true - self.currentValue = self.getCurrentValue() - }) - .onEnded({ value in - self.showValue = false - self.touchLocation = -1 - }) - ) - .gesture(TapGesture() - ) - } - - func getCurrentValue()-> Int{ - let index = max(0,min(self.data.count-1,Int(floor((self.touchLocation*self.formSize.width)/(self.formSize.width/CGFloat(self.data.count)))))) - return self.data[index] - } -} - -#if DEBUG -struct ChartView_Previews : PreviewProvider { - static var previews: some View { - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") - } -} -#endif diff --git a/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift b/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift new file mode 100644 index 00000000..a90b6796 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift @@ -0,0 +1,315 @@ +import SwiftUI + +public struct AxisLabels: View { + @Environment(\.chartAxisConfig) private var axisConfig + + @State private var preferredDataPoints: [(Double, Double)] = [] + @State private var preferredXRange: ClosedRange? + @State private var preferredYRange: ClosedRange? + @State private var preferredXDomainMode: ChartXDomainMode = .numeric + + private let yAxisWidth: CGFloat = 42 + private let xAxisHeight: CGFloat = 24 + + let content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + private var visibleXValues: [Double] { + preferredDataPoints + .filter { preferredXRange?.contains($0.0) ?? true } + .map(\.0) + } + + private var visibleYValues: [Double] { + preferredDataPoints + .filter { preferredXRange?.contains($0.0) ?? true } + .map(\.1) + } + + private var xRangeForScale: ClosedRange? { + preferredXRange ?? axisConfig.axisXRange + } + + private var xDomainModeForScale: ChartXDomainMode { + preferredDataPoints.isEmpty ? axisConfig.axisXDomainMode : preferredXDomainMode + } + + private var resolvedXAxisLabels: [ChartXAxisLabel] { + if !axisConfig.axisXLabels.isEmpty { + return axisConfig.axisXLabels + } + + guard let autoCount = axisConfig.axisXAutoTickCount else { return [] } + return autoGeneratedXAxisLabels(count: autoCount) + } + + private var resolvedYAxisLabels: [String] { + if !axisConfig.axisYLabels.isEmpty { + return axisConfig.axisYLabels + } + + guard let autoCount = axisConfig.axisYAutoTickCount else { return [] } + return autoGeneratedYAxisLabels(count: autoCount) + } + + private var hasYLabels: Bool { + !resolvedYAxisLabels.isEmpty + } + + private var hasXLabels: Bool { + !resolvedXAxisLabels.isEmpty + } + + private var effectiveYAxisWidth: CGFloat { + hasYLabels ? yAxisWidth : 0 + } + + private var effectiveXAxisHeight: CGFloat { + hasXLabels ? xAxisHeight : 0 + } + + private var leftAxisGutter: CGFloat { + axisConfig.axisLabelsYPosition == .leading ? effectiveYAxisWidth : 0 + } + + private var rightAxisGutter: CGFloat { + axisConfig.axisLabelsYPosition == .trailing ? effectiveYAxisWidth : 0 + } + + private var xScale: ChartXScale { + let scaleValues = visibleXValues.isEmpty ? resolvedXAxisLabels.map(\.value) : visibleXValues + return ChartXScale(values: scaleValues, + rangeX: xRangeForScale, + mode: xDomainModeForScale, + slotCountHint: max(scaleValues.count, resolvedXAxisLabels.count)) + } + + var yAxis: some View { + VStack(spacing: 0) { + ForEach(Array(resolvedYAxisLabels.reversed().enumerated()), id: \.offset) { index, axisYData in + Text(axisYData) + .font(axisConfig.axisFont) + .foregroundColor(axisConfig.axisFontColor) + .frame(maxWidth: .infinity, + alignment: axisConfig.axisLabelsYPosition == .leading ? .trailing : .leading) + + if index < resolvedYAxisLabels.count - 1 { + Spacer(minLength: 0) + } + } + } + .padding(.horizontal, 4) + } + + var xAxis: some View { + GeometryReader { geometry in + let safeSize = geometry.size.sanitized + let width = safeSize.width + let labels = visibleXAxisLabels(width: width) + + ZStack(alignment: .topLeading) { + ForEach(Array(labels.enumerated()), id: \.offset) { _, xLabel in + positionedXLabel(xLabel, width: width) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: xAxisHeight, alignment: .top) + } + + public var body: some View { + GeometryReader { geometry in + let safeSize = geometry.size.sanitized + let axisHeight = effectiveXAxisHeight + let chartHeight = max(0, safeSize.height - axisHeight) + + VStack(spacing: 0) { + HStack(spacing: 0) { + if leftAxisGutter > 0 { + yAxis + .frame(width: leftAxisGutter, height: chartHeight, alignment: .trailing) + } + + content() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + if rightAxisGutter > 0 { + yAxis + .frame(width: rightAxisGutter, height: chartHeight, alignment: .leading) + } + } + .frame(height: chartHeight, alignment: .top) + + if axisHeight > 0 { + HStack(spacing: 0) { + if leftAxisGutter > 0 { + Color.clear.frame(width: leftAxisGutter, height: axisHeight) + } + + xAxis + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + if rightAxisGutter > 0 { + Color.clear.frame(width: rightAxisGutter, height: axisHeight) + } + } + .frame(height: axisHeight, alignment: .top) + } + } + .frame(width: safeSize.width, height: safeSize.height, alignment: .topLeading) + } + .onPreferenceChange(ChartDataPointsPreferenceKey.self) { snapshot in + preferredDataPoints = snapshot.points + } + .onPreferenceChange(ChartXRangePreferenceKey.self) { range in + preferredXRange = range + } + .onPreferenceChange(ChartYRangePreferenceKey.self) { range in + preferredYRange = range + } + .onPreferenceChange(ChartXDomainModePreferenceKey.self) { mode in + preferredXDomainMode = mode + } + } + + private func visibleXAxisLabels(width: CGFloat) -> [ChartXAxisLabel] { + let labels = resolvedXAxisLabels.sorted(by: { $0.value < $1.value }) + guard labels.count > 2, width > 0 else { return labels } + + let rotationFactor = abs(axisConfig.axisXLabelRotation.degrees) > 0 ? 1.4 : 1.0 + let minimumSpacing = 28.0 * rotationFactor + let minNormalizedDistance = minimumSpacing / width + var filtered: [ChartXAxisLabel] = [] + var lastPlacedX = -Double.greatestFiniteMagnitude + + for (index, label) in labels.enumerated() { + let x = xScale.normalizedX(for: label.value) + if index == 0 || index == labels.count - 1 || (x - lastPlacedX) >= minNormalizedDistance { + filtered.append(label) + lastPlacedX = x + } + } + + return filtered + } + + @ViewBuilder + private func positionedXLabel(_ xLabel: ChartXAxisLabel, width: CGFloat) -> some View { + let normalized = xScale.normalizedX(for: xLabel.value) + let clamped = min(1.0, max(0.0, normalized)) + let label = axisLabelText(xLabel.title) + + if clamped <= 0.001 { + label + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else if clamped >= 0.999 { + label + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } else { + label + .position(x: width * CGFloat(clamped), y: xAxisHeight / 2) + } + } + + private func axisLabelText(_ title: String) -> some View { + Text(title) + .lineLimit(1) + .minimumScaleFactor(0.8) + .font(axisConfig.axisFont) + .foregroundColor(axisConfig.axisFontColor) + .rotationEffect(axisConfig.axisXLabelRotation, anchor: .top) + } + + private func autoGeneratedXAxisLabels(count: Int) -> [ChartXAxisLabel] { + guard count > 1 else { return [] } + + switch xDomainModeForScale { + case .categorical: + let values: [Double] + if let range = xRangeForScale { + let lower = Int(floor(range.lowerBound)) + let upper = Int(ceil(range.upperBound)) + values = Array(lower...upper).map(Double.init) + } else { + values = Array(Set(visibleXValues)).sorted() + } + + guard !values.isEmpty else { return [] } + return sampled(values, targetCount: count).map { + ChartXAxisLabel(value: $0, title: formatAxisTick($0, format: axisConfig.axisXTickFormat)) + } + + case .numeric: + let bounds: ClosedRange? + if let range = xRangeForScale { + bounds = range + } else if let minValue = visibleXValues.min(), let maxValue = visibleXValues.max(), minValue != maxValue { + bounds = minValue...maxValue + } else { + bounds = nil + } + + guard let range = bounds else { return [] } + let span = range.upperBound - range.lowerBound + guard span.isFinite, span > 0 else { return [] } + let step = span / Double(count - 1) + return (0.. [String] { + guard count > 1 else { return [] } + + let bounds: ClosedRange? + if let preferredYRange = preferredYRange { + bounds = preferredYRange + } else if let minValue = visibleYValues.min(), let maxValue = visibleYValues.max(), minValue != maxValue { + bounds = minValue...maxValue + } else { + bounds = nil + } + + guard let range = bounds else { return [] } + let span = range.upperBound - range.lowerBound + guard span.isFinite, span > 0 else { return [] } + let step = span / Double(count - 1) + return (0.. [Double] { + guard values.count > targetCount, targetCount > 1 else { return values } + + let stride = Double(values.count - 1) / Double(targetCount - 1) + var result: [Double] = [] + for index in 0.. String { + switch format { + case .number: + if abs(value.rounded() - value) < 0.001 { + return String(Int(value.rounded())) + } + return String(format: "%.1f", value) + case .shortDate: + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter.string(from: Date(timeIntervalSince1970: value)) + } + } +} diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift new file mode 100644 index 00000000..66735d7b --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum AxisLabelsYPosition { + case leading + case trailing +} + +public enum AxisLabelsXPosition { + case top + case bottom +} diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift new file mode 100644 index 00000000..61535a81 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift @@ -0,0 +1,4 @@ +import SwiftUI + +@available(*, deprecated, message: "Use chartAxis* modifiers and ChartAxisConfig") +public typealias AxisLabelsStyle = ChartAxisConfig diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift new file mode 100644 index 00000000..00d38bef --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift @@ -0,0 +1,4 @@ +import SwiftUI + +@available(*, deprecated, message: "Use chartAxis* modifiers and ChartAxisConfig") +public typealias AxisLabelsData = ChartAxisConfig diff --git a/Sources/SwiftUICharts/Base/CardView/CardView.swift b/Sources/SwiftUICharts/Base/CardView/CardView.swift new file mode 100644 index 00000000..2f9f11a3 --- /dev/null +++ b/Sources/SwiftUICharts/Base/CardView/CardView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// View containing data and chart content. +public struct CardView: View { + @Environment(\.colorScheme) private var colorScheme + + let content: () -> Content + + private var showShadow: Bool + + public init(showShadow: Bool = true, @ViewBuilder content: @escaping () -> Content) { + self.showShadow = showShadow + self.content = content + } + + public var body: some View { + ZStack { + if showShadow { + RoundedRectangle(cornerRadius: 20) + .fill(cardBackgroundColor) + .shadow(color: shadowColor, radius: 8, x: 0, y: 2) + } + VStack(alignment: .leading) { + content() + } + .clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0)) + } + } + + private var cardBackgroundColor: Color { + colorScheme == .dark ? Color.white.opacity(0.08) : Color.white + } + + private var shadowColor: Color { + colorScheme == .dark ? Color.black.opacity(0.45) : Color.black.opacity(0.12) + } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartBase.swift b/Sources/SwiftUICharts/Base/Chart/ChartBase.swift new file mode 100644 index 00000000..39d2ebca --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartBase.swift @@ -0,0 +1,4 @@ +import SwiftUI + +@available(*, deprecated, message: "Use View-based chart modifiers (chartData, chartXRange, chartYRange) with chart views.") +public protocol ChartBase: View {} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartData.swift b/Sources/SwiftUICharts/Base/Chart/ChartData.swift new file mode 100644 index 00000000..194400bb --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartData.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Value-backed data model for chart rendering. +public struct ChartData { + public var data: [(Double, Double)] + public var rangeY: ClosedRange? + public var rangeX: ClosedRange? + public var xDomainMode: ChartXDomainMode + + var points: [Double] { + data.filter { rangeX?.contains($0.0) ?? true }.map { $0.1 } + } + + var values: [Double] { + data.filter { rangeX?.contains($0.0) ?? true }.map { $0.0 } + } + + var normalisedPoints: [Double] { + let absolutePoints = points.map { abs($0) } + var maxPoint = absolutePoints.max() + if let rangeY = rangeY { + maxPoint = Double(rangeY.overreach) + return points.map { ($0 - rangeY.lowerBound) / (maxPoint ?? 1.0) } + } + + return points.map { $0 / (maxPoint ?? 1.0) } + } + + var normalisedValues: [Double] { + let xScale = ChartXScale(values: values, + rangeX: rangeX, + mode: xDomainMode, + slotCountHint: values.count) + return values.map { xScale.normalizedX(for: $0) } + } + + var normalisedData: [(Double, Double)] { + Array(zip(normalisedValues, normalisedPoints)) + } + + var normalisedYRange: Double { + rangeY == nil ? (normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0) : 1 + } + + var normalisedXRange: Double { + rangeX == nil ? (normalisedValues.max() ?? 0.0) - (normalisedValues.min() ?? 0.0) : 1 + } + + var isInNegativeDomain: Bool { + if let rangeY = rangeY { + return rangeY.lowerBound < 0 + } + + return (points.min() ?? 0.0) < 0 + } + + public init(_ data: [Double], + rangeY: ClosedRange? = nil, + rangeX: ClosedRange? = nil, + xDomainMode: ChartXDomainMode = .categorical) { + self.data = data.enumerated().map { (index, value) in (Double(index), value) } + self.rangeY = rangeY + self.rangeX = rangeX + self.xDomainMode = xDomainMode + } + + public init(_ data: [(Double, Double)], + rangeY: ClosedRange? = nil, + rangeX: ClosedRange? = nil, + xDomainMode: ChartXDomainMode = .numeric) { + self.data = data + self.rangeY = rangeY + self.rangeX = rangeX + self.xDomainMode = xDomainMode + } + + public init(xDomainMode: ChartXDomainMode = .numeric) { + self.data = [] + self.rangeY = nil + self.rangeX = nil + self.xDomainMode = xDomainMode + } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartDownsampler.swift b/Sources/SwiftUICharts/Base/Chart/ChartDownsampler.swift new file mode 100644 index 00000000..ce7443af --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartDownsampler.swift @@ -0,0 +1,20 @@ +import Foundation + +enum ChartDownsampler { + static func reduced(_ data: [(Double, Double)], maxPoints: Int) -> [(Double, Double)] { + let limit = max(2, maxPoints) + guard data.count > limit else { return data } + + let stride = Double(data.count - 1) / Double(limit - 1) + var reduced: [(Double, Double)] = [] + reduced.reserveCapacity(limit) + + for index in 0.. Void diff --git a/Sources/SwiftUICharts/Base/Chart/ChartStreamingDataSource.swift b/Sources/SwiftUICharts/Base/Chart/ChartStreamingDataSource.swift new file mode 100644 index 00000000..27214150 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartStreamingDataSource.swift @@ -0,0 +1,64 @@ +import Foundation + +public final class ChartStreamingDataSource: ObservableObject { + @Published public private(set) var values: [Double] + + public var windowSize: Int + public var autoScroll: Bool + + private var startingIndex: Int + + public var latestValue: Double { + values.last ?? 0 + } + + public var xLabels: [String] { + guard !values.isEmpty else { return [] } + return (startingIndex..<(startingIndex + values.count)).map(String.init) + } + + public var suggestedYRange: ClosedRange { + let minValue = values.min() ?? 0 + let maxValue = values.max() ?? 1 + let span = max(1, maxValue - minValue) + let padding = max(1, span * 0.15) + return (minValue - padding)...(maxValue + padding) + } + + public init(initialValues: [Double] = [], + windowSize: Int = 20, + autoScroll: Bool = true, + startingIndex: Int = 1) { + self.values = initialValues + self.windowSize = max(1, windowSize) + self.autoScroll = autoScroll + self.startingIndex = max(0, startingIndex) + normalizeWindow() + } + + public func append(_ value: Double) { + values.append(value) + normalizeWindow() + } + + public func append(contentsOf newValues: [Double]) { + values.append(contentsOf: newValues) + normalizeWindow() + } + + public func reset(_ newValues: [Double], startingIndex: Int = 1) { + values = newValues + self.startingIndex = max(0, startingIndex) + normalizeWindow() + } + + private func normalizeWindow() { + guard autoScroll else { return } + + let overflow = max(0, values.count - windowSize) + if overflow > 0 { + values.removeFirst(overflow) + startingIndex += overflow + } + } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartValue.swift b/Sources/SwiftUICharts/Base/Chart/ChartValue.swift new file mode 100644 index 00000000..9b223aa8 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartValue.swift @@ -0,0 +1,11 @@ +import SwiftUI + +/// Representation of a single data point in a chart that is being observed. +public final class ChartValue: ObservableObject { + @Published public var currentValue: Double = 0 + @Published public var interactionInProgress: Bool = false + + public init() { + // no-op + } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartXScale.swift b/Sources/SwiftUICharts/Base/Chart/ChartXScale.swift new file mode 100644 index 00000000..70282ce3 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartXScale.swift @@ -0,0 +1,64 @@ +import Foundation + +struct ChartXScale { + private let values: [Double] + private let rangeX: ClosedRange? + private let mode: ChartXDomainMode + private let slotCountHint: Int + + init(values: [Double], + rangeX: ClosedRange?, + mode: ChartXDomainMode, + slotCountHint: Int = 0) { + self.values = values + self.rangeX = rangeX + self.mode = mode + self.slotCountHint = slotCountHint + } + + func normalizedX(for value: Double) -> Double { + let normalized: Double + switch mode { + case .numeric: + normalized = normalizeNumeric(value) + case .categorical: + normalized = normalizeCategorical(value) + } + return min(1.0, max(0.0, normalized)) + } + + private func normalizeNumeric(_ value: Double) -> Double { + if let range = rangeX { + let overreach = range.overreach + guard overreach.isFinite, overreach > 0 else { return 0.5 } + return (value - range.lowerBound) / overreach + } + + guard let minValue = values.min(), + let maxValue = values.max() else { return 0.5 } + let span = maxValue - minValue + guard span.isFinite, span > 0 else { return 0.5 } + return (value - minValue) / span + } + + private func normalizeCategorical(_ value: Double) -> Double { + let lowerBound: Double + if let range = rangeX { + lowerBound = range.lowerBound + } else { + lowerBound = values.min() ?? 0 + } + + let slotCount: Int + if let range = rangeX { + let derived = Int(max(1, floor(range.overreach) + 1)) + slotCount = max(1, derived) + } else if slotCountHint > 0 { + slotCount = slotCountHint + } else { + slotCount = max(1, values.count) + } + + return (value - lowerBound + 0.5) / Double(slotCount) + } +} diff --git a/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift b/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift new file mode 100644 index 00000000..ea8357f1 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public struct ViewGeometry: View where T: PreferenceKey { + public var body: some View { + GeometryReader { geometry in + Color.clear + .preference(key: T.self, value: [ViewSizeData(size: geometry.size)] as! T.Value) + } + } +} diff --git a/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift b/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift new file mode 100644 index 00000000..b6671040 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift @@ -0,0 +1,13 @@ +import SwiftUI + +public protocol ViewPreferenceKey: PreferenceKey where Value == [ViewSizeData] {} + +public extension ViewPreferenceKey { + static var defaultValue: [ViewSizeData] { + [] + } + + static func reduce(value: inout [ViewSizeData], nextValue: () -> [ViewSizeData]) { + value.append(contentsOf: nextValue()) + } +} diff --git a/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift b/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift new file mode 100644 index 00000000..9a53cec3 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public struct ViewSizeData: Identifiable, Equatable, Hashable { + public let id: UUID = UUID() + public let size: CGSize + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Sources/SwiftUICharts/Base/Config/ChartAxisConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartAxisConfig.swift new file mode 100644 index 00000000..e3e9f20d --- /dev/null +++ b/Sources/SwiftUICharts/Base/Config/ChartAxisConfig.swift @@ -0,0 +1,57 @@ +import SwiftUI + +public enum ChartAxisTickFormat { + case number + case shortDate +} + +public struct ChartXAxisLabel: Equatable { + public let value: Double + public let title: String + + public init(value: Double, title: String) { + self.value = value + self.title = title + } +} + +public struct ChartAxisConfig { + public var axisYLabels: [String] + public var axisXLabels: [ChartXAxisLabel] + public var axisXRange: ClosedRange? + public var axisXDomainMode: ChartXDomainMode + public var axisXAutoTickCount: Int? + public var axisYAutoTickCount: Int? + public var axisXTickFormat: ChartAxisTickFormat + public var axisYTickFormat: ChartAxisTickFormat + public var axisXLabelRotation: Angle + public var axisFont: Font + public var axisFontColor: Color + public var axisLabelsYPosition: AxisLabelsYPosition + + public init(axisYLabels: [String] = [], + axisXLabels: [ChartXAxisLabel] = [], + axisXRange: ClosedRange? = nil, + axisXDomainMode: ChartXDomainMode = .categorical, + axisXAutoTickCount: Int? = nil, + axisYAutoTickCount: Int? = nil, + axisXTickFormat: ChartAxisTickFormat = .number, + axisYTickFormat: ChartAxisTickFormat = .number, + axisXLabelRotation: Angle = .degrees(0), + axisFont: Font = .callout, + axisFontColor: Color = .primary, + axisLabelsYPosition: AxisLabelsYPosition = .leading) { + self.axisYLabels = axisYLabels + self.axisXLabels = axisXLabels + self.axisXRange = axisXRange + self.axisXDomainMode = axisXDomainMode + self.axisXAutoTickCount = axisXAutoTickCount + self.axisYAutoTickCount = axisYAutoTickCount + self.axisXTickFormat = axisXTickFormat + self.axisYTickFormat = axisYTickFormat + self.axisXLabelRotation = axisXLabelRotation + self.axisFont = axisFont + self.axisFontColor = axisFontColor + self.axisLabelsYPosition = axisLabelsYPosition + } +} diff --git a/Sources/SwiftUICharts/Base/Config/ChartGridConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartGridConfig.swift new file mode 100644 index 00000000..e3a462ae --- /dev/null +++ b/Sources/SwiftUICharts/Base/Config/ChartGridConfig.swift @@ -0,0 +1,24 @@ +import SwiftUI + +public struct ChartGridConfig { + public var numberOfHorizontalLines: Int + public var numberOfVerticalLines: Int + public var strokeStyle: StrokeStyle + public var color: Color + public var showBaseLine: Bool + public var baseStrokeStyle: StrokeStyle + + public init(numberOfHorizontalLines: Int = 3, + numberOfVerticalLines: Int = 3, + strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, 10]), + color: Color = Color.secondary.opacity(0.35), + showBaseLine: Bool = true, + baseStrokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5, 0])) { + self.numberOfHorizontalLines = numberOfHorizontalLines + self.numberOfVerticalLines = numberOfVerticalLines + self.strokeStyle = strokeStyle + self.color = color + self.showBaseLine = showBaseLine + self.baseStrokeStyle = baseStrokeStyle + } +} diff --git a/Sources/SwiftUICharts/Base/Config/ChartLineConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartLineConfig.swift new file mode 100644 index 00000000..ae2bfc28 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Config/ChartLineConfig.swift @@ -0,0 +1,24 @@ +import SwiftUI + +public struct ChartLineConfig { + public var lineWidth: CGFloat + public var backgroundGradient: ColorGradient? + public var showChartMarks: Bool + public var customChartMarksColors: ColorGradient? + public var lineStyle: LineStyle + public var animationEnabled: Bool + + public init(lineWidth: CGFloat = 2.0, + backgroundGradient: ColorGradient? = nil, + showChartMarks: Bool = true, + customChartMarksColors: ColorGradient? = nil, + lineStyle: LineStyle = .curved, + animationEnabled: Bool = true) { + self.lineWidth = lineWidth + self.backgroundGradient = backgroundGradient + self.showChartMarks = showChartMarks + self.customChartMarksColors = customChartMarksColors + self.lineStyle = lineStyle + self.animationEnabled = animationEnabled + } +} diff --git a/Sources/SwiftUICharts/Base/Config/ChartPerformanceConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartPerformanceConfig.swift new file mode 100644 index 00000000..83d03f83 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Config/ChartPerformanceConfig.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum ChartPerformanceMode { + case none + case automatic(threshold: Int, maxPoints: Int, simplifyLineStyle: Bool) + case downsample(maxPoints: Int, simplifyLineStyle: Bool) +} + +public struct ChartPerformanceConfig { + public var mode: ChartPerformanceMode + + public init(mode: ChartPerformanceMode = .none) { + self.mode = mode + } +} diff --git a/Sources/SwiftUICharts/Base/Config/ChartSeriesConfig.swift b/Sources/SwiftUICharts/Base/Config/ChartSeriesConfig.swift new file mode 100644 index 00000000..8705320c --- /dev/null +++ b/Sources/SwiftUICharts/Base/Config/ChartSeriesConfig.swift @@ -0,0 +1,12 @@ +import SwiftUI + +public struct ChartSeriesConfig { + public var seriesID: String? + public var hiddenSeriesIDs: Set + + public init(seriesID: String? = nil, + hiddenSeriesIDs: Set = []) { + self.seriesID = seriesID + self.hiddenSeriesIDs = hiddenSeriesIDs + } +} diff --git a/Sources/SwiftUICharts/Base/Environment/ChartEnvironmentKeys.swift b/Sources/SwiftUICharts/Base/Environment/ChartEnvironmentKeys.swift new file mode 100644 index 00000000..2199ba6e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Environment/ChartEnvironmentKeys.swift @@ -0,0 +1,116 @@ +import SwiftUI + +public enum ChartXDomainMode { + case categorical + case numeric +} + +private struct ChartDataPointsKey: EnvironmentKey { + static let defaultValue: [(Double, Double)] = [] +} + +private struct ChartXDomainModeKey: EnvironmentKey { + static let defaultValue: ChartXDomainMode = .numeric +} + +private struct ChartXRangeKey: EnvironmentKey { + static let defaultValue: ClosedRange? = nil +} + +private struct ChartYRangeKey: EnvironmentKey { + static let defaultValue: ClosedRange? = nil +} + +private struct ChartStyleKey: EnvironmentKey { + static let defaultValue = ChartStyle(backgroundColor: Color.primary.opacity(0.04), foregroundColor: .orangeBright) +} + +private struct ChartInteractionValueKey: EnvironmentKey { + static let defaultValue: ChartValue? = nil +} + +private struct ChartSelectionHandlerKey: EnvironmentKey { + static let defaultValue: ChartSelectionHandler? = nil +} + +private struct ChartGridConfigKey: EnvironmentKey { + static let defaultValue = ChartGridConfig() +} + +private struct ChartAxisConfigKey: EnvironmentKey { + static let defaultValue = ChartAxisConfig() +} + +private struct ChartLineConfigKey: EnvironmentKey { + static let defaultValue = ChartLineConfig() +} + +private struct ChartSeriesConfigKey: EnvironmentKey { + static let defaultValue = ChartSeriesConfig() +} + +private struct ChartPerformanceConfigKey: EnvironmentKey { + static let defaultValue = ChartPerformanceConfig() +} + +public extension EnvironmentValues { + var chartDataPoints: [(Double, Double)] { + get { self[ChartDataPointsKey.self] } + set { self[ChartDataPointsKey.self] = newValue } + } + + var chartXDomainMode: ChartXDomainMode { + get { self[ChartXDomainModeKey.self] } + set { self[ChartXDomainModeKey.self] = newValue } + } + + var chartXRange: ClosedRange? { + get { self[ChartXRangeKey.self] } + set { self[ChartXRangeKey.self] = newValue } + } + + var chartYRange: ClosedRange? { + get { self[ChartYRangeKey.self] } + set { self[ChartYRangeKey.self] = newValue } + } + + var chartStyle: ChartStyle { + get { self[ChartStyleKey.self] } + set { self[ChartStyleKey.self] = newValue } + } + + var chartInteractionValue: ChartValue? { + get { self[ChartInteractionValueKey.self] } + set { self[ChartInteractionValueKey.self] = newValue } + } + + var chartSelectionHandler: ChartSelectionHandler? { + get { self[ChartSelectionHandlerKey.self] } + set { self[ChartSelectionHandlerKey.self] = newValue } + } + + var chartGridConfig: ChartGridConfig { + get { self[ChartGridConfigKey.self] } + set { self[ChartGridConfigKey.self] = newValue } + } + + var chartAxisConfig: ChartAxisConfig { + get { self[ChartAxisConfigKey.self] } + set { self[ChartAxisConfigKey.self] = newValue } + } + + var chartLineConfig: ChartLineConfig { + get { self[ChartLineConfigKey.self] } + set { self[ChartLineConfigKey.self] = newValue } + } + + var chartSeriesConfig: ChartSeriesConfig { + get { self[ChartSeriesConfigKey.self] } + set { self[ChartSeriesConfigKey.self] = newValue } + } + + var chartPerformanceConfig: ChartPerformanceConfig { + get { self[ChartPerformanceConfigKey.self] } + set { self[ChartPerformanceConfigKey.self] = newValue } + } +} diff --git a/Sources/SwiftUICharts/Base/Environment/ChartPreferenceKeys.swift b/Sources/SwiftUICharts/Base/Environment/ChartPreferenceKeys.swift new file mode 100644 index 00000000..c968fab7 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Environment/ChartPreferenceKeys.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ChartDataPointsSnapshot: Equatable { + let points: [(Double, Double)] + + static func == (lhs: ChartDataPointsSnapshot, rhs: ChartDataPointsSnapshot) -> Bool { + guard lhs.points.count == rhs.points.count else { return false } + return zip(lhs.points, rhs.points).allSatisfy { lhsPoint, rhsPoint in + lhsPoint.0 == rhsPoint.0 && lhsPoint.1 == rhsPoint.1 + } + } +} + +struct ChartDataPointsPreferenceKey: PreferenceKey { + static var defaultValue: ChartDataPointsSnapshot = ChartDataPointsSnapshot(points: []) + + static func reduce(value: inout ChartDataPointsSnapshot, nextValue: () -> ChartDataPointsSnapshot) { + let next = nextValue() + if next.points.count >= value.points.count { + value = next + } + } +} + +struct ChartXRangePreferenceKey: PreferenceKey { + static var defaultValue: ClosedRange? = nil + + static func reduce(value: inout ClosedRange?, nextValue: () -> ClosedRange?) { + if let next = nextValue() { + value = next + } + } +} + +struct ChartYRangePreferenceKey: PreferenceKey { + static var defaultValue: ClosedRange? = nil + + static func reduce(value: inout ClosedRange?, nextValue: () -> ClosedRange?) { + if let next = nextValue() { + value = next + } + } +} + +struct ChartXDomainModePreferenceKey: PreferenceKey { + static var defaultValue: ChartXDomainMode = .numeric + + static func reduce(value: inout ChartXDomainMode, nextValue: () -> ChartXDomainMode) { + let next = nextValue() + if value == .categorical || next == .categorical { + value = .categorical + } else { + value = .numeric + } + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift new file mode 100644 index 00000000..1e4bedd7 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift @@ -0,0 +1,26 @@ +import Foundation + +extension Array where Element == ColorGradient { + + /// <#Description#> + /// - Parameter index: offset in data table + /// - Returns: <#description#> + func rotate(for index: Int) -> ColorGradient { + if self.isEmpty { + return ColorGradient.orangeBright + } + + if self.count <= index { + return self[index % self.count] + } + + return self[index] + } +} + +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift new file mode 100644 index 00000000..afaa7512 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift @@ -0,0 +1,38 @@ +import SwiftUI + +extension CGPoint { + + /// Calculate X and Y delta for each data point, based on data min/max and enclosing frame. + /// - Parameters: + /// - frame: Rectangle of enclosing frame + /// - data: array of `Double` + /// - Returns: X and Y delta as a `CGPoint` + static func getStep(frame: CGRect, data: [Double]) -> CGPoint { + guard data.count > 1 else { + return .zero + } + + guard let minPoint = data.min(), let maxPoint = data.max(), minPoint != maxPoint else { + return .zero + } + + let padding: CGFloat = 0 + let stepWidth = frame.size.width / CGFloat(data.count - 1) + let stepHeight: CGFloat + + if minPoint <= 0 { + stepHeight = (frame.size.height - padding) / CGFloat(maxPoint - minPoint) + } else { + stepHeight = (frame.size.height - padding) / CGFloat(maxPoint + minPoint) + } + + return CGPoint(x: stepWidth, y: stepHeight) + } + + func denormalize(with geometry: GeometryProxy) -> CGPoint { + let frame = geometry.frame(in: .local).sanitized + let width = frame.width + let height = frame.height + return CGPoint(x: self.x * width, y: self.y * height) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift new file mode 100644 index 00000000..d45f5742 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftUI + +extension CGRect { + + /// Midpoint of rectangle + /// - Returns: the coordinate for a rectangle center + public var mid: CGPoint { + return CGPoint(x: self.midX, y: self.midY) + } + + /// Returns a rectangle with finite origin and non-negative finite size. + public var sanitized: CGRect { + CGRect(x: origin.x.isFinite ? origin.x : 0, + y: origin.y.isFinite ? origin.y : 0, + width: max(0, size.width.isFinite ? size.width : 0), + height: max(0, size.height.isFinite ? size.height : 0)) + } +} + +extension CGSize { + /// Returns a size with non-negative finite width and height. + public var sanitized: CGSize { + CGSize(width: max(0, width.isFinite ? width : 0), + height: max(0, height.isFinite ? height : 0)) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift new file mode 100644 index 00000000..6f3bd22f --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift @@ -0,0 +1,25 @@ +import SwiftUI + +extension Color { + /// Create a `Color` from a hexadecimal representation + /// - Parameter hexString: 3, 6, or 8-character string, with optional (ignored) punctuation such as "#" + init(hexString: String) { + let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int = UInt64() + Scanner(string: hex).scanHexInt64(&int) + let red, green, blue: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (red, green, blue) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (red, green, blue) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + // FIXME: I think we need an an alpha value on this one. See link below. + // https://stackoverflow.com/a/56874327/4475605 + (red, green, blue) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (red, green, blue) = (0, 0, 0) + } + self.init(red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift new file mode 100644 index 00000000..5120626f --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift @@ -0,0 +1,442 @@ +import SwiftUI + +extension Path { + private static func sanitizedRect(_ rect: CGRect) -> CGRect { + rect.sanitized + } + + func trimmedPath(for percent: CGFloat) -> Path { + let boundsDistance: CGFloat = 0.001 + let completion: CGFloat = 1 - boundsDistance + + let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent) + + // Start/end points centered around given percentage, but capped if right at the very end + let start = pct > completion ? completion : pct - boundsDistance + let end = pct > completion ? 1 : pct + boundsDistance + return trimmedPath(from: start, to: end) + } + + func point(for percent: CGFloat) -> CGPoint { + let path = trimmedPath(for: percent) + return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY) + } + + func point(to maxX: CGFloat) -> CGPoint { + let total = length + let sub = length(to: maxX) + let percent = sub / total + return point(for: percent) + } + + var length: CGFloat { + var ret: CGFloat = 0.0 + var start: CGPoint? + var point = CGPoint.zero + + forEach { ele in + switch ele { + case .move(let to): + if start == nil { + start = to + } + point = to + case .line(let to): + ret += point.line(to: to) + point = to + case .quadCurve(let to, let control): + ret += point.quadCurve(to: to, control: control) + point = to + case .curve(let to, let control1, let control2): + ret += point.curve(to: to, control1: control1, control2: control2) + point = to + case .closeSubpath: + if let to = start { + ret += point.line(to: to) + point = to + } + start = nil + } + } + return ret + } + + func length(to maxX: CGFloat) -> CGFloat { + var ret: CGFloat = 0.0 + var start: CGPoint? + var point = CGPoint.zero + var finished = false + + forEach { ele in + if finished { + return + } + switch ele { + case .move(let to): + if to.x > maxX { + finished = true + return + } + if start == nil { + start = to + } + point = to + case .line(let to): + if to.x > maxX { + finished = true + ret += point.line(to: to, x: maxX) + return + } + ret += point.line(to: to) + point = to + case .quadCurve(let to, let control): + if to.x > maxX { + finished = true + ret += point.quadCurve(to: to, control: control, x: maxX) + return + } + ret += point.quadCurve(to: to, control: control) + point = to + case .curve(let to, let control1, let control2): + if to.x > maxX { + finished = true + ret += point.curve(to: to, control1: control1, control2: control2, x: maxX) + return + } + ret += point.curve(to: to, control1: control1, control2: control2) + point = to + case .closeSubpath: + fatalError("Can't include closeSubpath") + } + } + return ret + } + + static func quadCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path { + var path = Path() + if points.count < 2 { + return path + } + let offset = globalOffset ?? points.min()! + // guard let offset = points.min() else { return path } + var point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) + path.move(to: point1) + for pointIndex in 1.. Path { + var path = Path() + if data.count < 2 { + return path + } + let rect = sanitizedRect(rect) + + let convertedXValues = data.map { CGFloat($0.0) * rect.width } + let convertedYPoints = data.map { CGFloat($0.1) * rect.height } + + var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0]) + path.move(to: point1) + for pointIndex in 1.. Path { + var path = Path() + let filteredData = data.filter { $0.1 <= 1 && $0.1 >= 0 } + + if filteredData.count < 1 { + return path + } + let rect = sanitizedRect(rect) + + let convertedXValues = filteredData.map { CGFloat($0.0) * rect.width } + let convertedYPoints = filteredData.map { CGFloat($0.1) * rect.height } + + let markerSize = CGSize(width: 8, height: 8) + for pointIndex in 0.. Path { + var path = Path() + let rect = sanitizedRect(rect) + + if numberOfHorizontalLines > 1 { + for index in 0.. 1 { + for index in 0.. Path { + var path = Path() + if data.count < 2 { + return path + } + let rect = sanitizedRect(rect) + + let convertedXValues = data.map { CGFloat($0.0) * rect.width } + let convertedYPoints = data.map { CGFloat($0.1) * rect.height } + + path.move(to: CGPoint(x: convertedXValues[0], y: 0)) + var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0]) + path.addLine(to: point1) + for pointIndex in 1.. Path { + var path = Path() + if data.count < 2 { + return path + } + let rect = sanitizedRect(rect) + + let convertedXValues = data.map { CGFloat($0.0) * rect.width } + let convertedYPoints = data.map { CGFloat($0.1) * rect.height } + + let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0]) + path.move(to: point1) + for pointIndex in 1.. Path { + var path = Path() + if data.count < 2 { + return path + } + let rect = sanitizedRect(rect) + + let convertedXValues = data.map { CGFloat($0.0) * rect.width } + let convertedYPoints = data.map { CGFloat($0.1) * rect.height } + path.move(to: .zero) + + let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0]) + path.addLine(to: point1) + + for pointIndex in 1.. CGPoint { + let a = (to.y - self.y) / (to.x - self.x) + let y = self.y + (x - self.x) * a + return CGPoint(x: x, y: y) + } + + func line(to: CGPoint) -> CGFloat { + dist(to: to) + } + + func line(to: CGPoint, x: CGFloat) -> CGFloat { + dist(to: point(to: to, x: x)) + } + + func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0.. CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0..= x { + return dist + } else if b.x > x { + dist += a.line(to: b, x: x) + return dist + } else if b.x == x { + dist += a.line(to: b) + return dist + } + + dist += a.line(to: b) + } + return dist + } + + func point(to: CGPoint, t: CGFloat, control: CGPoint) -> CGPoint { + let x = CGPoint.value(x: self.x, y: to.x, t: t, c: control.x) + let y = CGPoint.value(x: self.y, y: to.y, t: t, c: control.y) + + return CGPoint(x: x, y: y) + } + + func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0.. CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0..= x { + return dist + } else if b.x > x { + dist += a.line(to: b, x: x) + return dist + } else if b.x == x { + dist += a.line(to: b) + return dist + } + + dist += a.line(to: b) + } + + return dist + } + + func point(to: CGPoint, t: CGFloat, control1: CGPoint, control2: CGPoint) -> CGPoint { + let x = CGPoint.value(x: self.x, y: to.x, t: t, control1: control1.x, control2: control2.x) + let y = CGPoint.value(x: self.y, y: to.y, t: t, control1: control1.y, control2: control2.x) + + return CGPoint(x: x, y: y) + } + + static func value(x: CGFloat, y: CGFloat, t: CGFloat, c: CGFloat) -> CGFloat { + var value: CGFloat = 0.0 + // (1-t)^2 * p0 + 2 * (1-t) * t * c1 + t^2 * p1 + value += pow(1-t, 2) * x + value += 2 * (1-t) * t * c + value += pow(t, 2) * y + return value + } + + static func value(x: CGFloat, y: CGFloat, t: CGFloat, control1: CGFloat, control2: CGFloat) -> CGFloat { + var value: CGFloat = 0.0 + // (1-t)^3 * p0 + 3 * (1-t)^2 * t * c1 + 3 * (1-t) * t^2 * c2 + t^3 * p1 + value += pow(1-t, 3) * x + value += 3 * pow(1-t, 2) * t * control1 + value += 3 * (1-t) * pow(t, 2) * control2 + value += pow(t, 3) * y + return value + } + + static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint { + return CGPoint( + x: point1.x + (point2.x - point1.x) / 2, + y: point1.y + (point2.y - point1.y) / 2 + ) + } + + func dist(to: CGPoint) -> CGFloat { + return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2))) + } + + static func midPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint { + return CGPoint( + x: (firstPoint.x + secondPoint.x) / 2, + y: (firstPoint.y + secondPoint.y) / 2) + } + + static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint { + var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint) + let diffY = abs(secondPoint.y - controlPoint.y) + + if firstPoint.y < secondPoint.y { + controlPoint.y += diffY + } else if firstPoint.y > secondPoint.y { + controlPoint.y -= diffY + } + return controlPoint + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift new file mode 100644 index 00000000..547a0435 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension ClosedRange where Bound: AdditiveArithmetic { + var overreach: Bound { + self.upperBound - self.lowerBound + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift new file mode 100644 index 00000000..5624e0db --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift @@ -0,0 +1,17 @@ +import SwiftUI + +extension Shape { + func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View { + self + .stroke(strokeStyle, lineWidth: lineWidth) + .background(self.fill(fillStyle)) + } +} + +extension InsettableShape { + func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View { + self + .strokeBorder(strokeStyle, lineWidth: lineWidth) + .background(self.fill(fillStyle)) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift new file mode 100644 index 00000000..7cb2e0fa --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift @@ -0,0 +1,9 @@ +import SwiftUI + +extension View { + func toStandardCoordinateSystem() -> some View { + self + .rotationEffect(.degrees(180), anchor: .center) + .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift b/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift new file mode 100644 index 00000000..e93ff937 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct ChartGrid: View { + let content: () -> Content + + @Environment(\.chartGridConfig) private var gridConfig + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + ZStack { + ChartGridShape(numberOfHorizontalLines: gridConfig.numberOfHorizontalLines, + numberOfVerticalLines: gridConfig.numberOfVerticalLines) + .stroke(gridConfig.color, style: gridConfig.strokeStyle) + if gridConfig.showBaseLine { + ChartGridBaseShape() + .stroke(gridConfig.color, style: gridConfig.baseStrokeStyle) + .rotationEffect(.degrees(180), anchor: .center) + } + content() + } + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift b/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift new file mode 100644 index 00000000..57ca9669 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct ChartGridBaseShape: Shape { + func path(in rect: CGRect) -> Path { + let rect = rect.sanitized + var path = Path() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + return path + } +} + +struct ChartGridBaseShape_Previews: PreviewProvider { + static var previews: some View { + ChartGridBaseShape() + .stroke() + .rotationEffect(.degrees(180), anchor: .center) + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift b/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift new file mode 100644 index 00000000..ccb6e18b --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct ChartGridShape: Shape { + var numberOfHorizontalLines: Int + var numberOfVerticalLines: Int + + func path(in rect: CGRect) -> Path { + let path = Path.drawGridLines(numberOfHorizontalLines: numberOfHorizontalLines, + numberOfVerticalLines: numberOfVerticalLines, + in: rect) + return path + } +} + +struct ChartGridShape_Previews: PreviewProvider { + static var previews: some View { + Group { + ChartGridShape(numberOfHorizontalLines: 5, numberOfVerticalLines: 0) + .stroke() + .toStandardCoordinateSystem() + + ChartGridShape(numberOfHorizontalLines: 4, numberOfVerticalLines: 4) + .stroke() + .toStandardCoordinateSystem() + } + .padding() + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift b/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift new file mode 100644 index 00000000..f1e68d56 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift @@ -0,0 +1,4 @@ +import SwiftUI + +@available(*, deprecated, renamed: "ChartGridConfig") +public typealias GridOptions = ChartGridConfig diff --git a/Sources/SwiftUICharts/Base/Label/ChartLabel.swift b/Sources/SwiftUICharts/Base/Label/ChartLabel.swift new file mode 100644 index 00000000..870e38fc --- /dev/null +++ b/Sources/SwiftUICharts/Base/Label/ChartLabel.swift @@ -0,0 +1,132 @@ +import SwiftUI + +/// What kind of label - this affects color, size, position of the label. +public enum ChartLabelType { + case title + case subTitle + case largeTitle + case custom(size: CGFloat, padding: EdgeInsets, color: Color) + case legend +} + +/// A chart may contain any number of labels in pre-set positions based on their `ChartLabelType`. +public struct ChartLabel: View { + @Environment(\.chartInteractionValue) private var chartValue + + private var title: String + private var format: String + private let labelType: ChartLabelType + + public init(_ title: String, + type: ChartLabelType = .title, + format: String = "%.01f") { + self.title = title + self.labelType = type + self.format = format + } + + public var body: some View { + if let chartValue = chartValue { + ChartLabelObservedValue(title: title, + format: format, + labelSize: labelSize, + labelPadding: labelPadding, + labelColor: labelColor, + chartValue: chartValue) + } else { + HStack { + Text(title) + .font(.system(size: labelSize)) + .bold() + .foregroundColor(labelColor) + .padding(labelPadding) + Spacer() + } + } + } + + private var labelSize: CGFloat { + switch labelType { + case .title: + return 32.0 + case .legend: + return 14.0 + case .subTitle: + return 24.0 + case .largeTitle: + return 38.0 + case .custom(let size, _, _): + return size + } + } + + private var labelPadding: EdgeInsets { + switch labelType { + case .title: + return EdgeInsets(top: 16.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .legend: + return EdgeInsets(top: 4.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .subTitle: + return EdgeInsets(top: 8.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .largeTitle: + return EdgeInsets(top: 24.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .custom(_, let padding, _): + return padding + } + } + + private var labelColor: Color { + switch labelType { + case .title: + return .primary + case .legend: + return .secondary + case .subTitle: + return .primary + case .largeTitle: + return .primary + case .custom(_, _, let color): + return color + } + } +} + +private struct ChartLabelObservedValue: View { + @ObservedObject var chartValue: ChartValue + + let title: String + let format: String + let labelSize: CGFloat + let labelPadding: EdgeInsets + let labelColor: Color + + init(title: String, + format: String, + labelSize: CGFloat, + labelPadding: EdgeInsets, + labelColor: Color, + chartValue: ChartValue) { + self.title = title + self.format = format + self.labelSize = labelSize + self.labelPadding = labelPadding + self.labelColor = labelColor + self.chartValue = chartValue + } + + var body: some View { + HStack { + Text(chartValue.interactionInProgress + ? String(format: format, chartValue.currentValue) + : title) + .font(.system(size: labelSize)) + .bold() + .foregroundColor(labelColor) + .padding(labelPadding) + + if !chartValue.interactionInProgress { + Spacer() + } + } + } +} diff --git a/Sources/SwiftUICharts/Base/Label/ChartLegend.swift b/Sources/SwiftUICharts/Base/Label/ChartLegend.swift new file mode 100644 index 00000000..5946402b --- /dev/null +++ b/Sources/SwiftUICharts/Base/Label/ChartLegend.swift @@ -0,0 +1,60 @@ +import SwiftUI + +public struct ChartLegendItem: Identifiable { + public let id: String + public let title: String + public let color: ColorGradient + + public init(id: String, title: String, color: ColorGradient) { + self.id = id + self.title = title + self.color = color + } +} + +public struct ChartLegend: View { + private let items: [ChartLegendItem] + @Binding private var hiddenSeries: Set + + public init(items: [ChartLegendItem], hiddenSeries: Binding>) { + self.items = items + self._hiddenSeries = hiddenSeries + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(items) { item in + Button(action: { toggle(item.id) }) { + HStack(spacing: 8) { + Circle() + .fill(item.color.linearGradient(from: .topLeading, to: .bottomTrailing)) + .frame(width: 10, height: 10) + .opacity(isHidden(item.id) ? 0.25 : 1) + + Text(item.title) + .font(.caption) + .foregroundColor(.primary) + .strikethrough(isHidden(item.id), color: .secondary) + + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibility(label: Text(isHidden(item.id) ? "Show \(item.title)" : "Hide \(item.title)")) + } + } + } + + private func isHidden(_ id: String) -> Bool { + hiddenSeries.contains(id) + } + + private func toggle(_ id: String) { + if hiddenSeries.contains(id) { + hiddenSeries.remove(id) + } else { + hiddenSeries.insert(id) + } + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartAxisModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartAxisModifiers.swift new file mode 100644 index 00000000..ed9dfc48 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartAxisModifiers.swift @@ -0,0 +1,189 @@ +import SwiftUI + +private struct ChartXAxisLabelsModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let labels: [ChartXAxisLabel] + let range: ClosedRange? + let mode: ChartXDomainMode + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisXLabels = labels + updated.axisXRange = range + updated.axisXDomainMode = mode + updated.axisXAutoTickCount = nil + return content.environment(\.chartAxisConfig, updated) + } +} + +private struct ChartYAxisLabelsModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let labels: [String] + let position: AxisLabelsYPosition + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisYLabels = labels + updated.axisLabelsYPosition = position + updated.axisYAutoTickCount = nil + return content.environment(\.chartAxisConfig, updated) + } +} + +private struct ChartXAxisAutoTicksModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let count: Int + let format: ChartAxisTickFormat + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisXAutoTickCount = max(2, count) + updated.axisXTickFormat = format + updated.axisXLabels = [] + return content.environment(\.chartAxisConfig, updated) + } +} + +private struct ChartYAxisAutoTicksModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let count: Int + let format: ChartAxisTickFormat + let position: AxisLabelsYPosition + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisYAutoTickCount = max(2, count) + updated.axisYTickFormat = format + updated.axisYLabels = [] + updated.axisLabelsYPosition = position + return content.environment(\.chartAxisConfig, updated) + } +} + +private struct ChartXAxisLabelRotationModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let angle: Angle + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisXLabelRotation = angle + return content.environment(\.chartAxisConfig, updated) + } +} + +private struct ChartAxisFontModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let font: Font + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisFont = font + return content.environment(\.chartAxisConfig, updated) + } +} + +private struct ChartAxisColorModifier: ViewModifier { + @Environment(\.chartAxisConfig) private var currentConfig + + let color: Color + + func body(content: Content) -> some View { + var updated = currentConfig + updated.axisFontColor = color + return content.environment(\.chartAxisConfig, updated) + } +} + +public extension View { + func chartXAxisLabels(_ labels: [String]) -> some View { + modifier(ChartXAxisLabelsModifier(labels: ChartAxisLabelMapper.mapXAxis(labels), + range: labels.isEmpty ? nil : 0...Double(labels.count - 1), + mode: .categorical)) + } + + func chartXAxisLabels(_ labels: [(Double, String)], range: ClosedRange) -> some View { + let mapped = ChartAxisLabelMapper.mapXAxis(labels, in: range) + return modifier(ChartXAxisLabelsModifier(labels: mapped, + range: Double(range.lowerBound)...Double(range.upperBound), + mode: .numeric)) + } + + func chartYAxisLabels(_ labels: [String], + position: AxisLabelsYPosition = .leading) -> some View { + modifier(ChartYAxisLabelsModifier(labels: labels, position: position)) + } + + func chartYAxisLabels(_ labels: [(Double, String)], + range: ClosedRange, + position: AxisLabelsYPosition = .leading) -> some View { + modifier(ChartYAxisLabelsModifier(labels: ChartAxisLabelMapper.mapYAxis(labels, in: range), position: position)) + } + + func chartAxisFont(_ font: Font) -> some View { + modifier(ChartAxisFontModifier(font: font)) + } + + func chartAxisColor(_ color: Color) -> some View { + modifier(ChartAxisColorModifier(color: color)) + } + + func chartXAxisAutoTicks(_ count: Int = 5, + format: ChartAxisTickFormat = .number) -> some View { + modifier(ChartXAxisAutoTicksModifier(count: count, format: format)) + } + + func chartYAxisAutoTicks(_ count: Int = 5, + format: ChartAxisTickFormat = .number, + position: AxisLabelsYPosition = .leading) -> some View { + modifier(ChartYAxisAutoTicksModifier(count: count, + format: format, + position: position)) + } + + func chartXAxisLabelRotation(_ angle: Angle) -> some View { + modifier(ChartXAxisLabelRotationModifier(angle: angle)) + } +} + +enum ChartAxisLabelMapper { + static func mapXAxis(_ labels: [String]) -> [ChartXAxisLabel] { + labels.enumerated().map { index, label in + ChartXAxisLabel(value: Double(index), title: label) + } + } + + static func mapXAxis(_ labels: [(Double, String)], in range: ClosedRange) -> [ChartXAxisLabel] { + var labelsByValue: [Double: String] = [:] + for (value, title) in labels { + labelsByValue[value] = title + } + + for value in range { + labelsByValue[Double(value)] = labelsByValue[Double(value)] ?? "" + } + + return labelsByValue + .sorted(by: { $0.key < $1.key }) + .map { ChartXAxisLabel(value: $0.key, title: $0.value) } + } + + static func mapYAxis(_ labels: [(Double, String)], in range: ClosedRange) -> [String] { + let count = max(0, range.overreach + 1) + guard count > 0 else { return [] } + + var labelArray = Array(repeating: "", count: count) + for (value, label) in labels { + let index = Int(value) - range.lowerBound + if index >= 0, index < labelArray.count { + labelArray[index] = label + } + } + return labelArray + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartDataModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartDataModifiers.swift new file mode 100644 index 00000000..e22958d6 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartDataModifiers.swift @@ -0,0 +1,57 @@ +import SwiftUI + +private struct ChartDataValuesModifier: ViewModifier { + let points: [(Double, Double)] + let xDomainMode: ChartXDomainMode + + func body(content: Content) -> some View { + content + .environment(\.chartDataPoints, points) + .environment(\.chartXDomainMode, xDomainMode) + .preference(key: ChartDataPointsPreferenceKey.self, value: ChartDataPointsSnapshot(points: points)) + .preference(key: ChartXDomainModePreferenceKey.self, value: xDomainMode) + } +} + +private struct ChartXRangeModifier: ViewModifier { + let range: ClosedRange? + + func body(content: Content) -> some View { + content + .environment(\.chartXRange, range) + .preference(key: ChartXRangePreferenceKey.self, value: range) + } +} + +private struct ChartYRangeModifier: ViewModifier { + let range: ClosedRange? + + func body(content: Content) -> some View { + content + .environment(\.chartYRange, range) + .preference(key: ChartYRangePreferenceKey.self, value: range) + } +} + +public extension View { + func chartData(_ stream: ChartStreamingDataSource) -> some View { + chartData(stream.values) + } + + func chartData(_ points: [Double]) -> some View { + let indexed = points.enumerated().map { (index, value) in (Double(index), value) } + return modifier(ChartDataValuesModifier(points: indexed, xDomainMode: .categorical)) + } + + func chartData(_ points: [(Double, Double)]) -> some View { + modifier(ChartDataValuesModifier(points: points, xDomainMode: .numeric)) + } + + func chartXRange(_ range: ClosedRange?) -> some View { + modifier(ChartXRangeModifier(range: range)) + } + + func chartYRange(_ range: ClosedRange?) -> some View { + modifier(ChartYRangeModifier(range: range)) + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartGridModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartGridModifiers.swift new file mode 100644 index 00000000..dc179321 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartGridModifiers.swift @@ -0,0 +1,59 @@ +import SwiftUI + +private struct ChartGridLinesModifier: ViewModifier { + @Environment(\.chartGridConfig) private var currentConfig + + let horizontal: Int + let vertical: Int + + func body(content: Content) -> some View { + var updated = currentConfig + updated.numberOfHorizontalLines = horizontal + updated.numberOfVerticalLines = vertical + return content.environment(\.chartGridConfig, updated) + } +} + +private struct ChartGridStrokeModifier: ViewModifier { + @Environment(\.chartGridConfig) private var currentConfig + + let style: StrokeStyle + let color: Color + + func body(content: Content) -> some View { + var updated = currentConfig + updated.strokeStyle = style + updated.color = color + return content.environment(\.chartGridConfig, updated) + } +} + +private struct ChartGridBaselineModifier: ViewModifier { + @Environment(\.chartGridConfig) private var currentConfig + + let visible: Bool + let style: StrokeStyle? + + func body(content: Content) -> some View { + var updated = currentConfig + updated.showBaseLine = visible + if let style = style { + updated.baseStrokeStyle = style + } + return content.environment(\.chartGridConfig, updated) + } +} + +public extension View { + func chartGridLines(horizontal: Int, vertical: Int) -> some View { + modifier(ChartGridLinesModifier(horizontal: horizontal, vertical: vertical)) + } + + func chartGridStroke(style: StrokeStyle, color: Color) -> some View { + modifier(ChartGridStrokeModifier(style: style, color: color)) + } + + func chartGridBaseline(_ visible: Bool, style: StrokeStyle? = nil) -> some View { + modifier(ChartGridBaselineModifier(visible: visible, style: style)) + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartInteractionModifier.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartInteractionModifier.swift new file mode 100644 index 00000000..8db88a1e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartInteractionModifier.swift @@ -0,0 +1,27 @@ +import SwiftUI + +private struct ChartInteractionModifier: ViewModifier { + let value: ChartValue? + + func body(content: Content) -> some View { + content.environment(\.chartInteractionValue, value) + } +} + +private struct ChartSelectionHandlerModifier: ViewModifier { + let handler: ChartSelectionHandler? + + func body(content: Content) -> some View { + content.environment(\.chartSelectionHandler, handler) + } +} + +public extension View { + func chartInteractionValue(_ value: ChartValue?) -> some View { + modifier(ChartInteractionModifier(value: value)) + } + + func chartSelectionHandler(_ handler: @escaping ChartSelectionHandler) -> some View { + modifier(ChartSelectionHandlerModifier(handler: handler)) + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartLineModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartLineModifiers.swift new file mode 100644 index 00000000..ffd0a3e6 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartLineModifiers.swift @@ -0,0 +1,85 @@ +import SwiftUI + +private struct ChartLineWidthModifier: ViewModifier { + @Environment(\.chartLineConfig) private var currentConfig + + let width: CGFloat + + func body(content: Content) -> some View { + var updated = currentConfig + updated.lineWidth = width + return content.environment(\.chartLineConfig, updated) + } +} + +private struct ChartLineBackgroundModifier: ViewModifier { + @Environment(\.chartLineConfig) private var currentConfig + + let gradient: ColorGradient? + + func body(content: Content) -> some View { + var updated = currentConfig + updated.backgroundGradient = gradient + return content.environment(\.chartLineConfig, updated) + } +} + +private struct ChartLineMarksModifier: ViewModifier { + @Environment(\.chartLineConfig) private var currentConfig + + let visible: Bool + let color: ColorGradient? + + func body(content: Content) -> some View { + var updated = currentConfig + updated.showChartMarks = visible + updated.customChartMarksColors = color + return content.environment(\.chartLineConfig, updated) + } +} + +private struct ChartLineStyleModifier: ViewModifier { + @Environment(\.chartLineConfig) private var currentConfig + + let style: LineStyle + + func body(content: Content) -> some View { + var updated = currentConfig + updated.lineStyle = style + return content.environment(\.chartLineConfig, updated) + } +} + +private struct ChartLineAnimationModifier: ViewModifier { + @Environment(\.chartLineConfig) private var currentConfig + + let enabled: Bool + + func body(content: Content) -> some View { + var updated = currentConfig + updated.animationEnabled = enabled + return content.environment(\.chartLineConfig, updated) + } +} + +public extension View { + func chartLineWidth(_ width: CGFloat) -> some View { + modifier(ChartLineWidthModifier(width: width)) + } + + func chartLineBackground(_ gradient: ColorGradient?) -> some View { + modifier(ChartLineBackgroundModifier(gradient: gradient)) + } + + func chartLineMarks(_ visible: Bool, color: ColorGradient? = nil) -> some View { + modifier(ChartLineMarksModifier(visible: visible, color: color)) + } + + func chartLineStyle(_ style: LineStyle) -> some View { + modifier(ChartLineStyleModifier(style: style)) + } + + func chartLineAnimation(_ enabled: Bool) -> some View { + modifier(ChartLineAnimationModifier(enabled: enabled)) + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartPerformanceModifier.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartPerformanceModifier.swift new file mode 100644 index 00000000..36eb929c --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartPerformanceModifier.swift @@ -0,0 +1,15 @@ +import SwiftUI + +private struct ChartPerformanceModifier: ViewModifier { + let mode: ChartPerformanceMode + + func body(content: Content) -> some View { + content.environment(\.chartPerformanceConfig, ChartPerformanceConfig(mode: mode)) + } +} + +public extension View { + func chartPerformance(_ mode: ChartPerformanceMode) -> some View { + modifier(ChartPerformanceModifier(mode: mode)) + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartSeriesModifiers.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartSeriesModifiers.swift new file mode 100644 index 00000000..c003d8d5 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartSeriesModifiers.swift @@ -0,0 +1,35 @@ +import SwiftUI + +private struct ChartSeriesIDModifier: ViewModifier { + @Environment(\.chartSeriesConfig) private var currentConfig + + let seriesID: String? + + func body(content: Content) -> some View { + var updated = currentConfig + updated.seriesID = seriesID + return content.environment(\.chartSeriesConfig, updated) + } +} + +private struct ChartHiddenSeriesModifier: ViewModifier { + @Environment(\.chartSeriesConfig) private var currentConfig + + let hiddenSeries: Set + + func body(content: Content) -> some View { + var updated = currentConfig + updated.hiddenSeriesIDs = hiddenSeries + return content.environment(\.chartSeriesConfig, updated) + } +} + +public extension View { + func chartSeriesID(_ id: String?) -> some View { + modifier(ChartSeriesIDModifier(seriesID: id)) + } + + func chartHiddenSeries(_ ids: Set) -> some View { + modifier(ChartHiddenSeriesModifier(hiddenSeries: ids)) + } +} diff --git a/Sources/SwiftUICharts/Base/Modifiers/ChartStyleModifier.swift b/Sources/SwiftUICharts/Base/Modifiers/ChartStyleModifier.swift new file mode 100644 index 00000000..d2d35bca --- /dev/null +++ b/Sources/SwiftUICharts/Base/Modifiers/ChartStyleModifier.swift @@ -0,0 +1,15 @@ +import SwiftUI + +private struct ChartStyleModifier: ViewModifier { + let style: ChartStyle + + func body(content: Content) -> some View { + content.environment(\.chartStyle, style) + } +} + +public extension View { + func chartStyle(_ style: ChartStyle) -> some View { + modifier(ChartStyleModifier(style: style)) + } +} diff --git a/Sources/SwiftUICharts/Base/Style/ChartStyle.swift b/Sources/SwiftUICharts/Base/Style/ChartStyle.swift new file mode 100644 index 00000000..451ad18e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Style/ChartStyle.swift @@ -0,0 +1,43 @@ +import SwiftUI + +public struct ChartStyle { + public let backgroundColor: ColorGradient + public let foregroundColor: [ColorGradient] + + public init(backgroundColor: Color, foregroundColor: [ColorGradient]) { + self.backgroundColor = ColorGradient(backgroundColor) + self.foregroundColor = foregroundColor + } + + public init(backgroundColor: Color, foregroundColor: ColorGradient) { + self.backgroundColor = ColorGradient(backgroundColor) + self.foregroundColor = [foregroundColor] + } + + public init(backgroundColor: ColorGradient, foregroundColor: ColorGradient) { + self.backgroundColor = backgroundColor + self.foregroundColor = [foregroundColor] + } + + public init(backgroundColor: ColorGradient, foregroundColor: [ColorGradient]) { + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor + } +} + +public extension ChartStyle { + static var highContrast: ChartStyle { + ChartStyle(backgroundColor: Color.primary.opacity(0.12), + foregroundColor: [ + ColorGradient(.yellow, .orange), + ColorGradient(.blue, .purple), + ColorGradient(.green, .yellow), + ColorGradient(.pink, .purple) + ]) + } + + static var highContrastMono: ChartStyle { + ChartStyle(backgroundColor: Color.primary.opacity(0.15), + foregroundColor: ColorGradient(.white, .primary)) + } +} diff --git a/Sources/SwiftUICharts/Base/Style/ColorGradient.swift b/Sources/SwiftUICharts/Base/Style/ColorGradient.swift new file mode 100644 index 00000000..6625428e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Style/ColorGradient.swift @@ -0,0 +1,33 @@ +import SwiftUI + +public struct ColorGradient: Equatable { + public let startColor: Color + public let endColor: Color + + public init(_ color: Color) { + self.startColor = color + self.endColor = color + } + + public init(_ startColor: Color, _ endColor: Color) { + self.startColor = startColor + self.endColor = endColor + } + + public var gradient: Gradient { + return Gradient(colors: [startColor, endColor]) + } +} + +extension ColorGradient { + public func linearGradient(from startPoint: UnitPoint, to endPoint: UnitPoint) -> LinearGradient { + return LinearGradient(gradient: self.gradient, startPoint: startPoint, endPoint: endPoint) + } +} + +extension ColorGradient { + public static let orangeBright = ColorGradient(ChartColors.orangeBright) + public static let redBlack = ColorGradient(.red, .black) + public static let greenRed = ColorGradient(.green, .red) + public static let whiteBlack = ColorGradient(.white, .black) +} diff --git a/Sources/SwiftUICharts/Base/Style/Colors.swift b/Sources/SwiftUICharts/Base/Style/Colors.swift new file mode 100644 index 00000000..a230e901 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Style/Colors.swift @@ -0,0 +1,9 @@ +import SwiftUI + +public enum ChartColors { + public static let orangeBright = Color(hexString: "#FF782C") + public static let orangeDark = Color(hexString: "#EC2301") + + public static let legendColor: Color = Color(hexString: "#E8E7EA") + public static let indicatorKnob: Color = Color(hexString: "#FF57A6") +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift new file mode 100644 index 00000000..d5cc07c0 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift @@ -0,0 +1,22 @@ +import SwiftUI + +public struct BarChart: View { + @Environment(\.chartDataPoints) private var points + @Environment(\.chartYRange) private var rangeY + @Environment(\.chartXRange) private var rangeX + @Environment(\.chartStyle) private var style + @Environment(\.chartSeriesConfig) private var seriesConfig + + public init() {} + + public var body: some View { + if isSeriesVisible { + BarChartRow(chartData: ChartData(points, rangeY: rangeY, rangeX: rangeX), style: style) + } + } + + private var isSeriesVisible: Bool { + guard let seriesID = seriesConfig.seriesID else { return true } + return !seriesConfig.hiddenSeriesIDs.contains(seriesID) + } +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift new file mode 100644 index 00000000..13153557 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift @@ -0,0 +1,52 @@ +import SwiftUI + +public struct BarChartCell: View { + var value: Double + var index: Int = 0 + var gradientColor: ColorGradient + var touchLocation: CGFloat + + @State private var didCellAppear: Bool = false + + public init( value: Double, + index: Int = 0, + gradientColor: ColorGradient, + touchLocation: CGFloat) { + self.value = value + self.index = index + self.gradientColor = gradientColor + self.touchLocation = touchLocation + } + + public var body: some View { + BarChartCellShape(value: didCellAppear ? value : 0.0) + .fill(gradientColor.linearGradient(from: .bottom, to: .top)) .onAppear { + self.didCellAppear = true + } + .onDisappear { + self.didCellAppear = false + } + .transition(.slide) + .animation(Animation.spring().delay(self.touchLocation < 0 || !didCellAppear ? Double(self.index) * 0.04 : 0)) + } +} + +struct BarChartCell_Previews: PreviewProvider { + static var previews: some View { + Group { + Group { + BarChartCell(value: 0, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat()) + + BarChartCell(value: 0.5, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat()) + BarChartCell(value: 0.75, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat()) + BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat()) + } + + Group { + BarChartCell(value: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat()) + BarChartCell(value: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat()) + BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat()) + }.environment(\.colorScheme, .dark) + } + } +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift new file mode 100644 index 00000000..268f4229 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct BarChartCellShape: Shape, Animatable { + var value: Double + var cornerRadius: CGFloat = 6.0 + + var animatableData: CGFloat { + get { CGFloat(value) } + set { value = Double(newValue) } + } + + func path(in rect: CGRect) -> Path { + let adjustedOriginY = rect.height - (rect.height * CGFloat(value)) + var path = Path() + path.move(to: CGPoint(x: 0.0 , y: rect.height)) + path.addLine(to: CGPoint(x: 0.0, y: adjustedOriginY + cornerRadius)) + path.addArc(center: CGPoint(x: cornerRadius, y: adjustedOriginY + cornerRadius), + radius: cornerRadius, + startAngle: Angle(radians: Double.pi), + endAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2), + clockwise: value < 0 ? true : false) + path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: value < 0 ? adjustedOriginY + 2 * cornerRadius : adjustedOriginY)) + path.addArc(center: CGPoint(x: rect.width - cornerRadius, y: adjustedOriginY + cornerRadius), + radius: cornerRadius, + startAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2), + endAngle: Angle(radians: 0), + clockwise: value < 0 ? true : false) + path.addLine(to: CGPoint(x: rect.width, y: rect.height)) + path.closeSubpath() + + return path + } +} + +struct BarChartCellShape_Previews: PreviewProvider { + static var previews: some View { + Group { + BarChartCellShape(value: 0.75) + .fill(Color.red) + + BarChartCellShape(value: 0.3) + .fill(Color.blue) + + BarChartCellShape(value: -0.3) + .fill(Color.blue) + .offset(x: 0, y: -600) + } + } +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift new file mode 100644 index 00000000..a68d9118 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift @@ -0,0 +1,101 @@ +import SwiftUI + +public struct BarChartRow: View { + @Environment(\.chartInteractionValue) private var chartValue + @Environment(\.chartSelectionHandler) private var selectionHandler + + var chartData: ChartData + @State private var touchLocation: CGFloat = -1.0 + + var style: ChartStyle + + public var body: some View { + GeometryReader { geometry in + let localFrame = geometry.frame(in: .local).sanitized + let safeWidth = localFrame.width + let safeHeight = localFrame.height + let barCount = max(1, chartData.data.count) + let slotWidth = safeWidth / CGFloat(barCount) + let barWidthRatio: CGFloat = 0.72 + + HStack(alignment: .bottom, spacing: 0) { + ForEach(0.. 0 else { return } + touchLocation = value.location.x / width + if let selected = getCurrentSelection(width: width) { + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: selected.value, + index: selected.index, + isActive: true) + } else { + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: nil, + index: nil, + isActive: false) + } + }) + .onEnded({ _ in + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: nil, + index: nil, + isActive: false) + touchLocation = -1 + }) + ) + } + } + + func getScaleSize(touchLocation: CGFloat, index: Int) -> CGSize { + if touchLocation > CGFloat(index) / CGFloat(max(1, chartData.data.count)) && + touchLocation < CGFloat(index + 1) / CGFloat(max(1, chartData.data.count)) { + return CGSize(width: 1.4, height: 1.1) + } + return CGSize(width: 1, height: 1) + } + + func getCurrentSelection(width: CGFloat) -> (index: Int, value: Double)? { + guard !chartData.data.isEmpty else { return nil } + guard width.isFinite, width > 0 else { return nil } + let denominator = width / CGFloat(chartData.data.count) + guard denominator > 0, denominator.isFinite else { return nil } + let index = max(0, min(chartData.data.count - 1, Int(floor((touchLocation * width) / denominator)))) + return (index, chartData.points[index]) + } + + private func formatted(_ value: Double) -> String { + if abs(value.rounded() - value) < 0.001 { + return String(Int(value.rounded())) + } + return String(format: "%.2f", value) + } +} + +struct BarChartRow_Previews: PreviewProvider { + static let chartData = ChartData([6, 2, 5, 8, 6]) + static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright) + + static var previews: some View { + BarChartRow(chartData: chartData, style: chartStyle) + } +} diff --git a/Sources/SwiftUICharts/LineChart/IndicatorPoint.swift b/Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift similarity index 52% rename from Sources/SwiftUICharts/LineChart/IndicatorPoint.swift rename to Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift index 2e8667da..69d8d88c 100644 --- a/Sources/SwiftUICharts/LineChart/IndicatorPoint.swift +++ b/Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift @@ -1,23 +1,15 @@ -// -// IndicatorPoint.swift -// LineChart -// -// Created by András Samu on 2019. 09. 03.. -// Copyright © 2019. András Samu. All rights reserved. -// - import SwiftUI struct IndicatorPoint: View { - var body: some View { - ZStack{ + public var body: some View { + ZStack { Circle() - .fill(Colors.IndicatorKnob) + .fill(ChartColors.indicatorKnob) Circle() .stroke(Color.white, style: StrokeStyle(lineWidth: 4)) } .frame(width: 14, height: 14) - .shadow(color: Colors.LegendColor, radius: 6, x: 0, y: 6) + .shadow(color: ChartColors.legendColor, radius: 6, x: 0, y: 6) } } diff --git a/Sources/SwiftUICharts/Charts/LineChart/Line.swift b/Sources/SwiftUICharts/Charts/LineChart/Line.swift new file mode 100644 index 00000000..50e769c2 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Line.swift @@ -0,0 +1,181 @@ +import SwiftUI + +/// A single line of data, a view in a `LineChart` +public struct Line: View { + @Environment(\.chartInteractionValue) private var chartValue + @Environment(\.chartSelectionHandler) private var selectionHandler + + var chartData: ChartData + var chartProperties: ChartLineConfig + + var style: ChartStyle + + @State private var didCellAppear: Bool = false + @State private var touchLocation: CGFloat = -1 + + public init(chartData: ChartData, + style: ChartStyle, + chartProperties: ChartLineConfig) { + self.chartData = chartData + self.style = style + self.chartProperties = chartProperties + } + + public var body: some View { + GeometryReader { geometry in + let safeFrame = geometry.frame(in: .local).sanitized + ZStack { + if didCellAppear, let backgroundColor = chartProperties.backgroundGradient { + LineBackgroundShapeView(chartData: chartData, + geometry: geometry, + backgroundColor: backgroundColor) + } + lineShapeView(geometry: geometry) + selectionOverlay(size: safeFrame.size) + accessibilityOverlay(size: safeFrame.size) + } + .frame(width: safeFrame.width, height: safeFrame.height, alignment: .topLeading) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged({ value in + guard safeFrame.width > 0 else { return } + touchLocation = max(0, min(1, value.location.x / safeFrame.width)) + publishSelectionState(active: true) + }) + .onEnded({ _ in + publishSelectionState(active: false) + touchLocation = -1 + })) + .onAppear { + didCellAppear = true + } + .onDisappear { + didCellAppear = false + } + } + } + + @ViewBuilder + private func lineShapeView(geometry: GeometryProxy) -> some View { + if chartProperties.animationEnabled { + LineShapeView(chartData: chartData, + chartProperties: chartProperties, + geometry: geometry, + style: style, + trimTo: didCellAppear ? 1.0 : 0.0) + .animation(Animation.easeIn(duration: 0.75)) + } else { + LineShapeView(chartData: chartData, + chartProperties: chartProperties, + geometry: geometry, + style: style, + trimTo: 1.0) + } + } + + @ViewBuilder + private func selectionOverlay(size: CGSize) -> some View { + if chartValue != nil, + touchLocation >= 0, + let selectedPoint = selectedChartPoint(size: size) { + ZStack { + Path { path in + path.move(to: CGPoint(x: selectedPoint.x, y: 0)) + path.addLine(to: CGPoint(x: selectedPoint.x, y: size.height)) + } + .stroke(style.foregroundColor.rotate(for: selectedPoint.index).startColor.opacity(0.28), + style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + + IndicatorPoint() + .position(x: selectedPoint.x, y: selectedPoint.y) + } + .toStandardCoordinateSystem() + } + } + + @ViewBuilder + private func accessibilityOverlay(size: CGSize) -> some View { + if !chartData.normalisedData.isEmpty { + ZStack { + ForEach(Array(chartData.normalisedData.enumerated()), id: \.offset) { index, point in + Circle() + .fill(Color.clear) + .frame(width: 30, height: 30) + .position(x: CGFloat(point.0) * size.width, + y: CGFloat(point.1) * size.height) + .accessibilityElement(children: .ignore) + .accessibility(label: Text("Point \(index + 1), value \(formatted(chartData.points[index]))")) + } + } + .toStandardCoordinateSystem() + .allowsHitTesting(false) + } + } + + private func selectedIndex() -> Int? { + guard !chartData.normalisedData.isEmpty, touchLocation >= 0 else { return nil } + + return chartData.normalisedData.enumerated().min { + abs($0.element.0 - Double(touchLocation)) < abs($1.element.0 - Double(touchLocation)) + }?.offset + } + + private func selectedChartPoint(size: CGSize) -> (index: Int, x: CGFloat, y: CGFloat)? { + guard let index = selectedIndex(), + index < chartData.normalisedData.count else { + return nil + } + + let point = chartData.normalisedData[index] + return (index, CGFloat(point.0) * size.width, CGFloat(point.1) * size.height) + } + + private func publishSelectionState(active: Bool) { + guard active else { + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: nil, + index: nil, + isActive: false) + return + } + + guard let index = selectedIndex(), index < chartData.points.count else { + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: nil, + index: nil, + isActive: false) + return + } + + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: chartData.points[index], + index: index, + isActive: true) + } + + private func formatted(_ value: Double) -> String { + if abs(value.rounded() - value) < 0.001 { + return String(Int(value.rounded())) + } + return String(format: "%.2f", value) + } +} + +struct Line_Previews: PreviewProvider { + static let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black)) + static let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red)) + + static var previews: some View { + Group { + Line(chartData: ChartData([8, 23, 32, 7, 23, -4]), + style: blackLineStyle, + chartProperties: ChartLineConfig()) + Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), + style: redLineStyle, + chartProperties: ChartLineConfig()) + } + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift new file mode 100644 index 00000000..3b57733e --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct LineBackgroundShape: Shape { + var data: [(Double, Double)] + func path(in rect: CGRect) -> Path { + let path = Path.quadClosedCurvedPathWithPoints(data: data, in: rect) + return path + } +} + +struct LineBackgroundShape_Previews: PreviewProvider { + static var previews: some View { + Group { + GeometryReader { geometry in + LineBackgroundShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)]) + .fill(Color.red) + .toStandardCoordinateSystem() + } + GeometryReader { geometry in + LineBackgroundShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)]) + .fill(Color.blue) + .toStandardCoordinateSystem() + } + } + } +} + diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift new file mode 100644 index 00000000..2a4f45eb --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct LineBackgroundShapeView: View { + var chartData: ChartData + var geometry: GeometryProxy + var backgroundColor: ColorGradient + + var body: some View { + LineBackgroundShape(data: chartData.normalisedData) + .fill(LinearGradient(gradient: Gradient(colors: [backgroundColor.startColor, + backgroundColor.endColor]), + startPoint: .bottom, + endPoint: .top)) + .toStandardCoordinateSystem() + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift b/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift new file mode 100644 index 00000000..1d933ed3 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift @@ -0,0 +1,77 @@ +import SwiftUI + +public struct LineChart: View { + @Environment(\.chartDataPoints) private var points + @Environment(\.chartYRange) private var rangeY + @Environment(\.chartXRange) private var rangeX + @Environment(\.chartXDomainMode) private var xDomainMode + @Environment(\.chartStyle) private var style + @Environment(\.chartLineConfig) private var lineConfig + @Environment(\.chartSeriesConfig) private var seriesConfig + @Environment(\.chartPerformanceConfig) private var performanceConfig + + public init() {} + + public var body: some View { + if isSeriesVisible { + Line(chartData: ChartData(performanceAdjustedPoints, + rangeY: rangeY, + rangeX: rangeX, + xDomainMode: xDomainMode), + style: style, + chartProperties: performanceAdjustedLineConfig) + } + } + + private var isSeriesVisible: Bool { + guard let seriesID = seriesConfig.seriesID else { return true } + return !seriesConfig.hiddenSeriesIDs.contains(seriesID) + } + + private var performanceAdjustedPoints: [(Double, Double)] { + let downsampled: [(Double, Double)] + + switch performanceConfig.mode { + case .none: + downsampled = points + case .automatic(let threshold, let maxPoints, _): + if points.count > max(2, threshold) { + downsampled = ChartDownsampler.reduced(points, maxPoints: maxPoints) + } else { + downsampled = points + } + case .downsample(let maxPoints, _): + downsampled = ChartDownsampler.reduced(points, maxPoints: maxPoints) + } + + if xDomainMode == .categorical { + return downsampled.enumerated().map { index, value in + (Double(index), value.1) + } + } + + return downsampled + } + + private var performanceAdjustedLineConfig: ChartLineConfig { + var updated = lineConfig + + let shouldSimplify: Bool + switch performanceConfig.mode { + case .none: + shouldSimplify = false + case .automatic(let threshold, _, let simplify): + shouldSimplify = simplify && points.count > max(2, threshold) + case .downsample(_, let simplify): + shouldSimplify = simplify + } + + if shouldSimplify { + updated.lineStyle = .straight + updated.showChartMarks = false + updated.animationEnabled = false + } + + return updated + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift b/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift new file mode 100644 index 00000000..e22be2f4 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct LineShape: Shape { + var data: [(Double, Double)] + var lineStyle: LineStyle = .curved + func path(in rect: CGRect) -> Path { + var path = Path() + switch lineStyle { + case .curved: + path = Path.quadCurvedPathWithPoints(data: data, in: rect) + case .straight: + path = Path.linePathWithPoints(data: data, in: rect) + } + return path + } +} + +struct LineShape_Previews: PreviewProvider { + static var previews: some View { + Group { + LineShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)]) + .stroke() + .toStandardCoordinateSystem() + + LineShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)], lineStyle: .straight) + .stroke() + .toStandardCoordinateSystem() + } + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift b/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift new file mode 100644 index 00000000..bebf7616 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct LineShapeView: View, Animatable { + var chartData: ChartData + var chartProperties: ChartLineConfig + + var geometry: GeometryProxy + var style: ChartStyle + var trimTo: Double = 0 + + var animatableData: CGFloat { + get { CGFloat(trimTo) } + set { trimTo = Double(newValue) } + } + + var chartMarkColor: LinearGradient { + if let customColor = chartProperties.customChartMarksColors { + return customColor.linearGradient(from: .leading, to: .trailing) + } + + return LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient, + startPoint: .leading, + endPoint: .trailing) + } + + var body: some View { + ZStack { + LineShape(data: chartData.normalisedData, lineStyle: chartProperties.lineStyle) + .trim(from: 0, to: CGFloat(trimTo)) + .stroke(LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient, + startPoint: .leading, + endPoint: .trailing), + style: StrokeStyle(lineWidth: chartProperties.lineWidth, lineJoin: .round)) + .toStandardCoordinateSystem() + .clipped() + if chartProperties.showChartMarks { + MarkerShape(data: chartData.normalisedData) + .trim(from: 0, to: CGFloat(trimTo)) + .fill(style.backgroundColor.startColor.opacity(0.95), + strokeBorder: chartMarkColor, + lineWidth: chartProperties.lineWidth) + .toStandardCoordinateSystem() + } + } + } +} + +struct LineShapeView_Previews: PreviewProvider { + static let chartData = ChartData([6, 8, 6], rangeY: 6...10) + static let chartDataOutOfRange = ChartData([-1, 8, 6, 12, 3], rangeY: -5...15) + static let chartDataOutOfRange2 = ChartData([6, 6, 8, 5], rangeY: 5...10) + + static let chartStyle = ChartStyle(backgroundColor: Color.white, + foregroundColor: [ColorGradient(Color.orange, Color.red)]) + + static var previews: some View { + Group { + GeometryReader { geometry in + LineShapeView(chartData: chartData, + chartProperties: ChartLineConfig(), + geometry: geometry, + style: chartStyle, + trimTo: 1.0) + } + GeometryReader { geometry in + LineShapeView(chartData: chartDataOutOfRange, + chartProperties: ChartLineConfig(), + geometry: geometry, + style: chartStyle, + trimTo: 1.0) + } + GeometryReader { geometry in + LineShapeView(chartData: chartDataOutOfRange2, + chartProperties: ChartLineConfig(), + geometry: geometry, + style: chartStyle, + trimTo: 1.0) + } + } + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift b/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift new file mode 100644 index 00000000..0207b896 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct MarkerShape: Shape { + var data: [(Double, Double)] + func path(in rect: CGRect) -> Path { + let path = Path.drawChartMarkers(data: data, in: rect) + return path + } +} + +struct MarkerShape_Previews: PreviewProvider { + static var previews: some View { + Group { + MarkerShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)]) + .stroke() + .toStandardCoordinateSystem() + + MarkerShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)]) + .stroke() + .toStandardCoordinateSystem() + } + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift b/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift new file mode 100644 index 00000000..c6684624 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift @@ -0,0 +1,4 @@ +import SwiftUI + +@available(*, deprecated, renamed: "ChartLineConfig") +public typealias LineChartProperties = ChartLineConfig diff --git a/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift b/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift new file mode 100644 index 00000000..1612e8f2 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum LineStyle: Sendable { + case curved + case straight +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift new file mode 100644 index 00000000..0e1fcc57 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// A type of chart that displays a slice of "pie" for each data point +public struct PieChart: View { + @Environment(\.chartDataPoints) private var points + @Environment(\.chartYRange) private var rangeY + @Environment(\.chartXRange) private var rangeX + @Environment(\.chartStyle) private var style + @Environment(\.chartSeriesConfig) private var seriesConfig + + public init() {} + + public var body: some View { + if isSeriesVisible { + PieChartRow(chartData: ChartData(points, rangeY: rangeY, rangeX: rangeX), style: style) + .aspectRatio(1, contentMode: .fit) + } + } + + private var isSeriesVisible: Bool { + guard let seriesID = seriesConfig.seriesID else { return true } + return !seriesConfig.hiddenSeriesIDs.contains(seriesID) + } +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift new file mode 100644 index 00000000..465f6992 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift @@ -0,0 +1,119 @@ +import SwiftUI + +/// One slice of a `PieChartRow` +struct PieSlice: Identifiable { + var id = UUID() + var startDeg: Double + var endDeg: Double + var value: Double +} + +/// A single row of data, a view in a `PieChart` +public struct PieChartCell: View { + @State private var show: Bool = false + var rect: CGRect + private var safeRect: CGRect { + rect.sanitized + } + var radius: CGFloat { + return min(safeRect.width, safeRect.height)/2 + } + var startDeg: Double + var endDeg: Double + + /// Path representing this slice + var path: Path { + var path = Path() + path.addArc( + center: safeRect.mid, + radius: self.radius, + startAngle: Angle(degrees: self.startDeg), + endAngle: Angle(degrees: self.endDeg), + clockwise: false) + path.addLine(to: safeRect.mid) + path.closeSubpath() + return path + } + var index: Int + + // Section line border color + var backgroundColor: Color + + // Section color + var accentColor: ColorGradient + + /// The content and behavior of the `PieChartCell`. + /// + /// Fills and strokes with 2-pixel line (unless start/end degrees not yet set). Animates by scaling up to 100% when first appears. + public var body: some View { + Group { + path + .fill(self.accentColor.linearGradient(from: .bottom, to: .top)) + .overlay(path.stroke(self.backgroundColor, lineWidth: (startDeg == 0 && endDeg == 0 ? 0 : 2))) + .scaleEffect(self.show ? 1 : 0) + .animation(Animation.spring().delay(Double(self.index) * 0.04)) + .onAppear { + self.show = true + } + + } + } +} + +struct PieChartCell_Previews: PreviewProvider { + static var previews: some View { + Group { + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 00.0, + endDeg: 90.0, + index: 0, + backgroundColor: Color.red, + accentColor: ColorGradient.greenRed) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 0.0, + endDeg: 90.0, + index: 0, + backgroundColor: Color.green, + accentColor: ColorGradient.redBlack) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 100.0, + endDeg: 135.0, + index: 0, + backgroundColor: Color.black, + accentColor: ColorGradient.whiteBlack) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 185.0, + endDeg: 290.0, + index: 1, + backgroundColor: Color.purple, + accentColor: ColorGradient(.purple)) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 0, + endDeg: 0, + index: 0, + backgroundColor: Color.purple, + accentColor: ColorGradient(.purple)) + }.frame(width: 100, height: 100) + + }.previewLayout(.fixed(width: 125, height: 125)) + } +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift new file mode 100644 index 00000000..5dd0d4e0 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift @@ -0,0 +1,40 @@ +import SwiftUI + +func isPointInCircle(point: CGPoint, circleRect: CGRect) -> Bool { + let circleRect = circleRect.sanitized + let r = min(circleRect.width, circleRect.height) / 2 + guard r > 0 else { return false } + let center = CGPoint(x: circleRect.midX, y: circleRect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + let distance = sqrt(dx * dx + dy * dy) + return distance <= r +} + +func degree(for point: CGPoint, inCircleRect circleRect: CGRect) -> Double { + let circleRect = circleRect.sanitized + let center = CGPoint(x: circleRect.midX, y: circleRect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + guard dx != 0 else { + return dy >= 0 ? 90 : 270 + } + let acuteDegree = Double(atan(dy / dx)) * (180 / .pi) + + let isInBottomRight = dx >= 0 && dy >= 0 + let isInBottomLeft = dx <= 0 && dy >= 0 + let isInTopLeft = dx <= 0 && dy <= 0 + let isInTopRight = dx >= 0 && dy <= 0 + + if isInBottomRight { + return acuteDegree + } else if isInBottomLeft { + return 180 - abs(acuteDegree) + } else if isInTopLeft { + return 180 + abs(acuteDegree) + } else if isInTopRight { + return 360 - abs(acuteDegree) + } + + return 0 +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift new file mode 100644 index 00000000..516c2309 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift @@ -0,0 +1,91 @@ +import SwiftUI + +/// A single "row" (slice) of data, a view in a `PieChart` +public struct PieChartRow: View { + @Environment(\.chartInteractionValue) private var chartValue + @Environment(\.chartSelectionHandler) private var selectionHandler + + var chartData: ChartData + var style: ChartStyle + + var slices: [PieSlice] { + var tempSlices: [PieSlice] = [] + var lastEndDeg: Double = 0 + let maxValue: Double = chartData.points.reduce(0, +) + + for slice in chartData.points { + let normalized: Double = Double(slice) / (maxValue == 0 ? 1 : maxValue) + let startDeg = lastEndDeg + let endDeg = lastEndDeg + (normalized * 360) + lastEndDeg = endDeg + tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice)) + } + + return tempSlices + } + + @State private var currentTouchedIndex = -1 { + didSet { + guard oldValue != currentTouchedIndex else { + return + } + + if currentTouchedIndex == -1 { + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: nil, + index: nil, + isActive: false) + } else { + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: selectionHandler, + value: slices[currentTouchedIndex].value, + index: currentTouchedIndex, + isActive: true) + } + } + } + + public var body: some View { + GeometryReader { geometry in + let rect = geometry.frame(in: .local).sanitized + let total = max(0.0001, chartData.points.reduce(0, +)) + ZStack { + ForEach(Array(slices.indices), id: \.self) { index in + PieChartCell(rect: rect, + startDeg: slices[index].startDeg, + endDeg: slices[index].endDeg, + index: index, + backgroundColor: style.backgroundColor.startColor, + accentColor: style.foregroundColor.rotate(for: index)) + .scaleEffect(currentTouchedIndex == index ? 1.1 : 1) + .animation(Animation.spring()) + .accessibilityElement(children: .ignore) + .accessibility(label: Text("Slice \(index + 1), value \(formatted(slices[index].value)), \(formatted((slices[index].value / total) * 100)) percent")) + } + } + .frame(width: rect.width, height: rect.height, alignment: .topLeading) + .gesture(DragGesture() + .onChanged({ value in + let isTouchInPie = isPointInCircle(point: value.location, circleRect: rect) + if isTouchInPie { + let touchDegree = degree(for: value.location, inCircleRect: rect) + currentTouchedIndex = slices.firstIndex(where: { $0.startDeg < touchDegree && $0.endDeg > touchDegree }) ?? -1 + } else { + currentTouchedIndex = -1 + } + }) + .onEnded({ _ in + currentTouchedIndex = -1 + }) + ) + } + } + + private func formatted(_ value: Double) -> String { + if abs(value.rounded() - value) < 0.001 { + return String(Int(value.rounded())) + } + return String(format: "%.1f", value) + } +} diff --git a/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift b/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift new file mode 100644 index 00000000..c42599c1 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift @@ -0,0 +1,189 @@ +// +// Ring.swift +// ChartViewV2Demo +// +// Created by Dan Wood on 8/20/20. +// Based on article and playground code by Frank Jia +// https://medium.com/@frankjia/creating-activity-rings-in-swiftui-11ef7d336676 + +import SwiftUI + + +extension Double { + func toRadians() -> Double { + return self * Double.pi / 180 + } + func toCGFloat() -> CGFloat { + return CGFloat(self) + } +} + +struct RingShape: Shape { + /// Helper function to convert percent values to angles in degrees + /// - Parameters: + /// - percent: percent, greater than 100 is OK + /// - startAngle: angle to add after converting + /// - Returns: angle in degrees + static func percentToAngle(percent: Double, startAngle: Double) -> Double { + (percent / 100 * 360) + startAngle + } + private var percent: Double + private var startAngle: Double + private let drawnClockwise: Bool + + // This allows animations to run smoothly for percent values + var animatableData: Double { + get { + return percent + } + set { + percent = newValue + } + } + + init(percent: Double = 100, startAngle: Double = -90, drawnClockwise: Bool = false) { + self.percent = percent + self.startAngle = startAngle + self.drawnClockwise = drawnClockwise + } + + /// This draws a simple arc from the start angle to the end angle + /// + /// - Parameter rect: The frame of reference for describing this shape. + /// - Returns: A path that describes this shape. + func path(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let radius = min(width, height) / 2 + let center = CGPoint(x: width / 2, y: height / 2) + let endAngle = Angle(degrees: RingShape.percentToAngle(percent: self.percent, startAngle: self.startAngle)) + return Path { path in + path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startAngle), endAngle: endAngle, clockwise: drawnClockwise) + } + } +} + +struct Ring: View { + + private static let ShadowColor: Color = Color.black.opacity(0.2) + private static let ShadowRadius: CGFloat = 5 + private static let ShadowOffsetMultiplier: CGFloat = ShadowRadius + 2 + + private let ringWidth: CGFloat + private let percent: Double + private let foregroundColor: ColorGradient + private let startAngle: Double = -90 + + private let touchLocation: CGFloat + + + + private var gradientStartAngle: Double { + self.percent >= 100 ? relativePercentageAngle - 360 : startAngle + } + private var absolutePercentageAngle: Double { + RingShape.percentToAngle(percent: self.percent, startAngle: 0) + } + private var relativePercentageAngle: Double { + // Take into account the startAngle + absolutePercentageAngle + startAngle + } + private var lastGradientColor: Color { + self.foregroundColor.endColor + } + + private var ringGradient: AngularGradient { + AngularGradient( + gradient: self.foregroundColor.gradient, + center: .center, + startAngle: Angle(degrees: self.gradientStartAngle), + endAngle: Angle(degrees: relativePercentageAngle) + ) + } + + init(ringWidth: CGFloat, percent: Double, foregroundColor: ColorGradient, touchLocation:CGFloat) { + self.ringWidth = ringWidth + self.percent = percent + self.foregroundColor = foregroundColor + self.touchLocation = touchLocation + } + + var body: some View { + GeometryReader { geometry in + let safeFrame = geometry.size.sanitized + ZStack { + // Background for the ring. Use the final color with reduced opacity + RingShape() + .stroke(style: StrokeStyle(lineWidth: self.ringWidth)) + .fill(lastGradientColor.opacity(0.142857)) + // Foreground + RingShape(percent: self.percent, startAngle: self.startAngle) + .stroke(style: StrokeStyle(lineWidth: self.ringWidth, lineCap: .round)) + .fill(self.ringGradient) + // End of ring with drop shadow + if self.getShowShadow(frame: safeFrame) { + Circle() + .fill(self.lastGradientColor) + .frame(width: self.ringWidth, height: self.ringWidth, alignment: .center) + .offset(x: self.getEndCircleLocation(frame: safeFrame).0, + y: self.getEndCircleLocation(frame: safeFrame).1) + .shadow(color: Ring.ShadowColor, + radius: Ring.ShadowRadius, + x: self.getEndCircleShadowOffset().0, + y: self.getEndCircleShadowOffset().1) + } + } + .frame(width: safeFrame.width, height: safeFrame.height, alignment: .topLeading) + } + // Padding to ensure that the entire ring fits within the view size allocated + .padding(self.ringWidth / 2) + } + + private func getEndCircleLocation(frame: CGSize) -> (CGFloat, CGFloat) { + // Get angle of the end circle with respect to the start angle + let angleOfEndInRadians: Double = relativePercentageAngle.toRadians() + let offsetRadius = min(frame.width, frame.height) / 2 + return (offsetRadius * cos(angleOfEndInRadians).toCGFloat(), offsetRadius * sin(angleOfEndInRadians).toCGFloat()) + } + + private func getEndCircleShadowOffset() -> (CGFloat, CGFloat) { + let angleForOffset = absolutePercentageAngle + (self.startAngle + 90) + let angleForOffsetInRadians = angleForOffset.toRadians() + let relativeXOffset = cos(angleForOffsetInRadians) + let relativeYOffset = sin(angleForOffsetInRadians) + let xOffset = relativeXOffset.toCGFloat() * Ring.ShadowOffsetMultiplier + let yOffset = relativeYOffset.toCGFloat() * Ring.ShadowOffsetMultiplier + return (xOffset, yOffset) + } + + private func getShowShadow(frame: CGSize) -> Bool { + if self.percent >= 100 { + return true + } + let circleRadius = min(frame.width, frame.height) / 2 + let remainingAngleInRadians = (360 - absolutePercentageAngle).toRadians().toCGFloat() + + return circleRadius * remainingAngleInRadians <= self.ringWidth + } +} + +struct Ring_Previews: PreviewProvider { + static var previews: some View { + VStack { + Ring( + ringWidth: 50, percent: 5 , + foregroundColor: ColorGradient(.green, .blue), touchLocation: -1.0 + ) + .frame(width: 200, height: 200) + + Ring( + ringWidth: 20, percent: 110 , + foregroundColor: ColorGradient(.red, .blue), touchLocation: -1.0 + ) + .frame(width: 200, height: 200) + + + + } + } +} diff --git a/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift b/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift new file mode 100644 index 00000000..032a712e --- /dev/null +++ b/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift @@ -0,0 +1,26 @@ +import SwiftUI + +public struct RingsChart: View { + @Environment(\.chartDataPoints) private var points + @Environment(\.chartYRange) private var rangeY + @Environment(\.chartXRange) private var rangeX + @Environment(\.chartStyle) private var style + @Environment(\.chartSeriesConfig) private var seriesConfig + + public init() {} + + public var body: some View { + if isSeriesVisible { + RingsChartRow(width: 10.0, + spacing: 5.0, + chartData: ChartData(points, rangeY: rangeY, rangeX: rangeX), + style: style) + .aspectRatio(1, contentMode: .fit) + } + } + + private var isSeriesVisible: Bool { + guard let seriesID = seriesConfig.seriesID else { return true } + return !seriesConfig.hiddenSeriesIDs.contains(seriesID) + } +} diff --git a/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift b/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift new file mode 100644 index 00000000..8ea9bd81 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift @@ -0,0 +1,123 @@ +import SwiftUI + +public struct RingsChartRow: View { + var width: CGFloat + var spacing: CGFloat + + @Environment(\.chartInteractionValue) private var chartValue + @Environment(\.chartSelectionHandler) private var selectionHandler + var chartData: ChartData + @State var touchRadius: CGFloat = -1.0 + + var style: ChartStyle + + public var body: some View { + GeometryReader { geometry in + let safeSize = geometry.size.sanitized + ZStack { + Circle() + .fill(RadialGradient(gradient: style.backgroundColor.gradient, + center: .center, + startRadius: min(safeSize.width, safeSize.height) / 2.0, + endRadius: 1.0)) + + ForEach(0.. Bool { + let radius = min(size.width, size.height) / 2.0 + return index == touchedCircleIndex(maxRadius: radius) + } + + func touchedCircleIndex(maxRadius: CGFloat) -> Int? { + guard !chartData.data.isEmpty else { return nil } + + let radialDistanceFromEdge = (maxRadius + spacing / 2) - touchRadius + guard radialDistanceFromEdge >= 0 else { return nil } + + let touchIndex = Int(floor(radialDistanceFromEdge / (width + spacing))) + + if touchIndex >= chartData.data.count { return nil } + + return touchIndex + } + + func getCurrentSelection(maxRadius: CGFloat) -> (index: Int, value: Double)? { + guard let index = touchedCircleIndex(maxRadius: maxRadius) else { return nil } + return (index, chartData.points[index]) + } + + private func formatted(_ value: Double) -> String { + if abs(value.rounded() - value) < 0.001 { + return String(Int(value.rounded())) + } + return String(format: "%.1f", value) + } +} + +struct RingsChartRow_Previews: PreviewProvider { + static var previews: some View { + let multiStyle = ChartStyle(backgroundColor: ColorGradient(Color.black.opacity(0.05), Color.white), + foregroundColor: [ColorGradient(.purple, .blue), + ColorGradient(.orange, .red), + ColorGradient(.green, .yellow)]) + + return RingsChartRow(width: 20.0, + spacing: 10.0, + chartData: ChartData([25, 50, 75, 100, 125]), + style: multiStyle) + .frame(width: 300, height: 400) + } +} diff --git a/Sources/SwiftUICharts/Helpers.swift b/Sources/SwiftUICharts/Helpers.swift deleted file mode 100644 index 9f7e9293..00000000 --- a/Sources/SwiftUICharts/Helpers.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// File.swift -// -// -// Created by András Samu on 2019. 07. 19.. -// - -import Foundation -import SwiftUI - -public struct Colors { - public static let color1:Color = Color(hexString: "#E2FAE7") - public static let color1Accent:Color = Color(hexString: "#72BF82") - public static let color2:Color = Color(hexString: "#EEF1FF") - public static let color2Accent:Color = Color(hexString: "#4266E8") - public static let color3:Color = Color(hexString: "#FCECEA") - public static let color3Accent:Color = Color(hexString: "#E1614C") - public static let OrangeStart:Color = Color(hexString: "#FF782C") - public static let OrangeEnd:Color = Color(hexString: "#EC2301") - public static let LegendText:Color = Color(hexString: "#A7A6A8") - public static let LegendColor:Color = Color(hexString: "#E8E7EA") - public static let IndicatorKnob:Color = Color(hexString: "#FF57A6") - public static let GradientUpperBlue:Color = Color(hexString: "#C2E8FF") - public static let GradinetUpperBlue1:Color = Color(hexString: "#A8E1FF") - public static let GradientPurple:Color = Color(hexString: "#7B75FF") - public static let GradientNeonBlue:Color = Color(hexString: "#6FEAFF") - public static let GradientLowerBlue:Color = Color(hexString: "#F1F9FF") - public static let DarkPurple:Color = Color(hexString: "#1B205E") - public static let BorderBlue:Color = Color(hexString: "#4EBCFF") -} - -public struct Styles { - public static let lineChartStyleOne = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.black, - legendTextColor: Color.gray) - - public static let barChartStyleOrangeLight = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.black, - legendTextColor: Color.gray) - - public static let barChartStyleOrangeDark = ChartStyle( - backgroundColor: Color.black, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.white, - legendTextColor: Color.gray) - - public static let barChartStyleNeonBlueLight = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.GradientNeonBlue, - secondGradientColor: Colors.GradientPurple, - textColor: Color.black, - legendTextColor: Color.gray) - - public static let barChartStyleNeonBlueDark = ChartStyle( - backgroundColor: Color.black, - accentColor: Colors.GradientNeonBlue, - secondGradientColor: Colors.GradientPurple, - textColor: Color.white, - legendTextColor: Color.gray) - - public static let barChartMidnightGreenDark = ChartStyle( - backgroundColor: Color(hexString: "#36534D"), //3B5147, 313D34 - accentColor: Color(hexString: "#FFD603"), - secondGradientColor: Color(hexString: "#FFCA04"), - textColor: Color.white, - legendTextColor: Color(hexString: "#D2E5E1")) - - public static let barChartMidnightGreenLight = ChartStyle( - backgroundColor: Color.white, - accentColor: Color(hexString: "#84A094"), //84A094 , 698378 - secondGradientColor: Color(hexString: "#50675D"), - textColor: Color.black, - legendTextColor:Color.gray) - - public static let pieChartStyleOne = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.black, - legendTextColor: Color.gray) -} - -public struct Form { - #if os(watchOS) - public static let small = CGSize(width:120, height:90) - public static let medium = CGSize(width:120, height:160) - public static let large = CGSize(width:180, height:90) - public static let detail = CGSize(width:180, height:160) - #else - public static let small = CGSize(width:180, height:120) - public static let medium = CGSize(width:180, height:240) - public static let large = CGSize(width:360, height:120) - public static let detail = CGSize(width:180, height:120) - #endif - - -} - -public struct ChartStyle { - public var backgroundColor: Color - public var accentColor: Color - public var secondGradientColor: Color - public var textColor: Color - public var legendTextColor: Color - - public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color){ - self.backgroundColor = backgroundColor - self.accentColor = accentColor - self.secondGradientColor = secondGradientColor - self.textColor = textColor - self.legendTextColor = legendTextColor - } - - public init(formSize: CGSize){ - self.backgroundColor = Color.white - self.accentColor = Colors.OrangeStart - self.secondGradientColor = Colors.OrangeEnd - self.legendTextColor = Color.gray - self.textColor = Color.black - } -} - -class ChartData: ObservableObject { - @Published var points: [Int] = [Int]() - @Published var currentPoint: Int? = nil - - init(points:[Int]) { - self.points = points - } -} - -class TestData{ - static public var data:ChartData = ChartData(points: [37,72,51,22,39,47,66,85,50]) -} - -extension Color { - init(hexString: String) { - let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int = UInt64() - Scanner(string: hex).scanHexInt64(&int) - let r, g, b: UInt64 - switch hex.count { - case 3: // RGB (12-bit) - (r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: // RGB (24-bit) - (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: // ARGB (32-bit) - (r, g, b) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (r, g, b) = (0, 0, 0) - } - self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) - } -} - -class HapticFeedback { - #if os(watchOS) - //watchOS implementation - static func playSelection() -> Void { - WKInterfaceDevice.current().play(.click) - } - #else - //iOS implementation - let selectionFeedbackGenerator = UISelectionFeedbackGenerator() - static func playSelection() -> Void { - UISelectionFeedbackGenerator().selectionChanged() - } - #endif -} diff --git a/Sources/SwiftUICharts/LineChart/Legend.swift b/Sources/SwiftUICharts/LineChart/Legend.swift deleted file mode 100644 index a706c0d5..00000000 --- a/Sources/SwiftUICharts/LineChart/Legend.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// Legend.swift -// LineChart -// -// Created by András Samu on 2019. 09. 02.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -struct Legend: View { - @ObservedObject var data: ChartData - @Binding var frame: CGRect - @Binding var hideHorizontalLines: Bool - - var stepWidth: CGFloat { - return frame.size.width / CGFloat(data.points.count-1) - } - var stepHeight: CGFloat { - return frame.size.height / CGFloat(data.points.max()! + data.points.min()!) - } - - var body: some View { - ZStack(alignment: .topLeading){ - ForEach((0...4), id: \.self) { height in - HStack(alignment: .center){ - Text("\(self.getYLegend()![height])").offset(x: 0, y: (self.frame.height-CGFloat(self.getYLegend()![height])*self.stepHeight)-(self.frame.height/2)) - .foregroundColor(Colors.LegendText) - .font(.caption) - self.line(atHeight: CGFloat(self.getYLegend()![height]), width: self.frame.width) - .stroke(Colors.LegendColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5,height == 0 ? 0 : 10])) - .opacity((self.hideHorizontalLines && height != 0) ? 0 : 1) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .animation(.easeOut(duration: 0.2)) - .clipped() - } - - } - - } - } - - func line(atHeight: CGFloat, width: CGFloat) -> Path { - var hLine = Path() - hLine.move(to: CGPoint(x:5, y: atHeight*stepHeight)) - hLine.addLine(to: CGPoint(x: width, y: atHeight*stepHeight)) - return hLine - } - - func getYLegend() -> [Int]? { - guard let max = data.points.max() else { return nil } - guard let min = data.points.min() else { return nil } - if(min > 0){ - let upperBound = ((max/10)+1) * 10 - let step = upperBound/4 - return [step * 0,step * 1, step * 2, step * 3, step * 4] - } - - return nil - } -} - -struct Legend_Previews: PreviewProvider { - static var previews: some View { - GeometryReader{ geometry in - Legend(data: TestData.data, frame: .constant(geometry.frame(in: .local)), hideHorizontalLines: .constant(false)) - }.frame(width: 320, height: 200) - } -} diff --git a/Sources/SwiftUICharts/LineChart/Line.swift b/Sources/SwiftUICharts/LineChart/Line.swift deleted file mode 100644 index 363c4085..00000000 --- a/Sources/SwiftUICharts/LineChart/Line.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// Line.swift -// LineChart -// -// Created by András Samu on 2019. 08. 30.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -struct Line: View { - @ObservedObject var data: ChartData - @Binding var frame: CGRect - @Binding var touchLocation: CGPoint - @Binding var showIndicator: Bool - @State private var showFull: Bool = false - @State var showBackground: Bool = true - - var stepWidth: CGFloat { - return frame.size.width / CGFloat(data.points.count-1) - } - var stepHeight: CGFloat { - return frame.size.height / CGFloat(data.points.max()! + data.points.min()!) - } - var path: Path { - return Path.quadCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight)) - } - var closedPath: Path { - return Path.quadClosedCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight)) - } - - var body: some View { - ZStack { - if(self.showFull && self.showBackground){ - self.closedPath - .fill(LinearGradient(gradient: Gradient(colors: [Colors.GradientUpperBlue, .white]), startPoint: .bottom, endPoint: .top)) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .transition(.opacity) - .animation(.easeIn(duration: 1.6)) - } - self.path - .trim(from: 0, to: self.showFull ? 1:0) - .stroke(LinearGradient(gradient: Gradient(colors: [Colors.GradientPurple, Colors.GradientNeonBlue]), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3)) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .animation(.easeOut(duration: 1.2)) - .onAppear(){ - self.showFull.toggle() - }.drawingGroup() - if(self.showIndicator) { - IndicatorPoint() - .position(self.getClosestPointOnPath(touchLocation: self.touchLocation)) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - } - } - } - - func getClosestPointOnPath(touchLocation: CGPoint) -> CGPoint { - let percentage:CGFloat = min(max(touchLocation.x,0)/self.frame.width,1) - let closest = self.path.percentPoint(percentage) - return closest - } - -} - -extension CGPoint { - static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint { - return CGPoint( - x: point1.x + (point2.x - point1.x) / 2, - y: point1.y + (point2.y - point1.y) / 2 - ) - } - - func dist(to: CGPoint) -> CGFloat { - return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2))) - } - - static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2) - } - - static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2) - let diffY = abs(p2.y - controlPoint.y) - - if (p1.y < p2.y){ - controlPoint.y += diffY - } else if (p1.y > p2.y) { - controlPoint.y -= diffY - } - return controlPoint - } -} -extension Path { - static func quadCurvedPathWithPoints(points:[Int], step:CGPoint) -> Path { - var path = Path() - var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y) - path.move(to: p1) - if(points.count < 2){ - path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1]))) - return path - } - for pointIndex in 1.. Path { - var path = Path() - path.move(to: .zero) - var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y) - path.addLine(to: p1) - if(points.count < 2){ - path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1]))) - return path - } - for pointIndex in 1.. CGPoint { - // percent difference between points - let diff: CGFloat = 0.001 - let comp: CGFloat = 1 - diff - - // handle limits - let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent) - - let f = pct > comp ? comp : pct - let t = pct > comp ? 1 : pct + diff - let tp = self.trimmedPath(from: f, to: t) - - return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY) - } - -} - -struct Line_Previews: PreviewProvider { - static var previews: some View { - GeometryReader{ geometry in - Line(data: TestData.data, frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 300, y: 12)), showIndicator: .constant(true)) - }.frame(width: 320, height: 160) - } -} diff --git a/Sources/SwiftUICharts/LineChart/LineChartView.swift b/Sources/SwiftUICharts/LineChart/LineChartView.swift deleted file mode 100644 index 3cd0580c..00000000 --- a/Sources/SwiftUICharts/LineChart/LineChartView.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// LineCard.swift -// LineChart -// -// Created by András Samu on 2019. 08. 31.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct LineChartView: View { -// let selectionFeedbackGenerator = UISelectionFeedbackGenerator() - @ObservedObject var data:ChartData - public var title: String - public var legend: String? - public var style: ChartStyle - public var formSize:CGSize - @State private var touchLocation:CGPoint = .zero - @State private var showIndicatorDot: Bool = false - @State private var currentValue: Int = 2 { - didSet{ - if (oldValue != self.currentValue && showIndicatorDot) { -// selectionFeedbackGenerator.selectionChanged() - HapticFeedback.playSelection() - } - - } - } - let frame = CGSize(width: 180, height: 120) - - public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.lineChartStyleOne, form: CGSize? = Form.medium){ - self.data = ChartData(points: data) - self.title = title - self.legend = legend - self.style = style - self.formSize = form! - } - - public var body: some View { - ZStack(alignment: .center){ - RoundedRectangle(cornerRadius: 20).fill(self.style.backgroundColor).frame(width: frame.width, height: 240, alignment: .center).shadow(radius: 8) - VStack(alignment: .leading){ - if(!self.showIndicatorDot){ - VStack(alignment: .leading, spacing: 8){ - Text(self.title).font(.title).bold().foregroundColor(self.style.textColor) - if (self.legend != nil){ - Text(self.legend!).font(.callout).foregroundColor(self.style.legendTextColor) - } - HStack { - Image(systemName: "arrow.up") - Text("14%") - } - } - .transition(.opacity) - .animation(.easeIn(duration: 0.1)) - .padding([.leading, .top]) - }else{ - HStack{ - Spacer() - Text("\(self.currentValue)") - .font(.system(size: 41, weight: .bold, design: .default)) - .offset(x: 0, y: 30) - Spacer() - } - .transition(.scale) - .animation(.spring()) - - } - Spacer() - GeometryReader{ geometry in - Line(data: self.data, frame: .constant(geometry.frame(in: .local)), touchLocation: self.$touchLocation, showIndicator: self.$showIndicatorDot) - } - .frame(width: frame.width, height: frame.height) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .offset(x: 0, y: 0) - }.frame(width: self.formSize.width, height: self.formSize.height) - } - .gesture(DragGesture() - .onChanged({ value in - self.touchLocation = value.location - self.showIndicatorDot = true - self.getClosestDataPoint(toPoint: value.location, width:self.frame.width, height: self.frame.height) - }) - .onEnded({ value in - self.showIndicatorDot = false - }) - ) - } - - func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint { - let stepWidth: CGFloat = width / CGFloat(data.points.count-1) - let stepHeight: CGFloat = height / CGFloat(data.points.max()! + data.points.min()!) - - let index:Int = Int(round((toPoint.x)/stepWidth)) - if (index >= 0 && index < data.points.count){ - self.currentValue = self.data.points[index] - return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(self.data.points[index])*stepHeight) - } - return .zero - } -} - -struct WidgetView_Previews: PreviewProvider { - static var previews: some View { - Group { - LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Basic") - .environment(\.colorScheme, .light) - } - } -} diff --git a/Sources/SwiftUICharts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/PieChart/PieChartCell.swift deleted file mode 100644 index 352ce6b7..00000000 --- a/Sources/SwiftUICharts/PieChart/PieChartCell.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// PieChartCell.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -struct PieSlice: Identifiable { - var id = UUID() - var startDeg: Double - var endDeg: Double - var value: Int - var normalizedValue: Double -} - -public struct PieChartCell : View { - @State private var show:Bool = false - var rect: CGRect - var radius: CGFloat { - return min(rect.width, rect.height)/2 - } - var startDeg: Double - var endDeg: Double - var path: Path { - var path = Path() - path.addArc(center:rect.mid , radius:self.radius, startAngle: Angle(degrees: self.startDeg), endAngle: Angle(degrees: self.endDeg), clockwise: false) - path.addLine(to: rect.mid) - path.closeSubpath() - return path - } - var index: Int - var backgroundColor:Color - var accentColor:Color - public var body: some View { - path - .fill() - .foregroundColor(self.accentColor) - .overlay(path.stroke(self.backgroundColor, lineWidth: 2)) - .scaleEffect(self.show ? 1 : 0) - .animation(Animation.spring().delay(Double(self.index) * 0.04)) - .onAppear(){ - self.show = true - } - } -} - -extension CGRect { - var mid: CGPoint { - return CGPoint(x:self.midX, y: self.midY) - } -} - -#if DEBUG -struct PieChartCell_Previews : PreviewProvider { - static var previews: some View { - GeometryReader { geometry in - PieChartCell(rect: geometry.frame(in: .local),startDeg: 0.0,endDeg: 90.0, index: 0, backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0)) - }.frame(width:100, height:100) - - } -} -#endif diff --git a/Sources/SwiftUICharts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/PieChart/PieChartRow.swift deleted file mode 100644 index 354ebfff..00000000 --- a/Sources/SwiftUICharts/PieChart/PieChartRow.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PieChartRow.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct PieChartRow : View { - var data: [Int] - var backgroundColor: Color - var accentColor: Color - var slices: [PieSlice] { - var tempSlices:[PieSlice] = [] - var lastEndDeg:Double = 0 - let maxValue = data.reduce(0, +) - for slice in data { - let normalized:Double = Double(slice)/Double(maxValue) - let startDeg = lastEndDeg - let endDeg = lastEndDeg + (normalized * 360) - lastEndDeg = endDeg - tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice, normalizedValue: normalized)) - } - return tempSlices - } - public var body: some View { - GeometryReader { geometry in - ZStack{ - ForEach(0.. + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/Tests/SwiftUIChartsTests/ArrayExtensionTests.swift b/Tests/SwiftUIChartsTests/ArrayExtensionTests.swift new file mode 100644 index 00000000..f76c00e2 --- /dev/null +++ b/Tests/SwiftUIChartsTests/ArrayExtensionTests.swift @@ -0,0 +1,37 @@ +@testable import SwiftUICharts +import XCTest + +class ArrayExtensionTests: XCTestCase { + + func testArrayRotatingIndexEmpty() { + let colors = [ColorGradient]() + XCTAssertEqual(colors.rotate(for: 0), ColorGradient.orangeBright) + } + + func testArrayRotatingIndexOneValue() { + let colors = [ColorGradient.greenRed] + + XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed) + XCTAssertEqual(colors.rotate(for: 1), ColorGradient.greenRed) + XCTAssertEqual(colors.rotate(for: 2), ColorGradient.greenRed) + } + + func testArrayRotatingIndexLessValues() { + let colors = [ColorGradient.greenRed, ColorGradient.whiteBlack] + + XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed) + XCTAssertEqual(colors.rotate(for: 1), ColorGradient.whiteBlack) + XCTAssertEqual(colors.rotate(for: 2), ColorGradient.greenRed) + XCTAssertEqual(colors.rotate(for: 3), ColorGradient.whiteBlack) + XCTAssertEqual(colors.rotate(for: 4), ColorGradient.greenRed) + } + + func testArrayRotatingIndexMoreValues() { + let colors = [ColorGradient.greenRed, ColorGradient.whiteBlack, ColorGradient.orangeBright] + + XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed) + XCTAssertEqual(colors.rotate(for: 1), ColorGradient.whiteBlack) + + } + +} diff --git a/Tests/SwiftUIChartsTests/CGPointExtensionTests.swift b/Tests/SwiftUIChartsTests/CGPointExtensionTests.swift new file mode 100644 index 00000000..7a01359d --- /dev/null +++ b/Tests/SwiftUIChartsTests/CGPointExtensionTests.swift @@ -0,0 +1,38 @@ +// +// CGPointExtensionTests.swift +// SwiftUIChartsTests +// +// Created by Adrian Bolinger on 5/24/20. +// + +@testable import SwiftUICharts +import XCTest + +class CGPointExtensionTests: XCTestCase { + static let twentyElementArray: [Double] = Array(repeating: Double.random(in: 1...100), count: 20) + + func testGetStepWithOneElementArray() { + let frame = CGRect(x: 0, y: 0, width: 300, height: 300) + let oneElementArray: [Double] = [0.0] + + XCTAssertEqual(CGPoint.getStep(frame: frame, data: oneElementArray), .zero) + } + + func testGetStepWithMultiElementArrayWithNegativeValues() { + let frame = CGRect(x: 0, y: 0, width: 300, height: 300) + let multiElementArray: [Double] = [-5.0, 0.0, 5.0] + XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), CGPoint(x: 150.0, y: 30.0)) + } + + func testGetStepWithMultiElementArrayWithPositiveValues() { + let frame = CGRect(x: 0, y: 0, width: 300, height: 300) + let multiElementArray: [Double] = [5.0, 10.0, 15.0] + XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), CGPoint(x: 150.0, y: 15.0)) + } + + func testGetStepWithAllEqualValues() { + let frame = CGRect(x: 0, y: 0, width: 300, height: 300) + let multiElementArray: [Double] = [5.0, 5.0, 5.0] + XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), .zero) + } +} diff --git a/Tests/SwiftUIChartsTests/ChartSelectionDispatcherTests.swift b/Tests/SwiftUIChartsTests/ChartSelectionDispatcherTests.swift new file mode 100644 index 00000000..6928b510 --- /dev/null +++ b/Tests/SwiftUIChartsTests/ChartSelectionDispatcherTests.swift @@ -0,0 +1,45 @@ +@testable import SwiftUICharts +import XCTest + +final class ChartSelectionDispatcherTests: XCTestCase { + + func testPublishUpdatesChartValueAndEmitsCallback() { + let chartValue = ChartValue() + var received: ChartSelectionEvent? + + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: { event in + received = event + }, + value: 42, + index: 3, + isActive: true) + + XCTAssertTrue(chartValue.interactionInProgress) + XCTAssertEqual(chartValue.currentValue, 42) + XCTAssertEqual(received?.value, 42) + XCTAssertEqual(received?.index, 3) + XCTAssertEqual(received?.isActive, true) + } + + func testPublishInactiveClearsInteractionState() { + let chartValue = ChartValue() + chartValue.currentValue = 10 + chartValue.interactionInProgress = true + var received: ChartSelectionEvent? + + ChartSelectionDispatcher.publish(chartValue: chartValue, + handler: { event in + received = event + }, + value: nil, + index: nil, + isActive: false) + + XCTAssertFalse(chartValue.interactionInProgress) + XCTAssertEqual(chartValue.currentValue, 10) + XCTAssertNil(received?.value) + XCTAssertNil(received?.index) + XCTAssertEqual(received?.isActive, false) + } +} diff --git a/Tests/SwiftUIChartsTests/ChartXAlignmentTests.swift b/Tests/SwiftUIChartsTests/ChartXAlignmentTests.swift new file mode 100644 index 00000000..c67f09c5 --- /dev/null +++ b/Tests/SwiftUIChartsTests/ChartXAlignmentTests.swift @@ -0,0 +1,87 @@ +@testable import SwiftUICharts +import SwiftUI +import XCTest + +final class ChartXScaleTests: XCTestCase { + + func testCategoricalPositionsUseCenteredSlots() { + let scale = ChartXScale(values: [0, 1, 2, 3, 4, 5, 6], + rangeX: nil, + mode: .categorical, + slotCountHint: 7) + + let expected: [Double] = [1, 3, 5, 7, 9, 11, 13].map { $0 / 14.0 } + let actual = (0...6).map { scale.normalizedX(for: Double($0)) } + + for (a, e) in zip(actual, expected) { + XCTAssertEqual(a, e, accuracy: 0.0001) + } + } + + func testNumericPositionsUseEdgeMappingWithinRange() { + let scale = ChartXScale(values: [10, 20, 30], rangeX: 10...30, mode: .numeric) + + XCTAssertEqual(scale.normalizedX(for: 10), 0, accuracy: 0.0001) + XCTAssertEqual(scale.normalizedX(for: 20), 0.5, accuracy: 0.0001) + XCTAssertEqual(scale.normalizedX(for: 30), 1, accuracy: 0.0001) + } + + func testSingleCategoricalValueCentersAtHalf() { + let scale = ChartXScale(values: [0], rangeX: nil, mode: .categorical, slotCountHint: 1) + XCTAssertEqual(scale.normalizedX(for: 0), 0.5, accuracy: 0.0001) + } + + func testNormalizedXIsClamped() { + let scale = ChartXScale(values: [10, 20, 30], rangeX: 10...30, mode: .numeric) + XCTAssertEqual(scale.normalizedX(for: -100), 0, accuracy: 0.0001) + XCTAssertEqual(scale.normalizedX(for: 100), 1, accuracy: 0.0001) + } +} + +final class ChartDataXNormalizationTests: XCTestCase { + + func testArrayDataUsesCategoricalCenterMapping() { + let data = ChartData([10, 20, 30], xDomainMode: .categorical) + let expected = [1.0 / 6.0, 3.0 / 6.0, 5.0 / 6.0] + + for (a, e) in zip(data.normalisedValues, expected) { + XCTAssertEqual(a, e, accuracy: 0.0001) + } + } + + func testArrayDataWithRangeRemainsCenteredWithinVisibleSlots() { + let data = ChartData([11, 12, 13], rangeX: 1...2, xDomainMode: .categorical) + XCTAssertEqual(data.normalisedValues.count, 2) + XCTAssertEqual(data.normalisedValues[0], 0.25, accuracy: 0.0001) + XCTAssertEqual(data.normalisedValues[1], 0.75, accuracy: 0.0001) + } + + func testTupleDataUsesNumericMapping() { + let data = ChartData([(10, 1), (20, 2), (30, 3)], rangeX: 10...30, xDomainMode: .numeric) + XCTAssertEqual(data.normalisedValues[0], 0, accuracy: 0.0001) + XCTAssertEqual(data.normalisedValues[1], 0.5, accuracy: 0.0001) + XCTAssertEqual(data.normalisedValues[2], 1, accuracy: 0.0001) + } +} + +final class ChartAxisLabelMapperTests: XCTestCase { + + func testStringLabelsMapToIndexedAnchors() { + let mapped = ChartAxisLabelMapper.mapXAxis(["A", "B", "C"]) + XCTAssertEqual(mapped, [ + ChartXAxisLabel(value: 0, title: "A"), + ChartXAxisLabel(value: 1, title: "B"), + ChartXAxisLabel(value: 2, title: "C") + ]) + } + + func testTupleLabelsPreserveValuesAndFillMissingRangeEntries() { + let mapped = ChartAxisLabelMapper.mapXAxis([(1, "A"), (2.5, "B2"), (3, "C")], in: 1...3) + XCTAssertEqual(mapped, [ + ChartXAxisLabel(value: 1, title: "A"), + ChartXAxisLabel(value: 2, title: ""), + ChartXAxisLabel(value: 2.5, title: "B2"), + ChartXAxisLabel(value: 3, title: "C") + ]) + } +} diff --git a/Tests/SwiftUIChartsTests/ColorExtensionTests.swift b/Tests/SwiftUIChartsTests/ColorExtensionTests.swift new file mode 100644 index 00000000..2a016ed2 --- /dev/null +++ b/Tests/SwiftUIChartsTests/ColorExtensionTests.swift @@ -0,0 +1,56 @@ +// +// ColorExtensionTests.swift +// SwiftUIChartsTests +// +// Created by Adrian Bolinger on 5/24/20. +// + +@testable import SwiftUICharts +import SwiftUI +import XCTest + +class ColorExtensionTests: XCTestCase { + func testTwentyFourBitRGBColors() { + let actualWhite = Color(hexString: "FFFFFF") + let expectedWhite = Color(red: 1, green: 1, blue: 1) + XCTAssertEqual(actualWhite, expectedWhite) + + let actualBlack = Color(hexString: "000000") + let expectedBlack = Color(red: 0, green: 0, blue: 0) + XCTAssertEqual(actualBlack, expectedBlack) + + let actualRed = Color(hexString: "FF0000") + let expectedRed = Color(red: 255/255, green: 0, blue: 0) + XCTAssertEqual(actualRed, expectedRed) + + let actualGreen = Color(hexString: "00FF00") + let expectedGreen = Color(red: 0, green: 1, blue: 0) + XCTAssertEqual(actualGreen, expectedGreen) + + let actualBlue = Color(hexString: "0000FF") + let expectedBlue = Color(red: 0, green: 0, blue: 1) + XCTAssertEqual(actualBlue, expectedBlue) + } + + func testTwelveBitRGBColors() { + let actualWhite = Color(hexString: "FFF") + let expectedWhite = Color(red: 1, green: 1, blue: 1) + XCTAssertEqual(actualWhite, expectedWhite) + + let actualBlack = Color(hexString: "000") + let expectedBlack = Color(red: 0, green: 0, blue: 0) + XCTAssertEqual(actualBlack, expectedBlack) + + let actualRed = Color(hexString: "F00") + let expectedRed = Color(red: 255/255, green: 0, blue: 0) + XCTAssertEqual(actualRed, expectedRed) + + let actualGreen = Color(hexString: "0F0") + let expectedGreen = Color(red: 0, green: 1, blue: 0) + XCTAssertEqual(actualGreen, expectedGreen) + + let actualBlue = Color(hexString: "00F") + let expectedBlue = Color(red: 0, green: 0, blue: 1) + XCTAssertEqual(actualBlue, expectedBlue) + } +} diff --git a/Tests/SwiftUIChartsTests/ComposableUsageSmokeTests.swift b/Tests/SwiftUIChartsTests/ComposableUsageSmokeTests.swift new file mode 100644 index 00000000..482d69fa --- /dev/null +++ b/Tests/SwiftUIChartsTests/ComposableUsageSmokeTests.swift @@ -0,0 +1,219 @@ +@testable import SwiftUICharts +import SwiftUI +import XCTest + +#if canImport(AppKit) +import AppKit +#endif + +final class ComposableUsageSmokeTests: XCTestCase { + + func testBarChartRendersWithOptionalInteractionValue() { + let view = BarChart() + .chartData([12, 34, 23]) + .chartStyle(sampleStyle) + .frame(width: 240, height: 140) + + assertCanRender(view) + } + + func testPieChartRendersWithOptionalInteractionValue() { + let view = PieChart() + .chartData([34, 23, 12]) + .chartStyle(sampleStyle) + .frame(width: 240, height: 240) + + assertCanRender(view) + } + + func testRingsChartRendersWithOptionalInteractionValue() { + let view = RingsChart() + .chartData([25, 50, 75]) + .chartStyle(sampleStyle) + .frame(width: 240, height: 240) + + assertCanRender(view) + } + + func testPieAndRingsRenderInHeightOnlyHStackWithoutLayoutCrash() { + let view = HStack(spacing: 12) { + PieChart() + .chartData([34, 23, 12]) + .chartStyle(sampleStyle) + .frame(height: 180) + + RingsChart() + .chartData([25, 50, 75, 90]) + .chartStyle(sampleStyle) + .frame(height: 180) + } + + assertCanRender(view) + } + + func testChartLabelCanShareExternalChartValue() { + let sharedChartValue = ChartValue() + + let view = VStack(alignment: .leading) { + ChartLabel("Sales", type: .title) + BarChart() + .chartData([12, 34, 23]) + .chartStyle(sampleStyle) + .frame(height: 140) + } + .frame(width: 280, height: 220) + .chartInteractionValue(sharedChartValue) + + assertCanRender(view) + } + + func testMixedBarAndLineWithAxisLabelsRenders() { + let view = AxisLabels { + ChartGrid { + BarChart() + .chartData([2, 4, 1, 3, 5]) + .chartStyle(self.sampleStyle) + LineChart() + .chartLineMarks(true) + .chartData([2, 4, 1, 3, 5]) + .chartStyle(self.sampleStyle) + } + .chartGridLines(horizontal: 5, vertical: 5) + } + .chartXAxisLabels([(0, "A"), (1, "B"), (2, "C"), (3, "D"), (4, "E")], range: 0...4) + .chartAxisColor(.gray) + .chartAxisFont(.caption) + .frame(width: 280, height: 220) + + assertCanRender(view) + } + + func testLegendAndSeriesVisibilityModifiersCompile() { + let hidden: Set = ["forecast"] + let view = VStack(alignment: .leading, spacing: 8) { + ChartLegend(items: [ + ChartLegendItem(id: "sales", title: "Sales", color: ColorGradient(.orange, .red)), + ChartLegendItem(id: "forecast", title: "Forecast", color: ColorGradient(.blue, .purple)) + ], hiddenSeries: .constant(hidden)) + AxisLabels { + ChartGrid { + LineChart() + .chartSeriesID("sales") + .chartData([2, 4, 3, 5, 4]) + .chartStyle(self.sampleStyle) + LineChart() + .chartSeriesID("forecast") + .chartData([1, 2, 4, 4, 5]) + .chartStyle(self.sampleStyle) + } + } + .chartHiddenSeries(hidden) + } + .frame(width: 280, height: 260) + + assertCanRender(view) + } + + func testStreamingDataSourceOverloadCompiles() { + let stream = ChartStreamingDataSource(initialValues: [12, 14, 18, 16], + windowSize: 4, + autoScroll: true) + stream.append(20) + + let view = LineChart() + .chartData(stream) + .chartYRange(stream.suggestedYRange) + .chartStyle(sampleStyle) + .frame(width: 280, height: 180) + + assertCanRender(view) + } + + func testAutoTickAndRotationModifiersCompile() { + let timestamps: [(Double, Double)] = [ + (1_704_067_200, 10), (1_704_153_600, 14), (1_704_240_000, 18), (1_704_326_400, 20) + ] + + let view = AxisLabels { + ChartGrid { + LineChart() + .chartData(timestamps) + .chartYRange(8...24) + .chartXRange(1_704_067_200...1_704_326_400) + .chartStyle(self.sampleStyle) + } + } + .chartXAxisAutoTicks(4, format: .shortDate) + .chartYAxisAutoTicks(4, format: .number) + .chartXAxisLabelRotation(.degrees(-22)) + .frame(width: 300, height: 220) + + assertCanRender(view) + } + + func testHighContrastStylePresetCompiles() { + let view = AxisLabels { + ChartGrid { + BarChart() + .chartData([8, 14, 11, 17]) + .chartStyle(.highContrast) + } + } + .chartXAxisLabels(["Q1", "Q2", "Q3", "Q4"]) + .frame(width: 280, height: 220) + + assertCanRender(view) + } + + func testPerformanceModeModifierCompiles() { + let data = (0..<1200).map { index -> (Double, Double) in + let x = Double(index) + return (x, sin(x / 30.0)) + } + + let view = LineChart() + .chartData(data) + .chartPerformance(.automatic(threshold: 500, maxPoints: 120, simplifyLineStyle: true)) + .chartStyle(sampleStyle) + .frame(width: 300, height: 180) + + assertCanRender(view) + } + + func testSelectionHandlerModifierCompiles() { + let view = AxisLabels { + ChartGrid { + BarChart() + .chartData([8, 11, 13, 9, 12]) + .chartStyle(self.sampleStyle) + } + } + .chartXAxisLabels(["M", "T", "W", "T", "F"]) + .chartSelectionHandler { event in + _ = event.value + _ = event.index + _ = event.isActive + } + .frame(width: 300, height: 220) + + assertCanRender(view) + } + + private var sampleStyle: ChartStyle { + ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)) + } + + private func assertCanRender(_ view: Content, + file: StaticString = #filePath, + line: UInt = #line) { + #if canImport(AppKit) + let hostingView = NSHostingView(rootView: view) + hostingView.layoutSubtreeIfNeeded() + _ = hostingView.fittingSize + #else + _ = view + #endif + + XCTAssertTrue(true, file: file, line: line) + } +} diff --git a/Tests/SwiftUIChartsTests/DocumentationIntegrityTests.swift b/Tests/SwiftUIChartsTests/DocumentationIntegrityTests.swift new file mode 100644 index 00000000..c5b1156d --- /dev/null +++ b/Tests/SwiftUIChartsTests/DocumentationIntegrityTests.swift @@ -0,0 +1,49 @@ +import Foundation +import XCTest + +final class DocumentationIntegrityTests: XCTestCase { + + func testExampleFileExists() { + let examplePath = repositoryRoot.appendingPathComponent("example.md").path + XCTAssertTrue(FileManager.default.fileExists(atPath: examplePath)) + } + + func testReadmeLinksToExampleFile() throws { + let readme = try String(contentsOf: repositoryRoot.appendingPathComponent("README.md"), encoding: .utf8) + XCTAssertTrue(readme.contains("./example.md")) + } + + func testReferencedReadmeResourcesExist() throws { + let readme = try String(contentsOf: repositoryRoot.appendingPathComponent("README.md"), encoding: .utf8) + let referencedResources = extractResourcePaths(from: readme) + + XCTAssertFalse(referencedResources.isEmpty) + + for resourcePath in referencedResources { + let fullPath = repositoryRoot.appendingPathComponent(resourcePath).path + XCTAssertTrue(FileManager.default.fileExists(atPath: fullPath), "Missing resource: \(resourcePath)") + } + } + + private var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private func extractResourcePaths(from content: String) -> Set { + guard let regex = try? NSRegularExpression(pattern: #"Resources/[A-Za-z0-9_.-]+"#) else { + return [] + } + + let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) + + return Set(matches.compactMap { match in + guard let range = Range(match.range, in: content) else { + return nil + } + return String(content[range]) + }) + } +} diff --git a/Tests/SwiftUIChartsTests/MigrationGuideTests.swift b/Tests/SwiftUIChartsTests/MigrationGuideTests.swift new file mode 100644 index 00000000..575d6383 --- /dev/null +++ b/Tests/SwiftUIChartsTests/MigrationGuideTests.swift @@ -0,0 +1,41 @@ +import Foundation +import XCTest + +final class MigrationGuideTests: XCTestCase { + + func testMigrationGuideExists() { + let migrationPath = repositoryRoot.appendingPathComponent("MIGRATION.md").path + XCTAssertTrue(FileManager.default.fileExists(atPath: migrationPath)) + } + + func testMigrationGuideCoversRemovedPublicTypes() throws { + let migration = try String(contentsOf: repositoryRoot.appendingPathComponent("MIGRATION.md"), encoding: .utf8) + + let requiredSymbols = [ + "LineChartView", + "BarChartView", + "PieChartView", + "MultiLineChartView", + "LineView", + "GradientColor", + "GradientColors", + "Colors", + "Styles", + "ChartForm", + "MultiLineChartData", + "MagnifierRect", + "TestData" + ] + + for symbol in requiredSymbols { + XCTAssertTrue(migration.contains(symbol), "Missing migration mapping for \(symbol)") + } + } + + private var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } +} diff --git a/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift b/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift index 0171d836..dc7dc53a 100644 --- a/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift +++ b/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift @@ -9,6 +9,6 @@ final class SwiftUIChartsTests: XCTestCase { } static var allTests = [ - ("testExample", testExample), + ("testExample", testExample) ] } diff --git a/Tests/SwiftUIChartsTests/XCTestManifests.swift b/Tests/SwiftUIChartsTests/XCTestManifests.swift index a3999a87..e80e5857 100644 --- a/Tests/SwiftUIChartsTests/XCTestManifests.swift +++ b/Tests/SwiftUIChartsTests/XCTestManifests.swift @@ -3,7 +3,7 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { return [ - testCase(SwiftUIChartsTests.allTests), + testCase(SwiftUIChartsTests.allTests) ] } #endif diff --git a/docs/wiki/01-getting-started.md b/docs/wiki/01-getting-started.md new file mode 100644 index 00000000..1b82a067 --- /dev/null +++ b/docs/wiki/01-getting-started.md @@ -0,0 +1,57 @@ +# Getting Started + +## Install + +Add the package in Xcode using Swift Package Manager: + +`https://github.com/AppPear/ChartView` + +For version `2.0.0`, pin to tag `2.0.0` or use a `from: "2.0.0"` rule. + +## Minimal line chart + +```swift +import SwiftUI +import SwiftUICharts + +struct ContentView: View { + var body: some View { + LineChart() + .chartData([3, 5, 4, 8, 6, 9, 7]) + .chartStyle( + ChartStyle( + backgroundColor: .white, + foregroundColor: ColorGradient(.blue, .purple) + ) + ) + .frame(height: 180) + .padding() + } +} +``` + +## Compose grid + axis + +```swift +AxisLabels { + ChartGrid { + LineChart() + .chartData([12, 22, 18, 26, 24, 30, 28]) + .chartYRange(10...32) + .chartStyle( + ChartStyle( + backgroundColor: .white, + foregroundColor: ColorGradient(.green, .blue) + ) + ) + } + .chartGridLines(horizontal: 5, vertical: 6) +} +.chartXAxisLabels(["M", "T", "W", "T", "F", "S", "S"]) +.chartAxisColor(.secondary) +.chartAxisFont(.caption) +``` + +## Core rule + +In `2.x`, always use `chart...` modifiers. Legacy `1.x` mutating chains are removed. diff --git a/docs/wiki/02-composable-modifiers.md b/docs/wiki/02-composable-modifiers.md new file mode 100644 index 00000000..25742141 --- /dev/null +++ b/docs/wiki/02-composable-modifiers.md @@ -0,0 +1,63 @@ +# Composable Modifiers + +The `2.x` API is environment-driven and modifier-first. + +## Data and range + +```swift +LineChart() + .chartData([Double]) // categorical + .chartData([(Double, Double)]) // numeric + .chartXRange(0...10) // optional + .chartYRange(0...100) // optional +``` + +## Grid + +```swift +ChartGrid { + LineChart() +} +.chartGridLines(horizontal: 5, vertical: 6) +.chartGridStroke(style: StrokeStyle(lineWidth: 1, dash: [6]), color: .gray) +.chartGridBaseline(true, style: StrokeStyle(lineWidth: 1)) +``` + +## Axis + +```swift +AxisLabels { + ChartGrid { + LineChart() + } +} +.chartXAxisLabels(["Q1", "Q2", "Q3", "Q4"]) +.chartYAxisLabels(["0", "50", "100"]) +.chartXAxisAutoTicks(6, format: .number) +.chartYAxisAutoTicks(5, format: .number) +.chartXAxisLabelRotation(.degrees(-20)) +.chartAxisFont(.caption) +.chartAxisColor(.secondary) +``` + +## Line options + +```swift +LineChart() + .chartLineWidth(3) + .chartLineBackground(ColorGradient(.blue.opacity(0.2), .clear)) + .chartLineMarks(true, color: ColorGradient(.blue, .purple)) + .chartLineStyle(.curved) // or .straight + .chartLineAnimation(true) +``` + +## Composition pattern + +Recommended build order: + +1. chart primitive (`LineChart`, `BarChart`, `PieChart`, `RingsChart`) +2. data + ranges (`chartData`, `chartXRange`, `chartYRange`) +3. style + chart-specific options +4. wrap in `ChartGrid` +5. wrap in `AxisLabels` +6. apply axis/grid interaction modifiers at container level diff --git a/docs/wiki/03-interaction-and-selection.md b/docs/wiki/03-interaction-and-selection.md new file mode 100644 index 00000000..49a0d2da --- /dev/null +++ b/docs/wiki/03-interaction-and-selection.md @@ -0,0 +1,59 @@ +# Interaction and Selection + +`2.x` supports two interaction styles: + +1. shared observable value (`ChartValue`) +2. callback events (`chartSelectionHandler`) + +## Shared interaction value + +Use when multiple views should react to the same selection state. + +```swift +let selected = ChartValue() + +VStack(alignment: .leading) { + ChartLabel("Weekly Sales", type: .title) + ChartLabel("Drag to inspect values", type: .legend, format: "%.1f") + + BarChart() + .chartData([12, 18, 16, 24, 21, 19, 22]) +} +.chartInteractionValue(selected) +``` + +## Callback-based interaction + +Use when you prefer event handling and do not want shared object state. + +```swift +@State private var selectionText = "No selection" + +BarChart() + .chartData([8, 12, 10, 14, 9]) + .chartSelectionHandler { event in + guard event.isActive, + let value = event.value, + let index = event.index else { + selectionText = "No selection" + return + } + selectionText = "Selected index \(index): \(value)" + } +``` + +## `ChartSelectionEvent` + +```swift +public struct ChartSelectionEvent { + public let value: Double? + public let index: Int? + public let isActive: Bool +} +``` + +## Notes + +- Basic rendering does not require `ChartValue`. +- Interaction is available on line, bar, pie, and rings charts. +- `ChartLabel` displays static text unless `chartInteractionValue` is present. diff --git a/docs/wiki/04-dynamic-and-streaming-data.md b/docs/wiki/04-dynamic-and-streaming-data.md new file mode 100644 index 00000000..91dea3cb --- /dev/null +++ b/docs/wiki/04-dynamic-and-streaming-data.md @@ -0,0 +1,57 @@ +# Dynamic and Streaming Data + +Use `ChartStreamingDataSource` for live or mock dynamic feeds. + +## Basic setup + +```swift +@ObservedObject private var stream = ChartStreamingDataSource( + initialValues: [18, 21, 20, 24, 23, 26], + windowSize: 6, + autoScroll: true +) +``` + +## Bind to a chart + +```swift +AxisLabels { + ChartGrid { + LineChart() + .chartData(stream) + .chartYRange(stream.suggestedYRange) + .chartStyle( + ChartStyle( + backgroundColor: .white, + foregroundColor: ColorGradient(.green, .blue) + ) + ) + } + .chartGridLines(horizontal: 5, vertical: max(2, stream.values.count)) +} +.chartXAxisLabels(stream.xLabels) +``` + +## Update the stream + +```swift +stream.append(27) +stream.append(contentsOf: [26, 29, 31]) +stream.reset([15, 17, 16], startingIndex: 1) +``` + +## Source behavior + +- `windowSize`: max visible points (with `autoScroll == true`) +- `autoScroll`: trims oldest points after overflow +- `xLabels`: generated from current visible window indices +- `suggestedYRange`: dynamic padded range based on current values + +## Internet-backed flow + +Typical pattern: + +1. fetch values from API +2. map response to `[Double]` +3. call `stream.append(...)` or `stream.reset(...)` +4. chart updates automatically through `ObservableObject` diff --git a/docs/wiki/05-performance-and-large-datasets.md b/docs/wiki/05-performance-and-large-datasets.md new file mode 100644 index 00000000..8a909947 --- /dev/null +++ b/docs/wiki/05-performance-and-large-datasets.md @@ -0,0 +1,45 @@ +# Performance and Large Datasets + +`LineChart` supports built-in downsampling through `chartPerformance(_:)`. + +## Modes + +```swift +public enum ChartPerformanceMode { + case none + case automatic(threshold: Int, maxPoints: Int, simplifyLineStyle: Bool) + case downsample(maxPoints: Int, simplifyLineStyle: Bool) +} +``` + +## Recommended setup + +```swift +LineChart() + .chartData(largeSeries) // e.g. 2000+ points + .chartPerformance(.automatic( + threshold: 600, + maxPoints: 180, + simplifyLineStyle: true + )) +``` + +## What simplification changes + +When `simplifyLineStyle` is enabled: + +- line style switches to straight +- chart marks are disabled +- line animation is disabled + +This reduces render overhead for dense data. + +## For categorical data + +For `chartData([Double])`, downsampled values are remapped to categorical slot indices to keep axis alignment stable. + +## Validation checklist + +1. Compare visual shape with and without downsampling. +2. Ensure tooltips/selection still target expected points. +3. Tune `threshold` and `maxPoints` for your dataset size and UI refresh rate. diff --git a/docs/wiki/06-unified-axis-and-label-alignment.md b/docs/wiki/06-unified-axis-and-label-alignment.md new file mode 100644 index 00000000..c4e9e4e6 --- /dev/null +++ b/docs/wiki/06-unified-axis-and-label-alignment.md @@ -0,0 +1,52 @@ +# Unified Axis and Label Alignment + +`2.x` uses a shared X-positioning pipeline across: + +- chart rendering (`LineChart`, `BarChart`) +- axis labels (`AxisLabels`) + +This removes drift where labels and marks used different spacing logic. + +## Domain behavior + +### Categorical data + +```swift +chartData([Double]) +``` + +- interpreted as category slots +- points are centered in slots +- labels map to slot-centered anchors + +### Numeric data + +```swift +chartData([(Double, Double)]) +``` + +- interpreted as continuous numeric domain +- points map by numeric x-values and optional `chartXRange` + +## Label APIs + +```swift +.chartXAxisLabels(["M", "T", "W", "T", "F", "S", "S"]) +.chartXAxisLabels([(0, "A"), (1, "B"), (2, "C")], range: 0...2) +``` + +## Auto ticks + +```swift +.chartXAxisAutoTicks(6, format: .number) +.chartYAxisAutoTicks(5, format: .number) +.chartXAxisLabelRotation(.degrees(-20)) +``` + +## Mixed bar + line guidance + +For aligned mixed charts: + +1. use the same data domain semantics for both layers +2. keep explicit `chartXRange` consistent when using numeric tuples +3. apply axis labels in the same container (`AxisLabels`) that wraps both layers diff --git a/docs/wiki/07-migration-from-1x.md b/docs/wiki/07-migration-from-1x.md new file mode 100644 index 00000000..2fa56e53 --- /dev/null +++ b/docs/wiki/07-migration-from-1x.md @@ -0,0 +1,42 @@ +# Migration from 1.x + +This page summarizes migration to `2.0.0`. + +## Breaking change summary + +- Legacy chart view types are replaced by composable chart primitives. +- Legacy mutating method chains are replaced by modifier APIs. +- Interaction no longer requires `@EnvironmentObject`. + +## Replace these first + +| 1.x | 2.x | +| --- | --- | +| `LineChartView` | `LineChart` | +| `BarChartView` | `BarChart` | +| `PieChartView` | `PieChart` | +| `MultiLineChartView` | multiple `LineChart` layers | + +## Replace common method chains + +| 1.x | 2.x | +| --- | --- | +| `.data(...)` | `.chartData(...)` | +| `.rangeX(...)` | `.chartXRange(...)` | +| `.rangeY(...)` | `.chartYRange(...)` | +| `.setAxisXLabels(...)` | `.chartXAxisLabels(...)` | +| `.setNumberOfHorizontalLines(...)` | `.chartGridLines(...)` | +| `.showChartMarks(...)` | `.chartLineMarks(...)` | + +## Interaction migration + +Pick one: + +1. shared state: + - `.chartInteractionValue(ChartValue())` +2. callback style: + - `.chartSelectionHandler { event in ... }` + +## Full mapping + +See [MIGRATION.md](../../MIGRATION.md) for complete type + method mapping. diff --git a/docs/wiki/README.md b/docs/wiki/README.md new file mode 100644 index 00000000..9dfc36c0 --- /dev/null +++ b/docs/wiki/README.md @@ -0,0 +1,22 @@ +# SwiftUICharts Wiki + +This wiki documents the `2.x` composable API. + +## Pages + +1. [Getting Started](./01-getting-started.md) +2. [Composable Modifiers](./02-composable-modifiers.md) +3. [Interaction and Selection](./03-interaction-and-selection.md) +4. [Dynamic and Streaming Data](./04-dynamic-and-streaming-data.md) +5. [Performance for Large Datasets](./05-performance-and-large-datasets.md) +6. [Unified Axis and Label Alignment](./06-unified-axis-and-label-alignment.md) +7. [Migration from 1.x](./07-migration-from-1x.md) + +## Version scope + +This wiki targets: + +- `2.0.0` and newer in the `2.x` line +- Composable modifier-based API only + +For old APIs, see [MIGRATION.md](../../MIGRATION.md). diff --git a/example.md b/example.md new file mode 100644 index 00000000..db8ef99f --- /dev/null +++ b/example.md @@ -0,0 +1,170 @@ +# SwiftUICharts + +### Example codes (modifier-based composable API) + +## Notes for 2.0.0 usage + +- Use modifier APIs only (`chartData`, `chartXRange`, `chartYRange`, `chartGridLines`, `chartXAxisLabels`, etc.). +- Use `chartInteractionValue(_:)` when you want shared interaction state. +- Use `chartSelectionHandler(_:)` when you prefer callback-based interaction events. +- Use `ChartStreamingDataSource` for dynamic feeds and `chartPerformance(_:)` for large datasets. + +

+ +

+ +```swift +import SwiftUI +import SwiftUICharts + +struct DemoView: View { + var body: some View { + VStack(alignment: .leading) { + Text("Sneakers sold") + .font(.title) + Text("Last week") + .font(.subheadline) + .foregroundColor(.gray) + .padding(.bottom, 8.0) + HStack { + AxisLabels { + ChartGrid { + LineChart() + .chartLineMarks(true) + .chartYRange(10...40) + .chartData([12, 34, 23, 18, 36, 22, 26]) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: ColorGradient(.blue, .purple))) + } + .chartGridLines(horizontal: 5, vertical: 0) + } + .chartXAxisLabels([(1, "M"), (2, "T"), (3, "W"), (4, "T"), (5, "F"), (6, "S"), (7, "S")], + range: 1...7) + .chartAxisColor(.gray) + .chartAxisFont(.caption) + + VStack(alignment: .leading, spacing: 8.0) { + Text("Highest revenue:") + .font(.callout) + Text("Tuesday") + .font(.subheadline) + .bold() + + Text("Most sales:") + .font(.callout) + Text("Friday") + .font(.subheadline) + .bold() + Spacer() + } + .frame(maxWidth: .infinity) + } + } + .padding(16.0) + .background(RoundedRectangle(cornerRadius: 20) + .fill(.white) + .shadow(radius: 8.0)) + .padding(32) + .frame(width: 450, height: 350) + } +} +``` + +

+ +

+ +```swift +import SwiftUI +import SwiftUICharts + +struct DemoView: View { + let chartValue = ChartValue() + + var body: some View { + VStack(alignment: .leading) { + Text("Sneaker brands") + .font(.title) + Text("By popularity") + .font(.subheadline) + .foregroundColor(.gray) + .padding(.bottom, 8.0) + HStack { + AxisLabels { + ChartGrid { + BarChart() + .chartData([34, 23, 12]) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: [ColorGradient(.red, .orange), + ColorGradient(.blue, .purple), + ColorGradient(.green, .yellow)])) + } + .chartGridLines(horizontal: 5, vertical: 0) + } + .chartYAxisLabels([(1, "0"), (2, "100"), (3, "200")], range: 1...3) + .chartAxisColor(.gray) + .chartAxisFont(.caption) + + VStack(alignment: .leading, spacing: 8.0) { + Text("Current") + .font(.callout) + ChartLabel("Sales", type: .legend, format: "%.0f") + .chartInteractionValue(chartValue) + Spacer() + } + .frame(maxWidth: .infinity) + } + } + .chartInteractionValue(chartValue) + .padding(16.0) + .background(RoundedRectangle(cornerRadius: 20) + .fill(.white) + .shadow(radius: 8.0)) + .padding(32) + .frame(width: 450, height: 350) + } +} +``` + +

+ +

+ +```swift +import SwiftUI +import SwiftUICharts + +struct DemoView: View { + var body: some View { + VStack(alignment: .leading) { + Text("Sneaker brands") + .font(.title) + Text("By popularity") + .font(.subheadline) + .foregroundColor(.gray) + .padding(.bottom, 8.0) + HStack { + PieChart() + .chartData([34, 23, 12]) + .chartStyle(ChartStyle(backgroundColor: .white, + foregroundColor: [ColorGradient(.red, .orange), + ColorGradient(.blue, .purple), + ColorGradient(.yellow, .green)])) + + VStack(alignment: .leading, spacing: 8.0) { + Text("Legend") + .font(.callout) + Spacer() + } + .frame(maxWidth: .infinity) + } + } + .padding(16.0) + .background(RoundedRectangle(cornerRadius: 20) + .fill(.white) + .shadow(radius: 8.0)) + .padding(32) + .frame(width: 450, height: 350) + } +} +``` diff --git a/midnightgreen.gif b/midnightgreen.gif deleted file mode 100644 index 7fc8f746..00000000 Binary files a/midnightgreen.gif and /dev/null differ diff --git a/showcase1.gif b/showcase1.gif deleted file mode 100644 index 52bba15a..00000000 Binary files a/showcase1.gif and /dev/null differ diff --git a/showcase2.gif b/showcase2.gif deleted file mode 100644 index 81a85c64..00000000 Binary files a/showcase2.gif and /dev/null differ diff --git a/showcase3.gif b/showcase3.gif deleted file mode 100644 index 497fed2a..00000000 Binary files a/showcase3.gif and /dev/null differ diff --git a/showcase4.png b/showcase4.png deleted file mode 100644 index a58c861f..00000000 Binary files a/showcase4.png and /dev/null differ diff --git a/showcase5.png b/showcase5.png deleted file mode 100644 index 319cd116..00000000 Binary files a/showcase5.png and /dev/null differ diff --git a/watchos1.png b/watchos1.png deleted file mode 100644 index 65d42e5e..00000000 Binary files a/watchos1.png and /dev/null differ