Skip to content

d.legend: Support logarithmic scale for ranges with non-positive values - #8060

Merged
petrasovaa merged 4 commits into
OSGeo:mainfrom
petrasovaa:d.legend-log-shift
Oct 21, 2026
Merged

petrasovaa merged 4 commits into
OSGeo:mainfrom
petrasovaa:d.legend-log-shift

Conversation

@petrasovaa

Copy link
Copy Markdown
Contributor

This is a replacement of #1507, taking the approach implemented by @HuidaeCho in #1937 for r.colors: shifts the range so that its minimum becomes 1 and applies the logarithmic scale to the shifted values.

Example of new behavior:

tools.g_region(raster="elevation")

tools.r_grow_distance(input="lakes", distance="dist_lakes")
tools.r_colors(map="dist_lakes", color="byg", flags="g")

m = gj.Map(width=600)
m.d_rast(map="dist_lakes")
m.d_legend(raster="dist_lakes", flags="lts", label_step=1, units=" m",
           at=(5, 50, 80, 85), title="Distance to lakes")
m.d_legend(raster="dist_lakes", flags="ts", units=" m", label_step=1000,
           at=(5, 50, 3, 8), title="Distance to lakes")
m.show()
image

ranges = {
    "zero_to_max": "dist_lakes",
    "mixed": "dist_lakes - 1000",
    "non_positive": "0 - dist_lakes",
    "zero_to_one": "dist_lakes / 5321.24",
}
for name, expression in ranges.items():
    tools.r_mapcalc(expression=f"{name} = {expression}")
    tools.r_colors(map=name, color="byg", flags="g")

m = gj.Map(width=800, height=400)
for i, name in enumerate(ranges):
    left = 5 + i * 25
    m.d_legend(raster=name, flags="lts", label_step=1,
               at=(10, 85, left, left + 5), title=name)
m.show()
image

Developed with Claude Code.

A logarithmic legend (-l) was a fatal error when the raster range
included zero or negative values, although r.colors -g handles such
rasters by shifting the range so that its minimum becomes 1. Apply the
same shift in d.legend so the legend matches the color table.

Set the continuous range of an integer raster before the bar is drawn;
the logarithmic bar read it uninitialized before. With -l, label_step
labels now stop at the maximum and a range starting at or below zero
is labeled at its minimum too.
@github-actions github-actions Bot added raster Related to raster data processing Python Related code is in Python C Related code is in C HTML Related code is in HTML module docs markdown Related to markdown, markdown files display tests Related to Test Suite labels Oct 13, 2026

@wenzeslaus wenzeslaus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much needed update!

I find the documentation hard to understand from user perspective. Are my display values now wrong or is that an implementation detail? There seems to be a discrepancy between r.color and the implementation here, but I did not dig into it myself. Overlaps of the labels are visible on the example provided. Do you want to comment on that?

As a side note the use of Tools to create object for rendering is great and it is exactly how I meant that in the sense of Tools being understandable API which can lend itself naturally to many different specializations. Nevertheless, you can consider grass.jupyter.Map which is heavier, but leaves rendering details (variable names) out of the test.

AI-generated review

Here is my AI-boosted analysis. The findings were checked by building this branch's d.legend, running it with and without the PR, and checking label positions with an instrumented build. Line numbers refer to draw.c in this PR.

1. Integer maps: all default labels are drawn on top of each other in a smooth legend. In the !fp branch of the labelnum loop, only tcell is set. The log coef at lines 573 and 603 uses val, which still holds the last value from the bar loop. So every label gets the same position (coef 0.9969 for all of 1, 251, 501, 751, 1001):

g.region rows=100 cols=100 n=100 s=0 e=100 w=0
r.mapcalc "icell = int(1 + row() * col() / 10)"
r.colors -g map=icell color=rainbow
d.legend -l raster=icell at=10,90,10,20

This happens whenever an integer legend is smooth: with -s, with a horizontal legend, or when the map has too many categories. The stale val was already there before this PR, but -l on integer maps was broken in other ways before (empty bar, NaN positions), so nobody saw it. Now that integer maps are supported, the labels are visibly wrong. GCC warns about it: draw.c:603: 'val' may be used uninitialized. One option is to set val = tcell; in that branch.

