Livret PDF v2, graphe des liens côté campagne, et refactor de l'import

Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
  en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
  ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
  fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
  explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
  visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)

Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
  endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
  autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
  filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
  PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)

Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
  inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
  budget SCSS anyComponentStyle relevé à 12 ko
This commit is contained in:
2026-07-04 11:15:16 +02:00
parent b2c3800bf8
commit beafbc2fa9
69 changed files with 3720 additions and 1509 deletions

View File

@@ -44,7 +44,7 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kb",
"maximumWarning": "12kb",
"maximumError": "16kb"
}
]

View File

@@ -5,7 +5,8 @@
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build": "npm run i18n:check && ng build",
"i18n:check": "node scripts/check-i18n.mjs",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:unit": "vitest run",

127
web/scripts/check-i18n.mjs Normal file
View File

@@ -0,0 +1,127 @@
// Vérificateur de cohérence i18n.
//
// Les traductions vivent en DOUBLE : les fichiers consolidés (src/assets/i18n/fr.json,
// en.json — ceux que charge ngx-translate) et les fragments de maintenance
// (src/assets/i18n/fragments/*.fr.json / *.en.json). Une section peut être alimentée
// par PLUSIEURS fragments (ex. playthroughDetail) : un fragment est donc un
// SOUS-ENSEMBLE du consolidé, pas une copie exacte.
//
// ERREURS (code retour 1) :
// 1. clé de fragment absente du consolidé, ou valeur différente (le consolidé est
// ce que voit l'utilisateur : un fragment en avance = trad perdue en prod) ;
// 2. deux fragments qui définissent la même clé avec des valeurs contradictoires ;
// 3. parité structurelle fr/en rompue (fr.json vs en.json, et chaque paire de
// fragments x.fr.json / x.en.json) ;
// 4. JSON invalide ou paire de fragments incomplète.
//
// AVERTISSEMENTS (non bloquants) : clés du consolidé couvertes par aucun fragment
// (dette de maintenance, pas un bug utilisateur).
//
// Usage : node scripts/check-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const webDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const i18nDir = path.join(webDir, 'src', 'assets', 'i18n');
const fragmentsDir = path.join(i18nDir, 'fragments');
const errors = [];
const warnings = [];
function loadJson(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch (e) {
errors.push(`${path.relative(webDir, file)} : JSON invalide (${e.message})`);
return null;
}
}
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
/** Feuilles d'un objet : Map "a.b.c" -> valeur (les tableaux sont des feuilles). */
function leaves(obj, prefix = '', out = new Map()) {
for (const [key, value] of Object.entries(obj)) {
const p = prefix ? `${prefix}.${key}` : key;
if (isObject(value)) leaves(value, p, out);
else out.set(p, value);
}
return out;
}
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** Parité structurelle : mêmes chemins de feuilles des deux côtés. */
function structuralDiff(labelA, a, labelB, b) {
const pathsA = leaves(a);
const pathsB = leaves(b);
for (const p of pathsA.keys()) if (!pathsB.has(p)) errors.push(`${labelB} : clé manquante « ${p} » (présente dans ${labelA})`);
for (const p of pathsB.keys()) if (!pathsA.has(p)) errors.push(`${labelA} : clé manquante « ${p} » (présente dans ${labelB})`);
}
// --- Chargement -------------------------------------------------------------
const consolidated = {
fr: loadJson(path.join(i18nDir, 'fr.json')),
en: loadJson(path.join(i18nDir, 'en.json'))
};
const consolidatedLeaves = {
fr: consolidated.fr ? leaves(consolidated.fr) : new Map(),
en: consolidated.en ? leaves(consolidated.en) : new Map()
};
const fragmentFiles = readdirSync(fragmentsDir).filter(f => f.endsWith('.json')).sort();
// --- 1 + 2 : chaque clé de fragment doit exister À L'IDENTIQUE au consolidé ---
const fragmentLeafOwner = { fr: new Map(), en: new Map() };
for (const file of fragmentFiles) {
const match = file.match(/^(.+)\.(fr|en)\.json$/);
if (!match) { errors.push(`fragments/${file} : nom inattendu (attendu <nom>.fr.json / <nom>.en.json)`); continue; }
const lang = match[2];
const fragment = loadJson(path.join(fragmentsDir, file));
if (!fragment || !consolidated[lang]) continue;
for (const [leafPath, value] of leaves(fragment)) {
const owner = fragmentLeafOwner[lang].get(leafPath);
if (owner && !sameValue(owner.value, value)) {
errors.push(`Clé « ${leafPath} » (${lang}) contradictoire entre fragments/${owner.file} et fragments/${file}`);
}
fragmentLeafOwner[lang].set(leafPath, { file, value });
if (!consolidatedLeaves[lang].has(leafPath)) {
errors.push(`${lang}.json : clé « ${leafPath} » manquante (définie par fragments/${file})`);
} else if (!sameValue(consolidatedLeaves[lang].get(leafPath), value)) {
errors.push(`${lang}.json ≠ fragments/${file} : valeur différente pour « ${leafPath} »`);
}
}
}
// --- 3 : parité structurelle fr/en -------------------------------------------
if (consolidated.fr && consolidated.en) structuralDiff('fr.json', consolidated.fr, 'en.json', consolidated.en);
const fragmentNames = new Set(fragmentFiles.map(f => f.replace(/\.(fr|en)\.json$/, '')));
for (const name of fragmentNames) {
const frFile = `${name}.fr.json`;
const enFile = `${name}.en.json`;
if (!fragmentFiles.includes(frFile) || !fragmentFiles.includes(enFile)) {
errors.push(`fragments/${name} : paire fr/en incomplète`);
continue;
}
const fr = loadJson(path.join(fragmentsDir, frFile));
const en = loadJson(path.join(fragmentsDir, enFile));
if (fr && en) structuralDiff(`fragments/${frFile}`, fr, `fragments/${enFile}`, en);
}
// --- Avertissements : clés consolidées sans fragment (dette, non bloquant) ----
for (const lang of ['fr']) { // fr suffit : la parité fr/en est déjà vérifiée
const uncovered = [...consolidatedLeaves[lang].keys()].filter(p => !fragmentLeafOwner[lang].has(p));
if (uncovered.length > 0) {
warnings.push(`${uncovered.length} clé(s) de ${lang}.json sans fragment (dette de maintenance), ex. : ${uncovered.slice(0, 5).join(', ')}`);
}
}
// --- Rapport ------------------------------------------------------------------
for (const w of warnings) console.warn(`i18n (avertissement) : ${w}`);
if (errors.length > 0) {
console.error(`\ni18n : ${errors.length} incohérence(s) bloquante(s) :\n`);
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log(`i18n OK — ${Object.keys(consolidated.fr ?? {}).length} sections, ${consolidatedLeaves.fr.size} clés, ${fragmentFiles.length} fragments.`);

View File

@@ -4,7 +4,6 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
@@ -17,6 +16,7 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/graph', loadComponent: () => import('./campaigns/campaign-graph/campaign-graph.component').then(m => m.CampaignGraphComponent) },
{ path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
@@ -109,7 +110,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -127,7 +129,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre arcs frères via l'arbre
// (même route pattern), et ngOnInit ne se relance pas.
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {

View File

@@ -0,0 +1,101 @@
@if (campaign) {
<div class="graph-page">
<header class="graph-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
{{ 'campaignGraph.back' | translate }}
</button>
<div class="graph-title">
<h1>
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
{{ 'campaignGraph.title' | translate:{ name: campaign.name } }}
</h1>
<p class="graph-subtitle">
{{ 'campaignGraph.subtitle' | translate:{ pages: pageCount, npcs: npcCount, scenes: sceneCount, quests: questCount, edges: edgeCount } }}
</p>
</div>
<!-- Légende cliquable : masquer/afficher un type d'entité (préférence mémorisée). -->
<div class="graph-legend">
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'campaignGraph.legendPage' | translate }}</span>
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('npc')"
(click)="toggleKind('npc')" [title]="'campaignGraph.toggleHint' | translate">
<span class="legend-dot legend-dot--npc"></span> {{ 'campaignGraph.legendNpc' | translate }}
</button>
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('scene')"
(click)="toggleKind('scene')" [title]="'campaignGraph.toggleHint' | translate">
<span class="legend-dot legend-dot--scene"></span> {{ 'campaignGraph.legendScene' | translate }}
</button>
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('quest')"
(click)="toggleKind('quest')" [title]="'campaignGraph.toggleHint' | translate">
<span class="legend-dot legend-dot--quest"></span> {{ 'campaignGraph.legendQuest' | translate }}
</button>
</div>
<button type="button" class="btn-back" (click)="resetLayout()"
[title]="'campaignGraph.resetTitle' | translate">
<lucide-icon [img]="RotateCcw" [size]="14"></lucide-icon>
{{ 'campaignGraph.reset' | translate }}
</button>
</header>
@if (!hasLore) {
<div class="graph-empty">
{{ 'campaignGraph.noLore' | translate }}
</div>
} @else if (nodes.length === 0) {
<div class="graph-empty">
{{ 'campaignGraph.empty' | translate }}
</div>
} @else {
<div class="graph-scroll">
<svg #svgEl
[attr.width]="svgWidth"
[attr.height]="svgHeight"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointerleave)="onPointerUp($event)">
<!-- Arêtes (sous les nœuds) — estompées quand un autre nœud a le focus -->
@for (e of edges; track e.key) {
<line
[attr.x1]="e.x1" [attr.y1]="e.y1"
[attr.x2]="e.x2" [attr.y2]="e.y2"
[class.edge-page]="e.kind === 'page'"
[class.edge-npc]="e.kind === 'npc'"
[class.edge-scene]="e.kind === 'scene'"
[class.edge-quest]="e.kind === 'quest'"
[class.dimmed]="edgeDimmed(e)" />
}
<!-- Nœuds : survol = focus sur le nœud et ses voisins -->
@for (n of nodes; track n.id) {
<g class="node"
[class.node--npc]="n.kind === 'npc'"
[class.node--scene]="n.kind === 'scene'"
[class.node--quest]="n.kind === 'quest'"
[class.dragging]="draggingId === n.id"
[class.dimmed]="nodeDimmed(n)"
(pointerenter)="onNodeEnter(n)"
(pointerleave)="onNodeLeave()"
(pointerdown)="onPointerDown($event, n)">
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
<text class="node-label"
[attr.x]="n.x"
[attr.y]="n.y + radiusOf(n) + 14"
text-anchor="middle">
@for (line of n.lines; track $index) {
<tspan [attr.x]="n.x" [attr.dy]="$index === 0 ? 0 : 12">{{ line }}</tspan>
}
</text>
<title>{{ n.label }}</title>
</g>
}
</svg>
</div>
@if (edgeCount === 0) {
<p class="graph-hint">
{{ 'campaignGraph.hint' | translate }}
</p>
}
}
</div>
}

