Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .issueflows/03-solved-issues/issue181_original.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Issue #181 — turning off mute by group reduces interpretability

Source: https://github.com/cellpy/cellpy-simple-gui/issues/181

Here is an example with mute by group on: (screenshot — cells of one group share
a colour)

Here is after turning it off: (screenshot — every cell has its own colour)

It is almost impossible to get an impression of what group each cells belong
to. This is however very important information. Propose to keep cells in the
same group with the same color also when mute by group is off. Consider option
for using a gradient within group (saturation).
37 changes: 37 additions & 0 deletions .issueflows/03-solved-issues/issue181_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Plan — #181 keep group colours when "Mute by group" is off

## Findings

- cellpy colours per-cell traces by **group** whenever `group_cells` is on,
independent of `group_legend_muting`; muting only decides what goes on
`legendgroup` (the group number, or the cell name).
- The app's own colorway pass (`collect._apply_colorway`, used by the `safe`
and `muted` schemes) keys colours by `legendgroup or name`. With muting off
that key is the cell name, so every cell gets its own colour — the issue.
- With muting on, cells of one group are drawn in **identical** colours (cellpy
removes the sub-group markers), so the members cannot be told apart either.
- `library.PALETTE` (= the `safe` scheme) also paints the sidebar swatches by
`(group - 1) % len`; the plot used order of appearance instead.

## Approach

Colour by group, shade by member — in both muting modes, for every scheme:

1. `collect.cell_groups(records)` → `{batch key or group name: group}`;
`plotting` passes it to `figure_json(cell_groups=…)` for the summary
(ungrouped) and the Cycles pane (`per_cycle` layout; `per_cell`/`film`
colour by cycle and are left alone).
2. `_apply_colorway(fig, scheme, cell_groups=…)`: traces are bucketed by group
via name / legendgroup lookup. A group's base colour is the scheme colour at
`(group - 1) % len` (so `safe` matches the sidebar swatches, and colours
stay put when a group disappears) or, for the `cellpy` scheme, the colour
cellpy already gave the group. Members of a multi-cell group get a lightness
gradient around the base (`_shade_series`, HLS, clamped so nothing goes
white/black); singletons keep the base exactly. Ungrouped traces keep the
previous order-of-appearance behaviour. Colouring moves ahead of legend
truncation in `_restyle` so truncated names cannot collide.
3. Tests: muting off keeps one hue per group (safe scheme); members of a group
get distinct shades of the same hue in both muting modes; `cellpy` scheme
shades from cellpy's own group colour; `per_cell` layout untouched; `safe`
base colour equals the sidebar swatch.
4. Design doc (`group-legend-muting.md` / `plot-appearance.md`) + README line.
40 changes: 40 additions & 0 deletions .issueflows/03-solved-issues/issue181_status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Status — #181 keep group colours when "Mute by group" is off

- [x] Done

## What was done

- `collect.cell_groups(records)` maps trace names (batch keys and group names)
to group ids; `plotting.summary_figure` and `plotting.cycles_figure`
(`per_cycle` layout) pass it to `figure_json(cell_groups=…)`.
- `collect._apply_colorway` buckets per-cell traces by group and paints each
group with one hue — the scheme colour at `(group - 1) % len` (so the `safe`
scheme matches the sidebar swatches) or, for the `cellpy` scheme, the colour
cellpy already gave the group — fanned into lightness shades per member via
the new `_shade_series` / `_parse_color` / `_trace_color` / `_paint`
helpers. Singletons keep the base colour exactly. Traces outside any group
(group averages, `per_cell` facets coloured by cycle) keep the previous
order-of-appearance colouring.
- Colouring now runs before legend truncation in `_restyle`, so truncated
names cannot collide.
- Result is identical with **Mute by group** on or off; muting only decides
what a legend click toggles.

## Tests

