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.
- {lightgraph-1.1.1 → lightgraph-1.2.0}/PKG-INFO +1 -1
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/assets/lightgraph.js +48 -7
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/network.py +24 -1
- {lightgraph-1.1.1 → lightgraph-1.2.0}/pyproject.toml +1 -1
- {lightgraph-1.1.1 → lightgraph-1.2.0}/tests/test_network.py +22 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/uv.lock +1 -1
- {lightgraph-1.1.1 → lightgraph-1.2.0}/README.md +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/__init__.py +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/analytics.py +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/assets/d3.v7.min.js +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/data/football_edges.csv +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/data/football_nodes.csv +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/data/got.csv +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/data/les_mis.csv +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/lightgraph/datasets.py +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/test.ipynb +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/tests/__init__.py +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/tests/test_analytics.py +0 -0
- {lightgraph-1.1.1 → lightgraph-1.2.0}/tests/test_datasets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightgraph
|
|
3
|
-
Version: 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:
|
|
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 ?
|
|
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(
|
|
1648
|
+
context.fillStyle = hexToRgba(getGroupColor(group), config.groups.fillOpacity);
|
|
1612
1649
|
context.fill();
|
|
1613
|
-
context.strokeStyle =
|
|
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: ${
|
|
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 ?
|
|
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:
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|