@perspective-dev/viewer 5.5.1 → 5.6.0

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 (194) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  43. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  44. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  45. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  46. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  47. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  48. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  49. package/dist/wasm/perspective-viewer.d.ts +115 -35
  50. package/dist/wasm/perspective-viewer.js +314 -190
  51. package/dist/wasm/perspective-viewer.wasm +0 -0
  52. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  53. package/package.json +31 -18
  54. package/src/css/column-selector.css +205 -50
  55. package/src/css/ui/containers/context-menu.css +12 -1
  56. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  57. package/src/css/ui/containers/scroll-panel.css +4 -1
  58. package/src/rust/agent/client/protocol.rs +0 -89
  59. package/src/rust/agent/markdown/mod.rs +0 -238
  60. package/src/rust/agent/preamble.md +1 -0
  61. package/src/rust/agent/tools.rs +2 -16
  62. package/src/rust/components/column_selector/active_column.rs +15 -5
  63. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  64. package/src/rust/components/column_selector/config_selector.rs +34 -6
  65. package/src/rust/components/column_selector/derived_control.rs +229 -0
  66. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  67. package/src/rust/components/column_selector/filter_column.rs +0 -2
  68. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  69. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  70. package/src/rust/components/column_selector/sort_column.rs +4 -3
  71. package/src/rust/components/column_selector.rs +21 -1
  72. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  73. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  74. package/src/rust/components/column_settings_sidebar.rs +25 -19
  75. package/src/rust/components/debug_panel.rs +38 -29
  76. package/src/rust/components/dragdrop_list.rs +8 -9
  77. package/src/rust/components/main_panel/msg.rs +6 -4
  78. package/src/rust/components/main_panel/reconcile.rs +6 -1
  79. package/src/rust/components/main_panel/render.rs +70 -51
  80. package/src/rust/components/main_panel/update.rs +39 -1
  81. package/src/rust/components/main_panel.rs +97 -59
  82. package/src/rust/components/new_panel_menu.rs +69 -1
  83. package/src/rust/components/panel_menu.rs +63 -12
  84. package/src/rust/components/plugin_tab.rs +3 -2
  85. package/src/rust/components/render_warning.rs +2 -1
  86. package/src/rust/components/settings_panel.rs +4 -4
  87. package/src/rust/components/status_bar.rs +74 -24
  88. package/src/rust/components/status_indicator.rs +8 -1
  89. package/src/rust/components/viewer/filters.rs +13 -1
  90. package/src/rust/components/viewer/msg.rs +4 -0
  91. package/src/rust/components/viewer/panels.rs +41 -1
  92. package/src/rust/components/viewer/render.rs +1 -0
  93. package/src/rust/components/viewer/settings.rs +0 -22
  94. package/src/rust/components/viewer/snapshots.rs +2 -2
  95. package/src/rust/components/viewer/wiring.rs +14 -0
  96. package/src/rust/components/viewer.rs +45 -25
  97. package/src/rust/components/window_editor.rs +5 -4
  98. package/src/rust/config/column_config_schema.rs +38 -377
  99. package/src/rust/config/number_string_format.rs +0 -94
  100. package/src/rust/config/options.rs +3 -7
  101. package/src/rust/config/plugin_static_config.rs +0 -42
  102. package/src/rust/config/viewer_config.rs +47 -17
  103. package/src/rust/config/workspace_config.rs +158 -128
  104. package/src/rust/custom_elements/viewer.rs +187 -131
  105. package/src/rust/custom_events.rs +33 -18
  106. package/src/rust/derived.rs +266 -0
  107. package/src/rust/exprtk/tokenize.rs +0 -54
  108. package/src/rust/js/plugin.rs +1 -1
  109. package/src/rust/lib.rs +3 -0
  110. package/src/rust/presentation.rs +23 -4
  111. package/src/rust/queries/column_locator.rs +55 -48
  112. package/src/rust/queries/get_viewer_config.rs +1 -1
  113. package/src/rust/queries/plugin_column_styles.rs +22 -23
  114. package/src/rust/renderer/dispatch.rs +2 -0
  115. package/src/rust/renderer/plugin_config.rs +652 -487
  116. package/src/rust/renderer/props.rs +4 -0
  117. package/src/rust/renderer/registry.rs +2 -6
  118. package/src/rust/renderer/state.rs +205 -0
  119. package/src/rust/renderer.rs +201 -59
  120. package/src/rust/session/metadata.rs +30 -9
  121. package/src/rust/session/op_queue.rs +430 -0
  122. package/src/rust/session/panel_state.rs +327 -0
  123. package/src/rust/session/view_subscription.rs +42 -3
  124. package/src/rust/session.rs +900 -395
  125. package/src/rust/tasks/apply_global_filters.rs +49 -35
  126. package/src/rust/tasks/auto_pause.rs +22 -12
  127. package/src/rust/tasks/copy_export.rs +5 -0
  128. package/src/rust/tasks/create_panel.rs +34 -9
  129. package/src/rust/tasks/derive.rs +312 -0
  130. package/src/rust/tasks/mod.rs +6 -2
  131. package/src/rust/tasks/pipeline.rs +145 -89
  132. package/src/rust/tasks/reset_all.rs +31 -20
  133. package/src/rust/tasks/restore_derived.rs +200 -0
  134. package/src/rust/tasks/restore_panel.rs +153 -141
  135. package/src/rust/tasks/send_column_config.rs +56 -29
  136. package/src/rust/tasks/send_plugin_config.rs +52 -23
  137. package/src/rust/tasks/set_edit_mode.rs +33 -25
  138. package/src/rust/tasks/sync_update_panels.rs +39 -12
  139. package/src/rust/tasks/table_lifecycle.rs +70 -34
  140. package/src/rust/tasks/transactional_restore.rs +387 -0
  141. package/src/rust/tasks/update_theme.rs +45 -6
  142. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  143. package/src/rust/ui/containers/split_panel.rs +0 -41
  144. package/src/rust/ui/context_menu.rs +157 -18
  145. package/src/rust/ui/font_loader.rs +23 -35
  146. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  147. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  148. package/src/rust/ui/form/palette_selector.rs +0 -51
  149. package/src/rust/ui/portal.rs +4 -10
  150. package/src/rust/utils/browser/mod.rs +2 -3
  151. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  152. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  153. package/src/rust/utils/css.rs +0 -360
  154. package/src/rust/utils/local_fonts.rs +0 -36
  155. package/src/rust/utils/mod.rs +2 -3
  156. package/src/rust/utils/modal_position.rs +15 -21
  157. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  158. package/src/rust/utils/pubsub.rs +0 -1
  159. package/src/rust/workspace.rs +187 -140
  160. package/src/svg/derived.svg +6 -0
  161. package/src/themes/icons.css +1 -0
  162. package/src/themes/intl/ar.css +10 -0
  163. package/src/themes/intl/de.css +10 -0
  164. package/src/themes/intl/es.css +10 -0
  165. package/src/themes/intl/fr.css +10 -0
  166. package/src/themes/intl/ja.css +10 -0
  167. package/src/themes/intl/pt.css +10 -0
  168. package/src/themes/intl/zh.css +10 -0
  169. package/src/themes/intl.css +10 -0
  170. package/src/ts/extensions.ts +3 -36
  171. package/src/ts/perspective-viewer.ts +1 -1
  172. package/src/ts/plugin.ts +4 -1
  173. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  174. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  175. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  176. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  177. package/src/ts/ts-rs/TableRef.ts +9 -0
  178. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  179. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  180. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  181. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  182. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  183. package/dist/esm/perspective-viewer.inline.js +0 -21
  184. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  185. package/src/rust/tasks/restore_and_render.rs +0 -107
  186. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  187. package/src/rust/utils/tests/mod.rs +0 -14
  188. package/src/rust/utils/tests/pubsub.rs +0 -95
  189. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  190. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  191. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  192. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  193. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  194. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -13,13 +13,16 @@
