ipyvasp 0.9.91__py2.py3-none-any.whl → 0.9.94__py2.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.
ipyvasp/widgets.py CHANGED
@@ -4,37 +4,36 @@ __all__ = [
4
4
  "Files",
5
5
  "PropsPicker",
6
6
  "BandsWidget",
7
- "KpathWidget",
7
+ "KPathWidget",
8
8
  ]
9
9
 
10
10
 
11
11
  import inspect, re
12
- from time import time
13
12
  from pathlib import Path
14
13
  from collections.abc import Iterable
15
14
  from functools import partial
16
- from pprint import pformat
17
15
 
18
16
  # Widgets Imports
19
17
  from IPython.display import display
20
- import ipywidgets as ipw
21
18
  from ipywidgets import (
22
19
  Layout,
23
20
  Button,
24
- Box,
25
21
  HBox,
26
22
  VBox,
27
23
  Dropdown,
28
24
  Text,
29
- Checkbox,
30
25
  Stack,
31
26
  SelectMultiple,
27
+ TagsInput,
32
28
  )
33
29
 
34
30
  # More imports
35
31
  import numpy as np
36
32
  import pandas as pd
33
+ import ipywidgets as ipw
34
+ import traitlets
37
35
  import plotly.graph_objects as go
36
+ import einteract as ei
38
37
 
39
38
  # Internal imports
40
39
  from . import utils as gu
@@ -184,9 +183,9 @@ class Files:
184
183
  "Apply a func(path) -> dict and create a dataframe."
185
184
  return summarize(self._files,func, **kwargs)
186
185
 
187
- def load_results(self):
188
- "Load result.json files from these paths into a dataframe."
189
- return load_results(self._files)
186
+ def load_results(self,exclude_keys=None):
187
+ "Load result.json files from these paths into a dataframe, with optionally excluding keys."
188
+ return load_results(self._files,exclude_keys=exclude_keys)
190
189
 
191
190
  def input_info(self, *tags):
192
191
  "Grab input information into a dataframe from POSCAR and INCAR. Provide INCAR tags (case-insinsitive) to select only few of them."
@@ -202,7 +201,7 @@ class Files:
202
201
  lines = [(k,v) for k,v in lines if k in tags]
203
202
  d = {k:v for k,v in lines if not k.startswith('#')}
204
203
  d.update({k:len(v) for k,v in p.types.items()})
205
- d.update(zip('abcvαβγ', [*p.norms,p.volume,*p.angles]))
204
+ d.update(zip(['a','b','c','v','alpha','beta','gamma'], [*p.norms,p.volume,*p.angles]))
206
205
  return d
207
206
 
208
207
  return self.with_name('POSCAR').summarize(info, tags=tags)
@@ -219,6 +218,18 @@ class Files:
219
218
  if old in dd.options:
220
219
  dd.value = old
221
220
 
221
+ def to_dropdown(self,description='File'):
222
+ """
223
+ Convert this instance to Dropdown. If there is only one file, adds an
224
+ empty option to make that file switchable.
225
+ Options of this dropdown are update on calling `Files.update` method."""
226
+ if hasattr(self,'_dd'):
227
+ return self._dd # already created
228
+
229
+ options = self._files if len(self._files) != 1 else ['', *self._files] # make single file work
230
+ self._dd = Dropdown(description=description, options=options)
231
+ return self._dd
232
+
222
233
  def add(self, path_or_files, glob = '*', exclude=None, **kwargs):
