lightgraph 1.1.0__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.0
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
@@ -1,5 +1,6 @@
1
1
  from .network import net_vis, NetworkVisualization
2
2
  from . import analytics
3
+ from . import datasets
3
4
  from .analytics import (
4
5
  degree,
5
6
  betweenness,
@@ -17,6 +18,7 @@ __all__ = [
17
18
  'net_vis',
18
19
  'NetworkVisualization',
19
20
  'analytics',
21
+ 'datasets',
20
22
  'degree',
21
23
  'betweenness',
22
24
  'closeness',
@@ -34,9 +34,9 @@
34
34
  edges: {
35
35
  defaultColor: '#333333',
36
36
  selectedColor: '#999999',
37
- defaultOpacity: 0.1,
37
+ defaultOpacity: 0.3,
38
38
  selectedOpacity: 0.6,
39
- defaultWidth: 0.3,
39
+ defaultWidth: 0.45,
40
40
  selectedWidth: 2,
41
41
  // Scale edge opacity (and, when weight-mapped widths make
42
42
  // strokes thick, edge width) by on-screen ink density so dense
@@ -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',
@@ -112,7 +120,14 @@
112
120
  theme: 'light',
113
121
  showLegend: true,
114
122
  showStatistics: false,
115
- showTooltips: true
123
+ showTooltips: true,
124
+ // Metadata describing a numeric metric encoded as node size
125
+ // and/or color, rendered as an extra legend section. The
126
+ // Python/R wrappers set this automatically for node_metric;
127
+ // standalone users can supply it directly:
128
+ // { label, min, max, map: 'size'|'color'|'both',
129
+ // sizeRange: [minPx, maxPx], colors: [lowHex, highHex] }
130
+ metricLegend: null
116
131
  },
117
132
  layout: 'force' // 'force' or 'circular'
118
133
  };
@@ -172,6 +187,25 @@
172
187
  return value;
173
188
  }
174
189
 
190
+ // Linear interpolation between two '#rrggbb' colors; used for the
191
+ // metric legend's size dots when the metric also drives color.
192
+ function lerpHex(hex0, hex1, t) {
193
+ const c0 = [1, 3, 5].map(i => parseInt(hex0.slice(i, i + 2), 16));
194
+ const c1 = [1, 3, 5].map(i => parseInt(hex1.slice(i, i + 2), 16));
195
+ return '#' + c0.map((v, i) => Math.round(v + (c1[i] - v) * t)
196
+ .toString(16).padStart(2, '0')).join('');
197
+ }
198
+
199
+ // Compact human-readable form of a metric value for legend labels.
200
+ function formatMetricValue(value) {
201
+ if (!Number.isFinite(value)) return '';
202
+ const abs = Math.abs(value);
203
+ if (abs !== 0 && (abs < 0.001 || abs >= 100000)) {
204
+ return value.toExponential(2);
205
+ }
206
+ return String(parseFloat(value.toPrecision(3)));
207
+ }
208
+
175
209
  // Eigen decomposition of a symmetric 2x2 covariance matrix; used for the
176
210
  // orientation and radii of group ellipses.
177
211
  function computeEigen(covMatrix) {
@@ -268,6 +302,10 @@
268
302
  throw new Error('LightGraph: d3 v7 must be loaded (or passed via options.d3)');
269
303
  }
270
304
  const self = this;
305
+ // Expose the instance on the container element so embedding hosts
306
+ // (the R htmlwidget, generated Python/R pages, onRender hooks) can
307
+ // reach the API without holding the constructor's return value.
308
+ container.lightgraph = this;
271
309
  const userConfig = options.config || {};
272
310
  const theme = (userConfig.ui && userConfig.ui.theme) || DEFAULT_CONFIG.ui.theme;
273
311
  let config = mergeConfig(
@@ -793,20 +831,18 @@
793
831
  bottom: '16px',
794
832
  left: '16px',
795
833
  padding: '10px 12px',
796
- maxHeight: '220px',
834
+ // Cap the legend to roughly half the viewport; long group
835
+ // lists (plus the metric section) scroll beyond that.
836
+ maxHeight: 'min(50%, 340px)',
797
837
  overflowY: 'auto',
798
838
  fontSize: '12px',
799
839
  display: 'none',
800
840
  minWidth: '130px'
801
841
  });
