agi-page-network-map 0.1.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.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: agi-page-network-map
3
+ Version: 0.1.0
4
+ Summary: AGILAB page bundle for network-aware geospatial analysis.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: agi-gui<2027.0,>=2026.05.12.post3
8
+ Requires-Dist: agi-node<2027.0,>=2026.05.12.post3
9
+ Requires-Dist: ipython
10
+ Requires-Dist: networkx
11
+ Requires-Dist: plotly
12
+ Requires-Dist: matplotlib
13
+ Requires-Dist: sqlalchemy>=2.0.43
14
+
15
+ AGILAB page bundle for network-aware geospatial analysis.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "agi-page-network-map"
3
+ version = "0.1.0"
4
+ description = "AGILAB page bundle for network-aware geospatial analysis."
5
+ readme = { text = "AGILAB page bundle for network-aware geospatial analysis.", content-type = "text/markdown" }
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "agi-gui>=2026.05.12.post3,<2027.0",
9
+ "agi-node>=2026.05.12.post3,<2027.0",
10
+ "ipython",
11
+ "networkx",
12
+ "plotly",
13
+ "matplotlib",
14
+ "sqlalchemy>=2.0.43",
15
+ ]
16
+
17
+ [project.entry-points."agilab.pages"]
18
+ view_maps_network = "view_maps_network:bundle_root"
19
+
20
+ [tool.uv.sources]
21
+ agi-gui = { path = "../../lib/agi-gui", editable = true }
22
+ agi-env = { path = "../../core/agi-env", editable = true }
23
+ agi-node = { path = "../../core/agi-node", editable = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: agi-page-network-map
3
+ Version: 0.1.0
4
+ Summary: AGILAB page bundle for network-aware geospatial analysis.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: agi-gui<2027.0,>=2026.05.12.post3
8
+ Requires-Dist: agi-node<2027.0,>=2026.05.12.post3
9
+ Requires-Dist: ipython
10
+ Requires-Dist: networkx
11
+ Requires-Dist: plotly
12
+ Requires-Dist: matplotlib
13
+ Requires-Dist: sqlalchemy>=2.0.43
14
+
15
+ AGILAB page bundle for network-aware geospatial analysis.
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ src/agi_page_network_map.egg-info/PKG-INFO
3
+ src/agi_page_network_map.egg-info/SOURCES.txt
4
+ src/agi_page_network_map.egg-info/dependency_links.txt
5
+ src/agi_page_network_map.egg-info/entry_points.txt
6
+ src/agi_page_network_map.egg-info/requires.txt
7
+ src/agi_page_network_map.egg-info/top_level.txt
8
+ src/view_maps_network/__init__.py
9
+ src/view_maps_network/edge_selection.py
10
+ src/view_maps_network/maps_network_graph.py
11
+ src/view_maps_network/notebook_inline.py
12
+ src/view_maps_network/view_maps_network.py
@@ -0,0 +1,2 @@
1
+ [agilab.pages]
2
+ view_maps_network = view_maps_network:bundle_root
@@ -0,0 +1,7 @@
1
+ agi-gui<2027.0,>=2026.05.12.post3
2
+ agi-node<2027.0,>=2026.05.12.post3
3
+ ipython
4
+ networkx
5
+ plotly
6
+ matplotlib
7
+ sqlalchemy>=2.0.43
@@ -0,0 +1,7 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def bundle_root() -> Path:
5
+ """Return the installed root for this AGILAB analysis page bundle."""
6
+
7
+ return Path(__file__).resolve().parent
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Sequence
6
+
7
+ NONE_OPTION = "(none)"
8
+ CUSTOM_OPTION = "(custom path…)"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class EdgesPickerState:
13
+ picker_options: list[str]
14
+ choice: str
15
+ custom_value: str
16
+ edges_clean: str
17
+ recovered_from_missing: bool = False
18
+
19
+
20
+ def _path_exists(value: str) -> bool:
21
+ if not value.strip():
22
+ return False
23
+ try:
24
+ return Path(value).expanduser().exists()
25
+ except (OSError, RuntimeError, TypeError, ValueError):
26
+ return False
27
+
28
+
29
+ def _preferred_recovery_candidate(edges_prev: str, edges_candidates: Sequence[str]) -> str | None:
30
+ if not edges_candidates:
31
+ return None
32
+ prev_name = Path(edges_prev).name.lower()
33
+ tokens: list[str] = []
34
+ if "topology" in prev_name:
35
+ tokens.extend(["topology", "ilp_topology"])
36
+ if "routing_edges" in prev_name:
37
+ tokens.append("routing_edges")
38
+ elif "edges" in prev_name:
39
+ tokens.append("edges")
40
+ for token in tokens:
41
+ for candidate in edges_candidates:
42
+ if token in Path(candidate).name.lower():
43
+ return candidate
44
+ return edges_candidates[0]
45
+
46
+
47
+ def resolve_edges_picker_state(
48
+ edges_prev: str,
49
+ edges_candidates: Sequence[str],
50
+ *,
51
+ current_choice: str | None = None,
52
+ current_custom: str | None = None,
53
+ ) -> EdgesPickerState:
54
+ picker_options = [NONE_OPTION, *edges_candidates, CUSTOM_OPTION]
55
+ choice = current_choice or ""
56
+ custom_value = current_custom or ""
57
+ recovered_from_missing = False
58
+
59
+ if (
60
+ choice == CUSTOM_OPTION
61
+ and custom_value.strip()
62
+ and not _path_exists(custom_value)
63
+ and edges_candidates
64
+ ):
65
+ choice = _preferred_recovery_candidate(custom_value, edges_candidates) or NONE_OPTION
66
+ custom_value = ""
67
+ recovered_from_missing = bool(choice and choice != NONE_OPTION)
68
+
69
+ if choice not in picker_options:
70
+ if edges_prev and edges_prev in edges_candidates:
71
+ choice = edges_prev
72
+ elif edges_prev and _path_exists(edges_prev):
73
+ choice = CUSTOM_OPTION
74
+ if not custom_value:
75
+ custom_value = edges_prev
76
+ elif edges_prev and edges_candidates:
77
+ choice = _preferred_recovery_candidate(edges_prev, edges_candidates) or NONE_OPTION
78
+ recovered_from_missing = bool(choice and choice != NONE_OPTION)
79
+ elif edges_prev:
80
+ choice = CUSTOM_OPTION
81
+ if not custom_value:
82
+ custom_value = edges_prev
83
+ else:
84
+ choice = edges_candidates[0] if edges_candidates else NONE_OPTION
85
+
86
+ if choice == CUSTOM_OPTION:
87
+ edges_clean = custom_value.strip()
88
+ elif choice == NONE_OPTION:
89
+ edges_clean = ""
90
+ else:
91
+ edges_clean = choice.strip()
92
+
93
+ return EdgesPickerState(
94
+ picker_options=picker_options,
95
+ choice=choice,
96
+ custom_value=custom_value,
97
+ edges_clean=edges_clean,
98
+ recovered_from_missing=recovered_from_missing,
99
+ )
@@ -0,0 +1,14 @@
1
+ """Support module for the maps network Streamlit page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ try:
9
+ from .view_maps_network import * # type: ignore # noqa: F401,F403
10
+ except ImportError: # pragma: no cover
11
+ _HERE = Path(__file__).resolve().parent
12
+ if str(_HERE) not in sys.path:
13
+ sys.path.insert(0, str(_HERE))
14
+ from view_maps_network import * # type: ignore # noqa: F401,F403
@@ -0,0 +1,455 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import networkx as nx
9
+ import pandas as pd
10
+ import plotly.graph_objects as go
11
+ from IPython.display import Markdown
12
+
13
+
14
+ def _coerce_str_list(value: Any) -> list[str]:
15
+ if value is None:
16
+ return []
17
+ if isinstance(value, str):
18
+ raw_items = value.replace(";", ",").replace("\n", ",").split(",")
19
+ elif isinstance(value, (list, tuple, set)):
20
+ raw_items = [str(item) for item in value]
21
+ else:
22
+ raw_items = [str(value)]
23
+ items: list[str] = []
24
+ seen: set[str] = set()
25
+ for item in raw_items:
26
+ cleaned = str(item).strip()
27
+ if not cleaned or cleaned in seen:
28
+ continue
29
+ seen.add(cleaned)
30
+ items.append(cleaned)
31
+ return items
32
+
33
+
34
+ def _read_toml_dict(path: str | Path | None) -> dict[str, Any]:
35
+ if not path:
36
+ return {}
37
+ try:
38
+ candidate = Path(path).expanduser()
39
+ except (OSError, RuntimeError, TypeError, ValueError):
40
+ return {}
41
+ if not candidate.exists():
42
+ return {}
43
+ try:
44
+ import tomllib
45
+
46
+ with open(candidate, "rb") as handle:
47
+ payload = tomllib.load(handle)
48
+ except (OSError, ValueError, tomllib.TOMLDecodeError):
49
+ return {}
50
+ return payload if isinstance(payload, dict) else {}
51
+
52
+
53
+ def _page_setting_sources(export_payload: dict[str, Any]) -> list[dict[str, Any]]:
54
+ page_key = "view_maps_network"
55
+ sources: list[dict[str, Any]] = []
56
+ for candidate in (
57
+ Path(export_payload.get("artifact_dir") or "") / "app_settings.toml",
58
+ export_payload.get("app_settings_file"),
59
+ ):
60
+ payload = _read_toml_dict(candidate)
61
+ if not payload:
62
+ continue
63
+ direct = payload.get(page_key)
64
+ if isinstance(direct, dict):
65
+ sources.append(direct)
66
+ pages = payload.get("pages")
67
+ if isinstance(pages, dict):
68
+ nested = pages.get(page_key)
69
+ if isinstance(nested, dict):
70
+ sources.append(nested)
71
+ return sources
72
+
73
+
74
+ def _first_nonempty_setting(sources: list[dict[str, Any]], *keys: str) -> str:
75
+ for source in sources:
76
+ if not isinstance(source, dict):
77
+ continue
78
+ for key in keys:
79
+ value = source.get(key)
80
+ if isinstance(value, str) and value.strip():
81
+ return value.strip()
82
+ return ""
83
+
84
+
85
+ def _setting_list(sources: list[dict[str, Any]], *keys: str) -> list[str]:
86
+ items: list[str] = []
87
+ seen: set[str] = set()
88
+ for source in sources:
89
+ if not isinstance(source, dict):
90
+ continue
91
+ for key in keys:
92
+ for item in _coerce_str_list(source.get(key)):
93
+ if item in seen:
94
+ continue
95
+ seen.add(item)
96
+ items.append(item)
97
+ return items
98
+
99
+
100
+ def _candidate_base_dirs(export_payload: dict[str, Any], sources: list[dict[str, Any]]) -> list[Path]:
101
+ artifact_dir = Path(export_payload.get("artifact_dir") or ".").expanduser()
102
+ roots: list[Path] = [
103
+ artifact_dir,
104
+ artifact_dir / "pipeline",
105
+ Path.home() / "localshare",
106
+ Path.home() / "export",
107
+ ]
108
+ subdirs = _setting_list(sources, "dataset_subpath", "datadir_rel")
109
+ for base in list(roots):
110
+ for subdir in subdirs:
111
+ roots.append(base / subdir)
112
+ unique: list[Path] = []
113
+ seen: set[Path] = set()
114
+ for root in roots:
115
+ try:
116
+ resolved = root.expanduser().resolve(strict=False)
117
+ except (OSError, RuntimeError, TypeError, ValueError):
118
+ resolved = root.expanduser()
119
+ if resolved in seen:
120
+ continue
121
+ seen.add(resolved)
122
+ unique.append(resolved)
123
+ return unique
124
+
125
+
126
+ def _resolve_declared_path(value: str, base_dirs: list[Path]) -> Path | None:
127
+ raw = str(value or "").strip()
128
+ if not raw:
129
+ return None
130
+ path = Path(raw).expanduser()
131
+ if path.is_absolute():
132
+ return path
133
+ for base in base_dirs:
134
+ candidate = (base / raw).expanduser()
135
+ if candidate.exists():
136
+ return candidate
137
+ return None
138
+
139
+
140
+ def _expand_globs(patterns: list[str], base_dirs: list[Path]) -> list[Path]:
141
+ matches: list[Path] = []
142
+ seen: set[Path] = set()
143
+ for raw_pattern in patterns:
144
+ pattern = str(raw_pattern or "").strip()
145
+ if not pattern:
146
+ continue
147
+ path = Path(pattern).expanduser()
148
+ candidates = [str(path)] if path.is_absolute() else [str(base / pattern) for base in base_dirs]
149
+ for candidate in candidates:
150
+ for match in glob.glob(candidate, recursive=True):
151
+ path_match = Path(match).expanduser()
152
+ if not path_match.is_file():
153
+ continue
154
+ try:
155
+ resolved = path_match.resolve(strict=False)
156
+ except (OSError, RuntimeError, TypeError, ValueError):
157
+ resolved = path_match
158
+ if resolved in seen:
159
+ continue
160
+ seen.add(resolved)
161
+ matches.append(resolved)
162
+ matches.sort(key=lambda path: path.stat().st_mtime if path.exists() else 0.0, reverse=True)
163
+ return matches
164
+
165
+
166
+ def _discover_topology_path(sources: list[dict[str, Any]], base_dirs: list[Path]) -> Path | None:
167
+ declared = _first_nonempty_setting(sources, "edges_file")
168
+ resolved = _resolve_declared_path(declared, base_dirs)
169
+ if resolved and resolved.exists():
170
+ return resolved
171
+ patterns = [
172
+ "pipeline/topology.gml",
173
+ "pipeline/ilp_topology.gml",
174
+ "pipeline/topology.json",
175
+ "network_sim/pipeline/topology.gml",
176
+ "network_sim/pipeline/ilp_topology.gml",
177
+ "network_sim/pipeline/topology.json",
178
+ ]
179
+ matches = _expand_globs(patterns, base_dirs)
180
+ return matches[0] if matches else None
181
+
182
+
183
+ def _discover_trajectory_paths(sources: list[dict[str, Any]], base_dirs: list[Path]) -> list[Path]:
184
+ patterns = _setting_list(sources, "traj_glob", "default_traj_globs")
185
+ if not patterns:
186
+ patterns = [
187
+ "flight_trajectory/pipeline/*.csv",
188
+ "flight_trajectory/pipeline/*.parquet",
189
+ "*trajectory*/pipeline/*.csv",
190
+ "*trajectory*/pipeline/*.parquet",
191
+ ]
192
+ return _expand_globs(patterns, base_dirs)
193
+
194
+
195
+ def _load_graph(path: Path | None) -> nx.Graph | None:
196
+ if path is None or not path.exists():
197
+ return None
198
+ try:
199
+ return nx.read_gml(path)
200
+ except Exception:
201
+ pass
202
+ try:
203
+ payload = json.loads(path.read_text(encoding="utf-8"))
204
+ except Exception:
205
+ return None
206
+ graph = nx.Graph()
207
+ if isinstance(payload, dict):
208
+ for node in payload.get("nodes", []) or []:
209
+ if isinstance(node, dict):
210
+ node_id = str(node.get("id", "") or "").strip()
211
+ if node_id:
212
+ graph.add_node(node_id, **{k: v for k, v in node.items() if k != "id"})
213
+ elif node is not None:
214
+ graph.add_node(str(node))
215
+ for edge in payload.get("edges", []) or []:
216
+ if isinstance(edge, dict):
217
+ source = str(edge.get("source", "") or "").strip()
218
+ target = str(edge.get("target", "") or "").strip()
219
+ if source and target:
220
+ graph.add_edge(source, target, **{k: v for k, v in edge.items() if k not in {"source", "target"}})
221
+ elif isinstance(edge, (list, tuple)) and len(edge) >= 2:
222
+ graph.add_edge(str(edge[0]), str(edge[1]))
223
+ elif isinstance(payload, list):
224
+ for edge in payload:
225
+ if isinstance(edge, (list, tuple)) and len(edge) >= 2:
226
+ graph.add_edge(str(edge[0]), str(edge[1]))
227
+ return graph if graph.number_of_nodes() else None
228
+
229
+
230
+ def _load_frame(path: Path) -> pd.DataFrame:
231
+ if path.suffix.lower() in {".parquet", ".pq", ".parq"}:
232
+ return pd.read_parquet(path)
233
+ return pd.read_csv(path)
234
+
235
+
236
+ def _first_column(columns: dict[str, str], *names: str) -> str:
237
+ for name in names:
238
+ candidate = columns.get(name)
239
+ if candidate:
240
+ return candidate
241
+ return ""
242
+
243
+
244
+ def _best_id_column(df: pd.DataFrame) -> str:
245
+ lowered = {column.lower(): column for column in df.columns}
246
+ for candidate in (
247
+ "plane_id",
248
+ "trajectory_id",
249
+ "node_id",
250
+ "flight_id",
251
+ "id",
252
+ "plane_label",
253
+ "stable_flight_id",
254
+ "sat_name",
255
+ "name",
256
+ "callsign",
257
+ "call_sign",
258
+ ):
259
+ column = lowered.get(candidate)
260
+ if column:
261
+ return column
262
+ return ""
263
+
264
+
265
+ def _load_positions(paths: list[Path]) -> pd.DataFrame:
266
+ frames: list[pd.DataFrame] = []
267
+ for path in paths:
268
+ try:
269
+ df = _load_frame(path)
270
+ except Exception:
271
+ continue
272
+ lowered = {column.lower(): column for column in df.columns}
273
+ time_col = _first_column(lowered, "time_s", "t_now_s", "time", "t", "time_index")
274
+ lat_col = _first_column(lowered, "latitude", "lat")
275
+ lon_col = _first_column(lowered, "longitude", "lon", "long")
276
+ alt_col = _first_column(lowered, "alt_m", "altitude_m", "altitude", "alt")
277
+ id_col = _best_id_column(df)
278
+ if not (time_col and lat_col and lon_col and id_col):
279
+ continue
280
+ subset = pd.DataFrame(
281
+ {
282
+ "node_id": df[id_col].astype(str),
283
+ "time_value": pd.to_numeric(df[time_col], errors="coerce"),
284
+ "lat": pd.to_numeric(df[lat_col], errors="coerce"),
285
+ "lon": pd.to_numeric(df[lon_col], errors="coerce"),
286
+ "alt": pd.to_numeric(df[alt_col], errors="coerce") if alt_col else 0.0,
287
+ }
288
+ ).dropna(subset=["node_id", "lat", "lon"])
289
+ if subset.empty:
290
+ continue
291
+ subset["source_file"] = str(path)
292
+ subset = subset.sort_values("time_value")
293
+ frames.append(subset.groupby("node_id", as_index=False).tail(1))
294
+ if not frames:
295
+ return pd.DataFrame(columns=["node_id", "lat", "lon", "alt", "source_file"])
296
+ result = pd.concat(frames, ignore_index=True)
297
+ return result.groupby("node_id", as_index=False).tail(1).reset_index(drop=True)
298
+
299
+
300
+ def _geo_map_figure(graph: nx.Graph | None, positions: pd.DataFrame, *, title: str) -> go.Figure:
301
+ fig = go.Figure()
302
+ pos_index = positions.set_index("node_id", drop=False)
303
+ if graph is not None:
304
+ edge_lons: list[float | None] = []
305
+ edge_lats: list[float | None] = []
306
+ for source, target in graph.edges():
307
+ if str(source) not in pos_index.index or str(target) not in pos_index.index:
308
+ continue
309
+ src = pos_index.loc[str(source)]
310
+ dst = pos_index.loc[str(target)]
311
+ edge_lons.extend([float(src["lon"]), float(dst["lon"]), None])
312
+ edge_lats.extend([float(src["lat"]), float(dst["lat"]), None])
313
+ if edge_lons:
314
+ fig.add_trace(
315
+ go.Scattergeo(
316
+ lon=edge_lons,
317
+ lat=edge_lats,
318
+ mode="lines",
319
+ line=dict(width=1.5, color="#5b6c87"),
320
+ opacity=0.7,
321
+ name="Topology",
322
+ hoverinfo="skip",
323
+ )
324
+ )
325
+ fig.add_trace(
326
+ go.Scattergeo(
327
+ lon=positions["lon"],
328
+ lat=positions["lat"],
329
+ text=positions["node_id"],
330
+ mode="markers+text",
331
+ textposition="top center",
332
+ marker=dict(size=9, color="#1f77b4", line=dict(width=1, color="#ffffff")),
333
+ name="Nodes",
334
+ hovertemplate="Node: %{text}<br>Lat: %{lat}<br>Lon: %{lon}<extra></extra>",
335
+ )
336
+ )
337
+ fig.update_layout(
338
+ title=title,
339
+ height=620,
340
+ margin=dict(l=10, r=10, t=50, b=10),
341
+ geo=dict(
342
+ projection_type="equirectangular",
343
+ showland=True,
344
+ landcolor="#eef3f8",
345
+ showcountries=True,
346
+ countrycolor="#b9c4d0",
347
+ showocean=True,
348
+ oceancolor="#dbe8f4",
349
+ fitbounds="locations",
350
+ ),
351
+ legend=dict(orientation="h"),
352
+ )
353
+ return fig
354
+
355
+
356
+ def _topology_figure(graph: nx.Graph, *, title: str) -> go.Figure:
357
+ pos = nx.spring_layout(graph, seed=42)
358
+ edge_x: list[float | None] = []
359
+ edge_y: list[float | None] = []
360
+ for source, target in graph.edges():
361
+ x0, y0 = pos[source]
362
+ x1, y1 = pos[target]
363
+ edge_x.extend([x0, x1, None])
364
+ edge_y.extend([y0, y1, None])
365
+ node_x = [pos[node][0] for node in graph.nodes()]
366
+ node_y = [pos[node][1] for node in graph.nodes()]
367
+ node_text = [str(node) for node in graph.nodes()]
368
+ fig = go.Figure(
369
+ data=[
370
+ go.Scatter(x=edge_x, y=edge_y, mode="lines", line=dict(width=1.2, color="#8fa0bc"), hoverinfo="skip"),
371
+ go.Scatter(
372
+ x=node_x,
373
+ y=node_y,
374
+ mode="markers+text",
375
+ text=node_text,
376
+ textposition="top center",
377
+ marker=dict(size=10, color="#1f77b4"),
378
+ hovertemplate="Node: %{text}<extra></extra>",
379
+ ),
380
+ ]
381
+ )
382
+ fig.update_layout(
383
+ title=title,
384
+ height=620,
385
+ margin=dict(l=10, r=10, t=50, b=10),
386
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
387
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
388
+ showlegend=False,
389
+ )
390
+ return fig
391
+
392
+
393
+ def _summary_markdown(
394
+ *,
395
+ title: str,
396
+ topology_path: Path | None,
397
+ trajectory_paths: list[Path],
398
+ graph: nx.Graph | None,
399
+ positions: pd.DataFrame,
400
+ expected_artifacts: list[str],
401
+ checked_roots: list[Path],
402
+ ) -> Markdown:
403
+ lines = [f"#### {title}", ""]
404
+ if topology_path is not None:
405
+ lines.append(f"- Topology source: `{topology_path}`")
406
+ else:
407
+ lines.append("- Topology source: not found")
408
+ if trajectory_paths:
409
+ lines.append(f"- Trajectory files: {len(trajectory_paths)}")
410
+ lines.append(f" - First match: `{trajectory_paths[0]}`")
411
+ else:
412
+ lines.append("- Trajectory files: none found")
413
+ if graph is not None:
414
+ lines.append(f"- Graph nodes/edges: {graph.number_of_nodes()} / {graph.number_of_edges()}")
415
+ if not positions.empty:
416
+ lines.append(f"- Positioned nodes: {len(positions)}")
417
+ if graph is None and positions.empty:
418
+ lines.extend(
419
+ [
420
+ "",
421
+ "No notebook-native map could be rendered because no topology or trajectory artifacts were found.",
422
+ ]
423
+ )
424
+ if expected_artifacts:
425
+ lines.append("- Expected artifacts:")
426
+ lines.extend(f" - `{artifact}`" for artifact in expected_artifacts)
427
+ lines.append("- Checked roots:")
428
+ lines.extend(f" - `{root}`" for root in checked_roots)
429
+ return Markdown("\n".join(lines))
430
+
431
+
432
+ def render_inline(*, page: str, record: dict[str, Any], export_payload: dict[str, Any]) -> list[Any]:
433
+ sources = _page_setting_sources(export_payload)
434
+ base_dirs = _candidate_base_dirs(export_payload, sources)
435
+ topology_path = _discover_topology_path(sources, base_dirs)
436
+ trajectory_paths = _discover_trajectory_paths(sources, base_dirs)
437
+ graph = _load_graph(topology_path)
438
+ positions = _load_positions(trajectory_paths)
439
+ title = str(record.get("label") or page or "Maps Network")
440
+ outputs: list[Any] = [
441
+ _summary_markdown(
442
+ title=title,
443
+ topology_path=topology_path,
444
+ trajectory_paths=trajectory_paths,
445
+ graph=graph,
446
+ positions=positions,
447
+ expected_artifacts=[str(item) for item in record.get("artifacts", [])],
448
+ checked_roots=base_dirs,
449
+ )
450
+ ]
451
+ if not positions.empty:
452
+ outputs.append(_geo_map_figure(graph, positions, title=title))
453
+ elif graph is not None:
454
+ outputs.append(_topology_figure(graph, title=title))
455
+ return outputs