anytimeline 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.
@@ -0,0 +1,12 @@
1
+ """anytimeline: GPU-accelerated timeline and event charts for notebooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from anytimeline.events import Route
6
+ from anytimeline.presets import gantt
7
+ from anytimeline.styling import SYMBOLS
8
+ from anytimeline.widget import Timeline, save_html
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = ["SYMBOLS", "Route", "Timeline", "__version__", "gantt", "save_html"]
anytimeline/codec.py ADDED
@@ -0,0 +1,98 @@
1
+ """Binary encodings for arrays sent to the browser.
2
+
3
+ Arrays travel as raw little-endian typed buffers described by a small JSON descriptor, so
4
+ the browser can view them without parsing or copying. The descriptor's ``id`` is a hash of
5
+ the bytes and the dtype: identical data always has the same id and is sent once.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ DTYPES = {
18
+ "float64": "<f8",
19
+ "float32": "<f4",
20
+ "int32": "<i4",
21
+ "uint32": "<u4",
22
+ "uint16": "<u2",
23
+ "uint8": "u1",
24
+ }
25
+ """The dtypes the browser reads, and their NumPy spelling."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Encoded:
30
+ """An encoded array: a JSON-able descriptor plus the raw bytes."""
31
+
32
+ descriptor: dict[str, Any]
33
+ data: bytes
34
+
35
+ @property
36
+ def id(self) -> str:
37
+ return self.descriptor["id"]
38
+
39
+
40
+ def _descriptor(data: bytes, dtype: str, count: int, components: int) -> dict[str, Any]:
41
+ return {
42
+ "id": hashlib.blake2b(data, digest_size=12).hexdigest() + f"-{dtype}",
43
+ "dtype": dtype,
44
+ "count": count,
45
+ "components": components,
46
+ "byteLength": len(data),
47
+ }
48
+
49
+
50
+ def encode(values: Any, dtype: str, *, components: int = 1) -> Encoded:
51
+ """Encode an array as ``dtype`` (a key of ``DTYPES``).
52
+
53
+ Args:
54
+ values: Array of shape ``(n,)`` or ``(n, components)``.
55
+ dtype: The type the browser views the bytes as.
56
+ components: Interleaved components per element (e.g. 4 for RGBA).
57
+ """
58
+ if dtype not in DTYPES:
59
+ raise ValueError(f"unknown dtype {dtype!r}; known: {list(DTYPES)}")
60
+ arr = np.asarray(values)
61
+ if arr.ndim == 2 and arr.shape[1] != components:
62
+ raise ValueError(f"expected {components} components, got shape {arr.shape}")
63
+ if arr.ndim == 1 and components > 1:
64
+ raise ValueError("pass a (n, components) array for interleaved data")
65
+ if arr.dtype.kind == "f" and DTYPES[dtype][1] != "f" and not np.all(np.isfinite(arr)):
66
+ raise ValueError("values must be finite for an integer dtype")
67
+ data = np.ascontiguousarray(arr.astype(DTYPES[dtype], copy=False)).tobytes()
68
+ count = int(arr.shape[0]) if arr.ndim else 1
69
+ return Encoded(_descriptor(data, dtype, count, components), data)
70
+
71
+
72
+ def encode_strings(strings: Sequence[str]) -> Encoded:
73
+ """Encode text as ``count + 1`` little-endian uint32 byte offsets, then the UTF-8 bytes.
74
+
75
+ String ``i`` is ``utf8[offsets[i]:offsets[i + 1]]``; the browser decodes it with a
76
+ ``TextDecoder``, without parsing JSON.
77
+ """
78
+ parts = [s.encode("utf-8") for s in strings]
79
+ offsets = np.zeros(len(parts) + 1, dtype="<u4")
80
+ offsets[1:] = np.cumsum([len(p) for p in parts])
81
+ data = offsets.tobytes() + b"".join(parts)
82
+ return Encoded(_descriptor(data, "utf8", len(parts), 1), data)
83
+
84
+
85
+ def decode(descriptor: dict[str, Any], data: bytes) -> np.ndarray:
86
+ """Inverse of ``encode`` (for tests and round trips)."""
87
+ arr = np.frombuffer(data, dtype=DTYPES[descriptor["dtype"]])
88
+ if descriptor["components"] > 1:
89
+ arr = arr.reshape(-1, descriptor["components"])
90
+ return arr
91
+
92
+
93
+ def decode_strings(descriptor: dict[str, Any], data: bytes) -> list[str]:
94
+ """Inverse of ``encode_strings``."""
95
+ n = descriptor["count"]
96
+ offsets = np.frombuffer(data, dtype="<u4", count=n + 1)
97
+ text = data[4 * (n + 1) :]
98
+ return [text[offsets[i] : offsets[i + 1]].decode("utf-8") for i in range(n)]
anytimeline/data.py ADDED
@@ -0,0 +1,120 @@
1
+ """Reading columns from tables: Polars, PyArrow, pandas, DuckDB (via narwhals) or a dict of
2
+ arrays."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+
12
+ class NotFoundError(KeyError):
13
+ """A missing column or layer: a ``KeyError`` whose message reads as written."""
14
+
15
+ def __str__(self) -> str:
16
+ return str(self.args[0]) if self.args else ""
17
+
18
+
19
+ def frame(data: Any) -> Any:
20
+ """``data`` as an eager narwhals DataFrame, or the mapping itself."""
21
+ if isinstance(data, Mapping):
22
+ return data
23
+ import narwhals as nw
24
+
25
+ try:
26
+ out = nw.from_native(data)
27
+ except TypeError:
28
+ raise TypeError(
29
+ f"unsupported data of type {type(data).__name__}; pass a Polars, PyArrow, pandas "
30
+ "or DuckDB table, or a dict of arrays"
31
+ ) from None
32
+ if isinstance(out, nw.LazyFrame):
33
+ out = out.collect()
34
+ return out
35
+
36
+
37
+ def column_names(table: Any) -> list[str]:
38
+ """The names of a table's columns (``table`` from ``frame``)."""
39
+ if isinstance(table, Mapping):
40
+ return [str(k) for k in table]
41
+ return list(table.columns)
42
+
43
+
44
+ def column(table: Any, name: str) -> np.ndarray:
45
+ """A column as a 1-D NumPy array; a missing text value as None."""
46
+ check(table, [name])
47
+ if isinstance(table, Mapping):
48
+ values = table[name]
49
+ if isinstance(values, list | tuple) and any(
50
+ isinstance(v, list | tuple | set | np.ndarray) for v in values
51
+ ):
52
+ # cells that are lists (several ids per row): one object per cell
53
+ out = np.empty(len(values), dtype=object)
54
+ for i, v in enumerate(values):
55
+ out[i] = v
56
+ return out
57
+ return np.asarray(values).ravel()
58
+ series = table[name]
59
+ out = series.to_numpy()
60
+ if out.dtype == object:
61
+ missing = np.asarray(series.is_null().to_numpy(), dtype=bool)
62
+ if missing.any():
63
+ out = out.copy()
64
+ out[missing] = None
65
+ return out
66
+
67
+
68
+ def time_zone(table: Any, name: str) -> str | None:
69
+ """The time zone of a datetime column (None: naive, or not a datetime)."""
70
+ if isinstance(table, Mapping):
71
+ values = table[name]
72
+ tz = getattr(getattr(values, "dt", None), "tz", None) or getattr(values, "tz", None)
73
+ return str(tz) if tz is not None else None
74
+ import narwhals as nw
75
+
76
+ dtype = table.schema[name]
77
+ if isinstance(dtype, nw.Datetime):
78
+ return dtype.time_zone
79
+ return None
80
+
81
+
82
+ def categories(table: Any, name: str) -> list[Any] | None:
83
+ """The declared order of a column's values: a Polars ``Enum`` or a pandas
84
+ ``Categorical``; None for other columns."""
85
+ if isinstance(table, Mapping):
86
+ values = table[name]
87
+ cat = getattr(values, "cat", None)
88
+ if cat is not None and hasattr(cat, "categories"):
89
+ return list(cat.categories)
90
+ return list(values.categories) if hasattr(values, "categories") else None
91
+ import narwhals as nw
92
+
93
+ dtype = table.schema[name]
94
+ if isinstance(dtype, nw.Enum):
95
+ return list(dtype.categories)
96
+ native = nw.to_native(table)
97
+ if dtype == nw.Categorical and hasattr(native, "iloc"): # pandas: its own categories
98
+ return list(native[name].cat.categories)
99
+ return None
100
+
101
+
102
+ def check(table: Any, names: list[str]) -> None:
103
+ """Raise ``NotFoundError`` naming the columns that ``table`` lacks."""
104
+ available = column_names(table)
105
+ missing = [n for n in names if n not in available]
106
+ if missing:
107
+ raise NotFoundError(f"columns not found: {missing}; available: {available}")
108
+
109
+
110
+ def is_missing(values: np.ndarray) -> np.ndarray:
111
+ """True where a value is missing: None, NaN or NaT."""
112
+ if values.dtype.kind in "fc":
113
+ return np.isnan(values)
114
+ if values.dtype.kind in "mM":
115
+ return np.isnat(values)
116
+ if values.dtype.kind == "O":
117
+ return np.array(
118
+ [v is None or (isinstance(v, float) and v != v) for v in values], dtype=bool
119
+ )
120
+ return np.zeros(values.shape, dtype=bool)
anytimeline/events.py ADDED
@@ -0,0 +1,336 @@
1
+ """The events layer: one mark per event at the moment it happened, without a duration.
2
+
3
+ ``build`` reads a table into an ``EventsRecord`` (everything except the plot's shared time
4
+ base and lanes). ``layout`` then sorts the events by lane, time and order, and encodes the
5
+ buffers the browser draws.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Literal
13
+
14
+ import numpy as np
15
+
16
+ from anytimeline import data as tables
17
+ from anytimeline import styling
18
+ from anytimeline.codec import Encoded, encode, encode_strings
19
+ from anytimeline.timebase import TimeBase, Times, times
20
+
21
+ Overlap = Literal["dodge", "overlay"]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Route:
26
+ """Lines joining the events of one group in the order they happened.
27
+
28
+ Args:
29
+ group: The column that names a group (a request, a user, a process).
30
+ color: ``None``: each segment in the colour of the event it starts at; or one
31
+ colour (``"theme"`` follows the plot's text colour).
32
+ width: Line width in CSS pixels.
33
+ """
34
+
35
+ group: str
36
+ color: str | None = None
37
+ width: float = 1.5
38
+
39
+ @classmethod
40
+ def of(cls, value: Route | str | None) -> Route | None:
41
+ if value is None or isinstance(value, Route):
42
+ return value
43
+ if isinstance(value, str):
44
+ return cls(group=value)
45
+ raise TypeError(f"route must be a column name or a Route, got {type(value).__name__}")
46
+
47
+
48
+ @dataclass
49
+ class EventsRecord:
50
+ """An events layer as read from its table (rows with a time, or an order, only)."""
51
+
52
+ name: str
53
+ rows: np.ndarray # table row of each kept event
54
+ times: Times | None # None: order only
55
+ order: np.ndarray | None # float64 tie-break / sequence
56
+ lanes: np.ndarray # lane value per event (object)
57
+ lane_title: str
58
+ lane_declared: list[Any] | None
59
+ color: styling.Encoding
60
+ symbol: styling.Encoding
61
+ size: styling.Encoding
62
+ text: list[str] | None
63
+ tooltip: dict[str, np.ndarray]
64
+ route: Route | None
65
+ groups: np.ndarray | None
66
+ overlap: Overlap
67
+ importance: np.ndarray | None
68
+ hover: bool
69
+ select: bool
70
+ dropped: int
71
+ perm: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype=np.int64))
72
+ lane_groups: np.ndarray | None = None # each row's group (group=), or None
73
+ lane_group_declared: list[Any] | None = None
74
+ """Event index in the browser (sorted) → index into ``rows``; set by ``layout``."""
75
+
76
+ @property
77
+ def time_columns(self) -> list[Times]:
78
+ return [self.times] if self.times is not None else []
79
+
80
+
81
+ def build(
82
+ data: Any,
83
+ *,
84
+ name: str,
85
+ time: str | None,
86
+ lane: str | None,
87
+ order: str | None,
88
+ color: str | None,
89
+ colormap: str | Sequence[str] | None,
90
+ clim: Any,
91
+ palette: Mapping[Any, str] | Sequence[str] | None,
92
+ opacity: float,
93
+ symbol: str | None,
94
+ symbols: Mapping[Any, str] | Sequence[str] | None,
95
+ size: float | str | None,
96
+ size_range: tuple[float, float],
97
+ text: str | None,
98
+ tooltip: Sequence[str] | None,
99
+ route: Route | str | None,
100
+ overlap: Overlap,
101
+ importance: str | None,
102
+ time_unit: str | None,
103
+ hover: bool,
104
+ select: bool,
105
+ ) -> EventsRecord:
106
+ """Read an events layer from a table."""
107
+ if time is None and order is None:
108
+ raise ValueError("pass time= (a column of datetimes, durations or numbers) or order=")
109
+ if overlap not in ("dodge", "overlay"):
110
+ raise ValueError(f"overlap must be 'dodge' or 'overlay', got {overlap!r}")
111
+ table = tables.frame(data)
112
+ names = tables.column_names(table)
113
+ route_ = Route.of(route)
114
+
115
+ def col(arg: str | None) -> np.ndarray | None:
116
+ return tables.column(table, arg) if arg is not None else None
117
+
118
+ n = len(tables.column(table, names[0])) if names else 0
119
+ keep = np.ones(n, dtype=bool)
120
+ t: Times | None = None
121
+ if time is not None:
122
+ t = times(
123
+ tables.column(table, time),
124
+ timezone=tables.time_zone(table, time),
125
+ unit=time_unit,
126
+ )
127
+ keep &= t.valid
128
+ order_values = None
129
+ if order is not None:
130
+ order_values = _numbers(tables.column(table, order), order)
131
+ keep &= np.isfinite(order_values)
132
+ rows = np.flatnonzero(keep)
133
+ if t is not None:
134
+ t = t.take(rows)
135
+
136
+ def pick(arr: np.ndarray | None) -> np.ndarray | None:
137
+ return arr[rows] if arr is not None else None
138
+
139
+ lane_values = pick(col(lane))
140
+ if lane_values is None:
141
+ lane_values = np.full(len(rows), name, dtype=object)
142
+ else:
143
+ lane_values = lane_values.astype(object)
144
+ lane_values[tables.is_missing(lane_values)] = "(missing)"
145
+
146
+ color_is_column = color is not None and color in names
147
+ if color is not None and not color_is_column and not styling.is_color(color):
148
+ raise tables.NotFoundError(
149
+ f"color={color!r} is neither a column nor a colour; columns: {names}"
150
+ )
151
+ color_enc = styling.colors(
152
+ pick(col(color)) if color_is_column else None,
153
+ solid=None if color_is_column else color,
154
+ title=color if color_is_column else "",
155
+ declared=tables.categories(table, color) if color_is_column and color else None,
156
+ colormap=colormap,
157
+ clim=clim,
158
+ palette=palette,
159
+ opacity=opacity,
160
+ )
161
+ symbol_is_column = symbol is not None and symbol in names
162
+ symbol_enc = styling.symbols(
163
+ pick(col(symbol)) if symbol_is_column else None,
164
+ solid=symbol if (symbol is not None and not symbol_is_column) else "circle",
165
+ title=symbol or "",
166
+ declared=tables.categories(table, symbol) if symbol_is_column and symbol else None,
167
+ mapping=symbols,
168
+ )
169
+ if isinstance(size, str):
170
+ size_enc = styling.sizes(
171
+ _numbers(pick(col(size)), size), # ty: ignore[invalid-argument-type]
172
+ solid=styling.DEFAULT_SIZE,
173
+ title=size,
174
+ size_range=size_range,
175
+ )
176
+ else:
177
+ size_enc = styling.sizes(
178
+ None, solid=float(size) if size is not None else styling.DEFAULT_SIZE, title=""
179
+ )
180
+
181
+ text_values = _texts(table, text, rows) if text is not None else None
182
+ tip_cols = list(tooltip) if tooltip is not None else []
183
+ tables.check(table, tip_cols)
184
+ tips = {c: tables.column(table, c)[rows] for c in tip_cols}
185
+ groups = None
186
+ if route_ is not None:
187
+ groups = pick(col(route_.group))
188
+ imp = pick(col(importance))
189
+ return EventsRecord(
190
+ name=name,
191
+ rows=rows,
192
+ times=t,
193
+ order=order_values[rows] if order_values is not None else None,
194
+ lanes=lane_values,
195
+ lane_title=lane or "",
196
+ lane_declared=tables.categories(table, lane) if lane else None,
197
+ color=color_enc,
198
+ symbol=symbol_enc,
199
+ size=size_enc,
200
+ text=text_values,
201
+ tooltip=tips,
202
+ route=route_,
203
+ groups=groups,
204
+ overlap=overlap,
205
+ importance=_numbers(imp, importance or "") if imp is not None else None,
206
+ hover=hover,
207
+ select=select,
208
+ dropped=int(n - len(rows)),
209
+ )
210
+
211
+
212
+ def _numbers(values: np.ndarray, name: str) -> np.ndarray:
213
+ if values.dtype.kind in "iufb":
214
+ return values.astype(np.float64)
215
+ if values.dtype.kind == "O":
216
+ try:
217
+ return np.array([np.nan if v is None else float(v) for v in values])
218
+ except (TypeError, ValueError):
219
+ pass
220
+ raise TypeError(f"column {name!r} must hold numbers, got {values.dtype}")
221
+
222
+
223
+ def _texts(table: Any, template: str, rows: np.ndarray) -> list[str]:
224
+ """A column name, or a template such as ``"{event} ({user})"``, per kept row."""
225
+ names = tables.column_names(table)
226
+ if template in names:
227
+ return [
228
+ styling.label(v) if v is not None else "" for v in tables.column(table, template)[rows]
229
+ ]
230
+ used = [n for n in names if "{" + n in template]
231
+ if not used:
232
+ raise tables.NotFoundError(
233
+ f"text={template!r} names no column; use a column name or a template such as "
234
+ f"'{{{names[0] if names else 'column'}}}'; columns: {names}"
235
+ )
236
+ cols = {n: tables.column(table, n)[rows] for n in used}
237
+ out = []
238
+ for i in range(len(rows)):
239
+ values = {n: ("–" if cols[n][i] is None else cols[n][i]) for n in used}
240
+ out.append(template.format_map(values))
241
+ return out
242
+
243
+
244
+ @dataclass
245
+ class Layout:
246
+ """An events layer laid out on the plot's time base and lanes: its spec and buffers."""
247
+
248
+ spec: dict[str, Any]
249
+ buffers: list[Encoded]
250
+ lo: np.ndarray # sorted: the events' ticks (or order, without times)
251
+ hi: np.ndarray # the same (an event has no duration)
252
+ lanes: np.ndarray
253
+ instants: list[tuple[np.ndarray, np.ndarray]] # (ticks, tie-break) for the order
254
+
255
+
256
+ def layout(rec: EventsRecord, base: TimeBase | None, lane_index: Mapping[Any, int]) -> Layout:
257
+ """Sort the events by lane, time and order, and encode their buffers."""
258
+ n = len(rec.rows)
259
+ ticks = base.ticks(rec.times) if (base is not None and rec.times is not None) else None
260
+ order = rec.order if rec.order is not None else np.zeros(n)
261
+ lane = np.array([lane_index[v] for v in rec.lanes.tolist()], dtype=np.uint32)
262
+ primary = ticks if ticks is not None else order
263
+ perm = np.lexsort((np.arange(n), order, primary, lane))
264
+ rec.perm = perm
265
+ lane_s, primary_s, order_s = lane[perm], primary[perm], order[perm]
266
+ buffers: list[Encoded] = []
267
+
268
+ def put(enc: Encoded) -> dict[str, Any]:
269
+ buffers.append(enc)
270
+ return enc.descriptor
271
+
272
+ n_lanes = max(lane_index.values(), default=-1) + 1
273
+ starts = np.searchsorted(lane_s, np.arange(n_lanes + 1), side="left").astype(np.uint32)
274
+ spec: dict[str, Any] = {
275
+ "type": "events",
276
+ "name": rec.name,
277
+ "count": n,
278
+ "time": put(encode(primary_s, "float64")),
279
+ "lane": put(encode(lane_s, "uint32")),
280
+ "lane_starts": put(encode(starts, "uint32")),
281
+ "overlap": rec.overlap,
282
+ "hover": rec.hover,
283
+ "select": rec.select,
284
+ "dropped": rec.dropped,
285
+ "has_time": ticks is not None,
286
+ }
287
+ c = rec.color
288
+ spec["color"] = {
289
+ "rgba": put(encode(c.values[perm], "uint8", components=4))
290
+ if c.values is not None
291
+ else None,
292
+ "solid": c.value,
293
+ "alpha": c.extra.get("alpha", 255),
294
+ "legend": c.legend,
295
+ }
296
+ s = rec.symbol
297
+ spec["symbol"] = {
298
+ "values": put(encode(s.values[perm], "uint8")) if s.values is not None else None,
299
+ "value": s.value,
300
+ "names": list(styling.SYMBOLS),
301
+ "legend": s.legend,
302
+ }
303
+ z = rec.size
304
+ spec["size"] = {
305
+ "values": put(encode(z.values[perm], "float32")) if z.values is not None else None,
306
+ "value": z.value,
307
+ "legend": z.legend,
308
+ }
309
+ spec["text"] = put(encode_strings([rec.text[i] for i in perm])) if rec.text else None
310
+ spec["importance"] = (
311
+ put(encode(rec.importance[perm], "float32")) if rec.importance is not None else None
312
+ )
313
+ spec["tooltip"] = list(rec.tooltip)
314
+ spec["route"] = None
315
+ if rec.route is not None and rec.groups is not None:
316
+ segments = _segments(rec.groups[perm], primary_s, order_s)
317
+ spec["route"] = {
318
+ "segments": put(encode(segments, "uint32", components=2)),
319
+ "count": len(segments),
320
+ "color": rec.route.color,
321
+ "width": rec.route.width,
322
+ }
323
+ return Layout(spec, buffers, primary_s, primary_s, lane_s, [(primary_s, order_s)])
324
+
325
+
326
+ def _segments(groups: np.ndarray, primary: np.ndarray, order: np.ndarray) -> np.ndarray:
327
+ """Pairs of event indices (sorted space) joining each group's events in time order."""
328
+ keep = ~tables.is_missing(groups)
329
+ idx = np.flatnonzero(keep)
330
+ if len(idx) < 2:
331
+ return np.zeros((0, 2), dtype=np.uint32)
332
+ _, codes = np.unique(groups[idx].astype(str), return_inverse=True)
333
+ seq = idx[np.lexsort((idx, order[idx], primary[idx], codes))]
334
+ code_s = codes[np.lexsort((idx, order[idx], primary[idx], codes))]
335
+ same = code_s[1:] == code_s[:-1]
336
+ return np.stack([seq[:-1][same], seq[1:][same]], axis=1).astype(np.uint32)