// =============================================================================
// iframe-manager.service.ts — Gestion de N iframes avec file d'actions différées
// =============================================================================
// Chaque iframe a un état de readiness indépendant.
// Les actions envoyées à une iframe non prête sont mises en file d'attente
// et dépilées automatiquement dès qu'elle signale qu'elle est ready.
// Un mécanisme de « iframe active » permet de cibler une seule iframe.
// =============================================================================
import {
Injectable,
signal,
computed,
effect,
DestroyRef,
inject,
NgZone,
} from '@angular/core';
import { MapSignalStore } from './map-store';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Types d'actions que le parent peut envoyer à une iframe carte */
export type IframeActionType =
| 'SYNC_STATE' // Synchronise tout le state
| 'FIT_BOUNDS' // Centre la carte sur des coordonnées
| 'HIGHLIGHT' // Met en surbrillance un objet
| 'CLEAR_HIGHLIGHT' // Annule la surbrillance
| 'ZOOM_TO' // Zoom sur un point
| 'DRAW' // Active un mode de dessin
| 'CUSTOM'; // Action arbitraire
export interface IframeAction {
type: IframeActionType;
payload?: unknown;
/** Si true, remplace les actions du même type déjà en queue */
deduplicate?: boolean;
/** Timestamp d'ajout en queue */
enqueuedAt?: number;
}
export interface IframeInstance {
/** Identifiant unique de l'iframe */
id: string;
/** Nom lisible (ex: "Carte principale", "Carte satellite") */
label: string;
/** Référence vers l'élément iframe dans le DOM */
elementRef: HTMLIFrameElement | null;
/** L'iframe a-t-elle signalé qu'elle est prête ? */
ready: boolean;
/** File d'actions en attente tant que ready === false */
pendingActions: IframeAction[];
/** URL source de l'iframe */
src: string;
/** Métadonnées libres */
meta?: Record<string, unknown>;
}
/** Messages postés entre parent ↔ iframe */
export interface IframeBridgeMessage {
source: 'map-parent' | 'map-iframe';
iframeId: string;
type: 'ACTION' | 'READY' | 'EVENT' | 'STATE_SYNC';
payload: unknown;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable({ providedIn: 'root' })
export class IframeManagerService {
private readonly store = inject(MapSignalStore);
private readonly zone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
// ---- State ----
private readonly _instances = signal<Map<string, IframeInstance>>(new Map());
private readonly _activeId = signal<string | null>(null);
// ---- Selectors ----
readonly instances = this._instances.asReadonly();
readonly instanceList = computed(() => [...this._instances().values()]);
readonly activeId = this._activeId.asReadonly();
readonly activeInstance = computed(() => {
const id = this._activeId();
return id ? this._instances().get(id) ?? null : null;
});
readonly readyInstances = computed(() =>
this.instanceList().filter(i => i.ready),
);
readonly pendingActionCount = computed(() =>
this.instanceList().reduce((sum, i) => sum + i.pendingActions.length, 0),
);
constructor() {
// Écoute les messages venant des iframes
this.listenToIframeMessages();
// Auto-sync : à chaque changement du store, envoie le state aux iframes ready
effect(() => {
const snapshot = this.store.snapshot(); // tracking dependency
const readyFrames = this.readyInstances(); // tracking dependency
for (const iframe of readyFrames) {
this.postToIframe(iframe.id, {
type: 'STATE_SYNC',
payload: this.serializeSnapshot(snapshot),
});
}
});
// Nettoyage à la destruction
this.destroyRef.onDestroy(() => this.disposeAll());
}
// ===========================================================================
// Gestion du cycle de vie des iframes
// ===========================================================================
/**
* Enregistre une nouvelle instance d'iframe.
* L'iframe n'est PAS encore prête — les actions seront mises en file.
*/
register(config: {
id: string;
label: string;
src: string;
meta?: Record<string, unknown>;
}): void {
const instance: IframeInstance = {
id: config.id,
label: config.label,
src: config.src,
elementRef: null,
ready: false,
pendingActions: [],
meta: config.meta,
};
this._instances.update(map => {
const next = new Map(map);
next.set(config.id, instance);
return next;
});
// La première iframe enregistrée devient active par défaut
if (this._activeId() === null) {
this._activeId.set(config.id);
}
}
/**
* Associe un élément <iframe> DOM à une instance enregistrée.
* Appelé typiquement depuis une directive ou un composant host.
*/
attachElement(iframeId: string, el: HTMLIFrameElement): void {
this.updateInstance(iframeId, inst => ({
...inst,
elementRef: el,
}));
}
/**
* Désenregistre une iframe et nettoie sa file d'attente.
*/
unregister(iframeId: string): void {
this._instances.update(map => {
const next = new Map(map);
next.delete(iframeId);
return next;
});
if (this._activeId() === iframeId) {
const remaining = this.instanceList();
this._activeId.set(remaining.length > 0 ? remaining[0].id : null);
}
}
// ===========================================================================
// Readiness
// ===========================================================================
/**
* Marque une iframe comme prête et dépile toute la file d'actions.
* Appelé automatiquement à réception du message READY de l'iframe,
* ou manuellement si besoin.
*/
markReady(iframeId: string): void {
const inst = this._instances().get(iframeId);
if (!inst || inst.ready) return;
console.log(`[IframeManager] ✓ iframe "${iframeId}" is ready — flushing ${inst.pendingActions.length} pending action(s)`);
// On envoie d'abord un state sync complet
this.postToIframe(iframeId, {
type: 'STATE_SYNC',
payload: this.serializeSnapshot(this.store.snapshot()),
});
// Puis on dépile les actions en attente dans l'ordre
for (const action of inst.pendingActions) {
this.postToIframe(iframeId, {
type: 'ACTION',
payload: action,
});
}
// Met à jour l'état
this.updateInstance(iframeId, i => ({
...i,
ready: true,
pendingActions: [],
}));
}
/**
* Remet une iframe en état « non prête » (ex: rechargement).
* Les nouvelles actions iront à nouveau en file d'attente.
*/
markNotReady(iframeId: string): void {
this.updateInstance(iframeId, i => ({
...i,
ready: false,
}));
}
// ===========================================================================
// Actions
// ===========================================================================
/**
* Envoie une action à une iframe spécifique.
* Si l'iframe n'est pas prête, l'action est mise en file d'attente.
*/
dispatch(iframeId: string, action: IframeAction): void {
const inst = this._instances().get(iframeId);
if (!inst) {
console.warn(`[IframeManager] iframe "${iframeId}" not found`);
return;
}
const enrichedAction: IframeAction = {
...action,
enqueuedAt: Date.now(),
};
if (inst.ready) {
// Envoi immédiat
this.postToIframe(iframeId, {
type: 'ACTION',
payload: enrichedAction,
});
} else {
// File d'attente
console.log(`[IframeManager] ⏳ queuing "${action.type}" for iframe "${iframeId}"`);
this.updateInstance(iframeId, i => {
let queue = [...i.pendingActions];
// Dé-duplication optionnelle
if (action.deduplicate) {
queue = queue.filter(a => a.type !== action.type);
}
queue.push(enrichedAction);
return { ...i, pendingActions: queue };
});
}
}
/**
* Envoie une action à TOUTES les iframes (broadcast).
*/
broadcast(action: IframeAction): void {
for (const inst of this.instanceList()) {
this.dispatch(inst.id, action);
}
}
/**
* Envoie une action à l'iframe active uniquement.
*/
dispatchToActive(action: IframeAction): void {
const id = this._activeId();
if (id) this.dispatch(id, action);
}
/**
* Vide la file d'attente d'une iframe sans exécuter les actions.
*/
clearPendingActions(iframeId: string): void {
this.updateInstance(iframeId, i => ({
...i,
pendingActions: [],
}));
}
// ===========================================================================
// Iframe active
// ===========================================================================
setActive(iframeId: string): void {
if (!this._instances().has(iframeId)) {
console.warn(`[IframeManager] cannot activate unknown iframe "${iframeId}"`);
return;
}
this._activeId.set(iframeId);
}
// ===========================================================================
// Communication postMessage
// ===========================================================================
private postToIframe(
iframeId: string,
msg: Omit<IframeBridgeMessage, 'source' | 'iframeId'>,
): void {
const inst = this._instances().get(iframeId);
if (!inst?.elementRef?.contentWindow) {
console.warn(`[IframeManager] no contentWindow for iframe "${iframeId}"`);
return;
}
const fullMsg: IframeBridgeMessage = {
source: 'map-parent',
iframeId,
...msg,
};
inst.elementRef.contentWindow.postMessage(fullMsg, '*');
}
private listenToIframeMessages(): void {
const handler = (event: MessageEvent<IframeBridgeMessage>) => {
const data = event.data;
if (!data || data.source !== 'map-iframe') return;
this.zone.run(() => {
switch (data.type) {
case 'READY':
this.markReady(data.iframeId);
break;
case 'EVENT':
this.handleIframeEvent(data.iframeId, data.payload);
break;
default:
console.log(`[IframeManager] unhandled message type: ${data.type}`);
}
});
};
window.addEventListener('message', handler);
this.destroyRef.onDestroy(() => window.removeEventListener('message', handler));
}
/**
* Traite les événements remontés par les iframes (clics, dessins, etc.)
* et les traduit en mutations du store.
*/
private handleIframeEvent(iframeId: string, payload: unknown): void {
const event = payload as { name: string; data: any };
if (!event?.name) return;
switch (event.name) {
case 'object-clicked':
this.store.toggleSelection(event.data.id);
break;
case 'object-hovered':
this.store.setHovered(event.data.id);
break;
case 'viewport-changed':
this.store.setViewport(event.data);
break;
case 'object-created':
this.store.upsertObject(event.data);
break;
default:
console.log(`[IframeManager] unhandled iframe event: ${event.name}`);
}
}
// ===========================================================================
// Helpers internes
// ===========================================================================
private updateInstance(
id: string,
updater: (inst: IframeInstance) => IframeInstance,
): void {
this._instances.update(map => {
const inst = map.get(id);
if (!inst) return map;
const next = new Map(map);
next.set(id, updater(inst));
return next;
});
}
/**
* Sérialise le snapshot pour postMessage
* (Map et Set ne passent pas bien en structured clone dans certains browsers)
*/
private serializeSnapshot(snapshot: any): any {
return {
...snapshot,
objects: Object.fromEntries(snapshot.objects),
selection: {
...snapshot.selection,
selectedIds: [...snapshot.selection.selectedIds],
},
};
}
private disposeAll(): void {
for (const inst of this.instanceList()) {
this.unregister(inst.id);
}
}
}