Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s

- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
This commit is contained in:
2026-04-25 11:41:14 +02:00
parent 0582690dca
commit 550078268c
54 changed files with 620 additions and 92 deletions

View File

@@ -27,6 +27,11 @@
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer l'arc

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
/**
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
@@ -18,15 +20,17 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({
selector: 'app-arc-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss']
})
export class ArcCreateComponent implements OnInit, OnDestroy {
readonly BookOpen = BookOpen;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
form: FormGroup;
campaignId = '';
selectedIcon: string | null = null;
private existingArcCount = 0;
constructor(
@@ -80,7 +84,8 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name,
description: this.form.value.description,
campaignId: this.campaignId,
order: this.existingArcCount + 1
order: this.existingArcCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
@@ -63,6 +71,11 @@
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field-row">
<div class="field">
<label for="arc-edit-themes">Thèmes principaux</label>
@@ -136,17 +149,6 @@
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>

View File

@@ -16,6 +16,8 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
/**
* Écran de détail/modification d'un Arc.
@@ -29,13 +31,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({
selector: 'app-arc-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss']
})
export class ArcEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
@@ -122,6 +126,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.selectedIcon = arc.icon ?? null;
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name);
@@ -167,7 +172,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -2,7 +2,10 @@
<header class="view-header">
<div>
<h1>{{ arc.name }}</h1>
<h1>
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">Arc narratif</p>
</div>
<div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
@@ -29,6 +30,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';

View File

@@ -0,0 +1,66 @@
import {
Bookmark,
Theater, Drama, Compass, Flag, Calendar, Map, Castle, Tent,
Swords, Skull, Crown, Heart, Eye, Footprints, Dice5, Hourglass,
Flame, Snowflake, Cloud, Sun, Moon, Star, Zap, Target,
Music, MessageCircle, Lock, Key as KeyIcon, Coins, Gift,
LucideIconData
} from 'lucide-angular';
// Type local equivalent a IconOption de lore-icons. Duplique volontairement
// pour eviter une dependance circulaire (lore-icons importe ce fichier).
export interface CampaignIconOption {
key: string;
icon: LucideIconData;
}
/**
* Banque d'icones dediee aux entites narratives d'une campagne (arc, chapitre, scene).
*
* Pourquoi separe de LORE_ICON_OPTIONS ? Les icones lore sont plutot orientees
* "objets de monde" (chateau, foret, dragon...). Ici on est sur du sequencement
* narratif et de la mise en scene : ambiances, actes, decisions. Cles uniques
* (prefixees `c-` quand un nom existerait deja dans le lore) pour eviter les
* collisions avec LORE_ICON_OPTIONS — le resolver consulte les deux registres.
*/
export const CAMPAIGN_ICON_OPTIONS: CampaignIconOption[] = [
{ key: 'c-theater', icon: Theater },
{ key: 'c-drama', icon: Drama },
{ key: 'c-compass', icon: Compass },
{ key: 'c-flag', icon: Flag },
{ key: 'c-calendar', icon: Calendar },
{ key: 'c-map', icon: Map },
{ key: 'c-castle', icon: Castle },
{ key: 'c-tent', icon: Tent },
{ key: 'c-swords', icon: Swords },
{ key: 'c-skull', icon: Skull },
{ key: 'c-crown', icon: Crown },
{ key: 'c-heart', icon: Heart },
{ key: 'c-eye', icon: Eye },
{ key: 'c-footprints', icon: Footprints },
{ key: 'c-dice', icon: Dice5 },
{ key: 'c-hourglass', icon: Hourglass },
{ key: 'c-flame', icon: Flame },
{ key: 'c-snowflake', icon: Snowflake },
{ key: 'c-cloud', icon: Cloud },
{ key: 'c-sun', icon: Sun },
{ key: 'c-moon', icon: Moon },
{ key: 'c-star', icon: Star },
{ key: 'c-zap', icon: Zap },
{ key: 'c-target', icon: Target },
{ key: 'c-music', icon: Music },
{ key: 'c-message', icon: MessageCircle },
{ key: 'c-lock', icon: Lock },
{ key: 'c-key', icon: KeyIcon },
{ key: 'c-coins', icon: Coins },
{ key: 'c-gift', icon: Gift },
];
/** Icone par defaut quand une entite narrative n'en a pas. */
export const DEFAULT_CAMPAIGN_ICON: LucideIconData = Bookmark;
/** Resolveur dedie. Prefere passer par `resolveIcon` dans lore-icons qui consulte les deux. */
export function resolveCampaignIcon(key: string | null | undefined): LucideIconData {
if (!key) return DEFAULT_CAMPAIGN_ICON;
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_CAMPAIGN_ICON;
}

