diff --git a/future_changes.md b/future_changes.md index 0444da02773..08ae732eea9 100644 --- a/future_changes.md +++ b/future_changes.md @@ -15,10 +15,14 @@ ### Changed +- [BREAKING] `geom_boxplot()` no longer support parameter `sampling`. + + - Reduce the default `width`/`height` values for `geom_errorbar()`. ### Fixed + - ggsave: saving geomImshow() to SVG produces fuzzy picture [[LPK-188](https://github.com/JetBrains/lets-plot-kotlin/issues/188)]. - ggsave: saving geomImshow() to raster format produces fuzzy picture. - geom_livemap: memory leak when re-run cells without reloading a page diff --git a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/GeomMeta.kt b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/GeomMeta.kt index 085787a0c0a..0bcf7a2faca 100644 --- a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/GeomMeta.kt +++ b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/GeomMeta.kt @@ -229,12 +229,11 @@ object GeomMeta { ) GeomKind.BOX_PLOT -> listOf( - Aes.LOWER, // NaN for 'outlier' data-point - Aes.MIDDLE, // NaN for 'outlier' data-point - Aes.UPPER, // NaN for 'outlier' data-point + Aes.LOWER, + Aes.MIDDLE, + Aes.UPPER, Aes.X, - Aes.Y, // NaN for 'box' data-point (used for outliers) Aes.YMAX, Aes.YMIN, diff --git a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/geom/BoxplotGeom.kt b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/geom/BoxplotGeom.kt index 3156d51a544..1836f03be16 100644 --- a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/geom/BoxplotGeom.kt +++ b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/geom/BoxplotGeom.kt @@ -7,29 +7,19 @@ package jetbrains.datalore.plot.base.geom import jetbrains.datalore.base.geometry.DoubleRectangle import jetbrains.datalore.base.geometry.DoubleVector -import jetbrains.datalore.base.values.Color import jetbrains.datalore.plot.base.* -import jetbrains.datalore.plot.base.aes.AestheticsDefaults import jetbrains.datalore.plot.base.geom.util.* import jetbrains.datalore.plot.base.geom.util.GeomUtil.extendHeight import jetbrains.datalore.plot.base.geom.util.HintColorUtil.colorWithAlpha -import jetbrains.datalore.plot.base.interact.NullGeomTargetCollector import jetbrains.datalore.plot.base.interact.TipLayoutHint import jetbrains.datalore.plot.base.render.LegendKeyElementFactory import jetbrains.datalore.plot.base.render.SvgRoot -import jetbrains.datalore.plot.base.render.point.PointShape import jetbrains.datalore.vis.svg.SvgLineElement class BoxplotGeom : GeomBase() { - var fattenMidline: Double = 1.0 - var whiskerWidth: Double = 0.5 - - var outlierColor: Color? = null - var outlierFill: Color? = null - var outlierShape: PointShape? = null - var outlierSize: Double? = null - var outlierStroke: Double? = null + var fattenMidline: Double = DEF_FATTEN_MIDLINE + var whiskerWidth: Double = DEF_WHISKER_WIDTH override val legendKeyElementFactory: LegendKeyElementFactory get() = LEGEND_FACTORY @@ -47,7 +37,6 @@ class BoxplotGeom : GeomBase() { clientRectByDataPoint(ctx, geomHelper, isHintRect = false) ) buildLines(root, aesthetics, ctx, geomHelper) - buildOutliers(root, aesthetics, pos, coord, ctx) BarTooltipHelper.collectRectangleTargets( listOf(Aes.YMAX, Aes.UPPER, Aes.MIDDLE, Aes.LOWER, Aes.YMIN), aesthetics, pos, coord, ctx, @@ -121,59 +110,12 @@ class BoxplotGeom : GeomBase() { } } - private fun buildOutliers( - root: SvgRoot, - aesthetics: Aesthetics, - pos: PositionAdjustment, - coord: CoordinateSystem, - ctx: GeomContext - ) { - val outlierAesthetics = getOutliersAesthetics(aesthetics) - PointGeom() - .buildIntern(root, outlierAesthetics, pos, coord, ctx.withTargetCollector(NullGeomTargetCollector())) - } - - private fun getOutliersAesthetics(aesthetics: Aesthetics): Aesthetics { - return MappedAesthetics(aesthetics) { p -> - toOutlierDataPointAesthetics(p) - } - } - - /** - * The geom `Aesthetics` contains both: reqular data-points and "outlier" data-points. - * Regular data-point do not yave Y defined. We use this feature to feature to - * detect regular data-points and ignore them. - */ - private fun toOutlierDataPointAesthetics(p: DataPointAesthetics): DataPointAesthetics { - if (!p.defined(Aes.Y)) { - // not an "outlier" data-point - return p - } - - return object : DataPointAestheticsDelegate(p) { - override operator fun get(aes: Aes): T? { - val value: Any? = when (aes) { - Aes.COLOR -> outlierColor ?: super.get(aes) - Aes.FILL -> outlierFill ?: super.get(aes) - Aes.SHAPE -> outlierShape ?: super.get(aes) - Aes.SIZE -> outlierSize ?: OUTLIER_DEF_SIZE // 'size' of 'super' is line thickness on box-plot - Aes.STROKE -> outlierStroke ?: OUTLIER_DEF_STROKE // other elements of boxplot has no 'stroke' aes - Aes.ALPHA -> 1.0 // Don't apply boxplot' alpha to outlier points. - else -> super.get(aes) - } - @Suppress("UNCHECKED_CAST") - return value as T? - } - } - } - - companion object { + const val DEF_FATTEN_MIDLINE = 2.5 + const val DEF_WHISKER_WIDTH = 0.5 const val HANDLES_GROUPS = false private val LEGEND_FACTORY = CrossBarHelper.legendFactory(true) - private val OUTLIER_DEF_SIZE = AestheticsDefaults.point().defaultValue(Aes.SIZE) - private val OUTLIER_DEF_STROKE = AestheticsDefaults.point().defaultValue(Aes.STROKE) private fun clientRectByDataPoint( ctx: GeomContext, diff --git a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotOutlierStat.kt b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotOutlierStat.kt new file mode 100644 index 00000000000..8c92aec56ea --- /dev/null +++ b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotOutlierStat.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package jetbrains.datalore.plot.base.stat + +import jetbrains.datalore.plot.base.Aes +import jetbrains.datalore.plot.base.DataFrame +import jetbrains.datalore.plot.base.StatContext +import jetbrains.datalore.plot.base.data.TransformVar +import jetbrains.datalore.plot.common.data.SeriesUtil +import kotlin.math.sqrt + +class BoxplotOutlierStat( + private val whiskerIQRRatio: Double, // ggplot: 'coef' + private val computeWidth: Boolean // ggplot: 'varWidth' +) : BaseStat(DEF_MAPPING) { + // Note: outliers will need 'width' value, for the 'dodge' positioning to work correctly for all data-points. + + override fun hasDefaultMapping(aes: Aes<*>): Boolean { + return super.hasDefaultMapping(aes) || + aes == Aes.WIDTH && computeWidth + } + + override fun getDefaultMapping(aes: Aes<*>): DataFrame.Variable { + return if (aes == Aes.WIDTH) { + Stats.WIDTH + } else { + super.getDefaultMapping(aes) + } + } + + override fun consumes(): List> { + return listOf(Aes.X, Aes.Y) + } + + override fun apply(data: DataFrame, statCtx: StatContext, messageConsumer: (s: String) -> Unit): DataFrame { + if (!hasRequiredValues(data, Aes.Y)) { + return withEmptyStatValues() + } + + val ys = data.getNumeric(TransformVar.Y) + val xs = if (data.has(TransformVar.X)) { + data.getNumeric(TransformVar.X) + } else { + List(ys.size) { 0.0 } + } + + val statData = buildStat(xs, ys, whiskerIQRRatio) + val statCount = statData.remove(Stats.COUNT) + + if (computeWidth) { + // 'width' is in range 0..1 + val maxCountPerBin = statCount?.maxOrNull()?.toInt() ?: 0 + val norm = sqrt(maxCountPerBin.toDouble()) + val statWidth = statCount!!.map { count -> sqrt(count) / norm } + statData[Stats.WIDTH] = statWidth + } + + val builder = DataFrame.Builder() + for ((variable, series) in statData) { + builder.putNumeric(variable, series) + } + return builder.build() + } + + companion object { + private val DEF_MAPPING: Map, DataFrame.Variable> = mapOf( + Aes.X to Stats.X, + Aes.Y to Stats.Y + ) + + private fun buildStat( + xs: List, + ys: List, + whiskerIQRRatio: Double + ): MutableMap> { + val xyPairs = SeriesUtil.filterFinite(xs, ys) + .let { (xs, ys) -> xs zip ys } + if (xyPairs.isEmpty()) { + return mutableMapOf() + } + + val binnedData: MutableMap> = HashMap() + for ((x, y) in xyPairs) { + binnedData.getOrPut(x) { ArrayList() }.add(y) + } + + val statX = ArrayList() + val statY = ArrayList() + val statCount = ArrayList() + + for ((x, bin) in binnedData) { + val count = bin.size.toDouble() + val summary = FiveNumberSummary(bin) + val lowerHinge = summary.firstQuartile + val upperHinge = summary.thirdQuartile + val IQR = upperHinge - lowerHinge + val lowerFence = lowerHinge - IQR * whiskerIQRRatio + val upperFence = upperHinge + IQR * whiskerIQRRatio + val outliers = bin.filter { y -> y < lowerFence || y > upperFence } + for (y in outliers) { + statX.add(x) + statY.add(y) + statCount.add(count) + } + + // If there are no outliers, add a fake one to correct splitting for additional grouping + if (outliers.isEmpty() && count > 0) { + statX.add(x) + statY.add(Double.NaN) + statCount.add(count) + } + } + + return mutableMapOf( + Stats.X to statX, + Stats.Y to statY, + Stats.COUNT to statCount + ) + } + } +} \ No newline at end of file diff --git a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotStat.kt b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotStat.kt index 1c9ee47400c..770fc3c746f 100644 --- a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotStat.kt +++ b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/BoxplotStat.kt @@ -16,20 +16,14 @@ import kotlin.math.sqrt /** * Calculate components of box and whisker plot. * - * Creates a "stat" dataframe contaning: - * a) "box" data-points - * x - * y = NaN - * width - width of box - * ymin - lower whisker = smallest observation greater than or equal to lower hinge - 1.5 * IQR - * lower - lower hinge, 25% quantile - * middle - median, 50% quantile - * upper - upper hinge, 75% quantile - * ymax - upper whisker = largest observation less than or equal to upper hinge + 1.5 * IQR - * - * b) "outlier" data-points - * x, y, width - * ymin, lower... = NaN + * Creates a "stat" dataframe with: + * x + * width - width of box + * ymin - lower whisker = smallest observation greater than or equal to lower hinge - 1.5 * IQR + * lower - lower hinge, 25% quantile + * middle - median, 50% quantile + * upper - upper hinge, 75% quantile + * ymax - upper whisker = largest observation less than or equal to upper hinge + 1.5 * IQR * * Not implemented: * notchlower - lower edge of notch = median - 1.58 * IQR / sqrt(n) @@ -66,7 +60,7 @@ class BoxplotStat( val xs = if (data.has(TransformVar.X)) { data.getNumeric(TransformVar.X) } else { - List(ys.size) { 0.0 } + List(ys.size) { 0.0 } } val statData = buildStat(xs, ys, whiskerIQRRatio) @@ -96,7 +90,6 @@ class BoxplotStat( private val DEF_MAPPING: Map, DataFrame.Variable> = mapOf( Aes.X to Stats.X, - Aes.Y to Stats.Y, Aes.YMIN to Stats.Y_MIN, Aes.YMAX to Stats.Y_MAX, Aes.LOWER to Stats.LOWER, @@ -104,26 +97,24 @@ class BoxplotStat( Aes.UPPER to Stats.UPPER ) - fun buildStat( + private fun buildStat( xs: List, ys: List, whiskerIQRRatio: Double ): MutableMap> { - val xyPairs = xs.zip(ys).filter { (x, y) -> - SeriesUtil.allFinite(x, y) - } + val xyPairs = SeriesUtil.filterFinite(xs, ys) + .let { (xs, ys) -> xs zip ys } if (xyPairs.isEmpty()) { return mutableMapOf() } val binnedData: MutableMap> = HashMap() for ((x, y) in xyPairs) { - binnedData.getOrPut(x!!) { ArrayList() }.add(y!!) + binnedData.getOrPut(x) { ArrayList() }.add(y) } val statX = ArrayList() - val statY = ArrayList() val statMiddle = ArrayList() val statLower = ArrayList() val statUpper = ArrayList() @@ -146,7 +137,7 @@ class BoxplotStat( var lowerWhisker = lowerFence var upperWhisker = upperFence if (SeriesUtil.allFinite(lowerFence, upperFence)) { - val boxed = bin.filter { y -> y >= lowerFence && y <= upperFence } + val boxed = bin.filter { y -> y in lowerFence..upperFence } val range = SeriesUtil.range(boxed) if (range != null) { lowerWhisker = range.lowerEnd @@ -154,28 +145,7 @@ class BoxplotStat( } } - // add outliers first - val outliers = bin.filter { y -> y < lowerFence || y > upperFence } - for (y in outliers) { - // 'outlier' data-point - statX.add(x) - statY.add(y) - // no 'box' data - statMiddle.add(Double.NaN) - statLower.add(Double.NaN) - statUpper.add(Double.NaN) - statMin.add(Double.NaN) - statMax.add(Double.NaN) - - statCount.add(count) - - // Note: outliers will also need 'width' value, - // for the 'dodge' positioning to work correctly for all data-points. - } - - // add 'box' data-point statX.add(x) - statY.add(Double.NaN) // no Y for 'box' data-point statMiddle.add(middle) statLower.add(lowerHinge) statUpper.add(upperHinge) @@ -187,7 +157,6 @@ class BoxplotStat( return mutableMapOf( Stats.X to statX, - Stats.Y to statY, Stats.MIDDLE to statMiddle, Stats.LOWER to statLower, Stats.UPPER to statUpper, diff --git a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/Stats.kt b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/Stats.kt index 93c0ece83e0..2ea3f146bdb 100644 --- a/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/Stats.kt +++ b/plot-base-portable/src/commonMain/kotlin/jetbrains/datalore/plot/base/stat/Stats.kt @@ -236,6 +236,13 @@ object Stats { return BoxplotStat(whiskerIQRRatio, computeWidth) } + fun boxplotOutlier( + whiskerIQRRatio: Double = BoxplotStat.DEF_WHISKER_IQR_RATIO, + computeWidth: Boolean = BoxplotStat.DEF_COMPUTE_WIDTH + ): BoxplotOutlierStat { + return BoxplotOutlierStat(whiskerIQRRatio, computeWidth) + } + fun density( trim: Boolean = DensityStat.DEF_TRIM, bandWidth: Double? = null, diff --git a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/GeomLayer.kt b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/GeomLayer.kt index 86fbdf40da1..7d1d7b3037c 100644 --- a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/GeomLayer.kt +++ b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/GeomLayer.kt @@ -56,7 +56,7 @@ interface GeomLayer { val fillByAes: Aes - fun renderedAes(): List> + fun renderedAes(considerOrientation: Boolean = false): List> fun hasBinding(aes: Aes<*>): Boolean diff --git a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/LayerRendererUtil.kt b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/LayerRendererUtil.kt index 4782d4d7696..c75415e6586 100644 --- a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/LayerRendererUtil.kt +++ b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/LayerRendererUtil.kt @@ -25,7 +25,7 @@ object LayerRendererUtil { ) val aesthetics = PlotUtil.createLayerAesthetics( layer, - layer.renderedAes(), + layer.renderedAes(considerOrientation = true), aestheticMappers, ) diff --git a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/PlotUtil.kt b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/PlotUtil.kt index ba67f316237..5abaeada7c8 100644 --- a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/PlotUtil.kt +++ b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/PlotUtil.kt @@ -253,7 +253,7 @@ object PlotUtil { xAesMapper = Mappers.IDENTITY, yAesMapper = Mappers.IDENTITY ) - return createLayerAesthetics(layer, layer.renderedAes(), mappers) + return createLayerAesthetics(layer, layer.renderedAes(considerOrientation = true), mappers) } } } diff --git a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/GeomLayerBuilder.kt b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/GeomLayerBuilder.kt index eeb7c829c18..616bf40bad8 100644 --- a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/GeomLayerBuilder.kt +++ b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/GeomLayerBuilder.kt @@ -23,6 +23,7 @@ import jetbrains.datalore.plot.base.pos.PositionAdjustments import jetbrains.datalore.plot.base.render.LegendKeyElementFactory import jetbrains.datalore.plot.base.stat.SimpleStatContext import jetbrains.datalore.plot.base.stat.Stats +import jetbrains.datalore.plot.base.util.YOrientationBaseUtil import jetbrains.datalore.plot.base.util.afterOrientation import jetbrains.datalore.plot.builder.GeomLayer import jetbrains.datalore.plot.builder.MarginSide @@ -287,8 +288,12 @@ class GeomLayerBuilder( get() = geom is LiveMapGeom - override fun renderedAes(): List> { - return myRenderedAes + override fun renderedAes(considerOrientation: Boolean): List> { + return if (considerOrientation && isYOrientation) { + myRenderedAes.map { YOrientationBaseUtil.flipAes(it) } + } else { + myRenderedAes + } } override fun hasBinding(aes: Aes<*>): Boolean { diff --git a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/PositionalScalesUtil.kt b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/PositionalScalesUtil.kt index 8217a81cac2..52fbfa709e3 100644 --- a/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/PositionalScalesUtil.kt +++ b/plot-builder-portable/src/commonMain/kotlin/jetbrains/datalore/plot/builder/assemble/PositionalScalesUtil.kt @@ -135,7 +135,7 @@ internal object PositionalScalesUtil { } private fun positionalDryRunAesthetics(layer: GeomLayer): Aesthetics { - val aesList = layer.renderedAes().filter { + val aesList = layer.renderedAes(considerOrientation = true).filter { Aes.affectingScaleX(it) || Aes.affectingScaleY(it) || it == Aes.HEIGHT || diff --git a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/GeomProviderFactory.kt b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/GeomProviderFactory.kt index ad017d3d21e..492359c4f23 100644 --- a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/GeomProviderFactory.kt +++ b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/GeomProviderFactory.kt @@ -128,15 +128,6 @@ internal object GeomProviderFactory { if (layerConfig.hasOwn(Option.Geom.Boxplot.WHISKER_WIDTH)) { geom.whiskerWidth = layerConfig.getDouble(Option.Geom.Boxplot.WHISKER_WIDTH)!! } - if (layerConfig.hasOwn(Option.Geom.BoxplotOutlier.COLOR)) { - geom.outlierColor = layerConfig.getColor(Option.Geom.BoxplotOutlier.COLOR)!! - } - if (layerConfig.hasOwn(Option.Geom.BoxplotOutlier.FILL)) { - geom.outlierFill = layerConfig.getColor(Option.Geom.BoxplotOutlier.FILL)!! - } - geom.outlierShape = layerConfig.getShape(Option.Geom.BoxplotOutlier.SHAPE) - geom.outlierSize = layerConfig.getDouble(Option.Geom.BoxplotOutlier.SIZE) - geom.outlierStroke = layerConfig.getDouble(Option.Geom.BoxplotOutlier.STROKE) geom } diff --git a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/Option.kt b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/Option.kt index c501588b33e..b7ec0b3a26b 100644 --- a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/Option.kt +++ b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/Option.kt @@ -216,14 +216,6 @@ object Option { const val WHISKER_WIDTH = "whisker_width" } - object BoxplotOutlier { - const val COLOR = "outlier_color" - const val FILL = "outlier_fill" - const val SHAPE = "outlier_shape" - const val SIZE = "outlier_size" - const val STROKE = "outlier_stroke" - } - object AreaRidges { const val SCALE = "scale" const val MIN_HEIGHT = "min_height" diff --git a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatKind.kt b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatKind.kt index 58f4a6586cb..f18e925ab36 100644 --- a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatKind.kt +++ b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatKind.kt @@ -18,6 +18,7 @@ enum class StatKind { CONTOUR, CONTOURF, BOXPLOT, + BOXPLOT_OUTLIER, DENSITYRIDGES, YDENSITY, YDOTPLOT, diff --git a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatProto.kt b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatProto.kt index fc5526d2186..ff4ed2ad77f 100644 --- a/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatProto.kt +++ b/plot-config-portable/src/commonMain/kotlin/jetbrains/datalore/plot/config/StatProto.kt @@ -84,7 +84,14 @@ object StatProto { StatKind.BOXPLOT -> { return Stats.boxplot( - whiskerIQRRatio = options.getDoubleDef(Boxplot.COEF, BoxplotStat.DEF_WHISKER_IQR_RATIO), + whiskerIQRRatio = options.getDouble(Boxplot.COEF) ?: BoxplotStat.DEF_WHISKER_IQR_RATIO, + computeWidth = options.getBoolean(Boxplot.VARWIDTH, BoxplotStat.DEF_COMPUTE_WIDTH) + ) + } + + StatKind.BOXPLOT_OUTLIER -> { + return Stats.boxplotOutlier( + whiskerIQRRatio = options.getDouble(Boxplot.COEF) ?: BoxplotStat.DEF_WHISKER_IQR_RATIO, computeWidth = options.getBoolean(Boxplot.VARWIDTH, BoxplotStat.DEF_COMPUTE_WIDTH) ) } diff --git a/plot-config-portable/src/jvmTest/kotlin/plot/YOrientationGeomBuildingTest.kt b/plot-config-portable/src/jvmTest/kotlin/plot/YOrientationGeomBuildingTest.kt index 685c4918d93..5bd67687311 100644 --- a/plot-config-portable/src/jvmTest/kotlin/plot/YOrientationGeomBuildingTest.kt +++ b/plot-config-portable/src/jvmTest/kotlin/plot/YOrientationGeomBuildingTest.kt @@ -19,7 +19,6 @@ import jetbrains.datalore.plot.builder.GeomLayer import jetbrains.datalore.plot.builder.coord.CoordProviders import jetbrains.datalore.plot.config.TestUtil import org.junit.Test -import kotlin.Double.Companion.NaN import kotlin.math.round import kotlin.random.Random import kotlin.test.assertEquals @@ -115,13 +114,12 @@ class YOrientationGeomBuildingTest { } private object ExpectedAes { - val X: List = listOf(0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0) - val Y: List = listOf(-1.9958, 2.912, -2.4416, NaN, -2.5263, 2.8484, 2.6058, NaN) - val LOWER: List = listOf(NaN, NaN, NaN, -0.2541, NaN, NaN, NaN, -0.6743) - val MIDDLE: List = listOf(NaN, NaN, NaN, 0.3333, NaN, NaN, NaN, -0.1441) - val UPPER: List = listOf(NaN, NaN, NaN, 0.767, NaN, NaN, NaN, 0.5131) - val YMIN: List = listOf(NaN, NaN, NaN, -1.7853, NaN, NaN, NaN, -2.3092) - val YMAX: List = listOf(NaN, NaN, NaN, 2.083, NaN, NaN, NaN, 2.0206) + val X: List = listOf(0.0, 1.0) + val LOWER: List = listOf(-0.2541, -0.6743) + val MIDDLE: List = listOf(0.3333, -0.1441) + val UPPER: List = listOf(0.767, 0.5131) + val YMIN: List = listOf(-1.7853, -2.3092) + val YMAX: List = listOf(2.083, 2.0206) } private class GeomLayerStub( @@ -160,7 +158,6 @@ class YOrientationGeomBuildingTest { } assertEquals(ExpectedAes.X, toList(aesthetics, Aes.X), message = "X") - assertEquals(ExpectedAes.Y, toList(aesthetics, Aes.Y), message = "Y") assertEquals(ExpectedAes.LOWER, toList(aesthetics, Aes.LOWER), message = "LOWER") assertEquals(ExpectedAes.MIDDLE, toList(aesthetics, Aes.MIDDLE), message = "MIDDLE") assertEquals(ExpectedAes.UPPER, toList(aesthetics, Aes.UPPER), message = "UPPER") diff --git a/plot-demo-common/src/commonMain/kotlin/jetbrains/datalore/plotDemo/model/plotConfig/BoxPlot.kt b/plot-demo-common/src/commonMain/kotlin/jetbrains/datalore/plotDemo/model/plotConfig/BoxPlot.kt index cb366d8e85f..94ac93d42b9 100644 --- a/plot-demo-common/src/commonMain/kotlin/jetbrains/datalore/plotDemo/model/plotConfig/BoxPlot.kt +++ b/plot-demo-common/src/commonMain/kotlin/jetbrains/datalore/plotDemo/model/plotConfig/BoxPlot.kt @@ -29,47 +29,31 @@ open class BoxPlot { ) } - companion object { - private val DATA = - data() // make it stable between calls - - private fun data(): Map> { - val count1 = 50 - val count2 = 100 - - val ratingA = gauss(count1, 12, 0.0, 1.0) - val ratingB = gauss(count2, 24, 0.0, 1.0) - val rating = zip(ratingA, ratingB) - val cond = zip(fill("a", count1), fill("b", count2)) -// val group = ArrayList(fill("G1", count1)) -// group.addAll(fill("G2", count2)) - - val map = HashMap>() - map["cond"] = cond - map["rating"] = rating -// map["group"] = group - return map - } - - - //=========================== - + private val DATA = data() fun basic(): MutableMap { - val spec = "{" + - " 'kind': 'plot'," + - " 'mapping': {" + - " 'x': 'cond'," + - " 'y': 'rating'" + - " }," + - - " 'layers': [" + - " {" + - " 'geom': 'boxplot'" + - " }" + - " ]" + - "}" + val spec = """ + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating' + }, + 'ggtitle': { + 'text': 'Basic demo' + }, + 'layers': [ + { + 'geom': 'boxplot' + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier' + } + ] + } + """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -77,20 +61,28 @@ open class BoxPlot { } fun withVarWidth(): MutableMap { - val spec = "{" + - " 'kind': 'plot'," + - " 'mapping': {" + - " 'x': 'cond'," + - " 'y': 'rating'" + - " }," + - - " 'layers': [" + - " {" + - " 'geom': 'boxplot'," + - " 'varwidth': true" + - " }" + - " ]" + - "}" + val spec = """ + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating' + }, + 'ggtitle': { + 'text': 'With varwidth' + }, + 'layers': [ + { + 'geom': 'boxplot', + 'varwidth': true + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier' + } + ] + } + """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -98,21 +90,29 @@ open class BoxPlot { } fun withCondColored(): MutableMap { - val spec = "{" + - " 'kind': 'plot'," + - " 'mapping': {" + - " 'x': 'cond'," + - " 'y': 'rating'," + - " 'fill': 'cond'" + - " }," + - - " 'layers': [" + - " {" + - " 'geom': 'boxplot'," + - " 'whisker_width': 0.5" + - " }" + - " ]" + - "}" + val spec = """ + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating', + 'fill': 'cond' + }, + 'ggtitle': { + 'text': 'With fill aes' + }, + 'layers': [ + { + 'geom': 'boxplot', + 'whisker_width': 0.5 + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier' + } + ] + } + """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -120,22 +120,30 @@ open class BoxPlot { } fun withOutlierOverride(): MutableMap { - val spec = "{" + - " 'kind': 'plot'," + - " 'mapping': {" + - " 'x': 'cond'," + - " 'y': 'rating'" + - " }," + - - " 'layers': [" + - " {" + - " 'geom': 'boxplot'," + - " 'outlier_color': 'red'," + - " 'outlier_shape': 1," + - " 'outlier_size': 15" + - " }" + - " ]" + - "}" + val spec = """ + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating' + }, + 'ggtitle': { + 'text': 'With specified outlier options' + }, + 'layers': [ + { + 'geom': 'boxplot' + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier', + 'color': 'red', + 'shape': 1, + 'size': 15 + } + ] + } + """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -143,20 +151,28 @@ open class BoxPlot { } fun withGrouping(): MutableMap { - val spec = "{" + - " 'kind': 'plot'," + - " 'mapping': {" + - " 'x': 'cond'," + - " 'y': 'rating'," + - " 'color': 'cond'" + - " }," + - - " 'layers': [" + - " {" + - " 'geom': 'boxplot'" + - " }" + - " ]" + - "}" + val spec = """ + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating', + 'color': 'cond' + }, + 'ggtitle': { + 'text': 'With grouping' + }, + 'layers': [ + { + 'geom': 'boxplot' + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier' + } + ] + } + """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -164,21 +180,29 @@ open class BoxPlot { } fun withGroupingAndVarWidth(): MutableMap { - val spec = "{" + - " 'kind': 'plot'," + - " 'mapping': {" + - " 'x': 'cond'," + - " 'y': 'rating'," + - " 'color': 'cond'" + - " }," + - - " 'layers': [" + - " {" + - " 'geom': 'boxplot'," + - " 'varwidth': true" + - " }" + - " ]" + - "}" + val spec = """ + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating', + 'color': 'cond' + }, + 'ggtitle': { + 'text': 'With grouping and varwidth' + }, + 'layers': [ + { + 'geom': 'boxplot', + 'varwidth': true + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier' + } + ] + } + """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -188,27 +212,32 @@ open class BoxPlot { fun withMiddlePoint(): MutableMap { // This one is not working. val spec = """ - | { - | 'kind': 'plot', - | 'mapping': { - | 'x': 'cond', - | 'y': 'rating' - | }, - | 'layers': [ - | { - | 'geom': 'point', - | 'stat': 'boxplot', - | 'mapping': {'y': '..middle..'}, - | 'size': 7, - | 'color': 'red' - | } - | ] - | } - """.trimMargin() - -// | { -// | 'geom': 'boxplot' -// | }, + { + 'kind': 'plot', + 'mapping': { + 'x': 'cond', + 'y': 'rating' + }, + 'ggtitle': { + 'text': 'Point geometry' + }, + 'layers': [ + { + 'geom': 'point', + 'stat': 'boxplot', + 'mapping': { + 'y': '..middle..' + }, + 'size': 7, + 'color': 'red' + }, + { + 'geom': 'point', + 'stat': 'boxplot_outlier' + } + ] + } + """.trimMargin() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA @@ -220,23 +249,43 @@ open class BoxPlot { // see this issue: https://github.com/JetBrains/lets-plot/issues/325 val layerSpec = if (x == null) { - "{'geom': 'boxplot'}" + "{'geom': 'boxplot'}, {'geom': 'point', 'stat': 'boxplot_outlier'}" } else { - "{'geom': 'boxplot', 'x':'$x'}" + "{'geom': 'boxplot', 'x': '$x'}, {'geom': 'point', 'stat': 'boxplot_outlier', 'x': '$x'}" } - val spec = """ - { - 'kind': 'plot', - 'mapping': {'y': 'rating'}, - 'layers': [$layerSpec] - } + { + 'kind': 'plot', + 'mapping': { + 'y': 'rating' + }, + 'ggtitle': { + 'text': 'One box, x = $x' + }, + 'layers': [$layerSpec] + } """.trimIndent() val plotSpec = HashMap(parsePlotSpec(spec)) plotSpec["data"] = DATA return plotSpec } + + private fun data(): Map> { + val count1 = 50 + val count2 = 100 + + val ratingA = gauss(count1, 12, 0.0, 1.0) + val ratingB = gauss(count2, 24, 0.0, 1.0) + val rating = zip(ratingA, ratingB) + val cond = zip(fill("a", count1), fill("b", count2)) + + val map = HashMap>() + map["cond"] = cond + map["rating"] = rating + + return map + } } } diff --git a/python-package/lets_plot/plot/geom.py b/python-package/lets_plot/plot/geom.py index a381a3a7508..04b7098bbee 100644 --- a/python-package/lets_plot/plot/geom.py +++ b/python-package/lets_plot/plot/geom.py @@ -5,6 +5,7 @@ from lets_plot.geo_data_internals.utils import is_geocoder from .core import FeatureSpec, LayerSpec +from .pos import position_dodge from .util import as_annotated_data, is_geo_data_frame, geo_data_frame_to_crs, get_geo_data_frame_meta # @@ -2934,7 +2935,7 @@ def geom_vline(mapping=None, *, data=None, stat=None, position=None, show_legend **other_args) -def geom_boxplot(mapping=None, *, data=None, stat=None, position=None, show_legend=None, sampling=None, tooltips=None, +def geom_boxplot(mapping=None, *, data=None, stat=None, position=None, show_legend=None, tooltips=None, orientation=None, fatten=None, outlier_color=None, outlier_fill=None, outlier_shape=None, outlier_size=None, outlier_stroke=None, @@ -2963,9 +2964,6 @@ def geom_boxplot(mapping=None, *, data=None, stat=None, position=None, show_lege or the result of a call to a position adjustment function. show_legend : bool, default=True False - do not show legend for this layer. - sampling : `FeatureSpec` - Result of the call to the `sampling_xxx()` function. - To prevent any sampling for this layer pass value "none" (string "none"). tooltips : `layer_tooltips` Result of the call to the `layer_tooltips()` function. Specify appearance, style and content. @@ -3017,6 +3015,7 @@ def geom_boxplot(mapping=None, *, data=None, stat=None, position=None, show_lege `geom_boxplot()` understands the following aesthetics mappings: + - x : x-axis coordinates. - lower : lower hinge. - middle : median. - upper : upper hinge. @@ -3107,25 +3106,40 @@ def geom_boxplot(mapping=None, *, data=None, stat=None, position=None, show_lege alpha=.5, width=.5, show_legend=False) """ - return _geom('boxplot', - mapping=mapping, - data=data, - stat=stat, - position=position, - show_legend=show_legend, - sampling=sampling, - tooltips=tooltips, - orientation=orientation, - fatten=fatten, - outlier_color=outlier_color, - outlier_fill=outlier_fill, - outlier_shape=outlier_shape, - outlier_size=outlier_size, - outlier_stroke=outlier_stroke, - varwidth=varwidth, - whisker_width=whisker_width, - color_by=color_by, fill_by=fill_by, - **other_args) + boxplot_layer = _geom('boxplot', + mapping=mapping, + data=data, + stat=stat, + position=position, + show_legend=show_legend, + sampling=None, + tooltips=tooltips, + orientation=orientation, + fatten=fatten, + varwidth=varwidth, + whisker_width=whisker_width, + color_by=color_by, fill_by=fill_by, + **other_args) + if stat is None or stat == 'boxplot': + default_position = position_dodge(width=.95) + box_color = other_args.get('color') + box_fill = other_args.get('fill') + box_size = other_args.get('size') + boxplot_layer += _geom('point', + mapping=mapping, + data=data, + stat='boxplot_outlier', + position=position or default_position, + show_legend=False, + sampling=None, + orientation=orientation, + color=outlier_color or box_color, + fill=outlier_fill or box_fill, + shape=outlier_shape, + size=outlier_size or box_size, + stroke=outlier_stroke, + color_by=color_by, fill_by=fill_by) + return boxplot_layer def geom_violin(mapping=None, *, data=None, stat=None, position=None, show_legend=None, sampling=None, tooltips=None,