223
234
  """Add more files or with a diffrent glob on top of exitsing files. Returns same instance.
224
235
  Useful to add multiple globbed files into a single chained call.
@@ -231,190 +242,39 @@ class Files:
231
242
  def _unique(self, *files_tuples):
232
243
  return tuple(np.unique(np.hstack(files_tuples)))
233
244
 
234
- def interactive(self, func, *args,
235
- free_widgets=None,
236
- options={"manual": False},
237
- height="400px",
238
- panel_size = 25,
239
- **kwargs):
240
- """
241
- Interact with `func(path, *args, <free_widgets,optional>, **kwargs)` that takes selected Path as first argument. Returns a widget that saves attributes of the function call such as .f, .args, .kwargs.
242
- `args` are widgets to be modified by func, such as plotly's FigureWidget.
243
- Note that `free_widgets` can also be passed to function but does not run function when changed.
244
-
245
- See docs of self.interact for more details on the parameters. kwargs are passed to ipywidgets.interactive to create controls.
246
-
247
- >>> import plotly.graph_objects as go
248
- >>> import ipyvasp as ipv
249
- >>> fs = ipv.Files('.','**/POSCAR')
250
- >>> def plot(path, fig, bl,plot_cell,eqv_sites):
251
- >>> ipv.iplot2widget(ipv.POSCAR(path).iplot_lattice(
252
- >>> bond_length=bl,plot_cell=plot_cell,eqv_sites=eqv_sites
253
- >>> ),fig_widget=fig) # it keeps updating same view
254
- >>> out = fs.interactive(plot, go.FigureWidget(),bl=(0,6,2),plot_cell=True, eqv_sites=True)
255
-
256
- >>> out.f # function
257
- >>> out.args # arguments
258
- >>> out.kwargs # keyword arguments
259
- >>> out.result # result of function call which is same as out.f(*out.args, **out.kwargs)
260
-
261
- .. note::
262
- If you don't need to interpret the result of the function call, you can use the @self.interact decorator instead.
263
- """
264
- info = ipw.HTML().add_class("fprogess")
265
- dd = Dropdown(description='File', options=['',*self._files]) # allows single file workable
266
-
267
- def interact_func(fname, **kws):
268
- if fname and str(fname) != '': # This would be None if no file is selected
269
- info.value = _progress_svg
270
- try:
271
- start = time()
272
- if 'free_widgets' in func.__code__.co_varnames:
273
- kws['free_widgets'] = free_widgets # user allowed to pass this
274
- names = ', '.join(func.__code__.co_varnames)
275
- print(f"Running {func.__name__}({names})")
276
- func(Path(fname).absolute(), *args, **kws) # Have Path object absolue if user changes directory
277
- print(f"Finished in {time() - start:.3f} seconds.")
278
- finally:
279
- info.value = ""
280
-
281
- out = ipw.interactive(interact_func, options, fname=dd, **kwargs)
282
- out._dd = dd # save reference to dropdown
283
- out.output_widget = out.children[-1] # save reference to output widget for other widgets to use
284
-
285
- if options.get("manual", False):
286
- out.interact_button = out.children[-2] # save reference to interact button for other widgets to use
287
-
288
- output = out.children[-1] # get output widget
289
- output.clear_output(wait=True) # clear output by waiting to avoid flickering, this is important
290
- output.layout = Layout(
291
- overflow="auto", max_height="100%", width="100%"
292
- ) # make output scrollable and avoid overflow
293
-
294
- others = out.children[1:-1] # exclude files_dd and Output widget
295
- if not isinstance(panel_size,int):
296
- raise TypeError('panel_size should be integer in units of em')
245
+ @_sub_doc(ei.interactive)
246
+ def interactive(self, *funcs, auto_update=True, app_layout=None, grid_css={},**kwargs):
247
+ if 'file' in kwargs:
248
+ raise KeyError("file is a reserved keyword argument to select path to file!")
297
249
 
298
- _style = f"""<style>
299
- .files-interact {{
300
- --jp-widgets-inline-label-width: 4em;
301
- --jp-widgets-inline-width: {panel_size-2}em;
302
- --jp-widgets-inline-width-short: 9em;
303
- }}
304
- .files-interact {{max-height:{height};width:100%;}}
305
- .files-interact > div {{overflow:auto;max-height:100%;padding:8px;}}
306
- .files-interact > div:first-child {{width:{panel_size}em}}
307
- .files-interact > div:last-child {{width:calc(100% - {panel_size}em)}}
308
- .files-interact .fprogess {{position:absolute !important; left:50%; top:50%; transform:translate(-50%,-50%); z-index:1}}
309
- </style>"""
310
- if others:
311
- others = [ipw.HTML(f"<hr/>{_style}"), *others]
312
- else:
313
- others = [ipw.HTML(_style)]
314
-
315
- if free_widgets and not isinstance(free_widgets, (list, tuple)):
316
- raise TypeError("free_widgets must be a list or tuple of widgets.")
250
+ has_file_param = False
251
+ for func in funcs:
252
+ if not callable(func):
253
+ raise TypeError(f"Each item in *funcs should be callable, got {type(func)}")
254
+ params = [k for k,v in inspect.signature(func).parameters.items()]
255
+ for key in params:
256
+ if key == 'file':
257
+ has_file_param = True
258
+ break
317
259
 
318
- for w in args:
319
- if not isinstance(w,ipw.DOMWidget):
320
- raise TypeError(f'args can only contain a DOMWidget instance, got {type(w)}')
260
+ if funcs and not has_file_param: # may be no func yet, that is test below
261
+ raise KeyError("At least one of funcs should take 'file' as parameter, none got it!")
321
262
 
322
- if args:
323
- output.layout.max_height = "200px"
324
- output.layout.min_height = "8em" # fisrt fix
325
- out_collapser = Checkbox(description="Hide output widget", value=False)
326
-
327
- def toggle_output(change):
328
- if out_collapser.value:
329
- output.layout.height = "0px" # dont use display = 'none' as it will clear widgets and wont show again
330
- output.layout.min_height = "0px"
331
- else:
332
- output.layout.height = "auto"
333
- output.layout.min_height = "8em"
334
-
335
- out_collapser.observe(toggle_output, "value")
336
- others.append(out_collapser)
337
-
338
- # This should be below output collapser
339
- others = [
340
- *others,
341
- ipw.HTML(f"<hr/>"),
342
- *(free_widgets or []),
343
- ] # add hr to separate other controls
344
-
345
- out.children = [
346
- HBox([ # reset children to include new widgets
347
- VBox([dd, VBox(others)]), # other widgets in box to make scrollable independent file selection
348
- VBox([Box([output]), *args, info]), # output in box to make scrollable,
349
- ],layout=Layout(height=height, max_height=height),
350
- ).add_class("files-interact")
351
- ] # important for every widget separately
352
- return out
263
+ return ei.interactive(*funcs,auto_update=auto_update, app_layout = app_layout, grid_css=grid_css, file = self.to_dropdown(), **kwargs)
353
264
 
354
- def _attributed_interactive(self, box, func, *args, **kwargs):
355
- box._files = self
356
- box._interact = self.interactive(func, *args, **kwargs)
357
- box.children = box._interact.children
358
- box._files._dd = box._interact._dd
359
-
360
- def interact(self, *args,
361
- free_widgets=None,
362
- options={"manual": False},
363
- height="400px",
364
- panel_size=25,
365
- **kwargs,
366
- ):
367
- """Interact with a `func(path, *args, <free_widgets,optional>, **kwargs)`. `path` is passed from selected File.
368
- A CSS class 'files-interact' is added to the final widget to let you style it.
369
-
370
- Parameters
371
- ----------
372
- args :
373
- Any displayable widget can be passed. These are placed below the output widget of interact.
374
- For example you can add plotly's FigureWidget that updates based on the selection, these are passed to function after path.
375
- free_widgets : list/tuple
376
- Default is None. If not None, these are assumed to be ipywidgets and are placed below the widgets created by kwargs.
377
- These can be passed to the decorated function if added as arguemnt there like `func(..., free_widgets)`, but don't trigger execution.
378
- options : dict
379
- Default is {'manual':False}. If True, the decorated function is not called automatically, and you have to call it manually on button press. You can pass button name as 'manual_name' in options.
380
- height : str
381
- Default is '90vh'. height of the final widget. This is important to avoid very long widgets.
382
- panel_size: int
383
- Side panel size in units of em.
384
-
385
- kwargs are passed to ipywidgets.interactive and decorated function. Resulting widgets are placed below the file selection widget.
386
- Widgets in `args` can be controlled by `free_widgets` externally if defined gloablly or inside function if you pass `free_widgets` as argument like `func(..., free_widgets)`.
387
-
388
- The decorated function can be called later separately as well, and has .args and .kwargs attributes to access the latest arguments
389
- and .result method to access latest. For a function `f`, `f.result` is same as `f(*f.args, **f.kwargs)`.
390
-
391
- >>> import plotly.graph_objects as go
392
- >>> import ipyvasp as ipv
393
- >>> fs = ipv.Files('.','**/POSCAR')
394
- >>> @fs.interact(go.FigureWidget(),bl=(0,6,2),plot_cell=True, eqv_sites=True)
395
- >>> def plot(path, fig, bl,plot_cell,eqv_sites):
396
- >>> ipv.iplot2widget(ipv.POSCAR(path).iplot_lattice(
397
- >>> bond_length=bl,plot_cell=plot_cell,eqv_sites=eqv_sites
398
- >>> ),fig_widget=fig) # it keeps updating same view
399
-
400
- .. note::
401
- Use self.interactive to get a widget that stores the argements and can be called later in a notebook cell.
402
- """
403
-
265
+ @_sub_doc(ei.interact)
266
+ def interact(self, *funcs, auto_update=True, app_layout=None, grid_css={},**kwargs):
404
267
  def inner(func):
405
- display(self.interactive(func, *args,
406
- free_widgets=free_widgets,
407
- options=options,
408
- height=height,
409
- panel_size=panel_size,
268
+ display(self.interactive(func, *funcs,
269
+ auto_update=auto_update, app_layout = app_layout, grid_css=grid_css,
410
270
  **kwargs)
411
271
  )
412
272
  return func
413
273
  return inner
414
274
 
415
275
  def kpath_widget(self, height='400px'):
416
- "Get KpathWidget instance with these files."
417
- return KpathWidget(files = self.with_name('POSCAR'), height = height)
276
+ "Get KPathWidget instance with these files."
277
+ return KPathWidget(files = self.with_name('POSCAR'), height = height)
418
278
 
419
279
  def bands_widget(self, height='450px'):
420
280
  "Get BandsWidget instance with these files."
@@ -471,84 +331,109 @@ class Files:
471
331
 
472
332
  @fix_signature
473
333
  class _PropPicker(VBox):
334
+ """Single projection picker with atoms and orbitals selection"""
335
+ props = traitlets.Dict({})
336
+
474
337
  def __init__(self, system_summary=None):
475
338
  super().__init__()
476
- self._widgets = {
477
- "atoms": Dropdown(description="Atoms"),
478
- "orbs": Dropdown(description="Orbs"),
479
- }
480
- self._html = ipw.HTML() # to observe
481
-
482
- def observe_change(change):
483
- self._html.value = change.new # is a string
484
-
485
- self._widgets["atoms"].observe(observe_change, "value")
486
- self._widgets["orbs"].observe(observe_change, "value")
487
-
488
- self._atoms = {}
489
- self._orbs = {}
339
+ self._atoms = TagsInput(description="Atoms", allowed_tags=[],
340
+ placeholder="Select atoms", allow_duplicates = False).add_class('props-tags')
341
+ self._orbs = TagsInput(description="Orbs", allowed_tags=[],
342
+ placeholder="Select orbitals", allow_duplicates = False).add_class('props-tags')
343
+ self.children = [self._atoms, self._orbs]
344
+ self.layout.width = '100%' # avoid horizontal collapse
345
+ self._atoms_map = {}
346
+ self._orbs_map = {}
347
+
348
+ # Link changes
349
+ self._atoms.observe(self._update_props, 'value')
350
+ self._orbs.observe(self._update_props, 'value')
490
351
  self._process(system_summary)
491
352
 
353
+ def _update_props(self, change):
354
+ """Update props trait when selections change"""
355
+ _atoms = [self._atoms_map.get(tag, None) for tag in self._atoms.value]
356
+ _orbs = [self._orbs_map.get(tag, None) for tag in self._orbs.value]
357
+
358
+ # Filter out None values, and flatten
359
+ # Flatten and filter atoms
360
+ atoms = []
361
+ for ats in _atoms:
362
+ atoms.extend(ats if ats is not None else [])
363
+
364
+ # Flatten and filter orbitals
365
+ orbs = []
366
+ for ors in _orbs:
367
+ orbs.extend(ors if ors is not None else [])
368
+
369
+ if atoms and orbs:
370
+ self.props = {
371
+ 'atoms': atoms, 'orbs': orbs,
372
+ 'label': f"{'+'.join(self._atoms.value)} | {'+'.join(self._orbs.value)}"
373
+ }
374
+ else:
375
+ self.props = {}
376
+
492
377
  def _process(self, system_summary):
493
- if not hasattr(system_summary, "orbs"):
494
- self.children = [
495
- ipw.HTML(f"❌ No projection data found from given summary!")
496
- ]
497
- return None
378
+ """Process system data and setup widget options"""
379
+ if system_summary is None or not hasattr(system_summary, "orbs"):
380
+ return
498
381
 
499
- self.children = [self._widgets["atoms"], self._widgets["orbs"]]
500
382
  sorbs = system_summary.orbs
383
+ self._orbs_map = {"All": range(len(sorbs)), "s": [0]}
501
384
 
502
- orbs = {"-": [], "All": range(len(sorbs)), "s": [0]}
385
+ # p-orbitals
503
386
  if set(["px", "py", "pz"]).issubset(sorbs):
504
- orbs["p"] = range(1, 4)
505
- orbs["px+py"] = [
506
- idx for idx, key in enumerate(sorbs) if key in ("px", "py")
507
- ]
508
- orbs.update({k: [v] for k, v in zip(sorbs[1:], range(1, 4))})
387
+ self._orbs_map.update({
388
+ "p": range(1, 4),
389
+ "px+py": [idx for idx, key in enumerate(sorbs) if key in ("px", "py")],
390
+ **{k: [v] for k, v in zip(sorbs[1:4], range(1, 4))}
391
+ })
392
+
393
+ # d-orbitals
509
394
  if set(["dxy", "dyz"]).issubset(sorbs):
510
- orbs["d"] = range(4, 9)
511
- orbs.update({k: [v] for k, v in zip(sorbs[4:], range(4, 9))})
395
+ self._orbs_map.update({
396
+ "d": range(4, 9),
397
+ **{k: [v] for k, v in zip(sorbs[4:9], range(4, 9))}
398
+ })
399
+
400
+ # f-orbitals
512
401
  if len(sorbs) == 16:
513
- orbs["f"] = range(9, 16)
514
- orbs.update({k: [v] for k, v in zip(sorbs[9:], range(9, 16))})
515
- if len(sorbs) > 16: # What the hell here
516
- orbs.update({k: [idx] for idx, k in enumerate(sorbs[16:], start=16)})
517
-
518
- self._orbs = orbs
519
- old_orb = self._widgets["orbs"].value
520
- self._widgets["orbs"].options = list(orbs.keys())
521
- if old_orb in self._widgets["orbs"].options:
522
- self._widgets["orbs"].value = old_orb
523
-
524
- atoms = {"-": [], "All": range(system_summary.NIONS)}
525
- for key, tp in system_summary.types.to_dict().items():
526
- atoms[key] = tp
527
- for n, v in enumerate(tp, start=1):
528
- atoms[f"{key}{n}"] = [v]
529
-
530
- self._atoms = atoms
531
- old_atom = self._widgets["atoms"].value
532
- self._widgets["atoms"].options = list(atoms.keys())
533
- if old_atom in self._widgets["atoms"].options:
534
- self._widgets["atoms"].value = old_atom
402
+ self._orbs_map.update({
403
+ "f": range(9, 16),
404
+ **{k: [v] for k, v in zip(sorbs[9:16], range(9, 16))}
405
+ })
406
+
407
+ # Extra orbitals beyond f
408
+ if len(sorbs) > 16:
409
+ self._orbs_map.update({
410
+ k: [idx] for idx, k in enumerate(sorbs[16:], start=16)
411
+ })
412
+
413
+ self._orbs.allowed_tags = list(self._orbs_map.keys())
414
+
415
+ # Process atoms
416
+ self._atoms_map = {
417
+ "All": range(system_summary.NIONS),
418
+ **{k: v for k,v in system_summary.types.to_dict().items()},
419
+ **{f"{k}{n}": [v] for k,tp in system_summary.types.to_dict().items()
420
+ for n,v in enumerate(tp, 1)}
421
+ }
422
+ self._atoms.allowed_tags = list(self._atoms_map.keys())
423
+ self._update_props(None) # Trigger props update
535
424
 
536
425
  def update(self, system_summary):
537
- return self._process(system_summary)
538
-
539
- @property
540
- def props(self):
541
- items = {k: w.value for k, w in self._widgets.items()}
542
- items["atoms"] = self._atoms.get(items["atoms"], [])
543
- items["orbs"] = self._orbs.get(items["orbs"], [])
544
- items[
545
- "label"
546
- ] = f"{self._widgets['atoms'].value or ''}-{self._widgets['orbs'].value or ''}"
547
- return items
548
-
426
+ """Update widget with new system data while preserving selections"""
427
+ old_atoms = self._atoms.value
428
+ old_orbs = self._orbs.value
429
+ self._process(system_summary)
430
+
431
+ # Restore previous selections if still valid
432
+ self._atoms.value = [tag for tag in old_atoms if tag in self._atoms.allowed_tags]
433
+ self._orbs.value = [tag for tag in old_orbs if tag in self._orbs.allowed_tags]
549
434
 
550
435
  @fix_signature
551
- class PropsPicker(VBox):
436
+ class PropsPicker(VBox): # NOTE: remove New Later
552
437
  """
