CorePlot Bar Chart confusion on plots and drawing - core-plot

I'm trying to use a bar chart to show my data. I want to show things grouped by date, and for each date I have 7 people and their associated value.
I created a tubular CPTBarPlot for each date with a barWidth of 0.25
I'm confused as to what to set the barOffset to for each plot so things draw properly.
For the storage of data, I used var plotData: [NSDate : [Double]]?. That array of double has exactly 7 values for each NSDate (one for each person, in order)
The numberForPlot method I have is drawing all the values, but the grouping seems to be by person instead of date, and they're drawing over each other. This is what I used:
func numberForPlot(plot: CPTPlot, field fieldEnum: UInt, recordIndex idx: UInt) -> AnyObject? {
guard let type = CPTBarPlotField(rawValue: Int(fieldEnum)) else { return nil }
switch type {
case .BarBase:
fatalError()
case .BarLocation:
return idx
case .BarTip:
guard let date = plot.identifier as? NSDate, values = plotData?[date] else {
fatalError()
}
return values[Int(idx)]
}
}

The bars for each plot are 0.25 unit wide and one unit apart. Since you have seven plots, they are going to overlap unless you decrease the bar width. I would suggest using offsets of -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, and 0.3. This will leave a small space between the sets of bars to set the groups apart. Use a bar width of 0.1 on each plot if you don't want any overlap.

Related

Swift Charts: How to show only values and labels for values in array?

I have a Chart with WeatherKit.HourWeather objects spanning over multiple days on the x axis. However, I want to exclude the nighttime hours. It looks like I can do this with the chartXScale modifier like this:
let myDataSeperatedByHours = arrayWithAllDates.filter { ... }.sorted(...) // Array of WeatherKit.HourWeather objects filtered by isDaylight = true and sorted by date
let allDaytimeDates = myDataSeperatedByHours.map { $0.date } //only the Date objects
Chart {
ForEach(myDataSeperatedByHours, id: \.date) { hourData in
LineMark(
x: .value("hour", hourData.date),
y: .value("value", hourData.value)
)
}
}
.chartXAxis {
AxisMarks(position: .bottom, values: allDaytimeDates) { axisValue in
if let date = axisValue.as(Date.self) {
AxisValueLabel(
"\(Self.shortTimeFormatter.calendar.component(.hour, from: date))"
)
}
}
}
.chartXScale(domain: allDaytimeDates, type: .category)
However the Chart still displays part where there is no value. (the nighttime)
I want everything removed when there is night. I've marked it green on the image below. Maybe I have to use two Charts next to each other. One for every day, but I can't believe that there's no way to do it with one Chart only.
I've created an example app that you can download and test here: https://github.com/Iomegan/DateChartIssue
As per chart scale modifier documentation for domain parameter:
The possible data values along the x axis in the chart. You can define the domain with a ClosedRange for number or Date values (e.g., 0 ... 500), and with an array for categorical values (e.g., ["A", "B", "C"])
It seems for date type values this function is expecting a range but since you are specifying an array the method invocation traps.
Instead of providing the domain directly, you can provide an automatic scale domain modifying the inferred domain. To set the domain to your calculated allDaytimeDates use:
.chartXScale(domain: .automatic(dataType: Date.self) { dates in
dates = allDaytimeDates
})
Update 1
There are multiple approaches you can try to ignore night time date scale on X-axis. The simpler and not recommended approach is to provided X-axis value in your line mark as a String instead of a Date.
The issue with specifying X-axis value as Date is you can only supply a range for the axis scale and you can't just pick multiple ranges as scale for your axis as of now and similarly you can't specify your scale to ignore certain range or values (i.e. night time). With specifying X-axis value as string you will be able to just ignore night time values:
LineMark(
x: .value("hour", "\(hourData.date)"),
y: .value("value", hourData.value)
)
The demerit with this approach is temprature variations as obtained from this graph is wrong as all your data points will be just separated equally regardless of their date value.
The preferred approach is to manually adjust the X-axis position for next day's data points. For your scenario you can create a DayHourWeather type with custom X-position value:
struct DayHourWeather: Plottable {
let position: TimeInterval // Manually calculated X-axis position
let date: Date
let temp: Double
let series: String // The day this data belongs to
var primitivePlottable: TimeInterval { position }
init?(primitivePlottable: TimeInterval) { nil }
init(position: TimeInterval, date: Date, temp: Double, series: String) {
self.position = position
self.date = date
self.temp = temp
self.series = series
}
}
You can customize the position data to move daytime plots closer together ignoring night time values. Then you can create DayHourWeathers from your HourWeathers:
/// assumes `hourWeathers` are filtered containing only day time data and already sorted
func getDayHourWeathers(from hourWeathers: [HourWeather]) -> [DayHourWeather] {
let padding: TimeInterval = 10000 // distance between lat day's last data point and next day's first data point
var translation: TimeInterval = 0 // The negetive translation required on X-axis for certain day
var series: Int = 0 // Current day series
var result: [DayHourWeather] = []
result.reserveCapacity(hourWeathers.count)
for (index, hourWeather) in hourWeathers.enumerated() {
defer {
result.append(
.init(
position: hourWeather.date.timeIntervalSince1970 - translation,
date: hourWeather.date,
temp: hourWeather.temp,
series: "Day \(series + 1)"
)
)
}
guard
index > 0,
case let lastWeather = hourWeathers[index - 1],
!Calendar.current.isDate(lastWeather.date, inSameDayAs: hourWeather.date)
else { continue }
// move next day graph to left occupying previous day's night scale
translation = hourWeather.date.timeIntervalSince1970 - (result.last!.position + padding)
series += 1
}
return result
}
Now to plot your chart you can use the newly created DayHourWeather values:
var body: some View {
let dayWeathers = getDayHourWeathers(from: myDataSeperatedByHours)
Chart {
ForEach(dayWeathers, id: \.date) { hourData in
LineMark(
x: .value("hour", hourData.position), // custom X-axis position calculated
y: .value("value", hourData.temp)
)
.foregroundStyle(by: .value("Day", hourData.series))
}
}
.chartXScale(domain: dayWeathers.first!.position...dayWeathers.last!.position) // provide scale range for calculated custom X-axis positions
}
Note that with above changes your X-axis marker will display your custom X-axis positions. To change it back to the actual date label you want to display you can specify custom X-axis label:
.chartXAxis {
AxisMarks(position: .bottom, values: dayWeathers) {
AxisValueLabel(
"\(Self.shortTimeFormatter.calendar.component(.hour, from: dayWeathers[$0.index].date))"
)
}
}
The values argument for AxisMarks only accepts an array of Plottable items, this is why confirming DayHourWeather to Plottable is needed. After above changes the chart obtained will look similar to this:
Note that I have created a different series for each day data. Although you can combine them into a single series, I will advise against doing so as the resulting chart is misleading to viewer since you are removing part of the X-axis scale.

