Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4914600
feat(symbology): Invert Color Ramp reverses the colors of a layer's c…
ninhhaidang Nov 3, 2026
7606e52
fix(symbology): Invert Color Ramp says which classes it leaves as the…
ninhhaidang Nov 3, 2026
75b5ab7
fix(symbology): Invert Color Ramp reverses along the legend's order a…
ninhhaidang Nov 3, 2026
7168053
docs(symbology): Invert Color Ramp leaves a polygon fill's opacity at…
ninhhaidang Nov 3, 2026
e2963a9
fix(symbology): Invert Color Ramp keeps only the classes whose own st…
ninhhaidang Nov 3, 2026
90deb10
fix(symbology): Invert Color Ramp reverses the classes a stack takes …
ninhhaidang Nov 3, 2026
75c7da4
fix(symbology): Invert Color Ramp says when the layer's own stack hid…
ninhhaidang Nov 3, 2026
2d689d7
fix(symbology): Invert Color Ramp refuses a graphics layer and only s…
ninhhaidang Nov 3, 2026
540d9c0
fix(symbology): hide Invert Color Ramp on a graphics layer, where the…
ninhhaidang Nov 4, 2026
006a446
Merge remote-tracking branch 'upstream/main' into feat/invert-color-r…
ninhhaidang Nov 4, 2026
faaa413
fix(symbology): Invert Color Ramp says when fewer than two classes le…
ninhhaidang Nov 4, 2026
16f602a
fix(symbology): Invert Color Ramp names the layer's stack only when a…
ninhhaidang Nov 6, 2026
99216f4
Merge remote-tracking branch 'upstream/main' into feat/invert-color-r…
ninhhaidang Nov 6, 2026
80053d2
fix(symbology): a point's hand-picked outline travels with its class'…
ninhhaidang Nov 6, 2026
70cf58f
fix(symbology): Invert Color Ramp says when Symbol by Expression colo…
ninhhaidang Nov 6, 2026
10976fd
Merge branch 'main' into feat/invert-color-ramp-1355
ninhhaidang Nov 6, 2026
561360a
Merge branch 'main' into feat/invert-color-ramp-1355
ninhhaidang Nov 6, 2026
516cdce
Merge main into invert color ramp
giswqs Nov 7, 2026
dd06212
Merge main and declare invert-ramp execution effects
giswqs Nov 7, 2026
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
67 changes: 67 additions & 0 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,62 @@ pub fn commands() -> Vec<C> {
})
}).roles(&[]).effects(crate::policy::Effects::NONE).params(r#"{"layer"?, "ramp": colour ramp name}"#)
.when(need_layer),
C::new("layer.invertRamp", "Invert Color Ramp", "Style › Drawing", |s, p| {
use rustgis_map::symbology::RampReversal;
let id = s.layer_id(p)?;
let l = s.map_mut().layer_mut(id).ok_or("no such layer")?;
Comment thread
ninhhaidang marked this conversation as resolved.
Resolved
if l.graphics && l.raster.is_none() {
return Err("a graphics layer draws each feature in its own style, so it has no color scheme to invert".into());
}
let raster = l.raster.is_some();
Comment thread
giswqs marked this conversation as resolved.
Resolved
let by_expression = !raster && (l.symbol_by.fill.is_some() || l.symbol_by.stroke.is_some());
let (done, layer_stack) = match &mut l.raster {
Some(rl) => (rl.style.invert_ramp().then_some(RampReversal { reversed: 0, kept: 0, on_layer_stack: 0 }), None),
None => {
let g = l.geometry_type();
let stack = layer_stack_hiding_symbol(l, g);
(l.renderer.reverse_ramp(g), stack)
}
};
let Some(done) = done else {
return Err("the layer has no color scheme to invert: use Graduated or Unique Values (or a Stretch raster)".into());
};
// A raster's Invert is a switch, so it has no classes to count. Classes with fewer than two
// to change places, and none left for their own stacks (which say so below), change nothing.
if !raster && done.reversed == 0 && done.kept == 0 {
return Err("the layer has fewer than two classes showing the color scheme, so there is nothing to invert".into());
}
if done.kept > 0 {
// Their own stacks are drawn instead of the symbol, so the reversal can't reach them.
let kept = done.kept;
s.notify(
format!(
"{kept} class(es) drawn with fills or strokes of their own were left as they are: \
edit those with their fill and stroke editors"
),
false,
);
}
if let Some(what) = layer_stack.filter(|_| done.on_layer_stack > 0) {
// The classes without a stack of their own are drawn from the layer's, in colors of its own.
s.notify(
format!(
"The layer's stacked {what} set their own colors, so the classes show those and little of the color \
Comment thread
giswqs marked this conversation as resolved.
Resolved
scheme: the colors were reversed, but change the {what} to see them"
),
false,
);
}
if by_expression && done.reversed > 0 {
// Drawn over the class colors, wherever the expression gives a color.
s.notify(
"The layer's Symbol by Expression sets some features' colors, so the reversed colors show only where it gives none",
false,
);
}
Ok(Value::Null)
Comment thread
ninhhaidang marked this conversation as resolved.
Resolved
}).roles(&[]).effects(crate::policy::Effects::NONE).params(r#"{"layer"?}"#)
.when(need_layer),
C::new("layer.classStyle", "Class Style", "Style › Drawing", class_style).roles(&[]).effects(crate::policy::Effects::NONE)
.when(need_feature_layer)
.params(
Expand Down Expand Up @@ -770,6 +826,17 @@ fn show_fill(sym: &mut rustgis_map::Symbol) {
}
}

/// "fills" or "strokes" when the layer's own stack (`fill_layers` of a polygon layer, `line_strokes` of a
/// line or point layer), which the classes without a stack of their own are drawn from, hides the
/// symbol's colors, which a color scheme sets ([`rustgis_map::symbology::stack_hides_symbol`]).
fn layer_stack_hiding_symbol(l: &rustgis_map::Layer, g: GeometryType) -> Option<&'static str> {
Comment thread
giswqs marked this conversation as resolved.
Resolved
rustgis_map::symbology::stack_hides_symbol(g, &l.fill_layers, &l.line_strokes).then_some(if g == GeometryType::Polygon {
"fills"
} else {
"strokes"
})
}

/// `layer.fillLayers`: a polygon layer's stacked fills (or a class's own, with `value`, `index`
/// or `other`), bottom first; `[]` goes back to the symbol's single fill.
fn fill_layers(s: &mut Session, p: &Value) -> CmdResult {
Expand Down
207 changes: 207 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30128,6 +30128,213 @@ fn a_pdf_says_which_frames_and_pages_it_draws_as_pictures() {
);
}

#[test]
fn layer_invert_ramp_reverses_the_class_colors() {
// #1355: Invert Color Ramp reverses the order of a layer's class colours.
let mut s = world();
let colors = |s: &Session| -> Vec<rustgis_map::Rgba> {
match &s.map().layer_by_name("Countries").unwrap().renderer {
rustgis_map::Renderer::Graduated { symbols, .. } => symbols.iter().map(|y| y.fill).collect(),
rustgis_map::Renderer::Unique { classes, .. } => classes.iter().map(|c| c.1.fill).collect(),
_ => vec![],
}
};
// A single symbol has no color scheme to invert.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "single"})).unwrap();
assert!(s.run("layer.invertRamp", &json!({"layer": "Countries"})).is_err());
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap();
let before = colors(&s);
assert_eq!(before.len(), 5);
let mark = s.notice_mark();
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
assert!(s.notices_since(mark).is_empty(), "nothing was left as it was, so nothing is said: {:?}", s.notices_since(mark));
let mut reversed = before.clone();
reversed.reverse();
assert_eq!(colors(&s), reversed);
// Twice is the original; one undo step undoes one inversion.
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
assert_eq!(colors(&s), before);
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
s.run("edit.undo", &Value::Null).unwrap();
assert_eq!(colors(&s), before);
// Unique Values too.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap();
let before = colors(&s);
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
let mut reversed = before.clone();
reversed.reverse();
assert_eq!(colors(&s), reversed);
assert!(s.run("layer.invertRamp", &json!({"layer": "Nope"})).is_err());

// A colour set by hand travels with the reversal, and each polygon fill keeps its own opacity.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap();
s.run("layer.classStyle", &json!({"layer": "Countries", "index": 0, "color": "#11223344"})).unwrap();
let before = colors(&s);
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
let after = colors(&s);
assert_eq!(after.len(), 5);
assert_eq!(after[4].with_alpha(0), before[0].with_alpha(0), "the edited colour moved to the other end");
assert_eq!(after[4].a(), before[4].a(), "a fill keeps its own opacity");
assert_eq!(after[0].a(), before[0].a());
assert_eq!(before[0].a(), 0x44);

// A class drawn with a stack of fills of its own is drawn instead of its symbol, so the
// reversal can't reach it: it keeps its fills, and the command says so.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap();
s.run("layer.fillLayers", &json!({"layer": "Countries", "index": 0, "fills": [{"color": "#FF0000", "pattern": "solid"}]})).unwrap();
let own = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer {
rustgis_map::Renderer::Graduated { class_fills, .. } => class_fills.clone(),
_ => Default::default(),
};
let mark = s.notice_mark();
let before = colors(&s);
let fills_before = own(&s);
assert_eq!(fills_before.keys().copied().collect::<Vec<_>>(), vec![0]);
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
assert_eq!(own(&s), fills_before, "the class keeps its own fills, as they were");
// Class 0 keeps its colour, and the others reverse among themselves.
let after = colors(&s);
assert_eq!(after[0], before[0]);
assert_eq!(after[1..], [before[4], before[3], before[2], before[1]]);
let notices = s.notices_since(mark);
assert!(
notices.iter().any(|n| n.text.starts_with("1 class(es) drawn with fills or strokes of their own were left as they are")),
"{notices:?}"
);

// A Stretch raster switches its invert option, and other rasters are refused.
use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster};
let gt = GeoTransform { origin_x: 0.0, origin_y: 2.0, pixel_width: 1.0, pixel_height: -1.0 };
let r = Raster::new("dem", 2, 2, vec![vec![1.0, 2.0, 3.0, 4.0]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap();
s.map_mut().add_raster("dem", rustgis_map::RasterLayer::from_path(r, "dem.tif"));
s.run("raster.symbology", &json!({"layer": "dem", "type": "stretch", "ramp": "Terrain"})).unwrap();
let inverted = |s: &Session| match s.map().layer_by_name("dem").and_then(|l| l.raster.as_ref()).map(|r| r.style.renderer.clone()) {
Some(rustgis_map::RasterRenderer::Stretch { invert, .. }) => Some(invert),
_ => None,
};
assert_eq!(inverted(&s), Some(false));
s.run("layer.invertRamp", &json!({"layer": "dem"})).unwrap();
assert_eq!(inverted(&s), Some(true));
s.run("layer.invertRamp", &json!({"layer": "dem"})).unwrap();
assert_eq!(inverted(&s), Some(false));
s.run("raster.symbology", &json!({"layer": "dem", "type": "classify", "classes": 2, "method": "equalInterval"})).unwrap();
assert!(s.run("layer.invertRamp", &json!({"layer": "dem"})).is_err(), "a classified raster has no invert option");
}

#[test]
fn layer_invert_ramp_says_when_the_layers_own_stack_hides_the_colors() {
// #1355: classes without a stack of their own are drawn from the layer's `line_strokes` or
// `fill_layers`; when every layer of it sets its own color, the color scheme shows in none of
// them, and the command says so (the colors are reversed all the same).
let notices = |s: &mut Session, layer: &str| -> Vec<String> {
let mark = s.notice_mark();
s.run("layer.invertRamp", &json!({"layer": layer})).unwrap();
s.notices_since(mark).iter().map(|n| n.text.clone()).collect()
};
let mut s = world();
// A line layer: strokes that all set a color hide the symbol; a core that follows the class doesn't.
s.run("layer.symbology", &json!({"layer": "Rivers", "type": "graduated", "field": "scalerank", "classes": 4})).unwrap();
assert!(notices(&mut s, "Rivers").is_empty(), "no stack, nothing to say");
let own = json!([{"color": "#000000", "width": 5}, {"color": "#FFFFFF", "width": 2}]);
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": own})).unwrap();
let said = notices(&mut s, "Rivers");
assert_eq!(said.len(), 1, "{said:?}");
assert!(said[0].starts_with("The layer's stacked strokes set their own colors"), "{said:?}");
let casing_and_core = json!([{"color": "#000000", "width": 5}, {"width": 2}]);
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": casing_and_core})).unwrap();
assert!(notices(&mut s, "Rivers").is_empty(), "the core follows the class, so the scheme shows");
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": []})).unwrap();
assert!(notices(&mut s, "Rivers").is_empty());
// A polygon layer: its stacked fills.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 4})).unwrap();
s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"color": "#00AA00", "pattern": "solid"}]})).unwrap();
let said = notices(&mut s, "Countries");
assert_eq!(said.len(), 1, "{said:?}");
assert!(said[0].starts_with("The layer's stacked fills set their own colors"), "{said:?}");
s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "solid"}]})).unwrap();
assert!(notices(&mut s, "Countries").is_empty(), "a fill that follows the class shows the scheme");

