Coverage for install/scipp/visualization/show.py: 68%
297 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-17 01:51 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-17 01:51 +0000
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
3# @author Simon Heybrock
4from __future__ import annotations
6from html import escape
7from typing import Any
9import numpy as np
11from ..core import DataArray, Dataset, Variable
12from . import colors
13from .resources import load_style
15# Unit is `em`. This particular value is chosen to avoid a horizontal scroll
16# bar with the readthedocs theme.
17_svg_width = 40
19_cubes_in_full_width = 24
21# We are effectively rescaling our svg by setting an explicit viewport size.
22# Here we compute relative font sizes, based on a cube width of "1" (px).
23_svg_em = _cubes_in_full_width / _svg_width
24_large_font = round(1.6 * _svg_em, 2)
25_normal_font = round(_svg_em, 2)
26_small_font = round(0.8 * _svg_em, 2)
27_smaller_font = round(0.6 * _svg_em, 2)
30def _color_variants(hex_color: str) -> tuple[str, str, str]:
31 """
32 Produce darker and lighter color variants, given an input color.
33 """
34 rgb = colors.hex_to_rgb(hex_color)
35 dark = colors.rgb_to_hex(np.clip(rgb - 40, 0, 255))
36 light = colors.rgb_to_hex(np.clip(rgb + 30, 0, 255))
37 return light, hex_color, dark
40def _truncate_long_string(long_string: str, max_title_length: int) -> str:
41 return (
42 (long_string[:max_title_length] + '..')
43 if len(long_string) > max_title_length
44 else long_string
45 )
48def _build_svg(
49 content: Any, left: float, top: float, width: float, height: float
50) -> str:
51 return (
52 f'<svg width={_svg_width}em viewBox="{left} {top} {width} {height}"'
53 ' class="sc-root">'
54 f'<defs>{load_style()}</defs>{content}</svg>'
55 )
58class VariableDrawer:
59 def __init__(
60 self,
61 variable: Variable,
62 margin: float = 1.0,
63 target_dims: tuple[str, ...] | None = None,
64 show_alignment: bool = False,
65 ) -> None:
66 self._margin = margin
67 self._variable = variable
68 self._target_dims = target_dims or self._dims()
69 self._show_alignment = show_alignment
70 self._x_stride = 1
71 if len(self._dims()) > 3:
72 raise RuntimeError(f"Cannot visualize {len(self._dims())}-D data")
74 def _dims(self) -> tuple[str, ...]:
75 return self._variable.dims
77 def _draw_box(
78 self, origin_x: float, origin_y: float, color: str, xlen: int = 1
79 ) -> str:
80 return (
81 " ".join(
82 [
83 '<rect',
84 'style="fill:{};fill-opacity:1;stroke:#000;stroke-width:0.05"',
85 'id="rect"',
86 'width="xlen" height="1" x="origin_x" y="origin_y"/>',
87 '<path',
88 'style="fill:{};stroke:#000;stroke-width:0.05;stroke-linejoin:round"',
89 'd="m origin_x origin_y l 0.3 -0.3 h xlen l -0.3 0.3 z"',
90 'id="path1" />',
91 '<path',
92 'style="fill:{};stroke:#000;stroke-width:0.05;stroke-linejoin:round"',
93 'd="m origin_x origin_y m xlen 0 l 0.3 -0.3 v 1 l -0.3 0.3 z"',
94 'id="path2" />',
95 ]
96 )
97 .format(*_color_variants(color))
98 .replace("origin_x", str(origin_x))
99 .replace("origin_y", str(origin_y))
100 .replace("xlen", str(xlen))
101 )
103 def _draw_dots(self, x0: float, y0: float) -> str:
104 dots = ""
105 for x, y in 0.1 + 0.8 * np.random.rand(7, 2) + np.array([x0, y0]):
106 dots += f'<circle cx="{x}" cy="{y}" r="0.07"/>'
107 return dots
109 def _variance_offset(self) -> float:
110 shape = self._extents()
111 depth = shape[-3] + 1
112 return 0.3 * depth
114 def _extents(self) -> list[int]:
115 """Compute 3D extent, remapping dimension order to target dim order"""
116 shape = self._variable.shape
117 dims = self._dims()
118 d = dict(zip(dims, shape, strict=True))
119 e = []
120 max_extent = _cubes_in_full_width // 2
121 for dim in self._target_dims:
122 if dim in d:
123 e.append(min(d[dim], max_extent))
124 else:
125 e.append(1)
126 return [1] * (3 - len(e)) + e
128 def _events_height(self) -> float:
129 if self._variable.bins is None:
130 return 0
131 events = self._variable.bins.constituents['data']
132 # Rough estimate of vertical space taken by depiction of events buffer
133 if isinstance(events, Variable):
134 return 1
135 else:
136 return 1 + 1.3 * (len(events.deprecated_meta) + len(events.masks))
138 def size(self) -> tuple[float, float]:
139 """Return the size (width and height) of the rendered output"""
140 width = 2 * self._margin
141 height = 3 * self._margin # double margin on top for title space
142 shape = self._extents()
144 width += shape[-1]
145 height += shape[-2]
146 depth = shape[-3]
148 extra_item_count = 0
149 if self._variable.variances is not None:
150 extra_item_count += 1
151 if self._variable.values is None:
152 # No data
153 extra_item_count -= 1
154 depth += extra_item_count * (depth + 1)
155 width += 0.3 * depth
156 height += 0.3 * depth
157 height = max(height, self._events_height())
158 return width, height
160 def _draw_array(
161 self,
162 color: str,
163 offset: tuple[float, float] | None = None,
164 events: bool = False,
165 ) -> str:
166 """Draw the array of boxes"""
167 offset = offset or (0, 0)
168 dx = offset[0]
169 dy = offset[1] + 0.3 # extra offset for top face of top row of cubes
170 svg = ''
172 lz, ly, lx = self._extents()
173 for z in range(lz):
174 for y in reversed(range(ly)):
175 true_lx = lx
176 x_scale = 1
177 for x in range(true_lx):
178 # Do not draw hidden boxes
179 if z != lz - 1 and y != 0 and x != lx - 1:
180 continue
181 origin_x = dx + x * x_scale + self._margin + 0.3 * (lz - z - 1)
182 origin_y = dy + y + 2 * self._margin + 0.3 * z
183 svg += self._draw_box(origin_x, origin_y, color, x_scale)
184 if events:
185 svg += self._draw_dots(origin_x, origin_y)
186 return svg
188 def _draw_labels(self, offset: tuple[float, float]) -> str:
189 view_height = self.size()[1]
190 svg = ''
191 dx = offset[0]
192 dy = offset[1]
194 def make_label(dim: str, extent: float, axis: int) -> str:
195 if axis == 2:
196 x_pos = dx + self._margin + 0.5 * extent
197 y_pos = dy + view_height - self._margin + _smaller_font
198 return f'<text x="{x_pos}" y="{y_pos}"\
199 class="sc-label" \
200 style="font-size:#smaller-font">{escape(dim)}</text>'
202 if axis == 1:
203 x_pos = dx + self._margin - 0.3 * _smaller_font
204 y_pos = dy + view_height - self._margin - 0.5 * extent
205 return f'<text x="{x_pos}" y="{y_pos}" \
206 class="sc-label" style="font-size:#smaller-font" \
207 transform="rotate(-90, {x_pos}, {y_pos})">\
208 {escape(dim)}</text>'
210 if axis == 0:
211 x_pos = dx + self._margin + 0.3 * 0.5 * extent - 0.2 * _smaller_font
212 y_pos = (
213 dy
214 + view_height
215 - self._margin
216 - self._extents()[-2]
217 - 0.3 * 0.5 * extent
218 - 0.2 * _smaller_font
219 )
220 return f'<text x="{x_pos}" y="{y_pos}" \
221 class="sc-label" style="font-size:#smaller-font" \
222 transform="rotate(-45, {x_pos}, {y_pos})">\
223 {escape(dim)}</text>'
225 raise ValueError(f"Invalid axis: {axis}")
227 extents = self._extents()
228 for dim in self._variable.dims:
229 i = self._target_dims.index(dim) + (3 - len(self._target_dims))
230 # 1 is a dummy extent so events dim label is drawn at correct pos
231 extent = max(extents[i], 1)
232 svg += make_label(dim, extent, i)
233 return svg
235 def _draw_info(self, offset: tuple[float, float], title: str | None) -> str:
236 try:
237 unit = str(self._variable.unit)
238 except Exception:
239 unit = '(undefined)'
240 details = 'dims={}, shape={}, unit={}, variances={}'.format(
241 self._variable.dims,
242 self._variable.shape,
243 unit,
244 self._variable.variances is not None,
245 )
246 x_pos = offset[0]
247 y_pos = offset[1] + 0.6
248 weight = (
249 "font-weight: bold"
250 if self._show_alignment and self._variable.aligned
251 else ""
252 )
253 if title is not None:
254 # Crudely avoid label overlap, ignoring actual character width
255 brief_title = _truncate_long_string(str(title), int(2.5 * self.size()[0]))
256 svg = f'<text x="{x_pos}" y="{y_pos}" \
257 class="sc-name" style="font-size:#normal-font;{weight}"> \
258 {escape(brief_title)}</text>'
260 svg += f'<title>{title}({escape(details)})</title>'
261 else:
262 svg = f'<text x="{x_pos}" y="{y_pos}" \
263 class="sc-name" style="font-size:#small-font;{weight}"> \
264 {escape(details)}</text>'
266 return svg
268 def _draw_bins_buffer(self) -> str:
269 if self._variable.bins is None:
270 return ''
271 svg = ''
272 x0 = self._margin + self._extents()[-1]
273 y0 = 2 * self._margin + 0.3 * self._extents()[-3]
274 height = self._events_height()
275 style = 'class="sc-inset-line"'
276 svg += f'<line x1={x0} y1={y0+0} x2={x0+2} y2={y0-1} {style}/>'
277 svg += f'<line x1={x0} y1={y0+1} x2={x0+2} y2={y0+height} {style}/>'
278 svg += '<g transform="translate({},{}) scale(0.5)">{}</g>'.format(
279 self.size()[0] + 1,
280 0,
281 make_svg(self._variable.bins.constituents['data'], content_only=True),
282 )
283 return svg
285 def draw(
286 self,
287 color: str,
288 offset: tuple[float, float] | None = None,
289 title: str | None = None,
290 ) -> str:
291 offset = (0, 0) if offset is None else offset
292 svg = '<g>'
293 svg += self._draw_info(offset, title)
294 items = []
295 if self._variable.variances is not None:
296 items.append(('variances', color))
297 if self._variable.values is not None:
298 items.append(('values', color))
300 for i, (name, color) in enumerate(items):
301 svg += '<g>'
302 svg += f'<title>{name}</title>'
303 svg += self._draw_array(
304 color=color,
305 offset=(
306 offset[0] + (len(items) - i - 1) * self._variance_offset(),
307 offset[1] + i * self._variance_offset(),
308 ),
309 events=self._variable.bins is not None,
310 )
311 svg += '</g>'
312 svg += self._draw_labels(offset=offset)
313 svg += '</g>'
314 svg += self._draw_bins_buffer()
315 return (
316 svg.replace('#normal-font', f'{_normal_font}px')
317 .replace('#small-font', f'{_small_font}px')
318 .replace('#smaller-font', f'{_smaller_font}px')
319 )
321 def make_svg(self, content_only: bool = False) -> str:
322 if content_only:
323 return self.draw(color=colors.STYLE['data'])
324 return _build_svg(
325 self.make_svg(content_only=True),
326 0,
327 0,
328 max(_cubes_in_full_width, self.size()[0]),
329 self.size()[1],
330 )
333class DrawerItem:
334 def __init__(
335 self, name: str, data: Variable, color: str, show_alignment: bool = False
336 ) -> None:
337 self._name = name
338 self._data = data
339 self._color = color
340 self._show_alignment = show_alignment
342 def append_to_svg(
343 self,
344 content: str,
345 width: float,
346 height: float,
347 offset: tuple[float, float],
348 layout_direction: str,
349 margin: float,
350 dims: tuple[str, ...],
351 ) -> tuple[str, float, float, tuple[float, float]]:
352 drawer = VariableDrawer(
353 self._data, margin, target_dims=dims, show_alignment=self._show_alignment
354 )
355 content += drawer.draw(color=self._color, offset=offset, title=self._name)
356 size = drawer.size()
357 width, height, offset = _new_size_and_offset(
358 size, width, height, layout_direction
359 )
360 return content, width, height, offset
363class EllipsisItem:
364 @staticmethod
365 def append_to_svg(
366 content: str,
367 width: float,
368 height: float,
369 offset: tuple[float, float],
370 layout_direction: str,
371 *unused: object,
372 ) -> tuple[str, float, float, tuple[float, float]]:
373 x_pos = offset[0] + 0.3
374 y_pos = offset[1] + 2.0
375 content += f'<text x="{x_pos}" y="{y_pos}" class="sc-label" \
376 style="font-size:{_large_font}px"> ... </text>'
378 ellipsis_size = (1.5, 2.0)
379 width, height, offset = _new_size_and_offset(
380 ellipsis_size, width, height, layout_direction
381 )
382 return content, width, height, offset
385def _new_size_and_offset(
386 added_size: tuple[float, float], width: float, height: float, layout_direction: str
387) -> tuple[float, float, tuple[float, float]]:
388 if layout_direction == 'x':
389 width += added_size[0]
390 height = max(height, added_size[1])
391 offset = (width, 0.0)
392 else:
393 width = max(width, added_size[0])
394 height += added_size[1]
395 offset = (0.0, height)
396 return width, height, offset
399class DatasetDrawer:
400 def __init__(self, dataset: DataArray | Dataset) -> None:
401 self._dataset = dataset
403 def _dims(self) -> tuple[str, ...]:
404 dims = self._dataset.dims
405 if isinstance(self._dataset, DataArray):
406 # Handle, e.g., bin edges of a slice, where data lacks the edge dim
407 for item in self._dataset.deprecated_meta.values():
408 for dim in item.dims:
409 if dim not in dims:
410 dims = (dim, *dims)
411 if len(dims) > 3:
412 raise RuntimeError(f"Cannot visualize {len(dims)}-D data")
413 return dims
415 def make_svg(self, content_only: bool = False) -> str:
416 content = ''
417 width = 0.0
418 height = 0.0
419 margin = 0.5
420 dims = self._dims()
421 # TODO bin edges (offset by 0.5)
422 # TODO limit number of drawn cubes if shape exceeds certain limit
423 # (draw just a single cube with correct edge proportions?)
425 # We are drawing in several areas:
426 #
427 # (y-coords) | (data > 1d) | (z-coords)
428 # -------------------------------------
429 # (0d) | (x-coords) |
430 #
431 # X and Y coords are thus aligning optically with the data, and are
432 # where normal axes are expected. Data that depends only on X or only
433 # on Y is also drawn in the respective areas, this makes it clear
434 # that the respective other coords do not apply: It avoids
435 # intersection with imaginary grid lines drawn from the X coord up or
436 # from the Y coord towards the right.
437 # For the same reason, 0d variables are drawn in the bottom left, not
438 # intersecting any of the imaginary grid lines.
439 # If there is more than one data item in the center area they are
440 # stacked. Unfortunately this breaks the optical alignment with the Y
441 # coordinates, but in a static picture there is probably no other way.
442 area_x: list[DrawerItem | EllipsisItem] = []
443 area_y: list[DrawerItem | EllipsisItem] = []
444 area_z: list[DrawerItem | EllipsisItem] = []
445 area_xy: list[DrawerItem | EllipsisItem] = []
446 area_0d: list[DrawerItem | EllipsisItem] = []
447 if isinstance(self._dataset, DataArray):
448 area_xy.append(DrawerItem('', self._dataset.data, colors.STYLE['data']))
449 else:
450 # Render highest-dimension items last so coords are optically
451 # aligned
452 for name, data in sorted(self._dataset.items()):
453 item = DrawerItem(name, data.data, colors.STYLE['data'])
454 # Using only x and 0d areas for 1-D dataset
455 if len(dims) == 1 or data.dims != dims:
456 if len(data.dims) == 0:
457 area_0d.append(item)
458 elif len(data.dims) != 1:
459 area_xy[-1:-1] = [item]
460 elif data.dims[0] == dims[-1]:
461 area_x.append(item)
462 elif data.dims[0] == dims[-2]:
463 area_y.append(item)
464 else:
465 area_z.append(item)
466 else:
467 area_xy.append(item)
469 ds = self._dataset
470 if isinstance(ds, DataArray):
471 categories = zip(
472 ['coords', 'masks', 'attrs'],
473 [ds.coords, ds.masks, ds.deprecated_attrs],
474 strict=True,
475 )
476 else:
477 categories = zip(['coords'], [ds.coords], strict=True)
478 for what, items in categories:
479 for name, var in sorted(items.items()):
480 item = DrawerItem(
481 name,
482 var,
483 colors.STYLE[what],
484 show_alignment=what == 'coords',
485 )
486 if len(var.dims) == 0:
487 area_0d.append(item)
488 elif var.dims[-1] == dims[-1]:
489 area_x.append(item)
490 elif var.dims[-1] == dims[-2]:
491 area_y.append(item)
492 else:
493 area_z.append(item)
495 def draw_area(
496 area: list[DrawerItem | EllipsisItem],
497 layout_direction: str,
498 reverse: bool = False,
499 truncate: bool = False,
500 ) -> tuple[str, float, float]:
501 number_of_items = len(area)
502 min_items_before_worth_truncate = 5
503 if truncate and number_of_items > min_items_before_worth_truncate:
504 area[1:-1] = [EllipsisItem()]
506 content = ''
507 width = 0.0
508 height = 0.0
509 offset = (0.0, 0.0)
510 for item in reversed(area) if reverse else area:
511 content, width, height, offset = item.append_to_svg(
512 content, width, height, offset, layout_direction, margin, dims
513 )
514 return content, width, height
516 top = 0.0
517 left = 0.0
519 c, w, h = draw_area(area_xy, 'y')
520 content += f'<g transform="translate(0,{height})">{c}</g>'
521 c_x, w_x, h_x = draw_area(area_x, 'y')
522 c_y, w_y, h_y = draw_area(area_y, 'x', reverse=True)
523 height += max(h, h_y)
524 width += max(w, w_x)
526 c, w, h = draw_area(area_z, 'x')
527 content += f'<g transform="translate({width},{height - h})">{c}</g>'
528 width += w
530 content += f'<g transform="translate({-w_y},{height - h_y})">{c_y}</g>'
532 c, w_0d, h_0d = draw_area(area_0d, 'x', reverse=True, truncate=True)
533 content += f'<g transform="translate({-w_0d},{height})">{c}</g>'
534 width += max(w_y, w_0d)
535 left -= max(w_y, w_0d)
537 content += f'<g transform="translate(0,{height})">{c_x}</g>'
538 height += max(h_x, h_0d)
540 if content_only:
541 return content
542 return _build_svg(content, left, top, max(_cubes_in_full_width, width), height)
545def make_svg(
546 container: Variable | DataArray | Dataset, content_only: bool = False
547) -> str:
548 """
549 Return an SVG representation of a variable, data array, or dataset.
550 """
551 if isinstance(container, Variable):
552 draw: VariableDrawer | DatasetDrawer = VariableDrawer(container)
553 else:
554 draw = DatasetDrawer(container)
555 return draw.make_svg(content_only=content_only)
558def show(container: Variable | DataArray | Dataset) -> None:
559 """
560 Show a graphical representation of a variable, data array, or dataset.
562 See 'SVG representation' in
563 `Representations and Tables <../../user-guide/representations-and-tables.rst>`_
564 for details.
565 """
566 from IPython.core.display import HTML, display
568 display(HTML(make_svg(container))) # type: ignore[no-untyped-call]