Skip to content

Commit

Permalink
Add Lollipops to the HealthKit Charts for Daily Details (#34)
Browse files Browse the repository at this point in the history
# Add Lollipops to the HealthKit Charts for Daily Details

## ♻️ Current situation & Problem
This is related to #13 where we want to help the users understand their
health data more clearly. In the previous visualizations, we found it
hard to identify the daily average, max, and min with the ticks and
small plots. Therefore, we want to show more details when users click on
a specific bar/point.

## ⚙️ Release Notes 
- Enable clicking on the chart bars/points to show a summary of the day,
and click again to hide the summary.
- For step counts, add a "lollipops" to the chart directly to show the
sum of step count of the day
- For oxygen saturation and heart rates, as we have all the average,
max, and min of the day, it is too crowded to display them on the chart,
so we show the summary above the chart.
- Highlight the clicked bar/point by setting the color of average lines
and other bars/points to grey and the color of the clicked bar/point to
indigo to
- This PR reference to the codes in the lollipop visualization from the
[Swift-Charts-Examples
repo](https://github.com/jordibruin/Swift-Charts-Examples/blob/main/Swift%20Charts%20Examples/Charts/LineCharts/SingleLineLollipop.swift)

Below are the examples for before (left) and after one of the bars in
all charts (right two)
<p float="left">
<img
src="https://github.com/CS342/2024-PICS/assets/32094663/9b513f44-af13-4a55-a11d-e7531062bad8"
width="200"/>
<img
src="https://github.com/CS342/2024-PICS/assets/32094663/77a7188d-4fd7-4ad7-88a7-db7a4ec3cbb7"
width="200"/>
<img
src="https://github.com/CS342/2024-PICS/assets/32094663/f358d568-6161-46e3-bfec-be3aa65405dc"
width="200"/>
</p>

## 📚 Documentation
Related comments are added to the codes.

## ✅ Testing
Tested the visualization and clicking manually with imported health kit
data on the simulator.

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
YurenSUN authored Feb 28, 2024
1 parent 56cd2d6 commit c98b2f5
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 10 deletions.
148 changes: 143 additions & 5 deletions PICS/HealthVisulization/HKVisualizationItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,37 +26,65 @@ struct HKVisualizationItem: View {
var plotAvg = false
var scatterData: [HKData] = []

// Variables for lollipops.
let lollipopColor: Color = .indigo

@State private var selectedElement: HKData?

var body: some View {
Text(self.title)
.font(.title3.bold())
// Remove line below text.
// Remove line below text.
.listRowSeparator(.hidden)
if !self.helperText.isEmpty {
Text(self.helperText)
.font(.caption)
.listRowSeparator(.hidden)
}
// Helper text to show data when clicked.
if let elm = selectedElement, elm.sumValue == 0 {
let details = (
String(localized: "HKVIZ_SUMMARY") +
String(elm.date.formatted(.dateTime.year().month().day())) +
":\n" +
String(localized: "HKVIZ_AVERAGE_STRING") +
String(round(elm.avgValue * 10) / 10) +
", " +
String(localized: "HKVIZ_MAX_STRING") +
String(Int(round(elm.maxValue))) +
", " +
String(localized: "HKVIZ_MIN_STRING") +
String(Int(round(elm.minValue)))
)
Text(details)
.font(.footnote)
.listRowSeparator(.hidden)
}
chart
}

private var chart: some View {
Chart {
ForEach(scatterData) { dataPoint in
PointMark(
x: .value(self.xName, dataPoint.date, unit: .day),
y: .value(self.yName, dataPoint.sumValue)
)
.foregroundStyle(((self.threshold > 0 && dataPoint.sumValue > self.threshold) ? Color.blue : Color.accentColor).opacity(0.2))
.foregroundStyle(getBarColor(value: dataPoint.sumValue, date: dataPoint.date).opacity(0.2))
}
ForEach(data) { dataPoint in
BarMark(
x: .value(self.xName, dataPoint.date, unit: .day),
y: .value(self.yName, dataPoint.sumValue),
width: .fixed(10)
)
.foregroundStyle(dataPoint.sumValue > self.threshold ? Color.blue : Color.accentColor)
.foregroundStyle(getBarColor(value: dataPoint.sumValue, date: dataPoint.date))
if self.plotAvg {
LineMark(
x: .value(self.xName, dataPoint.date, unit: .day),
y: .value(self.yName, dataPoint.avgValue)
)
.foregroundStyle(.purple)
.foregroundStyle(selectedElement != nil ? Color.gray : Color.purple)
.lineStyle(StrokeStyle(lineWidth: 2))
}
}
Expand All @@ -71,9 +99,49 @@ struct HKVisualizationItem: View {
.padding(.top, 10)
.chartYScale(domain: self.ymin...self.ymax)
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 3))
AxisMarks(values: .stride(by: .day, count: 4))
}
// For the lollipops, click on data to show details.
.chartOverlay { proxy in
GeometryReader { geo in
if let selectedElement,
selectedElement.sumValue > 0,
let proxyF = proxy.plotFrame,
let dateInterval = Calendar.current.dateInterval(of: .day, for: selectedElement.date) {
// Build the lollipop contents.
let startPositionX1 = proxy.position(forX: dateInterval.start) ?? 0
let endPositionX1 = proxy.position(forX: dateInterval.end) ?? 0
let lineX = (startPositionX1 + endPositionX1) / 2 + geo[proxyF].origin.x
let lineHeight = geo[proxyF].maxY
let geoSizeWidth = geo.size.width
getLollipop(lineX: lineX, lineHeight: lineHeight, geoSizeWidth: geoSizeWidth, elm: selectedElement)
}
// The rectangle to detect user's clicked position and bar.
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
.gesture(
SpatialTapGesture()
.onEnded { value in
let element = findElement(location: value.location, proxy: proxy, geometry: geo)
if selectedElement?.date == element?.date {
// If tapping the same element, clear the selection.
selectedElement = nil
} else {
selectedElement = element
}
}
.exclusively(
before: DragGesture()
.onChanged { value in
selectedElement = findElement(location: value.location, proxy: proxy, geometry: geo)
}
)
)
}
}
}


init(
data: [HKData],
Expand Down Expand Up @@ -101,6 +169,76 @@ struct HKVisualizationItem: View {
self.plotAvg = data.map(\.avgValue).max() ?? 0 > 0
self.scatterData = scatterData
}

func getBarColor(value: Double, date: Date) -> Color {
let calendar = Calendar.current
return if let elm = selectedElement {
if calendar.component(.day, from: date) == calendar.component(.day, from: elm.date) {
// Highlight the clicked element with purple.
.indigo
} else {
.gray
}
} else if self.threshold > 0 && value > self.threshold {
.blue
} else {
.accentColor
}
}

@ViewBuilder
func getLollipop(lineX: CGFloat, lineHeight: CGFloat, geoSizeWidth: CGFloat, elm: HKData) -> some View {
let lollipopBoxWidth: CGFloat = if elm.sumValue > 0 {
100
} else {
200
}
let boxOffset = max(0, min(geoSizeWidth - lollipopBoxWidth, lineX - lollipopBoxWidth / 2))

Rectangle()
.fill(lollipopColor)
.frame(width: 2, height: lineHeight)
.position(x: lineX, y: lineHeight / 2)

VStack(alignment: .center) {
Text("\(elm.date, format: .dateTime.year().month().day())")
.font(.callout)
.foregroundStyle(.secondary)
Text("\(elm.sumValue, format: .number)")
.font(.title2.bold())
.foregroundColor(.primary)
}
.accessibilityElement(children: .combine)
.frame(width: lollipopBoxWidth, alignment: .center)
.background(Color.gray.brightness(0.4))
.cornerRadius(5)
.offset(x: boxOffset)
}

private func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> HKData? {
guard let proxyF = proxy.plotFrame else {
print("Failed to get proxy plotframe")
return nil
}
let relativeXPosition = location.x - geometry[proxyF].origin.x
if let date = proxy.value(atX: relativeXPosition) as Date? {
// Find the closest date element.
var minDistance: TimeInterval = .infinity
var index: Int?
for HKDataIndex in data.indices {
let nthHKDataDistance = data[HKDataIndex].date.distance(to: date)
if abs(nthHKDataDistance) < minDistance {
minDistance = abs(nthHKDataDistance)
index = HKDataIndex
}
}
if let index {
print(data[index])
return data[index]
}
}
return nil
}
}

#Preview {
Expand Down
10 changes: 5 additions & 5 deletions PICS/PICSDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ class PICSDelegate: SpeziAppDelegate {
)
}

// Currently, we collect data for the past three months as the patients
// have in-hospital appointments every three months. In the future, we
// might change this to collecting data since three months before the first
// or the closest incoming appointment if we collect the appointment dates.
private var monthTraceBack = -3
// Currently, we collect data for the past month. In the future, we might
// change this to collecting data since three months before the first
// or the closest incoming appointment if we collect the appointment dates
// and able to access those data here.
private var monthTraceBack = -1

private var predicateThreeMonth: NSPredicate {
let calendar = Calendar(identifier: .gregorian)
Expand Down
47 changes: 47 additions & 0 deletions PICS/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"strings" : {
"" : {

},
"%@" : {

},
"%lld cm" : {

Expand Down Expand Up @@ -335,6 +338,39 @@
}
}
},
"HKVIZ_AVERAGE_STRING" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Daily Average: "
}
}
}
},
"HKVIZ_MAX_STRING" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Maximum: "
}
}
}
},
"HKVIZ_MIN_STRING" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Minimum: "
}
}
}
},
"HKVIZ_NAVIGATION_TITLE" : {
"extractionState" : "manual",
"localizations" : {
Expand Down Expand Up @@ -401,6 +437,17 @@
}
}
},
"HKVIZ_SUMMARY" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Your summary for "
}
}
}
},
"HKVIZ_TAB_TITLE" : {
"extractionState" : "manual",
"localizations" : {
Expand Down

0 comments on commit c98b2f5

Please sign in to comment.