@webpieces/nx-webpieces-rules 0.4.873 → 0.4.875

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.
Files changed (35) hide show
  1. package/README.md +31 -18
  2. package/package.json +7 -7
  3. package/src/executors/visualize/executor.d.ts +17 -3
  4. package/src/executors/visualize/executor.js +53 -33
  5. package/src/executors/visualize/executor.js.map +1 -1
  6. package/src/lib/graph-color-modes.d.ts +56 -34
  7. package/src/lib/graph-color-modes.js +111 -100
  8. package/src/lib/graph-color-modes.js.map +1 -1
  9. package/src/lib/graph-filter.client.d.ts +13 -2
  10. package/src/lib/graph-filter.client.js +27 -7
  11. package/src/lib/graph-filter.client.js.map +1 -1
  12. package/src/lib/graph-impact.d.ts +37 -3
  13. package/src/lib/graph-impact.js +42 -11
  14. package/src/lib/graph-impact.js.map +1 -1
  15. package/src/lib/graph-legend.js +1 -1
  16. package/src/lib/graph-legend.js.map +1 -1
  17. package/src/lib/graph-level-bands.d.ts +8 -0
  18. package/src/lib/graph-level-bands.js +13 -1
  19. package/src/lib/graph-level-bands.js.map +1 -1
  20. package/src/lib/graph-node-menu.js +10 -108
  21. package/src/lib/graph-node-menu.js.map +1 -1
  22. package/src/lib/graph-page-shell.d.ts +31 -1
  23. package/src/lib/graph-page-shell.js +75 -7
  24. package/src/lib/graph-page-shell.js.map +1 -1
  25. package/src/lib/graph-page-styles.d.ts +14 -3
  26. package/src/lib/graph-page-styles.js +89 -18
  27. package/src/lib/graph-page-styles.js.map +1 -1
  28. package/src/lib/graph-render-model.d.ts +14 -6
  29. package/src/lib/graph-render-model.js +22 -8
  30. package/src/lib/graph-render-model.js.map +1 -1
  31. package/src/lib/graph-visualizer.client.d.ts +82 -5
  32. package/src/lib/graph-visualizer.client.js +351 -23
  33. package/src/lib/graph-visualizer.client.js.map +1 -1
  34. package/src/lib/graph-visualizer.js +1 -1
  35. package/src/lib/graph-visualizer.js.map +1 -1
