diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index 1b8bad2f..c09c89c4 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -198,14 +198,17 @@ convention resolve_X/sanitize_X que `quiz_config.py`.
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
supprimable depuis le panneau Propriétés — voir
-`forgeDocRenderScenarioNodeHtml`, static/document/js/document-editor.js).
-Chaque nœud est `{"id": str, "text": str, "choices": [...]}` — `id` est
-une référence STABLE (générée côté client, jamais recalculée côté
-serveur) vers laquelle un choix d'un AUTRE nœud peut pointer via son
-`target_id` ; un nœud SANS choix est une fin de branche (état normal,
-pas une erreur — contrairement à l'ancien modèle qui exigeait 2 à 4
-choix par scénario). Un choix est `{"text": str, "target_id": str |
-None}` — `target_id` à `None` signifie "fin de branche à ce choix".
+`forgeDocScenarioGraphRenderInspector`, static/document/js/
+document-editor.js). Chaque nœud est `{"id": str, "text": str,
+"x": float, "y": float, "choices": [...]}` — `id` est une référence
+STABLE (générée côté client, jamais recalculée côté serveur) vers
+laquelle un choix d'un AUTRE nœud peut pointer via son `target_id` ;
+`x`/`y` positionnent le nœud sur le canevas du graphe visuel (voir
+`forgeDocRenderScenarioTreeModal`) ; un nœud SANS choix est une fin de
+branche (état normal, pas une erreur — contrairement à l'ancien modèle
+qui exigeait 2 à 4 choix par scénario). Un choix est `{"text": str,
+"target_id": str | None}` — `target_id` à `None` signifie "fin de
+branche à ce choix".
### `MAX_SCENARIO_CHOICES: int`
Borne technique du nombre de choix par nœud (`4`) — évite une UI
@@ -230,8 +233,13 @@ est validé indépendamment (voir `_sanitize_scenario`/
`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier
-est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Le
-scénario entier est supprimé s'il ne reste plus aucun nœud valide après
+est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Un
+`x`/`y` manquant ou invalide (pas un nombre, ou un booléen — `bool`
+hérite de `int` en Python) retombe sur un quadrillage en cascade dérivé
+de l'INDEX du nœud dans la liste (`_scenario_node_fallback_position`),
+jamais `(0, 0)` pour tous les nœuds, ce qui les empilerait exactement au
+même endroit sur le graphe visuel. Le scénario entier est supprimé s'il
+ne reste plus aucun nœud valide après
nettoyage (il faut au moins la situation initiale). Une fois l'ensemble
des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
existant (nœud invalide/supprimé) est silencieusement remis à `None`
diff --git a/document_engine/labels/scenario_config.py b/document_engine/labels/scenario_config.py
index ea4b079f..e8a13708 100644
--- a/document_engine/labels/scenario_config.py
+++ b/document_engine/labels/scenario_config.py
@@ -16,16 +16,27 @@ jamais, et renvoie toujours un dict complet.
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
depuis le panneau Propriétés). Chaque nœud est
-{"id": str, "text": str, "choices": [...]} — `id` est une référence
-STABLE (générée côté client, jamais recalculée ici) vers laquelle un
-choix d'un AUTRE nœud peut pointer via son `target_id` ; un nœud sans
-choix est une fin de branche. Un choix est {"text": str,
-"target_id": str | None} — `target_id` à None signifie "fin de branche
-à ce choix" (aucun nœud suivant)."""
+{"id": str, "text": str, "x": float, "y": float, "choices": [...]} —
+`id` est une référence STABLE (générée côté client, jamais recalculée
+ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son
+`target_id` ; un nœud sans choix est une fin de branche. `x`/`y`
+positionnent le nœud sur le canevas du graphe visuel (voir
+document_engine/rendering/render_document_element.py::
+_render_scenario_player et forgeDocRenderScenarioGraph, static/document/
+js/document-editor.js) — une position manquante/invalide retombe sur un
+quadrillage en cascade calculé depuis l'INDEX du nœud dans la liste
+(_scenario_node_fallback_position), jamais (0, 0) pour tous les nœuds,
+ce qui les empilerait exactement au même endroit. Un choix est
+{"text": str, "target_id": str | None} — `target_id` à None signifie
+"fin de branche à ce choix" (aucun nœud suivant)."""
from typing import Any
MAX_SCENARIO_CHOICES = 4
+_NODE_GRID_COLUMNS = 4
+_NODE_GRID_STEP_X = 220
+_NODE_GRID_STEP_Y = 160
+_NODE_GRID_MARGIN = 40
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
@@ -48,7 +59,22 @@ def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None:
return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
-def _sanitize_scenario_node(raw: Any) -> dict[str, Any] | None:
+def _scenario_node_fallback_position(index: int) -> tuple[float, float]:
+ """Position par défaut d'un nœud dont x/y est manquant/invalide — un
+ quadrillage en cascade dérivé de son INDEX dans la liste, jamais
+ (0, 0) pour tous les nœuds (qui les empilerait exactement au même
+ endroit, rendant le graphe visuel illisible à la première ouverture
+ d'un scénario créé avant l'ajout de x/y au modèle, ou d'un nœud
+ ajouté par un client qui n'enverrait pas encore de position)."""
+ col = index % _NODE_GRID_COLUMNS
+ row = index // _NODE_GRID_COLUMNS
+ return (
+ _NODE_GRID_MARGIN + col * _NODE_GRID_STEP_X,
+ _NODE_GRID_MARGIN + row * _NODE_GRID_STEP_Y,
+ )
+
+
+def _sanitize_scenario_node(raw: Any, index: int) -> dict[str, Any] | None:
"""None si le nœud est invalide (id absent, ou texte vide) — même
convention que les autres sanitize_X de ce module. Un nœud SANS choix
est parfaitement valide (fin de branche), contrairement à l'ancien
@@ -67,7 +93,11 @@ def _sanitize_scenario_node(raw: Any) -> dict[str, Any] | None:
if isinstance(raw_choices, list):
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
choices = choices[:MAX_SCENARIO_CHOICES]
- return {"id": node_id, "text": text, "choices": choices}
+ fallback_x, fallback_y = _scenario_node_fallback_position(index)
+ raw_x, raw_y = raw.get("x"), raw.get("y")
+ x = raw_x if isinstance(raw_x, (int, float)) and not isinstance(raw_x, bool) else fallback_x
+ y = raw_y if isinstance(raw_y, (int, float)) and not isinstance(raw_y, bool) else fallback_y
+ return {"id": node_id, "text": text, "x": x, "y": y, "choices": choices}
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
@@ -87,10 +117,10 @@ def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
raw_nodes = raw.get("nodes")
if not isinstance(raw_nodes, list):
return None
- nodes = []
+ nodes: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for item in raw_nodes:
- node = _sanitize_scenario_node(item)
+ node = _sanitize_scenario_node(item, len(nodes))
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md
index 527b8c37..be288ef2 100644
--- a/document_engine/rendering/rendering.md
+++ b/document_engine/rendering/rendering.md
@@ -130,7 +130,16 @@ regroupement à chaque appel.
dans une MODALE dédiée depuis le panneau Propriétés (voir
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
— trop de structure (nœuds + choix + destinations) pour la colonne
- étroite du panneau Propriétés, contrairement aux autres mini-jeux.
+ étroite du panneau Propriétés, contrairement aux autres mini-jeux. La
+ modale est un VRAI graphe visuel (retour utilisateur du 21/09/2026) :
+ chaque nœud a une position `x`/`y` (voir `scenario_config.py`),
+ affiché comme une carte déplaçable à la souris sur un canevas ; chaque
+ choix relié à un `target_id` est dessiné comme une flèche SVG
+ étiquetée par son texte, jamais un simple menu déroulant. Éditer le
+ texte/les choix d'un nœud se fait dans l'inspecteur (panneau de
+ droite) du nœud sélectionné ; relier un choix se fait en cliquant
+ "Relier" puis le nœud cible sur le graphe (mode connexion, Échap
+ annule).
- **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé
du type (voir `document_engine/labels/element_kind_labels.py`) —
emplacement réservé, formulaire de contenu dédié hors périmètre de
diff --git a/static/document/document-editor.css b/static/document/document-editor.css
index b5f670ed..d3a27549 100644
--- a/static/document/document-editor.css
+++ b/static/document/document-editor.css
@@ -1469,6 +1469,220 @@ img.docImage {
justify-content: center;
}
+/* ---- Scénario : éditeur d'arbre en GRAPHE VISUEL (retour utilisateur
+ du 21/09/2026 : "passons à un véritable graphe visuel", remplace la
+ première version en liste de nœuds + menus déroulants). Dialogue bien
+ plus grand que la modale générique par défaut : un graphe a besoin de
+ place pour être lisible. ---- */
+#docScenarioTreeModal .docModalDialog {
+ width: min(1180px, 95vw);
+ max-height: min(88vh, 820px);
+}
+
+#docScenarioTreeModal .docModalBody {
+ padding: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.docScenarioGraphTitleRow {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--doc-border);
+}
+
+.docScenarioGraphTitleRow input {
+ width: 100%;
+ border: 1px solid var(--doc-border);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--doc-text);
+ background: var(--doc-bg-2);
+}
+
+.docScenarioGraphToolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 20px;
+ border-bottom: 1px solid var(--doc-border);
+}
+
+.docScenarioGraphHint {
+ font-size: 12.5px;
+ color: var(--doc-muted);
+}
+
+.docScenarioGraphHint.is-connecting {
+ color: var(--doc-accent);
+ font-weight: 600;
+}
+
+.docScenarioGraphSplit {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+}
+
+.docScenarioGraphCanvas {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: auto;
+ background:
+ radial-gradient(circle, var(--doc-border) 1px, transparent 1px) 0 0 / 24px 24px,
+ var(--doc-bg-2);
+ cursor: default;
+}
+
+.docScenarioGraphCanvas.is-connecting {
+ cursor: crosshair;
+}
+
+.docScenarioGraphCanvas.is-connecting .docScenarioGraphNode {
+ cursor: crosshair;
+}
+
+.docScenarioGraphInner {
+ position: relative;
+ width: 2200px;
+ height: 1600px;
+}
+
+.docScenarioGraphSvg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+.docScenarioGraphEdge {
+ fill: none;
+ stroke: var(--doc-muted);
+ stroke-width: 2;
+}
+
+.docScenarioGraphEdgeLabel {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ background: var(--doc-bg-2);
+ border: 1px solid var(--doc-border);
+ border-radius: 6px;
+ padding: 2px 7px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--doc-muted);
+ white-space: nowrap;
+ max-width: 140px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.docScenarioGraphNode {
+ position: absolute;
+ width: 180px;
+ border: 1px solid var(--doc-border);
+ border-radius: 10px;
+ background: var(--doc-card);
+ padding: 10px 12px;
+ cursor: grab;
+ user-select: none;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.docScenarioGraphNode:active {
+ cursor: grabbing;
+}
+
+.docScenarioGraphNode.is-root {
+ border-color: var(--doc-accent);
+}
+
+.docScenarioGraphNode.is-selected {
+ border-color: var(--doc-accent);
+ box-shadow: 0 0 0 2px var(--doc-accent);
+}
+
+.docScenarioGraphNode.is-connect-target:hover {
+ border-color: var(--doc-quiz-success-border);
+ box-shadow: 0 0 0 2px var(--doc-quiz-success-border);
+}
+
+.docScenarioGraphNodeLabel {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: var(--doc-accent);
+ margin-bottom: 4px;
+}
+
+.docScenarioGraphNodeText {
+ font-size: 12.5px;
+ line-height: 1.35;
+ color: var(--doc-text);
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+ overflow: hidden;
+}
+
+.docScenarioGraphInspector {
+ flex: 0 0 300px;
+ border-left: 1px solid var(--doc-border);
+ padding: 16px;
+ overflow-y: auto;
+}
+
+.docScenarioGraphInspectorEmpty {
+ font-size: 13px;
+ color: var(--doc-muted);
+ text-align: center;
+ padding: 30px 10px;
+}
+
+.docScenarioChoiceTargetRow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.docScenarioChoiceTargetLabel {
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ color: var(--doc-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.docScenarioChoiceLinkBtn {
+ border: 1px solid var(--doc-border);
+ background: transparent;
+ color: var(--doc-text);
+ border-radius: 6px;
+ padding: 3px 8px;
+ font-size: 11.5px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.docScenarioChoiceLinkBtn:hover {
+ border-color: var(--doc-accent);
+}
+
+.docScenarioChoiceLinkBtn.is-active {
+ border-color: var(--doc-accent);
+ background: var(--doc-accent);
+ color: #14100c;
+}
+
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
@media (width <= 900px) {
.docCrumbDim {
diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js
index 2c6c90f3..d6f61220 100644
--- a/static/document/js/document-editor.js
+++ b/static/document/js/document-editor.js
@@ -1873,12 +1873,16 @@ function forgeDocScenarioNewScenario() {
// Mots mêlés.
return {
title: 'Nouveau scénario',
- nodes: [{ id: 'n1', text: 'Nouvelle situation', choices: [] }],
+ nodes: [{
+ id: 'n1', text: 'Nouvelle situation', x: 40, y: 40, choices: [],
+ }],
};
}
-function forgeDocScenarioNewNode(id) {
- return { id, text: 'Nouveau nœud', choices: [] };
+function forgeDocScenarioNewNode(id, x, y) {
+ return {
+ id, text: 'Nouveau nœud', x, y, choices: [],
+ };
}
function forgeDocScenarioGenerateNodeId(nodes) {
@@ -1953,176 +1957,463 @@ function forgeDocRenderScenarioProps(panel, el) {
}
/* ---------------------------------------------------------------------
- * Scénario — modale de construction de l'arbre : une carte par nœud
- * (texte + liste de choix), chaque choix a un menu déroulant "mène à"
- * listant les autres nœuds du même scénario (ou "— Fin de branche —").
- * La situation initiale (nodes[0]) n'est jamais supprimable depuis ici.
- * Persistance identique aux autres panneaux : chaque modification
- * envoie l'état COMPLET du scénario au serveur (voir patch ci-dessous),
- * jamais un état simulé côté client qui pourrait diverger de ce que
- * sanitize_scenario_config a réellement accepté.
+ * Scénario — modale de construction de l'arbre : un VRAI graphe visuel
+ * (retour utilisateur du 21/09/2026 : "passons à un véritable graphe
+ * visuel", remplace la première version en liste de nœuds + menus
+ * déroulants). Les nœuds sont des cartes positionnées/déplaçables à la
+ * souris (glisser, comme les formes libres du canevas — voir
+ * forgeDocBindShapeDrag) ; les choix qui ont une destination sont
+ * dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par
+ * le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire
+ * d'édition (texte + choix) dans l'inspecteur (colonne de droite) —
+ * relier un choix à un autre nœud se fait en cliquant "Relier" puis en
+ * cliquant le nœud cible sur le graphe (mode "connexion", Échap annule).
+ * La situation initiale (nodes[0]) reste déplaçable mais jamais
+ * supprimable. Persistance identique aux autres panneaux : chaque
+ * modification envoie l'état COMPLET du scénario au serveur (voir
+ * forgeDocScenarioGraphPatch), jamais un état simulé côté client qui
+ * pourrait diverger de ce que sanitize_scenario_config a réellement
+ * accepté — SAUF la position pendant un glisser, mise à jour localement
+ * en continu pour rester fluide, et persistée en un seul appel au
+ * relâchement de la souris (même principe que forgeDocBindShapeDrag).
* ------------------------------------------------------------------- */
-function forgeDocRenderScenarioNodeHtml(node, nIndex, allNodes, isRoot) {
- const choicesHtml = node.choices.map((choice, cIndex) => `
-