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