diff --git a/src/extensions/score_sphinx_needs_templates/BUILD b/src/extensions/score_sphinx_needs_templates/BUILD
index d09d9b057..e2f571f3e 100644
--- a/src/extensions/score_sphinx_needs_templates/BUILD
+++ b/src/extensions/score_sphinx_needs_templates/BUILD
@@ -16,7 +16,10 @@ load("@score_tools//score_pytest:pytest.bzl", "score_pytest")
filegroup(
name = "all_sources",
- srcs = ["__init__.py"],
+ srcs = [
+ "__init__.py",
+ "report_pie.py",
+ ],
visibility = ["//visibility:public"],
)
@@ -35,7 +38,10 @@ score_pytest(
# The template regression test builds a temporary Sphinx HTML project,
# so it needs more time than a pure Python unit test.
size = "medium",
- srcs = ["tests/test_needs_templates.py"],
+ srcs = [
+ "tests/test_needs_templates.py",
+ "tests/test_report_pie.py",
+ ],
deps = [
":score_sphinx_needs_templates",
"//src/extensions/score_metamodel:score_metamodel",
diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md
index 31d0e8111..0fd3f399d 100644
--- a/src/extensions/score_sphinx_needs_templates/README.md
+++ b/src/extensions/score_sphinx_needs_templates/README.md
@@ -27,6 +27,11 @@ The extension provides:
* the `needs_of_type(need_type)` helper for selecting Needs by type;
* the `id_filter_list`, `workproduct_rows` and `safety_pie_row` helpers, which
render RST fragments shared by several report templates;
+* the `score-report-pie` directive used by `safety_pie_row`: it counts one
+ Sphinx-Needs filter per content line like `needpie`, but draws a compact
+ chart without legend and slice labels and with large slice numbers, sized
+ for three charts per row (`:labels:` and `:colors:` are required, one per
+ line; the labels go into the image's alt text);
* support for graph-driven `post_template`s that are rendered after parallel
Need collection has been merged;
* ordinary Sphinx page navigation for sections generated by those
diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py
index 322c4a290..6d445c884 100644
--- a/src/extensions/score_sphinx_needs_templates/__init__.py
+++ b/src/extensions/score_sphinx_needs_templates/__init__.py
@@ -19,6 +19,8 @@
from src.helper_lib import config_setdefault
+from . import report_pie
+
_build_environment: BuildEnvironment | None = None
# Post-templates containing this marker need a second read after parallel Need
@@ -423,29 +425,37 @@ def __call__(
f".. rubric:: {safety_label} — {subject}",
f" :class: safety-class-heading safety-{safety_css}",
"",
- ".. grid:: 1 2 3 3",
- " :gutter: 2",
- " :class-row: score-pie-row",
]
+ if count == 0:
+ lines.extend(
+ [
+ ".. raw:: html",
+ "",
+ f'
no {safety_label} {subject.lower()}
',
+ ]
+ )
+ return "\n".join(lines) + "\n"
+
+ lines.extend(
+ [
+ ".. grid:: 1 2 3 3",
+ " :gutter: 2",
+ " :class-row: score-pie-row",
+ ]
+ )
def grid_item(name: str, pie: list[str]) -> None:
- lines.extend(["", " .. grid-item::", ""])
- if count == 0:
- lines.extend(
- [
- " .. raw:: html",
- "",
- ' ',
- f' {subject} {name} ({safety_label})',
- f' no {safety_label} {subject.lower()}',
- "
",
- ]
- )
- else:
- lines.append(
- f" .. needpie:: {chart_prefix} {name} ({safety_label})"
- )
- lines.extend(f" {line}".rstrip() for line in pie)
+ # The continuation line keeps the chart title on two narrow lines.
+ lines.extend(
+ [
+ "",
+ " .. grid-item::",
+ "",
+ f" .. score-report-pie:: {chart_prefix}",
+ f" {name} ({safety_label})",
+ ]
+ )
+ lines.extend(f" {line}".rstrip() for line in pie)
def counted_by(func: str) -> list[str]:
# sphinx-needs splits the call on its last ".", so encode the version.
@@ -460,7 +470,6 @@ def counted_by(func: str) -> list[str]:
[
f":labels: {', '.join(label for label, _ in statuses)}",
f":colors: {status_colors}",
- ":legend:",
*(
counted_by("get_component_status_metrics")
if component
@@ -476,7 +485,6 @@ def counted_by(func: str) -> list[str]:
[
":labels: fully covered, partially covered, not covered",
":colors: #37a12d, #f0a500, #ca2828",
- ":legend:",
*(
counted_by("get_component_coverage_metrics")
if component
@@ -495,7 +503,6 @@ def counted_by(func: str) -> list[str]:
[
":labels: referenced by lower, not referenced",
":colors: #37a12d, #ca2828",
- ":legend:",
"",
f"id in {_id_filter_list_callable(referenced or [])}",
f"id in {_id_filter_list_callable(missing or [])}",
@@ -612,6 +619,7 @@ def setup(app: Sphinx) -> dict[str, object]:
app.config.needs_render_context.setdefault(
"safety_pie_row", _safety_pie_row_callable
)
+ report_pie.setup(app)
app.connect("builder-inited", _capture_build_environment)
# Run after the source-code linker has injected generated testcase Needs and
# their verification backlinks (priority 525), so report templates can
diff --git a/src/extensions/score_sphinx_needs_templates/report_pie.py b/src/extensions/score_sphinx_needs_templates/report_pie.py
new file mode 100644
index 000000000..eb3074abb
--- /dev/null
+++ b/src/extensions/score_sphinx_needs_templates/report_pie.py
@@ -0,0 +1,224 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""``score-report-pie``: the pie chart of the verification reports.
+
+It counts its slices exactly like ``needpie`` (one Sphinx-Needs filter per
+content line, evaluated once all Needs are resolved), but draws a compact chart
+built for three charts per row: no legend and no slice labels, since the
+reports explain the colours once per page, and large numbers in the slices.
+``needpie`` itself stays untouched for every other chart.
+"""
+
+import hashlib
+from collections.abc import Iterator, Sequence
+from contextlib import contextmanager
+from typing import Any
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from matplotlib import style
+from matplotlib.figure import Figure
+from matplotlib.text import Text
+from sphinx.application import Sphinx
+from sphinx.util import logging
+from sphinx.util.docutils import SphinxDirective
+from sphinx_needs.config import NeedsSphinxConfig
+from sphinx_needs.data import SphinxNeedsData
+from sphinx_needs.exceptions import NeedsInvalidFilter
+from sphinx_needs.filter_common import filter_needs_parts
+from sphinx_needs.utils import (
+ check_and_get_external_filter_func,
+ save_matplotlib_figure,
+)
+from sphinx_needs.views import NeedsAndPartsListView
+
+_FIGURE_INCHES = (5.0, 4.0)
+# Inches kept free above the circle for the two-line title and below it.
+_TITLE_INCHES, _BOTTOM_INCHES = 0.75, 0.15
+_VALUE_FONT_SIZE = 14
+_TITLE_FONT_SIZE = 13
+# Slices below this share get no number, as their text would overlap.
+_MIN_LABELLED_SHARE = 0.02
+# Slices below this share get their number at half size to fit.
+_SMALL_SLICE_SHARE = 0.05
+
+
+logger = logging.getLogger(__name__)
+
+
+class ReportPie(nodes.General, nodes.Element):
+ pass
+
+
+class ReportPieDirective(SphinxDirective):
+ """Pie chart with one slice per content line, each a Sphinx-Needs filter.
+
+ Alternatively ``:filter-func:`` names a function that fills the slice sizes,
+ called exactly like ``needpie`` calls it.
+ """
+
+ has_content = True
+ optional_arguments = 1
+ final_argument_whitespace = True
+ option_spec = {
+ "labels": directives.unchanged_required,
+ "colors": directives.unchanged_required,
+ "filter-func": directives.unchanged_required,
+ }
+
+ def run(self) -> Sequence[nodes.Node]:
+ content: list[str] = list(self.content)
+ filters = [line for line in content if line.strip()]
+ labels = _split(self.options.get("labels", ""))
+ colors = _split(self.options.get("colors", ""))
+ filter_func = str(self.options.get("filter-func", ""))
+ if filter_func and filters:
+ raise self.error("score-report-pie takes filter lines or :filter-func:")
+ if len(labels) != len(colors) or (
+ not filter_func and len(filters) != len(labels)
+ ):
+ raise self.error(
+ "score-report-pie needs one label and one colour per slice"
+ )
+ node = ReportPie(
+ title=self.arguments[0] if self.arguments else "",
+ filters=filters,
+ filter_func=filter_func,
+ labels=labels,
+ colors=colors,
+ target_id=f"score-report-pie-{self.env.docname}-"
+ f"{self.env.new_serialno('score-report-pie')}",
+ )
+ self.set_source_info(node)
+ return [node]
+
+
+def _split(value: str) -> list[str]:
+ return [part.strip() for part in value.split(",") if part.strip()]
+
+
+@contextmanager
+def _report_pie_style() -> Iterator[None]:
+ """Pin the matplotlib settings, which other charts may have changed globally."""
+ with style.context(["default", {"savefig.bbox": "standard"}]):
+ yield
+
+
+def draw_report_pie(title: str, sizes: Sequence[float], colors: list[str]) -> Figure:
+ """Draw the chart; ``sizes`` must contain at least one non-zero value."""
+ total = sum(sizes)
+ height = _FIGURE_INCHES[1]
+ figure = Figure(figsize=_FIGURE_INCHES)
+ figure.subplots_adjust(
+ left=0.02,
+ right=0.98,
+ top=1 - _TITLE_INCHES / height,
+ bottom=_BOTTOM_INCHES / height,
+ )
+ axes = figure.add_subplot(aspect="equal")
+ pie: Any = axes.pie(
+ sizes,
+ colors=colors,
+ startangle=90,
+ autopct=lambda pct: f"{pct:.1f}%\n({round(pct * total / 100)})",
+ textprops={"fontsize": _VALUE_FONT_SIZE, "fontweight": "bold"},
+ )
+ values: list[Text] = pie[2]
+ for size, value in zip(sizes, values, strict=True):
+ value.set_visible(size >= total * _MIN_LABELLED_SHARE)
+ if size < total * _SMALL_SLICE_SHARE:
+ value.set_fontsize(_VALUE_FONT_SIZE / 2)
+ axes.set(xlim=(-1.05, 1.05), ylim=(-1.05, 1.05))
+ axes.set_title(title, fontsize=_TITLE_FONT_SIZE, wrap=True)
+ return figure
+
+
+def _call_filter_func(signature: str, need_list: NeedsAndPartsListView) -> list[float]:
+ """Call a ``needpie``-style filter function and return its slice sizes."""
+ result = check_and_get_external_filter_func(signature)
+ assert result is not None
+ args = result.args.split(",") if result.args else []
+ sizes: list[float] = []
+ result.func(
+ needs=need_list,
+ results=sizes,
+ **{f"arg{index + 1}": arg for index, arg in enumerate(args)},
+ )
+ return sizes
+
+
+def _process_report_pies(app: Sphinx, doctree: nodes.document, docname: str) -> None:
+ pies: list[nodes.Element] = list(doctree.findall(ReportPie))
+ if not pies:
+ return
+ needs_config = NeedsSphinxConfig(app.config)
+ need_list = SphinxNeedsData(app.env).get_needs_view().to_list_with_parts()
+ for pie in pies:
+ title: str = pie["title"]
+ filters: list[str] = pie["filters"]
+ labels: list[str] = pie["labels"]
+ target_id: str = pie["target_id"]
+ filter_func: str = pie["filter_func"]
+ sizes: list[float]
+ if filter_func:
+ try:
+ sizes = _call_filter_func(filter_func, need_list)
+ except NeedsInvalidFilter as error:
+ logger.warning(
+ "score-report-pie: %s", error, location=pie, type="score_report_pie"
+ )
+ pie.replace_self(nodes.paragraph(text=f"{title}: invalid filter"))
+ continue
+ if len(sizes) != len(labels):
+ logger.warning(
+ "score-report-pie: %s returned %d values for %d labels",
+ filter_func,
+ len(sizes),
+ len(labels),
+ location=pie,
+ type="score_report_pie",
+ )
+ pie.replace_self(nodes.paragraph(text=f"{title}: invalid filter"))
+ continue
+ else:
+ sizes = [
+ float(
+ len(filter_needs_parts(need_list, needs_config, line, location=pie))
+ )
+ for line in filters
+ ]
+ counts = ", ".join(
+ f"{label} {size:g}" for label, size in zip(labels, sizes, strict=True)
+ )
+ alt = f"{' '.join(title.split())}: {counts}"
+ if not any(sizes):
+ pie.replace_self(nodes.paragraph(text=f"{alt} (no Needs found)"))
+ continue
+ digest = hashlib.sha256(target_id.encode()).hexdigest()[:10]
+ with _report_pie_style():
+ image = save_matplotlib_figure(
+ app,
+ draw_report_pie(title, sizes, pie["colors"]),
+ f"score_report_pie_{digest}",
+ docname,
+ )
+ image["alt"] = alt
+ image["classes"].append("score-report-pie")
+ pie.replace_self(image)
+
+
+def setup(app: Sphinx) -> None:
+ app.add_node(ReportPie)
+ app.add_directive("score-report-pie", ReportPieDirective)
+ # After Sphinx-Needs (default priority) has resolved all Need nodes.
+ app.connect("doctree-resolved", _process_report_pies, priority=600)
diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py
index b00580225..a90a7db73 100644
--- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py
+++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py
@@ -240,14 +240,14 @@ def test_safety_pie_row_qm_counts_inspected_as_valid() -> None:
assert ":class: safety-class-heading safety-asil-b" in asil
-def test_safety_pie_row_empty_class_renders_placeholders_only() -> None:
+def test_safety_pie_row_empty_class_renders_one_slim_placeholder() -> None:
safety_pie_row = vars(templates)["_SafetyPieRow"]()
row = safety_pie_row("Comp", "Requirements", "BASE", "QM", "QM", 0, True)
- assert "needpie" not in row
- assert row.count('') == 3
- assert "no QM requirements" in row
+ assert "score-report-pie" not in row
+ assert ".. grid::" not in row
+ assert row.count('
no QM requirements
') == 1
def test_safety_pie_row_reference_pie_only_on_request() -> None:
@@ -259,12 +259,15 @@ def test_safety_pie_row_reference_pie_only_on_request() -> None:
)
assert "Reference Coverage" not in without
- assert without.count(".. needpie::") == 2
- assert with_ref.count(".. needpie::") == 3
+ assert without.count(".. score-report-pie::") == 2
+ assert with_ref.count(".. score-report-pie::") == 3
assert 'id in ["a", "b"]' in with_ref
assert 'id in ["c"]' in with_ref
# An empty title must not leave a leading blank in the chart title.
- assert ".. needpie:: Stakeholder Requirements Status (QM)" in with_ref
+ assert (
+ ".. score-report-pie:: Stakeholder Requirements\n Status (QM)\n"
+ in with_ref
+ )
def test_id_filter_list_renders_quoted_literal() -> None:
diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py b/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py
new file mode 100644
index 000000000..b97eb84a1
--- /dev/null
+++ b/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py
@@ -0,0 +1,186 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""Tests for the ``score-report-pie`` directive of the verification reports."""
+
+import re
+from pathlib import Path
+
+import pytest
+from score_pytest.attribute_plugin import add_test_properties
+from score_sphinx_needs_templates.report_pie import draw_report_pie
+from sphinx.testing.util import SphinxTestApp
+
+
+@pytest.mark.parametrize(
+ ("sizes", "expected"),
+ [
+ # 1 of 31 is 3.2%: still numbered, at half size.
+ ([1, 30, 0], [("3.2%\n(1)", 7), ("96.8%\n(30)", 14)]),
+ # 1 of 61 is 1.6%: too small for any number; 0 is never numbered.
+ ([1, 60, 0], [("98.4%\n(60)", 14)]),
+ ],
+)
+def test_draw_report_pie_sizes_slice_numbers_by_share(
+ sizes: list[int], expected: list[tuple[str, float]]
+) -> None:
+ figure = draw_report_pie("Comp Requirements\nStatus (QM)", sizes, ["g", "y", "r"])
+ axes = figure.axes[0]
+
+ visible = [t for t in axes.texts if t.get_visible() and t.get_text()]
+
+ assert tuple(figure.get_size_inches()) == (5, 4)
+ assert axes.get_legend() is None
+ assert [(t.get_text(), t.get_fontsize()) for t in visible] == expected
+ assert axes.get_title() == "Comp Requirements\nStatus (QM)"
+
+
+@add_test_properties(
+ partially_verifies=[],
+ test_type="interface-test",
+ derivation_technique="explorative-testing",
+)
+def test_report_pie_counts_filters_and_leaves_needpie_untouched(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Slices are counted per filter line; a plain needpie keeps its own layout."""
+ monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(tmp_path))
+ (tmp_path / "conf.py").write_text(
+ 'extensions = ["sphinx_needs", "score_sphinx_needs_templates"]\n'
+ 'master_doc = "index"\n',
+ encoding="utf-8",
+ )
+ (tmp_path / "index.rst").write_text(
+ """
+Report
+======
+
+.. req:: First open
+ :id: REQ_001
+ :status: open
+
+.. req:: Second open
+ :id: REQ_002
+ :status: open
+
+.. req:: Closed
+ :id: REQ_003
+ :status: closed
+
+.. score-report-pie:: Comp Requirements
+ Status (QM)
+ :labels: open, closed
+ :colors: #37a12d, #ca2828
+
+ type == "req" and status == "open"
+ type == "req" and status == "closed"
+
+.. needpie:: Plain chart
+ :labels: open, closed
+
+ type == "req" and status == "open"
+ type == "req" and status == "closed"
+""",
+ encoding="utf-8",
+ )
+
+ app = SphinxTestApp(
+ srcdir=tmp_path,
+ outdir=tmp_path / "_build",
+ buildername="html",
+ freshenv=True,
+ )
+ try:
+ app.build(force_all=True)
+ html = (app.outdir / "index.html").read_text(encoding="utf-8")
+ images = app.outdir / "_images"
+ report_svgs = list(images.glob("score_report_pie_*.svg"))
+ needpie_svgs = list(images.glob("need_pie_*.svg"))
+ report_svg = report_svgs[0].read_text(encoding="utf-8")
+ needpie_svg = needpie_svgs[0].read_text(encoding="utf-8")
+ finally:
+ app.cleanup()
+
+ assert len(report_svgs) == len(needpie_svgs) == 1
+ assert re.search(
+ r'
![Comp Requirements Status \(QM\): open 2, closed 1]()
None:
+ """``:filter-func:`` receives the Needs and its arguments like in ``needpie``."""
+ monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(tmp_path))
+ monkeypatch.syspath_prepend(str(tmp_path))
+ (tmp_path / "pie_counts.py").write_text(
+ "def by_status(needs, results, arg1, arg2):\n"
+ " for status in (arg1, arg2):\n"
+ ' results.append(sum(n["status"] == status for n in needs))\n',
+ encoding="utf-8",
+ )
+ (tmp_path / "conf.py").write_text(
+ 'extensions = ["sphinx_needs", "score_sphinx_needs_templates"]\n'
+ 'master_doc = "index"\n',
+ encoding="utf-8",
+ )
+ (tmp_path / "index.rst").write_text(
+ """
+Report
+======
+
+.. req:: Open
+ :id: REQ_001
+ :status: open
+
+.. req:: Closed
+ :id: REQ_002
+ :status: closed
+
+.. req:: Also closed
+ :id: REQ_003
+ :status: closed
+
+.. score-report-pie:: Comp Requirements
+ Status (QM)
+ :labels: open, closed
+ :colors: #37a12d, #ca2828
+ :filter-func: pie_counts.by_status(open,closed)
+""",
+ encoding="utf-8",
+ )
+
+ app = SphinxTestApp(
+ srcdir=tmp_path,
+ outdir=tmp_path / "_build",
+ buildername="html",
+ freshenv=True,
+ )
+ try:
+ app.build(force_all=True)
+ html = (app.outdir / "index.html").read_text(encoding="utf-8")
+ finally:
+ app.cleanup()
+
+ assert 'alt="Comp Requirements Status (QM): open 1, closed 2"' in html
diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need
index 160473087..013b44a4c 100644
--- a/src/needs_templates/module_verification_report.need
+++ b/src/needs_templates/module_verification_report.need
@@ -83,7 +83,7 @@
/* Scales every chart to its column; together with the fixed three-column
raster this gives every chart the same size as in the platform report. */
- .score-pie-row img[id^="needpie-"] {
+ .score-pie-row img.score-report-pie {
width: 100%;
height: auto;
}
@@ -99,15 +99,12 @@
p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; }
p.rubric.safety-class-heading.safety-qm { background: #6c757d; }
- /* Matches the aspect ratio of the rendered pie charts. */
+ /* Stands in for a whole pie row of a safety class without any Need. */
.empty-pie {
- display: flex; flex-direction: column; align-items: center;
- justify-content: center; gap: 0.4em;
- aspect-ratio: 16 / 9; padding: 0.5em; text-align: center;
- border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d;
+ padding: 0.2em 0.8em; margin-bottom: 0.5em;
+ border: 2px dashed #b0b0b0; border-radius: 6px;
+ color: #6c757d; font-style: italic;
}
- .empty-pie-title { font-weight: 600; }
- .empty-pie-text { font-style: italic; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; }
.chart-legend-group {
diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need
index 62efcf1e8..ae3e3f61a 100644
--- a/src/needs_templates/platform_verification_report.need
+++ b/src/needs_templates/platform_verification_report.need
@@ -76,7 +76,7 @@
.wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; }
/* Scales every chart to its column; together with the fixed three-column
raster this gives every chart in the report the same size. */
- .score-pie-row img[id^="needpie-"] {
+ .score-pie-row img.score-report-pie {
width: 100%;
height: auto;
}
@@ -92,15 +92,12 @@
p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; }
p.rubric.safety-class-heading.safety-qm { background: #6c757d; }
- /* Matches the aspect ratio of the rendered pie charts. */
+ /* Stands in for a whole pie row of a safety class without any Need. */
.empty-pie {
- display: flex; flex-direction: column; align-items: center;
- justify-content: center; gap: 0.4em;
- aspect-ratio: 16 / 9; padding: 0.5em; text-align: center;
- border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d;
+ padding: 0.2em 0.8em; margin-bottom: 0.5em;
+ border: 2px dashed #b0b0b0; border-radius: 6px;
+ color: #6c757d; font-style: italic;
}
- .empty-pie-title { font-weight: 600; }
- .empty-pie-text { font-style: italic; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; }
.chart-legend-group {