/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes — Font Awesome 6 Free.
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
dans le
. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
);
};
/* ============================================================
Tooltip — apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
{children}
{show && (
{label}
)}
);
}
/* ============================================================
IconButton — bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
);
}
/* ============================================================
Toggle on/off — switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
{icon && }
{label && {label} }
onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
);
}
/* ============================================================
Status LED — pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
)}
>
);
}
/* ============================================================
BatteryGauge — jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
{(icon || label) && (
{icon && }
{label && {label} }
)}
{value}{unit}
);
}
return (
{label && (
{label}
{value}{unit}
)}
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
);
}
/* ============================================================
RadialGauge — jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
{/* arc background */}
{/* arc value glow */}
{/* arc value crisp */}
{value}%
{label &&
{label}
}
);
}
/* ============================================================
BigRadialGauge — la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return ;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return {v} ;
})}
{/* arc bg */}
{/* arc value glow */}
{/* arc value */}
{/* needle */}
);
}
/* ============================================================
Popup — modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
{children}
{footer && (
{footer}
)}
);
}
/* ============================================================
Button — bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
{icon && }
{children}
);
}
/* ============================================================
TreeNav — arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
{groups.map(g => (
setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
{g.label}
{g.count != null && (
{g.count}
)}
{open[g.id] && (
{g.children.map(c => {
const active = c.id === activeId;
return (
onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
{c.label}
{c.meta && {c.meta} }
);
})}
)}
))}
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
);
}
/* ============================================================
LineChart — grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
{v}
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
{lb}
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
);
})}
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();