Facing error in swift UI “Invalid frame dimension (negative or non-finite)” while drawing negative value in chart

In widget iOS14, I want to show values in graphical form using bar chart.
I have used this library "https://github.com/dawigr/BarChart" to draw bar chart.
But in Xcode12+, it's not showing negative values and considering negative value as 0 and throwing warning as shown in screen shot.
"[SwiftUI] Invalid frame dimension (negative or non-finite)"
You could try to normalise your input Values to prevent getting errors like this.
e.g.: if your data set contains values from -10 to 100, your min normalised value would be 0 and your max normalised value 1. This only works if your numbers are CGFloat, Double or something like this, numbers in Int format would be rounded up.
This could be done by using an extension like this:
extension Array where Element == Double {
var noramlized: [Double] {
if let min = self.min(), let max = self.max() {
return self.map{ ($0 - min) / (max - min) }
}
return []
}
}
I don't no how you get your values for the frame exactly, but I think you did something like this:
// normalise your data set:
let data : [Double] = [Double]()
youChart(inputData: data.noramlized)
// get values for the frame
let height = data.noramlized.max()
// if your normalised value is too small for your purpose (your max will always be 1.0 but in relation to the rest it might fit), you can scale it up like height * 20.
// the width might be a non changing value that you will enter manually or it will append on the count of bars in your chart.

New to IoS charts not sure how to get data onto the chart

