This commit is contained in:
coco
2026-07-03 15:56:07 +08:00
commit caef23209c
5767 changed files with 1004268 additions and 0 deletions
@@ -0,0 +1,38 @@
## :loudspeaker: Type of change
<!--- Put an `x` in the boxes that apply, remove checkboxes from remaining -->
- [ ] Bugfix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactoring
## :scroll: Description
<!--- Describe your changes in detail -->
## :bulb: Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
## :green_heart: How did you test it?
<!--- Describe how the changes were tested -->
## :pencil: Checklist
<!--- Put an `x` in the boxes that apply, remove checkboxes from remaining -->
- [ ] I reviewed submitted code
- [ ] I added tests to verify changes
- [ ] All tests passing
- [ ] All linters passing
- [ ] There are no code climate issues
## :crystal_ball: Next steps
<!--- Describe any changes that need to be made after merging this PR -->
<!--- Describe any changes that you would like to make after merging this PR -->
## :camera_flash: Screenshots / GIFs
<!--- Mandatory for UI changes -->
<!-- Uncomment the next line and replace it with links to your screenshots. -->
<!--
<img src="https://placekitten.com/260/260" width="260">&emsp;
<img src="https://placekitten.com/300/300" width="260">&emsp;
+26
View File
@@ -0,0 +1,26 @@
name: Android CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Clean and Build with Gradle
run: ./gradlew clean build
+28
View File
@@ -0,0 +1,28 @@
name: Maven Release
on:
push:
tags:
- '*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Java for publishing to Maven Central Repository
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew :YChartsLib:clean build publish
env:
MAVEN_USERNAME: ${{ secrets.MAVENCENTRALUSERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVENCENTRALPASSWORD}}
MEMORY_KEY: ${{ secrets.SIGNINGINMEMORYKEY}}
SIGNINGKEY: ${{ secrets.SIGNINGKEYID}}
SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD}}
+18
View File
@@ -0,0 +1,18 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
.idea
/buildSrc/build
/YChartsLib/build
+3
View File
@@ -0,0 +1,3 @@
# Default code owner for this repo
* @preetham1316 @dkk009 @kikoso
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+661
View File
@@ -0,0 +1,661 @@
# YCharts
YCharts is a Jetpack-compose based charts library which enables developers to easily integrate various types of charts/graphs into their existing ui to visually represent statistical data. YCharts supports both cartesian(XY-charts) and polar charts(Radial charts), which include:
A. Cartesian charts:
1. Line chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/58352fc4-8039-46e6-9248-1f338efc705d"/>
</figure>
</div>
2. Bar chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/5af22ba1-232f-4d1a-8067-c08f56a61b13"/>
</figure>
</div>
3. Wave chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/be694054-d5cc-49ec-b7ee-be80c1562df7"/>
</figure>
</div>
4. Bubble chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/cb177d4d-3fa8-4171-b3bd-7064458b1964"/>
</figure>
</div>
5. Combined chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/809bff58-a857-4ed1-8415-cd1d97be300f"/>
</figure>
</div>
B.Radial charts:
1. Pie chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/ac6eb6c7-033d-439b-b416-d6083ba0ee83"/>
</figure>
</div>
2. Donut chart
<div>
<figure>
<img width=100 src="https://github.com/AkYML/YCharts/assets/102035914/3296d983-4a9a-4b91-8b46-49f84a80041c"/>
</figure>
</div>
It comprises two main modules:
- `YChartslib` (Chart components for Jetpack Compose)
- `app` (sample app to showcase chart components)
## Adding YCharts to your project:
You can add the library via Maven:
Gradle:
```groovy
implementation 'co.yml:ycharts:2.1.0'
```
## Modules
The following table outlines the modules included in this library:
| Artifact | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------ |
| `axis` | Includes the horizontal and vertical axis components along with the math engines. |
| `charts` | Includes the all the chart components i.e: Line, Bar, Combined, Pie & Donut etc. also the math engines |
| `chartcontainer` | Provides the base container to draw any chart inside it with scroll tap feature etc out of the box. |
| `piechart` | Includes all the 360' chart components i.e Pie, Donut charts etc. |
## Sample app
Included in this repository is a [sample app](https://github.com/yml-org/YCharts/tree/main/app) with multiple charts with different styling in each section. Studying the source code of the app will give you a deep understanding of how to use YCharts, including customizing and styling the charts. All of the charts i.e line, bar, groupedBar, pie & donut are implemented in the sample app.
## Examples
Let's see how we can use the chart components and style them with available customization options.
### 1. Line Chart:
- Create list of points with x & y co-ordinates using `Point` data class.
```kotlin
val pointsData: List<Point> =
listOf(Point(0f, 40f), Point(1f, 90f), Point(2f, 0f), Point(3f, 60f), Point(4f, 10f))
```
- Initialize X and Y Axis builders using the `AxisData` data class.
```kotlin
val xAxisData = AxisData.Builder()
.axisStepSize(100.dp)
.backgroundColor(Color.Blue)
.steps(pointsData.size - 1)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.backgroundColor(Color.Red)
.labelAndAxisLinePadding(20.dp)
.labelData { i ->
val yScale = 100 / steps
(i * yScale).formatToSinglePrecision()
}.build()
```
- Initialize the Line chart data with axis and other line related styling using `LineChartData` data class.
```kotlin
val lineChartData = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
LineStyle(),
IntersectionPoint(),
SelectionHighlightPoint(),
ShadowUnderLine(),
SelectionHighlightPopUp()
)
),
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines(),
backgroundColor = Color.White
)
```
- Finally use the _**`LineChart`**_ Component to render the line chart with the above input params.
```kotlin
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = lineChartData
)
```
<figure>
<div align = "center">
<img width=317 src="https://github.com/AkYML/YCharts/assets/102035914/58352fc4-8039-46e6-9248-1f338efc705d"/>
<p> Line chart looks like this! </p>
<img width=317 src="https://github.com/AkYML/YCharts/assets/102035914/a5d5c153-c2df-43d7-8d05-41548ed64e5c"/>
<p> Line chart with dots looks like this! </p>
</div>
</figure>
### 2. Bar Chart:
- Create list of barChartData using the random generator extension and `BarData` data class.
```kotlin
val barChartData = DataUtils.getBarChartData(barChartListSize, maxRange)
```
- Initialize X and Y Axis builders using the `AxisData` data class.
```kotlin
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(barChartData.size - 1)
.bottomPadding(40.dp)
.axisLabelAngle(20f)
.labelData { index -> barData[index].label }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
```
- Initialize the Bar chart data with axis and other line related styling using `BarChartData` data class.
```kotlin
val barChartData = BarChartData(
chartData = barChartData,
xAxisData = xAxisData,
yAxisData = yAxisData,
paddingBetweenBars = 20.dp,
barWidth = 25.dp
)
```
- Last, use the _**`BarChart`**_ Component to render the bar chart with the above input params.
```kotlin
BarChart(modifier = Modifier.height(350.dp), barChartData = barChartData)
```
<figure>
<div align = "center">
<img width=347 src="https://github.com/AkYML/YCharts/assets/102035914/0d2a6cfd-98c4-46ee-92a7-b88814f8d186" />
<p> Bar chart looks like this!</p>
<img width=347 src="https://github.com/AkYML/YCharts/assets/102035914/6dec92d7-95ed-4892-89b7-44a03d0711b9" />
<p> Horizontal Bar chart looks like this!</p>
</div>
</figure>
### 3. Grouped Bar Chart:
- Create list of grouped combinations of bar chart data using the random generator extension and `BarPlotData` data class.
```kotlin
val groupBarPlotData = BarPlotData(
groupBarList = DataUtils.getGroupBarChartData(
barChartListSize,
maxRange,
eachGroupBarSize
),
barColorPaletteList = getColorPaletteList(barSize)
)
```
- Initialize X and Y Axis builders using the `AxisData` data class.
```kotlin
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(groupBarData.size - 1)
.bottomPadding(40.dp)
.labelData { index -> groupBarData[index].label }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
```
- Initialize the group bar chart data with axis and other line related styling using `GroupBarChartData` data class.
```kotlin
val groupBarChartData = GroupBarChartData(
barPlotData = groupBarPlotData,
xAxisData = xAxisData,
yAxisData = yAxisData
)
```
- Use the _**`GroupBarChart`**_ Component to render the bar chart with the above input params.
```kotlin
GroupBarChart(modifier = Modifier.height(300.dp), groupBarChartData = groupBarChartData)
```
<figure>
<div align = "center">
<img width=338 src="https://github.com/AkYML/YCharts/assets/102035914/55df945d-bcd9-4b08-b47e-5d439a7abf39" />
<p> Grouped Bar chart looks like this!</p>
<img width=338 src="https://github.com/AkYML/YCharts/assets/102035914/00af9045-4054-4544-b4c5-93b568df2b85" />
<p> Stacked Bar chart looks like this!</p>
</div>
</figure>
### 4. Pie Chart:
- Create list of slices using the `PieChartData` data class.
```kotlin
val pieChartData = PieChartData(
slices = listOf(
PieChartData.Slice("SciFi", 65f, Color(0xFF333333)),
PieChartData.Slice("Comedy", 35f, Color(0xFF666a86)),
PieChartData.Slice("Drama", 10f, Color(0xFF95B8D1)),
PieChartData.Slice("Romance", 40f, Color(0xFFF53844))
)
)
```
- Initialize the pie chart config with `PieChartConfig` data class in order to achieve styling and configurations related to pie chart.
```kotlin
val pieChartConfig = PieChartConfig(
percentVisible = true,
isAnimationEnable = true,
showSliceLabels = false,
animationDuration = 1500
)
```
- Finally, use the _**`PieChart`**_ component to render the chart.
```kotlin
PieChart(
modifier = Modifier
.width(400.dp)
.height(400.dp),
pieChartData,
pieChartConfig
)
```
<figure>
<div align = "center">
<img width=238 src="https://github.com/AkYML/YCharts/assets/102035914/457b6034-eef0-4f70-bd40-0fc0e950298a" />
<p> Pie chart looks like this!</p>
<img width=238 src="https://github.com/AkYML/YCharts/assets/102035914/ac6eb6c7-033d-439b-b416-d6083ba0ee83" />
<p> Pie chart with lables like this!</p>
</div>
</figure>
### 5. Donut Chart:
- Similar to pie chart here we need create list of slices using the `PieChartData` data class.
```kotlin
val donutChartData = PieChartData(
slices = listOf(
PieChartData.Slice("HP", 15f, Color(0xFF5F0A87)),
PieChartData.Slice("Dell", 30f, Color(0xFF20BF55)),
PieChartData.Slice("Lenovo", 40f, Color(0xFFEC9F05)),
PieChartData.Slice("Asus", 10f, Color(0xFFF53844))
)
)
```
- Initialize the pie chart config with `PieChartConfig` data class in order to achieve styling and configurations related to pie chart.
```kotlin
val donutChartConfig = PieChartConfig(
percentVisible = true,
percentageFontSize = 42.sp,
strokeWidth = 120f,
percentColor = Color.Black,
activeSliceAlpha = .9f,
isAnimationEnable = true
)
```
- Finally, use the _**`DonutPieChart`**_ component to render the chart.
```kotlin
DonutPieChart(
modifier = Modifier
.fillMaxWidth()
.height(500.dp),
donutChartData,
donutChartConfig
)
```
<figure>
<div align = "center">
<img width=233 src="https://github.com/AkYML/YCharts/assets/102035914/3296d983-4a9a-4b91-8b46-49f84a80041c" />
<p> Donut chart looks like this!</p>
</div>
</figure>
### 6. Combined Chart:
- Similar to line and bar chart we can combine both entities in one chart, just need to initialize the line and bar plot data using the random generator extension and add styling related to individual component.
```kotlin
val linePlotData = LinePlotData(
lines = listOf(
Line(
DataUtils.getLineChartData(listSize, maxRange = 100),
lineStyle = LineStyle(color = Color.Blue),
intersectionPoint = IntersectionPoint(),
selectionHighlightPoint = SelectionHighlightPoint(),
selectionHighlightPopUp = SelectionHighlightPopUp()
),
Line(
DataUtils.getLineChartData(listSize, maxRange),
lineStyle = LineStyle(color = Color.Black),
intersectionPoint = IntersectionPoint(),
selectionHighlightPoint = SelectionHighlightPoint(),
selectionHighlightPopUp = SelectionHighlightPopUp()
)
)
)
val barPlotData = BarPlotData(
groupBarList = DataUtils.getGroupBarChartData(listSize, maxValueRange, barSize),
barStyle = BarStyle(barWidth = 35.dp),
barColorPaletteList = colorPaletteList
)
```
- Initialize X and Y Axis builders using the `AxisData` data class.
```kotlin
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(maxOf(barChartData.size - 1, lineChartData.size - 1))
.bottomPadding(40.dp)
.labelData { index -> index.toString() }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
```
- Initialize the combined chart config data with `CombinedChartData` data class in order to achieve styling and configurations related to same.
```kotlin
val combinedChartData = CombinedChartData(
combinedPlotDataList = listOf(barPlotData, linePlotData),
xAxisData = xAxisData,
yAxisData = yAxisData
)
```
- Finally, use the _**`CombinedChart`**_ component to render the chart.
```kotlin
CombinedChart(
modifier = Modifier.height(400.dp),
combinedCharthData = combinedChartData
)
```
_Note_ : To show legends infomartion related to bar, `Legends` component can be used.
<figure>
<div align = "center">
<img width=292 src="https://github.com/AkYML/YCharts/assets/102035914/809bff58-a857-4ed1-8415-cd1d97be300f" />
<p> Combined bar with line chart looks like this!</p>
</div>
</figure>
<br>
### 7. Wave Chart:
- Wave charts, also referred to as waveform charts or waveform displays, are specialized visualizations used primarily in the field of signal processing and electronics. These charts illustrate the amplitude (vertical axis) and time (horizontal axis) of a waveform.
```kotlin
val waveChartData = WaveChartData(
wavePlotData = WavePlotData(
lines = listOf(
Wave(
dataPoints = pointsData,
waveStyle = LineStyle(color = Color.Black),
selectionHighlightPoint = SelectionHighlightPoint(),
shadowUnderLine = ShadowUnderLine(),
selectionHighlightPopUp = SelectionHighlightPopUp(),
waveFillColor = WaveFillColor(topColor = Color.Green, bottomColor = Color.Red),
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
```
- Initialize X and Y Axis builders using the `AxisData` data class.
```kotlin
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(maxOf(barChartData.size - 1, lineChartData.size - 1))
.bottomPadding(40.dp)
.labelData { index -> index.toString() }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
```
- Initialize the Wave chart config data with `WaveChartData` data class in order to achieve styling and configurations related to same.
```kotlin
val waveChartData = WaveChartData(
wavePlotData = WavePlotData(
lines = listOf(
Wave(
dataPoints = pointsData,
waveStyle = LineStyle(color = Color.Black),
selectionHighlightPoint = SelectionHighlightPoint(),
shadowUnderLine = ShadowUnderLine(),
selectionHighlightPopUp = SelectionHighlightPopUp(),
waveFillColor = WaveFillColor(topColor = Color.Green, bottomColor = Color.Red),
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
```
- Finally, use the _**`WaveChart`**_ component to render the chart.
```kotlin
WaveChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
waveChartData = data
)
```
_Note_ : To show legends infomartion related to bar, `Legends` component can be used.
<figure>
<div align = "center">
<img width=292 src="https://github.com/AkYML/YCharts/assets/102035914/be694054-d5cc-49ec-b7ee-be80c1562df7" />
<p> Wavechart looks like this!</p>
<img width=292 src="https://github.com/AkYML/YCharts/assets/102035914/2d8b02af-1252-44fb-83e1-06d1f6230733" />
<p> Sinewavechart looks like this!</p>
</div>
</figure>
<br>
### 8. Bubble Chart:
- Bubble charts are a variation of the scatter plot, where the data points are represented as bubbles or circles instead of simple dots. The bubbles' size represents the value of a third variable, in addition to the x-axis and y-axis variables. Bubble charts are effective in visualizing and comparing three different dimensions of data simultaneously.
```kotlin
val bubbleChartData = BubbleChartData(
DataUtils.getBubbleChartDataWithGradientStyle(pointsData),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
```
- Initialize X and Y Axis builders using the `AxisData` data class.
```kotlin
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(maxOf(barChartData.size - 1, lineChartData.size - 1))
.bottomPadding(40.dp)
.labelData { index -> index.toString() }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
```
- Initialize the Bubble chart config data with `BubbleChartData` data class in order to achieve styling and configurations related to same.
```kotlin
val bubbleChartData = BubbleChartData(
DataUtils.getBubbleChartDataWithGradientStyle(pointsData),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
```
- Finally, use the _**`BubbleChart`**_ component to render the chart.
```kotlin
BubbleChart(
modifier = Modifier
.fillMaxWidth()
.height(500.dp),
bubbleChartData = data
)
```
_Note_ : To show legends infomartion related to bar, `Legends` component can be used.
<figure>
<div align = "center">
<img width=292 src="https://github.com/AkYML/YCharts/assets/102035914/be103f93-8562-4939-add8-1693d76441ef" />
<p> Gradient-Bubble chart looks like this!</p>
<img width=292 src="https://github.com/AkYML/YCharts/assets/102035914/cb177d4d-3fa8-4171-b3bd-7064458b1964" />
<p> Bubble chart looks like this!</p>
</div>
</figure>
<br>
## Accessibility Support
To interact with your device with touch and spoken feedback, you can turn on the TalkBack screen reader. TalkBack describes the graphs/charts when tapped on the graph container.
Compose views by deafult supports accessibility services, but for views drawn using canvas has no straight forward approach as of now, hence all our graph components supports an accessibility popup out of the box that will be shown over the graph with tapped on the container, with all the values described in an order to support accessibility services. User will be able to scroll the popup and find all the points, bars, slices or combined values in a descriptive manner, where user can tap and talkback would read it out loud.
<figure>
<div align = "center">
<span align = "center" style = "display:inline-block">
<img width=250 src="https://user-images.githubusercontent.com/107846675/195833801-24e17e52-d3cd-45e1-9d3e-1d9bab4b0e22.png" />
<p> fig (a)</p>
</span>
<span align = "center" style = "display:inline-block; padding-left:30px">
<img width= 250 src="https://user-images.githubusercontent.com/107846675/195833551-28f21bdb-a035-4858-832c-a9a0c4dc9927.png" />
<p> fig (b)</p>
</span>
</div>
</figure>
Here fig(a) represents the line graph with the container being highlighted & fig(b) represents the accessibility sheet with all values laid out in detail so that talkback can describe the graph values.<br><br>
_Note_: All the descriptions that are visible in the accessibility popup can be customized to the required string.
## KMM Support.
- For the ongoing effort to provide multiplatform solution, Ycharts now being written in Compose Multiplatform, adopting KMM has been a game-changer, we hope to achieve good results in this effort with community contribution.
Currently KMM compatible code can be found at _**`kmm-main`**_ branch
## Changes in version 2.0
- Fix for multiple line graph/chart has been added, now it can be used to plot multiple line graphs/line charts on a single canvas or a single line with multiple colors.
- Background color for Pie chart/Donut chart now can be set via _**`backgroundColor`**_ parameter through PieChartConfig model, this is done to keep the behaviour consistent with current syntax pattern.
- Kotlin-library updates: _**`Kotlin version`**_ has been upgraded to '1.8.10' along with agp, compose-compiler, core-ktx, target sdk, and other compatible library updates.
- Sample App has been updated to showcase more use-cases for each chart type.
## License
```
Copyright 2022 YCharts
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
+107
View File
@@ -0,0 +1,107 @@
@Suppress("DSL_SCOPE_VIOLATION") // scope violation issue: work around suggested from: https://github.com/gradle/gradle/issues/22797
plugins {
id("ycharts.android.library")
id("ycharts.android.library.compose")
id("ycharts.android.test")
id("maven-publish")
id("signing")
alias(versionCatalogLibs.plugins.dokka)
}
android {
compileSdk = 33
namespace = "co.yml.charts.components"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
val dokkaOutputDir = "$buildDir/dokka"
tasks.dokkaHtml {
outputDirectory.set(file(dokkaOutputDir))
}
val deleteDokkaOutputDir by tasks.register<Delete>("deleteDokkaOutputDirectory") {
delete(dokkaOutputDir)
}
val javadocJar = tasks.register<Jar>("javadocJar") {
dependsOn(deleteDokkaOutputDir, tasks.dokkaHtml)
archiveClassifier.set("javadoc")
from(dokkaOutputDir)
}
publishing {
repositories {
maven {
name = "YCharts"
setUrl("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2")
credentials {
username = project.findProperty("mavenCentralUsername")?.toString() ?: System.getenv("MAVEN_USERNAME")
password = project.findProperty("mavenCentralPassword")?.toString() ?: System.getenv("MAVEN_PASSWORD")
}
}
}
publications {
register<MavenPublication>("release") {
groupId = "co.yml"
artifactId = "ycharts"
version = "2.1.0"
afterEvaluate {
from(components["release"])
}
artifact(javadocJar)
pom {
name.set("YCharts")
description.set("YCharts is a light and extensible chart library for Jetpack Compose system.")
url.set("https://github.com/yml-org/YCharts")
licenses {
license {
name.set("The Apache License, Version 2.0")
url.set("http://www.apache.org/licenses/LICENSE-2.0.txt")
distribution.set("http://www.apache.org/licenses/LICENSE-2.0.txt")
}
}
developers {
developer {
id.set("dkk009")
name.set("Deepak KK")
url.set("https://github.com/dkk009")
}
developer {
id.set("preetham1316")
name.set("Preetham Ivan Dsouza")
url.set("https://github.com/preetham1316")
}
developer {
id.set("kikoso")
name.set("Enrique López Mañas")
url.set("https://github.com/kikoso")
}
}
scm {
url.set("https://github.com/yml-org/YCharts")
connection.set("scm:git:git://github.com/yml-org/YCharts.git")
developerConnection.set("scm:git:ssh://git@github.com:yml-org/YCharts.git")
}
}
}
}
}
signing {
useInMemoryPgpKeys(
project.findProperty("signing.keyId")?.toString() ?: System.getenv("SIGNINGKEY"),
project.findProperty("signing.InMemoryKey")?.toString() ?: System.getenv("MEMORY_KEY"),
project.findProperty("signing.password")?.toString()?:System.getenv("SIGNINGPASSWORD")
)
sign(publishing.publications)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,71 @@
package co.yml.charts
import androidx.compose.foundation.layout.height
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.axis.AxisData
import co.yml.charts.axis.Gravity
import co.yml.charts.axis.YAxis
import org.junit.Ignore
import org.junit.Rule
class YChartsTest {
@get:Rule
val composeTestRule = createComposeRule()
@Ignore("Need to fix as nodes are not visible for components drawn inside canvas")
fun whenGraphAxisIsRenderedYaxisLabelShouldBeDisplayed(): Unit =
with(composeTestRule) {
// Given
val axisData = AxisData.Builder()
.steps(DEFAULT_AXIS_STEP_SIZE)
.axisPosition(Gravity.LEFT)
.axisLabelFontSize(AXIS_LABEL_FONT_SIZE.sp)
.labelData { index -> index.toString() }
.build()
// When
setContent {
YAxis(modifier = Modifier.height(AXIS_HEIGHT.dp), yAxisData = axisData)
}
// Then
onNode(hasText("8")).assertIsDisplayed()
}
@Ignore("Need to fix as nodes are not visible for components drawn inside canvas")
fun whenGivenYaxisLineNotRequiredIsConfiguredShouldNotBeVisible(): Unit =
with(composeTestRule) {
// Given
val axisData = co.yml.charts.axis.AxisData.Builder()
.axisPosition(Gravity.LEFT)
.axisLabelFontSize(AXIS_LABEL_FONT_SIZE.sp)
.axisConfig(co.yml.charts.axis.AxisConfig(isAxisLineRequired = false))
.labelData { index -> index.toString() }
.build()
// When
setContent {
YAxis(
modifier = Modifier.height(AXIS_HEIGHT.dp),
yAxisData = axisData
)
}
// Then
composeTestRule.onNodeWithText("8").assertIsNotDisplayed()
}
companion object {
private const val AXIS_HEIGHT = 250
private const val AXIS_LABEL_FONT_SIZE = 14
private const val DEFAULT_AXIS_STEP_SIZE = 10
}
}
@@ -0,0 +1,199 @@
package co.yml.charts.barchart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.extensions.getMaxElementInYAxis
import co.yml.charts.common.model.Point
import co.yml.charts.ui.barchart.StackedBarChart
import co.yml.charts.ui.barchart.models.BarData
import co.yml.charts.ui.barchart.models.BarPlotData
import co.yml.charts.ui.barchart.models.BarStyle
import co.yml.charts.ui.barchart.models.GroupBar
import co.yml.charts.ui.barchart.models.GroupBarChartData
import co.yml.charts.ui.barchart.models.SelectionHighlightData
import org.junit.Rule
import org.junit.Test
class StackedBarChartTest {
@get:Rule
val composeTestRule = createComposeRule()
private val barSize = 3
private val listSize = 5
private val groupBarData = getGroupBarChartTestData(listSize, barSize)
private val yStepSize = 10
private val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(listSize - 1)
.startDrawPadding(48.dp)
.labelData { index -> "C $index" }
.build()
private val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index ->
val valueList = mutableListOf<Float>()
groupBarData.map { groupBar ->
var yMax = 0f
groupBar.barList.forEach {
yMax += it.point.y
}
valueList.add(yMax)
}
val maxElementInYAxis = getMaxElementInYAxis(valueList.maxOrNull() ?: 0f, yStepSize)
(index * (maxElementInYAxis / yStepSize)).toString()
}
.topPadding(36.dp)
.build()
private val colorPaletteList = listOf(Color.Black, Color.Yellow, Color.Blue)
private val groupBarPlotData = BarPlotData(
groupBarList = groupBarData,
barStyle = BarStyle(
barWidth = 35.dp,
selectionHighlightData = SelectionHighlightData(
isHighlightFullBar = true,
groupBarPopUpLabel = { name, value ->
"Name"
}
)
),
barColorPaletteList = colorPaletteList
)
private val groupBarChartData = GroupBarChartData(
barPlotData = groupBarPlotData,
xAxisData = xAxisData,
yAxisData = yAxisData,
paddingBetweenStackedBars = 4.dp
)
private fun startStackedBarChart(isTalkBackEnabled: Boolean = false) {
composeTestRule.setContent {
StackedBarChart(
modifier = Modifier,
groupBarChartData = groupBarChartData,
isTalkBackEnabled
)
}
}
@Test
fun assertIfStackedBarChartIsDisplayed() {
startStackedBarChart()
composeTestRule.onNodeWithTag("stacked_bar_chart", useUnmergedTree = true)
.assertIsDisplayed()
}
@Test
fun assertIfCanvasIsDisplayed() {
startStackedBarChart()
composeTestRule.onNodeWithTag("chart_canvas", useUnmergedTree = true)
.assertExists()
}
@Test
fun assertIfXAxisIsDisplayed() {
startStackedBarChart()
composeTestRule.onNodeWithTag("x_axis", useUnmergedTree = true)
.assertExists()
}
@Test
fun assertIfYAxisIsDisplayed() {
startStackedBarChart()
composeTestRule.onNodeWithTag("y_axis", useUnmergedTree = true)
.assertExists()
}
@Test
fun assertIfStackedBarClicked() {
startStackedBarChart()
composeTestRule.onAllNodesWithTag("chart_canvas", useUnmergedTree = true)
.onFirst()
.performClick()
}
@Test
fun assertIfStackedBarChartHasCorrectBarData() {
startStackedBarChart(isTalkBackEnabled = true)
composeTestRule.onNodeWithText("Bar at 0 with label B0 has value 20.00")
.assertExists()
}
@Test
fun assertIfStackedBarHasChartHasCorrectXAxisLabel() {
startStackedBarChart(isTalkBackEnabled = true)
composeTestRule.onNodeWithText("X Axis label C 0")
.assertExists()
}
@Test
fun assertIfStackedBarHasChartHasCorrectPrimaryCategoryData() {
startStackedBarChart(isTalkBackEnabled = true)
composeTestRule.onNodeWithTag("AccessibilityBottomSheet List")
.onChildren()
.assertCountEquals(5)
}
@Test
fun assertIfPrimaryCategoryHasCorrectSecondaryVariableData() {
startStackedBarChart(isTalkBackEnabled = true)
composeTestRule.onNodeWithText("X Axis label C 0")
.onSiblings()
.assertCountEquals(4)
}
}
fun getGroupBarChartTestData(listSize: Int, barSize: Int): List<GroupBar> {
val list = mutableListOf<GroupBar>()
for (index in 0 until listSize) {
val barList = mutableListOf<BarData>()
for (i in 0 until barSize) {
val barValue = (((i + 1) * 10) + ((index + 1) * 10)).toFloat()
barList.add(
BarData(
Point(
index.toFloat(),
barValue
),
label = "B$i",
description = "Bar at $index with label B$i has value ${
String.format(
"%.2f", barValue
)
}"
)
)
}
list.add(GroupBar(index.toString(), barList))
}
return list
}
@@ -0,0 +1,72 @@
package co.yml.charts.piechart
import androidx.compose.foundation.layout.Column
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import co.yml.charts.common.components.Legends
import co.yml.charts.common.model.PlotType
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.piechart.charts.DonutPieChart
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.charts.ui.piechart.models.PieChartData
import org.junit.Rule
import org.junit.Test
class DonutPieChartTest {
@get:Rule
val composeTestRule = createComposeRule()
private val pieChartConfig = PieChartConfig(
labelVisible = false,
strokeWidth = 120f,
labelColor = Color.Black
)
private val pieChartData = PieChartData(
slices = listOf(
PieChartData.Slice("A", 15f, Color(0xFF58BDFF)),
PieChartData.Slice("B", 35f, Color(0xFF125B7F)),
PieChartData.Slice("C", 40f, Color(0xFF092D40)),
),
plotType = PlotType.Donut
)
@OptIn(ExperimentalMaterialApi::class)
@Test
fun whenIsLegendVisibleIsTrueLegendLabelsAreVisible() {
composeTestRule.setContent {
Column(modifier = Modifier) {
Legends(legendsConfig = DataUtils.getLegendsConfigFromPieChartData(pieChartData, 3))
DonutPieChart(
modifier = Modifier,
pieChartData = pieChartData,
pieChartConfig = pieChartConfig
)
}
}
composeTestRule.onNodeWithText("A").assertIsDisplayed()
composeTestRule.onNodeWithText("B").assertIsDisplayed()
composeTestRule.onNodeWithText("C").assertIsDisplayed()
}
@OptIn(ExperimentalMaterialApi::class)
@Test
fun whenIsLegendVisibleIsFalseNoLegendLabelsAreShown() {
composeTestRule.setContent {
DonutPieChart(
modifier = Modifier, pieChartData = pieChartData,
pieChartConfig = pieChartConfig
)
}
composeTestRule.onNodeWithText("A").assertDoesNotExist()
composeTestRule.onNodeWithText("B").assertDoesNotExist()
composeTestRule.onNodeWithText("C").assertDoesNotExist()
}
}
@@ -0,0 +1,67 @@
package co.yml.charts.piechart
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import co.yml.charts.common.components.Legends
import co.yml.charts.common.model.PlotType
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.piechart.charts.PieChart
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.charts.ui.piechart.models.PieChartData
import org.junit.Rule
import org.junit.Test
class PieChartTest {
@get:Rule
val composeTestRule = createComposeRule()
private val pieChartConfig = PieChartConfig(
labelVisible = false,
strokeWidth = 120f,
labelColor = Color.Black
)
private val pieChartData = PieChartData(
slices = listOf(
PieChartData.Slice("A", 15f, Color(0xFF58BDFF)),
PieChartData.Slice("B", 35f, Color(0xFF125B7F)),
PieChartData.Slice("C", 40f, Color(0xFF092D40)),
), plotType = PlotType.Pie
)
@Test
fun whenIsLegendVisibleIsTrueLegendLabelsAreVisible() {
composeTestRule.setContent {
Column(modifier = Modifier) {
Legends(legendsConfig = DataUtils.getLegendsConfigFromPieChartData(pieChartData, 3))
PieChart(
modifier = Modifier,
pieChartData = pieChartData,
pieChartConfig = pieChartConfig
)
}
}
composeTestRule.onNodeWithText("A").assertIsDisplayed()
composeTestRule.onNodeWithText("B").assertIsDisplayed()
composeTestRule.onNodeWithText("C").assertIsDisplayed()
}
@Test
fun whenIsLegendVisibleIsFalseNoLegendLabelsAreShown() {
composeTestRule.setContent {
PieChart(
modifier = Modifier, pieChartData = pieChartData,
pieChartConfig = pieChartConfig
)
}
composeTestRule.onNodeWithText("A").assertDoesNotExist()
composeTestRule.onNodeWithText("B").assertDoesNotExist()
composeTestRule.onNodeWithText("C").assertDoesNotExist()
}
}
@@ -0,0 +1,124 @@
package co.yml.charts.wavechart
import android.graphics.Typeface
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.extensions.formatToSinglePrecision
import co.yml.charts.common.model.Point
import co.yml.charts.ui.linechart.getMappingPointsToGraph
import co.yml.charts.ui.linechart.model.LineStyle
import co.yml.charts.ui.linechart.model.LineType
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
import co.yml.charts.ui.linechart.model.ShadowUnderLine
import co.yml.charts.ui.wavechart.WaveChart
import co.yml.charts.ui.wavechart.model.Wave
import co.yml.charts.ui.wavechart.model.WaveChartData
import co.yml.charts.ui.wavechart.model.WavePlotData
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
class WaveChartTest {
@get:Rule
val composeTestRule = createComposeRule()
private val pointsData = listOf(
Point(0f, 0f),
Point(1f, -3f),
Point(2f, -5f),
Point(3f, -2f),
Point(4f, 2f),
Point(5f, 5f),
Point(6f, 4f),
Point(7f, 0f),
Point(8f, -1f),
Point(9f, -3f),
Point(10f, 0f),
)
private val steps = 10
private val xAxisData = AxisData.Builder()
.axisStepSize(40.dp)
.steps(pointsData.size - 1)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.axisLineColor(Color.Red)
.build()
private val yAxisData = AxisData.Builder()
.steps(steps)
.labelData { i ->
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}
.axisLineColor(Color.Red)
.labelAndAxisLinePadding(20.dp)
.bottomPadding(15.dp)
.build()
private val data = WaveChartData(
wavePlotData = WavePlotData(
lines = listOf(
Wave(
dataPoints = pointsData,
waveStyle = LineStyle(
lineType = LineType.SmoothCurve(isDotted = true),
color = Color.Green
),
shadowUnderLine = ShadowUnderLine(),
selectionHighlightPoint = SelectionHighlightPoint(
color = Color.Green
),
selectionHighlightPopUp = SelectionHighlightPopUp(
backgroundColor = Color.Black,
backgroundStyle = Stroke(2f),
labelColor = Color.Red,
labelTypeface = Typeface.DEFAULT_BOLD
)
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData
)
@Test
fun assertIfWaveChartIsDisplayed() {
composeTestRule.setContent {
WaveChart(modifier = Modifier, waveChartData = data)
}
composeTestRule.onNodeWithTag("wave_chart").assertIsDisplayed()
}
@Test
fun assertIfScrollableContainerIsDisplayed() {
composeTestRule.setContent {
WaveChart(modifier = Modifier, waveChartData = data)
}
composeTestRule.onAllNodesWithTag(testTag = "scrollable_container", useUnmergedTree = true)
.onFirst()
.assertIsDisplayed()
}
@Test
fun getMappingPointsToGraphTest() {
val mappedPoints = getMappingPointsToGraph(
pointsData, 0f, 1f, 0f, 0f, 0f, 10f, 1f
)
val expectedResult = mutableListOf<Offset>()
pointsData.forEach {
expectedResult.add(Offset(it.x, (10f - it.y)))
}
Assert.assertEquals(expectedResult, mappedPoints)
}
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest/>
@@ -0,0 +1,192 @@
package co.yml.charts.axis
import android.graphics.Typeface
import android.text.TextUtils
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
*
* YAxis data class params used in drawing yAxis in any graph.
* @param steps: No of step for label segmentation
* @param axisBottomPadding: Axis Label offset bottom padding
* @param labelData(Int)-> String: lambda method for providing labels, @param Int will be the index
* given for each level in Axis
* @param axisLabelFontSize: Font size of axis label data
* @param axisPos :Gravity of axis
* @param labelAndAxisLinePadding: Text label padding from Axis
* @param axisOffset: Drawing offset for axis.
* @param axisLineThickness: Thickness of yAxis line
* @param axisTopPadding: Axis top padding
* @param axisStartPadding: Axis start padding.
* @param indicatorLineWidth: Indicator width on Y axis line for showing points
* @param axisConfig: All config related param to toggle the elements while drawing graph
* @param axisStepSize: Size of each Axis step in Dp
* @param axisLineColor Color of the Y or X axis
* @param axisLabelColor Color of the Y or X axis labels
* @param backgroundColor Background color of the Y or X components
* @param typeface The type of font style
* @param axisBottomPadding: Axis bottom padding,
* @param axisLabelAngle: Angle for the axis labels
* @param startDrawPadding: Padding between Axis and first point on the Axis
* @param shouldDrawAxisLineTillEnd : Boolean to draw axis line till end.
* @param axisLabelDescription: Description to describe axis value for accessibility service
*/
data class AxisData(
val steps: Int,
val labelData: (Int) -> String,
val axisPos: Gravity,
val labelAndAxisLinePadding: Dp,
val axisOffset: Dp,
val axisTopPadding: Dp,
val axisBottomPadding: Dp,
val axisStartPadding: Dp,
val axisEndPadding: Dp,
val axisStepSize: Dp,
val axisLabelAngle: Float,
val axisLineColor: Color,
val axisLabelColor: Color,
val axisLabelFontSize: TextUnit,
val axisLineThickness: Dp,
val indicatorLineWidth: Dp,
val backgroundColor: Color,
val typeface: Typeface,
val axisConfig: AxisConfig,
val startDrawPadding: Dp,
val shouldDrawAxisLineTillEnd: Boolean,
val axisLabelDescription: (String) -> String,
val dataCategoryOptions: DataCategoryOptions
) {
class Builder {
private var steps: Int = 1
private var labelData: (Int) -> String = { _ -> "" }
private var axisPos: Gravity = Gravity.LEFT
private var labelAndAxisLinePadding: Dp = 20.dp
private var axisStartPadding: Dp = 10.dp
private var axisOffset: Dp = 10.dp
private var axisTopPadding: Dp = 20.dp
private var axisEndPadding: Dp = 20.dp
private var axisBottomPadding: Dp = 10.dp
private var axisStepSize: Dp = 30.dp
private var axisConfig = AxisConfig()
private var indicatorLineWidth: Dp = 5.dp
private var backgroundColor: Color = Color.Transparent
private var typeface: Typeface = Typeface.DEFAULT
private var axisLineColor: Color = Color.Black
private var axisLabelFontSize: TextUnit = 14.sp
private var axisLineThickness: Dp = 2.dp
private var axisLabelColor: Color = Color.Black
private var startDrawPadding: Dp = 0.dp
private var axisLabelAngle: Float = 0f
private var shouldDrawAxisLineTillEnd: Boolean = false
private var dataCategoryOptions: DataCategoryOptions = DataCategoryOptions()
private var axisLabelDescription: (String) -> String =
{ label -> if (dataCategoryOptions.isDataCategoryInYAxis) "Y Axis label $label" else "X Axis label $label" }
fun steps(count: Int) = apply { this.steps = count }
fun axisOffset(offset: Dp) = apply { this.axisOffset = offset }
fun labelAndAxisLinePadding(padding: Dp) = apply { this.labelAndAxisLinePadding = padding }
fun axisStepSize(size: Dp) = apply { this.axisStepSize = size }
fun labelData(labelData: (Int) -> String) = apply { this.labelData = labelData }
fun axisLineColor(lineColor: Color) = apply { this.axisLineColor = lineColor }
fun axisLabelFontSize(fontSize: TextUnit) = apply { this.axisLabelFontSize = fontSize }
fun axisPosition(pos: Gravity) = apply { this.axisPos = pos }
fun axisLineThickness(thickness: Dp) = apply { this.axisLineThickness = thickness }
fun topPadding(padding: Dp) = apply { this.axisTopPadding = padding }
fun startPadding(padding: Dp) = apply { this.axisStartPadding = padding }
fun endPadding(padding: Dp) = apply { this.axisEndPadding = padding }
fun bottomPadding(padding: Dp) = apply { this.axisBottomPadding = padding }
fun indicatorLineWidth(lineWidth: Dp) = apply { this.indicatorLineWidth = lineWidth }
fun backgroundColor(color: Color) = apply { this.backgroundColor = color }
fun typeFace(typeface: Typeface) = apply { this.typeface = typeface }
fun axisConfig(config: AxisConfig) = apply { this.axisConfig = config }
fun axisLabelColor(color: Color) = apply { this.axisLabelColor = color }
fun startDrawPadding(padding: Dp) =
apply { this.startDrawPadding = padding }
fun axisLabelAngle(angle: Float) = apply { this.axisLabelAngle = angle }
fun shouldDrawAxisLineTillEnd(flag: Boolean) =
apply { this.shouldDrawAxisLineTillEnd = flag }
fun axisLabelDescription(description: (String) -> String) =
apply { this.axisLabelDescription = description }
fun setDataCategoryOptions(dataCategoryOptions: DataCategoryOptions) = apply {
this.dataCategoryOptions = dataCategoryOptions
}
fun build() = AxisData(
steps,
labelData,
axisPos,
labelAndAxisLinePadding,
axisOffset,
axisTopPadding,
axisBottomPadding,
axisStartPadding,
axisEndPadding,
axisStepSize,
axisLabelAngle,
axisLineColor,
axisLabelColor,
axisLabelFontSize,
axisLineThickness,
indicatorLineWidth,
backgroundColor,
typeface,
axisConfig,
startDrawPadding,
shouldDrawAxisLineTillEnd,
axisLabelDescription,
dataCategoryOptions
)
}
}
/**
*
* AxisConfig data class used to mention all config related param required to draw graph.
* @param isAxisLineRequired : true if should show the axis and points on the line else false
* @param shouldEllipsizeAxisLabel : true if should ellipsize the axis label at end else false
* @param minTextWidthToEllipsize : minimum width of the axis label post which label will be ellipsized
* @param ellipsizeAt : position at which the label will be truncated or ellipsized
*/
data class AxisConfig(
val isAxisLineRequired: Boolean = true,
val shouldEllipsizeAxisLabel: Boolean = false,
val minTextWidthToEllipsize: Dp = 40.dp,
val ellipsizeAt: TextUtils.TruncateAt = TextUtils.TruncateAt.END
)
/**
* DataCategoryOptions used to hold information about data category like where should draw the data category.
* @param isDataCategoryInYAxis: true if data category draws in y axis, false if it draws is in x axis.
* @param isDataCategoryStartFromBottom true when data category start from bottom of y axis, false if it start from top of y axis
*/
data class DataCategoryOptions(
val isDataCategoryInYAxis: Boolean = false,
val isDataCategoryStartFromBottom: Boolean = false
)
@@ -0,0 +1,8 @@
package co.yml.charts.axis
/**
* To specify gravity positions
*/
enum class Gravity {
TOP, LEFT, RIGHT, BOTTOM
}
@@ -0,0 +1,242 @@
package co.yml.charts.axis
import android.graphics.Paint
import android.text.TextPaint
import android.text.TextUtils
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.withRotation
import co.yml.charts.common.extensions.getTextHeight
import co.yml.charts.common.extensions.getTextWidth
import co.yml.charts.common.model.Point
import kotlin.math.ceil
/**
*
* XAxis compose method used for drawing xAxis in any given graph.
* @param xAxisData : All data needed to draw Yaxis
* @see co.yml.charts.axis.AxisData Data class to save all params related to axis
* @param modifier : All modifier related property.
* @param xStart : Start position of xAxis Points.
* @param scrollOffset : Offset of delta scrolled position.
* @param zoomScale : Scale at which zoom transformation being applied.
* @param chartData : List of data points used in the graph.
*/
@Composable
fun XAxis(
xAxisData: AxisData,
modifier: Modifier,
xStart: Float,
scrollOffset: Float,
zoomScale: Float,
chartData: List<Point>,
axisStart: Float
) {
with(xAxisData) {
var xAxisHeight by remember { mutableStateOf(0.dp) }
Row(modifier = modifier.clipToBounds()) {
Canvas(
modifier = modifier
.fillMaxWidth()
.height(xAxisHeight)
.semantics {
this.testTag = "x_axis"
}
.background(backgroundColor)
) {
val (_, _, xAxisScale) = getXAxisScale(chartData, steps)
//this is used when data category draws in Y axis and value in X axis
val xAxisSegmentWidth = (size.width - xStart - axisEndPadding.toPx()) / steps
var xPos = xStart + (startDrawPadding.toPx() * zoomScale) - scrollOffset
// used in the case of barchart
if (startDrawPadding != 0.dp) {
drawLine(
axisLineColor,
Offset(axisStart, 0f),
Offset(xStart + (startDrawPadding.toPx() * zoomScale), 0f),
strokeWidth = axisLineThickness.toPx()
)
}
for (index in 0..steps) {
xAxisHeight = drawXAxisLabel(
xAxisData,
index,
xAxisScale,
xPos,
xStart,
xAxisSegmentWidth
)
drawAxisLineWithPointers(
xPos,
xAxisData,
zoomScale,
xAxisScale,
index != steps,
xStart,
index,
xAxisSegmentWidth,
startDrawPadding.toPx()
)
xPos += ((axisStepSize.toPx() * (zoomScale * xAxisScale)))
}
}
}
}
}
private fun DrawScope.drawAxisLineWithPointers(
xPos: Float,
axisData: AxisData,
zoomScale: Float,
xAxisScale: Float,
canDrawEndLine: Boolean, // Added check to avoid drawing an extra line post the last point
xStart: Float,
index: Int,
dataValueWidth: Float,
startDrawPadding: Float
) {
with(axisData) {
if (axisConfig.isAxisLineRequired) {
if (canDrawEndLine) {
val axisStepWidth = (axisStepSize.toPx() * (zoomScale * xAxisScale))
drawLine(
axisLineColor,
if (axisData.dataCategoryOptions.isDataCategoryInYAxis) Offset(
xStart,
0f
) else Offset(xStart + (startDrawPadding * zoomScale), 0f),
if (shouldDrawAxisLineTillEnd) {
Offset(
(xPos + (axisStepWidth / 2) + axisStepWidth) + (startDrawPadding * zoomScale),
0f
)
} else {
if (axisData.dataCategoryOptions.isDataCategoryInYAxis) Offset(
xStart + (dataValueWidth * (index + 1)),
0f
) else Offset(xPos + axisStepWidth, 0f)
}, strokeWidth = axisLineThickness.toPx()
)
}
drawLine(
axisLineColor,
if (axisData.dataCategoryOptions.isDataCategoryInYAxis) Offset(
xStart + (dataValueWidth * index),
0f
) else Offset(xPos, 0f),
if (axisData.dataCategoryOptions.isDataCategoryInYAxis) Offset(
xStart + (dataValueWidth * index),
indicatorLineWidth.toPx()
) else Offset(xPos, indicatorLineWidth.toPx()),
strokeWidth = axisLineThickness.toPx()
)
}
}
}
private fun DrawScope.drawXAxisLabel(
axisData: AxisData,
index: Int,
xAxisScale: Float,
xPos: Float,
xStart: Float,
dataValueWidth: Float
): Dp = with(axisData) {
val calculatedXAxisHeight: Dp
val xAxisTextPaint = TextPaint().apply {
textSize = axisLabelFontSize.toPx()
color = axisLabelColor.toArgb()
textAlign = Paint.Align.LEFT
typeface = axisData.typeface
}
val xLabel =
if (axisData.dataCategoryOptions.isDataCategoryInYAxis) labelData(index) else labelData((index * xAxisScale).toInt())
val labelHeight = xLabel.getTextHeight(xAxisTextPaint)
val labelWidth = xLabel.getTextWidth(xAxisTextPaint)
calculatedXAxisHeight =
if (axisConfig.isAxisLineRequired) {
labelHeight.toDp() + axisLineThickness +
indicatorLineWidth + labelAndAxisLinePadding + axisBottomPadding
} else labelHeight.toDp() + labelAndAxisLinePadding
val ellipsizedText = TextUtils.ellipsize(
xLabel,
xAxisTextPaint,
axisStepSize.toPx(),
axisConfig.ellipsizeAt
)
drawContext.canvas.nativeCanvas.apply {
val x =
if (axisData.dataCategoryOptions.isDataCategoryInYAxis) xStart + (dataValueWidth * index) - (labelWidth / 2) else xPos - (labelWidth / 2)
val y = labelHeight / 2 + indicatorLineWidth.toPx() + labelAndAxisLinePadding.toPx()
withRotation(axisLabelAngle, x, y) {
drawText(
if (axisConfig.shouldEllipsizeAxisLabel) ellipsizedText.toString() else xLabel,
x,
y,
xAxisTextPaint
)
}
}
calculatedXAxisHeight
}
/**
* Returns triple of Xmax, Xmin & scale for given list of points and steps
* @param points: List of points in axis
* @param steps: Total steps in axis
*/
fun getXAxisScale(
points: List<Point>,
steps: Int,
): Triple<Float, Float, Float> {
val xMin = points.takeIf { it.isNotEmpty() }?.minOf { it.x } ?: 0f
val xMax = points.takeIf { it.isNotEmpty() }?.maxOf { it.x } ?: 0f
val totalSteps = (xMax - xMin)
val temp = totalSteps / steps
val scale = ceil(temp)
return Triple(xMin, xMax, scale)
}
@Preview(showBackground = true)
@Composable
private fun XAxisPreview() {
val axisData = AxisData.Builder()
.labelAndAxisLinePadding(10.dp)
.startDrawPadding(12.dp)
.axisPosition(Gravity.BOTTOM)
.axisLabelFontSize(14.sp)
.labelData { index -> index.toString() }
.build()
XAxis(
modifier = Modifier.height(40.dp),
xAxisData = axisData,
xStart = 0f,
scrollOffset = 0f,
zoomScale = 1f,
chartData = listOf(),
axisStart = 0f
)
}
@@ -0,0 +1,335 @@
package co.yml.charts.axis
import android.graphics.Paint
import android.text.TextPaint
import android.text.TextUtils
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.common.extensions.getTextHeight
import co.yml.charts.common.extensions.getTextWidth
import co.yml.charts.common.model.Point
import kotlin.math.ceil
/**
*
* YAxis compose method used for drawing yAxis in any given graph.
* @param modifier : All modifier related property.
* @param yAxisData : All data needed to draw Yaxis.
* @param scrollOffset : Offset of delta scrolled position.
* @param zoomScale : Scale at which zoom transformation being applied.
* @param chartData : List of data points used in the graph.
* @param dataCategoryWidth length of horizontal bar
* @param yStart start position of Y axis pointer
* @see co.yml.charts.axis Data class to save all params related to Yaxis
*/
@Composable
fun YAxis(
modifier: Modifier,
yAxisData: AxisData,
scrollOffset: Float = 0f,
zoomScale: Float = 0f,
chartData: List<Point> = emptyList(),
dataCategoryWidth: Float = 0f,
yStart: Float = 0f,
barWidth: Float = 0f
) {
with(yAxisData) {
var yAxisWidth by remember { mutableStateOf(0.dp) }
val isRightAligned = axisPos == Gravity.RIGHT
Column(modifier = modifier.clipToBounds()) {
val steps = steps + 1
Canvas(
modifier = modifier
.clipToBounds()
.width(yAxisWidth)
.semantics {
this.testTag = "y_axis"
}
.background(backgroundColor)
) {
val (yAxisHeight, segmentHeight) = getAxisInitValues(
yAxisData,
size.height,
axisBottomPadding.toPx(),
axisTopPadding.toPx(),
dataCategoryOptions.isDataCategoryInYAxis,
dataCategoryWidth
)
val (_, _, yAxisScale) = getYAxisScale(chartData, yAxisData.steps)
val yPositionFromBottom = yAxisHeight - yStart + scrollOffset
var yPos =
if (dataCategoryOptions.isDataCategoryStartFromBottom) yPositionFromBottom else {
if (zoomScale < 1) yPositionFromBottom else yStart - scrollOffset
}
for (index in 0 until steps) {
// Drawing the axis labels
yAxisWidth = drawAxisLabel(
yPos,
index,
yAxisData,
yAxisWidth,
isRightAligned,
yAxisHeight,
segmentHeight,
zoomScale,
steps - 1
)
drawAxisLineWithPointers(
yPos,
yAxisData,
index,
steps,
isRightAligned,
yAxisWidth,
yAxisHeight,
segmentHeight,
zoomScale,
yAxisScale,
yStart,
barWidth
)
val yPosChangeFromBottom = (axisStepSize.toPx() * (zoomScale * yAxisScale))
if (dataCategoryOptions.isDataCategoryStartFromBottom) {
yPos -= yPosChangeFromBottom
} else {
if (zoomScale < 1) {
yPos -= yPosChangeFromBottom
} else {
yPos += ((axisStepSize.toPx() * (zoomScale * yAxisScale)))
}
}
}
}
}
}
}
fun getAxisInitValues(
axisData: AxisData,
canvasHeight: Float,
bottomPadding: Float,
topPadding: Float,
isDataCategoryInYAxis: Boolean = false,
dataCategoryWidth: Float = 0f
): Pair<Float, Float> = with(axisData) {
val yAxisHeight = canvasHeight - bottomPadding
// Minus the top padding to avoid cropping at the top
val segmentHeight = if (isDataCategoryInYAxis) {
dataCategoryWidth
} else {
(yAxisHeight - topPadding) / axisData.steps
}
Pair(yAxisHeight, segmentHeight)
}
private fun DrawScope.drawAxisLineWithPointers(
yPos: Float,
axisData: AxisData,
index: Int,
totalSteps: Int,
isRightAligned: Boolean,
yAxisWidth: Dp,
yAxisHeight: Float,
segmentHeight: Float,
zoomScale: Float,
yAxisScale: Float,
yStart: Float = 0f,
barWidth: Float
) {
with(axisData) {
if (axisConfig.isAxisLineRequired) {
// Draw line only until reqXLabelsQuo -1 else will be a extra line at top with no label
val axisStepWidth = (axisStepSize.toPx() * (zoomScale * yAxisScale))
if (startDrawPadding != 0.dp && dataCategoryOptions.isDataCategoryInYAxis) {
drawLine(
start = Offset(
x = if (isRightAligned) 0.dp.toPx() else yAxisWidth.toPx(),
y = yAxisHeight - startDrawPadding.toPx() * zoomScale
),
end = Offset(
x = if (isRightAligned) 0.dp.toPx() else yAxisWidth.toPx(),
y = yAxisHeight
),
color = axisLineColor, strokeWidth = axisLineThickness.toPx()
)
}
if (index != (totalSteps - 1)) {
//Draw Yaxis line
drawLine(
start = Offset(
x = if (isRightAligned) 0.dp.toPx() else yAxisWidth.toPx(),
y = if (dataCategoryOptions.isDataCategoryInYAxis) {
if (shouldDrawAxisLineTillEnd) {
if (dataCategoryOptions.isDataCategoryStartFromBottom) yPos else {
if (zoomScale < 1) yPos else yPos - axisStepWidth / 2
}
} else
yPos
} else yAxisHeight - (segmentHeight * index)
),
end = Offset(
x = if (isRightAligned) 0.dp.toPx() else yAxisWidth.toPx(),
y = if (dataCategoryOptions.isDataCategoryInYAxis) {
if (dataCategoryOptions.isDataCategoryStartFromBottom) {
if (shouldDrawAxisLineTillEnd) {
yPos - axisStepWidth - barWidth / 2
} else {
yPos - axisStepWidth
}
} else {
if (zoomScale < 1) {
if (shouldDrawAxisLineTillEnd) {
yPos - axisStepWidth - barWidth / 2
} else {
yPos - axisStepWidth
}
} else {
if (shouldDrawAxisLineTillEnd) {
yPos + axisStepWidth + barWidth / 2 + (startDrawPadding.toPx() * zoomScale)
} else {
yPos + axisStepWidth + (startDrawPadding.toPx() * zoomScale)
}
}
}
} else yAxisHeight - (segmentHeight * (index + 1))
),
color = axisLineColor, strokeWidth = axisLineThickness.toPx()
)
}
//Draw pointer lines on Yaxis
drawLine(
start = Offset(
x = if (isRightAligned) 0.dp.toPx() else {
yAxisWidth.toPx() - indicatorLineWidth.toPx()
},
y = if (dataCategoryOptions.isDataCategoryInYAxis) {
yPos
} else yAxisHeight - (segmentHeight * index)
),
end = Offset(
x = if (isRightAligned) indicatorLineWidth.toPx() else yAxisWidth.toPx(),
y = if (dataCategoryOptions.isDataCategoryInYAxis) {
yPos
} else yAxisHeight - (segmentHeight * index)
),
color = axisLineColor, strokeWidth = axisLineThickness.toPx()
)
}
}
}
private fun DrawScope.drawAxisLabel(
yPos: Float,
index: Int,
axisData: AxisData,
yAxisWidth: Dp,
isRightAligned: Boolean,
yAxisHeight: Float,
segmentHeight: Float,
zoomScale: Float,
lastIndex: Int
): Dp = with(axisData) {
var calculatedYAxisWidth = yAxisWidth
val yAxisTextPaint = TextPaint().apply {
textSize = axisLabelFontSize.toPx()
color = axisLabelColor.toArgb()
textAlign = if (isRightAligned) Paint.Align.RIGHT else Paint.Align.LEFT
typeface = axisData.typeface
}
val yAxisLabel = if (dataCategoryOptions.isDataCategoryInYAxis) {
if (dataCategoryOptions.isDataCategoryStartFromBottom) labelData(index) else {
if (zoomScale < 1) labelData(lastIndex - index) else labelData(index)
}
} else labelData(index)
val measuredWidth = yAxisLabel.getTextWidth(yAxisTextPaint)
val height: Int = yAxisLabel.getTextHeight(yAxisTextPaint)
if (measuredWidth > calculatedYAxisWidth.toPx()) {
val width =
if (axisConfig.shouldEllipsizeAxisLabel) {
axisConfig.minTextWidthToEllipsize
} else measuredWidth.toDp()
calculatedYAxisWidth =
width + labelAndAxisLinePadding + axisOffset
}
val ellipsizedText = TextUtils.ellipsize(
yAxisLabel,
yAxisTextPaint,
axisConfig.minTextWidthToEllipsize.toPx(),
axisConfig.ellipsizeAt
)
drawContext.canvas.nativeCanvas.apply {
drawText(
if (axisConfig.shouldEllipsizeAxisLabel) ellipsizedText.toString() else yAxisLabel,
if (isRightAligned) calculatedYAxisWidth.toPx() - labelAndAxisLinePadding.toPx() else {
axisStartPadding.toPx()
},
if (dataCategoryOptions.isDataCategoryInYAxis)
yPos + height / 2
else yAxisHeight + height / 2 - ((segmentHeight * index)),
yAxisTextPaint
)
}
return calculatedYAxisWidth
}
/**
* Returns triple of Ymax, Ymin & scale for given list of points and steps
* @param points: List of points in axis
* @param steps: Total steps in axis
*/
fun getYAxisScale(
points: List<Point>,
steps: Int,
): Triple<Float, Float, Float> {
val yMin = points.takeIf { it.isNotEmpty() }?.minOf { it.y } ?: 0f
val yMax = points.takeIf { it.isNotEmpty() }?.maxOf { it.y } ?: 0f
val totalSteps = (yMax - yMin)
val temp = totalSteps / steps
val scale = ceil(temp)
return Triple(yMin, yMax, scale)
}
@Preview(showBackground = true)
@Composable
private fun YAxisPreview() {
val yAxisData = AxisData.Builder()
.steps(5)
.bottomPadding(10.dp)
.axisPosition(Gravity.LEFT)
.axisLabelFontSize(14.sp)
.labelData { index -> index.toString() }
.build()
YAxis(
modifier = Modifier.height(300.dp), yAxisData = yAxisData,
scrollOffset = 0f,
zoomScale = 1f,
chartData = listOf(),
150f
)
}
@@ -0,0 +1,128 @@
package co.yml.charts.chartcontainer.container
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.LayoutDirection
import co.yml.charts.chartcontainer.gestures.detectZoomGesture
/**
*
* ScrollableCanvasContainer is a container used to draw any graph which supports scroll,
* zoom & tap or drag gestures.
* @param modifier : All modifier related property.
* @param calculateMaxDistance: callback to calculate the maximum scrolling distance.
* @param onDraw: Draw any canvas inside the onDraw scope using the input params in the lambda fxn
* @param drawXAndYAxis: Draw the X and Y axis along with the drawing area.
* @param containerBackgroundColor: Background color of the whole container.
* @param isPinchZoomEnabled: True if user can zoom in and out else false
* @param layoutDirection: Used to define the direction of scroll.
* @param onPointClicked: Callback for tap detected along with offset for tap.
* @param onScroll: Callback when user starts scrolling the graph.
* @param onZoomInAndOut: Callback when user starts zoomIn and Out w.r.t to the graph
* @param scrollOrientation: Used to define the scroll orientation
*/
@Composable
fun ScrollableCanvasContainer(
modifier: Modifier,
calculateMaxDistance: DrawScope.(Float) -> Float,
onDraw: DrawScope.(Float, Float) -> Unit,
drawXAndYAxis: @Composable BoxScope.(Float, Float) -> Unit,
containerBackgroundColor: Color = Color.White,
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
onPointClicked: (Offset, Float) -> Unit = { _, _ -> },
isPinchZoomEnabled: Boolean = true,
onScroll: () -> Unit = {},
onZoomInAndOut: () -> Unit = {},
scrollOrientation: Orientation = Orientation.Horizontal
) {
val scrollOffset = remember { mutableStateOf(0f) }
val maxScrollOffset = remember { mutableStateOf(0f) }
val xZoom = remember { mutableStateOf(1f) }
val scrollState = rememberScrollableState { delta ->
scrollOffset.value -= delta
scrollOffset.value = checkAndGetMaxScrollOffset(
scrollOffset.value,
maxScrollOffset.value
)
delta
}
if (scrollState.isScrollInProgress){
onScroll()
}
CompositionLocalProvider(
LocalLayoutDirection provides layoutDirection
) {
Box(
modifier = modifier.clipToBounds()
) {
Canvas(modifier = modifier
.align(Alignment.Center)
.fillMaxHeight()
.fillMaxWidth()
.semantics {
this.testTag = "chart_canvas"
}
.background(containerBackgroundColor)
.scrollable(
state = scrollState, scrollOrientation, enabled = true
)
.pointerInput(Unit) {
detectTapGestures(onTap = {
onPointClicked(it, scrollOffset.value)
})
}
.pointerInput(Unit) {
detectZoomGesture(
isZoomAllowed = isPinchZoomEnabled,
onZoom = { zoom ->
xZoom.value *= zoom
onZoomInAndOut()
}
)
},
onDraw = {
maxScrollOffset.value = calculateMaxDistance(xZoom.value)
onDraw(scrollOffset.value, xZoom.value)
})
drawXAndYAxis(scrollOffset.value, xZoom.value)
}
}
}
/**
* Returns the scroll state within the start and computed max scrollOffset & filters invalid scroll states.
* @param currentScrollOffset: Current scroll offset when user trying to scroll the canvas.
* @param computedMaxScrollOffset: Maximum calculated scroll offset for given data set.
*/
fun checkAndGetMaxScrollOffset(currentScrollOffset: Float, computedMaxScrollOffset: Float): Float {
return when {
currentScrollOffset < 0f -> 0f
currentScrollOffset > computedMaxScrollOffset -> computedMaxScrollOffset
else -> currentScrollOffset
}
}
@@ -0,0 +1,56 @@
package co.yml.charts.chartcontainer.gestures
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged
import kotlin.math.abs
/**
* Gesture support to detect and filter pointer scopes to give a zoom start callback
* @param isZoomAllowed: True if user is allowed to zoom.
* @param onZoom: Callback when zoom gesture is detected.
*/
internal suspend fun PointerInputScope.detectZoomGesture(
isZoomAllowed: Boolean = true,
onZoom: (zoom: Float) -> Unit
) {
if (isZoomAllowed) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown(requireUnconsumed = false)
}
awaitPointerEventScope {
var zoom = 1f
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { it.isConsumed }
if (event.changes.size == 1) break
else if (event.changes.size == 2) {
if (isZoomAllowed) {
if (!canceled) {
val zoomChange = event.calculateZoom()
if (!pastTouchSlop) {
zoom *= zoomChange
val centroidSize =
event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
if (zoomMotion > touchSlop) pastTouchSlop = true
}
if (pastTouchSlop) {
if (zoomChange != 1f) onZoom(zoomChange)
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
}
}
} else break
} while (!canceled && event.changes.any { it.pressed })
}
}
}
}
@@ -0,0 +1,25 @@
package co.yml.charts.common.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
/**
* Draws a horizontal line as a divider for given thickness and color
* @param thickness: Defines the thickness of the divider.
* @param dividerColor: Defines the color of the divider.
*/
@Composable
fun ItemDivider(thickness: Dp, dividerColor: Color = Color.Black) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(thickness)
.background(dividerColor)
)
}
@@ -0,0 +1,63 @@
package co.yml.charts.common.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import co.yml.charts.common.model.LegendLabel
import co.yml.charts.common.model.LegendsConfig
/**
* Renders the list of legends in a grid format for given given grid column count
* @param modifier: Defines the arrangements of ui compositions.
* @param legendsConfig: Defines the configurations required for rendering legends in [LegendsConfig]
*/
@Composable
fun Legends(modifier: Modifier = Modifier, legendsConfig: LegendsConfig) {
with(legendsConfig) {
if (legendLabelList.size > 1) {
LazyVerticalGrid(
modifier = modifier
.fillMaxWidth()
.padding(
horizontal = gridPaddingHorizontal, vertical = gridPaddingVertical
),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
columns = GridCells.Fixed(gridColumnCount)
) {
items(legendLabelList) {
Legend(legendsConfig, it)
}
}
}
}
}
@Composable
private fun Legend(config: LegendsConfig, legendLabel: LegendLabel) {
Row(
horizontalArrangement = config.legendsArrangement,
verticalAlignment = Alignment.CenterVertically
) {
val boxModifier = Modifier.size(config.colorBoxSize)
if (legendLabel.brush != null) {
Box(modifier = boxModifier.background(legendLabel.brush))
} else {
Box(modifier = boxModifier.background(legendLabel.color))
}
Spacer(modifier = Modifier.padding(config.spaceBWLabelAndColorBox))
Text(
text = legendLabel.name, style = config.textStyle, overflow = TextOverflow.Ellipsis
)
}
}
@@ -0,0 +1,74 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* Bottom sheet to show items in vertical list view with a close button at top
*/
@ExperimentalMaterialApi
@Composable
fun AccessibilityBottomSheetDialog(
content: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color,
popUpTopRightButtonTitle: String,
popUpTopRightButtonDescription: String,
sheetState: ModalBottomSheetState
) {
val composeScope = rememberCoroutineScope()
ModalBottomSheetLayout(
sheetContent = {
Box(
modifier = modifier
.background(backgroundColor)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.End
) {
Button(onClick = {
composeScope.launch { sheetState.hide() }
}) {
Text(
text = popUpTopRightButtonTitle,
modifier = Modifier.semantics {
contentDescription = popUpTopRightButtonDescription
})
}
}
content()
}
}
},
sheetState = sheetState,
sheetBackgroundColor = Color.White,
sheetElevation = 0.dp
) {}
}
@@ -0,0 +1,60 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Composable to display each bar item for given bar chart
* @param axisLabelDescription: Axis label description
* @param barDescription: Bar description
* @param titleTextSize: TextUnit title font size
* @param descriptionTextSize: TextUnit description font size
*/
@Composable
fun BarInfo(
axisLabelDescription: String,
barDescription: String, barColor: Color,
titleTextSize:TextUnit,
descriptionTextSize:TextUnit
) {
// Merge elements below for accessibility purposes
Row(modifier = Modifier
.padding(start = 10.dp, end = 10.dp)
.clickable { }
.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically
) {
Text(axisLabelDescription, fontSize = titleTextSize)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.padding(5.dp)
.background(barColor)
.size(20.dp)
)
Text(barDescription, fontSize = descriptionTextSize)
}
}
}
}
@@ -0,0 +1,62 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Composable to display each Bubble point data for given bubble chart.
* @param axisLabelDescription: Axis label description.
* @param pointDescription: Details of each point on the line.
* @param bubbleColor: Color of each bubble.
* @param titleTextSize: TextUnit title font size
* @param descriptionTextSize: TextUnit description font size
*/
@Composable
fun BubblePointInfo(
axisLabelDescription: String,
pointDescription: String,
bubbleColor: Color,
titleTextSize: TextUnit,
descriptionTextSize: TextUnit
) {
// Merge elements below for accessibility purposes
Row(modifier = Modifier
.padding(start = 10.dp, end = 10.dp)
.clickable { }
.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically
) {
Text(axisLabelDescription, fontSize = titleTextSize)
Spacer(modifier = Modifier.width(5.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(5.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.padding(5.dp)
.background(bubbleColor)
.size(20.dp)
)
Text(pointDescription, fontSize = descriptionTextSize)
}
}
}
}
@@ -0,0 +1,93 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.ui.barchart.models.GroupBar
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.model.Point
/**
* Composable to display each combined chart item for given combined chart.
* @param pointsList: List of points in each item.
* @param lineColor: List of colors of lines as per line chart.
* @param groupBar: Details of each group bar.
* @param axisLabelDescription: Axis label description.
* @param barColorPaletteList: List of each bar colors for a given group bar.
* @param dividerColor: Divider color between each point items.
* @param titleTextSize: TextUnit title font size
* @param descriptionTextSize: TextUnit description font size
*/
@Composable
fun CombinedChartInfo(
pointsList: List<Point>,
lineColor: List<Color>,
groupBar: GroupBar?,
axisLabelDescription: String,
barColorPaletteList: List<Color>,
dividerColor: Color,
titleTextSize: TextUnit,
descriptionTextSize: TextUnit
) {
// Merge elements below for accessibility purposes
Row(modifier = Modifier
.padding(start = 10.dp, end = 10.dp)
.clickable { }
.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically
) {
Text(axisLabelDescription, fontSize = titleTextSize)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(5.dp)
) {
pointsList.forEachIndexed { index, point ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.padding(5.dp)
.background(
color = lineColor[index],
shape = RoundedCornerShape(corner = CornerSize(10.dp))
)
.size(10.dp)
)
Text(point.description, fontSize = descriptionTextSize)
}
Spacer(modifier = Modifier.height(5.dp))
}
ItemDivider(thickness = 1.dp, dividerColor = dividerColor)
groupBar?.barList?.forEachIndexed { index, value ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.padding(5.dp)
.background(barColorPaletteList[index])
.size(20.dp)
)
Text(value.description, fontSize = descriptionTextSize)
}
Spacer(modifier = Modifier.height(5.dp))
}
}
}
}
@@ -0,0 +1,68 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.ui.barchart.models.GroupBar
/**
* Composable to display each group bar item for given group bar chart.
* @param axisLabelDescription: Axis label description.
* @param groupBar: Details of each group bar.
* @param barColorPaletteList: List of each bar colors for a given group bar.
* @param titleTextSize: TextUnit title font size
* @param descriptionTextSize: TextUnit description font size
*/
@Composable
fun GroupBarInfo(
groupBar: GroupBar,
axisLabelDescription: String,
barColorPaletteList: List<Color>,
titleTextSize: TextUnit,
descriptionTextSize: TextUnit
) {
// Merge elements below for accessibility purposes
Row(modifier = Modifier
.padding(start = 10.dp, end = 10.dp)
.clickable { }
.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically
) {
Text(axisLabelDescription, fontSize =titleTextSize)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(5.dp)
) {
groupBar.barList.forEachIndexed { index, value ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.padding(5.dp)
.background(barColorPaletteList[index])
.size(20.dp)
)
Text(value.description, fontSize = descriptionTextSize)
}
Spacer(modifier = Modifier.height(5.dp))
}
}
}
}
@@ -0,0 +1,63 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Composable to display each line point data for given line chart.
* @param axisLabelDescription: Axis label description.
* @param pointDescription: Details of each point on the line.
* @param lineColor: Color of each line.
* @param titleTextSize: TextUnit title font size
* @param descriptionTextSize: TextUnit description font size
*
*/
@Composable
fun LinePointInfo(
axisLabelDescription: String,
pointDescription: String,
lineColor: Color,
titleTextSize: TextUnit,
descriptionTextSize: TextUnit
) {
// Merge elements below for accessibility purposes
Row(modifier = Modifier
.padding(start = 10.dp, end = 10.dp)
.clickable { }
.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically
) {
Text(axisLabelDescription, fontSize = titleTextSize)
Spacer(modifier = Modifier.width(5.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(5.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.padding(5.dp)
.background(lineColor)
.size(20.dp)
)
Text(pointDescription, fontSize = descriptionTextSize)
}
}
}
}
@@ -0,0 +1,48 @@
package co.yml.charts.common.components.accessibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.ui.piechart.models.PieChartData
/**
* Composable to display each slice for a given pie chart.
* @param slice: Details of each slice in pie/ donut chart.
* @param slicePercentage: Percentage of each slice.
* @param textSize: TextUnit text font size
*/
@Composable
fun SliceInfo(slice: PieChartData.Slice, slicePercentage: Int, textSize: TextUnit) {
// Merge elements below for accessibility purposes
Row(modifier = Modifier
.clickable { }
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.padding(5.dp)
.background(slice.color)
.size(30.dp)
)
Column(
modifier = Modifier
.weight(1f)
.padding(top = 10.dp, bottom = 10.dp)
) {
Text(slice.sliceDescription(slicePercentage), fontSize = textSize)
}
}
}
@@ -0,0 +1,343 @@
package co.yml.charts.common.extensions
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.graphics.Paint
import android.graphics.Rect
import android.text.TextPaint
import android.view.accessibility.AccessibilityManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import co.yml.charts.common.model.Point
import co.yml.charts.ui.linechart.model.GridLines
import java.text.DecimalFormat
import kotlin.math.roundToInt
/**
return the width of text in canvas drawn text
*/
fun String.getTextWidth(paint: Paint): Float {
return paint.measureText(this)
}
/**
return the height of text in canvas drawn text
*/
fun String.getTextHeight(paint: Paint): Int {
val bounds = Rect()
paint.getTextBounds(
this,
0,
this.length,
bounds
)
return bounds.height()
}
/**
return the shape that is used to mask a particular area for given leftPadding & rightPadding
*/
internal class RowClip(
private val leftPadding: Float,
private val rightPadding: Dp,
private val topPadding: Float = 0f
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
androidx.compose.ui.geometry.Rect(
leftPadding,
topPadding,
size.width - rightPadding.value * density.density,
size.height
)
)
}
}
/**
* Returns true or false if the object is null
*/
fun Any?.isNotNull() = this != null
/**
* Returns the background rect for the highlighted text.
* @param x : X point.
* @param y: Y point.
* @param text: Text to be drawn inside the background.
* @param paint: Background paint.
*/
fun getTextBackgroundRect(
x: Float,
y: Float,
text: String,
paint: TextPaint
): Rect {
val fontMetrics = paint.fontMetrics
val textLength = paint.measureText(text)
return Rect(
(x - (textLength / 2)).toInt(),
(y + fontMetrics.top).toInt(),
(x + (textLength / 2)).toInt(),
(y + fontMetrics.bottom).toInt()
)
}
/**
* Returns the maximum and minimum points of X axis.
* @param points: List of the points to be drawn.
*/
fun getXMaxAndMinPoints(
points: List<Point>,
): Pair<Float, Float> {
val xMin = points.minOf { it.x }
val xMax = points.maxOf { it.x }
return Pair(xMin, xMax)
}
/**
* Returns the maximum and minimum points of Y axis
* @param points List of points
*/
fun getYMaxAndMinPoints(
points: List<Point>,
): Pair<Float, Float> {
if (points.isEmpty())
return Pair(0f, 0f)
val yMin = points.minOf { it.y }
val yMax = points.maxOf { it.y }
return Pair(yMin, yMax)
}
/**
* Returns the maximum value of Y axis
* @param yMax Maximum value in the Y axis
* @param yStepSize size of one step in the Y axis
*/
fun getMaxElementInYAxis(yMax: Float, yStepSize: Int): Int {
var reqYLabelsQuo =
(yMax / yStepSize)
val reqYLabelsRem = yMax.rem(yStepSize)
if (reqYLabelsRem > 0f) {
reqYLabelsQuo += 1
}
return reqYLabelsQuo.toInt() * yStepSize
}
/**
* Return true if the point is locked
* @param dragOffset Tapped offset
* @param xOffset in the X axis
*/
fun Offset.isDragLocked(dragOffset: Float, xOffset: Float) =
((dragOffset) > x - xOffset / 2) && ((dragOffset) < x + xOffset / 2)
/**
* Return true if the point is selected
* @param tapOffset Tapped offset
* @param xOffset in the X axis
* @param bottom bottom Value
*/
fun Offset.isTapped(tapOffset: Offset, xOffset: Float, bottom: Float, tapPadding: Float) =
((tapOffset.x) > x - (xOffset + tapPadding) / 2) && ((tapOffset.x) < x + (xOffset + tapPadding) / 2) &&
((tapOffset.plus(Offset(0f, tapPadding))).y > y) && ((tapOffset.y) < bottom)
/**
* Returns true if the tapped point is withing the given boundries else false
* @param tapOffset Tapped offset
* @param tapPadding plus or minus padding from the point or clickable padding
*/
fun Offset.isPointTapped(tapOffset: Offset, tapPadding: Float) =
((tapOffset.x) > x - tapPadding) && ((tapOffset.x) < x + tapPadding) &&
((tapOffset.plus(Offset(0f, tapPadding))).y > y) &&
((tapOffset.minus(Offset(0f, tapPadding))).y < y)
/***
* Returns converted single precision string from float value
*/
fun Float.formatToSinglePrecision(): String = DecimalFormat("#.#").format(this)
/**
*
* DrawScope.drawGridLines is the extension method used to draw the grid lines on any graph
* @param yBottom : Bottom value for Y-Axis
* @param top: Top value for Y axis
* @param xLeft: Total left padding in X-Axis.
* @param paddingRight : Total right padding.
* @param scrollOffset : Total scroll offset.
* @param verticalPointsSize : Total points in the X-Axis.
* @param xZoom : Total zoom offset.
* @param xAxisScale: Scale of each point in X-Axis.
* @param ySteps : Number of steps in y-Axis.
* @param xAxisStepSize: The size of each X-Axis step.
* @param gridLines: Data class to handle styling related to grid lines.
*/
fun DrawScope.drawGridLines(
yBottom: Float,
top: Float,
xLeft: Float,
paddingRight: Dp,
scrollOffset: Float,
verticalPointsSize: Int,
xZoom: Float,
xAxisScale: Float,
ySteps: Int,
xAxisStepSize: Dp,
gridLines: GridLines
) {
val availableHeight = yBottom - top
val steps = ySteps + 1 // Considering 0 as step
val gridOffset = availableHeight / if (steps > 1) steps - 1 else 1
// Should start from 1 as we don't consider the XAxis
if (gridLines.enableHorizontalLines) {
(1 until steps).forEach {
val y = yBottom - (it * gridOffset)
gridLines.drawHorizontalLines(this, xLeft, y, size.width - paddingRight.toPx())
}
}
if (gridLines.enableVerticalLines) {
var xPos = xLeft - scrollOffset
(0 until verticalPointsSize).forEach { _ ->
gridLines.drawVerticalLines(this, xPos, yBottom, top)
xPos += ((xAxisStepSize.toPx() * (xZoom * xAxisScale)))
}
}
}
/**
* Returns true if accessibility services are enabled else false
*/
@Composable
internal fun Context.collectIsTalkbackEnabledAsState(): State<Boolean> {
val accessibilityManager =
this.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager?
fun isTalkbackEnabled(): Boolean {
val accessibilityServiceInfoList =
accessibilityManager?.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_SPOKEN)
return accessibilityServiceInfoList?.any {
it.resolveInfo.serviceInfo.processName.equals(TALKBACK_PACKAGE_NAME) || it.resolveInfo.serviceInfo.processName.equals(
TALKBACK_PACKAGE_NAME_SAMSUNG
)
} ?: false
}
val talkbackEnabled = remember { mutableStateOf(isTalkbackEnabled()) }
val accessibilityManagerEnabled = accessibilityManager?.isEnabled ?: false
var accessibilityEnabled by remember { mutableStateOf(accessibilityManagerEnabled) }
accessibilityManager?.addAccessibilityStateChangeListener { accessibilityEnabled = it }
LaunchedEffect(accessibilityEnabled) {
talkbackEnabled.value = if (accessibilityEnabled) isTalkbackEnabled() else false
}
return talkbackEnabled
}
private const val TALKBACK_PACKAGE_NAME = "com.google.android.marvin.talkback"
private const val TALKBACK_PACKAGE_NAME_SAMSUNG = "com.samsung.android.accessibility.talkback"
/**
return the shape that is used to mask a particular area for given top & bottom
*/
internal class ColumnClip(
private val leftPadding: Float = 0f,
private val topPadding: Float = 0f,
private val rightPadding: Float,
private val bottomPadding: Float
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
androidx.compose.ui.geometry.Rect(
leftPadding,
topPadding,
rightPadding,
size.height - bottomPadding
)
)
}
}
/**
* Returns the maximum value of X axis
* @param xMax Maximum value in the X axis
* @param xStepSize size of one step in the X axis
*/
fun getMaxElementInXAxis(xMax: Float, xStepSize: Int): Int {
var reqYLabelsQuo =
(xMax / xStepSize)
val reqYLabelsRem = xMax.rem(xStepSize)
if (reqYLabelsRem > 0f) {
reqYLabelsQuo += 1
}
return reqYLabelsQuo.toInt() * xStepSize
}
/**
* Return true if the point is selected
* @param tapOffset Tapped offset
* @param yOffset in the Y axis
* @param left left Value
* @param tapPadding plus or minus padding from the point or clickable padding
* @param xAxisWidth width of horizontal bar
*/
fun Offset.isYAxisTapped(
tapOffset: Offset,
yOffset: Float,
left: Float,
tapPadding: Float,
xAxisWidth: Float
) =
((tapOffset.y) < y + (yOffset + tapPadding) / 2) && ((tapOffset.y) > y - (yOffset + tapPadding) / 2) &&
((tapOffset.plus(Offset(tapPadding, 0f))).x < xAxisWidth) && ((tapOffset.x) > left)
/**
* Round double value to 2 decimal point
*/
fun Double.roundTwoDecimal(): Double {
return (this * 100.0).roundToInt() / 100.0
}
/**
* Return true if the point is selected
* @param tapOffset Tapped offset
* @param barWidth width of the bar
* @param barHeight height of the bar
* @param tapPadding plus or minus padding from the point or clickable padding
*/
fun Offset.isStackedBarTapped(
tapOffset: Offset,
barWidth: Float,
barHeight: Float,
tapPadding: Float
) =
((tapOffset.x) > x - (barWidth + tapPadding) / 2) && ((tapOffset.x) < x + (barWidth + tapPadding) / 2) &&
(tapOffset.y > y) && ((tapOffset.y) < barHeight)
@@ -0,0 +1,31 @@
package co.yml.charts.common.model
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.common.utils.ChartConstants
/**
* AccessibilityConfig is a data class to configure all params needed for accessibility service.
* @param shouldHandleBackWhenTalkBackPopUpShown: True by default to dismiss the accessibility dialog when back pressed else false.
* @param chartDescription: Description used by accessibility service when tapped on the chart.
* @param popUpTopRightButtonTitle: Title of the button visible on the accessibility popUp at the top right.
* @param popUpTopRightButtonDescription: Content description of the button visible on the accessibility popUp at the top right.
* @param dividerColor: Defines the color for the divider used in the accessibility popUp
* @param dividerThickness: Defines the thickness for the divider in Dp used in the accessibility popUp
* @param titleTextSize: Defines the font-size of titles used in the accessibility popUp
* @param descriptionTextSize: Defines the font-size of descriptions used in the accessibility popUp
*/
data class AccessibilityConfig(
val chartDescription: String = ChartConstants.CHART_DESCRIPTION,
val shouldHandleBackWhenTalkBackPopUpShown: Boolean = true,
val popUpTopRightButtonTitle: String = ChartConstants.POPUP_TOP_RIGHT_BUTTON_TITLE,
val popUpTopRightButtonDescription: String = ChartConstants.POPUP_TOP_RIGHT_BUTTON_DESCRIPTION,
val dividerColor: Color = Color.Gray,
val dividerThickness: Dp = 2.dp,
val titleTextSize: TextUnit = 14.sp,
val descriptionTextSize: TextUnit = 14.sp,
)
@@ -0,0 +1,44 @@
package co.yml.charts.common.model
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* LegendsConfig data class params used in config label in graph.
* @param legendLabelList: stackLabelList is used to show labels with colors
* @param gridColumnCount : Column Count for stackLabel grid
* @param gridPaddingHorizontal : Horizontal padding for stackLabel grid
* @param gridPaddingVertical : Vertical padding for stackLabel grid
* @param spaceBWLabelAndColorBox: Space between Label and ColorBox for stackLabel grid item
* @param colorBoxSize: Blend mode for the groupSeparator
* @param textStyle: TextStyle for label
* */
data class LegendsConfig(
val legendLabelList: List<LegendLabel>,
val textSize: TextUnit = 14.sp,
val gridColumnCount: Int = 1,
val gridPaddingHorizontal: Dp = 8.dp,
val gridPaddingVertical: Dp = 8.dp,
val colorBoxSize: Dp = 25.dp,
val textStyle: TextStyle = TextStyle(),
val spaceBWLabelAndColorBox: Dp = 8.dp,
val legendsArrangement: Arrangement.Horizontal = Arrangement.Center
)
/**
* LegendLabel data class params used in drawing label in graph.
* @param color : Color of label.
* @param name : Name of label.
* @param brush : Used for gradient color
* */
data class LegendLabel(
val color: Color = Color.Black,
val name: String,
val brush: Brush? = null
)
@@ -0,0 +1,20 @@
package co.yml.charts.common.model
/**
* PlotData is a base class to all the types of available graphs
* @param plotType: Type of the graph given in [PlotType]
*/
interface PlotData {
val plotType: PlotType
}
/**
* PlotType is a sealed class to define the types of supported graphs/plots
*/
sealed interface PlotType {
object Line : PlotType
object Bar : PlotType
object Pie : PlotType
object Donut : PlotType
object Wave : PlotType
}
@@ -0,0 +1,16 @@
package co.yml.charts.common.model
/**
*
* Point data class is used for holding the point on the graph.
* @param x: x co-ordinate value in the graph
* @param y: y co-ordinate value in the graph
* @param description: Description given to describe the value of the point for accessibility service
* Used this class over PointF as its a java class and unable to mock the same
*/
data class Point(
val x: Float,
val y: Float,
val description: String = "Value of point is ${String.format("%.2f", y)}"
)
@@ -0,0 +1,9 @@
package co.yml.charts.common.utils
object ChartConstants {
const val DEFAULT_YAXIS_BOTTOM_PADDING = 10
const val DEFAULT_DETECT_DRAG_TIME_OUT = 100L
const val CHART_DESCRIPTION = "Double tap to know the chart in detail"
const val POPUP_TOP_RIGHT_BUTTON_TITLE = "Close"
const val POPUP_TOP_RIGHT_BUTTON_DESCRIPTION = "Tap to close the dialog"
}
@@ -0,0 +1,410 @@
package co.yml.charts.common.utils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.text.TextStyle
import co.yml.charts.axis.DataCategoryOptions
import co.yml.charts.common.model.LegendLabel
import co.yml.charts.common.model.LegendsConfig
import co.yml.charts.common.model.PlotType
import co.yml.charts.common.model.Point
import co.yml.charts.ui.barchart.models.BarChartType
import co.yml.charts.ui.barchart.models.BarData
import co.yml.charts.ui.barchart.models.GroupBar
import co.yml.charts.ui.bubblechart.model.Bubble
import co.yml.charts.ui.bubblechart.model.BubbleGradientType
import co.yml.charts.ui.bubblechart.model.BubbleStyle
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
import co.yml.charts.ui.piechart.models.PieChartData
import kotlin.math.sin
import kotlin.random.Random
object DataUtils {
/**
* Returns list of points
* @param listSize: Size of total number of points needed.
* @param start: X values to start from. ex: 50 to 100
* @param maxRange: Max range of Y values
*/
fun getLineChartData(listSize: Int, start: Int = 0, maxRange: Int): List<Point> {
val list = arrayListOf<Point>()
for (index in 0 until listSize) {
list.add(
Point(
index.toFloat(),
(start until maxRange).random().toFloat()
)
)
}
return list
}
/**
* Returns list of points
* @param listSize: Size of total number of points needed.
* @param start: X values to start from. ex: 50 to 100
* @param maxRange: Max range of Y values
*/
fun getRandomPoints(listSize: Int, start: Int = 0, maxRange: Int): List<Point> {
val list = arrayListOf<Point>()
for (index in 0 until listSize) {
list.add(
Point(
index.toFloat(),
(start until maxRange).random().toFloat()
)
)
}
return list
}
/**
* Returns list of points
* @param listSize: Size of total number of points needed.
* @param start: X values to start from. ex: 50 to 100
* @param maxRange: Max range of Y values
*/
fun getBubbleChartDataWithGradientStyle(
points: List<Point>,
minDensity: Float = 10F,
maxDensity: Float = 100F
): List<Bubble> {
val list = arrayListOf<Bubble>()
points.forEachIndexed { index, point ->
val bubbleColor1 = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
val bubbleColor2 = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
val bubbleColor3 = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
val bubbleColor4 = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
when(Random.nextInt(0,5)){
0->{
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(gradientColors = listOf(bubbleColor1, bubbleColor2,bubbleColor3,bubbleColor4), useGradience = true, gradientType = BubbleGradientType.RadialGradient),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
1->{
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(gradientColors = listOf(bubbleColor1, bubbleColor2), useGradience = true, gradientType = BubbleGradientType.LinearGradient),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
2->{
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(gradientColors = listOf(bubbleColor1, bubbleColor2), useGradience = true, gradientType = BubbleGradientType.VerticalGradient),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
3->{
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(gradientColors = listOf(bubbleColor1, bubbleColor2), useGradience = true, gradientType = BubbleGradientType.HorizontalGradient),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
4->{
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(gradientColors = listOf(bubbleColor1, bubbleColor2,bubbleColor3,bubbleColor4), useGradience = true, gradientType = BubbleGradientType.HorizontalGradient,tileMode = TileMode.Repeated),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
5->{
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(gradientColors = listOf(bubbleColor1, bubbleColor2,bubbleColor3,bubbleColor4), useGradience = true, gradientType = BubbleGradientType.HorizontalGradient,tileMode = TileMode.Mirror),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
}
}
return list
}
/**
* Returns list of points
* @param listSize: Size of total number of points needed.
* @param start: X values to start from. ex: 50 to 100
* @param maxRange: Max range of Y values
*/
fun getBubbleChartDataWithSolidStyle(
points: List<Point>,
minDensity: Float = 10F,
maxDensity: Float = 100F
): List<Bubble> {
val list = arrayListOf<Bubble>()
points.forEachIndexed { index, point ->
val bubbleColor =Color(red = Random.nextInt(256),green= Random.nextInt(256),blue= Random.nextInt(256), alpha =Random.nextInt(from = 150, until = 256) )
list.add(
Bubble(
center = point,
density = (minDensity.toInt() until maxDensity.toInt()).random().toFloat(),
bubbleStyle = BubbleStyle(solidColor = bubbleColor, useGradience = false),
selectionHighlightPoint = SelectionHighlightPoint(Color.Black),
selectionHighlightPopUp = SelectionHighlightPopUp(Color.White)
)
)
}
return list
}
/**
* Return the sample bar chart data
* @param duration : Duration of the wave in seconds
* @param sampleRate : Number of samples per second
* @param frequency : Frequency of the wave in Hz
*/
fun getWaveChartData(duration: Double, sampleRate: Double, frequency: Double): List<Point> {
val list = mutableListOf<Point>()
val amplitude = 1.0 // Amplitude of the wave
val numSamples = (duration * sampleRate).toInt() // Total number of samples
for (i in 0 until numSamples) {
val time = i.toDouble() / sampleRate // Time at the current sample
val sample =
amplitude * sin(2.0 * Math.PI * frequency * time) // Calculate the sample value
list.add(Point(time.toFloat(), sample.toFloat()))
}
return list
}
fun getBarChartData(
listSize: Int,
maxRange: Int,
barChartType: BarChartType,
dataCategoryOptions: DataCategoryOptions
): List<BarData> {
val list = arrayListOf<BarData>()
for (index in 0 until listSize) {
val point = when (barChartType) {
BarChartType.VERTICAL -> {
Point(
index.toFloat(),
"%.2f".format(Random.nextDouble(1.0, maxRange.toDouble())).toFloat()
)
}
BarChartType.HORIZONTAL -> {
Point(
"%.2f".format(Random.nextDouble(1.0, maxRange.toDouble())).toFloat(),
index.toFloat()
)
}
}
list.add(
BarData(
point = point,
color = Color(
Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)
),
dataCategoryOptions = dataCategoryOptions,
label = "Bar$index",
)
)
}
return list
}
/**
* Return the sample gradient bar chart data
* @param listSize Size of the list
* @param maxRange Maximum range for the values
*/
fun getGradientBarChartData(listSize: Int, maxRange: Int): List<BarData> {
val list = arrayListOf<BarData>()
for (index in 0 until listSize) {
list.add(
BarData(
point = Point(
index.toFloat(),
"%.2f".format(Random.nextDouble(1.0, maxRange.toDouble())).toFloat()
),
gradientColorList = listOf(
Color(
Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)
),
Color(
Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)
),
Color(
Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)
),
Color(
Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)
)
),
label = "Bar$index"
)
)
}
return list
}
/**
* Returns sample pie chart data
*/
fun getPieChartData(): PieChartData {
return PieChartData(
slices = listOf(
PieChartData.Slice("SciFi", 15f, Color(0xFF333333)),
PieChartData.Slice("Comedy", 15f, Color(0xFF666a86)),
PieChartData.Slice("Drama", 10f, Color(0xFF95B8D1)),
PieChartData.Slice("Romance", 10f, Color(0xFFE8DDB5)),
PieChartData.Slice("Action", 20f, Color(0xFFEDAFB8)),
PieChartData.Slice("Thriller", 100f, Color(0xFFF94892)),
PieChartData.Slice("Western", 10f, Color(0xFFA675A1)),
PieChartData.Slice("Fantasy", 10f, Color(0xFF8F3985)),
),
plotType = PlotType.Pie
)
}
/**
* Returns sample pie chart data
*/
fun getPieChartData2(): PieChartData {
return PieChartData(
slices = listOf(
PieChartData.Slice("Android", 30f, Color(0xFF002B5B)),
PieChartData.Slice("iOS", 30f, Color(0xFF2B4865)),
PieChartData.Slice("Windows", 15f, Color(0xFF256D85)),
PieChartData.Slice("Other", 25f, Color(0xFF806D85)),
),
plotType = PlotType.Pie
)
}
/**
* Returns sample donut chart data
*/
fun getDonutChartData(): PieChartData {
return PieChartData(
slices = listOf(
PieChartData.Slice("HP", 15f, Color(0xFF5F0A87)),
PieChartData.Slice("Dell", 30f, Color(0xFF20BF55)),
PieChartData.Slice("Lenovo", 10f, Color(0xFFA40606)),
PieChartData.Slice("Asus", 15f, Color(0xFFF53844)),
PieChartData.Slice("Acer", 10f, Color(0xFFEC9F05)),
PieChartData.Slice("Apple", 30f, Color(0xFF009FFD)),
),
plotType = PlotType.Donut
)
}
/**
* Returns the sample gradient bar chart data.
* @param listSize Size of the list
* @param maxRange Maximum range for the values
* @param barSize size of bars in one group
*/
fun getGroupBarChartData(listSize: Int, maxRange: Int, barSize: Int): List<GroupBar> {
val list = mutableListOf<GroupBar>()
for (index in 0 until listSize) {
val barList = mutableListOf<BarData>()
for (i in 0 until barSize) {
val barValue = "%.2f".format(Random.nextDouble(1.0, maxRange.toDouble())).toFloat()
barList.add(
BarData(
Point(
index.toFloat(),
barValue
),
label = "B$i",
description = "Bar at $index with label B$i has value ${
String.format(
"%.2f", barValue
)
}"
)
)
}
list.add(GroupBar(index.toString(), barList))
}
return list
}
/**
* Returns the sample stackLabelList data
* @param colorPaletteList color list for each legend
*/
fun getLegendsLabelData(colorPaletteList: List<Color>): List<LegendLabel> {
val legendLabelList = mutableListOf<LegendLabel>()
for (index in colorPaletteList.indices) {
legendLabelList.add(
LegendLabel(
colorPaletteList[index],
"B$index"
)
)
}
return legendLabelList
}
/**
* Returns the sample colors list for given size
* @param listSize: Size of the colors list.
*/
fun getColorPaletteList(listSize: Int): List<Color> {
val colorList = mutableListOf<Color>()
for (index in 0 until listSize) {
colorList.add(
Color(
(0 until 256).random(), (0 until 256).random(), (0 until 256).random()
)
)
}
return colorList
}
/**
* Returns the legends config for given pie chart data
* @param pieChartData: Pie chart details.
* @param gridSize: Legends grid size.
*/
fun getLegendsConfigFromPieChartData(pieChartData: PieChartData, gridSize: Int): LegendsConfig {
val legendsList = mutableListOf<LegendLabel>()
pieChartData.slices.forEach { slice ->
legendsList.add(LegendLabel(slice.color, slice.label))
}
return LegendsConfig(
legendLabelList = legendsList,
gridColumnCount = gridSize,
legendsArrangement = Arrangement.Start,
textStyle = TextStyle()
)
}
}
@@ -0,0 +1,114 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.barchart
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.BarInfo
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.ui.barchart.models.BarChartData
import co.yml.charts.ui.barchart.models.BarChartType
import kotlinx.coroutines.launch
/**
*
* [BarChart] compose method for drawing bar chart.
* @param modifier: All modifier related properties
* @param barChartData : All data needed to Bar Chart
* @see [BarChartData] Data class to save all params related to Bar Chart
*/
@Composable
fun BarChart(modifier: Modifier, barChartData: BarChartData) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& barChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(modifier) {
when (barChartData.barChartType) {
BarChartType.HORIZONTAL -> HorizontalBarChart(
barChartData = barChartData,
modifier = modifier,
scope = scope,
accessibilitySheetState = accessibilitySheetState,
isTalkBackEnabled = isTalkBackEnabled
)
BarChartType.VERTICAL -> VerticalBarChart(
barChartData = barChartData,
modifier = modifier,
scope = scope,
accessibilitySheetState = accessibilitySheetState,
isTalkBackEnabled = isTalkBackEnabled
)
}
if (isTalkBackEnabled) {
with(barChartData) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(),
backgroundColor = Color.White,
content = {
LazyColumn {
items(chartData.size) { index ->
Column {
BarInfo(
if (barChartType == BarChartType.VERTICAL) {
xAxisData.axisLabelDescription(
xAxisData.labelData(
index
)
)
} else {
yAxisData.axisLabelDescription(
yAxisData.labelData(
index
)
)
},
chartData[index].description,
chartData[index].color,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
@@ -0,0 +1,326 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.barchart
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.GroupBarInfo
import co.yml.charts.common.extensions.RowClip
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.common.extensions.getMaxElementInYAxis
import co.yml.charts.common.extensions.isTapped
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.ChartConstants.DEFAULT_YAXIS_BOTTOM_PADDING
import co.yml.charts.ui.barchart.models.BarData
import co.yml.charts.ui.barchart.models.GroupBarChartData
import kotlinx.coroutines.launch
/**
*
* [GroupBarChart] compose method for drawing group bar chart.
* @param modifier: All modifier related properties
* @param groupBarChartData : All data needed to group bar chart
* @see [GroupBarChartData] Data class to save all params related to Bar Chart
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun GroupBarChart(modifier: Modifier, groupBarChartData: GroupBarChartData) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& groupBarChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(modifier.fillMaxSize()) {
with(groupBarChartData.barPlotData) {
var visibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(BarData(Point(0f, 0f))) }
var xOffset by remember { mutableStateOf(0f) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var isTapped by remember { mutableStateOf(false) }
var columnWidth by remember { mutableStateOf(0f) }
var horizontalGap by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
val paddingRight = groupBarChartData.paddingEnd
val valueList = groupBarList.map { it.yMax }
val bgColor = MaterialTheme.colorScheme.surface
val xMax = groupBarList.size
val yMax = valueList.maxOrNull() ?: 0f
val xAxisData =
groupBarChartData.xAxisData.copy(
axisStepSize = ((barStyle.barWidth * groupingSize) + barStyle.paddingBetweenBars),
shouldDrawAxisLineTillEnd = true,
steps = groupBarList.size - 1
)
val yAxisData =
groupBarChartData.yAxisData.copy(axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() })
val maxElementInYAxis = getMaxElementInYAxis(yMax, yAxisData.steps)
if (!groupBarChartData.showXAxis) {
rowHeight = LocalDensity.current.run { DEFAULT_YAXIS_BOTTOM_PADDING.dp.toPx() }
}
ScrollableCanvasContainer(modifier = modifier
.semantics {
contentDescription = groupBarChartData.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
containerBackgroundColor = groupBarChartData.backgroundColor,
calculateMaxDistance = { xZoom ->
horizontalGap = groupBarChartData.horizontalExtraSpace.toPx()
val xLeft = columnWidth + horizontalGap
xOffset =
((barStyle.barWidth.toPx() * groupingSize) + barStyle.paddingBetweenBars.toPx()) * xZoom
getMaxScrollDistance(
columnWidth,
xMax.toFloat(),
0f,
xOffset,
xLeft,
paddingRight.toPx(),
size.width
)
},
onDraw = { scrollOffset, xZoom ->
val yBottom = size.height - rowHeight
val yOffset = ((yBottom - yAxisData.axisTopPadding.toPx()) / maxElementInYAxis)
xOffset =
((barStyle.barWidth.toPx() * groupingSize) + barStyle.paddingBetweenBars.toPx()) * xZoom
val xLeft = columnWidth + horizontalGap
val dragLocks = mutableMapOf<Int, Pair<BarData, Offset>>()
// Draw bar lines
groupBarList.forEachIndexed { index, groupBarData ->
var insideOffset = 0f
groupBarData.barList.forEachIndexed { subIndex, individualBar ->
val drawOffset = getGroupBarDrawOffset(
x = index,
y = individualBar.point.y,
xOffset = xOffset,
xLeft = xLeft,
scrollOffset = scrollOffset,
yBottom = yBottom,
yOffset = yOffset,
yMin = 0f,
xMin = 0f,
startDrawPadding = xAxisData.startDrawPadding.toPx(),
zoomScale = xZoom,
barWidth = barStyle.barWidth.toPx()
)
val height = yBottom - drawOffset.y
val individualOffset = Offset(drawOffset.x + insideOffset, drawOffset.y)
// drawing each individual bars
groupBarChartData.drawBar(
this,
groupBarChartData,
barStyle,
individualOffset,
height,
subIndex
)
insideOffset += barStyle.barWidth.toPx()
val middleOffset = Offset(
individualOffset.x + barStyle.barWidth.toPx() / 2, drawOffset.y
)
// store the tap points for selection
if (isTapped && middleOffset.isTapped(
tapOffset,
barStyle.barWidth.toPx(),
yBottom,
groupBarChartData.tapPadding.toPx()
)
) {
dragLocks[0] = individualBar to individualOffset
}
}
if (groupBarChartData.groupSeparatorConfig.showSeparator && index != groupBarList.size - 1) {
// drawing each Group Separator bars
val yOffset2 = (yBottom - yAxisData.axisTopPadding.toPx())
val height = yBottom - yAxisData.axisTopPadding.toPx()
val drawOffset2 = getGroupBarDrawOffset(
x = index,
y = rowHeight,
xOffset = xOffset,
xLeft = xLeft,
scrollOffset = scrollOffset,
yBottom = yBottom,
yOffset = yOffset2,
yMin = 0f,
xMin = 0f,
startDrawPadding = xAxisData.startDrawPadding.toPx(),
zoomScale = xZoom,
barStyle.barWidth.toPx()
)
val xOffset2 =
(drawOffset2.x + insideOffset + (barStyle.paddingBetweenBars.toPx() / 2) - groupBarChartData.groupSeparatorConfig.separatorWidth.toPx() / 2)
val individualOffset = Offset(xOffset2, yAxisData.axisTopPadding.toPx())
drawGroupSeparator(
individualOffset,
height,
groupBarChartData.groupSeparatorConfig.separatorWidth.toPx(),
groupBarChartData.groupSeparatorConfig.separatorColor,
groupBarChartData
)
}
}
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
if (barStyle.selectionHighlightData != null) {
// highlighting the selected bar and showing the data points
identifiedPoint = highlightGroupBar(
dragLocks,
visibility,
identifiedPoint,
barStyle.selectionHighlightData,
isTapped,
columnWidth,
yBottom,
paddingRight,
yOffset,
barStyle.barWidth
)
}
},
drawXAndYAxis = { scrollOffset, xZoom ->
val points = mutableListOf<Point>()
for (index in groupBarList.indices) {
points.add(Point(index.toFloat(), 0f))
}
if (groupBarChartData.showXAxis) {
XAxis(
xAxisData = xAxisData,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.wrapContentHeight()
.clip(
RowClip(
columnWidth, paddingRight
)
)
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
},
xStart = columnWidth + horizontalGap + LocalDensity.current.run {
(barStyle.barWidth.toPx() * groupingSize) / 2
},
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = points,
axisStart = columnWidth
)
}
if (groupBarChartData.showYAxis) {
YAxis(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxHeight()
.wrapContentWidth()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
},
yAxisData = yAxisData,
)
}
},
onPointClicked = { offset: Offset, _: Float ->
isTapped = true
visibility = true
tapOffset = offset
},
onScroll = {
isTapped = false
visibility = false
})
}
if (isTalkBackEnabled) {
with(groupBarChartData) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(), backgroundColor = Color.White, content = {
LazyColumn {
items(barPlotData.groupBarList.size) { index ->
Column {
GroupBarInfo(
barPlotData.groupBarList[index],
xAxisData.axisLabelDescription(
xAxisData.labelData(index)
),
barPlotData.barColorPaletteList,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
@@ -0,0 +1,255 @@
package co.yml.charts.ui.barchart
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.DataCategoryOptions
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.extensions.ColumnClip
import co.yml.charts.common.extensions.getMaxElementInXAxis
import co.yml.charts.common.extensions.getXMaxAndMinPoints
import co.yml.charts.common.extensions.getYMaxAndMinPoints
import co.yml.charts.common.extensions.isYAxisTapped
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.ChartConstants
import co.yml.charts.ui.barchart.models.BarChartData
import co.yml.charts.ui.barchart.models.BarData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* compose method for drawing Horizontal bar chart
*
* @param barChartData
* @param modifier
* @param scope
* @param accessibilitySheetState
* @param isTalkBackEnabled
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun HorizontalBarChart(
barChartData: BarChartData,
modifier: Modifier,
scope: CoroutineScope,
accessibilitySheetState: ModalBottomSheetState,
isTalkBackEnabled: Boolean
) {
with(barChartData) {
var barHighlightVisibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(BarData(Point(0f, 0f))) }
var xOffset by remember { mutableStateOf(0f) }
var yOffset by remember { mutableStateOf(0f) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var isTapped by remember { mutableStateOf(false) }
var columnWidth by remember { mutableStateOf(0f) }
var horizontalGap by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
val points = chartData.map { it.point }
val bgColor = MaterialTheme.colorScheme.surface
val (_, xMax) = getXMaxAndMinPoints(points)
val (yMin, yMax) = getYMaxAndMinPoints(points)
val xAxisData = xAxisData.copy(
dataCategoryOptions = DataCategoryOptions(isDataCategoryInYAxis = true)
)
val yAxisData = yAxisData.copy(
axisStepSize = barStyle.barWidth + barStyle.paddingBetweenBars,
steps = chartData.size - 1,
axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() })
val maxElementInXAxis = getMaxElementInXAxis(xMax, xAxisData.steps)
if (!showXAxis) {
rowHeight =
LocalDensity.current.run { ChartConstants.DEFAULT_YAXIS_BOTTOM_PADDING.dp.toPx() }
}
ScrollableCanvasContainer(modifier = modifier
.semantics {
contentDescription = barChartData.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
scrollOrientation = Orientation.Vertical,
containerBackgroundColor = backgroundColor,
calculateMaxDistance = { yZoom ->
horizontalGap = horizontalExtraSpace.toPx()
val yStart = horizontalGap + (yAxisData.startDrawPadding.toPx() * yZoom)
yOffset =
(barStyle.barWidth.toPx() + barStyle.paddingBetweenBars.toPx()) * yZoom
getMaxScrollDistance(
rowHeight,
yMax,
yMin,
yOffset,
yStart,
yAxisData.axisTopPadding.toPx(),
size.height
)
},
onDraw = { scrollOffset, yZoom ->
val xLeft = columnWidth
val startPaddingFromBottom =
(yAxisData.startDrawPadding.toPx() * yZoom) + barStyle.barWidth.toPx() / 2
val yStart =
if (yAxisData.dataCategoryOptions.isDataCategoryStartFromBottom) startPaddingFromBottom else {
if (yZoom < 1) startPaddingFromBottom else yAxisData.axisTopPadding.toPx()
}
val yBottom = size.height - rowHeight
yOffset = (barStyle.barWidth.toPx() + barStyle.paddingBetweenBars.toPx()) * yZoom
xOffset = (size.width - xLeft - xAxisData.axisEndPadding.toPx()) / maxElementInXAxis
val dragLocks = mutableMapOf<Int, Pair<BarData, Offset>>()
// Draw bar lines
chartData.forEachIndexed { _, barData ->
val drawOffset = getDrawHorizontalOffset(
barData.point,
xLeft,
scrollOffset,
yBottom,
yOffset,
yMin,
yMax,
yStart,
yAxisData.dataCategoryOptions,
yZoom
)
val width = (barData.point.x) * xOffset
// drawing each individual bars
barChartData.drawBar(this, barData, drawOffset, width, barChartType, barStyle)
val middleOffset =
Offset(drawOffset.x, drawOffset.y + barStyle.barWidth.toPx() / 2)
val xAxisWidth =
xLeft + ((barData.point.x - 0) * xOffset) + barStyle.barWidth.toPx()
// store the tap points for selection
if (isTapped && middleOffset.isYAxisTapped(
tapOffset,
barStyle.barWidth.toPx(),
xLeft,
tapPadding.toPx(),
xAxisWidth
)
) {
dragLocks[0] = barData to drawOffset
}
}
drawUnderXAxisScrollMask(rowHeight, yAxisData.axisTopPadding, bgColor)
if (barStyle.selectionHighlightData != null) {
// highlighting the selected bar and showing the data points
identifiedPoint = highlightHorizontalBar(
dragLocks,
barHighlightVisibility,
identifiedPoint,
barChartData.barStyle,
isTapped,
yBottom,
yAxisData.axisTopPadding,
xLeft,
xOffset
)
}
},
drawXAndYAxis = { scrollOffset, xZoom ->
if (showXAxis) {
XAxis(
xAxisData = xAxisData,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.wrapContentHeight()
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
},
xStart = columnWidth,
scrollOffset = 0f,
zoomScale = xZoom,
chartData = points,
axisStart = columnWidth
)
}
if (showYAxis) {
val startPaddingFromBottom = LocalDensity.current.run {
(yAxisData.startDrawPadding.toPx() * xZoom)
}
YAxis(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxHeight()
.wrapContentWidth()
.clip(
ColumnClip(
0f,
LocalDensity.current.run { yAxisData.axisTopPadding.toPx() },
columnWidth,
rowHeight
)
)
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
},
yAxisData = yAxisData,
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = points,
dataCategoryWidth = LocalDensity.current.run { yAxisData.axisStepSize.toPx() },
yStart =
if (yAxisData.dataCategoryOptions.isDataCategoryStartFromBottom) {
startPaddingFromBottom
} else {
if (xZoom < 1) {
startPaddingFromBottom
} else {
LocalDensity.current.run {
(yAxisData.axisTopPadding.toPx() + barStyle.barWidth.toPx() / 2)
}
}
}, LocalDensity.current.run { barStyle.barWidth.toPx() }
)
}
}, onPointClicked = { offset: Offset, _: Float ->
isTapped = true
barHighlightVisibility = true
tapOffset = offset
}, onScroll = {
isTapped = false
barHighlightVisibility = false
})
}
}
@@ -0,0 +1,334 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.barchart
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.GroupBarInfo
import co.yml.charts.common.extensions.RowClip
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.common.extensions.getMaxElementInYAxis
import co.yml.charts.common.extensions.isStackedBarTapped
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.ChartConstants
import co.yml.charts.ui.barchart.models.BarData
import co.yml.charts.ui.barchart.models.GroupBarChartData
import kotlinx.coroutines.launch
/**
*
* [StackedBarChart] compose method for drawing stacked bar chart.
* @param modifier: All modifier related properties
* @param groupBarChartData : All data needed to stacked bar chart
* @see [GroupBarChartData] Data class to save all params related to stacked bar chart
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun StackedBarChart(
modifier: Modifier,
groupBarChartData: GroupBarChartData,
isTalkBackEnabled: Boolean = LocalContext.current.collectIsTalkbackEnabledAsState().value
) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& groupBarChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(
modifier
.fillMaxSize()
.testTag("stacked_bar_chart")) {
with(groupBarChartData.barPlotData) {
var visibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(BarData(Point(0f, 0f))) }
var xOffset by remember { mutableStateOf(0f) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var isTapped by remember { mutableStateOf(false) }
var columnWidth by remember { mutableStateOf(0f) }
var horizontalGap by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
val paddingRight = groupBarChartData.paddingEnd
val valueList = mutableListOf<Float>()
groupBarList.map { groupBar ->
var yMax = 0f
groupBar.barList.forEach {
yMax += it.point.y
}
valueList.add(yMax)
}
val bgColor = MaterialTheme.colorScheme.surface
val xMax = groupBarList.size
val yMax = valueList.maxOrNull() ?: 0f
val xAxisData =
groupBarChartData.xAxisData.copy(axisStepSize = barStyle.barWidth + barStyle.paddingBetweenBars)
val yAxisData =
groupBarChartData.yAxisData.copy(axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() })
val maxElementInYAxis = getMaxElementInYAxis(yMax, yAxisData.steps)
val paddingBetweenBars =
LocalDensity.current.run { groupBarChartData.paddingBetweenStackedBars.toPx() }
if (!groupBarChartData.showXAxis) {
rowHeight =
LocalDensity.current.run { ChartConstants.DEFAULT_YAXIS_BOTTOM_PADDING.dp.toPx() }
}
ScrollableCanvasContainer(
modifier = modifier
.semantics {
contentDescription = groupBarChartData.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
containerBackgroundColor = groupBarChartData.backgroundColor,
calculateMaxDistance = { xZoom ->
horizontalGap = groupBarChartData.horizontalExtraSpace.toPx()
val xLeft = (xAxisData.startDrawPadding.toPx() * xZoom) + horizontalGap
xOffset =
(barStyle.barWidth.toPx() + barStyle.paddingBetweenBars.toPx()) * xZoom
getMaxScrollDistance(
columnWidth = columnWidth,
xMax = xMax.toFloat(),
xMin = 0f,
xOffset = xOffset,
xLeft = xLeft,
paddingRight = paddingRight.toPx(),
canvasWidth = size.width
)
},
onDraw = { scrollOffset, xZoom ->
val isHighlightFullBar =
barStyle.selectionHighlightData?.isHighlightFullBar ?: false
val yBottom = size.height - rowHeight
val totalPaddingBtwBars =
(groupBarList.first().barList.size - 1) * paddingBetweenBars
val yOffset =
(yBottom - yAxisData.axisTopPadding.toPx() - totalPaddingBtwBars) / maxElementInYAxis
xOffset =
(barStyle.barWidth.toPx() + barStyle.paddingBetweenBars.toPx()) * xZoom
val xLeft = columnWidth
val dragLocks = mutableMapOf<Int, Pair<BarData, Offset>>()
// Draw bar lines
groupBarList.forEachIndexed { index, groupBarData ->
var insideOffset = 0f
val xPointOffset =
groupBarData.barList.first().point.x * xOffset + xLeft + (xAxisData.startDrawPadding.toPx() * xZoom) - barStyle.barWidth.toPx() / 2 - scrollOffset
val fullBarDetails = getFullBarDetails(
groupBarData.barList,
totalPaddingBtwBars,
yOffset,
yBottom,
xPointOffset
)
groupBarData.barList.forEachIndexed { subIndex, individualBar ->
val drawOffset = getGroupBarDrawOffset(
x = index,
y = individualBar.point.y,
xOffset = xOffset,
xLeft = xLeft,
scrollOffset = scrollOffset,
yBottom = yBottom,
yOffset = yOffset,
yMin = 0f,
xMin = 0f,
startDrawPadding = xAxisData.startDrawPadding.toPx(),
zoomScale = xZoom,
barWidth = barStyle.barWidth.toPx()
)
val height = yBottom - drawOffset.y
val individualOffset = Offset(drawOffset.x, drawOffset.y - insideOffset)
// drawing each individual bars
groupBarChartData.drawBar(
this,
groupBarChartData,
barStyle,
individualOffset,
height,
subIndex
)
insideOffset += height + paddingBetweenBars
val middleOffset =
Offset(
drawOffset.x + barStyle.barWidth.toPx() / 2,
individualOffset.y
)
if (isTapped && middleOffset.isStackedBarTapped(
tapOffset = tapOffset,
barWidth = barStyle.barWidth.toPx(),
barHeight = if (isHighlightFullBar) yBottom else individualOffset.y + height,
tapPadding = groupBarChartData.tapPadding.toPx()
)
) {
if (isHighlightFullBar) {
dragLocks[0] = fullBarDetails.first to fullBarDetails.second
} else {
dragLocks[0] = individualBar to individualOffset
}
}
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
if (barStyle.selectionHighlightData != null) {
// highlighting the selected bar and showing the data points
identifiedPoint = highlightGroupBar(
dragLocks = dragLocks,
visibility = visibility,
identifiedPoint = identifiedPoint,
selectionHighlightData = barStyle.selectionHighlightData,
isDragging = isTapped,
columnWidth = columnWidth,
yBottom = yBottom,
paddingRight = paddingRight,
yOffset = yOffset,
barWidth = barStyle.barWidth,
totalPaddingBtwBars,
isHighlightFullBar
)
}
}
}
},
drawXAndYAxis = { scrollOffset, xZoom ->
val points = mutableListOf<Point>()
for (index in groupBarList.indices) {
points.add(Point(index.toFloat(), 0f))
}
if (groupBarChartData.showXAxis) {
XAxis(
xAxisData = xAxisData,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.wrapContentHeight()
.clip(
RowClip(
columnWidth, paddingRight
)
)
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
},
xStart = columnWidth,
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = points,
axisStart = columnWidth
)
}
if (groupBarChartData.showYAxis) {
YAxis(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxHeight()
.wrapContentWidth()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
},
yAxisData = yAxisData,
)
}
},
onPointClicked = { offset: Offset, _: Float ->
isTapped = true
visibility = true
tapOffset = offset
},
onScroll = {
isTapped = false
visibility = false
}
)
}
if (isTalkBackEnabled) {
with(groupBarChartData) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(), backgroundColor = Color.White, content = {
LazyColumn(modifier = Modifier.semantics {
this.testTag = "AccessibilityBottomSheet List"
}) {
items(barPlotData.groupBarList.size) { index ->
Column {
GroupBarInfo(
barPlotData.groupBarList[index],
xAxisData.axisLabelDescription(
xAxisData.labelData(index)
),
barPlotData.barColorPaletteList,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
@@ -0,0 +1,219 @@
package co.yml.charts.ui.barchart
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.extensions.RowClip
import co.yml.charts.common.extensions.getMaxElementInYAxis
import co.yml.charts.common.extensions.getXMaxAndMinPoints
import co.yml.charts.common.extensions.getYMaxAndMinPoints
import co.yml.charts.common.extensions.isTapped
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.ChartConstants
import co.yml.charts.ui.barchart.models.BarChartData
import co.yml.charts.ui.barchart.models.BarData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* composable for drawing Vertical bar chart
*
* @param barChartData
* @param modifier
* @param scope
* @param accessibilitySheetState
* @param isTalkBackEnabled
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun VerticalBarChart(
barChartData: BarChartData,
modifier: Modifier,
scope: CoroutineScope,
accessibilitySheetState: ModalBottomSheetState,
isTalkBackEnabled: Boolean
) {
with(barChartData) {
var barHighlightVisibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(BarData(Point(0f, 0f))) }
var xOffset by remember { mutableStateOf(0f) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var isTapped by remember { mutableStateOf(false) }
var columnWidth by remember { mutableStateOf(0f) }
var horizontalGap by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
val paddingRight = paddingEnd
val points = chartData.map { it.point }
val bgColor = MaterialTheme.colorScheme.surface
val (xMin, xMax) = getXMaxAndMinPoints(points)
val (_, yMax) = getYMaxAndMinPoints(points)
val xAxisData =
xAxisData.copy(
axisStepSize = barStyle.barWidth + barStyle.paddingBetweenBars,
steps = chartData.size - 1
)
val yAxisData =
yAxisData.copy(
axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() }
)
val maxElementInYAxis = getMaxElementInYAxis(yMax, yAxisData.steps)
if (!showXAxis) {
rowHeight = LocalDensity.current.run { ChartConstants.DEFAULT_YAXIS_BOTTOM_PADDING.dp.toPx() }
}
ScrollableCanvasContainer(modifier = modifier
.semantics {
contentDescription = barChartData.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
containerBackgroundColor = backgroundColor,
calculateMaxDistance = { xZoom ->
horizontalGap = horizontalExtraSpace.toPx()
val xLeft = (xAxisData.startDrawPadding.toPx() * xZoom) + horizontalGap
xOffset =
(barStyle.barWidth.toPx() + barStyle.paddingBetweenBars.toPx()) * xZoom
getMaxScrollDistance(
columnWidth, xMax, xMin, xOffset, xLeft, paddingRight.toPx(), size.width
)
},
onDraw = { scrollOffset, xZoom ->
val yBottom = size.height - rowHeight
val yOffset = ((yBottom - yAxisData.axisTopPadding.toPx()) / maxElementInYAxis)
xOffset =
((barStyle.barWidth).toPx() + barStyle.paddingBetweenBars.toPx()) * xZoom
val xLeft = columnWidth
val dragLocks = mutableMapOf<Int, Pair<BarData, Offset>>()
// Draw bar lines
chartData.forEachIndexed { _, barData ->
val drawOffset = getDrawOffset(
barData.point,
xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yOffset,
0f,
xAxisData.startDrawPadding.toPx(),
xZoom,
barStyle.barWidth.toPx()
)
val height = yBottom - drawOffset.y
// drawing each individual bars
barChartData.drawBar(this, barData, drawOffset, height, barChartType, barStyle)
val middleOffset =
Offset(drawOffset.x + barStyle.barWidth.toPx() / 2, drawOffset.y)
// store the tap points for selection
if (isTapped && middleOffset.isTapped(
tapOffset, barStyle.barWidth.toPx(), yBottom, tapPadding.toPx()
)
) {
dragLocks[0] = barData to drawOffset
}
}
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
if (barStyle.selectionHighlightData != null) {
// highlighting the selected bar and showing the data points
identifiedPoint = highlightVerticalBar(
dragLocks,
barHighlightVisibility,
identifiedPoint,
barChartData.barStyle,
isTapped,
columnWidth,
yBottom,
paddingRight,
yOffset,
true,
0f,
barChartType
)
}
},
drawXAndYAxis = { scrollOffset, xZoom ->
if (showXAxis) {
XAxis(
xAxisData = xAxisData,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.wrapContentHeight()
.clip(
RowClip(
columnWidth, paddingRight
)
)
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
},
xStart = columnWidth,
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = points,
axisStart = columnWidth
)
}
if (showYAxis) {
YAxis(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxHeight()
.wrapContentWidth()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
},
yAxisData = yAxisData,
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = points
)
}
}, onPointClicked = { offset: Offset, _: Float ->
isTapped = true
barHighlightVisibility = true
tapOffset = offset
}, onScroll = {
isTapped = false
barHighlightVisibility = false
})
}
}
@@ -0,0 +1,127 @@
package co.yml.charts.ui.barchart.models
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.model.AccessibilityConfig
/**
* BarGraph data class params used in drawing bar graph.
* @param chartData : List of BarData
* @param xAxisData: All the configurations related to X-Axis to be defined here in [AxisData]
* @param yAxisData: All the configurations related to Y-Axis to be defined here in [AxisData]
* @param horizontalExtraSpace: Extra space added in the horizontal axis
* @param paddingEnd: End Padding
* @param paddingTop: Top Padding
* @param tapPadding: Extra padding area for tapping
* @param showXAxis: Boolean Flag to enable/disable X axis
* @param showYAxis: Boolean Flag to enable/disable Y axis
* @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig]
*/
data class BarChartData(
val chartData: List<BarData>,
val xAxisData: AxisData = AxisData.Builder().build(),
val yAxisData: AxisData = AxisData.Builder().build(),
val backgroundColor: Color = Color.White,
val horizontalExtraSpace: Dp = 0.dp,
val barStyle: BarStyle = BarStyle(),
val paddingEnd: Dp = 10.dp,
val paddingTop: Dp = 0.dp,
val tapPadding: Dp = 10.dp,
val showYAxis: Boolean = true,
val showXAxis: Boolean = true,
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig(),
val barChartType: BarChartType = BarChartType.VERTICAL,
val drawBar: (DrawScope, BarData, Offset, Float, BarChartType, BarStyle) -> Unit = { drawScope, barChartData, drawOffset, height, barChartType, barStyle ->
//default implementation
drawBarGraph(drawScope, barChartData, drawOffset, height, barChartType, barStyle)
}
)
/**
* BarStyle data class adds styling related to each bar in the bar graph
* @param barWidth: Width of a bar
* @param cornerRadius: Corner radius for the bars
* @param paddingBetweenBars: Space between adjacent bars
* @param isGradientEnabled: Boolean Flag to enable/disable gradient bars
* @param barBlendMode: Blend mode for the bars
* @param barDrawStyle: Draw style for the bars
* @param drawBar : Draw an individual bar
*/
data class BarStyle(
val barWidth: Dp = 30.dp,
val cornerRadius: Dp = 4.dp,
val paddingBetweenBars: Dp = 15.dp,
val isGradientEnabled: Boolean = false,
val barBlendMode: BlendMode = DrawScope.DefaultBlendMode,
val barDrawStyle: DrawStyle = Fill,
val selectionHighlightData: SelectionHighlightData? = SelectionHighlightData()
)
/**
*
* Used to draw the individual bars
* @param drawScope : Creates a scoped drawing environment
* @param barStyle : all meta data related to the bar styling
* @param barData : data related to a single bar
* @param drawOffset: top left offset for the drawing the bar
* @param height : height of the bar graph
* @param barChartType : type of bar chart
*/
fun drawBarGraph(
drawScope: DrawScope,
barData: BarData,
drawOffset: Offset,
height: Float,
barChartType: BarChartType,
barStyle: BarStyle
) {
with(drawScope) {
with(barStyle) {
// Draw bar lines
if (isGradientEnabled) {
val brush = Brush.verticalGradient(
colors = barData.gradientColorList
)
drawRoundRect(
brush = brush,
topLeft = drawOffset,
size = if (barChartType == BarChartType.VERTICAL) Size(
barWidth.toPx(),
height
) else Size(height, barWidth.toPx()),
cornerRadius = CornerRadius(
cornerRadius.toPx(), cornerRadius.toPx()
),
style = barDrawStyle,
blendMode = barBlendMode
)
} else {
drawRoundRect(
color = barData.color,
topLeft = drawOffset,
size = if (barChartType == BarChartType.VERTICAL) Size(
barWidth.toPx(),
height
) else Size(height, barWidth.toPx()),
cornerRadius = CornerRadius(
cornerRadius.toPx(), cornerRadius.toPx()
),
style = barDrawStyle,
blendMode = barBlendMode
)
}
}
}
}
@@ -0,0 +1,8 @@
package co.yml.charts.ui.barchart.models
/**
* To specify barchart type
*/
enum class BarChartType {
VERTICAL, HORIZONTAL
}
@@ -0,0 +1,28 @@
package co.yml.charts.ui.barchart.models
import androidx.compose.ui.graphics.Color
import co.yml.charts.axis.DataCategoryOptions
import co.yml.charts.common.model.Point
/**
* Data class for individual bar.
* @param point : Axis point
* @param color: Color of a bar
* @param label: label of a bar
* @param gradientColorList: Color list for the gradient bar
* @param description: Description to describe bar value for accessibility service.
*/
data class BarData(
val point: Point,
val color: Color = Color.Red,
val label: String = "",
val gradientColorList: List<Color> = listOf(Color.Red, Color.Blue),
val dataCategoryOptions: DataCategoryOptions = DataCategoryOptions(),
val description: String = if (dataCategoryOptions.isDataCategoryInYAxis) "Value of bar $label is value ${
String.format(
"%.2f",
point.x
)
}" else "Value of bar $label is value ${String.format("%.2f", point.y)}"
)
@@ -0,0 +1,23 @@
package co.yml.charts.ui.barchart.models
import androidx.compose.ui.graphics.Color
import co.yml.charts.common.model.PlotData
import co.yml.charts.common.model.PlotType
/**
* BarPlotData is a data class that holds bar graph related data and styling components
* @param plotType : Defines the type of plot/graph
* @param groupBarList : Data related to the bar point.
* @param barStyle : Styling related to the bars.
*/
data class BarPlotData(
override val plotType: PlotType = PlotType.Bar,
val groupBarList: List<GroupBar>,
val groupingSize: Int = groupBarList.firstOrNull()?.barList?.size ?: 1,
val barColorPaletteList: List<Color> = listOf(),
val barStyle: BarStyle = BarStyle(),
) : PlotData {
companion object {
fun default() = BarPlotData(groupBarList = listOf())
}
}
@@ -0,0 +1,11 @@
package co.yml.charts.ui.barchart.models
/**
* Data class for individual group bar.
* @param label: Label of a bar
* @param barList: List of individual bars inside a group bar with each bar data of type [BarData]
*/
data class GroupBar(val label: String, val barList: List<BarData>) {
val yMax: Float
get() = this.barList.map { it.point }.maxOf { it.y }
}
@@ -0,0 +1,88 @@
package co.yml.charts.ui.barchart.models
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.model.AccessibilityConfig
/**
* GroupBarGraph data class params used in drawing bar graph.
* @param barPlotData : Group bar plot data.
* @param xAxisData: All the configurations related to X-Axis to be defined here in [AxisData]
* @param yAxisData: All the configurations related to Y-Axis to be defined here in [AxisData]
* @param horizontalExtraSpace: Extra space added in the horizontal axis.
* @param backgroundColor: Background color of the graph.
* @param paddingEnd: End Padding.
* @param paddingTop: Top Padding.
* @param showXAxis: Boolean Flag to enable/disable X axis.
* @param showYAxis: Boolean Flag to enable/disable Y axis.
* @param tapPadding: Tap padding offset.
* @param groupSeparatorConfig : All config related to the GroupSeparator.
* @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig]
*/
data class GroupBarChartData(
val barPlotData: BarPlotData,
val xAxisData: AxisData = AxisData.Builder().build(),
val yAxisData: AxisData = AxisData.Builder().build(),
val backgroundColor: Color = Color.White,
val horizontalExtraSpace: Dp = 10.dp,
val paddingEnd: Dp = 10.dp,
val paddingTop: Dp = 0.dp,
val showYAxis: Boolean = true,
val showXAxis: Boolean = true,
val tapPadding: Dp = 10.dp,
val groupSeparatorConfig: GroupSeparatorConfig = GroupSeparatorConfig(),
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig(),
val paddingBetweenStackedBars: Dp = 0.dp,
val drawBar: (DrawScope, GroupBarChartData, BarStyle, Offset, Float, Int) -> Unit = { drawScope, barChartData, barStyle, drawOffset, height, barIndex ->
//default implementation
drawGroupBarGraph(
drawScope,
barChartData,
barStyle,
drawOffset,
height,
barPlotData.barColorPaletteList[barIndex],
barIndex
)
}
)
/**
*
* Used to draw the individual bar[Used round rec as a default shape]
* @param drawScope : Creates a scoped drawing environment
* @param barGraphData : all meta data related to the bar graph
* @param barStyle : all meta data related to the bar styling
* @param drawOffset: topLeft offset for the drawing the bar
* @param height : height of the bar graph
* @param barIndex : Index of the bar
*/
fun drawGroupBarGraph(
drawScope: DrawScope,
barGraphData: GroupBarChartData,
barStyle: BarStyle,
drawOffset: Offset,
height: Float,
barColor: Color,
barIndex: Int
) {
with(drawScope) {
drawRoundRect(
color = barColor,
topLeft = drawOffset,
size = Size(barStyle.barWidth.toPx(), height),
cornerRadius = CornerRadius(
barStyle.cornerRadius.toPx(), barStyle.cornerRadius.toPx()
),
style = barStyle.barDrawStyle,
blendMode = barStyle.barBlendMode
)
}
}
@@ -0,0 +1,21 @@
package co.yml.charts.ui.barchart.models
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* GroupSeparatorConfig data class params used in config groupSeparator.
* @param separatorWidth : width of the groupSeparator
* @param separatorColor : color of the groupSeparator
* @param showSeparator : Boolean Flag to show/hide groupSeparator
* @param separatorBlendMode: Blend mode for the groupSeparator
* */
data class GroupSeparatorConfig(
val separatorWidth: Dp = 1.dp,
val separatorColor: Color = Color.Gray,
val showSeparator: Boolean = true,
val separatorBlendMode: BlendMode = DrawScope.DefaultBlendMode,
)
@@ -0,0 +1,177 @@
package co.yml.charts.ui.barchart.models
import android.graphics.Paint
import android.graphics.Typeface
import android.text.TextPaint
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.common.extensions.getTextBackgroundRect
/**
* Used to customise the highlighted text and the bar
*
* @param highlightTextOffset: Padding between the highlighted bar and the text
* @param highlightTextSize: Text size of the highlighted bar text
* @param highlightTextColor: Text color of the highlighted bar text
* @param highlightTextBackgroundColor: Background color of the highlight background
* @param highlightTextBackgroundAlpha: Alpha for the highlighted text background
* @param highlightTextTypeface: Typeface of the highlighted bar text
* @param highlightBarCornerRadius: Corner radius of the highlighted bar
* @param highlightBarColor: Color of the highlighted bar
* @param highlightBarStrokeWidth: Stroke width of the highlighted bar
* @param highlightPopUpCornerRadius: Corner radius of the highlighted background
* @param backgroundColorFilter: ColorFilter to apply to the color when drawn into the destination.
* @param backgroundBlendMode:Blending algorithm to be applied to the path when it is drawn.
* @param backgroundStyle: Whether or not the path is stroked or filled in.
* @param isHighlightBarRequired: Boolean flag to enable disable highlight
* @param popUpLabel : The text that can be shown on the popup given 2 input params x and y values
* @param drawPopUp: Override this to change the default background implementation. You are provided
* with the selected offset, x, y values, center point of bar.
* @param drawHighlightBar: draw override this to change the default drawRoundRect implementation. You are provided
* with the actual point x, y, height, width.
* @param groupBarPopUpLabel: Popup label for selected bar in group bar chart.
* @param drawGroupBarPopUp: draw override this to change the default popup implementation. You are provided
* with the actual point x, y, height, width.
*/
data class SelectionHighlightData(
//highlight text
val highlightTextOffset: Dp = 15.dp,
val highlightTextSize: TextUnit = 12.sp,
val highlightTextColor: Color = Color.Black,
val highlightTextBackgroundColor: Color = Color.Yellow,
val highlightTextBackgroundAlpha: Float = 0.7f,
val highlightTextTypeface: Typeface = Typeface.DEFAULT,
// highlight bar
val highlightBarCornerRadius: Dp = 2.dp,
val highlightBarColor: Color = Color.Black,
val highlightBarStrokeWidth: Dp = 2.dp,
val highlightPopUpCornerRadius: CornerRadius = CornerRadius(5f),
val backgroundColorFilter: ColorFilter? = null,
val backgroundBlendMode: BlendMode = DrawScope.DefaultBlendMode,
val backgroundStyle: DrawStyle = Fill,
val highlightLabelAlignment: Paint.Align = Paint.Align.CENTER,
val isHighlightBarRequired: Boolean = true,
val isHighlightFullBar: Boolean = false,
val popUpLabel: (Float, Float) -> (String) = { x, y ->
val xLabel = "x : ${x.toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
},
val drawPopUp: DrawScope.(Offset, BarData, Float, Float, BarChartType) -> Unit = { selectedOffset, identifiedPoint, centerPointOfBar, selectedXAxisWidth, barChartType ->
val highlightTextPaint = TextPaint().apply {
textSize = highlightTextSize.toPx()
color = highlightTextColor.toArgb()
textAlign = highlightLabelAlignment
typeface = highlightTextTypeface
}
val label = popUpLabel(identifiedPoint.point.x, identifiedPoint.point.y)
drawContext.canvas.nativeCanvas.apply {
val x =
if (barChartType == BarChartType.VERTICAL) centerPointOfBar else selectedXAxisWidth + highlightTextOffset.toPx()
val y =
if (barChartType == BarChartType.VERTICAL) selectedOffset.y else centerPointOfBar
val background = getTextBackgroundRect(
x,
y,
label,
highlightTextPaint
)
drawRoundRect(
color = highlightTextBackgroundColor,
topLeft = Offset(
background.left.toFloat(),
if (barChartType == BarChartType.VERTICAL) background.top.toFloat() - highlightTextOffset.toPx() else (y - background.height() / 2)
),
size = Size(background.width().toFloat(), background.height().toFloat()),
alpha = highlightTextBackgroundAlpha,
cornerRadius = highlightPopUpCornerRadius,
colorFilter = backgroundColorFilter,
blendMode = backgroundBlendMode,
style = backgroundStyle
)
val drawTextX =
if (barChartType == BarChartType.VERTICAL) centerPointOfBar else selectedXAxisWidth + highlightTextOffset.toPx()
val drawTextY =
if (barChartType == BarChartType.VERTICAL) selectedOffset.y - highlightTextOffset.toPx() else (y + background.height() / 2) - (highlightTextPaint.fontMetrics.descent)
drawText(
label,
drawTextX,
drawTextY,
highlightTextPaint
)
}
},
val drawHighlightBar: DrawScope.(Float, Float, Float, Float, BarChartType) -> Unit = { x, y, width, height, barChartType ->
val rectWidth = if (barChartType == BarChartType.VERTICAL) width else height
val rectHeight = if (barChartType == BarChartType.VERTICAL) height else width
drawRoundRect(
color = highlightBarColor,
topLeft = Offset(x, y),
size = Size(rectWidth, rectHeight),
cornerRadius = CornerRadius(
highlightBarCornerRadius.toPx(),
highlightBarCornerRadius.toPx()
),
style = Stroke(width = highlightBarStrokeWidth.toPx())
)
},
val groupBarPopUpLabel: (String, Float) -> (String) = { name, value ->
val xLabel = "Name : $name "
val yLabel = "Value : ${String.format("%.2f", value)}"
"$xLabel $yLabel"
},
val drawGroupBarPopUp: DrawScope.(Offset, BarData, Float) -> Unit = { selectedOffset, identifiedPoint, centerPointOfBar ->
val highlightTextPaint = TextPaint().apply {
textSize = highlightTextSize.toPx()
color = highlightTextColor.toArgb()
textAlign = highlightLabelAlignment
typeface = highlightTextTypeface
}
val xLabel = "${identifiedPoint.point.x.toInt()}"
val label = groupBarPopUpLabel(xLabel, identifiedPoint.point.y)
drawContext.canvas.nativeCanvas.apply {
val background = getTextBackgroundRect(
centerPointOfBar,
selectedOffset.y,
label,
highlightTextPaint
)
drawRoundRect(
color = highlightTextBackgroundColor,
topLeft = Offset(
background.left.toFloat(),
background.top.toFloat() - highlightTextOffset.toPx()
),
size = Size(background.width().toFloat(), background.height().toFloat()),
alpha = highlightTextBackgroundAlpha,
cornerRadius = highlightPopUpCornerRadius,
colorFilter = backgroundColorFilter,
blendMode = backgroundBlendMode,
style = backgroundStyle
)
drawText(
label,
centerPointOfBar,
selectedOffset.y - highlightTextOffset.toPx(),
highlightTextPaint
)
}
},
val barChartType: BarChartType = BarChartType.VERTICAL
)
@@ -0,0 +1,497 @@
package co.yml.charts.ui.barchart
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import co.yml.charts.axis.DataCategoryOptions
import co.yml.charts.common.model.Point
import co.yml.charts.ui.barchart.models.BarChartType
import co.yml.charts.ui.barchart.models.BarData
import co.yml.charts.ui.barchart.models.BarStyle
import co.yml.charts.ui.barchart.models.GroupBarChartData
import co.yml.charts.ui.barchart.models.SelectionHighlightData
/**
*
* Used to draw the highlighted text
* @param identifiedPoint : Selected points
* @param selectedOffset: Offset selected
* @param barWidth: Width of single bar
* @param highlightData: Data for the highlight section
* @param selectedXAxisWidth: when data value is present in xAxis [e.g. Horizontal barchart]
*/
internal fun DrawScope.drawHighlightText(
identifiedPoint: BarData,
selectedOffset: Offset,
barWidth: Dp,
highlightData: SelectionHighlightData,
selectedXAxisWidth: Float = 0f,
barChartType: BarChartType
) {
val centerPointOfBar =
if (barChartType == BarChartType.VERTICAL) selectedOffset.x + barWidth.toPx() / 2 else selectedOffset.y + barWidth.toPx() / 2
// Drawing the highlighted background and text
highlightData.drawPopUp(
this,
selectedOffset,
identifiedPoint,
centerPointOfBar,
selectedXAxisWidth,
barChartType
)
}
/**
*
* returns identified point and displaying the data points and highlighted bar .
* @param dragLocks : Mutable map of BarData and drawOffset.
* @param barHighlightVisibility : Flag to control the visibility of highlighted text.
* @param identifiedPoint: selected bar data.
* @param barStyle: Data related to the bar graph styling.
* @param isDragging : Boolean flag for the dragging status.
* @param columnWidth : Width of the Y axis.
* @param yBottom : Bottom padding.
* @param paddingRight : Right padding.
* @param yOffset : Distance between two y points.
* @param shouldShowHighlightPopUp : Default true but if highlight popup not required make it false
*/
internal fun DrawScope.highlightVerticalBar(
dragLocks: MutableMap<Int, Pair<BarData, Offset>>,
barHighlightVisibility: Boolean,
identifiedPoint: BarData,
barStyle: BarStyle,
isDragging: Boolean,
columnWidth: Float,
yBottom: Float,
paddingRight: Dp,
yOffset: Float,
shouldShowHighlightPopUp: Boolean = true,
selectedXAxisWidth: Float = 0f,
barChartType: BarChartType
): BarData {
var mutableIdentifiedPoint: BarData = identifiedPoint
// Handle the show the selected bar
if (isDragging) {
val x = dragLocks.values.firstOrNull()?.second?.x
if (x != null) {
mutableIdentifiedPoint = dragLocks.values.map { it.first }.first()
}
// Draw highlight bar on selection
if (barStyle.selectionHighlightData?.isHighlightBarRequired == true) {
dragLocks.values.firstOrNull()?.let { (barData, location) ->
val (xPoint, yPoint) = location
if (xPoint >= columnWidth && xPoint <= size.width - paddingRight.toPx()) {
val y1 = yBottom - ((barData.point.y - 0) * yOffset)
barStyle.selectionHighlightData.drawHighlightBar(
this,
xPoint,
yPoint,
barStyle.barWidth.toPx(),
(yBottom - y1),
barStyle.selectionHighlightData.barChartType
)
}
}
}
}
if (shouldShowHighlightPopUp) {
val selectedOffset = dragLocks.values.firstOrNull()?.second
if (barHighlightVisibility && selectedOffset != null && barStyle.selectionHighlightData != null) {
drawHighlightText(
mutableIdentifiedPoint,
selectedOffset,
barStyle.barWidth,
barStyle.selectionHighlightData,
selectedXAxisWidth,
barChartType
)
}
}
return mutableIdentifiedPoint
}
/**
*
* returns identified point and displaying the data points and highlighted bar .
* @param dragLocks : Mutable map of BarData and drawOffset.
* @param barHighlightVisibility : Flag to control the visibility of highlighted text.
* @param identifiedPoint: selected bar data.
* @param barStyle: Data related to the bar graph styling.
* @param isDragging : Boolean flag for the dragging status.
* @param columnWidth : Width of the Y axis.
* @param yBottom : Bottom padding.
* @param paddingRight : Right padding.
* @param yOffset : Distance between two y points.
* @param shouldShowHighlightPopUp : Default true but if highlight popup not required make it false
*/
internal fun DrawScope.highlightHorizontalBar(
dragLocks: MutableMap<Int, Pair<BarData, Offset>>,
barHighlightVisibility: Boolean,
identifiedPoint: BarData,
barStyle: BarStyle,
isDragging: Boolean,
yBottom: Float,
paddingTop: Dp,
xStart: Float,
xOffset: Float,
shouldShowHighlightPopUp: Boolean = true
): BarData {
var mutableIdentifiedPoint: BarData = identifiedPoint
// Handle the show the selected bar
var selectedXAxisWidth = 0f
if (isDragging) {
val y = dragLocks.values.firstOrNull()?.second?.y
if (y != null) {
mutableIdentifiedPoint = dragLocks.values.map { it.first }.first()
}
if (barStyle.selectionHighlightData?.isHighlightBarRequired == true) {
dragLocks.values.firstOrNull()?.let { (barData, location) ->
val (xPoint, yPoint) = location
selectedXAxisWidth =
xStart + ((barData.point.x - 0) * xOffset) + barStyle.barWidth.toPx()
if (yPoint + barStyle.barWidth.toPx() <= yBottom && yPoint >= paddingTop.toPx()) {
val x1 = xStart + ((barData.point.x - 0) * xOffset)
barStyle.selectionHighlightData?.drawHighlightBar?.invoke(
this,
xPoint,
yPoint,
barStyle.barWidth.toPx(),
(x1 - xStart),
barStyle.selectionHighlightData.barChartType
)
}
}
}
}
if (shouldShowHighlightPopUp) {
val selectedOffset = dragLocks.values.firstOrNull()?.second
if (barHighlightVisibility && selectedOffset != null && barStyle.selectionHighlightData != null) {
drawHighlightText(
mutableIdentifiedPoint,
selectedOffset,
barStyle.barWidth,
barStyle.selectionHighlightData ?: SelectionHighlightData(),
selectedXAxisWidth,
barStyle.selectionHighlightData.barChartType
)
}
}
return mutableIdentifiedPoint
}
/**
*
* DrawScope.drawUnderXAxisScrollMask extension method used for drawing a rectangular mask to make graph scrollable under the XAxis.
* @param columnWidth : Width of the rectangular mask here width of Y Axis is used.
* @param paddingRight : Padding given at the end of the graph.
* @param bgColor : Background of the rectangular mask.
*/
internal fun DrawScope.drawUnderXAxisScrollMask(
columnWidth: Float,
paddingTop: Dp,
bgColor: Color
) {
// Draw column to make graph look scrollable under Xaxis
drawRect(
bgColor, Offset(0f, size.height - columnWidth), Size(size.width, columnWidth)
)
// Draw top padding
drawRect(
bgColor,
Offset(0f, 0f),
Size(size.width, paddingTop.toPx())
)
}
/**
* returns the draw offset for bar graph.
* @param point : bar point
* @param xMin: Minimum value on the x axis
* @param yMin: Minimum value on the y axis
* @param xOffset: Distance between bars
* @param yOffset: Distance between y axis points
* @param xLeft: X starting point of bar graph
* @param scrollOffset: Scroll offset
* @param yBottom: Y starting point of bar graph
*/
internal fun getDrawOffset(
point: Point,
xMin: Float,
xOffset: Float,
xLeft: Float,
scrollOffset: Float,
yBottom: Float,
yOffset: Float,
yMin: Float,
startDrawPadding: Float,
zoomScale: Float,
barWidth: Float
): Offset {
val (x, y) = point
val x1 =
((x - xMin) * xOffset) + xLeft + (startDrawPadding * zoomScale) - barWidth / 2 - scrollOffset
val y1 = yBottom - ((y - yMin) * yOffset)
return Offset(x1, y1)
}
/**
* Get draw horizontal offset
*
* @param point
* @param xLeft
* @param scrollOffset
* @param yBottom
* @param yOffset
* @param yMin
* @param yMax
* @param yStart
* @param dataCategoryOptions
* @param zoomScale
* @return
*/
internal fun getDrawHorizontalOffset(
point: Point,
xLeft: Float,
scrollOffset: Float,
yBottom: Float,
yOffset: Float,
yMin: Float,
yMax: Float,
yStart: Float,
dataCategoryOptions: DataCategoryOptions,
zoomScale: Float
): Offset {
val (_, y) = point
val x1 = xLeft
val y1 =
if (dataCategoryOptions.isDataCategoryStartFromBottom) yBottom - yStart - ((y - yMin) * yOffset) + scrollOffset else {
if (zoomScale < 1) {
yBottom - yStart - ((yMax - y) * yOffset) + scrollOffset
} else {
yStart + ((y - yMin) * yOffset) - scrollOffset
}
}
return Offset(x1, y1)
}
/////
/**
*
* Used to draw the highlighted text
* @param identifiedPoint : Selected points
* @param selectedOffset: Offset selected
* @param barWidth: Width of single bar
* @param highlightData: Data for the highlight section
*/
internal fun DrawScope.drawGroupHighlightText(
identifiedPoint: BarData,
selectedOffset: Offset,
barWidth: Dp,
highlightData: SelectionHighlightData
) {
val centerPointOfBar = selectedOffset.x + barWidth.toPx() / 2
// Drawing the highlighted background and text
highlightData.drawGroupBarPopUp(this, selectedOffset, identifiedPoint, centerPointOfBar)
}
/**
*
* returns identified point and displaying the data points and highlighted bar .
* @param dragLocks : Mutable map of BarData and drawOffset.
* @param visibility : Flag to control the visibility of highlighted text.
* @param identifiedPoint: selected bar data.
* @param selectionHighlightData: Data related to the bar graph highlight.
* @param isDragging : Boolean flag for the dragging status.
* @param columnWidth : Width of the Y axis.
* @param yBottom : Bottom padding.
* @param paddingRight : Right padding.
* @param yOffset : Distance between two y points.
* @param barWidth : Width of each bar.
* @param totalPaddingBtwBars : total padding between stacked bars. For group chart it will be 0.
* @param isHighlightFullBar : User configured value for highlighting the entire bar in case of stacked bar chart
*/
internal fun DrawScope.highlightGroupBar(
dragLocks: MutableMap<Int, Pair<BarData, Offset>>,
visibility: Boolean,
identifiedPoint: BarData,
selectionHighlightData: SelectionHighlightData?,
isDragging: Boolean,
columnWidth: Float,
yBottom: Float,
paddingRight: Dp,
yOffset: Float,
barWidth: Dp,
totalPaddingBtwBars: Float = 0f,
isHighlightFullBar: Boolean = false
): BarData {
var mutableIdentifiedPoint: BarData = identifiedPoint
// Handle the show the selected bar
if (isDragging) {
val x = dragLocks.values.firstOrNull()?.second?.x
if (x != null) {
mutableIdentifiedPoint = dragLocks.values.map { it.first }.first()
}
// Draw highlight bar on selection
if (selectionHighlightData?.isHighlightBarRequired == true) {
dragLocks.values.firstOrNull()?.let { (barData, location) ->
val (xPoint, yPoint) = location
if (xPoint >= columnWidth && xPoint <= size.width - paddingRight.toPx()) {
val y1 =
yBottom - ((barData.point.y - 0) * yOffset) - if (isHighlightFullBar) totalPaddingBtwBars else 0f
selectionHighlightData.drawHighlightBar(
this, xPoint, yPoint, barWidth.toPx(), yBottom - y1, BarChartType.VERTICAL
)
}
}
}
}
val selectedOffset = dragLocks.values.firstOrNull()?.second
if (visibility && selectedOffset != null && selectionHighlightData != null) {
drawGroupHighlightText(
mutableIdentifiedPoint, selectedOffset, barWidth, selectionHighlightData
)
}
return mutableIdentifiedPoint
}
/**
* returns the draw offset for bar graph.
* @param yMin: Minimum value on the y axis
* @param xOffset: Distance between bars
* @param yOffset: Distance between y axis points
* @param xLeft: X starting point of bar graph
* @param scrollOffset: Scroll offset
* @param yBottom: Y starting point of bar graph
*/
internal fun getGroupBarDrawOffset(
x: Int,
y: Float,
xOffset: Float,
xLeft: Float,
scrollOffset: Float,
yBottom: Float,
yOffset: Float,
yMin: Float,
xMin: Float,
startDrawPadding: Float,
zoomScale: Float,
barWidth: Float
): Offset {
val x1 =
((x - xMin) * xOffset) + xLeft + (startDrawPadding * zoomScale) - barWidth / 2 - scrollOffset
val y1 = yBottom - ((y - yMin) * yOffset)
return Offset(x1, y1)
}
/**
*
* Used to draw the group separator
* @param barGraphData: [GroupBarChartData]
* @param drawOffset: topLeft offset for the drawing the separator
* @param height : height of the separator
* @param width : width of the separator
* @param color : color of the separator
*/
internal fun DrawScope.drawGroupSeparator(
drawOffset: Offset,
height: Float,
width: Float,
color: Color,
barGraphData: GroupBarChartData,
) {
drawRoundRect(
color = color,
topLeft = drawOffset,
size = Size(width, height),
blendMode = barGraphData.groupSeparatorConfig.separatorBlendMode
)
}
/**
*
* returns the max scrollable distance based on the points to be drawn along with padding etc.
* @param columnWidth : Width of the Y-Axis.
* @param xMax : Max X-Axis value.
* @param xMin: Min X-Axis value.
* @param xOffset: Total distance between two X-Axis points.
* @param xLeft: Total Left padding.
* @param paddingRight : Padding at the end of the canvas.
* @param canvasWidth : Total available canvas width.
*/
internal fun getMaxScrollDistance(
columnWidth: Float,
xMax: Float,
xMin: Float,
xOffset: Float,
xLeft: Float,
paddingRight: Float,
canvasWidth: Float
): Float {
val xLastPoint = (xMax - xMin) * xOffset + xLeft + columnWidth + paddingRight
return if (xLastPoint > canvasWidth) {
xLastPoint - canvasWidth
} else 0f
}
/**
*
* DrawScope.drawUnderScrollMask extension method used for drawing a rectangular mask to make graph scrollable under the YAxis.
* @param columnWidth : Width of the rectangular mask here width of Y Axis is used.
* @param paddingRight : Padding given at the end of the graph.
* @param bgColor : Background of the rectangular mask.
*/
internal fun DrawScope.drawUnderScrollMask(columnWidth: Float, paddingRight: Dp, bgColor: Color) {
// Draw column to make graph look scrollable under Yaxis
drawRect(
bgColor, Offset(0f, 0f), Size(columnWidth, size.height)
)
// Draw right padding
drawRect(
bgColor,
Offset(size.width - paddingRight.toPx(), 0f),
Size(paddingRight.toPx(), size.height)
)
}
/**
* @param barDataList : List of bar details for selected stacked bar
* @param totalPaddingBtwBars : Total padding stacked bars
* @param yOffset : Offset of Y axis point
* @param yBottom : starting y offset of y Axis
* @param xPointOffset : Offset of X axis point
*/
internal fun getFullBarDetails(
barDataList: List<BarData>,
totalPaddingBtwBars: Float,
yOffset: Float,
yBottom: Float,
xPointOffset: Float
): Pair<BarData, Offset> {
var yPoint = 0f
barDataList.forEach {
yPoint += it.point.y
}
val fullBarYOffset = yBottom - totalPaddingBtwBars - (yPoint * yOffset)
val fullBarOffset = Offset(xPointOffset, fullBarYOffset)
val fullBarData = BarData(Point(barDataList.first().point.x, yPoint))
return Pair(fullBarData, fullBarOffset)
}
@@ -0,0 +1,400 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.bubblechart
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.axis.getXAxisScale
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.BubblePointInfo
import co.yml.charts.common.extensions.RowClip
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.common.extensions.drawGridLines
import co.yml.charts.common.extensions.isNotNull
import co.yml.charts.common.model.Point
import co.yml.charts.ui.bubblechart.model.Bubble
import co.yml.charts.ui.bubblechart.model.BubbleChartData
import co.yml.charts.ui.bubblechart.model.BubbleStyle
import co.yml.charts.ui.linechart.drawHighLightOnSelectedPoint
import co.yml.charts.ui.linechart.drawHighlightText
import co.yml.charts.ui.linechart.getMappingPointsToGraph
import co.yml.charts.ui.linechart.getMaxElementInYAxis
import co.yml.charts.ui.linechart.getYAxisScale
import co.yml.charts.ui.linechart.isTapped
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
import kotlinx.coroutines.launch
/**
* Bubble chart
*
* @param modifier
* @param bubbleChartData
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BubbleChart(modifier: Modifier, bubbleChartData: BubbleChartData) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& bubbleChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(modifier = modifier) {
with(bubbleChartData) {
var columnWidth by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
var xOffset by remember { mutableStateOf(0f) }
val bgColor = MaterialTheme.colorScheme.surface
var isTapped by remember { mutableStateOf(false) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var selectionTextVisibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(Point(0f, 0f)) }
val bubblePoints: List<Point> = bubbles.map { bubble -> bubble.center }
val (xMin, xMax, xAxisScale) = getXAxisScale(bubblePoints, xAxisData.steps)
val (yMin, _, yAxisScale) = getYAxisScale(bubblePoints, yAxisData.steps)
val maxElementInYAxis = getMaxElementInYAxis(yAxisScale, yAxisData.steps)
val xAxisData = xAxisData.copy(axisBottomPadding = bottomPadding)
val yAxisData = yAxisData.copy(
axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() },
axisTopPadding = paddingTop
)
ScrollableCanvasContainer(modifier = modifier
.semantics {
contentDescription = bubbleChartData.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
calculateMaxDistance = { xZoom ->
xOffset = xAxisData.axisStepSize.toPx() * xZoom
getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight.toPx(),
size.width,
containerPaddingEnd.toPx()
)
},
containerBackgroundColor = backgroundColor,
isPinchZoomEnabled = isZoomAllowed,
drawXAndYAxis = { scrollOffset, xZoom ->
YAxis(
modifier = Modifier
.fillMaxHeight()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
}, yAxisData = yAxisData
)
XAxis(xAxisData = xAxisData,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.align(Alignment.BottomStart)
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
}
.clip(
RowClip(
columnWidth, paddingRight
)
),
xStart = columnWidth,
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = bubblePoints,
axisStart = columnWidth)
},
onDraw = { scrollOffset, xZoom ->
val yBottom = size.height - rowHeight
val yOffset = ((yBottom - paddingTop.toPx()) / maxElementInYAxis)
xOffset = xAxisData.axisStepSize.toPx() * xZoom
val xLeft = columnWidth // To add extra space if needed
val pointsData = getMappingPointsToGraph(
bubblePoints,
xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
)
val tapPointLocks = mutableMapOf<Int, Pair<Point, Offset>>()
// Draw guide lines
gridLines?.let {
drawGridLines(
yBottom,
yAxisData.axisTopPadding.toPx(),
xLeft,
paddingRight,
scrollOffset,
bubbles.size,
xZoom,
xAxisScale,
yAxisData.steps,
xAxisData.axisStepSize,
it
)
}
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
pointsData.forEachIndexed { index, offset ->
val drawingOffset = Offset(offset.x, offset.y)
bubbles[index].draw(
this,
drawingOffset,
bubbleChartData.maximumBubbleRadius
)
if (isTapped && offset.isTapped(tapOffset.x, xOffset)) {
tapPointLocks[0] = bubbles[index].center to offset
identifiedPoint = tapPointLocks.values.map { it.first }.first()
drawHighLightOnSelectedPoint(
tapPointLocks,
columnWidth,
paddingRight,
yBottom,
bubbles[index].selectionHighlightPoint
)
}
val selectedOffset = tapPointLocks.values.firstOrNull()?.second
if (selectionTextVisibility && selectedOffset.isNotNull()) {
drawHighlightText(
identifiedPoint,
selectedOffset ?: Offset(0f, 0f),
bubbles[index].selectionHighlightPopUp
)
}
}
},
onPointClicked = { offset: Offset, _: Float ->
isTapped = true
selectionTextVisibility = true
tapOffset = offset
},
onScroll = {
isTapped = false
selectionTextVisibility = false
},
onZoomInAndOut = {
isTapped = false
selectionTextVisibility = false
})
}
if (isTalkBackEnabled) {
with(bubbleChartData) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(),
backgroundColor = Color.White,
content = {
LazyColumn {
bubbles.size?.let {
items(it) { index ->
Column {
BubblePointInfo(
xAxisData.axisLabelDescription(
xAxisData.labelData(
index
)
),
bubbles[index].center.description + "density is" + { bubbles[index].density },
bubbles[index].bubbleStyle.solidColor,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
/**
* Get max scroll distance
*
* @param columnWidth
* @param xMax
* @param xMin
* @param xOffset
* @param paddingRight
* @param canvasWidth
* @param containerPaddingEnd
* @return
*/
fun getMaxScrollDistance(
columnWidth: Float,
xMax: Float,
xMin: Float,
xOffset: Float,
paddingRight: Float,
canvasWidth: Float,
containerPaddingEnd: Float = 0f
): Float {
val xLastPoint = (xMax - xMin) * xOffset + columnWidth + paddingRight + containerPaddingEnd
return if (xLastPoint > canvasWidth) {
xLastPoint - canvasWidth
} else 0f
}
/**
*
* DrawScope.drawUnderScrollMask extension method used for drawing a rectangular mask to make graph scrollable under the YAxis.
* @param columnWidth : Width of the rectangular mask here width of Y Axis is used.
* @param paddingRight : Padding given at the end of the graph.
* @param bgColor : Background of the rectangular mask.
*/
private fun DrawScope.drawUnderScrollMask(columnWidth: Float, paddingRight: Dp, bgColor: Color) {
drawRect(
bgColor, Offset(0f, 0f), Size(columnWidth, size.height)
)
drawRect(
bgColor,
Offset(size.width - paddingRight.toPx(), 0f),
Size(paddingRight.toPx(), size.height)
)
}
/**
* Get cubic points
*
* @param pointsData
* @return
*/
fun getCubicPoints(pointsData: List<Offset>): Pair<MutableList<Offset>, MutableList<Offset>> {
val cubicPoints1 = mutableListOf<Offset>()
val cubicPoints2 = mutableListOf<Offset>()
for (i in 1 until pointsData.size) {
cubicPoints1.add(
Offset(
(pointsData[i].x + pointsData[i - 1].x) / 2, pointsData[i - 1].y
)
)
cubicPoints2.add(
Offset(
(pointsData[i].x + pointsData[i - 1].x) / 2, pointsData[i].y
)
)
}
return Pair(cubicPoints1, cubicPoints2)
}
/**
* Draw highlight text
*
* @param identifiedPoint
* @param selectedOffset
* @param selectionHighlightPopUp
*/
fun DrawScope.drawHighlightText(
identifiedPoint: Point,
selectedOffset: Offset,
selectionHighlightPopUp: SelectionHighlightPopUp?
) {
selectionHighlightPopUp?.run {
draw(selectedOffset, identifiedPoint)
}
}
/**
* Draw high light on selected point
*
* @param dragLocks
* @param columnWidth
* @param paddingRight
* @param yBottom
* @param selectionHighlightPoint
*/
fun DrawScope.drawHighLightOnSelectedPoint(
dragLocks: MutableMap<Int, Pair<Point, Offset>>,
columnWidth: Float,
paddingRight: Dp,
yBottom: Float,
selectionHighlightPoint: SelectionHighlightPoint?
) {
if (selectionHighlightPoint.isNotNull()) {
dragLocks.values.firstOrNull()?.let { (_, location) ->
val (x, y) = location
if (x >= columnWidth && x <= size.width - paddingRight.toPx()) {
selectionHighlightPoint?.draw?.let { it(this, Offset(x, y)) }
if (selectionHighlightPoint?.isHighlightLineRequired == true) {
selectionHighlightPoint.drawHighlightLine(
this, Offset(x, yBottom), Offset(x, y)
)
}
}
}
}
}
@@ -0,0 +1,135 @@
package co.yml.charts.ui.bubblechart.model
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.model.AccessibilityConfig
import co.yml.charts.common.model.Point
import co.yml.charts.ui.linechart.model.GridLines
import co.yml.charts.ui.linechart.model.IntersectionPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
/**
* Bubble chart data
*
* @property bubbles
* @property maximumBubbleRadius
* @property xAxisData
* @property yAxisData
* @property isZoomAllowed
* @property paddingTop
* @property bottomPadding
* @property paddingRight
* @property containerPaddingEnd
* @property backgroundColor
* @property gridLines
* @property accessibilityConfig
* @constructor Create empty Bubble chart data
*/
data class BubbleChartData(
val bubbles: List<Bubble>,
val maximumBubbleRadius:Float = 100f,
val xAxisData: AxisData = AxisData.Builder().build(),
val yAxisData: AxisData = AxisData.Builder().build(),
val isZoomAllowed: Boolean = true,
val paddingTop: Dp = 30.dp,
val bottomPadding: Dp = 10.dp,
val paddingRight: Dp = 10.dp,
val containerPaddingEnd: Dp = 15.dp,
val backgroundColor: Color = Color.White,
val gridLines: GridLines? = null,
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig()
)
/**
* Bubble
*
* @property center
* @property density
* @property bubbleStyle
* @property intersectionPoint
* @property selectionHighlightPoint
* @property selectionHighlightPopUp
* @property draw
* @constructor Create empty Bubble
*/
data class Bubble(
val center: Point,
val density: Float,
val bubbleStyle: BubbleStyle = BubbleStyle(),
val intersectionPoint: IntersectionPoint? = null,
val selectionHighlightPoint: SelectionHighlightPoint? = null,
val selectionHighlightPopUp: SelectionHighlightPopUp? = null,
val draw: DrawScope.(Offset,Float) -> Unit = { center,maximumRadius ->
val drawingRadius:Float = (density / maximumRadius) * 100
if (bubbleStyle.useGradience) {
drawCircle(
brush = getBrush(bubbleStyle, center, density),
center = center,
radius = drawingRadius,
alpha = bubbleStyle.alpha,
style = bubbleStyle.style,
colorFilter = bubbleStyle.colorFilter,
blendMode = bubbleStyle.blendMode
)
} else {
drawCircle(
bubbleStyle.solidColor,
drawingRadius,
center,
bubbleStyle.alpha,
bubbleStyle.style,
bubbleStyle.colorFilter,
bubbleStyle.blendMode
)
}
}
)
private fun getBrush(bubbleStyle: BubbleStyle, center: Offset, density: Float): Brush {
when (bubbleStyle.gradientType) {
BubbleGradientType.RadialGradient -> {
return Brush.radialGradient(
colors = bubbleStyle.gradientColors,
center = center,
radius = density,
tileMode = bubbleStyle.tileMode
)
}
BubbleGradientType.LinearGradient -> {
return Brush.linearGradient(
colors = bubbleStyle.gradientColors,
tileMode = bubbleStyle.tileMode,
start = center,
end = center
)
}
BubbleGradientType.VerticalGradient -> {
return Brush.verticalGradient(
colors = bubbleStyle.gradientColors,
tileMode = bubbleStyle.tileMode,
startY = center.y - density / 2,
endY = center.y + density / 2,
)
}
BubbleGradientType.HorizontalGradient -> {
return Brush.horizontalGradient(
colors = bubbleStyle.gradientColors,
tileMode = bubbleStyle.tileMode,
startX = center.x - density / 2,
endX = center.x + density / 2,
)
}
}
}
@@ -0,0 +1,77 @@
package co.yml.charts.ui.bubblechart.model
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.LinearGradient
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import co.yml.charts.ui.linechart.model.LineType
/**
* Bubble style
*
* @property gradientColors
* @property gradientType
* @property solidColor
* @property useGradience
* @property width
* @property alpha
* @property style
* @property colorFilter
* @property blendMode
* @constructor Create empty Bubble style
*/
data class BubbleStyle(
val gradientColors: List<Color> = listOf(Color.Blue, Color.Red),
val gradientType: BubbleGradientType = BubbleGradientType.HorizontalGradient,
val tileMode: TileMode = TileMode.Clamp,
val useGradience: Boolean = false,
val solidColor: Color = Color.Blue,
val width: Float = 8f,
val alpha: Float = 1.0f,
val style: DrawStyle = Fill,
val colorFilter: ColorFilter? = null,
val blendMode: BlendMode = DefaultBlendMode
)
/**
* Bubble gradient type
*
* @constructor Create empty Bubble gradient type
*/
sealed class BubbleGradientType {
/**
* Linear gradient
*
* @constructor Create empty Linear gradient
*/
object LinearGradient : BubbleGradientType()
/**
* Radial gradient
*
* @constructor Create empty Radial gradient
*/
object RadialGradient : BubbleGradientType()
/**
* Vertical gradient
*
* @constructor Create empty Vertical gradient
*/
object VerticalGradient : BubbleGradientType()
/**
* Horizontal gradient
*
* @constructor Create empty Horizontal gradient
*/
object HorizontalGradient : BubbleGradientType()
}
@@ -0,0 +1,454 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.combinedchart
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.CombinedChartInfo
import co.yml.charts.common.extensions.*
import co.yml.charts.common.extensions.getMaxElementInYAxis
import co.yml.charts.common.model.PlotData
import co.yml.charts.common.model.PlotType
import co.yml.charts.common.model.Point
import co.yml.charts.ui.barchart.drawUnderScrollMask
import co.yml.charts.ui.barchart.getGroupBarDrawOffset
import co.yml.charts.ui.barchart.getMaxScrollDistance
import co.yml.charts.ui.barchart.highlightGroupBar
import co.yml.charts.ui.barchart.models.BarData
import co.yml.charts.ui.barchart.models.BarPlotData
import co.yml.charts.ui.barchart.models.GroupBar
import co.yml.charts.ui.combinedchart.model.CombinedChartData
import co.yml.charts.ui.linechart.drawHighLightOnSelectedPoint
import co.yml.charts.ui.linechart.drawHighlightText
import co.yml.charts.ui.linechart.drawShadowUnderLineAndIntersectionPoint
import co.yml.charts.ui.linechart.drawStraightOrCubicLine
import co.yml.charts.ui.linechart.getCubicPoints
import co.yml.charts.ui.linechart.getMappingPointsToGraph
import co.yml.charts.ui.linechart.model.LinePlotData
import kotlinx.coroutines.launch
/**
*
* CombinedChart compose method for drawing combined line and bar charts.
* @param modifier: All modifier related properties
* @param combinedChartData : All data needed to draw combined chart. [CombinedChartData] Data
* class to save all params related to combined line and bar chart.
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CombinedChart(modifier: Modifier, combinedChartData: CombinedChartData) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& combinedChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(modifier) {
with(combinedChartData) {
var xOffset by remember { mutableStateOf(0f) }
var isTapped by remember { mutableStateOf(false) }
var columnWidth by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
val paddingRight = paddingEnd
val linePlotData: LinePlotData =
getDataFromType(combinedPlotDataList, PlotType.Line) as? LinePlotData
?: LinePlotData.default()
val barPlotData: BarPlotData =
getDataFromType(combinedPlotDataList, PlotType.Bar) as? BarPlotData
?: BarPlotData.default()
val linePoints: List<Point> =
linePlotData.lines.flatMap { line -> line.dataPoints.map { it } }
val barPoints = barPlotData.groupBarList.flatMap { bar -> bar.barList.map { it } }
val bgColor = MaterialTheme.colorScheme.surface
val xMin =
minOf(if(linePoints.isEmpty()) 0.0f else linePoints.minOf { it.x }, (barPlotData.groupBarList.size).toFloat())
val xMax =
maxOf(if(linePoints.isEmpty()) 0.0f else linePoints.maxOf { it.x }, (barPlotData.groupBarList.size).toFloat())
val yMin = minOf(if(linePoints.isEmpty()) 0.0f else linePoints.minOf { it.y }, if(barPoints.isEmpty())0.0f else barPoints.minOf { it.point.y })
val yMax = maxOf(if(linePoints.isEmpty()) 0.0f else linePoints.maxOf { it.y }, if(barPoints.isEmpty())0.0f else barPoints.maxOf { it.point.y })
val requiredSteps =
maxOf(
if(linePlotData.lines.isEmpty()) 0 else linePlotData.lines.map { it.dataPoints.size - 1 }.maxOf { it },
if(barPlotData.groupBarList.isEmpty()) 0 else barPlotData.groupBarList.size
)
val xAxisData = xAxisData.copy(
axisStepSize = if(barPlotData.groupBarList.isEmpty()) 30.dp else((barPlotData.barStyle.barWidth * barPlotData.groupingSize) +
barPlotData.barStyle.paddingBetweenBars),
steps = requiredSteps,
startDrawPadding = LocalDensity.current.run { columnWidth.toDp() },
shouldDrawAxisLineTillEnd = true
)
val yAxisData =
yAxisData.copy(
axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() },
axisTopPadding = paddingTop
)
val maxElementInYAxis = getMaxElementInYAxis(yMax, yAxisData.steps)
var identifiedBarPoint by remember { mutableStateOf(BarData(Point(0f, 0f))) }
var identifiedPoint by remember { mutableStateOf(Point(0f, 0f)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
ScrollableCanvasContainer(
modifier = modifier
.semantics {
contentDescription = accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
containerBackgroundColor = backgroundColor,
isPinchZoomEnabled = isZoomAllowed,
calculateMaxDistance = { xZoom ->
xOffset =
((barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) +
barPlotData.barStyle.paddingBetweenBars.toPx()) * xZoom
getMaxScrollDistance(
columnWidth + (xAxisData.startDrawPadding.toPx() * xZoom),
xMax,
xMin,
xOffset,
0f,
paddingRight.toPx(),
size.width
)
},
drawXAndYAxis = { scrollOffset, xZoom ->
val axisPoints = mutableListOf<Point>()
for (index in 0 until xMax.toInt()) {
axisPoints.add(Point(index.toFloat(), 0f))
}
XAxis(
xAxisData = xAxisData,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.wrapContentHeight()
.clip(
RowClip(
columnWidth,
paddingRight
)
)
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
},
xStart = columnWidth + LocalDensity.current.run {
(barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) / 2 + horizontalExtraSpace.toPx()
},
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = if (barPoints.isEmpty()) linePoints else axisPoints,
axisStart = columnWidth
)
YAxis(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxHeight()
.wrapContentWidth()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
},
yAxisData = yAxisData
)
},
onDraw = { scrollOffset, xZoom ->
val yBottom = size.height - rowHeight
val yOffset =
((yBottom - yAxisData.axisTopPadding.toPx()) / maxElementInYAxis)
xOffset = if(barPoints.isEmpty())xAxisData.axisStepSize.toPx() * xZoom else
((barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) +
barPlotData.barStyle.paddingBetweenBars.toPx()) * xZoom
val xLeft =
columnWidth + horizontalExtraSpace.toPx() + (xAxisData.startDrawPadding.toPx() * xZoom)
val barTapLocks = mutableMapOf<Int, Pair<BarData, Offset>>()
val linePointLocks = mutableMapOf<Int, Pair<Point, Offset>>()
for (plotData in combinedPlotDataList) {
when (plotData) {
is LinePlotData -> {
// Draw line chart
val xStartPosition =
columnWidth + horizontalExtraSpace.toPx() +
((barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) / 2) + (xAxisData.startDrawPadding.toPx() * xZoom)
plotData.lines.forEach { line ->
val pointsData = getMappingPointsToGraph(
line.dataPoints,
xMin,
xOffset,
xStartPosition,
scrollOffset,
yBottom,
yMin,
yOffset
)
val (cubicPoints1, cubicPoints2) = getCubicPoints(pointsData)
// Draw cubic line using the points and form a line graph
val cubicPath =
drawStraightOrCubicLine(
pointsData,
cubicPoints1,
cubicPoints2,
line.lineStyle
)
// Draw area under curve
drawShadowUnderLineAndIntersectionPoint(
cubicPath,
pointsData,
yBottom,
line
)
pointsData.forEachIndexed { index, point ->
if (isTapped && point.isPointTapped(
tapOffset,
tapPadding.toPx()
)
) {
// Dealing with only one line graph hence tapPointLocks[0]
linePointLocks[0] = line.dataPoints[index] to point
}
}
if (isTapped && linePointLocks.isNotEmpty()) {
drawHighLightOnSelectedPoint(
linePointLocks,
columnWidth,
paddingRight,
yBottom,
line.selectionHighlightPoint?.copy(
isHighlightLineRequired = false
)
)
if (line.selectionHighlightPopUp != null) {
val x =
linePointLocks.values.firstOrNull()?.second?.x
if (x != null) identifiedPoint =
linePointLocks.values.map { it.first }.first()
val selectedOffset =
linePointLocks.values.firstOrNull()?.second
if (selectedOffset.isNotNull()) {
drawHighlightText(
identifiedPoint,
selectedOffset ?: Offset(0f, 0f),
line.selectionHighlightPopUp
)
}
}
}
}
}
is BarPlotData -> {
// Draw bar graph
plotData.groupBarList.forEachIndexed { index, groupBarData ->
var insideOffset = 0f
groupBarData.barList.forEachIndexed { subIndex, individualBar ->
val drawOffset = getGroupBarDrawOffset(
index, individualBar.point.y, xOffset, xLeft,
scrollOffset, yBottom, yOffset, 0f,
0f,
xAxisData.startDrawPadding.toPx(),
xZoom,
barPlotData.barStyle.barWidth.toPx()
)
val height = yBottom - drawOffset.y
val individualOffset =
Offset(drawOffset.x + insideOffset, drawOffset.y)
// drawing each individual bars
drawGroupBarGraph(
plotData,
individualOffset,
height,
subIndex
)
insideOffset += plotData.barStyle.barWidth.toPx()
val middleOffset =
Offset(
individualOffset.x + plotData.barStyle.barWidth.toPx() / 2,
drawOffset.y
)
// store the tap points for selection
if (isTapped && middleOffset.isTapped(
tapOffset,
plotData.barStyle.barWidth.toPx(),
yBottom,
tapPadding.toPx()
)
) {
barTapLocks[0] = individualBar to individualOffset
}
}
}
}
}
}
if (isTapped && linePointLocks.isEmpty() &&
barPlotData.barStyle.selectionHighlightData != null
) {
// highlighting the selected bar and showing the data points
identifiedBarPoint = highlightGroupBar(
barTapLocks,
true,
identifiedBarPoint,
barPlotData.barStyle.selectionHighlightData,
isTapped,
columnWidth,
yBottom,
paddingRight,
yOffset,
barPlotData.barStyle.barWidth
)
}
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
},
onPointClicked = { offset: Offset, _: Float ->
isTapped = true
tapOffset = offset
},
onScroll = {
isTapped = false
}
)
if (isTalkBackEnabled) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(), backgroundColor = Color.White, content = {
LazyColumn {
items(xMax.toInt()) { index ->
Column {
val lineData: MutableList<Point> = mutableListOf()
val lineColors: MutableList<Color> = mutableListOf()
linePlotData.lines.forEachIndexed { _, line ->
lineColors.add(line.lineStyle.color)
line.dataPoints
line.dataPoints.forEachIndexed { pointIndex, point ->
if (pointIndex == index) {
lineData.add(point)
}
}
}
val groupBarData: GroupBar? =
barPlotData.groupBarList.filterIndexed { barIndex, _ -> barIndex == index }
.firstOrNull()
CombinedChartInfo(
pointsList = lineData,
lineColor = lineColors,
groupBar = groupBarData,
axisLabelDescription = xAxisData.axisLabelDescription(
xAxisData.labelData(index)
),
barColorPaletteList = barPlotData.barColorPaletteList,
dividerColor = accessibilityConfig.dividerColor,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
/**
* Returns data for given plot type from the combinedPlotDataList
* @param combinedPlotDataList : List of combined plot data
* @param type: Type of plot of with data is to be returned.
*/
private fun getDataFromType(combinedPlotDataList: List<PlotData>, type: PlotType): PlotData? {
return when (type) {
is PlotType.Line -> combinedPlotDataList.filterIsInstance<LinePlotData?>().firstOrNull()
is PlotType.Bar -> combinedPlotDataList.filterIsInstance<BarPlotData?>().firstOrNull()
else -> null // Handle if required in future.
}
}
/**
*
* Used to draw the individual bars
* @param barPlotData : all meta data related to the bar graph
* @param drawOffset: topLeft offset for the drawing the bar
* @param height : height of the bar graph
* @param subIndex : Index of the bar
*/
private fun DrawScope.drawGroupBarGraph(
barPlotData: BarPlotData, drawOffset: Offset,
height: Float,
subIndex: Int
) {
val color = if (subIndex < barPlotData.barColorPaletteList.size) {
barPlotData.barColorPaletteList[subIndex]
} else Color.Transparent
with(barPlotData.barStyle) {
drawRoundRect(
color = color,
topLeft = drawOffset,
size = Size(barWidth.toPx(), height),
cornerRadius = CornerRadius(
cornerRadius.toPx(),
cornerRadius.toPx()
),
style = barDrawStyle,
blendMode = barBlendMode
)
}
}
@@ -0,0 +1,39 @@
package co.yml.charts.ui.combinedchart.model
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.model.AccessibilityConfig
import co.yml.charts.common.model.PlotData
/**
*
* CombinedLineAndBarGraphData data class that contains all params user need to define to draw a bar and line graph.
* @param combinedPlotDataList: Defines list of plot data's to be drawn and order of graph drawing is maintained as
* per the list order. Distinct plot data's are only allowed.
* @param xAxisData: All the configurations related to X-Axis to be defined here in [AxisData]
* @param yAxisData: All the configurations related to Y-Axis to be defined here in [AxisData]
* @param paddingTop: Padding from the top of the canvas to start of the graph container.
* @param bottomPadding: Padding from the bottom of the canvas to bottom of the graph container.
* @param containerPaddingEnd: Container inside padding end after the last point of the graph.
* @param backgroundColor: Background color of the Y & X components.,
* @param isZoomAllowed: True if zoom in for all vertical graph components is allowed else false.
* @param tapPadding: Tap padding offset for selected point.
* @param horizontalExtraSpace: Extra padding at the end of the canvas container.
* @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig]
*/
data class CombinedChartData(
val combinedPlotDataList: List<PlotData>,
val xAxisData: AxisData = AxisData.Builder().build(),
val yAxisData: AxisData = AxisData.Builder().build(),
val paddingTop: Dp = 30.dp,
val bottomPadding: Dp = 10.dp,
val paddingEnd: Dp = 10.dp,
val horizontalExtraSpace: Dp = 10.dp,
val containerPaddingEnd: Dp = 15.dp,
val backgroundColor: Color = Color.White,
val tapPadding: Dp = 10.dp,
val isZoomAllowed: Boolean = true,
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig()
)
@@ -0,0 +1,524 @@
@file:OptIn(ExperimentalMaterialApi::class, ExperimentalMaterialApi::class)
package co.yml.charts.ui.linechart
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import co.yml.charts.axis.XAxis
import co.yml.charts.axis.YAxis
import co.yml.charts.axis.getXAxisScale
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.CombinedChartInfo
import co.yml.charts.common.components.accessibility.LinePointInfo
import co.yml.charts.common.extensions.RowClip
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.common.extensions.drawGridLines
import co.yml.charts.common.extensions.isNotNull
import co.yml.charts.common.model.Point
import co.yml.charts.ui.linechart.model.IntersectionPoint
import co.yml.charts.ui.linechart.model.Line
import co.yml.charts.ui.linechart.model.LineChartData
import co.yml.charts.ui.linechart.model.LineStyle
import co.yml.charts.ui.linechart.model.LineType
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
import kotlinx.coroutines.launch
/**
*
* [LineChart] compose method used for drawing a Line Chart.
* @param modifier :All modifier related property.
* Data class [LineChartData] to save all params needed to draw the line chart.
* @param lineChartData : Add data related to line chart.
*/
@Composable
fun LineChart(modifier: Modifier, lineChartData: LineChartData) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& lineChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(modifier = modifier) {
with(lineChartData) {
var columnWidth by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
var xOffset by remember { mutableStateOf(0f) }
val bgColor = MaterialTheme.colorScheme.surface
var isTapped by remember { mutableStateOf(false) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var selectionTextVisibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(Point(0f, 0f)) }
// Update must required values
val linePoints: List<Point> = linePlotData.lines.flatMap { line -> line.dataPoints.map { it } }
val (xMin, xMax, xAxisScale) = getXAxisScale(linePoints, xAxisData.steps)
val (yMin, _, yAxisScale) = getYAxisScale(linePoints, yAxisData.steps)
val maxElementInYAxis = getMaxElementInYAxis(yAxisScale, yAxisData.steps)
val xAxisData = xAxisData.copy(axisBottomPadding = bottomPadding)
val yAxisData = yAxisData.copy(
axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() },
axisTopPadding = paddingTop
)
ScrollableCanvasContainer(modifier = modifier
.semantics {
contentDescription = lineChartData.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
calculateMaxDistance = { xZoom ->
xOffset = xAxisData.axisStepSize.toPx() * xZoom
getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight.toPx(),
size.width,
containerPaddingEnd.toPx()
)
},
containerBackgroundColor = backgroundColor,
isPinchZoomEnabled = isZoomAllowed,
drawXAndYAxis = { scrollOffset, xZoom ->
YAxis(
modifier = Modifier
.fillMaxHeight()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
}, yAxisData = yAxisData
)
XAxis(xAxisData = xAxisData,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.align(Alignment.BottomStart)
.onGloballyPositioned {
rowHeight = it.size.height.toFloat()
}
.clip(
RowClip(
columnWidth, paddingRight
)
),
xStart = columnWidth,
scrollOffset = scrollOffset,
zoomScale = xZoom,
chartData = linePoints,axisStart = columnWidth)
},
onDraw = { scrollOffset, xZoom ->
linePlotData.lines.forEach { line->
val yBottom = size.height - rowHeight
val yOffset = ((yBottom - paddingTop.toPx()) / maxElementInYAxis)
xOffset = xAxisData.axisStepSize.toPx() * xZoom
val xLeft = columnWidth // To add extra space if needed
val pointsData = getMappingPointsToGraph(
line.dataPoints, xMin, xOffset, xLeft, scrollOffset, yBottom, yMin, yOffset
)
val (cubicPoints1, cubicPoints2) = getCubicPoints(pointsData)
val tapPointLocks = mutableMapOf<Int, Pair<Point, Offset>>()
// Draw guide lines
gridLines?.let {
drawGridLines(
yBottom,
yAxisData.axisTopPadding.toPx(),
xLeft,
paddingRight,
scrollOffset,
pointsData.size,
xZoom,
xAxisScale,
yAxisData.steps,
xAxisData.axisStepSize,
it
)
}
// Draw cubic line using the points and form a line graph
val cubicPath = drawStraightOrCubicLine(
pointsData, cubicPoints1, cubicPoints2, line.lineStyle
)
// Draw Lines and Points and AreaUnderLine
// Draw area under curve
drawShadowUnderLineAndIntersectionPoint(
cubicPath, pointsData, yBottom, line
)
// Draw column to make graph look scrollable under Yaxis
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
pointsData.forEachIndexed { index, point ->
if (isTapped && point.isTapped(tapOffset.x, xOffset)) {
// Dealing with only one line graph hence tapPointLocks[0]
tapPointLocks[0] = line.dataPoints[index] to point
}
}
val selectedOffset = tapPointLocks.values.firstOrNull()?.second
if (selectionTextVisibility && selectedOffset.isNotNull()) {
drawHighlightText(
identifiedPoint,
selectedOffset ?: Offset(0f, 0f),
line.selectionHighlightPopUp
)
}
if (isTapped) {
val x = tapPointLocks.values.firstOrNull()?.second?.x
if (x != null) identifiedPoint =
tapPointLocks.values.map { it.first }.first()
drawHighLightOnSelectedPoint(
tapPointLocks,
columnWidth,
paddingRight,
yBottom,
line.selectionHighlightPoint
)
}
}
},
onPointClicked = { offset: Offset, _: Float ->
isTapped = true
selectionTextVisibility = true
tapOffset = offset
},
onScroll = {
isTapped = false
selectionTextVisibility = false
},
onZoomInAndOut = {
isTapped = false
selectionTextVisibility = false
})
if (isTalkBackEnabled) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(),
backgroundColor = Color.White,
content = {
LazyColumn {
items(count = linePlotData.lines.size) { lineIndex ->
linePlotData.lines[lineIndex].dataPoints.forEachIndexed { pointIndex, point ->
Column {
LinePointInfo(
xAxisData.axisLabelDescription(
xAxisData.labelData(
pointIndex
)
),
point.description,
linePlotData.lines[lineIndex].lineStyle.color,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
/**
*
* returns the list of transformed points supported to be drawn on the container using the input points .
* @param lineChartPoints :Input data points
* @param xMin: Min X-Axis value.
* @param xOffset : Total distance between two X-Axis points.
* @param xLeft: Total left padding in X-Axis.
* @param scrollOffset : Total scrolled offset.
* @param yBottom : Bottom start offset for X-Axis.
* @param yMin : Min Y-Axis value.
* @param yOffset : Distance between two Y-Axis points.
*/
fun getMappingPointsToGraph(
lineChartPoints: List<Point>,
xMin: Float,
xOffset: Float,
xLeft: Float,
scrollOffset: Float,
yBottom: Float,
yMin: Float,
yOffset: Float,
): MutableList<Offset> {
val pointsData = mutableListOf<Offset>()
lineChartPoints.forEachIndexed { _, point ->
val (x, y) = point
val x1 = ((x - xMin) * xOffset) + xLeft - scrollOffset
val y1 = yBottom - ((y - yMin) * yOffset)
pointsData.add(Offset(x1, y1))
}
return pointsData
}
/**
*
* returns the max scrollable distance based on the points to be drawn along with padding etc.
* @param columnWidth : Width of the Y-Axis.
* @param xMax : Max X-Axis value.
* @param xMin: Min X-Axis value.
* @param xOffset: Total distance between two X-Axis points.
* @param paddingRight : Padding at the end of the canvas.
* @param canvasWidth : Total available canvas width.
* @param containerPaddingEnd : Container inside padding end after the last point of the graph.
*/
fun getMaxScrollDistance(
columnWidth: Float,
xMax: Float,
xMin: Float,
xOffset: Float,
paddingRight: Float,
canvasWidth: Float,
containerPaddingEnd: Float = 0f
): Float {
val xLastPoint = (xMax - xMin) * xOffset + columnWidth + paddingRight + containerPaddingEnd
return if (xLastPoint > canvasWidth) {
xLastPoint - canvasWidth
} else 0f
}
/**
*
* DrawScope.drawStraightOrCubicLine extension method used for drawing a straight/cubic line for a given Point(x,y).
* @param pointsData : List of points to be drawn on the canvas
* @param cubicPoints1 : List of average left side values for a given Point(x,y).
* @param cubicPoints2 : List of average right side values for a given Point(x,y).
* @param lineStyle : All styles related to the path are included in [LineStyle].
*/
fun DrawScope.drawStraightOrCubicLine(
pointsData: MutableList<Offset>,
cubicPoints1: MutableList<Offset>,
cubicPoints2: MutableList<Offset>,
lineStyle: LineStyle
): Path {
val path = Path()
path.moveTo(pointsData.first().x, pointsData.first().y)
for (i in 1 until pointsData.size) {
when (lineStyle.lineType) {
is LineType.Straight -> {
path.lineTo(pointsData[i].x, pointsData[i].y)
}
is LineType.SmoothCurve -> {
path.cubicTo(
cubicPoints1[i - 1].x,
cubicPoints1[i - 1].y,
cubicPoints2[i - 1].x,
cubicPoints2[i - 1].y,
pointsData[i].x,
pointsData[i].y
)
}
}
}
with(lineStyle) {
drawPath(
path,
color = color,
style = getDrawStyleForPath(lineStyle.lineType, lineStyle),
alpha = alpha,
colorFilter = colorFilter,
blendMode = blendMode
)
}
return path
}
/**
*
* Returns the Drawstyle for the path.
* @param lineType : Type of the line [LineType]
* @param lineStyle : The style for the path [lineStyle]
*/
internal fun getDrawStyleForPath(
lineType: LineType, lineStyle: LineStyle
): DrawStyle = if (lineType.isDotted) Stroke(
width = lineStyle.width, pathEffect = PathEffect.dashPathEffect(lineType.intervals)
) else lineStyle.style
/**
*
* DrawScope.drawPointOnLine extension method used for drawing a circle/mark on a line for a given Point(x,y).
* @param offset : Point at which circle/mark has to be drawn.
*/
internal fun DrawScope.drawPointOnLine(offset: Offset, intersectionPoint: IntersectionPoint?) {
intersectionPoint?.draw?.let { it(this, offset) }
}
/**
*
* DrawScope.drawUnderScrollMask extension method used for drawing a rectangular mask to make graph scrollable under the YAxis.
* @param columnWidth : Width of the rectangular mask here width of Y Axis is used.
* @param paddingRight : Padding given at the end of the graph.
* @param bgColor : Background of the rectangular mask.
*/
private fun DrawScope.drawUnderScrollMask(columnWidth: Float, paddingRight: Dp, bgColor: Color) {
drawRect(
bgColor, Offset(0f, 0f), Size(columnWidth, size.height)
)
drawRect(
bgColor,
Offset(size.width - paddingRight.toPx(), 0f),
Size(paddingRight.toPx(), size.height)
)
}
/**
*
* DrawScope.drawShadowUnderLineAndIntersectionPoint extension method used for drawing a
* shadow below the line graph points and also drawing intersection points on the line graph.
* @param cubicPath : Path used to draw the shadow
* @param pointsData : List of the points on the Line graph.
* @param yBottom : Offset of X-Axis starting position i.e shade to be drawn until.
* @param line : line on which shadow & intersectionPoints has to be drawn.
*/
fun DrawScope.drawShadowUnderLineAndIntersectionPoint(
cubicPath: Path, pointsData: MutableList<Offset>, yBottom: Float, line: Line
) {
if (line.shadowUnderLine.isNotNull()) {
cubicPath.lineTo(pointsData.last().x, yBottom)
cubicPath.lineTo(pointsData.first().x, yBottom)
line.shadowUnderLine?.draw?.let { it(this, cubicPath) }
}
if (line.intersectionPoint.isNotNull()) {
pointsData.forEach { offset ->
drawPointOnLine(offset, line.intersectionPoint)
}
}
}
/**
*
* getCubicPoints method provides left and right average value for a given point to get a smooth curve.
* @param pointsData : List of the points on the Line graph.
*/
fun getCubicPoints(pointsData: List<Offset>): Pair<MutableList<Offset>, MutableList<Offset>> {
val cubicPoints1 = mutableListOf<Offset>()
val cubicPoints2 = mutableListOf<Offset>()
for (i in 1 until pointsData.size) {
cubicPoints1.add(
Offset(
(pointsData[i].x + pointsData[i - 1].x) / 2, pointsData[i - 1].y
)
)
cubicPoints2.add(
Offset(
(pointsData[i].x + pointsData[i - 1].x) / 2, pointsData[i].y
)
)
}
return Pair(cubicPoints1, cubicPoints2)
}
/**
* Used to draw the highlighted text
* @param identifiedPoint : Selected points
* @param selectedOffset: Offset selected
* @param selectionHighlightPopUp : Data class with all styling related info [SelectionHighlightPopUp]
*/
fun DrawScope.drawHighlightText(
identifiedPoint: Point,
selectedOffset: Offset,
selectionHighlightPopUp: SelectionHighlightPopUp?
) {
selectionHighlightPopUp?.run {
draw(selectedOffset, identifiedPoint)
}
}
/**
*
* DrawScope.drawHighLightOnSelectedPoint extension method used for drawing and highlight the selected
* point when dragging.
* @param dragLocks : List of points to be drawn on the canvas and that can be selected.
* @param columnWidth : Width of the Axis in the left or right.
* @param paddingRight : Padding given to the right side of the canvas.
* @param yBottom : Start position from below of the canvas.
* @param selectionHighlightPoint : Data class to define all the styles to be drawn in [SelectionHighlightPoint]
*/
fun DrawScope.drawHighLightOnSelectedPoint(
dragLocks: MutableMap<Int, Pair<Point, Offset>>,
columnWidth: Float,
paddingRight: Dp,
yBottom: Float,
selectionHighlightPoint: SelectionHighlightPoint?
) {
if (selectionHighlightPoint.isNotNull()) {
dragLocks.values.firstOrNull()?.let { (_, location) ->
val (x, y) = location
if (x >= columnWidth && x <= size.width - paddingRight.toPx()) {
selectionHighlightPoint?.draw?.let { it(this, Offset(x, y)) }
if (selectionHighlightPoint?.isHighlightLineRequired == true) {
selectionHighlightPoint.drawHighlightLine(
this, Offset(x, yBottom), Offset(x, y)
)
}
}
}
}
}
@@ -0,0 +1,38 @@
package co.yml.charts.ui.linechart
import androidx.compose.ui.geometry.Offset
import co.yml.charts.common.model.Point
/**
* returns total offset for given no of steps and offset .
* @param offset: Distance of each step.
* @param steps: No of steps in Y-Axis.
*/
fun getMaxElementInYAxis(offset: Float, steps: Int): Float {
return (if (steps > 1) steps - 1 else 1) * offset
}
/**
* returns Triple value with minY,maxY and scale of each Y-Axis step.
* @param points: List of points to be drawn.
* @param steps: Count of steps in the Y-Axis.
*/
fun getYAxisScale(
points: List<Point>,
steps: Int
): Triple<Float, Float, Float> {
val yMin = points.takeIf { it.isNotEmpty() }?.minOf { it.y } ?: 0f
val yMax = points.takeIf { it.isNotEmpty() }?.maxOf { it.y } ?: 0f
val totalSteps = (yMax - yMin)
val scale =
totalSteps / if (steps > 1) (steps - 1) else 1 // First step starts from 0 by default
return Triple(yMin, yMax, scale)
}
/**
* Returns true if the given tap offset is selected point or not else false
* @param tapOffset: Offset of the tapped point.
* @param xOffset: Distance between two points in X-Axis.
*/
fun Offset.isTapped(tapOffset: Float, xOffset: Float) =
((tapOffset) > x - xOffset / 2) && ((tapOffset) < x + xOffset / 2)
@@ -0,0 +1,57 @@
package co.yml.charts.ui.linechart.model
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Represents the grid lines for any graph
* @param color: Defines the color of the grid lines.
* @param lineWidth: Defines the width of the lines.
* @param pathEffect optional effect or pattern to apply to the line.
* @param alpha Opacity to be applied to the path from 0.0f to 1.0f representing.
* fully transparent to fully opaque respectively.
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination.
* @param blendMode Blending algorithm to be applied to the path when it is drawn.
* @param enableHorizontalLines False to disable horizontal line else true.
* @param enableVerticalLines False to disable vertical lines else true.
* @param drawHorizontalLines Draw param used to draw the horizontal lines with given input params.
* @param drawVerticalLines Draw param used to draw the vertical lines with given input params.
*/
data class GridLines(
val color: Color = Color.LightGray,
val lineWidth: Dp = 1.dp,
val pathEffect: PathEffect? = null,
val alpha: Float = 1.0f,
val colorFilter: ColorFilter? = null,
val blendMode: BlendMode = DrawScope.DefaultBlendMode,
val enableHorizontalLines: Boolean = true,
val enableVerticalLines: Boolean = true,
val drawHorizontalLines: DrawScope.(Float, Float, Float) -> Unit = { xStart, y, xEnd ->
drawLine(
color = color,
start = Offset(xStart, y),
end = Offset(xEnd, y),
strokeWidth = lineWidth.toPx(),
pathEffect = pathEffect,
colorFilter = colorFilter,
blendMode = blendMode
)
},
val drawVerticalLines: DrawScope.(Float, Float, Float) -> Unit = { x, yStart, yEnd ->
drawLine(
color = color,
start = Offset(x, yStart),
end = Offset(x, yEnd),
strokeWidth = lineWidth.toPx(),
pathEffect = pathEffect,
colorFilter = colorFilter,
blendMode = blendMode
)
}
)
@@ -0,0 +1,44 @@
package co.yml.charts.ui.linechart.model
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Represents a point on the line graph
*
* @param color The color or fill to be applied to the circle
* @param radius The radius of the circle
* @param alpha Opacity to be applied to the circle from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param style Whether or not the circle is stroked or filled in
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination
* @param blendMode Blending algorithm to be applied to the brush
* @param draw override this to change the default drawCircle implementation. You are provided
* with the actual [Point] that represents the intersection.
*/
data class IntersectionPoint(
val color: Color = Color.Black,
val radius: Dp = 6.dp,
val alpha: Float = 1.0f,
val style: DrawStyle = Fill,
val colorFilter: ColorFilter? = null,
val blendMode: BlendMode = DrawScope.DefaultBlendMode,
val draw: DrawScope.(Offset) -> Unit = { center ->
drawCircle(
color,
radius.toPx(),
center,
alpha,
style,
colorFilter,
blendMode
)
}
)
@@ -0,0 +1,59 @@
package co.yml.charts.ui.linechart.model
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.model.AccessibilityConfig
import co.yml.charts.common.model.Point
/**
*
* LineGraphData data class that contains all params user need to define to draw a line graph.
* @param linePlotData: The path to be drawn on the graph represented by a line.
* @param xAxisData: All the configurations related to X-Axis to be defined here in [AxisData]
* @param yAxisData: All the configurations related to Y-Axis to be defined here in [AxisData]
* @param isZoomAllowed: True if zoom in for all vertical graph components is allowed else false.
* @param paddingTop: Padding from the top of the canvas to start of the graph container.
* @param paddingRight: Padding from the end of the canvas to end of the graph container.
* @param bottomPadding: Padding from the bottom of the canvas to bottom of the graph container.
* @param containerPaddingEnd: Container inside padding end after the last point of the graph.
* @param backgroundColor: Background color of the Y & X components,
* @param gridLines: This enables graph to draw horizontal and vertical grid lines
* @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig]
*/
data class LineChartData(
val linePlotData: LinePlotData,
val xAxisData: AxisData = AxisData.Builder().build(),
val yAxisData: AxisData = AxisData.Builder().build(),
val isZoomAllowed: Boolean = true,
val paddingTop: Dp = 30.dp,
val bottomPadding: Dp = 10.dp,
val paddingRight: Dp = 10.dp,
val containerPaddingEnd: Dp = 15.dp,
val backgroundColor: Color = Color.White,
val gridLines: GridLines? = null,
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig()
)
/**
* Represent a Line in the [co.yml.charts.ui.linechart]
*
* @param dataPoints list of points [Point] in the line
* @param lineStyle Adds styling options in [LineStyle] to the line path drawn.
* @param intersectionPoint drawing logic to draw the point itself in [IntersectionPoint].
* If null, the point is not drawn.
* @param selectionHighlightPoint drawing logic to draw the highlight at the point when it is selected
* in [SelectionHighlightPoint] If null, the point won't be highlighted on selection
* @param shadowUnderLine drawing logic for the section under the line in [ShadowUnderLine].
* @param selectionHighlightPopUp All prams related to selection popup to be added here in [SelectionHighlightPopUp]
*/
data class Line(
val dataPoints: List<Point>,
val lineStyle: LineStyle = LineStyle(),
val intersectionPoint: IntersectionPoint? = null,
val selectionHighlightPoint: SelectionHighlightPoint? = null,
val shadowUnderLine: ShadowUnderLine? = null,
val selectionHighlightPopUp: SelectionHighlightPopUp? = null
)
@@ -0,0 +1,19 @@
package co.yml.charts.ui.linechart.model
import co.yml.charts.common.model.PlotData
import co.yml.charts.common.model.PlotType
/**
* LinePlotData is a data class that holds line graph related data and styling components
* @param plotType : Defines the type of plot/graph
* @param lines : Data related to the list of lines to be drawn.
*/
data class LinePlotData(
override val plotType: PlotType = PlotType.Line,
val lines: List<Line>
) : PlotData {
companion object {
fun default() =
LinePlotData(lines = listOf())
}
}
@@ -0,0 +1,52 @@
package co.yml.charts.ui.linechart.model
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Stroke
/**
* Handles styling for the path drawn in the line graph
*
* @param color Defines the color of the path or line.
* @param width Defines the width of the path/line stroke.
* @param alpha Defines the alpha of the path/line.
* @param style Defines if the path/line is filled or stroke.
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination.
* @param blendMode All prams related to selection popup to be added here in [SelectionHighlightPopUp]
*/
data class LineStyle(
val lineType: LineType = LineType.SmoothCurve(isDotted = false),
val color: Color = Color.Black,
val width: Float = 8f,
val alpha: Float = 1.0f,
val style: DrawStyle = Stroke(width = width),
val colorFilter: ColorFilter? = null,
val blendMode: BlendMode = DefaultBlendMode
)
/**
* Represent different types of line to be drawn
* @see LineType.Straight Draws straight lines with no curves, just a connection from
* one point to another.
* @see LineType.SmoothCurve Draws curved lines using cubic paths
* It has @param isDotted true if line has to be dotted else false.
* Also @param intervals Array of "on" and "off" distances for the dashed line segments
* phase - Pixel offset into the intervals array
*/
sealed class LineType {
abstract val isDotted: Boolean
abstract var intervals: FloatArray
data class Straight(
override val isDotted: Boolean = false,
override var intervals: FloatArray = floatArrayOf(30f, 10f)
) : LineType()
data class SmoothCurve(
override val isDotted: Boolean = false,
override var intervals: FloatArray = floatArrayOf(30f, 10f)
) : LineType()
}
@@ -0,0 +1,61 @@
package co.yml.charts.ui.linechart.model
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Represents the point when it is selected on the line graph
*
* @param color The color or fill to be applied to the circle
* @param radius The radius of the circle
* @param alpha Opacity to be applied to the circle from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param style Whether or not the circle is stroked or filled in
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination
* @param blendMode Blending algorithm to be applied to the brush
* @param draw override this to change the default drawCircle implementation. You are provided
* with the actual point [Offset].
*/
data class SelectionHighlightPoint(
val color: Color = Color.Red,
val radius: Dp = 6.dp,
val alpha: Float = 1.0f,
val style: DrawStyle = Fill,
val colorFilter: ColorFilter? = null,
val blendMode: BlendMode = DrawScope.DefaultBlendMode,
val draw: DrawScope.(Offset) -> Unit = { point ->
drawCircle(
color,
radius.toPx(),
point,
alpha,
style,
colorFilter,
blendMode
)
},
val isHighlightLineRequired: Boolean = true,
val drawHighlightLine: DrawScope.(Offset, Offset) -> Unit = { start, end ->
drawLine(
color,
start,
end,
(radius / 2).toPx(),
Stroke.DefaultCap,
PathEffect.dashPathEffect(floatArrayOf(40f, 20f)),
alpha,
colorFilter,
blendMode
)
}
)
@@ -0,0 +1,92 @@
package co.yml.charts.ui.linechart.model
import android.graphics.Paint
import android.graphics.Typeface
import android.text.TextPaint
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.common.extensions.getTextBackgroundRect
import co.yml.charts.common.model.Point
/**
* SelectionHighlightPopUp is a data class used to draw the pop on the given selected point on a line
* to identify the dimensions of the selected point.All the styling related params are included here
* @param backgroundColor : Defines the background color of the popup.
* @param backgroundAlpha : Defines the alpha of the background color.
* @param backgroundCornerRadius : Defines the corner radius of the background.
* @param backgroundColorFilter : ColorFilter to apply to the color when drawn into the destination.
* @param backgroundBlendMode :Blending algorithm to be applied to the path when it is drawn.
* @param backgroundStyle : Whether or not the path is stroked or filled in.
* @param labelSize : The size of the popUp label in [TextUnit].
* @param labelColor : The color of the label text.
* @param labelAlignment : The alignment of the label text.
* @param labelTypeface : The style of the label text.
* @param paddingBetweenPopUpAndPoint : The padding between the anchor position/ popup
* start position and the selected point on the line.
* @param popUpLabel : The text that can be shown on the popup given 2 input params x and y values
* @param draw : Draw the popUp marker on the selected point with 2 inputs [Offset] i.e selectedPoint
* and [Point] i.e the input data w.r.t selected point
*/
data class SelectionHighlightPopUp(
val backgroundColor: Color = Color.White,
val backgroundAlpha: Float = 0.7f,
val backgroundCornerRadius: CornerRadius = CornerRadius(5f),
val backgroundColorFilter: ColorFilter? = null,
val backgroundBlendMode: BlendMode = DrawScope.DefaultBlendMode,
val backgroundStyle: DrawStyle = Fill,
val paddingBetweenPopUpAndPoint: Dp = 20.dp,
val labelSize: TextUnit = 14.sp,
val labelColor: Color = Color.Black,
val labelAlignment: Paint.Align = Paint.Align.CENTER,
val labelTypeface: Typeface = Typeface.DEFAULT,
val popUpLabel: (Float, Float) -> (String) = { x, y ->
val xLabel = "x : ${x.toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
},
val draw: DrawScope.(Offset, Point) -> Unit = { selectedOffset, identifiedPoint ->
val highlightTextPaint = TextPaint().apply {
textSize = labelSize.toPx()
color = labelColor.toArgb()
textAlign = labelAlignment
typeface = labelTypeface
}
val label = popUpLabel(identifiedPoint.x, identifiedPoint.y)
drawContext.canvas.nativeCanvas.apply {
val background = getTextBackgroundRect(
selectedOffset.x,
selectedOffset.y,
label,
highlightTextPaint
)
drawRoundRect(
color = backgroundColor,
topLeft = Offset(
background.left.toFloat(),
background.top.toFloat() - paddingBetweenPopUpAndPoint.toPx()
),
size = Size(background.width().toFloat(), background.height().toFloat()),
alpha = backgroundAlpha,
cornerRadius = backgroundCornerRadius,
colorFilter = backgroundColorFilter,
blendMode = backgroundBlendMode,
style = backgroundStyle
)
drawText(
label,
selectedOffset.x,
selectedOffset.y - paddingBetweenPopUpAndPoint.toPx(),
highlightTextPaint
)
}
}
)
@@ -0,0 +1,43 @@
package co.yml.charts.ui.linechart.model
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
/**
* Controls the drawing behaviour of the area/section under the line.
*
* @param color Color to be applied to the path
* @param alpha Opacity to be applied to the path from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param style Whether or not the path is stroked or filled in
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination
* @param blendMode Blending algorithm to be applied to the path when it is drawn
* @param draw override this to change the default drawPath implementation. You are provided with
* the [Path] of the line
* @param drawMultiColor override this to change the default drawPath implementation. You are provided with
* the [Path] of the line, custom [Color] and [Brush]
*/
data class ShadowUnderLine(
val color: Color = Color.Black,
val brush: Brush? = null,
val alpha: Float = 0.1f,
val style: DrawStyle = Fill,
val colorFilter: ColorFilter? = null,
val blendMode: BlendMode = DrawScope.DefaultBlendMode,
val draw: DrawScope.(Path) -> Unit = { path ->
if (brush != null) {
drawPath(path, brush, alpha, style, colorFilter, blendMode)
} else {
drawPath(path, color, alpha, style, colorFilter, blendMode)
}
},
val drawMultiColor: DrawScope.(Path, Color, Brush?) -> Unit = { path, multiColor, multiColorBrush ->
if (multiColorBrush != null) {
drawPath(path, multiColorBrush, alpha, style, colorFilter, blendMode)
} else {
drawPath(path, multiColor, alpha, style, colorFilter, blendMode)
}
}
)
@@ -0,0 +1,52 @@
package co.yml.charts.ui.piechart
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import co.yml.charts.common.model.PlotType
import co.yml.charts.ui.piechart.charts.DonutPieChart
import co.yml.charts.ui.piechart.charts.PieChart
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.charts.ui.piechart.models.PieChartData
@OptIn(ExperimentalMaterialApi::class)
object ChartWrapper {
/**
* Wrapper compose method for drawing Pie Chart and Donut chart.
* @param modifier : All modifier related property
* @param plotType: Type of the chart (Pie or Donut)
* @param pieChartData: data list for the pie chart
* @param onSliceClick: Callback when any slice is clicked.
*/
@Composable
fun DrawChart(
modifier: Modifier,
plotType: PlotType,
pieChartData: PieChartData,
pieChartConfig: PieChartConfig,
onSliceClick: (PieChartData.Slice) -> Unit = {}
) {
when (plotType) {
is PlotType.Pie -> {
PieChart(
modifier = modifier,
pieChartData = pieChartData,
pieChartConfig = pieChartConfig,
onSliceClick = onSliceClick
)
}
is PlotType.Donut -> {
DonutPieChart(
modifier = modifier,
pieChartData = pieChartData,
pieChartConfig = pieChartConfig,
onSliceClick = onSliceClick
)
}
else -> { // T0DO Handle if required for other types
}
}
}
}
@@ -0,0 +1,14 @@
package co.yml.charts.ui.piechart
object PieChartConstants {
const val DEFAULT_PADDING = 10
const val DEFAULT_START_ANGLE = 270f
const val DEFAULT_STROKE_WIDTH = 100f
const val DEFAULT_SLICE_LABEL_TEXT_SIZE = 12
const val MINIMUM_PERCENTAGE_FOR_SLICE_LABELS = 5
const val TOTAL_ANGLE = 360
const val ONE_HUNDRED = 100
const val DESCRIPTION = "Double tap to know the chart in detail"
const val NO_SELECTED_SLICE = -1
}
@@ -0,0 +1,302 @@
package co.yml.charts.ui.piechart.charts
import android.graphics.Paint
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.NativeCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.SliceInfo
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.common.model.PlotType
import co.yml.charts.ui.piechart.PieChartConstants.NO_SELECTED_SLICE
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.charts.ui.piechart.models.PieChartData
import co.yml.charts.ui.piechart.utils.convertTouchEventPointToAngle
import co.yml.charts.ui.piechart.utils.proportion
import co.yml.charts.ui.piechart.utils.sweepAngles
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* Compose function for Drawing Donut chart
* @param modifier : All modifier related property
* @param pieChartData: data list for the pie chart
* @param pieChartConfig: configuration for the pie chart
* @param onSliceClick(pieChartData.Slice)->Unit: The event that captures the click
*/
@ExperimentalMaterialApi
@Composable
fun DonutPieChart(
modifier: Modifier,
pieChartData: PieChartData,
pieChartConfig: PieChartConfig,
onSliceClick: (PieChartData.Slice) -> Unit = {}
) {
// Sum of all the values
val sumOfValues = pieChartData.totalLength
// Calculate each proportion value
val proportions = pieChartData.slices.proportion(sumOfValues)
// Convert each proportions to angle
val sweepAngles = proportions.sweepAngles()
val progressSize = mutableListOf<Float>()
progressSize.add(sweepAngles.first())
for (x in 1 until sweepAngles.size) {
progressSize.add(sweepAngles[x] + progressSize[x - 1])
}
var activePie by rememberSaveable {
mutableStateOf(NO_SELECTED_SLICE)
}
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& pieChartConfig.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(
modifier = modifier
) {
val boxModifier = if (pieChartConfig.isClickOnSliceEnabled) {
modifier
.aspectRatio(ratio = 1f)
.background(pieChartConfig.backgroundColor)
.semantics {
contentDescription = pieChartConfig.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
}
} else {
modifier
.aspectRatio(1f)
.semantics {
contentDescription = pieChartConfig.accessibilityConfig.chartDescription
}
}
BoxWithConstraints(
modifier = boxModifier
) {
val sideSize = Integer.min(constraints.maxWidth, constraints.maxHeight)
val padding = (sideSize * pieChartConfig.chartPadding) / 100f
val size = Size(sideSize.toFloat() - padding, sideSize.toFloat() - padding)
val pathPortion = remember {
Animatable(initialValue = 0f)
}
if (pieChartConfig.isAnimationEnable) {
LaunchedEffect(key1 = Unit) {
pathPortion.animateTo(
1f, animationSpec = tween(pieChartConfig.animationDuration)
)
}
}
val canvasModifier = if (pieChartConfig.isClickOnSliceEnabled) {
Modifier
.width(sideSize.dp)
.height(sideSize.dp)
.pointerInput(true) {
detectTapGestures {
val clickedAngle = convertTouchEventPointToAngle(
sideSize.toFloat(),
sideSize.toFloat(),
it.x,
it.y
)
progressSize.forEachIndexed { index, item ->
if (clickedAngle <= item) {
activePie = if (activePie != index)
index
else
NO_SELECTED_SLICE
onSliceClick(pieChartData.slices[index])
return@detectTapGestures
}
}
}
}
} else {
Modifier
.width(sideSize.dp)
.height(sideSize.dp)
}
Canvas(
modifier = canvasModifier
) {
var sAngle = pieChartConfig.startAngle
sweepAngles.forEachIndexed { index, arcProgress ->
drawPie(
color = pieChartData.slices[index].color,
startAngle = sAngle,
arcProgress = if (pieChartConfig.isAnimationEnable)
arcProgress * pathPortion.value else arcProgress,
size = size,
padding = padding,
isDonut = pieChartData.plotType == PlotType.Donut,
strokeWidth = pieChartConfig.strokeWidth,
isActive = activePie == index,
pieChartConfig = pieChartConfig
)
sAngle += arcProgress
}
when {
activePie != -1 && pieChartConfig.labelVisible -> {
val selectedSlice = pieChartData.slices[activePie]
drawContext.canvas.nativeCanvas.apply {
val fontSize = pieChartConfig.labelFontSize.toPx()
var isValue = false
val textToDraw = when (pieChartConfig.labelType) {
PieChartConfig.LabelType.PERCENTAGE -> "${
proportions[activePie].roundToInt()
}%"
PieChartConfig.LabelType.VALUE -> {
isValue = true
selectedSlice.value.toString()
}
}
val labelColor = when (pieChartConfig.labelColorType) {
PieChartConfig.LabelColorType.SPECIFIED_COLOR -> pieChartConfig.labelColor
PieChartConfig.LabelColorType.SLICE_COLOR -> selectedSlice.color
}
val shouldShowUnit = isValue && pieChartConfig.sumUnit.isNotEmpty()
drawLabel(
canvas = this,
pieChartConfig = pieChartConfig,
labelColor = labelColor,
shouldShowUnit = shouldShowUnit,
fontSize = fontSize,
textToDraw = textToDraw,
sideSize = sideSize
)
}
}
activePie == -1 && pieChartConfig.isSumVisible -> {
drawContext.canvas.nativeCanvas.apply {
val fontSize = pieChartConfig.labelFontSize.toPx()
val textToDraw = "$sumOfValues"
drawLabel(
canvas = this,
pieChartConfig = pieChartConfig,
labelColor = pieChartConfig.labelColor,
shouldShowUnit = pieChartConfig.sumUnit.isNotEmpty(),
fontSize = fontSize,
textToDraw = textToDraw,
sideSize = sideSize
)
}
}
}
}
}
if (isTalkBackEnabled) {
with(pieChartConfig) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(),
backgroundColor = Color.White,
content = {
LazyColumn {
items(pieChartData.slices.size) { index ->
SliceInfo(
pieChartData.slices[index],
proportions[index].roundToInt(),
accessibilityConfig.descriptionTextSize
)
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
private fun drawLabel(
canvas: NativeCanvas,
pieChartConfig: PieChartConfig,
labelColor: Color,
shouldShowUnit: Boolean,
fontSize: Float,
textToDraw: String,
sideSize: Int
) {
val paint = Paint().apply {
isAntiAlias = true
color = labelColor.toArgb()
textSize = fontSize
textAlign = Paint.Align.CENTER
typeface = pieChartConfig.labelTypeface
}
val x = (sideSize / 2).toFloat()
var y: Float = (sideSize / 2).toFloat() + fontSize / 3
if (shouldShowUnit)
y -= (paint.fontSpacing / 4)
canvas.drawText(
textToDraw,
x, y,
paint
)
y += paint.fontSpacing
canvas.drawText(
pieChartConfig.sumUnit,
x,
y,
paint
)
}
@@ -0,0 +1,47 @@
package co.yml.charts.ui.piechart.charts
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import co.yml.charts.ui.piechart.models.PieChartConfig
/**
* Extension for drawing arcs
* @param color : Color for slice
* @param startAngle : StartAngle for the slice, from where to start draw
* @param arcProgress : Process of slice
* @param size : Size of the chart
* @param strokeWidth : StrokeWidth for the pie chart
* @param padding : Padding from top left
* @param isDonut : DonutPieChart or PieChart to indicate
* @param isActive : DonutPieChart zoom slice if IsActive
*/
fun DrawScope.drawPie(
color: Color,
startAngle: Float,
arcProgress: Float,
size: Size,
strokeWidth: Float = 100f,
padding: Float,
isDonut: Boolean = false,
isActive: Boolean = false,
pieChartConfig: PieChartConfig
) {
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = arcProgress,
useCenter = !isDonut,
size = size,
alpha = if (isActive) pieChartConfig.activeSliceAlpha else pieChartConfig.inActiveSliceAlpha,
style = if (isDonut) Stroke(
width = if (isActive) (strokeWidth + 20f) else strokeWidth,
) else Fill,
topLeft = Offset(padding / 2, padding / 2)
)
}
@@ -0,0 +1,261 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.piechart.charts
import android.graphics.Paint
import android.text.TextPaint
import android.text.TextUtils
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withRotation
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.SliceInfo
import co.yml.charts.common.extensions.collectIsTalkbackEnabledAsState
import co.yml.charts.common.extensions.getTextHeight
import co.yml.charts.common.model.PlotType
import co.yml.charts.ui.piechart.PieChartConstants.MINIMUM_PERCENTAGE_FOR_SLICE_LABELS
import co.yml.charts.ui.piechart.PieChartConstants.NO_SELECTED_SLICE
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.charts.ui.piechart.models.PieChartData
import co.yml.charts.ui.piechart.utils.convertTouchEventPointToAngle
import co.yml.charts.ui.piechart.utils.getSliceCenterPoints
import co.yml.charts.ui.piechart.utils.proportion
import co.yml.charts.ui.piechart.utils.sweepAngles
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Compose function used to Draw the Pie Chart.
* @param modifier : All modifier related property
* @param pieChartData: data list for the pie chart
* @param pieChartConfig: configuration for the pie chart
* @param onSliceClick(pieChartData.Slice)->Unit: The event that captures the click
*/
@Composable
fun PieChart(
modifier: Modifier,
pieChartData: PieChartData,
pieChartConfig: PieChartConfig,
onSliceClick: (PieChartData.Slice) -> Unit = {}
) {
// Sum of all the values
val sumOfValues = pieChartData.totalLength
// Calculate each proportion value
val proportions = pieChartData.slices.proportion(sumOfValues)
// Convert each proportions to angle
val sweepAngles = proportions.sweepAngles()
val progressSize = mutableListOf<Float>()
progressSize.add(sweepAngles.first())
for (x in 1 until sweepAngles.size) {
progressSize.add(sweepAngles[x] + progressSize[x - 1])
}
var activePie by rememberSaveable { mutableStateOf(NO_SELECTED_SLICE) }
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled && pieChartConfig.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(
modifier = modifier.fillMaxWidth()
) {
val boxModifier = if (pieChartConfig.isClickOnSliceEnabled) {
modifier
.aspectRatio(ratio = 1f)
.background(pieChartConfig.backgroundColor)
.semantics {
contentDescription = pieChartConfig.accessibilityConfig.chartDescription
}
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
}
} else {
modifier
.aspectRatio(1f)
.semantics {
contentDescription = pieChartConfig.accessibilityConfig.chartDescription
}
}
BoxWithConstraints(
modifier = boxModifier
) {
val sideSize = Integer.min(constraints.maxWidth, constraints.maxHeight)
val padding = (sideSize * pieChartConfig.chartPadding) / 100f
val size = Size(sideSize.toFloat() - padding, sideSize.toFloat() - padding)
val pathPortion = remember {
Animatable(initialValue = 0f)
}
if (pieChartConfig.isAnimationEnable) {
LaunchedEffect(key1 = Unit) {
pathPortion.animateTo(
1f, animationSpec = tween(pieChartConfig.animationDuration)
)
}
}
val canvasModifier = if (pieChartConfig.isClickOnSliceEnabled) {
Modifier
.width(sideSize.dp)
.height(sideSize.dp)
.pointerInput(true) {
detectTapGestures {
val clickedAngle = convertTouchEventPointToAngle(
sideSize.toFloat(),
sideSize.toFloat(),
it.x,
it.y
)
progressSize.forEachIndexed { index, item ->
if (clickedAngle <= item) {
activePie = if (activePie != index)
index
else
NO_SELECTED_SLICE
onSliceClick(pieChartData.slices[index])
return@detectTapGestures
}
}
}
}
} else {
Modifier
.width(sideSize.dp)
.height(sideSize.dp)
}
Canvas(modifier = canvasModifier) {
var sAngle = pieChartConfig.startAngle
val sliceLabelPaint = TextPaint().apply {
isAntiAlias = true
textSize = pieChartConfig.sliceLabelTextSize.toPx()
textAlign = Paint.Align.CENTER
color = pieChartConfig.sliceLabelTextColor.toArgb()
typeface = pieChartConfig.sliceLabelTypeface
}
sweepAngles.forEachIndexed { index, arcProgress ->
drawPie(
color = pieChartData.slices[index].color,
startAngle = sAngle,
arcProgress = if (pieChartConfig.isAnimationEnable) arcProgress * pathPortion.value else arcProgress,
size = size,
padding = padding,
isDonut = (pieChartData.plotType == PlotType.Pie).not(),
isActive = activePie == index,
pieChartConfig = pieChartConfig
)
// if percentage is less than 5 width of slice will be very small
if (pieChartConfig.showSliceLabels && proportions[index] >= MINIMUM_PERCENTAGE_FOR_SLICE_LABELS) {
val (arcCenter, x, y) = getSliceCenterPoints(
sAngle,
arcProgress,
size,
padding
)
// find the height of text
val height = pieChartData.slices[index].label.getTextHeight(sliceLabelPaint)
var label = pieChartData.slices[index].label
val ellipsizedText by lazy {
TextUtils.ellipsize(
label,
sliceLabelPaint,
pieChartConfig.sliceMinTextWidthToEllipsize.toPx(),
pieChartConfig.sliceLabelEllipsizeAt
).toString()
}
drawIntoCanvas {
it.nativeCanvas.withRotation(
arcCenter, x, y
) {
if (pieChartConfig.labelVisible) {
label = "$label ${proportions[index].roundToInt()}%"
}
it.nativeCanvas.drawText(
if (pieChartConfig.isEllipsizeEnabled) ellipsizedText
else label, x, y + abs(height) / 2, sliceLabelPaint
)
}
}
}
sAngle += arcProgress
}
}
}
if (isTalkBackEnabled) {
with(pieChartConfig) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(),
backgroundColor = Color.White,
content = {
LazyColumn {
items(pieChartData.slices.size) { index ->
SliceInfo(
pieChartData.slices[index], proportions[index].roundToInt(),
accessibilityConfig.descriptionTextSize
)
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
@@ -0,0 +1,83 @@
package co.yml.charts.ui.piechart.models
import android.graphics.Typeface
import android.text.TextUtils
import androidx.annotation.IntRange
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.common.model.AccessibilityConfig
import co.yml.charts.ui.piechart.PieChartConstants
import co.yml.charts.ui.piechart.PieChartConstants.DEFAULT_PADDING
import co.yml.charts.ui.piechart.PieChartConstants.DEFAULT_SLICE_LABEL_TEXT_SIZE
import co.yml.charts.ui.piechart.PieChartConstants.DEFAULT_START_ANGLE
import co.yml.charts.ui.piechart.PieChartConstants.DEFAULT_STROKE_WIDTH
/**
* PieChartConfig data class used to mention all config related param required to draw PieChart.
* @param startAngle: Starting angle
* @param showSliceLabels: Control the labels visibility
* @param sliceLabelTextSize: Text size of the labels
* @param sliceLabelTextColor: Text color of the labels
* @param sliceLabelTypeface: Typeface of the labels
* @param isAnimationEnable: Boolean Flag for enabling animation
* @param animationDuration: Duration of animation
* @param strokeWidth: Stroke width of Donut Chart
* @param labelFontSize: Label text font size
* @param labelTypeface: Label text typeface
* @param labelVisible: Label text visibility
* @param labelType: Type of label (percentage or value of slice), value available only for Donut Chart
* @param labelColor: Label text color
* @param labelColorType: Label text color according to the specified in color in labelColor or the slice color, only for Donut Chart
* @param activeSliceAlpha: Opacity of the active slice
* @param inActiveSliceAlpha: Opacity of the inactive slice
* @param isEllipsizeEnabled: Boolean flag for enabling ellipsize
* @param sliceMinTextWidthToEllipsize: Minimum width of the label post which label will be ellipsized
* @param sliceLabelEllipsizeAt: Position at which the label will be truncated or ellipsized
* @param chartPadding: Padding for the Pie chart/Donut Chart
* @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig]
* @param isSumVisible: When no slice is selected show the sum of values, only used for Donut Chart
* @param isClickOnSliceEnabled: Enable/Disable the click on slice
* @param sumUnit: The unit of the sum of values, only used for Donut Chart
*/
data class PieChartConfig(
val startAngle: Float = DEFAULT_START_ANGLE,
val showSliceLabels: Boolean = true,
val sliceLabelTextSize: TextUnit = DEFAULT_SLICE_LABEL_TEXT_SIZE.sp,
val sliceLabelTextColor: Color = Color.White,
val sliceLabelTypeface: Typeface = Typeface.DEFAULT,
val isAnimationEnable: Boolean = false,
@IntRange(from = 1) val animationDuration: Int = 500,
val strokeWidth: Float = DEFAULT_STROKE_WIDTH,
val labelFontSize: TextUnit = 24.sp,
val labelTypeface: Typeface = Typeface.DEFAULT,
val labelVisible: Boolean = false,
val labelType: LabelType = LabelType.PERCENTAGE,
val labelColor: Color = Color.White,
val labelColorType: LabelColorType = LabelColorType.SPECIFIED_COLOR,
val backgroundColor: Color = Color.White,
val activeSliceAlpha: Float = .8f,
val inActiveSliceAlpha: Float = 1f,
val isEllipsizeEnabled: Boolean = false,
val sliceMinTextWidthToEllipsize: Dp = 80.dp,
val sliceLabelEllipsizeAt: TextUtils.TruncateAt = TextUtils.TruncateAt.END,
val chartPadding: Int = DEFAULT_PADDING,
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig(
chartDescription = PieChartConstants.DESCRIPTION
),
val isSumVisible: Boolean = false,
val sumUnit: String = "",
val isClickOnSliceEnabled: Boolean = true
){
enum class LabelType {
PERCENTAGE,
VALUE
}
enum class LabelColorType {
SPECIFIED_COLOR,
SLICE_COLOR
}
}
@@ -0,0 +1,35 @@
package co.yml.charts.ui.piechart.models
import androidx.compose.ui.graphics.Color
import co.yml.charts.common.model.PlotData
import co.yml.charts.common.model.PlotType
import co.yml.charts.ui.piechart.models.PieChartData.Slice
import co.yml.charts.ui.piechart.utils.sum
/**
* PieChartData is a data class to mention all the data needed to draw slices in the pie/donut chart.
* @param slices: Defines the list of slices [Slice] to be drawn.
* @param plotType: Defines the type of the chart.
*/
data class PieChartData(val slices: List<Slice>, override val plotType: PlotType) : PlotData {
val totalLength: Float
get() {
return slices.sum()
}
/**
* Slice is data class to mention data of each arc in a 360 degree chart.
* @param label: Name of the arc.
* @param value: Value of the arc.
* @param color: Color of the arc.
* @param sliceDescription: Description of the arc for accessibility service.
*/
data class Slice(
val label: String,
val value: Float,
val color: Color,
val sliceDescription: (Int) -> String = { slicePercentage ->
"Slice name : $label \nPercentage : $slicePercentage %"
}
)
}
@@ -0,0 +1,87 @@
package co.yml.charts.ui.piechart.utils
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import co.yml.charts.ui.piechart.PieChartConstants.ONE_HUNDRED
import co.yml.charts.ui.piechart.PieChartConstants.TOTAL_ANGLE
import co.yml.charts.ui.piechart.models.PieChartData
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
* Returns the angle for given touch point
* @param width: Width of the chart.
* @param height: Height of the chart.
* @param xPos: X offset of the tap point.
* @param yPos: Y offset of the tap point.
*/
fun convertTouchEventPointToAngle(
width: Float,
height: Float,
xPos: Float,
yPos: Float
): Double {
val x = xPos - (width * 0.5f)
val y = yPos - (height * 0.5f)
var angle = Math.toDegrees(atan2(y.toDouble(), x.toDouble()) + Math.PI / 2)
angle = if (angle < 0) angle + 360 else angle
return angle
}
/**
* Returns the sum of all the arc values
*/
fun List<PieChartData.Slice>.sum(): Float {
return this.map { it.value }.sum()
}
/**
* Returns the center points of the slice
* @param sAngle Start angle of the point
* @param arcProgress Progress angle of the point
* @param size Size of the canvas
* @param padding padding of the canvas
*/
fun getSliceCenterPoints(sAngle: Float, arcProgress: Float, size: Size, padding: Float):
Triple<Float, Float, Float> {
val arcCenter = sAngle + (arcProgress / 2)
// Middle point radius is half of the radius of the pie chart
val pointRadius = size.width / 4
/* Calculate the x & y co-ordinates to show the label/percentage tex
* find points using angle and radius
*https://en.wikipedia.org/wiki/Polar_coordinate_system#Converting_between_polar_and_Cartesian_coordinates
* */
val x =
(pointRadius * cos(Math.toRadians(arcCenter.toDouble()))) +
size.center.x + padding / 2
val y =
(pointRadius * sin(Math.toRadians(arcCenter.toDouble()))) +
size.center.y + padding / 2
return Triple(arcCenter,x.toFloat(),y.toFloat())
}
/**
* Returns the calculated proportion value of each arc
* @param total: Total of the the slices.
*/
fun List<PieChartData.Slice>.proportion(total:Float): List<Float> {
return this.map {
it.value * ONE_HUNDRED / total
}
}
/**
* Returns the list of sweep angles
*/
fun List<Float>.sweepAngles(): List<Float> {
return this.map {
TOTAL_ANGLE * it / ONE_HUNDRED
}
}
@@ -0,0 +1,988 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.charts.ui.wavechart
import android.graphics.Paint
import android.text.TextPaint
import android.text.TextUtils
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withRotation
import co.yml.charts.axis.AxisData
import co.yml.charts.axis.YAxis
import co.yml.charts.axis.getXAxisScale
import co.yml.charts.chartcontainer.container.ScrollableCanvasContainer
import co.yml.charts.common.components.ItemDivider
import co.yml.charts.common.components.accessibility.AccessibilityBottomSheetDialog
import co.yml.charts.common.components.accessibility.LinePointInfo
import co.yml.charts.common.extensions.*
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.ChartConstants
import co.yml.charts.ui.barchart.drawUnderScrollMask
import co.yml.charts.ui.linechart.*
import co.yml.charts.ui.linechart.model.LineStyle
import co.yml.charts.ui.linechart.model.LineType
import co.yml.charts.ui.wavechart.model.AxisPosition
import co.yml.charts.ui.wavechart.model.Wave
import co.yml.charts.ui.wavechart.model.WaveChartData
import kotlinx.coroutines.launch
import kotlin.math.*
import co.yml.charts.ui.linechart.getMaxElementInYAxis as getMaxElementInLineYAxis
/**
*
* [WaveChart] compose method used for drawing a Wave Chart.
* @param modifier :All modifier related property.
* Data class [WaveChartData] to save all params needed to draw the wave chart.
* @param waveChartData : Add data related to wave chart.
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WaveChart(modifier: Modifier, waveChartData: WaveChartData) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val isTalkBackEnabled by LocalContext.current.collectIsTalkbackEnabledAsState()
if (accessibilitySheetState.isVisible && isTalkBackEnabled
&& waveChartData.accessibilityConfig.shouldHandleBackWhenTalkBackPopUpShown
) {
BackHandler {
scope.launch {
accessibilitySheetState.hide()
}
}
}
Surface(modifier = modifier.testTag("wave_chart")) {
with(waveChartData) {
var columnWidth by remember { mutableStateOf(0f) }
var rowHeight by remember { mutableStateOf(0f) }
var xOffset by remember { mutableStateOf(0f) }
val bgColor = MaterialTheme.colors.surface
var isTapped by remember { mutableStateOf(false) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
var selectionTextVisibility by remember { mutableStateOf(false) }
var identifiedPoint by remember { mutableStateOf(Point(0f, 0f)) }
val wave = wavePlotData.lines.first()
rowHeight =
LocalDensity.current.run { ChartConstants.DEFAULT_YAXIS_BOTTOM_PADDING.dp.toPx() }
// Update must required values
val xAxisData = xAxisData.copy(
axisBottomPadding = bottomPadding,
)
val yAxisData = yAxisData.copy(
axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() },
)
val (xMin, xMax, xAxisScale) = getXAxisScale(wave.dataPoints, xAxisData.steps)
val (yMin, _, yAxisScale) = getYAxisScale(wave.dataPoints, yAxisData.steps)
val maxElementInYAxis = getMaxElementInLineYAxis(yAxisScale, yAxisData.steps)
ScrollableCanvasContainer(modifier = modifier
.semantics {
contentDescription = waveChartData.accessibilityConfig.chartDescription
}
.testTag("scrollable_container")
.clickable {
if (isTalkBackEnabled) {
scope.launch {
accessibilitySheetState.show()
}
}
},
calculateMaxDistance = { xZoom ->
xOffset = xAxisData.axisStepSize.toPx() * xZoom
getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight.toPx(),
size.width,
containerPaddingEnd.toPx()
)
},
containerBackgroundColor = backgroundColor,
isPinchZoomEnabled = isZoomAllowed,
drawXAndYAxis = { _, _ ->
YAxis(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxHeight()
.wrapContentWidth()
.onGloballyPositioned {
columnWidth = it.size.width.toFloat()
}, yAxisData = yAxisData
)
},
onDraw = { scrollOffset, xZoom ->
val yBottom = size.height - rowHeight
val yOffset = ((yBottom - paddingTop.toPx()).div(maxElementInYAxis))
xOffset = xAxisData.axisStepSize.toPx() * xZoom
val xLeft = columnWidth // To add extra space if needed
val pointsData = getMappingPointsToGraph(
wave.dataPoints, xMin, xOffset, xLeft, scrollOffset, yBottom, yMin, yOffset
)
val yPointOfOrigin = yBottom - ((0.minus(yMin)) * yOffset)
val tapPointLocks = mutableMapOf<Int, Pair<Point, Offset>>()
var xPos = xLeft - scrollOffset
// Draw guide lines
gridLines?.let {
drawGridLines(
yBottom,
yAxisData.axisTopPadding.toPx(),
xLeft,
paddingRight,
scrollOffset,
pointsData.size,
xZoom,
xAxisScale,
yAxisData.steps,
xAxisData.axisStepSize,
it
)
}
// Draw cubic line using the points and form a wave graph
drawStraightOrCubicLineForWave(
pointsData, wave.waveStyle, yPointOfOrigin, wave
)
// Draw Lines and Points and AreaUnderLine
// Draw area under curve
drawShadowUnderLineAndIntersectionPoint(
pointsData,
wave
)
// Draw the X-axis
for (index in 0..xAxisData.steps) {
drawXAxisLabel(
xAxisData,
index,
xAxisScale,
yPointOfOrigin,
xPos
)
drawAxisLineWithPointers(
xPos,
xAxisData,
xZoom,
xAxisScale,
yPointOfOrigin,
index != xAxisData.steps
)
xPos += (xAxisData.axisStepSize.toPx() * (xZoom * xAxisScale))
}
// Draw column to make graph look scrollable under Yaxis
drawUnderScrollMask(columnWidth, paddingRight, bgColor)
pointsData.forEachIndexed { index, point ->
if (isTapped && point.isTapped(tapOffset.x, xOffset)) {
// Dealing with only one line graph hence tapPointLocks[0]
tapPointLocks[0] = wave.dataPoints[index] to point
}
}
val selectedOffset = tapPointLocks.values.firstOrNull()?.second
if (selectionTextVisibility && selectedOffset.isNotNull()) {
drawHighlightText(
identifiedPoint,
selectedOffset ?: Offset(0f, 0f),
wave.selectionHighlightPopUp
)
}
if (isTapped) {
val x = tapPointLocks.values.firstOrNull()?.second?.x
if (x != null) identifiedPoint =
tapPointLocks.values.map { it.first }.first()
drawHighLightOnSelectedPoint(
tapPointLocks,
columnWidth,
paddingRight,
yBottom,
wave.selectionHighlightPoint
)
}
},
onPointClicked = { offset: Offset, _: Float ->
isTapped = true
selectionTextVisibility = true
tapOffset = offset
},
onScroll = {
isTapped = false
selectionTextVisibility = false
},
onZoomInAndOut = {
isTapped = false
selectionTextVisibility = false
})
}
if (isTalkBackEnabled) {
with(waveChartData) {
AccessibilityBottomSheetDialog(
modifier = Modifier.fillMaxSize(),
backgroundColor = Color.White,
content = {
val linePoints = wavePlotData.lines.firstOrNull()?.dataPoints
LazyColumn {
items(linePoints?.size ?: 0) { index ->
Column {
LinePointInfo(
xAxisData.axisLabelDescription(
xAxisData.labelData(
index
)
),
linePoints?.get(index)?.description ?: "",
wavePlotData.lines.firstOrNull()?.waveStyle?.color
?: Color.Transparent,
accessibilityConfig.titleTextSize,
accessibilityConfig.descriptionTextSize
)
ItemDivider(
thickness = accessibilityConfig.dividerThickness,
dividerColor = accessibilityConfig.dividerColor
)
}
}
}
},
popUpTopRightButtonTitle = accessibilityConfig.popUpTopRightButtonTitle,
popUpTopRightButtonDescription = accessibilityConfig.popUpTopRightButtonDescription,
sheetState = accessibilitySheetState
)
}
}
}
}
/**
*
* DrawScope.drawShadowUnderLineAndIntersectionPoint extension method used for drawing a
* shadow below the line graph points and also drawing intersection points on the line graph.
* @param pointsData : List of the points on the Line graph.
* @param line : line on which shadow & intersectionPoints has to be drawn.
*/
private fun DrawScope.drawShadowUnderLineAndIntersectionPoint(
pointsData: MutableList<Offset>,
line: Wave
) {
if (line.intersectionPoint.isNotNull()) {
pointsData.forEach { offset ->
drawPointOnLine(offset, line.intersectionPoint)
}
}
}
/**
* DrawScope.drawAxisLineWithPointers extension method used for drawing the axis line and the
* axis pointers.
* @param xPos : X coordinate of the axis line
* @param axisData : Axis data.
* @param zoomScale : Zoom scale
* @param xAxisScale : The scale of increment of X-Axis
* @param yPos : Y coordinate of the axis line
* @param canDrawEndLine : Boolean letting us know if it is the end point.
*/
private fun DrawScope.drawAxisLineWithPointers(
xPos: Float,
axisData: AxisData,
zoomScale: Float,
xAxisScale: Float,
yPos: Float,
canDrawEndLine: Boolean, // Added check to avoid drawing an extra line post the last point
) {
with(axisData) {
if (axisConfig.isAxisLineRequired) {
if (canDrawEndLine) {
val axisStepWidth = (axisStepSize.toPx() * (zoomScale * xAxisScale))
drawLine(
axisLineColor,
Offset(xPos, yPos),
if (shouldDrawAxisLineTillEnd) {
Offset((xPos + (axisStepWidth / 2) + axisStepWidth), yPos)
} else {
Offset(xPos + axisStepWidth, yPos)
}, strokeWidth = axisLineThickness.toPx()
)
}
drawLine(
axisLineColor,
Offset(xPos, yPos),
Offset(xPos, yPos + indicatorLineWidth.toPx()),
strokeWidth = axisLineThickness.toPx()
)
}
}
}
/**
* DrawScope.drawXAxisLabel extension method used for drawing the axis line labels.
* @param xPos : X coordinate of the axis line
* @param axisData : Axis data.
* @param index : Index of label data.
* @param xAxisScale : The scale of increment of X-Axis
* @param yPos : Y coordinate of the axis line
*/
private fun DrawScope.drawXAxisLabel(
axisData: AxisData,
index: Int,
xAxisScale: Float,
yPos: Float,
xPos: Float
) {
with(axisData) {
val xAxisTextPaint = TextPaint().apply {
textSize = axisLabelFontSize.toPx()
color = axisLabelColor.toArgb()
textAlign = Paint.Align.LEFT
typeface = axisData.typeface
}
val xLabel = labelData((index * xAxisScale).toInt())
val labelHeight = xLabel.getTextHeight(xAxisTextPaint)
val labelWidth = xLabel.getTextWidth(xAxisTextPaint)
val ellipsizedText = TextUtils.ellipsize(
xLabel,
xAxisTextPaint,
axisStepSize.toPx(),
axisConfig.ellipsizeAt
)
drawContext.canvas.nativeCanvas.apply {
val x = xPos - (labelWidth / 2)
val y =
yPos + labelHeight / 2 + indicatorLineWidth.toPx() + labelAndAxisLinePadding.toPx()
withRotation(axisLabelAngle, x, y) {
drawText(
if (axisConfig.shouldEllipsizeAxisLabel) ellipsizedText.toString() else xLabel,
x,
y,
xAxisTextPaint
)
}
}
}
}
/**
*
* DrawScope.drawStraightOrCubicLine extension method used for drawing a straight/cubic line for a given Point(x,y).
* @param pointsData : List of points to be drawn on the canvas
* @param lineStyle : All styles related to the path are included in [LineStyle].
* @param yPointOfOrigin : y position of x Axis
* @param wave : Wave data
*/
fun DrawScope.drawStraightOrCubicLineForWave(
pointsData: MutableList<Offset>,
lineStyle: LineStyle,
yPointOfOrigin: Float = 0f,
wave: Wave
): Path {
val path = Path()
when (lineStyle.lineType) {
is LineType.Straight -> {
drawLinePath(
path,
pointsData,
lineStyle,
wave,
yPointOfOrigin
)
}
is LineType.SmoothCurve -> {
//draw the path using wave points
drawWavePath(
path,
pointsData,
lineStyle,
wave,
yPointOfOrigin
)
}
}
return path
}
/**
* Used to draw the wave
* @param path : Represent the single cubic bezier curve path using the given points
* @param pointsData : curve points
* @param lineStyle : styling for the path drawn in the line graph
* @param wave : Wave data
* @param yPointOfOrigin : y position of x Axis
*/
fun DrawScope.drawWavePath(
path: Path,
pointsData: MutableList<Offset>,
lineStyle: LineStyle,
wave: Wave,
yPointOfOrigin: Float
): Path {
for (i in 1 until pointsData.size) {
//find control points to draw the cubic curve
val (cubicPoints1, cubicPoints2) = findControlPoints(
pointsData[i - 1],
pointsData[i]
)
path.moveTo(
pointsData[i - 1].x,
pointsData[i - 1].y
)
//draw cubic bezier curve
path.cubicTo(
cubicPoints1.x,
cubicPoints1.y,
cubicPoints2.x,
cubicPoints2.y,
pointsData[i].x,
pointsData[i].y
)
//Color Filling part//
val pointPosition = findPointPosition(
pointsData[i - 1].y,
pointsData[i].y,
yPointOfOrigin
)
if (pointPosition == AxisPosition.INTERSECT) {
val curvePoints =
getCurvePoints(pointsData[i - 1], cubicPoints1, cubicPoints2, pointsData[i])
fillIntersectionCurvePath(
curvePoints,
yPointOfOrigin,
wave
)
} else {
fillCurvePath(
yPointOfOrigin,
pointPosition,
wave,
pointsData[i - 1],
pointsData[i],
cubicPoints1,
cubicPoints2
)
}
}
with(lineStyle) {
drawPath(
path,
color = color,
style = getDrawStyleForPath(lineStyle.lineType, lineStyle),
alpha = alpha,
colorFilter = colorFilter,
blendMode = blendMode
)
}
return path
}
fun DrawScope.drawLinePath(
path: Path,
pointsData: MutableList<Offset>,
lineStyle: LineStyle,
wave: Wave,
yPointOfOrigin: Float
) {
path.moveTo(pointsData.first().x, pointsData.first().y)
// slop and y intercept of x axis line
val (m1, b1) = findSlopeAndYIntercept(
Offset(0f, yPointOfOrigin),
Offset(size.width, yPointOfOrigin)
)
for (i in 1 until pointsData.size) {
//Draw the line with out filling color
path.lineTo(pointsData[i].x, pointsData[i].y)
with(lineStyle) {
drawPath(
path,
color = color,
style = getDrawStyleForPath(lineStyle.lineType, lineStyle),
alpha = alpha,
colorFilter = colorFilter,
blendMode = blendMode
)
}
//Color Filling part//
val pointPosition = findPointPosition(
pointsData[i - 1].y,
pointsData[i].y,
yPointOfOrigin
)
if (pointPosition == AxisPosition.INTERSECT) {
val (m2, b2) = findSlopeAndYIntercept(pointsData[i - 1], pointsData[i])
//find intersection points of the line
val intersectionPoint =
findLineIntersection(m1, b1, m2, b2) ?: Offset(0f, 0f)
fillIntersectionLinePath(
pointsData[i - 1],
pointsData[i],
yPointOfOrigin,
intersectionPoint,
wave
)
} else {
fillLinePath(
yPointOfOrigin,
pointPosition,
wave,
pointsData[i - 1],
pointsData[i]
)
}
}
}
/**
* Used to get the points which helps to construct the bezier curve
* @param startPoint : startPoint of the curve
* @param cubicPoint1 : cubicPoint1 of the curve
* @param cubicPoint2 : cubicPoint2 of the curve
* @param endPoint : endPoint of the curve
*/
fun getCurvePoints(
startPoint: Offset,
cubicPoint1: Offset,
cubicPoint2: Offset,
endPoint: Offset
): List<Offset> {
val start = 0.0
val end = 1.0
val step = 0.01
//generate t values for constructing the cubic curve
val values = generateSequence(start) { (it + step).roundTwoDecimal() }
.takeWhile { it <= end }
val curvePoints = mutableListOf<Offset>()
for (t in values) {
val point =
findCubicCurvePoints(
t,
startPoint,
cubicPoint1,
cubicPoint2,
endPoint
)
curvePoints.add(point)
}
return curvePoints.toList()
}
/**
* Used to fill the line path
* @param yPointOfOrigin y offset point on xAxis
* @param pointPosition : line point position
* @param wave : Wave data
* @param startPoint: startPoint of the curve
* @param endPoint : endPoint of the curve
* @param cubicPoints1: control point 1
* @param cubicPoints2: control point 2
*/
fun DrawScope.fillCurvePath(
yPointOfOrigin: Float,
pointPosition: AxisPosition,
wave: Wave,
startPoint: Offset,
endPoint: Offset,
cubicPoints1: Offset,
cubicPoints2: Offset
) {
val curvePath = Path()
curvePath.moveTo(startPoint.x, startPoint.y)
curvePath.cubicTo(
cubicPoints1.x,
cubicPoints1.y,
cubicPoints2.x,
cubicPoints2.y,
endPoint.x,
endPoint.y
)
curvePath.lineTo(endPoint.x, yPointOfOrigin)
curvePath.lineTo(startPoint.x, yPointOfOrigin)
val fillColor: Color
val fillBrush: Brush?
if (pointPosition == AxisPosition.BOTTOM) {
fillColor = wave.waveFillColor.bottomColor
fillBrush = wave.waveFillColor.bottomBrush
} else {
fillColor = wave.waveFillColor.topColor
fillBrush = wave.waveFillColor.topBrush
}
wave.shadowUnderLine?.drawMultiColor?.let { it(this, curvePath, fillColor, fillBrush) }
}
/**
* Used to fill the wave path using given color
* @param curvePoints : points which create the bezier curve
* @param yPointOfOrigin : y position of x Axis
* @param wave : Wave data
*/
fun DrawScope.fillIntersectionCurvePath(
curvePoints: List<Offset>,
yPointOfOrigin: Float,
wave: Wave
) {
val bottomPath = Path()
val topPath = Path()
val startPoint = curvePoints.first()
val endPoint = curvePoints.last()
val isStartPointBottom =
findPointPosition(startPoint.y, yPointOfOrigin, yPointOfOrigin) == AxisPosition.BOTTOM
val intersectionPoint = getCurveIntersectionPoint(curvePoints, yPointOfOrigin)
if (isStartPointBottom) {
bottomPath.moveTo(startPoint.x, startPoint.y)
topPath.moveTo(intersectionPoint.x, intersectionPoint.y)
} else {
bottomPath.moveTo(intersectionPoint.x, intersectionPoint.y)
topPath.moveTo(startPoint.x, startPoint.y)
}
for (i in 1 until curvePoints.size) {
if (curvePoints[i - 1].y > yPointOfOrigin) {
//BOTTOM
bottomPath.lineTo(curvePoints[i - 1].x, curvePoints[i - 1].y)
bottomPath.lineTo(curvePoints[i].x, curvePoints[i].y)
} else {
//TOP
topPath.lineTo(curvePoints[i - 1].x, curvePoints[i - 1].y)
topPath.lineTo(curvePoints[i].x, curvePoints[i].y)
}
}
// Fill rest of the bottom path
if (isStartPointBottom) {
bottomPath.lineTo(intersectionPoint.x, yPointOfOrigin)
bottomPath.lineTo(startPoint.x, yPointOfOrigin)
topPath.lineTo(endPoint.x, yPointOfOrigin)
topPath.moveTo(intersectionPoint.x, yPointOfOrigin)
} else {
bottomPath.lineTo(endPoint.x, yPointOfOrigin)
bottomPath.moveTo(intersectionPoint.x, yPointOfOrigin)
topPath.lineTo(intersectionPoint.x, yPointOfOrigin)
topPath.lineTo(startPoint.x, yPointOfOrigin)
}
wave.shadowUnderLine?.drawMultiColor?.let {
it(
this,
bottomPath,
wave.waveFillColor.bottomColor,
wave.waveFillColor.bottomBrush
)
}
wave.shadowUnderLine?.drawMultiColor?.let {
it(
this,
topPath,
wave.waveFillColor.topColor,
wave.waveFillColor.topBrush
)
}
}
/**
* Used to fill the line path
* @param yPointOfOrigin y offset point on xAxis
* @param pointPosition : line point position
* @param wave : Wave data
* @param startPoint: startPoint of the line
* @param endPoint : endPoint of the line
*/
fun DrawScope.fillLinePath(
yPointOfOrigin: Float,
pointPosition: AxisPosition,
wave: Wave,
startPoint: Offset,
endPoint: Offset
) {
val linePath = Path()
linePath.moveTo(startPoint.x, startPoint.y)
linePath.lineTo(endPoint.x, endPoint.y)
linePath.lineTo(endPoint.x, yPointOfOrigin)
linePath.lineTo(startPoint.x, yPointOfOrigin)
val fillColor: Color
val fillBrush: Brush?
if (pointPosition == AxisPosition.BOTTOM) {
fillColor = wave.waveFillColor.bottomColor
fillBrush = wave.waveFillColor.bottomBrush
} else {
fillColor = wave.waveFillColor.topColor
fillBrush = wave.waveFillColor.topBrush
}
wave.shadowUnderLine?.drawMultiColor?.let { it(this, linePath, fillColor, fillBrush) }
}
/**
* Used to fill the line path when two lines are intersecting
* @param startPoint: startPoint of the line
* @param endPoint : endPoint of the line
* @param yPointOfOrigin : y position of x Axis
* @param intersectionPoint : intersectionPoint of lines
* @param wave : Wave data
*/
fun DrawScope.fillIntersectionLinePath(
startPoint: Offset,
endPoint: Offset,
yPointOfOrigin: Float,
intersectionPoint: Offset,
wave: Wave
) {
val bottomPath = Path()
val topPath = Path()
val isStartPointBottom =
findPointPosition(startPoint.y, yPointOfOrigin, yPointOfOrigin) == AxisPosition.BOTTOM
//move to corresponding start point
if (isStartPointBottom) {
bottomPath.moveTo(startPoint.x, startPoint.y)
topPath.moveTo(intersectionPoint.x, intersectionPoint.y)
} else {
bottomPath.moveTo(intersectionPoint.x, intersectionPoint.y)
topPath.moveTo(startPoint.x, startPoint.y)
}
// Fill rest of the bottom path
if (isStartPointBottom) {
bottomPath.lineTo(intersectionPoint.x, intersectionPoint.y)
bottomPath.lineTo(intersectionPoint.x, yPointOfOrigin)
bottomPath.lineTo(startPoint.x, yPointOfOrigin)
topPath.lineTo(endPoint.x, endPoint.y)
topPath.lineTo(endPoint.x, yPointOfOrigin)
topPath.moveTo(intersectionPoint.x, yPointOfOrigin)
} else {
bottomPath.lineTo(endPoint.x, endPoint.y)
bottomPath.lineTo(endPoint.x, yPointOfOrigin)
bottomPath.moveTo(intersectionPoint.x, yPointOfOrigin)
topPath.lineTo(intersectionPoint.x, intersectionPoint.y)
topPath.lineTo(intersectionPoint.x, yPointOfOrigin)
topPath.lineTo(startPoint.x, yPointOfOrigin)
}
wave.shadowUnderLine?.drawMultiColor?.let {
it(
this,
bottomPath,
wave.waveFillColor.bottomColor,
wave.waveFillColor.bottomBrush
)
}
wave.shadowUnderLine?.drawMultiColor?.let {
it(
this,
topPath,
wave.waveFillColor.topColor,
wave.waveFillColor.topBrush
)
}
}
/**
* Used to get the intersection point between the curve and line
* @param curvePoints : list of curve points
* @param yPointOfOrigin : y offset point on xAxis
*/
fun getCurveIntersectionPoint(curvePoints: List<Offset>, yPointOfOrigin: Float): Offset {
var intersectionPoint = curvePoints.firstOrNull { it.y == yPointOfOrigin }
if (intersectionPoint == null) {
intersectionPoint = findNearestValue(curvePoints, yPointOfOrigin)
}
return intersectionPoint
}
/**
* Used to find the closest value for a given input
* @param list : list of offsets
* @param intersectionPoint : point on xAxis
*/
fun findNearestValue(list: List<Offset>, intersectionPoint: Float): Offset {
var nearestValue = list[0]
var smallestDifference = abs(intersectionPoint - list[0].y)
for (value in list) {
val difference = abs(intersectionPoint - value.y)
if (difference < smallestDifference) {
smallestDifference = difference
nearestValue = value
}
}
return nearestValue
}
/**
* Used to get the control points of the give curve
* @param startPoint : start point of the curve
* @param endPoint : endPoint of the curve
*/
fun findControlPoints(startPoint: Offset, endPoint: Offset): Pair<Offset, Offset> {
// Calculate the difference between the x-coordinates of the two points
val dx = endPoint.x - startPoint.x
// Set the value of K to control the shape of the curve
val k = 0.3f
// Calculate the control points of the curve
val control1X = startPoint.x + k * dx
val control1Y = startPoint.y
val control2X = endPoint.x - k * dx
val control2Y = endPoint.y
return Pair(Offset(control1X, control1Y), Offset(control2X, control2Y))
}
/**
* Used to find the position of the start and end point of the bezier curve
* @param p1 : start point
* @param p2 : end point
* @param yPointOfOrigin : point where x axis reside
*/
fun findPointPosition(
p1: Float,
p2: Float,
yPointOfOrigin: Float
): AxisPosition {
return if (p1 > yPointOfOrigin && p2 > yPointOfOrigin) {
AxisPosition.BOTTOM
} else if (p1 < yPointOfOrigin && p2 < yPointOfOrigin) {
AxisPosition.TOP
} else if (p1 == yPointOfOrigin) {
if (p2 > yPointOfOrigin) {
AxisPosition.BOTTOM
} else {
AxisPosition.TOP
}
} else if (p2 == yPointOfOrigin) {
if (p1 > yPointOfOrigin) {
AxisPosition.BOTTOM
} else {
AxisPosition.TOP
}
} else {
AxisPosition.INTERSECT
}
}
/**
* Return slop and y intercept of the line
* @param p1 : points on a line 1
* @param p2 : points on a line 2
*/
fun findSlopeAndYIntercept(p1: Offset, p2: Offset): Pair<Float, Float> {
val m = (p2.y - p1.y) / (p2.x - p1.x)
val b = p1.y - m * p1.x
return Pair(m, b)
}
/**
* Used to compute the intersection point of the lines
* @param m1 : slop of line 1
* @param b1 : y intercept of line 1
* @param m2 : slop of line 2
* @param b2 : y intercept of line 2
*/
fun findLineIntersection(m1: Float, b1: Float, m2: Float, b2: Float): Offset? {
if (m1 == m2) {
return null // Lines are parallel
}
val x = (b2 - b1) / (m1 - m2)
val y = m1 * x + b1
return Offset(x, y)
}
/**
* Using De Casteljaus algorithm to find the intermediate points on the curve
* @param t : value between 0 and 1
* @param p0: start point of the curve
* @param p1: control point 1
* @param p1: control point 2
* @param p3: end point of the curve
* @return a point which lies on the curve based on t value
*/
fun findCubicCurvePoints(
t: Double,
p0: Offset,
p1: Offset,
p2: Offset,
p3: Offset
): Offset {
// First level of interpolation
//calculate the points on the line segments
val p01 = interpolate(t, p0, p1)
val p12 = interpolate(t, p1, p2)
val p23 = interpolate(t, p2, p3)
// Second level of interpolation
val p012 = interpolate(t, p01, p12)
val p123 = interpolate(t, p12, p23)
// Third level of interpolation
val p0123 = interpolate(t, p012, p123)
return p0123
}
/**
* Used to returns the interpolated point between p0 and p1 at the given t value.
* @param t: value between 0 and 1
* @param p0 : Curve point
* @param p1: Curve point
*/
fun interpolate(t: Double, p0: Offset, p1: Offset): Offset {
val x = (1 - t) * p0.x + t * p1.x
val y = (1 - t) * p0.y + t * p1.y
return Offset(x.toFloat(), y.toFloat())
}
@@ -0,0 +1,83 @@
package co.yml.charts.ui.wavechart.model
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.model.AccessibilityConfig
import co.yml.charts.common.model.Point
import co.yml.charts.ui.linechart.model.*
import co.yml.charts.ui.wavechart.model.AxisPosition.INTERSECT
/**
*
* WaveChartData data class that contains all params user need to define to draw a wave graph.
* @param wavePlotData: The path to be drawn on the graph represented by a wave.
* @param xAxisData: All the configurations related to X-Axis to be defined here in [AxisData]
* @param yAxisData: All the configurations related to Y-Axis to be defined here in [AxisData]
* @param isZoomAllowed: True if zoom in for all vertical graph components is allowed else false.
* @param paddingTop: Padding from the top of the canvas to start of the graph container.
* @param paddingRight: Padding from the end of the canvas to end of the graph container.
* @param bottomPadding: Padding from the bottom of the canvas to bottom of the graph container.
* @param containerPaddingEnd: Container inside padding end after the last point of the graph.
* @param backgroundColor: Background color of the Y & X components,
* @param gridLines: This enables graph to draw horizontal and vertical grid lines
* @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig]
*/
data class WaveChartData(
val wavePlotData: WavePlotData,
val xAxisData: AxisData = AxisData.Builder().build(),
val yAxisData: AxisData = AxisData.Builder().build(),
val isZoomAllowed: Boolean = true,
val paddingTop: Dp = 30.dp,
val bottomPadding: Dp = 10.dp,
val paddingRight: Dp = 10.dp,
val containerPaddingEnd: Dp = 15.dp,
val backgroundColor: Color = Color.White,
val gridLines: GridLines? = null,
val accessibilityConfig: AccessibilityConfig = AccessibilityConfig()
)
/**
* Represent a Wave in the [co.yml.charts.ui.wavechart]
*
* @param dataPoints list of points [Point] in the wave
* @param waveStyle Adds styling options in [LineStyle] to the wave path drawn.
* @param intersectionPoint drawing logic to draw the point itself in [IntersectionPoint].
* If null, the point is not drawn.
* @param selectionHighlightPoint drawing logic to draw the highlight at the point when it is selected
* in [SelectionHighlightPoint] If null, the point won't be highlighted on selection
* @param shadowUnderLine drawing logic for the section under the wave in [ShadowUnderLine].
* @param selectionHighlightPopUp All prams related to selection popup to be added here in [SelectionHighlightPopUp]
*/
data class Wave(
val dataPoints: List<Point>,
val waveStyle: LineStyle = LineStyle(),
val intersectionPoint: IntersectionPoint? = null,
val selectionHighlightPoint: SelectionHighlightPoint? = null,
val shadowUnderLine: ShadowUnderLine? = null,
val selectionHighlightPopUp: SelectionHighlightPopUp? = null,
val waveFillColor: WaveFillColor = WaveFillColor()
)
/**
* hold the color values
*/
data class WaveFillColor(
val topColor: Color = Color.Black,
val bottomColor: Color = Color.Black,
val topBrush: Brush? = null,
val bottomBrush: Brush? = null
)
/**
* Represents the start and end point positions of the bezier curve
* @property INTERSECT : Means both lines are not in the same region
*/
enum class AxisPosition {
TOP,
BOTTOM,
INTERSECT
}
@@ -0,0 +1,19 @@
package co.yml.charts.ui.wavechart.model
import co.yml.charts.common.model.PlotData
import co.yml.charts.common.model.PlotType
/**
* WavePlotData is a data class that holds line graph related data and styling components
* @param plotType : Defines the type of plot/graph
* @param lines : Data related to the list of waves to be drawn.
*/
data class WavePlotData(
override val plotType: PlotType = PlotType.Wave,
val lines: List<Wave>
) : PlotData {
companion object {
fun default() =
WavePlotData(lines = listOf())
}
}
@@ -0,0 +1,60 @@
package co.yml.charts.axis
import co.yml.charts.common.model.Point
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class XGraphExtensionsTest {
@Test
fun `Given list of points exact xMin and xMax value should be calculated`() {
// Given
val chartData = listOf(
Point(0f, 10f), Point(1f, 30f), Point(2f, 15f), Point(3f, 50f)
)
val steps = 2
// When
val (xMin, xMax, scale) = getXAxisScale(chartData, steps)
//Then
assertEquals(xMin.toInt(), 0)
assertEquals(xMax.toInt(), 3)
}
@Test
fun `Given list of points when drawing axis using the scale the value should be withing xMin and xMax`() {
// Given
val chartData = listOf(
Point(0f, 10f), Point(1f, 30f), Point(2f, 15f), Point(3f, 50f)
)
val steps = 2
// When
val (xMin, xMax, scale) = getXAxisScale(chartData, steps)
//Then
for (i in 0 until steps) {
val value = i * scale
assertTrue(value >= xMin)
assertTrue(value <= xMax)
}
}
@Test
fun `Given empty list of points all the values should be zero`() {
// Given
val chartData = listOf<Point>()
val steps = 2
// When
val (xMin, xMax, scale) = getXAxisScale(chartData, steps)
//Then
assertEquals(xMin.toInt(), 0)
assertEquals(xMax.toInt(), 0)
assertEquals(scale.toInt(), 0)
}
}
@@ -0,0 +1,101 @@
package co.yml.charts.axis
import co.yml.charts.common.model.Point
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import org.junit.Assert
import org.junit.Assert.assertTrue
import org.junit.Test
class YChartsExtensionsTest {
@MockK
private val axisData: AxisData = mockk()
@Test
fun `When getAxisInitValues extension is invoked should not return values lesser than zero`() {
// Given
every { axisData.steps }.returns(5)
// When
val values = getAxisInitValues(
axisData,
687f, // 250.dp.toPx()
20f,
20f
)
// Then
assertTrue(values.first > 0)
assertTrue(values.second > 0)
}
@Test
fun `Given yAxisData segmentHeight should be less than yaxisHeight `() {
// Given
every { axisData.steps }.returns(5)
// When
val values = getAxisInitValues(
axisData,
687f, // 250.dp.toPx()
20f,
20f
)
// Then
assertTrue(values.second < values.first)
}
@Test
fun `Given list of points exact yMin and yMax value should be calculated`() {
// Given
val chartData = listOf(
Point(10f, 0f), Point(30f, 1f), Point(15f, 2f), Point(50f, 3f)
)
val steps = 2
// When
val (yMin, yMax, yAxisScale) = getYAxisScale(chartData, steps)
//Then
Assert.assertEquals(yMin.toInt(), 0)
Assert.assertEquals(yMax.toInt(), 3)
}
@Test
fun `Given list of points when drawing axis using the scale the value should be withing xMin and xMax`() {
// Given
val chartData = listOf(
Point(10f, 0f), Point(30f, 1f), Point(15f, 2f), Point(50f, 3f)
)
val steps = 2
// When
val (yMin, yMax, yAxisScale) = getYAxisScale(chartData, steps)
//Then
for (i in 0 until steps) {
val value = i * yAxisScale
assertTrue(value >= yMin)
assertTrue(value <= yMax)
}
}
@Test
fun `Given empty list of points all the values should be zero`() {
// Given
val chartData = listOf<Point>()
val steps = 2
// When
val (yMin, yMax, yAxisScale) = getYAxisScale(chartData, steps)
//Then
Assert.assertEquals(yMin.toInt(), 0)
Assert.assertEquals(yMax.toInt(), 0)
Assert.assertEquals(yAxisScale.toInt(), 0)
}
}
@@ -0,0 +1,48 @@
package co.yml.charts.chartcontainer
import co.yml.charts.chartcontainer.container.checkAndGetMaxScrollOffset
import org.junit.Assert.assertEquals
import org.junit.Test
class ScrollableCanvasContainerTest {
@Test
fun `Given negative scroll offset canvas should not scroll`() {
// Given
val scrollOffset = -20f
val maxScrollOffset = 200f
// When
val calculatedScrollOffset = checkAndGetMaxScrollOffset(scrollOffset, maxScrollOffset)
// Then
assertEquals(calculatedScrollOffset, 0f)
}
@Test
fun `Given scroll offset canvas should support scroll if within maxScrollOffset`() {
// Given
val scrollOffset = 20f
val maxScrollOffset = 200f
// When
val calculatedScrollOffset = checkAndGetMaxScrollOffset(scrollOffset, maxScrollOffset)
// Then
assertEquals(calculatedScrollOffset, 20f)
}
@Test
fun `Given scroll offset more then maxScrollOffset then scroll offset should be reset to same`() {
// Given
val scrollOffset = 220f
val maxScrollOffset = 200f
// When
val calculatedScrollOffset = checkAndGetMaxScrollOffset(scrollOffset, maxScrollOffset)
// Then
assertEquals(calculatedScrollOffset, maxScrollOffset)
}
}
@@ -0,0 +1,153 @@
package co.yml.charts.extensions
import androidx.compose.ui.geometry.Offset
import co.yml.charts.common.extensions.*
import co.yml.charts.common.model.Point
import org.junit.Assert.assertEquals
import org.junit.Test
class ExtensionsTest{
@Test
fun `Find proper maximum and minimum X values from the given points`() {
val pointList = listOf(
Point(0f, 10f), Point(1f, 20f), Point(2f, 30f), Point(3f, 40f)
)
val (xMin, xMax) = getXMaxAndMinPoints(pointList)
assertEquals(xMin, 0f)
assertEquals(xMax, 3f)
}
@Test
fun `Find proper maximum and minimum Y values from the given points`() {
val pointList = listOf(
Point(0f, 10f), Point(1f, 20f), Point(2f, 30f), Point(3f, 40f)
)
val (yMin, yMax) = getYMaxAndMinPoints(pointList)
assertEquals(yMin, 10f)
assertEquals(yMax, 40f)
}
@Test
fun `Given empty list maximum and minimum points should be zero`() {
val pointList = emptyList<Point>()
val (yMin, yMax) = getYMaxAndMinPoints(pointList)
assertEquals(yMin, 0f)
assertEquals(yMax, 0f)
}
@Test
fun `Find the maximum Y point from the steps size and max value`() {
val stepSize = 10
val maxValue = 45f
val maximumYValue = getMaxElementInYAxis(maxValue,stepSize)
assertEquals(maximumYValue, 50)
}
@Test
fun `Given a point and proper drag lock status should be calculated`() {
val offset = Offset(30f, 20f)
val dragOffsetX = 25f
val xOffset = 20f
val isDragLocked = offset.isDragLocked(dragOffsetX, xOffset)
assertEquals(isDragLocked, true)
}
@Test
fun `Find the maximum X point from the steps size and max value`() {
val stepSize = 10
val maxValue = 45f
val maximumYValue = getMaxElementInXAxis(maxValue, stepSize)
assertEquals(maximumYValue, 50)
}
@Test
fun `Return true when tapped point within the vertical bar bound`() {
val middleOffset = Offset(747.5f, 727.8f)
val tapOffset = Offset(745f, 807f)
val xOffset = 122.5f
val bottom = 950f
val tapPadding = 35f
val isTapped = middleOffset.isTapped(tapOffset, xOffset, bottom, tapPadding)
assertEquals(isTapped, true)
}
@Test
fun `Return false when tapped point outside the vertical bar bound`() {
val middleOffset = Offset(747.5f, 727.8f)
val tapOffset = Offset(500f, 807f)
val xOffset = 122.5f
val bottom = 950f
val tapPadding = 35f
val isTapped = middleOffset.isTapped(tapOffset, xOffset, bottom, tapPadding)
assertEquals(isTapped, false)
}
@Test
fun `Return true when tapped point within the horizontal bar bound`() {
val middleOffset = Offset(250f, 708.8f)
val tapOffset = Offset(360.7f, 723.6f)
val yOffset = 122.5f
val left = 250f
val tapPadding = 35f
val xAxisWidth = 636.75f
val isTapped = middleOffset.isYAxisTapped(tapOffset, yOffset, left, tapPadding, xAxisWidth)
assertEquals(isTapped, true)
}
@Test
fun `Return false when tapped point outside the horizontal bar bound`() {
val middleOffset = Offset(250f, 708.8f)
val tapOffset = Offset(360.7f, 800f)
val yOffset = 122.5f
val left = 250f
val tapPadding = 35f
val xAxisWidth = 636.75f
val isTapped = middleOffset.isYAxisTapped(tapOffset, yOffset, left, tapPadding, xAxisWidth)
assertEquals(isTapped, false)
}
@Test
fun `Return true when tapped point within the selected stacked bar bound`() {
val middleOffset = Offset(250f, 500f)
val tapOffset = Offset(260f, 550f)
val barWidth = 25f
val barHeight = 600f
val tapPadding = 35f
val isTapped = middleOffset.isStackedBarTapped(tapOffset, barWidth, barHeight, tapPadding)
assertEquals(isTapped, true)
}
@Test
fun `Return true when tapped point outside the the selected stacked bar bound`() {
val middleOffset = Offset(250f, 500f)
val tapOffset = Offset(260f, 770f)
val barWidth = 25f
val barHeight = 600f
val tapPadding = 35f
val isTapped = middleOffset.isStackedBarTapped(tapOffset, barWidth, barHeight, tapPadding)
assertEquals(isTapped, false)
}
}
@@ -0,0 +1,193 @@
package co.yml.charts.ui.bargraph
import androidx.compose.ui.geometry.Offset
import co.yml.charts.axis.DataCategoryOptions
import co.yml.charts.common.model.Point
import co.yml.charts.ui.barchart.getDrawHorizontalOffset
import co.yml.charts.ui.barchart.getDrawOffset
import co.yml.charts.ui.barchart.getFullBarDetails
import co.yml.charts.ui.barchart.getMaxScrollDistance
import co.yml.charts.ui.barchart.models.BarData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BarChartTest {
@Test
fun `Given a container with input values valid scroll offset should be returned`() {
// Given
val columnWidth = 104f
val xMax = 100f
val xMin = 0f
val xOffset = 200f
val xLeft = 10f
val paddingRight = 40f
val canvasWidth = 500f
// When
val scrollDistance = getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
xLeft,
paddingRight,
canvasWidth
)
assertTrue(scrollDistance > 0)
}
@Test
fun `Given a total drawing width lesser than container width container should not be able to scrolled`() {
// Given
val columnWidth = 20f
val xMax = 20f
val xMin = 0f
val xOffset = 20f
val xLeft = 10f
val paddingRight = 40f
val canvasWidth = 500f
// When
val scrollDistance = getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
xLeft,
paddingRight,
canvasWidth
)
assertEquals(scrollDistance, 0f)
}
@Test
fun `Given a point drawOffset should be positive`() {
val point = Point(100f, 20f)
val drawOffset = getDrawOffset(
point = point,
xMin = 0f,
xOffset = 10f,
xLeft = 20f,
scrollOffset = 50f,
yBottom = 1500f,
yOffset = 20f,
yMin = 0f,
startDrawPadding = 20f,
barWidth = 40f,
zoomScale = 1f
)
assertTrue(drawOffset.x > 0 && drawOffset.y > 0)
}
@Test
fun `Given scroll offset,xleft,xmin are zero drawOffset should be product of xOffset and xValue`() {
val point = Point(1f, 20f)
val xOffset = 20f
val drawOffset = getDrawOffset(
point = point,
xMin = 0f,
xOffset = xOffset,
xLeft = 0f,
scrollOffset = 0f,
yBottom = 1500f,
yOffset = 20f,
yMin = 0f,
startDrawPadding = 20f,
barWidth = 40f,
zoomScale = 1f
)
assertEquals(drawOffset.x, xOffset * point.x)
}
@Test
fun `Given a point horizontal drawOffset should be positive`() {
val point = Point(250f, 700f)
val drawOffset = getDrawHorizontalOffset(
point = point,
xLeft = 250f,
scrollOffset = 50f,
yBottom = 950f,
yOffset = 192.5f,
yMin = 0f,
yMax = 9f,
yStart = 150f,
dataCategoryOptions = DataCategoryOptions(),
zoomScale = 1f
)
assertTrue(drawOffset.x > 0 && drawOffset.y > 0)
}
@Test
fun `Given scroll offset,yStart,ymin are zero drawOffset should be product of yOffset and yValue`() {
val point = Point(250f, 700f)
val yOffset = 192.5f
val drawOffset = getDrawHorizontalOffset(
point = point,
xLeft = 250f,
scrollOffset = 0f,
yBottom = 950f,
yOffset = 192.5f,
yMin = 0f,
yMax = 9f,
yStart = 0f,
dataCategoryOptions = DataCategoryOptions(),
zoomScale = 1f
)
assertEquals(drawOffset.y, yOffset * point.y)
}
@Test
fun `Given individual stacked bar data points return it as full bar points`() {
// Given
val totalPaddingBtwBars = 10f
val yOffset = 50f
val yBottom = 100f
val xPointOffSet = 100f
val barDetails = listOf<BarData>(
BarData(Point(50f, 100f)),
BarData(Point(50f, 200f)),
BarData(Point(50f, 300f))
)
//When
val fullBarDetails =
getFullBarDetails(barDetails, totalPaddingBtwBars, yOffset, yBottom, xPointOffSet)
//Then
assertEquals(fullBarDetails.first, BarData(Point(50f, 600f)))
}
@Test
fun `Given individual stacked bar data points return its full bar offset`() {
// Given
val totalPaddingBtwBars = 10f
val yOffset = 5f
val yBottom = 1200f
val xPointOffSet = 100f
val barDetails = listOf<BarData>(
BarData(Point(50f, 100f)),
BarData(Point(50f, 200f)),
BarData(Point(50f, 300f))
)
//When
val fullBarDetails =
getFullBarDetails(barDetails, totalPaddingBtwBars, yOffset, yBottom, xPointOffSet)
//Then
assertEquals(fullBarDetails.second, Offset(100f, -1810f))
}
}
@@ -0,0 +1,192 @@
package co.yml.charts.ui.bubblechart
import androidx.compose.ui.geometry.Offset
import co.yml.charts.ui.linechart.getCubicPoints
import co.yml.charts.ui.linechart.getMappingPointsToGraph
import co.yml.charts.ui.linechart.getMaxScrollDistance
import co.yml.charts.common.model.Point
import org.junit.Assert
import org.junit.Test
class BubbleChartTest {
@Test
fun `Given few points data n-1 size of cubicPoints1 & cubicPoints2 should be returned`() {
// Given
val pointsData = listOf(Offset(1f, 30f), Offset(2f, 10f), Offset(3f, 55f))
// When
val pairCubicPoints = getCubicPoints(pointsData)
//Then
Assert.assertEquals(pointsData.size - 1, pairCubicPoints.first.size)
Assert.assertEquals(pointsData.size - 1, pairCubicPoints.second.size)
}
@Test
fun `Given two points calculated cubicPoints1 value should be returned`() {
// Given
val pointsData = listOf(Offset(1f, 30f), Offset(2f, 10f))
// When
val pairCubicPoints = getCubicPoints(pointsData)
//Then
val expectedCubic1Point = Offset(
(pointsData[1].x + pointsData.first().x) / 2,
pointsData.first().y
)
Assert.assertEquals(expectedCubic1Point, pairCubicPoints.first.first())
}
@Test
fun `Given two points calculated cubicPoints2 value should be returned`() {
// Given
val pointsData = listOf(Offset(1f, 30f), Offset(2f, 10f))
// When
val pairCubicPoints = getCubicPoints(pointsData)
//Then
val expectedCubic1Point = Offset(
(pointsData[1].x + pointsData.first().x) / 2,
pointsData[1].y
)
Assert.assertEquals(expectedCubic1Point, pairCubicPoints.second.first())
}
@Test
fun `Given a container with input values valid scroll offset should be returned`() {
// Given
val columnWidth = 104f
val xMax = 100f
val xMin = 0f
val xOffset = 200f
val paddingRight = 40f
val canvasWidth = 500f
// When
val scrollDistance = getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight,
canvasWidth
)
Assert.assertTrue(scrollDistance > 0)
}
@Test
fun `Given a total drawing width lesser than container width container should not be able to scrolled`() {
// Given
val columnWidth = 20f
val xMax = 20f
val xMin = 0f
val xOffset = 20f
val paddingRight = 40f
val canvasWidth = 500f
// When
val scrollDistance = getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight,
canvasWidth
)
Assert.assertEquals(scrollDistance, 0f)
}
@Test
fun `Given input points size the transformed list size should be same`() {
// Given
val pointsData = listOf(Point(1f, 30f), Point(2f, 10f), Point(3f, 55f))
val xLeft = 20f
val scrollOffset = 0f
val xMin = 0f
val xOffset = 20f
val yBottom = 40f
val yMin = 0f
val yOffset = 20f
//When
val transformedPointsList = getMappingPointsToGraph(
pointsData, xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
)
// Then
Assert.assertEquals(pointsData.size, transformedPointsList.size)
}
@Test
fun `Given a point x and transformed point x to w_r_t container should be same `() {
// Given
val pointData = listOf(Point(1f, 30f))
val xLeft = 20f
val scrollOffset = 0f
val xMin = 0f
val xOffset = 20f
val yBottom = 40f
val yMin = 0f
val yOffset = 20f
//When
val transformedXPoint = getMappingPointsToGraph(
pointData, xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
).first().x
//Then
val expectedXPoint = ((pointData.first().x - xMin) * xOffset) + xLeft - scrollOffset
Assert.assertEquals(expectedXPoint, transformedXPoint)
}
@Test
fun `Given a point y and transformed point y to w_r_t container should be same `() {
// Given
val pointData = listOf(Point(1f, 30f))
val xLeft = 20f
val scrollOffset = 0f
val xMin = 0f
val xOffset = 20f
val yBottom = 40f
val yMin = 0f
val yOffset = 20f
//When
val transformedYPoint = getMappingPointsToGraph(
pointData, xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
).first().y
//Then
val expectedYPoint = yBottom - ((pointData.first().y - yMin) * yOffset)
Assert.assertEquals(expectedYPoint, transformedYPoint)
}
}
@@ -0,0 +1,58 @@
package co.yml.charts.ui.linegraph
import co.yml.charts.ui.linechart.getYAxisScale
import co.yml.charts.common.model.Point
import org.junit.Assert
import org.junit.Test
class LineChartExtensionsTest {
@Test
fun `Given list of points exact yMin and yMax value should be calculated`() {
// Given
val chartData = listOf(
Point(0f, 10f), Point(1f, 30f), Point(2f, 15f), Point(3f, 50f)
)
val steps = 2
// When
val (yMin, yMax, scale) = getYAxisScale(chartData, steps)
//Then
Assert.assertEquals(yMin.toInt(), 10)
Assert.assertEquals(yMax.toInt(), 50)
}
@Test
fun `Given list of points when drawing axis using the scale the value should be withing yMin and yMax`() {
// Given
val chartData = listOf(
Point(0f, 10f), Point(1f, 30f), Point(2f, 15f), Point(3f, 50f)
)
val steps = 2
// When
val (yMin, yMax, scale) = getYAxisScale(chartData, steps)
//Then
for (i in 1 until steps) {
val value = i * scale
Assert.assertTrue(value >= yMin)
Assert.assertTrue(value <= yMax)
}
}
@Test
fun `Given empty list of points all the values should be zero`() {
// Given
val chartData = listOf<Point>()
val steps = 2
// When
val (yMin, yMax, scale) = getYAxisScale(chartData, steps)
//Then
Assert.assertEquals(yMin.toInt(), 0)
Assert.assertEquals(yMax.toInt(), 0)
Assert.assertEquals(scale.toInt(), 0)
}
}
@@ -0,0 +1,192 @@
package co.yml.charts.ui.linegraph
import androidx.compose.ui.geometry.Offset
import co.yml.charts.ui.linechart.getCubicPoints
import co.yml.charts.ui.linechart.getMappingPointsToGraph
import co.yml.charts.ui.linechart.getMaxScrollDistance
import co.yml.charts.common.model.Point
import org.junit.Assert
import org.junit.Test
class LineChartTest {
@Test
fun `Given few points data n-1 size of cubicPoints1 & cubicPoints2 should be returned`() {
// Given
val pointsData = listOf(Offset(1f, 30f), Offset(2f, 10f), Offset(3f, 55f))
// When
val pairCubicPoints = getCubicPoints(pointsData)
//Then
Assert.assertEquals(pointsData.size - 1, pairCubicPoints.first.size)
Assert.assertEquals(pointsData.size - 1, pairCubicPoints.second.size)
}
@Test
fun `Given two points calculated cubicPoints1 value should be returned`() {
// Given
val pointsData = listOf(Offset(1f, 30f), Offset(2f, 10f))
// When
val pairCubicPoints = getCubicPoints(pointsData)
//Then
val expectedCubic1Point = Offset(
(pointsData[1].x + pointsData.first().x) / 2,
pointsData.first().y
)
Assert.assertEquals(expectedCubic1Point, pairCubicPoints.first.first())
}
@Test
fun `Given two points calculated cubicPoints2 value should be returned`() {
// Given
val pointsData = listOf(Offset(1f, 30f), Offset(2f, 10f))
// When
val pairCubicPoints = getCubicPoints(pointsData)
//Then
val expectedCubic1Point = Offset(
(pointsData[1].x + pointsData.first().x) / 2,
pointsData[1].y
)
Assert.assertEquals(expectedCubic1Point, pairCubicPoints.second.first())
}
@Test
fun `Given a container with input values valid scroll offset should be returned`() {
// Given
val columnWidth = 104f
val xMax = 100f
val xMin = 0f
val xOffset = 200f
val paddingRight = 40f
val canvasWidth = 500f
// When
val scrollDistance = getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight,
canvasWidth
)
Assert.assertTrue(scrollDistance > 0)
}
@Test
fun `Given a total drawing width lesser than container width container should not be able to scrolled`() {
// Given
val columnWidth = 20f
val xMax = 20f
val xMin = 0f
val xOffset = 20f
val paddingRight = 40f
val canvasWidth = 500f
// When
val scrollDistance = getMaxScrollDistance(
columnWidth,
xMax,
xMin,
xOffset,
paddingRight,
canvasWidth
)
Assert.assertEquals(scrollDistance, 0f)
}
@Test
fun `Given input points size the transformed list size should be same`() {
// Given
val pointsData = listOf(Point(1f, 30f), Point(2f, 10f), Point(3f, 55f))
val xLeft = 20f
val scrollOffset = 0f
val xMin = 0f
val xOffset = 20f
val yBottom = 40f
val yMin = 0f
val yOffset = 20f
//When
val transformedPointsList = getMappingPointsToGraph(
pointsData, xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
)
// Then
Assert.assertEquals(pointsData.size, transformedPointsList.size)
}
@Test
fun `Given a point x and transformed point x to w_r_t container should be same `() {
// Given
val pointData = listOf(Point(1f, 30f))
val xLeft = 20f
val scrollOffset = 0f
val xMin = 0f
val xOffset = 20f
val yBottom = 40f
val yMin = 0f
val yOffset = 20f
//When
val transformedXPoint = getMappingPointsToGraph(
pointData, xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
).first().x
//Then
val expectedXPoint = ((pointData.first().x - xMin) * xOffset) + xLeft - scrollOffset
Assert.assertEquals(expectedXPoint, transformedXPoint)
}
@Test
fun `Given a point y and transformed point y to w_r_t container should be same `() {
// Given
val pointData = listOf(Point(1f, 30f))
val xLeft = 20f
val scrollOffset = 0f
val xMin = 0f
val xOffset = 20f
val yBottom = 40f
val yMin = 0f
val yOffset = 20f
//When
val transformedYPoint = getMappingPointsToGraph(
pointData, xMin,
xOffset,
xLeft,
scrollOffset,
yBottom,
yMin,
yOffset
).first().y
//Then
val expectedYPoint = yBottom - ((pointData.first().y - yMin) * yOffset)
Assert.assertEquals(expectedYPoint, transformedYPoint)
}
}
@@ -0,0 +1,247 @@
package co.yml.charts.ui.wavechart
import androidx.compose.ui.geometry.Offset
import co.yml.charts.ui.wavechart.model.AxisPosition
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.math.abs
class WaveChartTest {
@Test
fun `Given two points on a line find slop and y intercept`() {
//Give
val p1 = Offset(-3f, 2f)
val p2 = Offset(-15f, -6f)
//When
val (m1, b1) = findSlopeAndYIntercept(p1, p2)
//Then
assertEquals(2f / 3f, m1)
assertEquals(4f, b1)
}
@Test
fun `Given two points on a line where y values are 0 then return 0 as slop and y intercept`() {
//Give
val p1 = Offset(-3f, 0f)
val p2 = Offset(-15f, 0f)
//When
val (m1, b1) = findSlopeAndYIntercept(p1, p2)
//Then
assertEquals(0f, abs(m1))
assertEquals(0f, abs(b1))
}
@Test
fun `Given slops and y intercepts of two lines find the intersection point`() {
//Give
val m1 = 2f
val b1 = 3f
val m2 = -3f
val b2 = 8f
//When
val intersection = findLineIntersection(m1, b1, m2, b2)
//Then
assertEquals(Offset(1f, 5f), intersection)
}
@Test
fun `Given slops and y intercepts of two lines return null when slops are equal`() {
//Give
val m1 = 2f
val b1 = 3f
val m2 = 2f
val b2 = 8f
//When
val intersection = findLineIntersection(m1, b1, m2, b2)
//Then
assertEquals(null, intersection)
}
@Test
fun `Given t and two points find intermediate point`() {
//Given
val t = 0.5
val p0 = Offset(10f, 10f)
val p1 = Offset(20f, 30f)
//When
val interpolate = interpolate(t, p0, p1)
//Then
assertEquals(Offset(15f, 20f), interpolate)
}
@Test
fun `Given 4 control points on a cubic curve find intermediate point based on t value`() {
//Given
val t = 0.5
val p0 = Offset(10f, 10f)
val p1 = Offset(20f, 30f)
val p2 = Offset(40f, 30f)
val p3 = Offset(50f, 10f)
//When
val curvePoint = findCubicCurvePoints(t, p0, p1, p2, p3)
//Then
assertEquals(Offset(30f, 25f), curvePoint)
}
@Test
fun `Given a list of Offsets find the closest y value for the given point`() {
//Given
val list = listOf(Offset(10f, 10f), Offset(15f, 15f), Offset(18f, 18f), Offset(20f, 20f))
val target = 16f
//When
val nearestValue = findNearestValue(list, target)
//Then
assertEquals(Offset(15f, 15f), nearestValue)
}
@Test
fun `Given two points calculated cubicPoints value should be returned`() {
// Given
val startPoint = Offset(1f, 30f)
val endPoint = Offset(2f, 10f)
// When
val pairCubicPoints = findControlPoints(startPoint, endPoint)
//Then
val expectedCubic1Point = Offset(1.3f, 30f)
val expectedCubic2Point = Offset(1.7f, 10f)
assertEquals(expectedCubic1Point, pairCubicPoints.first)
assertEquals(expectedCubic2Point, pairCubicPoints.second)
}
@Test
fun `Given two points calculated cubicPoints1 y value should be returned`() {
// Given
val startPoint = Offset(1f, 30f)
val endPoint = Offset(2f, 10f)
// When
val pairCubicPoints = findControlPoints(startPoint, endPoint)
//Then
assertEquals(30f, pairCubicPoints.first.y)
}
@Test
fun `Given y values of two points position should return bottom`() {
// Given
val startPoint = 650f
val endPoint = 700f
val xAxisYPosition = 500f
// When
val position = findPointPosition(startPoint, endPoint, xAxisYPosition)
//Then
assertEquals(AxisPosition.BOTTOM, position)
}
@Test
fun `Given y values of two points of curve position should return top`() {
// Given
val startPoint = 450f
val endPoint = 400f
val xAxisYPosition = 500f
// When
val position = findPointPosition(startPoint, endPoint, xAxisYPosition)
//Then
assertEquals(AxisPosition.TOP, position)
}
@Test
fun `Given y values of two points of curve position should return intersect`() {
// Given
val startPoint = 450f
val endPoint = 550f
val xAxisYPosition = 500f
// When
val position = findPointPosition(startPoint, endPoint, xAxisYPosition)
//Then
assertEquals(AxisPosition.INTERSECT, position)
}
@Test
fun `Given y values of two points of curve position should return top when start point is equal to xAxis y value`() {
// Given
val startPoint = 500f
val endPoint = 550f
val xAxisYPosition = 500f
// When
val position = findPointPosition(startPoint, endPoint, xAxisYPosition)
//Then
assertEquals(AxisPosition.BOTTOM, position)
}
@Test
fun `Given y values of two points of curve position should return bottom when end point is equal to xAxis y value`() {
// Given
val startPoint = 450f
val endPoint = 500f
val xAxisYPosition = 500f
// When
val position = findPointPosition(startPoint, endPoint, xAxisYPosition)
//Then
assertEquals(AxisPosition.TOP, position)
}
@Test
fun `Given curve points return the point intersect on xAxis y position`() {
// Given
val curvePoints = listOf(
Offset(100f, 150f),
Offset(200f, 250f),
Offset(300f, 350f),
Offset(400f, 450f),
Offset(500f, 650f),
Offset(600f, 750f)
)
val xAxisYPosition = 500f
// When
val intersectionPoint = getCurveIntersectionPoint(curvePoints, xAxisYPosition)
//Then
assertEquals(Offset(400f, 450f), intersectionPoint)
}
@Test
fun `Given control points of a cubic curve find the points on the curve`() {
// Given
val p0 = Offset(0f, 0f)
val p1 = Offset(1f, 2f)
val p2 = Offset(3f, 4f)
val p3 = Offset(4f, 3f)
// When
val controlPoints = getCurvePoints(p0, p1, p2, p3)
//Then
assertEquals(Offset(4f, 3f), controlPoints.last())
}
}
@@ -0,0 +1,70 @@
package co.yml.charts.utils
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import co.yml.charts.ui.piechart.PieChartConstants.DEFAULT_PADDING
import co.yml.charts.ui.piechart.PieChartConstants.DEFAULT_START_ANGLE
import co.yml.charts.ui.piechart.models.PieChartData
import co.yml.charts.ui.piechart.utils.convertTouchEventPointToAngle
import co.yml.charts.ui.piechart.utils.getSliceCenterPoints
import co.yml.charts.ui.piechart.utils.proportion
import co.yml.charts.ui.piechart.utils.sum
import co.yml.charts.ui.piechart.utils.sweepAngles
import org.junit.Assert.*
import org.junit.Test
class PieChartUtilsTest {
private val sliceList = listOf(
PieChartData.Slice("A", 10f, Color.Cyan),
PieChartData.Slice("B", 20f, Color.Red),
PieChartData.Slice("C", 40f, Color.Black),
PieChartData.Slice("D", 30f, Color.Blue)
)
@Test
fun `Slice total calculation is correct`() {
val sum = sliceList.sum()
assertEquals(100f, sum)
}
@Test
fun `Proportion calculation is correct`() {
val sum = sliceList.sum()
val proportions = sliceList.proportion(sum)
assertEquals(10f, proportions.first())
}
@Test
fun `Sweep angle calculation is correct`() {
val sum = sliceList.sum()
val proportions = sliceList.proportion(sum)
val sweepAngles = proportions.sweepAngles()
assertEquals(36f, sweepAngles.first())
}
@Test
fun `Arch center calculation is correct and x and y are positive`() {
val sideSize = 200
val padding = (sideSize * DEFAULT_PADDING) / 100f
val size = Size(sideSize.toFloat() - padding, sideSize.toFloat() - padding)
val (arcCenter, x, y) = getSliceCenterPoints(
DEFAULT_START_ANGLE,
30f,
size,
padding
)
assertEquals(285f, arcCenter)
assertTrue(x > 0)
assertTrue(y > 0)
}
@Test
fun `Calculated angle is in the correct range`() {
val angle = convertTouchEventPointToAngle(200f, 200f, 10f, 10f)
assertTrue(angle in 0.0..360.0)
}
}
+1
View File
@@ -0,0 +1 @@
/build
+31
View File
@@ -0,0 +1,31 @@
plugins {
id("ycharts.android.application")
id("ycharts.android.application.compose")
id("ycharts.android.test")
}
android {
namespace = "co.yml.ycharts.app"
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
defaultConfig {
versionCode = 1
versionName = "1.0"
applicationId = "co.yml.ycharts.app"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
}
dependencies {
implementation(project(mapOf("path" to ":YChartsLib")))
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,24 @@
package co.yml.ycharts.app
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("co.yml.ycharts.app", appContext.packageName)
}
}
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.YCharts"
tools:targetApi="31">
<activity
android:name="co.yml.ycharts.app.presentation.BarChartActivity"
android:exported="false"
android:label="@string/title_activity_bar_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name="co.yml.ycharts.app.presentation.LineChartActivity"
android:exported="false"
android:label="@string/title_activity_line_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name="co.yml.ycharts.app.presentation.WaveChartActivity"
android:exported="false"
android:label="@string/title_activity_wave_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name="co.yml.ycharts.app.presentation.PieChartActivity"
android:exported="false"
android:label="@string/title_activity_pie_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name="co.yml.ycharts.app.presentation.DonutChartActivity"
android:exported="false"
android:label="@string/title_activity_donut_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name="co.yml.ycharts.app.presentation.CombinedLineAndBarChartActivity"
android:exported="false"
android:label="@string/title_activity_combined_line_bar_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name="co.yml.ycharts.app.presentation.BubbleChartActivity"
android:exported="false"
android:label="@string/title_activity_bubble_chart"
android:theme="@style/Theme.YCharts" />
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.YCharts">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,150 @@
package co.yml.ycharts.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.yml.ycharts.app.presentation.*
import co.yml.ycharts.app.ui.theme.YChartsTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
ChartsMenu()
}
}
}
@Composable
private fun ChartsMenu() {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = { AppBar() }) {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize(),
verticalArrangement = Arrangement.Center
) {
ChartButton(title = getString(R.string.title_bar_chart), onClick = {
startActivity(
Intent(
this@MainActivity, BarChartActivity::class.java
)
)
addActivityInOutAnim()
})
ChartButton(title = getString(R.string.title_line_chart), onClick = {
startActivity(
Intent(
this@MainActivity, LineChartActivity::class.java
)
)
addActivityInOutAnim()
})
ChartButton(title = getString(R.string.title_wave_chart), onClick = {
startActivity(
Intent(
this@MainActivity, WaveChartActivity::class.java
)
)
addActivityInOutAnim()
})
ChartButton(title = getString(R.string.title_pie_chart), onClick = {
startActivity(
Intent(
this@MainActivity, PieChartActivity::class.java
)
)
addActivityInOutAnim()
})
ChartButton(title = getString(R.string.title_donut_chart), onClick = {
startActivity(
Intent(
this@MainActivity, DonutChartActivity::class.java
)
)
addActivityInOutAnim()
})
ChartButton(title = getString(R.string.title_bar_with_line_chart), onClick = {
startActivity(
Intent(
this@MainActivity, CombinedLineAndBarChartActivity::class.java
)
)
addActivityInOutAnim()
})
ChartButton(title = getString(R.string.bubble_chart), onClick = {
startActivity(
Intent(
this@MainActivity, BubbleChartActivity::class.java
)
)
addActivityInOutAnim()
})
}
}
}
private fun addActivityInOutAnim() {
overridePendingTransition(
R.anim.move_right_in_activity, R.anim.move_left_out_activity
)
}
}
@Composable
private fun AppBar() {
TopAppBar(modifier = Modifier.fillMaxWidth(),
backgroundColor = YChartsTheme.colors.button,
elevation = 6.dp,
title = {
Text(
text = stringResource(R.string.app_name),
color = YChartsTheme.colors.text,
textAlign = TextAlign.Center,
style = YChartsTheme.typography.header
)
})
}
@Composable
private fun ChartButton(title: String, onClick: () -> Unit) {
Column {
Spacer(modifier = Modifier.height(20.dp))
Button(
modifier = Modifier
.padding(end = 10.dp, start = 10.dp)
.fillMaxWidth()
.height(50.dp),
onClick = onClick,
colors = ButtonDefaults.buttonColors(backgroundColor = YChartsTheme.colors.button)
) {
Text(
text = title,
style = YChartsTheme.typography.button,
color = YChartsTheme.colors.text
)
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
ChartButton(title = "Chart", onClick = {})
}
@@ -0,0 +1,491 @@
package co.yml.ycharts.app.presentation
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.axis.DataCategoryOptions
import co.yml.charts.common.components.Legends
import co.yml.charts.common.extensions.getMaxElementInYAxis
import co.yml.charts.common.model.LegendsConfig
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.barchart.BarChart
import co.yml.charts.ui.barchart.GroupBarChart
import co.yml.charts.ui.barchart.StackedBarChart
import co.yml.charts.ui.barchart.models.*
import co.yml.ycharts.app.R
import kotlin.random.Random
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
/**
* Bar chart activity
*
* @constructor Create empty Bar chart activity
*/
class BarChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.title_bar_chart),
onBackPressed = {
onBackPressed()
})
})
{
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.TopCenter
) {
LazyColumn(content = {
items(6) { item ->
when (item) {
0 ->{ Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.barchart_solid_colors),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
BarchartWithSolidBars()
}
1 -> { Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.barchart_gradient_colors),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
BarchartWithGradientBars()}
2 ->{
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.barchart_background_color),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
BarchartWithBackgroundColor()
}
3 ->{
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.horizontal_bar_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
HorizontalBarChart()
}
4 ->{
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.grouped_bar_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
VerticalGroupBarChart()
}
5 ->{
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.stacked_barchart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
VerticalStackedBarChart()
}
}
}
})
}
}
}
}
}
}
/**
* Barchart with solid bars
*
*/
@Composable
private fun BarchartWithSolidBars() {
val maxRange = 50
val barData = DataUtils.getBarChartData(50, maxRange, BarChartType.VERTICAL, DataCategoryOptions())
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(barData.size - 1)
.bottomPadding(40.dp)
.axisLabelAngle(20f)
.startDrawPadding(48.dp)
.labelData { index -> barData[index].label }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val barChartData = BarChartData(
chartData = barData,
xAxisData = xAxisData,
yAxisData = yAxisData,
barStyle = BarStyle(
paddingBetweenBars = 20.dp,
barWidth = 25.dp
),
showYAxis = true,
showXAxis = true,
horizontalExtraSpace = 10.dp,
)
BarChart(modifier = Modifier.height(350.dp), barChartData = barChartData)
}
/**
* Barchart with gradient bars
*
*/
@Composable
private fun BarchartWithGradientBars() {
val maxRange = 100
val barData = DataUtils.getGradientBarChartData(50, 100)
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(barData.size - 1)
.bottomPadding(40.dp)
.axisLabelAngle(20f)
.startDrawPadding(48.dp)
.labelData { index -> barData[index].label }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val barChartData = BarChartData(
chartData = barData,
xAxisData = xAxisData,
yAxisData = yAxisData,
barStyle = BarStyle(paddingBetweenBars = 20.dp,
barWidth = 35.dp,
isGradientEnabled = true,
selectionHighlightData = SelectionHighlightData(
highlightBarColor = Color.Red,
highlightTextBackgroundColor = Color.Green,
popUpLabel = { _, y -> " Value : $y " }
)),
showYAxis = true,
showXAxis = true,
horizontalExtraSpace = 20.dp
)
BarChart(modifier = Modifier.height(350.dp), barChartData = barChartData)
}
/**
* Barchart with background color
*
*/
@Composable
private fun BarchartWithBackgroundColor() {
val maxRange = 100
val backgroundColor = Color.LightGray
val barData = DataUtils.getBarChartData(50, 100, BarChartType.VERTICAL, DataCategoryOptions())
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(barData.size - 1)
.bottomPadding(40.dp)
.startDrawPadding(48.dp)
.axisLabelAngle(20f)
.labelData { index -> barData[index].label }
.backgroundColor(backgroundColor)
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.backgroundColor(backgroundColor)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val barChartData = BarChartData(
chartData = barData,
xAxisData = xAxisData,
yAxisData = yAxisData,
barStyle = BarStyle(paddingBetweenBars = 20.dp,
barWidth = 35.dp,
selectionHighlightData = SelectionHighlightData(
highlightBarColor = Color.Red,
highlightTextBackgroundColor = Color.Green,
popUpLabel = { _, y -> " Value : $y " }
)),
showYAxis = true,
showXAxis = true,
horizontalExtraSpace = 20.dp,
backgroundColor = backgroundColor
)
BarChart(modifier = Modifier.height(350.dp), barChartData = barChartData)
}
@Composable
private fun VerticalBarChart() {
val maxRange = 50
val barData =
DataUtils.getBarChartData(50, maxRange, BarChartType.VERTICAL, DataCategoryOptions())
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(barData.size - 1)
.bottomPadding(12.dp)
.axisLabelAngle(20f)
.startDrawPadding(48.dp)
.shouldDrawAxisLineTillEnd(false)
.labelData { index -> barData[index].label }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val barChartData = BarChartData(
chartData = barData,
xAxisData = xAxisData,
yAxisData = yAxisData,
barStyle = BarStyle(
paddingBetweenBars = 20.dp,
barWidth = 25.dp
),
showYAxis = true,
showXAxis = true,
horizontalExtraSpace = 10.dp,
drawBar = { drawScope, barData, drawOffset, height, barChartType, barStyle ->
with(drawScope) {
with(barStyle) {
drawRect(
color = barData.color,
topLeft = drawOffset,
size = if (barChartType == BarChartType.VERTICAL) Size(
barWidth.toPx(),
height
) else Size(height, barWidth.toPx()),
style = barDrawStyle,
blendMode = barBlendMode
)
}
}
}
)
BarChart(modifier = Modifier.height(350.dp), barChartData = barChartData)
}
@Composable
private fun HorizontalBarChart() {
val maxRange = 30
val barData =
DataUtils.getBarChartData(
10,
maxRange,
BarChartType.HORIZONTAL,
DataCategoryOptions(isDataCategoryInYAxis = true)
)
val xStepSize = 10
val xAxisData = AxisData.Builder()
.steps(xStepSize)
.bottomPadding(12.dp)
.endPadding(40.dp)
.labelData { index -> (index * (maxRange / xStepSize)).toString() }
.build()
val yAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(barData.size - 1)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.setDataCategoryOptions(
DataCategoryOptions(
isDataCategoryInYAxis = true,
isDataCategoryStartFromBottom = false
)
)
.startDrawPadding(48.dp)
.labelData { index -> barData[index].label }
.build()
val barChartData = BarChartData(
chartData = barData,
xAxisData = xAxisData,
yAxisData = yAxisData,
barStyle = BarStyle(
isGradientEnabled = false,
paddingBetweenBars = 20.dp,
barWidth = 35.dp,
selectionHighlightData = SelectionHighlightData(
highlightBarColor = Color.Red,
highlightTextBackgroundColor = Color.Green,
popUpLabel = { x, _ -> " Value : $x " },
barChartType = BarChartType.HORIZONTAL
),
),
showYAxis = true,
showXAxis = true,
horizontalExtraSpace = 20.dp,
barChartType = BarChartType.HORIZONTAL
)
BarChart(
modifier = Modifier.height(350.dp),
barChartData = barChartData
)
}
@Composable
fun VerticalGroupBarChart() {
val maxRange = 100
val barSize = 3
val groupBarData = DataUtils.getGroupBarChartData(50, maxRange, barSize)
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.bottomPadding(5.dp)
.startDrawPadding(48.dp)
.labelData { index -> index.toString() }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val colorPaletteList = DataUtils.getColorPaletteList(barSize)
val legendsConfig = LegendsConfig(
legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList),
gridColumnCount = 3
)
val groupBarPlotData = BarPlotData(
groupBarList = groupBarData,
barStyle = BarStyle(barWidth = 35.dp),
barColorPaletteList = colorPaletteList
)
val groupBarChartData = GroupBarChartData(
barPlotData = groupBarPlotData,
xAxisData = xAxisData,
yAxisData = yAxisData,
groupSeparatorConfig = GroupSeparatorConfig(0.dp)
)
Column(
Modifier
.height(450.dp)
) {
GroupBarChart(
modifier = Modifier
.height(400.dp),
groupBarChartData = groupBarChartData
)
Legends(
legendsConfig = legendsConfig
)
}
}
@Composable
fun VerticalStackedBarChart() {
val barSize = 3
val listSize = 10
val groupBarData = DataUtils.getGroupBarChartData(listSize, 100, barSize)
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(listSize - 1)
.startDrawPadding(48.dp)
.labelData { index -> "C $index" }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index ->
val valueList = mutableListOf<Float>()
groupBarData.map { groupBar ->
var yMax = 0f
groupBar.barList.forEach {
yMax += it.point.y
}
valueList.add(yMax)
}
val maxElementInYAxis = getMaxElementInYAxis(valueList.maxOrNull() ?: 0f, yStepSize)
(index * (maxElementInYAxis / yStepSize)).toString()
}
.topPadding(36.dp)
.build()
val colorPaletteList = DataUtils.getColorPaletteList(barSize)
val legendsConfig = LegendsConfig(
legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList),
gridColumnCount = 3
)
val groupBarPlotData = BarPlotData(
groupBarList = groupBarData,
barStyle = BarStyle(
barWidth = 35.dp,
selectionHighlightData = SelectionHighlightData(
isHighlightFullBar = true,
groupBarPopUpLabel = { name, value ->
"Name : C$name Value : ${String.format("%.2f", value)}"
}
)
),
barColorPaletteList = colorPaletteList
)
val groupBarChartData = GroupBarChartData(
barPlotData = groupBarPlotData,
xAxisData = xAxisData,
yAxisData = yAxisData,
paddingBetweenStackedBars = 4.dp,
drawBar = { drawScope, barChartData, barStyle, drawOffset, height, barIndex ->
with(drawScope) {
drawRect(
color = colorPaletteList[barIndex],
topLeft = drawOffset,
size = Size(barStyle.barWidth.toPx(), height),
style = barStyle.barDrawStyle,
blendMode = barStyle.barBlendMode
)
}
}
)
Column(
Modifier
.height(500.dp)
) {
StackedBarChart(
modifier = Modifier
.height(400.dp),
groupBarChartData = groupBarChartData
)
Legends(
legendsConfig = legendsConfig
)
}
}
@@ -0,0 +1,185 @@
package co.yml.ycharts.app.presentation
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.extensions.formatToSinglePrecision
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.bubblechart.BubbleChart
import co.yml.charts.ui.bubblechart.model.BubbleChartData
import co.yml.charts.ui.linechart.model.GridLines
import co.yml.ycharts.app.R
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
/**
* Line chart activity
*
* @constructor Create empty Line chart activity
*/
class BubbleChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.bubble_chart),
onBackPressed = {
onBackPressed()
})
})
{
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.TopCenter
) {
LazyColumn(content = {
item {
Text(
modifier = Modifier.padding(12.dp),
text = getString(R.string.gradient_bubble_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
BubbleChartWithGrid(
pointsData = DataUtils.getRandomPoints(
200,
start = 30,
maxRange = 100
)
)
Spacer(modifier = Modifier.height(12.dp))
}
item {
Text(
modifier = Modifier.padding(12.dp),
text = getString(R.string.solid_bubble_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
SolidBubbleChart(
pointsData = DataUtils.getRandomPoints(
200,
start = 30,
maxRange = 900
)
)
Spacer(modifier = Modifier.height(12.dp))
}
})
}
}
}
}
}
}
/**
* Bubble chart with grid lines
*
* @param pointsData
*/
@Composable
private fun BubbleChartWithGrid(pointsData: List<Point>) {
val steps = 5
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(pointsData.size - 1)
.labelData { i -> pointsData[i].x.toInt().toString() }
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelAndAxisLinePadding(15.dp)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}.build()
val data = BubbleChartData(
DataUtils.getBubbleChartDataWithGradientStyle(pointsData),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
BubbleChart(
modifier = Modifier
.fillMaxWidth()
.height(500.dp),
bubbleChartData = data
)
}
/**
* Bubble chart with grid lines
*
* @param pointsData
*/
@Composable
private fun SolidBubbleChart(pointsData: List<Point>) {
val steps = 5
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(pointsData.size - 1)
.labelData { i ->pointsData[i].x.toInt().toString()}
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelAndAxisLinePadding(15.dp)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}.build()
val data = BubbleChartData(
DataUtils.getBubbleChartDataWithSolidStyle(pointsData),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
BubbleChart(
modifier = Modifier
.fillMaxWidth()
.height(500.dp),
bubbleChartData = data
)
}
@@ -0,0 +1,235 @@
@file:OptIn(ExperimentalMaterialApi::class)
package co.yml.ycharts.app.presentation
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
import co.yml.charts.ui.barchart.models.BarPlotData
import co.yml.charts.ui.barchart.models.BarStyle
import co.yml.charts.ui.combinedchart.CombinedChart
import co.yml.charts.ui.combinedchart.model.CombinedChartData
import co.yml.charts.common.components.Legends
import co.yml.charts.common.model.LegendsConfig
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.linechart.model.IntersectionPoint
import co.yml.charts.ui.linechart.model.Line
import co.yml.charts.ui.linechart.model.LinePlotData
import co.yml.charts.ui.linechart.model.LineStyle
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
import co.yml.ycharts.app.R
/**
* Combined line and bar chart activity
*
* @constructor Create empty Combined line and bar chart activity
*/
class CombinedLineAndBarChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.title_bar_with_line_chart),
onBackPressed = {
onBackPressed()
})
})
{
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.TopCenter
) {
LazyColumn(content = {
items(2) { item ->
when (item) {
0 ->{
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.combined_bar_line_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
BarWithLineChart()
}
1->{
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.combined_chart_with_background),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
BarWithLineChartAndBackground()
}
}
}
})
}
}
}
}
}
}
/**
* Bar with line chart
*
*/
@Composable
fun BarWithLineChart() {
val maxRange = 100
val groupBarData = DataUtils.getGroupBarChartData(50, 100, 3)
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.bottomPadding(5.dp)
.labelData { index -> index.toString() }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val linePlotData = LinePlotData(
lines = listOf(
Line(
DataUtils.getLineChartData(50, maxRange = 100),
lineStyle = LineStyle(color = Color.Blue),
intersectionPoint = IntersectionPoint(),
selectionHighlightPoint = SelectionHighlightPoint(),
selectionHighlightPopUp = SelectionHighlightPopUp()
),
Line(
DataUtils.getLineChartData(50, maxRange = 100),
lineStyle = LineStyle(color = Color.Black),
intersectionPoint = IntersectionPoint(),
selectionHighlightPoint = SelectionHighlightPoint(),
selectionHighlightPopUp = SelectionHighlightPopUp()
)
)
)
val colorPaletteList = DataUtils.getColorPaletteList(3)
val legendsConfig = LegendsConfig(
legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList),
gridColumnCount = 3
)
val barPlotData = BarPlotData(
groupBarList = groupBarData,
barStyle = BarStyle(barWidth = 35.dp),
barColorPaletteList = colorPaletteList
)
val combinedChartData = CombinedChartData(
combinedPlotDataList = listOf(barPlotData, linePlotData),
xAxisData = xAxisData,
yAxisData = yAxisData
)
Column(
Modifier
.height(500.dp)
) {
CombinedChart(
modifier = Modifier
.height(400.dp),
combinedChartData = combinedChartData
)
Legends(
legendsConfig = legendsConfig
)
}
}
/**
* Bar with line chart and background
*
*/
@Composable
fun BarWithLineChartAndBackground() {
val maxRange = 100
val groupBarData = DataUtils.getGroupBarChartData(50, 100, 3)
val yStepSize = 10
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.bottomPadding(5.dp)
.backgroundColor(Color.Yellow)
.labelData { index -> index.toString() }
.build()
val yAxisData = AxisData.Builder()
.steps(yStepSize)
.backgroundColor(Color.Yellow)
.labelAndAxisLinePadding(20.dp)
.axisOffset(20.dp)
.labelData { index -> (index * (maxRange / yStepSize)).toString() }
.build()
val linePlotData = LinePlotData(
lines = listOf(
Line(
DataUtils.getLineChartData(50, maxRange = 100),
lineStyle = LineStyle(color = Color.Blue),
intersectionPoint = IntersectionPoint(),
selectionHighlightPoint = SelectionHighlightPoint(),
selectionHighlightPopUp = SelectionHighlightPopUp()
),
Line(
DataUtils.getLineChartData(50, maxRange = 100),
lineStyle = LineStyle(color = Color.Black),
intersectionPoint = IntersectionPoint(),
selectionHighlightPoint = SelectionHighlightPoint(),
selectionHighlightPopUp = SelectionHighlightPopUp()
)
)
)
val colorPaletteList = DataUtils.getColorPaletteList(3)
val legendsConfig = LegendsConfig(
legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList),
gridColumnCount = 3
)
val barPlotData = BarPlotData(
groupBarList = groupBarData,
barStyle = BarStyle(barWidth = 35.dp),
barColorPaletteList = colorPaletteList
)
val combinedChartData = CombinedChartData(
combinedPlotDataList = listOf(barPlotData, linePlotData),
xAxisData = xAxisData,
yAxisData = yAxisData,
backgroundColor = Color.Yellow
)
Column(
Modifier
.height(500.dp)
) {
CombinedChart(
modifier = Modifier
.height(400.dp),
combinedChartData = combinedChartData
)
Legends(
legendsConfig = legendsConfig
)
}
}
@@ -0,0 +1,250 @@
package co.yml.ycharts.app.presentation
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.yml.charts.common.components.Legends
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.piechart.charts.DonutPieChart
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.charts.ui.piechart.utils.proportion
import co.yml.ycharts.app.R
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
/**
* Donut chart activity
*
* @constructor Create empty Donut chart activity
*/
@OptIn(ExperimentalMaterialApi::class)
class DonutChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.title_donut_chart),
onBackPressed = {
onBackPressed()
})
})
{
val context = LocalContext.current
LazyColumn(content = {
items(2) { item ->
when (item) {
0 -> {
Text(
modifier = Modifier.padding(12.dp),
text = getString(R.string.simple_donut_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
Box(
modifier = Modifier
.padding(it)
.fillMaxWidth()
) {
Spacer(modifier = Modifier.height(20.dp))
SimpleDonutChart(context)
}
}
1 -> {
Text(
modifier = Modifier.padding(12.dp),
text = getString(R.string.multiple_donuts),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
MultipleSmallDonutCharts(context)
}
}
}
})
}
}
}
}
}
/**
* Simple donut chart
*
* @param context
*/
@ExperimentalMaterialApi
@Composable
private fun SimpleDonutChart(context: Context) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val data = DataUtils.getDonutChartData()
// Sum of all the values
val sumOfValues = data.totalLength
// Calculate each proportion value
val proportions = data.slices.proportion(sumOfValues)
val pieChartConfig =
PieChartConfig(
labelVisible = true,
strokeWidth = 120f,
labelColor = Color.Black,
activeSliceAlpha = .9f,
isEllipsizeEnabled = true,
labelTypeface = Typeface.defaultFromStyle(Typeface.BOLD),
isAnimationEnable = true,
chartPadding = 25,
labelFontSize = 42.sp,
)
Column(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
) {
Legends(legendsConfig = DataUtils.getLegendsConfigFromPieChartData(pieChartData = data, 3))
DonutPieChart(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
data,
pieChartConfig
) { slice ->
Toast.makeText(context, slice.label, Toast.LENGTH_SHORT).show()
}
}
}
/**
* Multiple small donut charts
*
* @param context
*/
@ExperimentalMaterialApi
@Composable
private fun MultipleSmallDonutCharts(context: Context) {
val accessibilitySheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val data = DataUtils.getDonutChartData()
// Sum of all the values
val sumOfValues = data.totalLength
// Calculate each proportion value
val proportions = data.slices.proportion(sumOfValues)
val firstPieChartConfig =
PieChartConfig(
labelVisible = true,
strokeWidth = 50f,
labelColor = Color.Black,
backgroundColor = Color.Yellow,
activeSliceAlpha = .9f,
isEllipsizeEnabled = true,
labelTypeface = Typeface.defaultFromStyle(Typeface.BOLD),
isAnimationEnable = true,
chartPadding = 25,
labelFontSize = 16.sp
)
val secondPieChartConfig =
PieChartConfig(
labelVisible = true,
strokeWidth = 50f,
labelColor = Color.Black,
activeSliceAlpha = .9f,
isEllipsizeEnabled = true,
backgroundColor = Color.Black,
labelTypeface = Typeface.defaultFromStyle(Typeface.BOLD),
isAnimationEnable = true,
chartPadding = 25,
labelFontSize = 16.sp,
isSumVisible = true,
sumUnit = "unit",
labelColorType = PieChartConfig.LabelColorType.SLICE_COLOR,
labelType = PieChartConfig.LabelType.VALUE
)
val thirdPieChartConfig =
PieChartConfig(
labelVisible = true,
strokeWidth = 50f,
labelColor = Color.Black,
activeSliceAlpha = .9f,
backgroundColor = Color.LightGray,
isEllipsizeEnabled = true,
labelTypeface = Typeface.defaultFromStyle(Typeface.BOLD),
isAnimationEnable = true,
chartPadding = 25,
labelFontSize = 16.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
) {
Legends(legendsConfig = DataUtils.getLegendsConfigFromPieChartData(pieChartData = data, 3))
Spacer(modifier = Modifier.height(20.dp))
LazyRow(
modifier = Modifier
.fillMaxWidth()
) {
item {
DonutPieChart(
modifier = Modifier
.width(100.dp)
.height(100.dp),
data,
firstPieChartConfig
) { slice ->
Toast.makeText(context, slice.label, Toast.LENGTH_SHORT).show()
}
Spacer(modifier = Modifier.width(30.dp))
}
item {
DonutPieChart(
modifier = Modifier
.width(100.dp)
.height(100.dp),
data,
secondPieChartConfig
) { slice ->
Toast.makeText(context, slice.label, Toast.LENGTH_SHORT).show()
}
Spacer(modifier = Modifier.width(30.dp))
}
item {
DonutPieChart(
modifier = Modifier
.width(100.dp)
.height(100.dp),
data,
thirdPieChartConfig
) { slice ->
Toast.makeText(context, slice.label, Toast.LENGTH_SHORT).show()
}
Spacer(modifier = Modifier.width(30.dp))
}
}
}
}
@@ -0,0 +1,642 @@
package co.yml.ycharts.app.presentation
import android.graphics.Typeface
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.components.Legends
import co.yml.charts.common.extensions.formatToSinglePrecision
import co.yml.charts.common.model.LegendsConfig
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.linechart.LineChart
import co.yml.charts.ui.linechart.model.GridLines
import co.yml.charts.ui.linechart.model.IntersectionPoint
import co.yml.charts.ui.linechart.model.Line
import co.yml.charts.ui.linechart.model.LineChartData
import co.yml.charts.ui.linechart.model.LinePlotData
import co.yml.charts.ui.linechart.model.LineStyle
import co.yml.charts.ui.linechart.model.LineType
import co.yml.charts.ui.linechart.model.SelectionHighlightPoint
import co.yml.charts.ui.linechart.model.SelectionHighlightPopUp
import co.yml.charts.ui.linechart.model.ShadowUnderLine
import co.yml.ycharts.app.R
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
/**
* Line chart activity
*
* @constructor Create empty Line chart activity
*/
class LineChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.title_line_chart),
onBackPressed = {
onBackPressed()
})
})
{
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.TopCenter
) {
LazyColumn(content = {
item {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.linechart_default_style),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
SingleLineChartWithGridLines(
DataUtils.getLineChartData(
100,
start = 50,
maxRange = 100
)
)
Spacer(modifier = Modifier.height(12.dp))
Divider(modifier = Modifier
.fillMaxWidth()
.height(1.dp))
}
item {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.linechart_straight_line_style),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
StraightLinechart(DataUtils.getLineChartData(50, maxRange = 200))
Spacer(modifier = Modifier.height(12.dp))
Divider(modifier = Modifier
.fillMaxWidth()
.height(1.dp))
}
item {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.linechart_dotted_style),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
DottedLinechart(DataUtils.getLineChartData(
200,
start = -50,
maxRange = 50
))
Spacer(modifier = Modifier.height(12.dp))
Divider(modifier = Modifier
.fillMaxWidth()
.height(1.dp))
}
item {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.linechart_multiple_tones),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
MultipleToneLinechart(DataUtils.getLineChartData(
200,
start = -50,
maxRange = 50
))
Spacer(modifier = Modifier.height(12.dp))
Divider(modifier = Modifier
.fillMaxWidth()
.height(1.dp))
}
item {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.combined_line_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
CombinedLinechart(DataUtils.getLineChartData(
200,
start = -50,
maxRange = 50
))
Spacer(modifier = Modifier.height(12.dp))
Divider(modifier = Modifier
.fillMaxWidth()
.height(1.dp))
}
item {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.combined_line_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
CombinedLinechartWithBackground(DataUtils.getLineChartData(
200,
start = -50,
maxRange = 50
))
}
})
}
}
}
}
}
}
/**
* Single line chart with grid lines
*
* @param pointsData
*/
@Composable
private fun SingleLineChartWithGridLines(pointsData: List<Point>) {
val steps = 5
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.topPadding(105.dp)
.steps(pointsData.size - 1)
.labelData { i -> pointsData[i].x.toInt().toString() }
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelAndAxisLinePadding(20.dp)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}.build()
val data = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
LineStyle(),
IntersectionPoint(),
SelectionHighlightPoint(),
ShadowUnderLine(),
SelectionHighlightPopUp()
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = data
)
}
/**
* Straight linechart
*
* @param pointsData
*/
@Composable
private fun StraightLinechart(pointsData: List<Point>) {
val xAxisData = AxisData.Builder()
.axisStepSize(40.dp)
.steps(pointsData.size - 1)
.labelData { i -> (1900 + i).toString() }
.axisLabelAngle(20f)
.labelAndAxisLinePadding(15.dp)
.axisLabelColor(Color.Blue)
.axisLineColor(Color.DarkGray)
.typeFace(Typeface.DEFAULT_BOLD)
.build()
val yAxisData = AxisData.Builder()
.steps(10)
.labelData { i -> "${(i * 20)}k" }
.labelAndAxisLinePadding(30.dp)
.axisLabelColor(Color.Blue)
.axisLineColor(Color.DarkGray)
.typeFace(Typeface.DEFAULT_BOLD)
.build()
val data = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
lineStyle = LineStyle(lineType = LineType.Straight(), color = Color.Blue),
intersectionPoint = IntersectionPoint(color = Color.Red),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData
)
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = data
)
}
/**
* Multiple tone linechart
*
* @param pointsData
*/
@Composable
private fun MultipleToneLinechart(pointsData: List<Point>) {
val xAxisData = AxisData.Builder()
.axisStepSize(40.dp)
.steps(pointsData.size - 1)
.labelData { i -> (1900 + i).toString() }
.axisLabelAngle(20f)
.labelAndAxisLinePadding(15.dp)
.axisLabelColor(Color.Blue)
.axisLineColor(Color.DarkGray)
.typeFace(Typeface.DEFAULT_BOLD)
.build()
val yAxisData = AxisData.Builder()
.steps(10)
.labelData { i -> "${(i * 20)}k" }
.labelAndAxisLinePadding(30.dp)
.axisLabelColor(Color.Blue)
.axisLineColor(Color.DarkGray)
.typeFace(Typeface.DEFAULT_BOLD)
.build()
val data = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
lineStyle = LineStyle(lineType = LineType.Straight(), color = Color.Blue),
intersectionPoint = IntersectionPoint(color = Color.Red),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
), Line(
dataPoints = pointsData.subList(0, 10),
lineStyle = LineStyle(lineType = LineType.Straight(), color = Color.Magenta),
intersectionPoint = IntersectionPoint(color = Color.Red),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
), Line(
dataPoints = pointsData.subList(15, 30),
lineStyle = LineStyle(lineType = LineType.Straight(), color = Color.Yellow),
intersectionPoint = IntersectionPoint(color = Color.Red),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData
)
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = data
)
}
/**
* Dotted linechart
*
* @param pointsData
*/
@Composable
private fun DottedLinechart(pointsData: List<Point>) {
val steps = 10
val xAxisData = AxisData.Builder()
.axisStepSize(40.dp)
.steps(pointsData.size - 1)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.axisLineColor(Color.Red)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelData { i ->
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}
.axisLineColor(Color.Red)
.labelAndAxisLinePadding(20.dp)
.build()
val data = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
lineStyle = LineStyle(
lineType = LineType.SmoothCurve(isDotted = true),
color = Color.Green
),
shadowUnderLine = ShadowUnderLine(
brush = Brush.verticalGradient(
listOf(
Color.Green,
Color.Transparent
)
), alpha = 0.3f
),
selectionHighlightPoint = SelectionHighlightPoint(
color = Color.Green
),
selectionHighlightPopUp = SelectionHighlightPopUp(
backgroundColor = Color.Black,
backgroundStyle = Stroke(2f),
labelColor = Color.Red,
labelTypeface = Typeface.DEFAULT_BOLD
)
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData
)
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = data
)
}
/**
* Combined linechart
*
* @param pointsData
*/
@Composable
private fun CombinedLinechart(pointsData: List<Point>) {
val steps = 5
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(pointsData.size - 1)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelAndAxisLinePadding(20.dp)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}.build()
val colorPaletteList = listOf<Color>(Color.Blue,Color.Yellow,Color.Magenta,Color.DarkGray)
val legendsConfig = LegendsConfig(
legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList),
gridColumnCount = 4
)
val data = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
lineStyle = LineStyle(
lineType = LineType.SmoothCurve(isDotted = true),
color = colorPaletteList.first()
),
shadowUnderLine = ShadowUnderLine(
brush = Brush.verticalGradient(
listOf(
Color.Green,
Color.Transparent
)
), alpha = 0.3f
),
selectionHighlightPoint = SelectionHighlightPoint(
color = Color.Green
),
selectionHighlightPopUp = SelectionHighlightPopUp(
backgroundColor = Color.Black,
backgroundStyle = Stroke(2f),
labelColor = Color.Red,
labelTypeface = Typeface.DEFAULT_BOLD
)
),
Line(
dataPoints = pointsData.subList(10,20),
lineStyle = LineStyle(lineType = LineType.SmoothCurve(), color = colorPaletteList[1]),
intersectionPoint = IntersectionPoint(color = Color.Red),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
),
Line(
dataPoints = DataUtils.getLineChartData(
20,
start = 0,
maxRange = 50
),
LineStyle(color = colorPaletteList[2]),
IntersectionPoint(),
SelectionHighlightPoint(),
shadowUnderLine = ShadowUnderLine(
brush = Brush.verticalGradient(
listOf(
Color.Cyan,
Color.Blue
)
), alpha = 0.5f
),
SelectionHighlightPopUp()
),
Line(
dataPoints = pointsData.subList(10, 20),
LineStyle(color = colorPaletteList[3]),
IntersectionPoint(),
SelectionHighlightPoint(),
ShadowUnderLine(),
SelectionHighlightPopUp()
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
Column(modifier = Modifier.height(400.dp)) {
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = data
)
Legends(
legendsConfig = legendsConfig
)
}
}
/**
* Combined linechart with background
*
* @param pointsData
*/
@Composable
private fun CombinedLinechartWithBackground(pointsData: List<Point>) {
val steps = 5
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.steps(pointsData.size - 1)
.backgroundColor(Color.Yellow)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.backgroundColor(Color.Yellow)
.labelAndAxisLinePadding(20.dp)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}.build()
val colorPaletteList = listOf<Color>(Color.Blue,Color.Yellow,Color.Magenta,Color.DarkGray)
val legendsConfig = LegendsConfig(
legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList),
gridColumnCount = 4
)
val data = LineChartData(
linePlotData = LinePlotData(
lines = listOf(
Line(
dataPoints = pointsData,
lineStyle = LineStyle(
lineType = LineType.SmoothCurve(isDotted = true),
color = colorPaletteList.first()
),
shadowUnderLine = ShadowUnderLine(
brush = Brush.verticalGradient(
listOf(
Color.Green,
Color.Transparent
)
), alpha = 0.3f
),
selectionHighlightPoint = SelectionHighlightPoint(
color = Color.Green
),
selectionHighlightPopUp = SelectionHighlightPopUp(
backgroundColor = Color.Black,
backgroundStyle = Stroke(2f),
labelColor = Color.Red,
labelTypeface = Typeface.DEFAULT_BOLD
)
),
Line(
dataPoints = pointsData.subList(10,20),
lineStyle = LineStyle(lineType = LineType.SmoothCurve(), color = colorPaletteList[1]),
intersectionPoint = IntersectionPoint(color = Color.Red),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
),
Line(
dataPoints = DataUtils.getLineChartData(
20,
start = 0,
maxRange = 50
),
LineStyle(color = colorPaletteList[2]),
IntersectionPoint(),
SelectionHighlightPoint(),
shadowUnderLine = ShadowUnderLine(
brush = Brush.verticalGradient(
listOf(
Color.Cyan,
Color.Blue
)
), alpha = 0.5f
),
SelectionHighlightPopUp()
),
Line(
dataPoints = pointsData.subList(10, 20),
LineStyle(color = colorPaletteList[3]),
IntersectionPoint(),
SelectionHighlightPoint(),
ShadowUnderLine(),
SelectionHighlightPopUp()
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines(),
backgroundColor = Color.Yellow
)
Column(
modifier = Modifier
.height(400.dp)
) {
LineChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
lineChartData = data
)
Legends(
legendsConfig = legendsConfig
)
}
}
@@ -0,0 +1,156 @@
package co.yml.ycharts.app.presentation
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
import android.text.TextUtils
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.yml.charts.common.components.Legends
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.piechart.charts.PieChart
import co.yml.charts.ui.piechart.models.PieChartConfig
import co.yml.ycharts.app.R
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
/**
* Pie chart activity
*
* @constructor Create empty Pie chart activity
*/
class PieChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.title_pie_chart),
onBackPressed = {
onBackPressed()
})
})
{
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.TopCenter
) {
LazyColumn(content = {
items(3) { item ->
when (item) {
0 -> {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.simple_piechart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
SimplePiechart(LocalContext.current)
}
1 -> {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.piechart_with_lables),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
PiechartWithSliceLables(LocalContext.current)
}
}
}
})
}
}
}
}
}
}
/**
* Simple piechart
*
* @param context
*/
@Composable
private fun SimplePiechart(context: Context) {
val pieChartData = DataUtils.getPieChartData()
val pieChartConfig =
PieChartConfig(
labelVisible = true,
activeSliceAlpha = .9f,
isEllipsizeEnabled = true,
sliceLabelEllipsizeAt = TextUtils.TruncateAt.MIDDLE,
isAnimationEnable = true,
chartPadding = 30,
backgroundColor = Color.Black,
showSliceLabels = false,
animationDuration = 1500
)
Column(modifier = Modifier.height(500.dp)) {
Spacer(modifier = Modifier.height(20.dp))
Legends(legendsConfig = DataUtils.getLegendsConfigFromPieChartData(pieChartData, 3))
PieChart(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
pieChartData,
pieChartConfig
) { slice ->
Toast.makeText(context, slice.label, Toast.LENGTH_SHORT).show()
}
}
}
/**
* Piechart with slice lables
*
* @param context
*/
@Composable
private fun PiechartWithSliceLables(context: Context) {
val pieChartData = DataUtils.getPieChartData2()
val pieChartConfig =
PieChartConfig(
activeSliceAlpha = .9f,
isEllipsizeEnabled = true,
sliceLabelEllipsizeAt = TextUtils.TruncateAt.MIDDLE,
sliceLabelTypeface = Typeface.defaultFromStyle(Typeface.ITALIC),
isAnimationEnable = true,
chartPadding = 20,
showSliceLabels = true,
labelVisible = true
)
Column(modifier = Modifier.height(500.dp)) {
Legends(legendsConfig = DataUtils.getLegendsConfigFromPieChartData(pieChartData, 3))
PieChart(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
pieChartData,
pieChartConfig
) { slice ->
Toast.makeText(context, slice.label, Toast.LENGTH_SHORT).show()
}
}
}
@@ -0,0 +1,296 @@
package co.yml.ycharts.app.presentation
import android.graphics.Typeface
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.yml.charts.axis.AxisData
import co.yml.charts.common.extensions.formatToSinglePrecision
import co.yml.charts.common.model.Point
import co.yml.charts.common.utils.DataUtils
import co.yml.charts.ui.linechart.model.*
import co.yml.charts.ui.wavechart.WaveChart
import co.yml.charts.ui.wavechart.model.Wave
import co.yml.charts.ui.wavechart.model.WaveChartData
import co.yml.charts.ui.wavechart.model.WaveFillColor
import co.yml.charts.ui.wavechart.model.WavePlotData
import co.yml.ycharts.app.R
import co.yml.ycharts.app.ui.compositions.AppBarWithBackButton
import co.yml.ycharts.app.ui.theme.YChartsTheme
class WaveChartActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YChartsTheme {
Scaffold(modifier = Modifier.fillMaxSize(),
backgroundColor = YChartsTheme.colors.background,
topBar = {
AppBarWithBackButton(
stringResource(id = R.string.title_wave_chart),
onBackPressed = {
onBackPressed()
})
})
{
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.TopCenter
) {
LazyColumn(content = {
items(3) { item ->
when (item) {
0 -> {
Text(
modifier = Modifier.padding(12.dp),
text = getString(R.string.simple_wave_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
WaveGraph1(
DataUtils.getWaveChartData(
15.0,
50.0,
0.5
)
)
}
1 -> {
Text(
modifier=Modifier.padding(12.dp),
text = getString(R.string.sine_wave_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
WaveGraph2(
DataUtils.getWaveChartData(
15.0,
50.0,
0.2
)
)
}
2 -> {
Text(
modifier = Modifier.padding(12.dp),
text = getString(R.string.amplitude_wave_chart),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
WaveGraph3(
DataUtils.getWaveChartData(
20.0,
5.0,
1.0
)
)
}
}
}
})
}
}
}
}
}
}
@Composable
private fun WaveGraph1(pointsData: List<Point>) {
val steps = 5
val xAxisData = AxisData.Builder()
.axisStepSize(30.dp)
.startDrawPadding(48.dp)
.steps(pointsData.size - 1)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelAndAxisLinePadding(20.dp)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}.build()
val data = WaveChartData(
wavePlotData = WavePlotData(
lines = listOf(
Wave(
dataPoints = pointsData,
waveStyle = LineStyle(color = Color.Black),
selectionHighlightPoint = SelectionHighlightPoint(),
shadowUnderLine = ShadowUnderLine(),
selectionHighlightPopUp = SelectionHighlightPopUp(),
waveFillColor = WaveFillColor(topColor = Color.Green, bottomColor = Color.Red),
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData,
gridLines = GridLines()
)
WaveChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
waveChartData = data
)
}
@Composable
private fun WaveGraph2(pointsData: List<Point>) {
val steps = 10
val xAxisData = AxisData.Builder()
.axisStepSize(40.dp)
.startDrawPadding(48.dp)
.steps(pointsData.size - 1)
.labelData { i -> (i).toString() }
.axisLabelAngle(20f)
.labelAndAxisLinePadding(15.dp)
.axisLabelColor(Color.Blue)
.axisLineColor(Color.DarkGray)
.typeFace(Typeface.DEFAULT_BOLD)
.build()
val yAxisData = AxisData.Builder()
.steps(10)
.labelData { i ->
// Add yMin to get the negative axis values to the scale
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}
.labelAndAxisLinePadding(30.dp)
.axisLabelColor(Color.Blue)
.axisLineColor(Color.DarkGray)
.typeFace(Typeface.DEFAULT_BOLD)
.build()
val data = WaveChartData(
wavePlotData = WavePlotData(
lines = listOf(
Wave(
dataPoints = pointsData,
waveStyle = LineStyle(lineType = LineType.SmoothCurve(), color = Color.Blue),
shadowUnderLine = ShadowUnderLine(alpha = 0.5f),
waveFillColor = WaveFillColor(
topColor = Color.Yellow,
bottomColor = Color.Magenta
),
selectionHighlightPopUp = SelectionHighlightPopUp(popUpLabel = { x, y ->
val xLabel = "x : ${(1900 + x).toInt()} "
val yLabel = "y : ${String.format("%.2f", y)}"
"$xLabel $yLabel"
})
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData
)
WaveChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
waveChartData = data
)
}
@Composable
private fun WaveGraph3(pointsData: List<Point>) {
val steps = 10
val xAxisData = AxisData.Builder()
.axisStepSize(40.dp)
.startDrawPadding(48.dp)
.steps(pointsData.size - 1)
.labelData { i -> i.toString() }
.labelAndAxisLinePadding(15.dp)
.axisLineColor(Color.Red)
.build()
val yAxisData = AxisData.Builder()
.steps(steps)
.labelData { i ->
val yMin = pointsData.minOf { it.y }
val yMax = pointsData.maxOf { it.y }
val yScale = (yMax - yMin) / steps
((i * yScale) + yMin).formatToSinglePrecision()
}
.axisLineColor(Color.Red)
.labelAndAxisLinePadding(20.dp)
.bottomPadding(15.dp)
.build()
val data = WaveChartData(
wavePlotData = WavePlotData(
lines = listOf(
Wave(
dataPoints = pointsData,
waveStyle = LineStyle(
lineType = LineType.Straight(isDotted = true),
color = Color.Green
),
shadowUnderLine = ShadowUnderLine(
brush = Brush.verticalGradient(
listOf(
Color.Green,
Color.Transparent
)
), alpha = 0.3f
),
selectionHighlightPoint = SelectionHighlightPoint(
color = Color.Green
),
selectionHighlightPopUp = SelectionHighlightPopUp(
backgroundColor = Color.Black,
backgroundStyle = Stroke(2f),
labelColor = Color.Red,
labelTypeface = Typeface.DEFAULT_BOLD
),
waveFillColor = WaveFillColor(
topColor = Color.Green,
bottomColor = Color.Red,
topBrush = Brush.verticalGradient(
listOf(
Color.Green,
Color.Blue
)
),
bottomBrush = Brush.verticalGradient(
listOf(
Color.Green,
Color.Red
)
)
)
)
)
),
xAxisData = xAxisData,
yAxisData = yAxisData
)
WaveChart(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
waveChartData = data
)
}

Some files were not shown because too many files have changed in this diff Show More