View File

@@ -57,14 +57,34 @@
gap: 0.45rem;
}
// Entrées cliquables : masquer/afficher un type d'entité.
.legend-toggle {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
&:hover { color: #e5e7eb; }
&.legend-toggle--off {
opacity: 0.4;
text-decoration: line-through;
}
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
&.legend-dot--scene { background: #365314; border: 1px solid #a3e635; }
&.legend-dot--quest { background: #581c87; border: 1px solid #d8b4fe; }
}
.legend-line {
@@ -91,6 +111,13 @@ svg {
display: block;
touch-action: none; // requis pour le drag au pointeur sur tactile
// Focus au survol : tout ce qui n'est pas le nœud survolé ou un voisin s'estompe.
line, .node {
transition: opacity 0.12s;
&.dimmed { opacity: 0.12; }
}
line {
&.edge-page {
stroke: #4f4f7a;
@@ -103,6 +130,20 @@ svg {
stroke-dasharray: 5 4;
opacity: 0.8;
}
&.edge-scene {
stroke: #65a30d;
stroke-width: 1.4;
stroke-dasharray: 5 4;
opacity: 0.8;
}
&.edge-quest {
stroke: #a855f7;
stroke-width: 1.4;
stroke-dasharray: 5 4;
opacity: 0.8;
}
}
.node {
@@ -143,6 +184,36 @@ svg {
stroke: #fbbf24;
}
}
// Scènes : palette verte (cohérente avec les cartes de scène de l'app).
&.node--scene {
circle {
fill: #1a2e05;
stroke: #65a30d;
}
.node-label { fill: #d9f99d; }
&:hover circle {
fill: #365314;
stroke: #a3e635;
}
}
// Quêtes : palette pourpre claire, distincte de l'indigo des pages.
&.node--quest {
circle {
fill: #3b0764;
stroke: #a855f7;
}
.node-label { fill: #e9d5ff; }
&:hover circle {
fill: #581c87;
stroke: #c084fc;
}
}
}
}

View File

@@ -0,0 +1,688 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { LucideAngularModule, ArrowLeft, Network, RotateCcw } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Scene } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../campaign-tree.helper';
/** Type d'un nœud du graphe (préfixe des ids, style et légende). */
type NodeKind = 'page' | 'npc' | 'scene' | 'quest';
/** Nœud du graphe : une page de Lore, ou une entité de campagne qui référence des pages. */
interface GraphNode {
id: string; // '<kind>:<id>' (évite les collisions d'IDs entre tables)
kind: NodeKind;
label: string;
/** Libellé affiché sur 1 à 2 lignes (moins de troncature qu'une ligne unique). */
lines: string[];
route: string[]; // navigation au clic
x: number; // centre du nœud (coords SVG)
y: number;
degree: number; // nombre de liens (taille du nœud)
}
interface GraphEdge {
key: string;
kind: NodeKind; // page↔page ou entité→page (style distinct par type)
a: string; // ids des nœuds reliés — pour le focus au survol
b: string;
x1: number; y1: number; x2: number; y2: number;
}
/**
* Graphe des liens d'une CAMPAGNE : ses PNJ, scènes et quêtes reliés aux pages
* de son univers (Lore).
*
* Nœuds = toutes les pages du Lore associé + les entités de la campagne (PNJ,
* scènes, quêtes) qui référencent au moins une page. Arêtes = `relatedPageIds`
* des pages (liens page↔page) et des entités (liens entité→page).
*
* Vivait historiquement côté Lore ; déplacé côté campagne car il mélange des
* entités de campagne (PNJ) avec les pages — c'est une vue de PRÉPARATION de
* campagne, pas une vue d'univers.
*
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
* que chapter-graph, sans dépendance externe.
*/
@Component({
selector: 'app-campaign-graph',
imports: [RouterModule, LucideAngularModule, TranslatePipe],
templateUrl: './campaign-graph.component.html',
styleUrls: ['./campaign-graph.component.scss']
})
export class CampaignGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Network = Network;
readonly RotateCcw = RotateCcw;
campaignId = '';
campaign: Campaign | null = null;
/** Faux tant que la campagne n'a pas d'univers (Lore) associé → état vide dédié. */
hasLore = false;
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
npcCount = 0;
sceneCount = 0;
questCount = 0;
pageCount = 0;
edgeCount = 0;
readonly MAX_LABEL_CHARS = 22;
private readonly MARGIN = 70;
svgWidth = 800;
svgHeight = 600;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
draggingId: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
private adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
// différée après un drag (évite un PUT par pixel déplacé).
private savedPositions: Record<string, { x: number; y: number }> | null = null;
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private positionsDirty = false;
// Filtres de la légende : types d'entités masqués (persistés en localStorage).
private static readonly HIDDEN_KINDS_KEY = 'loremind.campaignGraph.hiddenKinds';
hiddenKinds = new Set<NodeKind>();
// Données brutes gardées pour reconstruire le graphe quand on (dé)masque un type.
private cachedPages: Page[] = [];
private cachedTree: CampaignTreeData | null = null;
// Focus au survol : le nœud survolé + ses voisins restent nets, le reste s'estompe.
hoveredId: string | null = null;
private hoverNeighbors = new Set<string>();
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
try {
const raw = localStorage.getItem(CampaignGraphComponent.HIDDEN_KINDS_KEY);
if (raw) this.hiddenKinds = new Set(JSON.parse(raw) as NodeKind[]);
} catch { /* préférence corrompue → tout visible */ }
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;
this.hasLore = !!campaign.loreId;
this.savedPositions = this.parsePositions(campaign.graphPositions);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate));
this.pageTitleService.set(this.translate.instant('campaignGraph.title', { name: campaign.name }));
// Les PNJ arrivent déjà avec l'arbre (une seule requête /tree) ; seules
// les pages du Lore associé restent à charger.
const pages$ = campaign.loreId ? this.pageService.getByLoreId(campaign.loreId) : of([] as Page[]);
return pages$.pipe(map(pages => ({ pages, treeData })));
})
).subscribe(({ pages, treeData }) => {
this.cachedPages = pages;
this.cachedTree = treeData;
this.buildGraph(pages, treeData);
});
}
// --- Filtres de la légende --------------------------------------------------
isHidden(kind: NodeKind): boolean {
return this.hiddenKinds.has(kind);
}
/** (Dé)masque un type d'entité et reconstruit le graphe (positions sauvées conservées). */
toggleKind(kind: NodeKind): void {
if (this.hiddenKinds.has(kind)) this.hiddenKinds.delete(kind);
else this.hiddenKinds.add(kind);
try {
localStorage.setItem(CampaignGraphComponent.HIDDEN_KINDS_KEY, JSON.stringify([...this.hiddenKinds]));
} catch { /* stockage indisponible : préférence non persistée */ }
this.hoveredId = null;
this.hoverNeighbors.clear();
if (this.cachedTree) this.buildGraph(this.cachedPages, this.cachedTree);
}
// --- Focus au survol ----------------------------------------------------------
onNodeEnter(node: GraphNode): void {
if (this.draggingId) return; // pas de changement de focus en plein drag
this.hoveredId = node.id;
this.hoverNeighbors = new Set(
this.adjacency.flatMap(e => e.a === node.id ? [e.b] : e.b === node.id ? [e.a] : []));
}
onNodeLeave(): void {
this.hoveredId = null;
this.hoverNeighbors.clear();
}
nodeDimmed(node: GraphNode): boolean {
return this.hoveredId !== null && node.id !== this.hoveredId && !this.hoverNeighbors.has(node.id);
}
edgeDimmed(edge: GraphEdge): boolean {
return this.hoveredId !== null && edge.a !== this.hoveredId && edge.b !== this.hoveredId;
}
// --- Construction du graphe ----------------------------------------------
private buildGraph(pages: Page[], treeData: CampaignTreeData): void {
const loreId = this.campaign?.loreId ?? '';
const pageIds = new Set(pages.map(p => p.id!));
this.pageCount = pages.length;
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
const nodes: GraphNode[] = pages.map(p => ({
id: `page:${p.id}`,
kind: 'page' as const,
label: p.title,
lines: this.splitLabel(p.title),
route: ['/lore', loreId, 'pages', p.id!],
x: 0, y: 0, degree: 0
}));
const linksTo = (ids?: string[]) => (ids ?? []).some(pid => pageIds.has(pid));
// Entités de campagne : seulement celles qui référencent au moins une page du
// Lore (une entité sans lien n'apporte rien à la carte), et dont le TYPE n'est
// pas masqué par la légende.
const linkedNpcs = this.hiddenKinds.has('npc') ? [] : treeData.npcs.filter(n => linksTo(n.relatedPageIds));
for (const n of linkedNpcs) {
nodes.push({
id: `npc:${n.id}`,
kind: 'npc',
label: n.name,
lines: this.splitLabel(n.name),
route: ['/campaigns', this.campaignId, 'npcs', n.id!],
x: 0, y: 0, degree: 0
});
}
this.npcCount = linkedNpcs.length;
// Scènes liées : la route de détail exige arc + chapitre → index inverse depuis l'arbre.
const arcOfChapter = new Map<string, string>();
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
}
const linkedScenes: Array<{ scene: Scene; chapterId: string }> = [];
if (!this.hiddenKinds.has('scene')) {
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
if (!arcOfChapter.has(chapterId)) continue;
for (const sc of scenes) {
if (linksTo(sc.relatedPageIds)) linkedScenes.push({ scene: sc, chapterId });
}
}
}
for (const { scene, chapterId } of linkedScenes) {
nodes.push({
id: `scene:${scene.id}`,
kind: 'scene',
label: scene.name,
lines: this.splitLabel(scene.name),
route: ['/campaigns', this.campaignId, 'arcs', arcOfChapter.get(chapterId)!,
'chapters', chapterId, 'scenes', scene.id!],
x: 0, y: 0, degree: 0
});
}
this.sceneCount = linkedScenes.length;
const linkedQuests = this.hiddenKinds.has('quest')
? [] : (treeData.quests ?? []).filter(q => linksTo(q.relatedPageIds));
for (const q of linkedQuests) {
nodes.push({
id: `quest:${q.id}`,
kind: 'quest',
label: q.name,
lines: this.splitLabel(q.name),
route: ['/campaigns', this.campaignId, 'quests', q.id!],
x: 0, y: 0, degree: 0
});
}
this.questCount = linkedQuests.length;
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
const adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
const seenPairs = new Set<string>();
for (const p of pages) {
for (const targetId of p.relatedPageIds ?? []) {
if (!pageIds.has(targetId) || targetId === p.id) continue;
const pair = [p.id!, targetId].sort().join('|');
if (seenPairs.has(pair)) continue;
seenPairs.add(pair);
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
}
}
const entityEdges = (kind: NodeKind, id: string, relatedPageIds?: string[]) => {
for (const targetId of new Set(relatedPageIds ?? [])) {
if (!pageIds.has(targetId)) continue;
adjacency.push({ key: `${kind}:${id}|${targetId}`, kind, a: `${kind}:${id}`, b: `page:${targetId}` });
}
};
for (const n of linkedNpcs) entityEdges('npc', n.id!, n.relatedPageIds);
for (const { scene } of linkedScenes) entityEdges('scene', scene.id!, scene.relatedPageIds);
for (const q of linkedQuests) entityEdges('quest', q.id!, q.relatedPageIds);
this.adjacency = adjacency;
this.edgeCount = adjacency.length;
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
const degree = new Map<string, number>();
for (const e of adjacency) {
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
}
for (const node of nodes) {
node.degree = degree.get(node.id) ?? 0;
}
this.nodes = nodes;
this.runForceLayout();
this.applySavedPositions();
this.recomputeEdges();
}
// --- Disposition personnalisée (persistée sur la campagne) -----------------
private parsePositions(json: string | null | undefined): Record<string, { x: number; y: number }> | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null; // JSON corrompu → disposition auto, sans casser l'écran
}
}
/** Réapplique les positions sauvegardées ; les nœuds inconnus gardent le layout auto. */
private applySavedPositions(): void {
if (!this.savedPositions) return;
let applied = false;
for (const node of this.nodes) {
const p = this.savedPositions[node.id];
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') continue;
node.x = p.x;
node.y = p.y;
applied = true;
}
if (applied) this.fitSvgToNodes();
}
/** Sauvegarde différée (800 ms après le dernier drag) de la disposition courante. */
private scheduleSave(): void {
this.positionsDirty = true;
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.savePositions(), 800);
}
private savePositions(): void {
if (!this.positionsDirty || !this.campaign?.id) return;
this.positionsDirty = false;
const positions: Record<string, { x: number; y: number }> = {};
for (const n of this.nodes) positions[n.id] = { x: Math.round(n.x), y: Math.round(n.y) };
this.savedPositions = positions;
this.campaignService.updateGraphPositions(this.campaign.id, JSON.stringify(positions)).subscribe();
}
/** Recalcule la disposition automatique et oublie les déplacements manuels. */
resetLayout(): void {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.positionsDirty = false;
this.savedPositions = null;
this.runForceLayout();
this.recomputeEdges();
if (this.campaign?.id) {
this.campaignService.updateGraphPositions(this.campaign.id, '').subscribe();
}
}
/**
* Layout force-directed (Fruchterman-Reingold simplifié) :
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
* centre (regroupe les composantes déconnectées), refroidissement progressif.
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
* convention projet d'éviter Math.random pour des rendus reproductibles).
*/
private runForceLayout(): void {
const n = this.nodes.length;
if (n === 0) {
this.svgWidth = 800; this.svgHeight = 400;
return;
}
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
const w = side, h = side;
const cx = w / 2, cy = h / 2;
// Init en spirale : angle d'or → répartition uniforme et déterministe.
const golden = Math.PI * (3 - Math.sqrt(5));
this.nodes.forEach((node, i) => {
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
const a = i * golden;
node.x = cx + r * Math.cos(a);
node.y = cy + r * Math.sin(a);
});
const index = new Map(this.nodes.map(node => [node.id, node]));
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
let temperature = side / 8;
for (let iter = 0; iter < 300; iter++) {
const dx = new Map<string, number>();
const dy = new Map<string, number>();
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
// Répulsion entre toutes les paires.
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = this.nodes[i], b = this.nodes[j];
let vx = a.x - b.x, vy = a.y - b.y;
let d = Math.hypot(vx, vy);
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
const force = (k * k) / d;
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
}
}
// Attraction le long des arêtes.
for (const e of this.adjacency) {
const a = index.get(e.a)!, b = index.get(e.b)!;
const vx = a.x - b.x, vy = a.y - b.y;
const d = Math.max(0.01, Math.hypot(vx, vy));
const force = (d * d) / k;
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
}
// Gravité douce vers le centre (sinon les composantes isolées fuient).
for (const node of this.nodes) {
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
}
// Application bornée par la température, SANS clamp au cadre : le clamp à
// chaque itération écrasait des rangées de nœuds contre les bords (libellés
// illisibles). Le cadrage se fait après coup dans normalizeToFrame().
for (const node of this.nodes) {
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
const d = Math.max(0.01, Math.hypot(ddx, ddy));
const step = Math.min(d, temperature);
node.x += (ddx / d) * step;
node.y += (ddy / d) * step;
}
temperature *= 0.96;
}
// Sans clamp, le nuage libre est très étendu (la gravité n'équilibre la
// répulsion qu'à grande distance) : on le remet d'abord à l'échelle du cadre
// cible, puis l'anti-chevauchement ré-étend LOCALEMENT là où c'est nécessaire.
this.rescaleTo(side);
this.fanOutLeaves();
this.resolveLabelOverlaps();
this.normalizeToFrame();
}
/**
* Dispose en ÉVENTAIL les feuilles (degré 1) autour de leur hub (degré ≥ 3) :
* huit PNJ pointant le même lieu forment une couronne régulière au lieu d'un
* amas. Angles déterministes (départ = direction hub→extérieur du nuage).
*/
private fanOutLeaves(): void {
const neighbors = new Map<string, string[]>();
for (const e of this.adjacency) {
(neighbors.get(e.a) ?? neighbors.set(e.a, []).get(e.a)!).push(e.b);
(neighbors.get(e.b) ?? neighbors.set(e.b, []).get(e.b)!).push(e.a);
}
const leavesByHub = new Map<string, GraphNode[]>();
for (const node of this.nodes) {
const nbs = neighbors.get(node.id) ?? [];
if (nbs.length !== 1) continue;
const hubId = nbs[0];
if ((neighbors.get(hubId)?.length ?? 0) < 3) continue;
(leavesByHub.get(hubId) ?? leavesByHub.set(hubId, []).get(hubId)!).push(node);
}
if (leavesByHub.size === 0) return;
const index = new Map(this.nodes.map(n => [n.id, n]));
const cx = this.nodes.reduce((s, n) => s + n.x, 0) / this.nodes.length;
const cy = this.nodes.reduce((s, n) => s + n.y, 0) / this.nodes.length;
for (const [hubId, leaves] of leavesByHub) {
const hub = index.get(hubId);
if (!hub) continue;
leaves.sort((a, b) => a.label.localeCompare(b.label)); // ordre stable
const radius = 85 + leaves.length * 6;
// Première feuille vers l'extérieur du nuage : la couronne empiète moins
// sur le cœur du graphe.
const start = Math.atan2(hub.y - cy, hub.x - cx);
const step = (2 * Math.PI) / leaves.length;
leaves.forEach((leaf, i) => {
leaf.x = hub.x + radius * Math.cos(start + i * step);
leaf.y = hub.y + radius * Math.sin(start + i * step);
});
}
}
/** Réduit homothétiquement le nuage pour tenir dans un carré `side` (jamais d'agrandissement). */
private rescaleTo(side: number): void {
const xs = this.nodes.map(n => n.x), ys = this.nodes.map(n => n.y);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const span = Math.max(1, maxX - minX, maxY - minY);
const scale = Math.min(1, (side - 2 * this.MARGIN) / span);
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
for (const node of this.nodes) {
node.x = cx + (node.x - cx) * scale;
node.y = cy + (node.y - cy) * scale;
}
}
/** Largeur approximative du libellé affiché (police 11px ≈ 6.4px/caractère, ligne la plus longue). */
private labelWidth(node: GraphNode): number {
const longest = node.lines.reduce((max, l) => Math.max(max, l.length), 0);
return longest * 6.4 + 14;
}
/**
* Anti-chevauchement TENANT COMPTE DES LIBELLÉS : le layout de force espace les
* centres des cercles, mais un libellé fait ~120px de large — deux nœuds voisins
* se lisaient l'un sur l'autre. Écarte itérativement chaque paire trop proche
* selon l'axe le moins coûteux. Déterministe (pas d'aléatoire).
*/
private resolveLabelOverlaps(): void {
const nodes = this.nodes;
for (let iter = 0; iter < 80; iter++) {
let moved = false;
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
const needX = (this.labelWidth(a) + this.labelWidth(b)) / 2 + 6;
const needY = this.radiusOf(a) + this.radiusOf(b) + 42; // cercles + jusqu'à 2 lignes de texte
const dx = b.x - a.x, dy = b.y - a.y;
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
const overlapX = needX - Math.abs(dx);
const overlapY = needY - Math.abs(dy);
if (overlapX / needX < overlapY / needY) {
const shift = overlapX / 2 + 0.5;
const sign = dx !== 0 ? Math.sign(dx) : (i % 2 === 0 ? 1 : -1);
a.x -= sign * shift;
b.x += sign * shift;
} else {
const shift = overlapY / 2 + 0.5;
const sign = dy !== 0 ? Math.sign(dy) : (i % 2 === 0 ? 1 : -1);
a.y -= sign * shift;
b.y += sign * shift;
}
moved = true;
}
}
if (!moved) break;
}
}
/**
* Recadre le nuage dans le SVG : translation pour que tout (cercles ET libellés)
* soit visible avec une marge, taille du SVG = étendue réelle du graphe.
*/
private normalizeToFrame(): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of this.nodes) {
const half = Math.max(this.labelWidth(node) / 2, this.radiusOf(node));
minX = Math.min(minX, node.x - half);
maxX = Math.max(maxX, node.x + half);
minY = Math.min(minY, node.y - this.radiusOf(node));
maxY = Math.max(maxY, node.y + this.radiusOf(node) + 38); // libellé (2 lignes max) sous le cercle
}
const pad = 24;
for (const node of this.nodes) {
node.x += pad - minX;
node.y += pad - minY;
}
this.svgWidth = Math.max(600, Math.ceil(maxX - minX + pad * 2));
this.svgHeight = Math.max(400, Math.ceil(maxY - minY + pad * 2));
}
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
private recomputeEdges(): void {
const index = new Map(this.nodes.map(n => [n.id, n]));
this.edges = this.adjacency
.filter(e => index.has(e.a) && index.has(e.b))
.map(e => {
const a = index.get(e.a)!, b = index.get(e.b)!;
return { key: e.key, kind: e.kind, a: e.a, b: e.b, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
});
}
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
radiusOf(node: GraphNode): number {
return 14 + Math.min(10, node.degree * 1.5);
}
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
const { x, y } = this.toSvgCoords(evt);
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
if (!this.dragMoved) {
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
onPointerUp(evt: PointerEvent): void {
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
if (moved) {
this.scheduleSave();
return;
}
// Clic simple → ouvre la fiche de l'entité (page, PNJ, scène, quête).
const node = this.nodes.find(n => n.id === id);
if (node) this.router.navigate(node.route);
}
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
private fitSvgToNodes(): void {
for (const n of this.nodes) {
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
}
}
/**
* Découpe un libellé en 1 à 2 lignes de {@link MAX_LABEL_CHARS} max (coupure aux
* mots) : « 1 - Le convoi de marchands » reste lisible au lieu d'être tronqué.
*/
private splitLabel(text: string): string[] {
const max = this.MAX_LABEL_CHARS;
if (text.length <= max) return [text];
let line1 = '';
for (const word of text.split(/\s+/)) {
const candidate = line1 ? `${line1} ${word}` : word;
if (candidate.length > max) break;
line1 = candidate;
}
if (!line1) line1 = text.slice(0, max); // premier mot plus long que la ligne
let rest = text.slice(line1.length).trim();
if (!rest) return [line1];
if (rest.length > max) rest = rest.slice(0, max - 1) + '…';
return [line1, rest];
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {
// Une sauvegarde en attente part immédiatement (sinon le drag serait perdu).
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.savePositions();
}
// Sidebar : volontairement rien — le composant suivant appellera show().
// Eviter d'appeler hide() ici previent le clignotement.
}
}

View File

@@ -1,4 +1,5 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, OnInit, Output, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms';
@@ -53,7 +54,8 @@ export class CampaignCreateComponent implements OnInit {
constructor(
private fb: FormBuilder,
private loreService: LoreService,
private gameSystemService: GameSystemService
private gameSystemService: GameSystemService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -77,7 +79,7 @@ export class CampaignCreateComponent implements OnInit {
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
// en mode creation inline. On reinitialise immediatement le control a ''
// pour que la sentinelle ne reste pas en valeur reelle du form.
this.form.get('gameSystemId')?.valueChanges.subscribe(value => {
this.form.get('gameSystemId')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
this.startCreateGameSystem();

View File

@@ -35,6 +35,11 @@
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
[title]="'campaignDetail.graphTitle' | translate">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
{{ 'campaignDetail.graph' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
<lucide-icon [img]="FileText" [size]="14"></lucide-icon>
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}

View File

@@ -6,7 +6,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X, Network } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of, Observable } from 'rxjs';
@@ -57,6 +57,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly X = X;
readonly Network = Network;
/** Export Foundry en cours (anti double-clic). */
exportingFoundry = false;
@@ -416,6 +417,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
});
}
/** Ouvre le graphe des liens : PNJ de la campagne reliés aux pages de son Lore. */
openGraph(): void {
if (this.campaign?.id) {
this.router.navigate(['/campaigns', this.campaign.id, 'graph']);
}
}
/** Génère et télécharge le livret PDF de la campagne. */
exportPdf(): void {
if (!this.campaign?.id || this.exportingPdf) return;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
@@ -114,7 +115,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -131,7 +133,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener } from '@angular/core';
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -111,11 +112,12 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private enemyService: EnemyService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
this.campaignId = pm.get('campaignId')!;
this.arcId = pm.get('arcId')!;
this.chapterId = pm.get('chapterId')!;
@@ -130,7 +132,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;
// Mode plat = campagne simple (1 arc, 1 chapitre) SANS compter la plomberie

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -54,11 +55,12 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -66,11 +67,12 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) {

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -42,11 +43,12 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) {

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -106,7 +107,8 @@ export class QuestEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -118,7 +120,7 @@ export class QuestEditComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
this.campaignId = pm.get('campaignId')!;
this.questId = pm.get('questId'); // null sur la route /create
this.arcIdParam = this.route.snapshot.queryParamMap.get('arcId'); // ?arcId= depuis un arc HUB

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@@ -66,11 +67,12 @@ export class QuestViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newQuestId = pm.get('questId')!;
if (newQuestId !== this.questId || newCampaignId !== this.campaignId) {

View File

@@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
@@ -35,11 +36,12 @@ export class RandomTableViewComponent implements OnInit {
private route: ActivatedRoute,
private router: Router,
private service: RandomTableService,
private campaignSidebar: CampaignSidebarService
private campaignSidebar: CampaignSidebarService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId');
const newTableId = pm.get('tableId');
if (newCampaignId === this.campaignId && newTableId === this.tableId) return;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
@@ -169,7 +170,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -195,7 +197,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre scènes frères via l'arbre
// (même route pattern), et ngOnInit ne se relance pas.
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;
@@ -226,7 +228,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(({ campaign, allCampaigns, scene, chapterScenes, treeData, pages, loreId }) => {
this.scene = scene;
this.pageTitleService.set(scene.name);

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -57,11 +58,12 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;

View File

@@ -8,11 +8,6 @@
<p class="description">{{ lore.description }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
[title]="'loreDetail.graphTitle' | translate">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
{{ 'loreDetail.graph' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="startEdit()" [title]="'loreDetail.editTitle' | translate">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
{{ 'common.edit' | translate }}

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } from 'lucide-angular';
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
@@ -25,7 +26,6 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
readonly Plus = Plus;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly Network = Network;
lore: Lore | null = null;
/** Tous les dossiers du Lore (racines + enfants). */
@@ -47,14 +47,15 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
// On s'abonne à paramMap (pas snapshot) pour recharger quand on switche
// d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise
// le même composant et ngOnInit ne se relance pas tout seul.
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const id = pm.get('id');
if (id && id !== this.lore?.id) {
this.load(id);
@@ -85,13 +86,6 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
}
/** Ouvre la vue graphe : pages du Lore + PNJ liés, reliés par leurs liens. */
openGraph(): void {
if (this.lore?.id) {
this.router.navigate(['/lore', this.lore.id, 'graph']);
}
}
// ─────────────── Édition / suppression du Lore ───────────────
startEdit(): void {

View File

@@ -1,69 +0,0 @@
@if (lore) {
<div class="graph-page">
<header class="graph-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
{{ 'loreGraph.back' | translate }}
</button>
<div class="graph-title">
<h1>
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
{{ 'loreGraph.title' | translate:{ name: lore.name } }}
</h1>
<p class="graph-subtitle">
{{ 'loreGraph.subtitle' | translate:{ pages: (nodes.length - npcCount), npcs: npcCount, edges: edgeCount } }}
</p>
</div>
<div class="graph-legend">
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'loreGraph.legendPage' | translate }}</span>
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> {{ 'loreGraph.legendNpc' | translate }}</span>
<span class="legend-item"><span class="legend-line legend-line--npc"></span> {{ 'loreGraph.legendLink' | translate }}</span>
</div>
</header>
@if (nodes.length === 0) {
<div class="graph-empty">
{{ 'loreGraph.empty' | translate }}
</div>
} @else {
<div class="graph-scroll">
<svg #svgEl
[attr.width]="svgWidth"
[attr.height]="svgHeight"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointerleave)="onPointerUp($event)">
<!-- Arêtes (sous les nœuds) -->
@for (e of edges; track e.key) {
<line
[attr.x1]="e.x1" [attr.y1]="e.y1"
[attr.x2]="e.x2" [attr.y2]="e.y2"
[class.edge-page]="e.kind === 'page'"
[class.edge-npc]="e.kind === 'npc'" />
}
<!-- Nœuds -->
@for (n of nodes; track n.id) {
<g class="node"
[class.node--npc]="n.kind === 'npc'"
[class.dragging]="draggingId === n.id"
(pointerdown)="onPointerDown($event, n)">
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
<text class="node-label"
[attr.x]="n.x"
[attr.y]="n.y + radiusOf(n) + 14"
text-anchor="middle">{{ n.displayLabel }}</text>
<title>{{ n.label }}</title>
</g>
}
</svg>
</div>
@if (edgeCount === 0) {
<p class="graph-hint">
{{ 'loreGraph.hint' | translate }}
</p>
}
}
</div>
}

View File

@@ -1,352 +0,0 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { NpcService } from '../../services/npc.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Lore } from '../../services/lore.model';
import { Page } from '../../services/page.model';
import { Npc } from '../../services/npc.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */
interface GraphNode {
id: string; // 'page:<id>' ou 'npc:<id>' (évite les collisions d'IDs)
kind: 'page' | 'npc';
label: string;
displayLabel: string;
route: string[]; // navigation au clic
x: number; // centre du nœud (coords SVG)
y: number;
degree: number; // nombre de liens (taille du nœud)
}
interface GraphEdge {
key: string;
kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct)
x1: number; y1: number; x2: number; y2: number;
}
/**
* Graphe du Lore : vue d'ensemble des pages et de leurs liens.
*
* Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore)
* qui référencent au moins une page. Arêtes = `relatedPageIds` des pages
* (liens page↔page) et des PNJ (liens PNJ→page).
*
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
* que chapter-graph, sans dépendance externe.
*/
@Component({
selector: 'app-lore-graph',
imports: [RouterModule, LucideAngularModule, TranslatePipe],
templateUrl: './lore-graph.component.html',
styleUrls: ['./lore-graph.component.scss']
})
export class LoreGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Network = Network;
loreId = '';
lore: Lore | null = null;
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
npcCount = 0;
edgeCount = 0;
readonly MAX_LABEL_CHARS = 22;
private readonly MARGIN = 70;
svgWidth = 800;
svgHeight = 600;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
draggingId: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
npcs: this.npcService.getByLore(this.loreId)
}).subscribe(({ sidebar, npcs }) => {
this.lore = sidebar.lore;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.pageTitleService.set(this.translate.instant('loreGraph.title', { name: sidebar.lore.name }));
this.buildGraph(sidebar.pages, npcs);
});
}
// --- Construction du graphe ----------------------------------------------
private buildGraph(pages: Page[], npcs: Npc[]): void {
const pageIds = new Set(pages.map(p => p.id!));
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
const nodes: GraphNode[] = pages.map(p => ({
id: `page:${p.id}`,
kind: 'page' as const,
label: p.title,
displayLabel: this.truncate(p.title),
route: ['/lore', this.loreId, 'pages', p.id!],
x: 0, y: 0, degree: 0
}));
// Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore
// (un PNJ sans lien n'apporte rien à la carte des connexions).
const linkedNpcs = npcs.filter(n =>
(n.relatedPageIds ?? []).some(pid => pageIds.has(pid)));
for (const n of linkedNpcs) {
nodes.push({
id: `npc:${n.id}`,
kind: 'npc',
label: n.name,
displayLabel: this.truncate(n.name),
route: ['/campaigns', n.campaignId, 'npcs', n.id!],
x: 0, y: 0, degree: 0
});
}
this.npcCount = linkedNpcs.length;
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
// (A→B et B→A = un seul trait).
const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
const seenPairs = new Set<string>();
for (const p of pages) {
for (const targetId of p.relatedPageIds ?? []) {
if (!pageIds.has(targetId) || targetId === p.id) continue;
const pair = [p.id!, targetId].sort().join('|');
if (seenPairs.has(pair)) continue;
seenPairs.add(pair);
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
}
}
for (const n of linkedNpcs) {
for (const targetId of new Set(n.relatedPageIds ?? [])) {
if (!pageIds.has(targetId)) continue;
adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` });
}
}
this.adjacency = adjacency;
this.edgeCount = adjacency.length;
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
const degree = new Map<string, number>();
for (const e of adjacency) {
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
}
for (const node of nodes) {
node.degree = degree.get(node.id) ?? 0;
}
this.nodes = nodes;
this.runForceLayout();
this.recomputeEdges();
}
/**
* Layout force-directed (Fruchterman-Reingold simplifié) :
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
* centre (regroupe les composantes déconnectées), refroidissement progressif.
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
* convention projet d'éviter Math.random pour des rendus reproductibles).
*/
private runForceLayout(): void {
const n = this.nodes.length;
if (n === 0) {
this.svgWidth = 800; this.svgHeight = 400;
return;
}
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
const w = side, h = side;
const cx = w / 2, cy = h / 2;
// Init en spirale : angle d'or → répartition uniforme et déterministe.
const golden = Math.PI * (3 - Math.sqrt(5));
this.nodes.forEach((node, i) => {
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
const a = i * golden;
node.x = cx + r * Math.cos(a);
node.y = cy + r * Math.sin(a);
});
const index = new Map(this.nodes.map(node => [node.id, node]));
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
let temperature = side / 8;
for (let iter = 0; iter < 300; iter++) {
const dx = new Map<string, number>();
const dy = new Map<string, number>();
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
// Répulsion entre toutes les paires.
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = this.nodes[i], b = this.nodes[j];
let vx = a.x - b.x, vy = a.y - b.y;
let d = Math.hypot(vx, vy);
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
const force = (k * k) / d;
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
}
}
// Attraction le long des arêtes.
for (const e of this.adjacency) {
const a = index.get(e.a)!, b = index.get(e.b)!;
const vx = a.x - b.x, vy = a.y - b.y;
const d = Math.max(0.01, Math.hypot(vx, vy));
const force = (d * d) / k;
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
}
// Gravité douce vers le centre (sinon les composantes isolées fuient).
for (const node of this.nodes) {
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
}
// Application bornée par la température, dans le cadre.
for (const node of this.nodes) {
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
const d = Math.max(0.01, Math.hypot(ddx, ddy));
const step = Math.min(d, temperature);
node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step));
node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step));
}
temperature *= 0.96;
}
this.svgWidth = w;
this.svgHeight = h;
}
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
private recomputeEdges(): void {
const index = new Map(this.nodes.map(n => [n.id, n]));
this.edges = this.adjacency
.filter(e => index.has(e.a) && index.has(e.b))
.map(e => {
const a = index.get(e.a)!, b = index.get(e.b)!;
return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
});
}
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
radiusOf(node: GraphNode): number {
return 14 + Math.min(10, node.degree * 1.5);
}
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
const { x, y } = this.toSvgCoords(evt);
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
if (!this.dragMoved) {
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
onPointerUp(evt: PointerEvent): void {
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
if (moved) return;
// Clic simple → ouvre la page / la fiche PNJ.
const node = this.nodes.find(n => n.id === id);
if (node) this.router.navigate(node.route);
}
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
private fitSvgToNodes(): void {
for (const n of this.nodes) {
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
}
}
private truncate(text: string): string {
return text.length > this.MAX_LABEL_CHARS
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
: text;
}
back(): void {
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement.
}
}

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
@@ -62,7 +63,8 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -76,7 +78,7 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
// Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar
// sans démonter le composant).
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newId = pm.get('folderId')!;
if (newId !== this.folderId) {
this.folderId = newId;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -67,7 +68,8 @@ export class PageCreateComponent implements OnInit, OnDestroy {
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {
this.form = this.fb.group({
title: ['', Validators.required],
@@ -116,7 +118,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
}
}
this.form.get('nodeId')?.valueChanges.subscribe(nodeId => {
this.form.get('nodeId')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(nodeId => {
this.autoSelectTemplateForNode(nodeId);
});

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -109,7 +110,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {
this.chatPrimaryAction = { label: this.translate.instant('pageEdit.chatPrimaryAction') };
this.chatQuickSuggestions = [
@@ -126,7 +128,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
// navigue d'une page à une autre (ex. via les chips du lore-link-picker),
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
// resterait figé sur l'ancienne page.
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -65,14 +66,15 @@ export class PageViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
// Même pattern que page-edit : on s'abonne à paramMap pour gérer la
// navigation d'une page à l'autre (Angular réutilise le composant).
this.route.paramMap.subscribe(pm => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId;

View File

@@ -10,6 +10,8 @@ export interface Campaign {
loreId?: string | null;
/** ID du GameSystem associé (weak reference cross-context). `null` = campagne générique. */
gameSystemId?: string | null;
/** Positions des nœuds du graphe de campagne (JSON `"<kind>:<id>" -> {x,y}`). Null = auto. */
graphPositions?: string | null;
}
// Interface pour la création de Campaign (sans id)

View File

@@ -73,6 +73,11 @@ export class CampaignService {
return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
}
/** Sauvegarde la disposition du graphe de campagne (JSON opaque ; '' = disposition auto). */
updateGraphPositions(id: string, positionsJson: string): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/${id}/graph-positions`, { positions: positionsJson });
}
/** Bilan de préparation (Pilier B) — alimente les pastilles de l'arbre de la sidebar. */
getReadiness(id: string): Observable<CampaignReadinessAssessment> {
return this.http.get<CampaignReadinessAssessment>(`${this.apiUrl}/${id}/readiness`);

View File

@@ -16,11 +16,6 @@ export class NpcService {
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
/** PNJ de toutes les campagnes liées à un Lore — alimente le graphe du Lore. */
getByLore(loreId: string): Observable<Npc[]> {
return this.http.get<Npc[]>(`${this.apiUrl}/lore/${loreId}`);
}
getById(id: string): Observable<Npc> {
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
}

View File

@@ -198,8 +198,6 @@
"submit": "Create lore"
},
"loreDetail": {
"graph": "Graph",
"graphTitle": "View the graph of pages and their links (NPCs included)",
"editTitle": "Edit Lore",
"deleteTitle": "Delete Lore",
"folders": "Folders",
@@ -221,14 +219,19 @@
"irreversible": "This action is irreversible."
}
},
"loreGraph": {
"back": "Back to Lore",
"campaignGraph": {
"back": "Back to campaign",
"title": "{{name}} — Graph",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{scenes}} scene(s) · {{quests}} quest(s) · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"legendPage": "Lore page",
"legendNpc": "NPC",
"legendLink": "NPC → page link",
"empty": "No pages in this Lore yet — the graph will fill in as you go.",
"legendScene": "Scene",
"legendQuest": "Quest",
"reset": "Auto layout",
"resetTitle": "Recompute the automatic layout (forgets your manual moves)",
"toggleHint": "Click to hide / show this entity type",
"noLore": "No world (Lore) is linked to this campaign: link one from the campaign header to see the graph.",
"empty": "No pages in the linked world yet — the graph will fill in as you go.",
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
},
"loreNodeCreate": {
@@ -613,6 +616,8 @@
"foundryExporting": "Exporting…",
"pdfExport": "Export to PDF",
"pdfExporting": "Generating…",
"graph": "Graph",
"graphTitle": "View the graph of NPCs and their linked lore pages",
"gameSystemChange": {
"title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",

View File

@@ -198,8 +198,6 @@
"submit": "Créer le lore"
},
"loreDetail": {
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)",
"editTitle": "Modifier le Lore",
"deleteTitle": "Supprimer le Lore",
"folders": "Dossiers",
@@ -221,14 +219,19 @@
"irreversible": "Cette action est irréversible."
}
},
"loreGraph": {
"back": "Retour au Lore",
"campaignGraph": {
"back": "Retour à la campagne",
"title": "{{name}} — Graphe",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{scenes}} scène(s) · {{quests}} quête(s) · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"legendPage": "Page de Lore",
"legendNpc": "PNJ",
"legendLink": "Lien PNJ → page",
"empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.",
"legendScene": "Scène",
"legendQuest": "Quête",
"reset": "Disposition auto",
"resetTitle": "Recalculer la disposition automatique (oublie vos déplacements manuels)",
"toggleHint": "Cliquer pour masquer / afficher ce type d'entité",
"noLore": "Aucun univers (Lore) n'est associé à cette campagne : associez-en un depuis l'en-tête de la campagne pour voir le graphe.",
"empty": "Aucune page dans l'univers associé pour l'instant — le graphe se remplira au fur et à mesure.",
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
},
"loreNodeCreate": {
@@ -613,6 +616,8 @@
"foundryExporting": "Export…",
"pdfExport": "Exporter en PDF",
"pdfExporting": "Génération…",
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des PNJ et des pages de lore liées",
"gameSystemChange": {
"title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",

View File

@@ -81,6 +81,8 @@
"foundryExporting": "Exporting…",
"pdfExport": "Export to PDF",
"pdfExporting": "Generating…",
"graph": "Graph",
"graphTitle": "View the graph of NPCs and their linked lore pages",
"gameSystemChange": {
"title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
@@ -104,6 +106,21 @@
"irreversible": "This action is irreversible."
}
},
"campaignGraph": {
"back": "Back to campaign",
"title": "{{name}} — Graph",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{scenes}} scene(s) · {{quests}} quest(s) · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"legendPage": "Lore page",
"legendNpc": "NPC",
"legendScene": "Scene",
"legendQuest": "Quest",
"reset": "Auto layout",
"resetTitle": "Recompute the automatic layout (forgets your manual moves)",
"toggleHint": "Click to hide / show this entity type",
"noLore": "No world (Lore) is linked to this campaign: link one from the campaign header to see the graph.",
"empty": "No pages in the linked world yet — the graph will fill in as you go.",
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
},
"campaignImport": {
"pageTitle": "Import a campaign",
"back": "Back to the campaign",

View File

@@ -81,6 +81,8 @@
"foundryExporting": "Export…",
"pdfExport": "Exporter en PDF",
"pdfExporting": "Génération…",
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des PNJ et des pages de lore liées",
"gameSystemChange": {
"title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
@@ -104,6 +106,21 @@
"irreversible": "Cette action est irréversible."
}
},
"campaignGraph": {
"back": "Retour à la campagne",
"title": "{{name}} — Graphe",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{scenes}} scène(s) · {{quests}} quête(s) · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"legendPage": "Page de Lore",
"legendNpc": "PNJ",
"legendScene": "Scène",
"legendQuest": "Quête",
"reset": "Disposition auto",
"resetTitle": "Recalculer la disposition automatique (oublie vos déplacements manuels)",
"toggleHint": "Cliquer pour masquer / afficher ce type d'entité",
"noLore": "Aucun univers (Lore) n'est associé à cette campagne : associez-en un depuis l'en-tête de la campagne pour voir le graphe.",
"empty": "Aucune page dans l'univers associé pour l'instant — le graphe se remplira au fur et à mesure.",
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
},
"campaignImport": {
"pageTitle": "Importer une campagne",
"back": "Retour à la campagne",

View File

@@ -45,8 +45,6 @@
"submit": "Create lore"
},
"loreDetail": {
"graph": "Graph",
"graphTitle": "View the graph of pages and their links (NPCs included)",
"editTitle": "Edit Lore",
"deleteTitle": "Delete Lore",
"folders": "Folders",
@@ -68,16 +66,6 @@
"irreversible": "This action is irreversible."
}
},
"loreGraph": {
"back": "Back to Lore",
"title": "{{name}} — Graph",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"legendPage": "Lore page",
"legendNpc": "NPC",
"legendLink": "NPC → page link",
"empty": "No pages in this Lore yet — the graph will fill in as you go.",
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
},
"loreNodeCreate": {
"title": "Create a new folder",
"subtitle": "Folders let you organize your pages by category",

View File

@@ -45,8 +45,6 @@
"submit": "Créer le lore"
},
"loreDetail": {
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)",
"editTitle": "Modifier le Lore",
"deleteTitle": "Supprimer le Lore",
"folders": "Dossiers",
@@ -68,16 +66,6 @@
"irreversible": "Cette action est irréversible."
}
},
"loreGraph": {
"back": "Retour au Lore",
"title": "{{name}} — Graphe",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"legendPage": "Page de Lore",
"legendNpc": "PNJ",
"legendLink": "Lien PNJ → page",
"empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.",
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
},
"loreNodeCreate": {
"title": "Créer un nouveau dossier",
"subtitle": "Les dossiers permettent d'organiser vos pages par catégorie",