553
438
  A widget to pick atoms and orbitals for plotting.
554
439
 
@@ -556,91 +441,87 @@ class PropsPicker(VBox):
556
441
  ----------
557
442
  system_summary : (Vasprun,Vaspout).summary
558
443
  N : int, default is 3, number of projections to pick.
559
- on_button_click : callable, takes button as arguemnet. Default is None, a function to call when button is clicked.
560
- on_selection_changed : callable, takes change as argument. Default is None, a function to call when selection is changed.
444
+
445
+ You can observe `projections` trait.
561
446
  """
562
-
563
- def __init__(
564
- self, system_summary=None, N=3, on_button_click=None, on_selection_changed=None
565
- ):
447
+ projections = traitlets.Dict({})
448
+
449
+ def __init__(self, system_summary=None, N=3):
566
450
  super().__init__()
567
- self._linked = Dropdown(
568
- options=[str(i + 1) for i in range(N)]
569
- if N != 3
570
- else ("Red", "Green", "Blue"),
571
- description="Projection" if N != 3 else "Color",
572
- )
573
- self._stacked = Stack(
574
- children=tuple(_PropPicker(system_summary) for _ in range(N)),
575
- selected_index=0,
451
+ self._N = N
452
+ self._pickers = [_PropPicker(system_summary) for _ in range(N)]
453
+ self.add_class("props-picker")
454
+
455
+ # Create widgets with consistent width
456
+ self._picker = Dropdown(
457
+ description="Color" if N == 3 else "Projection",
458
+ options=["Red", "Green", "Blue"] if N == 3 else [str(i+1) for i in range(N)],
576
459
  )
577
- self._button = Button(description="Run Function")
578
-
579
- if callable(on_button_click):
580
- self._button.on_click(on_button_click)
581
-
582
- for w in [self._button, self._linked]:
583
- w.layout.width = "max-content"
584
-
585
- ipw.link((self._linked, "index"), (self._stacked, "selected_index"))
586
- self.children = [HBox([self._linked, self._button]), self._stacked]
587
-
588
- if callable(on_selection_changed):
589
- for child in self._stacked.children:
590
- child._html.observe(on_selection_changed, names="value")
591
-
460
+ self._stack = Stack(children=self._pickers, selected_index=0)
461
+ # Link picker dropdown to stack
462
+ ipw.link((self._picker, 'index'), (self._stack, 'selected_index'))
463
+
464
+ # Setup layout
465
+ self.children = [self._picker, self._stack]
466
+
467
+ # Observe pickers for props changes and button click
468
+ for picker in self._pickers:
469
+ picker.observe(self._update_projections, names=['props'])
470
+
471
+ def _update_projections(self, change):
472
+ """Update combined projections when any picker changes"""
473
+ projs = {}
474
+ for picker in self._pickers:
475
+ if picker.props: # Only add non-empty selections
476
+ projs[picker.props['label']] = (
477
+ picker.props['atoms'],
478
+ picker.props['orbs']
479
+ )
480
+ self.projections = projs
481
+
592
482
  def update(self, system_summary):
593
- for child in self._stacked.children:
594
- child.update(system_summary)
595
-
596
- @property
597
- def button(self):
598
- return self._button
599
-
600
- @property
601
- def projections(self):
602
- out = {}
603
- for child in self._stacked.children:
604
- props = child.props
605
- if props["atoms"] and props["orbs"]: # discard empty
606
- out[props["label"]] = (props["atoms"], props["orbs"])
607
-
608
- return out
609
-
610
-
611
- def __store_figclick_data(fig, store_dict, callback=None, selection=False):
612
- "Store clicked data in a dict. callback takes trace as argument and is called after storing data."
613
- if not isinstance(fig, go.FigureWidget):
614
- raise TypeError("fig must be a FigureWidget")
615
- if not isinstance(store_dict, dict):
616
- raise TypeError("store_dict must be a dict")
617
- if callback and not callable(callback):
618
- raise TypeError("callback must be callable if given")
619
-
620
- def handle_click(trace, points, state):
621
- store_dict["data"] = points
622
- if callback:
623
- callback(trace)
624
-
625
- for trace in fig.data:
626
- if selection:
627
- trace.on_selection(handle_click)
628
- else:
629
- trace.on_click(handle_click)
630
-
631
-
632
- def store_clicked_data(fig, store_dict, callback=None):
633
- "Store clicked point data to a store_dict. callback takes trace being clicked as argument."
634
- return __store_figclick_data(fig, store_dict, callback, selection=False)
635
-
636
-
637
- def store_selected_data(fig, store_dict, callback=None):
638
- "Store multipoints selected data to a store_dict. callback takes trace being clicked as argument."
639
- return __store_figclick_data(fig, store_dict, callback, selection=True)
640
-
641
-
642
- def load_results(paths_list):
643
- "Loads result.json from paths_list and returns a dataframe."
483
+ """Update all pickers with new system data"""
484
+ for picker in self._pickers:
485
+ picker.update(system_summary)
486
+
487
+ def _clean_legacy_data(path):
488
+ "clean old style keys like VBM to vbm"
489
+ data = serializer.load(path.absolute()) # Old data loaded
490
+ if not any(key in data for key in ['VBM', 'α','vbm_k']):
491
+ return data # already clean
492
+
493
+ keys_map = {
494
+ "SYSTEM": "sys",
495
+ "VBM": "vbm", # Old: New
496
+ "CBM": "cbm",
497
+ "VBM_k": "kvbm", "vbm_k": "kvbm",
498
+ "CBM_k": "kcbm", "cbm_k": "kcbm",
499
+ "E_gap": "gap",
500
+ "\u0394_SO": "soc",
501
+ "α": "alpha",
502
+ "β": "beta",
503
+ "γ": "gamma",
504
+ }
505
+ new_data = {k:v for k,v in data.items() if k not in (*keys_map.keys(),*keys_map.values())} # keep other data
506
+ for old, new in keys_map.items():
507
+ if old in data:
508
+ new_data[new] = data[old] # Transfer value from old key to new key
509
+ elif new in data:
510
+ new_data[new] = data[new] # Keep existing new style keys
511
+
512
+ # save cleaned data
513
+ serializer.dump(new_data,format="json",outfile=path)
514
+ return new_data
515
+
516
+
517
+ def load_results(paths_list, exclude_keys=None):
518
+ "Loads result.json from paths_list and returns a dataframe. Use exclude_keys to get subset of data."
519
+ if exclude_keys is not None:
520
+ if not isinstance(exclude_keys, (list,tuple)):
521
+ raise TypeError(f"exclude_keys should be list of keys, got {type(exclude_keys)}")
522
+ if not all([isinstance(key,str) for key in exclude_keys]):
523
+ raise TypeError(f"all keys in exclude_keys should be str!")
524
+
644
525
  paths_list = [Path(p) for p in paths_list]
645
526
  result_paths = []
646
527
  if paths_list:
@@ -652,217 +533,339 @@ def load_results(paths_list):
652
533
 
653
534
  def load_data(path):
654
535
  try:
655
- return serializer.load(str(path.absolute()))
536
+ data = _clean_legacy_data(path)
537
+ return {k:v for k,v in data.items() if k not in (exclude_keys or [])}
656
538
  except:
657
539
  return {} # If not found, return empty dictionary
658
540
 
659
541
  return summarize(result_paths, load_data)
660
542
 
543
+ def _get_css(mode):
544
+ return {
545
+ '--jp-widgets-color': 'white' if mode == 'dark' else 'black',
546
+ '--jp-widgets-label-color': 'white' if mode == 'dark' else 'black',
547
+ '--jp-widgets-readout-color': 'white' if mode == 'dark' else 'black',
548
+ '--jp-widgets-input-color': 'white' if mode == 'dark' else 'black',
549
+ '--jp-widgets-input-background-color': '#222' if mode == 'dark' else '#f7f7f7',
550
+ '--jp-widgets-input-border-color': '#8988' if mode == 'dark' else '#ccc',
551
+ '--jp-layout-color2': '#555' if mode == 'dark' else '#ddd', # buttons
552
+ '--jp-ui-font-color1': 'whitesmoke' if mode == 'dark' else 'black', # buttons
553
+ '--jp-content-font-color1': 'white' if mode == 'dark' else 'black', # main text
554
+ '--jp-layout-color1': '#111' if mode == 'dark' else '#fff', # background
555
+ ':fullscreen': {'min-height':'100vh'},
556
+ 'background': 'var(--jp-widgets-input-background-color)', 'border-radius': '4px', 'padding':'4px 4px 0 4px',
557
+ '> *': {
558
+ 'box-sizing': 'border-box',
559
+ 'background': 'var(--jp-layout-color1)',
560
+ 'border-radius': '4px', 'grid-gap': '8px', 'padding': '8px',
561
+ },
562
+ '.left-sidebar .sm': {
563
+ 'flex-grow': 1,
564
+ 'select': {'height': '100%',},
565
+ },
566
+ '.footer': {'overflow': 'auto','padding':0},
567
+ '.widget-vslider, .jupyter-widget-vslider': {'width': 'auto'}, # otherwise it spans too much area
568
+ 'table': { # dataframe display sucks
569
+ 'color':'var(--jp-content-font-color1)',
570
+ 'background':'var(--jp-layout-color1)',
571
+ 'tr': {
572
+ '^:nth-child(odd)': {'background':'var(--jp-widgets-input-background-color)',},
573
+ '^:nth-child(even)': {'background':'var(--jp-layout-color1)',},
574
+ },
575
+ },
576
+ '.props-picker': {
577
+ 'background': 'var(--jp-widgets-input-background-color)', # make feels like single widget
578
+ 'overflow-x': 'hidden', 'border-radius': '4px', 'padding': '4px',
579
+ },
580
+ '.props-tags': {
581
+ 'background':'var(--jp-layout-color1)', 'border-radius': '4px', 'padding': '4px',
582
+ '> input': {'width': '100%'},
583
+ '> input::placeholder': {'color': 'var(--jp-ui-font-color1)'},
584
+ },
585
+ }
586
+
587
+ class _ThemedFigureInteract(ei.InteractBase):
588
+ "Keeps self._fig anf self._theme button attributes for subclasses to use."
589
+ def __init__(self, *args, **kwargs):
590
+ self._fig = ei.patched_plotly(go.FigureWidget())
591
+ self._theme = Button(icon='sun', description=' ', tooltip="Toggle Theme")
592
+ super().__init__(*args, **kwargs)
593
+
594
+ if not all([hasattr(self.params, 'fig'), hasattr(self.params, 'theme')]):
595
+ raise AttributeError("subclass must include already initialized "
596
+ "{'fig': self._fig,'theme':self._theme} in returned dict of _interactive_params() method.")
597
+ self._update_theme(self._fig,self._theme) # fix theme in starts
598
+
599
+ def _interactive_params(self): return {}
600
+
601
+ def __init_subclass__(cls):
602
+ if (not '_update_theme' in cls.__dict__) or (not hasattr(cls._update_theme,'_is_interactive_callback')):
603
+ raise AttributeError("implement _update_theme(self, fig, theme) decorated by @callback in subclass, "
604
+ "which should only call super()._update_theme(fig, theme) in its body.")
605
+ super().__init_subclass__()
606
+
607
+ @ei.callback
608
+ def _update_theme(self, fig, theme):
609
+ require_dark = (theme.icon == 'sun')
610
+ theme.icon = 'moon' if require_dark else 'sun' # we are not observing icon, so we can do this
611
+ fig.layout.template = "plotly_dark" if require_dark else "plotly_white"
612
+ self.set_css() # automatically sets dark/light, ensure after icon set
613
+ fig.layout.autosize = True # must
614
+
615
+ @_sub_doc(ei.InteractBase.set_css) # overriding to alway be able to set_css
616
+ def set_css(self, main=None, center=None):
617
+ # This is after setting icon above, so logic is fliipped
618
+ style = _get_css("light" if self._theme.icon == 'sun' else 'dark') # infer from icon to match
619
+ if isinstance(main, dict):
620
+ style = {**style, **main} # main should allow override
621
+ elif main is not None:
622
+ raise TypeError("main must be a dict or None, got: {}".format(type(main)))
623
+ super().set_css(style, center)
624
+
625
+ @property
626
+ def files(self):
627
+ "Use self.files.update(...) to keep state of widget preserved with new files."
628
+ if not hasattr(self, '_files'): # subclasses must set this, although no check unless user dots it
629
+ raise AttributeError("self._files = Files(...) was never set!")
630
+ return self._files
631
+
661
632
 
662
633
  @fix_signature
663
- class BandsWidget(VBox):
634
+ class BandsWidget(_ThemedFigureInteract):
664
635
  """Visualize band structure from VASP calculation. You can click on the graph to get the data such as VBM, CBM, etc.