13
13
  //! The multi-panel model backing a single `<perspective-viewer>`.
14
14
 
15
15
  use std::cell::{Cell, RefCell};
16
+ use std::collections::BTreeMap;
16
17
  use std::rc::Rc;
17
18
 
18
19
  use perspective_client::Client;
19
20
  use perspective_client::config::Filter;
20
21
 
22
+ use crate::config::{DerivedTableConfig, TableRef};
23
+ use crate::derived::{DerivedRegistry, DerivedTable};
21
24
  use crate::renderer::Renderer;
22
- use crate::session::Session;
25
+ use crate::session::{OverlayClause, Session};
23
26
  use crate::utils::{EffectLedger, PubSub, Subscription, spawn_owned};
24
27
 
25
28
  /// A unique identifier for a [`Panel`] within a [`Workspace`].
@@ -54,22 +57,37 @@ impl From<&str> for PanelId {
54
57
  #[derive(Default)]
55
58
  struct GlobalFilterSet {
56
59
  restored: Vec<Filter>,
57
- contributions: Vec<(PanelId, Vec<Filter>)>,
60
+
61
+ /// Each master's selection, every clause typed as the MASTER types its
62
+ /// column — what decides which listeners it applies to.
63
+ contributions: Vec<(PanelId, Vec<OverlayClause>)>,
58
64
  }
59
65
 
60
66
  impl GlobalFilterSet {
61
67
  /// The effective set: `restored`, then each contribution in order,
62
68
  /// deduped by clause equality.
63
69
  fn flatten(&self) -> Vec<Filter> {
64
- let mut flat: Vec<Filter> = Vec::new();
70
+ self.overlay().into_iter().map(|x| x.filter).collect()
71
+ }
72
+
73
+ /// [`Self::flatten`] with each clause's broadcaster type — untyped for the
74
+ /// restored bucket, which has no broadcaster.
75
+ fn overlay(&self) -> Vec<OverlayClause> {
76
+ let mut flat: Vec<OverlayClause> = Vec::new();
65
77
  let all = self
66
78
  .restored
67
79
  .iter()
68
- .chain(self.contributions.iter().flat_map(|(_, fs)| fs.iter()));
80
+ .cloned()
81
+ .map(OverlayClause::from)
82
+ .chain(
83
+ self.contributions
84
+ .iter()
85
+ .flat_map(|(_, fs)| fs.iter().cloned()),
86
+ );
69
87
 
70
- for filter in all {
71
- if !flat.contains(filter) {
72
- flat.push(filter.clone());
88
+ for clause in all {
89
+ if !flat.iter().any(|x| x.filter == clause.filter) {
90
+ flat.push(clause);
73
91
  }
74
92
  }
75
93
 
@@ -86,7 +104,8 @@ impl GlobalFilterSet {
86
104
  }
87
105
 
88
106
  /// Replace `id`'s contribution.
89
- fn set_contribution(&mut self, id: &PanelId, filters: Vec<Filter>) -> bool {
107
+ fn set_contribution(&mut self, id: &PanelId, filters: Vec<impl Into<OverlayClause>>) -> bool {
108
+ let filters = filters.into_iter().map(Into::into).collect::<Vec<_>>();
90
109
  self.with_change(|s| {
91
110
  if filters.is_empty() {
92
111
  s.contributions.retain(|(pid, _)| pid != id);
@@ -109,14 +128,14 @@ impl GlobalFilterSet {
109
128
  let owners = self
110
129
  .contributions
111
130
  .iter()
112
- .filter(|(_, fs)| fs.contains(&clause))
131
+ .filter(|(_, fs)| fs.iter().any(|f| f.filter == clause))
113
132
  .map(|(pid, _)| pid.clone())
114
133
  .collect();
115
134
 
116
135
  let changed = self.with_change(|s| {
117
136
  s.restored.retain(|f| f != &clause);
118
137
  for (_, fs) in s.contributions.iter_mut() {
119
- fs.retain(|f| f != &clause);
138
+ fs.retain(|f| f.filter != clause);
120
139
  }
121
140
 
122
141
  s.contributions.retain(|(_, fs)| !fs.is_empty());
@@ -308,9 +327,22 @@ struct WorkspaceData {
308
327
  filters: GlobalFilterSet,
309
328
  filters_changed: Rc<PubSub<()>>,
310
329
 
330
+ /// The set of hosted `Table`s changed: a client pushed a hosted-tables
331
+ /// update (observed post-sweep), or a derived `Table` was registered or
332
+ /// removed.
333
+ hosted_tables_changed: Rc<PubSub<()>>,
334
+
335
+ /// A panel's table binding changed (a rebind/pend committed, or a panel
336
+ /// entered or left the placed set), which is the input to per-table
337
+ /// consumer counts ([`Workspace::derived_consumers`]).
338
+ bindings_changed: Rc<PubSub<()>>,
339
+
311
340
  /// The loaded-clients registry + default designation.
312
341
  clients: ClientRegistry,
313
342
 
343
+ /// The live derived `Table`s this workspace owns.
344
+ derived: DerivedRegistry,
345
+
314
346
  /// Monotonic counter backing [`Workspace::generate_id`].
315
347
  next_id: usize,
316
348
 
@@ -356,7 +388,10 @@ impl Workspace {
356
388
  active: None,
357
389
  filters: GlobalFilterSet::default(),
358
390
  filters_changed: Rc::new(PubSub::default()),
391
+ hosted_tables_changed: Rc::new(PubSub::default()),
392
+ bindings_changed: Rc::new(PubSub::default()),
359
393
  clients: ClientRegistry::default(),
394
+ derived: DerivedRegistry::default(),
360
395
  next_id: 0,
361
396
  reservation: Reservation::Idle,
362
397
  pending_layout: None,
@@ -477,12 +512,26 @@ impl Workspace {
477
512
  self.0.borrow().filters.flatten()
478
513
  }
479
514
 
515
+ /// [`Self::global_filters`] as broadcast to the panels: each clause with
516
+ /// the type its column has in the master that selected it.
517
+ pub fn overlay(&self) -> Vec<OverlayClause> {
518
+ self.0.borrow().filters.overlay()
519
+ }
520
+
480
521
  /// A handle to the `filters_changed` PubSub (fires after any change to the
481
522
  /// global filter set).
482
523
  pub fn filters_changed(&self) -> Rc<PubSub<()>> {
483
524
  self.0.borrow().filters_changed.clone()
484
525
  }
485
526
 
527
+ pub fn hosted_tables_changed(&self) -> Rc<PubSub<()>> {
528
+ self.0.borrow().hosted_tables_changed.clone()
529
+ }
530
+
531
+ pub fn bindings_changed(&self) -> Rc<PubSub<()>> {
532
+ self.0.borrow().bindings_changed.clone()
533
+ }
534
+
486
535
  /// Run `f` against the filter set inside the borrow, then emit
487
536
  /// `filters_changed` — outside it — iff the effective set changed.
488
537
  /// Returns `f`'s auxiliary payload.
@@ -507,13 +556,13 @@ impl Workspace {
507
556
 
508
557
  /// Replace master `id`'s contribution with a new selection (empty =
509
558
  /// deselect). See [`GlobalFilterSet::set_contribution`].
510
- pub fn set_contribution(&self, id: &PanelId, filters: Vec<Filter>) {
559
+ pub fn set_contribution(&self, id: &PanelId, filters: Vec<OverlayClause>) {
511
560
  self.mutate_filters(|s| (s.set_contribution(id, filters), ()));
512
561
  }
513
562
 
514
563
  /// Drop master `id`'s contribution (deselect / demote / close).
515
564
  pub fn clear_contribution(&self, id: &PanelId) {
516
- self.mutate_filters(|s| (s.set_contribution(id, Vec::new()), ()));
565
+ self.mutate_filters(|s| (s.set_contribution(id, Vec::<OverlayClause>::new()), ()));
517
566
  }
518
567
 
519
568
  /// Remove the effective-set clause at `index`.
@@ -574,12 +623,19 @@ impl Workspace {
574
623
  }
575
624
  }
576
625
 
577
- /// Generate a fresh [`PanelId`], unique within this workspace.
626
+ /// Generate a fresh [`PanelId`], unique within this workspace — skipping
627
+ /// any taken by a placed panel (a restored config may carry saved
628
+ /// generated ids).
578
629
  pub fn generate_id(&self) -> PanelId {
579
630
  let mut data = self.0.borrow_mut();
580
- let n = data.next_id;
581
- data.next_id += 1;
582
- PanelId(format!("PERSPECTIVE_GENERATED_ID_{n}"))
631
+ loop {
632
+ let n = data.next_id;
633
+ data.next_id += 1;
634
+ let id = PanelId(format!("PERSPECTIVE_GENERATED_ID_{n}"));
635
+ if !data.panels.iter().any(|p| p.panel.id == id) {
636
+ return id;
637
+ }
638
+ }
583
639
  }
584
640
 
585
641
  /// The id of the active panel, or `None` with zero panels.
@@ -702,6 +758,8 @@ impl Workspace {
702
758
  if let Some(pubsub) = staged_pubsub {
703
759
  pubsub.emit(());
704
760
  }
761
+
762
+ self.bindings_changed().emit(());
705
763
  }
706
764
 
707
765
  /// Hold `panel` in the reservation slot ([`Reservation::Pending`]):
@@ -805,7 +863,9 @@ impl Workspace {
805
863
  pub fn remove_panel(&self, id: &PanelId) -> Option<Panel> {
806
864
  let (removed, changed, filters_pubsub, was_staged, staged_pubsub) = {
807
865
  let mut data = self.0.borrow_mut();
808
- let changed = data.filters.set_contribution(id, Vec::new());
866
+ let changed = data
867
+ .filters
868
+ .set_contribution(id, Vec::<OverlayClause>::new());
809
869
  let (removed, was_staged) = match data.panels.iter().position(|p| &p.panel.id == id) {
810
870
  Some(idx) => {
811
871
  let entry = data.panels.remove(idx);
@@ -838,6 +898,10 @@ impl Workspace {
838
898
  staged_pubsub.emit(());
839
899
  }
840
900
 
901
+ if removed.is_some() {
902
+ self.bindings_changed().emit(());
903
+ }
904
+
841
905
  removed
842
906
  }
843
907
 
@@ -1013,136 +1077,119 @@ impl Workspace {
1013
1077
  None => current.is_none().then(|| self.default_client()).flatten(),
1014
1078
  }
1015
1079
  }
1016
- }
1017
1080
 
1018
- #[cfg(test)]
1019
- mod tests {
1020
- use perspective_client::config::FilterTerm;
1021
-
1022
- use super::*;
1023
-
1024
- fn f(col: &str, term: &str) -> Filter {
1025
- Filter::new(col, "==", FilterTerm::Scalar(term.into()))
1026
- }
1027
-
1028
- fn p(id: &str) -> PanelId {
1029
- PanelId::from(id)
1030
- }
1031
-
1032
- #[test]
1033
- fn flatten_orders_and_dedups_across_buckets() {
1034
- let mut s = GlobalFilterSet::default();
1035
- assert!(s.set_restored(vec![f("a", "1"), f("b", "2")]));
1036
- // A non-empty contribution drops the restored bucket entirely.
1037
- assert!(s.set_contribution(&p("x"), vec![f("b", "2"), f("c", "3")]));
1038
- assert_eq!(s.flatten(), vec![f("b", "2"), f("c", "3")]);
1039
- assert!(s.set_contribution(&p("y"), vec![f("c", "3"), f("d", "4")]));
1040
- assert_eq!(s.flatten(), vec![f("b", "2"), f("c", "3"), f("d", "4")]);
1041
- }
1042
-
1043
- #[test]
1044
- fn set_contribution_replaces_not_accumulates() {
1045
- let mut s = GlobalFilterSet::default();
1046
- assert!(s.set_contribution(&p("x"), vec![f("a", "1")]));
1047
- assert!(s.set_contribution(&p("x"), vec![f("a", "2")]));
1048
- assert_eq!(s.flatten(), vec![f("a", "2")]);
1049
- // Re-selecting the same value reports no visible change.
1050
- assert!(!s.set_contribution(&p("x"), vec![f("a", "2")]));
1051
- // Empty = deselect: the entry is removed.
1052
- assert!(s.set_contribution(&p("x"), Vec::new()));
1053
- assert_eq!(s.flatten(), Vec::<Filter>::new());
1054
- }
1055
-
1056
- #[test]
1057
- fn contributions_are_per_panel() {
1058
- let mut s = GlobalFilterSet::default();
1059
- s.set_contribution(&p("x"), vec![f("a", "1")]);
1060
- s.set_contribution(&p("y"), vec![f("b", "2")]);
1061
- // Clearing one master's contribution leaves the other's intact.
1062
- assert!(s.set_contribution(&p("x"), Vec::new()));
1063
- assert_eq!(s.flatten(), vec![f("b", "2")]);
1064
- }
1065
-
1066
- #[test]
1067
- fn remove_clause_removes_from_every_bucket_and_reports_owners() {
1068
- let mut s = GlobalFilterSet::default();
1069
- s.set_contribution(&p("x"), vec![f("a", "1"), f("b", "2")]);
1070
- s.set_contribution(&p("y"), vec![f("a", "1")]);
1071
- // flatten = [a==1, b==2]; removing index 0 must drop BOTH copies of
1072
- // a==1 (owners x AND y), and the now-empty "y" entry with it.
1073
- let (changed, owners) = s.remove_clause(0);
1074
- assert!(changed);
1075
- assert_eq!(owners, vec![p("x"), p("y")]);
1076
- assert_eq!(s.flatten(), vec![f("b", "2")]);
1077
- // Out-of-range is a no-op.
1078
- assert_eq!(s.remove_clause(5), (false, Vec::new()));
1079
- }
1080
-
1081
- #[test]
1082
- fn restored_bucket_survives_deselect_but_not_selection() {
1083
- let mut s = GlobalFilterSet::default();
1084
- s.set_restored(vec![f("a", "1")]);
1085
- // A deselect (empty contribution) does NOT drop the restored bucket.
1086
- assert!(!s.set_contribution(&p("x"), Vec::new()));
1087
- assert_eq!(s.flatten(), vec![f("a", "1")]);
1088
- // A real selection replaces it.
1089
- assert!(s.set_contribution(&p("x"), vec![f("b", "2")]));
1090
- assert_eq!(s.flatten(), vec![f("b", "2")]);
1091
- assert!(s.restored.is_empty());
1092
- }
1093
-
1094
- #[test]
1095
- fn clear_drops_everything_and_reports_owners() {
1096
- let mut s = GlobalFilterSet::default();
1097
- s.set_restored(vec![f("a", "1")]);
1098
- s.set_contribution(&p("x"), vec![f("b", "2")]);
1099
- let (changed, owners) = s.clear();
1100
- assert!(changed);
1101
- assert_eq!(owners, vec![p("x")]);
1102
- assert_eq!(s.flatten(), Vec::<Filter>::new());
1103
- assert_eq!(s.clear(), (false, Vec::new()));
1104
- }
1105
-
1106
- #[test]
1107
- fn flush_state_spawns_exactly_once_per_cycle() {
1108
- let (state, spawn) = FlushState::Idle.mark(true, false);
1109
- assert!(spawn);
1110
- assert_eq!(state, FlushState::Queued {
1111
- layout: true,
1112
- active: false
1113
- });
1081
+ /// Every derivation's persisted definition, for `saveWorkspace`.
1082
+ pub fn derived_configs(&self) -> BTreeMap<String, DerivedTableConfig> {
1083
+ self.0.borrow().derived.configs()
1084
+ }
1114
1085
 
1115
- // Subsequent marks merge without re-spawning.
1116
- let (state, spawn) = state.mark(false, true);
1117
- assert!(!spawn);
1118
- assert_eq!(state, FlushState::Queued {
1119
- layout: true,
1120
- active: true
1121
- });
1086
+ pub fn is_derived(&self, name: &str) -> bool {
1087
+ self.0.borrow().derived.contains(name)
1088
+ }
1122
1089
 
1123
- let (state, spawn) = state.mark(true, true);
1124
- assert!(!spawn);
1125
- assert_eq!(state, FlushState::Queued {
1126
- layout: true,
1127
- active: true
1128
- });
1090
+ pub fn derived_names(&self) -> Vec<String> {
1091
+ self.0.borrow().derived.names()
1129
1092
  }
1130
1093
 
1131
- #[test]
1132
- fn flush_state_ignores_empty_marks() {
1133
- // A no-event mark neither queues nor spawns — `Queued{false,false}`
1134
- // is unconstructible through `mark`.
1135
- let (state, spawn) = FlushState::Idle.mark(false, false);
1136
- assert!(!spawn);
1137
- assert_eq!(state, FlushState::Idle);
1094
+ /// The recorded definition of a derived `Table`.
1095
+ pub fn derived_config(&self, name: &str) -> Option<DerivedTableConfig> {
1096
+ self.0.borrow().derived.get(name).map(|x| x.config())
1097
+ }
1098
+
1099
+ /// The name of a live derivation structurally equal to `config`.
1100
+ pub fn derived_find_structural(&self, config: &DerivedTableConfig) -> Option<String> {
1101
+ self.0.borrow().derived.find_structural(config)
1102
+ }
1138
1103
 
1139
- let queued = FlushState::Queued {
1140
- layout: true,
1141
- active: false,
1104
+ /// `name`'s definition as a self-contained [`TableRef`], recursively
1105
+ /// inlining each derived ancestor down to a concrete table name.
1106
+ pub fn inline_table_ref(&self, name: &str) -> TableRef {
1107
+ let ancestors = self.derived_ancestors(name);
1108
+ let Some(base) = ancestors.last() else {
1109
+ return TableRef::Name(name.to_owned());
1142
1110
  };
1143
1111
 
1144
- let (state, spawn) = queued.mark(false, false);
1145
- assert!(!spawn);
1146
- assert_eq!(state, queued);
1112
+ let mut out = TableRef::Name(base.clone());
1113
+ let chain = std::iter::once(name)
1114
+ .chain(ancestors[..ancestors.len() - 1].iter().map(String::as_str))
1115
+ .collect::<Vec<_>>();
1116
+
1117
+ for level in chain.into_iter().rev() {
1118
+ match self.derived_config(level) {
1119
+ Some(config) => {
1120
+ out = TableRef::Derived(Box::new(DerivedTableConfig {
1121
+ table: out,
1122
+ view_config: config.view_config,
1123
+ }));
1124
+ },
1125
+ None => return TableRef::Name(name.to_owned()),
1126
+ }
1127
+ }
1128
+
1129
+ out
1130
+ }
1131
+
1132
+ /// The `Table` names `name` derives from, nearest first.
1133
+ pub fn derived_ancestors(&self, name: &str) -> Vec<String> {
1134
+ self.0.borrow().derived.ancestors(name)
1135
+ }
1136
+
1137
+ /// How many panels and derivations read `name`.
1138
+ pub fn derived_consumers(&self, name: &str) -> usize {
1139
+ let panels = self.panels();
1140
+ let data = self.0.borrow();
1141
+ let bound = panels
1142
+ .iter()
1143
+ .filter(|panel| {
1144
+ panel
1145
+ .session
1146
+ .get_table()
1147
+ .map(|table| table.get_name().to_owned())
1148
+ .or_else(|| panel.session.pending_table())
1149
+ .is_some_and(|bound| bound == name)
1150
+ })
1151
+ .count();
1152
+
1153
+ bound + data.derived.children(name).len()
1154
+ }
1155
+
1156
+ pub fn register_derived(&self, name: String, table: DerivedTable) {
1157
+ self.0.borrow_mut().derived.insert(name, table);
1158
+ self.hosted_tables_changed().emit(());
1159
+ }
1160
+
1161
+ /// Take a derivation out of the registry so the caller can await its
1162
+ /// deletion outside the borrow.
1163
+ pub fn take_derived(&self, name: &str) -> Option<DerivedTable> {
1164
+ let derived = self.0.borrow_mut().derived.remove(name);
1165
+ if derived.is_some() {
1166
+ self.hosted_tables_changed().emit(());
1167
+ }
1168
+
1169
+ derived
1170
+ }
1171
+
1172
+ /// Which of `order` may be reused rather than rebuilt.
1173
+ pub fn derived_reusable(
1174
+ &self,
1175
+ order: &[String],
1176
+ configs: &BTreeMap<String, DerivedTableConfig>,
1177
+ ) -> std::collections::BTreeSet<String> {
1178
+ crate::derived::reusable(order, configs, &self.0.borrow().derived)
1179
+ }
1180
+
1181
+ /// The definitions waiting on a parent that is not yet hosted.
1182
+ pub fn derived_pending(&self) -> BTreeMap<String, DerivedTableConfig> {
1183
+ self.0.borrow().derived.pending()
1184
+ }
1185
+
1186
+ /// Whether `name` is a derived definition awaiting its parent, which a
1187
+ /// panel bind must PEND on rather than reject as unknown.
1188
+ pub fn is_derived_pending(&self, name: &str) -> bool {
1189
+ self.0.borrow().derived.pending().contains_key(name)
1190
+ }
1191
+
1192
+ pub fn set_derived_pending(&self, pending: BTreeMap<String, DerivedTableConfig>) {
1193
+ self.0.borrow_mut().derived.set_pending(pending);
1147
1194
  }
1148
1195
  }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="#042121"
2
+ stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
3
+ <circle cx="3.5" cy="3.5" r="1.75"/>
4
+ <circle cx="9.5" cy="9.5" r="1.75"/>
5
+ <path d="M3.5 5.25v2.25a2 2 0 0 0 2 2h2.25"/>
6
+ </svg>
@@ -63,6 +63,7 @@ perspective-viewer-tab {
63
63
  --psp-icon--reset--mask-image: url("../svg/revert-icon.svg");
64
64
  --psp-icon--copy--mask-image: url("../svg/duplicate-icon.svg");
65
65
  --psp-icon--new-panel--mask-image: url("../svg/new-panel-icon.svg");
66
+ --psp-derived--mask-image: url("../svg/derived.svg");
66
67
  --psp-icon--theme--mask-image: url("../svg/theme-icon.svg");
67
68
 
68
69
  --psp-icon--drawer-tab--mask-image: url(../svg/drawer-tab.svg);
@@ -69,6 +69,8 @@ perspective-viewer {
69
69
  --psp-label--scroll-lock-alt-toggle--content: "تمرير محاذى";
70
70
 
71
71
  --psp-label--color--content: "اللون";
72
+ --psp-label--palette--content: "لوحة الألوان";
73
+ --psp-label--gradient--content: "تدرج";
72
74
  --psp-label--timezone--content: "المنطقة الزمنية";
73
75
  --psp-label--date-style--content: "نمط التاريخ";
74
76
  --psp-label--time-style--content: "نمط الوقت";
@@ -195,4 +197,12 @@ perspective-viewer {
195
197
  --psp-label--legend-opacity--content: "العتامة";
196
198
  --psp-label--tooltip-max-column-px--content: "أقصى عرض للعمود";
197
199
  --psp-label--tooltip-opacity--content: "العتامة";
200
+
201
+ /* Derived tables */
202
+ --psp-label--derive-table--content: "اشتقاق جدول";
203
+ --psp-label--derive-table-title--content: "اشتقاق جدول جديد من هذا العرض";
204
+ --psp-label--derived-snapshot--content: "لقطة";
205
+ --psp-label--derived-snapshot-title--content: "مصدر البيانات هذا لا يبث التحديثات - بيانات هذا الجدول ثابتة عند لحظة اشتقاقه.";
206
+ --psp-label--derived-release-title--content: "العودة إلى الجدول الأصل";
207
+ --psp-label--derived-release-delete-title--content: "العودة إلى الجدول الأصل - انقر مع الضغط على Shift لحذفه";
198
208
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Farbe";
77
+ --psp-label--palette--content: "Palette";
78
+ --psp-label--gradient--content: "Gradient";
77
79
  --psp-label--timezone--content: "Zeitzone";
78
80
  --psp-label--date-style--content: "Datumsstil";
79
81
  --psp-label--time-style--content: "Zeitstil";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Spalte";
205
207
  --psp-label--group-format--content: "Format";
206
208
  --psp-label--column-size-override--content: "Spaltenbreite";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Tabelle ableiten";
212
+ --psp-label--derive-table-title--content: "Eine neue Tabelle aus dieser Ansicht ableiten";
213
+ --psp-label--derived-snapshot--content: "Snapshot";
214
+ --psp-label--derived-snapshot-title--content: "Diese Datenquelle streamt nicht - die Daten dieser Tabelle sind auf den Stand ihrer Ableitung eingefroren.";
215
+ --psp-label--derived-release-title--content: "Zurück zur übergeordneten Tabelle";
216
+ --psp-label--derived-release-delete-title--content: "Zurück zur übergeordneten Tabelle - Umschalt-Klick löscht sie";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Color";
77
+ --psp-label--palette--content: "Paleta";
78
+ --psp-label--gradient--content: "Gradiente";
77
79
  --psp-label--timezone--content: "Zona horaria";
78
80
  --psp-label--date-style--content: "Estilo de fecha";
79
81
  --psp-label--time-style--content: "Estilo de hora";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Columna";
205
207
  --psp-label--group-format--content: "Formato";
206
208
  --psp-label--column-size-override--content: "Ancho de columna";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Derivar tabla";
212
+ --psp-label--derive-table-title--content: "Derivar una nueva tabla a partir de esta vista";
213
+ --psp-label--derived-snapshot--content: "instantánea";
214
+ --psp-label--derived-snapshot-title--content: "Esta fuente de datos no transmite en continuo - los datos de esta tabla quedan fijados en el momento de su derivación.";
215
+ --psp-label--derived-release-title--content: "Volver a la tabla padre";
216
+ --psp-label--derived-release-delete-title--content: "Volver a la tabla padre - mayús+clic para eliminarla";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Couleur";
77
+ --psp-label--palette--content: "Palette";
78
+ --psp-label--gradient--content: "Dégradé";
77
79
  --psp-label--timezone--content: "Fuseau horaire";
78
80
  --psp-label--date-style--content: "Style de date";
79
81
  --psp-label--time-style--content: "Style d'heure";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Colonne";
205
207
  --psp-label--group-format--content: "Format";
206
208
  --psp-label--column-size-override--content: "Largeur de colonne";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Dériver une table";
212
+ --psp-label--derive-table-title--content: "Dériver une nouvelle table à partir de cette vue";
213
+ --psp-label--derived-snapshot--content: "instantané";
214
+ --psp-label--derived-snapshot-title--content: "Cette source de données ne diffuse pas en continu - les données de cette table sont figées au moment de sa dérivation.";
215
+ --psp-label--derived-release-title--content: "Revenir à la table parente";
216
+ --psp-label--derived-release-delete-title--content: "Revenir à la table parente - maj-clic pour la supprimer";
207
217
  }
@@ -75,6 +75,8 @@ perspective-viewer {
75
75
 
76
76
  /* Column Settings */
77
77
  --psp-label--color--content: "色";
78
+ --psp-label--palette--content: "パレット";
79
+ --psp-label--gradient--content: "グラデーション";
78
80
  --psp-label--timezone--content: "タイムゾーン";
79
81
  --psp-label--date-style--content: "日付スタイル";
80
82
  --psp-label--time-style--content: "時間スタイル";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "列";
205
207
  --psp-label--group-format--content: "書式";
206
208
  --psp-label--column-size-override--content: "列の幅";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "テーブルを派生";
212
+ --psp-label--derive-table-title--content: "このビューから新しいテーブルを派生します";
213
+ --psp-label--derived-snapshot--content: "スナップショット";
214
+ --psp-label--derived-snapshot-title--content: "このデータソースはストリーミングしません - このテーブルのデータは派生した時点で固定されます。";
215
+ --psp-label--derived-release-title--content: "親テーブルに戻る";
216
+ --psp-label--derived-release-delete-title--content: "親テーブルに戻る - Shiftクリックで削除";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Cor";
77
+ --psp-label--palette--content: "Paleta";
78
+ --psp-label--gradient--content: "Gradiente";
77
79
  --psp-label--timezone--content: "Fuso horário";
78
80
  --psp-label--date-style--content: "Estilo de data";
79
81
  --psp-label--time-style--content: "Estilo de tempo";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Coluna";
205
207
  --psp-label--group-format--content: "Formato";
206
208
  --psp-label--column-size-override--content: "Largura da coluna";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Derivar tabela";
212
+ --psp-label--derive-table-title--content: "Derivar uma nova tabela a partir desta visualização";
213
+ --psp-label--derived-snapshot--content: "instantâneo";
214
+ --psp-label--derived-snapshot-title--content: "Esta fonte de dados não transmite em contínuo - os dados desta tabela ficam fixados no momento da derivação.";
215
+ --psp-label--derived-release-title--content: "Voltar à tabela pai";
216
+ --psp-label--derived-release-delete-title--content: "Voltar à tabela pai - shift+clique para excluí-la";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "颜色";
77
+ --psp-label--palette--content: "调色板";
78
+ --psp-label--gradient--content: "渐变";
77
79
  --psp-label--timezone--content: "时区";
78
80
  --psp-label--date-style--content: "日期样式";
79
81
  --psp-label--time-style--content: "时间风格";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "列";
205
207
  --psp-label--group-format--content: "格式";
206
208
  --psp-label--column-size-override--content: "列宽";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "派生表";
212
+ --psp-label--derive-table-title--content: "从此视图派生新表";
213
+ --psp-label--derived-snapshot--content: "快照";
214
+ --psp-label--derived-snapshot-title--content: "此数据源不支持流式更新 - 该表的数据固定在派生时的状态。";
215
+ --psp-label--derived-release-title--content: "返回父表";
216
+ --psp-label--derived-release-delete-title--content: "返回父表 - Shift+点击可将其删除";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Color";
77
+ --psp-label--palette--content: "Palette";
78
+ --psp-label--gradient--content: "Gradient";
77
79
  --psp-label--timezone--content: "Timezone";
78
80
  --psp-label--date-style--content: "Date Style";
79
81
  --psp-label--time-style--content: "Time Style";
@@ -206,4 +208,12 @@ perspective-viewer {
206
208
  --psp-label--legend-opacity--content: "Opacity";
207
209
  --psp-label--tooltip-max-column-px--content: "Max column width";
208
210
  --psp-label--tooltip-opacity--content: "Opacity";
211
+
212
+ /* Derived tables */
213
+ --psp-label--derive-table--content: "Derive table";
214
+ --psp-label--derive-table-title--content: "Derive a new table from this view";
215
+ --psp-label--derived-snapshot--content: "snapshot";
216
+ --psp-label--derived-snapshot-title--content: "This data source does not stream - this table's data is fixed at the point it was derived.";
217
+ --psp-label--derived-release-title--content: "Back to the parent table";
218
+ --psp-label--derived-release-delete-title--content: "Back to the parent table - shift-click to delete this one";
209
219
  }