From 5135570523540282fac2ba737786f1b5ffed96ca Mon Sep 17 00:00:00 2001 From: "maksim.pelevin" Date: Wed, 30 Jun 2021 15:45:28 +0000 Subject: [PATCH] Move Charts to the platform GitOrigin-RevId: 5cd6ffb9e48459c3a50cf238d49fdf383f2022f5 --- .../src/com/intellij/ui/charts/BarChart.kt | 148 +++++++ .../src/com/intellij/ui/charts/ChartDsl.kt | 196 +++++++++ .../com/intellij/ui/charts/ChartWrapper.kt | 403 +++++++++++++++++ .../src/com/intellij/ui/charts/LineChart.kt | 306 +++++++++++++ .../src/com/intellij/ui/charts/Overlays.kt | 64 +++ .../com/intellij/ui/charts/package-info.java | 5 + .../intellij/ui/charts/BarChartKotlinDemo.kt | 150 +++++++ .../intellij/ui/charts/LineChartJavaTest.java | 135 ++++++ .../intellij/ui/charts/LineChartKotlinDemo.kt | 412 ++++++++++++++++++ .../intellij/ui/charts/LineChartKotlinTest.kt | 103 +++++ 10 files changed, 1922 insertions(+) create mode 100644 platform/platform-impl/src/com/intellij/ui/charts/BarChart.kt create mode 100644 platform/platform-impl/src/com/intellij/ui/charts/ChartDsl.kt create mode 100644 platform/platform-impl/src/com/intellij/ui/charts/ChartWrapper.kt create mode 100644 platform/platform-impl/src/com/intellij/ui/charts/LineChart.kt create mode 100644 platform/platform-impl/src/com/intellij/ui/charts/Overlays.kt create mode 100644 platform/platform-impl/src/com/intellij/ui/charts/package-info.java create mode 100644 platform/platform-tests/testSrc/com/intellij/ui/charts/BarChartKotlinDemo.kt create mode 100644 platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartJavaTest.java create mode 100644 platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinDemo.kt create mode 100644 platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinTest.kt diff --git a/platform/platform-impl/src/com/intellij/ui/charts/BarChart.kt b/platform/platform-impl/src/com/intellij/ui/charts/BarChart.kt new file mode 100644 index 000000000000..d2515913d962 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/charts/BarChart.kt @@ -0,0 +1,148 @@ +// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package com.intellij.ui.charts + +import java.awt.Graphics2D +import java.awt.Rectangle +import java.awt.geom.Area +import java.lang.Double.min +import javax.swing.SwingConstants +import kotlin.math.abs +import kotlin.math.max + +class BarDataset: Dataset() { + + var stacked: Boolean = false + var values: Iterable + get() = data + set(value) { + data = value + } + var showValues : ((T) -> String)? = null + + init { + fillColor = lineColor + } + + companion object { + @JvmStatic + fun of(vararg values: T): BarDataset = BarDataset().apply { + data = values.toList() + } + } + +} + +abstract class BarChart: GridChartWrapper() { + + var datasets: List> = mutableListOf() + override val ranges: Grid = Grid() + var gap: Int = 10 + var space: Int = -1; + + override fun paintComponent(g: Graphics2D) { + + val xy = findMinMax() + if (xy.isInitialized) { + val datasetCount = getDatasetCount() + val grid = g.create(margins.left, margins.top, gridWidth, gridHeight) as Graphics2D + paintGrid(grid, g, xy) + var index = 0 + datasets.forEach { dataset -> + dataset.paintDataset(index, datasetCount, grid, xy) + if (!dataset.stacked) { + index++ + } + } + grid.dispose() + } + } + + fun getDatasetCount() = datasets.map { if (it.stacked) 0 else 1 }.sum() + + override fun findMinMax() = if (ranges.isInitialized) ranges else ranges * (ranges + MinMax()).apply { + datasets.forEach { it.data.forEachIndexed { i, v -> process(i, v) } } + } + + protected abstract fun BarDataset.paintDataset(datasetIndex: Int, datasetCount: Int, g: Graphics2D, xy: MinMax) + +} + +class HorizontalBarChart : BarChart() { + + override fun BarDataset.paintDataset(datasetIndex: Int, datasetCount: Int, g: Graphics2D, xy: MinMax) { + assert(xy.xMin == 0) { "Int value must start with 0" } + + val columns = xy.xMax + 1 + val max = max(xy.yMax.toDouble(), 0.0) + val min = min(xy.yMin.toDouble(), 0.0) + if (max == min) { + return + } + + val cb = g.clipBounds + + val z = cb.height / (max - min) + val axis = (z * abs(min)).toInt() + + data.forEachIndexed { column, value -> + + var h = (value.toDouble() * z).toInt() + var y = cb.height - h - axis + + val groupW = cb.width / columns - gap + val groupX = column * cb.width / columns + gap / 2 + + val space = if (space < 0) max(1, groupW / 10) else space + val w = max(1, (groupW - space * (datasetCount - 1)) / datasetCount) + val x = groupX + datasetIndex * w + space * datasetIndex + + if (h < 0) { + y += h + h = abs(h) + } + lineColor?.let { + g.paint = lineColor + g.drawRect(x, y, w, h) + } + fillColor?.let { + g.paint = it + g.fillRect(x, y, w, h) + } + + if (stacked) { + val area = Area(g.clip) + area.subtract(Area(Rectangle(x, y, w, h))) + g.clip = area + } + + showValues?.let { toString -> + val str = toString(value) + val bounds = g.fontMetrics.getStringBounds(str, g) + g.drawString(str, x + (w - bounds.width.toInt()) / 2, y - 5) + } + } + } + + override fun findGridLineX(gl: GridLine, x: Int): Double { + return gridWidth * ((x + 1).toDouble() - gl.xy.xMin.toDouble()) / (gl.xy.xMax.toDouble() - gl.xy.xMin.toDouble() + 1) + } + + override fun findY(xy: MinMax, y: T): Double { + val isRanged = xy.yMin.toDouble() <= 0.0 && 0.0 <= xy.yMax.toDouble() + val yMin = if (isRanged || xy.yMin.toDouble() < 0) xy.yMin.toDouble() else 0.0 + val yMax = if (isRanged || xy.yMax.toDouble() > 0) xy.yMax.toDouble() else 0.0 + val height = height - (margins.top + margins.bottom) + return height - height * (y.toDouble() - yMin) / (yMax - yMin) + } + + override fun findGridLabelOffset(line: GridLine<*, *, *>, g: Graphics2D): Coordinates { + val onLineAlignment = super.findGridLabelOffset(line, g) + if (line.orientation == SwingConstants.VERTICAL) { + val width = width - (margins.left + margins.right) + val columnWidth = width / (line.xy.xMax.toDouble() - line.xy.xMin.toDouble() + 1) + return (onLineAlignment.x + columnWidth / 2) to onLineAlignment.y + } else { + return onLineAlignment + } + } +} \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/ui/charts/ChartDsl.kt b/platform/platform-impl/src/com/intellij/ui/charts/ChartDsl.kt new file mode 100644 index 000000000000..76b78a6ede0a --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/charts/ChartDsl.kt @@ -0,0 +1,196 @@ +@file:JvmName("ChartUtils") +package com.intellij.ui.charts + +import java.awt.Insets +import java.util.function.Consumer + +// Common DSL + +fun > T.ranges(init: MinMax.() -> Unit) { + init(this.ranges) +} + +fun > T.grid(init: Grid.() -> Unit) { + init(this.ranges) +} + +fun ChartWrapper.margins(init: Insets.() -> Unit) { + init(this.margins) +} + +// Generator DSL + +fun enumerator(vararg values: T) : ValueIterable = object : ValueIterable() { + override fun iterator(): Iterator = values.iterator() +} +fun generator(step: Int) : ValueIterable = object: ValueIterable() { + override fun iterator(): Iterator = (min..max step step).iterator() +} +fun generator(step: Long) : ValueIterable = object: ValueIterable() { + override fun iterator(): Iterator = (min..max step step).iterator() +} +fun generator(step: Float) : ValueIterable = object: ValueIterable() { + override fun iterator(): Iterator = object: Iterator { + var position: Int = 0 + override fun hasNext() = position * step + min <= max + override fun next(): Float { + val next = position * step + min + position += 1 + return next + } + } +} + +fun generator(step: Double) : ValueIterable = object: ValueIterable() { + override fun iterator(): Iterator = object: Iterator { + var position: Int = 0 + override fun hasNext() = position * step + min <= max + override fun next(): Double { + val next = position * step + min + position += 1 + return next + } + } +} + +// Grid + +fun Grid.xPainter(converter: GridLine.() -> Unit) { + xPainter = Consumer { converter(it) } +} +fun Grid.yPainter(converter: GridLine.() -> Unit) { + yPainter = Consumer { converter(it) } +} + +fun Grid.format(str: String) = Consumer> { + it.label = str.format(it.value) +} + +/* XY Line DSL */ + +class XYDataHolder(val data: MutableList>) + +class XYDataCreator { + lateinit var x: Iterable + lateinit var y: Iterable +} + +class XYDataGenerator { + lateinit var x: Iterable + lateinit var y: (x: X) -> Y +} + +fun lineChart(ink: XYLineChart.() -> Unit) = XYLineChart().apply(ink) + +fun XYLineChart.datasets(body: XYDataHolder.() -> Unit) { + datasets = XYDataHolder(mutableListOf>()).apply(body).data +} + +fun XYDataHolder.dataset(body: XYLineDataset.() -> Unit) { + data.add(XYLineDataset().apply(body)) +} +fun XYLineChart.dataset(body: XYLineDataset.() -> Unit) { + datasets = mutableListOf(XYLineDataset().apply(body)) +} + +fun XYLineDataset.values(creator: XYDataCreator.() -> Unit) { + val values = XYDataCreator().apply(creator) + this.data = object: Iterable> { + override fun iterator(): Iterator> = object: Iterator> { + val xIter = values.x.iterator() + val yIter = values.y.iterator() + override fun hasNext() = xIter.hasNext() && yIter.hasNext() + override fun next() = xIter.next() to yIter.next() + } + } +} + +fun XYLineDataset.generate(generator: XYDataGenerator.() -> Unit) { + val iterator = XYDataGenerator().apply(generator) + this.data = object: Iterable> { + override fun iterator(): Iterator> = object: Iterator> { + val i = iterator.x.iterator() + override fun hasNext() = i.hasNext() + override fun next(): Coordinates = i.next().let { it to iterator.y(it) } + } + } +} + +/* Category Line DSL */ + +class CategoryDataHolder(val data: MutableList>) + +fun lineChart(ink: CategoryLineChart.() -> Unit) = CategoryLineChart().apply(ink) + +fun CategoryLineChart.datasets(body: CategoryDataHolder.() -> Unit) { + datasets = CategoryDataHolder(mutableListOf>()).apply(body).data +} + +fun CategoryDataHolder.dataset(body: CategoryLineDataset.() -> Unit) { + data.add(CategoryLineDataset().apply(body)) +} + +fun CategoryLineChart.dataset(body: CategoryLineDataset.() -> Unit) { + datasets = mutableListOf(CategoryLineDataset().apply(body)) +} + +/* Bar Chart DSL */ + +class BarDataGenerator { + var count: Int = -1 + set(value) { + if (value < 0) throw IllegalArgumentException("Value cannot be less than 0") + field = value + } + lateinit var function: (x: Int) -> R +} + +class BarDataHolder(val data: MutableList>) + +fun barChart(ink: HorizontalBarChart.() -> Unit) = HorizontalBarChart().apply(ink) + +fun HorizontalBarChart.datasets(body: BarDataHolder.() -> Unit) { + datasets = BarDataHolder(mutableListOf>()).apply(body).data +} + +fun HorizontalBarChart.dataset(body: BarDataset.() -> Unit) { + datasets = listOf(BarDataset().apply(body)) +} + +fun BarDataHolder.dataset(body: BarDataset.() -> Unit) { + data.add(BarDataset().apply(body)) +} + +fun generate(generator: BarDataGenerator.() -> Unit): Iterable { + val iterator = BarDataGenerator().apply(generator) + return object : Iterable { + override fun iterator(): Iterator = object: Iterator { + var index = -1 + override fun hasNext() = index < iterator.count + override fun next(): R = iterator.function(++index) + } + } +} + +// Timeline helpers + +val times = arrayOf( + 1, 2, 5, 10, 25, 50, 100, 200, 500, // milliseconds + 1_000, 2_000, 5_000, 15_000, 30_000, // seconds + 60_000, 120_000, 300_000, 600_000, 900_000, 1_800_000, // minutes + 3_600_000, 7_200_000, 14_400_000 // hours +) + +fun ChartWrapper.findScale(xMin: Long, xMax: Long, spaceWidth: Int = 100): Int { + val duration = xMax - xMin + val width = width.toDouble() + val lineCount = width / spaceWidth + var scale = times[0] + for (i in times.indices) { + scale = times[i] + if (duration / scale <= lineCount) { + break + } + } + return scale +} \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/ui/charts/ChartWrapper.kt b/platform/platform-impl/src/com/intellij/ui/charts/ChartWrapper.kt new file mode 100644 index 000000000000..6c0c6d770343 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/charts/ChartWrapper.kt @@ -0,0 +1,403 @@ +package com.intellij.ui.charts + +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.util.ui.GraphicsUtil +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import org.intellij.lang.annotations.MagicConstant +import java.awt.* +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.util.function.Consumer +import javax.swing.JComponent +import javax.swing.SwingConstants +import kotlin.math.absoluteValue +import kotlin.math.roundToInt +import kotlin.math.sign + +interface ChartComponent { + fun paintComponent(g: Graphics2D) +} + +/** + * Holds instance of ChartWrapper in which has been added. + */ +abstract class Overlay: ChartComponent { + + var wrapper: ChartWrapper? = null + set(value) { + field = value + afterChartInitialized() + } + + var mouseLocation: Point? = null + + /** + * Typed value for {@code wrapper}. + * + * Normally wrapper is already initialized when added to ChartWrapper. + */ + val chart: T + @Suppress("UNCHECKED_CAST") get() = wrapper as T + + open fun afterChartInitialized() { } + + fun Point.toChartSpace(): Point? = wrapper?.let { + if (it.margins.left < x && x < it.width - it.margins.right && it.margins.top < y && y < it.height - it.margins.bottom) { + Point(x - it.margins.left, y - it.margins.top) + } else null + } + +} + +interface XYChartComponent { + val ranges: MinMax +} + +abstract class GridChartWrapper: ChartWrapper(), XYChartComponent { + + abstract override val ranges: Grid + val grid: Grid + get() = ranges + val gridWidth: Int + get() = width - (margins.left + margins.right) + val gridHeight: Int + get() = height - (margins.top + margins.bottom) + + var gridColor: Color = JBColor(Color(0xF0F0F0), Color(0x313335)) + var gridLabelColor: Color = ColorUtil.withAlpha(JBColor.foreground(), 0.6) + + protected fun paintGrid(grid: Graphics2D, chart: Graphics2D, xy: MinMax) { + val gc = Color(gridColor.rgb) + val gcd = gc.darker() + val bounds = grid.clipBounds + val xOrigin: Int = if (ranges.xOriginInitialized) findX(xy, ranges.xOrigin).toInt() else 0 + val yOrigin: Int = if (ranges.yOriginInitialized) findY(xy, ranges.yOrigin).toInt() else height + + var tmp: Int // — helps to filter line drawing, when lines are met too often + + // draws vertical grid lines + tmp = -1 + ranges.xLines.apply { + min = xy.xMin + max = xy.xMax + }.forEach { + val gl = GridLine(it, xy, SwingConstants.VERTICAL).apply(ranges.xPainter::accept) + val px = findGridLineX(gl, it).roundToInt() + if (gl.paintLine) { + if ((tmp - px).absoluteValue < 1) { return@forEach } else { tmp = px } + grid.color = if (gl.majorLine) gcd else gc + grid.drawLine(px, bounds.y, px, bounds.y + bounds.height) + } + gl.label?.let { label -> + chart.color = gridLabelColor + val (x, y) = findGridLabelOffset(gl, chart) + chart.drawString(label, px + margins.left - x.toInt(), yOrigin - margins.bottom + y.toInt()) + } + } + + // draws horizontal grid lines + tmp = -1 + ranges.yLines.apply { + min = xy.yMin + max = xy.yMax + }.forEach { + val gl = GridLine(it, xy).apply(ranges.yPainter::accept) + val py = findGridLineY(gl, it).roundToInt() + if (gl.paintLine) { + if ((tmp - py).absoluteValue < 1) { return@forEach } else { tmp = py } + grid.color = if (gl.majorLine) gcd else gc + grid.drawLine(bounds.x, py, bounds.x + bounds.width, py) + } + gl.label?.let { label -> + chart.color = gridLabelColor + val (x, y) = findGridLabelOffset(gl, chart) + chart.drawString(label, xOrigin + margins.left - x.toInt(), py + margins.top + y.toInt() ) + } + } + } + + protected open fun findGridLineX(gl: GridLine, x: X) = findX(gl.xy, x) + + protected open fun findGridLineY(gl: GridLine, y: Y) = findY(gl.xy, y) + + abstract fun findMinMax(): MinMax + + protected open fun findX(xy: MinMax, x: X): Double { + val width = width - (margins.left + margins.right) + return width * (x.toDouble() - xy.xMin.toDouble()) / (xy.xMax.toDouble() - xy.xMin.toDouble()) + } + + protected open fun findY(xy: MinMax, y: Y): Double { + val height = height - (margins.top + margins.bottom) + return height - height * (y.toDouble() - xy.yMin.toDouble()) / (xy.yMax.toDouble() - xy.yMin.toDouble()) + } + + protected open fun findGridLabelOffset(line: GridLine<*, *, *>, g: Graphics2D): Coordinates { + val s = JBUI.scale(4).toDouble() + val b = g.fontMetrics.getStringBounds(line.label, null) + val x = when (line.horizontalAlignment) { + SwingConstants.RIGHT -> -s + SwingConstants.CENTER -> b.width / 2 + SwingConstants.LEFT -> b.width + s + else -> -s + } + val y = b.height - when (line.verticalAlignment) { + SwingConstants.TOP -> b.height + s + SwingConstants.CENTER -> b.height / 2 + s / 2 // compensate + SwingConstants.BOTTOM -> 0.0 + else -> 0.0 + } + return x to y + } +} + +abstract class ChartWrapper : ChartComponent { + var width: Int = 0 + private set + var height: Int = 0 + private set + var background: Color = JBColor.background() + var overlays: List = mutableListOf() + set(value) { + field.forEach { if (it is Overlay<*>) it.wrapper = null } + (field as MutableList).addAll(value) + field.forEach { if (it is Overlay<*>) it.wrapper = this } + } + var margins = Insets(0, 0, 0, 0) + + open fun paintOverlay(g: Graphics2D) { + overlays.forEach { + it.paintComponent(g) + } + } + + open val component: JComponent by lazy { + createCentralPanel().apply { + with(MouseAware()) { + addMouseMotionListener(this) + addMouseListener(this) + } + } + } + + fun update() = component.repaint() + + protected open fun createCentralPanel(): JComponent = CentralPanel() + + protected var mouseLocation: Point? = null + private set(value) { + field = value + overlays.forEach { if (it is Overlay<*>) it.mouseLocation = value } + } + + private inner class MouseAware : MouseAdapter() { + override fun mouseMoved(e: MouseEvent) { + mouseLocation = e.point + component.repaint() + } + + override fun mouseEntered(e: MouseEvent) { + mouseLocation = e.point + component.repaint() + } + + override fun mouseExited(e: MouseEvent) { + mouseLocation = null + component.repaint() + } + + override fun mouseDragged(e: MouseEvent) { + mouseLocation = e.point + component.repaint() + } + } + + private inner class CentralPanel : JComponent() { + override fun paintComponent(g: Graphics) { + g.clip = Rectangle(0, 0, width, height) + g.color = this@ChartWrapper.background + (g as Graphics2D).fill(g.clip) + this@ChartWrapper.height = height + this@ChartWrapper.width = width + val gridGraphics = (g.create(0, 0, this@ChartWrapper.width, this@ChartWrapper.height) as Graphics2D).apply { + setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED) + setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE) + GraphicsUtil.setupAntialiasing(this) + } + try { + this@ChartWrapper.paintComponent(gridGraphics) + } finally { + gridGraphics.dispose() + } + + val overlayGraphics = (g.create() as Graphics2D).apply { + setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED) + setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE) + GraphicsUtil.setupAntialiasing(this) + } + try { + this@ChartWrapper.paintOverlay(overlayGraphics) + } finally { + overlayGraphics.dispose() + } + } + } +} + +open class Dataset { + + var label: String? = null + var lineColor: Paint? = JBColor.foreground() + var fillColor: Paint? = null + + open var data: Iterable = mutableListOf() + + fun add(vararg values: T) { + addAll(values.toList()) + } + + @Suppress("UNCHECKED_CAST") + fun addAll(values: Collection) { + (data as? MutableList)?.addAll(values) ?: throw UnsupportedOperationException() + } + + fun Color.transparent(alpha: Double) = ColorUtil.withAlpha(this, alpha) +} + +data class Coordinates(val x: X, val y: Y) { + + companion object { + @JvmStatic fun of(x: X, y: Y) : Coordinates = Coordinates(x, y) + } + +} + +infix fun X.to(y: Y): Coordinates = Coordinates(this, y) + +open class MinMax { + + lateinit var xMin: X + val xMinInitialized + get() = this::xMin.isInitialized + + lateinit var xMax: X + val xMaxInitialized + get() = this::xMax.isInitialized + + lateinit var yMin: Y + val yMinInitialized + get() = this::yMin.isInitialized + + lateinit var yMax: Y + val yMaxInitialized + get() = this::yMax.isInitialized + + fun process(point: Coordinates) { + val (x, y) = point + process(x, y) + } + + fun process(x: X, y: Y) { + processX(x) + processY(y) + } + + fun processX(x: X) { + xMin = if (!xMinInitialized || xMin.toDouble() > x.toDouble()) x else xMin + xMax = if (!xMaxInitialized || xMax.toDouble() < x.toDouble()) x else xMax + } + + fun processY(y: Y) { + yMin = if (!yMinInitialized || yMin.toDouble() > y.toDouble()) y else yMin + yMax = if (!yMaxInitialized || yMax.toDouble() < y.toDouble()) y else yMax + } + + operator fun times(other: MinMax): MinMax { + val my = MinMax() + if (this.xMinInitialized) my.xMin = this.xMin else if (other.xMinInitialized) my.xMin = other.xMin + if (this.xMaxInitialized) my.xMax = this.xMax else if (other.xMaxInitialized) my.xMax = other.xMax + if (this.yMinInitialized) my.yMin = this.yMin else if (other.yMinInitialized) my.yMin = other.yMin + if (this.yMaxInitialized) my.yMax = this.yMax else if (other.yMaxInitialized) my.yMax = other.yMax + return my + } + + operator fun plus(other: MinMax) : MinMax { + val my = MinMax() + help(this.xMinInitialized, this::xMin, other.xMinInitialized, other::xMin, -1, my::xMin.setter) + help(this.xMaxInitialized, this::xMax, other.xMaxInitialized, other::xMax, 1, my::xMax.setter) + help(this.yMinInitialized, this::yMin, other.yMinInitialized, other::yMin, -1, my::yMin.setter) + help(this.yMaxInitialized, this::yMax, other.yMaxInitialized, other::yMax, 1, my::yMax.setter) + return my + } + + private fun help(thisInitialized: Boolean, thisGetter: () -> T, otherInitialized: Boolean, otherGetter: () -> T, sign: Int, calc: (T) -> Unit) { + when { + thisInitialized && otherInitialized -> { + val thisValue = thisGetter().toDouble() + val thatValue = otherGetter().toDouble() + calc(if (thisValue.compareTo(thatValue).sign == sign) thisGetter() else otherGetter()) + } + thisInitialized && !otherInitialized -> calc((thisGetter())) + !thisInitialized && otherInitialized -> calc(otherGetter()) + } + } + + operator fun component1(): X = xMin + operator fun component2(): X = xMax + operator fun component3(): Y = yMin + operator fun component4(): Y = yMax + + + val isInitialized get() = xMinInitialized && xMaxInitialized && yMinInitialized && yMaxInitialized +} + +class Grid: MinMax() { + lateinit var xOrigin: X + val xOriginInitialized + get() = this::xOrigin.isInitialized + var xLines: ValueIterable = ValueIterable.createStub() + var xPainter: (Consumer>) = Consumer { } + + lateinit var yOrigin: Y + val yOriginInitialized + get() = this::yOrigin.isInitialized + var yLines: ValueIterable = ValueIterable.createStub() + var yPainter: (Consumer>) = Consumer { } +} + +class GridLine(val value: T, @get:JvmName("getXY") val xy: MinMax, @MagicConstant val orientation: Int = SwingConstants.HORIZONTAL) { + var paintLine = true + var majorLine = false + var label: String? = null + @MagicConstant var horizontalAlignment = SwingConstants.CENTER + @MagicConstant var verticalAlignment = SwingConstants.BOTTOM +} + +abstract class ValueIterable : Iterable { + + lateinit var min: X + lateinit var max: X + + open fun prepare(min: X, max: X): ValueIterable { + this.min = min + this.max = max + return this + } + + companion object { + fun createStub(): ValueIterable { + return object: ValueIterable() { + val iter = object: Iterator { + override fun hasNext() = false + override fun next(): T { error("not implemented") } + } + override fun iterator(): Iterator = iter + } + } + } + +} \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/ui/charts/LineChart.kt b/platform/platform-impl/src/com/intellij/ui/charts/LineChart.kt new file mode 100644 index 000000000000..d790aeb0f390 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/charts/LineChart.kt @@ -0,0 +1,306 @@ +package com.intellij.ui.charts + +import java.awt.BasicStroke +import java.awt.BasicStroke.CAP_BUTT +import java.awt.BasicStroke.JOIN_ROUND +import java.awt.Graphics2D +import java.awt.Rectangle +import java.awt.geom.Area +import java.awt.geom.Path2D +import java.awt.geom.Point2D +import java.util.* +import kotlin.NoSuchElementException +import kotlin.math.hypot +import kotlin.math.min + +/** + * Simple Line Chart. + * + * Has options: + *
    + *
  • stepped — can be set to LineStepped.(NONE|BEFORE|AFTER) + *
  • stacked — if true area under chart's line subtracts from result graphic (every next chart cannot paint on this area anymore) + *
  • stroke — set custom stroke for the line + *
