Mise en ligne de la version 0.2.0
This commit is contained in:
204
web/src/app/campaigns/chapter-graph/chapter-graph.component.ts
Normal file
204
web/src/app/campaigns/chapter-graph/chapter-graph.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user