Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

View File

@@ -0,0 +1,204 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
interface GraphEdge { label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
/**
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
* Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph.
*/
@Component({
selector: 'app-chapter-graph',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule],
templateUrl: './chapter-graph.component.html',
styleUrls: ['./chapter-graph.component.scss']
})
export class ChapterGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
scenes: Scene[] = [];
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
readonly NODE_WIDTH = 220;
readonly NODE_HEIGHT = 64;
readonly H_SPACING = 50;
readonly V_SPACING = 90;
readonly MAX_LABEL_CHARS = 26;
svgWidth = 600;
svgHeight = 400;
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.campaignId = pm.get('campaignId')!;
this.arcId = pm.get('arcId')!;
this.chapterId = pm.get('chapterId')!;
this.load();
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;
this.pageTitleService.set(`${chapter.name} — Carte`);
this.buildGraph();
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
/**
* Layout en niveaux par BFS depuis la scène d'entrée (order le plus bas).
* Scènes non atteignables rassemblées dans un niveau "orphelin" tout en bas.
*/
private buildGraph(): void {
if (this.scenes.length === 0) {
this.nodes = []; this.edges = [];
this.svgWidth = 600; this.svgHeight = 200;
return;
}
const sorted = [...this.scenes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const entry = sorted[0];
const levelOf = new Map<string, number>();
levelOf.set(entry.id!, 0);
const queue: string[] = [entry.id!];
while (queue.length > 0) {
const curId = queue.shift()!;
const curLevel = levelOf.get(curId)!;
const curScene = this.scenes.find(s => s.id === curId);
if (!curScene?.branches) continue;
for (const b of curScene.branches) {
if (!levelOf.has(b.targetSceneId)) {
levelOf.set(b.targetSceneId, curLevel + 1);
queue.push(b.targetSceneId);
}
}
}
const reachableMax = levelOf.size > 0 ? Math.max(...Array.from(levelOf.values())) : 0;
const orphanLevel = reachableMax + 1;
for (const s of this.scenes) {
if (!levelOf.has(s.id!)) levelOf.set(s.id!, orphanLevel);
}
const byLevel = new Map<number, Scene[]>();
for (const s of this.scenes) {
const lvl = levelOf.get(s.id!)!;
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
byLevel.get(lvl)!.push(s);
}
const maxPerLevel = Math.max(...Array.from(byLevel.values()).map(arr => arr.length));
const rowWidth = maxPerLevel * this.NODE_WIDTH + (maxPerLevel - 1) * this.H_SPACING;
const nodes: GraphNode[] = [];
for (const [lvl, arr] of byLevel.entries()) {
const count = arr.length;
const levelWidth = count * this.NODE_WIDTH + (count - 1) * this.H_SPACING;
const startX = (rowWidth - levelWidth) / 2;
arr.forEach((s, i) => {
nodes.push({
id: s.id!,
name: s.name,
displayName: this.truncate(s.name),
x: startX + i * (this.NODE_WIDTH + this.H_SPACING),
y: lvl * (this.NODE_HEIGHT + this.V_SPACING)
});
});
}
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const edges: GraphEdge[] = [];
for (const scene of this.scenes) {
const from = nodeMap.get(scene.id!);
if (!from || !scene.branches) continue;
// On positionne chaque label a une fraction t differente de l'arete selon
// son index parmi les sorties du meme noeud source. Evite le chevauchement
// des labels au milieu quand plusieurs aretes convergent/divergent.
const siblings = scene.branches.filter(b => nodeMap.has(b.targetSceneId));
const count = siblings.length;
siblings.forEach((b, idx) => {
const to = nodeMap.get(b.targetSceneId)!;
const x1 = from.x + this.NODE_WIDTH / 2;
const y1 = from.y + this.NODE_HEIGHT;
const x2 = to.x + this.NODE_WIDTH / 2;
const y2 = to.y;
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
edges.push({
label: b.label,
x1, y1, x2, y2,
labelX: x1 + (x2 - x1) * t,
labelY: y1 + (y2 - y1) * t - 4
});
});
}
this.nodes = nodes;
this.edges = edges;
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
private truncate(text: string): string {
return text.length > this.MAX_LABEL_CHARS
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
: text;
}
openScene(sceneId: string): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', sceneId]);
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}