Scan to open on your phone
Point a camera at the QR code - it opens this exact paste, no app needed.
https://codepastes.com/massisans-titrePlain Text
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