├── .gitignore ├── zodiackit-icon.png ├── .spi.yml ├── .vscode └── settings.json ├── .github └── pr-title-checker-config.json ├── Sources ├── ZodiacKit │ ├── Zodiacs │ │ ├── ZodiacPresets.swift │ │ ├── Presets │ │ │ ├── Tropical.swift │ │ │ ├── SideReal.swift │ │ │ ├── EqualLength.swift │ │ │ └── AstronomicalIAU.swift │ │ ├── Chinese.swift │ │ └── Western.swift │ ├── Protocols │ │ ├── ZodiacSign.swift │ │ └── ZodiacMetadataRepresentable.swift │ ├── Extensions │ │ ├── Calendar+Ext.swift │ │ ├── Int+Ext.swift │ │ ├── Date+Ext.swift │ │ └── AgnosticColor+Ext.swift │ ├── Utilities │ │ └── DateUtils.swift │ ├── Models │ │ ├── WesternZodiacSystem.swift │ │ ├── Zodiac.swift │ │ ├── ZodiacOverview.swift │ │ ├── ZodiacMetadata.swift │ │ └── ZodiacError.swift │ └── Services │ │ ├── ZodiacLoader.swift │ │ ├── ZodiacValidator.swift │ │ └── ZodiacService.swift └── Resources │ └── Localizable.xcstrings ├── Package.swift ├── LICENCE ├── Tests └── ZodiacKitTests │ ├── Western.swift │ ├── Dates.swift │ ├── Setup.swift │ └── Metadata.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .swiftpm 2 | .build -------------------------------------------------------------------------------- /zodiackit-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ZodiacKit/HEAD/zodiackit-icon.png -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [ZodiacKit] 5 | platform: ios 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "lldb.library": "/Applications/Xcode-16.3.0.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB", 3 | "lldb.launch.expressions": "native" 4 | } 5 | -------------------------------------------------------------------------------- /.github/pr-title-checker-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL": { 3 | "name": "PR title not in correct format", 4 | "color": "EEEEEE" 5 | }, 6 | "CHECKS": { 7 | "prefixes": [], 8 | "regexp": "^(19|20)\\d{2}(\/|-)(0[1-9]|1[1,2])(\/|-)(0[1-9]|[12][0-9]|3[01])", 9 | "regexpFlags": "", 10 | "ignoreLabels": [ 11 | "ignore-title" 12 | ] 13 | }, 14 | "MESSAGES": { 15 | "success": "All OK", 16 | "failure": "Failing CI test", 17 | "notice": "" 18 | } 19 | } -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/ZodiacPresets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// A namespace for predefined Western zodiac configurations. 10 | /// 11 | /// Each preset represents a different method for assigning zodiac date ranges, such as tropical 12 | /// (season-based) or sidereal (constellation-aligned). 13 | /// 14 | /// These are used by `ZodiacLoader` when loading zodiac systems like `.tropical` or `.sidereal`. 15 | internal enum ZodiacPresets {} 16 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Protocols/ZodiacSign.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// A protocol representing a zodiac sign. 10 | /// 11 | /// Conforming types must support encoding/decoding and be safe for concurrent use. 12 | /// 13 | /// This protocol is typically adopted by types representing specific zodiac signs (e.g. Western 14 | /// or Chinese systems), and allows for polymorphic usage. 15 | /// 16 | /// Conformance: 17 | /// - `Codable`: Enables serialization to/from external representations (e.g. JSON). 18 | /// - `Sendable`: Ensures safe use across concurrency boundaries (e.g. tasks, actors). 19 | internal protocol ZodiacSign: Codable, Sendable {} 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ZodiacKit", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .iOS(.v15), 10 | .macOS(.v12), 11 | .macCatalyst(.v15), 12 | .tvOS(.v15), 13 | .watchOS(.v8), 14 | .visionOS(.v1) 15 | ], 16 | products: [ 17 | .library( 18 | name: "ZodiacKit", 19 | targets: ["ZodiacKit"] 20 | ) 21 | ], 22 | targets: [ 23 | .target( 24 | name: "ZodiacKit", 25 | dependencies: [], 26 | path: "Sources", 27 | resources: [.process("Resources")] 28 | ), 29 | .testTarget( 30 | name: "ZodiacKitTests", 31 | dependencies: ["ZodiacKit"], 32 | path: "Tests" 33 | ) 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Extensions/Calendar+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Internal extension to provide commonly used calendar instances. 10 | internal extension Calendar { 11 | 12 | /// A Gregorian calendar instance configured to use GMT (UTC) timezone. 13 | /// 14 | /// Useful for consistent date calculations that do not depend on the user's local time zone. 15 | static let gregorian = Calendar(identifier: .gregorian).settingGMT() 16 | } 17 | 18 | private extension Calendar { 19 | 20 | /// Returns a copy of the calendar with its time zone set to GMT (UTC). 21 | /// 22 | /// This ensures date calculations remain consistent regardless of the user's local time zone. 23 | /// - Returns: A `Calendar` instance with the time zone set to GMT. 24 | func settingGMT() -> Calendar { 25 | var calendar = self 26 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! 27 | return calendar 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mark Battistella 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Extensions/Int+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | internal extension Int { 10 | 11 | /// A leap year used as a fixed reference point for calendar calculations. 12 | static let leapYear: Int = 2000 13 | 14 | /// The maximum number of days in a leap year (366). 15 | static let maxDayOfYear = 366 16 | 17 | /// The number of years in the Chinese zodiac cycle. 18 | static let chineseZodiacCycle = 12 19 | 20 | /// Converts a day-of-year integer into a `Date` using a leap year as the base year. 21 | /// 22 | /// This method uses the year defined in `Int.leapYear` to ensure it can always resolve day 23 | /// 366 (February 29), which is not present in non-leap years. 24 | /// 25 | /// - Parameter dayOfYear: The day of the year (1–366). 26 | /// - Returns: A `Date` if the value is valid within the leap year, otherwise `nil`. 27 | static func fromDayOfYear(_ dayOfYear: Int) -> Date? { 28 | let calendar = Calendar.gregorian 29 | return calendar.date(from: DateComponents(year: Int.leapYear, day: dayOfYear)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Utilities/DateUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Utility methods for working with zodiac-related date ranges. 10 | internal enum DateUtils { 11 | 12 | /// Computes all day-of-year values between two `ZodiacDay` values. 13 | /// 14 | /// This function handles wraparound cases (e.g. Capricorn: Dec 22 – Jan 19) by spanning from 15 | /// the `start` day to the end of the year, then continuing from day 1 to the `end`. 16 | /// 17 | /// - Parameters: 18 | /// - start: The starting `ZodiacDay`. 19 | /// - end: The ending `ZodiacDay`. 20 | /// - Throws: 21 | /// - `ZodiacError.couldNotConstructLeapDate` if a date cannot be created. 22 | /// - `ZodiacError.couldNotGetDayOfYear` if the day-of-year cannot be calculated. 23 | /// - Returns: An array of integers representing all covered day-of-year values (1–366). 24 | internal static func daysInRange( 25 | from start: Zodiac.ZodiacDay, 26 | to end: Zodiac.ZodiacDay 27 | ) throws -> [Int] { 28 | let startDay = try start.toDate().dayOfYear() 29 | let endDay = try end.toDate().dayOfYear() 30 | 31 | if endDay >= startDay { 32 | return Array(startDay...endDay) 33 | } else { 34 | // Wraps around the year boundary 35 | return Array(startDay...Int.maxDayOfYear) + Array(1...endDay) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/Presets/Tropical.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | extension ZodiacPresets { 10 | 11 | /// The traditional tropical zodiac system, based on the seasons and the Earth's position 12 | /// relative to the Sun. 13 | /// 14 | /// This is the most widely used system in Western astrology. Fixed date ranges based on 15 | /// equinoxes and solstices. 16 | public static var tropical: [Zodiac] {[ 17 | .init(sign: .aries, start: .init(day: 21, month: 3), end: .init(day: 19, month: 4)), 18 | .init(sign: .taurus, start: .init(day: 20, month: 4), end: .init(day: 20, month: 5)), 19 | .init(sign: .gemini, start: .init(day: 21, month: 5), end: .init(day: 20, month: 6)), 20 | .init(sign: .cancer, start: .init(day: 21, month: 6), end: .init(day: 22, month: 7)), 21 | .init(sign: .leo, start: .init(day: 23, month: 7), end: .init(day: 22, month: 8)), 22 | .init(sign: .virgo, start: .init(day: 23, month: 8), end: .init(day: 22, month: 9)), 23 | .init(sign: .libra, start: .init(day: 23, month: 9), end: .init(day: 22, month: 10)), 24 | .init(sign: .scorpio, start: .init(day: 23, month: 10), end: .init(day: 21, month: 11)), 25 | .init(sign: .sagittarius,start: .init(day: 22, month: 11), end: .init(day: 21, month: 12)), 26 | .init(sign: .capricorn, start: .init(day: 22, month: 12), end: .init(day: 19, month: 1)), 27 | .init(sign: .aquarius, start: .init(day: 20, month: 1), end: .init(day: 18, month: 2)), 28 | .init(sign: .pisces, start: .init(day: 19, month: 2), end: .init(day: 20, month: 3)) 29 | ]} 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/Presets/SideReal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | extension ZodiacPresets { 10 | 11 | /// The sidereal system aligns sign boundaries with current positions of constellations. 12 | /// 13 | /// Common in Eastern astrology (e.g., Vedic) and reflects the true astronomical sky due to 14 | /// precession. 15 | /// - Important: Includes 12 signs, no Ophiuchus. 16 | public static var sidereal: [Zodiac] {[ 17 | .init(sign: .aries, start: .init(day: 14, month: 4), end: .init(day: 14, month: 5)), 18 | .init(sign: .taurus, start: .init(day: 15, month: 5), end: .init(day: 15, month: 6)), 19 | .init(sign: .gemini, start: .init(day: 16, month: 6), end: .init(day: 16, month: 7)), 20 | .init(sign: .cancer, start: .init(day: 17, month: 7), end: .init(day: 16, month: 8)), 21 | .init(sign: .leo, start: .init(day: 17, month: 8), end: .init(day: 16, month: 9)), 22 | .init(sign: .virgo, start: .init(day: 17, month: 9), end: .init(day: 16, month: 10)), 23 | .init(sign: .libra, start: .init(day: 17, month: 10), end: .init(day: 15, month: 11)), 24 | .init(sign: .scorpio, start: .init(day: 16, month: 11), end: .init(day: 15, month: 12)), 25 | .init(sign: .sagittarius,start: .init(day: 16, month: 12), end: .init(day: 14, month: 1)), 26 | .init(sign: .capricorn, start: .init(day: 15, month: 1), end: .init(day: 12, month: 2)), 27 | .init(sign: .aquarius, start: .init(day: 13, month: 2), end: .init(day: 14, month: 3)), 28 | .init(sign: .pisces, start: .init(day: 15, month: 3), end: .init(day: 13, month: 4)) 29 | ]} 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/Presets/EqualLength.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | extension ZodiacPresets { 10 | 11 | /// An equal-length system that divides the zodiac wheel into 14 signs of uniform duration. 12 | /// 13 | /// Useful for abstracted or balanced representations. 14 | /// - Important: Includes Ophiuchus and Cetus. 15 | public static var equalLength: [Zodiac] {[ 16 | .init(sign: .aries, start: .init(day: 16, month: 4), end: .init(day: 11, month: 5)), 17 | .init(sign: .cetus, start: .init(day: 12, month: 5), end: .init(day: 6, month: 6)), 18 | .init(sign: .taurus, start: .init(day: 7, month: 6), end: .init(day: 2, month: 7)), 19 | .init(sign: .gemini, start: .init(day: 3, month: 7), end: .init(day: 28, month: 7)), 20 | .init(sign: .cancer, start: .init(day: 29, month: 7), end: .init(day: 23, month: 8)), 21 | .init(sign: .leo, start: .init(day: 24, month: 8), end: .init(day: 18, month: 9)), 22 | .init(sign: .virgo, start: .init(day: 19, month: 9), end: .init(day: 14, month: 10)), 23 | .init(sign: .libra, start: .init(day: 15, month: 10), end: .init(day: 9, month: 11)), 24 | .init(sign: .scorpio, start: .init(day: 10, month: 11), end: .init(day: 5, month: 12)), 25 | .init(sign: .ophiuchus, start: .init(day: 6, month: 12), end: .init(day: 31, month: 12)), 26 | .init(sign: .sagittarius, start: .init(day: 1, month: 1), end: .init(day: 26, month: 1)), 27 | .init(sign: .capricorn, start: .init(day: 27, month: 1), end: .init(day: 21, month: 2)), 28 | .init(sign: .aquarius, start: .init(day: 22, month: 2), end: .init(day: 20, month: 3)), 29 | .init(sign: .pisces, start: .init(day: 21, month: 3), end: .init(day: 15, month: 4)) 30 | ]} 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/Presets/AstronomicalIAU.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | extension ZodiacPresets { 10 | 11 | /// The astronomical IAU system, based on actual constellation boundaries defined by the 12 | /// International Astronomical Union. 13 | /// 14 | /// Includes 13 signs, with Ophiuchus positioned between Scorpio and Sagittarius. Date ranges 15 | /// reflect the real span of constellations in the sky. 16 | public static var astronomicalIAU: [Zodiac] {[ 17 | .init(sign: .aries, start: .init(day: 19, month: 4), end: .init(day: 13, month: 5)), 18 | .init(sign: .taurus, start: .init(day: 14, month: 5), end: .init(day: 21, month: 6)), 19 | .init(sign: .gemini, start: .init(day: 22, month: 6), end: .init(day: 20, month: 7)), 20 | .init(sign: .cancer, start: .init(day: 21, month: 7), end: .init(day: 10, month: 8)), 21 | .init(sign: .leo, start: .init(day: 11, month: 8), end: .init(day: 16, month: 9)), 22 | .init(sign: .virgo, start: .init(day: 17, month: 9), end: .init(day: 30, month: 10)), 23 | .init(sign: .libra, start: .init(day: 31, month: 10), end: .init(day: 23, month: 11)), 24 | .init(sign: .scorpio, start: .init(day: 24, month: 11), end: .init(day: 29, month: 11)), 25 | .init(sign: .ophiuchus, start: .init(day: 30, month: 11), end: .init(day: 17, month: 12)), 26 | .init(sign: .sagittarius, start: .init(day: 18, month: 12), end: .init(day: 20, month: 1)), 27 | .init(sign: .capricorn, start: .init(day: 21, month: 1), end: .init(day: 16, month: 2)), 28 | .init(sign: .aquarius, start: .init(day: 17, month: 2), end: .init(day: 11, month: 3)), 29 | .init(sign: .pisces, start: .init(day: 12, month: 3), end: .init(day: 18, month: 4)) 30 | ]} 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Models/WesternZodiacSystem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Represents the system used to define Western zodiac date ranges. 10 | /// 11 | /// These systems differ in how zodiac boundaries are calculated — from traditional seasonal 12 | /// divisions to astronomical precision. This enum also supports custom user-defined systems. 13 | public enum WesternZodiacSystem: Codable { 14 | 15 | /// The traditional tropical zodiac system based on the seasons and equinoxes. 16 | case tropical 17 | 18 | /// The sidereal zodiac system, aligning signs with current star constellations. 19 | case sidereal 20 | 21 | /// A simplified system where all signs are given equal-length durations. 22 | case equalLength 23 | 24 | /// A system based on actual International Astronomical Union (IAU) constellation boundaries. 25 | case astronomicalIAU 26 | 27 | /// A user-defined custom zodiac system using explicit start/end dates. 28 | case custom([Zodiac]) 29 | } 30 | 31 | extension WesternZodiacSystem: Equatable, Hashable { 32 | 33 | /// Compares two `WesternZodiacSystem` values for equality. 34 | /// 35 | /// - Parameters: 36 | /// - lhs: The left-hand side value. 37 | /// - rhs: The right-hand side value. 38 | /// - Returns: `true` if both values represent the same zodiac system, including matching 39 | /// custom zodiac sets; otherwise, `false`. 40 | public static func == (lhs: WesternZodiacSystem, rhs: WesternZodiacSystem) -> Bool { 41 | switch (lhs, rhs) { 42 | case (.tropical, .tropical), 43 | (.sidereal, .sidereal), 44 | (.equalLength, .equalLength), 45 | (.astronomicalIAU, .astronomicalIAU): 46 | return true 47 | case let (.custom(lhsZodiacs), .custom(rhsZodiacs)): 48 | return lhsZodiacs == rhsZodiacs 49 | default: 50 | return false 51 | } 52 | } 53 | 54 | // `Hashable` conformance is automatic unless you plan to manually implement `hash(into:)`. 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Extensions/Date+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | internal extension Date { 10 | 11 | /// Creates a `Date` instance from individual day, month, and year components. 12 | /// 13 | /// - Parameters: 14 | /// - day: The day component of the date. 15 | /// - month: The month component of the date. 16 | /// - year: The year component of the date. 17 | /// - calendar: The calendar to use for date construction. Defaults to Gregorian. 18 | /// - Returns: A `Date` if the components form a valid date, otherwise `nil`. 19 | static func from( 20 | day: Int, 21 | month: Int, 22 | year: Int, 23 | calendar: Calendar = .gregorian 24 | ) -> Date? { 25 | calendar.date(from: DateComponents(year: year, month: month, day: day)) 26 | } 27 | 28 | /// Returns the day of the year for the current date using a leap year as reference. 29 | /// 30 | /// This ensures consistency regardless of whether the year of `self` is a leap year. 31 | /// 32 | /// - Throws: 33 | /// - `ZodiacError.invalidDateComponents` if the day or month can't be extracted. 34 | /// - `ZodiacError.couldNotConstructLeapDate` if a leap-year date can't be formed. 35 | /// - `ZodiacError.couldNotGetDayOfYear` if the ordinal day can't be determined. 36 | /// - Returns: The day of the year as an `Int`, where January 1st is 1. 37 | func dayOfYear() throws -> Int { 38 | let calendar = Calendar.gregorian 39 | let components = calendar.dateComponents([.day, .month], from: self) 40 | 41 | guard let day = components.day, let month = components.month else { 42 | throw ZodiacError.invalidDateComponents(date: self) 43 | } 44 | 45 | let dateComponents = DateComponents(year: Int.leapYear, month: month, day: day) 46 | 47 | guard let adjustedDate = calendar.date(from: dateComponents) else { 48 | throw ZodiacError.couldNotConstructLeapDate(month: month, day: day) 49 | } 50 | 51 | guard let dayOfYear = calendar.ordinality(of: .day, in: .year, for: adjustedDate) else { 52 | throw ZodiacError.couldNotGetDayOfYear(adjustedDate: adjustedDate) 53 | } 54 | 55 | return dayOfYear 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Models/Zodiac.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// The bundle associated with the current Swift Package module. 10 | /// 11 | /// Use this property to access resources (such as assets, storyboards, or JSON files) included in 12 | /// the package target’s bundle. 13 | public let module: Bundle = .module 14 | 15 | /// A representation of a zodiac sign and its corresponding date range. 16 | public struct Zodiac: Codable { 17 | 18 | /// The western zodiac sign. 19 | public let sign: Western 20 | 21 | /// The start date of the zodiac sign’s period. 22 | public let start: ZodiacDay 23 | 24 | /// The end date of the zodiac sign’s period. 25 | public let end: ZodiacDay 26 | } 27 | 28 | extension Zodiac { 29 | 30 | /// A day and month pair representing a date in the zodiac calendar. 31 | /// 32 | /// Used for date comparisons and calculations independent of year. 33 | public struct ZodiacDay: Codable { 34 | 35 | /// The day of the month (1–31). 36 | public let day: Int 37 | 38 | /// The month of the year (1–12). 39 | public let month: Int 40 | 41 | /// Converts the `ZodiacDay` into a full `Date` using a leap year for consistency. 42 | /// 43 | /// This ensures February 29 is always valid and allows consistent day-of-year comparisons. 44 | /// 45 | /// - Throws: `ZodiacError.couldNotConstructLeapDate` if the date cannot be constructed. 46 | /// - Returns: A `Date` representing this `ZodiacDay` within the leap year. 47 | internal func toDate() throws -> Date { 48 | let components = DateComponents(year: Int.leapYear, month: self.month, day: self.day) 49 | guard let date = Calendar.gregorian.date(from: components) else { 50 | throw ZodiacError.couldNotConstructLeapDate(month: month, day: day) 51 | } 52 | return date 53 | } 54 | } 55 | } 56 | 57 | // MARK: - Conformances 58 | 59 | extension Zodiac: Equatable, Hashable {} 60 | extension Zodiac.ZodiacDay: Equatable, Hashable {} 61 | 62 | extension Zodiac.ZodiacDay: Comparable { 63 | 64 | /// Compares two `ZodiacDay` values chronologically. 65 | /// 66 | /// - Parameters: 67 | /// - lhs: The first day to compare. 68 | /// - rhs: The second day to compare. 69 | /// - Returns: `true` if `lhs` is earlier in the year than `rhs`. 70 | public static func < (lhs: Self, rhs: Self) -> Bool { 71 | (lhs.month, lhs.day) < (rhs.month, rhs.day) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Models/ZodiacOverview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Metadata that describes the traits and attributes of a zodiac sign. 10 | /// 11 | /// This includes symbolic information, personality traits, elemental associations, and 12 | /// compatibility with other signs. 13 | public struct ZodiacOverview { 14 | 15 | /// The zodiac name. 16 | public let name: String 17 | 18 | /// The emoji representing the zodiac sign (e.g. "♈️", "🐉"). 19 | public let emoji: String 20 | 21 | /// The classical element associated with the sign (e.g. Fire, Water). 22 | public let element: String 23 | 24 | /// An emoji representing the element (e.g. "🔥", "💧"). 25 | public let elementEmoji: String 26 | 27 | /// The modality of the sign (e.g. Cardinal, Fixed, Mutable). 28 | public let modality: String 29 | 30 | /// The polarity of the sign (e.g. Positive/Negative, Yin/Yang). 31 | public let polarity: String 32 | 33 | /// The Yin or Yang classification of the sign. 34 | public let yinYang: String 35 | 36 | /// The name of the ruling planet. 37 | public let rulingPlanetName: String 38 | 39 | /// The traditional ruling planet, if different from the modern one. 40 | public let traditionalRulingPlanetName: String? 41 | 42 | /// The astronomical symbol of the ruling planet (e.g. "♄" for Saturn). 43 | public let rulingPlanetSymbol: String 44 | 45 | /// The astrological house associated with the sign. 46 | public let rulingHouse: String 47 | 48 | /// The primary color associated with the sign, as a hex string. 49 | public let colorHEX: String 50 | 51 | /// The symbolic representation of the sign (e.g. "Ram", "Tiger"). 52 | public let symbol: String 53 | 54 | /// An emoji representing the symbol (e.g. "🐏", "🐅"). 55 | public let symbolEmoji: String 56 | 57 | /// The birthstone commonly associated with the sign. 58 | public let birthstone: String 59 | 60 | /// The season most strongly associated with the sign. 61 | public let season: String 62 | 63 | /// The brightest star or notable celestial body in the sign's constellation. 64 | public let brightestStar: String 65 | 66 | /// A general set of personality characteristics associated with the sign. 67 | public let characteristics: [String] 68 | 69 | /// Positive traits often attributed to this sign. 70 | public let strengths: [String] 71 | 72 | /// Common weaknesses or challenges related to this sign. 73 | public let weaknesses: [String] 74 | 75 | /// Key traits that best define the essence of the sign. 76 | public let keyTraits: [String] 77 | } 78 | -------------------------------------------------------------------------------- /Tests/ZodiacKitTests/Western.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import XCTest 8 | @testable import ZodiacKit 9 | 10 | // MARK: - Custom Western Zodiacs 11 | 12 | extension ZodiacKitTests { 13 | 14 | /// Validates that a correctly constructed custom zodiac array (with one full sign per month) 15 | /// passes validation without throwing any errors. 16 | /// 17 | /// This is used as the baseline for custom `.custom()` systems. 18 | func testValidZodiacs() { 19 | let zodiacs = Fixtures.validMonthBasedZodiacs() 20 | let expectedSigns = Set(zodiacs.map(\.sign)) 21 | XCTAssertNoThrow( 22 | try ZodiacValidator.validate(zodiacs: zodiacs, expectedZodiacs: expectedSigns) 23 | ) 24 | } 25 | 26 | /// Ensures that the validator throws a `.missingDates` error when there are gaps between 27 | /// zodiac ranges (i.e., unassigned days). 28 | /// 29 | /// This test uses a modified fixture where January ends on the 30th. 30 | func testZodiacValidation_ThrowsForMissingDates() { 31 | let zodiacs = Fixtures.withMissingDates() 32 | let expectedSigns = Set(zodiacs.map(\.sign)) 33 | 34 | XCTAssertThrowsError( 35 | try ZodiacValidator.validate(zodiacs: zodiacs, expectedZodiacs: expectedSigns) 36 | ) { error in 37 | assertZodiacError(error, expected: .missingDays(missingDays: [])) 38 | } 39 | } 40 | 41 | /// Ensures that the validator throws an `.overlappingDates` error when one sign's date 42 | /// range overlaps with another's. 43 | /// 44 | /// This test uses a modified fixture where Aquarius begins on January 31, 45 | /// overlapping with the end of Capricorn. 46 | func testZodiacValidation_ThrowsForOverlappingDates() { 47 | let zodiacs = Fixtures.withOverlappingDates() 48 | let expectedSigns = Set(zodiacs.map(\.sign)) 49 | 50 | XCTAssertThrowsError( 51 | try ZodiacValidator.validate(zodiacs: zodiacs, expectedZodiacs: expectedSigns) 52 | ) { error in 53 | assertZodiacError(error, expected: .overlappingDays(days: [])) 54 | } 55 | } 56 | 57 | /// Ensures that the validator throws a `.duplicatedZodiac` error when two entries use the 58 | /// same zodiac sign. 59 | /// 60 | /// This test uses a fixture where both Capricorn and Aquarius are set to Capricorn. 61 | func testZodiacValidation_ThrowsForDuplicateSigns() { 62 | let zodiacs = Fixtures.withDuplicateSigns() 63 | let expectedSigns = Set(Western.tropicalCases) 64 | 65 | XCTAssertThrowsError( 66 | try ZodiacValidator.validate(zodiacs: zodiacs, expectedZodiacs: expectedSigns) 67 | ) { error in 68 | assertZodiacError(error, expected: .duplicateZodiacsFound(duplicates: [])) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Services/ZodiacLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// A utility enum for loading and transforming zodiac data based on a specified system. 10 | internal enum ZodiacLoader { 11 | 12 | /// Loads an array of `Zodiac` values based on the provided `WesternZodiacSystem`. 13 | /// 14 | /// This method selects the appropriate preset or custom configuration for the system and 15 | /// validates the loaded zodiacs to ensure they cover all expected signs. 16 | /// 17 | /// - Parameter system: The zodiac system to load (`tropical`, `sidereal`, etc.). 18 | /// - Throws: `ZodiacError.validationFailed` if validation fails. 19 | /// - Returns: An array of validated `Zodiac` instances. 20 | static func loadZodiacs(from system: WesternZodiacSystem) throws -> [Zodiac] { 21 | let zodiacs: [Zodiac] 22 | let expectedSigns: Set 23 | 24 | switch system { 25 | case .tropical: 26 | zodiacs = ZodiacPresets.tropical 27 | expectedSigns = Set(Western.tropicalCases) 28 | 29 | case .sidereal: 30 | zodiacs = ZodiacPresets.sidereal 31 | expectedSigns = Set(Western.siderealCases) 32 | 33 | case .equalLength: 34 | zodiacs = ZodiacPresets.equalLength 35 | expectedSigns = Set(Western.equalLengthCases) 36 | 37 | case .astronomicalIAU: 38 | zodiacs = ZodiacPresets.astronomicalIAU 39 | expectedSigns = Set(Western.astronomicalIAUCases) 40 | 41 | case .custom(let custom): 42 | zodiacs = custom 43 | expectedSigns = Set(custom.map(\.sign)) 44 | } 45 | 46 | // Validate loaded zodiacs match the expected set 47 | try ZodiacValidator.validate(zodiacs: zodiacs, expectedZodiacs: expectedSigns) 48 | return zodiacs 49 | } 50 | 51 | /// Maps a list of `Zodiac` entries to a dictionary keyed by day-of-year. 52 | /// 53 | /// Each day of the year within a zodiac's date range is associated with that sign. Useful for 54 | /// fast lookup by date (e.g. "what sign is this day?"). 55 | /// 56 | /// - Parameter zodiacs: An array of zodiac date ranges. 57 | /// - Throws: Errors if any date range is invalid or cannot be processed. 58 | /// - Returns: A dictionary mapping day-of-year integers (1–366) to `Western` signs. 59 | static func mapZodiacsToDaysOfYear(from zodiacs: [Zodiac]) throws -> [Int: Western] { 60 | var mapping: [Int: Western] = [:] 61 | 62 | for zodiac in zodiacs { 63 | let days = try DateUtils.daysInRange(from: zodiac.start, to: zodiac.end) 64 | for day in days { 65 | mapping[day] = zodiac.sign 66 | } 67 | } 68 | 69 | return mapping 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/ZodiacKitTests/Dates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import XCTest 8 | @testable import ZodiacKit 9 | 10 | // MARK: - System Start/End/Middle Date Tests 11 | 12 | @MainActor 13 | extension ZodiacKitTests { 14 | 15 | func testStartDatesReturnCorrectSignForAllSystems() throws { 16 | for (system, label) in systemsToTest { 17 | let service = ZodiacService(system: system) 18 | for zodiac in service.zodiacs { 19 | let startDate = try zodiac.start.toDate() 20 | let result = try service.getWesternZodiac(from: startDate) 21 | XCTAssertEqual( 22 | result, 23 | zodiac.sign, 24 | "\(label): Expected \(zodiac.sign) for start date \(zodiac.start)" 25 | ) 26 | } 27 | } 28 | } 29 | 30 | func testEndDatesReturnCorrectSignForAllSystems() throws { 31 | for (system, label) in systemsToTest { 32 | let service = ZodiacService(system: system) 33 | for zodiac in service.zodiacs { 34 | let endDate = try zodiac.end.toDate() 35 | let result = try service.getWesternZodiac(from: endDate) 36 | XCTAssertEqual( 37 | result, 38 | zodiac.sign, 39 | "\(label): Expected \(zodiac.sign) for end date \(zodiac.end)" 40 | ) 41 | } 42 | } 43 | } 44 | 45 | func testMiddleDatesReturnCorrectSignForAllSystems() throws { 46 | for (system, label) in systemsToTest { 47 | let service = ZodiacService(system: system) 48 | 49 | for zodiac in service.zodiacs { 50 | guard 51 | let startDate = try? zodiac.start.toDate(), 52 | var endDate = try? zodiac.end.toDate() 53 | else { 54 | XCTFail("Could not resolve dates for \(zodiac.sign)") 55 | continue 56 | } 57 | 58 | if endDate < startDate { 59 | endDate = Calendar.gregorian.date(byAdding: .year, value: 1, to: endDate)! 60 | } 61 | 62 | let totalDays = Calendar.gregorian.dateComponents( 63 | [.day], 64 | from: startDate, 65 | to: endDate 66 | ).day ?? 0 67 | let midOffset = totalDays / 2 68 | 69 | guard let midDate = Calendar.gregorian.date( 70 | byAdding: .day, 71 | value: midOffset, 72 | to: startDate 73 | ) else { 74 | XCTFail("Could not calculate mid date for \(zodiac.sign) in \(label)") 75 | continue 76 | } 77 | 78 | let result = try service.getWesternZodiac(from: midDate) 79 | XCTAssertEqual( 80 | result, 81 | zodiac.sign, 82 | "\(label): Expected \(zodiac.sign) for middle date of its range" 83 | ) 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Extensions/AgnosticColor+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | #if canImport(UIKit) 8 | import UIKit 9 | 10 | /// A platform-agnostic typealias for color, using `UIColor` on iOS/tvOS/watchOS. 11 | public typealias AgnosticColor = UIColor 12 | 13 | #elseif canImport(AppKit) 14 | import AppKit 15 | 16 | /// A platform-agnostic typealias for color, using `NSColor` on macOS. 17 | public typealias AgnosticColor = NSColor 18 | #endif 19 | 20 | extension AgnosticColor { 21 | 22 | /// Initializes a color from a hexadecimal string. 23 | /// 24 | /// Supported formats: 25 | /// - `#RGB` 26 | /// - `#RGBA` 27 | /// - `#RRGGBB` 28 | /// - `#RRGGBBAA` 29 | /// 30 | /// The `#` prefix is optional. Alpha defaults to 1.0 if not provided. 31 | /// 32 | /// - Parameter hex: A hexadecimal color string. 33 | public convenience init?(hex: String) { 34 | guard let parsedColor = HexColorParser(hex: hex) else { return nil } 35 | self.init( 36 | red: parsedColor.red, 37 | green: parsedColor.green, 38 | blue: parsedColor.blue, 39 | alpha: parsedColor.alpha 40 | ) 41 | } 42 | } 43 | 44 | /// A helper struct to parse hexadecimal color strings into RGBA components. 45 | internal struct HexColorParser { 46 | 47 | /// Alpha component of the color (0.0 - 1.0). 48 | let alpha: CGFloat 49 | 50 | /// Red component of the color (0.0 - 1.0). 51 | let red: CGFloat 52 | 53 | /// Green component of the color (0.0 - 1.0). 54 | let green: CGFloat 55 | 56 | /// Blue component of the color (0.0 - 1.0). 57 | let blue: CGFloat 58 | 59 | /// Parses a hexadecimal color string into its RGBA components. 60 | /// 61 | /// Handles both short and full formats, with or without alpha: 62 | /// - `RGB`, `RGBA`, `RRGGBB`, `RRGGBBAA` 63 | /// 64 | /// - Parameter hex: A string containing the hexadecimal color. 65 | /// - Returns: `nil` if the string is invalid or cannot be parsed. 66 | init?(hex: String) { 67 | var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() 68 | 69 | if hexString.hasPrefix("#") { 70 | hexString.removeFirst() 71 | } 72 | 73 | // Expand shorthand formats like RGB or RGBA to RRGGBB or RRGGBBAA 74 | if hexString.count == 3 || hexString.count == 4 { 75 | hexString = hexString.enumerated().map { "\($0.element)\($0.element)" }.joined() 76 | } 77 | 78 | guard hexString.count == 6 || hexString.count == 8 else { return nil } 79 | 80 | let scanner = Scanner(string: hexString) 81 | var rgbValue: UInt64 = 0 82 | guard scanner.scanHexInt64(&rgbValue) else { return nil } 83 | 84 | func normalizeColorComponent(_ component: UInt64) -> CGFloat { 85 | CGFloat(component) / 255.0 86 | } 87 | 88 | let alpha = hexString.count == 8 ? (rgbValue >> 24) & 0xFF : 0xFF 89 | self.alpha = normalizeColorComponent(alpha) 90 | self.red = normalizeColorComponent((rgbValue >> 16) & 0xFF) 91 | self.green = normalizeColorComponent((rgbValue >> 8) & 0xFF) 92 | self.blue = normalizeColorComponent(rgbValue & 0xFF) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Models/ZodiacMetadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Metadata that describes the traits and attributes of a zodiac sign. 10 | /// 11 | /// This includes symbolic information, personality traits, elemental associations, and 12 | /// compatibility with other signs. 13 | internal struct ZodiacMetadata { 14 | 15 | /// The emoji representing the zodiac sign (e.g. "♈️", "🐉"). 16 | let emoji: String 17 | 18 | /// The classical element associated with the sign (e.g. Fire, Water). 19 | let element: String 20 | 21 | /// An emoji representing the element (e.g. "🔥", "💧"). 22 | let elementEmoji: String 23 | 24 | /// The modality of the sign (e.g. Cardinal, Fixed, Mutable). 25 | let modality: String 26 | 27 | /// The polarity of the sign (e.g. Positive/Negative, Yin/Yang). 28 | let polarity: String 29 | 30 | /// The Yin or Yang classification of the sign. 31 | let yinYang: String 32 | 33 | /// The name of the ruling planet. 34 | let rulingPlanetName: String 35 | 36 | /// The traditional ruling planet, if different from the modern one. 37 | let traditionalRulingPlanetName: String? 38 | 39 | /// The astronomical symbol of the ruling planet (e.g. "♄" for Saturn). 40 | let rulingPlanetSymbol: String 41 | 42 | /// The astrological house associated with the sign. 43 | let rulingHouse: String 44 | 45 | /// The primary color associated with the sign, as a hex string. 46 | let colorHEX: String 47 | 48 | /// The symbolic representation of the sign (e.g. "Ram", "Tiger"). 49 | let symbol: String 50 | 51 | /// An emoji representing the symbol (e.g. "🐏", "🐅"). 52 | let symbolEmoji: String 53 | 54 | /// The birthstone commonly associated with the sign. 55 | let birthstone: String 56 | 57 | /// The season most strongly associated with the sign. 58 | let season: String 59 | 60 | /// The brightest star or notable celestial body in the sign's constellation. 61 | let brightestStar: String 62 | 63 | /// A general set of personality characteristics associated with the sign. 64 | let characteristics: [String] 65 | 66 | /// Positive traits often attributed to this sign. 67 | let strengths: [String] 68 | 69 | /// Common weaknesses or challenges related to this sign. 70 | let weaknesses: [String] 71 | 72 | /// Key traits that best define the essence of the sign. 73 | let keyTraits: [String] 74 | 75 | /// Compatibility information for this sign with others. 76 | let compatibilityInfo: CompatibilityInfo 77 | } 78 | 79 | /// A structure that describes zodiac sign compatibility. 80 | /// 81 | /// Defines relationships between one sign and others across various levels of alignment. 82 | internal struct CompatibilityInfo: Codable, Hashable { 83 | 84 | /// Signs that are considered the most naturally compatible. 85 | let bestMatches: Set 86 | 87 | /// Signs that have average or neutral compatibility. 88 | let averageMatches: Set 89 | 90 | /// Signs that may have conflict or tension, but are not inherently harmful. 91 | let conflictingMatches: Set 92 | 93 | /// Signs that tend to have strong incompatibility or disharmony. 94 | let harmfulMatches: Set 95 | } 96 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Protocols/ZodiacMetadataRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// A protocol that defines the metadata associated with a zodiac sign. 10 | /// 11 | /// Conforming types provide descriptive properties such as symbolism, traits, compatibility, and 12 | /// astronomical associations. This protocol supports both Western and Chinese zodiac systems 13 | /// through a shared contract. 14 | internal protocol ZodiacMetadataRepresentable: RawRepresentable, CaseIterable, Codable, Hashable where RawValue == String { 15 | 16 | static var metadataMap: [Self: ZodiacMetadata] { get } 17 | 18 | // MARK: - Display 19 | 20 | /// The human-readable name of the zodiac sign. 21 | var name: String { get } 22 | 23 | /// An emoji representing the sign (e.g. "♌", "🐉"). 24 | var emoji: String { get } 25 | 26 | // MARK: - Elemental Attributes 27 | 28 | /// The classical element associated with the sign (e.g. Fire, Earth). 29 | var element: String { get } 30 | 31 | /// An emoji representation of the element (e.g. "🔥", "🌎"). 32 | var elementEmoji: String { get } 33 | 34 | // MARK: - Traits 35 | 36 | /// General characteristics commonly associated with the sign. 37 | var characteristics: [String] { get } 38 | 39 | /// Primary strengths of the sign. 40 | var strengths: [String] { get } 41 | 42 | /// Common weaknesses or challenges for the sign. 43 | var weaknesses: [String] { get } 44 | 45 | /// A distilled list of key traits that define the sign. 46 | var keyTraits: [String] { get } 47 | 48 | // MARK: - Aesthetic 49 | 50 | /// The primary color associated with the sign, expressed as a hex string (e.g. `#FF5733`). 51 | var colorHEX: String { get } 52 | 53 | // MARK: - Astrological Properties 54 | 55 | /// The modern ruling planet of the sign (e.g. Mars, Mercury). 56 | var rulingPlanetName: String { get } 57 | 58 | /// The traditional ruling planet, if different from the modern one. 59 | var traditionalRulingPlanetName: String? { get } 60 | 61 | /// The astronomical symbol of the ruling planet (e.g. "♄", "♀"). 62 | var rulingPlanetSymbol: String { get } 63 | 64 | /// The modality of the sign (e.g. Cardinal, Fixed, Mutable). 65 | var modality: String { get } 66 | 67 | /// The polarity of the sign (e.g. Positive, Negative). 68 | var polarity: String { get } 69 | 70 | /// The astrological house associated with the sign (e.g. "6th House"). 71 | var rulingHouse: String { get } 72 | 73 | /// The brightest or most notable star in the constellation of the sign. 74 | var brightestStar: String { get } 75 | 76 | /// The Yin or Yang classification of the sign. 77 | var yinYang: String { get } 78 | 79 | /// The season most strongly associated with the sign (e.g. "Spring"). 80 | var season: String { get } 81 | 82 | // MARK: - Symbolism 83 | 84 | /// A symbolic name or totem of the sign (e.g. "Scorpion", "Tiger"). 85 | var symbol: String { get } 86 | 87 | /// An emoji representing the symbol (e.g. "🦂", "🐅"). 88 | var symbolEmoji: String { get } 89 | 90 | /// The gemstone traditionally associated with the sign. 91 | var birthstone: String { get } 92 | 93 | // MARK: - Compatibility 94 | 95 | /// Compatibility information describing how this sign interacts with others. 96 | // var compatibilityInfo: CompatibilityInfo { get } 97 | 98 | /// Signs that are considered the most naturally compatible. 99 | var bestMatches: [Self] { get } 100 | 101 | /// Signs that have average or neutral compatibility. 102 | var averageMatches: [Self] { get } 103 | 104 | /// Signs that may have conflict or tension, but are not inherently harmful. 105 | var conflictingMatches: [Self] { get } 106 | 107 | /// Signs that tend to have strong incompatibility or disharmony. 108 | var harmfulMatches: [Self] { get } 109 | } 110 | 111 | extension ZodiacMetadataRepresentable { 112 | 113 | /// Provides detailed metadata for the zodiac sign. 114 | internal var metadata: ZodiacMetadata { 115 | Self.metadataMap[self]! 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Services/ZodiacValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// A utility responsible for validating arrays of `Zodiac` values. 10 | /// 11 | /// Ensures that a zodiac system is fully defined, non-overlapping, and continuous across the year. 12 | internal struct ZodiacValidator { 13 | 14 | /// Validates a zodiac configuration against a set of expected signs. 15 | /// 16 | /// This performs multiple validation steps: 17 | /// 1. Ensures no duplicate signs exist. 18 | /// 2. Ensures all expected signs are included. 19 | /// 3. Ensures every day of the year is covered by one and only one zodiac. 20 | /// 4. Ensures there are no overlapping date ranges. 21 | /// 5. Ensures the date ranges are continuous from day 1 through 366. 22 | /// 23 | /// - Parameters: 24 | /// - zodiacs: The zodiac definitions to validate. 25 | /// - expectedZodiacs: A set of expected signs for the system. 26 | /// - Throws: A `ZodiacError` describing the first validation failure encountered. 27 | internal static func validate(zodiacs: [Zodiac], expectedZodiacs: Set) throws { 28 | 29 | // Check for duplicate zodiac signs 30 | let duplicates = duplicateSigns(in: zodiacs) 31 | guard duplicates.isEmpty else { 32 | throw ZodiacError.duplicateZodiacsFound(duplicates: duplicates) 33 | } 34 | 35 | // Check for missing signs 36 | let missing = missingSigns(in: zodiacs, expected: expectedZodiacs) 37 | guard missing.isEmpty else { 38 | throw ZodiacError.missingZodiacs(missing: missing) 39 | } 40 | 41 | // Check all days of the year are covered 42 | try validateAllDaysCovered(zodiacs: zodiacs) 43 | 44 | // Check for no overlapping days 45 | try validateNoOverlap(zodiacs: zodiacs) 46 | 47 | // Check date ranges are continuous 48 | try validateContinuousRanges(zodiacs: zodiacs) 49 | } 50 | } 51 | 52 | // MARK: - Helpers 53 | 54 | private extension ZodiacValidator { 55 | 56 | // MARK: - Duplicate and Missing Sign Checks 57 | 58 | /// Detects any duplicate zodiac signs in the input. 59 | /// 60 | /// - Parameter zodiacs: The array of zodiacs to inspect. 61 | /// - Returns: An array of duplicated `Western` signs. 62 | private static func duplicateSigns( 63 | in zodiacs: [Zodiac] 64 | ) -> [Western] { 65 | Dictionary(grouping: zodiacs, by: \.sign) 66 | .filter { $1.count > 1 } 67 | .map { $0.key } 68 | } 69 | 70 | /// Identifies any missing signs compared to the expected set. 71 | /// 72 | /// - Parameters: 73 | /// - zodiacs: The current zodiac definitions. 74 | /// - expected: The full set of signs expected to be present. 75 | /// - Returns: An array of missing `Western` signs. 76 | private static func missingSigns( 77 | in zodiacs: [Zodiac], 78 | expected: Set 79 | ) -> [Western] { 80 | let providedSigns = Set(zodiacs.map(\.sign)) 81 | return Array(expected.subtracting(providedSigns)) 82 | } 83 | 84 | // MARK: - Day Coverage Validation 85 | 86 | /// Ensures every day of the year is covered by the zodiac definitions. 87 | /// 88 | /// - Parameter zodiacs: The zodiacs to validate. 89 | /// - Throws: `ZodiacError.missingDays` if any days are left uncovered. 90 | private static func validateAllDaysCovered( 91 | zodiacs: [Zodiac] 92 | ) throws { 93 | var coveredDays = Set() 94 | 95 | for zodiac in zodiacs { 96 | let days = try DateUtils.daysInRange(from: zodiac.start, to: zodiac.end) 97 | coveredDays.formUnion(days) 98 | } 99 | 100 | let expectedDays = Set(1...Int.maxDayOfYear) 101 | let missingDays = expectedDays.subtracting(coveredDays) 102 | 103 | if !missingDays.isEmpty { 104 | let missingDates = missingDays.compactMap { Int.fromDayOfYear($0) }.sorted() 105 | throw ZodiacError.missingDays(missingDays: missingDates) 106 | } 107 | } 108 | 109 | // MARK: - Overlap Validation 110 | 111 | /// Ensures no overlapping day-of-year values exist across zodiac definitions. 112 | /// 113 | /// - Parameter zodiacs: The zodiacs to inspect. 114 | /// - Throws: `ZodiacError.overlappingDays` if any days are claimed by more than one sign. 115 | private static func validateNoOverlap( 116 | zodiacs: [Zodiac] 117 | ) throws { 118 | var seenDays = Set() 119 | var overlappingDays = Set() 120 | 121 | for zodiac in zodiacs { 122 | let days = try DateUtils.daysInRange(from: zodiac.start, to: zodiac.end) 123 | for day in days { 124 | if !seenDays.insert(day).inserted { 125 | overlappingDays.insert(day) 126 | } 127 | } 128 | } 129 | 130 | if !overlappingDays.isEmpty { 131 | throw ZodiacError.overlappingDays(days: overlappingDays.sorted()) 132 | } 133 | } 134 | 135 | // MARK: - Continuity Validation 136 | 137 | /// Validates that the combined date ranges are continuous from day 1 to day 366. 138 | /// 139 | /// - Parameter zodiacs: The zodiac definitions to validate. 140 | /// - Throws: `ZodiacError.nonContinuousRanges` if there are gaps or reordering. 141 | private static func validateContinuousRanges( 142 | zodiacs: [Zodiac] 143 | ) throws { 144 | let allDaysSorted = try zodiacs.flatMap { 145 | try DateUtils.daysInRange(from: $0.start, to: $0.end) 146 | }.sorted() 147 | 148 | guard allDaysSorted == Array(1...Int.maxDayOfYear) else { 149 | throw ZodiacError.nonContinuousRanges 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Tests/ZodiacKitTests/Setup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import XCTest 8 | @testable import ZodiacKit 9 | 10 | final class ZodiacKitTests: XCTestCase { 11 | 12 | var service: ZodiacService! 13 | 14 | @MainActor 15 | override func setUp() async throws { 16 | await MainActor.run { 17 | self.service = ZodiacService() 18 | } 19 | } 20 | 21 | internal func assertZodiacAttribute( 22 | attributeName: String, 23 | expectedValues: [T], 24 | attributeClosure: (Z) -> T 25 | ) { 26 | for (index, sign) in Z.allCases.enumerated() { 27 | XCTAssertEqual( 28 | attributeClosure(sign), 29 | expectedValues[index], 30 | "\(attributeName) is incorrect for \(sign.rawValue)" 31 | ) 32 | } 33 | } 34 | 35 | internal func assertZodiacError( 36 | _ error: Error, 37 | expected: ZodiacError, 38 | file: StaticString = #filePath, 39 | line: UInt = #line 40 | ) { 41 | switch (error, expected) { 42 | 43 | case (ZodiacError.duplicateZodiacsFound, .duplicateZodiacsFound), 44 | (ZodiacError.missingZodiacs, .missingZodiacs), 45 | (ZodiacError.missingDays, .missingDays), 46 | (ZodiacError.overlappingDays, .overlappingDays), 47 | (ZodiacError.nonContinuousRanges, .nonContinuousRanges), 48 | (ZodiacError.invalidData, .invalidData), 49 | (ZodiacError.dayNumberNotFound, .dayNumberNotFound), 50 | (ZodiacError.invalidDateComponents, .invalidDateComponents), 51 | (ZodiacError.couldNotConstructLeapDate, .couldNotConstructLeapDate), 52 | (ZodiacError.couldNotGetDayOfYear, .couldNotGetDayOfYear): 53 | 54 | break // Considered matching — ignoring associated values 55 | 56 | default: 57 | XCTFail("Expected \(expected), but got \(error)", file: file, line: line) 58 | } 59 | } 60 | 61 | internal func date(_ day: Int, _ month: Int) -> Date { 62 | Calendar.gregorian.date(from: DateComponents(year: .leapYear, month: month, day: day))! 63 | } 64 | 65 | internal let systemsToTest: [(WesternZodiacSystem, String)] = [ 66 | (.tropical, "Tropical"), 67 | (.sidereal, "Sidereal"), 68 | (.equalLength, "Equal"), 69 | (.astronomicalIAU, "IAU"), 70 | (.custom(Fixtures.validMonthBasedZodiacs()), "Custom Month-Based") 71 | ] 72 | } 73 | 74 | // MARK: - Test Fixtures 75 | 76 | internal extension ZodiacKitTests { 77 | 78 | enum Fixtures { 79 | 80 | static func validMonthBasedZodiacs() -> [Zodiac] { 81 | return [ 82 | .init( 83 | sign: .capricorn, 84 | start: .init(day: 1, month: 1), 85 | end: .init(day: 31, month: 1) 86 | ), 87 | .init( 88 | sign: .aquarius, 89 | start: .init(day: 1, month: 2), 90 | end: .init(day: 29, month: 2) 91 | ), 92 | .init( 93 | sign: .pisces, 94 | start: .init(day: 1, month: 3), 95 | end: .init(day: 31, month: 3) 96 | ), 97 | .init( 98 | sign: .aries, 99 | start: .init(day: 1, month: 4), 100 | end: .init(day: 30, month: 4) 101 | ), 102 | .init( 103 | sign: .taurus, 104 | start: .init(day: 1, month: 5), 105 | end: .init(day: 31, month: 5) 106 | ), 107 | .init( 108 | sign: .gemini, 109 | start: .init(day: 1, month: 6), 110 | end: .init(day: 30, month: 6) 111 | ), 112 | .init( 113 | sign: .cancer, 114 | start: .init(day: 1, month: 7), 115 | end: .init(day: 31, month: 7) 116 | ), 117 | .init( 118 | sign: .leo, 119 | start: .init(day: 1, month: 8), 120 | end: .init(day: 31, month: 8) 121 | ), 122 | .init( 123 | sign: .virgo, 124 | start: .init(day: 1, month: 9), 125 | end: .init(day: 30, month: 9) 126 | ), 127 | .init( 128 | sign: .libra, 129 | start: .init(day: 1, month: 10), 130 | end: .init(day: 31, month: 10) 131 | ), 132 | .init( 133 | sign: .scorpio, 134 | start: .init(day: 1, month: 11), 135 | end: .init(day: 30, month: 11) 136 | ), 137 | .init( 138 | sign: .sagittarius, 139 | start: .init(day: 1, month: 12), 140 | end: .init(day: 31, month: 12) 141 | ) 142 | ] 143 | } 144 | 145 | static func withMissingDates() -> [Zodiac] { 146 | var zodiacs = validMonthBasedZodiacs() 147 | zodiacs[0] = .init( 148 | sign: .capricorn, 149 | start: .init(day: 1, month: 1), 150 | end: .init(day: 30, month: 1) 151 | ) 152 | return zodiacs 153 | } 154 | 155 | static func withOverlappingDates() -> [Zodiac] { 156 | var zodiacs = validMonthBasedZodiacs() 157 | zodiacs[1] = .init( 158 | sign: .aquarius, 159 | start: .init(day: 31, month: 1), 160 | end: .init(day: 29, month: 2) 161 | ) 162 | return zodiacs 163 | } 164 | 165 | static func withDuplicateSigns() -> [Zodiac] { 166 | var zodiacs = validMonthBasedZodiacs() 167 | zodiacs[1] = zodiacs[0] 168 | return zodiacs 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Models/ZodiacError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Represents all possible errors that can occur while working with the zodiac system. 10 | public enum ZodiacError: Error { 11 | 12 | /// The provided `Date` has missing or invalid components (e.g. nil day or month). 13 | case invalidDateComponents(date: Date) 14 | 15 | /// A date could not be constructed from the given month and day using the leap year. 16 | /// 17 | /// This typically means the combination is invalid (e.g. February 30). 18 | case couldNotConstructLeapDate(month: Int, day: Int) 19 | 20 | /// The system couldn't calculate the day of the year for the given date. 21 | /// 22 | /// This can happen if the adjusted date is invalid or out of bounds. 23 | case couldNotGetDayOfYear(adjustedDate: Date) 24 | 25 | /// There are duplicate zodiac entries for the same sign. 26 | /// 27 | /// This indicates a conflict in the zodiac configuration. 28 | case duplicateZodiacsFound(duplicates: [Western]) 29 | 30 | /// One or more expected zodiac signs were not defined. 31 | /// 32 | /// All expected signs must be present for the system to be considered valid. 33 | case missingZodiacs(missing: [Western]) 34 | 35 | /// One or more calendar days are not covered by any zodiac range. 36 | /// 37 | /// This breaks continuity and indicates an incomplete zodiac system. 38 | case missingDays(missingDays: [Date]) 39 | 40 | /// One or more days are assigned to more than one zodiac sign. 41 | /// 42 | /// Each day of the year must map to a single sign only. 43 | case overlappingDays(days: [Int]) 44 | 45 | /// The combined zodiac ranges do not form a continuous sequence from day 1 through 366. 46 | /// 47 | /// This suggests that the date ranges either overlap, leave gaps, or are unordered. 48 | case nonContinuousRanges 49 | 50 | /// The zodiac data is invalid or could not be parsed. 51 | /// 52 | /// This is a general-purpose fallback for unrecoverable or malformed data. 53 | case invalidData 54 | 55 | /// A zodiac sign could not be resolved for the specified day of the year. 56 | /// 57 | /// This typically means the mapping is incomplete or incorrectly indexed. 58 | case dayNumberNotFound(dayNumber: Int) 59 | } 60 | 61 | extension ZodiacError: LocalizedError { 62 | 63 | /// A user-facing description of the zodiac-related error. 64 | /// 65 | /// Each case uses `String(localized:)` to support translation. 66 | /// Use interpolated placeholders (`\(variable)`) for dynamic data. 67 | public var errorDescription: String? { 68 | switch self { 69 | 70 | case .invalidDateComponents(let date): 71 | return String( 72 | localized: "Invalid date components were found in \(compactDate(date)).", 73 | comment: "Shown when the provided date object has missing or invalid components." 74 | ) 75 | 76 | case .couldNotConstructLeapDate(let month, let day): 77 | return String( 78 | localized: "Could not construct a date for the given day (\(day)) and month (\(month)) using a leap year.", 79 | comment: "Shown when date construction fails due to invalid leap year combination." 80 | ) 81 | 82 | case .couldNotGetDayOfYear(let adjustedDate): 83 | return String( 84 | localized: "Unable to determine the day of the year for the adjusted date: \(compactDate(adjustedDate)).", 85 | comment: "Shown when system cannot calculate the day of the year from an adjusted date." 86 | ) 87 | 88 | case .duplicateZodiacsFound(let duplicates): 89 | let signs = duplicates.map(\.name).joined(separator: ", ") 90 | return String( 91 | localized: "Duplicate zodiac signs found: \(signs). Each sign should only appear once.", 92 | comment: "Shown when more than one zodiac entry is found for the same sign." 93 | ) 94 | 95 | case .missingZodiacs(let missing): 96 | let signs = missing.map(\.name).joined(separator: ", ") 97 | return String( 98 | localized: "Missing zodiac definitions for: \(signs). All expected signs must be present.", 99 | comment: "Shown when one or more expected zodiac signs are missing from configuration." 100 | ) 101 | 102 | case .missingDays(let missingDays): 103 | let formatted = missingDays.map { compactDate($0) }.joined(separator: ", ") 104 | return String( 105 | localized: "Some days are not covered by any zodiac sign: \(formatted).", 106 | comment: "Shown when calendar days exist that are not assigned to any zodiac sign." 107 | ) 108 | 109 | case .overlappingDays(let days): 110 | let list = days.map(String.init).joined(separator: ", ") 111 | return String( 112 | localized: "Multiple zodiac signs overlap on the same day(s): \(list). Each day should be uniquely assigned.", 113 | comment: "Shown when two or more zodiac ranges overlap on the same calendar days." 114 | ) 115 | 116 | case .nonContinuousRanges: 117 | return String( 118 | localized: "Zodiac date ranges are not continuous. Every day of the year must be covered without gaps.", 119 | comment: "Shown when zodiac date ranges are not sequential or leave gaps." 120 | ) 121 | 122 | case .invalidData: 123 | return String( 124 | localized: "The zodiac data is invalid or corrupted.", 125 | comment: "Shown when zodiac data cannot be parsed or is malformed." 126 | ) 127 | 128 | case .dayNumberNotFound(let dayNumber): 129 | return String( 130 | localized: "No zodiac sign was found for day number \(dayNumber).", 131 | comment: "Shown when no zodiac mapping exists for the given day number." 132 | ) 133 | } 134 | } 135 | } 136 | 137 | extension ZodiacError { 138 | 139 | /// A lightweight and efficient string formatter for displaying dates as `YYYY-MM-DD`. 140 | /// 141 | /// Designed for iOS 13 compatibility without using `DateFormatter` or `.formatted`. 142 | /// 143 | /// - Parameter date: The date to format. 144 | /// - Returns: A string in `YYYY-MM-DD` format. 145 | private func compactDate(_ date: Date) -> String { 146 | let components = Calendar.gregorian.dateComponents([.year, .month, .day], from: date) 147 | let year = components.year ?? 0 148 | let month = components.month ?? 0 149 | let day = components.day ?? 0 150 | return String(format: "%04d-%02d-%02d", year, month, day) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Services/ZodiacService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// A service responsible for resolving zodiac signs from dates and validating zodiac configurations. 10 | /// 11 | /// This class supports both Western and Chinese zodiac systems, providing accurate mapping based 12 | /// on the selected configuration. It is designed to be `@MainActor` safe and integrates smoothly 13 | /// with SwiftUI via `ObservableObject`. 14 | @MainActor 15 | public final class ZodiacService: ObservableObject { 16 | 17 | /// The validated list of Western zodiac sign definitions based on the selected system. 18 | @Published public private(set) var zodiacs: [Zodiac] = [] 19 | 20 | /// The most recent validation error, if any. 21 | @Published public private(set) var validationError: ZodiacError? = nil 22 | 23 | /// The configured zodiac system (e.g., tropical, sidereal, custom). 24 | private let system: WesternZodiacSystem 25 | 26 | /// A mapping from day-of-year to corresponding Western zodiac sign. 27 | private let dayOfYearToZodiac: [Int: Western] 28 | 29 | /// Indicates whether the zodiac configuration is valid. 30 | public var isValid: Bool { validationError == nil } 31 | 32 | /// Returns all Chinese zodiac signs in cycle order. 33 | public var allChineseZodiacs: [Chinese] { 34 | Chinese.allCases 35 | } 36 | 37 | /// Returns all Western zodiac signs in the current system’s order. 38 | public var allWesternZodiacs: [Western] { 39 | zodiacs.map(\.sign) 40 | } 41 | 42 | /// Creates a new instance of `ZodiacService` with the specified zodiac system. 43 | /// 44 | /// If the configuration fails validation, the service will provide an empty zodiac list 45 | /// and set the `validationError` accordingly. 46 | /// 47 | /// - Parameter system: The zodiac system to use. Defaults to `.tropical`. 48 | public init(system: WesternZodiacSystem = .tropical) { 49 | self.system = system 50 | 51 | do { 52 | let loadedZodiacs = try ZodiacLoader.loadZodiacs(from: system) 53 | self.zodiacs = loadedZodiacs 54 | self.dayOfYearToZodiac = try ZodiacLoader.mapZodiacsToDaysOfYear(from: loadedZodiacs) 55 | } catch let error as ZodiacError { 56 | self.validationError = error 57 | self.zodiacs = [] 58 | self.dayOfYearToZodiac = [:] 59 | } catch { 60 | self.validationError = .invalidData 61 | self.zodiacs = [] 62 | self.dayOfYearToZodiac = [:] 63 | } 64 | } 65 | } 66 | 67 | // MARK: - Public Methods 68 | 69 | extension ZodiacService { 70 | 71 | /// Resolves the Western zodiac sign for a given date. 72 | /// 73 | /// - Parameter date: The date to evaluate. 74 | /// - Returns: A `Western` zodiac sign. 75 | /// - Throws: `ZodiacError.dayNumberNotFound` if the day could not be mapped. 76 | public func getWesternZodiac(from date: Date) throws -> Western { 77 | let dayOfYear = try date.dayOfYear() 78 | guard let sign = dayOfYearToZodiac[dayOfYear] else { 79 | throw ZodiacError.dayNumberNotFound(dayNumber: dayOfYear) 80 | } 81 | return sign 82 | } 83 | 84 | /// Resolves the Chinese zodiac sign for a given Gregorian date. 85 | /// 86 | /// - Parameter date: The date to evaluate. 87 | /// - Returns: A `Chinese` zodiac sign corresponding to the year of the date. 88 | /// - Throws: `ZodiacError.invalidData` if the lunar year cannot be resolved. 89 | public func getChineseZodiac(from date: Date) throws -> Chinese { 90 | let chineseCalendar = Calendar(identifier: .chinese) 91 | let chineseYearComponent = chineseCalendar.component(.year, from: date) 92 | let index = (chineseYearComponent - 1) % Int.chineseZodiacCycle 93 | return Chinese.allCases[index] 94 | } 95 | } 96 | 97 | // MARK: - Get Metadata 98 | 99 | extension ZodiacService { 100 | 101 | /// Returns detailed metadata for a given Western zodiac sign. 102 | /// 103 | /// - Parameter sign: The sign to retrieve metadata for. 104 | /// - Returns: A fully populated `ZodiacOverview` object. 105 | public func metadata(for sign: Western) -> ZodiacOverview { 106 | .init( 107 | name: sign.name, 108 | emoji: sign.emoji, 109 | element: sign.element, 110 | elementEmoji: sign.elementEmoji, 111 | modality: sign.modality, 112 | polarity: sign.polarity, 113 | yinYang: sign.yinYang, 114 | rulingPlanetName: sign.rulingPlanetName, 115 | traditionalRulingPlanetName: sign.traditionalRulingPlanetName, 116 | rulingPlanetSymbol: sign.rulingPlanetSymbol, 117 | rulingHouse: sign.rulingHouse, 118 | colorHEX: sign.colorHEX, 119 | symbol: sign.symbol, 120 | symbolEmoji: sign.symbolEmoji, 121 | birthstone: sign.birthstone, 122 | season: sign.season, 123 | brightestStar: sign.brightestStar, 124 | characteristics: sign.characteristics, 125 | strengths: sign.strengths, 126 | weaknesses: sign.weaknesses, 127 | keyTraits: sign.keyTraits 128 | ) 129 | } 130 | 131 | /// Returns detailed metadata for a given Chinese zodiac sign. 132 | /// 133 | /// - Parameter sign: The Chinese sign to retrieve metadata for. 134 | /// - Returns: A fully populated `ZodiacOverview` object. 135 | public func metadata(for sign: Chinese) -> ZodiacOverview { 136 | .init( 137 | name: sign.name, 138 | emoji: sign.emoji, 139 | element: sign.element, 140 | elementEmoji: sign.elementEmoji, 141 | modality: sign.modality, 142 | polarity: sign.polarity, 143 | yinYang: sign.yinYang, 144 | rulingPlanetName: sign.rulingPlanetName, 145 | traditionalRulingPlanetName: sign.traditionalRulingPlanetName, 146 | rulingPlanetSymbol: sign.rulingPlanetSymbol, 147 | rulingHouse: sign.rulingHouse, 148 | colorHEX: sign.colorHEX, 149 | symbol: sign.symbol, 150 | symbolEmoji: sign.symbolEmoji, 151 | birthstone: sign.birthstone, 152 | season: sign.season, 153 | brightestStar: sign.brightestStar, 154 | characteristics: sign.characteristics, 155 | strengths: sign.strengths, 156 | weaknesses: sign.weaknesses, 157 | keyTraits: sign.keyTraits 158 | ) 159 | } 160 | } 161 | 162 | // MARK: - Dates 163 | 164 | extension ZodiacService { 165 | 166 | /// Returns the date range associated with a given Western zodiac sign. 167 | /// 168 | /// - Parameter sign: The zodiac sign to evaluate. 169 | /// - Returns: A tuple containing the start and end `Date` values, or `nil` if unavailable. 170 | public func range(for sign: Western) -> (start: Date, end: Date)? { 171 | guard let zodiac = zodiacs.first(where: { $0.sign == sign }), 172 | let startDate = try? zodiac.start.toDate(), 173 | let endDate = try? zodiac.end.toDate() else { 174 | return nil 175 | } 176 | return (startDate, endDate) 177 | } 178 | 179 | /// Determines whether a given date falls within a specified zodiac sign’s range. 180 | /// 181 | /// - Parameters: 182 | /// - date: The date to check. 183 | /// - sign: The zodiac sign to test against. 184 | /// - Returns: `true` if the date falls within the sign’s range, otherwise `false`. 185 | /// - Throws: `ZodiacError.dayNumberNotFound` if the day cannot be resolved. 186 | public func isInRange(_ date: Date, in sign: Western) throws -> Bool { 187 | let dayOfYear = try date.dayOfYear() 188 | guard let expectedSign = dayOfYearToZodiac[dayOfYear] else { 189 | return false 190 | } 191 | return expectedSign == sign 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Tests/ZodiacKitTests/Metadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import XCTest 8 | @testable import ZodiacKit 9 | 10 | // MARK: - Metadata 11 | 12 | @MainActor 13 | extension ZodiacKitTests { 14 | 15 | /// Validates the completeness of Western zodiac metadata across all supported systems. 16 | /// 17 | /// For each system (e.g., Tropical, Sidereal, Equal, IAU, Custom), this test verifies that 18 | /// every zodiac sign has non-empty values for all critical metadata fields including: 19 | /// - Display (name, emoji, symbol, symbolEmoji) 20 | /// - Astrological attributes (element, polarity, modality, planet, house) 21 | /// - Visual representation (color, birthstone, brightestStar) 22 | /// - Descriptions (characteristics, strengths, weaknesses, key traits) 23 | /// - Compatibility groupings (best, average, conflicting, harmful) 24 | /// 25 | /// This ensures each sign can be reliably used in frontend UIs and internal logic. 26 | func testWesternZodiacMetadataCompleteness() { 27 | for (system, label) in systemsToTest { 28 | let service = ZodiacService(system: system) 29 | for sign in service.allWesternZodiacs { 30 | XCTAssertFalse( 31 | sign.name.isEmpty, 32 | "\(label): \(sign) has empty name" 33 | ) 34 | XCTAssertFalse( 35 | sign.emoji.isEmpty, 36 | "\(label): \(sign) has empty emoji" 37 | ) 38 | XCTAssertFalse( 39 | sign.symbol.isEmpty, 40 | "\(label): \(sign) has empty symbol" 41 | ) 42 | XCTAssertFalse( 43 | sign.symbolEmoji.isEmpty, 44 | "\(label): \(sign) has empty symbol emoji" 45 | ) 46 | XCTAssertFalse( 47 | sign.element.isEmpty, 48 | "\(label): \(sign) has empty element" 49 | ) 50 | XCTAssertFalse( 51 | sign.elementEmoji.isEmpty, 52 | "\(label): \(sign) has empty element emoji" 53 | ) 54 | XCTAssertFalse( 55 | sign.modality.isEmpty, 56 | "\(label): \(sign) has empty modality" 57 | ) 58 | XCTAssertFalse( 59 | sign.rulingPlanetName.isEmpty, 60 | "\(label): \(sign) has empty rulingPlanetName" 61 | ) 62 | XCTAssertFalse( 63 | sign.rulingPlanetSymbol.isEmpty, 64 | "\(label): \(sign) has empty rulingPlanetSymbol" 65 | ) 66 | XCTAssertFalse( 67 | sign.rulingHouse.isEmpty, 68 | "\(label): \(sign) has empty rulingHouse" 69 | ) 70 | XCTAssertFalse( 71 | sign.yinYang.isEmpty, 72 | "\(label): \(sign) has empty yinYang" 73 | ) 74 | XCTAssertFalse( 75 | sign.season.isEmpty, 76 | "\(label): \(sign) has empty season" 77 | ) 78 | XCTAssertFalse( 79 | sign.birthstone.isEmpty, 80 | "\(label): \(sign) has empty birthstone" 81 | ) 82 | XCTAssertFalse( 83 | sign.colorHEX.isEmpty, 84 | "\(label): \(sign) has empty colorHEX" 85 | ) 86 | XCTAssertFalse( 87 | sign.brightestStar.isEmpty, 88 | "\(label): \(sign) has empty brightestStar" 89 | ) 90 | XCTAssertFalse( 91 | sign.characteristics.isEmpty, 92 | "\(label): \(sign) missing characteristics" 93 | ) 94 | XCTAssertFalse( 95 | sign.strengths.isEmpty, 96 | "\(label): \(sign) missing strengths" 97 | ) 98 | XCTAssertFalse( 99 | sign.weaknesses.isEmpty, 100 | "\(label): \(sign) missing weaknesses" 101 | ) 102 | XCTAssertFalse( 103 | sign.keyTraits.isEmpty, 104 | "\(label): \(sign) missing key traits" 105 | ) 106 | XCTAssertFalse( 107 | sign.bestMatches.isEmpty, 108 | "\(label): \(sign) has no best matches" 109 | ) 110 | XCTAssertFalse( 111 | sign.averageMatches.isEmpty, 112 | "\(label): \(sign) has no average matches" 113 | ) 114 | XCTAssertFalse( 115 | sign.conflictingMatches.isEmpty, 116 | "\(label): \(sign) has no conflicting matches" 117 | ) 118 | XCTAssertFalse( 119 | sign.harmfulMatches.isEmpty, 120 | "\(label): \(sign) has no harmful matches" 121 | ) 122 | } 123 | } 124 | } 125 | 126 | /// Validates the completeness of Chinese zodiac metadata for all 12 animal signs. 127 | /// 128 | /// Ensures every sign in the Chinese system provides valid, non-empty values for 129 | /// essential metadata such as: 130 | /// - Display fields (name, emoji, symbol) 131 | /// - Astrological properties (element, ruling planet, yin/yang, polarity) 132 | /// - Symbolism (season, brightest star, birthstone, etc.) 133 | /// - Character profiling (strengths, weaknesses, traits) 134 | /// - Compatibility relationships (best, average, conflicting, harmful) 135 | /// 136 | /// This test confirms each sign is sufficiently described for use in logic, UI, and content generation. 137 | func testChineseZodiacMetadataCompleteness() { 138 | for sign in Chinese.allCases { 139 | XCTAssertFalse( 140 | sign.name.isEmpty, 141 | "\(sign): name is empty" 142 | ) 143 | XCTAssertFalse( 144 | sign.emoji.isEmpty, 145 | "\(sign): emoji is empty" 146 | ) 147 | XCTAssertFalse( 148 | sign.symbol.isEmpty, 149 | "\(sign): symbol is empty" 150 | ) 151 | XCTAssertFalse( 152 | sign.symbolEmoji.isEmpty, 153 | "\(sign): symbol emoji is empty" 154 | ) 155 | XCTAssertFalse( 156 | sign.element.isEmpty, 157 | "\(sign): element is empty" 158 | ) 159 | XCTAssertFalse( 160 | sign.elementEmoji.isEmpty, 161 | "\(sign): element emoji is empty" 162 | ) 163 | XCTAssertFalse( 164 | sign.modality.isEmpty, 165 | "\(sign): modality is empty" 166 | ) 167 | XCTAssertFalse( 168 | sign.rulingPlanetName.isEmpty, 169 | "\(sign): ruling planet name is empty" 170 | ) 171 | XCTAssertFalse( 172 | sign.rulingPlanetSymbol.isEmpty, 173 | "\(sign): ruling planet symbol is empty" 174 | ) 175 | XCTAssertFalse( 176 | sign.rulingHouse.isEmpty, 177 | "\(sign): ruling house is empty" 178 | ) 179 | XCTAssertFalse( 180 | sign.yinYang.isEmpty, 181 | "\(sign): yinYang is empty" 182 | ) 183 | XCTAssertFalse( 184 | sign.polarity.isEmpty, 185 | "\(sign): polarity is empty" 186 | ) 187 | XCTAssertFalse( 188 | sign.season.isEmpty, 189 | "\(sign): season is empty" 190 | ) 191 | XCTAssertFalse( 192 | sign.colorHEX.isEmpty, 193 | "\(sign): colorHEX is empty" 194 | ) 195 | XCTAssertFalse( 196 | sign.brightestStar.isEmpty, 197 | "\(sign): brightestStar is empty" 198 | ) 199 | XCTAssertFalse( 200 | sign.characteristics.isEmpty, 201 | "\(sign): characteristics are missing" 202 | ) 203 | XCTAssertFalse( 204 | sign.strengths.isEmpty, 205 | "\(sign): strengths are missing" 206 | ) 207 | XCTAssertFalse( 208 | sign.weaknesses.isEmpty, 209 | "\(sign): weaknesses are missing" 210 | ) 211 | XCTAssertFalse( 212 | sign.keyTraits.isEmpty, 213 | "\(sign): keyTraits are missing" 214 | ) 215 | XCTAssertFalse( 216 | sign.bestMatches.isEmpty, 217 | "\(sign): bestMatches is empty" 218 | ) 219 | XCTAssertFalse( 220 | sign.averageMatches.isEmpty, 221 | "\(sign): averageMatches is empty" 222 | ) 223 | XCTAssertFalse( 224 | sign.conflictingMatches.isEmpty, 225 | "\(sign): conflictingMatches is empty" 226 | ) 227 | XCTAssertFalse( 228 | sign.harmfulMatches.isEmpty, 229 | "\(sign): harmfulMatches is empty" 230 | ) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Sources/Resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage": "en", 3 | "strings": { 4 | "Could not construct a date for the given day (%lld) and month (%lld) using a leap year.": { 5 | "comment": "Shown when date construction fails due to invalid leap year combination.", 6 | "localizations": { 7 | "de": { "stringUnit": { "state": "translated", "value": "Konnte kein Datum für den angegebenen Tag (%1$lld) und Monat (%2$lld) mit einem Schaltjahr erstellen." } }, 8 | "es": { "stringUnit": { "state": "translated", "value": "No se pudo construir una fecha para el día (%1$lld) y el mes (%2$lld) usando un año bisiesto." } }, 9 | "fr": { "stringUnit": { "state": "translated", "value": "Impossible de construire une date pour le jour (%1$lld) et le mois (%2$lld) en utilisant une année bissextile." } }, 10 | "it": { "stringUnit": { "state": "translated", "value": "Impossibile creare una data per il giorno (%1$lld) e il mese (%2$lld) utilizzando un anno bisestile." } }, 11 | "ja": { "stringUnit": { "state": "translated", "value": "うるう年を使用して日 (%1$lld) と月 (%2$lld) の日付を作成できませんでした。" } }, 12 | "ko": { "stringUnit": { "state": "translated", "value": "윤년을 사용하여 날짜(%1$lld)와 월(%2$lld)에 대한 날짜를 생성할 수 없습니다." } }, 13 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível construir uma data para o dia (%1$lld) e o mês (%2$lld) usando um ano bissexto." } }, 14 | "ru": { "stringUnit": { "state": "translated", "value": "Не удалось создать дату для дня (%1$lld) и месяца (%2$lld), используя високосный год." } }, 15 | "uk": { "stringUnit": { "state": "translated", "value": "Не вдалося створити дату для дня (%1$lld) і місяця (%2$lld) із використанням високосного року." } }, 16 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法使用闰年来构建给定日期 (%1$lld) 和月份 (%2$lld) 的日期。" } }, 17 | "en": { "stringUnit": { "state": "new", "value": "Could not construct a date for the given day (%1$lld) and month (%2$lld) using a leap year." } } 18 | } 19 | }, 20 | "Duplicate zodiac signs found: %@. Each sign should only appear once.": { 21 | "comment": "Shown when more than one zodiac entry is found for the same sign.", 22 | "localizations": { 23 | "de": { "stringUnit": { "state": "translated", "value": "Doppelte Sternzeichen gefunden: %@. Jedes Zeichen darf nur einmal erscheinen." } }, 24 | "es": { "stringUnit": { "state": "translated", "value": "Se encontraron signos del zodíaco duplicados: %@. Cada signo debe aparecer solo una vez." } }, 25 | "fr": { "stringUnit": { "state": "translated", "value": "Signes du zodiaque en double trouvés : %@. Chaque signe ne doit apparaître qu’une seule fois." } }, 26 | "it": { "stringUnit": { "state": "translated", "value": "Trovati segni zodiacali duplicati: %@. Ogni segno deve apparire solo una volta." } }, 27 | "ja": { "stringUnit": { "state": "translated", "value": "重複した星座が見つかりました: %@。各星座は一度だけ表示される必要があります。" } }, 28 | "ko": { "stringUnit": { "state": "translated", "value": "중복된 별자리가 발견되었습니다: %@. 각 별자리는 한 번만 나타나야 합니다." } }, 29 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Foram encontrados signos do zodíaco duplicados: %@. Cada signo deve aparecer apenas uma vez." } }, 30 | "ru": { "stringUnit": { "state": "translated", "value": "Найдены дублирующиеся знаки зодиака: %@. Каждый знак должен появляться только один раз." } }, 31 | "uk": { "stringUnit": { "state": "translated", "value": "Знайдено дублікати знаків зодіаку: %@. Кожен знак повинен з’являтися лише один раз." } }, 32 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "发现重复的星座:%@。每个星座只能出现一次。" } }, 33 | "en": { "stringUnit": { "state": "new", "value": "Duplicate zodiac signs found: %@. Each sign should only appear once." } } 34 | } 35 | }, 36 | "Invalid date components were found in %@.": { 37 | "comment": "Shown when the provided date object has missing or invalid components.", 38 | "localizations": { 39 | "de": { "stringUnit": { "state": "translated", "value": "Ungültige Datumskomponenten wurden in %@ gefunden." } }, 40 | "es": { "stringUnit": { "state": "translated", "value": "Se encontraron componentes de fecha no válidos en %@." } }, 41 | "fr": { "stringUnit": { "state": "translated", "value": "Des composants de date non valides ont été trouvés dans %@." } }, 42 | "it": { "stringUnit": { "state": "translated", "value": "Sono stati trovati componenti di data non validi in %@." } }, 43 | "ja": { "stringUnit": { "state": "translated", "value": "%@ に無効な日付コンポーネントが見つかりました。" } }, 44 | "ko": { "stringUnit": { "state": "translated", "value": "%@에서 잘못된 날짜 구성 요소가 발견되었습니다." } }, 45 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Foram encontrados componentes de data inválidos em %@." } }, 46 | "ru": { "stringUnit": { "state": "translated", "value": "В %@ найдены недопустимые компоненты даты." } }, 47 | "uk": { "stringUnit": { "state": "translated", "value": "У %@ знайдено недійсні компоненти дати." } }, 48 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "在 %@ 中发现无效的日期组件。" } }, 49 | "en": { "stringUnit": { "state": "new", "value": "Invalid date components were found in %@." } } 50 | } 51 | }, 52 | "Missing zodiac definitions for: %@. All expected signs must be present.": { 53 | "comment": "Shown when one or more expected zodiac signs are missing from configuration.", 54 | "localizations": { 55 | "de": { "stringUnit": { "state": "translated", "value": "Fehlende Sternzeichendefinitionen für: %@. Alle erwarteten Zeichen müssen vorhanden sein." } }, 56 | "es": { "stringUnit": { "state": "translated", "value": "Faltan definiciones del zodíaco para: %@. Todos los signos esperados deben estar presentes." } }, 57 | "fr": { "stringUnit": { "state": "translated", "value": "Définitions du zodiaque manquantes pour : %@. Tous les signes attendus doivent être présents." } }, 58 | "it": { "stringUnit": { "state": "translated", "value": "Definizioni zodiacali mancanti per: %@. Tutti i segni previsti devono essere presenti." } }, 59 | "ja": { "stringUnit": { "state": "translated", "value": "次の星座の定義が欠落しています: %@。すべての星座が存在する必要があります。" } }, 60 | "ko": { "stringUnit": { "state": "translated", "value": "%@에 대한 별자리 정의가 누락되었습니다. 모든 예상된 별자리가 포함되어야 합니다." } }, 61 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Definições de signos do zodíaco ausentes para: %@. Todos os signos esperados devem estar presentes." } }, 62 | "ru": { "stringUnit": { "state": "translated", "value": "Отсутствуют определения знаков зодиака для: %@. Все ожидаемые знаки должны быть указаны." } }, 63 | "uk": { "stringUnit": { "state": "translated", "value": "Відсутні визначення знаків зодіаку для: %@. Усі очікувані знаки мають бути присутні." } }, 64 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "缺少以下星座定义:%@。必须包含所有预期的星座。" } }, 65 | "en": { "stringUnit": { "state": "new", "value": "Missing zodiac definitions for: %@. All expected signs must be present." } } 66 | } 67 | }, 68 | "Multiple zodiac signs overlap on the same day(s): %@. Each day should be uniquely assigned.": { 69 | "comment": "Shown when two or more zodiac ranges overlap on the same calendar days.", 70 | "localizations": { 71 | "de": { "stringUnit": { "state": "translated", "value": "Mehrere Sternzeichen überlappen an den gleichen Tagen: %@. Jeder Tag sollte eindeutig zugewiesen sein." } }, 72 | "es": { "stringUnit": { "state": "translated", "value": "Múltiples signos del zodíaco se superponen en los mismos días: %@. Cada día debe asignarse de forma única." } }, 73 | "fr": { "stringUnit": { "state": "translated", "value": "Plusieurs signes du zodiaque se chevauchent aux mêmes jours : %@. Chaque jour doit être attribué de façon unique." } }, 74 | "it": { "stringUnit": { "state": "translated", "value": "Più segni zodiacali si sovrappongono negli stessi giorni: %@. Ogni giorno deve essere assegnato in modo univoco." } }, 75 | "ja": { "stringUnit": { "state": "translated", "value": "同じ日に複数の星座が重複しています: %@。各日は一意に割り当てられる必要があります。" } }, 76 | "ko": { "stringUnit": { "state": "translated", "value": "여러 별자리가 같은 날짜에 중복되어 있습니다: %@. 각 날짜는 고유하게 할당되어야 합니다." } }, 77 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Vários signos do zodíaco se sobrepõem nos mesmos dias: %@. Cada dia deve ser atribuído exclusivamente." } }, 78 | "ru": { "stringUnit": { "state": "translated", "value": "Некоторые знаки зодиака пересекаются в одни и те же дни: %@. Каждый день должен быть назначен однозначно." } }, 79 | "uk": { "stringUnit": { "state": "translated", "value": "Декілька знаків зодіаку перекриваються в одні й ті ж дні: %@. Кожен день має бути призначений унікально." } }, 80 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "多个星座在同一天重叠:%@。每一天必须唯一分配。" } }, 81 | "en": { "stringUnit": { "state": "new", "value": "Multiple zodiac signs overlap on the same day(s): %@. Each day should be uniquely assigned." } } 82 | } 83 | }, 84 | "No zodiac sign was found for day number %lld.": { 85 | "comment": "Shown when no zodiac mapping exists for the given day number.", 86 | "localizations": { 87 | "de": { "stringUnit": { "state": "translated", "value": "Kein Sternzeichen wurde für die Tagesnummer %1$lld gefunden." } }, 88 | "es": { "stringUnit": { "state": "translated", "value": "No se encontró ningún signo del zodíaco para el número de día %1$lld." } }, 89 | "fr": { "stringUnit": { "state": "translated", "value": "Aucun signe du zodiaque n’a été trouvé pour le numéro de jour %1$lld." } }, 90 | "it": { "stringUnit": { "state": "translated", "value": "Nessun segno zodiacale è stato trovato per il numero di giorno %1$lld." } }, 91 | "ja": { "stringUnit": { "state": "translated", "value": "日数 %1$lld に対して星座が見つかりませんでした。" } }, 92 | "ko": { "stringUnit": { "state": "translated", "value": "일 번호 %1$lld에 해당하는 별자리를 찾을 수 없습니다." } }, 93 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Nenhum signo do zodíaco foi encontrado para o número do dia %1$lld." } }, 94 | "ru": { "stringUnit": { "state": "translated", "value": "Не найден знак зодиака для номера дня %1$lld." } }, 95 | "uk": { "stringUnit": { "state": "translated", "value": "Не знайдено знак зодіаку для номера дня %1$lld." } }, 96 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "未找到日期编号为 %1$lld 的星座。" } }, 97 | "en": { "stringUnit": { "state": "new", "value": "No zodiac sign was found for day number %1$lld." } } 98 | } 99 | }, 100 | "Some days are not covered by any zodiac sign: %@.": { 101 | "comment": "Shown when calendar days exist that are not assigned to any zodiac sign.", 102 | "localizations": { 103 | "de": { "stringUnit": { "state": "translated", "value": "Einige Tage sind keinem Sternzeichen zugeordnet: %@." } }, 104 | "es": { "stringUnit": { "state": "translated", "value": "Algunos días no están cubiertos por ningún signo del zodíaco: %@." } }, 105 | "fr": { "stringUnit": { "state": "translated", "value": "Certains jours ne sont couverts par aucun signe du zodiaque : %@." } }, 106 | "it": { "stringUnit": { "state": "translated", "value": "Alcuni giorni non sono ricompresi in alcun segno zodiacale: %@." } }, 107 | "ja": { "stringUnit": { "state": "translated", "value": "いくつかの日がどの星座にも含まれていません:%@。" } }, 108 | "ko": { "stringUnit": { "state": "translated", "value": "일부 날짜는 어떤 별자리에 할당되어 있지 않습니다: %@." } }, 109 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Alguns dias não são cobertos por nenhum signo do zodíaco: %@." } }, 110 | "ru": { "stringUnit": { "state": "translated", "value": "Некоторые дни не покрыты ни одним знаком зодиака: %@." } }, 111 | "uk": { "stringUnit": { "state": "translated", "value": "Деякі дні не охоплені жодним знаком зодіаку: %@." } }, 112 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "某些日期未被任何星座涵盖:%@。" } }, 113 | "en": { "stringUnit": { "state": "new", "value": "Some days are not covered by any zodiac sign: %@." } } 114 | } 115 | }, 116 | "The zodiac data is invalid or corrupted.": { 117 | "comment": "Shown when zodiac data cannot be parsed or is malformed.", 118 | "localizations": { 119 | "de": { "stringUnit": { "state": "translated", "value": "Die Sternzeichendaten sind ungültig oder beschädigt." } }, 120 | "es": { "stringUnit": { "state": "translated", "value": "Los datos del zodíaco son inválidos o están corruptos." } }, 121 | "fr": { "stringUnit": { "state": "translated", "value": "Les données du zodiaque sont invalides ou corrompues." } }, 122 | "it": { "stringUnit": { "state": "translated", "value": "I dati dello zodiaco sono non validi o danneggiati." } }, 123 | "ja": { "stringUnit": { "state": "translated", "value": "星座データが無効または破損しています。" } }, 124 | "ko": { "stringUnit": { "state": "translated", "value": "별자리 데이터가 유효하지 않거나 손상되었습니다." } }, 125 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Os dados do zodíaco são inválidos ou corrompidos." } }, 126 | "ru": { "stringUnit": { "state": "translated", "value": "Данные зодиака недействительны или повреждены." } }, 127 | "uk": { "stringUnit": { "state": "translated", "value": "Дані зодіаку недійсні або пошкоджені." } }, 128 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "星座数据无效或已损坏。" } }, 129 | "en": { "stringUnit": { "state": "new", "value": "The zodiac data is invalid or corrupted." } } 130 | } 131 | }, 132 | "Unable to determine the day of the year for the adjusted date: %@.": { 133 | "comment": "Shown when system cannot calculate the day of the year from an adjusted date.", 134 | "localizations": { 135 | "de": { "stringUnit": { "state": "translated", "value": "Kann den Tag des Jahres für das angepasste Datum nicht bestimmen: %@." } }, 136 | "es": { "stringUnit": { "state": "translated", "value": "No se puede determinar el día del año para la fecha ajustada: %@." } }, 137 | "fr": { "stringUnit": { "state": "translated", "value": "Impossible de déterminer le jour de l’année pour la date ajustée : %@." } }, 138 | "it": { "stringUnit": { "state": "translated", "value": "Impossibile determinare il giorno dell’anno per la data modificata: %@." } }, 139 | "ja": { "stringUnit": { "state": "translated", "value": "調整された日付 %@ の年内通算日を決定できません。" } }, 140 | "ko": { "stringUnit": { "state": "translated", "value": "조정된 날짜 %@에 대해 연중 일을 결정할 수 없습니다." } }, 141 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível determinar o dia do ano para a data ajustada: %@." } }, 142 | "ru": { "stringUnit": { "state": "translated", "value": "Не удалось определить день года для скорректированной даты: %@." } }, 143 | "uk": { "stringUnit": { "state": "translated", "value": "Не вдалося визначити номер дня у році для скоригованої дати: %@." } }, 144 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法确定调整日期 %@ 的年度第几天。" } }, 145 | "en": { "stringUnit": { "state": "new", "value": "Unable to determine the day of the year for the adjusted date: %@." } } 146 | } 147 | }, 148 | "Zodiac date ranges are not continuous. Every day of the year must be covered without gaps.": { 149 | "comment": "Shown when zodiac date ranges are not sequential or leave gaps.", 150 | "localizations": { 151 | "de": { "stringUnit": { "state": "translated", "value": "Die Sternzeichenzeiträume sind nicht durchgängig. Jeder Tag des Jahres muss ohne Lücken abgedeckt sein." } }, 152 | "es": { "stringUnit": { "state": "translated", "value": "Los rangos de fechas del zodíaco no son continuos. Cada día del año debe estar cubierto sin huecos." } }, 153 | "fr": { "stringUnit": { "state": "translated", "value": "Les plages de dates du zodiaque ne sont pas continues. Chaque jour de l’année doit être couvert sans interruption." } }, 154 | "it": { "stringUnit": { "state": "translated", "value": "Gli intervalli di date zodiacali non sono continui. Ogni giorno dell’anno deve essere coperto senza lacune." } }, 155 | "ja": { "stringUnit": { "state": "translated", "value": "星座の日付範囲が連続していません。年のすべての日が隙間なくカバーされる必要があります。" } }, 156 | "ko": { "stringUnit": { "state": "translated", "value": "별자리 날짜 범위가 연속적이지 않습니다. 연중 모든 날이 격차 없이 포함되어야 합니다." } }, 157 | "pt-BR": { "stringUnit": { "state": "translated", "value": "Os intervalos de datas do zodíaco não são contínuos. Cada dia do ano deve estar coberto sem lacunas." } }, 158 | "ru": { "stringUnit": { "state": "translated", "value": "Диапазоны дат зодиака не непрерывны. Каждый день года должен быть покрыт без разрывов." } }, 159 | "uk": { "stringUnit": { "state": "translated", "value": "Інтервали дат зодіаку не є безперервними. Кожен день року має бути охоплений без пропусків." } }, 160 | "zh-Hans": { "stringUnit": { "state": "translated", "value": "星座日期范围不连续。每年的每一天都必须被覆盖且不能有空隙。" } }, 161 | "en": { "stringUnit": { "state": "new", "value": "Zodiac date ranges are not continuous. Every day of the year must be covered without gaps." } } 162 | } 163 | } 164 | }, 165 | "version": "1.1" 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | Swift Package logo 5 | 6 | # ZodiacKit 7 | 8 | ![Swift Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmarkbattistella%2FZodiacKit%2Fbadge%3Ftype%3Dswift-versions) 9 | 10 | ![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmarkbattistella%2FZodiacKit%2Fbadge%3Ftype%3Dplatforms) 11 | 12 | ![Licence](https://img.shields.io/badge/Licence-MIT-white?labelColor=blue&style=flat) 13 | 14 |
15 | 16 | `ZodiacKit` is a Swift package that determines **Western** and **Chinese** zodiac signs from a given date. It supports multiple astrological systems, provides extensive zodiac metadata, and includes strong validation with clear error handling. 17 | 18 | ## Description 19 | 20 | `ZodiacKit` makes it easy to: 21 | 22 | - Fetch a user's **Western zodiac sign** based on one of four systems (Tropical, Sidereal, Equal-Length, or Astronomical). 23 | - Determine their **Chinese zodiac sign** based on the lunar new year. 24 | - Access traits, elements, ruling planets, emojis, and compatibility details. 25 | - Validate zodiac date ranges with automatic error detection. 26 | 27 | `ZodiacKit` includes a `ZodiacService` class and robust enums for both `Western` and `Chinese` signs. 28 | 29 | ## Installation 30 | 31 | Add `ZodiacKit` to your Swift project using Swift Package Manager. 32 | 33 | ```swift 34 | dependencies: [ 35 | .package(url: "https://github.com/markbattistella/ZodiacKit", from: "x.y.z") 36 | ] 37 | ``` 38 | 39 | > [!CAUTION] 40 | > `v1.x` to `v2.x` has some breaking changes. 41 | > `v2.x` to `v3.x` has some breaking changes. 42 | 43 | ## Usage 44 | 45 | Remember to import the `ZodiacKit` module: 46 | 47 | ```swift 48 | import ZodiacKit 49 | ``` 50 | 51 | ### Basic Usage 52 | 53 | The default usage of the ZodiacKit package is quite straightforward. Here's an example: 54 | 55 | ```swift 56 | let zodiacService = try ZodiacService() 57 | let dateComponents = DateComponents(year: 1991, month: 5, day: 29) 58 | let birthDate = Calendar.current.date(from: dateComponents) 59 | 60 | let westernZodiacSign = try? zodiacService.getWesternZodiac(from: birthDate!) 61 | let chineseZodiacSign = try? zodiacService.getChineseZodiac(from: birthDate!) 62 | 63 | // westernZodiacSign.name: Gemini 64 | // chineseZodiacSign.name: Goat 65 | ``` 66 | 67 | This will give you the corresponding information and attributes based on the date provided. 68 | 69 | You can then use the properties of the `Western` and `Chinese` to get information about those zodiac signs. 70 | 71 | #### Custom date ranges (Western only) 72 | 73 | If you want to use custom zodiac date ranges instead of the defaults (for the `Western`), you can do so by passing a custom array of `Zodiac` structs during `ZodiacService` initialisation: 74 | 75 | ```swift 76 | let customZodiacs: [Zodiac] = [ 77 | Zodiac( 78 | sign: .aquarius, 79 | startDate: .init(day: 22, month: 1), 80 | endDate: .init(day: 19, month: 2) 81 | ), 82 | // ... 83 | ] 84 | 85 | let zodiacService = try? ZodiacService(system: .custom(customZodiacs)) 86 | ``` 87 | 88 | ### In-app usage 89 | 90 | #### UIKit 91 | 92 | Here's an example of how to use ZodiacKit in a UIKit `UIViewController`. 93 | 94 | ```swift 95 | import UIKit 96 | import ZodiacKit 97 | 98 | class ViewController: UIViewController { 99 | override func viewDidLoad() { 100 | super.viewDidLoad() 101 | 102 | do { 103 | let zodiacService = try ZodiacService() 104 | let dateComponents = DateComponents(year: 1991, month: 5, day: 29) 105 | let birthDate = Calendar.current.date(from: dateComponents) 106 | 107 | let westernZodiacSign = try zodiacService.getWesternZodiac(from: birthDate!) 108 | let chineseZodiacSign = try zodiacService.getChineseZodiac(from: birthDate!) 109 | 110 | // Use signs... 111 | print(westernZodiacSign.name) // Gemini 112 | print(chineseZodiacSign.name) // Goat 113 | } catch { 114 | print("Failed to get zodiac sign: \(error)") 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | #### SwiftUI 121 | 122 | Below is an example of using ZodiacKit in a SwiftUI view. 123 | 124 | ```swift 125 | import SwiftUI 126 | import ZodiacKit 127 | 128 | struct ContentView: View { 129 | @StateObject private var zodiacService = ZodiacService() 130 | 131 | @State private var westernZodiacSign: WesternZodiacSign? 132 | @State private var chineseZodiacSign: ChineseZodiacSign? 133 | 134 | var body: some View { 135 | VStack { 136 | if let westernSign = westernZodiacSign, let chineseSign = chineseZodiacSign { 137 | Text("Your zodiac sign is \(westernSign.name)") 138 | Text("Your Chinese Zodiac sign is: \(chineseSign.name)") 139 | } else { 140 | Text("Failed to get zodiac sign") 141 | } 142 | } 143 | .task { 144 | do { 145 | let dateComponents = DateComponents(year: 1991, month: 5, day: 29) 146 | let birthDate = Calendar.current.date(from: dateComponents) 147 | westernZodiacSign = try zodiacService.getWesternZodiac(from: birthDate!) 148 | chineseZodiacSign = try zodiacService.getChineseZodiac(from: birthDate!) 149 | } catch { 150 | print("Failed to get zodiac sign: \(error)") 151 | } 152 | } 153 | } 154 | } 155 | ``` 156 | 157 | #### AppKit 158 | 159 | Here's how to use ZodiacKit in an AppKit `NSViewController`. 160 | 161 | ```swift 162 | import AppKit 163 | import ZodiacKit 164 | 165 | class ViewController: NSViewController { 166 | override func viewDidLoad() { 167 | super.viewDidLoad() 168 | 169 | do { 170 | let zodiacService = try ZodiacService() 171 | let dateComponents = DateComponents(year: 1991, month: 5, day: 29) 172 | let birthDate = Calendar.current.date(from: dateComponents) 173 | 174 | let westernZodiacSign = try zodiacService.getWesternZodiac(from: birthDate!) 175 | let chineseZodiacSign = try zodiacService.getChineseZodiac(from: birthDate!) 176 | 177 | // Use signs... 178 | print(westernZodiacSign.name) // Gemini 179 | print(chineseZodiacSign.name) // Goat 180 | } catch { 181 | print("Failed to get zodiac sign: \(error)") 182 | } 183 | } 184 | } 185 | ``` 186 | 187 | ## Default Date Ranges 188 | 189 | ### Tropical System 190 | 191 | This is the one most commonly used in Western astrology. 192 | 193 | | Zodiac Sign | Start Date | End Date | 194 | |--------------|--------------|--------------| 195 | | Aries | 21 March | 19 April | 196 | | Taurus | 20 April | 20 May | 197 | | Gemini | 21 May | 20 June | 198 | | Cancer | 21 June | 22 July | 199 | | Leo | 23 July | 22 August | 200 | | Virgo | 23 August | 22 September | 201 | | Libra | 23 September | 22 October | 202 | | Scorpio | 23 October | 21 November | 203 | | Sagittarius | 22 November | 21 December | 204 | | Capricorn | 22 December | 19 January | 205 | | Aquarius | 20 January | 18 February | 206 | | Pisces | 19 February | 20 March | 207 | 208 | ### Sidereal System (Vedic) 209 | 210 | Based on the actual position of constellations in the sky, accounting for precession. 211 | 212 | | Zodiac Sign | Start Date | End Date | 213 | |--------------|--------------|--------------| 214 | | Aries | 14 April | 14 May | 215 | | Taurus | 15 May | 15 June | 216 | | Gemini | 16 June | 16 July | 217 | | Cancer | 17 July | 16 August | 218 | | Leo | 17 August | 16 September | 219 | | Virgo | 17 September | 16 October | 220 | | Libra | 17 October | 15 November | 221 | | Scorpio | 16 November | 15 December | 222 | | Sagittarius | 16 December | 14 January | 223 | | Capricorn | 15 January | 12 February | 224 | | Aquarius | 13 February | 14 March | 225 | | Pisces | 15 March | 13 April | 226 | 227 | ### Equal-Length System 228 | 229 | Each sign gets approximately 30.4 days, ignoring constellation size. Based on reconstructed Hellenistic tradition. 230 | 231 | | Zodiac Sign | Start Date | End Date | 232 | |--------------|--------------|--------------| 233 | | Aries | 16 April | 11 May | 234 | | Cetus | 12 May | 6 June | 235 | | Taurus | 7 June | 2 July | 236 | | Gemini | 3 July | 28 July | 237 | | Cancer | 29 July | 23 August | 238 | | Leo | 24 August | 18 September | 239 | | Virgo | 19 September | 14 October | 240 | | Libra | 15 October | 9 November | 241 | | Scorpio | 10 November | 5 December | 242 | | Ophiuchus | 6 December | 31 December | 243 | | Sagittarius | 1 January | 26 January | 244 | | Capricorn | 27 January | 21 February | 245 | | Aquarius | 22 February | 20 March | 246 | | Pisces | 21 March | 15 April | 247 | 248 | ### Astronomical (IAU) System 249 | 250 | This system follows the actual star boundaries defined by the International Astronomical Union. Sign durations vary significantly. 251 | 252 | | Zodiac Sign | Start Date | End Date | 253 | |--------------|--------------|--------------| 254 | | Aries | 19 April | 13 May | 255 | | Taurus | 14 May | 21 June | 256 | | Gemini | 22 June | 20 July | 257 | | Cancer | 21 July | 10 August | 258 | | Leo | 11 August | 16 September | 259 | | Virgo | 17 September | 30 October | 260 | | Libra | 31 October | 23 November | 261 | | Scorpio | 24 November | 29 November | 262 | | Ophiuchus | 30 November | 17 December | 263 | | Sagittarius | 18 December | 20 January | 264 | | Capricorn | 21 January | 16 February | 265 | | Aquarius | 17 February | 11 March | 266 | | Pisces | 12 March | 18 April | 267 | 268 | ## Validation 269 | 270 | `ZodiacKit` includes built-in validation to ensure the consistency and correctness of the provided zodiac sign data. 271 | 272 | During the initialisation of `ZodiacService`, the package performs several checks: 273 | 274 | 1. Ensures no duplicate signs exist. 275 | 2. Ensures all expected signs are included (internal use only). 276 | 3. Ensures every day of the year is covered by one and only one zodiac. 277 | 4. Ensures there are no overlapping date ranges. 278 | 5. Ensures the date ranges are continuous from day 1 through 366. 279 | 280 | ## Error Handling 281 | 282 | `ZodiacKit` performs several validations to ensure data consistency and accuracy. If an issue is found during initialisation or zodiac lookup, it throws a `ZodiacError`. 283 | 284 | Below are the possible errors: 285 | 286 | | Error Case | Description | 287 | |-|-| 288 | | `invalidDateComponents(date:)` | The provided `Date` has missing or invalid components such as a nil day or month. | 289 | | `couldNotConstructLeapDate(month:day:)` | A valid leap-year date could not be formed from the given month and day, likely due to an invalid combination (e.g. February 30). | 290 | | `couldNotGetDayOfYear(adjustedDate:)` | The system failed to calculate the day of the year from the adjusted date, often due to invalid or out-of-range values. | 291 | | `duplicateZodiacsFound(duplicates:)` | There are multiple definitions for the same zodiac sign, indicating a configuration conflict. | 292 | | `missingZodiacs(missing:)` | Some expected zodiac signs were not defined, making the system incomplete. | 293 | | `missingDays(missingDays:)` | One or more calendar days aren’t assigned to any zodiac sign, causing coverage gaps. | 294 | | `overlappingDays(days:)` | Some days are assigned to more than one zodiac sign, violating the one-sign-per-day rule. | 295 | | `nonContinuousRanges` | Zodiac ranges don't form a complete, gap-free sequence from day 1 through 366. | 296 | | `invalidData` | Zodiac data is corrupted or couldn't be parsed correctly. | 297 | | `dayNumberNotFound(dayNumber:)` | No zodiac sign could be determined for a specific day of the year, typically due to misconfiguration. | 298 | 299 | ### Example 300 | 301 | ```swift 302 | /// A demo view that shows zodiac sign results from multiple Western systems and the Chinese zodiac. Users can select a date, and the relevant signs are calculated and displayed. 303 | struct ZodiacDemo: View { 304 | @StateObject private var serviceTropical = ZodiacService(system: .tropical) 305 | @StateObject private var serviceSidereal = ZodiacService(system: .sidereal) 306 | @StateObject private var serviceEqual = ZodiacService(system: .equalLength) 307 | @StateObject private var serviceIAU = ZodiacService(system: .astronomicalIAU) 308 | 309 | @State private var western: ( 310 | tropical: Western, 311 | sidereal: Western, 312 | equal: Western, 313 | iau: Western 314 | )? = nil 315 | 316 | @State private var chinese: Chinese? = nil 317 | @State private var selectedDate: Date = .now 318 | 319 | var body: some View { 320 | Form { 321 | if let western { 322 | Section("Western Zodiac") { 323 | LabeledContent("Tropical", value: western.tropical.name) 324 | LabeledContent("Sidereal", value: western.sidereal.name) 325 | LabeledContent("Equal Length", value: western.equal.name) 326 | LabeledContent("Astronomical IAU", value: western.iau.name) 327 | } 328 | } 329 | 330 | if let chinese { 331 | Section("Chinese Zodiac") { 332 | LabeledContent("Chinese", value: chinese.name) 333 | } 334 | } 335 | 336 | DatePicker( 337 | "Select a date", 338 | selection: $selectedDate, 339 | displayedComponents: [.date] 340 | ) 341 | .datePickerStyle(.graphical) 342 | } 343 | .task { loadZodiac(for: selectedDate) } 344 | .onChange(of: selectedDate) { _, newDate in 345 | loadZodiac(for: newDate) 346 | } 347 | .scrollBounceBehavior(.basedOnSize) 348 | } 349 | 350 | /// Loads zodiac signs for all supported systems based on the given date. 351 | /// Western signs are loaded from four different systems, and Chinese zodiac is shared across. 352 | private func loadZodiac(for date: Date) { 353 | Task { 354 | do { 355 | let tropical = try serviceTropical.getWesternZodiac(from: date) 356 | let sidereal = try serviceSidereal.getWesternZodiac(from: date) 357 | let equal = try serviceEqual.getWesternZodiac(from: date) 358 | let iau = try serviceIAU.getWesternZodiac(from: date) 359 | 360 | self.western = (tropical, sidereal, equal, iau) 361 | self.chinese = try serviceTropical.getChineseZodiac(from: date) 362 | } catch { 363 | print("Failed to get zodiac signs: \(error.localizedDescription)") 364 | self.western = nil 365 | self.chinese = nil 366 | } 367 | } 368 | } 369 | } 370 | ``` 371 | 372 | ## Attributes 373 | 374 | ### `WesternZodiacSign` 375 | 376 | | Attribute | Description | Example / Values | 377 | |-----------|-------------|------------------| 378 | | `name` | The capitalised name of the sign. | `"Leo"` | 379 | | `emoji` | An emoji representing the astrological glyph. | ♈️, ♉️, ♊️ | 380 | | `element` | The associated classical element. | `"Fire"`, `"Earth"` | 381 | | `elementEmoji` | Emoji for the element. | 🔥, 🌍 | 382 | | `characteristics` | Primary personality descriptors. | `["Bold", "Loyal", "Dramatic"]` | 383 | | `colorHEX` | Associated HEX colour value. | `#FFD700`, `#FF4500` | 384 | | `color` | Platform-agnostic colour (derived from `colorHEX`). | `.init(hex: "#FFD700")` | 385 | | `rulingPlanetName` | Modern ruling planet. | `"Sun"`, `"Mars"` | 386 | | `traditionalRulingPlanetName` | Traditional ruling planet (if different). | `"Earth"` | 387 | | `rulingPlanetSymbol` | Planetary symbol. | `☉`, `♂` | 388 | | `modality` | Cardinal, Fixed, or Mutable. | `"Fixed"` | 389 | | `polarity` | Astrological polarity. | `"Positive"` | 390 | | `rulingHouse` | Governing astrological house. | `"5th House"` | 391 | | `brightestStar` | Brightest star in constellation. | `"Regulus"` | 392 | | `yinYang` | Yin or Yang classification. | `"Yang"` | 393 | | `season` | Season associated with the sign. | `"Summer"` | 394 | | `symbol` | Symbolic name of the sign. | `"Lion"` | 395 | | `symbolEmoji` | Emoji representation of symbol. | 🦁 | 396 | | `birthstone` | Traditional birthstone. | `"Peridot"` | 397 | | `strengths` | Strong qualities. | `["Creative", "Warm-hearted"]` | 398 | | `weaknesses` | Common flaws. | `["Arrogant", "Stubborn"]` | 399 | | `keyTraits` | Distilled trait summary. | `["Leader", "Passionate"]` | 400 | | `bestMatches` | Highly compatible signs. | `[.aries, .sagittarius]` | 401 | | `averageMatches` | Neutral compatibility signs. | `[.gemini, .libra]` | 402 | | `conflictingMatches` | Possible tension with these signs. | `[.taurus, .scorpio]` | 403 | | `harmfulMatches` | Signs with strong incompatibility. | `[.capricorn, .virgo]` | 404 | 405 | ### `ChineseZodiacSign` 406 | 407 | | Attribute | Description | Example / Values | 408 | |-----------|-------------|------------------| 409 | | `name` | The capitalised name of the zodiac animal. | `"Tiger"` | 410 | | `emoji` | Emoji representing the animal. | 🐅 | 411 | | `element` | Classical element associated with the sign. | `"Wood"` | 412 | | `elementEmoji` | Emoji for the element. | 🪵 | 413 | | `characteristics` | Descriptive traits. | `["Courageous", "Ambitious"]` | 414 | | `colorHEX` | HEX value for sign colour. | `#FFA500` | 415 | | `color` | Platform-agnostic colour object. | `.init(hex: "#FFA500")` | 416 | | `rulingPlanetName` | Associated planet. | `"Jupiter"` | 417 | | `traditionalRulingPlanetName` | Optional traditional ruler. | `"Saturn"` | 418 | | `rulingPlanetSymbol` | Planetary symbol. | ♃ | 419 | | `modality` | Yin or Yang descriptor. | `"Yang"` | 420 | | `polarity` | Energetic polarity (Positive/Negative). | `"Positive"` | 421 | | `rulingHouse` | Seasonal/directional alignment. | `"East"` | 422 | | `brightestStar` | Notable star in the sign's constellation. | `"Aldebaran"` | 423 | | `yinYang` | Yin or Yang classification. | `"Yang"` | 424 | | `season` | Season most aligned with the sign. | `"Spring"` | 425 | | `symbol` | Animal or mythical creature. | `"Tiger"` | 426 | | `symbolEmoji` | Emoji for symbol. | 🐅 | 427 | | `birthstone` | Birthstone of the sign. | `"Jade"` | 428 | | `strengths` | Positive traits. | `["Brave", "Energetic"]` | 429 | | `weaknesses` | Typical challenges. | `["Reckless", "Impatient"]` | 430 | | `keyTraits` | Key summarised traits. | `["Fierce", "Leader", "Adventurous"]` | 431 | | `bestMatches` | Highly compatible signs. | `[.horse, .dragon]` | 432 | | `averageMatches` | Moderately compatible. | `[.rat, .rooster]` | 433 | | `conflictingMatches` | Signs with potential conflict. | `[.monkey, .snake]` | 434 | | `harmfulMatches` | Traditionally avoided matches. | `[.goat, .ox]` | 435 | 436 | ## Contributing 437 | 438 | Contributions are more than welcome. If you find a bug or have an idea for an enhancement, please open an issue or provide a pull request. Please follow the code style present in the current code base when making contributions. 439 | 440 | ## Licence 441 | 442 | The Zodiac Signs package is released under the MIT license. See LICENCE for more information. 443 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/Chinese.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Represents the 12 animals of the Chinese Zodiac. 10 | public enum Chinese: String, ZodiacSign { 11 | 12 | /// The Dog sign. 13 | case dog 14 | 15 | /// The Dragon sign. 16 | case dragon 17 | 18 | /// The Goat sign. 19 | case goat 20 | 21 | /// The Horse sign. 22 | case horse 23 | 24 | /// The Monkey sign. 25 | case monkey 26 | 27 | /// The Ox sign. 28 | case ox 29 | 30 | /// The Pig sign. 31 | case pig 32 | 33 | /// The Rabbit sign. 34 | case rabbit 35 | 36 | /// The Rat sign. 37 | case rat 38 | 39 | /// The Rooster sign. 40 | case rooster 41 | 42 | /// The Snake sign. 43 | case snake 44 | 45 | /// The Tiger sign. 46 | case tiger 47 | 48 | /// An ordered list of all Chinese Zodiac signs, following the traditional cycle. 49 | public static var allCases: [Chinese] { 50 | [.rat, .ox, .tiger, .rabbit, .dragon, .snake, 51 | .horse, .goat, .monkey, .rooster, .dog, .pig] 52 | } 53 | } 54 | 55 | extension Chinese: ZodiacMetadataRepresentable { 56 | 57 | /// The capitalised name of the zodiac sign (e.g., "Rat"). 58 | public var name: String { rawValue.capitalized } 59 | 60 | /// The emoji representation of the zodiac animal (e.g., 🐀). 61 | public var emoji: String { metadata.emoji } 62 | 63 | /// The classical element (e.g., Fire, Water). 64 | public var element: String { metadata.element } 65 | 66 | /// An emoji symbol for the element (e.g., 🔥). 67 | public var elementEmoji: String { metadata.elementEmoji } 68 | 69 | /// Descriptive personality traits associated with the sign. 70 | public var characteristics: [String] { metadata.characteristics } 71 | 72 | /// The primary color associated with the sign in hexadecimal format. 73 | public var colorHEX: String { metadata.colorHEX } 74 | 75 | /// A platform-agnostic color representation (UIColor or NSColor). 76 | public var color: AgnosticColor { .init(hex: colorHEX) ?? .clear } 77 | 78 | /// The name of the ruling planet. 79 | public var rulingPlanetName: String { metadata.rulingPlanetName } 80 | 81 | /// An optional traditional ruling planet name. 82 | public var traditionalRulingPlanetName: String? { metadata.traditionalRulingPlanetName } 83 | 84 | /// The planetary symbol (e.g., ♃ for Jupiter). 85 | public var rulingPlanetSymbol: String { metadata.rulingPlanetSymbol } 86 | 87 | /// Represents energetic patterning (e.g., Yin or Yang). 88 | public var modality: String { metadata.modality } 89 | 90 | /// The polarity classification (Positive or Negative). 91 | public var polarity: String { metadata.polarity } 92 | 93 | /// The associated directional or seasonal house. 94 | public var rulingHouse: String { metadata.rulingHouse } 95 | 96 | /// The brightest star linked with this sign. 97 | public var brightestStar: String { metadata.brightestStar } 98 | 99 | /// The Yin or Yang classification. 100 | public var yinYang: String { metadata.yinYang } 101 | 102 | /// The seasonal association of the sign. 103 | public var season: String { metadata.season } 104 | 105 | /// The symbolic name of the animal (e.g., "Snake"). 106 | public var symbol: String { metadata.symbol } 107 | 108 | /// The emoji representation of the symbol (e.g., 🐍). 109 | public var symbolEmoji: String { metadata.symbolEmoji } 110 | 111 | /// The associated birthstone (may be empty if not specified). 112 | public var birthstone: String { metadata.birthstone } 113 | 114 | /// Positive qualities typically attributed to this sign. 115 | public var strengths: [String] { metadata.strengths } 116 | 117 | /// Common challenges or flaws associated with the sign. 118 | public var weaknesses: [String] { metadata.weaknesses } 119 | 120 | /// Key summarised traits (useful for UI or quick reference). 121 | public var keyTraits: [String] { metadata.keyTraits } 122 | 123 | /// Signs that are considered the most naturally compatible. 124 | public var bestMatches: [Chinese] { Array(metadata.compatibilityInfo.bestMatches) } 125 | 126 | /// Signs that have average or neutral compatibility. 127 | public var averageMatches: [Chinese] { Array(metadata.compatibilityInfo.averageMatches) } 128 | 129 | /// Signs that may have conflict or tension, but are not inherently harmful. 130 | public var conflictingMatches: [Chinese] { Array(metadata.compatibilityInfo.conflictingMatches) } 131 | 132 | /// Signs that tend to have strong incompatibility or disharmony. 133 | public var harmfulMatches: [Chinese] { Array(metadata.compatibilityInfo.harmfulMatches) } 134 | } 135 | 136 | // MARK: - Metadata Mapping 137 | 138 | extension Chinese { 139 | 140 | /// A static mapping of all Chinese zodiac signs to their full metadata descriptions. 141 | /// 142 | /// This dictionary is used internally to power the `ZodiacMetadataRepresentable` conformance. 143 | internal static var metadataMap: [Chinese: ZodiacMetadata] {[ 144 | .rat: ZodiacMetadata( 145 | emoji: "🐀", 146 | element: "Water", 147 | elementEmoji: "💧", 148 | modality: "Yang", 149 | polarity: "Positive", 150 | yinYang: "Yang", 151 | rulingPlanetName: "Mercury", 152 | traditionalRulingPlanetName: nil, 153 | rulingPlanetSymbol: "☿", 154 | rulingHouse: "North", 155 | colorHEX: "#7D9D9C", 156 | symbol: "Rat", 157 | symbolEmoji: "🐀", 158 | birthstone: "", 159 | season: "Winter", 160 | brightestStar: "Sirius", 161 | characteristics: [ 162 | "Quick-witted", 163 | "Resourceful", 164 | "Versatile", 165 | "Kind", 166 | "Smart", 167 | "Adaptable" 168 | ], 169 | strengths: [ 170 | "Adaptable", 171 | "Intelligent", 172 | "Alert", 173 | "Positive", 174 | "Flexible" 175 | ], 176 | weaknesses: [ 177 | "Timid", 178 | "Stubborn", 179 | "Picky", 180 | "Gossipy", 181 | "Opportunistic" 182 | ], 183 | keyTraits: [ 184 | "Clever", 185 | "Careful", 186 | "Diligent" 187 | ], 188 | compatibilityInfo: compatibilityInfo[.rat]! 189 | ), 190 | .ox: ZodiacMetadata( 191 | emoji: "🐂", 192 | element: "Earth", 193 | elementEmoji: "🌎", 194 | modality: "Yin", 195 | polarity: "Negative", 196 | yinYang: "Yin", 197 | rulingPlanetName: "Saturn", 198 | traditionalRulingPlanetName: nil, 199 | rulingPlanetSymbol: "♄", 200 | rulingHouse: "North-Northeast", 201 | colorHEX: "#2B4865", 202 | symbol: "Ox", 203 | symbolEmoji: "🐂", 204 | birthstone: "", 205 | season: "Winter", 206 | brightestStar: "Aldebaran", 207 | characteristics: [ 208 | "Diligent", 209 | "Dependable", 210 | "Strong", 211 | "Determined", 212 | "Honest", 213 | "Patient" 214 | ], 215 | strengths: [ 216 | "Patient", 217 | "Hardworking", 218 | "Trustworthy", 219 | "Reliable", 220 | "Methodical" 221 | ], 222 | weaknesses: [ 223 | "Stubborn", 224 | "Conventional", 225 | "Inflexible", 226 | "Judgmental", 227 | "Demanding" 228 | ], 229 | keyTraits: [ 230 | "Reliable", 231 | "Strong", 232 | "Conservative" 233 | ], 234 | compatibilityInfo: compatibilityInfo[.ox]! 235 | ), 236 | .tiger: ZodiacMetadata( 237 | emoji: "🐅", 238 | element: "Wood", 239 | elementEmoji: "🌳", 240 | modality: "Yang", 241 | polarity: "Positive", 242 | yinYang: "Yang", 243 | rulingPlanetName: "Mars", 244 | traditionalRulingPlanetName: nil, 245 | rulingPlanetSymbol: "♂", 246 | rulingHouse: "Northeast", 247 | colorHEX: "#F29727", 248 | symbol: "Tiger", 249 | symbolEmoji: "🐅", 250 | birthstone: "", 251 | season: "Winter", 252 | brightestStar: "Regulus", 253 | characteristics: [ 254 | "Brave", 255 | "Confident", 256 | "Competitive", 257 | "Unpredictable", 258 | "Charming", 259 | "Intense" 260 | ], 261 | strengths: [ 262 | "Courageous", 263 | "Enthusiastic", 264 | "Confident", 265 | "Charismatic", 266 | "Leader" 267 | ], 268 | weaknesses: [ 269 | "Impulsive", 270 | "Rebellious", 271 | "Short-tempered", 272 | "Overconfident", 273 | "Suspicious" 274 | ], 275 | keyTraits: [ 276 | "Brave", 277 | "Confident", 278 | "Competitive" 279 | ], 280 | compatibilityInfo: compatibilityInfo[.tiger]! 281 | ), 282 | .rabbit: ZodiacMetadata( 283 | emoji: "🐇", 284 | element: "Wood", 285 | elementEmoji: "🌳", 286 | modality: "Yin", 287 | polarity: "Negative", 288 | yinYang: "Yin", 289 | rulingPlanetName: "Venus", 290 | traditionalRulingPlanetName: nil, 291 | rulingPlanetSymbol: "♀", 292 | rulingHouse: "East", 293 | colorHEX: "#F2BED1", 294 | symbol: "Rabbit", 295 | symbolEmoji: "🐇", 296 | birthstone: "", 297 | season: "Spring", 298 | brightestStar: "Vega", 299 | characteristics: [ 300 | "Gentle", 301 | "Quiet", 302 | "Elegant", 303 | "Alert", 304 | "Quick", 305 | "Skillful" 306 | ], 307 | strengths: [ 308 | "Gentle", 309 | "Compassionate", 310 | "Elegant", 311 | "Artistic", 312 | "Diplomatic" 313 | ], 314 | weaknesses: [ 315 | "Timid", 316 | "Superficial", 317 | "Detached", 318 | "Self-indulgent", 319 | "Overly cautious" 320 | ], 321 | keyTraits: [ 322 | "Quiet", 323 | "Elegant", 324 | "Kind" 325 | ], 326 | compatibilityInfo: compatibilityInfo[.rabbit]! 327 | ), 328 | .dragon: ZodiacMetadata( 329 | emoji: "🐉", 330 | element: "Earth", 331 | elementEmoji: "🌎", 332 | modality: "Yang", 333 | polarity: "Positive", 334 | yinYang: "Yang", 335 | rulingPlanetName: "Jupiter", 336 | traditionalRulingPlanetName: nil, 337 | rulingPlanetSymbol: "♃", 338 | rulingHouse: "East-Southeast", 339 | colorHEX: "#E94560", 340 | symbol: "Dragon", 341 | symbolEmoji: "🐉", 342 | birthstone: "", 343 | season: "Spring", 344 | brightestStar: "Alpha Draconis", 345 | characteristics: [ 346 | "Confident", 347 | "Intelligent", 348 | "Enthusiastic", 349 | "Ambitious", 350 | "Romantic", 351 | "Passionate" 352 | ], 353 | strengths: [ 354 | "Confident", 355 | "Ambitious", 356 | "Intelligent", 357 | "Energetic", 358 | "Charismatic" 359 | ], 360 | weaknesses: [ 361 | "Arrogant", 362 | "Impulsive", 363 | "Unrealistic", 364 | "Domineering", 365 | "Inflexible" 366 | ], 367 | keyTraits: [ 368 | "Powerful", 369 | "Ambitious", 370 | "Lucky" 371 | ], 372 | compatibilityInfo: compatibilityInfo[.dragon]! 373 | ), 374 | .snake: ZodiacMetadata( 375 | emoji: "🐍", 376 | element: "Fire", 377 | elementEmoji: "🔥", 378 | modality: "Yin", 379 | polarity: "Negative", 380 | yinYang: "Yin", 381 | rulingPlanetName: "Pluto", 382 | traditionalRulingPlanetName: nil, 383 | rulingPlanetSymbol: "♇", 384 | rulingHouse: "Southeast", 385 | colorHEX: "#557153", 386 | symbol: "Snake", 387 | symbolEmoji: "🐍", 388 | birthstone: "", 389 | season: "Spring", 390 | brightestStar: "Serpens", 391 | characteristics: [ 392 | "Enigmatic", 393 | "Intuitive", 394 | "Wise", 395 | "Determined", 396 | "Refined", 397 | "Analytical" 398 | ], 399 | strengths: [ 400 | "Wise", 401 | "Intuitive", 402 | "Elegant", 403 | "Determined", 404 | "Mysterious" 405 | ], 406 | weaknesses: [ 407 | "Jealous", 408 | "Suspicious", 409 | "Possessive", 410 | "Manipulative", 411 | "Materialistic" 412 | ], 413 | keyTraits: [ 414 | "Wise", 415 | "Mysterious", 416 | "Charming" 417 | ], 418 | compatibilityInfo: compatibilityInfo[.snake]! 419 | ), 420 | .horse: ZodiacMetadata( 421 | emoji: "🐎", 422 | element: "Fire", 423 | elementEmoji: "🔥", 424 | modality: "Yang", 425 | polarity: "Positive", 426 | yinYang: "Yang", 427 | rulingPlanetName: "Mars", 428 | traditionalRulingPlanetName: nil, 429 | rulingPlanetSymbol: "♂", 430 | rulingHouse: "South", 431 | colorHEX: "#C63D2F", 432 | symbol: "Horse", 433 | symbolEmoji: "🐎", 434 | birthstone: "", 435 | season: "Summer", 436 | brightestStar: "Kitalpha", 437 | characteristics: [ 438 | "Energetic", 439 | "Independent", 440 | "Warm-hearted", 441 | "Enthusiastic", 442 | "Free-spirited", 443 | "Positive" 444 | ], 445 | strengths: [ 446 | "Energetic", 447 | "Independent", 448 | "Adventurous", 449 | "Warm-hearted", 450 | "Versatile" 451 | ], 452 | weaknesses: [ 453 | "Impatient", 454 | "Impulsive", 455 | "Stubborn", 456 | "Self-centered", 457 | "Rebellious" 458 | ], 459 | keyTraits: [ 460 | "Energetic", 461 | "Independent", 462 | "Adventurous" 463 | ], 464 | compatibilityInfo: compatibilityInfo[.horse]! 465 | ), 466 | .goat: ZodiacMetadata( 467 | emoji: "🐐", 468 | element: "Earth", 469 | elementEmoji: "🌎", 470 | modality: "Yin", 471 | polarity: "Negative", 472 | yinYang: "Yin", 473 | rulingPlanetName: "Neptune", 474 | traditionalRulingPlanetName: nil, 475 | rulingPlanetSymbol: "♆", 476 | rulingHouse: "Southwest", 477 | colorHEX: "#F0E5CF", 478 | symbol: "Goat", 479 | symbolEmoji: "🐐", 480 | birthstone: "", 481 | season: "Summer", 482 | brightestStar: "Capella", 483 | characteristics: [ 484 | "Gentle", 485 | "Empathetic", 486 | "Creative", 487 | "Calm", 488 | "Artistic", 489 | "Elegant" 490 | ], 491 | strengths: [ 492 | "Gentle", 493 | "Creative", 494 | "Compassionate", 495 | "Artistic", 496 | "Peaceful" 497 | ], 498 | weaknesses: [ 499 | "Indecisive", 500 | "Timid", 501 | "Pessimistic", 502 | "Dependent", 503 | "Worrisome" 504 | ], 505 | keyTraits: [ 506 | "Gentle", 507 | "Creative", 508 | "Compassionate" 509 | ], 510 | compatibilityInfo: compatibilityInfo[.goat]! 511 | ), 512 | .monkey: ZodiacMetadata( 513 | emoji: "🐒", 514 | element: "Metal", 515 | elementEmoji: "🔧", 516 | modality: "Yang", 517 | polarity: "Positive", 518 | yinYang: "Yang", 519 | rulingPlanetName: "Mercury", 520 | traditionalRulingPlanetName: nil, 521 | rulingPlanetSymbol: "☿", 522 | rulingHouse: "West-Southwest", 523 | colorHEX: "#FCAF3C", 524 | symbol: "Monkey", 525 | symbolEmoji: "🐒", 526 | birthstone: "", 527 | season: "Summer", 528 | brightestStar: "Arcturus", 529 | characteristics: [ 530 | "Intelligent", 531 | "Witty", 532 | "Flexible", 533 | "Innovative", 534 | "Problem solver", 535 | "Mischievous" 536 | ], 537 | strengths: [ 538 | "Intelligent", 539 | "Creative", 540 | "Versatile", 541 | "Witty", 542 | "Adaptable" 543 | ], 544 | weaknesses: [ 545 | "Dishonest", 546 | "Impulsive", 547 | "Opportunistic", 548 | "Vain", 549 | "Manipulative" 550 | ], 551 | keyTraits: [ 552 | "Clever", 553 | "Versatile", 554 | "Quick-witted" 555 | ], 556 | compatibilityInfo: compatibilityInfo[.monkey]! 557 | ), 558 | .rooster: ZodiacMetadata( 559 | emoji: "🐓", 560 | element: "Metal", 561 | elementEmoji: "🔧", 562 | modality: "Yin", 563 | polarity: "Negative", 564 | yinYang: "Yin", 565 | rulingPlanetName: "Venus", 566 | traditionalRulingPlanetName: nil, 567 | rulingPlanetSymbol: "♀", 568 | rulingHouse: "West", 569 | colorHEX: "#FA7070", 570 | symbol: "Rooster", 571 | symbolEmoji: "🐓", 572 | birthstone: "", 573 | season: "Autumn", 574 | brightestStar: "Spica", 575 | characteristics: [ 576 | "Observant", 577 | "Hardworking", 578 | "Courageous", 579 | "Talented", 580 | "Confident", 581 | "Honest" 582 | ], 583 | strengths: [ 584 | "Honest", 585 | "Observant", 586 | "Practical", 587 | "Organized", 588 | "Confident" 589 | ], 590 | weaknesses: [ 591 | "Critical", 592 | "Perfectionist", 593 | "Blunt", 594 | "Conservative", 595 | "Arrogant" 596 | ], 597 | keyTraits: [ 598 | "Observant", 599 | "Hardworking", 600 | "Courageous" 601 | ], 602 | compatibilityInfo: compatibilityInfo[.rooster]! 603 | ), 604 | .dog: ZodiacMetadata( 605 | emoji: "🐕", 606 | element: "Earth", 607 | elementEmoji: "🌎", 608 | modality: "Yang", 609 | polarity: "Positive", 610 | yinYang: "Yang", 611 | rulingPlanetName: "Pluto", 612 | traditionalRulingPlanetName: nil, 613 | rulingPlanetSymbol: "♇", 614 | rulingHouse: "West-Northwest", 615 | colorHEX: "#C4DFAA", 616 | symbol: "Dog", 617 | symbolEmoji: "🐕", 618 | birthstone: "", 619 | season: "Autumn", 620 | brightestStar: "Sirius", 621 | characteristics: [ 622 | "Loyal", 623 | "Honest", 624 | "Responsible", 625 | "Courageous", 626 | "Sincere", 627 | "Protective" 628 | ], 629 | strengths: [ 630 | "Loyal", 631 | "Honest", 632 | "Responsible", 633 | "Brave", 634 | "Protective" 635 | ], 636 | weaknesses: [ 637 | "Anxious", 638 | "Conservative", 639 | "Stubborn", 640 | "Critical", 641 | "Pessimistic" 642 | ], 643 | keyTraits: [ 644 | "Loyal", 645 | "Honest", 646 | "Protective" 647 | ], 648 | compatibilityInfo: compatibilityInfo[.dog]! 649 | ), 650 | .pig: ZodiacMetadata( 651 | emoji: "🐖", 652 | element: "Water", 653 | elementEmoji: "💧", 654 | modality: "Yin", 655 | polarity: "Negative", 656 | yinYang: "Yin", 657 | rulingPlanetName: "Jupiter", 658 | traditionalRulingPlanetName: nil, 659 | rulingPlanetSymbol: "♃", 660 | rulingHouse: "Northwest", 661 | colorHEX: "#CB1C8D", 662 | symbol: "Pig", 663 | symbolEmoji: "🐖", 664 | birthstone: "", 665 | season: "Winter", 666 | brightestStar: "Alderamin", 667 | characteristics: [ 668 | "Compassionate", 669 | "Generous", 670 | "Diligent", 671 | "Peace-loving", 672 | "Honest", 673 | "Optimistic" 674 | ], 675 | strengths: [ 676 | "Kind", 677 | "Generous", 678 | "Diligent", 679 | "Honest", 680 | "Brave" 681 | ], 682 | weaknesses: [ 683 | "Naive", 684 | "Over-reliant", 685 | "Self-indulgent", 686 | "Materialistic", 687 | "Gullible" 688 | ], 689 | keyTraits: [ 690 | "Kind", 691 | "Wealth-oriented", 692 | "Honest" 693 | ], 694 | compatibilityInfo: compatibilityInfo[.pig]! 695 | ), 696 | ]} 697 | } 698 | 699 | // MARK: - Compatibility Info 700 | 701 | extension Chinese { 702 | 703 | /// Defines zodiac compatibility rules for each Chinese sign. 704 | /// 705 | /// Compatibility is categorised as: 706 | /// - `bestMatches`: Ideal pairings 707 | /// - `averageMatches`: Neutral or balanced pairings 708 | /// - `conflictingMatches`: Challenging but not harmful 709 | /// - `harmfulMatches`: Traditionally unlucky or unstable pairings 710 | internal static var compatibilityInfo: [Chinese: CompatibilityInfo] {[ 711 | .rat: CompatibilityInfo( 712 | bestMatches: [.dragon, .monkey, .ox], 713 | averageMatches: [.tiger, .snake, .rooster], 714 | conflictingMatches: [.rabbit, .goat], 715 | harmfulMatches: [.horse] 716 | ), 717 | .ox: CompatibilityInfo( 718 | bestMatches: [.rat, .snake, .rooster], 719 | averageMatches: [.tiger, .monkey, .pig], 720 | conflictingMatches: [.dragon, .horse], 721 | harmfulMatches: [.goat] 722 | ), 723 | .tiger: CompatibilityInfo( 724 | bestMatches: [.horse, .dog, .pig], 725 | averageMatches: [.rat, .rabbit, .dragon], 726 | conflictingMatches: [.monkey, .snake], 727 | harmfulMatches: [.ox] 728 | ), 729 | .rabbit: CompatibilityInfo( 730 | bestMatches: [.goat, .dog, .pig], 731 | averageMatches: [.tiger, .horse, .monkey], 732 | conflictingMatches: [.rat, .dragon], 733 | harmfulMatches: [.rooster] 734 | ), 735 | .dragon: CompatibilityInfo( 736 | bestMatches: [.rat, .monkey, .rooster], 737 | averageMatches: [.tiger, .snake, .pig], 738 | conflictingMatches: [.ox, .rabbit], 739 | harmfulMatches: [.dog] 740 | ), 741 | .snake: CompatibilityInfo( 742 | bestMatches: [.ox, .rooster, .monkey], 743 | averageMatches: [.dragon, .goat, .dog], 744 | conflictingMatches: [.tiger, .rabbit], 745 | harmfulMatches: [.pig] 746 | ), 747 | .horse: CompatibilityInfo( 748 | bestMatches: [.tiger, .goat, .dog], 749 | averageMatches: [.rabbit, .monkey, .pig], 750 | conflictingMatches: [.ox, .rooster], 751 | harmfulMatches: [.rat] 752 | ), 753 | .goat: CompatibilityInfo( 754 | bestMatches: [.rabbit, .horse, .pig], 755 | averageMatches: [.snake, .monkey, .dog], 756 | conflictingMatches: [.rat, .ox], 757 | harmfulMatches: [.rooster] 758 | ), 759 | .monkey: CompatibilityInfo( 760 | bestMatches: [.rat, .dragon, .snake], 761 | averageMatches: [.ox, .rabbit, .goat], 762 | conflictingMatches: [.tiger, .pig], 763 | harmfulMatches: [.horse] 764 | ), 765 | .rooster: CompatibilityInfo( 766 | bestMatches: [.ox, .dragon, .snake], 767 | averageMatches: [.rat, .tiger, .dog], 768 | conflictingMatches: [.horse, .rabbit], 769 | harmfulMatches: [.goat] 770 | ), 771 | .dog: CompatibilityInfo( 772 | bestMatches: [.tiger, .rabbit, .horse], 773 | averageMatches: [.rooster, .goat, .snake], 774 | conflictingMatches: [.ox, .monkey], 775 | harmfulMatches: [.dragon] 776 | ), 777 | .pig: CompatibilityInfo( 778 | bestMatches: [.tiger, .rabbit, .goat], 779 | averageMatches: [.ox, .dragon, .horse], 780 | conflictingMatches: [.monkey, .snake], 781 | harmfulMatches: [.rooster] 782 | ), 783 | ]} 784 | } 785 | -------------------------------------------------------------------------------- /Sources/ZodiacKit/Zodiacs/Western.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ZodiacKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | /// Represents the Western zodiac signs, including traditional and extended constellations. 8 | /// 9 | /// Includes the 12 traditional signs, along with optionally recognised signs such as `ophiuchus` 10 | /// and `cetus`. 11 | public enum Western: String, ZodiacSign { 12 | 13 | /// The Aquarius sign. 14 | case aquarius 15 | 16 | /// The Aries sign. 17 | case aries 18 | 19 | /// The Cancer sign. 20 | case cancer 21 | 22 | /// The Capricorn sign. 23 | case capricorn 24 | 25 | /// The Gemini sign. 26 | case gemini 27 | 28 | /// The Leo sign. 29 | case leo 30 | 31 | /// The Libra sign. 32 | case libra 33 | 34 | /// The Pisces sign. 35 | case pisces 36 | 37 | /// The Sagittarius sign. 38 | case sagittarius 39 | 40 | /// The Scorpio sign. 41 | case scorpio 42 | 43 | /// The Taurus sign. 44 | case taurus 45 | 46 | /// The Virgo sign. 47 | case virgo 48 | 49 | /// The Ophiuchus sign — sometimes considered a "13th" zodiac sign in modern astrology. 50 | case ophiuchus 51 | 52 | /// The Cetus sign — an extremely rare and unofficial zodiac sign occasionally referenced in 53 | /// sidereal astrology. 54 | case cetus 55 | 56 | /// The 12 traditional zodiac signs, excluding `ophiuchus` and `cetus`. 57 | /// Used as the base for systems like the tropical and sidereal zodiacs. 58 | private static var traditionalCases: [Western] { 59 | allCases.filter { $0 != .ophiuchus && $0 != .cetus } 60 | } 61 | 62 | /// The tropical zodiac signs, commonly used in Western astrology. 63 | /// Matches the 12 traditional signs, excluding `ophiuchus` and `cetus`. 64 | public static var tropicalCases: [Western] { traditionalCases } 65 | 66 | /// The sidereal zodiac signs, based on constellations. 67 | /// Matches the 12 traditional signs, excluding `ophiuchus` and `cetus`. 68 | public static var siderealCases: [Western] { traditionalCases } 69 | 70 | /// An equal division of the ecliptic into 14 segments, including all signs. 71 | /// Includes `ophiuchus` and `cetus`. 72 | public static var equalLengthCases: [Western] { allCases } 73 | 74 | /// Zodiac signs recognized in the IAU (International Astronomical Union) model. 75 | /// Includes `ophiuchus` but excludes `cetus`, which is not officially part of the zodiac belt. 76 | public static var astronomicalIAUCases: [Western] { 77 | allCases.filter { $0 != .cetus } 78 | } 79 | } 80 | 81 | extension Western: ZodiacMetadataRepresentable { 82 | 83 | /// The capitalised name of the sign (e.g., "Aries"). 84 | public var name: String { rawValue.capitalized } 85 | 86 | /// An emoji representing the astrological glyph or icon. 87 | public var emoji: String { metadata.emoji } 88 | 89 | /// The associated classical element (e.g., Fire, Earth). 90 | public var element: String { metadata.element } 91 | 92 | /// Emoji for the classical element (e.g., 🔥). 93 | public var elementEmoji: String { metadata.elementEmoji } 94 | 95 | /// Primary personality descriptors for the sign. 96 | public var characteristics: [String] { metadata.characteristics } 97 | 98 | /// The associated colour in HEX format. 99 | public var colorHEX: String { metadata.colorHEX } 100 | 101 | /// A platform-agnostic colour (UIColor / NSColor). 102 | public var color: AgnosticColor { .init(hex: colorHEX) ?? .clear } 103 | 104 | /// The ruling planet for the sign in modern astrology. 105 | public var rulingPlanetName: String { metadata.rulingPlanetName } 106 | 107 | /// The traditional ruling planet, if different from modern. 108 | public var traditionalRulingPlanetName: String? { metadata.traditionalRulingPlanetName } 109 | 110 | /// The planet's astronomical or astrological symbol. 111 | public var rulingPlanetSymbol: String { metadata.rulingPlanetSymbol } 112 | 113 | /// The modality (e.g., Cardinal, Fixed, Mutable). 114 | public var modality: String { metadata.modality } 115 | 116 | /// The astrological polarity (Positive or Negative). 117 | public var polarity: String { metadata.polarity } 118 | 119 | /// The associated astrological house (1st–12th). 120 | public var rulingHouse: String { metadata.rulingHouse } 121 | 122 | /// The brightest star associated with the sign's constellation. 123 | public var brightestStar: String { metadata.brightestStar } 124 | 125 | /// The Yin or Yang classification. 126 | public var yinYang: String { metadata.yinYang } 127 | 128 | /// The seasonal placement of the sign. 129 | public var season: String { metadata.season } 130 | 131 | /// The symbolic representation of the sign (e.g., "Ram"). 132 | public var symbol: String { metadata.symbol } 133 | 134 | /// The emoji version of the symbol (e.g., 🐏). 135 | public var symbolEmoji: String { metadata.symbolEmoji } 136 | 137 | /// The birthstone traditionally linked with the sign. 138 | public var birthstone: String { metadata.birthstone } 139 | 140 | /// Positive qualities most strongly associated with the sign. 141 | public var strengths: [String] { metadata.strengths } 142 | 143 | /// Challenges or character flaws tied to the sign. 144 | public var weaknesses: [String] { metadata.weaknesses } 145 | 146 | /// Key summary traits often used in personality insights or horoscopes. 147 | public var keyTraits: [String] { metadata.keyTraits } 148 | 149 | /// Signs that are considered the most naturally compatible. 150 | public var bestMatches: [Western] { Array(metadata.compatibilityInfo.bestMatches) } 151 | 152 | /// Signs that have average or neutral compatibility. 153 | public var averageMatches: [Western] { Array(metadata.compatibilityInfo.averageMatches) } 154 | 155 | /// Signs that may have conflict or tension, but are not inherently harmful. 156 | public var conflictingMatches: [Western] { Array(metadata.compatibilityInfo.conflictingMatches) } 157 | 158 | /// Signs that tend to have strong incompatibility or disharmony. 159 | public var harmfulMatches: [Western] { Array(metadata.compatibilityInfo.harmfulMatches) } 160 | } 161 | 162 | // MARK: - Metadata Mapping 163 | 164 | extension Western { 165 | 166 | /// A static mapping of all Western zodiac signs to their full metadata descriptions. 167 | /// 168 | /// This dictionary is used internally to power the `ZodiacMetadataRepresentable` conformance. 169 | internal static var metadataMap: [Western: ZodiacMetadata] {[ 170 | .aquarius: ZodiacMetadata( 171 | emoji: "♒", 172 | element: "Air", 173 | elementEmoji: "💨", 174 | modality: "Fixed", 175 | polarity: "Positive", 176 | yinYang: "Yang", 177 | rulingPlanetName: "Uranus", 178 | traditionalRulingPlanetName: "Saturn", 179 | rulingPlanetSymbol: "♅", 180 | rulingHouse: "11th House", 181 | colorHEX: "#1CA9C9", 182 | symbol: "Water Bearer", 183 | symbolEmoji: "🏺", 184 | birthstone: "Amethyst", 185 | season: "Winter", 186 | brightestStar: "Sadalsuud", 187 | characteristics: [ 188 | "Independent", 189 | "Original", 190 | "Humanitarian", 191 | "Intellectual", 192 | "Progressive", 193 | "Idealistic" 194 | ], 195 | strengths: [ 196 | "Visionary", 197 | "Loyal", 198 | "Original", 199 | "Inventive", 200 | "Altruistic" 201 | ], 202 | weaknesses: [ 203 | "Detached", 204 | "Stubborn", 205 | "Unpredictable", 206 | "Aloof", 207 | "Rebellious" 208 | ], 209 | keyTraits: [ 210 | "Innovative", 211 | "Eccentric", 212 | "Humanitarian" 213 | ], 214 | compatibilityInfo: compatibilityInfo[.aquarius]! 215 | ), 216 | .pisces: ZodiacMetadata( 217 | emoji: "♓", 218 | element: "Water", 219 | elementEmoji: "💧", 220 | modality: "Mutable", 221 | polarity: "Negative", 222 | yinYang: "Yin", 223 | rulingPlanetName: "Neptune", 224 | traditionalRulingPlanetName: "Jupiter", 225 | rulingPlanetSymbol: "♆", 226 | rulingHouse: "12th House", 227 | colorHEX: "#7C9ED9", 228 | symbol: "Fish", 229 | symbolEmoji: "🐟", 230 | birthstone: "Aquamarine", 231 | season: "Winter/Spring Cusp", 232 | brightestStar: "Eta Piscium", 233 | characteristics: [ 234 | "Compassionate", 235 | "Intuitive", 236 | "Dreamy", 237 | "Artistic", 238 | "Gentle", 239 | "Spiritual" 240 | ], 241 | strengths: [ 242 | "Empathetic", 243 | "Creative", 244 | "Intuitive", 245 | "Adaptable", 246 | "Selfless" 247 | ], 248 | weaknesses: [ 249 | "Escapist", 250 | "Idealistic", 251 | "Oversensitive", 252 | "Indecisive", 253 | "Victim mentality" 254 | ], 255 | keyTraits: [ 256 | "Dreamy", 257 | "Mystical", 258 | "Empathetic" 259 | ], 260 | compatibilityInfo: compatibilityInfo[.pisces]! 261 | ), 262 | .aries: ZodiacMetadata( 263 | emoji: "♈", 264 | element: "Fire", 265 | elementEmoji: "🔥", 266 | modality: "Cardinal", 267 | polarity: "Positive", 268 | yinYang: "Yang", 269 | rulingPlanetName: "Mars", 270 | traditionalRulingPlanetName: nil, 271 | rulingPlanetSymbol: "♂", 272 | rulingHouse: "1st House", 273 | colorHEX: "#FF4136", 274 | symbol: "Ram", 275 | symbolEmoji: "🐏", 276 | birthstone: "Diamond", 277 | season: "Spring", 278 | brightestStar: "Hamal", 279 | characteristics: [ 280 | "Courageous", 281 | "Energetic", 282 | "Confident", 283 | "Enthusiastic", 284 | "Direct", 285 | "Leader" 286 | ], 287 | strengths: [ 288 | "Brave", 289 | "Determined", 290 | "Confident", 291 | "Enthusiastic", 292 | "Optimistic" 293 | ], 294 | weaknesses: [ 295 | "Impatient", 296 | "Moody", 297 | "Impulsive", 298 | "Aggressive", 299 | "Short-tempered" 300 | ], 301 | keyTraits: [ 302 | "Energetic", 303 | "Adventurous", 304 | "Pioneer" 305 | ], 306 | compatibilityInfo: compatibilityInfo[.aries]! 307 | ), 308 | .taurus: ZodiacMetadata( 309 | emoji: "♉", 310 | element: "Earth", 311 | elementEmoji: "🌎", 312 | modality: "Fixed", 313 | polarity: "Negative", 314 | yinYang: "Yin", 315 | rulingPlanetName: "Venus", 316 | traditionalRulingPlanetName: nil, 317 | rulingPlanetSymbol: "♀", 318 | rulingHouse: "2nd House", 319 | colorHEX: "#2ECC40", 320 | symbol: "Bull", 321 | symbolEmoji: "🐂", 322 | birthstone: "Emerald", 323 | season: "Spring", 324 | brightestStar: "Aldebaran", 325 | characteristics: [ 326 | "Reliable", 327 | "Patient", 328 | "Practical", 329 | "Sensual", 330 | "Stubborn", 331 | "Devoted" 332 | ], 333 | strengths: [ 334 | "Reliable", 335 | "Patient", 336 | "Practical", 337 | "Loyal", 338 | "Responsible" 339 | ], 340 | weaknesses: [ 341 | "Stubborn", 342 | "Possessive", 343 | "Materialistic", 344 | "Self-indulgent", 345 | "Inflexible" 346 | ], 347 | keyTraits: [ 348 | "Patient", 349 | "Reliable", 350 | "Sensual" 351 | ], 352 | compatibilityInfo: compatibilityInfo[.taurus]! 353 | ), 354 | .gemini: ZodiacMetadata( 355 | emoji: "♊", 356 | element: "Air", 357 | elementEmoji: "💨", 358 | modality: "Mutable", 359 | polarity: "Positive", 360 | yinYang: "Yang", 361 | rulingPlanetName: "Mercury", 362 | traditionalRulingPlanetName: nil, 363 | rulingPlanetSymbol: "☿", 364 | rulingHouse: "3rd House", 365 | colorHEX: "#FFDC00", 366 | symbol: "Twins", 367 | symbolEmoji: "👯", 368 | birthstone: "Pearl", 369 | season: "Spring/Summer Cusp", 370 | brightestStar: "Pollux", 371 | characteristics: [ 372 | "Versatile", 373 | "Curious", 374 | "Communicative", 375 | "Witty", 376 | "Adaptable", 377 | "Intellectual" 378 | ], 379 | strengths: [ 380 | "Adaptable", 381 | "Outgoing", 382 | "Intelligent", 383 | "Eloquent", 384 | "Curious" 385 | ], 386 | weaknesses: [ 387 | "Inconsistent", 388 | "Nervous", 389 | "Indecisive", 390 | "Superficial", 391 | "Restless" 392 | ], 393 | keyTraits: [ 394 | "Communicative", 395 | "Curious", 396 | "Versatile" 397 | ], 398 | compatibilityInfo: compatibilityInfo[.gemini]! 399 | ), 400 | .cancer: ZodiacMetadata( 401 | emoji: "♋", 402 | element: "Water", 403 | elementEmoji: "💧", 404 | modality: "Cardinal", 405 | polarity: "Negative", 406 | yinYang: "Yin", 407 | rulingPlanetName: "Moon", 408 | traditionalRulingPlanetName: nil, 409 | rulingPlanetSymbol: "☽", 410 | rulingHouse: "4th House", 411 | colorHEX: "#DDDDDD", 412 | symbol: "Crab", 413 | symbolEmoji: "🦀", 414 | birthstone: "Ruby", 415 | season: "Summer", 416 | brightestStar: "Al Tarf", 417 | characteristics: [ 418 | "Nurturing", 419 | "Protective", 420 | "Intuitive", 421 | "Emotional", 422 | "Tenacious", 423 | "Sentimental" 424 | ], 425 | strengths: [ 426 | "Loyal", 427 | "Emotional", 428 | "Sympathetic", 429 | "Protective", 430 | "Intuitive" 431 | ], 432 | weaknesses: [ 433 | "Moody", 434 | "Overemotional", 435 | "Suspicious", 436 | "Manipulative", 437 | "Insecure" 438 | ], 439 | keyTraits: [ 440 | "Nurturing", 441 | "Protective", 442 | "Intuitive" 443 | ], 444 | compatibilityInfo: compatibilityInfo[.cancer]! 445 | ), 446 | .leo: ZodiacMetadata( 447 | emoji: "♌", 448 | element: "Fire", 449 | elementEmoji: "🔥", 450 | modality: "Fixed", 451 | polarity: "Positive", 452 | yinYang: "Yang", 453 | rulingPlanetName: "Sun", 454 | traditionalRulingPlanetName: nil, 455 | rulingPlanetSymbol: "☉", 456 | rulingHouse: "5th House", 457 | colorHEX: "#FF851B", 458 | symbol: "Lion", 459 | symbolEmoji: "🦁", 460 | birthstone: "Peridot", 461 | season: "Summer", 462 | brightestStar: "Regulus", 463 | characteristics: [ 464 | "Generous", 465 | "Warm-hearted", 466 | "Creative", 467 | "Enthusiastic", 468 | "Dignified", 469 | "Charismatic" 470 | ], 471 | strengths: [ 472 | "Confident", 473 | "Creative", 474 | "Generous", 475 | "Loyal", 476 | "Encouraging" 477 | ], 478 | weaknesses: [ 479 | "Arrogant", 480 | "Stubborn", 481 | "Self-centered", 482 | "Domineering", 483 | "Melodramatic" 484 | ], 485 | keyTraits: [ 486 | "Proud", 487 | "Charismatic", 488 | "Leader" 489 | ], 490 | compatibilityInfo: compatibilityInfo[.leo]! 491 | ), 492 | .virgo: ZodiacMetadata( 493 | emoji: "♍", 494 | element: "Earth", 495 | elementEmoji: "🌎", 496 | modality: "Mutable", 497 | polarity: "Negative", 498 | yinYang: "Yin", 499 | rulingPlanetName: "Mercury", 500 | traditionalRulingPlanetName: nil, 501 | rulingPlanetSymbol: "☿", 502 | rulingHouse: "6th House", 503 | colorHEX: "#B10DC9", 504 | symbol: "Virgin", 505 | symbolEmoji: "👩", 506 | birthstone: "Sapphire", 507 | season: "Summer/Autumn Cusp", 508 | brightestStar: "Spica", 509 | characteristics: [ 510 | "Analytical", 511 | "Practical", 512 | "Diligent", 513 | "Discriminating", 514 | "Helpful", 515 | "Modest" 516 | ], 517 | strengths: [ 518 | "Analytical", 519 | "Practical", 520 | "Diligent", 521 | "Meticulous", 522 | "Reliable" 523 | ], 524 | weaknesses: [ 525 | "Critical", 526 | "Perfectionist", 527 | "Overthinking", 528 | "Worrisome", 529 | "Fussy" 530 | ], 531 | keyTraits: [ 532 | "Analytical", 533 | "Precise", 534 | "Helpful" 535 | ], 536 | compatibilityInfo: compatibilityInfo[.virgo]! 537 | ), 538 | .libra: ZodiacMetadata( 539 | emoji: "♎", 540 | element: "Air", 541 | elementEmoji: "💨", 542 | modality: "Cardinal", 543 | polarity: "Positive", 544 | yinYang: "Yang", 545 | rulingPlanetName: "Venus", 546 | traditionalRulingPlanetName: nil, 547 | rulingPlanetSymbol: "♀", 548 | rulingHouse: "7th House", 549 | colorHEX: "#F012BE", 550 | symbol: "Scales", 551 | symbolEmoji: "⚖️", 552 | birthstone: "Opal", 553 | season: "Autumn", 554 | brightestStar: "Zubeneschamali", 555 | characteristics: [ 556 | "Diplomatic", 557 | "Fair-minded", 558 | "Social", 559 | "Cooperative", 560 | "Gracious", 561 | "Indecisive" 562 | ], 563 | strengths: [ 564 | "Diplomatic", 565 | "Fair", 566 | "Cooperative", 567 | "Social", 568 | "Gracious" 569 | ], 570 | weaknesses: [ 571 | "Indecisive", 572 | "Avoids confrontation", 573 | "Self-pitying", 574 | "Superficial", 575 | "Unreliable" 576 | ], 577 | keyTraits: [ 578 | "Diplomatic", 579 | "Partnership-oriented", 580 | "Refined" 581 | ], 582 | compatibilityInfo: compatibilityInfo[.libra]! 583 | ), 584 | .scorpio: ZodiacMetadata( 585 | emoji: "♏", 586 | element: "Water", 587 | elementEmoji: "💧", 588 | modality: "Fixed", 589 | polarity: "Negative", 590 | yinYang: "Yin", 591 | rulingPlanetName: "Pluto", 592 | traditionalRulingPlanetName: "Mars", 593 | rulingPlanetSymbol: "♇", 594 | rulingHouse: "8th House", 595 | colorHEX: "#85144B", 596 | symbol: "Scorpion", 597 | symbolEmoji: "🦂", 598 | birthstone: "Topaz", 599 | season: "Autumn", 600 | brightestStar: "Antares", 601 | characteristics: [ 602 | "Passionate", 603 | "Resourceful", 604 | "Brave", 605 | "Intense", 606 | "Mysterious", 607 | "Loyal" 608 | ], 609 | strengths: [ 610 | "Determined", 611 | "Brave", 612 | "Loyal", 613 | "Resourceful", 614 | "Passionate" 615 | ], 616 | weaknesses: [ 617 | "Jealous", 618 | "Secretive", 619 | "Resentful", 620 | "Suspicious", 621 | "Manipulative" 622 | ], 623 | keyTraits: [ 624 | "Intense", 625 | "Transformative", 626 | "Passionate" 627 | ], 628 | compatibilityInfo: compatibilityInfo[.scorpio]! 629 | ), 630 | .sagittarius: ZodiacMetadata( 631 | emoji: "♐", 632 | element: "Fire", 633 | elementEmoji: "🔥", 634 | modality: "Mutable", 635 | polarity: "Positive", 636 | yinYang: "Yang", 637 | rulingPlanetName: "Jupiter", 638 | traditionalRulingPlanetName: nil, 639 | rulingPlanetSymbol: "♃", 640 | rulingHouse: "9th House", 641 | colorHEX: "#0074D9", 642 | symbol: "Archer", 643 | symbolEmoji: "🏹", 644 | birthstone: "Turquoise", 645 | season: "Autumn/Winter Cusp", 646 | brightestStar: "Kaus Australis", 647 | characteristics: [ 648 | "Optimistic", 649 | "Freedom-loving", 650 | "Philosophical", 651 | "Straightforward", 652 | "Intellectual", 653 | "Adventurous" 654 | ], 655 | strengths: [ 656 | "Optimistic", 657 | "Freedom-loving", 658 | "Honest", 659 | "Intellectual", 660 | "Enthusiastic" 661 | ], 662 | weaknesses: [ 663 | "Tactless", 664 | "Restless", 665 | "Irresponsible", 666 | "Superficial", 667 | "Inconsistent" 668 | ], 669 | keyTraits: [ 670 | "Adventurous", 671 | "Optimistic", 672 | "Philosophical" 673 | ], 674 | compatibilityInfo: compatibilityInfo[.sagittarius]! 675 | ), 676 | .capricorn: ZodiacMetadata( 677 | emoji: "♑", 678 | element: "Earth", 679 | elementEmoji: "🌎", 680 | modality: "Cardinal", 681 | polarity: "Negative", 682 | yinYang: "Yin", 683 | rulingPlanetName: "Saturn", 684 | traditionalRulingPlanetName: nil, 685 | rulingPlanetSymbol: "♄", 686 | rulingHouse: "10th House", 687 | colorHEX: "#111111", 688 | symbol: "Sea Goat", 689 | symbolEmoji: "🐐", 690 | birthstone: "Garnet", 691 | season: "Winter", 692 | brightestStar: "Deneb Algedi", 693 | characteristics: [ 694 | "Responsible", 695 | "Disciplined", 696 | "Self-controlled", 697 | "Practical", 698 | "Cautious", 699 | "Ambitious" 700 | ], 701 | strengths: [ 702 | "Responsible", 703 | "Disciplined", 704 | "Self-controlled", 705 | "Practical", 706 | "Patient" 707 | ], 708 | weaknesses: [ 709 | "Pessimistic", 710 | "Fatalistic", 711 | "Rigid", 712 | "Cold", 713 | "Workaholic" 714 | ], 715 | keyTraits: [ 716 | "Ambitious", 717 | "Practical", 718 | "Disciplined" 719 | ], 720 | compatibilityInfo: compatibilityInfo[.capricorn]! 721 | ), 722 | .ophiuchus: ZodiacMetadata( 723 | emoji: "⛎", 724 | element: "Fire/Water", 725 | elementEmoji: "🔥💧", 726 | modality: "Transitional", 727 | polarity: "Balanced", 728 | yinYang: "Balanced", 729 | rulingPlanetName: "Chiron", 730 | traditionalRulingPlanetName: "Jupiter", 731 | rulingPlanetSymbol: "⚕", 732 | rulingHouse: "Not in traditional system", 733 | colorHEX: "#663399", 734 | symbol: "Serpent Bearer", 735 | symbolEmoji: "🐍", 736 | birthstone: "Amethyst/Obsidian", 737 | season: "Late Autumn", 738 | brightestStar: "Rasalhague", 739 | characteristics: [ 740 | "Healer", 741 | "Seeker", 742 | "Enlightened", 743 | "Mystical", 744 | "Passionate", 745 | "Balanced" 746 | ], 747 | strengths: [ 748 | "Healing", 749 | "Wisdom", 750 | "Seeking knowledge", 751 | "Intuitive", 752 | "Balanced", 753 | "Magnetic" 754 | ], 755 | weaknesses: [ 756 | "Jealous", 757 | "Secretive", 758 | "Arrogant", 759 | "Passionate to a fault", 760 | "Conflicted" 761 | ], 762 | keyTraits: [ 763 | "Healer", 764 | "Seeker of wisdom", 765 | "Mystical" 766 | ], 767 | compatibilityInfo: compatibilityInfo[.ophiuchus]! 768 | ), 769 | .cetus: ZodiacMetadata( 770 | emoji: "🐋", 771 | element: "Water", 772 | elementEmoji: "💧", 773 | modality: "Fixed", 774 | polarity: "Negative", 775 | yinYang: "Yin", 776 | rulingPlanetName: "Neptune", 777 | traditionalRulingPlanetName: "Moon", 778 | rulingPlanetSymbol: "♆", 779 | rulingHouse: "Not in traditional system", 780 | colorHEX: "#4169E1", 781 | symbol: "Sea Monster/Whale", 782 | symbolEmoji: "🐋", 783 | birthstone: "Aquamarine/Pearl", 784 | season: "Winter", 785 | brightestStar: "Menkar", 786 | characteristics: [ 787 | "Mysterious", 788 | "Deep", 789 | "Transformative", 790 | "Ancient", 791 | "Profound", 792 | "Primordial" 793 | ], 794 | strengths: [ 795 | "Depth", 796 | "Understanding", 797 | "Intuition", 798 | "Emotional intelligence", 799 | "Creativity", 800 | "Transformative" 801 | ], 802 | weaknesses: [ 803 | "Overwhelming", 804 | "Moody", 805 | "Secretive", 806 | "Withdrawn", 807 | "Overly complex" 808 | ], 809 | keyTraits: [ 810 | "Mysterious", 811 | "Deep", 812 | "Primordial" 813 | ], 814 | compatibilityInfo: compatibilityInfo[.cetus]! 815 | ) 816 | ]} 817 | } 818 | 819 | // MARK: - Compatibility Info 820 | 821 | extension Western { 822 | 823 | /// Defines zodiac compatibility rules for each Western sign. 824 | /// 825 | /// Compatibility is categorised as: 826 | /// - `bestMatches`: Strong romantic or personal synergy. 827 | /// - `averageMatches`: Reasonable alignment with some effort. 828 | /// - `conflictingMatches`: Tensions or misunderstandings possible. 829 | /// - `harmfulMatches`: Traditionally inauspicious or toxic pairings. 830 | internal static var compatibilityInfo: [Western: CompatibilityInfo] {[ 831 | .aquarius: CompatibilityInfo( 832 | bestMatches: [.gemini, .libra, .aquarius, .sagittarius], 833 | averageMatches: [.aries, .leo], 834 | conflictingMatches: [.taurus, .scorpio], 835 | harmfulMatches: [.cancer, .capricorn] 836 | ), 837 | .pisces: CompatibilityInfo( 838 | bestMatches: [.cancer, .scorpio, .pisces, .capricorn], 839 | averageMatches: [.taurus, .virgo], 840 | conflictingMatches: [.gemini, .sagittarius], 841 | harmfulMatches: [.libra, .aquarius] 842 | ), 843 | .aries: CompatibilityInfo( 844 | bestMatches: [.leo, .sagittarius, .aries, .gemini], 845 | averageMatches: [.aquarius, .libra], 846 | conflictingMatches: [.cancer, .capricorn], 847 | harmfulMatches: [.virgo, .pisces] 848 | ), 849 | .taurus: CompatibilityInfo( 850 | bestMatches: [.virgo, .capricorn, .taurus, .cancer], 851 | averageMatches: [.pisces, .scorpio], 852 | conflictingMatches: [.leo, .aquarius], 853 | harmfulMatches: [.sagittarius, .aries] 854 | ), 855 | .gemini: CompatibilityInfo( 856 | bestMatches: [.libra, .aquarius, .gemini, .aries], 857 | averageMatches: [.leo, .sagittarius], 858 | conflictingMatches: [.virgo, .pisces], 859 | harmfulMatches: [.scorpio, .capricorn] 860 | ), 861 | .cancer: CompatibilityInfo( 862 | bestMatches: [.scorpio, .pisces, .cancer, .taurus], 863 | averageMatches: [.virgo, .capricorn], 864 | conflictingMatches: [.aries, .libra], 865 | harmfulMatches: [.aquarius, .leo] 866 | ), 867 | .leo: CompatibilityInfo( 868 | bestMatches: [.aries, .sagittarius, .leo, .libra], 869 | averageMatches: [.gemini, .aquarius], 870 | conflictingMatches: [.taurus, .scorpio], 871 | harmfulMatches: [.virgo, .capricorn] 872 | ), 873 | .virgo: CompatibilityInfo( 874 | bestMatches: [.taurus, .capricorn, .virgo, .cancer], 875 | averageMatches: [.scorpio, .pisces], 876 | conflictingMatches: [.gemini, .sagittarius], 877 | harmfulMatches: [.aries, .leo] 878 | ), 879 | .libra: CompatibilityInfo( 880 | bestMatches: [.gemini, .aquarius, .libra, .leo], 881 | averageMatches: [.aries, .sagittarius], 882 | conflictingMatches: [.cancer, .capricorn], 883 | harmfulMatches: [.virgo, .pisces] 884 | ), 885 | .scorpio: CompatibilityInfo( 886 | bestMatches: [.cancer, .pisces, .scorpio, .capricorn], 887 | averageMatches: [.virgo, .taurus], 888 | conflictingMatches: [.leo, .aquarius], 889 | harmfulMatches: [.gemini, .sagittarius] 890 | ), 891 | .sagittarius: CompatibilityInfo( 892 | bestMatches: [.aries, .leo, .sagittarius, .aquarius], 893 | averageMatches: [.libra, .gemini], 894 | conflictingMatches: [.pisces, .virgo], 895 | harmfulMatches: [.taurus, .scorpio] 896 | ), 897 | .capricorn: CompatibilityInfo( 898 | bestMatches: [.taurus, .virgo, .capricorn, .scorpio], 899 | averageMatches: [.pisces, .cancer], 900 | conflictingMatches: [.aries, .libra], 901 | harmfulMatches: [.gemini, .sagittarius] 902 | ), 903 | .ophiuchus: CompatibilityInfo( 904 | bestMatches: [.pisces, .capricorn, .cancer, .virgo], 905 | averageMatches: [.scorpio, .taurus], 906 | conflictingMatches: [.leo, .aquarius], 907 | harmfulMatches: [.sagittarius, .gemini] 908 | ), 909 | .cetus: CompatibilityInfo( 910 | bestMatches: [.cancer, .scorpio, .pisces, .ophiuchus], 911 | averageMatches: [.capricorn, .taurus], 912 | conflictingMatches: [.aries, .leo], 913 | harmfulMatches: [.gemini, .aquarius] 914 | ) 915 | ]} 916 | } 917 | --------------------------------------------------------------------------------