Repository: jupyrdf/ipyforcegraph (language: python)

Issue:
LinkSelection Behavior
## Elevator Pitch

Just like `behaviors.NodeSelection`, but for links. 

## Motivation

Display information for links onClick, etc. 

## Design Ideas

n/a



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ba73021..679a3f4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@
 - improve types and add `py.typed` file
 - adds `background_color` (defaults to transparent, encoded as `rgba(0, 0, 0, 0.0)`)
 - adds `GraphData` which can capture the as-simulated data from the browser
+- adds `LinkSelection` which mirrors `NodeSelection`, but returns link indices in
+  `.source.links`, as they are not guaranteed to have a an `id` column
+- adds `LinkWidths`
 
 ### `@jupyrdf/jupyter-forcegraph 0.2.0`
 
diff --git a/examples/Behaviors.ipynb b/examples/Behaviors.ipynb
index 07963036..146da494 100644
--- a/examples/Behaviors.ipynb
+++ b/examples/Behaviors.ipynb
@@ -517,6 +517,65 @@
     "## Link Behaviors"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "0f05d5f4-d62a-4b42-8e32-004e236e52aa",
+   "metadata": {},
+   "source": [
+    "### `LinkSelection`\n",
+    "\n",
+    "Like the `NodeSelection` behavior, this allows for selecting one or more links from the\n",
+    "browser, or updating from the kernel."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d3e6b5df-517c-47e9-b08e-e71734d61f47",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def add_link_selection(fg, box):\n",
+    "    selection = B.LinkSelection()\n",
+    "    ui_selection = W.IntsInput(\n",
+    "        placeholder=\"select some link indices\",\n",
+    "        allowed_tags=[*range(len(fg.source.links))],\n",
+    "    )\n",
+    "    box.behaviors = {**box.behaviors, \"link_selection\": selection}\n",
+    "    box.link_ui = {**box.link_ui, \"link_selection\": ui_selection}\n",
+    "    T.link((selection, \"selected\"), (ui_selection, \"value\"))\n",
+    "    return fg, box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6cc595f4-f9c9-44ec-a395-73b3451e9dd4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_link_selection(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "19ce3d0c-68a2-41fd-a386-5009672ba1c6",
+   "metadata": {},
+   "source": [
+    "```{hint}\n",
+    "Note that the links changed colors. Click a link to select it, or use <kbd>ctrl</kbd> or <kbd>shift</kbd> to select multiple links.\n",
+    "```"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "358a1d65-d246-4085-9575-824bb809d9bf",
+   "metadata": {},
+   "source": [
+    "The selection is handed back from the client, and can be used with other widgets."
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "5ecec943-13b8-44e8-b207-6c4e498f81e8",
@@ -550,6 +609,39 @@
     "    add_link_colors(fg, box)"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "34acfd88-978e-4ab4-8b39-512b95372aec",
+   "metadata": {},
+   "source": [
+    "### `LinkWidths`\n",
+    "\n",
+    "Link widths can also be configured by column name or template."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "792063b3-a60f-49e1-92ab-9aad1db1e9f4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_widths = U.make_link_behavior_with_ui(\n",
+    "    B.LinkWidths, \"link: width\", \"value\", True\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "345a0b3b-e6b2-46e7-8704-df76bb3c4496",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_link_widths(fg, box)"
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "a832686d-3178-4a68-b290-c1478cd3a25d",
@@ -741,7 +833,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "6f15b305-75c1-4692-8250-f7eaf8d296b3",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "all_behaviors = [\n",
@@ -751,7 +845,9 @@
     "    add_node_selection,\n",
     "    add_node_colors,\n",
     "    add_node_labels,\n",
+    "    add_link_selection,\n",
     "    add_link_colors,\n",
+    "    add_link_widths,\n",
     "    add_link_labels,\n",
     "    add_link_directional_arrow_color,\n",
     "    add_link_directional_arrow_length,\n",
diff --git a/js/tokens.ts b/js/tokens.ts
index 96c54c8e..abb56510 100644
--- a/js/tokens.ts
+++ b/js/tokens.ts
@@ -38,6 +38,11 @@ export const DEFAULT_COLORS = {
   background: 'rgba(0, 0, 0, 0.0)',
 };
 
+export const DEFAULT_WIDTHS = {
+  link: 1,
+  selected: 2,
+};
+
 export const WIDGET_DEFAULTS = {
   _model_module: NAME,
   _model_module_version: VERSION,
@@ -49,6 +54,7 @@ export interface IBehave {
   updateRequested: ISignal<IBehave, void>;
   // link
   getLinkColor?(options: ILinkBehaveOptions): string | null;
+  getLinkWidth?(options: ILinkBehaveOptions): string | null;
   getLinkLabel?(options: ILinkBehaveOptions): string | null;
   getLinkDirectionalArrowColor?(options: ILinkBehaveOptions): string | null;
   getLinkDirectionalArrowLength?(options: ILinkBehaveOptions): string | null;
@@ -62,12 +68,14 @@ export interface IBehave {
   getNodeLabel?(options: INodeBehaveOptions): string | null;
   // evented
   onNodeClick?(options: INodeEventBehaveOptions): boolean;
+  onLinkClick?(options: ILinkEventBehaveOptions): boolean;
   onRender?(options: IRenderOptions): void;
 }
 
 export const ALL_LINK_METHODS = [
   'getLinkLabel',
   'getLinkColor',
+  'getLinkWidth',
   'getLinkDirectionalArrowColor',
   'getLinkDirectionalArrowLength',
   'getLinkDirectionalArrowRelPos',
@@ -99,6 +107,11 @@ export interface INodeEventBehaveOptions extends INodeBehaveOptions {
 
 export interface ILinkBehaveOptions extends IBehaveOptions {
   link: LinkObject;
+  index: number;
+}
+
+export interface ILinkEventBehaveOptions extends ILinkBehaveOptions {
+  event: MouseEvent;
 }
 
 export interface IRenderOptions extends IBehaveOptions {
@@ -120,4 +133,6 @@ export interface ISource {
   dataUpdated: ISignal<ISource, void>;
 }
 
+export type TSelectedSet = Set<string | number>;
+
 export const emptyArray = Object.freeze([]);
diff --git a/js/widgets/behaviors/index.ts b/js/widgets/behaviors/index.ts
index 0f55445a..a95be1fa 100644
--- a/js/widgets/behaviors/index.ts
+++ b/js/widgets/behaviors/index.ts
@@ -4,6 +4,8 @@
  */
 
 export * from './base';
+export * from './graph-data';
+export * from './graph-image';
 export * from './link-color';
 export * from './link-directional-arrow-color';
 export * from './link-directional-arrow-length';
@@ -13,8 +15,8 @@ export * from './link-directional-particle-speed';
 export * from './link-directional-particle-width';
 export * from './link-directional-particles';
 export * from './link-label';
-export * from './graph-data';
-export * from './graph-image';
+export * from './link-selection';
+export * from './link-width';
 export * from './node-color';
 export * from './node-label';
 export * from './node-selection';
diff --git a/js/widgets/behaviors/link-selection.ts b/js/widgets/behaviors/link-selection.ts
new file mode 100644
index 00000000..7726ca3d
--- /dev/null
+++ b/js/widgets/behaviors/link-selection.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBackboneModelOptions } from '@jupyter-widgets/base';
+
+import {
+  DEFAULT_COLORS,
+  DEFAULT_WIDTHS,
+  IBehave,
+  ILinkBehaveOptions,
+  ILinkEventBehaveOptions,
+  TSelectedSet,
+  WIDGET_DEFAULTS,
+} from '../../tokens';
+
+import { BehaviorModel } from './base';
+
+export class LinkSelectionModel extends BehaviorModel implements IBehave {
+  static model_name = 'LinkSelectionModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      ...WIDGET_DEFAULTS,
+      _model_name: LinkSelectionModel.model_name,
+      selected: [],
+      selected_color: DEFAULT_COLORS.selected,
+      selected_width: DEFAULT_WIDTHS.selected,
+      multiple: true,
+    };
+  }
+
+  initialize(attributes: Backbone.ObjectHash, options: IBackboneModelOptions) {
+    super.initialize(attributes, options);
+    this.on('change:selected', this.onValueChange, this);
+    this.onValueChange();
+  }
+
+  onValueChange(change?: any) {
+    this._updateRequested.emit(void 0);
+  }
+
+  get selected(): TSelectedSet {
+    return new Set([...(this.get('selected') || [])]);
+  }
+
+  set selected(selected: TSelectedSet) {
+    this.set({ selected: [...selected] });
+    this.save_changes();
+  }
+
+  get selectedColor(): string {
+    return this.get('selected_color') || DEFAULT_COLORS.selected;
+  }
+
+  get selectedWidth(): string {
+    return this.get('selected_width') || DEFAULT_WIDTHS.selected;
+  }
+
+  getLinkWidth({ index }: ILinkBehaveOptions): string | null {
+    const width = this.selected.has(index) ? this.selectedWidth : null;
+    return width;
+  }
+
+  getLinkColor({ index }: ILinkBehaveOptions): string | null {
+    const color = this.selected.has(index) ? this.selectedColor : null;
+    return color;
+  }
+
+  onLinkClick = ({ index, event }: ILinkEventBehaveOptions): boolean => {
+    let { selected } = this;
+    if (this.get('multiple') && (event.ctrlKey || event.shiftKey || event.altKey)) {
+      selected.has(index) ? selected.delete(index) : selected.add(index);
+    } else {
+      selected.clear();
+      selected.add(index);
+    }
+
+    this.selected = selected;
+
+    return true;
+  };
+}
diff --git a/js/widgets/behaviors/link-width.ts b/js/widgets/behaviors/link-width.ts
new file mode 100644
index 00000000..bae5797f
--- /dev/null
+++ b/js/widgets/behaviors/link-width.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkWidthModel extends LinkColumnOrTemplateModel implements IBehave {
+  static model_name = 'LinkWidthModel';
+
+  defaults() {
+    return { ...super.defaults(), _model_name: LinkWidthModel.model_name };
+  }
+
+  getLinkWidth(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/node-selection.ts b/js/widgets/behaviors/node-selection.ts
index eb57a50c..e2ccd8c5 100644
--- a/js/widgets/behaviors/node-selection.ts
+++ b/js/widgets/behaviors/node-selection.ts
@@ -2,30 +2,21 @@
  * Copyright (c) 2023 ipyforcegraph contributors.
  * Distributed under the terms of the Modified BSD License.
  */
-import {
-  IBackboneModelOptions,
-  WidgetModel,
-  unpack_models as deserialize,
-} from '@jupyter-widgets/base';
+import { IBackboneModelOptions } from '@jupyter-widgets/base';
 
 import {
   DEFAULT_COLORS,
   IBehave,
   INodeBehaveOptions,
   INodeEventBehaveOptions,
+  TSelectedSet,
   WIDGET_DEFAULTS,
 } from '../../tokens';
 
 import { BehaviorModel } from './base';
 
-export type TSelectedSet = Set<string | number>;
-
 export class NodeSelectionModel extends BehaviorModel implements IBehave {
   static model_name = 'NodeSelectionModel';
-  static serializers = {
-    ...WidgetModel.serializers,
-    value: { deserialize },
-  };
 
   defaults() {
     return {
diff --git a/js/widgets/display/2d.ts b/js/widgets/display/2d.ts
index 79229ef9..5d4361d4 100644
--- a/js/widgets/display/2d.ts
+++ b/js/widgets/display/2d.ts
@@ -28,11 +28,13 @@ import {
   CSS,
   DEBUG,
   DEFAULT_COLORS,
+  DEFAULT_WIDTHS,
   EMOJI,
   EMPTY_GRAPH_DATA,
   IBehave,
   IHasGraph,
   ILinkBehaveOptions,
+  ILinkEventBehaveOptions,
   INodeBehaveOptions,
   INodeEventBehaveOptions,
   IRenderOptions,
@@ -67,6 +69,7 @@ export class ForceGraphModel extends DOMWidgetModel {
       behaviors: [],
       default_node_color: DEFAULT_COLORS.node,
       default_link_color: DEFAULT_COLORS.link,
+      default_link_width: DEFAULT_WIDTHS.link,
       background_color: DEFAULT_COLORS.background,
     };
   }
@@ -140,6 +143,10 @@ export class ForceGraphModel extends DOMWidgetModel {
     return this.get('default_link_color') || DEFAULT_COLORS.link;
   }
 
+  get defaultLinkWidth(): string {
+    return this.get('default_link_width') || DEFAULT_WIDTHS.link;
+  }
+
   get backgroundColor(): string {
     return this.get('background_color') || DEFAULT_COLORS.background;
   }
@@ -317,6 +324,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     if (graph) {
       // link
       graph.linkColor(this.wrapFunction(this.getLinkColor));
+      graph.linkWidth(this.wrapFunction(this.getLinkWidth));
       graph.linkLabel(this.wrapFunction(this.getLinkLabel));
 
       graph.linkDirectionalArrowColor(
@@ -347,6 +355,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
 
       // evented
       graph.onNodeClick(this.wrapFunction(this.onNodeClick));
+      graph.onLinkClick(this.wrapFunction(this.onLinkClick));
 
       // (3d-)force-graph-specific
       this.getOnRenderPostUpdate();
@@ -383,6 +392,9 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
   protected getLinkColor = (link: LinkObject): string => {
     return this.getComposedLinkAttr(link, 'getLinkColor', this.model.defaultLinkColor);
   };
+  protected getLinkWidth = (link: LinkObject): string => {
+    return this.getComposedLinkAttr(link, 'getLinkWidth', this.model.defaultLinkWidth);
+  };
 
   protected getLinkLabel = (link: LinkObject): string => {
     return this.getComposedLinkAttr(link, 'getLinkLabel', '');
@@ -444,6 +456,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     const graphData = (this.graph as ForceGraphInstance).graphData();
     const options: ILinkBehaveOptions = {
       view: this,
+      index: graphData.links.indexOf(link),
       graphData,
       link,
     };
@@ -513,6 +526,28 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     }
   };
 
+  protected onLinkClick = (link: LinkObject, event: MouseEvent) => {
+    const { behaviors } = this.model;
+    const graphData = (this.graph as ForceGraphInstance).graphData();
+    let shouldContinue = true;
+    const options: ILinkEventBehaveOptions = {
+      view: this,
+      graphData,
+      event,
+      link,
+      index: graphData.links.indexOf(link),
+    };
+    for (const behavior of behaviors) {
+      if (!behavior.onLinkClick) {
+        continue;
+      }
+      shouldContinue = behavior.onLinkClick(options);
+      if (!shouldContinue) {
+        return;
+      }
+    }
+  };
+
   protected updateRenderOptions(options: IRenderOptions): IRenderOptions {
     return options;
   }
diff --git a/src/ipyforcegraph/behaviors.py b/src/ipyforcegraph/behaviors.py
index f41ab759..3e91a868 100644
--- a/src/ipyforcegraph/behaviors.py
+++ b/src/ipyforcegraph/behaviors.py
@@ -144,6 +144,29 @@ class NodeColors(Behavior):
     ).tag(sync=True)
 
 
+@W.register
+class LinkSelection(Behavior):
+    """Enable link selection with synced ids of selected links."""
+
+    _model_name: str = T.Unicode("LinkSelectionModel").tag(sync=True)
+
+    selected: Tuple[Union[int, str], ...] = W.TypedTuple(
+        T.Union((T.Int(), T.Unicode())),
+        allow_none=True,
+        help="the 0-based indices of any selected links",
+    ).tag(sync=True)
+
+    multiple: bool = T.Bool(True).tag(sync=True)
+
+    selected_color: str = T.Unicode(
+        "rgba(31, 120, 179, 1.0)", help="the color of selected links"
+    ).tag(sync=True)
+
+    selected_width: float = T.Float(2, help="the width of selected links").tag(
+        sync=True
+    )
+
+
 @W.register
 class LinkColors(Behavior):
     _model_name: str = T.Unicode("LinkColorModel").tag(sync=True)
@@ -161,6 +184,23 @@ class LinkColors(Behavior):
     ).tag(sync=True)
 
 
+@W.register
+class LinkWidths(Behavior):
+    _model_name: str = T.Unicode("LinkWidthModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link widths.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link widths",
+    ).tag(sync=True)
+
+
 @W.register
 class LinkLabels(Behavior):
     _model_name: str = T.Unicode("LinkLabelModel").tag(sync=True)
diff --git a/src/ipyforcegraph/forcegraph.py b/src/ipyforcegraph/forcegraph.py
index 96ba0ab6..04656548 100644
--- a/src/ipyforcegraph/forcegraph.py
+++ b/src/ipyforcegraph/forcegraph.py
@@ -22,6 +22,7 @@ class ForceGraph(W.DOMWidget, ForceBase):
     source: DataFrameSource = T.Instance(
         DataFrameSource, kw={}, help="the source of `nodes` and `link` data"
     ).tag(sync=True, **W.widget_serialization)
+
     behaviors: Tuple[Behavior, ...] = W.TypedTuple(
         T.Instance(Behavior),
         kw={},
@@ -32,10 +33,17 @@ class ForceGraph(W.DOMWidget, ForceBase):
         "rgba(31, 120, 179, 1.0)",
         help="a default node color, which can be overridden by `NodeColors`",
     ).tag(sync=True)
+
     default_link_color: str = T.Unicode(
         "rgba(66, 66, 66, 0.5)",
         help="a default link color, which can be overridden by `LinkColors`",
     ).tag(sync=True)
+
+    default_link_width: str = T.Float(
+        1.0,
+        help="a default link width, which can be overridden by `LinkWidths`",
+    ).tag(sync=True)
+
     background_color: str = T.Unicode(
         "rgba(0, 0, 0, 0.0)", help="the graph background color"
     ).tag(sync=True)


Test patch:
diff --git a/atest/API/01_Behaviors.robot b/atest/API/01_Behaviors.robot
index 47d21023..e5b4fa61 100644
--- a/atest/API/01_Behaviors.robot
+++ b/atest/API/01_Behaviors.robot
@@ -24,6 +24,13 @@ ${SCREENS}      ${SCREENS ROOT}${/}api
     Remove Widget Tag
     Wait Until No Tag Widgets Exist
 
+2D Link Select
+    [Setup]    Set Up Behavior Example    LinkSelection    ${IPYFORCGRAPH CLASS}
+    Click IPyForceGraph Canvas
+    Wait Until Tag Widget Exists    0
+    Remove Widget Tag
+    Wait Until No Tag Widgets Exist
+
 2D Node Labels
     [Setup]    Set Up Behavior Example    NodeLabels    ${IPYFORCGRAPH CLASS}
     Click IPyForceGraph Canvas    text=hello world
@@ -35,6 +42,13 @@ ${SCREENS}      ${SCREENS ROOT}${/}api
     Remove Widget Tag
     Wait Until No Tag Widgets Exist
 
+3D Link Select
+    [Setup]    Set Up Behavior Example    LinkSelection    ${IPYFORCGRAPH CLASS 3D}
+    Click IPyForceGraph Canvas
+    Wait Until Tag Widget Exists    0
+    Remove Widget Tag
+    Wait Until No Tag Widgets Exist
+
 3D Node Labels
     [Setup]    Set Up Behavior Example    NodeLabels    ${IPYFORCGRAPH CLASS 3D}
     Click IPyForceGraph Canvas    text=hello world
diff --git a/atest/_resources/fixtures/api/LinkSelection.py b/atest/_resources/fixtures/api/LinkSelection.py
new file mode 100644
index 00000000..16c58ba9
--- /dev/null
+++ b/atest/_resources/fixtures/api/LinkSelection.py
@@ -0,0 +1,11 @@
+import traitlets as T, ipywidgets as W, pandas as pd
+from ipyforcegraph.forcegraph import WIDGET_CLASS
+from ipyforcegraph import behaviors as B
+
+b = B.LinkSelection()
+fg = WIDGET_CLASS(behaviors=[b], default_link_width=10)
+fg.source.nodes = pd.DataFrame([{"id": "hello"}, {"id": "world"}])
+fg.source.links = pd.DataFrame([{"source": "hello", "target": "world"}])
+t = W.IntsInput(allowed_tags=[0])
+T.link((b, "selected"), (t, "value"))
+W.VBox([t, fg])