// Reusable bits + service icons
const sfT = (lang, en, zh) => lang === 'en' ? en : zh;
// Service icon set (Lucide-style, 1.5 stroke, currentColor)
const SF_ICONS = {
cabling: ,
fiber: ,
wifi: ,
iptel: ,
cctv: ,
access: ,
iot: ,
serverroom: ,
fault: ,
hypervisor: ,
hci: ,
cyber: ,
soc: ,
sraa: ,
managed: ,
erp: ,
web: ,
app: ,
chatbot: ,
localllm: ,
workflow: ,
};
// ── Shared animated particle-network canvas ──────────────────────────────────
// Drop as the first child of any position:relative section.
// The canvas is position:absolute, inset:0, pointer-events:none so it never
// blocks clicks. Give sibling content position:relative; zIndex:1 to float above it.
//
// Props:
// accent — hub-node / edge-highlight color (default: '#E36918')
// opacity — canvas element opacity (default: 0.15)
// inverse — true on dark/navy sections → uses light node colour
const SFNetworkCanvas = ({ accent = '#E36918', opacity = 0.5, inverse = false }) => {
const canvasRef = React.useRef(null);
const frameRef = React.useRef(null);
const nodesRef = React.useRef(null);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W = 0, H = 0;
const LABELS = [
'CORE-SW','EDGE-01','EDGE-02','NGFW','AP-1F','AP-2F','AP-3F',
'NVR','SRV-01','SRV-02','UPS','PDU','VPN','SIEM','EDR',
'CAM-01','CAM-02','PBX','VLAN10','VLAN20','BGP','OSPF',
'RADIUS','LDAP','DNS','NTP','SNMP','LLDP','QoS','PoE',
];
const CONNECT_DIST = 170;
const SPEED = 0.26;
const NODE_COUNT = 38;
const makeNode = (i) => ({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * SPEED,
vy: (Math.random() - 0.5) * SPEED,
r: Math.random() < 0.16 ? 3.8 : 2,
label: Math.random() < 0.3 ? LABELS[i % LABELS.length] : null,
});
const resize = () => {
W = canvas.offsetWidth;
H = canvas.offsetHeight;
if (!W || !H) return;
canvas.width = W * devicePixelRatio;
canvas.height = H * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
if (!nodesRef.current) {
nodesRef.current = Array.from({ length: NODE_COUNT }, (_, i) => makeNode(i));
} else {
// Keep existing nodes; clamp positions
for (const n of nodesRef.current) {
n.x = Math.min(n.x, W);
n.y = Math.min(n.y, H);
}
}
};
const nodeColor = inverse ? 'rgba(255,255,255,0.55)' : (() => {
const s = getComputedStyle(document.documentElement);
return s.getPropertyValue('--fg-muted').trim() || '#56627A';
})();
const draw = () => {
ctx.clearRect(0, 0, W, H);
const nodes = nodesRef.current;
if (!nodes) { frameRef.current = requestAnimationFrame(draw); return; }
for (const n of nodes) {
n.x += n.vx; n.y += n.vy;
if (n.x < 0 || n.x > W) { n.vx *= -1; n.x = Math.max(0, Math.min(W, n.x)); }
if (n.y < 0 || n.y > H) { n.vy *= -1; n.y = Math.max(0, Math.min(H, n.y)); }
}
// Edges
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[i].x - nodes[j].x;
const dy = nodes[i].y - nodes[j].y;
const d = Math.sqrt(dx*dx + dy*dy);
if (d < CONNECT_DIST) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.strokeStyle = nodeColor;
ctx.globalAlpha = (1 - d / CONNECT_DIST) * 0.55;
ctx.lineWidth = 0.65;
ctx.stroke();
}
}
}
// Nodes + labels
for (const n of nodes) {
const isHub = n.r > 2.5;
ctx.beginPath();
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
ctx.fillStyle = isHub ? accent : nodeColor;
ctx.globalAlpha = isHub ? 0.75 : 0.45;
ctx.fill();
if (isHub) {
ctx.beginPath();
ctx.arc(n.x, n.y, n.r + 4, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.globalAlpha = 0.22;
ctx.lineWidth = 0.9;
ctx.stroke();
}
if (n.label) {
ctx.font = '500 8.5px "JetBrains Mono", monospace';
ctx.fillStyle = nodeColor;
ctx.globalAlpha = 0.5;
ctx.fillText(n.label, n.x + n.r + 4, n.y + 3);
}
}
ctx.globalAlpha = 1;
frameRef.current = requestAnimationFrame(draw);
};
const ro = new ResizeObserver(resize);
ro.observe(canvas);
resize();
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { frameRef.current = requestAnimationFrame(draw); }
else { cancelAnimationFrame(frameRef.current); }
});
io.observe(canvas);
return () => {
cancelAnimationFrame(frameRef.current);
ro.disconnect();
io.disconnect();
};
}, [accent, inverse]);
return (
);
};
// Inline SVG logo — auto-adapts to dark/light mode via data-theme attribute
const SFLogo = ({ height = 40 }) => {
const uid = React.useId().replace(/[^a-z0-9]/gi, '');
const gid = 'sfg' + uid;
const [dark, setDark] = React.useState(
() => document.documentElement.dataset.theme === 'dark'
);
React.useEffect(() => {
const mo = new MutationObserver(() =>
setDark(document.documentElement.dataset.theme === 'dark')
);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => mo.disconnect();
}, []);
const navy = dark ? '#FFFFFF' : '#0F2F54';
const slate = dark ? 'rgba(255,255,255,0.7)' : '#1B3A65';
return (
);
};
window.sfT = sfT;
window.SF_ICONS = SF_ICONS;
window.SFNetworkCanvas = SFNetworkCanvas;
window.SFLogo = SFLogo;