diff --git a/AGENTS.md b/AGENTS.md
index 1366145..b1ebd6b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,6 +80,6 @@ Stateless FastAPI — the frontend holds all scene state; the server validates i
- **Within frontend**: components emit/subscribe on the `EventBus` and read/write the `store`; the render loop reacts to `ctx` flags (`renderInterLayerEdgesFlag`, label flags, etc.).
### Network Data Model
-- Networks upload as TSV with mandatory columns `SourceNode`, `SourceLayer`, `TargetNode`, `TargetLayer` (optional: `Weight`, `Channel`, edge color columns).
+- Networks upload as TSV with mandatory columns `SourceNode`, `SourceLayer`, `TargetNode`, `TargetLayer` (optional: `Weight`, `Channel`, edge color columns). A minimal 2-column edgelist (`SourceNode`, `TargetNode`) is also accepted — all nodes land in a single default layer.
- Node/edge attribute files add per-node color/size/url/description and per-edge (optionally per-channel) color.
- Sessions export/import as JSON with full node/edge/layer/scene state. `POST /api/external` returns a token URL so another app can hand off a session.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c240456..04193f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
### Added
+- Network upload accepts a minimal 2-column edgelist (`SourceNode`, `TargetNode` only) — all nodes are spread out in a single default layer named `Layer1`. The optional `Weight` and `Channel` columns still apply. A downloadable example lives in the Help → Examples tab.
+
- Edge weight can now be shown as **edge thickness**, not only opacity. The Edge Actions panel replaces the "Edge Opacity By Weight" checkbox with a "Show Edge Weight As" radio — Nothing / Opacity / Width / Both — plus intra- and inter-layer width sliders for whichever property weight isn't driving. Sessions carry the choice as the independent `edgeOpacityByWeight` and `edgeWidthByWeight` booleans; files written before this default to opacity, so they render unchanged. Thickness needed `Line2` (instanced quads) because WebGL renders every line primitive at exactly 1px regardless of `linewidth`.
### Changed
diff --git a/backend/app/config.py b/backend/app/config.py
index 28051e8..508d4f9 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -8,6 +8,8 @@
# Input validation
MANDATORY_NETWORK_COLUMNS = ["SourceNode", "SourceLayer", "TargetNode", "TargetLayer"]
OPTIONAL_NETWORK_COLUMNS = ["Channel", "Weight"]
+# Layer assigned to every node when a minimal 2-column edgelist omits the layer columns
+DEFAULT_LAYER_NAME = "Layer1"
MANDATORY_JSON_OBJECTS = ["layers", "nodes", "edges"]
OPTIONAL_JSON_OBJECTS = ["scene", "universalLabelColor", "direction", "edgeOpacityByWeight"]
MANDATORY_JSON_NODE_COLUMNS = ["name", "layer"]
diff --git a/backend/app/routers/network.py b/backend/app/routers/network.py
index d447771..f31bf54 100644
--- a/backend/app/routers/network.py
+++ b/backend/app/routers/network.py
@@ -11,6 +11,8 @@
@router.post("/api/network", response_model=NetworkModel)
async def upload_network(file: UploadFile) -> NetworkModel:
+ """Parse an uploaded network TSV — the 4-column Arena3D format, or a minimal
+ 2-column edgelist (SourceNode, TargetNode) whose nodes all land in one default layer."""
raw_bytes = await file.read()
# DoS fix: reject oversized uploads before pandas parses the whole file
# into memory — MAX_EDGES was only checked *after* the parse completed.
diff --git a/backend/app/services/parser.py b/backend/app/services/parser.py
index 2fbd241..af9d594 100644
--- a/backend/app/services/parser.py
+++ b/backend/app/services/parser.py
@@ -34,7 +34,8 @@ def _validate(df: pd.DataFrame) -> None:
if not set(config.MANDATORY_NETWORK_COLUMNS).issubset(df.columns):
raise NetworkValidationError(
"Your network file must contain at least these four columns: "
- "SourceNode, SourceLayer, TargetNode, TargetLayer"
+ "SourceNode, SourceLayer, TargetNode, TargetLayer — or just "
+ "SourceNode and TargetNode for a minimal edgelist (single layer)."
)
# Reject rows with an empty/whitespace mandatory cell — a blank becomes NaN
# and would crash EdgeModel construction with a 500 instead of a clean 400.
@@ -63,6 +64,12 @@ def parse_network_tsv(text: str) -> NetworkModel:
df = pd.read_csv(StringIO(text), sep="\t", dtype=str)
except pd.errors.EmptyDataError as e:
raise NetworkValidationError("The network file is empty or has no columns.") from e
+
+ # minimal 2-column edgelist: no layer columns -> every node in one default layer
+ if "SourceLayer" not in df.columns and "TargetLayer" not in df.columns:
+ df["SourceLayer"] = config.DEFAULT_LAYER_NAME
+ df["TargetLayer"] = config.DEFAULT_LAYER_NAME
+
_validate(df)
# subset legit columns (mandatory + optional Channel/Weight, in fixed order)
diff --git a/backend/tests/test_network.py b/backend/tests/test_network.py
index 7467827..28b200d 100644
--- a/backend/tests/test_network.py
+++ b/backend/tests/test_network.py
@@ -55,6 +55,24 @@ def test_missing_mandatory_column_400() -> None:
assert "four columns" in resp.json()["detail"]
+def test_two_column_edgelist_single_default_layer() -> None:
+ body = _post("SourceNode\tTargetNode\nA\tB\nB\tC\n").json()
+ assert body["layers"] == ["Layer1"]
+ assert {n["layer"] for n in body["nodes"]} == {"Layer1"}
+ assert len(body["edges"]) == 2
+
+
+def test_two_column_edgelist_with_optional_columns() -> None:
+ body = _post("SourceNode\tTargetNode\tWeight\tChannel\nA\tB\t2\tch1\nB\tC\t4\tch2\n").json()
+ assert body["layers"] == ["Layer1"]
+ assert body["channels"] == ["ch1", "ch2"]
+
+
+def test_single_layer_column_still_400() -> None:
+ resp = _post("SourceNode\tTargetNode\tTargetLayer\nA\tB\tL1\n")
+ assert resp.status_code == 400
+
+
def test_non_numeric_weight_400() -> None:
tsv = f"{HDR}\tWeight\nA\tL1\tB\tL2\theavy\n"
assert _post(tsv).status_code == 400
diff --git a/frontend/public/data/edgelist_2columns.tsv b/frontend/public/data/edgelist_2columns.tsv
new file mode 100644
index 0000000..96abeec
--- /dev/null
+++ b/frontend/public/data/edgelist_2columns.tsv
@@ -0,0 +1,13 @@
+SourceNode TargetNode
+An Bn
+An Cn
+Bn Cn
+Cn Dn
+Dn En
+En Fn
+Fn Gn
+Gn Hn
+Hn An
+Bn Fn
+Cn Gn
+Dn Hn
diff --git a/frontend/src/ui/help.ts b/frontend/src/ui/help.ts
index 3b3bcea..5c1a00a 100644
--- a/frontend/src/ui/help.ts
+++ b/frontend/src/ui/help.ts
@@ -28,6 +28,8 @@ const HELP_HTML = `
1. The Upload Network option allows the user to upload network data in the
Arena3D format. This file consists of 4 mandatory columns with headers SourceNode, TargetNode,
SourceLayer and TargetLayer and 2 optional columns with the headers Weight and Channel.
+ Alternatively, a minimal 2-column edgelist with only the SourceNode and TargetNode headers is
+ accepted — all its nodes are then spread out in a single default layer (the optional columns still apply).
After the file is uploaded, the weight
values are
mapped in a [0-1] range and assigned to the respective edges. By default the weight is shown as edge transparency —
@@ -83,7 +85,15 @@ Kn Group4 Qn Group6 1 1
Kn Group4 Rn Group6 1 1
Kn Group4 Sn Group7 1 1
Kn Group4 Tn Group7 10 1
-
+
+
+
Example of the minimal 2-column edgelist format (all nodes placed in a single default layer).
+SourceNode TargetNode +An Bn +An Cn +Bn Cn +Cn Dn +
Example of the Arena3D Upload NODE attributes file format. All columns are ommitable except from Node and Layer ones. Users do not need to mention every node, just the ones of interest.
@@ -258,6 +268,11 @@ Kn Group4 Tn Group7 #4EFB7D
+ Minimal 2-column edgelist (single default layer):
+ A minimal edgelist with only SourceNode and TargetNode columns.
+
Random networks with different topologies mapped in 6 layers respectively:
The example network in the Arena3D format.