- `tests/test_core.py` (#181 section): `cell_groups` map, `_shade_series`,
summary colours follow groups for every scheme × both muting modes, both
muting modes give identical colours, Cycles pane follows groups while
`per_cell` stays coloured by cycle, group-average traces keep their swatch
colours. Full suite green.

## Docs

- `.issueflows/04-designs-and-guides/plot-appearance.md` and
`group-legend-muting.md` updated; README feature line; `llms-full.txt`
regenerated.

## Remaining

- Nothing. A saturation (rather than lightness) gradient was considered and
rejected: muted base colours lose their identity when desaturated further.
9 changes: 9 additions & 0 deletions .issueflows/04-designs-and-guides/group-legend-muting.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ series. The app did not expose the knob.
- Do not expose a separate `group_cells` checkbox; muting alone is enough.
- Cell explorer stays out of scope.

## Colours do not depend on muting (#181)

Turning **Mute by group** off used to recolour the figure: the app colorway was
keyed by Plotly `legendgroup`, which cellpy sets to the group id (muting on) or
the cell name (muting off), so each cell got its own colour and group identity
was lost. Since #181 the colorway is keyed by `collect.cell_groups(records)`:
cells of one group share a hue and differ by lightness, in both muting modes.
See [`plot-appearance.md`](plot-appearance.md).

## Alternatives considered

- Hide the control when N/A — rejected; disable + tooltip keeps layout stable.
Expand Down
10 changes: 10 additions & 0 deletions .issueflows/04-designs-and-guides/plot-appearance.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ light/dark shell hook.
- **Persistence:** `localStorage` keys `csg-figure-theme`, `csg-color-scheme`.
Existing Light/Dark choices in localStorage stay respected.
- **Cell-list swatches** stay on `PALETTE` and do not follow the plot scheme (v1).
- **Colour by group, shade by member (#181):** for per-cell traces
`_apply_colorway` takes a `cell_groups` map (`collect.cell_groups(records)`,
trace name → group id) and paints every group with one hue — scheme colour
index `(group - 1)` (so `safe` matches the sidebar swatches) or, for the
`cellpy` scheme, the colour cellpy already gave the group — fanned into
lightness shades per member (`_shade_series`, HLS lightness ±0.11 per
member, capped at ±0.275). The result is identical whether **Mute by
group** is on or off; muting only decides what a legend click toggles.
Group-average traces (`group_cells=False`) and `per_cell` cycle facets
(coloured by cycle) are left to the plain order-of-appearance pass.
- Keep legend truncation + colorway in app `_restyle`. As of cellpy
**2.1.1.post4** (#801), paper/plot/font colors and panel height go through
`layout_updates` / `height_per_panel` (`collect._inject_app_chrome`); facet
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
- **Cell list** — rename, group, name the groups, select, filter, sort, remove.
- **Projects** — save and reopen the loaded cells, grouping, group names, labels, and selection. Unsaved edits show as `name*`.
- **Export** — CSV, Excel, Parquet, JSON; figures as PNG, SVG, PDF.
- **Light and dark themes.**
- **Light and dark themes.** Plot colour schemes colour by group (one hue per group, a shade per cell) whether or not legend muting is by group.
- **Background loading** with progress.
- **Instruments** from the installed cellpy, including each loader's sub-models.
- **Developer mode** — every summary family cellpy registers, raw traces, diagnostics (`run --dev`).
Expand Down
2 changes: 1 addition & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ FILE: README.md
- **Cell list** — rename, group, name the groups, select, filter, sort, remove.
- **Projects** — save and reopen the loaded cells, grouping, group names, labels, and selection. Unsaved edits show as `name*`.
- **Export** — CSV, Excel, Parquet, JSON; figures as PNG, SVG, PDF.
- **Light and dark themes.**
- **Light and dark themes.** Plot colour schemes colour by group (one hue per group, a shade per cell) whether or not legend muting is by group.
- **Background loading** with progress.
- **Instruments** from the installed cellpy, including each loader's sub-models.
- **Developer mode** — every summary family cellpy registers, raw traces, diagnostics (`run --dev`).
Expand Down
174 changes: 150 additions & 24 deletions src/cellpy_simple_gui/core/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import annotations

import colorsys
import io
import logging

Expand Down Expand Up @@ -109,6 +110,20 @@ def group_titles(records: list[CellRecord]) -> dict[str, str]:
return {str(rec.group): rec.group_label for rec in records if rec.group_label}


def cell_groups(records: list[CellRecord]) -> dict[str, int]:
"""Legend key → group number, for colouring traces by group (#181).

Per-cell traces are named after the batch key; group-averaged traces are
named after the group (``group_name``). Both resolve here so the colour
pass can find a trace's group whatever ``legendgroup`` carries.
"""
out: dict[str, int] = {}
for key, rec in zip(batch_keys(records), records, strict=True):
out[key] = rec.group
out.setdefault(rec.group_name(), rec.group)
return out


def _apply_group_titles(fig, titles: dict[str, str]) -> None:
for tr in fig.data:
group = getattr(tr, "legendgroup", None)
Expand Down Expand Up @@ -878,6 +893,7 @@ def figure_json(
overlay: bool = False,
warnings: list[str] | None = None,
group_titles: dict[str, str] | None = None,
cell_groups: dict[str, int] | None = None,
**plot_kwargs,
) -> str:
# spread (mean ± std band) only makes sense once actually group-averaged.
Expand Down Expand Up @@ -905,7 +921,12 @@ def figure_json(
fig = _overlay_facets(
fig, height=opts["height_per_panel"] + opts["figure_border_height"]
)
_restyle(fig, figure_theme=figure_theme, color_scheme=color_scheme)
_restyle(
fig,
figure_theme=figure_theme,
color_scheme=color_scheme,
cell_groups=cell_groups,
)
if group_titles and not is_grouped(collection):
_apply_group_titles(fig, group_titles)
if y_ranges:
Expand Down Expand Up @@ -1366,47 +1387,152 @@ def _hex_to_rgba(color: str, alpha: float = 0.28) -> str:
return color


def _apply_colorway(fig, color_scheme: str) -> None:
"""Cycle a discrete colorway across legend series (name / legendgroup)."""
def _parse_color(color) -> tuple[int, int, int] | None:
"""``#rrggbb`` / ``rgb(…)`` / ``rgba(…)`` → ``(r, g, b)``; None when unknown."""
if not isinstance(color, str):
return None
c = color.strip()
try:
if c.startswith("#") and len(c) == 7:
return tuple(int(c[i : i + 2], 16) for i in (1, 3, 5)) # type: ignore[return-value]
if c.startswith("rgb"):
parts = c[c.index("(") + 1 : c.rindex(")")].split(",")[:3]
return tuple(int(float(p)) for p in parts) # type: ignore[return-value]
except (ValueError, IndexError):
return None
return None


def _shade_series(base: str, n: int) -> list[str]:
"""``n`` shades of ``base`` — same hue, lightness spread around it (#181).

One member keeps the base colour exactly, so a singleton group matches
its sidebar swatch. Several members fan out from darker to lighter, the
span growing with the count and clamped so no shade turns black or white.
"""
if n <= 1:
return [base]
rgb = _parse_color(base)
if rgb is None:
return [base] * n
h, lum, sat = colorsys.rgb_to_hls(*(v / 255 for v in rgb))
span = min(0.55, 0.22 * (n - 1))
out: list[str] = []
for i in range(n):
t = i / (n - 1)
l_i = min(0.86, max(0.2, lum + (t - 0.5) * span))
r, g, b = colorsys.hls_to_rgb(h, l_i, sat)
out.append(f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}")
return out


def _trace_color(tr) -> str | None:
line = getattr(tr, "line", None)
color = getattr(line, "color", None) if line is not None else None
if isinstance(color, str):
return color
marker = getattr(tr, "marker", None)
color = getattr(marker, "color", None) if marker is not None else None
return color if isinstance(color, str) else None


def _paint(tr, color: str) -> None:
try:
if getattr(tr, "line", None) is not None:
tr.line.color = color
if getattr(tr, "marker", None) is not None:
tr.marker.color = color
# Spread bands need alpha in fillcolor; tr.opacity is ignored for
# fills or washes out the mean line when fill+line share a trace.
fill = getattr(tr, "fill", None)
if fill and fill != "none":
tr.fillcolor = _hex_to_rgba(color, 0.28)
except Exception: # noqa: BLE001 - per-trace color is best-effort
pass


def _apply_colorway(
fig, color_scheme: str, *, cell_groups: dict[str, int] | None = None
) -> None:
"""Colour legend series: by group when the groups are known, else per series.

With ``cell_groups`` (#181) every trace that resolves to a group — by name
or ``legendgroup`` — takes that group's colour, whichever way legend muting
is set: the scheme colour at ``group - 1`` (so ``safe`` matches the sidebar
swatches) or, for the ``cellpy`` scheme, the colour cellpy already gave the
group. Members of a multi-cell group become shades of that colour so they
stay distinguishable. Traces outside any group cycle the scheme in order
of appearance, as before; without a scheme they are left to cellpy.
"""
colors = COLOR_SCHEMES.get(color_scheme)
if not colors:
if not colors and not cell_groups:
return
series_key: dict[str, int] = {}

def series_of(tr) -> str:
# A trace named after a cell is that cell's series even when legend
# muting puts the whole group on one legendgroup.
name = getattr(tr, "name", None)
if cell_groups and name is not None and str(name) in cell_groups:
return str(name)
return str(getattr(tr, "legendgroup", None) or name or id(tr))

def group_of(tr) -> int | None:
if not cell_groups:
return None
for attr in ("name", "legendgroup"):
value = getattr(tr, attr, None)
if value is not None and str(value) in cell_groups:
return cell_groups[str(value)]
return None

# group → series key → traces, all in order of appearance
grouped: dict[int, dict[str, list]] = {}
loose: list[tuple[str, object]] = []
for tr in fig.data:
key = getattr(tr, "legendgroup", None) or getattr(tr, "name", None) or id(tr)
key = str(key)
if key not in series_key:
series_key[key] = len(series_key)
color = colors[series_key[key] % len(colors)]
try:
if getattr(tr, "line", None) is not None:
tr.line.color = color
if getattr(tr, "marker", None) is not None:
tr.marker.color = color
# Spread bands need alpha in fillcolor; tr.opacity is ignored for
# fills or washes out the mean line when fill+line share a trace.
fill = getattr(tr, "fill", None)
if fill and fill != "none":
tr.fillcolor = _hex_to_rgba(color, 0.28)
except Exception: # noqa: BLE001 - per-trace color is best-effort
continue
g = group_of(tr)
if g is None:
loose.append((series_of(tr), tr))
else:
grouped.setdefault(g, {}).setdefault(series_of(tr), []).append(tr)

for g, members in grouped.items():
if colors:
base = colors[(g - 1) % len(colors)]
else:
base = next((c for traces in members.values() for tr in traces if (c := _trace_color(tr))), None)
if base is None:
continue
for shade, traces in zip(_shade_series(base, len(members)), members.values(), strict=True):
for tr in traces:
_paint(tr, shade)

if not colors:
return
series_index: dict[str, int] = {}
for key, tr in loose:
if key not in series_index:
series_index[key] = len(series_index)
_paint(tr, colors[series_index[key] % len(colors)])


def _restyle(
fig,
*,
figure_theme: str = "light",
color_scheme: str = "cellpy",
cell_groups: dict[str, int] | None = None,
) -> None:
"""Post-plot polish: legend truncation, colorway, margins, soft axes.

Paper/plot/font colors and panel height are preferably applied via cellpy
``layout_updates`` / ``height_per_panel`` (#801) in :func:`_inject_app_chrome`.
This pass keeps app-owned legend/colorway behaviour and axis grid styling.
"""
# Name truncation must not share fate with best-effort cosmetics.
# Colour first, while trace names are still the full batch keys the
# group lookup needs (#181); truncation must not share fate with the
# best-effort cosmetics below.
_apply_colorway(fig, color_scheme, cell_groups=cell_groups)
longest = _shorten_legend(fig)
_apply_colorway(fig, color_scheme)
tokens = _THEME_TOKENS.get(figure_theme, _THEME_TOKENS["light"])
try:
strip_pad = _FACET_STRIP_RIGHT_PAD if _has_right_facet_strips(fig) else 0
Expand Down
5 changes: 5 additions & 0 deletions src/cellpy_simple_gui/core/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ def summary_figure(records: list[CellRecord], spec: SummaryPlotSpec) -> str:
group_legend_muting=spec.group_legend_muting,
# Named groups caption their cells in the legend (#187).
group_titles=collect.group_titles(records),
# One colour per group, shaded per cell, whatever legend muting does (#181).
cell_groups=collect.cell_groups(records),
figure_theme=spec.figure_theme,
color_scheme=spec.color_scheme,
# Unit-bearing titles; cellpy defaults are pretty but unit-less (§18 / #38).
Expand Down Expand Up @@ -181,6 +183,9 @@ def cycles_figure(records: list[CellRecord], spec: CyclesPlotSpec) -> str:
family_kind=_CURVE_FAMILIES.get(spec.curve_kind, "cycles"),
group_legend_muting=spec.group_legend_muting,
group_titles=collect.group_titles(records),
# per_cell facets colour by cycle and film is a heatmap; only the
# per-cycle layout has one series per cell to colour by group (#181).
cell_groups=collect.cell_groups(records) if spec.layout == "per_cycle" else None,
figure_theme=spec.figure_theme,
color_scheme=spec.color_scheme,
x_range=spec.x_range,
Expand Down
Loading
Loading