802
- const legendTitle = createElement('div', { textContent: 'Groups' }, {
803
- marginBottom: '6px',
804
- paddingBottom: '5px',
805
- fontWeight: '600',
806
- borderBottom: '1px solid var(--lg-border)'
807
- });
842
+ // Section titles ('Groups', metric label) are rebuilt inside
843
+ // updateLegend along with the items.
808
844
  const legendContent = createElement('div', { id: 'legendContent' });
809
- legendPanel.append(legendTitle, legendContent);
845
+ legendPanel.append(legendContent);
810
846
  lightGraph.appendChild(legendPanel);
811
847
 
812
848
  // 1.3.4 Statistics Panel
@@ -890,6 +926,35 @@
890
926
  ...d3.schemeSet3
891
927
  ];
892
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
+ }
893
958
  // Modeless interaction: d3-zoom owns wheel events and drags that
894
959
  // start on empty space; node drags and shift box-selects fall
895
960
  // through to the canvas listeners below.
@@ -1020,6 +1085,76 @@
1020
1085
  function hideFloatingInput(x, y) {
1021
1086
  floatingInput.style.display = 'none';
1022
1087
  }
1088
+ function legendSectionTitle(text, isFirst) {
1089
+ return createElement('div', { textContent: text }, {
1090
+ marginTop: isFirst ? '0' : '10px',
1091
+ marginBottom: '6px',
1092
+ paddingBottom: '5px',
1093
+ fontWeight: '600',
1094
+ borderBottom: '1px solid var(--lg-border)'
1095
+ });
1096
+ }
1097
+
1098
+ // Legend section for a metric encoded as node size and/or color:
1099
+ // a row of proportionally sized dots for size, a gradient bar for
1100
+ // color, and min/max value labels underneath.
1101
+ function buildMetricLegend(metric) {
1102
+ const section = createElement('div', { id: 'metricLegend' });
1103
+ const mapsSize = metric.map === 'size' || metric.map === 'both';
1104
+ const mapsColor = metric.map === 'color' || metric.map === 'both';
1105
+ const colors = (mapsColor && Array.isArray(metric.colors)
1106
+ && metric.colors.length >= 2) ? metric.colors : null;
1107
+
1108
+ if (mapsSize) {
1109
+ const range = (Array.isArray(metric.sizeRange)
1110
+ && metric.sizeRange.length >= 2) ? metric.sizeRange : [4, 20];
1111
+ // Dots keep their relative proportions but are capped so
1112
+ // large node sizes do not blow up the panel.
1113
+ const scale = Math.min(1, 16 / Math.max(range[0], range[1], 1));
1114
+ const row = createElement('div', {}, {
1115
+ display: 'flex',
1116
+ alignItems: 'center',
1117
+ gap: '8px',
1118
+ margin: '2px 0 4px'
1119
+ });
1120
+ [0, 0.5, 1].forEach(t => {
1121
+ const d = Math.max(3,
1122
+ (range[0] + t * (range[1] - range[0])) * scale);
1123
+ row.appendChild(createElement('div', {}, {
1124
+ width: `${d}px`,
1125
+ height: `${d}px`,
1126
+ borderRadius: '50%',
1127
+ backgroundColor: colors
1128
+ ? lerpHex(colors[0], colors[1], t)
1129
+ : config.nodes.defaultColor,
1130
+ flexShrink: '0'
1131
+ }));
1132
+ });
1133
+ section.appendChild(row);
1134
+ }
1135
+ if (colors) {
1136
+ section.appendChild(createElement('div', { className: 'lg-metric-gradient' }, {
1137
+ height: '10px',
1138
+ borderRadius: '3px',
1139
+ background: `linear-gradient(to right, ${colors[0]}, ${colors[1]})`,
1140
+ margin: '2px 0 4px'
1141
+ }));
1142
+ }
1143
+ const labels = createElement('div', {}, {
1144
+ display: 'flex',
1145
+ justifyContent: 'space-between',
1146
+ gap: '12px',
1147
+ color: 'var(--lg-muted)',
1148
+ fontSize: '11px'
1149
+ });
1150
+ labels.append(
1151
+ createElement('span', { textContent: formatMetricValue(metric.min) }),
1152
+ createElement('span', { textContent: formatMetricValue(metric.max) })
1153
+ );
1154
+ section.appendChild(labels);
1155
+ return section;
1156
+ }
1157
+
1023
1158
  function updateLegend() {
1024
1159
  if (!config.ui.showLegend) {
1025
1160
  legendPanel.style.display = 'none';
@@ -1027,7 +1162,11 @@
1027
1162
  }
1028
1163
 
1029
1164
  const groups = [...new Set(nodes.map(node => node.group).filter(Boolean))].sort();
1030
- if (groups.length === 0) {
1165
+ const metric = config.ui.metricLegend;
1166
+ const hasMetric = !!(metric
1167
+ && ['size', 'color', 'both'].includes(metric.map)
1168
+ && Number.isFinite(metric.min) && Number.isFinite(metric.max));
1169
+ if (groups.length === 0 && !hasMetric) {
1031
1170
  legendPanel.style.display = 'none';
1032
1171
  return;
1033
1172
  }
@@ -1035,6 +1174,9 @@
1035
1174
  legendPanel.style.display = 'block';
1036
1175
  legendContent.innerHTML = '';
1037
1176
 
1177
+ if (groups.length > 0) {
1178
+ legendContent.appendChild(legendSectionTitle('Groups', true));
1179
+ }
1038
1180
  groups.forEach(group => {
1039
1181
  const count = nodes.filter(n => n.group === group).length;
1040
1182
  const item = createElement('div', { className: 'lg-legend-item' });
@@ -1043,7 +1185,7 @@
1043
1185
  width: '11px',
1044
1186
  height: '11px',
1045
1187
  borderRadius: '3px',
1046
- backgroundColor: groupColorScale(group),
1188
+ backgroundColor: getGroupColor(group),
1047
1189
  flexShrink: '0'
1048
1190
  });
1049
1191
 
@@ -1059,6 +1201,12 @@
1059
1201
 
1060
1202
  legendContent.appendChild(item);
1061
1203
  });
