diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2d1f421b..12ffae393b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 4.0.1 + +### Added + +- `YAxis.labelRotationAngle` rotates the y axis labels, like `XAxis.labelRotationAngle` does for the x axis. The chart reserves the width the rotated labels need. +- `PieChart.isRoundedSlicesReversed` rounds the slice ends the other way around. +- `PieChart.isTransparentCircleClippedToSlices` draws the translucent ring around the hole only over the slices, so the space between slices stays open. +- Pie entry labels and values that contain a line break are drawn as several lines; the legend shows the line break as a space. +- `chart.toBitmap(width, height)` draws the chart at any size, also a chart built only in code that was never laid out. A chart on screen is laid out at that size for the drawing and put back afterwards. + +### Fixed + +- A tap inside a bar of a combined chart selected a nearby line point instead of the bar. +- In a bar chart whose grouped data sets use different y axes, a tap often selected the bar of the other axis. +- Limit line labels at the edge of the content area were cut off. Labels of limit lines outside the visible area are no longer drawn. +- Auto scaling skipped a y axis that was disabled, even when data sets depend on it. +- Building a chart on a thread without a Looper, for example with an asynchronous layout inflater, crashed. The gesture detector is now created on the first touch. +- The default value formatter rebuilt its number format on every data change, even when the number of decimals stayed the same. + ## 4.0.0 A rewrite of the library in Kotlin. Same class, package and interface names, so imports survive; the call sites change. [MIGRATION.md](MIGRATION.md) lists the changes that need edits in your code, and the guides at [philjay.cc/mpandroidchart/docs](https://philjay.cc/mpandroidchart/docs/) cover the whole library in 35 chapters. diff --git a/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/AxisDrawTest.kt b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/AxisDrawTest.kt new file mode 100644 index 0000000000..e274471a7b --- /dev/null +++ b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/AxisDrawTest.kt @@ -0,0 +1,67 @@ +package com.github.mikephil.charting.devicetest + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.Paint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.LimitLine +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.utils.Utils +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AxisDrawTest { + + private fun lineChart(entries: List>): LineChart { + val chart = Fixtures.lay { LineChart(Fixtures.context()) } + chart.legend.isEnabled = false + chart.description.isEnabled = false + chart.data = LineData(Fixtures.lineSet(entries)) + Fixtures.resetViewport(chart) + return chart + } + + @Test + fun autoScaleRescalesAnAxisThatIsNotDrawn() { + val chart = lineChart(List(100) { Entry(it.toFloat(), it.toFloat()) }) + chart.axisLeft.isEnabled = false + chart.axisRight.isEnabled = false + chart.isAutoScaleMinMaxEnabled = true + + val matrix = Matrix(chart.viewPortHandler.matrixTouch) + matrix.postScale(10f, 1f, 0f, 0f) + chart.viewPortHandler.refresh(matrix, chart, false) + chart.draw(Fixtures.canvas()) + + assertTrue("left axis max ${chart.axisLeft.axisMaximum}", chart.axisLeft.axisMaximum < 20f) + } + + @Test + fun limitLineLabelsAtTheContentEdgeStayInsideTheContent() { + val chart = lineChart(List(11) { Entry(it.toFloat(), it * 10f) }) + chart.axisLeft.axisMinimum = 0f + chart.axisLeft.axisMaximum = 100f + val yLimit = LimitLine(100f, "top label").apply { labelPosition = LimitLine.LimitLabelPosition.RIGHT_TOP } + val xLimit = LimitLine(10f, "right label").apply { labelPosition = LimitLine.LimitLabelPosition.RIGHT_TOP } + chart.axisLeft.addLimitLine(yLimit) + chart.xAxis.addLimitLine(xLimit) + chart.notifyDataSetChanged() + + val canvas = RecordingCanvas(Bitmap.createBitmap(Fixtures.WIDTH, Fixtures.HEIGHT, Bitmap.Config.ARGB_8888)) + chart.draw(canvas) + + val paint = Paint().apply { textSize = Utils.convertDpToPixel(yLimit.textSize) } + val content = chart.viewPortHandler.contentRect + val top = canvas.texts.single { it.text == "top label" } + val right = canvas.texts.single { it.text == "right label" } + val topHeight = Utils.calcTextHeight(paint, "top label") + val rightWidth = Utils.calcTextWidth(paint, "right label") + + assertTrue("top label baseline ${top.y}, content top ${content.top}", top.y - topHeight >= content.top - 0.5f) + assertTrue("right label x ${right.x}, content right ${content.right}", right.x + rightWidth <= content.right + 0.5f) + } +} diff --git a/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/BackgroundThreadTest.kt b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/BackgroundThreadTest.kt new file mode 100644 index 0000000000..7d152b0108 --- /dev/null +++ b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/BackgroundThreadTest.kt @@ -0,0 +1,43 @@ +package com.github.mikephil.charting.devicetest + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.mikephil.charting.charts.BarChart +import com.github.mikephil.charting.charts.BubbleChart +import com.github.mikephil.charting.charts.CandleStickChart +import com.github.mikephil.charting.charts.CombinedChart +import com.github.mikephil.charting.charts.HorizontalBarChart +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.charts.PieChart +import com.github.mikephil.charting.charts.RadarChart +import com.github.mikephil.charting.charts.ScatterChart +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BackgroundThreadTest { + + @Test + fun everyChartCanBeBuiltOnAThreadWithoutALooper() { + val context = Fixtures.context() + var failure: Throwable? = null + val thread = Thread { + try { + LineChart(context) + BarChart(context) + HorizontalBarChart(context) + ScatterChart(context) + BubbleChart(context) + CandleStickChart(context) + CombinedChart(context) + PieChart(context) + RadarChart(context) + } catch (t: Throwable) { + failure = t + } + } + thread.start() + thread.join() + assertNull(failure) + } +} diff --git a/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/BitmapExportTest.kt b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/BitmapExportTest.kt new file mode 100644 index 0000000000..92f225adbb --- /dev/null +++ b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/BitmapExportTest.kt @@ -0,0 +1,61 @@ +package com.github.mikephil.charting.devicetest + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.data.LineData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BitmapExportTest { + + private fun onMain(block: () -> T): T { + var result: T? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun drawnPixels(bitmap: Bitmap): Int { + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + return pixels.count { it != Color.WHITE } + } + + @Test + fun aChartThatWasNeverLaidOutExportsAtTheAskedSize() { + val (unsized, exported) = onMain { + val chart = LineChart(Fixtures.context()) + chart.data = LineData(Fixtures.lineSet(Fixtures.lineEntries(50))) + chart.toBitmap() to chart.toBitmap(400, 300) + } + + assertEquals(1, unsized.width) + assertEquals(1, unsized.height) + assertEquals(400, exported.width) + assertEquals(300, exported.height) + assertTrue("the exported image holds no chart", drawnPixels(exported) > 1000) + } + + @Test + fun aChartOnScreenExportsAtAnotherSizeAndKeepsItsOwn() { + val chart = Fixtures.lay { LineChart(Fixtures.context()).apply { data = LineData(Fixtures.lineSet(Fixtures.lineEntries(50))) } } + val contentBefore = onMain { chart.viewPortHandler.contentRect.width() } + + val exported = onMain { chart.toBitmap(Fixtures.WIDTH / 2, Fixtures.HEIGHT / 2) } + + assertEquals(Fixtures.WIDTH / 2, exported.width) + assertEquals(Fixtures.HEIGHT / 2, exported.height) + assertTrue("the exported image holds no chart", drawnPixels(exported) > 1000) + onMain { + assertEquals(Fixtures.WIDTH, chart.width) + assertEquals(Fixtures.HEIGHT, chart.height) + assertEquals(contentBefore, chart.viewPortHandler.contentRect.width(), 0.5f) + } + } +} diff --git a/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/PieRendererTest.kt b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/PieRendererTest.kt new file mode 100644 index 0000000000..491381a623 --- /dev/null +++ b/MPChartDeviceTest/src/androidTest/kotlin/com/github/mikephil/charting/devicetest/PieRendererTest.kt @@ -0,0 +1,102 @@ +package com.github.mikephil.charting.devicetest + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.mikephil.charting.charts.PieChart +import com.github.mikephil.charting.data.PieData +import com.github.mikephil.charting.data.PieDataSet +import com.github.mikephil.charting.data.PieEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.cos +import kotlin.math.sin + +@RunWith(AndroidJUnit4::class) +class PieRendererTest { + + private fun pie(vararg entries: PieEntry, configure: PieChart.(PieDataSet) -> Unit = {}): PieChart { + val chart = Fixtures.lay { PieChart(Fixtures.context()) } + chart.legend.isEnabled = false + chart.description.isEnabled = false + chart.isDrawCenterTextEnabled = false + val set = PieDataSet(entries.toList(), "pie") + chart.configure(set) + chart.data = PieData(set) + return chart + } + + private fun texts(chart: PieChart): List { + val canvas = RecordingCanvas(Bitmap.createBitmap(Fixtures.WIDTH, Fixtures.HEIGHT, Bitmap.Config.ARGB_8888)) + chart.draw(canvas) + return canvas.texts + } + + @Test + fun aLabelWithLineBreaksIsDrawnAsLinesCenteredOnTheSingleLineSpot() { + val labelOnly: PieChart.(PieDataSet) -> Unit = { it.isDrawValuesEnabled = false } + val single = texts(pie(PieEntry(1f, "Single"), configure = labelOnly)).single() + val twoLines = pie(PieEntry(1f, "Top\nBottom"), configure = labelOnly) + val lines = texts(twoLines) + + assertEquals(listOf("Top", "Bottom"), lines.map { it.text }) + assertEquals("Top Bottom", twoLines.legend.entries.first().label) + assertEquals(single.x, lines[0].x, 0.01f) + assertEquals(single.x, lines[1].x, 0.01f) + assertEquals(single.y, (lines[0].y + lines[1].y) / 2f, 0.01f) + } + + /** The pixel of [chart] drawn at [angle] degrees and [radiusPercent] of the pie radius from the center. */ + private fun pixelAt(chart: PieChart, angle: Float, radiusPercent: Float): Int { + val bitmap = Bitmap.createBitmap(Fixtures.WIDTH, Fixtures.HEIGHT, Bitmap.Config.ARGB_8888) + chart.draw(android.graphics.Canvas(bitmap)) + val center = chart.centerCircleBox + val r = chart.radius * radiusPercent / 100f + val radians = Math.toRadians(angle.toDouble()) + return bitmap.getPixel((center.x + r * cos(radians)).toInt(), (center.y + r * sin(radians)).toInt()) + } + + @Test + fun reversedRoundedSlicesBulgeTheOtherWay() { + fun roundedPie(reversed: Boolean) = pie(PieEntry(1f), PieEntry(1f)) { set -> + set.colors = listOf(Color.RED, Color.BLUE) + set.isDrawValuesEnabled = false + isDrawEntryLabelsEnabled = false + isDrawRoundedSlicesEnabled = true + isRoundedSlicesReversed = reversed + rotationAngle = 270f + } + + // The red slice starts at the top; the blue one ends there. + val normal = roundedPie(false) + assertEquals(Color.BLUE, pixelAt(normal, 262f, 75f)) + assertEquals(Color.BLUE, pixelAt(normal, 278f, 75f)) + + val reversed = roundedPie(true) + assertEquals(Color.RED, pixelAt(reversed, 262f, 75f)) + assertEquals(Color.RED, pixelAt(reversed, 278f, 75f)) + } + + @Test + fun aClippedTransparentCircleLeavesTheSliceSpaceClear() { + fun spacedPie(clipped: Boolean) = pie(PieEntry(1f), PieEntry(1f)) { set -> + set.colors = listOf(Color.RED, Color.BLUE) + set.sliceSpace = 12f + set.isDrawValuesEnabled = false + isDrawEntryLabelsEnabled = false + transparentCircleRadius = 70f + isTransparentCircleClippedToSlices = clipped + rotationAngle = 270f + } + + // The space between the slices runs straight down from the center. + assertNotEquals(0, Color.alpha(pixelAt(spacedPie(false), 90f, 60f))) + + val clipped = spacedPie(true) + assertEquals(0, Color.alpha(pixelAt(clipped, 90f, 60f))) + assertNotEquals(Color.RED, pixelAt(clipped, 0f, 60f)) + assertEquals(Color.RED, pixelAt(clipped, 0f, 85f)) + } +} diff --git a/MPChartLib/api/MPChartLib.api b/MPChartLib/api/MPChartLib.api index f07e91e068..a7180db203 100644 --- a/MPChartLib/api/MPChartLib.api +++ b/MPChartLib/api/MPChartLib.api @@ -458,6 +458,9 @@ public abstract class com/github/mikephil/charting/charts/Chart : android/view/V protected final fun setupDefaultFormatter (FF)V public final fun stopAnimations ()V public final fun toBitmap ()Landroid/graphics/Bitmap; + public final fun toBitmap (I)Landroid/graphics/Bitmap; + public final fun toBitmap (II)Landroid/graphics/Bitmap; + public static synthetic fun toBitmap$default (Lcom/github/mikephil/charting/charts/Chart;IIILjava/lang/Object;)Landroid/graphics/Bitmap; public final fun valuesToHighlight ()Z } @@ -581,6 +584,8 @@ public class com/github/mikephil/charting/charts/PieChart : com/github/mikephil/ public final fun isDrawHoleEnabled ()Z public final fun isDrawRoundedSlicesEnabled ()Z public final fun isDrawSlicesUnderHoleEnabled ()Z + public final fun isRoundedSlicesReversed ()Z + public final fun isTransparentCircleClippedToSlices ()Z public final fun isUsePercentValuesEnabled ()Z public final fun needsHighlight (I)Z protected fun onDetachedFromWindow ()V @@ -605,7 +610,9 @@ public class com/github/mikephil/charting/charts/PieChart : com/github/mikephil/ public final fun setHoleRadius (F)V public final fun setMaxAngle (F)V public final fun setMinAngleForSlices (F)V + public final fun setRoundedSlicesReversed (Z)V public final fun setTransparentCircleAlpha (I)V + public final fun setTransparentCircleClippedToSlices (Z)V public final fun setTransparentCircleColor (I)V public final fun setTransparentCircleRadius (F)V public final fun setUsePercentValuesEnabled (Z)V @@ -1029,6 +1036,7 @@ public class com/github/mikephil/charting/components/YAxis : com/github/mikephil public fun calculate (FF)V public final fun getAxisDependency ()Lcom/github/mikephil/charting/components/YAxis$AxisDependency; public final fun getLabelPosition ()Lcom/github/mikephil/charting/components/YAxis$YAxisLabelPosition; + public final fun getLabelRotationAngle ()F public final fun getLabelXOffset ()F public final fun getMaxWidth ()F public final fun getMinWidth ()F @@ -1048,6 +1056,7 @@ public class com/github/mikephil/charting/components/YAxis : com/github/mikephil public final fun setDrawZeroLineEnabled (Z)V public final fun setInverted (Z)V public final fun setLabelPosition (Lcom/github/mikephil/charting/components/YAxis$YAxisLabelPosition;)V + public final fun setLabelRotationAngle (F)V public final fun setLabelXOffset (F)V public final fun setMaxWidth (F)V public final fun setMinWidth (F)V @@ -1788,6 +1797,7 @@ public class com/github/mikephil/charting/highlight/BarHighlighter : com/github/ protected fun getData ()Lcom/github/mikephil/charting/data/BarLineScatterCandleBubbleData; protected fun getDistance (FFFF)F public fun getHighlight (FF)Lcom/github/mikephil/charting/highlight/Highlight; + protected fun getHighlightsAtXValue (FFF)Ljava/util/List; public final fun getStackedHighlight (Lcom/github/mikephil/charting/highlight/Highlight;Lcom/github/mikephil/charting/interfaces/datasets/IBarDataSet;FF)Lcom/github/mikephil/charting/highlight/Highlight; } @@ -1810,6 +1820,7 @@ public class com/github/mikephil/charting/highlight/ChartHighlighter : com/githu public class com/github/mikephil/charting/highlight/CombinedHighlighter : com/github/mikephil/charting/highlight/ChartHighlighter { public fun (Lcom/github/mikephil/charting/interfaces/dataprovider/CombinedDataProvider;Lcom/github/mikephil/charting/interfaces/dataprovider/BarDataProvider;)V protected final fun getBarHighlighter ()Lcom/github/mikephil/charting/highlight/BarHighlighter; + protected fun getHighlightForX (FFF)Lcom/github/mikephil/charting/highlight/Highlight; protected fun getHighlightsAtXValue (FFF)Ljava/util/List; } @@ -2569,6 +2580,7 @@ public class com/github/mikephil/charting/renderer/PieChartRenderer : com/github public fun drawHighlighted (Landroid/graphics/Canvas;Ljava/util/List;)V protected fun drawHole (Landroid/graphics/Canvas;)V protected fun drawRoundedSlices (Landroid/graphics/Canvas;)V + public fun drawValue (Landroid/graphics/Canvas;Lcom/github/mikephil/charting/formatter/IValueFormatter;FLcom/github/mikephil/charting/data/Entry;IFFI)V public fun drawValues (Landroid/graphics/Canvas;)V protected final fun getBitmapCanvas ()Landroid/graphics/Canvas; protected final fun getChart ()Lcom/github/mikephil/charting/charts/PieChart; diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/BarLineChartBase.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/BarLineChartBase.kt index 19e49ee39a..7eab1bf330 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/BarLineChartBase.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/BarLineChartBase.kt @@ -320,8 +320,8 @@ public abstract class BarLineChartBase>>> @JvmOverl } - /** Draws the chart into a new ARGB_8888 bitmap of the view size, over its background or white. A chart not laid out yet gives a 1x1 bitmap. */ - public fun toBitmap(): Bitmap { - val returnedBitmap = Bitmap.createBitmap(width.coerceAtLeast(1), height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + /** + * Draws the chart into a new ARGB_8888 bitmap of [width] x [height] pixels, over its background or white. + * + * The size defaults to the chart's current size. When another size is asked for, the chart is laid out at that + * size for the drawing and put back to its previous size afterwards, so a chart that was never shown, for example + * one built in code only to export an image, can be drawn with `chart.toBitmap(1200, 800)`. A chart without a size + * and without an asked for size gives a 1x1 bitmap. + */ + @JvmOverloads + public fun toBitmap(width: Int = this.width, height: Int = this.height): Bitmap { + val bitmapWidth = width.coerceAtLeast(1) + val bitmapHeight = height.coerceAtLeast(1) + val previousWidth = this.width + val previousHeight = this.height + val resize = bitmapWidth != previousWidth || bitmapHeight != previousHeight + if (resize) layoutAt(bitmapWidth, bitmapHeight) + + val returnedBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(returnedBitmap) val bgDrawable = background if (bgDrawable != null) bgDrawable.draw(canvas) else canvas.drawColor(Color.WHITE) draw(canvas) + + if (resize && previousWidth > 0 && previousHeight > 0) layoutAt(previousWidth, previousHeight) return returnedBitmap } + private fun layoutAt(width: Int, height: Int) { + measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)) + layout(left, top, left + width, top + height) + } + /** * Saves the chart as an image into the device gallery through the MediaStore. * Below Android 10 the app needs the write external storage permission. diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/PieChart.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/PieChart.kt index f288496924..fb568a5737 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/PieChart.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/charts/PieChart.kt @@ -54,6 +54,12 @@ public open class PieChart @JvmOverloads constructor( /** Whether slice ends are rounded. Only applies with the hole drawn and slices not under the hole. */ public var isDrawRoundedSlicesEnabled: Boolean = false + /** + * Whether rounded slice ends point counter-clockwise instead of clockwise: each slice then bulges out at its + * start and is hollowed at its end. Only applies with [isDrawRoundedSlicesEnabled]. Default false. + */ + public var isRoundedSlicesReversed: Boolean = false + /** Text drawn in the center of the pie while [isDrawCenterTextEnabled] is true. */ public var centerText: CharSequence = "" @@ -68,6 +74,12 @@ public open class PieChart @JvmOverloads constructor( */ public var transparentCircleRadius: Float = 55f + /** + * Whether the translucent ring around the hole is drawn only over the slices, so that it tints each slice and + * leaves the slice space between them clear. Default false, which draws one closed ring. + */ + public var isTransparentCircleClippedToSlices: Boolean = false + /** Whether [centerText] is drawn. */ public var isDrawCenterTextEnabled: Boolean = true diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/components/YAxis.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/components/YAxis.kt index 9abad87227..7f2eddb980 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/components/YAxis.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/components/YAxis.kt @@ -2,6 +2,7 @@ package com.github.mikephil.charting.components import android.graphics.Color import android.graphics.Paint +import com.github.mikephil.charting.utils.FSize import com.github.mikephil.charting.utils.Utils import kotlin.math.abs import kotlin.math.max @@ -45,6 +46,12 @@ public open class YAxis(public val axisDependency: AxisDependency = AxisDependen /** Horizontal shift in dp added to the label positions. Default 0. */ public var labelXOffset: Float = 0f + /** + * Rotation of the labels in degrees, clockwise for positive values. The width reserved for the axis follows the + * rotated labels. Not used by horizontal bar and radar charts. Default 0. + */ + public var labelRotationAngle: Float = 0f + /** Smallest width in dp the chart reserves for this axis. Default 0. */ public var minWidth: Float = 0f @@ -66,15 +73,21 @@ public open class YAxis(public val axisDependency: AxisDependency = AxisDependen } /** - * Width in px this axis needs in a vertical chart: the widest label plus [xOffset] on both sides, clamped to - * [minWidth]..[maxWidth]. + * Width in px this axis needs in a vertical chart: the widest label, rotated by [labelRotationAngle], plus + * [xOffset] on both sides, clamped to [minWidth]..[maxWidth]. * @param p paint used to measure the labels; its text size is set to [textSize] */ public fun getRequiredWidthSpace(p: Paint): Float { p.textSize = Utils.convertDpToPixel(textSize) val label = longestLabel - var width = Utils.calcTextWidth(p, label).toFloat() + Utils.convertDpToPixel(xOffset) * 2f + var width = Utils.calcTextWidth(p, label).toFloat() + if (labelRotationAngle != 0f) { + val rotated = Utils.getSizeOfRotatedRectangleByDegrees(width, Utils.getLineHeight(p), labelRotationAngle) + width = rotated.width + FSize.recycleInstance(rotated) + } + width += Utils.convertDpToPixel(xOffset) * 2f var minWidth = minWidth var maxWidth = maxWidth diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/formatter/DefaultValueFormatter.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/formatter/DefaultValueFormatter.kt index a8191bd5be..5e09c40827 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/formatter/DefaultValueFormatter.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/formatter/DefaultValueFormatter.kt @@ -23,10 +23,11 @@ public open class DefaultValueFormatter(digits: Int) : IValueFormatter { } /** - * Rebuilds the number format. + * Rebuilds the number format. Does nothing when [digits] equals the current [decimalDigits]. * @param digits number of decimal digits; 0 or less formats whole numbers */ public fun setup(digits: Int) { + if (::format.isInitialized && digits == decimalDigits) return decimalDigits = digits format = DecimalFormat("###,###,###,##0" + decimalPattern(digits)) } diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/BarHighlighter.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/BarHighlighter.kt index 538ed3b08f..1e3d4a3071 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/BarHighlighter.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/BarHighlighter.kt @@ -88,6 +88,14 @@ public open class BarHighlighter(chart: BarDataProvider) : ChartHighlighter ranges[length].to) length else 0 } + /** Keeps only the candidates nearest to the touch, so the y axis choice cannot skip the touched bar. */ + override fun getHighlightsAtXValue(xVal: Float, x: Float, y: Float): List { + val highlights = super.getHighlightsAtXValue(xVal, x, y) + val nearest = highlights.minOfOrNull { getDistance(x, y, it.xPx, it.yPx) } ?: return highlights + highlightBuffer.removeAll { getDistance(x, y, it.xPx, it.yPx) > nearest } + return highlightBuffer + } + /** Only the horizontal distance counts for bars. */ override fun getDistance(x1: Float, y1: Float, x2: Float, y2: Float): Float = abs(x1 - x2) diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/CombinedHighlighter.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/CombinedHighlighter.kt index 0e1b407c41..1ca1792855 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/CombinedHighlighter.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/highlight/CombinedHighlighter.kt @@ -1,16 +1,22 @@ package com.github.mikephil.charting.highlight import com.github.mikephil.charting.data.BarData +import com.github.mikephil.charting.data.BarEntry import com.github.mikephil.charting.data.DataSet import com.github.mikephil.charting.interfaces.dataprovider.BarDataProvider import com.github.mikephil.charting.interfaces.dataprovider.CombinedDataProvider +import com.github.mikephil.charting.utils.MPPointD +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min /** * Highlighter for the combined chart. * * Strategy: collects candidates from every data object in the combined data and tags each with its * [Highlight.dataIndex]. Bar data is delegated to a [BarHighlighter] so stacked bars work; all other data - * is handled like [ChartHighlighter]. The nearest candidate by pixel distance wins. + * is handled like [ChartHighlighter]. A touch inside a bar selects that bar; otherwise the nearest candidate + * by pixel distance wins. * * @param barChart the same chart as [chart]; when it has no bar data at construction time, bars are * highlighted like any other data set. @@ -48,4 +54,28 @@ public open class CombinedHighlighter(chart: CombinedDataProvider, barChart: Bar return highlightBuffer } + + override fun getHighlightForX(xVal: Float, x: Float, y: Float): Highlight? { + return getTouchedBar(x, y) ?: super.getHighlightForX(xVal, x, y) + } + + /** Returns the highlight of the bar whose rectangle contains the pixel position ([x], [y]), or null. */ + private fun getTouchedBar(x: Float, y: Float): Highlight? { + val barHighlighter = barHighlighter ?: return null + val barData = chart.barData ?: return null + val high = barHighlighter.getHighlight(x, y) ?: return null + val set = barData.getDataSetByIndex(high.dataSetIndex) ?: return null + val entry = barData.getEntryForHighlight(high) as? BarEntry<*> ?: return null + + val bottom = if (entry.isStacked) -entry.negativeSum else min(0f, entry.y) + val top = if (entry.isStacked) entry.positiveSum else max(0f, entry.y) + + val pos = chart.getTransformer(set.axisDependency).getValuesByTouchPoint(x, y) + val inside = abs(pos.x - entry.x) <= barData.barWidth / 2f && pos.y >= bottom && pos.y <= top + MPPointD.recycleInstance(pos) + if (!inside) return null + + high.dataIndex = chart.combinedData?.allData?.indexOf(barData) ?: return null + return high + } } diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/listener/ChartTouchListener.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/listener/ChartTouchListener.kt index 13cb8f9a96..2d7af3910f 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/listener/ChartTouchListener.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/listener/ChartTouchListener.kt @@ -40,8 +40,11 @@ public abstract class ChartTouchListener>(protected val chart: T) : /** The highlight selected by the last touch, null when nothing is selected by touch. */ public var lastHighlighted: Highlight? = null - /** Detector that turns raw touch events into taps, long presses and flings. */ - protected val gestureDetector: GestureDetector = GestureDetector(chart.context, this) + /** + * Detector that turns raw touch events into taps, long presses and flings. It is created on first use, on the + * thread that handles touches, so a chart can be built on a thread without a Looper. + */ + protected val gestureDetector: GestureDetector by lazy(LazyThreadSafetyMode.NONE) { GestureDetector(chart.context, this) } /** Reports the start of a touch gesture to the chart's gesture listeners. */ public fun startAction(me: MotionEvent) { diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/LegendRenderer.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/LegendRenderer.kt index 5424a56b86..9d5b5affb8 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/LegendRenderer.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/LegendRenderer.kt @@ -79,7 +79,7 @@ public open class LegendRenderer(viewPortHandler: ViewPortHandler, protected val } else if (dataSet is IPieDataSet<*>) { var j = 0 while (j < clrs.size && j < entryCount) { - computedEntries.add(LegendEntry(dataSet.getEntryForIndex(j).label, dataSet.form, dataSet.formSize, dataSet.formLineWidth, dataSet.formLineDashEffect, clrs[j])) + computedEntries.add(LegendEntry(dataSet.getEntryForIndex(j).label?.replace('\n', ' '), dataSet.form, dataSet.formSize, dataSet.formLineWidth, dataSet.formLineDashEffect, clrs[j])) j++ } diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/PieChartRenderer.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/PieChartRenderer.kt index e579b1fc32..96600a2dbe 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/PieChartRenderer.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/PieChartRenderer.kt @@ -5,13 +5,17 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode import android.graphics.RectF import android.text.Layout import android.text.StaticLayout import android.text.TextPaint import com.github.mikephil.charting.animation.ChartAnimator import com.github.mikephil.charting.charts.PieChart +import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.PieDataSet +import com.github.mikephil.charting.formatter.IValueFormatter import com.github.mikephil.charting.highlight.Highlight import com.github.mikephil.charting.interfaces.datasets.IPieDataSet import com.github.mikephil.charting.utils.ColorTemplate @@ -101,6 +105,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char private val innerRectBuffer = RectF() private val roundedCircleBox = RectF() private val holeCirclePath = Path() + private val onSlicesOnly = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) /** * Path used to clip the center text to the hole. */ @@ -236,6 +241,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char val userInnerRadius = if (drawInnerArc) radius * (chart.holeRadius / 100f) else 0f val roundedRadius = (radius - (radius * chart.holeRadius / 100f)) / 2f val drawRoundedSlices = drawInnerArc && chart.isDrawRoundedSlicesEnabled + val roundedStartSweep = if (chart.isRoundedSlicesReversed) 180f else -180f var visibleAngleCount = 0 for (j in 0 until entryCount) { @@ -289,7 +295,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char pathBuffer.addCircle(center.x, center.y, radius, Path.Direction.CW) } else { if (drawRoundedSlices) { - pathBuffer.arcTo(roundedCircleBox, startAngleOuter + 180, -180f) + pathBuffer.arcTo(roundedCircleBox, startAngleOuter + 180, roundedStartSweep) } pathBuffer.arcTo(circleBox, startAngleOuter, sweepAngleOuter) @@ -332,7 +338,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char val x = center.x + (radius - roundedRadius) * cos(endAngleInner * Utils.FDEG2RAD) val y = center.y + (radius - roundedRadius) * sin(endAngleInner * Utils.FDEG2RAD) roundedCircleBox.set(x - roundedRadius, y - roundedRadius, x + roundedRadius, y + roundedRadius) - pathBuffer.arcTo(roundedCircleBox, endAngleInner, 180f) + pathBuffer.arcTo(roundedCircleBox, endAngleInner, -roundedStartSweep) } else { pathBuffer.lineTo( center.x + innerRadius * cos(endAngleInner * Utils.FDEG2RAD), @@ -401,7 +407,8 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char if (!chart.isDrawSlicesUnderHoleEnabled && chart.isDrawRoundedSlicesEnabled) { // shift by the rounded slice so the label sits inside it - rotationAngle += (roundedRadius * 360 / (Math.PI * 2 * radius)).toFloat() + val roundedAngle = (roundedRadius * 360 / (Math.PI * 2 * radius)).toFloat() + rotationAngle += if (chart.isRoundedSlicesReversed) -roundedAngle else roundedAngle } } @@ -547,7 +554,8 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char drawValue(c, formatter, value, entry, 0, labelPtx, labelPty, dataSet.getValueTextColor(j)) if (j < data.entryCount && entryLabel != null) { - drawEntryLabel(c, entryLabel, labelPtx, labelPty + lineHeight) + val labelY = labelPty + lineHeight + extraLineShift(formatter, value, entry, entryLabel) + drawEntryLabel(c, entryLabel, labelPtx, labelY) } } else if (drawXOutside) { if (j < data.entryCount && entryLabel != null) { @@ -568,7 +576,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char drawValue(c, formatter, value, entry, 0, x, y, dataSet.getValueTextColor(j)) if (j < data.entryCount && entryLabel != null) { - drawEntryLabel(c, entryLabel, x, y + lineHeight) + drawEntryLabel(c, entryLabel, x, y + lineHeight + extraLineShift(formatter, value, entry, entryLabel)) } } else if (drawXInside) { if (j < data.entryCount && entryLabel != null) { @@ -599,9 +607,42 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char /** * Draws an entry label with [paintEntryLabels] at the pixel position ([x], [y]), where [y] is the text baseline. + * A label containing line breaks is drawn one line per part, with the lines centered vertically on [y]. */ protected open fun drawEntryLabel(c: Canvas, label: String, x: Float, y: Float) { - c.drawText(label, x, y, paintEntryLabels) + drawTextLines(c, label, x, y, paintEntryLabels) + } + + /** + * Draws one value label with [valuePaint] in [color] at the pixel position ([x], [y]), where [y] is the text + * baseline. A formatted value containing line breaks is drawn one line per part, with the lines centered + * vertically on [y]. + */ + override fun drawValue(c: Canvas, formatter: IValueFormatter, value: Float, entry: Entry<*>, dataSetIndex: Int, x: Float, y: Float, color: Int) { + valuePaint.color = color + drawTextLines(c, formatter.getFormattedValue(value, entry, dataSetIndex, viewPortHandler), x, y, valuePaint) + } + + private fun drawTextLines(c: Canvas, text: String, x: Float, y: Float, paint: Paint) { + if ('\n' !in text) { + c.drawText(text, x, y, paint) + return + } + + val lines = text.split('\n') + val lineSpacing = paint.fontSpacing + var lineY = y - (lines.size - 1) * lineSpacing / 2f + for (line in lines) { + c.drawText(line, x, lineY, paint) + lineY += lineSpacing + } + } + + /** How far a label below a value moves down so that the extra lines of both do not overlap. */ + private fun extraLineShift(formatter: IValueFormatter, value: Float, entry: Entry<*>, label: String): Float { + val extraValueLines = formatter.getFormattedValue(value, entry, 0, viewPortHandler).count { it == '\n' } + val extraLabelLines = label.count { it == '\n' } + return (extraValueLines * valuePaint.fontSpacing + extraLabelLines * paintEntryLabels.fontSpacing) / 2f } /** @@ -615,7 +656,8 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char /** * Draws the hole and the transparent circle onto the bitmap canvas when the chart enables the hole. The - * transparent circle is only drawn when its radius is larger than the hole radius. + * transparent circle is only drawn when its radius is larger than the hole radius, and only over the slices + * when [PieChart.isTransparentCircleClippedToSlices] is set. */ protected open fun drawHole(c: Canvas) { val bitmapCanvas = bitmapCanvas ?: return @@ -639,8 +681,11 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char holeCirclePath.reset() holeCirclePath.addCircle(center.x, center.y, secondHoleRadius, Path.Direction.CW) holeCirclePath.addCircle(center.x, center.y, holeRadius, Path.Direction.CCW) + // the slices are already on the bitmap, so SRC_ATOP paints only where a slice is + if (chart.isTransparentCircleClippedToSlices) paintTransparentCircle.xfermode = onSlicesOnly bitmapCanvas.drawPath(holeCirclePath, paintTransparentCircle) + paintTransparentCircle.xfermode = null paintTransparentCircle.alpha = alpha } MPPointF.recycleInstance(center) @@ -725,6 +770,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char override fun drawHighlighted(c: Canvas, indices: List) { val drawInnerArc = chart.isDrawHoleEnabled && !chart.isDrawSlicesUnderHoleEnabled val drawRoundedSlices = drawInnerArc && chart.isDrawRoundedSlicesEnabled + val roundedStartSweep = if (chart.isRoundedSlicesReversed) 180f else -180f val bitmapCanvas = bitmapCanvas ?: return val data = chart.data ?: return @@ -804,7 +850,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char val x = center.x + (highlightedRadius - roundedRadius) * cos(startAngleShifted * Utils.FDEG2RAD) val y = center.y + (highlightedRadius - roundedRadius) * sin(startAngleShifted * Utils.FDEG2RAD) roundedCircleBox.set(x - roundedRadius, y - roundedRadius, x + roundedRadius, y + roundedRadius) - pathBuffer.arcTo(roundedCircleBox, startAngleShifted + 180f, -180f) + pathBuffer.arcTo(roundedCircleBox, startAngleShifted + 180f, roundedStartSweep) } else { pathBuffer.moveTo( center.x + highlightedRadius * cos(startAngleShifted * Utils.FDEG2RAD), @@ -858,7 +904,7 @@ public open class PieChartRenderer(protected val chart: PieChart, animator: Char val x = center.x + (highlightedRadius - roundedRadius) * cos(endAngleInner * Utils.FDEG2RAD) val y = center.y + (highlightedRadius - roundedRadius) * sin(endAngleInner * Utils.FDEG2RAD) roundedCircleBox.set(x - roundedRadius, y - roundedRadius, x + roundedRadius, y + roundedRadius) - pathBuffer.arcTo(roundedCircleBox, endAngleInner, 180f) + pathBuffer.arcTo(roundedCircleBox, endAngleInner, -roundedStartSweep) } else { pathBuffer.lineTo( center.x + innerRadius * cos(endAngleInner * Utils.FDEG2RAD), diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRenderer.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRenderer.kt index 0741b1aa68..311bdf2398 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRenderer.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRenderer.kt @@ -303,8 +303,8 @@ public open class XAxisRenderer(viewPortHandler: ViewPortHandler, protected val protected val limitLineClippingRect: RectF = RectF() /** - * Draws each enabled limit line as a vertical line at the pixel x of its limit value and its label at the top - * or bottom of the content rectangle. + * Draws each enabled limit line as a vertical line at the pixel x of its limit value and, while the line is + * inside the content rectangle, its label at the top or bottom of the content rectangle. */ override fun renderLimitLines(c: Canvas) { val limitLines = xAxis.limitLines @@ -328,7 +328,9 @@ public open class XAxisRenderer(viewPortHandler: ViewPortHandler, protected val transformer?.pointValuesToPixel(position) renderLimitLineLine(c, l, position) - renderLimitLineLabel(c, l, position, 2f + Utils.convertDpToPixel(l.yOffset)) + if (position[0] >= limitLineClippingRect.left && position[0] <= limitLineClippingRect.right) { + renderLimitLineLabel(c, l, position, 2f + Utils.convertDpToPixel(l.yOffset)) + } c.restoreToCount(clipRestoreCount) } @@ -362,8 +364,8 @@ public open class XAxisRenderer(viewPortHandler: ViewPortHandler, protected val } /** - * Draws the label of [limitLine] next to its line, at the corner given by its label position. Does nothing for - * an empty label. + * Draws the label of [limitLine] next to its line, at the corner given by its label position, moved sideways + * where needed so it stays inside the content rectangle. Does nothing for an empty label. * * @param position pixel position of the limit value, x at index 0 * @param yOffset distance in pixels between the label and the top or bottom content edge @@ -380,25 +382,28 @@ public open class XAxisRenderer(viewPortHandler: ViewPortHandler, protected val limitLinePaint.typeface = limitLine.typeface val xOffset = Utils.convertDpToPixel(limitLine.lineWidth) + Utils.convertDpToPixel(limitLine.xOffset) + val labelWidth = Utils.calcTextWidth(limitLinePaint, label).toFloat() + val rightX = minOf(position[0] + xOffset, viewPortHandler.contentRight - labelWidth) + val leftX = maxOf(position[0] - xOffset, viewPortHandler.contentLeft + labelWidth) when (limitLine.labelPosition) { LimitLine.LimitLabelPosition.RIGHT_TOP -> { val labelLineHeight = Utils.calcTextHeight(limitLinePaint, label).toFloat() limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, position[0] + xOffset, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) + c.drawText(label, rightX, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) } LimitLine.LimitLabelPosition.RIGHT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, position[0] + xOffset, viewPortHandler.contentBottom - yOffset, limitLinePaint) + c.drawText(label, rightX, viewPortHandler.contentBottom - yOffset, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_TOP -> { limitLinePaint.textAlign = Paint.Align.RIGHT val labelLineHeight = Utils.calcTextHeight(limitLinePaint, label).toFloat() - c.drawText(label, position[0] - xOffset, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) + c.drawText(label, leftX, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.RIGHT - c.drawText(label, position[0] - xOffset, viewPortHandler.contentBottom - yOffset, limitLinePaint) + c.drawText(label, leftX, viewPortHandler.contentBottom - yOffset, limitLinePaint) } } } diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRendererHorizontalBarChart.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRendererHorizontalBarChart.kt index b4e8e94f64..d0de550539 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRendererHorizontalBarChart.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/XAxisRendererHorizontalBarChart.kt @@ -199,7 +199,8 @@ public open class XAxisRendererHorizontalBarChart( /** * Draws each enabled limit line as a horizontal line at the pixel y of its limit value and its label at the - * left or right of the content rectangle. + * left or right of the content rectangle. A label that would stick out of the content rectangle is moved inside + * it; the label of a line outside the content rectangle is not drawn. */ override fun renderLimitLines(c: Canvas) { val limitLines = xAxis.limitLines @@ -236,8 +237,9 @@ public open class XAxisRendererHorizontalBarChart( limitLinePath.reset() val label = l.label + val lineVisible = pts[1] >= limitLineClippingRect.top && pts[1] <= limitLineClippingRect.bottom - if (label.isNotEmpty()) { + if (label.isNotEmpty() && lineVisible) { limitLinePaint.style = l.textStyle limitLinePaint.pathEffect = null limitLinePaint.color = l.textColor @@ -247,23 +249,25 @@ public open class XAxisRendererHorizontalBarChart( val labelLineHeight = Utils.calcTextHeight(limitLinePaint, label).toFloat() val xOffset = Utils.convertDpToPixel(4f) + Utils.convertDpToPixel(l.xOffset) val yOffset = Utils.convertDpToPixel(l.lineWidth) + labelLineHeight + Utils.convertDpToPixel(l.yOffset) + val topBaseline = pts[1] - yOffset + labelLineHeight + val bottomBaseline = pts[1] + yOffset + val baseline = when (l.labelPosition) { + LimitLine.LimitLabelPosition.RIGHT_TOP, LimitLine.LimitLabelPosition.LEFT_TOP -> topBaseline + LimitLine.LimitLabelPosition.RIGHT_BOTTOM, LimitLine.LimitLabelPosition.LEFT_BOTTOM -> bottomBaseline + }.coerceIn(viewPortHandler.contentTop + labelLineHeight, maxOf(viewPortHandler.contentBottom, viewPortHandler.contentTop + labelLineHeight)) when (l.labelPosition) { - LimitLine.LimitLabelPosition.RIGHT_TOP -> { + LimitLine.LimitLabelPosition.RIGHT_TOP, LimitLine.LimitLabelPosition.RIGHT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.RIGHT - c.drawText(label, viewPortHandler.contentRight - xOffset, pts[1] - yOffset + labelLineHeight, limitLinePaint) - } - LimitLine.LimitLabelPosition.RIGHT_BOTTOM -> { - limitLinePaint.textAlign = Paint.Align.RIGHT - c.drawText(label, viewPortHandler.contentRight - xOffset, pts[1] + yOffset, limitLinePaint) + c.drawText(label, viewPortHandler.contentRight - xOffset, baseline, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_TOP -> { limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, viewPortHandler.contentLeft + xOffset, pts[1] - yOffset + labelLineHeight, limitLinePaint) + c.drawText(label, viewPortHandler.contentLeft + xOffset, baseline, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, viewPortHandler.offsetLeft + xOffset, pts[1] + yOffset, limitLinePaint) + c.drawText(label, viewPortHandler.offsetLeft + xOffset, baseline, limitLinePaint) } } } diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRenderer.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRenderer.kt index 9bd89989f2..adc2919c4b 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRenderer.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRenderer.kt @@ -9,6 +9,7 @@ import com.github.mikephil.charting.components.LimitLine import com.github.mikephil.charting.components.YAxis import com.github.mikephil.charting.components.YAxis.AxisDependency import com.github.mikephil.charting.components.YAxis.YAxisLabelPosition +import com.github.mikephil.charting.utils.MPPointF import com.github.mikephil.charting.utils.Transformer import com.github.mikephil.charting.utils.Utils import com.github.mikephil.charting.utils.ViewPortHandler @@ -88,8 +89,9 @@ public open class YAxisRenderer(viewPortHandler: ViewPortHandler, protected val } /** - * Draws the axis labels at the horizontal pixel position [fixedPosition], plus the label x offset of the axis. - * Skips the first or last entry when the axis disables drawing the bottom or top label. + * Draws the axis labels at the horizontal pixel position [fixedPosition], plus the label x offset of the axis, + * rotated by the label rotation angle of the axis. Skips the first or last entry when the axis disables drawing + * the bottom or top label. * * @param positions pixel positions of the axis entries as x, y pairs, from [getTransformedPositions] * @param offset vertical pixel offset that centers the text on its grid line @@ -100,11 +102,25 @@ public open class YAxisRenderer(viewPortHandler: ViewPortHandler, protected val val to = minOf(if (yAxis.isDrawTopYLabelEntryEnabled) yAxis.entryCount else yAxis.entryCount - 1, positions.size / 2) val xOffset = Utils.convertDpToPixel(yAxis.labelXOffset) + val angle = yAxis.labelRotationAngle + val anchorX = when (paintAxisLabels.textAlign) { + Paint.Align.RIGHT -> 1f + Paint.Align.CENTER -> 0.5f + else -> 0f + } + val anchor = MPPointF.getInstance(anchorX, 0.5f) + val rotatedOffset = Utils.convertDpToPixel(yAxis.yOffset) for (i in from until to) { val text = yAxis.getFormattedLabel(i) - c.drawText(text, fixedPosition + xOffset, positions[i * 2 + 1] + offset, paintAxisLabels) + if (angle == 0f) { + c.drawText(text, fixedPosition + xOffset, positions[i * 2 + 1] + offset, paintAxisLabels) + } else { + Utils.drawXAxisValue(c, text, fixedPosition + xOffset, positions[i * 2 + 1] + rotatedOffset, paintAxisLabels, anchor, angle) + } } + + MPPointF.recycleInstance(anchor) } /** @@ -249,7 +265,8 @@ public open class YAxisRenderer(viewPortHandler: ViewPortHandler, protected val /** * Draws each enabled limit line as a horizontal line at the pixel y of its limit value and its label at the - * left or right of the content rectangle, above or below the line. + * left or right of the content rectangle, above or below the line. A label that would stick out of the content + * rectangle is moved inside it; the label of a line outside the content rectangle is not drawn. */ override fun renderLimitLines(c: Canvas) { val limitLines = yAxis.limitLines @@ -285,8 +302,9 @@ public open class YAxisRenderer(viewPortHandler: ViewPortHandler, protected val limitLinePath.reset() val label = l.label + val lineVisible = pts[1] >= limitLineClippingRect.top && pts[1] <= limitLineClippingRect.bottom - if (label.isNotEmpty()) { + if (label.isNotEmpty() && lineVisible) { limitLinePaint.style = l.textStyle limitLinePaint.pathEffect = null limitLinePaint.color = l.textColor @@ -297,23 +315,25 @@ public open class YAxisRenderer(viewPortHandler: ViewPortHandler, protected val val labelLineHeight = Utils.calcTextHeight(limitLinePaint, label).toFloat() val xOffset = Utils.convertDpToPixel(4f) + Utils.convertDpToPixel(l.xOffset) val yOffset = Utils.convertDpToPixel(l.lineWidth) + labelLineHeight + Utils.convertDpToPixel(l.yOffset) + val topBaseline = pts[1] - yOffset + labelLineHeight + val bottomBaseline = pts[1] + yOffset + val baseline = when (l.labelPosition) { + LimitLine.LimitLabelPosition.RIGHT_TOP, LimitLine.LimitLabelPosition.LEFT_TOP -> topBaseline + LimitLine.LimitLabelPosition.RIGHT_BOTTOM, LimitLine.LimitLabelPosition.LEFT_BOTTOM -> bottomBaseline + }.coerceIn(viewPortHandler.contentTop + labelLineHeight, maxOf(viewPortHandler.contentBottom, viewPortHandler.contentTop + labelLineHeight)) when (l.labelPosition) { - LimitLine.LimitLabelPosition.RIGHT_TOP -> { - limitLinePaint.textAlign = Paint.Align.RIGHT - c.drawText(label, viewPortHandler.contentRight - xOffset, pts[1] - yOffset + labelLineHeight, limitLinePaint) - } - LimitLine.LimitLabelPosition.RIGHT_BOTTOM -> { + LimitLine.LimitLabelPosition.RIGHT_TOP, LimitLine.LimitLabelPosition.RIGHT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.RIGHT - c.drawText(label, viewPortHandler.contentRight - xOffset, pts[1] + yOffset, limitLinePaint) + c.drawText(label, viewPortHandler.contentRight - xOffset, baseline, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_TOP -> { limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, viewPortHandler.contentLeft + xOffset, pts[1] - yOffset + labelLineHeight, limitLinePaint) + c.drawText(label, viewPortHandler.contentLeft + xOffset, baseline, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, viewPortHandler.offsetLeft + xOffset, pts[1] + yOffset, limitLinePaint) + c.drawText(label, viewPortHandler.offsetLeft + xOffset, baseline, limitLinePaint) } } } diff --git a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRendererHorizontalBarChart.kt b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRendererHorizontalBarChart.kt index dc1ff44e83..83afa6530c 100644 --- a/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRendererHorizontalBarChart.kt +++ b/MPChartLib/src/main/kotlin/com/github/mikephil/charting/renderer/YAxisRendererHorizontalBarChart.kt @@ -185,8 +185,9 @@ public open class YAxisRendererHorizontalBarChart(viewPortHandler: ViewPortHandl private val limitLinesBuffer = FloatArray(4) /** - * Draws each enabled limit line as a vertical line at the pixel x of its limit value and its label at the top - * or bottom of the content rectangle. + * Draws each enabled limit line as a vertical line at the pixel x of its limit value and, while the line is + * inside the content rectangle, its label at the top or bottom of the content rectangle, moved sideways where + * needed so it stays inside the content rectangle. */ override fun renderLimitLines(c: Canvas) { val limitLines = yAxis.limitLines @@ -225,8 +226,9 @@ public open class YAxisRendererHorizontalBarChart(viewPortHandler: ViewPortHandl limitLinePath.reset() val label = l.label + val lineVisible = pts[0] >= limitLineClippingRect.left && pts[0] <= limitLineClippingRect.right - if (label.isNotEmpty()) { + if (label.isNotEmpty() && lineVisible) { limitLinePaint.style = l.textStyle limitLinePaint.pathEffect = null limitLinePaint.color = l.textColor @@ -236,25 +238,28 @@ public open class YAxisRendererHorizontalBarChart(viewPortHandler: ViewPortHandl val xOffset = Utils.convertDpToPixel(l.lineWidth) + Utils.convertDpToPixel(l.xOffset) val yOffset = Utils.convertDpToPixel(2f) + Utils.convertDpToPixel(l.yOffset) + val labelWidth = Utils.calcTextWidth(limitLinePaint, label).toFloat() + val rightX = minOf(pts[0] + xOffset, viewPortHandler.contentRight - labelWidth) + val leftX = maxOf(pts[0] - xOffset, viewPortHandler.contentLeft + labelWidth) when (l.labelPosition) { LimitLine.LimitLabelPosition.RIGHT_TOP -> { val labelLineHeight = Utils.calcTextHeight(limitLinePaint, label).toFloat() limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, pts[0] + xOffset, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) + c.drawText(label, rightX, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) } LimitLine.LimitLabelPosition.RIGHT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.LEFT - c.drawText(label, pts[0] + xOffset, viewPortHandler.contentBottom - yOffset, limitLinePaint) + c.drawText(label, rightX, viewPortHandler.contentBottom - yOffset, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_TOP -> { limitLinePaint.textAlign = Paint.Align.RIGHT val labelLineHeight = Utils.calcTextHeight(limitLinePaint, label).toFloat() - c.drawText(label, pts[0] - xOffset, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) + c.drawText(label, leftX, viewPortHandler.contentTop + yOffset + labelLineHeight, limitLinePaint) } LimitLine.LimitLabelPosition.LEFT_BOTTOM -> { limitLinePaint.textAlign = Paint.Align.RIGHT - c.drawText(label, pts[0] - xOffset, viewPortHandler.contentBottom - yOffset, limitLinePaint) + c.drawText(label, leftX, viewPortHandler.contentBottom - yOffset, limitLinePaint) } } } diff --git a/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/AxisRendererTest.kt b/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/AxisRendererTest.kt index 11de083075..70eb1c5d4e 100644 --- a/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/AxisRendererTest.kt +++ b/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/AxisRendererTest.kt @@ -1,5 +1,6 @@ package com.github.mikephil.charting.test +import android.graphics.Paint import com.github.mikephil.charting.components.YAxis import com.github.mikephil.charting.renderer.YAxisRenderer import com.github.mikephil.charting.utils.ViewPortHandler @@ -87,4 +88,17 @@ class AxisRendererTest { assertEquals(30f, entries[2], 0.0001f) assertEquals(90f, entries[entries.size - 1], 0.0001f) } + + @Test + fun rotatedLabelsNeedTheWidthOfTheirRotatedBounds() { + val paint = object : Paint() { + override fun measureText(text: String): Float = 100f + } + val yAxis = YAxis() + yAxis.xOffset = 0f + + assertEquals(100f, yAxis.getRequiredWidthSpace(paint), 0.01f) + yAxis.labelRotationAngle = 90f + assertEquals(0f, yAxis.getRequiredWidthSpace(paint), 0.01f) + } } diff --git a/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/FormatterTest.kt b/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/FormatterTest.kt index 94ca440c9c..07a4b69340 100644 --- a/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/FormatterTest.kt +++ b/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/FormatterTest.kt @@ -10,6 +10,8 @@ import com.github.mikephil.charting.formatter.LargeValueFormatter import com.github.mikephil.charting.formatter.PercentFormatter import com.github.mikephil.charting.formatter.StackedValueFormatter import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame import org.junit.Before import org.junit.Test import java.util.Locale @@ -27,6 +29,19 @@ class FormatterTest { assertEquals("1,234.40", DefaultValueFormatter(2).getFormattedValue(1234.4f, Entry(0f, 0f), 0, ViewPortHandler())) } + @Test + fun defaultValueFormatterRebuildsOnlyWhenDigitsChange() { + val formatter = object : DefaultValueFormatter(2) { + val current get() = format + } + val built = formatter.current + formatter.setup(2) + assertSame(built, formatter.current) + formatter.setup(3) + assertNotSame(built, formatter.current) + assertEquals("1.500", formatter.getFormattedValue(1.5f, Entry(0f, 0f), 0, ViewPortHandler())) + } + @Test fun percentFormatterAppendsPercentSign() { assertEquals("12.3 %", PercentFormatter().getFormattedValue(12.34f, Entry(0f, 0f), 0, ViewPortHandler())) diff --git a/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/HighlighterTest.kt b/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/HighlighterTest.kt new file mode 100644 index 0000000000..833ff4bde6 --- /dev/null +++ b/MPChartLib/src/test/kotlin/com/github/mikephil/charting/test/HighlighterTest.kt @@ -0,0 +1,83 @@ +package com.github.mikephil.charting.test + +import android.graphics.RectF +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.BarData +import com.github.mikephil.charting.data.BarDataSet +import com.github.mikephil.charting.data.BarEntry +import com.github.mikephil.charting.data.BubbleData +import com.github.mikephil.charting.data.CandleData +import com.github.mikephil.charting.data.CombinedData +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.data.ScatterData +import com.github.mikephil.charting.formatter.DefaultValueFormatter +import com.github.mikephil.charting.formatter.IValueFormatter +import com.github.mikephil.charting.highlight.BarHighlighter +import com.github.mikephil.charting.highlight.CombinedHighlighter +import com.github.mikephil.charting.interfaces.dataprovider.CombinedDataProvider +import com.github.mikephil.charting.utils.MPPointF +import com.github.mikephil.charting.utils.Transformer +import com.github.mikephil.charting.utils.ViewPortHandler +import org.junit.Assert.assertEquals +import org.junit.Test + +// On the JVM the transformer matrices do nothing, so pixels equal values here. +class HighlighterTest { + + private class FakeChart(override val combinedData: CombinedData) : CombinedDataProvider { + private val handler = ViewPortHandler() + override val barData: BarData? get() = combinedData.barData + override val lineData: LineData? get() = combinedData.lineData + override val scatterData: ScatterData? get() = combinedData.scatterData + override val candleData: CandleData? get() = combinedData.candleData + override val bubbleData: BubbleData? get() = combinedData.bubbleData + override val data: CombinedData get() = combinedData + override val isDrawBarShadowEnabled = false + override val isDrawValueAboveBarEnabled = true + override val isHighlightFullBarEnabled = false + override fun getTransformer(axis: YAxis.AxisDependency) = Transformer(handler) + override fun isInverted(axis: YAxis.AxisDependency) = false + override fun getAxis(axis: YAxis.AxisDependency) = YAxis(axis) + override val lowestVisibleX = 0f + override val highestVisibleX = 10f + override val xChartMin = 0f + override val xChartMax = 10f + override val xRange = 10f + override val yChartMin = 0f + override val yChartMax = 10f + override val maxHighlightDistance = 500f + override val centerOfView = MPPointF.getInstance(0f, 0f) + override val centerOffsets = MPPointF.getInstance(0f, 0f) + override val contentRect = RectF() + override val defaultValueFormatter: IValueFormatter = DefaultValueFormatter(0) + override val maxVisibleCount = 100 + } + + @Test + fun tapOnGroupedBarSelectsItEvenWhenTheOtherBarUsesAnotherAxis() { + val left = BarDataSet(listOf(BarEntry(0.8f, 1.5f)), "left").apply { axisDependency = YAxis.AxisDependency.LEFT } + val right = BarDataSet(listOf(BarEntry(1.2f, 8f)), "right").apply { axisDependency = YAxis.AxisDependency.RIGHT } + val chart = FakeChart(CombinedData().apply { barData = BarData(left, right).apply { barWidth = 0.35f } }) + + val high = BarHighlighter(chart).getHighlight(1.2f, 1f) + + assertEquals(1, high?.dataSetIndex) + } + + @Test + fun tapInsideBarSelectsTheBarOverANearerLinePoint() { + val combined = CombinedData().apply { + lineData = LineData(LineDataSet(listOf(Entry(1f, 5f)), "line")) + barData = BarData(BarDataSet(listOf(BarEntry(1f, 10f)), "bar")) + } + val chart = FakeChart(combined) + + val insideBar = CombinedHighlighter(chart, chart).getHighlight(1.1f, 4f) + val besideBar = CombinedHighlighter(chart, chart).getHighlight(1.6f, 5f) + + assertEquals(combined.allData.indexOfFirst { it is BarData }, insideBar?.dataIndex) + assertEquals(combined.allData.indexOfFirst { it is LineData }, besideBar?.dataIndex) + } +} diff --git a/README.md b/README.md index e8543b216d..ef682ba92e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The example app opens with every chart type in one consistent style, in a dark a ## Documentation -The guides at [philjay.cc](https://philjay.cc/mpandroidchart/docs/) cover the library in 35 chapters, from a first chart to theming, Compose, custom renderers and troubleshooting. Every class, function and property also has KDoc, and the generated API reference is served by JitPack for each release: [MPChartLib](https://jitpack.io/com/github/PhilJay/MPAndroidChart/MPChartLib/v4.0.0/javadoc/) and [MPChartCompose](https://jitpack.io/com/github/PhilJay/MPAndroidChart/MPChartCompose/v4.0.0/javadoc/). To build it locally run `./gradlew dokkaGenerate` and open `build/dokka/html/index.html`. +The guides at [philjay.cc](https://philjay.cc/mpandroidchart/docs/) cover the library in 35 chapters, from a first chart to theming, Compose, custom renderers and troubleshooting. Every class, function and property also has KDoc, and the generated API reference is served by JitPack for each release: [MPChartLib](https://jitpack.io/com/github/PhilJay/MPAndroidChart/MPChartLib/v4.0.1/javadoc/) and [MPChartCompose](https://jitpack.io/com/github/PhilJay/MPAndroidChart/MPChartCompose/v4.0.1/javadoc/). To build it locally run `./gradlew dokkaGenerate` and open `build/dokka/html/index.html`. ## Requirements @@ -45,8 +45,8 @@ dependencyResolutionManagement { // build.gradle.kts dependencies { - implementation("com.github.PhilJay.MPAndroidChart:MPChartLib:v4.0.0") - implementation("com.github.PhilJay.MPAndroidChart:MPChartCompose:v4.0.0") // only for Compose + implementation("com.github.PhilJay.MPAndroidChart:MPChartLib:v4.0.1") + implementation("com.github.PhilJay.MPAndroidChart:MPChartCompose:v4.0.1") // only for Compose } ``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ab395b78a..4fc6ad30a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -mpandroidchart = "4.0.0" +mpandroidchart = "4.0.1" agp = "9.4.1" kotlin = "2.2.20" dokka = "2.2.0"