sans-titre

Pasted Oct 23, 2026, 2:48 PM1 file279 lines

Scan to open on your phone

Point a camera at the QR code - it opens this exact paste, no app needed.

https://codepastes.com/massi
sans-titre
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, afterNextRender, computed, effect, inject, signal, viewChild } from '@angular/core';
import { FONT_FAMILY } from './landscape-layout';
import { buildLandscapeSvg, type SvgApp, type SvgGroup } from './landscape-to-svg.converter';
import { stack, type LNode } from './landscape.model';
import {
  LANDSCAPE_WIDTH,
  steering,
  customerExchange,
  operation,
  resources,
  masterDataManagement,
} from './landscape.data';

const MIN_ZOOM = 0.03;
const MAX_ZOOM = 4;

interface ZoneEntry {
  id: string;
  node: LNode;
}

interface ZonePartition {
  zone: SvgGroup;
  groups: SvgGroup[];
  apps: SvgApp[];
}

interface Rect {
  x: number;
  y: number;
  w: number;
  h: number;
}

@Component({
  selector: 'app-landscape-map',
  imports: [],
  templateUrl: './landscape-map.component.html',
  styleUrl: './landscape-map.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LandscapeMapComponent {
  private readonly host = viewChild.required<ElementRef<HTMLElement>>('canvas');

  readonly fontFamily = FONT_FAMILY;

  // Ordre des 5 zones (aperçu uniquement, rien n'est sauvegardé).
  private readonly zones = signal<ZoneEntry[]>([
    { id: 'steering', node: steering },
    { id: 'customerExchange', node: customerExchange },
    { id: 'operation', node: operation },
    { id: 'resources', node: resources },
    { id: 'masterDataManagement', node: masterDataManagement },
  ]);

  readonly model = computed(() => {
    const tree = stack(this.zones().map(z => z.node), { gap: 40 });
    return buildLandscapeSvg(tree, LANDSCAPE_WIDTH);
  });

  /** Les 5 grandes boîtes de zone, dans l'ordre courant de `zones`. */
  private readonly zoneBoxes = computed(() => this.model().groups.filter(g => g.role === 'zone'));

  /** Répartit chaque box/app entre la zone qui la contient géométriquement. */
  private readonly zonePartition = computed<ZonePartition[]>(() => {
    const m = this.model();
    return this.zoneBoxes().map(zone => ({
      zone,
      groups: m.groups.filter(g => g.id !== zone.id && this.within(g, zone)),
      apps: m.apps.filter(a => this.within(a, zone)),
    }));
  });

  private within(n: Rect, z: Rect): boolean {
    return n.x >= z.x - 0.5 && n.y >= z.y - 0.5 && n.x + n.w <= z.x + z.w + 0.5 && n.y + n.h <= z.y + z.h + 0.5;
  }

  /** Zone en cours de glisser-déposer (affichée en dernier pour passer au-dessus des autres). */
  readonly zoneDragIndex = signal<number | null>(null);
  readonly dragOffset = signal<{ x: number; y: number }>({ x: 0, y: 0 });
  readonly hoveredTargetIndex = signal<number | null>(null);

  readonly orderedPartition = computed(() => {
    const list = this.zonePartition().map((zp, index) => ({ zp, index }));
    const di = this.zoneDragIndex();
    if (di === null) return list;
    const [dragged] = list.splice(di, 1);
    list.push(dragged);
    return list;
  });

  zoneDragTransform(index: number): string | null {
    if (this.zoneDragIndex() !== index) return null;
    const { x, y } = this.dragOffset();
    return `translate(${x} ${y})`;
  }

  private dragTracking: { index: number; pointerId: number; startX: number; startY: number } | null = null;
  private zoneDragMoved = false;

  onZonePointerDown(event: PointerEvent, index: number): void {
    if (event.button !== 0) return;
    event.stopPropagation(); // ne pas déclencher le pan du canvas en même temps
    this.dragTracking = { index, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY };
    this.zoneDragMoved = false;
    this.dragOffset.set({ x: 0, y: 0 });
    this.zoneDragIndex.set(index);
    (event.target as Element).setPointerCapture?.(event.pointerId);
  }

  onZonePointerMove(event: PointerEvent): void {
    const drag = this.dragTracking;
    if (!drag || drag.pointerId !== event.pointerId) return;

    const dxScreen = event.clientX - drag.startX;
    const dyScreen = event.clientY - drag.startY;
    if (Math.abs(dxScreen) + Math.abs(dyScreen) > 4) this.zoneDragMoved = true;

    const z = this.zoom();
    this.dragOffset.set({ x: dxScreen / z, y: dyScreen / z });

    const point = this.clientToSvgPoint(event.clientX, event.clientY);
    const boxes = this.zoneBoxes();
    const targetIndex = boxes.findIndex((box, i) => i !== drag.index && this.pointInRect(point, box));
    this.hoveredTargetIndex.set(targetIndex === -1 ? null : targetIndex);
  }

  onZonePointerUp(event: PointerEvent): void {
    const drag = this.dragTracking;
    if (!drag || drag.pointerId !== event.pointerId) return;

    const targetIndex = this.hoveredTargetIndex();
    if (targetIndex !== null && targetIndex !== drag.index) {
      const next = [...this.zones()];
      [next[drag.index], next[targetIndex]] = [next[targetIndex], next[drag.index]];
      this.zones.set(next);
    }

    this.dragTracking = null;
    this.zoneDragIndex.set(null);
    this.dragOffset.set({ x: 0, y: 0 });
    this.hoveredTargetIndex.set(null);
  }

  private pointInRect(p: { x: number; y: number }, r: Rect): boolean {
    return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
  }

  private clientToSvgPoint(clientX: number, clientY: number): { x: number; y: number } {
    const rect = this.host().nativeElement.getBoundingClientRect();
    const { x: panX, y: panY } = this.pan();
    const z = this.zoom();
    return { x: (clientX - rect.left - panX) / z, y: (clientY - rect.top - panY) / z };
  }

  readonly zoom = signal(1);
  private readonly pan = signal({ x: 0, y: 0 });
  readonly transform = computed(() => {
    const { x, y } = this.pan();
    return `translate(${x} ${y}) scale(${this.zoom()})`;
  });
  readonly zoomPercent = computed(() => Math.round(this.zoom() * 100));

  readonly hovered = signal<string | null>(null);
  readonly selected = signal<string | null>(null);
  readonly panning = signal(false);

  private dragging: { pointerId: number; startX: number; startY: number; panX: number; panY: number } | null = null;
  private dragged = false;
  private userAdjusted = false;
  private readonly viewReady = signal(false);
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    effect(() => {
      this.model();
      if (this.viewReady() && !this.userAdjusted) this.fitView();
    });

    afterNextRender(() => {
      this.viewReady.set(true);
      const el = this.host().nativeElement;
      this.fitView();
      // Le premier rendu peut précéder la résolution du flex : on recadre
      // tant que l'utilisateur n'a pas pris la main sur la vue.
      const observer = new ResizeObserver(() => {
        if (!this.userAdjusted) this.fitView();
      });
      observer.observe(el);
      this.destroyRef.onDestroy(() => observer.disconnect());
    });
  }

  fitView(padding = 0.03): void {
    const el = this.host().nativeElement;
    const rect = el.getBoundingClientRect();
    const { width, height } = this.model();
    if (!rect.width || !rect.height || !width || !height) return;

    const scale = Math.min(rect.width / width, rect.height / height) * (1 - padding * 2);
    const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale));
    this.zoom.set(clamped);
    this.pan.set({
      x: (rect.width - width * clamped) / 2,
      y: (rect.height - height * clamped) / 2,
    });
  }

  /** Recadre et redonne la main au recadrage automatique sur redimensionnement. */
  resetView(): void {
    this.userAdjusted = false;
    this.fitView();
  }

  onWheel(event: WheelEvent): void {
    event.preventDefault();
    this.userAdjusted = true;
    const rect = this.host().nativeElement.getBoundingClientRect();
    const px = event.clientX - rect.left;
    const py = event.clientY - rect.top;

    const current = this.zoom();
    const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, current * Math.exp(-event.deltaY * 0.0015)));
    if (next === current) return;

    const { x, y } = this.pan();
    const ratio = next / current;
    this.pan.set({ x: px - (px - x) * ratio, y: py - (py - y) * ratio });
    this.zoom.set(next);
  }

  onPointerDown(event: PointerEvent): void {
    if (event.button !== 0) return;
    const { x, y } = this.pan();
    this.dragging = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, panX: x, panY: y };
    this.dragged = false;
    this.panning.set(true);
    (event.target as Element).setPointerCapture?.(event.pointerId);
  }

  onPointerMove(event: PointerEvent): void {
    const drag = this.dragging;
    if (!drag || drag.pointerId !== event.pointerId) return;
    const dx = event.clientX - drag.startX;
    const dy = event.clientY - drag.startY;
    if (Math.abs(dx) + Math.abs(dy) > 4) {
      this.dragged = true;
      this.userAdjusted = true;
    }

    this.pan.set({ x: drag.panX + dx, y: drag.panY + dy });
  }

  onPointerUp(event: PointerEvent): void {
    if (this.dragging?.pointerId !== event.pointerId) return;
    this.dragging = null;
    this.panning.set(false);
  }

  zoomBy(factor: number): void {
    this.userAdjusted = true;
    const rect = this.host().nativeElement.getBoundingClientRect();
    const px = rect.width / 2;
    const py = rect.height / 2;
    const current = this.zoom();
    const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, current * factor));
    if (next === current) return;
    const { x, y } = this.pan();
    const ratio = next / current;
    this.pan.set({ x: px - (px - x) * ratio, y: py - (py - y) * ratio });
    this.zoom.set(next);
  }

  onAppClick(id: string): void {
    if (this.dragged || this.zoneDragMoved) return;
    this.selected.update(current => (current === id ? null : id));
  }
}

9,667 chars · codepastes.com