mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
A paint write that leaves a series mark's gradient as the graph drew it now keeps the series' stored gradient and its unit-box flag in two more cases: - a mark with no width or no height, such as a column for a value of 0, where the stored gradient was replaced by the mark's gradient in document coordinates and no mark took it on the next redraw; - a gradient kept in document coordinates (a file saved before the unit box), which a stroke weight written as it was moved to the unit box, so the next redraw placed it on each mark's own box.
857 lines
41 KiB
Rust
857 lines
41 KiB
Rust
use vectorcraft_color::BlendMode;
|
||
|
||
use super::axis::radar_axis;
|
||
use super::labels::{Role, filled_lines_on, label_para, label_style};
|
||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad};
|
||
|
||
// ---------- generation ----------
|
||
|
||
/// Series colours: black, then greys (graphs start in greyscale).
|
||
pub(super) fn default_series_color(i: usize) -> Color {
|
||
const LEVELS: [f32; 8] = [0.0, 0.55, 0.8, 0.3, 0.68, 0.15, 0.9, 0.42];
|
||
let l = LEVELS[i % LEVELS.len()];
|
||
Color::rgb(l, l, l)
|
||
}
|
||
|
||
pub(super) fn default_series_paint(i: usize) -> Paint {
|
||
Paint::solid(default_series_color(i))
|
||
}
|
||
|
||
/// Fill and stroke for one generated mark of series `i`. A stored fill is used only where the
|
||
/// generator paints with the series colour (`series_fill`). A stored stroke replaces that part's
|
||
/// own stroke, so a column's stroke stays a stroke and a line's markers do not repaint its line.
|
||
pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: Paint, width: f64) -> (Paint, Paint, f64) {
|
||
let slot = g.series_paints.get(i);
|
||
let fill = if series_fill { slot.and_then(|p| p.fill.clone()).unwrap_or_else(|| default_series_paint(i)) } else { Paint::None };
|
||
let stroke = slot.and_then(|p| p.stroke.clone()).unwrap_or(stroke);
|
||
let width = if slot.is_some_and(|p| p.stroke.is_some()) { slot.and_then(|p| p.stroke_width).unwrap_or(width) } else { width };
|
||
(fill, stroke, width)
|
||
}
|
||
|
||
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on
|
||
/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its
|
||
/// own. An opacity 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, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity));
|
||
if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() {
|
||
return items;
|
||
}
|
||
for item in &mut items {
|
||
if !matches!(item.kind, NodeKind::Path { .. }) {
|
||
continue;
|
||
}
|
||
let n = Arc::make_mut(item);
|
||
if let Some(o) = object {
|
||
n.opacity = o;
|
||
}
|
||
if let Some(b) = slot.blend {
|
||
n.blend = b;
|
||
}
|
||
let ap = &mut n.appearance;
|
||
if let Some(f) = ap.fill_mut() {
|
||
if let Some(o) = fill {
|
||
f.opacity = o;
|
||
}
|
||
if let Some(b) = slot.fill_blend {
|
||
f.blend = b;
|
||
}
|
||
}
|
||
if let Some(st) = ap.stroke_mut() {
|
||
if let Some(o) = stroke {
|
||
st.opacity = o;
|
||
}
|
||
if let Some(b) = slot.stroke_blend {
|
||
st.blend = b;
|
||
}
|
||
}
|
||
}
|
||
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)]
|
||
struct SeriesRef {
|
||
graph: NodeId,
|
||
group: NodeId,
|
||
index: usize,
|
||
}
|
||
|
||
/// How many series groups `spec`'s graph has: a group whose index is past it (the index comes from the file) is not one.
|
||
fn series_count(spec: &GraphSpec) -> usize {
|
||
spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES)
|
||
}
|
||
|
||
/// Every node in a graph's series groups (the groups too), found in one pass over the document.
|
||
/// Axes, the legend and groups whose index is past the graph's series (the index comes from the
|
||
/// file) are left out.
|
||
fn series_members(doc: &Document) -> HashMap<NodeId, SeriesRef> {
|
||
let mut out = HashMap::new();
|
||
doc.walk(|n| {
|
||
let Some(spec) = n.extras().graph.as_deref() else { return };
|
||
let count = series_count(spec);
|
||
for group in n.children().into_iter().flatten() {
|
||
let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue };
|
||
let r = SeriesRef { graph: n.id, group: group.id, index };
|
||
group.walk(&mut |m| {
|
||
out.insert(m.id, r);
|
||
});
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks
|
||
/// without naming them. They may include objects of groups that only look like series groups; the capture functions
|
||
/// check each one.
|
||
pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||
let mut out = Vec::new();
|
||
for id in roots {
|
||
if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) {
|
||
out.push(*id);
|
||
}
|
||
let Some(root) = doc.node(*id) else { continue };
|
||
root.walk(&mut |n| {
|
||
if n.extras().series_index.is_some() {
|
||
out.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||
}
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Group Selection on a graph series targets its constituent marks and legend swatch, not a
|
||
/// group-level appearance which the generated art does not inherit. Other ids pass through.
|
||
pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||
let members = series_members(doc);
|
||
if members.is_empty() {
|
||
return ids.to_vec();
|
||
}
|
||
let mut out = Vec::new();
|
||
for id in ids {
|
||
// The series' own marks: the art of a graph design inside it keeps the design's paint (painting it would
|
||
// last only until the graph is drawn again), so edit the design to change it.
|
||
match members.get(id) {
|
||
Some(r) if r.group == *id => {
|
||
if let Some(group) = doc.node(*id) {
|
||
out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id));
|
||
}
|
||
}
|
||
Some(r) if doc.parent_of(*id) != Some(r.group) || doc.node(*id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. })) => {}
|
||
_ => out.push(*id),
|
||
}
|
||
}
|
||
out.sort_unstable();
|
||
out.dedup();
|
||
out
|
||
}
|
||
|
||
/// `ids` with each graph series group replaced by the paths directly inside it, as [`paint_targets`] reads a series
|
||
/// group: the graph draws its series groups again with no rows of their own. A series group with no such path, other
|
||
/// ids and the art of a graph design pass through, and the order of `ids` is kept.
|
||
pub(crate) fn series_group_marks(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||
series_group_marks_where(doc, ids, |_| true)
|
||
}
|
||
|
||
/// [`series_group_marks`] for the series groups `stands` accepts; the others pass through.
|
||
pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: impl Fn(&Node) -> bool) -> Vec<NodeId> {
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut out = Vec::new();
|
||
for id in ids {
|
||
match doc.node(*id) {
|
||
Some(group) if is_series_group(doc, group) && stands(group) => {
|
||
let marks: Vec<NodeId> =
|
||
group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id).collect();
|
||
if marks.is_empty() { out.push(*id) } else { out.extend(marks) }
|
||
}
|
||
_ => out.push(*id),
|
||
}
|
||
}
|
||
out.retain(|id| seen.insert(*id));
|
||
out
|
||
}
|
||
|
||
/// Whether `n` is a graph series group: a child of a graph with one of the graph's series indexes ([`series_members`]).
|
||
/// It is looked up through its parent, with no pass over the document: the panels call this while they draw.
|
||
fn is_series_group(doc: &Document, n: &Node) -> bool {
|
||
let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false };
|
||
let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref());
|
||
graph.is_some_and(|spec| index < series_count(spec))
|
||
}
|
||
|
||
/// Whether `id` is the art of a graph design drawn in a graph series (Object › Graph › Column or Marker): an object
|
||
/// inside a series group other than a path directly in it. The graph draws that art again from the design.
|
||
pub(crate) fn is_design_art(doc: &Document, id: NodeId) -> bool {
|
||
let Some(chain) = doc.ancestry(id) else { return false };
|
||
let Some(at) = chain.iter().position(|a| doc.node(*a).is_some_and(|n| is_series_group(doc, n))) else { return false };
|
||
match chain.len() - at {
|
||
1 => false,
|
||
2 => doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. })),
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint).
|
||
/// With no such row: no paint.
|
||
pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
|
||
match node.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()) {
|
||
Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if !st.paint.is_none() => (st.paint.clone(), st.width),
|
||
Some(it) => (it.paint().clone(), 0.0),
|
||
None => (Paint::None, 0.0),
|
||
}
|
||
}
|
||
|
||
/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series
|
||
/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series'
|
||
/// fill or stroke; art left without such a row keeps no paint. Gradients, patterns and swatch links are kept as
|
||
/// [`Paint`], not reduced to a solid color. A series that draws Filled Lines bands stores no stroke: its
|
||
/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series'
|
||
/// captured fill or stroke changed.
|
||
pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
|
||
if edited.is_empty() {
|
||
return false;
|
||
}
|
||
let members = series_members(doc);
|
||
if members.is_empty() {
|
||
return false;
|
||
}
|
||
// Each series is checked once: the check reads the graph's whole grid.
|
||
let mut bands: HashMap<(NodeId, usize), bool> = HashMap::new();
|
||
let mut captures = Vec::new();
|
||
for (id, fill) in edited {
|
||
// Only the paths the graph draws again, not the copy of a graph design.
|
||
let Some(r) = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group)) else { continue };
|
||
let Some(node) = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. })) else { continue };
|
||
let drawn = || doc.node(r.graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| series_draws_bands(g, r.index));
|
||
if !*fill && *bands.entry((r.graph, r.index)).or_insert_with(drawn) {
|
||
continue;
|
||
}
|
||
let (paint, width) = shown_paint(node, *fill);
|
||
let width = (!*fill).then_some(width);
|
||
captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width)));
|
||
}
|
||
store_series_paints(doc, captures)
|
||
}
|
||
|
||
/// Does series `i` draw any Filled Lines band? A line needs two neighboring values, a scatter series two neighboring points
|
||
/// in different places (its index counts pairs of columns), and a radar series two categories with a value plotted away
|
||
/// from the center.
|
||
fn series_draws_bands(g: &GraphSpec, i: usize) -> bool {
|
||
let series = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||
if !g.connect_points || !filled_lines_on(g, series) {
|
||
return false;
|
||
}
|
||
let cells = g.cells();
|
||
let cell = |row: &[Option<f64>], s: usize| row.get(s).copied().flatten();
|
||
match g.series_kind(i) {
|
||
GraphKind::Line => cells.windows(2).any(|w| w.iter().all(|row| cell(row, i).is_some())),
|
||
GraphKind::Radar => {
|
||
let (lo, hi, _) = radar_axis(g);
|
||
cells.len() > 1 && cells.iter().any(|row| (cell(row, i).unwrap_or(0.0) - lo) / (hi - lo) > 0.0)
|
||
}
|
||
GraphKind::Scatter => {
|
||
let s = i.saturating_mul(2);
|
||
let point = |row: &[Option<f64>]| Some((cell(row, s)?, cell(row, s.saturating_add(1))?));
|
||
cells.windows(2).any(|w| matches!(w, [a, b] if point(a).is_some() && point(b).is_some() && point(a) != point(b)))
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Each graph's stored series paints before an edit's first capture into it. A later capture can put back what an earlier
|
||
/// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]).
|
||
type SeriesPaintsBefore = HashMap<NodeId, Vec<vectorcraft_doc::SeriesPaint>>;
|
||
|
||
/// What an edit gave the characters of graph label `id`, applied to a character style ([`capture_label_text`]).
|
||
pub(crate) type LabelTextChange<'a> = &'a dyn Fn(NodeId, &mut CharStyle);
|
||
|
||
/// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the
|
||
/// captures added past the old end and left empty are removed again, so they do not count as a change.
|
||
fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool {
|
||
let mut changed = false;
|
||
for (graph, was) in before {
|
||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||
let empty = vectorcraft_doc::SeriesPaint::default();
|
||
if spec.series_paints.get(was.len()..).is_some_and(|added| added.iter().all(|slot| *slot == empty)) {
|
||
spec.series_paints.truncate(was.len());
|
||
}
|
||
changed |= spec.series_paints != was;
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs.
|
||
const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX;
|
||
|
||
/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the
|
||
/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept.
|
||
/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box.
|
||
fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option<Rect>) -> Option<Paint> {
|
||
let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) };
|
||
let Some(Paint::Gradient(g)) = kept else { return None };
|
||
if !unit {
|
||
return Some(Paint::Gradient(g.clone()));
|
||
}
|
||
let on = on?;
|
||
let mut g = g.clone();
|
||
g.rebase(UNIT_BOX, on);
|
||
Some(Paint::Gradient(g))
|
||
}
|
||
|
||
/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the
|
||
/// rounding a move or a placement on another box leaves).
|
||
fn same_paint_placed(a: &Paint, b: &Paint) -> bool {
|
||
let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b };
|
||
let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6;
|
||
let mut a = (**a).clone();
|
||
match (&mut a.geom, &b.geom) {
|
||
(Some(ag), Some(bg)) => {
|
||
let focal = match (ag.focal, bg.focal) {
|
||
(Some(p), Some(q)) => near(p, q),
|
||
(p, q) => p.is_none() && q.is_none(),
|
||
};
|
||
if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) {
|
||
return false;
|
||
}
|
||
(ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal);
|
||
}
|
||
(None, None) => {}
|
||
_ => return false,
|
||
}
|
||
match (&mut a.freeform, &b.freeform) {
|
||
(Some(af), Some(bf)) => {
|
||
if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) {
|
||
return false;
|
||
}
|
||
for (p, q) in af.points.iter_mut().zip(&bf.points) {
|
||
p.at = q.at;
|
||
}
|
||
}
|
||
(None, None) => {}
|
||
_ => return false,
|
||
}
|
||
a == **b
|
||
}
|
||
|
||
/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown
|
||
/// by half the weight for a stroke. The box can have no width or no height.
|
||
fn paint_box(node: &Node, width: Option<f64>) -> Option<Rect> {
|
||
let b = node.geometric_bounds()?;
|
||
Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w)))
|
||
}
|
||
|
||
/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place
|
||
/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's
|
||
/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved.
|
||
fn unit_box_paint(paint: Paint, from: Option<Rect>) -> (Paint, bool) {
|
||
match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) {
|
||
(Paint::Gradient(mut g), Some(from)) => {
|
||
g.rebase(from, UNIT_BOX);
|
||
(Paint::Gradient(g), true)
|
||
}
|
||
(paint, _) => (paint, false),
|
||
}
|
||
}
|
||
|
||
/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the
|
||
/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in
|
||
/// document coordinates (from files saved before the unit box) are left as they are.
|
||
pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
|
||
let Some(slot) = g.series_paints.get(i) else { return items };
|
||
fn kept(p: &Option<Paint>, unit: bool) -> Option<&Paint> {
|
||
p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_)))
|
||
}
|
||
let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box));
|
||
if fill.is_none() && stroke.is_none() {
|
||
return items;
|
||
}
|
||
// A Filled Lines band takes the kept stroke as its fill when no fill is kept.
|
||
let band_fill = if slot.fill.is_none() { stroke } else { None };
|
||
let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p);
|
||
let stroke_from_store = |p: &Paint| stroke == Some(p);
|
||
for item in &mut items {
|
||
if !matches!(item.kind, NodeKind::Path { .. }) {
|
||
continue;
|
||
}
|
||
let Some(b) = item.geometric_bounds() else { continue };
|
||
let ap = &item.appearance;
|
||
if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) {
|
||
continue;
|
||
}
|
||
let ap = &mut Arc::make_mut(item).appearance;
|
||
if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint))
|
||
&& let Paint::Gradient(gp) = &mut f.paint
|
||
{
|
||
gp.rebase(UNIT_BOX, b);
|
||
}
|
||
if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) {
|
||
let to = st.paint_bounds(b);
|
||
if let Paint::Gradient(gp) = &mut st.paint {
|
||
gp.rebase(UNIT_BOX, to);
|
||
}
|
||
}
|
||
}
|
||
items
|
||
}
|
||
|
||
/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's
|
||
/// [`paint_box`].
|
||
type Capture = (NodeId, usize, bool, Paint, Option<f64>, Option<Rect>);
|
||
|
||
/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a
|
||
/// series' captured fill or stroke differs from what the graph stored before the first capture.
|
||
fn store_series_paints(doc: &mut Document, captures: Vec<Capture>) -> bool {
|
||
let mut before = SeriesPaintsBefore::new();
|
||
for (graph, index, fill, paint, width, from) 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 };
|
||
before.entry(graph).or_insert_with(|| 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 };
|
||
// A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is.
|
||
let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint));
|
||
let (paint, unit) = unit_box_paint(paint, from);
|
||
if fill {
|
||
if !drawn {
|
||
(slot.fill, slot.fill_unit_box) = (Some(paint), unit);
|
||
}
|
||
} else {
|
||
if !drawn {
|
||
(slot.stroke, slot.stroke_unit_box) = (Some(paint), unit);
|
||
}
|
||
slot.stroke_width = width;
|
||
}
|
||
}
|
||
settle_series_paints(doc, before)
|
||
}
|
||
|
||
/// Copy the opacity and blend mode of the topmost visible 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 and blend mode; art left
|
||
/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns
|
||
/// whether a series' kept opacity or blend mode 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 row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible());
|
||
let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||
let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend);
|
||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend)))
|
||
})
|
||
.collect();
|
||
let mut before = SeriesPaintsBefore::new();
|
||
for (graph, index, fill, kept, blend) 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 Normal row on a series the graph keeps nothing for leaves the graph as it is.
|
||
if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index {
|
||
continue;
|
||
}
|
||
before.entry(graph).or_insert_with(|| 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, slot.fill_blend) = (kept, blend);
|
||
} else {
|
||
(slot.stroke_opacity, slot.stroke_blend) = (kept, blend);
|
||
}
|
||
}
|
||
settle_series_paints(doc, before)
|
||
}
|
||
|
||
/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity
|
||
/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object
|
||
/// opacity or blend mode changed.
|
||
pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool {
|
||
let mut before = SeriesPaintsBefore::new();
|
||
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
|
||
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 Normal object on a series the graph keeps nothing for leaves the graph as it is.
|
||
if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index {
|
||
continue;
|
||
}
|
||
before.entry(graph).or_insert_with(|| 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 };
|
||
(slot.opacity, slot.blend) = (opacity, blend);
|
||
}
|
||
settle_series_paints(doc, before)
|
||
}
|
||
|
||
/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying
|
||
/// it.
|
||
pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool {
|
||
let mut after: HashMap<(NodeId, usize), (Option<f32>, Option<BlendMode>)> = HashMap::new();
|
||
let stored = |graph: NodeId, index: usize| {
|
||
let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref());
|
||
spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend))
|
||
};
|
||
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
|
||
after.insert((graph, index), (opacity, blend));
|
||
}
|
||
after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair)
|
||
}
|
||
|
||
/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a
|
||
/// series group, in order.
|
||
fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option<f32>, Option<BlendMode>)> {
|
||
if edited.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let members = series_members(doc);
|
||
if members.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
edited
|
||
.iter()
|
||
.filter_map(|id| {
|
||
// 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 { .. }))?;
|
||
Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend)))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Graph Data › Transpose row/column: categories become series and series categories.
|
||
pub(super) fn transpose(g: &mut GraphSpec) {
|
||
let cells = g.cells();
|
||
let width = cells.iter().map(Vec::len).max().unwrap_or(0);
|
||
let flipped: Vec<Vec<Option<f64>>> = (0..width).map(|s| cells.iter().map(|row| row.get(s).copied().flatten()).collect()).collect();
|
||
std::mem::swap(&mut g.series, &mut g.categories);
|
||
g.set_cells(flipped);
|
||
}
|
||
|
||
/// Graph Data › Switch x/y (scatter graphs): each series' y and x columns trade places, with their labels.
|
||
pub(super) fn switch_xy(g: &mut GraphSpec) {
|
||
let mut cells = g.cells();
|
||
// Labels for every column, so each pair's labels trade places with its data.
|
||
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len());
|
||
g.series.resize(width, String::new());
|
||
for row in &mut cells {
|
||
for pair in row.as_chunks_mut::<2>().0 {
|
||
pair.swap(0, 1);
|
||
}
|
||
}
|
||
for pair in g.series.as_chunks_mut::<2>().0 {
|
||
pair.swap(0, 1);
|
||
}
|
||
while g.series.last().is_some_and(String::is_empty) {
|
||
g.series.pop();
|
||
}
|
||
g.set_cells(cells);
|
||
}
|
||
|
||
/// Every generated label of the document's graphs: its graph and what it shows. Inside a graph's Axes group the
|
||
/// value axis numbers come first and the category labels (one per category with a label) last; a scatter graph's
|
||
/// are all numbers, a radar graph's all categories. Legend groups hold legend labels, and a pie graph's own texts
|
||
/// name its pies.
|
||
fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
|
||
let mut out = HashMap::new();
|
||
doc.walk(|n| {
|
||
let Some(g) = n.extras().graph.as_deref() else { return };
|
||
for c in n.children().into_iter().flatten() {
|
||
match (&c.kind, c.name.as_deref(), c.extras().series_index) {
|
||
(NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => {
|
||
out.insert(c.id, (n.id, Role::Category));
|
||
}
|
||
(NodeKind::Group { .. }, Some("Legend"), None) => {
|
||
for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) {
|
||
out.insert(t.id, (n.id, Role::Legend));
|
||
}
|
||
}
|
||
(NodeKind::Group { .. }, Some("Axes"), None) => {
|
||
let texts: Vec<NodeId> =
|
||
c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect();
|
||
let cats = match g.kind {
|
||
GraphKind::Scatter => 0,
|
||
GraphKind::Radar => texts.len(),
|
||
_ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()),
|
||
};
|
||
for (i, t) in texts.iter().enumerate() {
|
||
out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value }));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as
|
||
/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed.
|
||
pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool {
|
||
let labels = graph_labels(doc);
|
||
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
|
||
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
|
||
kinds.dedup();
|
||
let mut changed = false;
|
||
for (graph, role) in kinds {
|
||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||
let slot = match role {
|
||
Role::Value => &mut spec.label_styles.values_anti_alias,
|
||
Role::Category => &mut spec.label_styles.categories_anti_alias,
|
||
Role::Legend => &mut spec.label_styles.legend_anti_alias,
|
||
};
|
||
changed |= *slot != Some(method);
|
||
*slot = Some(method);
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
|
||
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
|
||
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
|
||
/// they undo together. Returns whether a kept style changed.
|
||
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
|
||
let mut changed = false;
|
||
for (graph, role) in label_kinds(doc, ids) {
|
||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||
let mut style = label_style(&spec.label_styles, role);
|
||
change(&mut style);
|
||
let slot = match role {
|
||
Role::Value => &mut spec.label_styles.values,
|
||
Role::Category => &mut spec.label_styles.categories,
|
||
Role::Legend => &mut spec.label_styles.legend,
|
||
};
|
||
changed |= slot.as_ref() != Some(&style);
|
||
*slot = Some(style);
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
|
||
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
|
||
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
|
||
pub(crate) fn capture_label_paragraphs(
|
||
doc: &mut Document,
|
||
ids: &[NodeId],
|
||
change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>,
|
||
) -> Result<bool> {
|
||
let mut changed = false;
|
||
for (graph, role) in label_kinds(doc, ids) {
|
||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||
let mut para = label_para(&spec.label_styles, role);
|
||
change(&mut para)?;
|
||
let slot = match role {
|
||
Role::Value => &mut spec.label_styles.values_para,
|
||
Role::Category => &mut spec.label_styles.categories_para,
|
||
Role::Legend => &mut spec.label_styles.legend_para,
|
||
};
|
||
changed |= slot.as_ref() != Some(¶);
|
||
*slot = Some(para);
|
||
}
|
||
Ok(changed)
|
||
}
|
||
|
||
/// The object rows graph label `n` shows ([`vectorcraft_doc::LabelRows`]): the paint, weight, opacity and blend mode of
|
||
/// its topmost visible fill and stroke row, its object opacity and blend mode, and where those rows paint. A placed
|
||
/// gradient is kept without its placement, so it fits each label it lands on. `None`: no visible row, opaque and Normal.
|
||
pub(crate) fn label_rows(n: &Node) -> Option<vectorcraft_doc::LabelRows> {
|
||
use vectorcraft_doc::AppearanceItem;
|
||
let items = &n.appearance.items;
|
||
let top = |fill: bool| items.iter().rposition(|it| it.is_fill() == fill && it.visible());
|
||
let opacity = |o: f32| (o != 1.0).then_some(o);
|
||
let blend = |b: BlendMode| (b != BlendMode::Normal).then_some(b);
|
||
let unplaced = crate::cmd::gradient::unplaced;
|
||
let mut rows = vectorcraft_doc::SeriesPaint { opacity: opacity(n.opacity), blend: blend(n.blend), ..Default::default() };
|
||
let (fill, stroke) = (top(true), top(false));
|
||
if let Some(f) = fill.and_then(|i| items.get(i)) {
|
||
(rows.fill, rows.fill_opacity, rows.fill_blend) = (Some(unplaced(f.paint())), opacity(f.opacity()), blend(f.blend()));
|
||
}
|
||
if let Some(AppearanceItem::Stroke(st)) = stroke.and_then(|i| items.get(i)) {
|
||
(rows.stroke, rows.stroke_width, rows.stroke_opacity, rows.stroke_blend) =
|
||
(Some(unplaced(&st.paint)), Some(st.width), opacity(st.opacity), blend(st.blend));
|
||
}
|
||
let contents = n.appearance.contents_at();
|
||
let below = [fill, stroke].into_iter().flatten().filter(|i| *i < contents).fold(0u8, |k, _| k + 1);
|
||
let rows = vectorcraft_doc::LabelRows {
|
||
paint: rows,
|
||
below: (below > 0).then_some(below),
|
||
stroke_under: matches!((fill, stroke), (Some(f), Some(s)) if s < f),
|
||
};
|
||
(rows != vectorcraft_doc::LabelRows::default()).then_some(rows)
|
||
}
|
||
|
||
/// Keep the object rows ([`label_rows`]) of graph labels `ids` for every label of their kind, as
|
||
/// [`capture_label_styles`] keeps a character format, even when a label already showed them: the last label of a kind
|
||
/// in the order of `ids` sets what that kind keeps. Objects other than type are left out before the graph's labels are
|
||
/// looked up. Called inside the same edit, so they undo together. Returns whether kept rows changed.
|
||
pub(crate) fn capture_label_rows(doc: &mut Document, ids: &[NodeId]) -> bool {
|
||
store_label_rows(doc, ids, false)
|
||
}
|
||
|
||
/// Keep the object opacity and blend mode of graph labels `ids` for every label of their kind, as [`capture_label_rows`]
|
||
/// keeps them, and leave the kept fill and stroke rows as they are. Returns whether what the graph keeps changed.
|
||
pub(crate) fn capture_label_object_opacity(doc: &mut Document, ids: &[NodeId]) -> bool {
|
||
store_label_rows(doc, ids, true)
|
||
}
|
||
|
||
/// Would [`capture_label_object_opacity`] change what a graph keeps for labels `ids`? Reads the document without copying
|
||
/// it.
|
||
pub(crate) fn label_object_opacity_differs(doc: &Document, ids: &[NodeId]) -> bool {
|
||
label_rows_of(doc, ids).into_iter().any(|(graph, role, rows)| {
|
||
let Some(spec) = doc.node(graph).and_then(|n| n.extras().graph.as_deref()) else { return false };
|
||
let slot = match role {
|
||
Role::Value => &spec.label_styles.values_rows,
|
||
Role::Category => &spec.label_styles.categories_rows,
|
||
Role::Legend => &spec.label_styles.legend_rows,
|
||
};
|
||
*slot != kept_label_rows(slot, rows, true)
|
||
})
|
||
}
|
||
|
||
/// [`capture_label_rows`], or with `object_only` [`capture_label_object_opacity`].
|
||
fn store_label_rows(doc: &mut Document, ids: &[NodeId], object_only: bool) -> bool {
|
||
let mut changed = false;
|
||
for (graph, role, rows) in label_rows_of(doc, ids) {
|
||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||
let slot = match role {
|
||
Role::Value => &mut spec.label_styles.values_rows,
|
||
Role::Category => &mut spec.label_styles.categories_rows,
|
||
Role::Legend => &mut spec.label_styles.legend_rows,
|
||
};
|
||
let kept = kept_label_rows(slot, rows, object_only);
|
||
changed |= *slot != kept;
|
||
*slot = kept;
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// What a kind keeps after a label of it shows `rows`: those rows, or with `object_only` what `slot` keeps with the
|
||
/// label's object opacity and blend mode.
|
||
fn kept_label_rows(
|
||
slot: &Option<vectorcraft_doc::LabelRows>,
|
||
rows: Option<vectorcraft_doc::LabelRows>,
|
||
object_only: bool,
|
||
) -> Option<vectorcraft_doc::LabelRows> {
|
||
if !object_only {
|
||
return rows;
|
||
}
|
||
let mut kept = slot.clone().unwrap_or_default();
|
||
let shown = rows.unwrap_or_default();
|
||
(kept.paint.opacity, kept.paint.blend) = (shown.paint.opacity, shown.paint.blend);
|
||
(kept != vectorcraft_doc::LabelRows::default()).then_some(kept)
|
||
}
|
||
|
||
/// The graph, kind and rows ([`label_rows`]) of the last label of each kind among `ids`.
|
||
fn label_rows_of(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role, Option<vectorcraft_doc::LabelRows>)> {
|
||
// Graph labels are type: without type among `ids`, the graph's labels are not looked up.
|
||
if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
|
||
return vec![];
|
||
}
|
||
let labels = graph_labels(doc);
|
||
let mut picked: Vec<(NodeId, Role, Option<vectorcraft_doc::LabelRows>)> = vec![];
|
||
for (&(g, role), n) in ids.iter().rev().filter_map(|id| Some((labels.get(id)?, doc.node(*id)?))) {
|
||
if !picked.iter().any(|(pg, pr, _)| *pg == g && *pr == role) {
|
||
picked.push((g, role, label_rows(n)));
|
||
}
|
||
}
|
||
picked
|
||
}
|
||
|
||
/// Keep what an edit gave all of the text of graph labels `ids` for every label of their kind: `change`, when given (the
|
||
/// character attributes the edit applied to label `id`), on the kind's kept style, as [`capture_label_styles`] keeps a
|
||
/// character format, and `para`, when given, as the kind's paragraph attributes ([`capture_label_paragraphs`]).
|
||
/// Attributes the edit did not apply stay as the kind keeps them. Returns whether a kept style or set of attributes
|
||
/// changed.
|
||
pub(crate) fn capture_label_text(
|
||
doc: &mut Document,
|
||
ids: &[NodeId],
|
||
change: Option<LabelTextChange>,
|
||
para: Option<&vectorcraft_doc::ParaStyle>,
|
||
) -> Result<bool> {
|
||
if (change.is_none() && para.is_none()) || !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
|
||
return Ok(false);
|
||
}
|
||
// The last label of each kind sets what the kind keeps.
|
||
let labels = graph_labels(doc);
|
||
let mut last: Vec<((NodeId, Role), NodeId)> = vec![];
|
||
for id in ids.iter().rev() {
|
||
if let Some(kind) = labels.get(id).filter(|k| !last.iter().any(|(l, _)| l == *k)) {
|
||
last.push((*kind, *id));
|
||
}
|
||
}
|
||
let mut changed = false;
|
||
for id in last.iter().rev().map(|(_, id)| id) {
|
||
if let Some(change) = change {
|
||
changed |= capture_label_styles(doc, &[*id], &|st| change(*id, st));
|
||
}
|
||
if let Some(para) = para {
|
||
changed |= capture_label_paragraphs(doc, &[*id], &|p| {
|
||
*p = para.clone();
|
||
Ok(())
|
||
})?;
|
||
}
|
||
}
|
||
Ok(changed)
|
||
}
|
||
|
||
/// The graphs and kinds of label among `ids`, each once.
|
||
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
|
||
if ids.is_empty() {
|
||
return vec![];
|
||
}
|
||
let labels = graph_labels(doc);
|
||
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
|
||
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
|
||
kinds.dedup();
|
||
kinds
|
||
}
|
||
|
||
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
|
||
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
|
||
pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
|
||
Ok(match p.get("seriesIndexes") {
|
||
Some(v) => {
|
||
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`seriesIndexes` is a non-empty list of series indexes"))?;
|
||
a.iter()
|
||
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(cmd, format!("no series {i}"))))
|
||
.collect::<Result<_>>()?
|
||
}
|
||
None if p.get("id").is_none() => {
|
||
let st = s.doc()?;
|
||
let members = series_members(&st.doc);
|
||
let picked: Option<Vec<usize>> = st.selection.objects.iter().map(|n| members.get(n).filter(|r| r.graph == id).map(|r| r.index)).collect();
|
||
let mut out = picked.unwrap_or_default();
|
||
out.sort_unstable();
|
||
out.dedup();
|
||
out
|
||
}
|
||
None => vec![],
|
||
})
|
||
}
|
||
|
||
/// Select the series groups `indexes` of graph `id` (they are new nodes after a regeneration).
|
||
pub(super) fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeId, indexes: &[u32]) {
|
||
let groups: Vec<NodeId> = d
|
||
.node(id)
|
||
.and_then(Node::children)
|
||
.into_iter()
|
||
.flatten()
|
||
.filter(|c| c.extras().series_index.is_some_and(|i| indexes.contains(&i)))
|
||
.map(|c| c.id)
|
||
.collect();
|
||
sel.set(groups);
|
||
}
|