mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 22:22:25 +00:00
Graphs: series marks keep a fill or stroke opacity when the graph is drawn again
A graph keeps the paint and stroke weight given to a series' marks, but not
the opacity of their fill or stroke row. An opacity set with
`transparency.set {item}` or `appearance.setItem {opacity}` on a column,
bar, line or other series path was lost the next time the graph was drawn
(a data, type or option change).
- `SeriesPaint` gains `fillOpacity` and `strokeOpacity`. A setItem opacity
on the topmost fill or stroke of a series path stores it, in the same
undo step, and the generator puts it on the topmost fill and stroke of
every path directly in that series' group. Graph designs keep their own
opacity. A value read from a file is held to 0..1.
- The Appearance commands that change the opacity of a series path's
topmost fill or stroke (add, remove, duplicate and move a row, Clear
Appearance, Reduce to Basic Appearance, `appearance.transfer` and the
Eyedropper) update the stored opacity. They do not change the stored
paint, which only `paint.setFill` and `paint.setStroke` set.
- An edit that leaves a series path fully opaque stores nothing for a
series the graph keeps nothing for.
This commit is contained in:
@@ -531,6 +531,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
}
|
||||
_ => vec![None; ids.len()],
|
||||
};
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut inserted = None;
|
||||
// Where the new fill went in on the active item's object, when it went above the active row.
|
||||
let mut above = None;
|
||||
@@ -577,6 +578,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(())
|
||||
})?;
|
||||
// A fill added above the active row becomes the active row; otherwise the rows above the new
|
||||
@@ -595,12 +597,28 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clear(n);
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an
|
||||
/// edit that replaces whole stacks.
|
||||
fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than
|
||||
/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
|
||||
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
|
||||
d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0)
|
||||
};
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
|
||||
}
|
||||
|
||||
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
|
||||
/// group keeps no fill or stroke rows of its own.
|
||||
fn clear(n: &mut Node) {
|
||||
@@ -636,7 +654,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clear(n);
|
||||
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
|
||||
}
|
||||
Ok(count_changed(d, &before, &[source, target]))
|
||||
let changed = if copy { vec![target] } else { vec![source, target] };
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||
@@ -689,7 +709,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -711,9 +732,14 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &[idx], aligned(p));
|
||||
super::overprint::edit_counted(s, "Appearance", |d| {
|
||||
let mut edited = Vec::new();
|
||||
for (id, at) in ids.iter().zip(&per) {
|
||||
let Some(&idx) = at.first() else { continue };
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let items = &n.appearance.items;
|
||||
if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) {
|
||||
edited.push((*id, fill));
|
||||
}
|
||||
if p.get("weight").is_none()
|
||||
&& let Some(pa) = &paint
|
||||
{
|
||||
@@ -744,7 +770,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*vis = v;
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
// The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again.
|
||||
let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
if let Some(pa) = &paint {
|
||||
s.remember_paint(pa);
|
||||
@@ -787,7 +815,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
@@ -813,7 +842,8 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
|
||||
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
|
||||
}
|
||||
let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p));
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p));
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
@@ -851,7 +881,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) }
|
||||
if !any {
|
||||
return Err(bad(C, format!("no item at index {}", idx[0])));
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(())
|
||||
})?;
|
||||
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
|
||||
let m = idx.len();
|
||||
@@ -910,7 +944,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.appearance.set_contents_at(k);
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
// The moved row stays the active one; the rows it passed shift by one.
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
@@ -953,7 +988,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
|
||||
n.appearance.set_contents_at(k);
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, ids))
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept))
|
||||
})?;
|
||||
let m = idx.len();
|
||||
let landed = first_landed.unwrap_or(to);
|
||||
@@ -1090,7 +1126,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
picked.apply(n, &attrs, append);
|
||||
}
|
||||
}
|
||||
Ok(count_changed(d, &before, &targets))
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets));
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept))
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv};
|
||||
use generate::generate;
|
||||
#[cfg(test)]
|
||||
use series::default_series_color;
|
||||
pub(crate) use series::{capture_label_styles, capture_series_paints, paint_targets};
|
||||
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::labels::{
|
||||
FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of,
|
||||
};
|
||||
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
|
||||
use super::series::series_marks;
|
||||
use super::series::{series_marks, with_series_opacity};
|
||||
use super::{
|
||||
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
|
||||
};
|
||||
@@ -1002,7 +1002,7 @@ impl<'a> Plot<'a> {
|
||||
}
|
||||
}
|
||||
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
|
||||
let grp = self.b.series(&name, s, items);
|
||||
let grp = self.b.series(&name, s, with_series_opacity(g, s, items));
|
||||
self.b.out.push(grp);
|
||||
}
|
||||
self.b.out.append(&mut self.on_top);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::labels::{Role, label_style};
|
||||
use super::{CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
|
||||
// ---------- generation ----------
|
||||
|
||||
@@ -25,6 +25,31 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their
|
||||
/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is
|
||||
/// held to 0..1, and one that is not a number is left out.
|
||||
pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
|
||||
let Some(slot) = g.series_paints.get(i) else { return items };
|
||||
let kept = |o: Option<f32>| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
|
||||
let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity));
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return items;
|
||||
}
|
||||
for item in &mut items {
|
||||
if !matches!(item.kind, NodeKind::Path { .. }) {
|
||||
continue;
|
||||
}
|
||||
let ap = &mut Arc::make_mut(item).appearance;
|
||||
if let (Some(o), Some(f)) = (fill, ap.fill_mut()) {
|
||||
f.opacity = o;
|
||||
}
|
||||
if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) {
|
||||
st.opacity = o;
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Where a node inside a graph's generated series art belongs: the graph, the series group and
|
||||
/// the series index stored on that group.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -119,6 +144,51 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
||||
changed
|
||||
}
|
||||
|
||||
/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
|
||||
/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind
|
||||
/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity
|
||||
/// changed.
|
||||
pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
|
||||
if edited.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let captures: Vec<_> = edited
|
||||
.iter()
|
||||
.filter_map(|(id, fill)| {
|
||||
// Only the paths the graph draws again, not the copy of a graph design.
|
||||
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
|
||||
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
|
||||
let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity)))
|
||||
})
|
||||
.collect();
|
||||
let mut changed = false;
|
||||
for (graph, index, fill, kept) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
// A fully opaque row on a series the graph keeps nothing for leaves the graph as it is.
|
||||
if kept.is_none() && spec.series_paints.len() <= index {
|
||||
continue;
|
||||
}
|
||||
let was = spec.series_paints.clone();
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
if spec.series_paints.len() <= index {
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
}
|
||||
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
|
||||
if fill {
|
||||
slot.fill_opacity = kept;
|
||||
} else {
|
||||
slot.stroke_opacity = kept;
|
||||
}
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Graph Data › Transpose row/column: categories become series and series categories.
|
||||
pub(super) fn transpose(g: &mut GraphSpec) {
|
||||
let cells = g.cells();
|
||||
|
||||
@@ -289,6 +289,239 @@ fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() {
|
||||
assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green)));
|
||||
}
|
||||
|
||||
/// An opacity set on the fill or stroke item of one series mark is kept by the graph: every path of that series has
|
||||
/// it after the graph is drawn again, the other series stay opaque, 100% forgets it, and undo takes it back.
|
||||
#[test]
|
||||
fn an_item_opacity_on_a_series_mark_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let opacities = |s: &Session, index: u32| -> Vec<(f32, f32)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, index).children().unwrap().to_vec();
|
||||
marks.iter().map(|m| (m.appearance.fill().unwrap().opacity, m.appearance.stroke().unwrap().opacity)).collect()
|
||||
};
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), Some(0.6)));
|
||||
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
|
||||
assert_eq!(restored, spec(&s, id));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(opacities(&s, 0).iter().all(|o| *o == (0.4, 0.6)), "{:?}", opacities(&s, 0));
|
||||
assert!(opacities(&s, 1).iter().all(|o| *o == (1.0, 1.0)), "{:?}", opacities(&s, 1));
|
||||
// Back to 100%: the graph keeps no opacity for the fill.
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
s.execute("graph.setType", &json!({"type": "bar"})).unwrap();
|
||||
assert!(opacities(&s, 0).iter().all(|o| *o == (1.0, 0.6)), "{:?}", opacities(&s, 0));
|
||||
// Undo goes back through the type change, the 100% and the data edit, then takes the stroke opacity away with its
|
||||
// edit.
|
||||
for _ in 0..4 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), None));
|
||||
}
|
||||
|
||||
/// Clear Appearance and Reduce to Basic Appearance on a series mark take its fill and stroke opacity away, and the
|
||||
/// graph keeps none, so the series stays opaque when it is drawn again.
|
||||
#[test]
|
||||
fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() {
|
||||
for cmd in ["appearance.clear", "appearance.reduceToBasic"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap();
|
||||
s.execute(cmd, &json!({"ids": [bar.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (None, None), "{cmd}");
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque
|
||||
/// (a blend mode alone) keeps nothing for its series.
|
||||
#[test]
|
||||
fn removing_a_series_marks_fill_forgets_its_kept_opacity() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.is_empty());
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
|
||||
s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": 0})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
// An opaque fill added over the kept one takes it away too.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
}
|
||||
|
||||
/// An opacity set on a row added to a graph design's copy inside a series is not kept for the series: the graph draws
|
||||
/// the design again from the design itself.
|
||||
#[test]
|
||||
fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
save_design(&mut s, "Square");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
|
||||
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
assert!(matches!(copy.kind, NodeKind::Group { .. }));
|
||||
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
|
||||
}
|
||||
|
||||
/// A series' kept opacity goes on its generated marks only: a column design made of one path keeps its own opacity.
|
||||
#[test]
|
||||
fn a_column_design_keeps_its_own_opacity_under_a_series_opacity() {
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let mut look = vectorcraft_doc::Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
|
||||
look.fill_mut().unwrap().opacity = 0.5;
|
||||
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let art = vectorcraft_doc::Node::path(d.alloc_id(), rect, look);
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Square".into(), art: std::sync::Arc::new(art) });
|
||||
let options = ColumnDesign { design: "Square".into(), column_type: ColumnType::Vertical, ..ColumnDesign::default() };
|
||||
let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(0.2), ..Default::default() }];
|
||||
let g = GraphSpec {
|
||||
rows: vec![vec![35.0]],
|
||||
axis_min: Some(0.0),
|
||||
axis_max: Some(50.0),
|
||||
series_columns: vec![Some(options)],
|
||||
series_paints: paints,
|
||||
..GraphSpec::default()
|
||||
};
|
||||
let out = super::generate(&mut d, &g);
|
||||
let group = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
let mut fills = vec![];
|
||||
group.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) {
|
||||
fills.push(n.appearance.fill().unwrap().opacity);
|
||||
}
|
||||
});
|
||||
assert!(!fills.is_empty() && fills.iter().all(|o| *o == 0.5), "{fills:?}");
|
||||
}
|
||||
|
||||
/// An opacity set on an item that the graph does not draw again (a second fill added to a mark) is not kept.
|
||||
#[test]
|
||||
fn an_opacity_on_an_added_item_of_a_series_mark_is_not_kept() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
let items = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.clone();
|
||||
let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
|
||||
assert!(items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap() != lower);
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
|
||||
}
|
||||
|
||||
/// The fill positions (lowest, topmost) in a mark's appearance stack.
|
||||
fn fill_rows(s: &Session, mark: NodeId) -> (usize, usize) {
|
||||
let items = s.doc().unwrap().doc.node(mark).unwrap().appearance.items.clone();
|
||||
let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
|
||||
let upper = items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
|
||||
assert!(lower < upper);
|
||||
(lower, upper)
|
||||
}
|
||||
|
||||
/// Moving a translucent fill to the top of a series mark's stack, or placing a copy of it there, makes the graph keep
|
||||
/// its opacity.
|
||||
#[test]
|
||||
fn moving_or_duplicating_a_series_marks_fill_to_the_top_keeps_its_opacity() {
|
||||
for cmd in ["appearance.moveItem", "appearance.duplicateItem"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
let (lower, upper) = fill_rows(&s, bar);
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()), "{cmd}");
|
||||
let p = if cmd == "appearance.moveItem" {
|
||||
json!({"ids": [bar.0], "from": lower, "to": upper})
|
||||
} else {
|
||||
json!({"ids": [bar.0], "index": lower, "to": upper + 1})
|
||||
};
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let top = fill_rows(&s, bar).1;
|
||||
assert_eq!(s.doc().unwrap().doc.node(bar).unwrap().appearance.items[top].opacity(), 0.4, "{cmd}");
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving or copying a translucent mark's appearance onto a mark of another series (the Layers panel's target
|
||||
/// circle, or the Eyedropper) makes the graph keep the opacity for that series; a move leaves the source series with
|
||||
/// none.
|
||||
#[test]
|
||||
fn moving_or_copying_an_appearance_onto_a_series_mark_keeps_its_opacity() {
|
||||
for (cmd, source_keeps) in [("move", None), ("copy", Some(0.4)), ("eyedropper", Some(0.4))] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
|
||||
s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap();
|
||||
match cmd {
|
||||
"eyedropper" => s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(),
|
||||
_ => s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": cmd == "copy"})).unwrap(),
|
||||
};
|
||||
let paints = spec(&s, id).series_paints;
|
||||
assert_eq!(paints.get(1).and_then(|p| p.fill_opacity), Some(0.4), "{cmd}");
|
||||
assert_eq!(paints[0].fill_opacity, source_keeps, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A line graph keeps a stroke opacity set on a series' line, and puts it on the line drawn again.
|
||||
#[test]
|
||||
fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let lines = |s: &Session| -> Vec<vectorcraft_doc::Node> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
marks.iter().filter(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).map(|m| (**m).clone()).collect()
|
||||
};
|
||||
let line = lines(&s).first().unwrap().clone();
|
||||
let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [line.id.0], "item": row, "opacity": 60})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.6));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4], [2, 5]]})).unwrap();
|
||||
let after = lines(&s);
|
||||
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
|
||||
}
|
||||
|
||||
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
|
||||
#[test]
|
||||
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(-1.0), stroke_opacity: Some(f32::NAN), ..Default::default() }];
|
||||
let paints = [paints, vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(40.0), ..Default::default() }]].concat();
|
||||
let g = GraphSpec { rows: vec![vec![35.0, 20.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_paints: paints, ..GraphSpec::default() };
|
||||
let out = super::generate(&mut d, &g);
|
||||
for (index, want) in [(0, 0.0), (1, 1.0)] {
|
||||
let group = out.iter().find(|n| n.extras().series_index == Some(index)).unwrap();
|
||||
let marks: Vec<_> = group.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert!(!marks.is_empty());
|
||||
for m in marks {
|
||||
assert_eq!(m.appearance.fill().unwrap().opacity, want);
|
||||
assert!(m.appearance.stroke().is_none_or(|st| st.opacity == 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A series index read from a file past the graph's series is not a series: painting its art
|
||||
/// paints the art alone and stores nothing (no allocation sized by the file).
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user