665
- Two attributes are important:
666
- self.clicked_data returns the last clicked point, that can also be stored as VBM, CBM etc, using Click dropdown.
667
- self.selected_data returns the last selection of points within a box or lasso. You can plot that output separately as plt.plot(data.xs, data.ys) after a selection.
668
- You can use `self.files.update` method to change source files without effecting state of widget.
669
- """
636
+
637
+ You can observe three traits:
670
638
 
671
- def __init__(self, files, height="450px"):
672
- super().__init__(_dom_classes=["BandsWidget"])
639
+ - file: Currently selected file
640
+ - clicked_data: Last clicked point data, which can be directly passed to a dataframe.
641
+ - selected_data: Last selection of points within a box or lasso, which can be directly passed to a dataframe and plotted accordingly.
642
+
643
+ - You can use `self.files.update` method to change source files without effecting state of widget.
644
+ - You can also use `self.iplot`, `self.splot` with `self.kws` to get static plts of current state, and self.results to get a dataframe.
645
+ - You can use store_clicks to provide extra names of points you want to click and save data, besides default ones.
646
+ """
647
+ file = traitlets.Any(allow_none=True)
648
+ clicked_data = traitlets.Dict(allow_none=True)
649
+ selected_data = traitlets.Dict(allow_none=True)
650
+
651
+ def __init__(self, files, height="600px", store_clicks=None):
652
+ self.add_class("BandsWidget")
653
+ self._kb_fig = go.FigureWidget() # for extra stuff
654
+ self._kb_fig.update_layout(margin=dict(l=40, r=0, b=40, t=40, pad=0)) # show compact
655
+ self._files = Files(files)
673
656
  self._bands = None
674
- self._fig = go.FigureWidget()
675
- self._tsd = Dropdown(
676
- description="Style", options=["plotly_white", "plotly_dark"]
677
- )
678
- self._click = Dropdown(description="Click", options=["None", "vbm", "cbm"])
679
- self._ktcicks = Text(description="kticks", tooltip="0 index maps to minimum value of kpoints slider.")
680
- self._brange = ipw.IntRangeSlider(description="bands",min=1, max=1) # number, not index
681
- self._krange = ipw.IntRangeSlider(description="kpoints",min=0, max=1,value=[0,1], tooltip="Includes non-zero weight kpoints")
682
- self._ppicks = PropsPicker(
683
- on_button_click=self._update_graph, on_selection_changed=self._warn_update
684
- )
685
- self._ppicks.button.description = "Update Graph"
686
- self._result = {} # store and save output results
687
- self._click_dict = {} # store clicked data
688
- self._select_dict = {} # store selection data
689
- self._kwargs = {}
657
+ self._kws = {}
658
+ self._result = {}
659
+ self._extra_clicks = ()
660
+
661
+ if store_clicks is not None:
662
+ if not isinstance(store_clicks, (list,tuple)):
663
+ raise TypeError("store_clicks should be list of names "
664
+ f"of point to be stored from click on figure, got {type(store_clicks)}")
665
+
666
+ for name in store_clicks:
667
+ if not isinstance(name, str) or not name.isidentifier():
668
+ raise ValueError(f"items in store_clicks should be a valid python variable name, got {name!r}")
669
+ if name in ["vbm", "cbm", "so_max", "so_min"]:
670
+ raise ValueError(f"{name!r} already exists in default click points!")
671
+ reserved = "gap soc v a b c alpha beta gamma direct".split()
672
+ if name in reserved:
673
+ raise ValueError(f"{name!r} conflicts with reserved keys {reserved}")
674
+
675
+ self._extra_clicks += tuple(store_clicks)
676
+
677
+ super().__init__() # after extra clicks
678
+
679
+ traitlets.dlink((self.params.file,'value'),(self, 'file'))
680
+ traitlets.dlink((self.params.fig,'clicked'),(self, 'clicked_data'))
681
+ traitlets.dlink((self.params.fig,'selected'),(self, 'selected_data'))
690
682
 