// The notice about the layer's stack comes only when some classes changed places: with every
// class drawn from a stack of its own, none did, and the layer's stack is not what shows.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 2})).unwrap();
s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"color": "#00AA00", "pattern": "solid"}]})).unwrap();
for index in 0..2 {
s.run("layer.fillLayers", &json!({"layer": "Countries", "index": index, "fills": [{"color": "#AA0000", "pattern": "solid"}]}))
.unwrap();
}
let said = notices(&mut s, "Countries");
assert_eq!(said.len(), 1, "{said:?}");
assert!(said[0].starts_with("2 class(es) drawn with fills or strokes of their own"), "{said:?}");

// Their own stacks replace the layer's even where they follow the class, so the layer's stack
// is not what shows and the command says nothing of it.
for index in 0..2 {
s.run("layer.fillLayers", &json!({"layer": "Countries", "index": index, "fills": [{"pattern": "solid"}]})).unwrap();
}
assert!(notices(&mut s, "Countries").is_empty(), "every class has a stack of its own");
// One class left on the layer's stack is enough to say it.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 3})).unwrap();
s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"color": "#00AA00", "pattern": "solid"}]})).unwrap();
s.run("layer.fillLayers", &json!({"layer": "Countries", "index": 0, "fills": [{"pattern": "solid"}]})).unwrap();
let said = notices(&mut s, "Countries");
assert_eq!(said.len(), 1, "{said:?}");
assert!(said[0].starts_with("The layer's stacked fills set their own colors"), "{said:?}");
}