@@ -10,16 +10,17 @@
10
10
  class WpModeState {
11
11
  static MODES = ['runtime', 'architecture', 'impact'];
12
12
  static NAMES = ['Runtime', 'Architecture', 'Impact'];
13
+ static SUBTITLES = ['where the code can run', 'servers · clients · APIs', 'what this branch touches'];
13
14
  static STORAGE_KEY = 'wp-architecture-graph-mode';
14
15
  impact = window.__WP_IMPACT__ ?? null;
15
16
  mode = 'runtime';
16
17
  constructor() {
17
18
  this.mode = this.initial();
18
19
  }
19
- /** '' when Impact can be shown, else the one line the drawer and the menu say instead. */
20
+ /** '' when Impact can be shown, else the one line the drawer and the Filter popover say instead. */
20
21
  impactReason() {
21
22
  if (this.impact === null)
22
- return 'No impact data for this branch. Run pnpm nx run architecture:generate to compute it.';
23
+ return 'No impact data for this branch. Run pnpm arch:visualize (nx run architecture:visualize) to compute it — no full regenerate needed.';
23
24
  return this.impact.available ? '' : `Impact unavailable: ${this.impact.reason}.`;
24
25
  }
25
26
  available(mode) {
@@ -28,6 +29,9 @@ class WpModeState {
28
29
  name(mode) {
29
30
  return WpModeState.NAMES[WpModeState.MODES.indexOf(mode)] ?? mode;
30
31
  }
32
+ subtitle(mode) {
33
+ return WpModeState.SUBTITLES[WpModeState.MODES.indexOf(mode)] ?? '';
34
+ }
31
35
  /** False (and nothing changes) for an unknown or unavailable mode. */
32
36
  set(mode) {
33
37
  if (!this.available(mode))
@@ -78,6 +82,87 @@ class WpModeState {
78
82
  }
79
83
  }
80
84
  }
85
+ /**
86
+ * One state of the Filter popover: a change scope plus the selected Runtime and Role chips. The page
87
+ * holds the APPLIED one; the popover edits a copy until "Show N projects".
88
+ */
89
+ class WpFilterChoice {
90
+ scope = 'everything';
91
+ runtimes = new Set();
92
+ roles = new Set();
93
+ copy() {
94
+ const copy = new WpFilterChoice();
95
+ copy.scope = this.scope;
96
+ for (const runtime of this.runtimes)
97
+ copy.runtimes.add(runtime);
98
+ for (const role of this.roles)
99
+ copy.roles.add(role);
100
+ return copy;
101
+ }
102
+ /** How many filters are active: the drawer button's badge. */
103
+ count() {
104
+ return (this.scope === 'everything' ? 0 : 1) + this.runtimes.size + this.roles.size;
105
+ }
106
+ }
107
+ /**
108
+ * The Filter popover's three groups, INTERSECTED: a box stays when it is in the change scope, AND has
109
+ * any selected runtime chip (when one is selected), AND has any selected role chip (likewise).
110
+ */
111
+ class WpProjectFilter {
112
+ model;
113
+ modes;
114
+ static SCOPE_NAMES = new Map([
115
+ ['everything', 'Everything'],
116
+ ['changed', 'Changed'],
117
+ ['dependents', 'Changed + what uses them'],
118
+ ['dependencies', 'Changed + what they use'],
119
+ ['build', 'Whole build of this branch'],
120
+ ]);
121
+ constructor(model, modes) {
122
+ this.model = model;
123
+ this.modes = modes;
124
+ }
125
+ /** The node ids a change scope keeps; null for "everything" (and whenever Impact is unavailable). */
126
+ scopeSet(scope) {
127
+ const impact = this.modes.impact;
128
+ if (scope === 'everything' || impact === null || this.modes.impactReason() !== '')
129
+ return null;
130
+ const ids = [...impact.touched];
131
+ if (scope === 'dependents' || scope === 'build')
132
+ ids.push(...impact.affected);
133
+ if (scope === 'dependencies')
134
+ ids.push(...impact.dependencies);
135
+ if (scope === 'build')
136
+ ids.push(...impact.buildInputs);
137
+ return new Set(ids);
138
+ }
139
+ /** How many drawn projects a scope keeps on its own: the count beside each option. */
140
+ scopeCount(scope) {
141
+ const set = this.scopeSet(scope);
142
+ return this.model.nodes.filter((node) => set === null || set.has(node.id)).length;
143
+ }
144
+ /** `base` (everything, or the Hide-unconnected chain) narrowed by `choice`. */
145
+ apply(base, choice) {
146
+ const scope = this.scopeSet(choice.scope);
147
+ const kept = new Set();
148
+ for (const node of this.model.nodes) {
149
+ if (!base.has(node.id))
150
+ continue;
151
+ if (scope !== null && !scope.has(node.id))
152
+ continue;
153
+ if (!this.anyOf(choice.runtimes, node.tags?.frameworks ?? []))
154
+ continue;
155
+ if (!this.anyOf(choice.roles, node.tags === null ? [] : [node.tags.role]))
156
+ continue;
157
+ kept.add(node.id);
158
+ }
159
+ return kept;
160
+ }
161
+ /** An empty chip group matches every box; otherwise the box must carry one of the chips. */
162
+ anyOf(selected, carried) {
163
+ return selected.size === 0 || carried.some((value) => selected.has(value));
164
+ }
165
+ }
81
166
  /** Architecture interactions bind to each new SVG; logical Lock/Filter state belongs to the page. */
82
167
  class GraphHighlighter {
83
168
  svg;
@@ -160,24 +245,121 @@ class GraphHighlighter {
160
245
  const locked = this.page.lockSelection() === name;
161
246
  items.push(new WpNodeMenuItem(locked ? 'Unlock' : 'Lock', () => this.page.setLock(locked ? null : name)));
162
247
  items.push(this.page.filterItem(name));
163
- items.push(this.page.modeItem());
164
248
  return items;
165
249
  });
166
250
  }
167
251
  }
