2025-08-20 18:25:24 +03:00

251 lines
7.2 KiB
Scala

package ocelot.desktop.ui.widget
import ocelot.desktop.ColorScheme
import ocelot.desktop.color.Color
import ocelot.desktop.geometry.{Padding2D, Rect2D, Size2D}
import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.event.handlers.{HoverHandler, MouseHandler}
import ocelot.desktop.ui.event.{ClickEvent, MouseEvent}
import ocelot.desktop.ui.widget.Plot.Axis
import ocelot.desktop.ui.widget.contextmenu.ContextMenu
import ocelot.desktop.util.DrawUtils
abstract class Plot extends Widget {
private var barsMin = new Array[Float](0)
private var barsMax = new Array[Float](0)
def xAxis: Axis
def yAxis: Axis
def points: (Array[Float], Array[Float])
def fill: Boolean = false
def setupContextMenu(menu: ContextMenu): Unit = {}
def onPanelLeftClick(): Unit = {}
def onPanelRightClick(): Unit = {
val menu = new ContextMenu
setupContextMenu(menu)
root.get.contextMenus.open(menu)
}
private val panel = new Widget with HoverHandler with MouseHandler {
override protected def receiveClickEvents: Boolean = true
eventHandlers += {
case ClickEvent(MouseEvent.Button.Left, _) => onPanelLeftClick()
case ClickEvent(MouseEvent.Button.Right, _) => onPanelRightClick()
}
override def minimumSize: Size2D = Size2D(50, 50)
override def maximumSize: Size2D = Size2D.Inf
override def draw(g: Graphics): Unit = {
resample(bounds)
val fillColor = ColorScheme("PlotFill")
val gridColor = ColorScheme("PlotGrid")
val lineColor = ColorScheme("PlotLine")
DrawUtils.panel(g, bounds.x - 2, bounds.y - 2, bounds.w + 4, bounds.h + 4)
for (tickX <- xAxis.ticks) {
val x = xAxis.mapPos(tickX, bounds.w).floor
g.line(bounds.x + x, bounds.y, bounds.x + x, bounds.y + bounds.h, 1, gridColor)
}
for (tickY <- yAxis.ticks) {
val y = yAxis.mapPos(tickY, bounds.h).floor + 0.5f
g.line(bounds.x, bounds.y + y, bounds.x + bounds.w, bounds.y + y, 1, gridColor)
}
if (fill) {
for ((max, x) <- barsMax.zipWithIndex) {
g.rect(bounds.x + x, bounds.y + max, 1, bounds.h - max, fillColor)
}
}
for (((min, max), x) <- barsMin.zip(barsMax).zipWithIndex) {
g.rect(bounds.x + x, bounds.y + min, 1, (max - min).max(1), lineColor)
}
}
}
children :+= new PaddingBox(
panel,
Padding2D(
left = 4,
right = if (yAxis.ticks.isEmpty || !xAxis.tickLabels) 4 else yAxis.tickWidth * 7 + 6,
top = 4,
bottom = if (xAxis.ticks.isEmpty || !xAxis.tickLabels) 4 else 14,
),
)
override def draw(g: Graphics): Unit = {
val xAxis = this.xAxis
val yAxis = this.yAxis
val pBounds = panel.bounds
panel.draw(g)
g.setSmallFont()
g.background = Color.Transparent
g.foreground = ColorScheme("PlotText")
if (xAxis.tickLabels) {
for (tickX <- xAxis.ticks) {
val x = xAxis.mapPos(tickX, pBounds.w).floor
val text = xAxis.tickFormatter(tickX)
g.text(pBounds.x + x - (text.length * 3.5f).floor, pBounds.y + pBounds.h + 4, text, shrink = 1)
}
}
if (yAxis.tickLabels) {
for (tickY <- yAxis.ticks) {
val y = yAxis.mapPos(tickY, pBounds.h).floor + 2
val text = yAxis.tickFormatter(tickY)
g.text(pBounds.x + pBounds.w + 4, pBounds.y + y - 4, text, shrink = 1)
}
}
if (panel.mouseOver) {
g.foreground = ColorScheme("PlotLine")
val pos = UiHandler.mousePosition
val x = xAxis.inversePos(pos.x - pBounds.x, pBounds.w)
val y = yAxis.inversePos(pos.y - pBounds.y, pBounds.h)
val text = f"${xAxis.hoverFormatter(x)}${xAxis.unit}; ${yAxis.hoverFormatter(y)}${yAxis.unit}"
g.text(pBounds.x + 1, pBounds.y + 1, text, shrink = 1)
}
g.setNormalFont()
}
private var mappedX: Array[Float] = new Array(0)
private var mappedY: Array[Float] = new Array(0)
private def resample(pBounds: Rect2D): Unit = {
val xAxis = this.xAxis
val yAxis = this.yAxis
val numBars = pBounds.w.toInt
if (barsMin.length != numBars) {
barsMin = new Array(numBars)
barsMax = new Array(numBars)
}
val points = this.points
val numPoints = points._1.length
assert(points._1.length == points._2.length)
if (mappedX.length != numPoints) {
mappedX = new Array(numPoints)
mappedY = new Array(numPoints)
}
var i = 0
while (i < numPoints) {
mappedX(i) = xAxis.mapPos(points._1(i), pBounds.w)
mappedY(i) = yAxis.mapPos(points._2(i), pBounds.h)
i += 1
}
var pointIdx = 0
var barIdx = 0
while (barIdx < numBars) {
var min = Float.PositiveInfinity
var max = Float.NegativeInfinity
var count = 0
while (pointIdx < numPoints && mappedX(pointIdx) <= barIdx) {
val y = mappedY(pointIdx)
if (y < min) min = y
if (y > max) max = y
pointIdx += 1
count += 1
}
val savedIdx = pointIdx
while (pointIdx < numPoints && mappedX(pointIdx) - barIdx <= 0.5f) {
val y = mappedY(pointIdx)
if (y < min) min = y
if (y > max) max = y
pointIdx += 1
count += 1
}
pointIdx = savedIdx
if (count > 0) {
barsMin(barIdx) = min
barsMax(barIdx) = max
} else {
val ax = mappedX(pointIdx - 1)
val ay = mappedY(pointIdx - 1)
val bx = if (pointIdx < numPoints) mappedX(pointIdx) else ax
val by = if (pointIdx < numPoints) mappedY(pointIdx) else ay
val t = (bx - barIdx) / (bx - ax)
val s = ay * t + by * (1 - t)
barsMin(barIdx) = s
barsMax(barIdx) = s
}
if (barIdx >= 1) {
if (barsMax(barIdx - 1) < barsMin(barIdx))
barsMax(barIdx - 1) = barsMin(barIdx)
if (barsMin(barIdx - 1) > barsMax(barIdx))
barsMax(barIdx) = barsMin(barIdx - 1)
}
barIdx += 1
}
}
}
object Plot {
case class Axis(min: Float, max: Float, ticks: List[Float] = List(),
flip: Boolean = false, logOffset: Option[Float] = None,
tickFormatter: Float => String = _.toString,
tickLabels: Boolean = true,
hoverFormatter: Float => String = _.toString,
unit: String = "") {
lazy val tickWidth: Int = if (ticks.isEmpty || !tickLabels) 0 else ticks.map(tickFormatter(_).length).max
def mapPos(pos: Float, size: Float): Float = {
var t = if (logOffset.isDefined) {
val o = logOffset.get
val logPos = math.log(pos + o).toFloat
val logMin = math.log(min + o).toFloat
val logMax = math.log(max + o).toFloat
(logPos - logMin) / (logMax - logMin)
} else {
(pos - min) / (max - min)
}
if (flip) t = 1 - t
if (t > 1) t = 1
if (t < 0) t = 0
t * size
}
def inversePos(pos: Float, size: Float): Float = {
var t = pos / size
if (flip) t = 1 - t
if (logOffset.isDefined) {
val o = logOffset.get
val logMin = math.log(min + o).toFloat
val logMax = math.log(max + o).toFloat
val logPos = t * (logMax - logMin) + logMin
(math.exp(logPos) - o).toFloat
} else {
t * (max - min) + min
}
}
}
}