interactive-streamlit-bar-plot 0.1.0__tar.gz → 0.2.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.
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/MANIFEST.in +1 -0
- {interactive_streamlit_bar_plot-0.1.0/interactive_streamlit_bar_plot.egg-info → interactive_streamlit_bar_plot-0.2.0}/PKG-INFO +29 -2
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/README.md +28 -1
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0/interactive_streamlit_bar_plot.egg-info}/PKG-INFO +29 -2
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/interactive_streamlit_bar_plot.egg-info/SOURCES.txt +1 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/pyproject.toml +1 -1
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/__init__.py +7 -1
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/frontend/chart.css +11 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/frontend/chart.js +169 -21
- interactive_streamlit_bar_plot-0.2.0/tests/test_axis.mjs +40 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/LICENSE +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/PUBLISHING.md +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/app.py +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/interactive_streamlit_bar_plot.egg-info/dependency_links.txt +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/interactive_streamlit_bar_plot.egg-info/requires.txt +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/interactive_streamlit_bar_plot.egg-info/top_level.txt +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/setup.cfg +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/model.py +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/tests/test_app.py +0 -0
- {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/tests/test_model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: interactive-streamlit-bar-plot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Clickable diverging bars with a live net line for Streamlit
|
|
5
5
|
Author: John Della Rosa
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,7 +32,33 @@ per chart across Streamlit reruns for the current session.
|
|
|
32
32
|
|
|
33
33
|
Positive values stack above the centered zero line; negative values stack below.
|
|
34
34
|
The line is the signed sum of the **visible** series at each position. All hidden
|
|
35
|
-
means a zero line.
|
|
35
|
+
means a zero line. **Fixed axis** is enabled by default: the symmetric y scale
|
|
36
|
+
stays unchanged when hiding, restoring, or undoing series visibility. Turn it
|
|
37
|
+
off to fit the visible contributions and both net lines. Axis mode survives
|
|
38
|
+
Streamlit reruns independently of visibility history. Changing the input data
|
|
39
|
+
or switching cumulative mode recalculates the scale. In cumulative mode, the
|
|
40
|
+
fixed scale reserves room for any subset of the original series so hiding
|
|
41
|
+
negative contributions cannot clip the filtered line.
|
|
42
|
+
|
|
43
|
+
Use **Interaction** to choose **Zoom X axis**, **Zoom Y axis**, or **Zoom both
|
|
44
|
+
axes**, then drag a region of the plot to zoom into it. **Reset zoom** restores the full view; a
|
|
45
|
+
double-click in a drag-zoom mode also resets it. Choose **Click bars** to resume
|
|
46
|
+
hiding series directly, or use the legend while in zoom mode.
|
|
47
|
+
|
|
48
|
+
Zoom survives visibility changes, Undo, and Streamlit reruns. The fixed-axis
|
|
49
|
+
setting controls the unzoomed scale; an explicit y zoom takes precedence.
|
|
50
|
+
Changing cumulative or fixed-axis mode resets y zoom but preserves x zoom.
|
|
51
|
+
Changing the input data resets the zoom. Cumulative totals always include the
|
|
52
|
+
preceding periods, even when x zoom hides them. Copied PNGs use the current
|
|
53
|
+
zoomed view, without the drag-selection overlay.
|
|
54
|
+
|
|
55
|
+
A dashed **Original net** line shows the signed sum of all input series, while
|
|
56
|
+
the solid line shows the net of the currently visible series. The reference
|
|
57
|
+
stays unchanged when hiding or restoring series and remains visible even when
|
|
58
|
+
all bars are hidden. With every series visible, the two lines overlap. Both
|
|
59
|
+
use running totals in cumulative mode, and the axis includes both lines.
|
|
60
|
+
The reference is recalculated when the input data changes. Copy image and
|
|
61
|
+
Download PNG include both lines and their legend keys.
|
|
36
62
|
|
|
37
63
|
Toggle **Cumulative net** to show the running signed sum of the visible net
|
|
38
64
|
values, starting at the first x position. For net values `[40, 50, -20]`, the
|
|
@@ -101,6 +127,7 @@ horizontally; long x labels are abbreviated in the plot and shown fully in toolt
|
|
|
101
127
|
|
|
102
128
|
```powershell
|
|
103
129
|
python -m unittest discover -s tests -v
|
|
130
|
+
node --test tests/test_axis.mjs
|
|
104
131
|
Get-Content streamlit_net_bars/frontend/chart.js -Raw | node --input-type=module --check
|
|
105
132
|
```
|
|
106
133
|
|
|
@@ -9,7 +9,33 @@ per chart across Streamlit reruns for the current session.
|
|
|
9
9
|
|
|
10
10
|
Positive values stack above the centered zero line; negative values stack below.
|
|
11
11
|
The line is the signed sum of the **visible** series at each position. All hidden
|
|
12
|
-
means a zero line.
|
|
12
|
+
means a zero line. **Fixed axis** is enabled by default: the symmetric y scale
|
|
13
|
+
stays unchanged when hiding, restoring, or undoing series visibility. Turn it
|
|
14
|
+
off to fit the visible contributions and both net lines. Axis mode survives
|
|
15
|
+
Streamlit reruns independently of visibility history. Changing the input data
|
|
16
|
+
or switching cumulative mode recalculates the scale. In cumulative mode, the
|
|
17
|
+
fixed scale reserves room for any subset of the original series so hiding
|
|
18
|
+
negative contributions cannot clip the filtered line.
|
|
19
|
+
|
|
20
|
+
Use **Interaction** to choose **Zoom X axis**, **Zoom Y axis**, or **Zoom both
|
|
21
|
+
axes**, then drag a region of the plot to zoom into it. **Reset zoom** restores the full view; a
|
|
22
|
+
double-click in a drag-zoom mode also resets it. Choose **Click bars** to resume
|
|
23
|
+
hiding series directly, or use the legend while in zoom mode.
|
|
24
|
+
|
|
25
|
+
Zoom survives visibility changes, Undo, and Streamlit reruns. The fixed-axis
|
|
26
|
+
setting controls the unzoomed scale; an explicit y zoom takes precedence.
|
|
27
|
+
Changing cumulative or fixed-axis mode resets y zoom but preserves x zoom.
|
|
28
|
+
Changing the input data resets the zoom. Cumulative totals always include the
|
|
29
|
+
preceding periods, even when x zoom hides them. Copied PNGs use the current
|
|
30
|
+
zoomed view, without the drag-selection overlay.
|
|
31
|
+
|
|
32
|
+
A dashed **Original net** line shows the signed sum of all input series, while
|
|
33
|
+
the solid line shows the net of the currently visible series. The reference
|
|
34
|
+
stays unchanged when hiding or restoring series and remains visible even when
|
|
35
|
+
all bars are hidden. With every series visible, the two lines overlap. Both
|
|
36
|
+
use running totals in cumulative mode, and the axis includes both lines.
|
|
37
|
+
The reference is recalculated when the input data changes. Copy image and
|
|
38
|
+
Download PNG include both lines and their legend keys.
|
|
13
39
|
|
|
14
40
|
Toggle **Cumulative net** to show the running signed sum of the visible net
|
|
15
41
|
values, starting at the first x position. For net values `[40, 50, -20]`, the
|
|
@@ -78,6 +104,7 @@ horizontally; long x labels are abbreviated in the plot and shown fully in toolt
|
|
|
78
104
|
|
|
79
105
|
```powershell
|
|
80
106
|
python -m unittest discover -s tests -v
|
|
107
|
+
node --test tests/test_axis.mjs
|
|
81
108
|
Get-Content streamlit_net_bars/frontend/chart.js -Raw | node --input-type=module --check
|
|
82
109
|
```
|
|
83
110
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: interactive-streamlit-bar-plot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Clickable diverging bars with a live net line for Streamlit
|
|
5
5
|
Author: John Della Rosa
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,7 +32,33 @@ per chart across Streamlit reruns for the current session.
|
|
|
32
32
|
|
|
33
33
|
Positive values stack above the centered zero line; negative values stack below.
|
|
34
34
|
The line is the signed sum of the **visible** series at each position. All hidden
|
|
35
|
-
means a zero line.
|
|
35
|
+
means a zero line. **Fixed axis** is enabled by default: the symmetric y scale
|
|
36
|
+
stays unchanged when hiding, restoring, or undoing series visibility. Turn it
|
|
37
|
+
off to fit the visible contributions and both net lines. Axis mode survives
|
|
38
|
+
Streamlit reruns independently of visibility history. Changing the input data
|
|
39
|
+
or switching cumulative mode recalculates the scale. In cumulative mode, the
|
|
40
|
+
fixed scale reserves room for any subset of the original series so hiding
|
|
41
|
+
negative contributions cannot clip the filtered line.
|
|
42
|
+
|
|
43
|
+
Use **Interaction** to choose **Zoom X axis**, **Zoom Y axis**, or **Zoom both
|
|
44
|
+
axes**, then drag a region of the plot to zoom into it. **Reset zoom** restores the full view; a
|
|
45
|
+
double-click in a drag-zoom mode also resets it. Choose **Click bars** to resume
|
|
46
|
+
hiding series directly, or use the legend while in zoom mode.
|
|
47
|
+
|
|
48
|
+
Zoom survives visibility changes, Undo, and Streamlit reruns. The fixed-axis
|
|
49
|
+
setting controls the unzoomed scale; an explicit y zoom takes precedence.
|
|
50
|
+
Changing cumulative or fixed-axis mode resets y zoom but preserves x zoom.
|
|
51
|
+
Changing the input data resets the zoom. Cumulative totals always include the
|
|
52
|
+
preceding periods, even when x zoom hides them. Copied PNGs use the current
|
|
53
|
+
zoomed view, without the drag-selection overlay.
|
|
54
|
+
|
|
55
|
+
A dashed **Original net** line shows the signed sum of all input series, while
|
|
56
|
+
the solid line shows the net of the currently visible series. The reference
|
|
57
|
+
stays unchanged when hiding or restoring series and remains visible even when
|
|
58
|
+
all bars are hidden. With every series visible, the two lines overlap. Both
|
|
59
|
+
use running totals in cumulative mode, and the axis includes both lines.
|
|
60
|
+
The reference is recalculated when the input data changes. Copy image and
|
|
61
|
+
Download PNG include both lines and their legend keys.
|
|
36
62
|
|
|
37
63
|
Toggle **Cumulative net** to show the running signed sum of the visible net
|
|
38
64
|
values, starting at the first x position. For net values `[40, 50, -20]`, the
|
|
@@ -101,6 +127,7 @@ horizontally; long x labels are abbreviated in the plot and shown fully in toolt
|
|
|
101
127
|
|
|
102
128
|
```powershell
|
|
103
129
|
python -m unittest discover -s tests -v
|
|
130
|
+
node --test tests/test_axis.mjs
|
|
104
131
|
Get-Content streamlit_net_bars/frontend/chart.js -Raw | node --input-type=module --check
|
|
105
132
|
```
|
|
106
133
|
|
{interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interactive-streamlit-bar-plot"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Clickable diverging bars with a live net line for Streamlit"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{name = "John Della Rosa"}]
|
|
@@ -57,6 +57,8 @@ def net_bar_chart(
|
|
|
57
57
|
state = st.session_state.get(key, {})
|
|
58
58
|
hidden = [name for name in state.get("hidden", []) if name in names]
|
|
59
59
|
cumulative = bool(state.get("cumulative", False))
|
|
60
|
+
fixed_axis = bool(state.get("fixed_axis", True))
|
|
61
|
+
viewport = state.get("viewport", {})
|
|
60
62
|
# Historical snapshots refer to series identities, not their numeric data.
|
|
61
63
|
history = [[name for name in snapshot if name in names]
|
|
62
64
|
for snapshot in state.get("history", [])]
|
|
@@ -77,11 +79,15 @@ def net_bar_chart(
|
|
|
77
79
|
result = _renderer(
|
|
78
80
|
key=key,
|
|
79
81
|
data={**data, "hidden": hidden, "history": history, "cumulative": cumulative,
|
|
82
|
+
"fixed_axis": fixed_axis, "viewport": viewport,
|
|
80
83
|
"title": str(title), "height": height},
|
|
81
|
-
default={"hidden": hidden, "history": history, "cumulative": cumulative
|
|
84
|
+
default={"hidden": hidden, "history": history, "cumulative": cumulative,
|
|
85
|
+
"fixed_axis": fixed_axis, "viewport": viewport},
|
|
82
86
|
on_history_change=lambda: None,
|
|
83
87
|
on_hidden_change=on_change or (lambda: None),
|
|
84
88
|
on_cumulative_change=on_change or (lambda: None),
|
|
89
|
+
on_fixed_axis_change=on_change or (lambda: None),
|
|
90
|
+
on_viewport_change=lambda: None,
|
|
85
91
|
)
|
|
86
92
|
hidden = [name for name in (result.hidden or []) if name in names]
|
|
87
93
|
return NetBarResult(hidden, [n for n in names if n not in hidden],
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
6
6
|
.mode-toggle[aria-pressed="true"] { background: color-mix(in srgb, #3b82b5 18%, transparent); border-color: #3b82b5; }
|
|
7
7
|
.mode-toggle[aria-pressed="false"] { opacity: 1; text-decoration: none; }
|
|
8
|
+
.axis-toggle[aria-pressed="true"] { background: color-mix(in srgb, #3b82b5 18%, transparent); border-color: #3b82b5; }
|
|
9
|
+
.axis-toggle[aria-pressed="false"] { opacity: 1; text-decoration: none; }
|
|
8
10
|
h3 { margin: 0; font-size: 19px; font-weight: 650; }
|
|
9
11
|
.hint { opacity: .65; font-size: 13px; margin: 8px 0 20px; line-height: 1.5; }
|
|
10
12
|
.legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
@@ -16,6 +18,8 @@ button:disabled { cursor: default; opacity: .4; }
|
|
|
16
18
|
.swatch { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 7px; }
|
|
17
19
|
.net-key { font-size: 13px; margin-left: 8px; }
|
|
18
20
|
.net-key::before { content: ''; display: inline-block; width: 20px; border-top: 3px solid currentColor; vertical-align: middle; margin-right: 7px; }
|
|
21
|
+
.original-key { color: #7b8494; }
|
|
22
|
+
.original-key::before { border-top-style: dashed; }
|
|
19
23
|
.plot-scroll { overflow-x: auto; }
|
|
20
24
|
svg { width: 100%; display: block; overflow: visible; }
|
|
21
25
|
svg text { fill: currentColor; font-size: 12px; }
|
|
@@ -25,8 +29,15 @@ svg text { fill: currentColor; font-size: 12px; }
|
|
|
25
29
|
.bar:hover { opacity: .7; }
|
|
26
30
|
.net-line { stroke: currentColor; stroke-width: 3; fill: none; pointer-events: none; stroke-linejoin: round; stroke-linecap: round; }
|
|
27
31
|
.net-dot { fill: var(--st-background-color, white); stroke: currentColor; stroke-width: 2.5; }
|
|
32
|
+
.original-line { stroke: #7b8494; stroke-width: 2.5; stroke-dasharray: 6 5; fill: none; pointer-events: none; }
|
|
33
|
+
.original-dot { fill: var(--st-background-color, white); stroke: #7b8494; stroke-width: 1.5; }
|
|
28
34
|
.status { margin: 12px 0 0; font-size: 12px; opacity: .65; }
|
|
29
35
|
.copy-feedback { margin: 8px 0 0; font-size: 13px; }
|
|
30
36
|
.copy-feedback:empty { display: none; }
|
|
31
37
|
.copy-feedback a { color: inherit; text-decoration: underline; }
|
|
38
|
+
.zoom-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 14px 0; font-size: 13px; }
|
|
39
|
+
.zoom-controls select { font: inherit; color: inherit; background: var(--st-background-color, white); padding: 7px; border: 1px solid color-mix(in srgb, currentColor 22%, transparent); border-radius: 8px; }
|
|
40
|
+
.zoom-hint { opacity: .65; font-size: 12px; }
|
|
41
|
+
.zoom-surface { cursor: crosshair; touch-action: none; }
|
|
42
|
+
.zoom-selection { fill: #3b82b5; fill-opacity: .16; stroke: #3b82b5; stroke-width: 1; pointer-events: none; }
|
|
32
43
|
@media (max-width: 600px) { .card { padding: 14px; } .heading { align-items: flex-start; flex-direction: column; } }
|
|
@@ -1,9 +1,45 @@
|
|
|
1
|
+
// A fixed domain includes every possible visible-series net, so hiding a
|
|
2
|
+
// negative contribution cannot push a cumulative line outside the plot.
|
|
3
|
+
export function axisExtent(series, hidden, cumulative, fixedAxis) {
|
|
4
|
+
const visible = series.filter(s => !hidden.has(s.name));
|
|
5
|
+
const scaleSeries = fixedAxis ? series : visible;
|
|
6
|
+
const prefixes = series.map(() => 0);
|
|
7
|
+
let originalRunning = 0, filteredRunning = 0, extent = 1;
|
|
8
|
+
for (let i = 0; i < series[0].values.length; i++) {
|
|
9
|
+
const positive = scaleSeries.reduce((sum, s) => sum + Math.max(0, s.values[i]), 0);
|
|
10
|
+
const negative = scaleSeries.reduce((sum, s) => sum + Math.min(0, s.values[i]), 0);
|
|
11
|
+
const original = series.reduce((sum, s) => sum + s.values[i], 0);
|
|
12
|
+
const filtered = visible.reduce((sum, s) => sum + s.values[i], 0);
|
|
13
|
+
originalRunning += original;
|
|
14
|
+
filteredRunning += filtered;
|
|
15
|
+
extent = Math.max(extent, positive, -negative,
|
|
16
|
+
Math.abs(cumulative ? originalRunning : original),
|
|
17
|
+
Math.abs(cumulative ? filteredRunning : filtered));
|
|
18
|
+
if (fixedAxis && cumulative) {
|
|
19
|
+
series.forEach((s, j) => { prefixes[j] += s.values[i]; });
|
|
20
|
+
extent = Math.max(extent,
|
|
21
|
+
prefixes.reduce((sum, value) => sum + Math.max(0, value), 0),
|
|
22
|
+
-prefixes.reduce((sum, value) => sum + Math.min(0, value), 0));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return extent * 1.12;
|
|
26
|
+
}
|
|
27
|
+
|
|
1
28
|
export default function(component) {
|
|
2
29
|
const {parentElement, data, setStateValue} = component;
|
|
3
30
|
const root = parentElement.querySelector('.net-bars');
|
|
4
31
|
const colors = ['#3b82b5', '#55b6a4', '#9c86cf', '#ed9b55', '#dc7081', '#a39567', '#6a97d6', '#b76bad'];
|
|
5
32
|
let hidden = new Set(data.hidden);
|
|
6
33
|
let cumulative = Boolean(data.cumulative);
|
|
34
|
+
let fixedAxis = data.fixed_axis !== false;
|
|
35
|
+
const signature = JSON.stringify([data.x, data.series]);
|
|
36
|
+
let viewport = data.viewport?.signature === signature ? {...data.viewport} : {x: null, y: null, mode: 'inspect'};
|
|
37
|
+
const saveView = () => {
|
|
38
|
+
viewport.signature = signature;
|
|
39
|
+
draw();
|
|
40
|
+
setStateValue('viewport', {...viewport});
|
|
41
|
+
};
|
|
42
|
+
const clipId = `net-bars-clip-${crypto.randomUUID()}`;
|
|
7
43
|
const history = (data.history || []).map(snapshot => [...snapshot]);
|
|
8
44
|
function publish() {
|
|
9
45
|
draw();
|
|
@@ -79,10 +115,22 @@ export default function(component) {
|
|
|
79
115
|
class: 'mode-toggle', title: 'Show a running total in x-axis order'}, 'Cumulative net');
|
|
80
116
|
mode.onclick = () => {
|
|
81
117
|
cumulative = !cumulative;
|
|
118
|
+
viewport.y = null;
|
|
82
119
|
draw();
|
|
120
|
+
setStateValue('viewport', {...viewport, signature});
|
|
83
121
|
setStateValue('cumulative', cumulative);
|
|
84
122
|
root.querySelector('.mode-toggle')?.focus();
|
|
85
123
|
};
|
|
124
|
+
const axis = make('button', {type: 'button', 'aria-pressed': String(fixedAxis),
|
|
125
|
+
class: 'axis-toggle', title: 'Keep the original scale when hiding series'}, 'Fixed axis');
|
|
126
|
+
axis.onclick = () => {
|
|
127
|
+
fixedAxis = !fixedAxis;
|
|
128
|
+
viewport.y = null;
|
|
129
|
+
draw();
|
|
130
|
+
setStateValue('viewport', {...viewport, signature});
|
|
131
|
+
setStateValue('fixed_axis', fixedAxis);
|
|
132
|
+
root.querySelector('.axis-toggle')?.focus();
|
|
133
|
+
};
|
|
86
134
|
const undo = make('button', {type: 'button', title: 'Undo the last visibility change'}, 'Undo');
|
|
87
135
|
undo.disabled = history.length === 0;
|
|
88
136
|
undo.onclick = () => {
|
|
@@ -98,10 +146,11 @@ export default function(component) {
|
|
|
98
146
|
hidden.clear();
|
|
99
147
|
publish();
|
|
100
148
|
};
|
|
101
|
-
actions.append(copy, mode, undo, reset);
|
|
149
|
+
actions.append(copy, mode, axis, undo, reset);
|
|
102
150
|
heading.append(actions);
|
|
103
151
|
card.append(heading, make('p', {class: 'hint'}, 'Click a bar to hide its series. Use the legend to bring it back. ' +
|
|
104
|
-
(cumulative ? '
|
|
152
|
+
(cumulative ? 'Lines show running totals from the first position. ' : '') +
|
|
153
|
+
'Solid = visible series; dashed = original net of all series.'));
|
|
105
154
|
const legend = make('div', {class: 'legend', role: 'group', 'aria-label': 'Series visibility'});
|
|
106
155
|
data.series.forEach((s, j) => {
|
|
107
156
|
const button = make('button', {type: 'button', 'aria-pressed': String(!hidden.has(s.name)), 'aria-label': `${hidden.has(s.name) ? 'Show' : 'Hide'} ${s.name}`});
|
|
@@ -112,28 +161,59 @@ export default function(component) {
|
|
|
112
161
|
legend.append(button);
|
|
113
162
|
});
|
|
114
163
|
const lineLabel = cumulative ? 'Cumulative net' : 'Net';
|
|
164
|
+
const originalLabel = cumulative ? 'Original cumulative net' : 'Original net';
|
|
115
165
|
legend.append(make('span', {class: 'net-key'}, lineLabel));
|
|
166
|
+
legend.append(make('span', {class: 'net-key original-key'}, originalLabel));
|
|
116
167
|
card.append(legend);
|
|
117
|
-
const
|
|
168
|
+
const zoomControls = make('div', {class: 'zoom-controls'});
|
|
169
|
+
const interactionLabel = make('label', {}, 'Interaction ');
|
|
170
|
+
const interaction = make('select', {'aria-label': 'Chart interaction'});
|
|
171
|
+
for (const [value, label] of [['inspect','Click bars'],['both','Zoom both axes'],['x','Zoom X axis'],['y','Zoom Y axis']]) {
|
|
172
|
+
interaction.append(make('option', {value}, label));
|
|
173
|
+
}
|
|
174
|
+
interaction.value = viewport.mode || 'inspect';
|
|
175
|
+
interaction.onchange = () => {viewport.mode = interaction.value; saveView();};
|
|
176
|
+
interactionLabel.append(interaction);
|
|
177
|
+
zoomControls.append(interactionLabel);
|
|
178
|
+
const fullX = [-.5, data.x.length-.5];
|
|
179
|
+
const xDomain = viewport.x || fullX;
|
|
180
|
+
const extent = axisExtent(data.series, hidden, cumulative, fixedAxis);
|
|
181
|
+
const yDomain = viewport.y || [-extent, extent];
|
|
182
|
+
const resetZoom = make('button', {type:'button'}, 'Reset zoom');
|
|
183
|
+
resetZoom.disabled = !viewport.x && !viewport.y;
|
|
184
|
+
resetZoom.onclick = () => {viewport.x = null; viewport.y = null; saveView();};
|
|
185
|
+
zoomControls.append(resetZoom);
|
|
186
|
+
zoomControls.append(make('span', {class:'zoom-hint'}, viewport.mode && viewport.mode !== 'inspect' ?
|
|
187
|
+
'Drag a region to zoom. Double-click the plot to reset.' : 'Choose a zoom interaction to drag a region.'));
|
|
188
|
+
card.append(zoomControls);
|
|
189
|
+
const width = Math.max(600, Math.ceil(xDomain[1]-xDomain[0]) * 65);
|
|
118
190
|
const height = data.height;
|
|
119
191
|
const left = 70, right = 28, top = 28, bottom = 54;
|
|
120
192
|
const plotW = width - left - right, plotH = height - top - bottom;
|
|
121
193
|
const visible = data.series.filter(s => !hidden.has(s.name));
|
|
122
194
|
const net = data.x.map((_,i) => visible.reduce((sum,s) => sum + s.values[i], 0));
|
|
195
|
+
// The reference always uses every series in the current input, independent
|
|
196
|
+
// of visibility. Convert both lines using the same selected mode.
|
|
197
|
+
const originalNet = data.x.map((_,i) => data.series.reduce((sum,s) => sum + s.values[i], 0));
|
|
123
198
|
let running = 0;
|
|
124
199
|
const line = cumulative ? net.map(value => (running += value)) : net;
|
|
125
|
-
|
|
126
|
-
const
|
|
200
|
+
let originalRunning = 0;
|
|
201
|
+
const originalLine = cumulative ? originalNet.map(value => (originalRunning += value)) : originalNet;
|
|
127
202
|
// Keep a symmetric scale so the zero line remains in the center.
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const x = i => left + step * (i + .5);
|
|
203
|
+
const y = v => top + plotH * (yDomain[1] - v) / (yDomain[1]-yDomain[0]);
|
|
204
|
+
const step = plotW / (xDomain[1]-xDomain[0]);
|
|
205
|
+
const x = i => left + step * (i-xDomain[0]);
|
|
132
206
|
const svg = shape('svg', {viewBox: `0 0 ${width} ${height}`, role: 'group', 'aria-label': data.title});
|
|
133
207
|
svg.style.minWidth = `${width}px`;
|
|
134
208
|
svg.append(shape('title', {}, 'Interactive contributions and net. Bar segments are buttons; press Enter or Space to hide a series.'));
|
|
135
|
-
|
|
136
|
-
|
|
209
|
+
const defs = shape('defs');
|
|
210
|
+
const clip = shape('clipPath', {id:clipId});
|
|
211
|
+
clip.append(shape('rect', {x:left, y:top, width:plotW, height:plotH}));
|
|
212
|
+
defs.append(clip);
|
|
213
|
+
svg.append(defs);
|
|
214
|
+
const marks = shape('g', {'clip-path':`url(#${clipId})`});
|
|
215
|
+
for (let tick = 0; tick <= 4; tick++) {
|
|
216
|
+
const value = yDomain[0]+(yDomain[1]-yDomain[0])*tick/4;
|
|
137
217
|
svg.append(shape('line', {x1: left, x2: width-right, y1: y(value), y2: y(value), class: tick === 0 ? 'zero' : 'grid'}));
|
|
138
218
|
svg.append(shape('text', {x: left-10, y: y(value)+4, 'text-anchor': 'end'}, fmt(value)));
|
|
139
219
|
}
|
|
@@ -150,20 +230,80 @@ export default function(component) {
|
|
|
150
230
|
rect.append(shape('title', {}, label));
|
|
151
231
|
rect.onclick = () => { toggle(s.name); focusLegend(s.name); };
|
|
152
232
|
rect.onkeydown = e => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); toggle(s.name); focusLegend(s.name);}};
|
|
153
|
-
|
|
233
|
+
marks.append(rect);
|
|
154
234
|
});
|
|
155
235
|
});
|
|
156
|
-
|
|
236
|
+
marks.append(shape('polyline', {points: originalLine.map((v,i) => `${x(i)},${y(v)}`).join(' '), class: 'original-line'}));
|
|
237
|
+
originalLine.forEach((v,i) => {
|
|
238
|
+
const dot = shape('circle', {cx: x(i), cy: y(v), r: 3, class: 'original-dot', tabindex: 0,
|
|
239
|
+
'aria-label': `${data.x[i]} ${originalLabel.toLowerCase()}: ${fmt(v)}`});
|
|
240
|
+
dot.append(shape('title', {}, `${data.x[i]} · ${originalLabel}: ${fmt(v)}`));
|
|
241
|
+
marks.append(dot);
|
|
242
|
+
});
|
|
243
|
+
marks.append(shape('polyline', {points: line.map((v,i) => `${x(i)},${y(v)}`).join(' '), class: 'net-line'}));
|
|
157
244
|
line.forEach((v,i) => {
|
|
158
245
|
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
246
|
dot.append(shape('title', {}, `${data.x[i]} · ${lineLabel}: ${fmt(v)}`));
|
|
160
|
-
|
|
161
|
-
|
|
247
|
+
marks.append(dot);
|
|
248
|
+
if (x(i) >= left && x(i) <= width-right) {
|
|
249
|
+
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]));
|
|
250
|
+
}
|
|
162
251
|
});
|
|
252
|
+
svg.append(marks);
|
|
253
|
+
if (viewport.mode && viewport.mode !== 'inspect') {
|
|
254
|
+
const surface = shape('rect', {x:left, y:top, width:plotW, height:plotH, class:'zoom-surface',
|
|
255
|
+
fill:'transparent', 'aria-label':'Drag to zoom the plot'});
|
|
256
|
+
const selection = shape('rect', {class:'zoom-selection', visibility:'hidden'});
|
|
257
|
+
let start = null;
|
|
258
|
+
const position = event => {
|
|
259
|
+
const point = svg.createSVGPoint();
|
|
260
|
+
point.x = event.clientX; point.y = event.clientY;
|
|
261
|
+
const local = point.matrixTransform(svg.getScreenCTM().inverse());
|
|
262
|
+
return [Math.max(left, Math.min(width-right, local.x)), Math.max(top, Math.min(top+plotH,local.y))];
|
|
263
|
+
};
|
|
264
|
+
surface.onpointerdown = event => {
|
|
265
|
+
if (event.button !== 0) return;
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
start = position(event);
|
|
268
|
+
surface.setPointerCapture(event.pointerId);
|
|
269
|
+
};
|
|
270
|
+
surface.onpointermove = event => {
|
|
271
|
+
if (!start) return;
|
|
272
|
+
const end = position(event);
|
|
273
|
+
selection.setAttribute('visibility','visible');
|
|
274
|
+
const sx = viewport.mode === 'y' ? left : Math.min(start[0],end[0]);
|
|
275
|
+
const sy = viewport.mode === 'x' ? top : Math.min(start[1],end[1]);
|
|
276
|
+
selection.setAttribute('x',sx); selection.setAttribute('y',sy);
|
|
277
|
+
selection.setAttribute('width',viewport.mode === 'y' ? plotW : Math.abs(end[0]-start[0]));
|
|
278
|
+
selection.setAttribute('height',viewport.mode === 'x' ? plotH : Math.abs(end[1]-start[1]));
|
|
279
|
+
};
|
|
280
|
+
surface.onpointercancel = () => {start=null; selection.setAttribute('visibility','hidden');};
|
|
281
|
+
surface.onpointerup = event => {
|
|
282
|
+
if (!start) return;
|
|
283
|
+
const end = position(event), origin = start;
|
|
284
|
+
start = null;
|
|
285
|
+
selection.setAttribute('visibility','hidden');
|
|
286
|
+
const zoomX = viewport.mode !== 'y', zoomY = viewport.mode !== 'x';
|
|
287
|
+
if ((zoomX && Math.abs(end[0]-origin[0]) < 8) || (zoomY && Math.abs(end[1]-origin[1]) < 8)) return;
|
|
288
|
+
if (zoomX) {
|
|
289
|
+
const a = xDomain[0]+(Math.min(origin[0],end[0])-left)/plotW*(xDomain[1]-xDomain[0]);
|
|
290
|
+
const b = xDomain[0]+(Math.max(origin[0],end[0])-left)/plotW*(xDomain[1]-xDomain[0]);
|
|
291
|
+
const span = Math.min(data.x.length,Math.max(1,b-a));
|
|
292
|
+
const low = Math.max(fullX[0],Math.min(fullX[1]-span,(a+b-span)/2));
|
|
293
|
+
viewport.x = [low,low+span];
|
|
294
|
+
}
|
|
295
|
+
if (zoomY) viewport.y = [
|
|
296
|
+
yDomain[1]-(Math.max(origin[1],end[1])-top)/plotH*(yDomain[1]-yDomain[0]),
|
|
297
|
+
yDomain[1]-(Math.min(origin[1],end[1])-top)/plotH*(yDomain[1]-yDomain[0])];
|
|
298
|
+
saveView();
|
|
299
|
+
};
|
|
300
|
+
surface.ondblclick = () => {viewport.x=null; viewport.y=null; saveView();};
|
|
301
|
+
svg.append(surface,selection);
|
|
302
|
+
}
|
|
163
303
|
const scroll = make('div', {class: 'plot-scroll'});
|
|
164
304
|
scroll.append(svg);
|
|
165
305
|
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 ·
|
|
306
|
+
(cumulative ? 'Cumulative net = running sum of visible net values in x-axis order' : 'Net = sum of visible values at each position') : 'All series hidden · Visible net is zero. The dashed line still shows the original net.'));
|
|
167
307
|
card.append(copyFeedback);
|
|
168
308
|
root.append(card);
|
|
169
309
|
}
|
|
@@ -173,7 +313,7 @@ export default function(component) {
|
|
|
173
313
|
// including theme colors, so the PNG matches the chart and works offline.
|
|
174
314
|
const sourceNodes = [svg, ...svg.querySelectorAll('*')];
|
|
175
315
|
const cloneNodes = [plot, ...plot.querySelectorAll('*')];
|
|
176
|
-
const properties = ['fill', 'stroke', 'stroke-width', 'stroke-linejoin',
|
|
316
|
+
const properties = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linejoin',
|
|
177
317
|
'stroke-linecap', 'opacity', 'font-family', 'font-size', 'font-weight', 'color'];
|
|
178
318
|
sourceNodes.forEach((node, i) => {
|
|
179
319
|
const computed = getComputedStyle(node);
|
|
@@ -183,6 +323,7 @@ export default function(component) {
|
|
|
183
323
|
}
|
|
184
324
|
cloneNodes[i].removeAttribute('tabindex');
|
|
185
325
|
});
|
|
326
|
+
plot.querySelectorAll('.zoom-surface, .zoom-selection').forEach(node => node.remove());
|
|
186
327
|
const width = svg.viewBox.baseVal.width;
|
|
187
328
|
const height = svg.viewBox.baseVal.height;
|
|
188
329
|
const exportWidth = width + 40;
|
|
@@ -199,15 +340,22 @@ export default function(component) {
|
|
|
199
340
|
measurement.font = '13px Arial';
|
|
200
341
|
const entries = data.series.map((s,i) => ({label: s.name, color: colors[i % colors.length], hidden: hidden.has(s.name)}))
|
|
201
342
|
.filter(s => !s.hidden);
|
|
202
|
-
entries.push({label: lineLabel, color: textColor});
|
|
343
|
+
entries.push({label: lineLabel, color: textColor, line: true});
|
|
344
|
+
entries.push({label: cumulative ? 'Original cumulative net' : 'Original net',
|
|
345
|
+
color: getComputedStyle(svg.querySelector('.original-line')).stroke, line: true, dashed: true});
|
|
203
346
|
entries.forEach(entry => {
|
|
204
|
-
const entryWidth = measurement.measureText(entry.label).width +
|
|
347
|
+
const entryWidth = measurement.measureText(entry.label).width + 52;
|
|
205
348
|
if (legendX + entryWidth > exportWidth - 24 && legendX > 24) {
|
|
206
349
|
legendX = 24;
|
|
207
350
|
legendY += 24;
|
|
208
351
|
}
|
|
209
|
-
|
|
210
|
-
|
|
352
|
+
if (entry.line) {
|
|
353
|
+
imageSvg.append(shape('line', {x1: legendX, x2: legendX+24, y1: legendY-4, y2: legendY-4,
|
|
354
|
+
stroke: entry.color, 'stroke-width': 2.5, 'stroke-dasharray': entry.dashed ? '6 4' : 'none'}));
|
|
355
|
+
} else {
|
|
356
|
+
imageSvg.append(shape('rect', {x: legendX, y: legendY-9, width: 10, height: 10, rx: 2, fill: entry.color}));
|
|
357
|
+
}
|
|
358
|
+
imageSvg.append(text(legendX+(entry.line ? 31 : 17), legendY, entry.label));
|
|
211
359
|
legendX += entryWidth;
|
|
212
360
|
});
|
|
213
361
|
const plotTop = legendY + 20;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
const source = await readFile(new URL('../streamlit_net_bars/frontend/chart.js', import.meta.url), 'utf8');
|
|
6
|
+
const {axisExtent} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
|
7
|
+
const series = [{name:'Income',values:[100,80]}, {name:'Costs',values:[-60,-90]}];
|
|
8
|
+
|
|
9
|
+
test('fixed axis remains identical for every visibility selection in both modes', () => {
|
|
10
|
+
for (const cumulative of [false,true]) {
|
|
11
|
+
const original = axisExtent(series, new Set(), cumulative, true);
|
|
12
|
+
for (const hidden of [['Income'],['Costs'],['Income','Costs']]) {
|
|
13
|
+
assert.equal(axisExtent(series, new Set(hidden), cumulative, true), original);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('automatic axis shrinks but keeps the original reference in view', () => {
|
|
19
|
+
const fixed = axisExtent(series, new Set(['Income']), false, true);
|
|
20
|
+
const automatic = axisExtent(series, new Set(['Income']), false, false);
|
|
21
|
+
assert.ok(automatic < fixed);
|
|
22
|
+
assert.ok(automatic >= 90);
|
|
23
|
+
assert.ok(axisExtent(series, new Set(['Income','Costs']), false, false) >= 40);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('cumulative fixed domain includes filtered nets larger than the original', () => {
|
|
27
|
+
const scale = axisExtent(series, new Set(), true, true);
|
|
28
|
+
assert.ok(scale >= 180); // Income alone, versus original cumulative net of 30.
|
|
29
|
+
assert.ok(scale >= 150); // Costs alone.
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('signed, changing contributions remain bounded for every subset', () => {
|
|
33
|
+
const mixed = [{name:'A',values:[100,-200,300]}, {name:'B',values:[-50,100,-400]}];
|
|
34
|
+
const fixed = axisExtent(mixed, new Set(), true, true);
|
|
35
|
+
for (const hidden of [[],['A'],['B'],['A','B']]) {
|
|
36
|
+
assert.equal(axisExtent(mixed, new Set(hidden), true, true), fixed);
|
|
37
|
+
assert.ok(axisExtent(mixed, new Set(hidden), true, false) <= fixed);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/tests/test_app.py
RENAMED
|
File without changes
|
{interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/tests/test_model.py
RENAMED
|
File without changes
|