View File

@@ -109,11 +109,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
id: `scene-${sc.id}`,
label: sc.name,
iconKey: sc.icon ?? undefined,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
}));
return {
id: `chapter-${ch.id}`,
label: ch.name,
iconKey: ch.icon ?? undefined,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{
@@ -127,6 +129,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
return {
id: `arc-${arc.id}`,
label: arc.name,
iconKey: arc.icon ?? undefined,
children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,

View File

@@ -28,6 +28,11 @@
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer le chapitre

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
/**
* Écran de création d'un nouveau chapitre rattaché à un arc.
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({
selector: 'app-chapter-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
})
export class ChapterCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup;
campaignId = '';
arcId = '';
@@ -82,7 +87,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name,
description: this.form.value.description,
arcId: this.arcId,
order: this.existingChapterCount + 1
order: this.existingChapterCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
error: () => console.error('Erreur lors de la création du chapitre')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
@@ -63,6 +71,11 @@
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field">
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
<textarea
@@ -116,17 +129,6 @@
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>

View File

@@ -16,6 +16,8 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
/**
* Écran de détail/modification d'un Chapitre.
@@ -27,13 +29,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
export class ChapterEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
@@ -113,6 +117,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({
@@ -153,7 +158,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -16,11 +16,16 @@
</div>
<div class="graph-container" *ngIf="scenes.length > 0">
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" class="graph-svg">
<svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)">
<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" />
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
</marker>
</defs>
@@ -28,20 +33,25 @@
<g class="edge" *ngFor="let edge of edges">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#6b7280" stroke-width="2"
stroke="#b8c0cc" stroke-width="2"
marker-end="url(#arrowhead)" />
<text *ngIf="edge.label"
[attr.x]="edge.labelX"
[attr.y]="edge.labelY"
text-anchor="middle"
class="edge-label">
class="edge-label"
[class.dragging]="draggingLabelKey === edge.key"
(pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }}
</text>
</g>
</g>
<g class="nodes">
<g class="node" *ngFor="let node of nodes" (click)="openScene(node.id)">
<g class="node"
[class.dragging]="draggingId === node.id"
*ngFor="let node of nodes"
(pointerdown)="onPointerDown($event, node)">
<title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y"
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
@@ -57,7 +67,7 @@
</svg>
<small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small>
</div>

View File

@@ -11,7 +11,7 @@
margin-bottom: 2rem;
.subtitle {
color: #6b7280;
color: #9ca3af;
font-size: 0.9rem;
margin: 0.25rem 0 0;
}
@@ -20,15 +20,17 @@
.graph-empty {
padding: 2rem;
text-align: center;
color: #6b7280;
background: #f9fafb;
color: #9ca3af;
background: #14141f;
border-radius: 8px;
border: 1px dashed #d1d5db;
border: 1px dashed #374151;
}
.graph-container {
background: #fafafa;
border: 1px solid #e5e7eb;
// Fond legerement plus sombre que la couleur des noeuds : creuse l'image
// sans aller jusqu'au noir pur (qui « brulerait » par contraste).
background: #0d0d18;
border: 1px solid #374151;
border-radius: 12px;
padding: 20px;
overflow: auto;
@@ -41,14 +43,27 @@
.graph-svg {
display: block;
max-width: 100%;
// Empeche le browser de clipper le contenu qui depasserait le viewport SVG
// pendant un drag — le scroll du conteneur prend le relais.
overflow: visible;
// Évite que le navigateur intercepte le drag pour faire de la sélection texte
// ou du panning natif sur les nœuds.
touch-action: none;
user-select: none;
}
.node {
cursor: pointer;
cursor: grab;
// Ombre portee douce pour detacher chaque noeud du fond. Faible alpha pour
// rester subtil sur fond sombre, large diffusion pour rester organique.
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
.node-box {
fill: #ffffff;
stroke: #1f2937;
// Indigo desature : reprend la palette accent (#6c63ff) en version assombrie
// pour donner du caractere aux noeuds sans saturer la vue. Bordure assortie
// un peu plus claire pour bien dessiner le contour.
fill: #1f1d3a;
stroke: #4f4a7a;
stroke-width: 2;
transition: fill 0.15s ease, stroke 0.15s ease;
}
@@ -56,31 +71,47 @@
.node-label {
font-size: 0.9rem;
font-weight: 500;
fill: #1f2937;
fill: #f3f4f6;
pointer-events: none;
}
&:hover .node-box {
fill: #eef2ff;
stroke: #4f46e5;
fill: #2c2952;
stroke: #8b80ff;
}
&.dragging {
cursor: grabbing;
.node-box {
fill: #2c2952;
stroke: #8b80ff;
filter: drop-shadow(0 4px 10px rgba(108, 99, 255, 0.35));
}
}
}
.edge-label {
font-size: 0.75rem;
fill: #4b5563;
fill: #e5e7eb;
font-style: italic;
// Halo blanc autour du texte pour garantir la lisibilité même s'il passe
// sur une ligne ou un autre élément.
cursor: grab;
// Halo sombre autour du texte pour rester lisible quand un label passe
// par-dessus une arête ou un autre nœud. Aligne sur la couleur du fond.
paint-order: stroke;
stroke: #fafafa;
stroke-width: 3px;
stroke: #0d0d18;
stroke-width: 4px;
stroke-linejoin: round;
&:hover { fill: #ffffff; }
&.dragging {
cursor: grabbing;
fill: #ffffff;
}
}
.graph-hint {
display: block;
margin-top: 1rem;
color: #6b7280;
color: #9ca3af;
font-size: 0.85rem;
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -11,7 +11,7 @@ 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; }
interface GraphEdge { key: string; 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.
@@ -45,6 +45,24 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
svgWidth = 600;
svgHeight = 400;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
// Etat de drag : id du noeud manipule, offset entre le pointeur et le coin
// haut-gauche du noeud (en coords SVG), et flag indiquant qu'un mouvement
// significatif a eu lieu (pour distinguer clic vs glisser).
draggingId: string | null = null;
draggingLabelKey: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Decalage manuel applique a chaque label d'arete, indexe par cle stable
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes
// pour que le label suive son arete quand on deplace un noeud, tout en
// conservant le repositionnement manuel de l'utilisateur.
private labelOffsets = new Map<string, { dx: number; dy: number }>();
constructor(
private route: ActivatedRoute,
private router: Router,
@@ -153,7 +171,18 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
});
}
const nodeMap = new Map(nodes.map(n => [n.id, n]));
this.nodes = nodes;
this.recomputeEdges();
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
/**
* Recalcule la geometrie des aretes a partir des positions courantes des noeuds.
* Appele apres le layout initial et apres chaque deplacement manuel d'un noeud.
*/
private recomputeEdges(): void {
const nodeMap = new Map(this.nodes.map(n => [n.id, n]));
const edges: GraphEdge[] = [];
for (const scene of this.scenes) {
const from = nodeMap.get(scene.id!);
@@ -171,19 +200,158 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
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;
const key = `${scene.id}|${b.targetSceneId}|${idx}`;
const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 };
edges.push({
key,
label: b.label,
x1, y1, x2, y2,
labelX: x1 + (x2 - x1) * t,
labelY: y1 + (y2 - y1) * t - 4
labelX: x1 + (x2 - x1) * t + offset.dx,
labelY: y1 + (y2 - y1) * t - 4 + offset.dy
});
});
}
this.nodes = nodes;
this.edges = edges;
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
/**
* Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM
* inverse. Necessaire car le SVG peut etre redimensionne par max-width.
*/
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 {
// Bouton gauche uniquement.
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 {
const { x, y } = this.toSvgCoords(evt);
if (this.draggingLabelKey) {
const edge = this.edges.find(e => e.key === this.draggingLabelKey);
if (!edge) return;
const newX = x - this.dragOffsetX;
const newY = y - this.dragOffsetY;
if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
// Recalcule la position automatique courante puis stocke la difference,
// pour que l'offset reste valable meme apres deplacement d'un noeud.
const auto = this.autoLabelPosition(edge.key);
if (auto) {
this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y });
}
edge.labelX = newX;
edge.labelY = newY;
return;
}
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
// Empeche le noeud de partir en coordonnees negatives : sinon il sort
// du viewport SVG et se fait clipper par le navigateur (le SVG a
// overflow: hidden par defaut quand on lui donne width/height explicites).
const newX = Math.max(0, x - this.dragOffsetX);
const newY = Math.max(0, y - this.dragOffsetY);
if (!this.dragMoved) {
const dx = newX - node.x;
const dy = newY - node.y;
if (Math.hypot(dx, dy) >= this.DRAG_THRESHOLD) this.dragMoved = true;
else return;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
/**
* Recalcule la position "auto" (sans offset manuel) du label d'une arete
* a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag.
*/
private autoLabelPosition(key: string): { x: number; y: number } | null {
const [sourceId, targetId, idxStr] = key.split('|');
const idx = Number(idxStr);
const scene = this.scenes.find(s => s.id === sourceId);
if (!scene?.branches) return null;
const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId));
const count = siblings.length;
if (idx >= count) return null;
const from = this.nodes.find(n => n.id === sourceId);
const to = this.nodes.find(n => n.id === targetId);
if (!from || !to) return null;
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;
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 };
}
onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void {
if (evt.button !== 0) return;
// Empeche l'event de remonter au <g class="node"> ou au svg, sinon on
// declencherait aussi un drag de noeud.
evt.stopPropagation();
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingLabelKey = edge.key;
this.dragOffsetX = x - edge.labelX;
this.dragOffsetY = y - edge.labelY;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
/**
* Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter
* que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la
* taille initiale du layout pour rester stable visuellement.
*/
private fitSvgToNodes(): void {
const margin = 40;
let maxX = 600;
let maxY = 200;
for (const n of this.nodes) {
if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin;
if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin;
}
if (maxX > this.svgWidth) this.svgWidth = maxX;
if (maxY > this.svgHeight) this.svgHeight = maxY;
}
onPointerUp(evt: PointerEvent): void {
if (this.draggingLabelKey) {
this.draggingLabelKey = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
return;
}
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);
// Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture.
if (!moved) this.openScene(id);
}
private truncate(text: string): string {

View File

@@ -2,7 +2,10 @@
<header class="view-header">
<div>
<h1>{{ chapter.name }}</h1>
<h1>
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">Chapitre</p>
</div>
<div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
@@ -29,6 +30,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';

View File

@@ -28,6 +28,11 @@
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer la scène

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
/**
* Écran de création d'une nouvelle scène rattachée à un chapitre.
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({
selector: 'app-scene-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss']
})
export class SceneCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup;
campaignId = '';
arcId = '';
@@ -84,7 +89,8 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name,
description: this.form.value.description,
chapterId: this.chapterId,
order: this.existingSceneCount + 1
order: this.existingSceneCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
error: () => console.error('Erreur lors de la création de la scène')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
@@ -63,6 +71,11 @@
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<!-- Section : Contexte et ambiance -->
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
<div class="field-row">
@@ -218,17 +231,6 @@
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>

View File

@@ -17,6 +17,8 @@ import { ExpandableSectionComponent } from '../../shared/expandable-section/expa
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
/**
* Écran de détail/modification d'une Scène.
@@ -25,13 +27,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({
selector: 'app-scene-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss']
})
export class SceneEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
@@ -131,6 +135,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.selectedIcon = scene.icon ?? null;
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
@@ -184,7 +189,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches
branches: this.branches,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -2,7 +2,10 @@
<header class="view-header">
<div>
<h1>{{ scene.name }}</h1>
<h1>
<lucide-icon *ngIf="scene.icon" [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ scene.name }}
</h1>
<p class="view-subtitle">Scène</p>
</div>
<div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
@@ -28,6 +29,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class SceneViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';