interactive-streamlit-bar-plot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interactive_streamlit_bar_plot-0.1.0.dist-info/METADATA +112 -0
- interactive_streamlit_bar_plot-0.1.0.dist-info/RECORD +9 -0
- interactive_streamlit_bar_plot-0.1.0.dist-info/WHEEL +5 -0
- interactive_streamlit_bar_plot-0.1.0.dist-info/licenses/LICENSE +21 -0
- interactive_streamlit_bar_plot-0.1.0.dist-info/top_level.txt +1 -0
- streamlit_net_bars/__init__.py +88 -0
- streamlit_net_bars/frontend/chart.css +32 -0
- streamlit_net_bars/frontend/chart.js +250 -0
- streamlit_net_bars/model.py +36 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: interactive-streamlit-bar-plot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clickable diverging bars with a live net line for Streamlit
|
|
5
|
+
Author: John Della Rosa
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/johndellarosa/interactive-streamlit-bar-plot
|
|
8
|
+
Project-URL: Issues, https://github.com/johndellarosa/interactive-streamlit-bar-plot/issues
|
|
9
|
+
Keywords: streamlit,component,chart,bar-plot,cumulative-net
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: streamlit<2,>=1.51
|
|
19
|
+
Provides-Extra: release
|
|
20
|
+
Requires-Dist: build>=1.2; extra == "release"
|
|
21
|
+
Requires-Dist: twine>=6; extra == "release"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Streamlit Net Bars
|
|
25
|
+
|
|
26
|
+
A reusable Streamlit component with diverging stacked bars and an overlaid net line.
|
|
27
|
+
Click any bar segment to hide that entire series across all x positions. Toggle
|
|
28
|
+
legend buttons to restore individual series, or select **Show all**.
|
|
29
|
+
Use **Undo** to reverse visibility changes one at a time, including legend
|
|
30
|
+
toggles and Show all. Undo is disabled until a change is made. History is kept
|
|
31
|
+
per chart across Streamlit reruns for the current session.
|
|
32
|
+
|
|
33
|
+
Positive values stack above the centered zero line; negative values stack below.
|
|
34
|
+
The line is the signed sum of the **visible** series at each position. All hidden
|
|
35
|
+
means a zero line. The symmetric y scale adjusts to the visible contributions.
|
|
36
|
+
|
|
37
|
+
Toggle **Cumulative net** to show the running signed sum of the visible net
|
|
38
|
+
values, starting at the first x position. For net values `[40, 50, -20]`, the
|
|
39
|
+
cumulative line is `[40, 90, 70]`. Positions use the supplied order, so sort time
|
|
40
|
+
data before passing it in. Bars keep their per-position contributions, and the
|
|
41
|
+
shared axis expands to fit the line. Toggle off to return to per-position net.
|
|
42
|
+
Mode survives reruns; hiding, restoring, and Undo work in either mode. Undo
|
|
43
|
+
continues to reverse visibility changes, independently of the selected mode.
|
|
44
|
+
|
|
45
|
+
**Copy image** copies a PNG of the current chart, title, visible-series legend,
|
|
46
|
+
and net mode at double resolution. It includes the full plot even when the chart
|
|
47
|
+
scrolls horizontally. Paste into an app that accepts images. Copying requires a
|
|
48
|
+
browser that supports image clipboard writes and a secure context (HTTPS or
|
|
49
|
+
localhost). A **Download PNG** link is offered after copying, and also as a
|
|
50
|
+
fallback if clipboard access is unavailable or denied. Copying does not change
|
|
51
|
+
visibility, mode, or Undo history.
|
|
52
|
+
|
|
53
|
+
## Run the demo
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
python -m venv .venv
|
|
57
|
+
.venv\Scripts\python -m pip install -e .
|
|
58
|
+
.venv\Scripts\python -m streamlit run app.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Once published on PyPI, install with `python -m pip install interactive-streamlit-bar-plot`.
|
|
62
|
+
|
|
63
|
+
## Use the component
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import streamlit as st
|
|
67
|
+
from streamlit_net_bars import net_bar_chart
|
|
68
|
+
|
|
69
|
+
result = net_bar_chart(
|
|
70
|
+
x=["Jan", "Feb", "Mar"],
|
|
71
|
+
series={"Revenue": [100, 120, 140], "Costs": [-60, -70, -80]},
|
|
72
|
+
key="cashflow", # unique, stable key per chart
|
|
73
|
+
title="Monthly cashflow",
|
|
74
|
+
height=420, # plot height in pixels; header and legend add height
|
|
75
|
+
)
|
|
76
|
+
st.write(result.net) # [40, 50, 60] with all series visible
|
|
77
|
+
st.write(result.hidden) # names of hidden series
|
|
78
|
+
st.write(result.visible) # names of visible series
|
|
79
|
+
st.write(result.cumulative) # whether cumulative mode is selected
|
|
80
|
+
st.write(result.line_values) # displayed line, including cumulative mode
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Pass a mapping of names to equally sized numeric sequences. DataFrame columns
|
|
84
|
+
work with `series={name: df[name].tolist() for name in columns}`. Values must be
|
|
85
|
+
finite; fill or drop missing values before calling. Labels are displayed in the
|
|
86
|
+
provided order; duplicate labels remain distinct positions. Series names are
|
|
87
|
+
their identities, so use stable names when updating data. Visibility survives
|
|
88
|
+
Streamlit reruns; new series start visible and removed series are ignored.
|
|
89
|
+
|
|
90
|
+
Use `on_change=callback` for a Streamlit callback when visibility or line mode changes. The
|
|
91
|
+
returned net values are recalculated in Python from the current visibility and
|
|
92
|
+
input data. Multiple chart instances are independent when given different keys.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
The frontend is plain JavaScript and SVG, with no Node build or CDN dependency.
|
|
97
|
+
Assets ship inside the Python package. The component uses Streamlit Components
|
|
98
|
+
v2 (Streamlit 1.51 or newer). Bars support Enter/Space and provide native hover
|
|
99
|
+
tooltips. The legend also supports hiding zero-only series. Long charts scroll
|
|
100
|
+
horizontally; long x labels are abbreviated in the plot and shown fully in tooltips.
|
|
101
|
+
|
|
102
|
+
```powershell
|
|
103
|
+
python -m unittest discover -s tests -v
|
|
104
|
+
Get-Content streamlit_net_bars/frontend/chart.js -Raw | node --input-type=module --check
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The demo uses illustrative monthly contributions, not real financial data.
|
|
108
|
+
|
|
109
|
+
## Releases
|
|
110
|
+
|
|
111
|
+
See [PUBLISHING.md](PUBLISHING.md) for the GitHub Actions Trusted Publishing
|
|
112
|
+
setup and release procedure. Licensed under MIT; copyright John Della Rosa.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interactive_streamlit_bar_plot-0.1.0.dist-info/licenses/LICENSE,sha256=NlHKkzYNvoR1Y437A4jBqKBcbg9mFTpQasqq7sEHjKo,1072
|
|
2
|
+
streamlit_net_bars/__init__.py,sha256=vRqjVrPZYmStcEEPlmfeDJwmGRexgfCb-8sMv7hWtiU,3571
|
|
3
|
+
streamlit_net_bars/model.py,sha256=0IMTP6JJoZdYdOzY6DlsUB9a7nnw9RfS8rH_VEP20Ag,1527
|
|
4
|
+
streamlit_net_bars/frontend/chart.css,sha256=0PxyyG2cln6sICf7ApYiIDolbFIWQmawNJnaw4CtOSU,2533
|
|
5
|
+
streamlit_net_bars/frontend/chart.js,sha256=g3bdBMXpQ7SmwXgUrLbMNoPu3tF-iAxhNj32OzG4URE,12924
|
|
6
|
+
interactive_streamlit_bar_plot-0.1.0.dist-info/METADATA,sha256=jZjd-6IYDgqg_jcg9Pb5eKWu3SHzGG0br4yMpmzIcc4,5173
|
|
7
|
+
interactive_streamlit_bar_plot-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
interactive_streamlit_bar_plot-0.1.0.dist-info/top_level.txt,sha256=YuL85to3_3BqXJg-___fxipmXDJV9Oi7f4BKPc92gho,19
|
|
9
|
+
interactive_streamlit_bar_plot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Della Rosa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
streamlit_net_bars
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Streamlit component with clickable series and an overlaid net line."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
|
|
8
|
+
from .model import cumulative_values, net_values, prepare_data
|
|
9
|
+
|
|
10
|
+
__all__ = ["net_bar_chart", "NetBarResult"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class NetBarResult:
|
|
15
|
+
hidden: list[str]
|
|
16
|
+
visible: list[str]
|
|
17
|
+
net: list[float]
|
|
18
|
+
cumulative: bool = False
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def line_values(self) -> list[float]:
|
|
22
|
+
"""Values currently displayed by the line; net stays per-position."""
|
|
23
|
+
return cumulative_values(self.net) if self.cumulative else list(self.net)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_renderer = None
|
|
27
|
+
_renderer_assets = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def net_bar_chart(
|
|
31
|
+
x: Sequence,
|
|
32
|
+
series: Mapping[str, Sequence[float]],
|
|
33
|
+
*,
|
|
34
|
+
key: str,
|
|
35
|
+
title: str = "Contributions & net",
|
|
36
|
+
height: int = 460,
|
|
37
|
+
on_change: Callable | None = None,
|
|
38
|
+
) -> NetBarResult:
|
|
39
|
+
"""Render diverging stacked bars. Click any segment to hide its series.
|
|
40
|
+
|
|
41
|
+
Positive values stack above zero; negative values stack below zero.
|
|
42
|
+
The line sums all visible series at each x position. Legend buttons toggle
|
|
43
|
+
series and Show all resets visibility. Undo reverses visibility changes.
|
|
44
|
+
The Cumulative net toggle displays a running total in the supplied x order.
|
|
45
|
+
A stable, unique key is required.
|
|
46
|
+
Missing, non-finite, and non-numeric values raise ValueError.
|
|
47
|
+
"""
|
|
48
|
+
import streamlit as st
|
|
49
|
+
|
|
50
|
+
global _renderer, _renderer_assets
|
|
51
|
+
if not isinstance(key, str) or not key:
|
|
52
|
+
raise ValueError("key must be a non-empty string unique to this chart.")
|
|
53
|
+
if not isinstance(height, int) or not 280 <= height <= 1000:
|
|
54
|
+
raise ValueError("height must be an integer between 280 and 1000.")
|
|
55
|
+
data = prepare_data(x, series)
|
|
56
|
+
names = [s["name"] for s in data["series"]]
|
|
57
|
+
state = st.session_state.get(key, {})
|
|
58
|
+
hidden = [name for name in state.get("hidden", []) if name in names]
|
|
59
|
+
cumulative = bool(state.get("cumulative", False))
|
|
60
|
+
# Historical snapshots refer to series identities, not their numeric data.
|
|
61
|
+
history = [[name for name in snapshot if name in names]
|
|
62
|
+
for snapshot in state.get("history", [])]
|
|
63
|
+
assets = Path(__file__).parent / "frontend"
|
|
64
|
+
css = (assets / "chart.css").read_text(encoding="utf-8")
|
|
65
|
+
js = (assets / "chart.js").read_text(encoding="utf-8")
|
|
66
|
+
asset_version = sha256((css + "\0" + js).encode("utf-8")).hexdigest()[:16]
|
|
67
|
+
# Imported modules persist between reruns. Refresh the renderer when assets
|
|
68
|
+
# change, and version its identity so clients fetch the new definition.
|
|
69
|
+
if _renderer is None or _renderer_assets != asset_version:
|
|
70
|
+
_renderer = st.components.v2.component(
|
|
71
|
+
f"streamlit_net_bars_{asset_version}",
|
|
72
|
+
html='<div class="net-bars"></div>',
|
|
73
|
+
css=css,
|
|
74
|
+
js=js,
|
|
75
|
+
)
|
|
76
|
+
_renderer_assets = asset_version
|
|
77
|
+
result = _renderer(
|
|
78
|
+
key=key,
|
|
79
|
+
data={**data, "hidden": hidden, "history": history, "cumulative": cumulative,
|
|
80
|
+
"title": str(title), "height": height},
|
|
81
|
+
default={"hidden": hidden, "history": history, "cumulative": cumulative},
|
|
82
|
+
on_history_change=lambda: None,
|
|
83
|
+
on_hidden_change=on_change or (lambda: None),
|
|
84
|
+
on_cumulative_change=on_change or (lambda: None),
|
|
85
|
+
)
|
|
86
|
+
hidden = [name for name in (result.hidden or []) if name in names]
|
|
87
|
+
return NetBarResult(hidden, [n for n in names if n not in hidden],
|
|
88
|
+
net_values(data, hidden), bool(result.cumulative))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.net-bars { font-family: var(--st-font, sans-serif); color: var(--st-text-color, #17253c); }
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
.card { border: 1px solid color-mix(in srgb, currentColor 16%, transparent); border-radius: 16px; padding: 22px; background: var(--st-background-color, #fff); }
|
|
4
|
+
.heading { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
|
5
|
+
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
6
|
+
.mode-toggle[aria-pressed="true"] { background: color-mix(in srgb, #3b82b5 18%, transparent); border-color: #3b82b5; }
|
|
7
|
+
.mode-toggle[aria-pressed="false"] { opacity: 1; text-decoration: none; }
|
|
8
|
+
h3 { margin: 0; font-size: 19px; font-weight: 650; }
|
|
9
|
+
.hint { opacity: .65; font-size: 13px; margin: 8px 0 20px; line-height: 1.5; }
|
|
10
|
+
.legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
11
|
+
button { color: inherit; background: transparent; font: inherit; border: 1px solid color-mix(in srgb, currentColor 22%, transparent); border-radius: 8px; padding: 7px 11px; cursor: pointer; font-size: 13px; }
|
|
12
|
+
button:hover { background: color-mix(in srgb, currentColor 7%, transparent); }
|
|
13
|
+
button:focus-visible, .bar:focus-visible { outline: 3px solid #568bf5; outline-offset: 3px; }
|
|
14
|
+
button[aria-pressed="false"] { opacity: .5; text-decoration: line-through; }
|
|
15
|
+
button:disabled { cursor: default; opacity: .4; }
|
|
16
|
+
.swatch { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 7px; }
|
|
17
|
+
.net-key { font-size: 13px; margin-left: 8px; }
|
|
18
|
+
.net-key::before { content: ''; display: inline-block; width: 20px; border-top: 3px solid currentColor; vertical-align: middle; margin-right: 7px; }
|
|
19
|
+
.plot-scroll { overflow-x: auto; }
|
|
20
|
+
svg { width: 100%; display: block; overflow: visible; }
|
|
21
|
+
svg text { fill: currentColor; font-size: 12px; }
|
|
22
|
+
.grid { stroke: currentColor; opacity: .1; }
|
|
23
|
+
.zero { stroke: currentColor; opacity: .4; }
|
|
24
|
+
.bar { cursor: pointer; transition: opacity .12s; }
|
|
25
|
+
.bar:hover { opacity: .7; }
|
|
26
|
+
.net-line { stroke: currentColor; stroke-width: 3; fill: none; pointer-events: none; stroke-linejoin: round; stroke-linecap: round; }
|
|
27
|
+
.net-dot { fill: var(--st-background-color, white); stroke: currentColor; stroke-width: 2.5; }
|
|
28
|
+
.status { margin: 12px 0 0; font-size: 12px; opacity: .65; }
|
|
29
|
+
.copy-feedback { margin: 8px 0 0; font-size: 13px; }
|
|
30
|
+
.copy-feedback:empty { display: none; }
|
|
31
|
+
.copy-feedback a { color: inherit; text-decoration: underline; }
|
|
32
|
+
@media (max-width: 600px) { .card { padding: 14px; } .heading { align-items: flex-start; flex-direction: column; } }
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
export default function(component) {
|
|
2
|
+
const {parentElement, data, setStateValue} = component;
|
|
3
|
+
const root = parentElement.querySelector('.net-bars');
|
|
4
|
+
const colors = ['#3b82b5', '#55b6a4', '#9c86cf', '#ed9b55', '#dc7081', '#a39567', '#6a97d6', '#b76bad'];
|
|
5
|
+
let hidden = new Set(data.hidden);
|
|
6
|
+
let cumulative = Boolean(data.cumulative);
|
|
7
|
+
const history = (data.history || []).map(snapshot => [...snapshot]);
|
|
8
|
+
function publish() {
|
|
9
|
+
draw();
|
|
10
|
+
setStateValue('history', history.map(snapshot => [...snapshot]));
|
|
11
|
+
setStateValue('hidden', [...hidden]);
|
|
12
|
+
}
|
|
13
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
14
|
+
const make = (tag, attrs = {}, text) => {
|
|
15
|
+
const node = document.createElement(tag);
|
|
16
|
+
for (const [k,v] of Object.entries(attrs)) node.setAttribute(k, v);
|
|
17
|
+
if (text !== undefined) node.textContent = text;
|
|
18
|
+
return node;
|
|
19
|
+
};
|
|
20
|
+
const shape = (tag, attrs = {}, text) => {
|
|
21
|
+
const node = document.createElementNS(ns, tag);
|
|
22
|
+
for (const [k,v] of Object.entries(attrs)) node.setAttribute(k, v);
|
|
23
|
+
if (text !== undefined) node.textContent = text;
|
|
24
|
+
return node;
|
|
25
|
+
};
|
|
26
|
+
const fmt = value => new Intl.NumberFormat(undefined, {maximumFractionDigits: 2, notation: Math.abs(value) >= 10000 ? 'compact' : 'standard'}).format(value);
|
|
27
|
+
function toggle(name) {
|
|
28
|
+
history.push([...hidden]);
|
|
29
|
+
if (hidden.has(name)) hidden.delete(name); else hidden.add(name);
|
|
30
|
+
publish();
|
|
31
|
+
}
|
|
32
|
+
function draw() {
|
|
33
|
+
root.replaceChildren();
|
|
34
|
+
const card = make('div', {class: 'card'});
|
|
35
|
+
const heading = make('div', {class: 'heading'});
|
|
36
|
+
heading.append(make('h3', {}, data.title));
|
|
37
|
+
const actions = make('div', {class: 'actions'});
|
|
38
|
+
const copy = make('button', {type: 'button', title: 'Copy the current chart as a PNG image'}, 'Copy image');
|
|
39
|
+
const copyFeedback = make('p', {class: 'copy-feedback', 'aria-live': 'polite'});
|
|
40
|
+
function offerDownload(blob) {
|
|
41
|
+
const download = make('a', {href: '#'}, 'Download PNG');
|
|
42
|
+
download.onclick = event => {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
const url = URL.createObjectURL(blob);
|
|
45
|
+
const link = make('a', {href: url, download: 'net-bars.png'});
|
|
46
|
+
link.click();
|
|
47
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
48
|
+
};
|
|
49
|
+
copyFeedback.append(document.createTextNode(' '), download);
|
|
50
|
+
}
|
|
51
|
+
copy.onclick = async () => {
|
|
52
|
+
copy.disabled = true;
|
|
53
|
+
copyFeedback.replaceChildren(document.createTextNode('Copying image…'));
|
|
54
|
+
let png;
|
|
55
|
+
try {
|
|
56
|
+
// Start writing during the click gesture. The image promise lets browsers
|
|
57
|
+
// retain user activation while SVG rasterization finishes asynchronously.
|
|
58
|
+
png = chartPng(svg, lineLabel);
|
|
59
|
+
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
|
60
|
+
throw new Error('Image clipboard unavailable');
|
|
61
|
+
}
|
|
62
|
+
await navigator.clipboard.write([new ClipboardItem({'image/png': png})]);
|
|
63
|
+
copyFeedback.textContent = 'Image copied. Paste it into your document or message.';
|
|
64
|
+
offerDownload(await png);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
try {
|
|
67
|
+
const blob = await png;
|
|
68
|
+
if (!blob) throw error;
|
|
69
|
+
copyFeedback.textContent = 'Could not copy the image. You can download it instead: ';
|
|
70
|
+
offerDownload(blob);
|
|
71
|
+
} catch {
|
|
72
|
+
copyFeedback.textContent = 'Could not create the image. Please try again.';
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
copy.disabled = false;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const mode = make('button', {type: 'button', 'aria-pressed': String(cumulative),
|
|
79
|
+
class: 'mode-toggle', title: 'Show a running total in x-axis order'}, 'Cumulative net');
|
|
80
|
+
mode.onclick = () => {
|
|
81
|
+
cumulative = !cumulative;
|
|
82
|
+
draw();
|
|
83
|
+
setStateValue('cumulative', cumulative);
|
|
84
|
+
root.querySelector('.mode-toggle')?.focus();
|
|
85
|
+
};
|
|
86
|
+
const undo = make('button', {type: 'button', title: 'Undo the last visibility change'}, 'Undo');
|
|
87
|
+
undo.disabled = history.length === 0;
|
|
88
|
+
undo.onclick = () => {
|
|
89
|
+
if (!history.length) return;
|
|
90
|
+
hidden = new Set(history.pop());
|
|
91
|
+
publish();
|
|
92
|
+
};
|
|
93
|
+
const reset = make('button', {type: 'button'}, 'Show all');
|
|
94
|
+
reset.disabled = hidden.size === 0;
|
|
95
|
+
reset.onclick = () => {
|
|
96
|
+
if (!hidden.size) return;
|
|
97
|
+
history.push([...hidden]);
|
|
98
|
+
hidden.clear();
|
|
99
|
+
publish();
|
|
100
|
+
};
|
|
101
|
+
actions.append(copy, mode, undo, reset);
|
|
102
|
+
heading.append(actions);
|
|
103
|
+
card.append(heading, make('p', {class: 'hint'}, 'Click a bar to hide its series. Use the legend to bring it back. ' +
|
|
104
|
+
(cumulative ? 'The line shows the running net total from the first position.' : 'The line shows the net of visible series at each position.')));
|
|
105
|
+
const legend = make('div', {class: 'legend', role: 'group', 'aria-label': 'Series visibility'});
|
|
106
|
+
data.series.forEach((s, j) => {
|
|
107
|
+
const button = make('button', {type: 'button', 'aria-pressed': String(!hidden.has(s.name)), 'aria-label': `${hidden.has(s.name) ? 'Show' : 'Hide'} ${s.name}`});
|
|
108
|
+
const swatch = make('span', {class: 'swatch'});
|
|
109
|
+
swatch.style.background = colors[j % colors.length];
|
|
110
|
+
button.append(swatch, document.createTextNode(s.name));
|
|
111
|
+
button.onclick = () => toggle(s.name);
|
|
112
|
+
legend.append(button);
|
|
113
|
+
});
|
|
114
|
+
const lineLabel = cumulative ? 'Cumulative net' : 'Net';
|
|
115
|
+
legend.append(make('span', {class: 'net-key'}, lineLabel));
|
|
116
|
+
card.append(legend);
|
|
117
|
+
const width = Math.max(600, data.x.length * 65);
|
|
118
|
+
const height = data.height;
|
|
119
|
+
const left = 70, right = 28, top = 28, bottom = 54;
|
|
120
|
+
const plotW = width - left - right, plotH = height - top - bottom;
|
|
121
|
+
const visible = data.series.filter(s => !hidden.has(s.name));
|
|
122
|
+
const net = data.x.map((_,i) => visible.reduce((sum,s) => sum + s.values[i], 0));
|
|
123
|
+
let running = 0;
|
|
124
|
+
const line = cumulative ? net.map(value => (running += value)) : net;
|
|
125
|
+
const pos = data.x.map((_,i) => visible.reduce((sum,s) => sum + Math.max(0,s.values[i]), 0));
|
|
126
|
+
const neg = data.x.map((_,i) => visible.reduce((sum,s) => sum + Math.min(0,s.values[i]), 0));
|
|
127
|
+
// Keep a symmetric scale so the zero line remains in the center.
|
|
128
|
+
const extent = Math.max(1, ...pos, ...neg.map(v => -v), ...line.map(Math.abs)) * 1.12;
|
|
129
|
+
const y = v => top + plotH * (extent - v) / (2 * extent);
|
|
130
|
+
const step = plotW / data.x.length;
|
|
131
|
+
const x = i => left + step * (i + .5);
|
|
132
|
+
const svg = shape('svg', {viewBox: `0 0 ${width} ${height}`, role: 'group', 'aria-label': data.title});
|
|
133
|
+
svg.style.minWidth = `${width}px`;
|
|
134
|
+
svg.append(shape('title', {}, 'Interactive contributions and net. Bar segments are buttons; press Enter or Space to hide a series.'));
|
|
135
|
+
for (let tick = -2; tick <= 2; tick++) {
|
|
136
|
+
const value = extent * tick / 2;
|
|
137
|
+
svg.append(shape('line', {x1: left, x2: width-right, y1: y(value), y2: y(value), class: tick === 0 ? 'zero' : 'grid'}));
|
|
138
|
+
svg.append(shape('text', {x: left-10, y: y(value)+4, 'text-anchor': 'end'}, fmt(value)));
|
|
139
|
+
}
|
|
140
|
+
const positive = data.x.map(() => 0), negative = data.x.map(() => 0);
|
|
141
|
+
data.series.forEach((s,j) => {
|
|
142
|
+
if (hidden.has(s.name)) return;
|
|
143
|
+
s.values.forEach((value,i) => {
|
|
144
|
+
const stack = value >= 0 ? positive : negative;
|
|
145
|
+
const from = stack[i];
|
|
146
|
+
stack[i] += value;
|
|
147
|
+
if (value === 0) return;
|
|
148
|
+
const label = `${s.name}, ${data.x[i]}: ${fmt(value)}. Click to hide ${s.name}.`;
|
|
149
|
+
const rect = shape('rect', {x: x(i)-step*.31, y: y(Math.max(from,stack[i])), width: step*.62, height: Math.abs(y(from)-y(stack[i])), fill: colors[j % colors.length], class: 'bar', tabindex: 0, role: 'button', 'aria-label': label});
|
|
150
|
+
rect.append(shape('title', {}, label));
|
|
151
|
+
rect.onclick = () => { toggle(s.name); focusLegend(s.name); };
|
|
152
|
+
rect.onkeydown = e => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); toggle(s.name); focusLegend(s.name);}};
|
|
153
|
+
svg.append(rect);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
svg.append(shape('polyline', {points: line.map((v,i) => `${x(i)},${y(v)}`).join(' '), class: 'net-line'}));
|
|
157
|
+
line.forEach((v,i) => {
|
|
158
|
+
const dot = shape('circle', {cx: x(i), cy: y(v), r: 4, class: 'net-dot', tabindex: 0, 'aria-label': `${data.x[i]} ${lineLabel.toLowerCase()}: ${fmt(v)}`});
|
|
159
|
+
dot.append(shape('title', {}, `${data.x[i]} · ${lineLabel}: ${fmt(v)}`));
|
|
160
|
+
svg.append(dot);
|
|
161
|
+
svg.append(shape('text', {x: x(i), y: height-20, 'text-anchor': 'middle'}, data.x[i].length > 12 ? data.x[i].slice(0,11)+'…' : data.x[i]));
|
|
162
|
+
});
|
|
163
|
+
const scroll = make('div', {class: 'plot-scroll'});
|
|
164
|
+
scroll.append(svg);
|
|
165
|
+
card.append(scroll, make('p', {class: 'status', role: 'status', 'aria-live': 'polite'}, visible.length ? `${visible.length} of ${data.series.length} series visible · ` +
|
|
166
|
+
(cumulative ? 'Cumulative net = running sum of visible net values in x-axis order' : 'Net = sum of visible values at each position') : 'All series hidden · Net is zero. Restore a series using the legend or Show all.'));
|
|
167
|
+
card.append(copyFeedback);
|
|
168
|
+
root.append(card);
|
|
169
|
+
}
|
|
170
|
+
async function chartPng(svg, lineLabel) {
|
|
171
|
+
const plot = svg.cloneNode(true);
|
|
172
|
+
// SVG images cannot inherit the shadow root's CSS. Freeze resolved styles,
|
|
173
|
+
// including theme colors, so the PNG matches the chart and works offline.
|
|
174
|
+
const sourceNodes = [svg, ...svg.querySelectorAll('*')];
|
|
175
|
+
const cloneNodes = [plot, ...plot.querySelectorAll('*')];
|
|
176
|
+
const properties = ['fill', 'stroke', 'stroke-width', 'stroke-linejoin',
|
|
177
|
+
'stroke-linecap', 'opacity', 'font-family', 'font-size', 'font-weight', 'color'];
|
|
178
|
+
sourceNodes.forEach((node, i) => {
|
|
179
|
+
const computed = getComputedStyle(node);
|
|
180
|
+
cloneNodes[i].removeAttribute('style');
|
|
181
|
+
for (const property of properties) {
|
|
182
|
+
cloneNodes[i].style.setProperty(property, computed.getPropertyValue(property));
|
|
183
|
+
}
|
|
184
|
+
cloneNodes[i].removeAttribute('tabindex');
|
|
185
|
+
});
|
|
186
|
+
const width = svg.viewBox.baseVal.width;
|
|
187
|
+
const height = svg.viewBox.baseVal.height;
|
|
188
|
+
const exportWidth = width + 40;
|
|
189
|
+
const textColor = getComputedStyle(root).color;
|
|
190
|
+
const background = getComputedStyle(root.querySelector('.card')).backgroundColor;
|
|
191
|
+
const imageSvg = shape('svg', {xmlns: ns, width: exportWidth});
|
|
192
|
+
imageSvg.append(shape('rect', {width: '100%', height: '100%', fill: background}));
|
|
193
|
+
const text = (x, y, label, size = 13) => shape('text', {
|
|
194
|
+
x, y, fill: textColor, 'font-family': 'Arial, sans-serif', 'font-size': size,
|
|
195
|
+
}, label);
|
|
196
|
+
imageSvg.append(text(24, 32, data.title, 20));
|
|
197
|
+
let legendX = 24, legendY = 62;
|
|
198
|
+
const measurement = document.createElement('canvas').getContext('2d');
|
|
199
|
+
measurement.font = '13px Arial';
|
|
200
|
+
const entries = data.series.map((s,i) => ({label: s.name, color: colors[i % colors.length], hidden: hidden.has(s.name)}))
|
|
201
|
+
.filter(s => !s.hidden);
|
|
202
|
+
entries.push({label: lineLabel, color: textColor});
|
|
203
|
+
entries.forEach(entry => {
|
|
204
|
+
const entryWidth = measurement.measureText(entry.label).width + 40;
|
|
205
|
+
if (legendX + entryWidth > exportWidth - 24 && legendX > 24) {
|
|
206
|
+
legendX = 24;
|
|
207
|
+
legendY += 24;
|
|
208
|
+
}
|
|
209
|
+
imageSvg.append(shape('rect', {x: legendX, y: legendY-9, width: 10, height: 10, rx: 2, fill: entry.color}));
|
|
210
|
+
imageSvg.append(text(legendX+17, legendY, entry.label));
|
|
211
|
+
legendX += entryWidth;
|
|
212
|
+
});
|
|
213
|
+
const plotTop = legendY + 20;
|
|
214
|
+
plot.setAttribute('x', '20');
|
|
215
|
+
plot.setAttribute('y', String(plotTop));
|
|
216
|
+
plot.setAttribute('width', String(width));
|
|
217
|
+
plot.setAttribute('height', String(height));
|
|
218
|
+
imageSvg.append(plot);
|
|
219
|
+
const exportHeight = plotTop + height + 30;
|
|
220
|
+
imageSvg.setAttribute('height', String(exportHeight));
|
|
221
|
+
imageSvg.setAttribute('viewBox', `0 0 ${exportWidth} ${exportHeight}`);
|
|
222
|
+
imageSvg.append(text(24, exportHeight-14, `${data.series.length-hidden.size} of ${data.series.length} series visible · ${lineLabel}`));
|
|
223
|
+
const blob = new Blob([new XMLSerializer().serializeToString(imageSvg)], {type: 'image/svg+xml;charset=utf-8'});
|
|
224
|
+
const url = URL.createObjectURL(blob);
|
|
225
|
+
try {
|
|
226
|
+
const image = new Image();
|
|
227
|
+
await new Promise((resolve, reject) => {
|
|
228
|
+
image.onload = resolve;
|
|
229
|
+
image.onerror = () => reject(new Error('SVG image could not be loaded'));
|
|
230
|
+
image.src = url;
|
|
231
|
+
});
|
|
232
|
+
const canvas = document.createElement('canvas');
|
|
233
|
+
canvas.width = exportWidth * 2;
|
|
234
|
+
canvas.height = exportHeight * 2;
|
|
235
|
+
const context = canvas.getContext('2d');
|
|
236
|
+
context.scale(2, 2);
|
|
237
|
+
context.drawImage(image, 0, 0, exportWidth, exportHeight);
|
|
238
|
+
return await new Promise((resolve, reject) => canvas.toBlob(
|
|
239
|
+
value => value ? resolve(value) : reject(new Error('PNG encoding failed')), 'image/png'));
|
|
240
|
+
} finally {
|
|
241
|
+
URL.revokeObjectURL(url);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function focusLegend(name) {
|
|
245
|
+
const buttons = root.querySelectorAll('.legend button');
|
|
246
|
+
const index = data.series.findIndex(s => s.name === name);
|
|
247
|
+
buttons[index]?.focus();
|
|
248
|
+
}
|
|
249
|
+
draw();
|
|
250
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Validation and net calculations, independent of Streamlit."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from numbers import Real
|
|
6
|
+
from itertools import accumulate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def prepare_data(x: Sequence, series: Mapping) -> dict:
|
|
10
|
+
labels = [str(label) for label in x]
|
|
11
|
+
if not labels:
|
|
12
|
+
raise ValueError("x must contain at least one label.")
|
|
13
|
+
if not isinstance(series, Mapping) or not series:
|
|
14
|
+
raise ValueError("series must be a non-empty mapping of names to values.")
|
|
15
|
+
traces = []
|
|
16
|
+
for name, values in series.items():
|
|
17
|
+
if not isinstance(name, str) or not name.strip():
|
|
18
|
+
raise ValueError("Series names must be non-empty strings.")
|
|
19
|
+
values = list(values)
|
|
20
|
+
if len(values) != len(labels):
|
|
21
|
+
raise ValueError(f"Series {name!r} must have {len(labels)} values.")
|
|
22
|
+
if any(isinstance(v, bool) or not isinstance(v, Real) or not math.isfinite(v) for v in values):
|
|
23
|
+
raise ValueError(f"Series {name!r} must contain finite numbers (no missing values).")
|
|
24
|
+
traces.append({"name": name, "values": [float(v) for v in values]})
|
|
25
|
+
return {"x": labels, "series": traces}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def net_values(data: dict, hidden=()) -> list[float]:
|
|
29
|
+
hidden = set(hidden)
|
|
30
|
+
return [sum(s["values"][i] for s in data["series"] if s["name"] not in hidden)
|
|
31
|
+
for i in range(len(data["x"]))]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cumulative_values(values: Sequence[float]) -> list[float]:
|
|
35
|
+
"""Running signed total in the caller's x-axis order."""
|
|
36
|
+
return list(accumulate(values))
|