Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add log-linear scale type #1548

Merged
merged 2 commits into from
May 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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