bfplot 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bfplot/result.py ADDED
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import matplotlib.pyplot as plt
8
+ from matplotlib.figure import Figure
9
+
10
+ from .adapters.matplotlib import translate_figures
11
+ from .artifact import prepare_destination, read_hash, write_result
12
+ from .bfir import BfirDocument
13
+ from .temporal import Animation, FigureSource
14
+
15
+
16
+ def figures_from(figure: FigureSource) -> list[Figure]:
17
+ if figure is None:
18
+ return [plt.figure(number) for number in plt.get_fignums()]
19
+ if isinstance(figure, Figure):
20
+ return [figure]
21
+ figures = list(figure)
22
+ if not all(isinstance(item, Figure) for item in figures):
23
+ raise TypeError("figure must be a Matplotlib Figure or iterable of Figures")
24
+ return figures
25
+
26
+
27
+ class Collection:
28
+ """Named figures and any number of animations, combined into one BFIR document."""
29
+
30
+ def __init__(self) -> None:
31
+ self._groups: list[BfirDocument | Animation] = []
32
+ self._names: set[str] = set()
33
+ self._next_figure = 0
34
+ self._next_artist = 0
35
+ self._closed = False
36
+
37
+ @property
38
+ def names(self) -> list[str]:
39
+ """Names of the figures captured so far, in result order."""
40
+ names: list[str] = []
41
+ for group in self._groups:
42
+ document = group.initial_bfir if isinstance(group, Animation) else group
43
+ names.extend(
44
+ figure["name"] for figure in document.document["figures"] if "name" in figure
45
+ )
46
+ return names
47
+
48
+ def add(
49
+ self,
50
+ figure: FigureSource = None,
51
+ *,
52
+ name: str | Iterable[str | None] | None = None,
53
+ ) -> None:
54
+ """Capture the current state of one or more figures.
55
+
56
+ ``name`` labels a single figure, or gives one name per figure for several. When
57
+ omitted, a figure's Matplotlib label (``plt.figure("spectrum")``) is used if it
58
+ is unique within the result.
59
+ """
60
+ self._check_open()
61
+ figures = figures_from(figure)
62
+ names = self._claim_names(figures, name)
63
+ document, self._next_artist = translate_figures(
64
+ figures,
65
+ names=names,
66
+ first_figure=self._next_figure,
67
+ first_artist=self._next_artist,
68
+ )
69
+ self._next_figure += len(figures)
70
+ self._groups.append(document)
71
+
72
+ def animation(
73
+ self,
74
+ figure: FigureSource = None,
75
+ *,
76
+ name: str | Iterable[str | None] | None = None,
77
+ fps: float = 30,
78
+ loop: bool = True,
79
+ ) -> Animation:
80
+ """Record a timeline for some figures of this result.
81
+
82
+ Each animation has its own timeline and playback controls. Its figures appear
83
+ where the animation started; figures added with ``add()`` stay static.
84
+ """
85
+ self._check_open()
86
+ figures = figures_from(figure)
87
+ names = self._claim_names(figures, name)
88
+ first_figure, first_artist = self._next_figure, self._next_artist
89
+ next_artist = first_artist
90
+
91
+ def translator(source: FigureSource) -> BfirDocument:
92
+ nonlocal next_artist
93
+ document, next_artist = translate_figures(
94
+ figures_from(source),
95
+ names=names,
96
+ first_figure=first_figure,
97
+ first_artist=first_artist,
98
+ )
99
+ return document
100
+
101
+ recording = Animation(figures, fps=fps, loop=loop, translator=translator)
102
+ self._next_figure += len(figures)
103
+ self._next_artist = next_artist
104
+ self._groups.append(recording)
105
+ return recording
106
+
107
+ def _check_open(self) -> None:
108
+ if self._closed:
109
+ raise RuntimeError("result is closed")
110
+
111
+ def _finish(self) -> BfirDocument:
112
+ for group in self._groups:
113
+ if isinstance(group, Animation):
114
+ group.close()
115
+ self._closed = True
116
+ if not self._groups:
117
+ raise ValueError("a result needs at least one figure")
118
+ return self._document()
119
+
120
+ def _claim_names(
121
+ self, figures: list[Figure], name: str | Iterable[str | None] | None
122
+ ) -> list[str | None]:
123
+ if isinstance(name, str):
124
+ if len(figures) != 1:
125
+ raise ValueError("a single name needs exactly one figure; pass a list of names")
126
+ requested: list[str | None] = [name]
127
+ elif name is None:
128
+ requested = [None] * len(figures)
129
+ else:
130
+ requested = list(name)
131
+ if len(requested) != len(figures):
132
+ raise ValueError("pass one name per figure")
133
+ claimed: list[str | None] = []
134
+ pending = set(self._names)
135
+ for figure, explicit in zip(figures, requested, strict=True):
136
+ if explicit is not None:
137
+ if not isinstance(explicit, str) or not explicit:
138
+ raise ValueError("figure names must be non-empty strings")
139
+ if explicit in pending:
140
+ raise ValueError(f"figure name {explicit!r} is already used in this result")
141
+ chosen: str | None = explicit
142
+ else:
143
+ label = figure.get_label()
144
+ chosen = label if label and label not in pending else None
145
+ if chosen is not None:
146
+ pending.add(chosen)
147
+ claimed.append(chosen)
148
+ self._names = pending
149
+ return claimed
150
+
151
+ def _document(self) -> BfirDocument:
152
+ figures: list[dict[str, Any]] = []
153
+ buffers: dict[str, Any] = {}
154
+ timelines: list[dict[str, Any]] = []
155
+ for group in self._groups:
156
+ document = group.finalized_bfir if isinstance(group, Animation) else group
157
+ renamed: dict[str, str] = {}
158
+ for buffer_id, buffer in document.buffers.items():
159
+ renamed[buffer_id] = f"buffer-{len(buffers)}"
160
+ buffers[renamed[buffer_id]] = buffer
161
+ content = _rename_buffers(document.document, renamed)
162
+ figures.extend(content["figures"])
163
+ timelines.extend(content.get("timelines", []))
164
+ content: dict[str, Any] = {"figures": figures}
165
+ if timelines:
166
+ content["timelines"] = timelines
167
+ return BfirDocument(content, buffers)
168
+
169
+
170
+ class Result(Collection):
171
+ """One BFPlot output: named figures and any number of animations.
172
+
173
+ Figures are captured when they are added. Nothing is written until the result
174
+ closes, and a result whose ``with`` block raises writes nothing. An existing BFPlot
175
+ result at the same path is replaced; any other existing file or directory is
176
+ reported when the result is created, before the script does expensive work.
177
+ """
178
+
179
+ def __init__(self, path: str | Path, *, preview: bool = True, _stacklevel: int = 1):
180
+ super().__init__()
181
+ self.path = Path(path)
182
+ self.preview = bool(preview)
183
+ prepare_destination(self.path, stacklevel=_stacklevel + 1)
184
+ self._written: Path | None = None
185
+ self._hash: str | None = None
186
+
187
+ @property
188
+ def hash(self) -> str | None:
189
+ """The written result's hash (``"sha256:..."``), or ``None`` until it is closed."""
190
+ return self._hash
191
+
192
+ def close(self) -> Path:
193
+ """Write the result and return its path."""
194
+ if self._written is not None:
195
+ return self._written
196
+ self._check_open()
197
+ document = self._finish()
198
+ self._written = write_result(self.path, document, preview=self.preview)
199
+ self._hash = read_hash(self._written)
200
+ return self._written
201
+
202
+ def abort(self) -> None:
203
+ """Discard the result without writing anything."""
204
+ self._closed = True
205
+ for group in self._groups:
206
+ if isinstance(group, Animation) and not group.closed:
207
+ group.abort()
208
+
209
+ def __enter__(self) -> Result:
210
+ return self
211
+
212
+ def __exit__(self, exception_type: Any, *_: Any) -> None:
213
+ if exception_type is None:
214
+ self.close()
215
+ else:
216
+ self.abort()
217
+
218
+
219
+ def _rename_buffers(value: Any, renamed: dict[str, str]) -> Any:
220
+ if isinstance(value, dict):
221
+ if set(value) == {"$buffer"}:
222
+ return {"$buffer": renamed[value["$buffer"]]}
223
+ return {key: _rename_buffers(item, renamed) for key, item in value.items()}
224
+ if isinstance(value, list):
225
+ return [_rename_buffers(item, renamed) for item in value]
226
+ return value
227
+
228
+
229
+ def result(path: str | Path, *, preview: bool = True) -> Result:
230
+ """Collect figures and animations into one output at ``path``."""
231
+ return Result(path, preview=preview, _stacklevel=2)
bfplot/temporal.py ADDED
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import math
5
+ from collections.abc import Callable, Iterable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from matplotlib.figure import Figure
11
+
12
+ from .bfir import ANIMATABLE, BfirDocument
13
+
14
+ FigureSource = Figure | Iterable[Figure] | None
15
+ Translator = Callable[[FigureSource], BfirDocument]
16
+
17
+
18
+ class Animation:
19
+ """Record mutable Matplotlib figures as a timeline of semantic BFIR updates.
20
+
21
+ Each ``capture()`` translates the figures again and stores only the fields that
22
+ changed. Figures, axes and artists cannot be added or removed while recording.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ figure: FigureSource,
28
+ *,
29
+ fps: float = 30,
30
+ loop: bool = True,
31
+ translator: Translator,
32
+ finisher: Callable[[BfirDocument], Path] | None = None,
33
+ ):
34
+ if not math.isfinite(fps) or fps <= 0:
35
+ raise ValueError("fps must be a positive finite number")
36
+ self.figure = figure
37
+ self.fps = float(fps)
38
+ self.loop = bool(loop)
39
+ self._translator = translator
40
+ self._finisher = finisher
41
+ self._closed = False
42
+ self._result: Path | None = None
43
+ self._final: BfirDocument | None = None
44
+ self._initial = translator(figure)
45
+ self._previous = self._initial
46
+ self._buffers = dict(self._initial.buffers)
47
+ self._frames: list[dict[str, Any]] = []
48
+
49
+ @property
50
+ def result(self) -> Path | None:
51
+ """The written result, or ``None`` while recording or inside ``bfplot.result()``."""
52
+ return self._result
53
+
54
+ @property
55
+ def closed(self) -> bool:
56
+ return self._closed
57
+
58
+ @property
59
+ def frame_count(self) -> int:
60
+ return len(self._frames)
61
+
62
+ @property
63
+ def initial_bfir(self) -> BfirDocument:
64
+ """A copy of the scene that playback starts from."""
65
+ initial = self._initial
66
+ return BfirDocument(copy.deepcopy(initial.document), dict(initial.buffers))
67
+
68
+ @property
69
+ def finalized_bfir(self) -> BfirDocument:
70
+ """The temporal BFIR document produced by ``close()``."""
71
+ if self._final is None:
72
+ raise RuntimeError("animation has not been closed")
73
+ return self._final
74
+
75
+ def capture(self, *, time: float | None = None) -> None:
76
+ """Capture the current figure state at a strictly increasing time (default: next frame)."""
77
+ if self._closed:
78
+ raise RuntimeError("animation is closed")
79
+ last = self._frames[-1]["time"] if self._frames else 0.0
80
+ timestamp = last + 1 / self.fps if time is None else float(time)
81
+ if not math.isfinite(timestamp):
82
+ raise ValueError("frame time must be finite")
83
+ if timestamp <= last:
84
+ raise ValueError("frame times must be strictly increasing and greater than zero")
85
+ current = self._translator(self.figure)
86
+ updates = _Diff(self._previous, current, self._buffers).updates
87
+ self._frames.append({"time": timestamp, "updates": updates})
88
+ self._previous = current
89
+
90
+ def close(self) -> Path | None:
91
+ """Finish recording; a standalone animation is written and its path returned."""
92
+ if self._final is not None:
93
+ return self._result
94
+ if self._closed:
95
+ raise RuntimeError("animation was aborted")
96
+ self._closed = True
97
+ duration = self._frames[-1]["time"] if self._frames else 0.0
98
+ document = copy.deepcopy(self._initial.document)
99
+ figure_ids = [figure["id"] for figure in document["figures"]]
100
+ document["timelines"] = [
101
+ {
102
+ "figures": figure_ids,
103
+ "fps": self.fps,
104
+ "duration": duration,
105
+ "loop": self.loop,
106
+ "frames": self._frames,
107
+ }
108
+ ]
109
+ final = BfirDocument(document, self._buffers)
110
+ final.validate()
111
+ self._final = final
112
+ if self._finisher is not None:
113
+ self._result = self._finisher(final)
114
+ return self._result
115
+
116
+ def abort(self) -> None:
117
+ """Stop recording without writing anything."""
118
+ self._closed = True
119
+
120
+ def __enter__(self) -> Animation:
121
+ return self
122
+
123
+ def __exit__(self, exception_type: Any, *_: Any) -> None:
124
+ if exception_type is None:
125
+ self.close()
126
+ else:
127
+ self.abort()
128
+
129
+
130
+ class _Diff:
131
+ """The timeline updates that turn ``old`` into ``new``.
132
+
133
+ Changed buffers are copied into ``buffers`` (the animation's buffer table) under
134
+ fresh IDs so that every frame's arrays remain addressable.
135
+ """
136
+
137
+ def __init__(self, old: BfirDocument, new: BfirDocument, buffers: dict[str, Any]):
138
+ self.old, self.new, self.buffers = old, new, buffers
139
+ self.updates: list[dict[str, Any]] = []
140
+ for old_figure, new_figure in self._pair(old.document, new.document, "figures"):
141
+ self._fields("figure", old_figure, new_figure)
142
+ for old_axes, new_axes in self._pair(old_figure, new_figure, "axes"):
143
+ self._fields("axes", old_axes, new_axes)
144
+ for old_artist, new_artist in self._pair(old_axes, new_axes, "artists"):
145
+ if old_artist["type"] != new_artist["type"]:
146
+ raise ValueError("animation cannot change an artist type")
147
+ for section in sorted(ANIMATABLE["artist"]):
148
+ before, after = old_artist[section], new_artist[section]
149
+ if before.keys() != after.keys():
150
+ raise ValueError(
151
+ f"animation cannot add or remove artist {section} fields"
152
+ )
153
+ for key in before:
154
+ self._compare("artist", old_artist["id"], [section, key], before, after)
155
+
156
+ @staticmethod
157
+ def _pair(old: dict[str, Any], new: dict[str, Any], key: str) -> list[tuple[Any, Any]]:
158
+ before = {item["id"]: item for item in old[key]}
159
+ after = {item["id"]: item for item in new[key]}
160
+ if before.keys() != after.keys():
161
+ hint = "; update visibility instead" if key == "artists" else ""
162
+ raise ValueError(f"animation cannot add or remove {key}{hint}")
163
+ return [(item, after[item_id]) for item_id, item in before.items()]
164
+
165
+ def _fields(self, kind: str, old: dict[str, Any], new: dict[str, Any]) -> None:
166
+ for field in sorted(ANIMATABLE[kind]):
167
+ if (field in old) != (field in new):
168
+ raise ValueError(f"animation cannot add or remove {kind} field {field!r}")
169
+ if field in old:
170
+ self._compare(kind, old["id"], [field], old, new)
171
+
172
+ def _compare(
173
+ self, kind: str, target: str, path: list[str], old: dict[str, Any], new: dict[str, Any]
174
+ ) -> None:
175
+ key = path[-1]
176
+ if not self._equal(old[key], new[key]):
177
+ value = self._import(new[key])
178
+ self.updates.append(
179
+ {"target_kind": kind, "target": target, "path": path, "value": value}
180
+ )
181
+
182
+ def _equal(self, left: Any, right: Any) -> bool:
183
+ if isinstance(left, dict) and "$buffer" in left:
184
+ if not (isinstance(right, dict) and "$buffer" in right):
185
+ return False
186
+ first = self.old.buffers[left["$buffer"]].array
187
+ second = self.new.buffers[right["$buffer"]].array
188
+ return (
189
+ first.dtype == second.dtype
190
+ and first.shape == second.shape
191
+ and np.array_equal(first, second, equal_nan=True)
192
+ )
193
+ if type(left) is not type(right):
194
+ return False
195
+ if isinstance(left, dict):
196
+ return left.keys() == right.keys() and all(
197
+ self._equal(left[key], right[key]) for key in left
198
+ )
199
+ if isinstance(left, list):
200
+ return len(left) == len(right) and all(map(self._equal, left, right))
201
+ return left == right
202
+
203
+ def _import(self, value: Any) -> Any:
204
+ if isinstance(value, dict):
205
+ if "$buffer" in value:
206
+ buffer_id = f"buffer-{len(self.buffers)}"
207
+ self.buffers[buffer_id] = self.new.buffers[value["$buffer"]]
208
+ return {"$buffer": buffer_id}
209
+ return {key: self._import(item) for key, item in value.items()}
210
+ if isinstance(value, list):
211
+ return [self._import(item) for item in value]
212
+ return copy.deepcopy(value)
@@ -0,0 +1,220 @@
1
+ Metadata-Version: 2.5
2
+ Name: bfplot
3
+ Version: 1.0.0
4
+ Summary: Interactive, reproducible scientific plots, with a Python implementation for Matplotlib
5
+ Project-URL: Homepage, https://bfichera.github.io/bfplot/
6
+ Project-URL: Documentation, https://bfichera.github.io/bfplot/
7
+ Project-URL: Repository, https://github.com/bfichera/bfplot
8
+ Project-URL: Issues, https://github.com/bfichera/bfplot/issues
9
+ Project-URL: Changelog, https://github.com/bfichera/bfplot/releases
10
+ Author: BFPlot contributors
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: matplotlib,plotting,reproducibility,scientific,visualization
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: Matplotlib
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: matplotlib>=3.7
23
+ Requires-Dist: numpy>=1.24
24
+ Requires-Dist: zstandard>=0.21
25
+ Description-Content-Type: text/markdown
26
+
27
+ # BFPlot
28
+
29
+ > **[Read the documentation →](https://bfichera.github.io/bfplot/)**
30
+
31
+ Basically I was tired of saving `matplotlib` outputs as non-interactive static files. I also [hate jupyter notebooks](https://www.youtube.com/watch?v=7jiPeIFXb6U)! `bfplot` lets you save `matplotlib` figures:
32
+
33
+ ```python
34
+ import matplotlib.pyplot as plt
35
+ import bfplot
36
+
37
+ fig, ax = plt.subplots()
38
+ ax.plot([1, 2, 3, 4], [1, 4, 9, 16], marker="o")
39
+
40
+ bfplot.savefig("my_plot.bfplot", fig)
41
+ ```
42
+
43
+ in a format that you can open up and interact with later using an integrated viewer app:
44
+
45
+ ```console
46
+ $ bfplot view my_plot.bfplot
47
+ ```
48
+
49
+ Here's what the viewer looks like:
50
+
51
+ ![The BFPlot viewer showing the example gallery: six panels of lines, bands, scatter, error bars, a histogram and bars, with animation controls in the left rail](https://raw.githubusercontent.com/bfichera/bfplot/main/docs/assets/gallery-core.png)
52
+
53
+ ## Installation
54
+
55
+ Install the `bfplot` app, which has the viewer built in, on macOS or Linux:
56
+
57
+ ```console
58
+ $ curl -LsSf https://github.com/bfichera/bfplot/releases/latest/download/install.sh | sh
59
+ ```
60
+
61
+ Then install the Python interface (Python 3.10+):
62
+
63
+ ```console
64
+ $ python -m pip install bfplot
65
+ ```
66
+
67
+ See [Installation](https://bfichera.github.io/bfplot/getting-started/installation/) for uninstalling and other options.
68
+
69
+ ## Building from source
70
+
71
+ On other platforms, or to build everything yourself, you need:
72
+
73
+ - Python 3.10+ (pip 25.1+ if you want the development dependency groups)
74
+ - Rust 1.88+ (install with [rustup](https://rustup.rs))
75
+ - Node.js, only if you change the viewer
76
+
77
+ Clone the repository and install both parts into a virtual environment:
78
+
79
+ ```console
80
+ $ git clone https://github.com/bfichera/bfplot.git
81
+ $ cd bfplot
82
+ $ python -m venv .venv
83
+ $ . .venv/bin/activate
84
+ $ python -m pip install --upgrade pip
85
+ $ python -m pip install ./crates/bfplot-cli -e .
86
+ ```
87
+
88
+ `./crates/bfplot-cli` compiles the `bfplot` app with Cargo (through maturin) and installs it into the same environment as the Python library, where `bfplot.show()` finds it even if the environment is not activated. `-e .` installs the Python library in editable mode. Check both parts:
89
+
90
+ ```console
91
+ $ python -c "import bfplot; print(bfplot.__version__)"
92
+ $ bfplot --version
93
+ ```
94
+
95
+ To build only the app, without Python:
96
+
97
+ ```console
98
+ $ cargo build --release --locked -p bfplot-cli
99
+ ```
100
+
101
+ The binary is written to `target/release/bfplot`; copy it anywhere on your `PATH`.
102
+
103
+ For the development setup, tests and linters, see [Contributing](https://github.com/bfichera/bfplot/blob/main/CONTRIBUTING.md).
104
+
105
+ ## Results
106
+
107
+ The code here:
108
+
109
+ ```python
110
+ with bfplot.result("results/fit.bfplot") as result:
111
+ result.add(fig_spectrum, name="spectrum")
112
+ result.add(fig_residuals, name="residuals")
113
+ ```
114
+ produces a directory called `results/fit.bfplot` with the following structure:
115
+ ```text
116
+ results/fit.bfplot/
117
+ ├── manifest.toml
118
+ ├── .gitignore
119
+ └── objects/
120
+ ```
121
+
122
+ The `.gitignore` file keeps the data (in `objects/`) out of version control.
123
+
124
+ By default, `manifest.toml` ends with a rough ASCII/Braille drawing of each figure, written as `#` comments. It lets you see the plot in a diff or commit without opening the viewer. Pass `preview=False` to leave it out.
125
+
126
+ ```toml
127
+ # Preview (approximate; regenerated on every save)
128
+ #
129
+ # ── calibration · Detector calibration ──────────────────────────────────
130
+ # 1.1┌──────────────── Response ─────────────────┐
131
+ # s │⠐⠲⣄⡴⠒⠲⡄ ⣠⠖⠂ │
132
+ # i │ ⢠⠟⣆ ⠙⣆ ⣰⠃ │
133
+ # g │⢀⠏ ⠘⣆ ⠘⡆ ⣰⠃ │
134
+ # -1.1└───────────────────────────────────────────┘
135
+ # 0 6.3
136
+ ```
137
+
138
+ ## Animations
139
+
140
+ ```python
141
+ with bfplot.animation("wave.bfplot", fig, fps=30) as recording:
142
+ for phase in np.linspace(0, 2 * np.pi, 180, endpoint=False):
143
+ line.set_ydata(np.sin(x + phase))
144
+ recording.capture()
145
+ ```
146
+
147
+ ## Hashes
148
+
149
+ Every result has one hash, recorded in its manifest. Two results have the same hash exactly when everything in them is identical.
150
+
151
+ ```python
152
+ bfplot.hash("results/fit.bfplot") # 'sha256:769fe097...'
153
+ bfplot.hash(fig) # the same figure, hashed without writing
154
+ bfplot.same("old/fit.bfplot", "new/fit.bfplot")
155
+ bfplot.verify("results/fit.bfplot") # check every object, return the hash
156
+ ```
157
+
158
+ ```console
159
+ $ bfplot hash results/fit.bfplot
160
+ sha256:769fe0976cfeecf73414d7b467013c6cde561405bce6c5af5828836e03016e37
161
+ $ bfplot compare old/fit.bfplot new/fit.bfplot
162
+ different
163
+ sha256:769fe097... old/fit.bfplot
164
+ sha256:45a78207... new/fit.bfplot
165
+ figures[0].axes[0].title: "Tent" -> "Tall tent"
166
+ ```
167
+
168
+ ## Serving
169
+
170
+ `bfplot view` serves a result and opens it in your browser. `bfplot serve` does the same without opening a browser. By default the server listens on `127.0.0.1:8080`; use `--port` to change the port.
171
+
172
+ To serve remotely, bind to another address explicitly:
173
+
174
+ ```console
175
+ $ bfplot serve my_plot.bfplot --bind 0.0.0.0 --port 9000
176
+ ```
177
+
178
+ ### Example: Using BFPlot with psana (SLAC S3DF)
179
+
180
+ On S3DF (`ssh <username>@s3df.slac.stanford.edu`, then to your psana node), start the server without a browser:
181
+
182
+ ```console
183
+ $ bfplot serve /path/to/my_plot.bfplot --port 9000
184
+ ```
185
+
186
+ On your own machine, forward the port through S3DF to the node running the server:
187
+
188
+ ```console
189
+ $ ssh -L 9000:<psana-node>:9000 <username>@s3df.slac.stanford.edu
190
+ ```
191
+
192
+ Then open <http://127.0.0.1:9000> in your local browser. If you run the server on the login node itself, use `localhost` in place of `<psana-node>`.
193
+
194
+ ## Supported plots
195
+
196
+ Lines, scatter plots, images, error bars, filled regions, histograms, contours, text, patches and common 3D plots. Anything else is shown as a visible placeholder, never silently dropped.
197
+
198
+ To see them all, run the [example gallery](https://github.com/bfichera/bfplot/blob/main/examples/gallery.py): `python examples/gallery.py`, then `bfplot view gallery.bfplot`.
199
+
200
+ | Images, contours and colorbars | Three-dimensional geometry |
201
+ | --- | --- |
202
+ | ![Raster image, contour lines, filled contours and closed paths](https://raw.githubusercontent.com/bfichera/bfplot/main/docs/assets/gallery-fields.png) | ![Helix, 3D scatter, surface and wire mesh](https://raw.githubusercontent.com/bfichera/bfplot/main/docs/assets/gallery-3d.png) |
203
+
204
+ | Dense data |
205
+ | --- |
206
+ | ![A 220,000-point line, a 65,000-point scatter, a long random walk and a large image](https://raw.githubusercontent.com/bfichera/bfplot/main/docs/assets/gallery-dense.png) |
207
+
208
+ The first two gallery figures are independent animations: each has its own play button, timeline slider and frame rate, shown in the left rail while that figure is on screen.
209
+
210
+ ## Security
211
+
212
+ Only open bfplot results from sources you trust.
213
+
214
+ ## More
215
+
216
+ - [Contributing](https://github.com/bfichera/bfplot/blob/main/CONTRIBUTING.md)
217
+
218
+ ## License
219
+
220
+ MIT License: [LICENSE](https://github.com/bfichera/bfplot/blob/main/LICENSE).
@@ -0,0 +1,15 @@
1
+ bfplot/__init__.py,sha256=3IjdsUrpQnPWzpPtDbmesg2mhT4XTyt-VfLftHl-XY8,630
2
+ bfplot/api.py,sha256=h6enDQ8WJIpd3skqsctFwmkI9lydTorZ4S4-1pms1nc,6097
3
+ bfplot/artifact.py,sha256=M6yCHVsxZWNRlsh3TM5PCHE-o88TpwzDobDmOh5gR2I,11183
4
+ bfplot/bfir.py,sha256=7Ga518wa3lRkQ00nX1djX0wtuj6WfMAqYmVH_BJ9RTI,10923
5
+ bfplot/bfir.schema.json,sha256=YiWOOfgmZ_TyRyep9wB9pZT7ngVILg9EYuPaAn6LIJY,3677
6
+ bfplot/preview.py,sha256=81qm3c95hEYlzs5-f1fDYEv4dkD7HTAFj0Tr-D0gt6g,22916
7
+ bfplot/result.py,sha256=J3O7X-_s2G1tYlrjIyTnPKJVZvubXDaYES6-o3psS3s,8615
8
+ bfplot/temporal.py,sha256=XABRnuh8WcPVK6E3I8-7wPVKkvfzgC3kNihfFwC5dcM,8507
9
+ bfplot/adapters/__init__.py,sha256=BGfxJ4NVAT5SSvi_cm8YmwhJjyAfRnzhoraRyx_Ep34,82
10
+ bfplot/adapters/matplotlib.py,sha256=yXHuCgC8dNSc3NQsbuSMrZ2-7VVW92M59b53nWXybjE,21956
11
+ bfplot/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ bfplot-1.0.0.dist-info/METADATA,sha256=VeNl5wAXF73TINPip6iYTz42zZ7oWBmkW2WkmgaqR9Q,8252
13
+ bfplot-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ bfplot-1.0.0.dist-info/licenses/LICENSE,sha256=8REjcyQ-BVwBMIhJQlfq1vUST8Z4URtceoWaTOWYBXQ,1070
15
+ bfplot-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any