252
+ /**
253
+ * The Filter popover: edits a COPY of the page's applied filter, shows what each choice would keep,
254
+ * and hands the copy to the page on "Show N projects". Closing it any other way discards the edit.
255
+ */
256
+ class GraphFilterPopover {
257
+ page;
258
+ draft = new WpFilterChoice();
259
+ constructor(page) {
260
+ this.page = page;
261
+ }
262
+ wire() {
263
+ this.byId('wp-filter-open')?.addEventListener('click', (ev) => {
264
+ ev.stopPropagation();
265
+ if (this.isOpen())
266
+ this.close();
267
+ else
268
+ this.open();
269
+ });
270
+ this.byId('wp-filter-close')?.addEventListener('click', () => this.close());
271
+ this.byId('wp-filter-clear')?.addEventListener('click', () => {
272
+ this.draft = new WpFilterChoice();
273
+ this.paint();
274
+ });
275
+ this.byId('wp-filter-apply')?.addEventListener('click', () => {
276
+ this.page.applyFilter(this.draft.copy());
277
+ this.close();
278
+ });
279
+ document.querySelectorAll('input[name="wp-scope"]').forEach((radio) => {
280
+ radio.addEventListener('change', () => {
281
+ if (!radio.checked)
282
+ return;
283
+ this.draft.scope = radio.value;
284
+ this.paint();
285
+ });
286
+ });
287
+ document.querySelectorAll('.wp-chip[data-wp-chip]').forEach((chip) => {
288
+ chip.addEventListener('click', () => {
289
+ const set = chip.dataset['wpChipGroup'] === 'role' ? this.draft.roles : this.draft.runtimes;
290
+ const value = chip.dataset['wpChip'] ?? '';
291
+ if (set.has(value))
292
+ set.delete(value);
293
+ else
294
+ set.add(value);
295
+ this.paint();
296
+ });
297
+ });
298
+ }
299
+ isOpen() {
300
+ return this.byId('wp-filter-pop')?.hidden === false;
301
+ }
302
+ open() {
303
+ const pop = this.byId('wp-filter-pop');
304
+ if (pop === null)
305
+ return;
306
+ this.draft = this.page.appliedFilter().copy();
307
+ this.paint();
308
+ pop.hidden = false;
309
+ this.byId('wp-filter-open')?.setAttribute('aria-expanded', 'true');
310
+ }
311
+ close() {
312
+ const pop = this.byId('wp-filter-pop');
313
+ if (pop === null || pop.hidden)
314
+ return;
315
+ pop.hidden = true;
316
+ this.byId('wp-filter-open')?.setAttribute('aria-expanded', 'false');
317
+ }
318
+ /** Repaint the controls from the draft: checked scope, pressed chips, counts and the footer. */
319
+ paint() {
320
+ const reason = this.page.scopeReason();
321
+ const group = this.byId('wp-scope-group');
322
+ if (group !== null)
323
+ group.disabled = reason !== '';
324
+ const reasonEl = this.byId('wp-scope-reason');
325
+ if (reasonEl !== null) {
326
+ reasonEl.textContent = reason;
327
+ reasonEl.hidden = reason === '';
328
+ }
329
+ document.querySelectorAll('input[name="wp-scope"]').forEach((radio) => {
330
+ radio.checked = radio.value === this.draft.scope;
331
+ });
332
+ document.querySelectorAll('[data-wp-scope-count]').forEach((count) => {
333
+ count.textContent = reason === '' ? String(this.page.scopeCount(count.dataset['wpScopeCount'] ?? '')) : '';
334
+ });
335
+ document.querySelectorAll('.wp-chip[data-wp-chip]').forEach((chip) => {
336
+ const set = chip.dataset['wpChipGroup'] === 'role' ? this.draft.roles : this.draft.runtimes;
337
+ chip.setAttribute('aria-pressed', String(set.has(chip.dataset['wpChip'] ?? '')));
338
+ });
339
+ const apply = this.byId('wp-filter-apply');
340
+ if (apply !== null) {
341
+ const shown = this.page.preview(this.draft);
342
+ apply.textContent = `Show ${shown} project${shown === 1 ? '' : 's'}`;
343
+ }
344
+ }
345
+ byId(id) {
346
+ return document.getElementById(id);
347
+ }
348
+ }
168
349
  /**
169
350
  * The drawer and its floating panels: every control outside the graph itself. Holds no graph state —
170
351
  * each control calls back into the page, and `sync()` repaints the controls from the page's state.
171
352
  */
