lightgraph 1.1.1__tar.gz → 1.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightgraph
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: A lightweight Python binding for lightGraph network visualization
5
5
  Author-email: Hao Zhu <haozhu233@gmail.com>, Aiden McComiskey <aiden.mccomiskey@tufts.edu>, Donna Slonim <donna.slonim@tufts.edu>
6
6
  Requires-Python: >=3.6
@@ -91,7 +91,15 @@
91
91
  groups: {
92
92
  fillOpacity: 0.125,
93
93
  strokeWidth: 2,
94
- showEllipses: true
94
+ showEllipses: true,
95
+ // Pinned assignments: {groupName: hexColor}. Wins over the
96
+ // palette for the named groups; unmatched names are ignored so
97
+ // one map can be reused across filtered subsets.
98
+ colors: null,
99
+ // Explicit palette domain: [groupName, ...]. Listed groups keep
100
+ // their palette slot even when absent from the data, so a shared
101
+ // order keeps colors stable across a series of figures.
102
+ colorOrder: null
95
103
  },
96
104
  canvas: {
97
105
  backgroundColor: '#ffffff',
@@ -918,6 +926,35 @@
918
926
  ...d3.schemeSet3
919
927
  ];
920
928
  const groupColorScale = d3.scaleOrdinal(extendedColors);
929
+ // Seed the palette domain deterministically on every data load:
930
+ // config.groups.colorOrder first (listed groups keep their slot even
931
+ // when absent from the data), then the remaining groups in sorted
932
+ // order. Without an explicit domain d3 assigns colors by first paint
933
+ // order, so the same group changes color whenever the node array does.
934
+ function updateGroupColorDomain() {
935
+ const present = [...new Set(nodes.map(n => n.group).filter(Boolean))].sort();
936
+ const order = config.groups.colorOrder;
937
+ const ordered = Array.isArray(order) ? order.map(String)
938
+ : (order != null ? [String(order)] : []);
939
+ const orderSet = new Set(ordered);
940
+ const domain = [...new Set([...ordered, ...present.filter(g => !orderSet.has(g))])];
941
+ if (domain.length > extendedColors.length) {
942
+ console.warn(`lightGraph: ${domain.length} groups exceed the `
943
+ + `${extendedColors.length}-color palette; colors will repeat. `
944
+ + `Pin colors via config.groups.colors to disambiguate.`);
945
+ }
946
+ groupColorScale.domain(domain);
947
+ }
948
+ // Single resolution point for every group-colored surface (legend
949
+ // swatch, node fill, ellipse, tooltip dot, SVG export): pinned
950
+ // config.groups.colors first, palette otherwise.
951
+ function getGroupColor(group) {
952
+ const pinned = config.groups.colors;
953
+ if (pinned && Object.prototype.hasOwnProperty.call(pinned, group)) {
954
+ return pinned[group];
955
+ }
956
+ return groupColorScale(group);
957
+ }
921
958
  // Modeless interaction: d3-zoom owns wheel events and drags that
922
959
  // start on empty space; node drags and shift box-selects fall
923
960
  // through to the canvas listeners below.
@@ -1148,7 +1185,7 @@
1148
1185
  width: '11px',
1149
1186
  height: '11px',
1150
1187
  borderRadius: '3px',
1151
- backgroundColor: groupColorScale(group),
1188
+ backgroundColor: getGroupColor(group),
1152
1189
  flexShrink: '0'
1153
1190
  });
1154
1191
 
@@ -1403,7 +1440,7 @@
1403
1440
  maxNodeSize = config.nodes.defaultSize;
