227 lines
9.4 KiB
TypeScript
227 lines
9.4 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
interface Node {
|
|
id: string;
|
|
label: string;
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
type: 'main' | 'side';
|
|
desc: string;
|
|
}
|
|
|
|
interface Edge {
|
|
from: string;
|
|
to: string;
|
|
type: 'main' | 'side';
|
|
}
|
|
|
|
const NODES: Node[] = [
|
|
{ id: 'frontend', label: 'FRONTEND', x: 80, y: 16, w: 160, h: 38, type: 'main', desc: 'Portales, apps y paneles:interfaces donde el usuario opera' },
|
|
{ id: 'api', label: 'API GATEWAY', x: 80, y: 108, w: 160, h: 38, type: 'main', desc: 'Seguridad, ruteo, rate limiting y versionado unificados' },
|
|
{ id: 'logic', label: 'LÓGICA DE NEGOCIO', x: 80, y: 200, w: 160, h: 38, type: 'main', desc: 'Reglas de negocio, validaciones, procesamiento y orquestación' },
|
|
{ id: 'db', label: 'BASE DE DATOS', x: 80, y: 292, w: 160, h: 38, type: 'main', desc: 'Persistencia diseñada para tu modelo:SQL, NoSQL, caché' },
|
|
{ id: 'infra', label: 'INFRAESTRUCTURA', x: 80, y: 384, w: 160, h: 36, type: 'main', desc: 'Nube, contenedores, CI/CD y observabilidad operacional' },
|
|
{ id: 'payments', label: 'PAGOS', x: 8, y: 172, w: 62, h: 28, type: 'side', desc: 'Stripe, Conekta:flujo de cobro integrado' },
|
|
{ id: 'erp', label: 'ERP / CRM', x: 8, y: 222, w: 62, h: 28, type: 'side', desc: 'SAP, Odoo:sincronización bidireccional' },
|
|
{ id: 'auth', label: 'ACCESO', x: 250, y: 84, w: 62, h: 28, type: 'side', desc: 'SSO, JWT, OAuth 2.0:identidad y permisos por rol' },
|
|
{ id: 'external', label: 'EXTERNOS', x: 250, y: 134, w: 62, h: 28, type: 'side', desc: 'APIs de terceros:logística, notificaciones, reportes' },
|
|
];
|
|
|
|
const EDGES: Edge[] = [
|
|
{ from: 'frontend', to: 'api', type: 'main' },
|
|
{ from: 'api', to: 'logic', type: 'main' },
|
|
{ from: 'logic', to: 'db', type: 'main' },
|
|
{ from: 'db', to: 'infra', type: 'main' },
|
|
{ from: 'payments', to: 'logic', type: 'side' },
|
|
{ from: 'erp', to: 'logic', type: 'side' },
|
|
{ from: 'auth', to: 'api', type: 'side' },
|
|
{ from: 'external', to: 'api', type: 'side' },
|
|
];
|
|
|
|
function nodeCenter(n: Node) {
|
|
return { cx: n.x + n.w / 2, cy: n.y + n.h / 2 };
|
|
}
|
|
|
|
function edgeLine(edge: Edge): { x1: number; y1: number; x2: number; y2: number } {
|
|
const from = NODES.find(n => n.id === edge.from)!;
|
|
const to = NODES.find(n => n.id === edge.to)!;
|
|
const fc = nodeCenter(from);
|
|
const tc = nodeCenter(to);
|
|
if (edge.type === 'main') {
|
|
return { x1: fc.cx, y1: from.y + from.h, x2: tc.cx, y2: to.y };
|
|
}
|
|
if (from.x < to.x) {
|
|
return { x1: from.x + from.w, y1: fc.cy, x2: to.x, y2: tc.cy };
|
|
}
|
|
return { x1: from.x, y1: fc.cy, x2: to.x + to.w, y2: tc.cy };
|
|
}
|
|
|
|
function isConnected(nodeId: string, activeId: string | null) {
|
|
if (!activeId || nodeId === activeId) return false;
|
|
return EDGES.some(e =>
|
|
(e.from === activeId && e.to === nodeId) ||
|
|
(e.to === activeId && e.from === nodeId),
|
|
);
|
|
}
|
|
|
|
function isEdgeActive(edge: Edge, activeId: string | null) {
|
|
if (!activeId) return false;
|
|
return edge.from === activeId || edge.to === activeId;
|
|
}
|
|
|
|
const TOOLTIP_W = 172;
|
|
const TAIL_H = 9;
|
|
const TAIL_W = 14;
|
|
|
|
function tooltipHeight(desc: string): number {
|
|
const charsPerLine = Math.floor((TOOLTIP_W - 26) / 6.3); // ~aprox chars por línea a 10.5px
|
|
const lines = Math.ceil(desc.length / charsPerLine);
|
|
return 14 + 16 + lines * 17 + 14; // paddingTop + label + líneas + paddingBottom
|
|
}
|
|
|
|
export function SystemMap({ isNavyBg = true }: { isNavyBg?: boolean }) {
|
|
const [active, setActive] = useState<string | null>(null);
|
|
|
|
const RED = '#e50914';
|
|
const dimStroke = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)';
|
|
const defStroke = isNavyBg ? 'rgba(255,255,255,0.25)' : 'rgba(0,31,91,0.3)';
|
|
const connStroke = 'rgba(229,9,20,0.45)';
|
|
const descText = isNavyBg ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.65)';
|
|
const tooltipBg = isNavyBg ? '#0a1628' : '#ffffff';
|
|
const tooltipBorder = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,31,91,0.15)';
|
|
void tooltipBorder;
|
|
const activeDesc = NODES.find(n => n.id === active);
|
|
|
|
return (
|
|
<div className="w-full" style={{ overflow: 'visible' }}>
|
|
<svg
|
|
viewBox="0 0 320 434"
|
|
width="100%"
|
|
aria-label="Mapa de sistema interactivo:hover para explorar capas"
|
|
role="img"
|
|
style={{ maxWidth: 340, display: 'block', margin: '0 auto', overflow: 'visible' }}
|
|
onMouseLeave={() => setActive(null)}
|
|
>
|
|
<defs>
|
|
<filter id="bubble-shadow" x="-15%" y="-15%" width="130%" height="130%">
|
|
<feDropShadow dx="0" dy="3" stdDeviation="4" floodColor="rgba(0,0,0,0.18)" />
|
|
</filter>
|
|
</defs>
|
|
{/* Edges */}
|
|
{EDGES.map((edge, i) => {
|
|
const { x1, y1, x2, y2 } = edgeLine(edge);
|
|
const edgeActive = isEdgeActive(edge, active);
|
|
return (
|
|
<line
|
|
key={i}
|
|
x1={x1} y1={y1} x2={x2} y2={y2}
|
|
stroke={edgeActive ? RED : edge.type === 'side' ? connStroke : defStroke}
|
|
strokeWidth={edgeActive ? 1.5 : 1}
|
|
strokeDasharray={edge.type === 'side' ? '3 3' : undefined}
|
|
style={{ transition: 'stroke 200ms, stroke-width 200ms' }}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Nodes */}
|
|
{NODES.map(node => {
|
|
const isActive = active === node.id;
|
|
const isConn = isConnected(node.id, active);
|
|
const isDim = active !== null && !isActive && !isConn;
|
|
const stroke = isActive ? RED : isConn ? 'rgba(229,9,20,0.5)' : dimStroke;
|
|
const fillOpacity = isActive ? 0.12 : 0;
|
|
const textColor = isActive ? RED : isDim
|
|
? (isNavyBg ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)')
|
|
: (isNavyBg ? 'rgba(255,255,255,0.65)' : 'rgba(0,31,91,0.75)');
|
|
const { cx } = nodeCenter(node);
|
|
|
|
return (
|
|
<g
|
|
key={node.id}
|
|
onMouseEnter={() => setActive(node.id)}
|
|
onFocus={() => setActive(node.id)}
|
|
onBlur={() => setActive(null)}
|
|
style={{ cursor: 'default', outline: 'none' }}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={`${node.label}: ${node.desc}`}
|
|
>
|
|
<rect
|
|
x={node.x} y={node.y}
|
|
width={node.w} height={node.h}
|
|
fill={`rgba(229,9,20,${fillOpacity})`}
|
|
stroke={stroke}
|
|
strokeWidth={isActive ? 1.5 : 1}
|
|
rx={2}
|
|
style={{ transition: 'fill 200ms, stroke 200ms' }}
|
|
/>
|
|
<text
|
|
x={cx}
|
|
y={node.y + node.h / 2 + 1}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
fill={textColor}
|
|
fontFamily="'SF Mono','Fira Code',Consolas,monospace"
|
|
fontSize={node.type === 'main' ? 10 : 8}
|
|
fontWeight={600}
|
|
letterSpacing={node.type === 'main' ? 1.2 : 0.8}
|
|
style={{ transition: 'fill 200ms', pointerEvents: 'none' }}
|
|
>
|
|
{node.label}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Tooltip burbuja estilo chat */}
|
|
{activeDesc && (() => {
|
|
const node = NODES.find(n => n.id === active)!;
|
|
const TOOLTIP_H = tooltipHeight(activeDesc.desc);
|
|
const cy = node.y + node.h / 2;
|
|
|
|
// nodos izquierda → burbuja a la derecha
|
|
// nodos derecha → burbuja a la izquierda
|
|
// nodos centro → burbuja a la derecha
|
|
const showLeft = node.x > 160; // solo los nodos de la derecha van a la izquierda
|
|
|
|
let tx: number, ty: number, tailPoints: string;
|
|
|
|
if (showLeft) {
|
|
tx = node.x - TOOLTIP_W - TAIL_H - 4;
|
|
ty = Math.min(Math.max(cy - TOOLTIP_H / 2, 4), 430 - TOOLTIP_H);
|
|
const tailCy = Math.min(Math.max(cy, ty + 16), ty + TOOLTIP_H - 16);
|
|
tailPoints = `${tx + TOOLTIP_W},${tailCy - TAIL_W / 2} ${tx + TOOLTIP_W},${tailCy + TAIL_W / 2} ${tx + TOOLTIP_W + TAIL_H},${tailCy}`;
|
|
} else {
|
|
tx = node.x + node.w + TAIL_H + 4;
|
|
ty = Math.min(Math.max(cy - TOOLTIP_H / 2, 4), 430 - TOOLTIP_H);
|
|
const tailCy = Math.min(Math.max(cy, ty + 16), ty + TOOLTIP_H - 16);
|
|
tailPoints = `${tx},${tailCy - TAIL_W / 2} ${tx},${tailCy + TAIL_W / 2} ${tx - TAIL_H},${tailCy}`;
|
|
}
|
|
|
|
return (
|
|
<g style={{ pointerEvents: 'none' }}>
|
|
<g filter="url(#bubble-shadow)">
|
|
<rect x={tx} y={ty} width={TOOLTIP_W} height={TOOLTIP_H} fill={tooltipBg} rx={12} />
|
|
<polygon points={tailPoints} fill={tooltipBg} />
|
|
</g>
|
|
<foreignObject x={tx} y={ty} width={TOOLTIP_W} height={TOOLTIP_H} style={{ overflow: 'visible' }}>
|
|
<div style={{ padding: '10px 13px' }}>
|
|
<p style={{ fontFamily: "'SF Mono',Consolas,monospace", fontSize: 8, letterSpacing: '0.12em', textTransform: 'uppercase', color: RED, marginBottom: 4, fontWeight: 600 }}>
|
|
{activeDesc.label}
|
|
</p>
|
|
<p style={{ fontSize: 10.5, color: descText, lineHeight: 1.5, margin: 0 }}>
|
|
{activeDesc.desc}
|
|
</p>
|
|
</div>
|
|
</foreignObject>
|
|
</g>
|
|
);
|
|
})()}
|
|
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|