So I went to use ios-Charts...
I have two data sets.
One is a time value(elapsed time for Y-axis) and one is a date value for the (x-axis)
I'm guessing that I want to use a line chart.
I'm just new to the library.
Below is a sample of data I will have:
Swims = [["Dec 1,2017",241.1],["Feb 4,2018",237.23],["Feb 21,2018",233.1],["Mar 23,2018",222.1],["Apr 1,2018",240.23],["Apr 15,2018",199.34]]
Dates will be on the X and duration in (min:sec.decimal format) on the Y axis.
Just trying to make heads or tails of how data is plotted on the ios-Charts
You need to use an array of ChartDataEntry to represent your data. ChartDataEntry takes a x and y, each being a Double.
Option 1 - Time-based x-axis
If you would like to represent data points that are horizontally spaced according to dates, you'll need to Convert your dates to TimeInterval, e.g. using Date.timeIntervalSince1970. The timeInterval from this conversion will become the x value of the data point.
There's actually an example of this in the demo project of the Charts library - LineChartTimeViewController.swift:
let now = Date().timeIntervalSince1970 // Here's the date to interval conversion
let hourSeconds: TimeInterval = 3600
let from = now - (Double(count) / 2) * hourSeconds
let to = now + (Double(count) / 2) * hourSeconds
let values = stride(from: from, to: to, by: hourSeconds).map { (x) -> ChartDataEntry in
let y = arc4random_uniform(range) + 50
return ChartDataEntry(x: x, y: Double(y))
}
Option 2 - Index-based x-axis
If you're fine with the data points being equally spaced, then just create the ChartDataEntry array using the indices of your original array for the x values. For example:
for (index, point) in Swim.enumerated() {
let entry = ChartDataEntry(x: index, y: point[1])
data.append(entry)
}
Formatting the x-axis
For either option, you'll need to implement the IAxisValueFormatter protocol in order to format the x axis labels
For option 1, you can use the value directly, converting it back to a date, then formatting it to your desired string
For option 2, the value will represent the index of the datapoint in the original array, you access it via something like:
let date = Swim[value][0]

CorePlot x axis labels

OK, my final issue with getting my bar chart setup is how to print the X axis labels. I tried this:
if let axis = graph.axisSet as? CPTXYAxisSet, xAxis = axis.xAxis {
let dateLabels = self!.dates!.map {
CPTAxisLabel(text: NSDateFormatter.localizedStringFromDate($0, dateStyle: .ShortStyle, timeStyle: .NoStyle), textStyle: nil)
}
xAxis.axisLabels = Set(dateLabels)
}
I'm getting nothing displayed though. I looked at DatePlot sample but I don't want to do what it's doing as it incorrectly assumes that a day is 86,400 seconds long, and that will break multiple times. Also, my date offsets are in months, so that makes it even worse. Can't I just somehow provide the already formatted date string?
Seems strange to me that "axisLabels" would be a set, since a set is not ordered.
Each axis label has a tickLocation. Set the tick location to the bar location for that label in the same units used in the plot space and datasource. Since the label tick location is not related to its position in the array, we can use an unordered collection like a set.

iOS-Charts null data point do not show on chart

Like the Health app on iOS 8 where null/empty data points are not displayed while X axis labels are still there. Using iOS-Charts as the chart library for my project is it possible to achieve the same?
You shouldn't pass a nil, it does not a accept a nil and Xcode will warn about it. What you should do, is just not pass it at all.
You do not have to pass a y value for every x index. You just have to make sure that the y values are ordered according to the x indices.
It can be achieved using iOS-Charts. I am currently making a project which requires various multiple line charts, multiple bar charts and line charts. Even if I have null values X axis labels are still there. You can ask me if you have any doubts.
Here's a function I wrote based on daniel.gindi's answer (I pass NaNs for the entries in the values array that are empty) :
func populateLineChartView(lineChartView: LineChartView, labels: [String], values: [Float]) {
var dataEntries: [ChartDataEntry] = []
for i in 0..<labels.count {
if !values[i].isNaN {
let dataEntry = ChartDataEntry(value: Double(values[i]), xIndex: i)
dataEntries.append(dataEntry)
}
}
let lineChartDataSet = LineChartDataSet(yVals: dataEntries, label: "Label")
let lineChartData = LineChartData(xVals: labels, dataSet: lineChartDataSet)
lineChartView.data = lineChartData
}
You can use the axis Maximum, so even if you don't have ChartDataEntry for that events it will still show
chartView.xAxis.axisMaximum = 60
chartView.xAxis.axisMaximum = 90
Simplest answer as Daniel mentioned is to just not pass the y value. Initially I was also confused as all functions needed x and y. This approach is how I did it
var dataEntries = [ChartDataEntry]()
for (iterating a source array){
let entry = ChartDataEntry()
if (some condition to check yValue exists) {
entry.x = xValue
entry.y = yValue
}
else
{
entry.x = xValue
}
dataEntries.append(entry)
}

Resources