1404
1441
  for (const d of nodes) {
1405
1442
  if (egoSet && !egoSet.has(d)) continue;
1406
- const color = d.group ? groupColorScale(d.group) : (d.color || config.nodes.defaultColor);
1443
+ const color = d.group ? getGroupColor(d.group) : (d.color || config.nodes.defaultColor);
1407
1444
  const isSelected = selectedNodes.has(d);
1408
1445
  const isFaded = highlightSet !== null && !highlightSet.has(d);
1409
1446
  let opacity = d.opacity !== undefined ? d.opacity : config.nodes.defaultOpacity;
@@ -1608,9 +1645,9 @@
1608
1645
  context.rotate(angle);
1609
1646
  context.beginPath();
1610
1647
  context.ellipse(0, 0, radiusX + 5, radiusY + 5, 0, 0, 2 * Math.PI);
1611
- context.fillStyle = hexToRgba(groupColorScale(group), config.groups.fillOpacity);
1648
+ context.fillStyle = hexToRgba(getGroupColor(group), config.groups.fillOpacity);
1612
1649
  context.fill();
1613
- context.strokeStyle = groupColorScale(group);
1650
+ context.strokeStyle = getGroupColor(group);
1614
1651
  context.lineWidth = config.groups.strokeWidth;
1615
1652
  context.stroke();
1616
1653
  context.restore();
@@ -1648,6 +1685,7 @@
1648
1685
  nodes = newNodes;
1649
1686
  edges = newEdges;
1650
1687
 
1688
+ updateGroupColorDomain();
1651
1689
  recalculateForce();
1652
1690
  updateEdgeAlpha();
1653
1691
  updateLegend();
@@ -1929,7 +1967,7 @@
1929
1967
  if (config.ui.showTooltips && hoveredNode) {
1930
1968
  let tooltipContent = `<strong>${escapeHtml(hoveredNode.id)}</strong>`;
1931
1969
  if (hoveredNode.group) {
1932
- tooltipContent += `<br><span style="color: ${groupColorScale(hoveredNode.group)};">● ${escapeHtml(hoveredNode.group)}</span>`;
1970
+ tooltipContent += `<br><span style="color: ${getGroupColor(hoveredNode.group)};">● ${escapeHtml(hoveredNode.group)}</span>`;
1933
1971
  }
1934
1972
  // Add any custom metadata
1935
1973
  if (hoveredNode.metadata) {
@@ -2158,7 +2196,7 @@
2158
2196
  circle.setAttribute('cy', node.y);
2159
2197
  circle.setAttribute('r',
2160
2198
  (node.size || config.nodes.defaultSize) * config.nodes.sizeScale / 2);
2161
- circle.setAttribute('fill', node.group ? groupColorScale(node.group) : (node.color || config.nodes.defaultColor));
2199
+ circle.setAttribute('fill', node.group ? getGroupColor(node.group) : (node.color || config.nodes.defaultColor));
2162
2200
  circle.setAttribute('stroke', config.nodes.borderColor);
2163
2201
  circle.setAttribute('stroke-width', config.nodes.borderWidth);
2164
2202
  g.appendChild(circle);
@@ -2246,6 +2284,9 @@
2246
2284
  // Merge a partial config over the current one and re-render.
2247
2285
  this.updateConfig = (partial = {}) => {
2248
2286
  config = mergeConfig(config, partial);
2287
+ if (partial.groups && ('colors' in partial.groups || 'colorOrder' in partial.groups)) {
2288
+ updateGroupColorDomain();
2289
+ }
2249
2290
  if (partial.edges && 'showArrows' in partial.edges) {
2250
2291
  showArrows = config.edges.showArrows;
2251
2292
  arrowsToggle.checked = showArrows;
@@ -8,7 +8,7 @@ import base64
8
8
  import uuid
9
9
  import numpy as np
10
10
  import os
11
- from typing import Optional, Dict, Union, Literal
11
+ from typing import Optional, Dict, List, Union, Literal
12
12
 
13
13
  from .analytics import _normalize_edges, communities as _communities
14
14
 
@@ -151,6 +151,8 @@ def net_vis(
151
151
  adj_matrix=None,
152
152
  node_names=None,
153
153
  node_groups: Optional[Union[Dict[str, str], str]] = None,
154
+ group_colors: Optional[Dict[str, str]] = None,
155
+ group_order: Optional[List[str]] = None,
154
156
  node_colors: Optional[Dict[str, str]] = None,
155
157
  node_sizes: Optional[Dict[str, float]] = None,
156
158
  remove_unconnected: bool = True,
@@ -252,6 +254,18 @@ def net_vis(
252
254
  Dictionary mapping node names to group identifiers for coloring.
253
255
  Pass 'auto' to detect communities automatically (networkx Louvain
254
256
  when installed, else a built-in label-propagation fallback).
257
+ group_colors : dict, optional
258
+ Mapping of group name to hex color (e.g. {'alpha': '#ff7f0e'}),
259
+ pinning those groups' colors. Unpinned groups stay on the default
260
+ palette. Names matching no group are ignored, so one mapping can be
261
+ reused across filtered subsets of the same data.
262
+ group_order : list, optional
263
+ Explicit group ordering for palette assignment. Groups keep their
264
+ palette slot even when absent from the data, so passing the same
265
+ list (e.g. computed once from the full dataset) to every figure in
266
+ a series keeps each group's color stable across subsets. Groups not
267
+ listed are appended in sorted order. Without group_order, groups
268
+ are assigned palette colors in sorted-name order.
255
269
  node_colors : dict, optional
256
270
  Dictionary mapping node names to hex color strings (e.g., '#FF5733').
257
271
  node_sizes : dict, optional
@@ -381,6 +395,10 @@ def net_vis(
381
395
  raise ValueError("node_groups must be a dictionary or 'auto'.")
382
396
  if node_colors is not None and not isinstance(node_colors, dict):
383
397
  raise ValueError("node_colors must be a dictionary.")
398
+ if group_colors is not None and not isinstance(group_colors, dict):
399
+ raise ValueError("group_colors must be a dictionary of {group: hex_color}.")
400
+ if group_order is not None and isinstance(group_order, (str, dict)):
401
+ raise ValueError("group_order must be a list of group names.")
384
402
  if node_sizes is not None and not isinstance(node_sizes, dict):
385
403
  raise ValueError("node_sizes must be a dictionary.")
386
404
  if node_metric is not None and not isinstance(node_metric, dict):
@@ -573,6 +591,11 @@ def net_vis(
573
591
  graph_config['ui']['metricLegend'] = metric_legend
574
592
 
575
593
  # Only set optional values if explicitly provided
594
+ if group_colors is not None:
595
+ graph_config['groups']['colors'] = {
596
+ str(k): str(v) for k, v in group_colors.items()}
597
+ if group_order is not None:
598
+ graph_config['groups']['colorOrder'] = [str(g) for g in group_order]
576
599
  if node_color is not None:
577
600
  graph_config['nodes']['defaultColor'] = node_color
578
601
  if edge_width is not None:
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "lightgraph"
7
- version = "1.1.1"
7
+ version = "1.2.0"
8
8
  description = "A lightweight Python binding for lightGraph network visualization"
9
9
  authors = [
10
10
  {name = "Hao Zhu", email = "haozhu233@gmail.com"},
@@ -367,6 +367,28 @@ class TestDataInputs:
367
367
  with pytest.raises(ValueError, match="dictionary or 'auto'"):
368
368
  net_vis(edges=[('A', 'B')], node_groups='louvain')
369
369
 
370
+ def test_group_colors_and_order_config(self):
371
+ result = net_vis(edges=[('A', 'B'), ('B', 'C')],
372
+ node_groups={'A': 'g1', 'B': 'g2', 'C': 'g1'},
373
+ group_colors={'g1': '#ff7f0e'},
374
+ group_order=['g2', 'g1'])
375
+ groups = self._extract_config(result)['groups']
376
+ assert groups['colors'] == {'g1': '#ff7f0e'}
377
+ assert groups['colorOrder'] == ['g2', 'g1']
378
+
379
+ def test_group_colors_omitted_by_default(self):
380
+ result = net_vis(edges=[('A', 'B')],
381
+ node_groups={'A': 'g1', 'B': 'g2'})
382
+ groups = self._extract_config(result)['groups']
383
+ assert 'colors' not in groups
384
+ assert 'colorOrder' not in groups
385
+
386
+ def test_group_colors_validation(self):
387
+ with pytest.raises(ValueError, match="group_colors"):
388
+ net_vis(edges=[('A', 'B')], group_colors=['#ff0000'])
389
+ with pytest.raises(ValueError, match="group_order"):
390
+ net_vis(edges=[('A', 'B')], group_order='g1')
391
+
370
392
  def test_sparse_matrix(self):
371
393
  scipy_sparse = pytest.importorskip('scipy.sparse')
372
394
  adj = scipy_sparse.csr_matrix(np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]))
@@ -1564,7 +1564,7 @@ wheels = [
1564
1564
 
1565
1565
  [[package]]
1566
1566
  name = "lightgraph"
1567
- version = "1.1.1"
1567
+ version = "1.2.0"
1568
1568
  source = { editable = "." }
1569
1569
  dependencies = [
1570
1570
  { name = "ipython", version = "7.16.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" },
File without changes
File without changes
File without changes