Skip to content
Open
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.

28 changes: 20 additions & 8 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,18 +223,25 @@ 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({tech.sector for tech in all_edges_list if tech.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 edges
num_sectors = len(sectors_to_place)
num_edges = len(all_edges_list)

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

sector_anchors = {}

for i, sector in enumerate(sectors_to_place):
angle = (i / num_sectors) * 2 * math.pi
Expand All @@ -238,7 +250,7 @@ def calculate_tech_graph_positions(
sector_anchors[sector] = (cx, cy)

# 3. Place each technology node near its sector's anchor point with jitter
for edge_tuple in all_edges:
for edge_tuple in all_edges_list:
primary_sector = edge_tuple.sector
if not primary_sector or primary_sector not in sector_anchors:
# Place nodes without a defined sector at the center
Expand Down
81 changes: 68 additions & 13 deletions temoa/utilities/network_vis_templates/graph_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,36 @@ document.addEventListener('DOMContentLoaded', function () {
primary_view_name: primaryViewName,
secondary_view_name: secondaryViewName,
} = data;
const optionsObject = (typeof optionsRaw === 'string') ? JSON.parse(optionsRaw) : optionsRaw;
// --- State ---

const optionsObject =
typeof optionsRaw === "string" ? JSON.parse(optionsRaw) : optionsRaw;

// Expose for debugging only — disable in production.
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production') {
window.__graph = {
data,
allNodesPrimary,
allEdgesPrimary,
allNodesSecondary,
allEdgesSecondary,
optionsObject,
};
}
// --- Visual State ---
let currentView = 'primary';
let primaryViewPositions = null;
let secondaryViewPositions = null;
let visualState = {
fontSize: 14
};
Comment on lines +50 to +52
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and read the graph_script.js file
find . -name "graph_script.js" -type f

Repository: TemoaProject/temoa

Length of output: 162


🏁 Script executed:

#!/bin/bash
# Read the graph_script.js file to understand the full context
wc -l ./temoa/utilities/network_vis_templates/graph_script.js

Repository: TemoaProject/temoa

Length of output: 121


🏁 Script executed:

#!/bin/bash
# Read the relevant sections of the file
cat -n ./temoa/utilities/network_vis_templates/graph_script.js | head -120 | tail -80

Repository: TemoaProject/temoa

Length of output: 3994


🏁 Script executed:

#!/bin/bash
# Read the beginning of the file to understand how optionsObject is created
cat -n ./temoa/utilities/network_vis_templates/graph_script.js | head -50

Repository: TemoaProject/temoa

Length of output: 2122


🏁 Script executed:

#!/bin/bash
# Search for font size references and how optionsObject is used
rg -n "fontSize|font.*size" ./temoa/utilities/network_vis_templates/graph_script.js

Repository: TemoaProject/temoa

Length of output: 709


🏁 Script executed:

#!/bin/bash
# Search for optionsObject usage and structure to see if it contains font settings
rg -n "optionsObject" ./temoa/utilities/network_vis_templates/graph_script.js | head -20

Repository: TemoaProject/temoa

Length of output: 350


🏁 Script executed:

#!/bin/bash
# Look for where optionsRaw/options_json_str is generated, likely in a Python backend
find . -name "*.py" -type f | xargs rg -l "options_json_str|network_vis" | head -10

Repository: TemoaProject/temoa

Length of output: 93


🏁 Script executed:

#!/bin/bash
# Read the visualizer.py file to understand how options_json_str is generated
wc -l ./temoa/utilities/visualizer.py

Repository: TemoaProject/temoa

Length of output: 97


🏁 Script executed:

#!/bin/bash
# Read the visualizer.py file to find options_json_str generation
rg -n "options_json_str|nodes.*font|edges.*font" ./temoa/utilities/visualizer.py

Repository: TemoaProject/temoa

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Read the visualizer.py file around line 242 and look for options construction
cat -n ./temoa/utilities/visualizer.py | sed -n '200,280p'

Repository: TemoaProject/temoa

Length of output: 3792


🏁 Script executed:

#!/bin/bash
# Look at the DEFAULT_VIS_OPTIONS to understand the structure
cat -n ./temoa/utilities/visualizer.py | sed -n '272,315p'

Repository: TemoaProject/temoa

Length of output: 1672


🏁 Script executed:

#!/bin/bash
# Check if the slider has a default value in the HTML template
rg -n "font-size-slider" ./temoa/utilities/network_vis_templates/ -A 2 -B 2

Repository: TemoaProject/temoa

Length of output: 1411


🏁 Script executed:

#!/bin/bash
# Verify scope - check that optionsObject is defined before line 50
cat -n ./temoa/utilities/network_vis_templates/graph_script.js | sed -n '30,55p'

Repository: TemoaProject/temoa

Length of output: 1021


Synchronize slider initial value with backend font size defaults to prevent UI/render mismatch.

visualState.fontSize and the slider are hardcoded to 14, but optionsObject (derived from backend options_json_str) may specify a different font size. When the network initializes, it uses the size from optionsObject, creating a mismatch where the actual render differs from the displayed slider value. Initialize both from optionsObject with a fallback to 14, and set the slider's initial value accordingly.

🛠️ Suggested fix
-    let visualState = {
-        fontSize: 14
-    };
+    const parsedFontSize = parseInt(
+        optionsObject?.nodes?.font?.size ?? optionsObject?.edges?.font?.size ?? 14,
+        10,
+    );
+    const defaultFontSize = Number.isFinite(parsedFontSize) ? parsedFontSize : 14;
+    let visualState = {
+        fontSize: defaultFontSize
+    };

And at line 60:

     const fontSizeSlider = document.getElementById('font-size-slider');
+    if (fontSizeSlider) fontSizeSlider.value = String(defaultFontSize);
🤖 Prompt for AI Agents
In `@temoa/utilities/network_vis_templates/graph_script.js` around lines 50 - 52,
visualState.fontSize is hardcoded to 14 while optionsObject (from
options_json_str) may specify a different font size, causing a UI/render
mismatch; update the initialization so visualState.fontSize =
(optionsObject?.font_size ?? 14) and set the slider's initial value to that same
value during the UI setup (use the same slider variable or DOM element used
where the slider is created) so both the rendered network and the displayed
slider are synchronized; locate references to visualState, optionsObject, and
the slider initialization code (where the slider value is set) and replace the
hardcoded 14 with the derived value and apply it to the slider control.


// --- DOM Elements ---
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 fontSizeSlider = document.getElementById('font-size-slider');
const searchInput = document.getElementById('search-input');
const resetButton = document.getElementById('reset-view-btn');
const sectorTogglesContainer = document.getElementById('sector-toggles');
Expand All @@ -61,9 +79,33 @@ document.addEventListener('DOMContentLoaded', function () {
});
}

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

// 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
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 +126,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 +176,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 +247,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 +269,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 +288,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 +310,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>
Comment on lines 20 to 26
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

LGTM! Font size slider integration.

The slider is well-integrated with proper labeling and accessibility attributes. The default value of 14 correctly matches DEFAULT_VIS_OPTIONS.

One consideration: a max of 100 is quite large for label fonts. If most users will stay within 6-30, you could consider tightening the range for finer control, but the current range does allow for extreme zoom-out scenarios where larger labels might be useful.

🤖 Prompt for AI Agents
In `@temoa/utilities/network_vis_templates/graph_template.html` around lines 20 -
26, Summary: tighten the font-size slider range to a more practical maximum.
Update the input element with id "font-size-slider" to reduce the max from 100
to 30 (and optionally add step="1" for finer control) while keeping value="14"
to match DEFAULT_VIS_OPTIONS; ensure any JS that reads the slider (e.g.,
handlers referencing "font-size-slider") still accepts the new range.

<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
86 changes: 86 additions & 0 deletions tests/test_commodity_visualizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from temoa.model_checking.commodity_graph import generate_commodity_graph
from temoa.model_checking.network_model_data import EdgeTuple, NetworkModelData
from temoa.types.core_types import Commodity, Period, Region, Sector, Technology, Vintage


def test_special_items_styling() -> None:
"""
Test that demand orphans, other orphans, and driven techs
are correctly styled in the commodity graph.
"""
region = Region('test_region')
period = Period(2025)

# Concrete NetworkModelData
network_data = NetworkModelData()
network_data.physical_commodities = {Commodity('comm_inter')}
network_data.source_commodities[(region, period)] = {Commodity('comm_source')}
network_data.demand_commodities[(region, period)] = {Commodity('comm_demand')}

# Define some special items
demand_orphans = [
EdgeTuple(
region,
Commodity('comm_inter'),
Technology('tech_demand_orphan'),
Vintage(2020),
Commodity('comm_demand'),
sector=Sector('S1'),
)
]
other_orphans = [
EdgeTuple(
region,
Commodity('comm_source'),
Technology('tech_other_orphan'),
Vintage(2020),
Commodity('comm_inter'),
sector=Sector('S2'),
)
]
driven_techs = [
EdgeTuple(
region,
Commodity('comm_source'),
Technology('tech_driven'),
Vintage(2020),
Commodity('comm_demand'),
sector=Sector('S3'),
)
]

# Generate the graph
dg, _sector_colors = generate_commodity_graph(
region,
period,
network_data,
demand_orphans=demand_orphans,
other_orphans=other_orphans,
driven_techs=driven_techs,
)

# 1. Check Node Styling
assert dg.nodes['comm_demand']['color']['border'] == '#d62728'
assert dg.nodes['comm_demand']['borderWidth'] == 4
assert 'Connected to Demand Orphan' in dg.nodes['comm_demand']['title']

assert dg.nodes['comm_inter']['color']['border'] == '#d62728'
assert dg.nodes['comm_inter']['borderWidth'] == 4

assert dg.nodes['comm_source']['color']['border'] == '#ff7f0e'
assert dg.nodes['comm_source']['borderWidth'] == 4

# 2. Check Edge Styling
edges = list(dg.edges(data=True))

edge_do = next(e for e in edges if (e[0] == 'comm_inter' and e[1] == 'comm_demand'))
assert edge_do[2]['dashes'] is True
assert edge_do[2]['color'] == '#d62728'

edge_oo = next(e for e in edges if (e[0] == 'comm_source' and e[1] == 'comm_inter'))
assert edge_oo[2]['dashes'] is True
assert edge_oo[2]['color'] == '#ff7f0e'

edge_dt = next(e for e in edges if (e[0] == 'comm_source' and e[1] == 'comm_demand'))
assert edge_dt[2]['dashes'] is True
assert edge_dt[2]['color'] == '#1f77b4'
Comment on lines +76 to +86
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using explicit assertions for edge lookup to improve test failure messages.

Using next(e for e in edges if ...) raises StopIteration if no matching edge is found, which produces an unhelpful error message. Consider extracting a helper or using list comprehension with an explicit assertion.

♻️ Suggested improvement for clearer test failures
-    edge_do = next(e for e in edges if (e[0] == 'comm_inter' and e[1] == 'comm_demand'))
+    edge_do_list = [e for e in edges if (e[0] == 'comm_inter' and e[1] == 'comm_demand')]
+    assert len(edge_do_list) == 1, f"Expected 1 edge from comm_inter to comm_demand, found {len(edge_do_list)}"
+    edge_do = edge_do_list[0]
     assert edge_do[2]['dashes'] is True
     assert edge_do[2]['color'] == '#d62728'

-    edge_oo = next(e for e in edges if (e[0] == 'comm_source' and e[1] == 'comm_inter'))
+    edge_oo_list = [e for e in edges if (e[0] == 'comm_source' and e[1] == 'comm_inter')]
+    assert len(edge_oo_list) == 1, f"Expected 1 edge from comm_source to comm_inter, found {len(edge_oo_list)}"
+    edge_oo = edge_oo_list[0]
     assert edge_oo[2]['dashes'] is True
     assert edge_oo[2]['color'] == '#ff7f0e'

-    edge_dt = next(e for e in edges if (e[0] == 'comm_source' and e[1] == 'comm_demand'))
+    edge_dt_list = [e for e in edges if (e[0] == 'comm_source' and e[1] == 'comm_demand')]
+    assert len(edge_dt_list) == 1, f"Expected 1 edge from comm_source to comm_demand, found {len(edge_dt_list)}"
+    edge_dt = edge_dt_list[0]
     assert edge_dt[2]['dashes'] is True
     assert edge_dt[2]['color'] == '#1f77b4'
🤖 Prompt for AI Agents
In `@tests/test_commodity_visualizer.py` around lines 76 - 86, The test uses
next(e for e in edges if ...) which raises StopIteration with a poor message;
change the lookup for edge_do, edge_oo, and edge_dt to perform a safe search
(e.g., use a list comprehension or a helper like find_edge(edges, src, dst)) and
then assert that the result is not None (or that the list is non-empty) before
checking ['dashes'] and ['color']; update the assertions so a missing edge
yields a clear failure message indicating which source/destination pair
(comm_inter->comm_demand, comm_source->comm_inter, comm_source->comm_demand) was
not found.