diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/Converters.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/Converters.kt index f3ff2387262..37213b8b91c 100644 --- a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/Converters.kt +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/Converters.kt @@ -11,18 +11,18 @@ import org.jetbrains.letsPlot.commons.values.Color import kotlin.math.* -fun rgbFromHcl(hcl: HCL, alpha: Double = 1.0): Color { +fun rgbFromHcl(hcl: HCL, opacity: Double = 1.0): Color { val luv = luvFromHcl(hcl) val xyz = xyzFromLuv(luv) val rgb = rgbFromXyz(xyz) - return rgb.changeAlpha((255 * alpha).roundToInt()) + return rgb.withOpacity(opacity) } -fun rgbFromLab(lab: LAB, alpha: Double = 1.0): Color { +fun rgbFromLab(lab: LAB, opacity: Double = 1.0): Color { val xyz = xyzFromLab(lab) val rgb = rgbFromXyz(xyz) - return rgb.changeAlpha((255 * alpha).roundToInt()) + return rgb.withOpacity(opacity) } diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/HSL.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/HSL.kt index eb186ad6793..856ab1d6735 100644 --- a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/HSL.kt +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/colorspace/HSL.kt @@ -58,7 +58,7 @@ fun hslFromRgb(rgb: Color): HSL { } -fun rgbFromHsl(hsl: HSL, alpha: Double = 1.0): Color { +fun rgbFromHsl(hsl: HSL, opacity: Double = 1.0): Color { val c = (1.0 - abs(2 * hsl.l - 1.0)) * hsl.s val h2 = hsl.h / 60 val x = c * (1 - abs(h2 % 2 - 1)) @@ -77,5 +77,5 @@ fun rgbFromHsl(hsl: HSL, alpha: Double = 1.0): Color { ((g1 + m) * 255).roundToInt(), ((b1 + m) * 255).roundToInt(), (255 * 1.0).roundToInt() - ).changeAlpha((255 * alpha).roundToInt()) + ).withOpacity(opacity) } diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Color.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Color.kt index eaa166136ee..d3238649944 100644 --- a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Color.kt +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Color.kt @@ -24,18 +24,26 @@ class Color @JvmOverloads constructor( ) { "Color components out of range: $this" } } - fun changeAlpha(newAlpha: Int): Color { + private val hexColorNoAlpha: String by lazy(LazyThreadSafetyMode.NONE) { + "#" + toHexColorPart(red) + toHexColorPart(green) + toHexColorPart(blue) + } + + private val hexColor: String by lazy(LazyThreadSafetyMode.NONE) { + if (alpha == 255) hexColorNoAlpha else hexColorNoAlpha + toHexColorPart(alpha) + } + + fun withAlpha(newAlpha: Int): Color { return Color(red, green, blue, newAlpha) } - fun changeAlpha(newAlpha: Double): Color { - val alphaInt = (newAlpha * 255).roundToInt() - return changeAlpha(alphaInt) + fun withOpacity(opacity: Double): Color { + val alpha = (opacity.coerceIn(0.0, 1.0) * 255).roundToInt() + return withAlpha(alpha) } - fun multiplyAlpha(mulAlpha: Double): Color { - val newAlpha = alpha / 255.0 * mulAlpha - return changeAlpha(newAlpha) + fun multiplyOpacity(opacity: Double): Color { + val newOpacity = alpha / 255.0 * opacity + return withOpacity(newOpacity) } override fun equals(other: Any?): Boolean { @@ -56,13 +64,12 @@ class Color @JvmOverloads constructor( } } + fun toHexColorNoAlpha(): String { + return hexColorNoAlpha + } + fun toHexColor(): String { - val rgb = "#" + toColorPart(red) + toColorPart(green) + toColorPart(blue) - if (alpha == 255) { - return rgb - } else { - return rgb + toColorPart(alpha) - } + return hexColor } override fun hashCode(): Int { @@ -243,43 +250,33 @@ class Color @JvmOverloads constructor( } fun parseRGB(text: String): Color { - val firstParen = findNext(text, "(", 0) - val prefix = text.substring(0, firstParen) - - val firstComma = findNext(text, ",", firstParen + 1) - val secondComma = findNext(text, ",", firstComma + 1) - - var thirdComma = -1 - - when { - prefix == RGBA -> thirdComma = findNext(text, ",", secondComma + 1) - prefix == COLOR -> thirdComma = text.indexOf(",", secondComma + 1) - prefix != RGB -> throw IllegalArgumentException(text) + val firstParen = text.indexOf("(") + val lastParen = text.lastIndexOf(")") + if (firstParen == -1 || lastParen == -1 || lastParen < firstParen) { + throw IllegalArgumentException("Invalid color value: $text") } - val lastParen = findNext(text, ")", thirdComma + 1) - val red = text.substring(firstParen + 1, firstComma).trim { it <= ' ' }.toInt() - val green = text.substring(firstComma + 1, secondComma).trim { it <= ' ' }.toInt() + val prefix = text.substring(0, firstParen) + val components = text.substring(firstParen + 1, lastParen).split(",").map(String::trim) - val blue: Int - val alpha: Int - if (thirdComma == -1) { - blue = text.substring(secondComma + 1, lastParen).trim { it <= ' ' }.toInt() - alpha = 255 - } else { - blue = text.substring(secondComma + 1, thirdComma).trim { it <= ' ' }.toInt() - alpha = (text.substring(thirdComma + 1, lastParen).trim { it <= ' ' }.toFloat() * 255).roundToInt() + when (prefix) { + RGB -> require(components.size == 3) { "RGB color format requires exactly 3 components: $text" } + RGBA -> require(components.size == 4) { "RGBA color format requires exactly 4 components: $text" } + COLOR -> require(components.size in 3..4) { "'color()' format requires 3 or 4 components: $text" } + else -> throw IllegalArgumentException("Unsupported RGB color format: $text") } - return Color(red, green, blue, alpha) - } + return try { + val red = components[0].toInt() + val green = components[1].toInt() + val blue = components[2].toInt() + val opacity = components.getOrNull(3)?.toFloat() ?: 1f + val alpha = (opacity * 255).roundToInt() - private fun findNext(s: String, what: String, from: Int): Int { - val result = s.indexOf(what, from) - if (result == -1) { - throw IllegalArgumentException("text=$s what=$what from=$from") + Color(red, green, blue, alpha) + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Invalid color value: $text", e) } - return result } fun parseHex(hexColor: String): Color { @@ -315,17 +312,6 @@ class Color @JvmOverloads constructor( return Color(r, g, b, a) } - private fun toColorPart(value: Int): String { - if (value < 0 || value > 255) { - throw IllegalArgumentException("RGB color part must be in range [0..255] but was $value") - } - - val result = value.toString(16) - return if (result.length == 1) { - "0$result" - } else { - result - } - } + private fun toHexColorPart(value: Int): String = value.toString(16).padStart(2, '0') } -} \ No newline at end of file +} diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Colors.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Colors.kt index 5a19136206b..be076628899 100644 --- a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Colors.kt +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/values/Colors.kt @@ -180,18 +180,35 @@ object Colors { * - rgba(r, g, b, a) * - color(r, g, b, a) * - #rrggbb + * - #rrggbbaa * - #rgb + * - #rgba * - white, green, etc. + * - steelblue / 0.35 with opacity in [0, 1] */ fun parseColor(c: String): Color { return when { c.indexOf('(') > 0 -> Color.parseRGB(c) c.startsWith("#") -> Color.parseHex(c) isColorName(c) -> forName(c) + c.contains("/") -> parseColorWithOpacity(c) else -> throw IllegalArgumentException("Error parsing color value: $c") } } + private fun parseColorWithOpacity(c: String): Color { + val components = c.split("/") + if (components.size != 2) { + throw IllegalArgumentException("Error parsing color value: $c") + } + + val color = parseColor(components[0].trim()) + val opacity = components[1].trim().toDoubleOrNull() + ?: throw IllegalArgumentException("Error parsing color value: $c") + + return color.withOpacity(opacity) + } + private fun normalizeColorName(name: String): String = name.replace("-", "") .replace("_", "") @@ -218,10 +235,10 @@ object Colors { * @param h hue, [0, 360] degree * @param s saturation, [0, 1] * @param v value, [0, 1] - * @param alpha [0, 1], 0 - transparent and 1 - opaque. + * @param opacity [0, 1], 0 - transparent and 1 - opaque. */ @JvmOverloads - fun rgbFromHsv(h: Double, s: Double, v: Double = 1.0, alpha: Double = 1.0): Color { + fun rgbFromHsv(h: Double, s: Double, v: Double = 1.0, opacity: Double = 1.0): Color { val hd = h / 60 val c = v * s val x = c * (1 - abs(hd % 2 - 1)) @@ -267,7 +284,7 @@ object Colors { (255 * (r + m)).roundToInt(), (255 * (g + m)).roundToInt(), (255 * (b + m)).roundToInt(), - (255 * alpha).roundToInt(), + (255 * opacity).roundToInt(), ) } @@ -306,17 +323,13 @@ object Colors { ) } - fun mimicTransparency(color: Color, alpha: Double, background: Color): Color { - val red = (color.red * alpha + background.red * (1 - alpha)).toInt() - val green = (color.green * alpha + background.green * (1 - alpha)).toInt() - val blue = (color.blue * alpha + background.blue * (1 - alpha)).toInt() + fun mimicTransparency(color: Color, opacity: Double, background: Color): Color { + val red = (color.red * opacity + background.red * (1 - opacity)).toInt() + val green = (color.green * opacity + background.green * (1 - opacity)).toInt() + val blue = (color.blue * opacity + background.blue * (1 - opacity)).toInt() return Color(red, green, blue) } - fun withOpacity(c: Color, opacity: Double): Color { - return c.changeAlpha(max(0, min(255, round(255 * opacity).toInt()))) - } - fun contrast(color: Color, other: Color): Double { return (luminance(color) + .05) / (luminance(other) + .05) } diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorTest.kt index 217183061d5..8546da8ae80 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorTest.kt @@ -36,6 +36,26 @@ class ColorTest { assertEquals("#11223344", Color(0x11, 0x22, 0x33, 0x44).toHexColor()) } + @Test + fun toHexColorNoAlpha() { + assertEquals("#112233", Color(0x11, 0x22, 0x33, 0x44).toHexColorNoAlpha()) + } + + @Test + fun withOpacityRoundsToNearestByte() { + assertEquals(128, Color.RED.withOpacity(0.5).alpha) + } + + @Test + fun withOpacityClampsBelowZero() { + assertEquals(0, Color.RED.withOpacity(-0.1).alpha) + } + + @Test + fun withOpacityClampsAboveOne() { + assertEquals(255, Color.RED.withOpacity(1.5).alpha) + } + @Test fun parseRGB() { assertEquals(Color.RED, Color.parseRGB("rgb(255,0,0)")) @@ -46,6 +66,24 @@ class ColorTest { assertEquals(Color.RED, Color.parseRGB("rgba(255,0,0,1.0)")) } + @Test + fun rgbaRequiresAlpha() { + val e = assertFailsWith { + Color.parseRGB("rgba(220, 240, 255)") + } + + assertEquals("RGBA color format requires exactly 4 components: rgba(220, 240, 255)", e.message) + } + + @Test + fun rgbRejectsExtraAlpha() { + val e = assertFailsWith { + Color.parseRGB("rgb(220, 240, 255, 0.5)") + } + + assertEquals("RGB color format requires exactly 3 components: rgb(220, 240, 255, 0.5)", e.message) + } + @Test fun parseColRGB() { assertEquals(Color.BLUE, Color.parseRGB("color(0,0,255)")) @@ -56,6 +94,15 @@ class ColorTest { assertEquals(Color.BLUE, Color.parseRGB("color(0,0,255,1.0)")) } + @Test + fun colorRejectsWrongComponentCount() { + val e = assertFailsWith { + Color.parseRGB("color(0,0)") + } + + assertEquals("'color()' format requires 3 or 4 components: color(0,0)", e.message) + } + @Test fun parseRgbWithSpaces() { assertEquals(Color.RED, Color.parseRGB("rgb(255, 0, 0)")) @@ -75,4 +122,4 @@ class ColorTest { Color.parseRGB("rbg(255, 0, )") } } -} \ No newline at end of file +} diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorsTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorsTest.kt index 2e9560d6f41..914a65223ed 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorsTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/values/ColorsTest.kt @@ -37,6 +37,11 @@ class ColorsTest { assertEquals(Color.RED, Colors.parseColor(Color.RED.toHexColor())) } + @Test + fun parseHexWithAlpha() { + assertEquals(Color(0, 255, 0, 128), Colors.parseColor("#00ff0080")) + } + @Test fun parseRGB() { assertEquals(Color.RED, Colors.parseColor("rgb(255,0,0)")) @@ -62,6 +67,30 @@ class ColorsTest { assertEquals(Color.MAGENTA, Colors.parseColor("magenta")) } + @Test + fun parseColorNameWithOpacity() { + assertEquals(Color.STEEL_BLUE.withOpacity(0.35), Colors.parseColor("steelblue / 0.35")) + } + + @Test + fun parseColorNameWithOpacityNoSpaces() { + assertEquals(Color.STEEL_BLUE.withOpacity(0.35), Colors.parseColor("steelblue/0.35")) + } + + @Test + fun percentOpacitySuffixIsNotSupported() { + assertFailsWith { + Colors.parseColor("steelblue / 35%") + } + } + + @Test + fun opacitySuffixRequiresSingleSlash() { + assertFailsWith { + Colors.parseColor("steelblue / 0.35 / 0.5") + } + } + @Test fun rgbFromHsv() { assertEquals(Color.BLACK, Colors.rgbFromHsv(0.0, 0.0, 0.0)) @@ -112,6 +141,13 @@ class ColorsTest { assertColors(Color(0, 0, 128), HSL(240.0, 1.0, 0.25)) // navy } + @Test + fun `color space conversions apply opacity`() { + assertEquals(128, rgbFromHsl(HSL(0.0, 1.0, 0.5), opacity = 0.5).alpha) + assertEquals(128, rgbFromHcl(HCL(15.0, 100.0, 65.0), opacity = 0.5).alpha) + assertEquals(128, rgbFromLab(LAB(l = 43.579, a = 45.164, b = 36.823), opacity = 0.5).alpha) + } + @Test fun hcl() { fun assertHclToRgb(hcl: HCL, hexRgb: String) { diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgDsl.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgDsl.kt index b1375043cc9..f195ac2629d 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgDsl.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgDsl.kt @@ -264,7 +264,7 @@ fun SvgSlimGroup.slimLine( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.line(x1, y1, x2, y2) - stroke?.let { el.setStroke(it, 1.0) } + stroke?.let { el.setStroke(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) el.appendTo(this) @@ -282,8 +282,8 @@ fun SvgSlimGroup.slimRect( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.rect(x.toDouble(), y.toDouble(), width.toDouble(), height.toDouble()) - stroke?.let { el.setStroke(it, 1.0) } - fill?.let { el.setFill(it, 1.0) } + stroke?.let { el.setStroke(it) } + fill?.let { el.setFill(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) @@ -301,8 +301,8 @@ fun SvgSlimGroup.slimCircle( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.circle(cx.toDouble(), cy.toDouble(), r.toDouble()) - stroke?.let { el.setStroke(it, 1.0) } - fill?.let { el.setFill(it, 1.0) } + stroke?.let { el.setStroke(it) } + fill?.let { el.setFill(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) @@ -318,8 +318,8 @@ fun SvgSlimGroup.slimPath( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.path(pathData) - stroke?.let { el.setStroke(it, 1.0) } - fill?.let { el.setFill(it, 1.0) } + stroke?.let { el.setStroke(it) } + fill?.let { el.setFill(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) el.appendTo(this) diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt index 8688ce4ac54..2ad5757c6b0 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt @@ -9,40 +9,38 @@ import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.commons.intern.observable.property.Property import org.jetbrains.letsPlot.commons.intern.observable.property.WritableProperty import org.jetbrains.letsPlot.commons.values.Color -import kotlin.math.max -import kotlin.math.min object SvgUtils { - private val OPACITY_TABLE: DoubleArray = DoubleArray(256) - - init { - for (alpha in 0..255) { - OPACITY_TABLE[alpha] = alpha / 255.0 - } - } + private val OPACITY_TABLE: DoubleArray = DoubleArray(256) { alpha -> alpha / 255.0 } + private val OPACITY_STRING_TABLE: Array = Array(256) { alpha -> OPACITY_TABLE[alpha].toString() } fun opacity(c: Color): Double { return OPACITY_TABLE[c.alpha] } - fun alpha2opacity(colorAlpha: Int): Double { - return OPACITY_TABLE[colorAlpha] + private fun opacityString(c: Color): String { + return OPACITY_STRING_TABLE[c.alpha] } - fun toARGB(c: Color): Int { - return toARGB(c.red, c.green, c.blue, c.alpha) + fun splitColorAndOpacity(color: Color): Pair { + return color.toHexColorNoAlpha() to if (color.alpha < 255) opacityString(color) else null } - fun toARGB(c: Color, alpha: Double): Int { - return toARGB( - c.red, - c.green, - c.blue, - max(0.0, min(255.0, alpha * 255)).toInt() - ) + fun fillAndOpacityStyle(color: Color, separator: String = ""): String { + val (fill, fillOpacity) = splitColorAndOpacity(color) + return buildString { + append("fill:$fill;$separator") + if (fillOpacity != null) { + append("fill-opacity:$fillOpacity;$separator") + } + } + } + + fun toARGB(c: Color): Int { + return toARGB(c.red, c.green, c.blue, c.alpha) } - fun toARGB(r: Int, g: Int, b: Int, alpha: Int): Int { + private fun toARGB(r: Int, g: Int, b: Int, alpha: Int): Int { val rgb = (r shl 16) + (g shl 8) + b return (alpha shl 24) + rgb } diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SlimBase.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SlimBase.kt index d0c5217bcc5..7c0b9e8041f 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SlimBase.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SlimBase.kt @@ -6,6 +6,7 @@ package org.jetbrains.letsPlot.datamodel.svg.dom.slim import org.jetbrains.letsPlot.commons.values.Color +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils import org.jetbrains.letsPlot.datamodel.svg.dom.SvgTransform internal abstract class SlimBase protected constructor(val elementName: String) : @@ -41,18 +42,16 @@ internal abstract class SlimBase protected constructor(val elementName: String) internal val ATTR_COUNT = ATTR_KEYS.size } - override fun setFill(c: Color, alpha: Double) { - setAttribute(fill, c.toHexColor()) - if (alpha < 1.0) { - setAttribute(fillOpacity, alpha.toString()) - } + override fun setFill(c: Color) { + val (color, opacity) = SvgUtils.splitColorAndOpacity(c) + setAttribute(fill, color) + opacity?.let { setAttribute(fillOpacity, it) } } - override fun setStroke(c: Color, alpha: Double) { - setAttribute(stroke, c.toHexColor()) - if (alpha < 1.0) { - setAttribute(strokeOpacity, alpha.toString()) - } + override fun setStroke(c: Color) { + val (color, opacity) = SvgUtils.splitColorAndOpacity(c) + setAttribute(stroke, color) + opacity?.let { setAttribute(strokeOpacity, it) } } override fun setStrokeWidth(v: Double) { diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SvgSlimShape.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SvgSlimShape.kt index dc9077db4f9..0132a51c488 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SvgSlimShape.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/slim/SvgSlimShape.kt @@ -9,8 +9,8 @@ import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.datamodel.svg.dom.SvgTransform interface SvgSlimShape : SvgSlimObject { - fun setFill(c: Color, alpha: Double) - fun setStroke(c: Color, alpha: Double) + fun setFill(c: Color) + fun setStroke(c: Color) fun setStrokeWidth(v: Double) fun setStrokeDashArray(v: String) fun strokeDashOffset(v: Double) diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheet.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheet.kt index c276d194bd5..fc36d825a3e 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheet.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheet.kt @@ -7,6 +7,7 @@ package org.jetbrains.letsPlot.datamodel.svg.style import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.commons.values.FontFace +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils import org.jetbrains.letsPlot.datamodel.svg.style.TextStyle.Companion.NONE_COLOR @@ -51,7 +52,7 @@ class StyleSheet constructor( private fun TextStyle.toCSS(): String { val css = StringBuilder() - css.appendLine("fill: ${color.toHexColor()};") + css.append(SvgUtils.fillAndOpacityStyle(color, separator = "\n")) css.appendLine("font-weight: ${face.weight};") css.appendLine("font-style: ${face.style};") if (!isNoneFamily) css.appendLine("font-family: $family;") @@ -68,7 +69,7 @@ class StyleSheet constructor( fun fromCSS(css: String, defaultFamily: String, defaultSize: Double): StyleSheet { fun parseProperty(styleProperties: String, propertyName: String): String? { - val regex = Regex("$propertyName:(.+);") + val regex = Regex("$propertyName:\\s*([^;]+);") return regex.find(styleProperties)?.groupValues?.get(1)?.trim() } @@ -85,16 +86,18 @@ class StyleSheet constructor( ?: defaultSize val color = parseProperty(styleProperties, "fill") + val fillOpacity = parseProperty(styleProperties, "fill-opacity")?.toDoubleOrNull() + val parsedColor = color?.let(Color::parseHex) ?: Color.BLACK classes[className] = TextStyle( family = fontFamily, face = FontFace(bold = fontWeight == "bold", italic = fontStyle == "italic"), size = fontSize, - color = color?.let(Color::parseHex) ?: Color.BLACK + color = fillOpacity?.let(parsedColor::multiplyOpacity) ?: parsedColor ) } return StyleSheet(classes, defaultFamily) } } -} \ No newline at end of file +} diff --git a/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheetTest.kt b/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheetTest.kt new file mode 100644 index 00000000000..a080ec90e3f --- /dev/null +++ b/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/style/StyleSheetTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.datamodel.svg.style + +import org.jetbrains.letsPlot.commons.values.Color +import org.jetbrains.letsPlot.commons.values.FontFace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StyleSheetTest { + + private fun sheetWith(color: Color): StyleSheet { + return StyleSheet( + mapOf("cls" to TextStyle("Arial", FontFace.NORMAL, 12.0, color)), + defaultFamily = "Arial" + ) + } + + // --- toCSS --- + + @Test + fun `opaque color does not emit fill-opacity`() { + val css = sheetWith(Color.RED).toCSS() + assertTrue(css.contains("fill:#ff0000")) + assertFalse(css.contains("fill-opacity")) + } + + @Test + fun `semi-transparent color emits fill and fill-opacity separately`() { + val css = sheetWith(Color(255, 0, 0, 128)).toCSS() + assertTrue(css.contains("fill:#ff0000")) + assertTrue(css.contains("fill-opacity:")) + } + + @Test + fun `fully transparent color emits fill-opacity of 0`() { + val css = sheetWith(Color(0, 255, 0, 0)).toCSS() + assertTrue(css.contains("fill-opacity:0")) + } + + // --- fromCSS roundtrip --- + + @Test + fun `semi-transparent color alpha survives CSS roundtrip`() { + val original = Color(255, 0, 0, 128) + val css = sheetWith(original).toCSS() + val parsed = StyleSheet.fromCSS(css, defaultFamily = "Arial", defaultSize = 12.0) + val color = parsed.getTextStyle("cls").color + assertEquals(original.red, color.red) + assertEquals(original.green, color.green) + assertEquals(original.blue, color.blue) + assertEquals(original.alpha, color.alpha) + } + + @Test + fun `fully transparent alpha survives CSS roundtrip`() { + val css = sheetWith(Color(0, 255, 0, 0)).toCSS() + val parsed = StyleSheet.fromCSS(css, defaultFamily = "Arial", defaultSize = 12.0) + assertEquals(0, parsed.getTextStyle("cls").color.alpha) + } + + @Test + fun `opaque color alpha survives CSS roundtrip`() { + val css = sheetWith(Color.BLUE).toCSS() + val parsed = StyleSheet.fromCSS(css, defaultFamily = "Arial", defaultSize = 12.0) + assertEquals(255, parsed.getTextStyle("cls").color.alpha) + } + + @Test + fun `color alpha and fill-opacity are composed`() { + val css = """ + .cls { + fill: #ff000080; + fill-opacity: 0.5; + } + """.trimIndent() + val parsed = StyleSheet.fromCSS(css, defaultFamily = "Arial", defaultSize = 12.0) + assertEquals(64, parsed.getTextStyle("cls").color.alpha) + } +} diff --git a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/DemoModelA.kt b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/DemoModelA.kt index 3de84003b5c..1f6b07246a8 100644 --- a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/DemoModelA.kt +++ b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/DemoModelA.kt @@ -116,15 +116,15 @@ object DemoModelA { var i = 20.0 while (i < 400) { val line = SvgSlimElements.line(i, 0.0, i, 200.0) - line.setStroke(Color.LIGHT_GREEN, 1.0) + line.setStroke(Color.LIGHT_GREEN) line.setStrokeWidth(20.0) line.appendTo(slimGroup) i += 40 } val ellipse = SvgSlimElements.circle(300.0, 60.0, 50.0) - ellipse.setFill(Color.LIGHT_YELLOW, 1.0) - ellipse.setStroke(Color.DARK_BLUE, 1.0) + ellipse.setFill(Color.LIGHT_YELLOW) + ellipse.setStroke(Color.DARK_BLUE) ellipse.setStrokeWidth(3.0) ellipse.appendTo(slimGroup) @@ -134,14 +134,14 @@ object DemoModelA { 175.0 ) ) - path.setFill(Color.CYAN, 1.0) - path.setStroke(Color.DARK_GREEN, 1.0) + path.setFill(Color.CYAN) + path.setStroke(Color.DARK_GREEN) path.setStrokeWidth(2.0) path.appendTo(slimGroup) val rect = SvgSlimElements.rect(160.0, 50.0, 80.0, 50.0) - rect.setFill(Color.LIGHT_MAGENTA, 1.0) - rect.setStroke(Color.DARK_MAGENTA, 1.0) + rect.setFill(Color.LIGHT_MAGENTA) + rect.setStroke(Color.DARK_MAGENTA) rect.setStrokeWidth(1.0) rect.appendTo(slimGroup) @@ -216,4 +216,4 @@ object DemoModelA { val dash2 = d2 * strokeWidth return "$dash1,$dash2" } -} \ No newline at end of file +} diff --git a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SampleImageData.kt b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SampleImageData.kt index b637ea956ca..d18d1bdba38 100644 --- a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SampleImageData.kt +++ b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SampleImageData.kt @@ -24,9 +24,9 @@ internal object SampleImageData { // .5 | 1 | .5 <-- gray, alpha return intArrayOf( - toARGB(RED, 0.5), toARGB(GREEN, 0.5), toARGB(BLUE, 0.5), + toARGB(RED.withOpacity(0.5)), toARGB(GREEN.withOpacity(0.5)), toARGB(BLUE.withOpacity(0.5)), toARGB(RED), toARGB(GREEN), toARGB(BLUE), - toARGB(BLACK, 0.5), toARGB(BLACK, 1.0), toARGB(BLACK, 0.5) + toARGB(BLACK.withOpacity(0.5)), toARGB(BLACK), toARGB(BLACK.withOpacity(0.5)) ) } } diff --git a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgDsl.kt b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgDsl.kt index afd12617281..53e26981998 100644 --- a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgDsl.kt +++ b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgDsl.kt @@ -266,7 +266,7 @@ internal fun SvgSlimGroup.slimLine( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.line(x1, y1, x2, y2) - stroke?.let { el.setStroke(it, 1.0) } + stroke?.let { el.setStroke(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) el.appendTo(this) @@ -284,8 +284,8 @@ internal fun SvgSlimGroup.slimRect( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.rect(x.toDouble(), y.toDouble(), width.toDouble(), height.toDouble()) - stroke?.let { el.setStroke(it, 1.0) } - fill?.let { el.setFill(it, 1.0) } + stroke?.let { el.setStroke(it) } + fill?.let { el.setFill(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) @@ -303,8 +303,8 @@ internal fun SvgSlimGroup.slimCircle( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.circle(cx.toDouble(), cy.toDouble(), r.toDouble()) - stroke?.let { el.setStroke(it, 1.0) } - fill?.let { el.setFill(it, 1.0) } + stroke?.let { el.setStroke(it) } + fill?.let { el.setFill(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) @@ -320,8 +320,8 @@ internal fun SvgSlimGroup.slimPath( config: SvgSlimShape.() -> Unit = {}, ): SvgSlimShape { val el = SvgSlimElements.path(pathData) - stroke?.let { el.setStroke(it, 1.0) } - fill?.let { el.setFill(it, 1.0) } + stroke?.let { el.setStroke(it) } + fill?.let { el.setFill(it) } strokeWidth?.let { el.setStrokeWidth(it.toDouble()) } el.apply(config) el.appendTo(this) diff --git a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgImageElementModel.kt b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgImageElementModel.kt index cfb8c4f00d9..75fe4065cbc 100644 --- a/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgImageElementModel.kt +++ b/demo/common-svg/src/commonMain/kotlin/demo/svgMapping/model/SvgImageElementModel.kt @@ -78,9 +78,9 @@ object SvgImageElementModel { // .5 | 1 | .5 <-- gray, alpha return intArrayOf( - SvgUtils.toARGB(Color.RED, 0.5), SvgUtils.toARGB(Color.GREEN, 0.5), SvgUtils.toARGB(Color.BLUE, 0.5), + SvgUtils.toARGB(Color.RED.withOpacity(0.5)), SvgUtils.toARGB(Color.GREEN.withOpacity(0.5)), SvgUtils.toARGB(Color.BLUE.withOpacity(0.5)), SvgUtils.toARGB(Color.RED), SvgUtils.toARGB(Color.GREEN), SvgUtils.toARGB(Color.BLUE), - SvgUtils.toARGB(Color.BLACK, 0.5), SvgUtils.toARGB(Color.BLACK, 1.0), SvgUtils.toARGB(Color.BLACK, 0.5) + SvgUtils.toARGB(Color.BLACK.withOpacity(0.5)), SvgUtils.toARGB(Color.BLACK), SvgUtils.toARGB(Color.BLACK.withOpacity(0.5)) ) } } @@ -92,4 +92,4 @@ object SvgImageElementModel { "}" } } -} \ No newline at end of file +} diff --git a/docs/dev/notebooks/alpha_opacity.ipynb b/docs/dev/notebooks/alpha_opacity.ipynb new file mode 100644 index 00000000000..9535cd4ca0a --- /dev/null +++ b/docs/dev/notebooks/alpha_opacity.ipynb @@ -0,0 +1,1294 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a438d36c-99d6-4273-bce7-52f9830cd4b5", + "metadata": {}, + "source": [ + "# Check alpha/opacity effects" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2f0fc161-5877-45e2-9832-272213c0cc77", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from lets_plot import *\n", + "import re\n", + "\n", + "LetsPlot.setup_html() " + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5623144c-99c3-4bf3-8ef3-0ccb3f83a171", + "metadata": {}, + "outputs": [], + "source": [ + "def text_plot(color, alpha):\n", + " rgba_alpha = None\n", + " m = re.match(r'rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([0-9.]+)\\s*\\)', color)\n", + " if m:\n", + " rgba_alpha = float(m.group(1))\n", + " \n", + " label = f\"{rgba_alpha}\\n{alpha}\"\n", + " \n", + " return (\n", + " ggplot() +\n", + " geom_text(\n", + " x=0, y=0,\n", + " label=label,\n", + " size=24,\n", + " color=color,\n", + " alpha=alpha\n", + " ) +\n", + " theme_void() +\n", + " theme(panel_grid_major=element_line(size=4)) +\n", + " ggsize(300, 200)\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "f6e42fd5-f76e-4a7d-9aad-3332b0e91c3d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gggrid([\n", + " text_plot(color='rgba(255,0,0,0)', alpha=None),\n", + " text_plot(color='rgba(255,0,0,0)', alpha=0),\n", + " text_plot(color='rgba(255,0,0,0)', alpha=0.3),\n", + " text_plot(color='rgba(255,0,0,0)', alpha=1),\n", + " #\n", + " text_plot(color='rgba(255,0,0,0.3)', alpha=None),\n", + " text_plot(color='rgba(255,0,0,0.3)', alpha=0),\n", + " text_plot(color='rgba(255,0,0,0.3)', alpha=0.3),\n", + " text_plot(color='rgba(255,0,0,0.3)', alpha=1),\n", + " #\n", + " text_plot(color='rgba(255,0,0,0.6)', alpha=None),\n", + " text_plot(color='rgba(255,0,0,0.6)', alpha=0),\n", + " text_plot(color='rgba(255,0,0,0.6)', alpha=0.3),\n", + " text_plot(color='rgba(255,0,0,0.6)', alpha=1),\n", + " #\n", + " text_plot(color='rgba(255,0,0,1)', alpha=None),\n", + " text_plot(color='rgba(255,0,0,1)', alpha=0),\n", + " text_plot(color='rgba(255,0,0,1)', alpha=0.3),\n", + " text_plot(color='rgba(255,0,0,1)', alpha=1)\n", + "], ncol=4) + ggsize(600, 800) + ggtitle('If set, the alpha aesthetic overrides the alpha from the color')" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4c3df636-913c-4316-a4a3-1ae8ca3fd333", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# element_text\n", + "(ggplot({'x': [1, 2, 3], 'y': [2, 4, 3]}, aes('x', 'y'))\n", + " + geom_point(size=5)\n", + " + geom_line()\n", + " + labs(\n", + " title='Plot Title',\n", + " subtitle='Plot Subtitle',\n", + " caption='Plot Caption',\n", + " tag='A'\n", + " )\n", + " + theme(\n", + " plot_title=element_text(color=\"rgba(255,0,0, 0.3)\", size=28),\n", + " plot_subtitle=element_text(color=\"rgba(0,255,0, 0.3)\", size=24),\n", + " plot_caption=element_text(color=\"rgba(0,255,255, 0.3)\", size=22),\n", + " plot_tag=element_text(color=\"rgba(255,0,255, 0.3)\", size=30, face='bold')\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "83b44f3e-eaef-4019-a09d-23669464066d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# geom_tile\n", + "(\n", + " ggplot({'x': [1, 2], 'y': [1, 1]}) \n", + " + geom_tile(\n", + " aes(x='x', y='y'), \n", + " color='rgba(0,0,0,0.2)', \n", + " size=2, \n", + " alpha = 0.3, \n", + " linetype=4\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "2b0c0af4-e63f-4771-b7af-484cde0b514d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = {\n", + " 'x': [0, 1, 2, 3],\n", + " 'y': [0, 0, 0, 0],\n", + " 'label': ['0%', '25%', '50%', '100%'],\n", + " 'color': ['#ff000000', '#ff000040', '#ff000080', '#ff0000ff'],\n", + "}\n", + "\n", + "(\n", + " ggplot(df, aes('x', 'y'))\n", + " + geom_text(aes(label='label', color='color'), size=16)\n", + " + scale_color_identity()\n", + " + ggsize(500, 180)\n", + ")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/f-26b/color_alpha.html b/docs/f-26b/color_alpha.html new file mode 100644 index 00000000000..cb92c8bf2a7 --- /dev/null +++ b/docs/f-26b/color_alpha.html @@ -0,0 +1,8236 @@ + + + + + +color_alpha + + + + + + + + + + + + +
+ + + +
+ + diff --git a/docs/f-26b/color_alpha.ipynb b/docs/f-26b/color_alpha.ipynb new file mode 100644 index 00000000000..2a666e9a26d --- /dev/null +++ b/docs/f-26b/color_alpha.ipynb @@ -0,0 +1,707 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "2e628eb1-a000-4272-9e26-5944d12438f3", + "metadata": {}, + "source": [ + "# Color Alpha Support\n", + "\n", + "Lets-Plot accepts color values that include an alpha channel. This makes it possible to keep the same base color while controlling transparency for annotations, labels, and other plot elements.\n", + "\n", + "The alpha component can be embedded directly in the color string in the following formats:\n", + "\n", + "| Format | Example | Alpha range |\n", + "|--------|---------|-------------|\n", + "| `name / a` | `steelblue / 0.14` | float 0.0 (transparent) – 1.0 (opaque) |\n", + "| `rgba(r, g, b, a)` | `rgba(70, 130, 180, 0.14)` | float 0.0 (transparent) – 1.0 (opaque) |\n", + "| `#RRGGBBAA` | `#4682B424` | hex byte 00–FF |\n", + "| `#RGBA` | `#48B2` | hex nibble 0–F (expanded to `#4488BB22`) |\n", + "| `color(r, g, b, a)` | `color(70, 130, 180, 0.14)` | float 0.0 – 1.0 |\n", + "\n", + "For alpha, zero means a transparent color, and 1.0 (hex FF) means opaque. Some of these formats also work without the alpha component for fully opaque colors (`green`, `rgb(...)`, `#RRGGBB`, `#RGB`)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "833e51f2-381d-43a5-a0e7-beb5058d0cc0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from datetime import date\n", + "from lets_plot import *\n", + "\n", + "LetsPlot.setup_html()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b09a66c1-e0a6-4206-927e-cde43c568e7f", + "metadata": {}, + "outputs": [], + "source": [ + "months = [date(2024, m, 1) for m in range(1, 7)]\n", + "\n", + "forecast = {\n", + " 'month': months,\n", + " 'leads': [18, 29, 41, 57, 63, 74],\n", + " 'lead_label': ['18k', '29k', '41k', '57k', '63k', '74k']\n", + "}\n", + "\n", + "series_color = 'steelblue'\n", + "area_fill = 'mediumturquoise'\n", + "paper = 'aliceblue'\n", + "muted_text = 'slategray'\n", + "title_text = 'midnightblue'\n", + "\n", + "base_plot = (\n", + " ggplot(forecast, aes('month', 'leads'))\n", + " + geom_area(fill=area_fill, alpha=0.35)\n", + " + geom_line(color=series_color, size=2.2)\n", + " + geom_point(color=series_color, fill='white', shape=21, size=4.5, stroke=1.6)\n", + " + geom_text(\n", + " aes(label='lead_label'),\n", + " nudge_y=6,\n", + " size=10,\n", + " color=series_color\n", + " )\n", + " + scale_x_datetime(breaks=months, format='%b')\n", + " + scale_y_continuous(limits=[0, 86], breaks=[0, 20, 40, 60, 80])\n", + " + ggsize(760, 420)\n", + " + theme(\n", + " plot_background=element_rect(fill=paper, color=paper),\n", + " panel_background=element_rect(fill=paper, color=paper),\n", + " panel_grid_minor=element_blank(),\n", + " panel_grid_major_x=element_blank(),\n", + " axis_text=element_text(color=muted_text),\n", + " axis_title_y=element_text(color=muted_text),\n", + " plot_title=element_text(color=title_text, face='bold', size=20),\n", + " plot_subtitle=element_text(color=muted_text, size=11)\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "color-alpha-tag-note", + "metadata": {}, + "source": [ + "## Plot Tag\n", + "\n", + "Here, the plot tag receives a translucent color value directly from `rgba(...)`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "color-alpha-tag", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(\n", + " base_plot\n", + " + labs(\n", + " title='Partner pipeline forecast',\n", + " subtitle='The semi-transparent tag color is specified with an alpha channel',\n", + " tag='DRAFT',\n", + " x='',\n", + " y='Qualified leads'\n", + " )\n", + " + theme(\n", + " plot_tag=element_text(\n", + " \n", + " color='rgba(70, 130, 180, 0.14)', # <-- alpha is part of the color value\n", + " \n", + " face='bold',\n", + " size=180,\n", + " angle=40,\n", + " ),\n", + " plot_tag_location='panel',\n", + " plot_tag_position=[0.5, 0.5]\n", + " )\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "color-alpha-text-note", + "metadata": {}, + "source": [ + "## Geom Text\n", + "\n", + "The same plot can place the translucent annotation in an inclined `geom_text()` layer instead of using the plot tag.\n", + "\n", + "**Note.** Each annotation row carries its own color string, using one of different alpha-enabled formats \n", + "that represent nearly the same color. The colors are mapped with `scale_color_identity()`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6519576b-8ecf-4196-81d1-5a5a52f33179", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "alpha_df = {\n", + " \"x\": [date(2024, 5, 5), date(2024, 3, 15), date(2024, 3, 18), date(2024, 1, 29)],\n", + " \"y\": [62.5, 62.5, 28.5, 28.5],\n", + " \"label\": [\"ALPHA\"] * 4,\n", + " \"color\": [\n", + " 'steelblue / 0.14', # named color + alpha\n", + " 'rgba(70, 130, 180, 0.14)', # rgba(...) with float alpha\n", + " '#4682B424', # #RRGGBBAA hex\n", + " '#48B2' # #RGBA short hex, almost the same appearance\n", + " ],\n", + "}\n", + "\n", + "(\n", + " base_plot\n", + " + geom_text(data=alpha_df,\n", + " mapping=aes(\"x\", \"y\", label=\"label\", color=\"color\"),\n", + " inherit_aes=False,\n", + " size=34,\n", + " angle=-30,\n", + " fontface='bold'\n", + " )\n", + " + scale_color_identity()\n", + " + labs(\n", + " title='Partner pipeline forecast',\n", + " subtitle='Each geom_text row uses a different alpha-enabled color format',\n", + " x='',\n", + " y='Qualified leads'\n", + " )\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/future_changes.md b/future_changes.md index b085c18caac..63e5cdeb0c2 100644 --- a/future_changes.md +++ b/future_changes.md @@ -34,6 +34,12 @@ See: [example notebook](https://raw.githack.com/JetBrains/lets-plot/master/docs/f-26b/removed_records_indication.html). +- Alpha (transparency) component: + - Hex colors accept `#RRGGBBAA` or `#RGBA` notation. + - Colors accept an opacity suffix in the form `"named color / opacity"`, for example `"steelblue / 0.35"`. + - Alpha in color values is supported in geoms and theme elements [[#1462](https://github.com/JetBrains/lets-plot/issues/1462)]. + + See: [example notebook](https://raw.githack.com/JetBrains/lets-plot/master/docs/f-26b/color_alpha.html). - Memo for Kotlin API users: - Added WasmJS support [[LPK-296](https://github.com/JetBrains/lets-plot-kotlin/issues/296)], [[LPC-52](https://github.com/JetBrains/lets-plot-compose/issues/52)]. @@ -45,4 +51,4 @@ - Add 'synchronized tooltips' feature [[#1415](https://github.com/JetBrains/lets-plot/issues/1415)]. - Alpha is not supported in element_text() [[#1462](https://github.com/JetBrains/lets-plot/issues/1462)]. -- geom_imshow(): should render transparency for NaNs when all other pixel values are identical. [[#1485](https://github.com/JetBrains/lets-plot/issues/1485)]. \ No newline at end of file +- geom_imshow(): should render transparency for NaNs when all other pixel values are identical. [[#1485](https://github.com/JetBrains/lets-plot/issues/1485)]. diff --git a/gis/src/commonMain/kotlin/org/jetbrains/letsPlot/gis/tileprotocol/json/MapStyleJsonParser.kt b/gis/src/commonMain/kotlin/org/jetbrains/letsPlot/gis/tileprotocol/json/MapStyleJsonParser.kt index bafaf235467..2cc3c794e60 100644 --- a/gis/src/commonMain/kotlin/org/jetbrains/letsPlot/gis/tileprotocol/json/MapStyleJsonParser.kt +++ b/gis/src/commonMain/kotlin/org/jetbrains/letsPlot/gis/tileprotocol/json/MapStyleJsonParser.kt @@ -104,7 +104,7 @@ object MapStyleJsonParser { val colors = HashMap() colorsJson.forEntries { - colorName, colorString -> colors[colorName] = parseHexWithAlpha(colorString as String) + colorName, colorString -> colors[colorName] = Color.parseHex(colorString as String) } return colors @@ -220,10 +220,5 @@ object MapStyleJsonParser { return layerConfig } - - private fun parseHexWithAlpha(colorString: String) = - Color - .parseHex(colorString.substring(0, 7)) - .changeAlpha(colorString.substring(7, 9).toInt(16)) } diff --git a/livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/livemap/chart/Utils.kt b/livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/livemap/chart/Utils.kt index 9918286033d..16e795db6b8 100644 --- a/livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/livemap/chart/Utils.kt +++ b/livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/livemap/chart/Utils.kt @@ -12,5 +12,5 @@ fun alphaScaledColor(color: Color, newAlpha: Int?): Color { return when { newAlpha == null -> color.alpha else -> min(newAlpha, color.alpha) - }.let(color::changeAlpha) + }.let(color::withAlpha) } diff --git a/platf-awt/src/main/kotlin/org/jetbrains/letsPlot/awt/plot/component/PlotPanelToolbar.kt b/platf-awt/src/main/kotlin/org/jetbrains/letsPlot/awt/plot/component/PlotPanelToolbar.kt index 3075fb97f0e..6412f4c1f46 100644 --- a/platf-awt/src/main/kotlin/org/jetbrains/letsPlot/awt/plot/component/PlotPanelToolbar.kt +++ b/platf-awt/src/main/kotlin/org/jetbrains/letsPlot/awt/plot/component/PlotPanelToolbar.kt @@ -253,9 +253,9 @@ internal class PlotPanelToolbar : JPanel() { // C_BACKGR with an alpha channel which on a white background looks the same as the solid C_BACKGR // and slightly darkens any darker background. private val C_BACKGR_TRANSPARENT = Color( - ((C_BACKGR.red - 255 * (255 - ALPHA) / 255.0) * 255.0 / ALPHA).toInt().coerceIn(0, 255), - ((C_BACKGR.green - 255 * (255 - ALPHA) / 255.0) * 255.0 / ALPHA).toInt().coerceIn(0, 255), - ((C_BACKGR.blue - 255 * (255 - ALPHA) / 255.0) * 255.0 / ALPHA).toInt().coerceIn(0, 255), + ((C_BACKGR.red - (255 - ALPHA)) * 255.0 / ALPHA).toInt().coerceIn(0, 255), + ((C_BACKGR.green - (255 - ALPHA)) * 255.0 / ALPHA).toInt().coerceIn(0, 255), + ((C_BACKGR.blue - (255 - ALPHA)) * 255.0 / ALPHA).toInt().coerceIn(0, 255), ALPHA ) } diff --git a/platf-batik/src/test/kotlin/org/jetbrains/letsPlot/batik/plot/util/BatikPlotComponentTest.kt b/platf-batik/src/test/kotlin/org/jetbrains/letsPlot/batik/plot/util/BatikPlotComponentTest.kt index 488ef169dd7..8215e889096 100644 --- a/platf-batik/src/test/kotlin/org/jetbrains/letsPlot/batik/plot/util/BatikPlotComponentTest.kt +++ b/platf-batik/src/test/kotlin/org/jetbrains/letsPlot/batik/plot/util/BatikPlotComponentTest.kt @@ -14,10 +14,19 @@ import org.jetbrains.letsPlot.commons.event.MouseEvent.Companion.leftButton import org.jetbrains.letsPlot.commons.event.MouseEvent.Companion.noButton import org.jetbrains.letsPlot.commons.geometry.Vector import org.jetbrains.letsPlot.commons.registration.Disposable +import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.util.sizing.SizingPolicy import org.jetbrains.letsPlot.datamodel.svg.dom.* import org.jetbrains.letsPlot.datamodel.svg.event.SvgEventSpec import org.jetbrains.letsPlot.datamodel.svg.event.SvgEventSpec.* +import org.jetbrains.letsPlot.datamodel.svg.util.SvgToString +import kotlin.math.abs +import org.apache.batik.transcoder.TranscoderInput +import org.apache.batik.transcoder.TranscoderOutput +import org.apache.batik.transcoder.image.ImageTranscoder +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.StringReader import javax.swing.JComponent import javax.swing.JLabel import kotlin.test.Test @@ -29,6 +38,29 @@ typealias AWTMouseEvent = java.awt.event.MouseEvent class BatikPlotComponentTest { + @Test + fun svgColorAlphaIsRendered() { + val svg = SvgSvgElement(4.0, 4.0).apply { + children().add(SvgRectElement(0.0, 0.0, 4.0, 4.0).apply { + fillColor().set(Color.WHITE) + }) + children().add(SvgRectElement(0.0, 0.0, 4.0, 4.0).apply { + fillColor().set(Color(255, 0, 0, 128)) + }) + } + val svgText = SvgToString.render(svg) + + assertTrue(svgText.contains("fill=\"rgb(255,0,0)\"")) + assertTrue(svgText.contains("fill-opacity=")) + + val image = renderSvg(svgText) + + val pixel = image.getRGB(2, 2) + assertEquals(255, pixel shr 16 and 0xff) + assertClose(127, pixel shr 8 and 0xff) + assertClose(127, pixel and 0xff) + } + @Test fun svgEventsTest() { val eventsLog = mutableListOf>() @@ -177,5 +209,24 @@ class BatikPlotComponentTest { return component } + private fun renderSvg(svg: String): BufferedImage { + lateinit var image: BufferedImage + val transcoder = object : ImageTranscoder() { + override fun createImage(width: Int, height: Int): BufferedImage { + return BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + } + + override fun writeImage(img: BufferedImage, output: TranscoderOutput) { + image = img + } + } + transcoder.transcode(TranscoderInput(StringReader(svg)), TranscoderOutput(ByteArrayOutputStream())) + return image + } + + private fun assertClose(expected: Int, actual: Int) { + assertTrue(abs(expected - actual) <= 1, "Expected $actual to be within 1 of $expected") + } + } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/color/GradientUtil.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/color/GradientUtil.kt index 08b648f60e7..614af43e63f 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/color/GradientUtil.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/color/GradientUtil.kt @@ -40,7 +40,7 @@ object GradientUtil { domain: DoubleSpan, colors: List, naColor: Color, - alpha: Double = 1.0 + opacity: Double = 1.0 ): (Double?) -> Color { val subdomainsCount = colors.size - 1 val subdomainLength = domain.length / subdomainsCount @@ -54,7 +54,7 @@ object GradientUtil { val (lowValue, lowColor) = low val (highValue, highColor) = high val subdomain = DoubleSpan(lowValue, highValue) - gradient(subdomain, lowColor, highColor, naColor, alpha) + gradient(subdomain, lowColor, highColor, naColor, opacity) } return { value -> @@ -74,21 +74,21 @@ object GradientUtil { } /** - * Alpha channel [0..1] (0 - transparent and 1 - opaque). + * Opacity [0..1] (0 - transparent and 1 - opaque). */ fun gradient( domain: DoubleSpan, low: Color, high: Color, naColor: Color, - alpha: Double = 1.0 + opacity: Double = 1.0 ): (Double?) -> Color { return gradientLAB( domain, labFromRgb(low), labFromRgb(high), naColor, - alpha + opacity ) } @@ -97,7 +97,7 @@ object GradientUtil { low: LAB, high: LAB, naColor: Color, - alpha: Double = 1.0 + opacity: Double = 1.0 ): (Double?) -> Color { val mapperA = Mappers.linear(domain, low.a, high.a, null) @@ -111,7 +111,7 @@ object GradientUtil { val a = mapperA(input)!! val b = mapperB(input)!! val l = mapperL(input)!! - rgbFromLab(LAB(l, a, b), alpha = alpha) + rgbFromLab(LAB(l, a, b), opacity = opacity) } } } @@ -121,7 +121,7 @@ object GradientUtil { low: HCL, high: HCL, naColor: Color, - alpha: Double = 1.0, + opacity: Double = 1.0, autoHueDirection: Boolean = false ): (Double?) -> Color { var lowH = low.h @@ -161,8 +161,8 @@ object GradientUtil { val h = if (hue >= 0) hue else 360 + hue val c = mapperC(input)!! val l = mapperL(input)!! - rgbFromHcl(HCL(h, c, l), alpha = alpha) + rgbFromHcl(HCL(h, c, l), opacity = opacity) } } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/colormap/ColorMaps.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/colormap/ColorMaps.kt index a621641c687..4d7474cbcb6 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/colormap/ColorMaps.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/commons/colormap/ColorMaps.kt @@ -19,7 +19,7 @@ object ColorMaps { fun getColors( cmName: String, - alpha: Double, + opacity: Double, hueRange: DoubleSpan, n: Int? = null ): List { @@ -30,7 +30,7 @@ object ColorMaps { red = (it.r * 255).roundToInt(), green = (it.g * 255).roundToInt(), blue = (it.b * 255).roundToInt(), - alpha = (alpha * 255).roundToInt(), + alpha = (opacity * 255).roundToInt() ) } } @@ -79,4 +79,4 @@ object ColorMaps { List(n) { i -> colors[(i * inc).roundToInt() + fromIndex] } } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/feedback/DrawRectFeedback.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/feedback/DrawRectFeedback.kt index 5a19a59a017..d1672439018 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/feedback/DrawRectFeedback.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/feedback/DrawRectFeedback.kt @@ -48,7 +48,7 @@ class DrawRectFeedback constructor( } private val selectionSvg = SvgPathElement().apply { - fillColor().set(Color.LIGHT_GRAY.changeAlpha(127)) + fillColor().set(Color.LIGHT_GRAY.withOpacity(0.5)) fillRule().set(SvgPathElement.FillRule.EVEN_ODD) } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtil.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtil.kt index 7bf34b6d115..144d55b1222 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtil.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtil.kt @@ -7,15 +7,27 @@ package org.jetbrains.letsPlot.core.plot.base.aes import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics +import org.jetbrains.letsPlot.core.plot.base.aes.AesInitValue.DEFAULT_SEGMENT_COLOR import org.jetbrains.letsPlot.core.plot.base.render.point.UpdatableShape import org.jetbrains.letsPlot.datamodel.svg.dom.SvgShape import org.jetbrains.letsPlot.datamodel.svg.dom.SvgTransform -import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils object AestheticsUtil { //affects bar, smooth, area and ribbon internal const val ALPHA_CONTROLS_BOTH = false + private fun isExplicitAlphaValue(alpha: Double?): Boolean { + return alpha != null && alpha != AesInitValue.DEFAULT_ALPHA + } + + private fun hasExplicitSegmentAlpha(p: DataPointAesthetics): Boolean { + return isExplicitAlphaValue(p.segmentAlpha()) + } + + private fun explicitAlpha(p: DataPointAesthetics): Double? { + return p.alpha()?.takeIf(::isExplicitAlphaValue) + } + fun fill(filled: Boolean, solid: Boolean, p: DataPointAesthetics): Color { if (filled) { return p.fill()!! @@ -33,28 +45,44 @@ object AestheticsUtil { strokeWidth: Double, transform: SvgTransform? ) { - val fill = fill(filled, solid, p) val stroke = p.color()!! - var fillAlpha = 0.0 - if (filled || solid) { - fillAlpha = alpha(fill, p) + val resolvedFill = if (filled || solid) { + applyAlpha(fill(filled, solid, p), p) + } else { + Color.TRANSPARENT } - var strokeAlpha = 0.0 - if (strokeWidth > 0) { - strokeAlpha = alpha(stroke, p) + val resolvedStroke = if (strokeWidth > 0) { + applyAlpha(stroke, p) + } else { + Color.TRANSPARENT } - shape.update(fill, fillAlpha, stroke, strokeAlpha, strokeWidth, transform) + shape.update(resolvedFill, resolvedStroke, strokeWidth, transform) } - fun alpha(color: Color, p: DataPointAesthetics): Double { - return if (p.alpha() != AesInitValue.DEFAULT_ALPHA) { // apply only custom 'aes' alpha - p.alpha()!! - } else { // else, override with color's alpha - SvgUtils.alpha2opacity(color.alpha) - } + private fun applyAlpha(color: Color, p: DataPointAesthetics): Color { + return explicitAlpha(p)?.let(color::withOpacity) ?: color + } + + fun effectiveSegmentAlpha(p: DataPointAesthetics): Double? { + return if (hasExplicitSegmentAlpha(p)) p.segmentAlpha() else p.alpha() + } + + fun effectiveSegmentColor(p: DataPointAesthetics): Color? { + return p.segmentColor() + ?.takeIf { it != DEFAULT_SEGMENT_COLOR } + ?: p.color() + } + + fun resolveColor(p: DataPointAesthetics, applyAlpha: Boolean): Color { + val color = p.color()!! + return if (applyAlpha) applyAlpha(color, p) else color + } + + fun resolveFill(p: DataPointAesthetics, color: Color = p.fill()!!): Color { + return applyAlpha(color, p) } fun strokeWidth(p: DataPointAesthetics) = AesScaling.strokeWidth(p) @@ -74,16 +102,12 @@ object AestheticsUtil { fun textSize(p: DataPointAesthetics) = AesScaling.textSize(p) fun updateStroke(shape: SvgShape, p: DataPointAesthetics, applyAlpha: Boolean) { - shape.strokeColor().set(p.color()) - if (p.alpha() != AesInitValue.DEFAULT_ALPHA && applyAlpha) { - shape.strokeOpacity().set(p.alpha()) - } + val resolvedStroke = resolveColor(p, applyAlpha) + shape.strokeColor().set(resolvedStroke) } fun updateFill(shape: SvgShape, p: DataPointAesthetics) { - shape.fillColor().set(p.fill()) - if (p.alpha() != AesInitValue.DEFAULT_ALPHA) { - shape.fillOpacity().set(p.alpha()) - } + val resolvedFill = resolveFill(p) + shape.fillColor().set(resolvedFill) } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PieGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PieGeom.kt index b145551e1b0..d8c576cb1ee 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PieGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PieGeom.kt @@ -11,14 +11,12 @@ import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveRe import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.resample import org.jetbrains.letsPlot.commons.interval.DoubleSpan import org.jetbrains.letsPlot.commons.values.Color -import org.jetbrains.letsPlot.commons.values.Colors import org.jetbrains.letsPlot.core.plot.base.* import org.jetbrains.letsPlot.core.plot.base.aes.AesScaling import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil import org.jetbrains.letsPlot.core.plot.base.geom.annotation.PieAnnotation import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomHelper import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil -import org.jetbrains.letsPlot.core.plot.base.geom.util.HintColorUtil import org.jetbrains.letsPlot.core.plot.base.render.LegendKeyElementFactory import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot import org.jetbrains.letsPlot.core.plot.base.render.svg.LinePath @@ -126,9 +124,7 @@ class PieGeom : GeomBase(), WithWidth, WithHeight { svgInnerArc(sector) } ).apply { - val fill = sector.p.fill()!! - val fillAlpha = AestheticsUtil.alpha(fill, sector.p) - fill().set(Colors.withOpacity(fill, fillAlpha)) + fill().set(AestheticsUtil.resolveFill(sector.p)) } } @@ -230,7 +226,7 @@ class PieGeom : GeomBase(), WithWidth, WithHeight { index = sector.p.index(), GeomTargetCollector.TooltipParams( markerColors = listOf( - HintColorUtil.applyAlpha(sector.p.fill()!!, sector.p.alpha()!!) + AestheticsUtil.resolveFill(sector.p) ) ) ) @@ -335,7 +331,7 @@ class PieGeom : GeomBase(), WithWidth, WithHeight { size.y / 2, shapeSize(p) / 2 ).apply { - fillColor().set(p.fill()) + fillColor().set(AestheticsUtil.resolveFill(p)) strokeColor().set(p.color()) strokeWidth().set(p.stroke()) } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RasterGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RasterGeom.kt index a555f4ec3fe..662487d1507 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RasterGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RasterGeom.kt @@ -10,6 +10,7 @@ import org.jetbrains.letsPlot.commons.values.Bitmap import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.commons.data.SeriesUtil import org.jetbrains.letsPlot.core.plot.base.* +import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomHelper import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil import org.jetbrains.letsPlot.core.plot.base.render.LegendKeyElementFactory @@ -58,8 +59,7 @@ class RasterGeom : GeomBase() { val center = boundsXY.center val text = "Raster image size\n[$width X $height]\nexceeds capability\nof\nyour imaging device" val label = Label(text) - label.textColor().set(Color.DARK_MAGENTA) - label.setTextOpacity(0.5) + label.textColor().set(Color.DARK_MAGENTA.withOpacity(0.5)) label.setFontSize(12.0) label.setLineHeight(16.0) label.setFontWeight("bold") @@ -91,8 +91,7 @@ class RasterGeom : GeomBase() { for (p in dataPoints) { val x = p.x() val y = p.y() - val alpha = p.alpha() - val color = p.fill() + val color = AestheticsUtil.resolveFill(p) var col = round((x!! - x0) / stepX).toInt() var row = round((y!! - y0) / stepY).toInt() @@ -105,7 +104,7 @@ class RasterGeom : GeomBase() { row = rows - (row + 1) } - argbValues[row * cols + col] = SvgUtils.toARGB(color!!, alpha!!) + argbValues[row * cols + col] = SvgUtils.toARGB(color) } val bitmap = Bitmap(cols, rows, argbValues) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RibbonGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RibbonGeom.kt index 27e0757b603..417de47a092 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RibbonGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/RibbonGeom.kt @@ -64,7 +64,7 @@ class RibbonGeom : GeomBase() { val ymax = p.finiteOrNull(Aes.YMAX) ?: continue hint.defaultCoord(p[Aes.X]!!) - .defaultColor(p.fill()!!, alpha = null) + .defaultColor(p.fill()!!) val hintsCollection = HintsCollection(p, helper) .addHint(hint.create(Aes.YMAX)) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SmoothGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SmoothGeom.kt index ab80c66f0cf..6b7b04e0d17 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SmoothGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SmoothGeom.kt @@ -6,6 +6,7 @@ package org.jetbrains.letsPlot.core.plot.base.geom import org.jetbrains.letsPlot.core.plot.base.* +import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil import org.jetbrains.letsPlot.core.plot.base.geom.util.* import org.jetbrains.letsPlot.core.plot.base.geom.util.HintsCollection.HintConfigFactory import org.jetbrains.letsPlot.core.plot.base.render.LegendKeyElementFactory @@ -76,7 +77,7 @@ class SmoothGeom : GeomBase() { .defaultObjectRadius(objectRadius) .defaultCoord(p1.x) .defaultKind(if (ctx.flipped) VERTICAL else HORIZONTAL) - .defaultColor(fill, aes1.alpha()) + .defaultColor(AestheticsUtil.resolveFill(aes1)) val hintsCollection = HintsCollection(aes1, helper) .addHint(hint.create(Aes.YMAX)) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt index ff3f9b4f4c7..c56d942b326 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt @@ -401,22 +401,13 @@ open class GeomHelper( p: DataPointAesthetics, applyAlphaToAll: Boolean = ALPHA_CONTROLS_BOTH ) { - val stroke = p.color()!! - val strokeAlpha = if (applyAlphaToAll) { - // apply alpha aes - AestheticsUtil.alpha(stroke, p) - } else { - // keep color's alpha - SvgUtils.alpha2opacity(stroke.alpha) - } - - val fill = p.fill()!! - val fillAlpha = AestheticsUtil.alpha(fill, p) + val resolvedStroke = AestheticsUtil.resolveColor(p, applyAlphaToAll) + val resolvedFill = AestheticsUtil.resolveFill(p) - shape.setFill(fill, fillAlpha) - shape.setStroke(stroke, strokeAlpha) + shape.setFill(resolvedFill) + shape.setStroke(resolvedStroke) shape.setStrokeWidth(AesScaling.strokeWidth(p)) StrokeDashArraySupport.apply(shape, AesScaling.strokeWidth(p), p.lineType()) } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintColorUtil.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintColorUtil.kt index 5c0a5447b82..a214a002cf5 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintColorUtil.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintColorUtil.kt @@ -11,33 +11,17 @@ import org.jetbrains.letsPlot.core.plot.base.GeomContext import org.jetbrains.letsPlot.core.plot.base.GeomKind import org.jetbrains.letsPlot.core.plot.base.GeomKind.* import org.jetbrains.letsPlot.core.plot.base.GeomMeta -import org.jetbrains.letsPlot.core.plot.base.aes.AesInitValue import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil import org.jetbrains.letsPlot.core.plot.base.render.point.NamedShape import org.jetbrains.letsPlot.core.plot.base.render.point.TinyPointShape object HintColorUtil { fun colorWithAlpha(p: DataPointAesthetics): Color { - return applyAlpha( - p.color()!!, - p.alpha()!! - ) + return AestheticsUtil.resolveColor(p, applyAlpha = true) } fun fillWithAlpha(p: DataPointAesthetics): Color { - return applyAlpha( - p.fill()!!, - p.alpha()!! - ) - } - - fun applyAlpha(color: Color, alpha: Double): Color { - val intAlpha = (255 * alpha).toInt() - return if (alpha != AesInitValue.DEFAULT_ALPHA) { - color.changeAlpha(intAlpha) - } else { - color - } + return AestheticsUtil.resolveFill(p) } fun createColorMarkerMapper( @@ -50,18 +34,19 @@ object HintColorUtil { ) } - private fun pointFillMapper(p:DataPointAesthetics): Color = + private fun pointFillMapper(p: DataPointAesthetics): Color = when (val shape = p.shape()) { - is NamedShape -> applyAlpha( - AestheticsUtil.fill(shape.isFilled, shape.isSolid, p), - p.alpha()!! - ) - TinyPointShape -> p.color()!! + is NamedShape -> when { + shape.isFilled -> AestheticsUtil.resolveFill(p) + shape.isSolid -> AestheticsUtil.resolveColor(p, applyAlpha = true) + else -> Color.TRANSPARENT + } + TinyPointShape -> AestheticsUtil.resolveColor(p, applyAlpha = true) else -> Color.TRANSPARENT } - private fun pointStrokeMapper(p:DataPointAesthetics): Color { - return when(val shape = p.shape()) { + private fun pointStrokeMapper(p: DataPointAesthetics): Color { + return when (val shape = p.shape()) { is NamedShape -> { when { shape.isSolid -> Color.TRANSPARENT diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintsCollection.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintsCollection.kt index 00157b5e7f2..6cd866e84f8 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintsCollection.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/HintsCollection.kt @@ -98,12 +98,8 @@ class HintsCollection(private val myPoint: DataPointAesthetics, private val myHe return this } - fun defaultColor(v: Color, alpha: Double?): HintConfigFactory { - myDefaultColor = if (alpha != null) { - v.changeAlpha((255 * alpha).toInt()) - } else { - v - } + fun defaultColor(v: Color): HintConfigFactory { + myDefaultColor = v return this } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/LinesHelper.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/LinesHelper.kt index af36f98f979..9b50f099fe8 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/LinesHelper.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/LinesHelper.kt @@ -11,7 +11,6 @@ import org.jetbrains.letsPlot.commons.intern.splitByNull import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.* import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.PIXEL_PRECISION import org.jetbrains.letsPlot.commons.intern.util.VectorAdapter -import org.jetbrains.letsPlot.commons.values.Colors.withOpacity import org.jetbrains.letsPlot.core.commons.geometry.PolylineSimplifier.Companion.DOUGLAS_PEUCKER_PIXEL_THRESHOLD import org.jetbrains.letsPlot.core.commons.geometry.PolylineSimplifier.Companion.douglasPeucker import org.jetbrains.letsPlot.core.plot.base.* @@ -350,12 +349,9 @@ open class LinesHelper( filled: Boolean, strokeScaler: (DataPointAesthetics) -> Double = AesScaling::strokeWidth ) { - val stroke = p.color() - val strokeAlpha = AestheticsUtil.alpha(stroke!!, p) - path.color().set(withOpacity(stroke, strokeAlpha)) - if (!AestheticsUtil.ALPHA_CONTROLS_BOTH && (filled || !myAlphaEnabled)) { - path.color().set(stroke) - } + val applyStrokeAlpha = AestheticsUtil.ALPHA_CONTROLS_BOTH || (!filled && myAlphaEnabled) + val resolvedStroke = AestheticsUtil.resolveColor(p, applyAlpha = applyStrokeAlpha) + path.color().set(resolvedStroke) if (filled) { decorateFillingPart(path, p) @@ -369,9 +365,8 @@ open class LinesHelper( } private fun decorateFillingPart(path: LinePath, p: DataPointAesthetics) { - val fill = p.fill() - val fillAlpha = AestheticsUtil.alpha(fill!!, p) - path.fill().set(withOpacity(fill, fillAlpha)) + val resolvedFill = AestheticsUtil.resolveFill(p) + path.fill().set(resolvedFill) } companion object { diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/TextUtil.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/TextUtil.kt index 954e95572c5..f811027b17e 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/TextUtil.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/TextUtil.kt @@ -12,8 +12,6 @@ import org.jetbrains.letsPlot.commons.values.FontFace import org.jetbrains.letsPlot.core.plot.base.Aes import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics import org.jetbrains.letsPlot.core.plot.base.GeomContext -import org.jetbrains.letsPlot.core.plot.base.aes.AesInitValue.DEFAULT_ALPHA -import org.jetbrains.letsPlot.core.plot.base.aes.AesInitValue.DEFAULT_SEGMENT_COLOR import org.jetbrains.letsPlot.core.plot.base.aes.AesScaling import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil import org.jetbrains.letsPlot.core.plot.base.render.svg.Label @@ -151,16 +149,8 @@ object TextUtil { fun lineheight(p: DataPointAesthetics, scale: Double) = p.lineheight()!! * fontSize(p, scale) fun decorate(label: Label, p: DataPointAesthetics, scale: Double = 1.0, applyAlpha: Boolean = true) { - val color = p.color()!! - label.textColor().set(color) - val alpha = if (applyAlpha) { - // apply alpha aes - AestheticsUtil.alpha(color, p) - } else { - // keep color's alpha - SvgUtils.alpha2opacity(color.alpha) - } - label.setTextOpacity(alpha) + val resolvedColor = AestheticsUtil.resolveColor(p, applyAlpha) + label.textColor().set(resolvedColor) label.setFontSize(fontSize(p, scale)) label.setLineHeight(lineheight(p, scale)) @@ -220,9 +210,9 @@ object TextUtil { override operator fun get(aes: Aes): T? { val value: Any? = when (aes) { - Aes.COLOR -> if (super.get(Aes.SEGMENT_COLOR) == DEFAULT_SEGMENT_COLOR) super.get(Aes.COLOR) else super.get(Aes.SEGMENT_COLOR) + Aes.COLOR -> AestheticsUtil.effectiveSegmentColor(p) Aes.SIZE -> super.get(Aes.SEGMENT_SIZE) - Aes.ALPHA -> if (super.get(Aes.SEGMENT_ALPHA) == DEFAULT_ALPHA) super.get(Aes.ALPHA) else super.get(Aes.SEGMENT_ALPHA) + Aes.ALPHA -> AestheticsUtil.effectiveSegmentAlpha(p) else -> super.get(aes) } @Suppress("UNCHECKED_CAST") @@ -356,4 +346,4 @@ object TextUtil { } } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/PointShapeSvg.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/PointShapeSvg.kt index ec824895c2b..94d754151f3 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/PointShapeSvg.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/PointShapeSvg.kt @@ -41,9 +41,7 @@ object PointShapeSvg { private fun createTinyDotShape(location: DoubleVector, p: DataPointAesthetics): SvgSlimObject { val r = SvgSlimElements.rect(location.x - 0.5, location.y - 0.5, 1.0, 1.0) - val color = p.color()!! - val alpha = AestheticsUtil.alpha(color, p) - r.setFill(color, alpha) + r.setFill(AestheticsUtil.resolveColor(p, applyAlpha = true)) r.setStrokeWidth(0.0) return r } @@ -93,4 +91,4 @@ object PointShapeSvg { STICK_SQUARE_TRIANGLE_UP -> return Glyphs.stickSquareTriangleUp(location, size, stroke) } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/UpdatableShape.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/UpdatableShape.kt index d86c127a770..5eda37d3273 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/UpdatableShape.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/UpdatableShape.kt @@ -11,9 +11,7 @@ import org.jetbrains.letsPlot.datamodel.svg.dom.SvgTransform interface UpdatableShape { fun update( fill: Color, - fillAlpha: Double, stroke: Color, - strokeAlpha: Double, strokeWidth: Double, transform: SvgTransform? ) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/GlyphPair.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/GlyphPair.kt index ee05591e226..54ef4c3045f 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/GlyphPair.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/GlyphPair.kt @@ -14,14 +14,12 @@ internal class GlyphPair(private val myG1: Glyph, private val myG2: Glyph) : override fun update( fill: Color, - fillAlpha: Double, stroke: Color, - strokeAlpha: Double, strokeWidth: Double, transform: SvgTransform? ) { - myG1.update(fill, fillAlpha, stroke, strokeAlpha, strokeWidth, transform) - myG2.update(fill, fillAlpha, stroke, strokeAlpha, strokeWidth, transform) + myG1.update(fill, stroke, strokeWidth, transform) + myG2.update(fill, stroke, strokeWidth, transform) } override fun appendTo(g: SvgSlimGroup) { diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/MultiShapeGlyph.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/MultiShapeGlyph.kt index a96170eeed3..bb781f42bc4 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/MultiShapeGlyph.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/MultiShapeGlyph.kt @@ -13,14 +13,12 @@ internal abstract class MultiShapeGlyph : Glyph { protected fun update( shape: SvgSlimShape?, fill: Color, - fillAlpha: Double, stroke: Color, - strokeAlpha: Double, strokeWidth: Double, transform: SvgTransform? ) { - shape?.setFill(fill, fillAlpha) - shape?.setStroke(stroke, strokeAlpha) + shape?.setFill(fill) + shape?.setStroke(stroke) shape?.setStrokeWidth(strokeWidth) transform?.let { shape?.setTransform(it) } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/SingletonGlyph.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/SingletonGlyph.kt index a419a6c9abd..4d9988877f5 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/SingletonGlyph.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/SingletonGlyph.kt @@ -26,14 +26,12 @@ abstract class SingletonGlyph : Glyph { override fun update( fill: Color, - fillAlpha: Double, stroke: Color, - strokeAlpha: Double, strokeWidth: Double, transform: SvgTransform? ) { - myShape.setFill(fill, fillAlpha) - myShape.setStroke(stroke, strokeAlpha) + myShape.setFill(fill) + myShape.setStroke(stroke) myShape.setStrokeWidth(strokeWidth) transform?.let { myShape.setTransform(it) } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/TwoShapeGlyph.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/TwoShapeGlyph.kt index cecb179f60d..30486ef33c9 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/TwoShapeGlyph.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/point/symbol/TwoShapeGlyph.kt @@ -21,14 +21,12 @@ internal abstract class TwoShapeGlyph : MultiShapeGlyph() { override fun update( fill: Color, - fillAlpha: Double, stroke: Color, - strokeAlpha: Double, strokeWidth: Double, transform: SvgTransform? ) { - update(myS1, fill, fillAlpha, stroke, strokeAlpha, strokeWidth, transform) - update(myS2, fill, fillAlpha, stroke, strokeAlpha, strokeWidth, transform) + update(myS1, fill, stroke, strokeWidth, transform) + update(myS2, fill, stroke, strokeWidth, transform) } override fun appendTo(g: SvgSlimGroup) { diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Label.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Label.kt index 3c40cb7a598..f7e1ee29226 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Label.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Label.kt @@ -59,7 +59,7 @@ class Label( return object : WritableProperty { override fun set(value: Color?) { // set attribute for svg->canvas mapping to work - myLines.forEach(SvgTextElement::fillColor) + myLines.forEach { it.fillColor().set(value) } // duplicate in 'style' to override styles of container myTextColor = value @@ -114,10 +114,6 @@ class Label( horizontalRepositionLines() } - fun setTextOpacity(value: Double?) { - myLines.forEach { it.fillOpacity().set(value) } - } - private fun updateStyleAttribute() { val styleAttr = Text.buildStyle( myTextColor, @@ -255,4 +251,4 @@ class Label( } } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Text.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Text.kt index 38d0d492267..0b0cb8cee49 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Text.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/Text.kt @@ -7,6 +7,7 @@ package org.jetbrains.letsPlot.core.plot.base.render.svg import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.datamodel.svg.dom.SvgConstants +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils object Text { @@ -63,22 +64,22 @@ object Text { ): String { val sb = StringBuilder() if (textColor != null) { - sb.append("fill:").append(textColor.toHexColor()).append(';') + sb.append(SvgUtils.fillAndOpacityStyle(textColor)) } // set each property separately if (!fontStyle.isNullOrBlank()) { - sb.append("font-style:").append(fontStyle).append(';') + sb.append("font-style:$fontStyle;") } if (!fontWeight.isNullOrEmpty()) { - sb.append("font-weight:").append(fontWeight).append(';') + sb.append("font-weight:$fontWeight;") } if (fontSize != null && fontSize > 0) { - sb.append("font-size:").append(fontSize).append("px;") + sb.append("font-size:${fontSize}px;") } if (!fontFamily.isNullOrEmpty()) { - sb.append("font-family:").append(fontFamily).append(';') + sb.append("font-family:$fontFamily;") } return sb.toString() } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/tooltip/TooltipRenderer.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/tooltip/TooltipRenderer.kt index 9b47a6c6e00..b1c0b0b7d84 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/tooltip/TooltipRenderer.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/tooltip/TooltipRenderer.kt @@ -36,6 +36,7 @@ import org.jetbrains.letsPlot.datamodel.svg.dom.SvgGElement import org.jetbrains.letsPlot.datamodel.svg.dom.SvgGraphicsElement.Visibility import org.jetbrains.letsPlot.datamodel.svg.dom.SvgNode import org.jetbrains.letsPlot.datamodel.svg.dom.SvgRectElement +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils import org.jetbrains.letsPlot.datamodel.svg.style.StyleSheet @@ -83,7 +84,7 @@ class TooltipRenderer( fadeEffectRect = SvgRectElement().apply { width().set(0.0) height().set(0.0) - fillColor().set(plotBackground.changeAlpha((255 * 0.7).toInt())) + fillColor().set(plotBackground.withOpacity(0.7)) visibility().set(Visibility.HIDDEN) decorationLayer.children().add(0, this) } @@ -358,7 +359,7 @@ class TooltipRenderer( val fillColor = when { spec.tooltipHint.placement == X_AXIS -> xAxisTheme.tooltipFill() spec.tooltipHint.placement == Y_AXIS -> yAxisTheme.tooltipFill() - spec.isSide -> (spec.fill ?: WHITE).let { mimicTransparency(it, it.alpha / 255.0, WHITE) } + spec.isSide -> (spec.fill ?: WHITE).let { mimicTransparency(it, SvgUtils.opacity(it), WHITE) } else -> tooltipsTheme.tooltipFill() } diff --git a/plot-base/src/commonTest/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtilTest.kt b/plot-base/src/commonTest/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtilTest.kt new file mode 100644 index 00000000000..7bb700d8776 --- /dev/null +++ b/plot-base/src/commonTest/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsUtilTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.core.plot.base.aes + +import org.jetbrains.letsPlot.commons.values.Color +import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics +import kotlin.test.Test +import kotlin.test.assertEquals + +class AestheticsUtilTest { + + private fun point( + color: Color = Color.RED, + fill: Color = Color.BLUE, + alpha: Double? = null, + segmentAlpha: Double? = null + ): DataPointAesthetics { + val builder = AestheticsBuilder(1) + .color(AestheticsBuilder.constant(color)) + .fill(AestheticsBuilder.constant(fill)) + if (alpha != null) { + builder.alpha(AestheticsBuilder.constant(alpha)) + } + if (segmentAlpha != null) { + builder.segmentAlpha(AestheticsBuilder.constant(segmentAlpha)) + } + return builder.build().dataPoints().first() + } + + // --- resolveColor() / resolveFill() --- + + @Test + fun `resolveFill with explicit fill no explicit alpha leaves color unchanged`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveFill(point(fill = color), color) + assertEquals(color, resolved) + } + + @Test + fun `resolveFill with default alpha sentinel leaves color alpha unchanged`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveFill(point(fill = color, alpha = AesInitValue.DEFAULT_ALPHA), color) + assertEquals(128, resolved.alpha) + } + + @Test + fun `resolveFill with explicit fill explicit alpha replaces color alpha`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveFill(point(fill = color, alpha = 0.25), color) + assertEquals(64, resolved.alpha) + } + + @Test + fun `resolveFill with explicit zero alpha makes color transparent`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveFill(point(fill = color, alpha = 0.0), color) + assertEquals(0, resolved.alpha) + } + + @Test + fun `resolveColor applyAlpha=true no explicit alpha - alpha comes from color`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveColor(point(color = color), applyAlpha = true) + assertEquals(128, resolved.alpha) + } + + @Test + fun `resolveColor applyAlpha=true explicit alpha - alpha comes from aesthetic`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveColor(point(color = color, alpha = 0.25), applyAlpha = true) + assertEquals(64, resolved.alpha) + } + + @Test + fun `resolveColor applyAlpha=false always uses color's alpha regardless of aesthetic`() { + val color = Color(255, 0, 0, 128) + val resolved = AestheticsUtil.resolveColor(point(color = color, alpha = 0.25), applyAlpha = false) + assertEquals(128, resolved.alpha) + } + + @Test + fun `resolveFill applies explicit alpha to fill color`() { + val fill = Color(0, 0, 255, 128) + val resolved = AestheticsUtil.resolveFill(point(fill = fill, alpha = 0.25)) + assertEquals(64, resolved.alpha) + assertEquals(fill.red, resolved.red) + assertEquals(fill.green, resolved.green) + assertEquals(fill.blue, resolved.blue) + } + + @Test + fun `effectiveSegmentAlpha uses alpha when segment alpha is not explicitly set`() { + assertEquals(0.25, AestheticsUtil.effectiveSegmentAlpha(point(alpha = 0.25))) + } + + @Test + fun `effectiveSegmentAlpha uses explicit segment alpha`() { + assertEquals(0.5, AestheticsUtil.effectiveSegmentAlpha(point(alpha = 0.25, segmentAlpha = 0.5))) + } +} diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/defaultTheme/DefaultGeomTheme.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/defaultTheme/DefaultGeomTheme.kt index 2b31ddb85d9..3ffdc53f981 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/defaultTheme/DefaultGeomTheme.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/defaultTheme/DefaultGeomTheme.kt @@ -6,7 +6,6 @@ package org.jetbrains.letsPlot.core.plot.builder.defaultTheme import org.jetbrains.letsPlot.commons.values.Color -import org.jetbrains.letsPlot.commons.values.Colors import org.jetbrains.letsPlot.core.plot.base.GeomKind import org.jetbrains.letsPlot.core.plot.base.aes.AesInitValue.DEFAULT_ALPHA import org.jetbrains.letsPlot.core.plot.base.aes.AesInitValue.DEFAULT_SEGMENT_COLOR @@ -140,7 +139,7 @@ internal class DefaultGeomTheme private constructor( GeomKind.RECT, GeomKind.RIBBON, GeomKind.BAND, - GeomKind.MAP -> Colors.withOpacity(color, 0.1) + GeomKind.MAP -> color.withOpacity(0.1) GeomKind.BAR, GeomKind.PIE, @@ -171,4 +170,4 @@ internal class DefaultGeomTheme private constructor( return DefaultGeomTheme(color, fill, alpha, size, lineWidth, colorTheme.pen(), pointSize, segmentColor, segmentSize, segmentAlpha) } } -} \ No newline at end of file +} diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/PaletteGenerator.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/PaletteGenerator.kt index 8150c75fc0a..ad28c706266 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/PaletteGenerator.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/PaletteGenerator.kt @@ -18,8 +18,8 @@ interface PaletteGenerator { val scaleMapper = createPaletteGeneratorScaleMapper(colorCount) return (0 until colorCount).map { i -> - scaleMapper(i.toDouble())?.toHexColor() + scaleMapper(i.toDouble())?.toHexColorNoAlpha() ?: throw IllegalStateException("Can't generate a palette color for index: $i") } } -} \ No newline at end of file +} diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/mapper/ColorMapperDefaults.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/mapper/ColorMapperDefaults.kt index 6f989270c34..c941e67e48f 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/mapper/ColorMapperDefaults.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/mapper/ColorMapperDefaults.kt @@ -44,7 +44,7 @@ object ColorMapperDefaults { Gradient.DEF_LOW, Gradient.DEF_HIGH, NA_VALUE, - alpha = 1.0 + opacity = 1.0 ) } } diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapMapperProvider.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapMapperProvider.kt index d4aa26f71ce..350f1fb4560 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapMapperProvider.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapMapperProvider.kt @@ -31,7 +31,7 @@ import org.jetbrains.letsPlot.core.plot.builder.scale.mapper.GuideMappers * - "turbo" * - "twilight" * - * @param alpha Alpha transparency channel. (0 means transparent and 1 means opaque). + * @param opacity Opacity. (0 means transparent and 1 means opaque). * @param begin Corresponds to a color hue to start at. * @param end Corresponds to a color hue to end with. * @param direction Sets the order of colors in the scale. If 1, the default, colors are as output by brewer.pal. @@ -40,7 +40,7 @@ import org.jetbrains.letsPlot.core.plot.builder.scale.mapper.GuideMappers */ class ColormapMapperProvider( cmapName: String?, - alpha: Double?, + opacity: Double?, begin: Double?, end: Double?, private val direction: Double?, @@ -49,13 +49,13 @@ class ColormapMapperProvider( PaletteGenerator { private val cmapName = cmapName ?: VIRIDIS - private val alpha = alpha ?: 1.0 + private val opacity = opacity ?: 1.0 private val begin = begin ?: 0.0 private val end = end ?: 1.0 init { val r01 = DoubleSpan(0.0, 1.0) - require(r01.contains(this.alpha)) { "'alpha' should be in range [0..1]" } + require(r01.contains(this.opacity)) { "'opacity' should be in range [0..1]" } require(r01.contains(this.begin)) { "'begin' should be in range [0..1]" } require(r01.contains(this.end)) { "'end' should be in range [0..1]" } } @@ -71,12 +71,12 @@ class ColormapMapperProvider( @Suppress("NAME_SHADOWING") val domain = MapperUtil.rangeWithLimitsAfterTransform(domain, trans) - val gradient = createGradient(domain, colors, naValue, alpha) + val gradient = createGradient(domain, colors, naValue, opacity) return GuideMappers.asContinuous(ScaleMapper.wrap(gradient)) } private fun colors(n: Int? = null): List { - val colors = ColorMaps.getColors(cmapName, alpha, DoubleSpan(begin, end), n) + val colors = ColorMaps.getColors(cmapName, opacity, DoubleSpan(begin, end), n) return when (direction?.let { direction < 0 } ?: false) { true -> colors.reversed() false -> colors diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/HclColorMapperProvider.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/HclColorMapperProvider.kt index 2164f89f912..7ae0e064a09 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/HclColorMapperProvider.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/HclColorMapperProvider.kt @@ -19,12 +19,12 @@ abstract class HclColorMapperProvider(naValue: Color) : MapperProviderBase, from: HCL, to: HCL): ScaleMapper { val mapperDomain = ensureApplicableRange(DoubleSpan.encloseAllQ(transformedDomain)) - val gradientMapper = GradientUtil.gradientHCL(mapperDomain, from, to, naValue, alpha = 1.0) + val gradientMapper = GradientUtil.gradientHCL(mapperDomain, from, to, naValue, opacity = 1.0) return GuideMappers.asNotContinuous(ScaleMapper.wrap(gradientMapper)) } protected fun createContinuousMapper(domain: DoubleSpan, from: HCL, to: HCL): GuideMapper { - val gradientMapper = GradientUtil.gradientHCL(domain, from, to, naValue, alpha = 1.0) + val gradientMapper = GradientUtil.gradientHCL(domain, from, to, naValue, opacity = 1.0) return GuideMappers.asContinuous(ScaleMapper.wrap(gradientMapper)) } } diff --git a/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapPaletteGeneratorTest.kt b/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapPaletteGeneratorTest.kt index bfac81b35ba..7fe9ce57116 100644 --- a/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapPaletteGeneratorTest.kt +++ b/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/scale/provider/ColormapPaletteGeneratorTest.kt @@ -15,7 +15,7 @@ class ColormapPaletteGeneratorTest { fun `respects direction parameter`() { val provider = ColormapMapperProvider( cmapName = "viridis", - alpha = 1.0, + opacity = 1.0, begin = 0.0, end = 1.0, direction = 1.0, @@ -24,7 +24,7 @@ class ColormapPaletteGeneratorTest { val providerReversed = ColormapMapperProvider( cmapName = "viridis", - alpha = 1.0, + opacity = 1.0, begin = 0.0, end = 1.0, direction = -1.0, diff --git a/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointLiveMapAesthetics.kt b/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointLiveMapAesthetics.kt index 8d82bb1d224..a550830ad81 100644 --- a/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointLiveMapAesthetics.kt +++ b/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointLiveMapAesthetics.kt @@ -109,7 +109,7 @@ internal class DataPointLiveMapAesthetics { val angle get() = myP.angle()!! val shape get() = myP.shape()!!.code val size get() = AestheticsUtil.textSize(myP) - val fillColor get() = colorWithAlpha(myP.fill()!!) + val fillColor get() = AestheticsUtil.resolveFill(myP) val label get() = myP.label()?.toString() ?: "n/a" val lineheight get() = myP.lineheight()!! @@ -179,7 +179,7 @@ internal class DataPointLiveMapAesthetics { val strokeColor get() = when (myLayerKind) { POLYGON, PIE -> myP.color()!! - else -> colorWithAlpha(myP.color()!!) + else -> AestheticsUtil.resolveColor(myP, applyAlpha = true) } private fun pointRadius(size: Double) = ceil(size / 2.0) @@ -200,7 +200,7 @@ internal class DataPointLiveMapAesthetics { } val fillArray: List - get() = myFillArray.map(::colorWithAlpha) + get() = myFillArray.map { AestheticsUtil.resolveFill(myP, it) } val sizeStart get() = pointRadius(AestheticsUtil.circleDiameter(myP, DataPointAesthetics::sizeStart)).px * 2.0 @@ -244,10 +244,6 @@ internal class DataPointLiveMapAesthetics { val clockwise: Boolean get() = myPieOptions?.clockwise != false - private fun colorWithAlpha(color: Color): Color { - return color.changeAlpha((AestheticsUtil.alpha(color, myP) * 255).toInt()) - } - fun setArrowSpec(arrowSpec: ArrowSpec?): DataPointLiveMapAesthetics { myPlotArrowSpec = arrowSpec return this @@ -274,4 +270,4 @@ internal class DataPointLiveMapAesthetics { private fun trimLonLat(p: Vec): Vec { return Vec(normalizeLon(p.x), limitLat(p.y)) } -} \ No newline at end of file +} diff --git a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/DebugOptions.kt b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/DebugOptions.kt index e6989a4240b..be8f64b4683 100644 --- a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/DebugOptions.kt +++ b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/DebugOptions.kt @@ -8,8 +8,6 @@ package org.jetbrains.letsPlot.raster.mapping.svg import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.canvas.Context2d import org.jetbrains.letsPlot.raster.scene.* -import kotlin.math.roundToInt - internal object DebugOptions { const val DEBUG_DRAWING_ENABLED: Boolean = false @@ -31,8 +29,8 @@ internal object DebugOptions { else -> Color.LIGHT_GRAY } - val fillColor = color.changeAlpha((255*0.02).roundToInt()) - val strokeColor = color.changeAlpha((255*0.7).roundToInt()) + val fillColor = color.withOpacity(0.02) + val strokeColor = color.withOpacity(0.7) ctx.setFillStyle(fillColor) ctx.setStrokeStyle(strokeColor) diff --git a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/SvgTextElementMapper.kt b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/SvgTextElementMapper.kt index 3d46d85c740..427d62a3ef5 100644 --- a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/SvgTextElementMapper.kt +++ b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/mapping/svg/SvgTextElementMapper.kt @@ -15,6 +15,7 @@ import org.jetbrains.letsPlot.core.canvas.FontStyle import org.jetbrains.letsPlot.core.canvas.FontWeight import org.jetbrains.letsPlot.datamodel.mapping.framework.Synchronizers import org.jetbrains.letsPlot.datamodel.svg.dom.* +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils import org.jetbrains.letsPlot.datamodel.svg.style.StyleSheet import org.jetbrains.letsPlot.datamodel.svg.style.TextStyle import org.jetbrains.letsPlot.raster.mapping.svg.attr.SvgTSpanElementAttrMapping @@ -66,7 +67,8 @@ internal class SvgTextElementMapper( target.fontStyle = toFontStyle(style.face) target.fontWeight = toFontWeight(style.face) - myTextAttrSupport.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "fill:${style.color.toHexColor()};") + val styleAttr = SvgUtils.fillAndOpacityStyle(style.color) + myTextAttrSupport.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, styleAttr) } } diff --git a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Figure.kt b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Figure.kt index 044162b8960..2a0ad8e66cc 100644 --- a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Figure.kt +++ b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Figure.kt @@ -85,7 +85,7 @@ internal abstract class Figure : Node() { val paint = Paint() paint.isStroke = true - paint.color = stroke.multiplyAlpha(strokeOpacity.toDouble()) + paint.color = stroke.multiplyOpacity(strokeOpacity.toDouble()) paint.strokeWidth = strokeWidth strokeMiter?.let { paint.strokeMiter = it } strokeDashArray.let { paint.strokeDashList = it.toDoubleArray() } @@ -97,8 +97,8 @@ internal abstract class Figure : Node() { if (fill == null) return null return Paint().also { paint -> - // opacity and alpha should be multiplied - paint.color = fill.multiplyAlpha(fillOpacity.toDouble()) + // SVG opacity and color alpha channel should be multiplied. + paint.color = fill.multiplyOpacity(fillOpacity.toDouble()) } } @@ -144,4 +144,4 @@ internal abstract class Figure : Node() { fillEvenOdd() } } -} \ No newline at end of file +} diff --git a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/TSpan.kt b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/TSpan.kt index 793ab5a6dbd..db3a7a7bb67 100644 --- a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/TSpan.kt +++ b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/TSpan.kt @@ -46,8 +46,8 @@ internal class TSpan : Figure() { private val styleData: StyleData by derivedAttr { StyleData( - fillPaint = fillPaint(fill), - strokePaint = strokePaint(stroke = stroke, strokeWidth = strokeWidth) + fillPaint = fillPaint(fill, fillOpacity), + strokePaint = strokePaint(stroke = stroke, strokeWidth = strokeWidth, strokeOpacity = strokeOpacity) ) } @@ -137,6 +137,9 @@ internal class TSpan : Figure() { ) val LineHeightAttrSpec = CLASS.registerDerivedAttr(TSpan::lineHeight, dependencies = setOf(FontAttrSpec)) val BaselineAttrSpec = CLASS.registerDerivedAttr(TSpan::baseline, dependencies = setOf(BaselineShiftAttrSpec, DyAttrSpec, LineHeightAttrSpec)) - val StyleDataAttrSpec = CLASS.registerDerivedAttr(TSpan::styleData, dependencies = setOf(FillAttrSpec, StrokeAttrSpec, StrokeWidthAttrSpec)) + val StyleDataAttrSpec = CLASS.registerDerivedAttr( + TSpan::styleData, + dependencies = setOf(FillAttrSpec, FillOpacityAttrSpec, StrokeAttrSpec, StrokeWidthAttrSpec, StrokeOpacityAttrSpec) + ) } } diff --git a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Text.kt b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Text.kt index 721ce65fd4d..828c19a4562 100644 --- a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Text.kt +++ b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Text.kt @@ -97,6 +97,7 @@ internal class Text : Container() { // propagate Text attributes to TSpan children using Figure and TSpan attrSpecs when (attrSpec) { FillAttrSpec -> tSpan.inheritValue(Figure.FillAttrSpec, fill) + FillOpacityAttrSpec -> tSpan.inheritValue(Figure.FillOpacityAttrSpec, fillOpacity) StrokeAttrSpec -> tSpan.inheritValue(Figure.StrokeAttrSpec, stroke) StrokeWidthAttrSpec -> tSpan.inheritValue(Figure.StrokeWidthAttrSpec, strokeWidth) StrokeDashArrayAttrSpec -> tSpan.inheritValue(Figure.StrokeDashArrayAttrSpec, strokeDashArray) @@ -115,6 +116,7 @@ internal class Text : Container() { val tSpan = event.newItem as TSpan tSpan.inheritValue(Figure.FillAttrSpec, fill) + tSpan.inheritValue(Figure.FillOpacityAttrSpec, fillOpacity) tSpan.inheritValue(Figure.StrokeAttrSpec, stroke) tSpan.inheritValue(Figure.StrokeWidthAttrSpec, strokeWidth) tSpan.inheritValue(Figure.StrokeOpacityAttrSpec, strokeOpacity) diff --git a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Util.kt b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Util.kt index 6702f4e7371..b6a5dd92541 100644 --- a/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Util.kt +++ b/plot-raster/src/commonMain/kotlin/org/jetbrains/letsPlot/raster/scene/Util.kt @@ -6,10 +6,8 @@ package org.jetbrains.letsPlot.raster.scene import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle -import org.jetbrains.letsPlot.commons.values.Color import kotlin.math.max import kotlin.math.min -import kotlin.math.roundToInt internal fun union(rects: List): DoubleRectangle? = rects.fold(null) { acc, rect -> @@ -77,4 +75,3 @@ internal fun reversedDepthFirstTraversal(node: Node): Sequence { return enumerate(node) } -fun Color.changeAlpha(a: Float) = changeAlpha((255 * a).roundToInt()) diff --git a/plot-raster/src/jvmTest/kotlin/org/jetbrains/letsPlot/raster/scene/TextTest.kt b/plot-raster/src/jvmTest/kotlin/org/jetbrains/letsPlot/raster/scene/TextTest.kt index d5ef703c6b4..6e59458db26 100644 --- a/plot-raster/src/jvmTest/kotlin/org/jetbrains/letsPlot/raster/scene/TextTest.kt +++ b/plot-raster/src/jvmTest/kotlin/org/jetbrains/letsPlot/raster/scene/TextTest.kt @@ -123,6 +123,50 @@ class TextTest { } } + @Test + fun `text fill opacity should propagate to child tspan`() { + val doc = mapSvg { + svgDocument(width = 400, height = 300) { + text(fill = SvgColors.BLUE, id = "text") { + fillOpacity().set(0.0) + tspan(text = "Hello", id = "tspan") + } + } + } + + doc.findElement("tspan").let { + assertThat(it.fill).isEqualTo(Color.BLUE) + assertThat(it.fillOpacity).isEqualTo(0f) + assertThat(it.fillPaint?.color?.alpha).isEqualTo(0) + } + } + + @Test + fun `text class style color alpha should propagate to child tspan`() { + val doc = mapSvg { + svgDocument(width = 400, height = 300) { + style( + """ + .text-style { + fill: #ff0000; + fill-opacity: 0; + } + """.trimIndent() + ) + + text(id = "text", styleClass = "text-style") { + tspan(text = "Hello", id = "tspan") + } + } + } + + doc.findElement("tspan").let { + assertThat(it.fill).isEqualTo(Color.RED) + assertThat(it.fillOpacity).isEqualTo(0f) + assertThat(it.fillPaint?.color?.alpha).isEqualTo(0) + } + } + @Test fun `tspan without attr and style in parent`() { val doc = mapSvg { diff --git a/plot-stem/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/spec/plotson/InlineOptionsTest.kt b/plot-stem/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/spec/plotson/InlineOptionsTest.kt index e5f923590a5..3b23cd15dea 100644 --- a/plot-stem/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/spec/plotson/InlineOptionsTest.kt +++ b/plot-stem/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/spec/plotson/InlineOptionsTest.kt @@ -6,6 +6,7 @@ package org.jetbrains.letsPlot.core.spec.plotson import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.letsPlot.commons.values.Color import java.util.Map.entry import kotlin.test.Test @@ -182,4 +183,20 @@ class InlineOptionsTest { ) ) } + + @Test + fun `color options preserve alpha`() { + class ColorOpts : Options() { + var color: Color? by map("COLOR") + } + + val opts = ColorOpts().apply { + color = Color(0x11, 0x22, 0x33, 0x44) + } + + @Suppress("UNCHECKED_CAST") + val json = toJson(opts) as Map + + assertThat(json).containsOnly(entry("COLOR", "#11223344")) + } } diff --git a/python-package/lets_plot/plot/theme_.py b/python-package/lets_plot/plot/theme_.py index 101a26d5457..fd151729364 100644 --- a/python-package/lets_plot/plot/theme_.py +++ b/python-package/lets_plot/plot/theme_.py @@ -525,9 +525,13 @@ def element_rect( Parameters ---------- fill : str - Fill color. + Fill color. Supports named colors, ``rgb(...)``, ``rgba(...)``, ``color(...)``, + ``#RRGGBB``, ``#RRGGBBAA``, ``#RGB``, ``#RGBA``, and named colors with + an opacity suffix, for example ``steelblue / 0.35``. color : str - Border color. + Border color. Supports named colors, ``rgb(...)``, ``rgba(...)``, ``color(...)``, + ``#RRGGBB``, ``#RRGGBBAA``, ``#RGB``, ``#RGBA``, and named colors with + an opacity suffix, for example ``steelblue / 0.35``. size : int Border size. linetype : int or str or list @@ -577,7 +581,9 @@ def element_line( Parameters ---------- color : str - Line color. + Line color. Supports named colors, ``rgb(...)``, ``rgba(...)``, ``color(...)``, + ``#RRGGBB``, ``#RRGGBBAA``, ``#RGB``, ``#RGBA``, and named colors with + an opacity suffix, for example ``steelblue / 0.35``. size : int Line size. linetype : int or str or list @@ -632,7 +638,9 @@ def element_text( Parameters ---------- color : str - Text color. + Text color. Supports named colors, ``rgb(...)``, ``rgba(...)``, ``color(...)``, + ``#RRGGBB``, ``#RRGGBBAA``, ``#RGB``, ``#RGBA``, and named colors with + an opacity suffix, for example ``steelblue / 0.35``. family : str Font family. face : str @@ -714,7 +722,9 @@ def element_markdown( Parameters ---------- color : str - Text color. + Text color. Supports named colors, ``rgb(...)``, ``rgba(...)``, ``color(...)``, + ``#RRGGBB``, ``#RRGGBBAA``, ``#RGB``, ``#RGBA``, and named colors with + an opacity suffix, for example ``steelblue / 0.35``. family : str Font family. face : {'plain', 'italic', 'bold', 'bold_italic'}, default='plain' @@ -839,4 +849,4 @@ def element_geom( )) """ - return locals() \ No newline at end of file + return locals() diff --git a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/canvas/CanvasDrawImageTest.kt b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/canvas/CanvasDrawImageTest.kt index cfad7bab031..8e899393dff 100644 --- a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/canvas/CanvasDrawImageTest.kt +++ b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/canvas/CanvasDrawImageTest.kt @@ -99,17 +99,17 @@ class CanvasDrawImageTest( fun canvas_drawImage_snapshotSeries(): Bitmap { val (tempCanvas, tempCtx) = createCanvas() - tempCtx.fillStyle = Color.BLACK.changeAlpha(0.5) + tempCtx.fillStyle = Color.BLACK.withOpacity(0.5) tempCtx.fillRect(0, 0, 50, 50) tempCanvas.takeSnapshot() - tempCtx.fillStyle = Color.RED.changeAlpha(0.5) + tempCtx.fillStyle = Color.RED.withOpacity(0.5) tempCtx.fillRect(25, 25, 50, 50) tempCanvas.takeSnapshot() - tempCtx.fillStyle = Color.BLUE.changeAlpha(0.5) + tempCtx.fillStyle = Color.BLUE.withOpacity(0.5) tempCtx.fillRect(50, 50, 50, 50) val snapshot = tempCanvas.takeSnapshot()