plotlet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotlet/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """plotlet — pure-Python SVG renderer, matplotlib-flavored.
2
+
3
+ Tabular API (recommended):
4
+
5
+ import plotlet as pt
6
+ c = pt.chart(df, title="...", xlabel="x", ylabel="y", legend=True, grid=True)
7
+ c.line(x="time", y="value", hue="series")
8
+ c # auto-renders in Jupyter
9
+
10
+ Chained API (legacy, still supported):
11
+
12
+ fig = pt.figure()
13
+ fig.plot([1, 2, 3], [1, 4, 9], label="squares")
14
+ fig.title("Hello").legend().grid(True)
15
+ fig
16
+ """
17
+ from ._spec import SPEC
18
+ from .colors import TAB10, colors
19
+ from .core import Figure, figure
20
+ from .chart import Chart, chart
21
+
22
+ __all__ = ["chart", "Chart", "figure", "Figure", "SPEC", "TAB10", "colors"]
23
+ __version__ = "0.1.0"
plotlet/_spec.py ADDED
@@ -0,0 +1,21 @@
1
+ """Visual spec — loaded from bundled spec.json. Internal.
2
+
3
+ The spec is the locked visual contract: colors, fonts, sizes, default alphas,
4
+ legend dimensions. Submodules read from here to avoid hardcoding literals.
5
+ """
6
+ import json
7
+ from pathlib import Path
8
+
9
+ _HERE = Path(__file__).parent
10
+ SPEC = json.loads((_HERE / "spec.json").read_text())
11
+
12
+ # Convenience handles to subsections — used by other modules.
13
+ _TAB10 = SPEC["colors"]["tab10"]
14
+ _COLOR_NAMES = SPEC["colors"]["named"]
15
+ _DASH = SPEC["linestyles"]
16
+ _D = SPEC["defaults"]
17
+ _FRAME = SPEC["frame"]
18
+ _GRIDSPEC = SPEC["grid"]
19
+ _FONTSPEC = SPEC["font"]
20
+ _LEGSPEC = SPEC["legend"]
21
+ _SIZESPEC = SPEC["size"]
plotlet/artists.py ADDED
@@ -0,0 +1,154 @@
1
+ """Per-artist SVG-emit helpers, marker primitive, histogram binning.
2
+
3
+ Each `_artist_<type>` takes the recorded artist dict, the x and y scales,
4
+ and the resolved color, and returns an SVG fragment. They're called from
5
+ `core._render`.
6
+ """
7
+ import math
8
+
9
+ from ._spec import _D, _DASH
10
+
11
+
12
+ def _to_pylist(obj):
13
+ """Convert numpy / pandas / arbitrary iterables to plain Python lists."""
14
+ if hasattr(obj, "tolist"):
15
+ return obj.tolist()
16
+ if isinstance(obj, (list, tuple)):
17
+ return list(obj)
18
+ return list(obj)
19
+
20
+
21
+ def _histogram(data, bins):
22
+ """Equal-width binning. Returns list of {'x0', 'x1', 'count'} dicts."""
23
+ data = [v for v in _to_pylist(data)
24
+ if v is not None and not (isinstance(v, float) and math.isnan(v))]
25
+ if not data:
26
+ return []
27
+ lo, hi = min(data), max(data)
28
+ if lo == hi:
29
+ hi = lo + 1
30
+ n = bins if isinstance(bins, int) else 10
31
+ width = (hi - lo) / n
32
+ counts = [0] * n
33
+ for v in data:
34
+ if v == hi:
35
+ counts[-1] += 1
36
+ else:
37
+ i = int((v - lo) / width)
38
+ if 0 <= i < n:
39
+ counts[i] += 1
40
+ return [{"x0": lo + i * width, "x1": lo + (i + 1) * width, "count": counts[i]}
41
+ for i in range(n)]
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Artist helpers
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _artist_plot(a, xs_, ys_, col):
49
+ out = []
50
+ opts = a["opts"]
51
+ pts = [(xs_(x), ys_(y)) for x, y in zip(a["xs"], a["ys"])]
52
+ pts = [(px, py) if (math.isfinite(px) and math.isfinite(py)) else None
53
+ for px, py in pts]
54
+ d_segs, started = [], False
55
+ for p in pts:
56
+ if p is None:
57
+ started = False
58
+ continue
59
+ d_segs.append(f'{"M" if not started else "L"}{p[0]:.2f},{p[1]:.2f}')
60
+ started = True
61
+ ls = opts.get("linestyle")
62
+ if ls not in ("", "none"):
63
+ da = f' stroke-dasharray="{_DASH[ls]}"' if ls and _DASH.get(ls) else ""
64
+ out.append(f'<path d="{"".join(d_segs)}" fill="none" stroke="{col}" '
65
+ f'stroke-width="{opts.get("linewidth", _D["linewidth"])}"{da}/>')
66
+ if opts.get("marker"):
67
+ sz = opts.get("markersize", _D["markersize"])
68
+ for p in pts:
69
+ if p is None:
70
+ continue
71
+ out.append(_marker_at(opts["marker"], p[0], p[1], sz, col, 1))
72
+ return "".join(out)
73
+
74
+
75
+ def _artist_scatter(a, xs_, ys_, col):
76
+ opts = a["opts"]
77
+ sz = math.sqrt(opts.get("s", _D["scatter_s"])) / 2
78
+ alpha = opts.get("alpha", _D["scatter_alpha"])
79
+ marker = opts.get("marker", "o")
80
+ out = []
81
+ for x, y in zip(a["xs"], a["ys"]):
82
+ px, py = xs_(x), ys_(y)
83
+ if not (math.isfinite(px) and math.isfinite(py)):
84
+ continue
85
+ out.append(_marker_at(marker, px, py, sz, col, alpha))
86
+ return "".join(out)
87
+
88
+
89
+ def _artist_bar(a, xs_, ys_, col):
90
+ out = []
91
+ opts = a["opts"]
92
+ bw = xs_.bandwidth
93
+ y0 = ys_(0)
94
+ alpha = opts.get("alpha", _D["bar_alpha"])
95
+ for c, v in zip(a["cats"], a["vals"]):
96
+ x = xs_(c)
97
+ y = ys_(v)
98
+ out.append(f'<rect x="{x:.2f}" y="{min(y0, y):.2f}" width="{bw:.2f}" '
99
+ f'height="{abs(y - y0):.2f}" fill="{col}" opacity="{alpha}"/>')
100
+ return "".join(out)
101
+
102
+
103
+ def _artist_hist(a, xs_, ys_, ih, col):
104
+ out = []
105
+ alpha = a["opts"].get("alpha", _D["hist_alpha"])
106
+ half_gap = _D["hist_gap"] / 2
107
+ for b in a["_bins"]:
108
+ x0 = xs_(b["x0"]) + half_gap
109
+ x1 = xs_(b["x1"]) - half_gap
110
+ w = max(0, x1 - x0)
111
+ y = ys_(b["count"])
112
+ h = ih - y
113
+ out.append(f'<rect x="{x0:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" '
114
+ f'fill="{col}" opacity="{alpha}"/>')
115
+ return "".join(out)
116
+
117
+
118
+ def _artist_fill_between(a, xs_, ys_, col):
119
+ upper = [(xs_(x), ys_(y)) for x, y in zip(a["xs"], a["y1"])]
120
+ lower = [(xs_(x), ys_(y)) for x, y in zip(a["xs"], a["y2"])]
121
+ pts = upper + list(reversed(lower))
122
+ if not pts:
123
+ return ""
124
+ d = "M" + "L".join(f"{p[0]:.2f},{p[1]:.2f}" for p in pts) + "Z"
125
+ alpha = a["opts"].get("alpha", _D["fill_alpha"])
126
+ return f'<path d="{d}" fill="{col}" opacity="{alpha}"/>'
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Marker primitive — used by plot/scatter and the legend
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def _marker_at(marker, x, y, size, col, alpha):
134
+ msw = _D["marker_stroke_width"]
135
+ if marker == "o":
136
+ return f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{size}" fill="{col}" opacity="{alpha}"/>'
137
+ if marker == "s":
138
+ return (f'<rect x="{x - size:.2f}" y="{y - size:.2f}" width="{2 * size}" '
139
+ f'height="{2 * size}" fill="{col}" opacity="{alpha}"/>')
140
+ if marker == "^":
141
+ return (f'<path d="M{x:.2f},{y - size:.2f}L{x + size:.2f},{y + size:.2f}'
142
+ f'L{x - size:.2f},{y + size:.2f}Z" fill="{col}" opacity="{alpha}"/>')
143
+ if marker == "v":
144
+ return (f'<path d="M{x:.2f},{y + size:.2f}L{x + size:.2f},{y - size:.2f}'
145
+ f'L{x - size:.2f},{y - size:.2f}Z" fill="{col}" opacity="{alpha}"/>')
146
+ if marker == "x":
147
+ return (f'<path d="M{x - size:.2f},{y - size:.2f}L{x + size:.2f},{y + size:.2f}'
148
+ f'M{x - size:.2f},{y + size:.2f}L{x + size:.2f},{y - size:.2f}" '
149
+ f'stroke="{col}" stroke-width="{msw}" opacity="{alpha}"/>')
150
+ if marker == "+":
151
+ return (f'<path d="M{x - size:.2f},{y:.2f}L{x + size:.2f},{y:.2f}'
152
+ f'M{x:.2f},{y - size:.2f}L{x:.2f},{y + size:.2f}" '
153
+ f'stroke="{col}" stroke-width="{msw}" opacity="{alpha}"/>')
154
+ return ""
plotlet/chart.py ADDED
@@ -0,0 +1,150 @@
1
+ """Tidy-table-friendly facade over Figure.
2
+
3
+ c = chart(df, title="...", xlabel="...", legend=True, grid=True)
4
+ c.line(x="time", y="value", hue="series")
5
+ c # auto-renders in Jupyter
6
+
7
+ Each mark method (`line`, `scatter`, `bar`, `hist`, `fill_between`) accepts
8
+ either column-name kwargs against the bound table, or the existing array form
9
+ as positional args. `hue=<col>` splits into one call per unique value with
10
+ auto-labels and tab10 colors.
11
+
12
+ The bound table can be anything that supports `df[col_name]` returning an
13
+ iterable: pandas / polars DataFrames, dict-of-lists, dict-of-arrays. No
14
+ pandas dependency.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from .core import Figure
19
+ from .artists import _to_pylist
20
+
21
+
22
+ class Chart:
23
+ def __init__(self, data=None, *, width: int | None = None, height: int | None = None,
24
+ margin: dict | None = None,
25
+ title: str | None = None,
26
+ xlabel: str | None = None, ylabel: str | None = None,
27
+ xlim: tuple | None = None, ylim: tuple | None = None,
28
+ xscale: str | None = None, yscale: str | None = None,
29
+ legend: bool | None = None, grid: bool | None = None):
30
+ self._fig = Figure(width=width, height=height, margin=margin)
31
+ self._data = data
32
+ if title is not None: self._fig.title(title)
33
+ if xlabel is not None: self._fig.xlabel(xlabel)
34
+ if ylabel is not None: self._fig.ylabel(ylabel)
35
+ if xlim is not None: self._fig.xlim(*xlim)
36
+ if ylim is not None: self._fig.ylim(*ylim)
37
+ if xscale is not None: self._fig.xscale(xscale)
38
+ if yscale is not None: self._fig.yscale(yscale)
39
+ if legend is not None: self._fig.legend(legend)
40
+ if grid is not None: self._fig.grid(grid)
41
+
42
+ # ---------- mark methods ----------
43
+
44
+ def line(self, *args, x=None, y=None, hue=None, data=None, **opts):
45
+ if x is not None or y is not None:
46
+ self._tabular("line", "plot", data, x, y, hue, opts)
47
+ else:
48
+ self._fig.plot(*args, **opts)
49
+ return self
50
+
51
+ def scatter(self, *args, x=None, y=None, hue=None, data=None, **opts):
52
+ if x is not None or y is not None:
53
+ self._tabular("scatter", "scatter", data, x, y, hue, opts)
54
+ else:
55
+ self._fig.scatter(*args, **opts)
56
+ return self
57
+
58
+ def bar(self, *args, x=None, y=None, data=None, **opts):
59
+ if x is not None or y is not None:
60
+ df = self._resolve_data(data, "bar")
61
+ self._fig.bar(_to_pylist(df[x]), _to_pylist(df[y]), **opts)
62
+ else:
63
+ self._fig.bar(*args, **opts)
64
+ return self
65
+
66
+ def hist(self, *args, x=None, data=None, **opts):
67
+ if x is not None:
68
+ df = self._resolve_data(data, "hist")
69
+ self._fig.hist(_to_pylist(df[x]), **opts)
70
+ else:
71
+ self._fig.hist(*args, **opts)
72
+ return self
73
+
74
+ def fill_between(self, *args, x=None, y1=None, y2=None, data=None, **opts):
75
+ if x is not None or y1 is not None or y2 is not None:
76
+ df = self._resolve_data(data, "fill_between")
77
+ self._fig.fill_between(
78
+ _to_pylist(df[x]), _to_pylist(df[y1]), _to_pylist(df[y2]), **opts)
79
+ else:
80
+ self._fig.fill_between(*args, **opts)
81
+ return self
82
+
83
+ # ---------- helpers ----------
84
+
85
+ def _resolve_data(self, data, public_name):
86
+ df = data if data is not None else self._data
87
+ if df is None:
88
+ raise ValueError(
89
+ f"Chart.{public_name}() with column-name kwargs requires a bound table; "
90
+ f"pass data=<table> or use chart(<table>)."
91
+ )
92
+ return df
93
+
94
+ def _tabular(self, public_name, kind, data, x_col, y_col, hue, opts):
95
+ df = self._resolve_data(data, public_name)
96
+ method = getattr(self._fig, kind)
97
+ if hue is None:
98
+ method(_to_pylist(df[x_col]), _to_pylist(df[y_col]), **opts)
99
+ return
100
+ hue_vals = _to_pylist(df[hue])
101
+ xs_all = _to_pylist(df[x_col])
102
+ ys_all = _to_pylist(df[y_col])
103
+ seen: list = []
104
+ for v in hue_vals:
105
+ if v not in seen:
106
+ seen.append(v)
107
+ opts.pop("label", None) # hue overrides any user-provided label
108
+ for v in seen:
109
+ xs_g = [xs_all[i] for i, h in enumerate(hue_vals) if h == v]
110
+ ys_g = [ys_all[i] for i, h in enumerate(hue_vals) if h == v]
111
+ method(xs_g, ys_g, label=str(v), **opts)
112
+
113
+ # ---------- frame metadata (passthrough) ----------
114
+
115
+ def title(self, s): self._fig.title(s); return self
116
+ def xlabel(self, s): self._fig.xlabel(s); return self
117
+ def ylabel(self, s): self._fig.ylabel(s); return self
118
+ def xlim(self, a, b): self._fig.xlim(a, b); return self
119
+ def ylim(self, a, b): self._fig.ylim(a, b); return self
120
+ def xscale(self, s): self._fig.xscale(s); return self
121
+ def yscale(self, s): self._fig.yscale(s); return self
122
+ def grid(self, on=True): self._fig.grid(on); return self
123
+ def legend(self, on=True): self._fig.legend(on); return self
124
+
125
+ # ---------- render ----------
126
+
127
+ def to_svg(self) -> str:
128
+ return self._fig.to_svg()
129
+
130
+ def to_html(self, full_page: bool = False) -> str:
131
+ return self._fig.to_html(full_page=full_page)
132
+
133
+ def _repr_html_(self) -> str:
134
+ return self._fig.to_svg()
135
+
136
+ def show(self):
137
+ self._fig.show()
138
+
139
+ def save_svg(self, path):
140
+ self._fig.save_svg(path)
141
+ return self
142
+
143
+ def write_html(self, path):
144
+ self._fig.write_html(path)
145
+ return self
146
+
147
+
148
+ def chart(data=None, **opts) -> Chart:
149
+ """Construct a table-bound Chart. See `Chart` for keyword arguments."""
150
+ return Chart(data, **opts)
plotlet/colors.py ADDED
@@ -0,0 +1,20 @@
1
+ """Color resolution: tab10 shortcuts, named colors, single-letter codes."""
2
+ from ._spec import _TAB10, _COLOR_NAMES
3
+
4
+ TAB10 = list(_TAB10)
5
+ colors = list(_TAB10)
6
+
7
+
8
+ def _resolve_color(c):
9
+ """Map a color spec to a hex string. Pass-through unrecognized strings.
10
+
11
+ Accepts: 'C0'..'C9' tab10 shortcuts, named colors ('red', 'blue', …),
12
+ single-letter codes ('k', 'r', 'g', 'b', 'w'), or any hex / CSS color.
13
+ """
14
+ if c is None:
15
+ return None
16
+ if isinstance(c, str):
17
+ if len(c) == 2 and c[0] == "C" and c[1].isdigit():
18
+ return _TAB10[int(c[1])]
19
+ return _COLOR_NAMES.get(c, c)
20
+ return c
plotlet/core.py ADDED
@@ -0,0 +1,336 @@
1
+ """Figure class, the render orchestrator, and the public `figure()` factory.
2
+
3
+ This is where the deferred-render pipeline lives:
4
+
5
+ 1. `figure()` returns a `Figure` whose methods record into `_calls`.
6
+ 2. `Figure.to_svg()` calls `_render(_replay(), ...)`.
7
+ 3. `_render` does: domain compute → scales → grid → artists → spines/ticks
8
+ → labels/title → legend.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from pathlib import Path
14
+
15
+ from ._spec import (
16
+ SPEC, _SIZESPEC, _FRAME, _GRIDSPEC, _FONTSPEC, _LEGSPEC, _D, _DASH,
17
+ )
18
+ from .colors import _resolve_color, TAB10
19
+ from .scales import _LinearScale, _LogScale, _BandScale, _nice_domain, _fmt_tick
20
+ from .font import _measure_text, _text_path
21
+ from .artists import (
22
+ _to_pylist, _histogram,
23
+ _artist_plot, _artist_scatter, _artist_bar, _artist_hist, _artist_fill_between,
24
+ _marker_at,
25
+ )
26
+
27
+ _TICK_LEN = _FRAME["tick_length"]
28
+ _TICK_PAD = _FRAME["tick_pad"]
29
+ _SPINE = _FRAME["color"]
30
+ _SPW = _FRAME["width"]
31
+ _GRID = _GRIDSPEC["color"]
32
+ _FONT = _FONTSPEC["family"]
33
+
34
+
35
+ _RECORDABLE = {
36
+ "plot", "scatter", "bar", "hist", "fill_between",
37
+ "title", "xlabel", "ylabel", "xlim", "ylim",
38
+ "xscale", "yscale", "grid", "legend",
39
+ }
40
+
41
+
42
+ class Figure:
43
+ def __init__(self, width: int | None = None, height: int | None = None,
44
+ margin: dict | None = None):
45
+ self._calls: list[tuple[str, list, dict]] = []
46
+ self._width = width if width is not None else _SIZESPEC["width"]
47
+ self._height = height if height is not None else _SIZESPEC["height"]
48
+ self._margin = margin if margin is not None else dict(_SIZESPEC["margin"])
49
+
50
+ def __getattr__(self, name):
51
+ if name in _RECORDABLE:
52
+ def recorder(*args, **kwargs):
53
+ self._calls.append((name, list(args), dict(kwargs)))
54
+ return self
55
+ return recorder
56
+ raise AttributeError(f"Figure has no method {name!r}")
57
+
58
+ # ------------------------------------------------------------- replay
59
+ def _replay(self):
60
+ st = {
61
+ "artists": [], "title": "", "xlabel": "", "ylabel": "",
62
+ "xlim": None, "ylim": None, "xscale": "linear", "yscale": "linear",
63
+ "grid": False, "legend": False,
64
+ }
65
+ for name, args, kw in self._calls:
66
+ if name == "plot":
67
+ st["artists"].append({"type": "plot", "xs": _to_pylist(args[0]),
68
+ "ys": _to_pylist(args[1]), "opts": kw})
69
+ elif name == "scatter":
70
+ st["artists"].append({"type": "scatter", "xs": _to_pylist(args[0]),
71
+ "ys": _to_pylist(args[1]), "opts": kw})
72
+ elif name == "bar":
73
+ st["artists"].append({"type": "bar", "cats": _to_pylist(args[0]),
74
+ "vals": _to_pylist(args[1]), "opts": kw})
75
+ elif name == "hist":
76
+ st["artists"].append({"type": "hist",
77
+ "data": _to_pylist(args[0]), "opts": kw})
78
+ elif name == "fill_between":
79
+ st["artists"].append({"type": "fill_between",
80
+ "xs": _to_pylist(args[0]),
81
+ "y1": _to_pylist(args[1]),
82
+ "y2": _to_pylist(args[2]), "opts": kw})
83
+ elif name == "title": st["title"] = args[0]
84
+ elif name == "xlabel": st["xlabel"] = args[0]
85
+ elif name == "ylabel": st["ylabel"] = args[0]
86
+ elif name == "xlim": st["xlim"] = (args[0], args[1])
87
+ elif name == "ylim": st["ylim"] = (args[0], args[1])
88
+ elif name == "xscale": st["xscale"] = args[0]
89
+ elif name == "yscale": st["yscale"] = args[0]
90
+ elif name == "grid": st["grid"] = (args[0] if args else True)
91
+ elif name == "legend": st["legend"] = (args[0] if args else True)
92
+ return st
93
+
94
+ # ------------------------------------------------------------- render
95
+ def to_svg(self) -> str:
96
+ return _render(self._replay(), self._width, self._height, self._margin)
97
+
98
+ def to_html(self, full_page: bool = False) -> str:
99
+ svg = self.to_svg()
100
+ if full_page:
101
+ return ('<!doctype html><html><head><meta charset="utf-8">'
102
+ '<title>plotlet</title></head>'
103
+ f'<body style="margin:24px">{svg}</body></html>')
104
+ return svg
105
+
106
+ def _repr_html_(self) -> str:
107
+ return self.to_svg()
108
+
109
+ def show(self):
110
+ try:
111
+ from IPython.display import HTML, display
112
+ except ImportError:
113
+ print(self.to_html(full_page=True))
114
+ return
115
+ display(HTML(self.to_svg()))
116
+
117
+ def write_html(self, filename):
118
+ Path(filename).write_text(self.to_html(full_page=True))
119
+ return self
120
+
121
+ def save_svg(self, filename):
122
+ Path(filename).write_text(self.to_svg())
123
+ return self
124
+
125
+
126
+ def figure(width: int | None = None, height: int | None = None, **opts) -> Figure:
127
+ """Construct a new Figure. All options are passed through to `Figure(...)`."""
128
+ return Figure(width=width, height=height, **opts)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # render orchestrator
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def _render(st, W, H, M):
136
+ iw = W - M["left"] - M["right"]
137
+ ih = H - M["top"] - M["bottom"]
138
+
139
+ # pre-bin histograms so they participate in y-domain
140
+ for a in st["artists"]:
141
+ if a["type"] == "hist":
142
+ a["_bins"] = _histogram(a["data"], a["opts"].get("bins", 10))
143
+
144
+ # ---- x scale ----
145
+ has_bar = any(a["type"] == "bar" for a in st["artists"])
146
+ if has_bar:
147
+ cats = []
148
+ for a in st["artists"]:
149
+ if a["type"] == "bar":
150
+ for c in a["cats"]:
151
+ if c not in cats: cats.append(c)
152
+ x_scale = _BandScale(cats, 0, iw)
153
+ x_ticks = cats
154
+ else:
155
+ x_min, x_max = math.inf, -math.inf
156
+ for a in st["artists"]:
157
+ if a["type"] in ("plot", "scatter", "fill_between"):
158
+ for v in a["xs"]:
159
+ if v < x_min: x_min = v
160
+ if v > x_max: x_max = v
161
+ elif a["type"] == "hist":
162
+ for b in a["_bins"]:
163
+ if b["x0"] < x_min: x_min = b["x0"]
164
+ if b["x1"] > x_max: x_max = b["x1"]
165
+ if st["xlim"] is not None:
166
+ x_min, x_max = st["xlim"]
167
+ elif math.isinf(x_min):
168
+ x_min, x_max = 0, 1
169
+ elif x_min == x_max:
170
+ x_min -= 0.5; x_max += 0.5
171
+ elif st["xscale"] == "log":
172
+ if x_min > 0 and x_max > 0:
173
+ x_min = 10 ** math.floor(math.log10(x_min))
174
+ x_max = 10 ** math.ceil(math.log10(x_max))
175
+ else:
176
+ x_min, x_max = _nice_domain(x_min, x_max)
177
+ x_scale = (_LogScale if st["xscale"] == "log" else _LinearScale)(x_min, x_max, 0, iw)
178
+ x_ticks = x_scale.ticks(8)
179
+
180
+ # ---- y scale ----
181
+ y_min, y_max = math.inf, -math.inf
182
+ for a in st["artists"]:
183
+ if a["type"] in ("plot", "scatter"):
184
+ for v in a["ys"]:
185
+ if v < y_min: y_min = v
186
+ if v > y_max: y_max = v
187
+ elif a["type"] == "bar":
188
+ for v in a["vals"]:
189
+ if v < y_min: y_min = v
190
+ if v > y_max: y_max = v
191
+ elif a["type"] == "fill_between":
192
+ for v in a["y1"] + a["y2"]:
193
+ if v < y_min: y_min = v
194
+ if v > y_max: y_max = v
195
+ elif a["type"] == "hist":
196
+ for b in a["_bins"]:
197
+ if b["count"] > y_max: y_max = b["count"]
198
+ if y_min > 0: y_min = 0
199
+ if has_bar and y_min > 0: y_min = 0
200
+ if st["ylim"] is not None:
201
+ y_min, y_max = st["ylim"]
202
+ elif math.isinf(y_min):
203
+ y_min, y_max = 0, 1
204
+ elif y_min == y_max:
205
+ y_min -= 0.5; y_max += 0.5
206
+ elif st["yscale"] == "log":
207
+ if y_min > 0 and y_max > 0:
208
+ y_min = 10 ** math.floor(math.log10(y_min))
209
+ y_max = 10 ** math.ceil(math.log10(y_max))
210
+ else:
211
+ y_min, y_max = _nice_domain(y_min, y_max)
212
+ y_scale = (_LogScale if st["yscale"] == "log" else _LinearScale)(y_min, y_max, ih, 0)
213
+ y_ticks = y_scale.ticks(8)
214
+
215
+ # ---- emit SVG ----
216
+ parts = [
217
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
218
+ f'viewBox="0 0 {W} {H}" font-family="{_FONT}" font-size="11" '
219
+ f'style="background:#fff">'
220
+ ]
221
+ parts.append(f'<g transform="translate({M["left"]},{M["top"]})">')
222
+
223
+ # grid
224
+ if st["grid"]:
225
+ gw = _GRIDSPEC["width"]; gd = _GRIDSPEC["dasharray"]
226
+ if not has_bar:
227
+ for t in x_ticks:
228
+ x = x_scale(t)
229
+ parts.append(f'<line x1="{x:.2f}" x2="{x:.2f}" y1="0" y2="{ih}" '
230
+ f'stroke="{_GRID}" stroke-width="{gw}" stroke-dasharray="{gd}"/>')
231
+ for t in y_ticks:
232
+ y = y_scale(t)
233
+ parts.append(f'<line x1="0" x2="{iw}" y1="{y:.2f}" y2="{y:.2f}" '
234
+ f'stroke="{_GRID}" stroke-width="{gw}" stroke-dasharray="{gd}"/>')
235
+
236
+ # artists
237
+ color_idx = [0]
238
+ def next_color():
239
+ c = TAB10[color_idx[0] % 10]; color_idx[0] += 1; return c
240
+
241
+ for a in st["artists"]:
242
+ col = _resolve_color(a["opts"].get("color")) or next_color()
243
+ a["_color"] = col
244
+ if a["type"] == "plot":
245
+ parts.append(_artist_plot(a, x_scale, y_scale, col))
246
+ elif a["type"] == "scatter":
247
+ parts.append(_artist_scatter(a, x_scale, y_scale, col))
248
+ elif a["type"] == "bar":
249
+ parts.append(_artist_bar(a, x_scale, y_scale, col))
250
+ elif a["type"] == "hist":
251
+ parts.append(_artist_hist(a, x_scale, y_scale, ih, col))
252
+ elif a["type"] == "fill_between":
253
+ parts.append(_artist_fill_between(a, x_scale, y_scale, col))
254
+
255
+ # spines (4 sides)
256
+ for x1, y1, x2, y2 in [(0, 0, iw, 0), (0, ih, iw, ih), (0, 0, 0, ih), (iw, 0, iw, ih)]:
257
+ parts.append(f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
258
+ f'stroke="{_SPINE}" stroke-width="{_SPW}"/>')
259
+
260
+ # x ticks + labels
261
+ tick_size = _FONTSPEC["tick_size"]
262
+ label_size = _FONTSPEC["label_size"]
263
+ title_size = _FONTSPEC["title_size"]
264
+
265
+ for t in x_ticks:
266
+ x = (x_scale(t) + x_scale.bandwidth / 2) if has_bar else x_scale(t)
267
+ parts.append(f'<line x1="{x:.2f}" x2="{x:.2f}" y1="{ih}" y2="{ih - _TICK_LEN}" '
268
+ f'stroke="{_SPINE}" stroke-width="{_SPW}"/>')
269
+ parts.append(f'<line x1="{x:.2f}" x2="{x:.2f}" y1="0" y2="{_TICK_LEN}" '
270
+ f'stroke="{_SPINE}" stroke-width="{_SPW}"/>')
271
+ parts.append(_text_path(_fmt_tick(t), x, ih + _TICK_LEN + _TICK_PAD + 8,
272
+ tick_size, anchor="middle"))
273
+
274
+ # y ticks + labels
275
+ for t in y_ticks:
276
+ y = y_scale(t)
277
+ parts.append(f'<line x1="0" x2="{_TICK_LEN}" y1="{y:.2f}" y2="{y:.2f}" '
278
+ f'stroke="{_SPINE}" stroke-width="{_SPW}"/>')
279
+ parts.append(f'<line x1="{iw}" x2="{iw - _TICK_LEN}" y1="{y:.2f}" y2="{y:.2f}" '
280
+ f'stroke="{_SPINE}" stroke-width="{_SPW}"/>')
281
+ parts.append(_text_path(_fmt_tick(t), -_TICK_PAD, y + 4, tick_size, anchor="end"))
282
+
283
+ # axis labels + title
284
+ if st["xlabel"]:
285
+ parts.append(_text_path(st["xlabel"], iw / 2, ih + M["bottom"] - 8,
286
+ label_size, anchor="middle"))
287
+ if st["ylabel"]:
288
+ ylabel_path = _text_path(st["ylabel"], 0, 0, label_size, anchor="middle")
289
+ parts.append(f'<g transform="translate({-(M["left"] - 12)},{ih/2}) rotate(-90)">'
290
+ f'{ylabel_path}</g>')
291
+ if st["title"]:
292
+ parts.append(_text_path(st["title"], iw / 2, -10, title_size, anchor="middle"))
293
+
294
+ # legend
295
+ if st["legend"]:
296
+ labeled = [a for a in st["artists"] if a["opts"].get("label")]
297
+ if labeled:
298
+ row_h = _LEGSPEC["row_height"]
299
+ pad_x = _LEGSPEC["pad_x"]
300
+ pad_y = _LEGSPEC["pad_y"]
301
+ sw = _LEGSPEC["swatch_width"]
302
+ max_text = max(_measure_text(a["opts"]["label"], tick_size) for a in labeled)
303
+ lw = sw + 6 + max_text + 2 * pad_x
304
+ lh = len(labeled) * row_h + 2 * pad_y
305
+ lx, ly = iw - lw - _LEGSPEC["border_offset"], _LEGSPEC["border_offset"]
306
+ parts.append(f'<g transform="translate({lx:.2f},{ly})">')
307
+ parts.append(f'<rect x="0" y="0" width="{lw:.2f}" height="{lh}" '
308
+ f'fill="{_LEGSPEC["background"]}" stroke="{_SPINE}" '
309
+ f'stroke-width="{_SPW}" opacity="{_LEGSPEC["opacity"]}"/>')
310
+ for i, a in enumerate(labeled):
311
+ ry = pad_y + i * row_h + row_h / 2
312
+ if a["type"] in ("plot", "fill_between"):
313
+ ls = a["opts"].get("linestyle")
314
+ da = f' stroke-dasharray="{_DASH[ls]}"' if ls and _DASH.get(ls) else ""
315
+ parts.append(f'<line x1="{pad_x}" x2="{pad_x + sw}" y1="{ry}" y2="{ry}" '
316
+ f'stroke="{a["_color"]}" '
317
+ f'stroke-width="{a["opts"].get("linewidth", _D["linewidth"])}"{da}/>')
318
+ if a["opts"].get("marker"):
319
+ parts.append(_marker_at(a["opts"]["marker"], pad_x + sw / 2, ry,
320
+ a["opts"].get("markersize", _D["markersize"]),
321
+ a["_color"], 1))
322
+ elif a["type"] == "scatter":
323
+ s_size = math.sqrt(a["opts"].get("s", _D["scatter_s"])) / 2
324
+ parts.append(_marker_at(a["opts"].get("marker", "o"),
325
+ pad_x + sw / 2, ry, s_size, a["_color"],
326
+ a["opts"].get("alpha", _D["scatter_alpha"])))
327
+ else:
328
+ parts.append(f'<rect x="{pad_x}" y="{ry - 5}" width="{sw}" height="10" '
329
+ f'fill="{a["_color"]}" '
330
+ f'opacity="{a["opts"].get("alpha", 1)}"/>')
331
+ parts.append(_text_path(a["opts"]["label"], pad_x + sw + 6, ry + 4,
332
+ tick_size, anchor="start"))
333
+ parts.append('</g>')
334
+
335
+ parts.append('</g></svg>')
336
+ return "".join(parts)
plotlet/font.py ADDED
@@ -0,0 +1,63 @@
1
+ """Font loading + text→SVG path conversion using bundled DejaVu Sans.
2
+
3
+ We extract glyph outlines and emit them as <path> elements rather than
4
+ <text>, so SVG renders identically on every machine regardless of installed
5
+ fonts. The same loaded font also gives us exact text-width measurement —
6
+ no `len(text) * fudge_factor` magic numbers.
7
+ """
8
+ from pathlib import Path
9
+
10
+ from fontTools.ttLib import TTFont
11
+ from fontTools.pens.svgPathPen import SVGPathPen
12
+ from fontTools.pens.transformPen import TransformPen
13
+ from fontTools.misc.transform import Transform
14
+
15
+ _HERE = Path(__file__).parent
16
+ _FONT_PATH = _HERE / "fonts" / "DejaVuSans.ttf"
17
+
18
+ _TTF = TTFont(str(_FONT_PATH))
19
+ _UPEM = _TTF["head"].unitsPerEm
20
+ _CMAP = _TTF.getBestCmap()
21
+ _GS = _TTF.getGlyphSet()
22
+ _ASCENT = _TTF["hhea"].ascent
23
+ _DESCENT = _TTF["hhea"].descent
24
+
25
+
26
+ def _glyph(ch):
27
+ return _GS[_CMAP.get(ord(ch), ".notdef")]
28
+
29
+
30
+ def _measure_text(s: str, size: float) -> float:
31
+ """Exact pixel width of `s` rendered in DejaVu Sans at `size` pt."""
32
+ if not s:
33
+ return 0.0
34
+ scale = size / _UPEM
35
+ return sum(_glyph(ch).width * scale for ch in s)
36
+
37
+
38
+ def _text_path(s: str, x: float, y: float, size: float,
39
+ anchor: str = "start", color: str = "#000") -> str:
40
+ """Render `s` as a single SVG <path> with its baseline at (x, y).
41
+
42
+ `anchor` matches SVG's text-anchor: 'start' | 'middle' | 'end'.
43
+ Useful for tick labels (anchor='middle'), y-tick labels (anchor='end').
44
+ """
45
+ if not s:
46
+ return ""
47
+ width = _measure_text(s, size)
48
+ if anchor == "middle":
49
+ x0 = x - width / 2
50
+ elif anchor == "end":
51
+ x0 = x - width
52
+ else:
53
+ x0 = x
54
+ pen = SVGPathPen(_GS)
55
+ scale = size / _UPEM
56
+ cx = x0
57
+ for ch in s:
58
+ g = _glyph(ch)
59
+ # SVG y points down, font y points up — flip with negative scale.
60
+ tpen = TransformPen(pen, Transform().translate(cx, y).scale(scale, -scale))
61
+ g.draw(tpen)
62
+ cx += g.width * scale
63
+ return f'<path d="{pen.getCommands()}" fill="{color}"/>'
Binary file
plotlet/scales.py ADDED
@@ -0,0 +1,116 @@
1
+ """Coordinate scales: linear, log, band — mirroring d3's scale types.
2
+
3
+ Each scale is a callable: `scale(data_value) -> pixel_position`. Each also
4
+ exposes `.ticks(n)` for axis tick generation.
5
+ """
6
+ import math
7
+
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Nice-numbers algorithm (1/2/5 × 10ⁿ)
11
+ # ---------------------------------------------------------------------------
12
+
13
+ def _nice_step(span, n):
14
+ if span <= 0:
15
+ return 1
16
+ step = span / n
17
+ mag = 10 ** math.floor(math.log10(step))
18
+ err = step / mag
19
+ if err < 1.5: return 1 * mag
20
+ if err < 3: return 2 * mag
21
+ if err < 7: return 5 * mag
22
+ return 10 * mag
23
+
24
+
25
+ def _nice_ticks(lo, hi, n=8):
26
+ if lo == hi:
27
+ return [lo]
28
+ step = _nice_step(hi - lo, n)
29
+ eps = abs(step) * 1e-9
30
+ start = math.ceil(lo / step - 1e-9) * step
31
+ out, t = [], start
32
+ while t <= hi + eps:
33
+ out.append(round(t, 10))
34
+ t += step
35
+ return out
36
+
37
+
38
+ def _nice_domain(lo, hi, n=8):
39
+ if lo == hi:
40
+ return (lo - 0.5, hi + 0.5)
41
+ step = _nice_step(hi - lo, n)
42
+ return (math.floor(lo / step) * step, math.ceil(hi / step) * step)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Scale classes
47
+ # ---------------------------------------------------------------------------
48
+
49
+ class _LinearScale:
50
+ def __init__(self, d0, d1, r0, r1):
51
+ self.d0, self.d1, self.r0, self.r1 = d0, d1, r0, r1
52
+
53
+ def __call__(self, v):
54
+ if self.d1 == self.d0:
55
+ return self.r0
56
+ return self.r0 + (v - self.d0) * (self.r1 - self.r0) / (self.d1 - self.d0)
57
+
58
+ def ticks(self, n=8):
59
+ return _nice_ticks(self.d0, self.d1, n)
60
+
61
+
62
+ class _LogScale:
63
+ def __init__(self, d0, d1, r0, r1):
64
+ if d0 <= 0 or d1 <= 0:
65
+ raise ValueError("log scale needs strictly positive domain")
66
+ self.d0, self.d1 = d0, d1
67
+ self.l0, self.l1 = math.log10(d0), math.log10(d1)
68
+ self.r0, self.r1 = r0, r1
69
+
70
+ def __call__(self, v):
71
+ if v <= 0:
72
+ return float("nan")
73
+ return self.r0 + (math.log10(v) - self.l0) * (self.r1 - self.r0) / (self.l1 - self.l0)
74
+
75
+ def ticks(self, n=8):
76
+ a, b = math.floor(self.l0), math.ceil(self.l1)
77
+ return [10 ** k for k in range(int(a), int(b) + 1) if self.d0 <= 10 ** k <= self.d1]
78
+
79
+
80
+ class _BandScale:
81
+ """Categorical scale — mirrors d3.scaleBand().padding(p) (inner = outer = p)."""
82
+
83
+ def __init__(self, cats, r0, r1, padding=0.2):
84
+ self.cats = list(cats)
85
+ self.r0, self.r1 = r0, r1
86
+ n = len(self.cats) or 1
87
+ total = r1 - r0
88
+ self.step = total / (n + padding)
89
+ self.bandwidth = self.step * (1 - padding)
90
+ self._start = self.r0 + padding * self.step
91
+
92
+ def __call__(self, cat):
93
+ try:
94
+ i = self.cats.index(cat)
95
+ except ValueError:
96
+ return float("nan")
97
+ return self._start + i * self.step
98
+
99
+ def ticks(self, n=None):
100
+ return list(self.cats)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Tick formatting
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def _fmt_tick(t):
108
+ """Format a tick value: 'g' for typical values, scientific for extremes."""
109
+ if isinstance(t, str):
110
+ return t
111
+ if t == 0:
112
+ return "0"
113
+ a = abs(t)
114
+ if a >= 1e4 or a < 1e-3:
115
+ return f"{t:.0e}".replace("e+0", "e").replace("e-0", "e-").replace("e+", "e")
116
+ return f"{t:g}"
plotlet/spec.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "size": {
4
+ "width": 600,
5
+ "height": 400,
6
+ "margin": {"top": 30, "right": 18, "bottom": 48, "left": 58}
7
+ },
8
+ "font": {
9
+ "family": "DejaVu Sans, Bitstream Vera Sans, Verdana, Arial, sans-serif",
10
+ "tick_size": 11,
11
+ "label_size": 12,
12
+ "title_size": 13
13
+ },
14
+ "frame": {
15
+ "color": "#000000",
16
+ "width": 0.8,
17
+ "tick_length": 3.5,
18
+ "tick_pad": 4
19
+ },
20
+ "grid": {
21
+ "color": "#b0b0b0",
22
+ "width": 0.5,
23
+ "dasharray": "2,3"
24
+ },
25
+ "colors": {
26
+ "tab10": [
27
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
28
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"
29
+ ],
30
+ "named": {
31
+ "blue": "#1f77b4",
32
+ "orange": "#ff7f0e",
33
+ "green": "#2ca02c",
34
+ "red": "#d62728",
35
+ "purple": "#9467bd",
36
+ "brown": "#8c564b",
37
+ "pink": "#e377c2",
38
+ "gray": "#7f7f7f",
39
+ "olive": "#bcbd22",
40
+ "cyan": "#17becf",
41
+ "k": "#000000",
42
+ "w": "#ffffff",
43
+ "b": "#1f77b4",
44
+ "g": "#2ca02c",
45
+ "r": "#d62728"
46
+ }
47
+ },
48
+ "linestyles": {
49
+ "-": null,
50
+ "--": "6,3",
51
+ ":": "1,3",
52
+ "-.": "6,3,1,3"
53
+ },
54
+ "defaults": {
55
+ "linewidth": 1.5,
56
+ "markersize": 4,
57
+ "marker_stroke_width": 1.4,
58
+ "scatter_s": 25,
59
+ "scatter_alpha": 0.85,
60
+ "fill_alpha": 0.3,
61
+ "bar_alpha": 1,
62
+ "hist_alpha": 1,
63
+ "bar_padding": 0.2,
64
+ "hist_gap": 1.0
65
+ },
66
+ "legend": {
67
+ "row_height": 16,
68
+ "pad_x": 8,
69
+ "pad_y": 6,
70
+ "swatch_width": 22,
71
+ "background": "#ffffff",
72
+ "opacity": 0.92,
73
+ "border_offset": 6
74
+ }
75
+ }
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: plotlet
3
+ Version: 0.1.0
4
+ Summary: Small, hackable Python library that emits matplotlib-style SVG plots.
5
+ Author: gitbamboo42
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gitbamboo42/plotlet
8
+ Project-URL: Repository, https://github.com/gitbamboo42/plotlet
9
+ Project-URL: Issues, https://github.com/gitbamboo42/plotlet/issues
10
+ Keywords: plot,svg,matplotlib,scientific,visualization,jupyter,reproducible
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Classifier: Topic :: Multimedia :: Graphics
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: fonttools>=4.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: jupyter; extra == "dev"
27
+ Requires-Dist: nbconvert; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # plotlet
31
+
32
+ A small, hackable Python library that emits matplotlib-style SVG plots.
33
+
34
+ ## Why
35
+
36
+ matplotlib is the right tool when you want the kitchen sink. plotlet's niche is **custom plot types** — genome tracks, Manhattan plots, phylogenetic trees, anything matplotlib's extension API makes painful. The whole library is ~700 lines of Python with a deliberately tiny, exposed core: adding a new plot type is a 3-step recipe, not an architecture project.
37
+
38
+ It's a **scaffold, not a feature catalog**: the core ships ~5 standard plots and the infrastructure for extending. Custom plot types live in your own project (or [`cookbook/`](cookbook/)), not upstream. See [docs/PHILOSOPHY.md](docs/PHILOSOPHY.md) for the full framing.
39
+
40
+ ```python
41
+ import plotlet as pt
42
+
43
+ data = {
44
+ "x": [1, 2, 3, 4, 5, 1, 2, 3, 4, 5],
45
+ "y": [1, 4, 9, 16, 25, 1, 8, 27, 64, 125],
46
+ "series": ["squares"] * 5 + ["cubes"] * 5,
47
+ }
48
+
49
+ c = pt.chart(data, title="Hello", xlabel="x", ylabel="y", legend=True, grid=True)
50
+ c.line(x="x", y="y", hue="series")
51
+ c # auto-renders in Jupyter
52
+ ```
53
+
54
+ ## Install
55
+
56
+ Not on PyPI yet — clone and install editable:
57
+
58
+ ```bash
59
+ git clone <repo>
60
+ cd plotlet
61
+ pip install -e .
62
+ ```
63
+
64
+ ## Properties
65
+
66
+ - **Lightweight.** `fonttools` for font handling. numpy / pandas / polars inputs work transparently if you have them.
67
+ - **Static SVG output.** No interactivity, no animation. Same script → byte-identical SVG.
68
+ - **Cross-machine reproducible.** Bundled DejaVu Sans + text-as-paths means rendering is identical on Linux, macOS, Windows, headless CI.
69
+ - **Jupyter-native.** `Figure._repr_html_` auto-renders the last expression in a cell.
70
+ - **Tiny output.** Each plot is ~50 KB SVG, self-contained.
71
+
72
+ ## API
73
+
74
+ `pt.chart(data, **opts)` returns a `Chart` bound to a table — any object that supports `data[col_name]` returning an iterable (pandas / polars DataFrames, dict-of-lists, dict-of-arrays). All methods return `self`; `_repr_html_` makes the chart auto-render as the last expression in a Jupyter cell.
75
+
76
+ ### Frame options
77
+
78
+ Pass at construction (`pt.chart(data, title=..., grid=True, ...)`) or as chained setters (`c.title(...)`, etc.):
79
+
80
+ `title`, `xlabel`, `ylabel`, `xlim=(a, b)`, `ylim=(a, b)`, `xscale="log"|"linear"`, `yscale=...`, `grid=True/False`, `legend=True/False`, `width`, `height`
81
+
82
+ ### Mark methods
83
+
84
+ | call | options |
85
+ | --- | --- |
86
+ | `.line(x=, y=, hue=, **opts)` | `color`, `label`, `linewidth`, `linestyle` (`"-"`, `"--"`, `":"`, `"-."`), `marker` (`"o"`, `"s"`, `"^"`, `"v"`, `"x"`, `"+"`), `markersize` |
87
+ | `.scatter(x=, y=, hue=, **opts)` | `color`, `label`, `s` (size), `alpha`, `marker` |
88
+ | `.bar(x=, y=, **opts)` | `color`, `label`, `alpha` |
89
+ | `.hist(x=, **opts)` | `bins`, `color`, `alpha`, `label` |
90
+ | `.fill_between(x=, y1=, y2=, **opts)` | `color`, `alpha`, `label` |
91
+
92
+ `hue=<col>` (on `.line` / `.scatter`) splits into one call per unique value with auto-labels and tab10 colors.
93
+
94
+ ### Render / save
95
+
96
+ ```python
97
+ c.show() # explicit display() inside a cell
98
+ c.to_svg() # raw SVG string
99
+ c.save_svg("plot.svg") # SVG file
100
+ c.write_html("plot.html") # standalone HTML
101
+ ```
102
+
103
+ ### Color shortcuts
104
+
105
+ - `"C0"`–`"C9"` → tab10 (matches matplotlib)
106
+ - Named: `"blue"`, `"orange"`, `"green"`, `"red"`, `"purple"`, `"brown"`, `"pink"`, `"gray"`, `"olive"`, `"cyan"`
107
+ - Single-letter: `"k"`, `"w"`, `"b"`, `"g"`, `"r"`
108
+ - Any hex / CSS color string passes through
109
+
110
+ ## Adding a new plot type
111
+
112
+ plotlet's central hackability claim: a custom plot type is a 3-step recipe (~50–100 lines) that gets axes, scales, legend, grid, and composability for free. The recommended home is your own project, or [`cookbook/`](cookbook/) as reference. Full guide: [docs/EXTENDING.md](docs/EXTENDING.md).
113
+
114
+ ## Testing
115
+
116
+ ```bash
117
+ python tests/test_chart.py # check vs. committed baselines
118
+ python tests/test_chart.py --update # regenerate (review the diff!)
119
+ python tests/test_chart.py --gallery # build tests/baseline_images/chart/index.html
120
+ ```
121
+
122
+ ## Non-goals
123
+
124
+ - No interactivity (hover, zoom, click). Static rendering is the point.
125
+ - Not competing with matplotlib on standard plots; matplotlib is bigger and battle-tested.
126
+ - Not a 3D plotter, not a dashboard tool.
127
+ - Not a feature catalog — new plot types belong in user projects or `cookbook/`, not in the core.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,15 @@
1
+ plotlet/__init__.py,sha256=3g8RTmrO7NVAPlFgjEOSQaLh8mjZuBk4gMcedcCcOnM,692
2
+ plotlet/_spec.py,sha256=9m6u5HoHLXlFVPb1mBCLdHR8pGT-HVTsKc9HvZdl4Nw,649
3
+ plotlet/artists.py,sha256=lMD6WxYEyF6XSssNQwpUAyZE7bzOjucn8tS766cmrnA,5728
4
+ plotlet/chart.py,sha256=9EgbeHb5ulZlngXiAmAuTlgW56nx0zWyLYPRygO3m-c,5810
5
+ plotlet/colors.py,sha256=OPQTxlqGe80-DjllJFb589idxtmnywdElfMxQARRym8,633
6
+ plotlet/core.py,sha256=kwUOEdua55xMW3bDcREcaYj5czBdMvNqQHtDmH-26cE,14511
7
+ plotlet/font.py,sha256=Qrpw0kYBKKpAKCFXd3iXxS_0eBGIuITrK4DmsjGx-T0,2028
8
+ plotlet/scales.py,sha256=aJ_l32t7JjbvQM61Wyd7Cjo4OFDsAPC_7GaO5KReYfg,3506
9
+ plotlet/spec.json,sha256=rMJqtUEhOOW46J5_R6vG1rX5T7GiZHpkEZrw65ux4IM,1564
10
+ plotlet/fonts/DejaVuSans.ttf,sha256=P99pyr8GBJ6nCgC1kZNA4s4ebQKwzDxLRPtoAb0eDSI,756072
11
+ plotlet-0.1.0.dist-info/licenses/LICENSE,sha256=-eDAeMETlonetrYKJB7m6cBRyYjtTbnurp_9KkbmU_4,1068
12
+ plotlet-0.1.0.dist-info/METADATA,sha256=QDAYYm0dPnqdxnNhqd3pFMgTF9UXEMHLujMdDp80boQ,5541
13
+ plotlet-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ plotlet-0.1.0.dist-info/top_level.txt,sha256=hLvsc31uP3a4QQS-dvoV_Ec_HXluZZ1W31AvDjHrb2I,8
15
+ plotlet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gitbamboo42
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ plotlet