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.
Files changed (20) hide show
  1. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/MANIFEST.in +1 -0
  2. {interactive_streamlit_bar_plot-0.1.0/interactive_streamlit_bar_plot.egg-info → interactive_streamlit_bar_plot-0.2.0}/PKG-INFO +29 -2
  3. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/README.md +28 -1
  4. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0/interactive_streamlit_bar_plot.egg-info}/PKG-INFO +29 -2
  5. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/interactive_streamlit_bar_plot.egg-info/SOURCES.txt +1 -0
  6. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/pyproject.toml +1 -1
  7. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/__init__.py +7 -1
  8. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/frontend/chart.css +11 -0
  9. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/frontend/chart.js +169 -21
  10. interactive_streamlit_bar_plot-0.2.0/tests/test_axis.mjs +40 -0
  11. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/LICENSE +0 -0
  12. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/PUBLISHING.md +0 -0
  13. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/app.py +0 -0
  14. {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
  15. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/interactive_streamlit_bar_plot.egg-info/requires.txt +0 -0
  16. {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
  17. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/setup.cfg +0 -0
  18. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/streamlit_net_bars/model.py +0 -0
  19. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/tests/test_app.py +0 -0
  20. {interactive_streamlit_bar_plot-0.1.0 → interactive_streamlit_bar_plot-0.2.0}/tests/test_model.py +0 -0
@@ -3,4 +3,5 @@ include app.py
3
3
  include LICENSE
4
4
  include PUBLISHING.md
5
5
  recursive-include tests *.py
6
+ recursive-include tests *.mjs
6
7
  recursive-include streamlit_net_bars/frontend *.js *.css
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interactive-streamlit-bar-plot
3
- Version: 0.1.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. The symmetric y scale adjusts to the visible contributions.
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. The symmetric y scale adjusts to the visible contributions.
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.1.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. The symmetric y scale adjusts to the visible contributions.
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
 
@@ -14,4 +14,5 @@ streamlit_net_bars/model.py
14
14
  streamlit_net_bars/frontend/chart.css
15
15
  streamlit_net_bars/frontend/chart.js
16
16
  tests/test_app.py
17
+ tests/test_axis.mjs
17
18
  tests/test_model.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interactive-streamlit-bar-plot"
7
- version = "0.1.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 ? 'The line shows the running net total from the first position.' : 'The line shows the net of visible series at each position.')));
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 width = Math.max(600, data.x.length * 65);
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
- 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));
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 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);
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
- for (let tick = -2; tick <= 2; tick++) {
136
- const value = extent * tick / 2;
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
- svg.append(rect);
233
+ marks.append(rect);
154
234
  });
155
235
  });
156
- svg.append(shape('polyline', {points: line.map((v,i) => `${x(i)},${y(v)}`).join(' '), class: 'net-line'}));
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
- 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]));
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 · Net is zero. Restore a series using the legend or Show all.'));
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 + 40;
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
- 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));
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
+