Skip to content

Commit

Permalink
add log-linear scale type (#1548)
Browse files Browse the repository at this point in the history
It behaves like a log scale for powers of 10 with
linear behavior in between. This helps spread out
the smaller values. This can be useful for things
like a heatmap view of percentile distributions.
  • Loading branch information
brharrington authored May 1, 2023
1 parent 49ea9b8 commit 3e5fe07
Show file tree
Hide file tree
Showing 22 changed files with 326 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright 2014-2023 Netflix, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.netflix.atlas.chart.graphics

/**
* Helper functions for log-linear scale. This scale does powers of 10 with a linear scale
* between each power of 10. This helps emphasize some of the smaller values when there is
* a larger overall range.
*/
private[graphics] object LogLinear {

/**
* Find the max value for a given bucket. If `i` is negative it will select the negative
* bucket value.
*/
def bucket(i: Int): Double = {
if (i < 0)
-bucket(-i - 1)
else
bucketSpan(i) * (i % 9 + 1)
}

/**
* Find the span for a given bucket. This should be the previous power of 10.
*/
def bucketSpan(i: Int): Double = {
val idx = if (i < 0) -i - 1 else i
val exp = idx / 9 - 9
math.pow(10, exp)
}

/** Power of 10 for a long value. */
private def pow(exp: Int): Long = {
var result = 1L
var i = 0
while (i < exp) {
result *= 10
i += 1
}
result
}

/**
* Find the bucket index for a given value. Negative values will return a negative index
* that reflect the positive scale.
*/
def bucketIndex(v: Double): Int = {
if (v < 0.0) {
-bucketIndex(-v) - 1
} else if (v == 0.0) {
0
} else {
val lg = math.max(-9.0, math.floor(math.log10(v)))
val prevBuckets = (lg.toInt + 9) * 9
val E = 6.0 - lg
if (E >= 0.0) {
// For values in this range convert to Long and use integer math
// to avoid some problems with floating point
val n = (v * math.pow(10, E)).toLong
val exp = lg.toInt + E.toInt
val p10 = pow(exp)
((n - 1) / p10).toInt + prevBuckets
} else {
val p10 = math.pow(10, lg)
val delta = v - p10
math.ceil(delta / p10).toInt + prevBuckets
}
}
}

private def ratio(v: Double, i: Int): Double = {
if (v < 0.0) {
1.0 - ratio(-v, -i - 1)
} else {
val span = bucketSpan(i)
val boundary = bucket(i) - span
(v - boundary) / span
}
}

/** Determine the pixel position for a given value. */
def position(v: Double, min: Int, pixelsPerBucket: Double): Double = {
val i = bucketIndex(v)
val offset = math.max(0.0, i - min - 1) * pixelsPerBucket
ratio(v, i) * pixelsPerBucket + offset
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ object Scales {
def factory(s: Scale): DoubleFactory = s match {
case Scale.LINEAR => yscale(linear)
case Scale.LOGARITHMIC => yscale(logarithmic)
case Scale.LOG_LINEAR => yscale(logLinear)
case Scale.POWER_2 => yscale(power(2.0))
case Scale.SQRT => yscale(power(0.5))
}
Expand Down Expand Up @@ -73,6 +74,26 @@ object Scales {
v => scale(log10(v))
}

/**
* Factory for a log linear mapping. It does a logarithmic behavior for powers of 10 and
* is linear in between them. This helps spread out smaller values if there is a big range
* on the data set.
*/
def logLinear(d1: Double, d2: Double, r1: Int, r2: Int): DoubleScale = {
val b1 =
if (d1 >= 0.0)
math.max(0, LogLinear.bucketIndex(d1) - 1)
else
LogLinear.bucketIndex(d1) - 1
val b2 = LogLinear.bucketIndex(d2)
if (b1 == b2) {
linear(d1, d2, r1, r2)
} else {
val pixelsPerBucket = (r2 - r1).toDouble / math.abs(b2 - b1).toDouble
v => LogLinear.position(v, b1, pixelsPerBucket).toInt + r1
}
}

private def pow(value: Double, exp: Double): Double = {
value match {
case v if v > 0.0 => math.pow(v, exp)
Expand Down Expand Up @@ -112,6 +133,7 @@ object Scales {
def inverted(s: Scale): InvertedFactory = s match {
case Scale.LINEAR => invertedScale(invertedLinear)
case Scale.LOGARITHMIC => invertedScale(invertedLogarithmic)
case Scale.LOG_LINEAR => invertedScale(invertedLogLinear)
case Scale.POWER_2 => invertedScale(invertedPower(2.0))
case Scale.SQRT => invertedScale(invertedPower(0.5))
}
Expand Down Expand Up @@ -139,6 +161,22 @@ object Scales {
v => pow10(scale(v))
}

/** Factory for an inverted log-linear mapping. */
def invertedLogLinear(d1: Double, d2: Double, r1: Int, r2: Int): InvertedScale = {
val b1 =
if (d1 >= 0.0)
math.max(0, LogLinear.bucketIndex(d1) - 1)
else
LogLinear.bucketIndex(d1) - 1
val b2 = LogLinear.bucketIndex(d2)
if (b1 == b2) {
invertedLinear(d1, d2, r1, r2)
} else {
val pixelsPerBucket = (r2 - r1).toDouble / math.abs(b2 - b1).toDouble
v => LogLinear.bucket(((v - r1) / pixelsPerBucket).toInt - b1)
}
}

/** Factory for an inverted power mapping. */
def invertedPower(exp: Double)(d1: Double, d2: Double, r1: Int, r2: Int): InvertedScale = {
val p1 = pow(d1, exp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,49 @@ object Ticks {
def value(v1: Double, v2: Double, n: Int, scale: Scale = Scale.LINEAR): List[ValueTick] = {
val r = validateAndGetRange(v1, v2)

valueTickSizes
.find(t => r / t._1 <= n)
.fold(sciTicks(v1, v2, n))(t => decimalTicks(v1, v2, n, t, scale))
if (scale == Scale.LOG_LINEAR) {
logLinear(v1, v2, n)
} else {
valueTickSizes
.find(t => r / t._1 <= n)
.fold(sciTicks(v1, v2, n))(t => decimalTicks(v1, v2, n, t, scale))
}
}

def logLinear(v1: Double, v2: Double, n: Int): List[ValueTick] = {
val s = LogLinear.bucketIndex(v1)
val e = LogLinear.bucketIndex(v2)
val posAndNeg = s < 0 && e > 0
val numBuckets = e - s
val majorMod = math.max(1, ((numBuckets / 9) + n - 1) / n * 9)
def idx(i: Int) = if (i < 0) -i - 1 else i
def isMajor(i: Int) = {
if (numBuckets <= n)
true
else
idx(i) % majorMod == 0
}
(s to e).toList
.flatMap { i =>
// Rules
// - The associated value is outside the range
// - If the range includes both positive and negative values, then ignore the first
// bucket for both sides and add a tick at zero.
// - Use all buckets if the number is less than requested number of ticks.
// - Otherwise, just include ticks at powers of 10. Major ticks should roughly match
// the requested number.
val b = LogLinear.bucket(i)
if (b < v1 || b > v2)
None
else if (posAndNeg && i == -1)
None
else if (posAndNeg && i == 0)
Some(ValueTick(0.0, 0.0))
else if (numBuckets < n * 10 || idx(i) % 9 == 0)
Some(ValueTick(LogLinear.bucket(i), 0.0, major = isMajor(i)))
else
None
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,20 @@ public enum Scale {

LINEAR,
LOGARITHMIC,
LOG_LINEAR,
POWER_2,
SQRT;

/** Returns the scale constant associated with a given name. */
public static Scale fromName(String s) {
return switch (s) {
case "linear" -> LINEAR;
case "log" -> LOGARITHMIC;
case "pow2" -> POWER_2;
case "sqrt" -> SQRT;
default -> throw new IllegalArgumentException("unknown scale type '" + s
+ "', should be linear, log, pow2, or sqrt");
case "linear" -> LINEAR;
case "log" -> LOGARITHMIC;
case "log-linear" -> LOG_LINEAR;
case "pow2" -> POWER_2;
case "sqrt" -> SQRT;
default -> throw new IllegalArgumentException("unknown scale type '" + s
+ "', should be linear, log, log-linear, pow2, or sqrt");
};
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,13 @@ abstract class PngGraphEngineSuite extends FunSuite {
check("heatmap_basic_log.png", graphDef)
}

test("heatmap_basic_log_linear") {
val graphDef = loadV2(s"$dataDir/heatmap_basic.json.gz").adjustPlots(
_.copy(scale = Scale.LOG_LINEAR)
)
check("heatmap_basic_log_linear.png", graphDef)
}

test("heatmap_basic_reds") {
val graphDef = loadV2(s"$dataDir/heatmap_basic.json.gz").adjustPlots(
_.copy(heatmap = Some(HeatmapDef(palette = Some(Palette.fromResource("reds")))))
Expand All @@ -785,6 +792,13 @@ abstract class PngGraphEngineSuite extends FunSuite {
check("heatmap_basic_color_log.png", graphDef)
}

test("heatmap_basic_color_log_linear") {
val graphDef = loadV2(s"$dataDir/heatmap_basic.json.gz").adjustPlots(
_.copy(heatmap = Some(HeatmapDef(colorScale = Scale.LOG_LINEAR)))
)
check("heatmap_basic_color_log_linear.png", graphDef)
}

test("heatmap_basic_color_power2") {
val graphDef = loadV2(s"$dataDir/heatmap_basic.json.gz").adjustPlots(
_.copy(heatmap = Some(HeatmapDef(colorScale = Scale.POWER_2)))
Expand All @@ -804,11 +818,25 @@ abstract class PngGraphEngineSuite extends FunSuite {
check("heatmap_timer.png", graphDef)
}

test("heatmap_timer_log_linear") {
val graphDef = loadV2(s"$dataDir/heatmap_timer.json.gz").adjustPlots(
_.copy(scale = Scale.LOG_LINEAR)
)
check("heatmap_timer_log_linear.png", graphDef)
}

test("heatmap_timer2") {
val graphDef = loadV2(s"$dataDir/heatmap_timer2.json.gz")
check("heatmap_timer2.png", graphDef)
}

test("heatmap_timer2_log_linear") {
val graphDef = loadV2(s"$dataDir/heatmap_timer2.json.gz").adjustPlots(
_.copy(scale = Scale.LOG_LINEAR)
)
check("heatmap_timer2_log_linear.png", graphDef)
}

test("heatmap_timer3") {
val graphDef = loadV2(s"$dataDir/heatmap_timer3.json.gz")
check("heatmap_timer3.png", graphDef)
Expand Down Expand Up @@ -884,6 +912,13 @@ abstract class PngGraphEngineSuite extends FunSuite {
val graphDef = loadV2(s"$dataDir/heatmap_dist.json.gz")
check("heatmap_dist.png", graphDef)
}

test("heatmap_dist_log_linear") {
val graphDef = loadV2(s"$dataDir/heatmap_dist.json.gz").adjustPlots {
_.copy(scale = Scale.LOG_LINEAR)
}
check("heatmap_dist_log_linear.png", graphDef)
}
}

case class GraphData(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2014-2023 Netflix, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.netflix.atlas.chart.graphics

import munit.FunSuite

import scala.collection.immutable.ArraySeq

class LogLinearSuite extends FunSuite {

private val expectedBuckets = {
val builder = ArraySeq.newBuilder[Double]
var v = 1L
var delta = 1L
while (v < 9_000_000_000_000_000_000L) {
builder += v / 1e9
v += delta
if (v % (10 * delta) == 0)
delta *= 10
}
builder.result()
}

test("bucket") {
var i = 0
while (i < expectedBuckets.length) {
val v = expectedBuckets(i)
assertEqualsDouble(LogLinear.bucket(i), v, 1e-12)
i += 1
}
}

test("bucketIndex") {
var i = 0
while (i < expectedBuckets.length) {
val v = expectedBuckets(i)
assertEquals(LogLinear.bucketIndex(v), i, s"$v")
val v2 = v - v / 20.0
assertEquals(LogLinear.bucketIndex(v2), i, s"$v2")
i += 1
}
}

test("bucketIndex near boundary") {
var i = 0
while (i < expectedBuckets.length) {
val b = expectedBuckets(i)
val delta = math.pow(10, math.log10(b) - 3.0)
val v = b + delta
assertEquals(LogLinear.bucketIndex(v), i + 1, s"$v")
val v2 = b - delta
assertEquals(LogLinear.bucketIndex(v2), i, s"$v2")
i += 1
}
}

test("bucketIndex near boundary negative") {
var i = 0
while (i < expectedBuckets.length) {
val b = -expectedBuckets(i)
val delta = math.pow(10, math.log10(-b) - 3.0)
val v = b + delta
assertEquals(LogLinear.bucketIndex(v), -i - 1, s"$v")
val v2 = b - delta
assertEquals(LogLinear.bucketIndex(v2), -i - 2, s"$v2")
i += 1
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 3e5fe07

Please sign in to comment.