#[test]
fn layer_invert_ramp_says_when_symbol_by_expression_sets_colors() {
// #1355: Symbol by Expression colors are drawn over the class colors, so the reversal shows only
// where the expression gives none; with no color expression (a size one doesn't set colors) it is quiet.
let notices = |s: &mut Session| -> Vec<String> {
let mark = s.notice_mark();
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
s.notices_since(mark).iter().map(|n| n.text.clone()).collect()
};
let mut s = world();
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 4})).unwrap();
assert!(notices(&mut s).is_empty());
s.run("layer.symbolBy", &json!({"layer": "Countries", "strokeWidth": "2"})).unwrap();
assert!(notices(&mut s).is_empty(), "a width is no color");
s.run("layer.symbolBy", &json!({"layer": "Countries", "fill": "if(pop_est > 1e8, '#ff0000', NULL)"})).unwrap();
let said = notices(&mut s);
assert_eq!(said.len(), 1, "{said:?}");
assert!(said[0].starts_with("The layer's Symbol by Expression sets some features' colors"), "{said:?}");
s.run("layer.symbolBy", &json!({"layer": "Countries", "fill": null, "stroke": "'#00ff00'"})).unwrap();
assert_eq!(notices(&mut s).len(), 1, "a stroke color counts too");
}

#[test]
fn layer_invert_ramp_says_when_there_is_nothing_to_invert() {
// #1355: with fewer than two classes showing the scheme, an inversion would change nothing, so
// the command refuses rather than succeed silently. A raster's Invert is a switch and is no
// class count, and a class kept for its own stack has its own notice.
let mut s = world();
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap();
let classes = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer {
rustgis_map::Renderer::Graduated { symbols, .. } => symbols.len(),
_ => 0,
};
assert_eq!(classes(&s), 1, "one class to invert");
let err = s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap_err();
assert!(err.contains("nothing to invert"), "{err}");
// Two classes reverse, with no notice.
s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 2})).unwrap();
let mark = s.notice_mark();
s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap();
assert!(s.notices_since(mark).is_empty());
}