172
353
  class GraphDrawer {
173
354
  page;
355
+ popover;
174
356
  constructor(page) {
175
357
  this.page = page;
358
+ this.popover = new GraphFilterPopover(page);
176
359
  }
177
360
  wire() {
178
- document.querySelectorAll('.wp-mode[data-wp-mode]').forEach((button) => {
179
- button.addEventListener('click', () => this.page.setMode(button.dataset['wpMode'] ?? 'runtime'));
180
- });
361
+ this.wireModeMenu();
362
+ this.popover.wire();
181
363
  this.wireLock();
182
364
  this.byId('wp-filter-toggle')?.addEventListener('click', () => this.page.toggleFilter());
183
365
  this.toggles('wp-collapse', 'wp-shell', true);
@@ -190,6 +372,45 @@ class GraphDrawer {
190
372
  this.toggles('wp-snapshot-open', 'wp-snapshot', false);
191
373
  window.addEventListener('keydown', (ev) => this.onKey(ev), true);
192
374
  window.addEventListener('hashchange', () => this.page.setMode(location.hash.slice(1)));
375
+ document.addEventListener('click', (ev) => {
376
+ const target = ev.target;
377
+ if (target instanceof Node && this.byId('wp-mode-menu')?.parentElement?.contains(target))
378
+ return;
379
+ this.closeModeMenu(false);
380
+ });
381
+ }
382
+ /** "Color by": the pill trigger opens the menu of three; picking one switches and closes it. */
383
+ wireModeMenu() {
384
+ this.byId('wp-mode-trigger')?.addEventListener('click', (ev) => {
385
+ ev.stopPropagation();
386
+ const menu = this.byId('wp-mode-menu');
387
+ if (menu === null)
388
+ return;
389
+ if (!menu.hidden) {
390
+ this.closeModeMenu(false);
391
+ return;
392
+ }
393
+ menu.hidden = false;
394
+ this.byId('wp-mode-trigger')?.setAttribute('aria-expanded', 'true');
395
+ menu.querySelector('.wp-mode[aria-checked="true"]')?.focus({ preventScroll: true });
396
+ });
397
+ document.querySelectorAll('.wp-mode[data-wp-mode]').forEach((button) => {
398
+ button.addEventListener('click', (ev) => {
399
+ ev.stopPropagation();
400
+ this.closeModeMenu(true);
401
+ this.page.setMode(button.dataset['wpMode'] ?? 'runtime');
402
+ });
403
+ });
404
+ }
405
+ closeModeMenu(returnFocus) {
406
+ const menu = this.byId('wp-mode-menu');
407
+ if (menu === null || menu.hidden)
408
+ return;
409
+ menu.hidden = true;
410
+ const trigger = this.byId('wp-mode-trigger');
411
+ trigger?.setAttribute('aria-expanded', 'false');
412
+ if (returnFocus)
413
+ trigger?.focus({ preventScroll: true });
193
414
  }
194
415
  /** Type-to-search lock: an exact project id (or a unique short name) locks, empty or Esc unlocks. */
195
416
  wireLock() {
@@ -221,11 +442,20 @@ class GraphDrawer {
221
442
  }
222
443
  /**
223
444
  * Page keys, in the CAPTURE phase so the open/closed state of the node menu is read before the
224
- * menu's own Escape handler closes it: Esc with a menu open only closes the menu.
445
+ * menu's own Escape handler closes it: Esc with a menu open only closes the menu. Esc closes an
446
+ * open Color-by menu or Filter popover first, too.
225
447
  */
226
448
  onKey(ev) {
227
449
  if (ev.altKey || ev.ctrlKey || ev.metaKey)
228
450
  return;
451
+ if (ev.key === 'Escape' && this.byId('wp-mode-menu')?.hidden === false) {
452
+ this.closeModeMenu(true);
453
+ return;
454
+ }
455
+ if (ev.key === 'Escape' && this.popover.isOpen()) {
456
+ this.popover.close();
457
+ return;
458
+ }
229
459
  const target = ev.target;
230
460
  if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
231
461
  return;
@@ -263,15 +493,17 @@ class GraphDrawer {
263
493
  });
264
494
  }
265
495
  /** Repaint every control from the page's state. */
266
- sync(modes, locked, filtering) {
496
+ sync(modes, locked, filtering, filter) {
267
497
  const reason = modes.impactReason();
268
498
  document.querySelectorAll('.wp-mode[data-wp-mode]').forEach((button) => {
269
499
  const mode = button.dataset['wpMode'] ?? '';
270
- button.setAttribute('aria-pressed', String(mode === modes.mode));
500
+ button.setAttribute('aria-checked', String(mode === modes.mode));
271
501
  button.disabled = !modes.available(mode);
272
502
  if (mode === 'impact')
273
503
  button.title = reason === '' ? 'Key 3' : reason;
274
504
  });
505
+ this.text('wp-mode-current', modes.name(modes.mode));
506
+ this.text('wp-mode-current-sub', modes.subtitle(modes.mode));
275
507
  const reasonEl = this.byId('wp-impact-reason');
276
508
  if (reasonEl !== null) {
277
509
  reasonEl.textContent = reason;
@@ -290,6 +522,7 @@ class GraphDrawer {
290
522
  if (input !== null && document.activeElement !== input)
291
523
  input.value = locked ?? '';
292
524
  this.syncFilterToggle(locked, filtering);
525
+ this.syncFilter(filter);
293
526
  this.crumb(modes.name(modes.mode), locked);
294
527
  }
295
528
  syncFilterToggle(locked, filtering) {
@@ -300,13 +533,67 @@ class GraphDrawer {
300
533
  toggle.disabled = !filtering && locked === null;
301
534
  toggle.title = toggle.disabled ? 'Lock a project first' : '';
302
535
  }
536
+ /** The drawer button's count badge, and one removable pill per active filter in the top bar. */
537
+ syncFilter(filter) {
538
+ const badge = this.byId('wp-filter-badge');
539
+ if (badge !== null) {
540
+ badge.textContent = String(filter.count());
541
+ badge.hidden = filter.count() === 0;
542
+ }
543
+ const pills = this.byId('wp-filter-pills');
544
+ if (pills === null)
545
+ return;
546
+ const items = [];
547
+ if (filter.scope !== 'everything')
548
+ items.push(this.pill(WpProjectFilter.SCOPE_NAMES.get(filter.scope) ?? filter.scope, 'scope', filter.scope));
549
+ for (const runtime of filter.runtimes)
550
+ items.push(this.pill(runtime, 'runtime', runtime));
551
+ for (const role of filter.roles)
552
+ items.push(this.pill(role, 'role', role));
553
+ pills.replaceChildren(...items);
554
+ }
555
+ /** A pill, built with text nodes so no value is parsed as HTML. */
556
+ pill(label, group, value) {
557
+ const pill = document.createElement('span');
558
+ pill.className = 'wp-pill';
559
+ pill.dataset['wpPill'] = `${group}:${value}`;
560
+ pill.append(label);
561
+ const remove = document.createElement('button');
562
+ remove.type = 'button';
563
+ remove.textContent = '✕';
564
+ remove.setAttribute('aria-label', `Remove filter ${label}`);
565
+ remove.addEventListener('click', (ev) => {
566
+ ev.stopPropagation();
567
+ this.page.removeFilter(group, value);
568
+ });
569
+ pill.append(remove);
570
+ return pill;
571
+ }
303
572
  impactNote(modes) {
304
573
  const impact = modes.impact;
305
574
  if (impact === null || !impact.available)
306
575
  return '';
307
- return (`${impact.touched.length} touched · ${impact.affected.length} affected · ` +
576
+ const stats = `${impact.touched.length} touched · ${impact.affected.length} affected · ` +
308
577
  `${impact.buildInputs.length} build inputs — nx affected since fork point ${impact.base}, ` +
309
- `${impact.changedFiles} changed files.`);
578
+ `${impact.changedFiles} changed files.`;
579
+ const cause = this.globalCause(impact);
580
+ return cause === '' ? stats : `${cause}. ${stats}`;
581
+ }
582
+ /**
583
+ * When files changed but no project owns one, everything nx affected is affected by a
584
+ * workspace-global input (a lockfile, the workspace manifest). That is correct for CI, and
585
+ * baffling on a graph, so the note names the cause.
586
+ */
587
+ globalCause(impact) {
588
+ if (impact.touched.length > 0 || impact.changedFiles === 0)
589
+ return '';
590
+ const affected = impact.affected.length;
591
+ const who = affected >= this.page.projectCount() ? 'Every project affected' : `${affected} projects affected`;
592
+ const files = impact.globalFiles;
593
+ if (files.length === 0)
594
+ return `${who}: ${impact.changedFiles} changed files that no project owns (workspace-global input)`;
595
+ const named = files.slice(0, 2).join(', ') + (files.length > 2 ? ` and ${files.length - 2} more` : '');
596
+ return `${who}: ${named} changed (workspace-global input${files.length > 1 ? 's' : ''})`;
310
597
  }
311
598
  /** "Mode: <b>Runtime</b> · Locked: <b>x</b>", built with text nodes so no name is parsed as HTML. */
312
599
  crumb(mode, locked) {
@@ -323,6 +610,11 @@ class GraphDrawer {
323
610
  parts.push(' · Locked: ', bold(locked));
324
611
  crumb.replaceChildren(...parts);
325
612
  }
613
+ text(id, value) {
614
+ const el = this.byId(id);
615
+ if (el !== null)
616
+ el.textContent = value;
617
+ }
326
618
  lockInput() {
327
619
  return document.getElementById('wp-lock');
328
620
  }
@@ -333,6 +625,8 @@ class GraphDrawer {
333
625
  class GraphPage extends WpFilterPage {
334
626
  highlighter = null;
335
627
  modes = new WpModeState();
628
+ projects = new WpProjectFilter(this.model, this.modes);
629
+ applied = new WpFilterChoice();
336
630
  drawer = new GraphDrawer(this);
337
631
  captureBinding(_svg) {
338
632
  const previous = this.highlighter;
@@ -357,7 +651,14 @@ class GraphPage extends WpFilterPage {
357
651
  return modes.runtime;
358
652
  }
359
653
  usesFullDot() {
360
- return this.modes.mode === 'runtime';
654
+ return this.modes.mode === 'runtime' && this.applied.count() === 0;
655
+ }
656
+ narrow(retained) {
657
+ return this.projects.apply(retained, this.applied);
658
+ }
659
+ /** Filtering keeps every box in its own L-row: an emptied level stays as a thin labeled band. */
660
+ keepsEmptyLevels() {
661
+ return true;
361
662
  }
362
663
  wireSvg(svg) {
363
664
  this.highlighter = new GraphHighlighter(svg, this.chain, this.model, this);
@@ -375,6 +676,9 @@ class GraphPage extends WpFilterPage {
375
676
  hasNode(id) {
376
677
  return this.model.nodes.some((node) => node.id === id);
377
678
  }
679
+ projectCount() {
680
+ return this.model.nodes.length;
681
+ }
378
682
  /** An exact id, else the one id whose short name (after any scope) is `text`; null if none or several. */
379
683
  resolveName(text) {
380
684
  if (text === '')
@@ -403,19 +707,43 @@ class GraphPage extends WpFilterPage {
403
707
  else if (this.locked !== null)
404
708
  this.filter(this.locked);
405
709
  }
406
- /** The node menu's "Mode ▸" submenu: one radio item per mode, Impact disabled with its reason. */
407
- modeItem() {
408
- const children = WpModeState.MODES.map((mode) => {
409
- const item = new WpNodeMenuItem(this.modes.name(mode), () => this.setMode(mode));
410
- item.checked = mode === this.modes.mode;
411
- if (!this.modes.available(mode))
412
- item.disabledReason = this.modes.impactReason();
413
- return item;
414
- });
415
- return new WpNodeMenuItem('Mode', () => { }, children);
710
+ appliedFilter() {
711
+ return this.applied;
712
+ }
713
+ /** '' when the change-scope group can be used, else Impact's reason (the group is disabled). */
714
+ scopeReason() {
715
+ return this.modes.impactReason();
716
+ }
717
+ scopeCount(scope) {
718
+ return this.projects.scopeCount(scope);
719
+ }
720
+ /** How many boxes `choice` would show, with the current Hide-unconnected chain. */
721
+ preview(choice) {
722
+ const base = this.anchor === null
723
+ ? new Set(this.model.nodes.map((node) => node.id))
724
+ : this.chain.nodes(this.anchor);
725
+ return this.projects.apply(base, choice).size;
726
+ }
727
+ applyFilter(choice) {
728
+ if (this.scopeReason() !== '')
729
+ choice.scope = 'everything';
730
+ this.applied = choice;
731
+ this.syncControls();
732
+ this.redraw();
733
+ }
734
+ /** A top-bar pill's ✕. */
735
+ removeFilter(group, value) {
736
+ const next = this.applied.copy();
737
+ if (group === 'scope')
738
+ next.scope = 'everything';
739
+ else if (group === 'role')
740
+ next.roles.delete(value);
741
+ else
742
+ next.runtimes.delete(value);
743
+ this.applyFilter(next);
416
744
  }
417
745
  syncControls() {
418
- this.drawer.sync(this.modes, this.locked, this.anchor !== null);
746
+ this.drawer.sync(this.modes, this.locked, this.anchor !== null, this.applied);
419
747
  }
420
748
  filterCards() {
421
749
  const lit = this.locked === null ? this.retained : this.chain.nodes(this.locked);