691
- Files(files)._attributed_interactive(self, self._load_data, self._fig,
692
- free_widgets=[
693
- self._tsd,
694
- self._brange,
695
- self._krange,
696
- self._ktcicks,
697
- ipw.HTML("<hr/>"),
698
- self._ppicks,
699
- ipw.HTML("<hr/>Click on graph to read selected option."),
700
- self._click,
683
+ self.relayout(
684
+ left_sidebar=[
685
+ 'head','file','krange','kticks','brange', 'ppicks',
686
+ [HBox(),('theme','button')], 'kb_fig',
701
687
  ],
702
- height=height,
688
+ center=['hdata','fig','cpoint'], footer = self.groups.outputs,
689
+ right_sidebar = ['showft'],
690
+ pane_widths=['25em',1,'2em'], pane_heights=[0,1,0], # footer only has uselessoutputs
691
+ height=height
703
692
  )
704
-
705
- self._tsd.observe(self._change_theme, "value")
706
- self._click.observe(self._click_save_data, "value")
707
- self._ktcicks.observe(self._warn_update, "value")
708
- self._krange.observe(self._set_krange, "value")
709
- self._brange.observe(self._warn_update, "value")
710
693
 
711
- @property
712
- def path(self):
713
- "Returns currently selected path."
714
- return self._interact._dd.value
694
+ @traitlets.validate('selected_data','clicked_data')
695
+ def _flatten_dict(self, proposal):
696
+ data = proposal['value']
697
+ if data is None: return None # allow None stuff
715
698
 
716
- @property
717
- def files(self):
718
- "Use slef.files.update(...) to keep state of widget preserved."
719
- return self._files
720
-
721
- def _load_data(self, path, fig): # Automatically redirectes to output widget
722
- if not hasattr(self, '_interact'): return # First time not availablebu
723
- self._interact.output_widget.clear_output(wait=True) # Why need again?
724
- with self._interact.output_widget:
725
- self._bands = (
726
- vp.Vasprun(path) if path.parts[-1].endswith('xml') else vp.Vaspout(path)
727
- ).bands
728
- self._ppicks.update(self.bands.source.summary)
729
-
730
- self._krange.max = self.bands.source.summary.NKPTS - 1
731
- self._krange.tooltip = f"Includes {self.bands.source.get_skipk()} non-zero weight kpoints"
732
- self.bands.source.set_skipk(0) # full range to view for slider flexibility after fix above
733
-
734
- self._kwargs['kpairs'] = [self._krange.value,]
735
- if (ticks := ", ".join(
736
- f"{k}:{v}" for k, v in self.bands.get_kticks()
737
- )): # Do not overwrite if empty
738
- self._ktcicks.value = ticks
739
-
740
- self._brange.max = self.bands.source.summary.NBANDS
741
- if self.bands.source.summary.LSORBIT:
742
- self._click.options = ["None", "vbm", "cbm", "so_max", "so_min"]
743
- else:
744
- self._click.options = ["None", "vbm", "cbm"]
745
-
746
- if (file := path.parent / "result.json").is_file():
747
- self._result = serializer.load(str(file.absolute())) # Old data loaded
748
-
749
- pdata = self.bands.source.poscar.data
750
- self._result.update(
751
- {
752
- "v": round(pdata.volume, 4),
753
- **{k: round(v, 4) for k, v in zip("abc", pdata.norms)},
754
- **{k: round(v, 4) for k, v in zip(["alpha","beta","gamma"], pdata.angles)},
755
- }
756
- )
757
- self._click_save_data(None) # Load into view
758
- self._warn_update(None)
759
-
760
- @property
761
- def source(self):
762
- "Returns data source object such as Vasprun or Vaspout."
763
- return self.bands.source
764
-
765
- @property
766
- def bands(self):
767
- "Bands class initialized"
768
- if not self._bands:
769
- raise ValueError("No data loaded by BandsWidget yet!")
770
- return self._bands
771
-
772
- @property
773
- def kwargs(self):
774
- "Selected kwargs from GUI"
775
- return self._kwargs
699
+ if not isinstance(data, dict):
700
+ raise traitlets.TraitError(f"Expected a dict for selected_data, got {type(data)}")
701
+
702
+ _data = {k:v for k,v in data.items() if k != 'customdata' and 'indexes' not in k}
703
+ _data.update(pd.DataFrame(data.get('customdata',{})).to_dict(orient='list'))
704
+ return _data # since we know customdata, we can flatten dict
776
705
 
777
- @property
778
- def clicked_data(self):
779
- "Clicked data from graph"
780
- return self._click_dict.get("data", None)
781
706
 
782
- @property
783
- def selected_data(self):
784
- "Data selected by box or lasso selection from graph"
785
- return self._select_dict.get("data", None)
786
-
787
- def _update_graph(self, btn):
788
- if not hasattr(self, '_interact'): return # First time not available
789
- self._interact.output_widget.clear_output(wait=True) # Why need again?
790
- with self._interact.output_widget:
791
- hsk = [
792
- [v.strip() for v in vs.split(":")]
793
- for vs in self._ktcicks.value.split(",")
794
- ]
795
- kticks = [(int(vs[0]), vs[1]) for vs in hsk if len(vs) == 2] or None
796
- self._kwargs = {**self._kwargs, "kticks": kticks, # below numbers instead of index and full shown range
797
- "bands": range(self._brange.value[0] - 1, self._brange.value[1]) if self._brange.value else None}
798
-
799
- if self._ppicks.projections:
800
- self._kwargs = {"projections": self._ppicks.projections, **self._kwargs}
801
- fig = self.bands.iplot_rgb_lines(**self._kwargs, name="Up")
802
- if self.bands.source.summary.ISPIN == 2:
803
- self.bands.iplot_rgb_lines(**self._kwargs, spin=1, name="Down", fig=fig)
804
-
805
- self.iplot = partial(self.bands.iplot_rgb_lines, **self._kwargs)
806
- self.splot = partial(self.bands.splot_rgb_lines, **self._kwargs)
807
- else:
808
- fig = self.bands.iplot_bands(**self._kwargs, name="Up")
809
- if self.bands.source.summary.ISPIN == 2:
810
- self.bands.iplot_bands(**self._kwargs, spin=1, name="Down", fig=fig)
811
-
812
- self.iplot = partial(self.bands.iplot_bands, **self._kwargs)
813
- self.splot = partial(self.bands.splot_bands, **self._kwargs)
814
-
815
- ptk.iplot2widget(fig, self._fig, template=self._tsd.value)
816
- self._click_dict.clear() # avoid data from previous figure
817
- self._select_dict.clear() # avoid data from previous figure
818
- store_clicked_data(
819
- self._fig,
820
- self._click_dict,
821
- callback=lambda trace: self._click_save_data("CLICK"),
822
- ) # 'CLICK' is needed to inntercept in a function
823
- store_selected_data(self._fig, self._select_dict, callback=None)
824
- self._ppicks.button.description = "Update Graph"
825
-
826
- def _change_theme(self, change):
827
- self._fig.layout.template = self._tsd.value
828
-
829
- def _set_krange(self, change):
830
- self._kwargs["kpairs"] = [self._krange.value,]
831
- self._warn_update(None) # Update warning
832
-
833
- def _click_save_data(self, change=None):
834
- def _show_and_save(data_dict):
835
- self._interact.output_widget.clear_output(wait=True) # Why need again?
836
- with self._interact.output_widget:
837
- print(pformat({key: value
838
- for key, value in data_dict.items()
839
- if key not in ("so_max", "so_min")
840
- }))
841
-
842
- serializer.dump(
843
- data_dict,
844
- format="json",
845
- outfile=self.path.parent / "result.json",
846
- )
707
+ @ei.callback
708
+ def _update_theme(self, fig, theme):
709
+ super()._update_theme(fig, theme)
710
+ self._kb_fig.layout.template = fig.layout.template
711
+ self._kb_fig.layout.autosize = True
712
+
713
+ def _interactive_params(self):
714
+ return dict(
715
+ fig = self._fig, theme = self._theme, # include theme and fig
716
+ kb_fig = self._kb_fig, # show selected data
717
+ head = ipw.HTML("<b>Band Structure Visualizer</b>"),
718
+ file = self.files.to_dropdown(),
719
+ ppicks = PropsPicker(),
720
+ button = Button(description="Update Graph", icon= 'update'),
721
+ krange = ipw.IntRangeSlider(description="kpoints",min=0, max=1,value=[0,1], tooltip="Includes non-zero weight kpoints"),
722
+ kticks = Text(description="kticks", tooltip="0 index maps to minimum value of kpoints slider."),
723
+ brange = ipw.IntRangeSlider(description="bands",min=1, max=1), # number, not index
724
+ cpoint = ipw.ToggleButtons(description="Select from options and click on figure to store data points",
725
+ value=None, options=["vbm", "cbm", *self._extra_clicks]).add_class('content-width-button'), # the point where clicked
726
+ showft = ipw.IntSlider(description = 'h', orientation='vertical',min=0,max=50, value=0,tooltip="outputs area's height ratio"),
727
+ cdata = 'fig.clicked',
728
+ projs = 'ppicks.projections', # for visual feedback on button
729
+ sdata = '.selected_data',
730
+ hdata = ipw.HTML(), # to show data in one place
731
+ )
732
+
733
+ @ei.callback('out-selected')
734
+ def _plot_data(self, kb_fig, sdata):
735
+ kb_fig.data = [] # clear in any case to avoid confusion
736
+ if not sdata: return # no change
737
+
738
+ df = pd.DataFrame(sdata)
739
+ if 'r' in sdata:
740
+ arr = df[['r','g','b']].to_numpy()
741
+ arr[arr == ''] = 0
742
+ arr, fmt = arr / (arr.max() or 1), lambda v : int(v*255) # color norms
743
+ df['color'] = [f"rgb({fmt(r)},{fmt(g)},{fmt(b)})" for r,g,b in arr]
744
+ else:
745
+ df['color'] = sdata['occ']
847
746
 
848
- if change is None: # called from other functions but not from store_clicked_data
849
- return _show_and_save(self._result)
850
- # Should be after checking change
851
- if self._click.value and self._click.value == "None":
852
- return # No need to act on None
747
+ df['msize'] = df['occ']*7 + 10
748
+ cdata = (df[["ys","occ","r","g","b"]] if 'r' in sdata else df[['ys','occ']]).to_numpy()
749
+ rgb_temp = '<br>orbs: (%{customdata[2]},%{customdata[3]},%{customdata[4]})' if 'r' in sdata else ''
853
750
 
751
+ kb_fig.add_trace(go.Scatter(x=df.nk, y = df.nb, mode = 'markers', marker = dict(size=df.msize,color=df.color), customdata=cdata))
752
+ kb_fig.update_traces(hovertemplate=f"nk: %{{x}}, nb: %{{y}})<br>en: %{{customdata[0]:.4f}}<br>occ: %{{customdata[1]:.4f}}{rgb_temp}<extra></extra>")
753
+ kb_fig.update_layout(template = self._fig.layout.template, autosize=True,
754
+ title = "Selected Data", showlegend=False,coloraxis_showscale=False,
755
+ margin=dict(l=40, r=0, b=40, t=40, pad=0),font=dict(family="stix, serif", size=14)
756
+ )
757
+
758
+ @ei.callback('out-data')
759
+ def _load_data(self, file):
760
+ if not file: return # First time not available
761
+ self._bands = (
762
+ vp.Vasprun(file) if file.parts[-1].endswith('xml') else vp.Vaspout(file)
763
+ ).bands
764
+ self.params.ppicks.update(self.bands.source.summary)
765
+ self.params.krange.max = self.bands.source.summary.NKPTS - 1
766
+ self.params.krange.tooltip = f"Includes {self.bands.source.get_skipk()} non-zero weight kpoints"
767
+ self.bands.source.set_skipk(0) # full range to view for slider flexibility after fix above
768
+ self._kws['kpairs'] = [self.params.krange.value,]
769
+ if (ticks := ", ".join(
770
+ f"{k}:{v}" for k, v in self.bands.get_kticks()
771
+ )): # Do not overwrite if empty
772
+ self.params.kticks.value = ticks
773
+
774
+ self.params.brange.max = self.bands.source.summary.NBANDS
775
+ if self.bands.source.summary.LSORBIT:
776
+ self.params.cpoint.options = ["vbm", "cbm", "so_max", "so_min", *self._extra_clicks]
777
+ else:
778
+ self.params.cpoint.options = ["vbm", "cbm",*self._extra_clicks]
779
+ if (path := file.parent / "result.json").is_file():
780
+ self._result = _clean_legacy_data(path)
781
+
782
+ pdata = self.bands.source.poscar.data
783
+ self._result.update(
784
+ {
785
+ "sys": pdata.SYSTEM, "v": round(pdata.volume, 4),
786
+ **{k: round(v, 4) for k, v in zip("abc", pdata.norms)},
787
+ **{k: round(v, 4) for k, v in zip(["alpha","beta","gamma"], pdata.angles)},
788
+ }
789
+ )
790
+ self._show_data(self._result) # Load into view
791
+
792
+ @ei.callback
793
+ def _toggle_footer(self, showft):
794
+ self._app.pane_heights = [0,100 - showft, showft]
795
+
796
+ @ei.callback
797
+ def _set_krange(self, krange):
798
+ self._kws["kpairs"] = [krange,]
799
+
800
+ @ei.callback
801
+ def _warn_update(self, file, kticks, brange, krange, projs):
802
+ self.params.button.description = "🔴 Update Graph"
803
+
804
+ @ei.callback('out-graph')
805
+ def _update_graph(self, fig, button):
806
+ if not self.bands: return # First time not available
807
+ fig.layout.autosize = True # must
808
+ hsk = [
809
+ [v.strip() for v in vs.split(":")]
810
+ for vs in self.params.kticks.value.split(",")
811
+ ]
812
+ kmin, kmax = self.params.krange.value or [0,0]
813
+ kticks = [(int(vs[0]), vs[1])
814
+ for vs in hsk # We are going to pick kticks silently in given range
815
+ if len(vs) == 2 and abs(int(vs[0])) < (kmax - kmin) # handle negative indices too
816
+ ] or None
817
+
818
+ _bands = None
819
+ if self.params.brange.value:
820
+ l, h = self.params.brange.value
821
+ _bands = range(l-1, h) # from number to index
822
+
823
+ self._kws = {**self._kws, "kticks": kticks, "bands": _bands}
824
+ ISPIN = self.bands.source.summary.ISPIN
825
+ if self.params.ppicks.projections:
826
+ self._kws["projections"] = self.params.ppicks.projections
827
+ _fig = self.bands.iplot_rgb_lines(**self._kws, name="Up" if ISPIN == 2 else "")
828
+ if ISPIN == 2:
829
+ self.bands.iplot_rgb_lines(**self._kws, spin=1, name="Down", fig=fig)
830
+
831
+ self.iplot = partial(self.bands.iplot_rgb_lines, **self._kws)
832
+ self.splot = partial(self.bands.splot_rgb_lines, **self._kws)
833
+ else:
834
+ self._kws.pop("projections",None) # may be previous one
835
+ _fig = self.bands.iplot_bands(**self._kws, name="Up" if ISPIN == 2 else "")
836
+ if self.bands.source.summary.ISPIN == 2:
837
+ self.bands.iplot_bands(**self._kws, spin=1, name="Down", fig=fig)
838
+
839
+ self.iplot = partial(self.bands.iplot_bands, **self._kws)
840
+ self.splot = partial(self.bands.splot_bands, **self._kws)
841
+
842
+ ptk.iplot2widget(_fig, fig, template=fig.layout.template)
843
+ fig.clicked = {} # avoid data from previous figure
844
+ fig.selected = {} # avoid data from previous figure
845
+ button.description = "Update Graph" # clear trigger
846
+
847
+ @ei.callback('out-click')
848
+ def _click_save_data(self, cdata):
849
+ if self.params.cpoint.value is None: return # at reset-
854
850
  data_dict = self._result.copy() # Copy old data
855
851
 
856
- if data := self.clicked_data: # No need to make empty dict
857
- x = round(data.xs[0], 6)
858
- y = round(float(data.ys[0]) + self.bands.data.ezero, 6) # Add ezero
852
+ if cdata: # No need to make empty dict
853
+ key = self.params.cpoint.value
854
+ if key:
855
+ y = round(float(*cdata['ys']) + self.bands.data.ezero, 6) # Add ezero
856
+ if not key in self._extra_clicks:
857
+ data_dict[key] = y # Assign value back
858
+
859
+ if not key.startswith("so_"): # not spin-orbit points
860
+ cst, = cdata.get('customdata',[{}]) # single item
861
+ kp = [cst.get(f"k{n}", None) for n in 'xyz']
862
+ kp = tuple([round(k,6) if k else k for k in kp])
863
+
864
+ if key in ("vbm","cbm"):
865
+ data_dict[f"k{key}"] = kp
866
+ else: # user points, stor both for reference
867
+ data_dict[key] = {"k":kp,"e":y}
859
868
 
860
- if key := self._click.value:
861
- data_dict[key] = y # Assign value back
862
- if not key.startswith("so"):
863
- data_dict[key + "_k"] = round(
864
- x, 6
865
- ) # Save x to test direct/indirect
866
869
 
867
870
  if data_dict.get("vbm", None) and data_dict.get("cbm", None):
868
871
  data_dict["gap"] = np.round(data_dict["cbm"] - data_dict["vbm"], 6)
@@ -873,22 +876,55 @@ class BandsWidget(VBox):
873
876
  )
874
877
 
875
878
  self._result.update(data_dict) # store new data
876
- _show_and_save(self._result)
877
-
878
- if change == "CLICK": # Called from store_clicked_data
879
- self._click.value = "None" # Reset to None to avoid accidental click
879
+ self._show_and_save(self._result, f"{key} = {data_dict[key]}")
880
+ self.params.cpoint.value = None # Reset to None to avoid accidental click at end
881
+
882
+ def _show_data(self, data, last_click=None):
883
+ "Show data in html widget, no matter where it was called."
884
+ keys = "sys vbm cbm gap direct soc v a b c alpha beta gamma".split()
885
+ data = {key:data[key] for key in keys if key in data} # show only standard data
886
+ kv, kc = [self._result.get(k,[None]*3) for k in ('kvbm','kcbm')]
887
+ data['direct'] = (kv == kc) if None not in kv else False
888
+
889
+ # Add a caption to the table
890
+ caption = f"<caption style='caption-side:bottom; opacity:0.7;'><code>{last_click or 'clicked data is shown here'}</code></caption>"
891
+
892
+ headers = "".join(f"<th>{key}</th>" for key in data.keys())
893
+ values = "".join(f"<td>{format(value, '.4f') if isinstance(value, float) else value}</td>" for value in data.values())
894
+ self.params.hdata.value = f"""<table border='1' style='width:100%;max-width:100% !important;border-collapse:collapse;'>
895
+ {caption}<tr>{headers}</tr>\n<tr>{values}</tr></table>"""
896
+
897
+ def _show_and_save(self, data_dict, last_click=None):
898
+ self._show_data(data_dict,last_click=last_click)
899
+ if self.file:
900
+ serializer.dump(data_dict,format="json",
901
+ outfile=self.file.parent / "result.json")
902
+
903
+ def results(self, exclude_keys=None):
904
+ "Generate a dataframe form result.json file in each folder, with optionally excluding keys."
905
+ return load_results(self.params.file.options, exclude_keys=exclude_keys)
906
+
907
+ @property
908
+ def source(self):
909
+ "Returns data source object such as Vasprun or Vaspout."
910
+ return self.bands.source
880
911
 
881
- def _warn_update(self, change):
882
- self._ppicks.button.description = "🔴 Update Graph"
912
+ @property
913
+ def bands(self):
914
+ "Bands class initialized"
915
+ if not self._bands:
916
+ raise ValueError("No data loaded by BandsWidget yet!")
917
+ return self._bands
883
918
 
884
919
  @property
885
- def results(self):
886
- "Generate a dataframe form result.json file in each folder."
887
- return load_results(self._interact._dd.options)
920
+ def kws(self):
921
+ "Selected keyword arguments from GUI"
922
+ return self._kws
923
+
888
924
 
889
925
 
890
926
  @fix_signature
891
- class KpathWidget(VBox):
927
+ class KPathWidget(_ThemedFigureInteract):
892
928
  """
893
929
  Interactively bulid a kpath for bandstructure calculation.
894
930
 
@@ -900,159 +936,164 @@ class KpathWidget(VBox):
900
936
  - Add labels to the points by typing in the "Labels" box such as "Γ,X" or "Γ 5,X" that will add 5 points in interval.
901
937
  - To break the path between two points "Γ" and "X" type "Γ 0,X" in the "Labels" box, zero means no points in interval.
902
938
 
903
- You can use `self.files.update` method to change source files without effecting state of widget.
939
+ - You can use `self.files.update` method to change source files without effecting state of widget.
940
+ - You can observe `self.file` trait to get current file selected and plot something, e.g. lattice structure.
904
941
  """
942
+ file = traitlets.Any(None, allow_none=True)
905
943
 
906
- def __init__(self, files, height="400px"):
907
- super().__init__(_dom_classes=["KpathWidget"])
908
- self._fig = go.FigureWidget()
909
- self._sm = SelectMultiple(options=[], layout=Layout(width="auto"))
910
- self._lab = Text(description="Labels", continuous_update=True)
911
- self._kpt = Text(description="KPOINT", continuous_update=False)
912
- self._add = Button(description="Lock", tooltip="Lock/Unlock adding more points")
913
- self._del = Button(description="❌ Point", tooltip="Delete Selected Points")
914
- self._tsb = Button(description="Dark Plot", tooltip="Toggle Plot Theme")
944
+ @property
945
+ def poscar(self): return self._poscar
946
+
947
+ def __init__(self, files, height="450px"):
948
+ self.add_class("KPathWidget")
915
949
  self._poscar = None
916
- self._clicktime = None
950
+ self._oldclick = None
917
951
  self._kpoints = {}
918
-
919
- free_widgets = [
920
- HBox([self._add, self._del, self._tsb], layout=Layout(min_height="24px")),
921
- ipw.HTML(
922
- "<style>.KpathWidget .widget-select-multiple { min-height: 180px; }\n .widget-select-multiple > select {height: 100%;}</style>"
923
- ),
924
- self._sm,
925
- self._lab,
926
- self._kpt,
927
- ]
928
-
929
- Files(files)._attributed_interactive(self,
930
- self._update_fig, self._fig, free_widgets=free_widgets, height=height
952
+ self._files = Files(files) # set name _files to ensure access to files
953
+ super().__init__()
954
+ traitlets.dlink((self.params.file,'value'),(self, 'file')) # update file trait
955
+
956
+ btns = [HBox(layout=Layout(min_height="24px")),('lock','delp', 'theme')]
957
+ self.relayout(
958
+ left_sidebar=['head','file',btns, 'info', 'sm','out-kpt','kpt', 'out-lab', 'lab'],
959
+ center=['fig'], footer = [c for c in self.groups.outputs if not c in ('out-lab','out-kpt')],
960
+ pane_widths=['25em',1,0], pane_heights=[0,1,0], # footer only has uselessoutputs
961
+ height=height
931
962
  )
932
-
933
- self._tsb.on_click(self._update_theme)
934
- self._add.on_click(self._toggle_lock)
935
- self._del.on_click(self._del_point)
936
- self._kpt.observe(self._take_kpt, "value")
937
- self._lab.observe(self._add_label)
938
963
 
939
- @property
940
- def path(self):
941
- "Returns currently selected path."
942
- return self._interact._dd.value # itself a Path object
943
-
944
- @property
945
- def files(self):
946
- "Use slef.files.update(...) to keep state of widget preserved."
947
- return self._files
948
-
949
- @property
950
- def poscar(self):
951
- "POSCAR class associated to current selection."
952
- return self._poscar
964
+ def _show_info(self, text, color='skyblue'):
965
+ self.params.info.value = f'<span style="color:{color}">{text}</span>'
966
+
967
+ def _interactive_params(self):
968
+ return dict(
969
+ fig = self._fig, theme = self._theme, # include theme and fig
970
+ head = ipw.HTML("<b>K-Path Builder</b>"),
971
+ file = self.files.to_dropdown(), # auto updatable on files.update
972
+ sm = SelectMultiple(description="KPOINTS", options=[], layout=Layout(width="auto")),
973
+ lab = Text(description="Labels", continuous_update=True),
974
+ kpt = Text(description="KPOINT", continuous_update=False),
975
+ delp = Button(description=" ", icon='trash', tooltip="Delete Selected Points"),
976
+ click = 'fig.clicked',
977
+ lock = Button(description=" ", icon='unlock', tooltip="Lock/Unlock adding more points"),
978
+ info = ipw.HTML(), # consise information in one place
979
+ )
953
980
 
954
- def _update_fig(self, path, fig):
955
- if not hasattr(self, '_interact'): return # First time not available
956
- from .lattice import POSCAR # to avoid circular import
981
+ @ei.callback('out-fig')
982
+ def _update_fig(self, file, fig):
983
+ if not file: return # empty one
957
984
 
958
- with self._interact.output_widget:
959
- template = (
960
- "plotly_dark" if "Light" in self._tsb.description else "plotly_white"
961
- )
962
- self._poscar = POSCAR(path)
963
- ptk.iplot2widget(
964
- self._poscar.iplot_bz(fill=False, color="red"), fig, template
965
- )
966
- with fig.batch_animate():
967
- fig.add_trace(
968
- go.Scatter3d(
969
- x=[],
970
- y=[],
971
- z=[],
972
- mode="lines+text",
973
- name="path",
974
- text=[],
975
- hoverinfo="none", # dont let it block other points
976
- textfont_size=18,
977
- )
978
- ) # add path that will be updated later
979
- self._click() # handle events
980
- print("Click points on plot to store for kpath.")
981
-
982
- def _click(self):
983
- def handle_click(trace, points, state):
984
- if self._clicktime and (time() - self._clicktime < 1):
985
- return # Avoid double clicks
986
-
987
- self._clicktime = time() # register this click's time
988
-
989
- if points.ys != []:
990
- index = points.point_inds[0]
991
- kp = trace.hovertext[index]
992
- kp = [float(k) for k in kp.split("[")[1].split("]")[0].split()]
993
-
994
- if self._sm.value:
995
- self._take_kpt(kp) # this updates plot back as well
996
- elif self._add.description == "Lock": # only add when open
985
+ from ipyvasp.lattice import POSCAR # to avoid circular import
986
+ self._poscar = POSCAR(file)
987
+ ptk.iplot2widget(
988
+ self._poscar.iplot_bz(fill=False, color="red"), fig, self.params.fig.layout.template
989
+ )
990
+ fig.layout.autosize = True # must
991
+
992
+ with fig.batch_animate():
993
+ fig.add_trace(
994
+ go.Scatter3d(x=[], y=[], z=[],
995
+ mode="lines+text",
996
+ name="path",
997
+ text=[],
998
+ hoverinfo="none", # dont let it block other points
999
+ textfont_size=18,
1000
+ )
1001
+ ) # add path that will be updated later
1002
+ self._show_info("Click points on plot to store for kpath.")
1003
+
1004
+ @ei.callback('out-click')
1005
+ def _click(self, click):
1006
+ # We are setting value on select multiple to get it done in one click conveniently
1007
+ # But that triggers infinite loop, so we need to check if click is different next time
1008
+ if click != self._oldclick and (tidx := click.get('trace_indexes',[])):
1009
+ self._oldclick = click # for next time
1010
+ data = self.params.fig.data # click depends on fig, so accessing here
1011
+ if not [data[i] for i in tidx if 'HSK' in data[i].name]: return
1012
+
1013
+ if cp := [*click.get('xs', []),*click.get('ys', []),*click.get('zs', [])]:
1014
+ kp = self._poscar.bz.to_fractional(cp) # reciprocal space
1015
+
1016
+ if self.params.sm.value:
1017
+ self._set_kpt(kp) # this updates plot back as well
1018
+ elif self.params.lock.icon == "unlock": # only add when open
997
1019
  self._add_point(kp)
998
-
999
- for trace in self._fig.data:
1000
- if "HSK" in trace.name:
1001
- trace.on_click(handle_click)
1002
-
1003
- def _update_selection(self):
1004
- with self._interact.output_widget:
1005
- coords, labels = self.get_coords_labels()
1006
- with self._fig.batch_animate():
1007
- for trace in self._fig.data:
1008
- if "path" in trace.name and coords.any():
1009
- trace.x = coords[:, 0]
1010
- trace.y = coords[:, 1]
1011
- trace.z = coords[:, 2]
1012
- trace.text = _fmt_labels(
1013
- labels
1014
- ) # convert latex to html equivalent
1015
-
1016
- def get_coords_labels(self):
1017
- "Returns tuple of (coordinates, labels) to directly plot."
1018
- with self._interact.output_widget:
1019
- points = self.get_kpoints()
1020
-
1021
- coords = (
1022
- self.poscar.bz.to_cartesian([p[:3] for p in points]).tolist()
1023
- if points
1024
- else []
1025
- )
1026
- labels = [
1027
- p[3] if (len(p) >= 4 and isinstance(p[3], str)) else "" for p in points
1028
- ]
1029
- numbers = [
1030
- p[4]
1031
- if len(p) == 5
1032
- else p[3]
1033
- if (len(p) == 4 and isinstance(p[3], int))
1034
- else ""
1035
- for p in points
1036
- ]
1037
-
1038
- j = 0
1039
- for i, n in enumerate(numbers, start=1):
1040
- if isinstance(n, int) and n == 0:
1041
- labels.insert(i + j, "")
1042
- coords.insert(i + j, [np.nan, np.nan, np.nan])
1043
- j += 1
1044
-
1045
- return np.array(coords), labels
1020
+
1021
+ @ei.callback('out-kpt')
1022
+ def _take_kpt(self, kpt):
1023
+ print("Add kpoint e.g. 0,1,3 at selection(s)")
1024
+ self._set_kpt(kpt)
1025
+
1026
+ @ei.callback('out-lab')
1027
+ def _set_lab(self, lab):
1028
+ print("Add label[:number] e.g. X:5,Y,L:9")
1029
+ self._add_label(lab)
1030
+
1031
+ @ei.callback
1032
+ def _update_theme(self, fig, theme):
1033
+ super()._update_theme(fig, theme) # call parent method, but important
1034
+
1035
+
1036
+ @ei.callback
1037
+ def _toggle_lock(self, lock):
1038
+ self.params.lock.icon = 'lock' if self.params.lock.icon == 'unlock' else 'unlock'
1039
+ self._show_info(f"{self.params.lock.icon}ed adding/deleting kpoints!")
1040
+
1041
+ @ei.callback
1042
+ def _del_point(self, delp):
1043
+ if self.params.lock.icon == 'unlock': # Do not delete locked
1044
+ sm = self.params.sm
1045
+ for v in sm.value: # for loop here is important to update selection properly
1046
+ sm.options = [opt for opt in sm.options if opt[1] != v]
1047
+ self._update_selection() # update plot as well
1048
+ else:
1049
+ self._show_info("Select point(s) to delete")
1050
+ else:
1051
+ self._show_info("cannot delete point when locked!", 'red')
1052
+
1053
+ def _add_point(self, kpt):
1054
+ sm = self.params.sm
1055
+ sm.options = [*sm.options, ("⋮", len(sm.options))]
1056
+ # select to receive point as well, this somehow makes infinit loop issues,
1057
+ # but need to work, so self._oldclick is used to check in _click callback
1058
+ sm.value = (sm.options[-1][1],)
1059
+ self._set_kpt(kpt) # add point, label and plot back
1060
+
1061
+ def _set_kpt(self,kpt):
1062
+ point = kpt
1063
+ if isinstance(kpt, str) and kpt:
1064
+ if len(kpt.split(",")) != 3: return # Enter at incomplete input
1065
+ point = [float(v) for v in kpt.split(",")] # kpt is value widget
1066
+
1067
+ if not isinstance(point,(list, tuple,np.ndarray)): return # None etc
1068
+
1069
+ if len(point) != 3:
1070
+ raise ValueError("Expects KPOINT of 3 floats")
1071
+ self._kpoints.update({v: point for v in self.params.sm.value})
1072
+ label = "{:>8.4f} {:>8.4f} {:>8.4f}".format(*point)
1073
+ self.params.sm.options = [
1074
+ (label, value) if value in self.params.sm.value else (lab, value)
1075
+ for (lab, value) in self.params.sm.options
1076
+ ]
1077
+ self._add_label(self.params.lab.value) # Re-adjust labels and update plot as well
1078
+
1079
+ def _add_label(self, lab):
1080
+ labs = [" ⋮ " for _ in self.params.sm.options] # as much as options
1081
+ for idx, (_, lb) in enumerate(zip(self.params.sm.options, (lab or "").split(","))):
1082
+ labs[idx] = labs[idx] + lb # don't leave empty anyhow
1083
+
1084
+ self.params.sm.options = [
1085
+ (v.split("⋮")[0].strip() + lb, idx)
1086
+ for (v, idx), lb in zip(self.params.sm.options, labs)
1087
+ ]
1088
+ self._update_selection() # Update plot in both cases, by click or manual input
1046
1089
 
1047
1090
  def get_kpoints(self):
1048
1091
  "Returns kpoints list including labels and numbers in intervals if given."
1049
- keys = [
1050
- idx for (_, idx) in self._sm.options if idx in self._kpoints
1051
- ] # order and existence is important
1092
+ keys = [idx for (_, idx) in self.params.sm.options if idx in self._kpoints] # order and existence is important
1052
1093
  kpts = [self._kpoints[k] for k in keys]
1053
1094
  LN = [
1054
1095
  lab.split("⋮")[1].strip().split()
1055
- for (lab, idx) in self._sm.options
1096
+ for (lab, idx) in self.params.sm.options
1056
1097
  if idx in keys
1057
1098
  ]
1058
1099
 
@@ -1072,76 +1113,43 @@ class KpathWidget(VBox):
1072
1113
  )
1073
1114
  return kpts
1074
1115
 
1075
- def _update_theme(self, btn):
1076
- if "Dark" in btn.description:
1077
- self._fig.layout.template = "plotly_dark"
1078
- btn.description = "Light Plot"
1079
- else:
1080
- self._fig.layout.template = "plotly_white"
1081
- btn.description = "Dark Plot"
1082
-
1083
- def _add_point(self, kpt):
1084
- with self._interact.output_widget:
1085
- self._sm.options = [*self._sm.options, ("⋮", len(self._sm.options))]
1086
- self._sm.value = (
1087
- self._sm.options[-1][1],
1088
- ) # select to receive point as well
1089
- self._take_kpt(kpt) # add point, label and plot back
1090
-
1091
- def _toggle_lock(self, btn):
1092
- if self._add.description == "Lock":
1093
- self._add.description = "Unlock"
1094
- else:
1095
- self._add.description = "Lock"
1096
-
1097
- def _del_point(self, btn):
1098
- with self._interact.output_widget:
1099
- for (
1100
- v
1101
- ) in (
1102
- self._sm.value
1103
- ): # for loop here is important to update selection properly
1104
- self._sm.options = [opt for opt in self._sm.options if opt[1] != v]
1105
- self._update_selection() # update plot as well
1116
+ def get_coords_labels(self):
1117
+ "Returns tuple of (coordinates, labels) to directly plot."
1118
+ points = self.get_kpoints()
1119
+ coords = self.poscar.bz.to_cartesian([p[:3] for p in points]).tolist() if points else []
1120
+ labels = [p[3] if (len(p) >= 4 and isinstance(p[3], str)) else "" for p in points]
1121
+ numbers = [
1122
+ p[4] if len(p) == 5
1123
+ else p[3] if (len(p) == 4 and isinstance(p[3], int))
1124
+ else "" for p in points]
1125
+
1126
+ j = 0
1127
+ for i, n in enumerate(numbers, start=1):
1128
+ if isinstance(n, int) and n == 0:
1129
+ labels.insert(i + j, "")
1130
+ coords.insert(i + j, [np.nan, np.nan, np.nan])
1131
+ j += 1
1132
+ return np.array(coords), labels
1106
1133
 
1107
- def _take_kpt(self, change_or_kpt):
1108
- with self._interact.output_widget:
1109
- if isinstance(change_or_kpt, (list, tuple)):
1110
- point = change_or_kpt
1111
- else:
1112
- point = [float(v) for v in self._kpt.value.split(",")]
1113
-
1114
- if len(point) != 3:
1115
- raise ValueError("Expects KPOINT of 3 floats")
1116
-
1117
- self._kpoints.update({v: point for v in self._sm.value})
1118
- label = "{:>8.4f} {:>8.4f} {:>8.4f}".format(*point)
1119
- self._sm.options = [
1120
- (label, value) if value in self._sm.value else (lab, value)
1121
- for (lab, value) in self._sm.options
1122
- ]
1123
- self._add_label(None) # Re-adjust labels and update plot as well
1124
-
1125
- def _add_label(self, change):
1126
- with self._interact.output_widget:
1127
- labs = [" ⋮ " for _ in self._sm.options] # as much as options
1128
- for idx, (_, lab) in enumerate(
1129
- zip(self._sm.options, self._lab.value.split(","))
1130
- ):
1131
- labs[idx] = labs[idx] + lab # don't leave empty anyhow
1132
-
1133
- self._sm.options = [
1134
- (v.split("⋮")[0].strip() + lab, idx)
1135
- for (v, idx), lab in zip(self._sm.options, labs)
1136
- ]
1137
-
1138
- self._update_selection() # Update plot in both cases, by click or manual input
1134
+ def _update_selection(self):
1135
+ coords, labels = self.get_coords_labels()
1136
+ with self.params.fig.batch_animate():
1137
+ for trace in self.params.fig.data:
1138
+ if "path" in trace.name and coords.any():
1139
+ trace.x = coords[:, 0]
1140
+ trace.y = coords[:, 1]
1141
+ trace.z = coords[:, 2]
1142
+ trace.text = _fmt_labels(labels) # convert latex to html equivalent
1139
1143
 
1140
1144
  @_sub_doc(lat.get_kpath, {"kpoints :.*n :": "n :", "rec_basis :.*\n\n": "\n\n"})
1141
1145
  @_sig_kwargs(lat.get_kpath, ("kpoints", "rec_basis"))
1142
1146
  def get_kpath(self, n=5, **kwargs):
1143
1147
  return self.poscar.get_kpath(self.get_kpoints(), n=n, **kwargs)
1144
1148
 
1149
+ def iplot(self):
1150
+ "Returns disconnected current plotly figure"
1151
+ return go.Figure(data=self.params.fig.data, layout=self.params.fig.layout)
1152
+
1145
1153
  def splot(self, plane=None, fmt_label=lambda x: x, plot_kws={}, **kwargs):
1146
1154
  """
1147
1155
  Same as `ipyvasp.lattice.POSCAR.splot_bz` except it also plots path on BZ.
@@ -1165,10 +1173,5 @@ class KpathWidget(VBox):
1165
1173
  ) # plots on ax automatically
1166
1174
  return ax
1167
1175
 
1168
- def iplot(self):
1169
- "Returns disconnected current plotly figure"
1170
- return go.Figure(data=self._fig.data, layout=self._fig.layout)
1171
-
1172
-
1173
1176
  # Should be at end
1174
1177
  del fix_signature # no more need