2. A constant FP raster <= 0 now gives NaN label positions instead of an error. With the fatal check removed, dmin == dmax <= 0 gets log_shift = 1 - dmin, so lmin == lmax == 0 and coef is 0/0 at lines 573, 603, 664 and 747. The labels end up clipped at the top edge. With label_step, the value is also drawn twice: once by the loop, then again by the val = dmax fallback at lines 794-797.

r.mapcalc "c0 = 0.0"
d.legend -l raster=c0 label_step=1

Before this PR, this stopped with Range [0.000, 0.000] out of the logarithm domain. A positive constant (e.g. 5.0) already gave NaN positions in log mode before this PR. A dmin == dmax guard would cover both cases.

3. A positive range with no power of ten in it gets no labels with label_step. For an FP raster from 2 to 9 with -l label_step=1, num = ceil(log10(2)) = 1 (line 731). The first val is 10, so the loop at line 736 never runs and the legend has no labels at all. Before this PR, one label (10.0) was drawn outside the bar. A range of 0.2 to 0.9 behaves the same way. Ranges with values <= 0 get a min/max fallback; positive ranges do not. One option is to use the same fallback when the loop draws nothing.

4. Labels crowd together on shifted ranges. On the mixed test raster (-98 to 100, log_shift 99), d.legend -l label_step=1 places -98 at coef 0.0, 1 at 0.870, 10 at 0.886 and 100 at 1.0. "1" and "10" overlap. The lower 87% of the bar has only the "-98" label, and 0 is never labeled. The test's own label_values=-98,0,10,100 crowds the same way (0 at 0.868, 10 at 0.886). Options:

  • Place step labels at powers of ten in shifted space, i.e. 10^k - log_shift (-98, -89, 1, 100 here).
  • Fall back to evenly spaced labels when log_shift > 0.
  • Skip labels that would overlap the previous one.

Smaller notes

  • The shift is computed from the legend range (data or range=), while Rast_log_colors() computes its shift from the color table range. Labels still line up with the colors, because the bar and the labels use exact inverse formulas. With range=, though, the gradient on the bar is uneven compared to the color table (e.g. data -10..1000 with r.colors -g, then d.legend -l range=5,1000). The new sentence in the docs, that the legend matches a color table created that way, is only true when the legend range equals the color table range. It may be worth saying so.
  • -l label_step=0 (or a negative value) never finishes. This is not new: it happens before this PR too, and linear mode hangs with label_step=-1. A check in main.c that label_step is positive would fix both modes.

Optional

  • The expression (log10(x + log_shift) - lmin) / (lmax - lmin) is repeated 4 times (lines 573, 603, 664, 747). A small static double log_coef(...) helper gives byte-identical output in local tests.
  • The tests would not catch issues 3 or 4. A cheap check is to render with GRASS_RENDER_TRANSPARENT=TRUE at a fixed at= and count non-transparent pixels to the right of the bar using Pillow. This catches "no labels drawn" (issue 3), but not label positions.

@petrasovaa

Copy link
Copy Markdown
Contributor Author

Addressed except item 4, I am leaning towards this being out of scope. Not sure how to improve more the documentation.

@wenzeslaus wenzeslaus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading both d.legend and r.colors doc seems to give assurance that the values are taken care of. I looked at couple example legends and the one for negative values does not give expected results if you are expecting log scale just flipped, but this PR matches current r.color behavior, so this is not actionable. There seems to be an issue with a single integer value rasters (edge case for legend but real use case and possible in time series).

AI-generated review

The new dmin == dmax check also applies to integer maps with a single value, where -l worked before:

r.mapcalc "i5 = 5"
d.legend -l raster=i5

Before this PR and with its first commit, this drew a category legend. Now it fails with Range [5.000, 5.000] has no extent for a logarithmic scale. One option is to run the check only where the logarithmic scale is actually used, i.e., for floating-point maps or when the legend is smooth.

@wenzeslaus wenzeslaus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The images are great! Looking forward to seeing it in the wild!

@petrasovaa
petrasovaa enabled auto-merge (squash) October 21, 2026 03:13
@petrasovaa
petrasovaa merged commit 8fb288b into OSGeo:main Oct 21, 2026
26 checks passed
@github-actions github-actions Bot added this to the 8.6.0 milestone Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C Related code is in C display docs HTML Related code is in HTML markdown Related to markdown, markdown files module Python Related code is in Python raster Related to raster data processing tests Related to Test Suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants