diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index c5b0b6e04..c2eac79c6 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -154,6 +154,62 @@ pub fn commands() -> Vec { }) }).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")?; + 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(); + 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 \ + 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) + }).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( @@ -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> { + 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 47caf136d..bbb513071 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { + 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![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 { + 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 { + 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. diff --git a/crates/map/src/raster.rs b/crates/map/src/raster.rs index 857d4b21a..5c2b8b1da 100644 --- a/crates/map/src/raster.rs +++ b/crates/map/src/raster.rs @@ -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 { diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 705cb9486..ced48715e 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -163,6 +163,26 @@ impl Symbol { self } + /// The colours a ramp gives a class, taken from `src`: the fill and the outline (a point's too, + /// which a ramp never sets, so it is one picked by hand). A polygon keeps its own fill opacity. + fn with_colors_of(mut self, src: &Symbol, g: GeometryType) -> Symbol { + match g { + GeometryType::Polyline => { + self.fill = src.fill; + self.stroke = src.stroke; + } + GeometryType::Polygon => { + self.fill = src.fill.with_alpha(self.fill.a()); + self.stroke = src.stroke; + } + _ => { + self.fill = src.fill; + self.stroke = src.stroke; + } + } + self + } + /// Ramp colours change hue, not a polygon's existing fill opacity (even below 150). fn with_ramp_fill(self, c: Rgba, g: GeometryType) -> Symbol { let mut out = self.with_fill(c, g); @@ -339,6 +359,30 @@ pub struct LineStroke { pub markers: Option, } +/// Whether a stack of fills (a polygon's) or strokes (a line's or a point's) hides the colors a +/// color scheme sets on a symbol (#1355): the stack is drawn for the geometry (a polygon's stacked +/// strokes and a line's stacked fills are not) and every layer of it sets a color of its own. A layer +/// without one takes the symbol's (`FillLayer::color`, `LineStroke::color`), so the class still shows +/// its color through it: a road's black casing over a core that follows the class. An empty stack is +/// no stack. Used for a class's own stack and for the layer's. +pub fn stack_hides_symbol(g: GeometryType, fills: &[FillLayer], strokes: &[LineStroke]) -> bool { + match g { + GeometryType::Polygon => !fills.is_empty() && fills.iter().all(|f| f.color.is_some()), + _ => !strokes.is_empty() && strokes.iter().all(|s| s.color.is_some()), + } +} + +/// What [`Renderer::reverse_ramp`] did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RampReversal { + /// How many classes changed colors with another: 0 when fewer than two classes show the scheme. + pub reversed: usize, + /// How many classes were left as they were, their own stack hiding their symbol. + pub kept: usize, + /// How many of the `reversed` classes have no stack of their own, so they are drawn from the layer's. + pub on_layer_stack: usize, +} + impl Default for LineStroke { fn default() -> Self { LineStroke { @@ -1575,6 +1619,89 @@ impl Renderer { } } + /// The classes whose colors a ramp gives, in the order the legend lists them, and how many + /// more the ramp can't recolor: a class whose own stack hides its symbol's colors. The stack + /// that is drawn depends on the geometry (a polygon's fills; a line's strokes or a point's + /// marks; see the drawing in `rustgis-render`, which doesn't draw a polygon's stacked strokes + /// or a line's stacked fills), an empty stack is no stack, and a layer of a stack without a + /// color of its own takes the symbol's (`FillLayer::color`, `LineStroke::color`), so the + /// class still shows its ramp color through it: a road's black casing over a core that follows + /// the class. Only a stack whose every layer sets its own color hides the symbol. Only the + /// first entry of a Unique Values value is drawn, so a repeated value counts once. They are + /// indices into `classes` (Unique Values) or `symbols` (Graduated Colors). `None` for the + /// other renderers. + fn ramp_slots(&self, g: GeometryType) -> Option<(Vec, usize, usize)> { + // Whether a class's own stacks hide its symbol's colors, for the geometry the layer draws. + let replaced = |fills: Option<&Vec>, strokes: Option<&Vec>| { + stack_hides_symbol(g, fills.map_or(&[][..], Vec::as_slice), strokes.map_or(&[][..], Vec::as_slice)) + }; + // A class's own stack, of the kind the layer draws, replaces the layer's whenever it isn't empty. + let bare = |fills: Option<&Vec>, strokes: Option<&Vec>| { + if g == GeometryType::Polygon { fills.is_none_or(|f| f.is_empty()) } else { strokes.is_none_or(|s| s.is_empty()) } + }; + let (mut slots, mut kept, mut on_layer) = (Vec::new(), 0, 0); + match self { + Renderer::Unique { classes, class_fills, class_strokes, .. } => { + let mut seen = std::collections::HashSet::new(); + for i in self.class_order() { + let Some((value, _)) = classes.get(i) else { continue }; + if !seen.insert(value.as_str()) { + continue; + } + if replaced(class_fills.get(value), class_strokes.get(value)) { + kept += 1; + } else { + slots.push(i); + on_layer += usize::from(bare(class_fills.get(value), class_strokes.get(value))); + } + } + } + Renderer::Graduated { symbols, class_fills, class_strokes, .. } => { + for i in 0..symbols.len() { + if replaced(class_fills.get(&i), class_strokes.get(&i)) { + kept += 1; + } else { + slots.push(i); + on_layer += usize::from(bare(class_fills.get(&i), class_strokes.get(&i))); + } + } + } + _ => return None, + } + Some((slots, kept, on_layer)) + } + + fn class_symbol_mut(&mut self, i: usize) -> Option<&mut Symbol> { + match self { + Renderer::Unique { classes, .. } => classes.get_mut(i).map(|c| &mut c.1), + Renderer::Graduated { symbols, .. } => symbols.get_mut(i), + _ => None, + } + } + + /// Reverses the colours of the classes (#1355), in the order the legend lists them: the first + /// class takes the last one's colour, and so on. Colours edited by hand travel with their + /// class's old position, and a polygon fill keeps its own opacity. Applied twice it gives the + /// original back. A class whose own stack hides its symbol (every layer of its fills, for a + /// polygon, or strokes, for a line or a point, sets a color of its own), and a repeat of a Unique + /// Values value (which no feature draws), keep theirs and take no part in the reversal. The + /// `` symbol is no ramp color and stays. Says how many classes changed places + /// and how many were kept for their own stacks, or `None` for a renderer without classes + /// coloured from a ramp. + pub fn reverse_ramp(&mut self, g: GeometryType) -> Option { + let (slots, kept, on_layer_stack) = self.ramp_slots(g)?; + let old: Vec = slots.iter().filter_map(|&i| self.class_symbol_mut(i).map(|s| *s)).collect(); + for (pos, &i) in slots.iter().enumerate() { + let from = old.len().checked_sub(1 + pos).and_then(|k| old.get(k)); + if let (Some(sym), Some(from)) = (self.class_symbol_mut(i), from) { + *sym = sym.with_colors_of(from, g); + } + } + // One class, or none, has no other to change places with. + let moved = slots.len() > 1; + Some(RampReversal { reversed: if moved { slots.len() } else { 0 }, kept, on_layer_stack: if moved { on_layer_stack } else { 0 } }) + } + /// Recolours every class from a ramp, preserving each polygon fill's opacity. pub fn apply_ramp(&mut self, ramp: Ramp, g: GeometryType) { match self { @@ -4219,4 +4346,156 @@ mod tests { more.carry_edits_from(&gold, g); assert_eq!(more, fresh, "another number of classes starts fresh"); } + + fn unique_with(values: &[(&str, u8)]) -> Renderer { + let sym = |v: u8| Symbol { fill: Rgba::rgb(v, 0, 0), ..Symbol::default_for(GeometryType::Point, 0) }; + Renderer::Unique { + field: "f".into(), + classes: values.iter().map(|(k, v)| (k.to_string(), sym(*v))).collect(), + other: sym(0), + ramp: Ramp::Greys, + class_labels: BTreeMap::new(), + other_label: None, + class_strokes: BTreeMap::new(), + other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), + expression: None, + } + } + + /// #1355: Invert Color Ramp reverses the colours along the legend's order, which is not the + /// order the classes are stored in; a value repeated in the classes isn't drawn, so it takes + /// no part; and a class with a stack of its own keeps its colours without shifting the rest. + #[test] + fn reverse_ramp_follows_the_legend_and_skips_what_is_not_drawn() { + let reds = |r: &Renderer| match r { + Renderer::Unique { classes, .. } => classes.iter().map(|c| (c.0.clone(), c.1.fill.r())).collect::>(), + _ => vec![], + }; + let g = GeometryType::Point; + // Stored 10, 100, 9: the legend lists 9, 10, 100 (numbers), so 9 and 100 swap colours. + let mut r = unique_with(&[("10", 1), ("100", 2), ("9", 3)]); + assert_eq!(r.reverse_ramp(g), Some(RampReversal { reversed: 3, kept: 0, on_layer_stack: 3 })); + assert_eq!(reds(&r), vec![("10".into(), 1), ("100".into(), 3), ("9".into(), 2)]); + assert!(matches!(&r, Renderer::Unique { other, .. } if other.fill.r() == 0), " is no ramp color"); + r.reverse_ramp(g); + assert_eq!(reds(&r), vec![("10".into(), 1), ("100".into(), 2), ("9".into(), 3)], "twice is the original"); + // A repeat of a value is not drawn: it stays, and the drawn entries reverse among themselves. + let mut r = unique_with(&[("a", 1), ("b", 2), ("a", 9), ("c", 3)]); + r.reverse_ramp(g); + assert_eq!(reds(&r), vec![("a".into(), 3), ("b".into(), 2), ("a".into(), 9), ("c".into(), 1)]); + // A class with a stack of its own is kept, counted, and the others reverse among themselves. + let mut r = unique_with(&[("a", 1), ("b", 2), ("c", 3), ("d", 4)]); + if let Renderer::Unique { class_strokes, .. } = &mut r { + class_strokes.insert("a".into(), vec![LineStroke { color: Some(Rgba::rgb(9, 9, 9)), ..LineStroke::default() }]); + } + assert_eq!(r.reverse_ramp(g), Some(RampReversal { reversed: 3, kept: 1, on_layer_stack: 3 })); + assert_eq!(reds(&r), vec![("a".into(), 1), ("b".into(), 4), ("c".into(), 3), ("d".into(), 2)]); + // A point's hand-picked outline travels with its fill. + let mut r = unique_with(&[("a", 1), ("b", 2)]); + if let Renderer::Unique { classes, .. } = &mut r + && let Some(c) = classes.first_mut() + { + c.1.stroke = Rgba::rgb(200, 0, 0); + } + r.reverse_ramp(g); + let strokes = |r: &Renderer| match r { + Renderer::Unique { classes, .. } => classes.iter().map(|c| (c.1.fill.r(), c.1.stroke.r())).collect::>(), + _ => vec![], + }; + assert_eq!(strokes(&r), vec![(2, 255), (1, 200)]); + r.reverse_ramp(g); + assert_eq!(strokes(&r), vec![(1, 200), (2, 255)], "twice is the original"); + // A lone class has no other to change places with. + assert_eq!(unique_with(&[("a", 1)]).reverse_ramp(g), Some(RampReversal { reversed: 0, kept: 0, on_layer_stack: 0 })); + // Nothing to reverse for a single symbol. + assert_eq!(Renderer::Single(Symbol::default_for(g, 0)).reverse_ramp(g), None); + } + + /// #1355: which stack replaces a class's symbol depends on what the layer draws: a polygon's + /// own fills, a line's or a point's own strokes. The other kind is not drawn, and an empty + /// stack is no stack, so those classes take part in the reversal. + #[test] + fn reverse_ramp_keeps_only_the_classes_whose_stack_is_drawn() { + let reds = |r: &Renderer| match r { + Renderer::Unique { classes, .. } => classes.iter().map(|c| c.1.fill.r()).collect::>(), + _ => vec![], + }; + let with = |fills: bool, strokes: bool, empty: bool| { + let mut r = unique_with(&[("a", 1), ("b", 2), ("c", 3)]); + if let Renderer::Unique { class_fills, class_strokes, .. } = &mut r { + let own = Some(Rgba::rgb(9, 9, 9)); + if fills { + class_fills.insert("a".into(), if empty { vec![] } else { vec![FillLayer { color: own, ..FillLayer::default() }] }); + } + if strokes { + class_strokes.insert("a".into(), if empty { vec![] } else { vec![LineStroke { color: own, ..LineStroke::default() }] }); + } + } + r + }; + let (kept, reversed) = (vec![1, 3, 2], vec![3, 2, 1]); + // (stacks of class a, geometry) -> the classes that reverse, and how many are kept. + for (fills, strokes, empty, g, take_part) in [ + (true, false, false, GeometryType::Polygon, false), + (false, true, false, GeometryType::Polygon, true), + (false, true, false, GeometryType::Polyline, false), + (true, false, false, GeometryType::Polyline, true), + (false, true, false, GeometryType::Point, false), + (true, false, false, GeometryType::Point, true), + (true, true, true, GeometryType::Polygon, true), + (true, true, true, GeometryType::Polyline, true), + ] { + let mut r = with(fills, strokes, empty); + let n = r.reverse_ramp(g).map(|x| x.kept); + let case = format!("fills {fills}, strokes {strokes}, empty {empty}, {g:?}"); + if take_part { + assert_eq!(n, Some(0), "{case}"); + assert_eq!(reds(&r), reversed, "{case}"); + } else { + assert_eq!(n, Some(1), "{case}"); + assert_eq!(reds(&r), kept, "{case}"); + } + } + } + + /// #1355: a stack layer without a color of its own takes the symbol's, so the class still shows + /// its ramp color through it and takes part in the reversal: a road's black casing over a core + /// that follows the class. Only a stack whose every layer has its own color hides the symbol. + #[test] + fn reverse_ramp_reverses_the_classes_a_stack_inherits_from() { + let reds = |r: &Renderer| match r { + Renderer::Unique { classes, .. } => classes.iter().map(|c| c.1.fill.r()).collect::>(), + _ => vec![], + }; + let own = Some(Rgba::rgb(9, 9, 9)); + let casing = LineStroke { color: own, width: Some(6.0), ..LineStroke::default() }; + let core = LineStroke::default(); + let (solid_own, solid_inherit) = (FillLayer { color: own, ..FillLayer::default() }, FillLayer::default()); + for (stack_of_strokes, g, hides) in [ + (vec![casing], GeometryType::Polyline, true), + (vec![casing, core], GeometryType::Polyline, false), + (vec![core], GeometryType::Polyline, false), + (vec![casing], GeometryType::Point, true), + (vec![casing, core], GeometryType::Point, false), + ] { + let mut r = unique_with(&[("a", 1), ("b", 2), ("c", 3)]); + if let Renderer::Unique { class_strokes, .. } = &mut r { + class_strokes.insert("a".into(), stack_of_strokes.clone()); + } + let n = r.reverse_ramp(g).map(|x| x.kept); + let case = format!("{} strokes, {g:?}", stack_of_strokes.len()); + assert_eq!((n, reds(&r)), if hides { (Some(1), vec![1, 3, 2]) } else { (Some(0), vec![3, 2, 1]) }, "{case}"); + } + for (fills, hides) in [(vec![solid_own], true), (vec![solid_own, solid_inherit], false), (vec![solid_inherit], false)] { + let mut r = unique_with(&[("a", 1), ("b", 2), ("c", 3)]); + if let Renderer::Unique { class_fills, .. } = &mut r { + class_fills.insert("a".into(), fills.clone()); + } + let n = r.reverse_ramp(GeometryType::Polygon).map(|x| x.kept); + let case = format!("{} fills", fills.len()); + assert_eq!((n, reds(&r)), if hides { (Some(1), vec![1, 3, 2]) } else { (Some(0), vec![3, 2, 1]) }, "{case}"); + } + } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index cce199fda..6f30d91a2 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -4254,7 +4254,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let e = if text.is_empty() { Value::Null } else { json!(text) }; let _ = app.run("layer.symbology", json!({"type": "unique", "expression": e, "ramp": ramp.name()})); } - ramp_picker(app, ui, ramp, t); + ramp_picker_with(app, ui, ramp, t, !l.graphics); ui.add_space(6.0); ui.label( egui::RichText::new(crate::i18n::fmt("{n} classes", &[("n", &classes.len().to_string())])).size(11.5).color(t.text_dim), @@ -4291,7 +4291,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); } - ramp_picker(app, ui, ramp, t); + ramp_picker_with(app, ui, ramp, t, !l.graphics); ui.add_space(6.0); legend_list(app, ui, &l, t); } @@ -4709,8 +4709,26 @@ fn field_combo(app: &mut RustGisApp, ui: &mut Ui, label: &str, cur: &str, fields } fn ramp_picker(app: &mut RustGisApp, ui: &mut Ui, cur: &Ramp, t: &Tokens) { + ramp_picker_with(app, ui, cur, t, false); +} + +/// The color scheme picker; `invert` adds an Invert Color Ramp button to its heading row, for a +/// layer whose classes take their colors from the scheme (#1355). +fn ramp_picker_with(app: &mut RustGisApp, ui: &mut Ui, cur: &Ramp, t: &Tokens, invert: bool) { ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Color scheme")).size(11.5).color(t.text_dim)); + let heading = egui::RichText::new(tl!("Color scheme")).size(11.5).color(t.text_dim); + if invert { + ui.horizontal(|ui| { + ui.label(heading); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button(tl!("Invert Color Ramp")).on_hover_text(tl!("Reverse the order of the classes' colors.")).clicked() { + let _ = app.run("layer.invertRamp", json!({})); + } + }); + }); + } else { + ui.label(heading); + } let grid = |app: &mut RustGisApp, ui: &mut Ui, id: &str, ramps: Vec| { // Two to a row, in the width left inside the group's indent. let w = (ui.available_width() - 8.0) / 2.0; diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 555ed5abe..bebf8f791 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -186,6 +186,34 @@ As with other class colour edits, changing the field or graduated ranges starts a different scheme through `layer.symbology` resets polygon opacity to its default. Layer/group transparency still applies on top of the symbol's opacity in maps and exports. +**Invert Color Ramp** (Symbology pane, or `layer.invertRamp`) reverses the colours of a Graduated +Colors or Unique Values layer's classes along the legend's order, so the highest class takes the +lowest class's colour. Colours edited by hand travel with the reversal, but a polygon fill's +opacity stays with its position in the legend, as it does when a colour scheme is applied: a +semi-transparent fill picked for the first class is still the first class's opacity afterwards. +Applying it twice gives the original back. The `` swatch is no colour of the +scheme and stays as it is. With fewer than two classes showing the scheme there is nothing to +reverse, and the command says so instead of succeeding. + +A class whose own stack hides its symbol keeps it, and the others reverse among themselves; a +notice says how many classes with a stack of their own were left as they are. A stack hides the +symbol when it is drawn (stacked fills on a polygon layer, stacked strokes on a line or point +layer; a polygon's stacked strokes and a line's stacked fills are not drawn) and every layer of it +sets a colour of its own. A layer of a stack left on "follow the class" shows the class's colour, +so that class reverses with the rest: a road's black casing over a core that follows the class. +When the layer's own stack (the one classes without a stack of their own are drawn from) sets a +colour in every layer, the classes show those colours and little of the colour scheme: the colours +are reversed all the same, and a notice says so. On a layer that holds several kinds of geometry, +the reversal treats every class as the layer's main kind, as choosing a colour scheme does. +A fill or stroke colour from **Symbol by Expression** is drawn over the classes' colours wherever +the expression gives one, so the reversal shows only where it gives none, and a notice says so. + +Choosing a colour scheme again starts from the scheme's own order, but classing the layer again +on the same field, ranges and scheme keeps the reversed colours, as it keeps other colours edited +by hand. On a Stretch raster the command switches the existing "Invert color scheme" option. A +graphics layer draws each feature in its own style, so it has no colour scheme to invert and the +command says so. + ### Line strokes A line layer draws one stroke by default. The **Strokes** section of the Symbology pane stacks