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

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 Optional 

8 

9import numpy as np 

10 

11from .._scipp import core as sc 

12from ..typing import VariableLike 

13from . import colors 

14from .resources import load_style 

15 

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

17# bar with the readthedocs theme. 

18_svg_width = 40 

19 

20_cubes_in_full_width = 24 

21 

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) 

29 

30 

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 

39 

40 

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 ) 

47 

48 

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 ) 

55 

56 

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

68 

69 def _dims(self): 

70 dims = self._variable.dims 

71 return dims 

72 

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 ) 

96 

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 

102 

103 def _variance_offset(self): 

104 shape = self._extents() 

105 depth = shape[-3] + 1 

106 return 0.3 * depth 

107 

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 

121 

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) 

133 

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

139 

140 width += shape[-1] 

141 height += shape[-2] 

142 depth = shape[-3] 

143 

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] 

155 

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

163 

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 

179 

180 def _draw_labels(self, offset): 

181 view_height = self.size()[1] 

182 svg = '' 

183 dx = offset[0] 

184 dy = offset[1] 

185 

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

193 

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

201 

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

216 

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 

224 

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

249 

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

255 

256 return svg 

257 

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 

274 

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

284 

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 ) 

308 

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 ) 

319 

320 

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 

327 

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 

340 

341 

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

349 

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 

355 

356 

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 

367 

368 

369class DatasetDrawer: 

370 def __init__(self, dataset): 

371 self._dataset = dataset 

372 

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 

384 

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

394 

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) 

438 

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) 

462 

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

468 

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 

480 

481 top = 0 

482 left = 0 

483 

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) 

490 

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

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

493 width += w 

494 

495 content += '<g transform="translate({},{})">{}</g>'.format( 

496 -w_y, height - h_y, c_y 

497 ) 

498 

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) 

503 

504 content += '<g transform="translate(0,{})">{}</g>'.format(height, c_x) 

505 height += max(h_x, h_0d) 

506 

507 if content_only: 

508 return content 

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

510 

511 

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) 

521 

522 

523def show(container: VariableLike): 

524 """ 

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

526 

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 

532 

533 display(HTML(make_svg(container)))