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

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 

5 

6from html import escape 

7from typing import Any 

8 

9import numpy as np 

10 

11from ..core import DataArray, Dataset, Variable 

12from . import colors 

13from .resources import load_style 

14 

15# Unit is `em`. This particular value is chosen to avoid a horizontal scroll 

16# bar with the readthedocs theme. 

17_svg_width = 40 

18 

19_cubes_in_full_width = 24 

20 

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) 

28 

29 

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 

38 

39 

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 ) 

46 

47 

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 ) 

56 

57 

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") 

73 

74 def _dims(self) -> tuple[str, ...]: 

75 return self._variable.dims 

76 

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 ) 

102 

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 

108 

109 def _variance_offset(self) -> float: 

110 shape = self._extents() 

111 depth = shape[-3] + 1 

112 return 0.3 * depth 

113 

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 

127 

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)) 

137 

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() 

143 

144 width += shape[-1] 

145 height += shape[-2] 

146 depth = shape[-3] 

147 

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 

159 

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 = '' 

171 

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 

187 

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] 

193 

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>' 

201 

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>' 

209 

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>' 

224 

225 raise ValueError(f"Invalid axis: {axis}") 

226 

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 

234 

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>' 

259 

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>' 

265 

266 return svg 

267 

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 

284 

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)) 

299 

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 ) 

320 

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 ) 

331 

332 

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 

341 

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 

361 

362 

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>' 

377 

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 

383 

384 

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 

397 

398 

399class DatasetDrawer: 

400 def __init__(self, dataset: DataArray | Dataset) -> None: 

401 self._dataset = dataset 

402 

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 

414 

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?) 

424 

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) 

468 

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) 

494 

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()] 

505 

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 

515 

516 top = 0.0 

517 left = 0.0 

518 

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) 

525 

526 c, w, h = draw_area(area_z, 'x') 

527 content += f'<g transform="translate({width},{height - h})">{c}</g>' 

528 width += w 

529 

530 content += f'<g transform="translate({-w_y},{height - h_y})">{c_y}</g>' 

531 

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) 

536 

537 content += f'<g transform="translate(0,{height})">{c_x}</g>' 

538 height += max(h_x, h_0d) 

539 

540 if content_only: 

541 return content 

542 return _build_svg(content, left, top, max(_cubes_in_full_width, width), height) 

543 

544 

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) 

556 

557 

558def show(container: Variable | DataArray | Dataset) -> None: 

559 """ 

560 Show a graphical representation of a variable, data array, or dataset. 

561 

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 

567 

568 display(HTML(make_svg(container))) # type: ignore[no-untyped-call]