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.
- anytimeline/__init__.py +12 -0
- anytimeline/codec.py +98 -0
- anytimeline/data.py +120 -0
- anytimeline/events.py +336 -0
- anytimeline/grouping.py +258 -0
- anytimeline/intervals.py +427 -0
- anytimeline/lanes.py +67 -0
- anytimeline/otel.py +200 -0
- anytimeline/presets.py +79 -0
- anytimeline/states.py +169 -0
- anytimeline/static/standalone.js +407 -0
- anytimeline/static/widget.css +1 -0
- anytimeline/static/widget.js +407 -0
- anytimeline/styling.py +306 -0
- anytimeline/synthetic.py +397 -0
- anytimeline/timebase.py +243 -0
- anytimeline/tree.py +171 -0
- anytimeline/values.py +240 -0
- anytimeline/widget.py +1249 -0
- anytimeline-0.1.0.dist-info/METADATA +144 -0
- anytimeline-0.1.0.dist-info/RECORD +23 -0
- anytimeline-0.1.0.dist-info/WHEEL +4 -0
- anytimeline-0.1.0.dist-info/licenses/LICENSE +21 -0
anytimeline/__init__.py
ADDED
|
@@ -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)
|