1204
+
1205
+ if (hasMetric) {
1206
+ legendContent.appendChild(legendSectionTitle(
1207
+ metric.label || 'Metric', groups.length === 0));
1208
+ legendContent.appendChild(buildMetricLegend(metric));
1209
+ }
1062
1210
  }
1063
1211
 
1064
1212
  function updateStatistics() {
@@ -1207,11 +1355,16 @@
1207
1355
  const meanScreenWidth = screenEdgeWidth(totalWidth / counted);
1208
1356
  const coverage = edges.length * meanScreenLen *
1209
1357
  meanScreenWidth * visibleShare / viewArea;
1210
- // Aim for ~40% accumulated alpha coverage. Only ever dim: sparse
1211
- // graphs keep their configured style (factors cap at 1), and
1212
- // the effective alpha never drops below 0.02.
1358
+ // Sub-linear ink budget, tuned on a matrix of graph sizes and
1359
+ // degrees (benchmark: 50-5000 nodes, mean degree 2-16): target
1360
+ // alpha ~= 0.12/sqrt(coverage). Linear-in-coverage budgets fail
1361
+ // at both ends — edges concentrate near the center, so a budget
1362
+ // loose enough for hairballs lets mid-density graphs go gray,
1363
+ // and one tight enough for mid-density erases hairball fringes.
1364
+ // Only ever dims: sparse graphs keep their configured style
1365
+ // (factors cap at 1), and effective alpha never drops below 0.02.
1213
1366
  const base = Math.max(baseEdgeAlpha(), 0.001);
1214
- const ideal = 0.4 / Math.max(coverage, 1e-6);
1367
+ const ideal = 0.12 / Math.sqrt(Math.max(coverage, 1e-6));
1215
1368
  const needed = Math.min(1, ideal / base);
1216
1369
  // Width absorbs up to half the correction (in log space), but
1217
1370
  // the mean stroke never shrinks below the 0.5px hairline — so
@@ -1287,7 +1440,7 @@
1287
1440
  maxNodeSize = config.nodes.defaultSize;
1288
1441
  for (const d of nodes) {
1289
1442
  if (egoSet && !egoSet.has(d)) continue;
1290
- 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);
1291
1444
  const isSelected = selectedNodes.has(d);
1292
1445
  const isFaded = highlightSet !== null && !highlightSet.has(d);
1293
1446
  let opacity = d.opacity !== undefined ? d.opacity : config.nodes.defaultOpacity;
@@ -1492,9 +1645,9 @@
1492
1645
  context.rotate(angle);
1493
1646
  context.beginPath();
1494
1647
  context.ellipse(0, 0, radiusX + 5, radiusY + 5, 0, 0, 2 * Math.PI);
1495
- context.fillStyle = hexToRgba(groupColorScale(group), config.groups.fillOpacity);
1648
+ context.fillStyle = hexToRgba(getGroupColor(group), config.groups.fillOpacity);
1496
1649
  context.fill();
1497
- context.strokeStyle = groupColorScale(group);
1650
+ context.strokeStyle = getGroupColor(group);
1498
1651
  context.lineWidth = config.groups.strokeWidth;
1499
1652
  context.stroke();
1500
1653
  context.restore();
@@ -1532,6 +1685,7 @@
1532
1685
  nodes = newNodes;
1533
1686
  edges = newEdges;
1534
1687
 
1688
+ updateGroupColorDomain();
1535
1689
  recalculateForce();
1536
1690
  updateEdgeAlpha();
1537
1691
  updateLegend();
@@ -1813,7 +1967,7 @@
1813
1967
  if (config.ui.showTooltips && hoveredNode) {
1814
1968
  let tooltipContent = `<strong>${escapeHtml(hoveredNode.id)}</strong>`;
1815
1969
  if (hoveredNode.group) {
1816
- 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>`;
1817
1971
  }
1818
1972
  // Add any custom metadata
1819
1973
  if (hoveredNode.metadata) {
@@ -2042,7 +2196,7 @@
2042
2196
  circle.setAttribute('cy', node.y);
2043
2197
  circle.setAttribute('r',
2044
2198
  (node.size || config.nodes.defaultSize) * config.nodes.sizeScale / 2);
2045
- 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));
2046
2200
  circle.setAttribute('stroke', config.nodes.borderColor);
2047
2201
  circle.setAttribute('stroke-width', config.nodes.borderWidth);
2048
2202
  g.appendChild(circle);
@@ -2130,6 +2284,9 @@
2130
2284
  // Merge a partial config over the current one and re-render.
2131
2285
  this.updateConfig = (partial = {}) => {
2132
2286
  config = mergeConfig(config, partial);
2287
+ if (partial.groups && ('colors' in partial.groups || 'colorOrder' in partial.groups)) {
2288
+ updateGroupColorDomain();
2289
+ }
2133
2290
  if (partial.edges && 'showArrows' in partial.edges) {
2134
2291
  showArrows = config.edges.showArrows;
2135
2292
  arrowsToggle.checked = showArrows;
@@ -2235,6 +2392,9 @@
2235
2392
  legendPanel, statsPanel, tooltip].forEach(el => el.remove());
2236
2393
  lightGraph.classList.remove('lg-root');
2237
2394
  lightGraph.removeAttribute('data-lg-theme');
2395
+ if (container.lightgraph === self) {
2396
+ delete container.lightgraph;
2397
+ }
2238
2398
  };
2239
2399
 
2240
2400
  // Initial data load
@@ -2309,6 +2469,8 @@
2309
2469
  mergeConfig,
2310
2470
  escapeHtml,
2311
2471
  hexToRgba,
2472
+ lerpHex,
2473
+ formatMetricValue,
2312
2474
  computeEigen,
2313
2475
  isNodeInSelection
2314
2476
  };