interactive-streamlit-bar-plot 0.1.0__tar.gz

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.
@@ -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,6 @@
1
+ include README.md
2
+ include app.py
3
+ include LICENSE
4
+ include PUBLISHING.md
5
+ recursive-include tests *.py
6
+ recursive-include streamlit_net_bars/frontend *.js *.css
@@ -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,68 @@
1
+ # Publishing interactive-streamlit-bar-plot
2
+
3
+ Package: `interactive-streamlit-bar-plot` · initial version: `0.1.0` · author: John Della Rosa · MIT.
4
+
5
+ ## One-time account setup
6
+
7
+ 1. Sign in to [PyPI](https://pypi.org/) and enable two-factor authentication if
8
+ your account is not already configured.
9
+ 2. Open [PyPI Publishing](https://pypi.org/manage/account/publishing/). Under
10
+ **Add a new pending publisher**, select GitHub and enter:
11
+
12
+ | Field | Value |
13
+ | --- | --- |
14
+ | PyPI project name | `interactive-streamlit-bar-plot` |
15
+ | Owner | `johndellarosa` |
16
+ | Repository name | `interactive-streamlit-bar-plot` |
17
+ | Workflow name | `publish.yml` |
18
+ | Environment name | `pypi` |
19
+
20
+ Use just the workflow filename, not `.github/workflows/publish.yml`.
21
+ If the project already exists under your account, add the same publisher
22
+ through that project's **Manage → Publishing** page instead.
23
+ 3. In the [GitHub repository environments](https://github.com/johndellarosa/interactive-streamlit-bar-plot/settings/environments),
24
+ create an environment named `pypi`. You can require a reviewer here if your
25
+ GitHub plan supports environment protection rules.
26
+
27
+ This setup lets the specified workflow publish the package using short-lived
28
+ credentials. No PyPI password or API token is stored in GitHub.
29
+ Pending publishers do not reserve package names; PyPI checks name availability
30
+ when the first upload occurs.
31
+
32
+ ## Publish the first release
33
+
34
+ 1. Commit and push the project, including `.github/workflows/publish.yml`, to
35
+ `johndellarosa/interactive-streamlit-bar-plot`.
36
+ 2. Open [Create a GitHub release](https://github.com/johndellarosa/interactive-streamlit-bar-plot/releases/new).
37
+ Create tag `v0.1.0` against the commit containing this package and workflow.
38
+ Add a release title and notes, then publish the release.
39
+ 3. Watch [GitHub Actions](https://github.com/johndellarosa/interactive-streamlit-bar-plot/actions/workflows/publish.yml).
40
+ The build job verifies the tag, runs tests, builds both distributions, and
41
+ checks metadata and packaged assets. The publish job uploads those artifacts
42
+ to PyPI. Approve the `pypi` environment deployment if configured.
43
+ 4. Confirm the version on [PyPI](https://pypi.org/project/interactive-streamlit-bar-plot/),
44
+ then install in a fresh environment:
45
+
46
+ ```powershell
47
+ python -m pip install interactive-streamlit-bar-plot==0.1.0
48
+ ```
49
+
50
+ The workflow only runs when a GitHub release is published. Ordinary commits and
51
+ pull requests do not publish packages. If the publisher settings are wrong,
52
+ correct them and rerun the failed publish job.
53
+
54
+ ## Local release checks
55
+
56
+ ```powershell
57
+ .venv\Scripts\python -m pip install -e '.[release]'
58
+ .venv\Scripts\python -m unittest discover -s tests -v
59
+ .venv\Scripts\python -m build
60
+ .venv\Scripts\python -m twine check --strict dist/*
61
+ ```
62
+
63
+ Before later releases, increment `project.version` in `pyproject.toml` and create
64
+ the matching `v<version>` release tag. PyPI does not allow replacing an uploaded
65
+ file with the same filename; use a new version for package changes.
66
+
67
+ References: [creating a project through Trusted Publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
68
+ and [publishing with GitHub Actions](https://docs.pypi.org/trusted-publishers/using-a-publisher/).
@@ -0,0 +1,89 @@
1
+ # Streamlit Net Bars
2
+
3
+ A reusable Streamlit component with diverging stacked bars and an overlaid net line.
4
+ Click any bar segment to hide that entire series across all x positions. Toggle
5
+ legend buttons to restore individual series, or select **Show all**.
6
+ Use **Undo** to reverse visibility changes one at a time, including legend
7
+ toggles and Show all. Undo is disabled until a change is made. History is kept
8
+ per chart across Streamlit reruns for the current session.
9
+
10
+ Positive values stack above the centered zero line; negative values stack below.
11
+ The line is the signed sum of the **visible** series at each position. All hidden
12
+ means a zero line. The symmetric y scale adjusts to the visible contributions.
13
+
14
+ Toggle **Cumulative net** to show the running signed sum of the visible net
15
+ values, starting at the first x position. For net values `[40, 50, -20]`, the
16
+ cumulative line is `[40, 90, 70]`. Positions use the supplied order, so sort time
17
+ data before passing it in. Bars keep their per-position contributions, and the
18
+ shared axis expands to fit the line. Toggle off to return to per-position net.
19
+ Mode survives reruns; hiding, restoring, and Undo work in either mode. Undo
20
+ continues to reverse visibility changes, independently of the selected mode.
21
+
22
+ **Copy image** copies a PNG of the current chart, title, visible-series legend,
23
+ and net mode at double resolution. It includes the full plot even when the chart
24
+ scrolls horizontally. Paste into an app that accepts images. Copying requires a
25
+ browser that supports image clipboard writes and a secure context (HTTPS or
26
+ localhost). A **Download PNG** link is offered after copying, and also as a
27
+ fallback if clipboard access is unavailable or denied. Copying does not change
28
+ visibility, mode, or Undo history.
29
+
30
+ ## Run the demo
31
+
32
+ ```powershell
33
+ python -m venv .venv
34
+ .venv\Scripts\python -m pip install -e .
35
+ .venv\Scripts\python -m streamlit run app.py
36
+ ```
37
+
38
+ Once published on PyPI, install with `python -m pip install interactive-streamlit-bar-plot`.
39
+
40
+ ## Use the component
41
+
42
+ ```python
43
+ import streamlit as st
44
+ from streamlit_net_bars import net_bar_chart
45
+
46
+ result = net_bar_chart(
47
+ x=["Jan", "Feb", "Mar"],
48
+ series={"Revenue": [100, 120, 140], "Costs": [-60, -70, -80]},
49
+ key="cashflow", # unique, stable key per chart
50
+ title="Monthly cashflow",
51
+ height=420, # plot height in pixels; header and legend add height
52
+ )
53
+ st.write(result.net) # [40, 50, 60] with all series visible
54
+ st.write(result.hidden) # names of hidden series
55
+ st.write(result.visible) # names of visible series
56
+ st.write(result.cumulative) # whether cumulative mode is selected
57
+ st.write(result.line_values) # displayed line, including cumulative mode
58
+ ```
59
+
60
+ Pass a mapping of names to equally sized numeric sequences. DataFrame columns
61
+ work with `series={name: df[name].tolist() for name in columns}`. Values must be
62
+ finite; fill or drop missing values before calling. Labels are displayed in the
63
+ provided order; duplicate labels remain distinct positions. Series names are
64
+ their identities, so use stable names when updating data. Visibility survives
65
+ Streamlit reruns; new series start visible and removed series are ignored.
66
+
67
+ Use `on_change=callback` for a Streamlit callback when visibility or line mode changes. The
68
+ returned net values are recalculated in Python from the current visibility and
69
+ input data. Multiple chart instances are independent when given different keys.
70
+
71
+ ## Development
72
+
73
+ The frontend is plain JavaScript and SVG, with no Node build or CDN dependency.
74
+ Assets ship inside the Python package. The component uses Streamlit Components
75
+ v2 (Streamlit 1.51 or newer). Bars support Enter/Space and provide native hover
76
+ tooltips. The legend also supports hiding zero-only series. Long charts scroll
77
+ horizontally; long x labels are abbreviated in the plot and shown fully in tooltips.
78
+
79
+ ```powershell
80
+ python -m unittest discover -s tests -v
81
+ Get-Content streamlit_net_bars/frontend/chart.js -Raw | node --input-type=module --check
82
+ ```
83
+
84
+ The demo uses illustrative monthly contributions, not real financial data.
85
+
86
+ ## Releases
87
+
88
+ See [PUBLISHING.md](PUBLISHING.md) for the GitHub Actions Trusted Publishing
89
+ setup and release procedure. Licensed under MIT; copyright John Della Rosa.
@@ -0,0 +1,36 @@
1
+ """Run with: streamlit run app.py"""
2
+ import streamlit as st
3
+ from streamlit_net_bars import net_bar_chart
4
+
5
+ st.set_page_config(page_title="Net Bars · Interactive contributions", page_icon="📊", layout="wide")
6
+ st.caption("STREAMLIT COMPONENT / INTERACTIVE CHARTS")
7
+ st.title("See what moves the net.")
8
+ st.write("Explore positive and negative contributions. Hide a series to see how the balance changes.")
9
+
10
+ periods = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
11
+ series = {
12
+ "Product sales": [95, 110, 105, 130, 145, 160, 155, 175, 190, 180, 210, 230],
13
+ "Services": [40, 42, 50, 48, 55, 62, 60, 68, 70, 75, 80, 90],
14
+ "Operating costs": [-70, -75, -78, -80, -88, -90, -92, -100, -105, -110, -115, -120],
15
+ "Marketing": [-25, -35, -30, -45, -40, -50, -48, -45, -55, -50, -65, -70],
16
+ }
17
+ result = net_bar_chart(periods, series, key="contributions", title="Monthly contributions", height=420)
18
+ col1, col2, col3 = st.columns(3)
19
+ col1.metric("Total visible net", f"{sum(result.net):,.0f}")
20
+ col2.metric("Visible series", f"{len(result.visible)} / {len(series)}")
21
+ col3.metric("Hidden series", len(result.hidden))
22
+ with st.expander("Inspect the current net values"):
23
+ st.dataframe({"Month": periods, "Net": result.net,
24
+ "Displayed line (cumulative)" if result.cumulative else "Displayed line": result.line_values},
25
+ hide_index=True, width="stretch")
26
+ st.write("Hidden:", ", ".join(result.hidden) or "None")
27
+ with st.expander("Use in your own app"):
28
+ st.code('''from streamlit_net_bars import net_bar_chart
29
+
30
+ result = net_bar_chart(
31
+ x=["Jan", "Feb", "Mar"],
32
+ series={"Revenue": [100, 120, 140], "Costs": [-60, -70, -80]},
33
+ key="my_chart",
34
+ )
35
+ st.write(result.net) # [40, 50, 60] when both series are visible
36
+ ''', language="python")
@@ -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,17 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLISHING.md
4
+ README.md
5
+ app.py
6
+ pyproject.toml
7
+ interactive_streamlit_bar_plot.egg-info/PKG-INFO
8
+ interactive_streamlit_bar_plot.egg-info/SOURCES.txt
9
+ interactive_streamlit_bar_plot.egg-info/dependency_links.txt
10
+ interactive_streamlit_bar_plot.egg-info/requires.txt
11
+ interactive_streamlit_bar_plot.egg-info/top_level.txt
12
+ streamlit_net_bars/__init__.py
13
+ streamlit_net_bars/model.py
14
+ streamlit_net_bars/frontend/chart.css
15
+ streamlit_net_bars/frontend/chart.js
16
+ tests/test_app.py
17
+ tests/test_model.py
@@ -0,0 +1,5 @@
1
+ streamlit<2,>=1.51
2
+
3
+ [release]
4
+ build>=1.2
5
+ twine>=6
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "interactive-streamlit-bar-plot"
7
+ version = "0.1.0"
8
+ description = "Clickable diverging bars with a live net line for Streamlit"
9
+ readme = "README.md"
10
+ authors = [{name = "John Della Rosa"}]
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["streamlit>=1.51,<2"]
15
+ keywords = ["streamlit", "component", "chart", "bar-plot", "cumulative-net"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Operating System :: OS Independent",
21
+ "Topic :: Scientific/Engineering :: Visualization",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/johndellarosa/interactive-streamlit-bar-plot"
26
+ Issues = "https://github.com/johndellarosa/interactive-streamlit-bar-plot/issues"
27
+
28
+ [project.optional-dependencies]
29
+ release = ["build>=1.2", "twine>=6"]
30
+
31
+ [tool.setuptools.packages.find]
32
+ include = ["streamlit_net_bars*"]
33
+
34
+ [tool.setuptools.package-data]
35
+ streamlit_net_bars = ["frontend/*.js", "frontend/*.css"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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))
@@ -0,0 +1,72 @@
1
+ """Backend integration checks; browser interactions are verified separately."""
2
+ import importlib.util
3
+ import unittest
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ from unittest.mock import patch
7
+
8
+ APP = str(Path(__file__).resolve().parents[1] / "app.py")
9
+
10
+
11
+ @unittest.skipUnless(importlib.util.find_spec("streamlit"), "Install project dependencies first")
12
+ class AppTests(unittest.TestCase):
13
+ def test_frontend_asset_changes_refresh_cached_renderer(self):
14
+ import streamlit_net_bars as chart
15
+ import streamlit as st
16
+ renderer = lambda **kwargs: SimpleNamespace(hidden=[], cumulative=False)
17
+ with patch.object(chart, "_renderer", None), patch.object(chart, "_renderer_assets", None), \
18
+ patch.object(st.components.v2, "component", return_value=renderer) as register, \
19
+ patch.object(Path, "read_text", side_effect=["css", "old js", "css", "new js", "css", "new js"]):
20
+ chart.net_bar_chart(["Jan"], {"A": [1]}, key="asset_test")
21
+ first_name = register.call_args.args[0]
22
+ chart.net_bar_chart(["Jan"], {"A": [1]}, key="asset_test")
23
+ self.assertNotEqual(first_name, register.call_args.args[0])
24
+ chart.net_bar_chart(["Jan"], {"A": [1]}, key="asset_test")
25
+ self.assertEqual(register.call_count, 2)
26
+
27
+ def test_cumulative_mode_updates_display_without_double_counting_total(self):
28
+ from streamlit.testing.v1 import AppTest
29
+ at = AppTest.from_file(APP).run(timeout=30)
30
+ total = at.metric[0].value
31
+ at.session_state["contributions"]["cumulative"] = True
32
+ at.run()
33
+ self.assertFalse(at.exception)
34
+ table = at.dataframe[0].value
35
+ self.assertEqual(table["Displayed line (cumulative)"].tolist(), table["Net"].cumsum().tolist())
36
+ self.assertEqual(at.metric[0].value, total)
37
+ at.session_state["contributions"]["hidden"] = ["Operating costs"]
38
+ at.run()
39
+ table = at.dataframe[0].value
40
+ self.assertEqual(table["Displayed line (cumulative)"].iloc[1], 227)
41
+ at.session_state["contributions"]["cumulative"] = False
42
+ at.run()
43
+ self.assertFalse(at.exception)
44
+ table = at.dataframe[0].value
45
+ self.assertEqual(table["Displayed line"].tolist(), table["Net"].tolist())
46
+
47
+ def test_demo_mounts_and_visibility_changes_net(self):
48
+ from streamlit.testing.v1 import AppTest
49
+ at = AppTest.from_file(APP).run(timeout=30)
50
+ self.assertFalse(at.exception)
51
+ self.assertEqual(at.metric[1].value, "4 / 4")
52
+ original_net = float(at.metric[0].value.replace(",", ""))
53
+ at.session_state["contributions"]["hidden"] = ["Operating costs"]
54
+ at.run()
55
+ self.assertFalse(at.exception)
56
+ self.assertEqual(at.metric[1].value, "3 / 4")
57
+ self.assertGreater(float(at.metric[0].value.replace(",", "")), original_net)
58
+
59
+ def test_all_hidden_and_restored(self):
60
+ from streamlit.testing.v1 import AppTest
61
+ at = AppTest.from_file(APP).run(timeout=30)
62
+ at.session_state["contributions"]["hidden"] = [
63
+ "Product sales", "Services", "Operating costs", "Marketing"
64
+ ]
65
+ at.run()
66
+ self.assertFalse(at.exception)
67
+ self.assertEqual(at.metric[0].value, "0")
68
+ self.assertEqual(at.metric[1].value, "0 / 4")
69
+ at.session_state["contributions"]["hidden"] = []
70
+ at.run()
71
+ self.assertFalse(at.exception)
72
+ self.assertEqual(at.metric[1].value, "4 / 4")
@@ -0,0 +1,41 @@
1
+ import unittest
2
+ from streamlit_net_bars.model import net_values, prepare_data
3
+ from streamlit_net_bars import NetBarResult
4
+
5
+
6
+ class NetTests(unittest.TestCase):
7
+ def setUp(self):
8
+ self.data = prepare_data(["Jan", "Feb"], {"Income": [100, 80], "Costs": [-60, -90]})
9
+
10
+ def test_signed_net(self):
11
+ self.assertEqual(net_values(self.data), [40, -10])
12
+
13
+ def test_hide_negative_or_positive(self):
14
+ self.assertEqual(net_values(self.data, ["Costs"]), [100, 80])
15
+ self.assertEqual(net_values(self.data, ["Income"]), [-60, -90])
16
+
17
+ def test_all_hidden_is_zero(self):
18
+ self.assertEqual(net_values(self.data, ["Income", "Costs"]), [0, 0])
19
+
20
+ def test_cumulative_line_uses_signed_visible_net(self):
21
+ result = NetBarResult([], ["Income", "Costs"], net_values(self.data), True)
22
+ self.assertEqual(result.line_values, [40, 30])
23
+ self.assertEqual(result.net, [40, -10])
24
+ result = NetBarResult(["Costs"], ["Income"], net_values(self.data, ["Costs"]), True)
25
+ self.assertEqual(result.line_values, [100, 180])
26
+ result = NetBarResult(["Income"], ["Costs"], net_values(self.data, ["Income"]), True)
27
+ self.assertEqual(result.line_values, [-60, -150])
28
+ result = NetBarResult(["Income", "Costs"], [], [0, 0], True)
29
+ self.assertEqual(result.line_values, [0, 0])
30
+
31
+ def test_invalid_inputs(self):
32
+ for x, series in [([], {"a": []}), (["a"], {}), (["a"], {"a": [1, 2]}),
33
+ (["a"], {"": [1]}), (["a"], {"a": [None]}),
34
+ (["a"], {"a": [float("nan")]}), (["a"], {"a": [float("inf")]}),
35
+ (["a"], {"a": [True]})]:
36
+ with self.subTest(x=x, series=series), self.assertRaises(ValueError):
37
+ prepare_data(x, series)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()