Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion awt/src/main/scala/org/nspl/awt.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.nspl

import java.awt.Graphics2D
import java.awt.font.TextAttribute

import JavaFontConversion._

Expand Down Expand Up @@ -101,11 +102,52 @@ object awtrenderer extends JavaAWTUtil {
implicit val textRenderer: Renderer[TextBox, JavaRC] =
new Renderer[TextBox, JavaRC] {

private def deriveFont(
base: java.awt.Font,
bold: Boolean,
oblique: Boolean,
subScript: Boolean,
superScript: Boolean,
underline: Boolean
): java.awt.Font = {
if (!bold && !oblique && !subScript && !superScript && !underline) base
else {
val map =
new java.util.HashMap[
java.awt.font.TextAttribute,
java.lang.Object
]()
if (bold) {
map.put(TextAttribute.WEIGHT, TextAttribute.WEIGHT_EXTRABOLD)
}
if (oblique) {
map.put(TextAttribute.POSTURE, TextAttribute.POSTURE_OBLIQUE)
}
if (underline) {
map.put(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON)
}
if (subScript && !superScript) {
map.put(TextAttribute.SUPERSCRIPT, TextAttribute.SUPERSCRIPT_SUB)
} else if (superScript && !subScript) {
map.put(TextAttribute.SUPERSCRIPT, TextAttribute.SUPERSCRIPT_SUPER)
}
base.deriveFont(map)
}
}

def render(ctx: JavaRC, elem: TextBox): Unit = {
if (!elem.layout.isEmpty && elem.color.a > 0) {
ctx.withTransform(elem.tx) {
ctx.withPaint(elem.color) {
val jfont = font2font(elem.font)
val baseFont = font2font(elem.font)
val jfont = deriveFont(
baseFont,
elem.bold,
elem.oblique,
elem.subScript,
elem.superScript,
elem.underline
)
if (!ctx.textAsShapes) {
ctx.graphics.setFont(jfont)
}
Expand Down
135 changes: 135 additions & 0 deletions awt/src/test/scala/interaction.test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package org.nspl

import org.nspl.awtrenderer._

class InteractionSpec extends munit.FunSuite {

/** Build a square xyplot covering the world rectangle (0..10, 0..10). */
private def squarePlot(): (XYPlotArea, Build[XYPlotArea]) = {
val data = dataSourceFromRows(
Seq((0d, 0d), (5d, 5d), (10d, 10d))
)
val build = xyplotareaBuild(
List(data -> List(point(noIdentifier = true))),
AxisSettings(LinearAxisFactory),
AxisSettings(LinearAxisFactory),
xlim = Some(0d -> 10d),
ylim = Some(0d -> 10d),
// disable padding margins so xMin/xMax = 0/10 exactly
xAxisMargin = 0d,
yAxisMargin = 0d
)
(build.build, build)
}

test("Selection zooms to the world rectangle under the selection box") {
val (initial, build) = squarePlot()
assertEqualsDouble(initial.xMin, 0d, 0d)
assertEqualsDouble(initial.xMax, 10d, 0d)
assertEqualsDouble(initial.yMin, 0d, 0d)
assertEqualsDouble(initial.yMax, 10d, 0d)

val plotAreaId = initial.frameElem.identifier match {
case p: PlotAreaIdentifier => p
case other => fail(s"frame elem had identifier $other")
}
// Pretend the plot area was rendered in a 100x100 screen rectangle.
val withBounds = plotAreaId.copy(bounds = Some(Bounds(0, 0, 100, 100)))

val sel = Selection(Point(25, 25), Point(75, 75), withBounds)
val next = build((Some(initial), sel))

assertEqualsDouble(next.xMin, 2.5, 1e-9)
assertEqualsDouble(next.xMax, 7.5, 1e-9)
assertEqualsDouble(next.yMin, 2.5, 1e-9)
assertEqualsDouble(next.yMax, 7.5, 1e-9)
}

test("Selection corners may be specified in either order") {
val (initial, build) = squarePlot()
val plotAreaId = initial.frameElem.identifier
.asInstanceOf[PlotAreaIdentifier]
.copy(bounds = Some(Bounds(0, 0, 100, 100)))
val sel = Selection(Point(75, 75), Point(25, 25), plotAreaId)
val next = build((Some(initial), sel))
assertEqualsDouble(next.xMin, 2.5, 1e-9)
assertEqualsDouble(next.xMax, 7.5, 1e-9)
assertEqualsDouble(next.yMin, 2.5, 1e-9)
assertEqualsDouble(next.yMax, 7.5, 1e-9)
}

test("Selection on a different plot area is ignored") {
val (initial, build) = squarePlot()
val otherId = PlotAreaIdentifier(new PlotId, Some(Bounds(0, 0, 100, 100)))
val sel = Selection(Point(25, 25), Point(75, 75), otherId)
val next = build((Some(initial), sel))
// unchanged
assertEqualsDouble(next.xMin, initial.xMin, 0d)
assertEqualsDouble(next.xMax, initial.xMax, 0d)
assertEqualsDouble(next.yMin, initial.yMin, 0d)
assertEqualsDouble(next.yMax, initial.yMax, 0d)
}

test("Degenerate (single-point) Selection is rejected") {
val (initial, build) = squarePlot()
val plotAreaId = initial.frameElem.identifier
.asInstanceOf[PlotAreaIdentifier]
.copy(bounds = Some(Bounds(0, 0, 100, 100)))
val sel = Selection(Point(50, 50), Point(50, 50), plotAreaId)
val next = build((Some(initial), sel))
assertEqualsDouble(next.xMin, initial.xMin, 0d)
assertEqualsDouble(next.xMax, initial.xMax, 0d)
}

test("Scroll event zooms (negative delta in, positive delta out)") {
val (initial, build) = squarePlot()
val plotAreaId = initial.frameElem.identifier
.asInstanceOf[PlotAreaIdentifier]
.copy(bounds = Some(Bounds(0, 0, 100, 100)))
val before = initial.xMax - initial.xMin

val zin = build((Some(initial), Scroll(-1d, Point(50, 50), plotAreaId)))
val zout = build((Some(initial), Scroll(1d, Point(50, 50), plotAreaId)))

assert(
zin.xMax - zin.xMin < before,
s"expected zoom-in: range=${zin.xMax - zin.xMin} >= $before"
)
assert(
zout.xMax - zout.xMin > before,
s"expected zoom-out: range=${zout.xMax - zout.xMin} <= $before"
)
}

test("ShapeElem.withIdentifier round-trips") {
val id = DataRowIdx(0, 0, 7)
val s = ShapeElem(Shape.circle(1d)).withIdentifier(id)
assertEquals(s.identifier, id: Identifier)
}

test("TextBox.withIdentifier and apply identifier round-trip") {
val id = TextBoxIdentifier("legend", 3)
val t1 = TextBox("hi").withIdentifier(id)
val t2 = TextBox("hi", identifier = id)
assertEquals(t1.identifier, id: Identifier)
assertEquals(t2.identifier, id: Identifier)
}

test("DataRowIdx and TextBoxIdentifier are distinct types") {
val data: Identifier = DataRowIdx(1, 2, 3)
val text: Identifier = TextBoxIdentifier("a", 0)
// Compile-time test: pattern matching disambiguates.
val dataKind = data match {
case _: DataRowIdx => "data"
case _: TextBoxIdentifier => "text"
case _ => "other"
}
val textKind = text match {
case _: DataRowIdx => "data"
case _: TextBoxIdentifier => "text"
case _ => "other"
}
assertEquals(dataKind, "data")
assertEquals(textKind, "text")
}
}
132 changes: 132 additions & 0 deletions awt/src/test/scala/render.test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package org.nspl

import org.nspl.awtrenderer._
import org.nspl.data._
import java.awt.image.BufferedImage
import java.awt.{Color => AwtColor, RenderingHints}

class RenderSmokeSpec extends munit.FunSuite {

private def renderToImage[K <: Renderable[K]](
elem: K,
width: Int = 400
)(implicit er: Renderer[K, JavaRC]): BufferedImage = {
val aspect = elem.bounds.h / elem.bounds.w
val height = math.max((width * aspect).toInt, 1)
val img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val g2d = img.createGraphics()
g2d.setRenderingHint(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON
)
g2d.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON
)
g2d.setColor(AwtColor.WHITE)
g2d.fillRect(0, 0, width, height)
val rc = new JavaRC(g2d, doRender = true)
rc.render(fitToBounds(elem, Bounds(0, 0, width, height)))
img
}

private def hasInk(img: BufferedImage): Boolean = {
val w = img.getWidth
val h = img.getHeight
var y = 0
while (y < h) {
var x = 0
while (x < w) {
val rgb = img.getRGB(x, y)
val r = (rgb >>> 16) & 0xff
val g = (rgb >>> 8) & 0xff
val b = rgb & 0xff
if (r < 240 || g < 240 || b < 240) return true
x += 1
}
y += 1
}
false
}

test("scatter plot renders without error and produces non-blank output") {
val data = (1 to 100).map(i => (i.toDouble, i.toDouble * 0.5))
val p = xyplot(data -> point())().build
val img = renderToImage(p)
assert(hasInk(img), "rendered image is blank")
assertEquals(img.getWidth, 400)
}

test("line plot renders without error") {
val data = (1 to 50).map(i => (i.toDouble, math.sin(i * 0.2)))
val p = xyplot(data -> line())().build
assert(hasInk(renderToImage(p)))
}

test("bar plot on a log y-axis renders (regression: would crash on worldToView(0))") {
val data = (1 to 5).map(i => (i.toDouble, math.pow(10d, i.toDouble)))
val p = xyplot(
data -> bar(width = 0.6)
)(par.ylog(true)).build
// Just exercise the render — the bug it guards against was a
// RuntimeException("<0") from log10(0) in bar()'s width calculation.
val img = renderToImage(p)
assert(hasInk(img))
}

test("error bars actually pick up the errorBarColor (regression for fill/stroke bug)") {
// Magenta error bars should appear as magenta-ish pixels in the
// output. The bug was that errorBarColor was being passed as `fill`
// on a stroked line (which is invisible), so the bars always rendered
// black. We just verify some pixels carry the requested color.
val data = Seq(
(1d, 5d, 0d, 0d, 0d, 7d, 3d), // x, y, color, size, shape, errTop, errBot
(2d, 5d, 0d, 0d, 0d, 7d, 3d),
(3d, 5d, 0d, 0d, 0d, 7d, 3d)
)
val magenta = Color(255, 0, 255, 255)
val p = xyplot(
data -> point(
size = 10d,
color = Color.black,
errorBarColor = magenta,
errorBarStroke = StrokeConf(0.5 fts)
)
)(par.xlim(Some(0d -> 4d)).ylim(Some(0d -> 10d))).build
val img = renderToImage(p, width = 600)
// Look for any pixel where R is much higher than G — magenta.
var found = false
val w = img.getWidth
val h = img.getHeight
var y = 0
while (y < h && !found) {
var x = 0
while (x < w && !found) {
val rgb = img.getRGB(x, y)
val r = (rgb >>> 16) & 0xff
val gg = (rgb >>> 8) & 0xff
val bb = rgb & 0xff
if (r > 150 && bb > 150 && gg < 100) found = true
x += 1
}
y += 1
}
assert(found, "no magenta error-bar pixels were rendered")
}

test("rendered image dimensions are sane for an extreme aspect ratio") {
// Long narrow data shouldn't produce a zero-height image.
val data = (1 to 200).map(i => (i.toDouble, math.sin(i * 0.1) * 0.001))
val p = xyplot(data -> line())().build
val img = renderToImage(p, width = 800)
assert(img.getHeight > 0, s"got height ${img.getHeight}")
}

test("histogram of constant data does not divide by zero") {
// Regression check for histogram code paths.
val hist = HistogramData(Seq.fill(20)(3.5), 1.0)
val d = hist.density
// Should not throw and should return *some* HistogramData.
assert(d.bins.nonEmpty || hist.bins.isEmpty)
}
}
Loading
Loading