#[test]
fn layer_invert_ramp_refuses_a_graphics_layer() {
// #1355: a graphics layer draws each feature in its own style, whatever its renderer holds, so
// reversing the renderer's colors would change nothing and say nothing.
let mut s = world();
s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 45], [10, 55]], "fill": "#FF000040"})).unwrap();
let id = s.map().layers.iter().find(|l| l.graphics).map(|l| l.id).unwrap();
let err = s.run("layer.invertRamp", &json!({"layer": id})).unwrap_err();
assert!(err.contains("graphics layer"), "{err}");
}

/// Calculate Field keeps a decimal result as it is in a decimal field, rather than rounding it to
/// the six places its text shows, and says what a whole-number field cuts: a fraction, or a number
/// past the 64-bit range (null). Results the field holds give no notice.
Expand Down
11 changes: 11 additions & 0 deletions crates/map/src/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,17 @@ impl RasterStyle {
}
}

/// Flips a Stretch's colour scheme end for end (#1355). Returns false for other renderers.
pub fn invert_ramp(&mut self) -> bool {
match &mut self.renderer {
RasterRenderer::Stretch { invert, .. } => {
*invert = !*invert;
true
}
_ => false,
}
}

/// Recolours with a colour scheme.
pub fn set_ramp(&mut self, ramp: Ramp) {
match &mut self.renderer {
Expand Down
Loading
Loading