+ */ +abstract class LineDataset: Dataset>() { + var stepped: LineStepped = LineStepped.NONE + var stacked: Boolean = false + var stroke = BasicStroke(1.5f, CAP_BUTT, JOIN_ROUND) + var smooth: Boolean = false + + var modificationFirst: Boolean = false + set(value) { + field = value + data = (if (value) LinkedList() else mutableListOf>()).apply { + data.forEach { this@LineDataset.add(it) } + } + } + + fun find(x: X): Y? = data.find { it.x == x }?.y + + companion object { + + @JvmStatic fun of(vararg values: T) = CategoryLineDataset().apply { + addAll(values.mapIndexed(::Coordinates).toList()) + } + + @JvmStatic fun of(xs: Array, ys: Array) = XYLineDataset().apply { + addAll(Array(min(xs.size, ys.size)) { i -> Coordinates(xs[i], ys[i]) }.toList()) + } + + @JvmStatic fun of(vararg points: Coordinates) = XYLineDataset().apply { + addAll(points.toList()) + } + } +} + +/** + * Type of stepped line: + * + * * NONE — line continuously connects every line + * * BEFORE — line changes value before connection to another point + * * AFTER — line changes value after connection to another point + */ +enum class LineStepped { + NONE, BEFORE, AFTER +} + +/** + * Default 2-dimensional dataset for function like f(x) = y. + */ +open class XYLineDataset : LineDataset() { +} + +/** + * Default category dataset, that is specific type of XYLineDataset, when x in range of (0..). + */ +open class CategoryLineDataset : LineDataset() { + var values: Iterable + get() = data.map { it.y } + set(value) { + data = value.mapIndexed(::Coordinates) + } +} + +/** + * Base chart component. + * + * For drawing uses GeneralPath from AWT library. + * + * Has options: + * + * * gridColor + * * borderPainted — if true draws a border around the chart and respects margins + * * ranges — grid based range, that holds all information about grid painting. Has minimal and maximum values for graphic. + */ +abstract class LineChart>: GridChartWrapper() { + + var datasets: List = mutableListOf() + + companion object { + + @JvmStatic fun > of(vararg values: D) = CategoryLineChart().apply { + datasets = mutableListOf(*values) + } + + @JvmStatic fun > of(vararg values: D) = XYLineChart().apply { + datasets = mutableListOf(*values) + } + + @JvmStatic fun of(vararg values: T) = CategoryLineChart().apply { + datasets = mutableListOf(LineDataset.of(*values)) + } + + @JvmStatic fun of(vararg points: Coordinates) = XYLineChart().apply { + datasets = mutableListOf(LineDataset.of(*points)) + } + } + + var borderPainted: Boolean = false + override val ranges = Grid() + + override fun paintComponent(g: Graphics2D) { + val gridWidth = width - (margins.left + margins.right) + val gridHeight = height - (margins.top + margins.bottom) + + if (borderPainted) { + g.color = gridColor + g.drawRect(margins.left, margins.top, gridWidth, gridHeight) + } + + val xy = findMinMax() + if (xy.isInitialized) { + val grid = g.create(margins.left, margins.top, gridWidth, gridHeight) as Graphics2D + paintGrid(grid, g, xy) + datasets.forEach { + it.paintDataset(grid, xy) + } + grid.dispose() + } + } + + override fun findMinMax() = if (ranges.isInitialized) ranges else ranges * (ranges + MinMax()).apply { + datasets.forEach { it.data.forEach(::process) } + } + + private fun D.paintDataset(g: Graphics2D, xy: MinMax) { + val path = Path2D.Double() + lateinit var first: Point2D + + val bounds = g.clipBounds + + g.paint = lineColor + g.stroke = stroke + + // set small array to store 4 points of values + val useSplines = smooth && stepped == LineStepped.NONE + val neighborhood = DoubleArray(8) { Double.NaN } + + data.forEachIndexed { i, (x, y) -> + val px = findX(xy, x) + val py = findY(xy, y) + neighborhood.shiftLeftByTwo(px, py) + if (i == 0) { + first = Point2D.Double(px, py) + path.moveTo(px, py) + } else { + if (!useSplines) { + when (stepped) { + LineStepped.AFTER -> path.lineTo(neighborhood[4], py) + LineStepped.BEFORE -> path.lineTo(px, neighborhood[5]) + } + path.lineTo(px, py) + } else if (i > 1) { + path.curveTo(neighborhood) + } + } + } + + // last step to draw tail of graphic, when spline is used + if (useSplines) { + neighborhood.shiftLeftByTwo(Double.NaN, Double.NaN) + path.curveTo(neighborhood) + } + + g.paint = lineColor + g.stroke = stroke + g.draw(path) + + // added some points + path.currentPoint?.let { last -> + path.lineTo(last.x, bounds.height + 1.0) + path.lineTo(first.x, bounds.height + 1.0) + path.closePath() + } + + fillColor?.let { + g.paint = it + g.fill(path) + } + + if (stacked) { + // fix stroke cutting + val area = Area(g.clip) + area.subtract(Area(path)) + g.clip = area + } + } + + fun findLocation(xy: MinMax, coordinates: Coordinates) = Point2D.Double( + findX(xy, coordinates.x) + margins.left, findY(xy, coordinates.y) + margins.top + ) + + open fun add(x: X, y: Y) { + datasets.firstOrNull()?.add(x to y) + } + + fun getDataset() = datasets.first() + + @JvmName("getDataset") + operator fun get(label: String): LineDataset = datasets.find { it.label == label } ?: throw NoSuchElementException("Cannot find dataset with label $datasets") + + fun clear() { + datasets.forEach { (it.data as MutableList).clear() } + } +} + +open class CategoryLineChart : LineChart>() + +open class XYLineChart : LineChart>() + +// HELPERS +/** + * Calculates control points for bezier function and curves line. + * Requires an array of 4 points in format (x0, y0, x1, y1, x2, y2, x3, y3), + * where (x2, y2) — is target point, x1, y1 — point, that can be acquired by Path2D.currentPoint. + * (x0, y0) — point before current and (x3, y3) —point after. + * + * @param neighbour array keeps 4 points (size 8) + * + * Using Monotone Cubic Splines: algorithm https://en.wikipedia.org/wiki/Monotone_cubic_interpolation + */ +private fun Path2D.Double.curveTo(neighbour: DoubleArray) { + + assert(neighbour.size == 8) { "Array must contain 4 points in format (x0, y0, x1, y1, x2, y2, x3, y3)" } + + val x0 = neighbour[0] + val y0 = neighbour[1] + val x1 = neighbour[2] + val y1 = neighbour[3] + val x2 = neighbour[4] + val y2 = neighbour[5] + val x3 = neighbour[6] + val y3 = neighbour[7] + + val slope0 = ((y1 - y0) / (x1 - x0)).orZero() + val slope1 = ((y2 - y1) / (x2 - x1)).orZero() + val slope2 = ((y3 - y2) / (x3 - x2)).orZero() + + var tan1 = if (slope0 * slope1 <= 0) 0.0 else (slope0 + slope1) / 2 + var tan2 = if (slope1 * slope2 <= 0) 0.0 else (slope1 + slope2) / 2 + + if (slope1 == 0.0) { + tan1 = 0.0 + tan2 = 0.0 + } else { + val a = tan1 / slope1 + val b = tan2 / slope1 + val h = hypot(a, b) + if (h > 3.0) { + val t = 3.0 / h + tan1 = t * a * slope1 + tan2 = t * b * slope1 + } + } + + val delta2 = (x2 - x1) / 3 + var cx0 = x1 + delta2 + var cy0 = y1 + delta2 * tan1 + + if (x0.isNaN() || y0.isNaN()) { + cx0 = x1 + cy0 = y1 + } + + val delta0 = (x2 - x1) / 3 + var cx1 = x2 - delta0 + var cy1 = y2 - delta0 * tan2 + + if (x3.isNaN() || y3.isNaN()) { + cx1 = x2 + cy1 = y2 + } + + curveTo(cx0, cy0, cx1, cy1, x2, y2) +} + +private fun Double.orZero() = if (this.isNaN()) 0.0 else this + +private fun DoubleArray.shiftLeftByTwo(first: Double, second: Double) { + for (j in 2 until size) { + this[j - 2] = this[j] + } + this[size - 2] = first + this[size - 1] = second +} diff --git a/platform/platform-impl/src/com/intellij/ui/charts/Overlays.kt b/platform/platform-impl/src/com/intellij/ui/charts/Overlays.kt new file mode 100644 index 000000000000..8403570c8c98 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/charts/Overlays.kt @@ -0,0 +1,64 @@ +package com.intellij.ui.charts + +import com.intellij.ui.JBColor +import java.awt.BasicStroke +import java.awt.Graphics2D +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.util.function.BiConsumer +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt + +class LabelOverlay: Overlay>() { + override fun paintComponent(g: Graphics2D) { + val bounds = g.fontMetrics.getStringBounds("SAMPLE", null) + g.stroke = BasicStroke(5F) + chart.datasets.forEachIndexed { i, dataset -> + g.paint = dataset.lineColor + val y = chart.height - (bounds.height + i * bounds.height).toInt() + g.drawLine(10, y, 30, y) + if (dataset.label != null) { + val text = dataset.label!! + g.drawString(text, 35, y + (bounds.height / 3).roundToInt()) + } + } + } +} + +class DragOverlay(val onRelease: BiConsumer): Overlay>() { + + private var startPoint: Point? = null + + override fun afterChartInitialized() { + chart.component.addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + startPoint = e.point.toChartSpace() + } + override fun mouseReleased(e: MouseEvent) { + val start = startPoint + val end = e.point.toChartSpace() + startPoint = null + if (start != null && end != null) + onRelease.accept(start, end) + } + }) + } + + override fun paintComponent(g: Graphics2D) { + val start = startPoint + if (start == null) { + return + } + val end = mouseLocation + if (end == null) { + return + } + + g.color = JBColor.BLACK + g.drawRect(min(start.x, end.x), min(start.y, end.y), abs(start.x - end.x), abs(start.y - end.y)) + + } + +} \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/ui/charts/package-info.java b/platform/platform-impl/src/com/intellij/ui/charts/package-info.java new file mode 100644 index 000000000000..f38ec002c900 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/charts/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +@ApiStatus.Experimental +package com.intellij.ui.charts; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/platform/platform-tests/testSrc/com/intellij/ui/charts/BarChartKotlinDemo.kt b/platform/platform-tests/testSrc/com/intellij/ui/charts/BarChartKotlinDemo.kt new file mode 100644 index 000000000000..cc31a312a73d --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/ui/charts/BarChartKotlinDemo.kt @@ -0,0 +1,150 @@ +package com.intellij.ui.charts + +import com.intellij.ide.ui.laf.IntelliJLaf +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.charts.* +import com.intellij.ui.ColorUtil +import com.intellij.ui.components.JBTabbedPane +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Dimension +import javax.swing.* +import javax.swing.border.Border +import kotlin.math.cos +import kotlin.system.exitProcess + +fun simple() = barChart { + gap = 20 + datasets { + dataset { + values = enumerator(1.0, 2.0, 1.0, -1.0, 3.0, 2.0, 2.0) + lineColor = Color(6, 128, 213) + fillColor = Color(6, 128, 213, 122) + } + dataset { + values = enumerator(1.5, 1.0, .5, -2.0, 2.0, 5.0, 1.0) + stacked = true + lineColor = Color(213, 6, 13) + fillColor = Color(213, 6, 13, 122) + } + dataset { + values = enumerator(2.0, 3.0, 1.5, -.20, 2.5, 1.0, 0.0) + lineColor = Color(56, 158, 38) + fillColor = Color(56, 158, 38, 122) + } + dataset { + values = generate { + count = 7 + function = { i -> i - count / 2.toDouble()} + } + } + } + grid { + xLines = generator(1) + yLines = generator(1.0) + yOrigin = 0.0 + xPainter { + label = "Test" + } + } +} + +fun performance() = barChart { + val format: (Double) -> String = { v -> "%.2f".format(v) } + val labels = listOf("macOS Mojave 10.14.6", "Ubuntu 16.04", "Windows 10") + ranges { + yMax = 15.0 + yMin = 0.0 + } + space = 1 + gap = 80 + margins { + top = 30 + bottom = 40 + left = 40 + right = 40 + } + datasets { + dataset { + showValues = format + label = "2019.1" + values = enumerator(8.57, 10.8, 12.53) + lineColor = null + fillColor = ColorUtil.fromHex("999999") + } + dataset { + showValues = format + label = "2019.2" + values = enumerator(7.14, 10.0, 10.42) + lineColor = null + fillColor = ColorUtil.fromHex("262525") + } + dataset { + showValues = format + label = "2019.3" + lineColor = null + values = enumerator(5.42, 6.24, 7.18) + fillColor = ColorUtil.fromHex("1255CC") + } + } + grid { + yLines = generator(5.0) + yPainter { + label = "%.0fs".format(value) + majorLine = 0.0 == value + horizontalAlignment = SwingUtilities.LEFT + verticalAlignment = SwingUtilities.CENTER + } + xLines = generator(1) + xPainter { + label = labels[value] + paintLine = false + } + xOrigin = 0 + } +} + +fun generate() = barChart { + dataset { + values = generate { + count = 180 + function = { i -> cos(Math.toRadians(i.toDouble())).toFloat() } + } + } +} + + +fun main() { + UIManager.setLookAndFeel(IntelliJLaf()) + SwingUtilities.invokeLater { + object: DialogWrapper(false) { + init { + init() + isModal = false + setUndecorated(false) + title = "Bar Chart Kotlin Demo" + } + + override fun createCenterPanel(): JComponent = JBTabbedPane().apply { + preferredSize = Dimension(640, 480) + tabPlacement = JTabbedPane.BOTTOM + + + add("Performance", performance().component).apply { (this as JComponent).border = JBUI.Borders.empty() } + add("Chart", simple().component).apply { (this as JComponent).border = JBUI.Borders.empty() } + add("Generate", generate().component).apply { (this as JComponent).border = JBUI.Borders.empty() } + } + + override fun createActions(): Array = arrayOf() + + override fun createSouthPanel(): JComponent? = null + + override fun createContentPaneBorder(): Border = JBUI.Borders.empty() + + override fun dispose() { + super.disposeIfNeeded() + exitProcess(0) + } + }.show() + } +} \ No newline at end of file diff --git a/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartJavaTest.java b/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartJavaTest.java new file mode 100644 index 000000000000..6a7e934d104c --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartJavaTest.java @@ -0,0 +1,135 @@ +package com.intellij.ui.charts; + +import com.intellij.util.Consumer; +import org.junit.Assert; +import org.junit.Test; + +import javax.swing.*; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public class LineChartJavaTest { + + @Test + public void createCategoryDatasetTest() { + CategoryLineDataset dataset = LineDataset.of(-1.0, 0.0, 1.0, 2.0, 3.0); + + Iterable> data = dataset.getData(); + Iterator> iterator = data.iterator(); + + Assert.assertTrue(data instanceof ArrayList); + Assert.assertEquals(Coordinates.of(0, -1.0), iterator.next()); + Assert.assertEquals(Coordinates.of(1, 0.0), iterator.next()); + Assert.assertEquals(Coordinates.of(2, 1.0), iterator.next()); + Assert.assertEquals(Coordinates.of(3, 2.0), iterator.next()); + Assert.assertEquals(Coordinates.of(4, 3.0), iterator.next()); + Assert.assertFalse(iterator.hasNext()); + } + + @Test + public void createXYDatasetTest() { + Integer[] xs = {-1, 0, 1}; + Double[] ys = {-100.0, 0.0, 100.0}; + XYLineDataset dataset = LineDataset.of(xs, ys); + + Iterable> data = dataset.getData(); + Iterator> iterator = data.iterator(); + + Assert.assertTrue(data instanceof ArrayList); + Assert.assertEquals(Coordinates.of(-1, -100.0), iterator.next()); + Assert.assertEquals(Coordinates.of(0, 0.0), iterator.next()); + Assert.assertEquals(Coordinates.of(1, 100.0), iterator.next()); + Assert.assertFalse(iterator.hasNext()); + } + + @Test + public void fastCreateLineChartTest() { + CategoryLineChart chart = LineChart.of(1.0, 2.0, 3.0, 4.0, 5.0); + CategoryLineDataset negativeValues = LineDataset.of(-1.0, -2.0, -3.0, -4.0, -5.0); + chart.getDatasets().add(negativeValues); + MinMax xy = chart.findMinMax(); + + Assert.assertEquals(2, chart.getDatasets().size()); + Assert.assertTrue(chart.getComponent() instanceof JComponent); + + Assert.assertEquals(Integer.valueOf(0), xy.xMin); + Assert.assertEquals(Integer.valueOf(4), xy.xMax); + Assert.assertEquals(Double.valueOf(-5.0), xy.yMin); + Assert.assertEquals(Double.valueOf(5.0), xy.yMax); + } + + @Test + public void gridLineChartTest() { + Double[] doubles = {1.0, 2.0, 3.0, 4.0, 5.0}; + CategoryLineChart chart = LineChart.of(doubles); + AtomicInteger counter = new AtomicInteger(); + + final Consumer> common = gridline -> { + MinMax xy = gridline.getXY(); + Assert.assertEquals(Integer.valueOf(0), xy.xMin); + Assert.assertEquals(Integer.valueOf(4), xy.xMax); + Assert.assertEquals(Double.valueOf(1.0), xy.yMin); + Assert.assertEquals(Double.valueOf(5.0), xy.yMax); + + // mutations: + xy.setXMin(-1); + xy.setXMax(60000); + xy.setYMin(-1.0); + xy.setYMax(-60000.0); + + Assert.assertEquals(doubles[counter.getAndIncrement()], gridline.getValue()); + }; + + chart.getGrid().setXPainter(gridline -> { + Assert.assertEquals(Integer.valueOf(counter.getAndIncrement()), gridline.getValue()); + common.consume(gridline); + }); + counter.set(0); + chart.getGrid().setYPainter(gridline -> { + Assert.assertEquals(doubles[counter.getAndIncrement()], gridline.getValue()); + common.consume(gridline); + }); + } + + @Test + public void dynamicUpdateTest() { + XYLineDataset maxValuesDataset = new XYLineDataset<>(); + maxValuesDataset.setLabel("MAX VALUES"); + + XYLineDataset minValuesDataset = new XYLineDataset<>(); + minValuesDataset.setLabel("MIN VALUES"); + + XYLineChart chart = LineChart.of(maxValuesDataset, minValuesDataset); + + Assert.assertFalse(chart.getDataset("MAX VALUES").getData().iterator().hasNext()); + Assert.assertFalse(chart.getDataset("MIN VALUES").getData().iterator().hasNext()); + + for (int i = 1; i < 1000; i++) { + chart.getDataset("MAX VALUES").add(Coordinates.of( Double.valueOf(i), Double.valueOf(i))); + Assert.assertEquals(i, ((List) chart.getDataset("MAX VALUES").getData()).size()); + Assert.assertFalse(chart.getDataset("MIN VALUES").getData().iterator().hasNext()); + } + } + + @Test + public void customGeneratorDataset() { + XYLineChart chart = LineChart.of(new XYLineDataset<>()); + ChartUtils.generator(45).prepare(-360, 360).forEach(value -> { + chart.getDataset().add(Coordinates.of(value, Math.sin(Math.toRadians(value)))); + }); + + MinMax xy = chart.findMinMax(); + Assert.assertEquals(Integer.valueOf(-360), xy.xMin); + Assert.assertEquals(Integer.valueOf(360), xy.xMax); + + Iterator> iterator = chart.getDataset().getData().iterator(); + for (int i = -360; i <= 360; i += 45) { + Coordinates next = iterator.next(); + Assert.assertEquals(Integer.valueOf(i), next.getX()); + Assert.assertEquals(Double.valueOf(Math.sin(Math.toRadians(i))), next.getY()); + } + Assert.assertFalse(iterator.hasNext()); + } +} diff --git a/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinDemo.kt b/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinDemo.kt new file mode 100644 index 000000000000..f03039c49eac --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinDemo.kt @@ -0,0 +1,412 @@ +package com.intellij.ui.charts + +import com.intellij.ide.ui.laf.IntelliJLaf +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.charts.* +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBTabbedPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import java.awt.Graphics2D +import java.util.function.BiConsumer +import javax.swing.* +import javax.swing.border.Border +import kotlin.math.* +import kotlin.random.Random +import kotlin.system.exitProcess + +fun sin() = lineChart { + + datasets { + dataset { + lineColor = JBColor.BLUE + stepped = LineStepped.NONE + label = "Discrete (step = 1)" + borderPainted = true + generate { + x = -360..360 step 1 + y = { sin(Math.toRadians(it.toDouble())) } + } + } + dataset { + lineColor = JBColor.BLACK + stepped = LineStepped.NONE + label = "Discrete (step = 45)" + borderPainted = true + generate { + x = -360..360 step 45 + y = { sin(Math.toRadians(it.toDouble())) } + } + } + dataset { + lineColor = JBColor.RED + stepped = LineStepped.NONE + label = "Smooth (step = 45)" + borderPainted = true + smooth = true + generate { + x = -360..360 step 45 + y = { sin(Math.toRadians(it.toDouble())) } + } + } + overlays = listOf(LabelOverlay()) + ranges { + yMin = -1.25 + yMax = 1.25 + } + grid { + xLines = generator(90) + xOrigin = 0 + xPainter { + label = "%d".format(value) + majorLine = value == 0 + } + + yLines = generator(0.25) + yOrigin = 0.0 + yPainter { + label = "%.2f".format(value) + majorLine = value == 0.0 + horizontalAlignment = SwingConstants.RIGHT + verticalAlignment = SwingConstants.TOP + } + } + } + +} + +fun many() = lineChart { + dataset { + generate { + x = generator(1.0).prepare(0.0, 500_000.0) + y = { x -> x.pow(3) } + } + } +} + +fun trivials() = lineChart { + val values = generator(0.01).prepare(0.0, 8.0) + + ranges { + xMin = 0.0 + xMax = 8.0 + yMin = 0.0 + yMax = 8.0 + } + datasets { + dataset { + generate { + x = values + y = 2.0::pow + } + lineColor = JBColor.GREEN + } + dataset { + generate { + x = values + y = { it.pow(2.0) } + } + lineColor = JBColor.ORANGE + } + dataset { + generate { + x = values + y = { it } + } + lineColor = JBColor.RED + } + dataset { + generate { + x = values + y = { log2(it) * it } + } + lineColor = JBColor.BLUE + } + dataset { + generate { + x = values + y = ::log2 + } + lineColor = JBColor.MAGENTA + } + } + grid { + xLines = generator(1.0) + yLines = generator(1.0) + } +} + +fun stepped() = lineChart { + dataset { + values = enumerator(1, 2, 1, 4, 3, 3, 2, 5, 3, 2, 5, 2, 1, 1, 2) + stepped = LineStepped.AFTER + fillColor = Color.GRAY + } + ranges { + yMin = 0 + yMax = 6 + } + grid { + xLines = generator(1) + yLines = generator(1) + } +} + +enum class YEAR { + JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC +} + +private fun simpleCategoryLineChart() = lineChart { + + margins { + bottom = 100 + } + datasets { + dataset { + values = listOf(1.0, 2.0, 3.0, 2.0, 3.0, 1.0, 1.0) + lineColor = Color.BLUE + } + dataset { + values = listOf(1.0, 2.0, 3.0, 2.0, 3.0, 1.0, 1.0) + smooth = true + lineColor = Color.RED + } + } + grid { + xLines = generator(1) + xPainter { + label = YEAR.values()[value].name + horizontalAlignment = SwingUtilities.CENTER + } + } +} + +private fun simpleXYLineChart() = lineChart { + datasets { + dataset { + data = listOf(1.0 to 1.0, 2.0 to 2.0, 2.5 to 3.0) + } + dataset { + data = listOf(1.0 to 1.0, 2.0 to 2.0, 3.0 to 1.0) + smooth = true + } + } +} + +private fun funcXYLineChart() = lineChart { + datasets { + // Generated values + dataset { + generate { + x = listOf(0.0, 1.0, 2.0, 3.0) + y = ::sin + } + } + dataset { + values { + x = generator(0.1).prepare(0.0, 0.3) + y = enumerator(0.0, 0.01, 0.002, 0.0003) + } + } + } +} + +fun areaChart() = lineChart { + val years = generator(5).prepare(1700, 1780).toList() + margins { + top = 40 + right = 40 + left = 40 + bottom = 30 + } + ranges { + yMin = 0 + yMax = 200 + } + grid { + xOrigin = 1780 + xLines = generator(10) + xPainter { + label = "%d".format(value) + verticalAlignment = SwingConstants.BOTTOM + horizontalAlignment = SwingConstants.CENTER + } + yLines = generator(10) + yPainter { + majorLine = value == 100 + label = "%d".format(value) + verticalAlignment = SwingConstants.CENTER + horizontalAlignment = SwingConstants.RIGHT + } + } + overlays = listOf( + object: Overlay() { + override fun paintComponent(g: Graphics2D) { + g.color = JBColor.foreground() + val str = "Exports and Imports to and from DENMARK & NORWAY from 1700 to 1780" + val w = g.fontMetrics.stringWidth(str) + g.drawString(str, (chart.width - w) / 2, 30) + } + } + ) + datasets { + dataset { + stacked = true + lineColor = Color(0, 0, 0, 0) + smooth = true + generate { + x = years + y = { min(get("Imports").find(it)!!, get("Exports").find(it)!!) } + } + } + dataset { + label = "Imports" + lineColor = ColorUtil.fromHex("E7D8B6") + fillColor = ColorUtil.fromHex("F8D9D8").transparent(0.5) + smooth = true + values { + x = years + y = listOf(70, 75, 80, 90, 100, 102, 98, 92, 92, 91, 90, 80, 77, 80, 85, 90, 90) + } + } + dataset { + label = "Exports" + lineColor = ColorUtil.fromHex("F8D9D8") + fillColor = ColorUtil.fromHex("E7D8B6").transparent(0.5) + smooth = true + values { + x = years + y = listOf(35, 43, 60, 80, 75, 70, 60, 60, 63, 72, 79, 80, 110, 150, 160, 180, 185, 190) + } + } + } +} + +fun zoom() = lineChart { + val values = 1..200 + val random = values.map { Random.nextInt(it).toDouble() } + dataset { + values { + x = values + y = random + } + grid { + xMin = 1 + xMax = 200 + yMin = 0.0 + yMax = 200.0 + } + } + overlays = listOf(DragOverlay(BiConsumer { start, end -> + val chart = this@lineChart + val chartWidth = chart.width - chart.margins.left - chart.margins.right + val chartHeight = chart.height - chart.margins.top - chart.margins.bottom + val upperLeftX = min(start.x, end.x) + val upperLeftY = min(start.y, end.y) + val bottomRightX = max(start.x, end.x) + val bottomRightY = max(start.y, end.y) + + val startXRatio = upperLeftX / chartWidth.toDouble() + val startYRatio = upperLeftY / chartHeight.toDouble() + val endXRatio = bottomRightX / chartWidth.toDouble() + val endYRatio = bottomRightY / chartHeight.toDouble() + + chart.grid { + val (xm, xx, ym, yx) = findMinMax() + xMin = floor((xx - xm) * startXRatio + xm).toInt() + xMax = ceil((xx - xm) * endXRatio + xm).toInt() + yMax = (yx - ym) * (1 - startYRatio) + ym + yMin = (yx - ym) * (1 - endYRatio) + ym + } + chart.update() + })) +} + +fun main() { + UIManager.setLookAndFeel(IntelliJLaf()) + SwingUtilities.invokeLater { + object: DialogWrapper(false) { + init { + init() + isModal = false + setUndecorated(false) + title = "Line Chart Kotlin Demo" + } + + override fun createCenterPanel(): JComponent = JBTabbedPane().apply { + preferredSize = Dimension(640, 480) + tabPlacement = JTabbedPane.BOTTOM + + fun Component.setEmptyBorders() = this.apply { + (this as JComponent).border = JBUI.Borders.empty() + } + + add("Sin", sin().component).setEmptyBorders() + add("Trivials", trivials().component).setEmptyBorders() + add("Many", many().component).setEmptyBorders() + add("Stepped", stepped().component).setEmptyBorders() + add("Category", simpleCategoryLineChart().component).setEmptyBorders() + add("XY", simpleXYLineChart().component).setEmptyBorders() + add("Func", funcXYLineChart().component).setEmptyBorders() + add("Import/Export", areaChart().component).setEmptyBorders() + + val zoomPane = BorderLayoutPanel() + val zoom = zoom() + zoomPane.addToCenter(zoom.component) + val box = Box(BoxLayout.X_AXIS) + box .add(JButton("Zoom in (+)").apply { + addActionListener { + val xChange = (zoom.grid.xMax - zoom.grid.xMin) / 4 + val yChange = (zoom.grid.yMax - zoom.grid.yMin) / 4 + zoom.grid.xMin += xChange + zoom.grid.xMax -= xChange + zoom.grid.yMin += yChange + zoom.grid.yMax -= yChange + zoom.update() + } + }) + box .add(JButton("Zoom out (-)").apply { + addActionListener { + val xChange = (zoom.grid.xMax - zoom.grid.xMin) / 2 + val yChange = (zoom.grid.yMax - zoom.grid.yMin) / 2 + zoom.grid.xMin -= xChange + zoom.grid.xMax += xChange + zoom.grid.yMin -= yChange + zoom.grid.yMax += yChange + zoom.update() + } + }) + box.add(JButton("Reset").apply { + val (myXMin, myXMax, myYMin, myYMax) = zoom.grid + addActionListener { + zoom.grid { + xMin = myXMin + xMax = myXMax + yMin = myYMin + yMax = myYMax + } + zoom.update() + } + }) + box.alignmentX = 0.0f + zoomPane.addToBottom(box) + add("Zoomed", zoomPane).setEmptyBorders() + } + + override fun createActions(): Array = arrayOf() + + override fun createSouthPanel(): JComponent? = null + + override fun createContentPaneBorder(): Border = JBUI.Borders.empty() + + override fun dispose() { + super.disposeIfNeeded() + exitProcess(0) + } + }.show() + } +} \ No newline at end of file diff --git a/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinTest.kt b/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinTest.kt new file mode 100644 index 000000000000..cc595ca580a6 --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/ui/charts/LineChartKotlinTest.kt @@ -0,0 +1,103 @@ +package com.intellij.ui.charts + +import com.intellij.ui.charts.* +import com.intellij.util.ui.ImageUtil +import org.junit.Assert +import org.junit.Test +import java.awt.Dimension +import java.awt.image.BufferedImage +import kotlin.math.roundToInt + +class LineChartKotlinTest { + @Test + fun simpleCreation() { + val chart = lineChart { + datasets { + dataset { + values { + x = listOf(2, 3, 4) + y = listOf(1, 2, 3) + } + } + } + } + + val xy = chart.findMinMax() + + Assert.assertEquals(2, xy.xMin) + Assert.assertEquals(4, xy.xMax) + Assert.assertEquals(1, xy.yMin) + Assert.assertEquals(3, xy.yMax) + } + + @Test + fun checkDoubleCreation() { + val chart = lineChart { + datasets { + dataset { + generate { + x = generator(0.01).prepare(0.0, 100.0) + y = { it } + } + } + } + } + + val xy = chart.findMinMax() + + Assert.assertEquals(0.0, xy.xMin, 1e-6) + Assert.assertEquals(100.0, xy.xMax, 1e-6) + Assert.assertEquals(0.0, xy.yMin, 1e-6) + Assert.assertEquals(100.0, xy.yMax, 1e-6) + } + + @Test + fun testLinearChart() { + val size = 1000 + val chart = lineChart { + dataset { + generate { + x = generator(10.0 / size).prepare(0.0, size / 10.0) + y = { it } + } + } + } + val img = ImageUtil.createImage(size, size, BufferedImage.TYPE_INT_RGB) + chart.component.apply { + this.size = Dimension(size, size) + invalidate() + paint(img.createGraphics()) + } + val xy = chart.findMinMax() + for (i in 0..size) { + val loc = chart.findLocation(xy, i / 10.0 to i / 10.0) + Assert.assertEquals("x fails, i = $i", i.toDouble(), loc.x, 1e-6) + Assert.assertEquals("y fails, i = $i", (size - i).toDouble(), loc.y, 1e-6) + } + } + + @Test + fun testLinearChartAndScaled() { + val size = 1000 + val chart = lineChart { + dataset { + generate { + x = generator(10.0 / size).prepare(0.0, size / 10.0) + y = { it } + } + } + } + val img = ImageUtil.createImage(size, size, BufferedImage.TYPE_INT_RGB) + chart.component.apply { + this.size = Dimension(size, (size * 1.5).roundToInt()) + invalidate() + paint(img.createGraphics()) + } + val xy = chart.findMinMax() + for (i in 0..size) { + val loc = chart.findLocation(xy, i / 10.0 to i / 10.0) + Assert.assertEquals("x fails, i = $i", i.toDouble(), loc.x, 1e-6) + Assert.assertEquals("y fails, i = $i", (size - i) * 1.5, loc.y, 1e-6) + } + } +} \ No newline at end of file