diff --git a/visualization/app/VisualReport/VisualReport.xcodeproj/project.pbxproj b/visualization/app/VisualReport/VisualReport.xcodeproj/project.pbxproj index 02a1d8be..2b166f05 100644 --- a/visualization/app/VisualReport/VisualReport.xcodeproj/project.pbxproj +++ b/visualization/app/VisualReport/VisualReport.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 04DB73752C725FDF0038AEFD /* LineChartDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DB73742C725FDF0038AEFD /* LineChartDataModel.swift */; }; 04DB73772C7265300038AEFD /* LineChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DB73762C7265300038AEFD /* LineChartViewModel.swift */; }; 04DB73792C7346140038AEFD /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DB73782C7346140038AEFD /* Double+Extension.swift */; }; + 04DB737B2C748FDE0038AEFD /* OptionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DB737A2C748FDE0038AEFD /* OptionPickerView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -47,6 +48,7 @@ 04DB73742C725FDF0038AEFD /* LineChartDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartDataModel.swift; sourceTree = ""; }; 04DB73762C7265300038AEFD /* LineChartViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartViewModel.swift; sourceTree = ""; }; 04DB73782C7346140038AEFD /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; + 04DB737A2C748FDE0038AEFD /* OptionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionPickerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -143,6 +145,7 @@ isa = PBXGroup; children = ( 048B86C82C67E75C008036D4 /* CSVFileReader.swift */, + 04DB737A2C748FDE0038AEFD /* OptionPickerView.swift */, ); path = Helper; sourceTree = ""; @@ -253,6 +256,7 @@ 048B86C32C67D6D8008036D4 /* ProductSelectionViewModel.swift in Sources */, 043E74D02BABF77700B9AF91 /* ContentView.swift in Sources */, 048B86C12C67D6CB008036D4 /* ProductSelectionScreen.swift in Sources */, + 04DB737B2C748FDE0038AEFD /* OptionPickerView.swift in Sources */, 04DB73752C725FDF0038AEFD /* LineChartDataModel.swift in Sources */, 048B86D22C6B58E9008036D4 /* SingleBarViewModel.swift in Sources */, 048B86D02C6B58D6008036D4 /* SingleBarScreen.swift in Sources */, diff --git a/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/Contents.json b/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db43..cca9ea71 100644 --- a/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -41,11 +41,13 @@ "size" : "256x256" }, { + "filename" : "cellphone-5.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "cellphone.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/cellphone-5.png b/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/cellphone-5.png new file mode 100644 index 00000000..452e48dc Binary files /dev/null and b/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/cellphone-5.png differ diff --git a/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/cellphone.png b/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/cellphone.png new file mode 100644 index 00000000..5f84d2d8 Binary files /dev/null and b/visualization/app/VisualReport/VisualReport/Assets.xcassets/AppIcon.appiconset/cellphone.png differ diff --git a/visualization/app/VisualReport/VisualReport/Extension/DateFormatter+Extension.swift b/visualization/app/VisualReport/VisualReport/Extension/DateFormatter+Extension.swift index 10be5fc9..72b73fad 100644 --- a/visualization/app/VisualReport/VisualReport/Extension/DateFormatter+Extension.swift +++ b/visualization/app/VisualReport/VisualReport/Extension/DateFormatter+Extension.swift @@ -36,4 +36,11 @@ extension DateFormatter { dateFormatter.dateFormat = "yy-MM" return dateFormatter }() + + public static let yearMontDayumber: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.calendar = Calendar(identifier: .gregorian) + dateFormatter.dateFormat = "yy-MM-dd" + return dateFormatter + }() } diff --git a/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartScreen.swift b/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartScreen.swift index b532f98f..d877f9bf 100644 --- a/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartScreen.swift +++ b/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartScreen.swift @@ -40,11 +40,22 @@ struct LineChartScreen: View { AxisTick() AxisValueLabel() { if let date = value.as(Date.self) { - Text(DateFormatter.monthYearShort.string(from: date)) + if viewModel.selectedChartOption.value == LineChartOption.last2Months.value { + Text(DateFormatter.dayMonthYearShort.string(from: date)) + .rotationEffect(.degrees(-90)) + .padding(.bottom, 20.0) + .padding(.leading, -16.0) + } else { + Text(DateFormatter.yearMontDayumber.string(from: date)) + .rotationEffect(.degrees(-90)) + .padding(.bottom, 20.0) + .padding(.leading, -16.0) + } } } } } + .chartYScale(domain: viewModel.minYValue...viewModel.maxYValue) .chartXScale( domain: firstDate...lastDate ) @@ -68,6 +79,19 @@ struct LineChartScreen: View { Text(viewModel.viewModelData.currencyUnitText) Spacer() Text(viewModel.viewModelData.dataSource) + Spacer() + OptionPickerView( + optionList: viewModel.chartOptions, + selectedOption: .init( + get: { + viewModel.selectedChartOption.value + }, + set: { newValue in + viewModel.updateSelection(newSelection: newValue) + } + ) + ) + .frame(maxWidth: 300.0) } .font(.system(size: 14.0, weight: .bold)) .foregroundStyle(Color.purple) diff --git a/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartViewModel.swift b/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartViewModel.swift index 6f611e93..7410537d 100644 --- a/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartViewModel.swift +++ b/visualization/app/VisualReport/VisualReport/Graph/LineChart/LineChartViewModel.swift @@ -12,8 +12,14 @@ final class LineChartViewModel: ObservableObject { @Published var uiModels: [LineChartDataSeries] @Published var uiFirstDate: Date? @Published var uiLastDate: Date? + @Published var maxYValue: Int = 0 + @Published var minYValue: Int = 0 @Published var chartYAxisValues: [Int] = [] @Published var chartXAxisValues: [Date] = [] + @Published var chartOptions: [String] = [] + @Published var selectedChartOption: LineChartOption = .allYear + private var years: [String] = [] + let viewModelData: LineChartViewModelData init(lineChartViewModelData: LineChartViewModelData) { @@ -25,36 +31,105 @@ final class LineChartViewModel: ObservableObject { guard uiModels.isEmpty else { return } + createUIModelForAllData(dataList: viewModelData.lineChartDataList) + setInitialChartOptions() + } + + func updateSelection(newSelection: String) { + selectedChartOption = LineChartOption(value: newSelection) + switch selectedChartOption { + case .allYear: + createUIModelForAllData(dataList: viewModelData.lineChartDataList) + case .allMonthlyAverage: + let monthlyAvgData = calculateMonthlyAverages(from: viewModelData.lineChartDataList) + createUIModelForAllData(dataList: monthlyAvgData) + case .last2Months: + let last60Elements = Array(viewModelData.lineChartDataList.suffix(60)) + createUIModelForAllData(dataList: last60Elements) + case .year(let string): + let yearData = filterData(forYear: string, from: viewModelData.lineChartDataList) + createUIModelForAllData(dataList: yearData) + } + } + + private func getRuleMarkList( + chartDataPoints: [LineChartDataModel] + ) -> [RuleMarkDataModel] { + let avgValue: Double = chartDataPoints.reduce(0.0) { + return $1.yValue + $0 + } / Double(chartDataPoints.count) + return [ + RuleMarkDataModel( + id: 1, + yValue: avgValue, + yName: "Average price:", + ruleMarkName: "Average price" + ) + ] + } + + private func setInitialChartOptions() { + // Set filter options guard let firstDate = viewModelData.lineChartDataList.first?.xTimeValue, let lastDate = viewModelData.lineChartDataList.last?.xTimeValue else { return } + years = distinctYears(from: firstDate, to: lastDate) + chartOptions = LineChartOption.allStaticCases + for year in years { + chartOptions.append(year) + } + selectedChartOption = .allYear + } + + private func distinctYears(from firstDate: Date, to lastDate: Date) -> [String] { + let calendar = Calendar.current + var years: [String] = [] + + var currentYear = calendar.component(.year, from: firstDate) + let lastYear = calendar.component(.year, from: lastDate) + + while currentYear <= lastYear { + years.append("\(currentYear)") + currentYear += 1 + } + return years + } + + private func createUIModelForAllData(dataList: [LineChartDataModel]) { + guard let firstDate = dataList.first?.xTimeValue, + let lastDate = dataList.last?.xTimeValue else { + return + } uiFirstDate = firstDate uiLastDate = lastDate + + // RuleMark Data let ruleMarks = getRuleMarkList( - chartDataPoints: viewModelData.lineChartDataList - ) - let uiModel = LineChartDataSeries( - type: "All year", - firstDate: firstDate, - lastDate: lastDate, - lineChartDataList: viewModelData.lineChartDataList, - ruleMarkDataList: ruleMarks + chartDataPoints: dataList ) - uiModels = [uiModel] - // Chart Data - let maxValue = Int(viewModelData.lineChartDataList.map { $0.yValue }.max() ?? 0.0) + 20 - var minValue = Int(viewModelData.lineChartDataList.map { $0.yValue }.min() ?? 0.0) - 20 - if minValue < 0 { - minValue = 0 + // Chart Axis Data + maxYValue = Int(dataList.map { $0.yValue }.max() ?? 0.0) + 20 + minYValue = Int(dataList.map { $0.yValue }.min() ?? 0.0) - 20 + if minYValue < 0 { + minYValue = 0 } - chartYAxisValues = stride(from: minValue, to: maxValue, by: 20).map { $0 } - + chartYAxisValues = stride(from: minYValue, to: maxYValue, by: 10).map { $0 } chartXAxisValues.removeAll() + var dayInterval = Int(dataList.count / 20) + if dayInterval < 14 { + dayInterval = 14 + } + var dateFormat = "(yy-mm-dd)" + if selectedChartOption.value == LineChartOption.last2Months.value { + dayInterval = 5 + dateFormat = "" + } else if selectedChartOption.value == LineChartOption.allMonthlyAverage.value { + dayInterval = 32 + } var currentDate = firstDate let calendar = Calendar.current - let dayInterval = Int(viewModelData.lineChartDataList.count / 10) while currentDate <= lastDate { chartXAxisValues.append(currentDate) if let nextDate = calendar.date(byAdding: .day, value: dayInterval, to: currentDate) { @@ -63,22 +138,55 @@ final class LineChartViewModel: ObservableObject { break } } + + // UPDATE UI DATA + let uiModel = LineChartDataSeries( + type: "\(selectedChartOption.value) \(dateFormat)", + firstDate: firstDate, + lastDate: lastDate, + lineChartDataList: dataList, + ruleMarkDataList: ruleMarks + ) + uiModels = [uiModel] } - private func getRuleMarkList( - chartDataPoints: [LineChartDataModel] - ) -> [RuleMarkDataModel] { - let avgValue: Double = chartDataPoints.reduce(0.0) { - return $1.yValue + $0 - } / Double(chartDataPoints.count) - return [ - RuleMarkDataModel( - id: 1, - yValue: avgValue, - yName: "Average price:", - ruleMarkName: "Average price" - ) - ] + private func filterData(forYear year: String, from allData: [LineChartDataModel]) -> [LineChartDataModel] { + let calendar = Calendar.current + // Convert the input year string to an integer + guard let yearInt = Int(year) else { + return [] // Return an empty list if the input year is not valid + } + // Filter the data where the xTimeValue year matches the input year + let filteredData = allData.filter { data in + let dataYear = calendar.component(.year, from: data.xTimeValue) + return dataYear == yearInt + } + return filteredData + } + + private func calculateMonthlyAverages(from allData: [LineChartDataModel]) -> [LineChartDataModel] { + let calendar = Calendar.current + // Group data by month and year + let groupedData = Dictionary(grouping: allData) { data in + calendar.date(from: calendar.dateComponents([.year, .month], from: data.xTimeValue))! + } + + // Calculate monthly averages + var monthlyAverageData: [LineChartDataModel] = [] + var index = 1 + for (date, dataList) in groupedData { + let totalYValue = dataList.reduce(0) { $0 + $1.yValue } + let averageYValue = totalYValue / Double(dataList.count) + + // Create a new LineChartDataModel with the average yValue + let monthlyAverage = LineChartDataModel(id: index, + xTimeValue: date, + yValue: averageYValue) + index += 1 + monthlyAverageData.append(monthlyAverage) + } + + return monthlyAverageData.sorted { $0.xTimeValue < $1.xTimeValue } } } @@ -91,3 +199,44 @@ struct LineChartViewModelData { let dataSource: String let lineChartDataList: [LineChartDataModel] } + +enum LineChartOption { + + case allYear, allMonthlyAverage, last2Months + case year(String) + + var value: String { + switch self { + case .allYear: + "All Year" + case .allMonthlyAverage: + "All Monthly Average" + case .last2Months: + "Last 2 Months" + case .year(let string): + string + } + } + + static var allStaticCases: [String] { + [ + LineChartOption.allYear.value, + LineChartOption.allMonthlyAverage.value, + LineChartOption.last2Months.value + ] + } + + init(value: String) { + + switch value { + case LineChartOption.allYear.value: + self = .allYear + case LineChartOption.allMonthlyAverage.value: + self = .allMonthlyAverage + case LineChartOption.last2Months.value: + self = .last2Months + default: + self = .year(value) + } + } +} diff --git a/visualization/app/VisualReport/VisualReport/Helper/OptionPickerView.swift b/visualization/app/VisualReport/VisualReport/Helper/OptionPickerView.swift new file mode 100644 index 00000000..352b47ae --- /dev/null +++ b/visualization/app/VisualReport/VisualReport/Helper/OptionPickerView.swift @@ -0,0 +1,40 @@ +// +// OptionPickerView.swift +// VisualReport +// +// Created by Taher's nimble macbook on 20/8/24. +// + +import SwiftUI + +struct OptionPickerView: View { + + private let optionList: [String] + @Binding private var selectedOption: String + + var body: some View { + HStack(spacing: .zero) { + Spacer() + Picker("", selection: $selectedOption) { + ForEach(optionList, id: \.self) { + Text($0) + } + } + .pickerStyle(.menu) + .clipped() + .labelsHidden() + .overlay( + RoundedRectangle(cornerRadius: 8.0) + .stroke( + Color.purple, + lineWidth: 1.0 + ) + ) + } + } + + init(optionList: [String], selectedOption: Binding) { + self.optionList = optionList + _selectedOption = selectedOption + } +}