Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/source/default/static/Network_Graph_utopia_1990.html

Large diffs are not rendered by default.

45 changes: 33 additions & 12 deletions temoa/utilities/graph_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,15 @@ def calculate_initial_positions(
return positions

# Arrange sector "anchors" in a large circle
layout_radius = 2000 # The radius of the main circle for sectors
jitter_radius = 1000 # How far nodes can be from their sector anchor
sector_anchors = {}
# Scale radius based on the number of sectors and nodes to handle small models better
num_sectors = len(sectors_to_place)
num_nodes = len(nodes_to_place)

# Base radius + incremental scaling
layout_radius = max(800, min(2000, 400 + 200 * num_sectors + 2 * num_nodes))
jitter_radius = layout_radius // 2

sector_anchors = {}

for i, sector in enumerate(sectors_to_place):
angle = (i / num_sectors) * 2 * math.pi
Expand Down Expand Up @@ -218,42 +223,58 @@ def calculate_tech_graph_positions(
"""
positions = {}

# Materialize the iterable to avoid consumption issues
all_edges_list = list(all_edges)

# 1. Identify all unique sectors present in the technology list
sectors_to_place = sorted({tech.sector for tech in all_edges if tech.sector})
sectors_to_place = sorted({edge.sector for edge in all_edges_list if edge.sector})

if not sectors_to_place:
# If no sectors, just return empty positions and let physics handle it
return {}

# 2. Arrange sector "anchors" in a large circle
layout_radius = 2500 # Use a large radius to ensure initial separation
jitter_radius = 600 # Controls the size of the initial clusters
sector_anchors = {}
# Scale radius based on the number of sectors and unique technologies
unique_techs_to_place = sorted(
{edge.tech for edge in all_edges_list if edge.tech}, key=lambda t: str(t)
)
Comment on lines +238 to +240
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Simplify the lambda to key=str.

The lambda lambda t: str(t) is equivalent to just str.

♻️ Proposed simplification
-    unique_techs_to_place = sorted(
-        {edge.tech for edge in all_edges_list if edge.tech}, key=lambda t: str(t)
-    )
+    unique_techs_to_place = sorted(
+        {edge.tech for edge in all_edges_list if edge.tech}, key=str
+    )
🧰 Tools
🪛 Ruff (0.15.7)

[warning] 239-239: Lambda may be unnecessary; consider inlining inner function

Inline function call

(PLW0108)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@temoa/utilities/graph_utils.py` around lines 238 - 240, The sorting uses an
unnecessary lambda; replace the key=lambda t: str(t) with key=str in the
unique_techs_to_place construction to simplify the call that builds
sorted({edge.tech for edge in all_edges_list if edge.tech}); update the
expression that assigns to unique_techs_to_place accordingly (refer to
unique_techs_to_place, all_edges_list, and edge.tech).

num_sectors = len(sectors_to_place)
num_nodes = len(unique_techs_to_place)

if not unique_techs_to_place:
return {}

layout_radius = max(1000, min(2500, 500 + 300 * num_sectors + 5 * num_nodes))
jitter_radius = layout_radius // 4

sector_anchors = {}

for i, sector in enumerate(sectors_to_place):
angle = (i / num_sectors) * 2 * math.pi
cx = layout_radius * math.cos(angle)
cy = layout_radius * math.sin(angle)
sector_anchors[sector] = (cx, cy)

# 3. Place each technology node near its sector's anchor point with jitter
for edge_tuple in all_edges:
primary_sector = edge_tuple.sector
# 3. Place each unique technology node near its sector's anchor point with jitter
# Create a mapping of tech to its primary sector from the edges
tech_to_sector = {edge.tech: edge.sector for edge in all_edges_list if edge.tech}

for tech in unique_techs_to_place:
primary_sector = tech_to_sector.get(tech)
Comment on lines +258 to +263
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve the first non-empty sector for each technology.

tech_to_sector = { ... } keeps the last sector seen for a tech, but generate_technology_graph later keeps the first one when it builds node metadata. If a technology shows up with mixed or missing sector values, the node can be positioned under one sector anchor and rendered as another.

🔧 Proposed fix
-    tech_to_sector = {edge.tech: edge.sector for edge in all_edges_list if edge.tech}
+    tech_to_sector = {}
+    for edge in all_edges_list:
+        if edge.tech and edge.sector and edge.tech not in tech_to_sector:
+            tech_to_sector[edge.tech] = edge.sector
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@temoa/utilities/graph_utils.py` around lines 258 - 263, tech_to_sector
currently built with a dict comprehension over all_edges_list captures the last
seen sector for a tech, causing mismatch with generate_technology_graph which
expects the first non-empty sector; replace the comprehension with an explicit
loop over all_edges_list that sets tech_to_sector[edge.tech] only if edge.tech
is not already present and edge.sector is non-empty (i.e., preserve the first
non-empty sector encountered), referencing tech_to_sector, all_edges_list,
unique_techs_to_place and generate_technology_graph to locate and align the
behavior.

if not primary_sector or primary_sector not in sector_anchors:
# Place nodes without a defined sector at the center
cx, cy = 0, 0
else:
cx, cy = sector_anchors[primary_sector]

# Apply deterministic "jitter" to prevent stacking (stable per-tech)
seed = uuid.uuid5(uuid.NAMESPACE_DNS, str(edge_tuple.tech)).int
seed = uuid.uuid5(uuid.NAMESPACE_DNS, str(tech)).int
rng = random.Random(seed)
rand_angle = rng.uniform(0, 2 * math.pi)
rand_radius = rng.uniform(0, jitter_radius)
x = cx + rand_radius * math.cos(rand_angle)
y = cy + rand_radius * math.sin(rand_angle)

positions[edge_tuple.tech] = {'x': x, 'y': y}
positions[tech] = {'x': x, 'y': y}

return positions
112 changes: 95 additions & 17 deletions temoa/utilities/network_vis_templates/graph_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,59 @@ document.addEventListener('DOMContentLoaded', function () {
primary_view_name: primaryViewName,
secondary_view_name: secondaryViewName,
} = data;
const optionsObject = (typeof optionsRaw === 'string') ? JSON.parse(optionsRaw) : optionsRaw;
// --- State ---
let currentView = 'primary';
let primaryViewPositions = null;
let secondaryViewPositions = null;

let optionsObject = {};
if (typeof optionsRaw === "string") {
try {
optionsObject = JSON.parse(optionsRaw);
} catch (e) {
console.error('Failed to parse graph options JSON:', e);
optionsObject = {};
}
} else {
optionsObject = optionsRaw || {};
}

// Expose for debugging only — enable in production.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo in comment: "enable in production" should be "disable in production".

The comment says "enable in production" but the intent is clearly to enable debugging only during development.

🔧 Proposed fix
-    // Expose for debugging only — enable in production.
+    // Expose for debugging only — disable in production.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Expose for debugging only — enable in production.
// Expose for debugging only — disable in production.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@temoa/utilities/network_vis_templates/graph_script.js` at line 44, Update the
inline comment that currently reads "// Expose for debugging only — enable in
production." to correctly state the intent by changing it to "// Expose for
debugging only — disable in production." so it warns that the exposed debug
surface should not be enabled in production; locate the comment string in
graph_script.js and replace it accordingly.

const isDebug = (typeof window !== 'undefined' && window.DEBUG_GRAPH) ||
(typeof URLSearchParams !== 'undefined' && new URLSearchParams(window.location.search).has('debugGraph'));
if (isDebug) {
window.__graph = {
data,
allNodesPrimary,
allEdgesPrimary,
allNodesSecondary,
allEdgesSecondary,
optionsObject,
};
}
// --- DOM Elements ---
const fontSizeSlider = document.getElementById('font-size-slider');
const configWrapper = document.getElementById('config-panel-wrapper');
const configHeader = document.querySelector('.config-panel-header');
const configToggleButton = document.querySelector('.config-toggle-btn');
const advancedControlsToggle = document.getElementById('advanced-controls-toggle');
const visConfigContainer = document.getElementById('vis-config-container');
const searchInput = document.getElementById('search-input');

// --- Visual State ---
let currentView = 'primary';
let primaryViewPositions = null;
let secondaryViewPositions = null;
let visualState = {
fontSize: (optionsObject?.nodes?.font?.size) || 14
};

if (fontSizeSlider) {
fontSizeSlider.value = String(visualState.fontSize);
}
const resetButton = document.getElementById('reset-view-btn');
const sectorTogglesContainer = document.getElementById('sector-toggles');
const viewToggleButton = document.getElementById('view-toggle-btn');
const graphContainer = document.getElementById('mynetwork');

// --- Config Panel Toggle ---
if (optionsObject.configure && optionsObject.configure.enabled) {
if (optionsObject?.configure?.enabled) {
optionsObject.configure.container = visConfigContainer;
configHeader.addEventListener('click', () => {
const isCollapsed = configWrapper.classList.toggle('collapsed');
Expand All @@ -61,9 +94,41 @@ document.addEventListener('DOMContentLoaded', function () {
});
}

// --- Visual Settings Sliders ---
let pendingRaf = null;
function updateVisualSettings() {
if (fontSizeSlider) visualState.fontSize = parseInt(fontSizeSlider.value, 10);

if (pendingRaf) return;

pendingRaf = requestAnimationFrame(() => {
pendingRaf = null;

// Use setOptions for global font size - works for edges with smooth enabled
// Note: Don't set per-edge font as it breaks rendering with smooth edges
network.setOptions({
nodes: { font: { size: visualState.fontSize } },
edges: { font: { size: visualState.fontSize, align: 'top' } }
});

// Also update nodes individually since they have per-node font from addWithCurrentFontSize
// Note: Per-node font properties must be overwritten because they would otherwise take precedence over the global setting
const nodeUpdates = nodes.get().map(n => ({
id: n.id,
font: { ...(n.font ?? {}), size: visualState.fontSize }
}));
nodes.update(nodeUpdates);

network.redraw();
});
}

if (fontSizeSlider) fontSizeSlider.addEventListener('input', updateVisualSettings);


// --- Vis.js Network Initialization ---
const nodes = new vis.DataSet(allNodesPrimary);
const edges = new vis.DataSet(allEdgesPrimary);
const nodes = new vis.DataSet();
const edges = new vis.DataSet();
const network = new vis.Network(graphContainer, { nodes, edges }, optionsObject);

// --- Core Functions ---
Expand All @@ -84,13 +149,13 @@ document.addEventListener('DOMContentLoaded', function () {
nodes.clear(); edges.clear();

if (currentView === 'primary') {
nodes.add(allNodesSecondary); edges.add(allEdgesSecondary);
addWithCurrentFontSize(allNodesSecondary, allEdgesSecondary);
currentView = 'secondary';
viewToggleButton.textContent = `Switch to ${primaryViewName}`;
viewToggleButton.setAttribute('aria-pressed', 'true');
applyPositions(secondaryViewPositions);
} else {
nodes.add(allNodesPrimary); edges.add(allEdgesPrimary);
addWithCurrentFontSize(allNodesPrimary, allEdgesPrimary);
currentView = 'primary';
viewToggleButton.textContent = `Switch to ${secondaryViewName}`;
viewToggleButton.setAttribute('aria-pressed', 'false');
Expand Down Expand Up @@ -134,8 +199,8 @@ document.addEventListener('DOMContentLoaded', function () {
const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
visibleEdges = activeEdgesData.filter(edge => visibleNodeIds.has(edge.from) && visibleNodeIds.has(edge.to));
}
nodes.clear(); edges.clear();
nodes.add(visibleNodes); edges.add(visibleEdges);

addWithCurrentFontSize(visibleNodes, visibleEdges);
applyPositions(currentPositions);
}

Expand Down Expand Up @@ -205,6 +270,20 @@ document.addEventListener('DOMContentLoaded', function () {
});
}

function addWithCurrentFontSize(newNodes, newEdges) {
nodes.clear();
edges.clear();
nodes.add(
newNodes.map(n => ({
...n,
font: { ...(n.font ?? {}), size: visualState.fontSize },
})),
);
// Don't set per-edge font - let network.setOptions() handle it
// vis.js ignores global font options when edges have per-item font set
edges.add(newEdges);
}

function resetView() {
searchInput.value = "";
primaryViewPositions = null;
Expand All @@ -213,8 +292,7 @@ document.addEventListener('DOMContentLoaded', function () {
switchView(); // This will switch back to primary and apply null positions
} else {
// If already on primary, just reload the original data
nodes.clear(); edges.clear();
nodes.add(allNodesPrimary); edges.add(allEdgesPrimary);
addWithCurrentFontSize(allNodesPrimary, allEdgesPrimary);
applyPositions(primaryViewPositions); // Apply null to reset
network.fit();
}
Expand All @@ -233,9 +311,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
const filteredNodes = activeNodes.filter(node => nodesToShow.has(node.id));
const filteredEdges = activeEdges.filter(edge => nodesToShow.has(edge.from) && nodesToShow.has(edge.to));
nodes.clear(); edges.clear();
nodes.add(filteredNodes);
edges.add(filteredEdges);
addWithCurrentFontSize(filteredNodes, filteredEdges);
network.fit();
}

Expand All @@ -257,4 +333,6 @@ document.addEventListener('DOMContentLoaded', function () {
createStyleLegend();
createSectorLegend();
createSectorToggles();
// Initial data load with consistent font handling
addWithCurrentFontSize(allNodesPrimary, allEdgesPrimary);
});
3 changes: 3 additions & 0 deletions temoa/utilities/network_vis_templates/graph_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ body, html {
.legend-item { display: flex; align-items: center; margin-bottom: 6px; }
.legend-color-swatch { width: 18px; height: 18px; margin-right: 8px; flex-shrink: 0; border: 1px solid #ccc; background-color: #f0f0f0; box-sizing: border-box; }
.legend-label { font-size: 13px; }
.control-group { display: flex; align-items: center; gap: 15px; margin-bottom: 8px; }
.control-group label { min-width: 120px; font-size: 13px; font-weight: 500; }
.control-group input[type=range] { flex-grow: 1; max-width: 250px; }
#advanced-controls-toggle { font-size: 12px; color: #007bff; cursor: pointer; text-decoration: none; margin-top: 15px; display: block; }
.view-toggle-panel { padding: 8px 15px; background-color: #343a40; color: white; display: flex; justify-content: center; align-items: center; }
.view-toggle-panel button { font-size: 14px; font-weight: 600; padding: 8px 16px; border-radius: 5px; border: 1px solid #6c757d; background-color: #495057; color: white; cursor: pointer; }
7 changes: 7 additions & 0 deletions temoa/utilities/network_vis_templates/graph_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ <h3>Configuration & Legend</h3>
aria-controls="config-container-content"></button>
</div>
<div id="config-container-content">
<div class="legend-section">
<h4>Visual Settings</h4>
<div class="control-group">
<label for="font-size-slider">Label Font Size</label>
<input type="range" id="font-size-slider" min="6" max="100" step="1" value="14">
</div>
</div>
<div class="legend-section">
<h4>Style Legend</h4>
<div id="style-legend-container" class="legend-container"></div>
Expand Down
6 changes: 4 additions & 2 deletions temoa/utilities/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ def make_nx_graph(
if any(info['attrs'].get('dashes', False) for info in techs_info):
combined_attrs['dashes'] = True

combined_attrs['value'] = sum(info['attrs'].get('value', 1) for info in techs_info)
# Use 'width' for thickness, 'value' breaks font rendering with smooth edges
combined_attrs['width'] = 2 + len(techs_info) # Base width + 1 per tech
multi_edge_key = f'{ic}-{oc}-{uuid.uuid4().hex[:8]}'
dg.add_edge(ic, oc, key=multi_edge_key, **combined_attrs)

Expand Down Expand Up @@ -280,6 +281,7 @@ def nx_to_vis(
'width': 2,
'smooth': {'type': 'continuous', 'roundness': 0.5},
'arrows': {'to': {'enabled': False, 'scaleFactor': 1}},
'font': {'align': 'top', 'size': 14},
},
'physics': {
'enabled': False,
Expand All @@ -304,7 +306,7 @@ def nx_to_vis(
'navigationButtons': False,
'keyboard': {'enabled': True, 'bindToWindow': False},
},
'layout': {'randomSeed': None, 'improvedLayout': True},
'layout': {'improvedLayout': True},
'configure': {
'enabled': True,
'showButton': False, # We have our own header, so hide the default floating button
Expand Down
Loading
Loading