Files
fymio.us/src/App.jsx
2026-02-28 11:18:08 +03:00

345 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react";
const DATA = {
"№1": { интер: 3, поток: 1, сети: 1, инст: 0 },
"№2": { интер: 1, поток: 1, сети: 0, инст: 2 },
"№3": { интер: 1, поток: 1, сети: 3, инст: 0 },
"№4": { интер: 0, поток: 0, сети: 0, инст: 0 },
"№5": { интер: 1, поток: 1, сети: 2, инст: 1 },
"№6": { интер: 2, поток: 2, сети: 1, инст: 2 },
"№7": { интер: 0, поток: 1, сети: 0, инст: 0 },
"№8": { интер: 0, поток: 2, сети: 1, инст: 2 },
"№9": { интер: 1, поток: 3, сети: 0, инст: 1 },
"№10": { интер: 2, поток: 1, сети: 1, инст: 0 },
"№11": { интер: 1, поток: 1, сети: 1, инст: 2 },
};
const CATS = ["интер", "поток", "сети", "инст"];
const LABELS = { интер: "Интеракция", поток: "Потоки", сети: "Сети", инст: "Институц." };
const COLORS = { интер: "#f97316", поток: "#06b6d4", сети: "#a3e635", инст: "#e879f9" };
const GROUPS = Object.keys(DATA);
const MAX_VAL = 3;
function polar(angleDeg, r, cx, cy) {
const rad = (angleDeg - 90) * Math.PI / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `${r},${g},${b}`;
}
function dominant(g) {
return CATS.reduce((a,b) => DATA[g][a] >= DATA[g][b] ? a : b);
}
function RadarChart({ highlighted, onSelect }) {
const S = 360, cx = 180, cy = 180, maxR = 130;
const step = 360 / CATS.length;
const rings = [1,2,3];
return (
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`}>
<defs>
{GROUPS.map(g => {
const dom = dominant(g);
const c = COLORS[dom];
return (
<radialGradient key={g} id={`grad-${g.replace('№','n')}`} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={c} stopOpacity="0.7"/>
<stop offset="100%" stopColor={c} stopOpacity="0.05"/>
</radialGradient>
);
})}
</defs>
{/* rings */}
{rings.map(r => {
const pts = CATS.map((_,i) => polar(i*step, (r/MAX_VAL)*maxR, cx, cy));
const d = pts.map((p,i) => `${i===0?'M':'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ') + ' Z';
return <path key={r} d={d} fill="none" stroke="rgba(255,255,255,0.07)" strokeWidth={r===MAX_VAL?1.5:1} strokeDasharray={r===MAX_VAL?"":"4,4"}/>;
})}
{/* axes */}
{CATS.map((cat,i) => {
const [x,y] = polar(i*step, maxR, cx, cy);
return <line key={cat} x1={cx} y1={cy} x2={x} y2={y} stroke="rgba(255,255,255,0.12)" strokeWidth={1}/>;
})}
{/* group polygons */}
{GROUPS.map(g => {
const isHl = highlighted === g;
const isOther = highlighted && !isHl;
const dom = dominant(g);
const col = COLORS[dom];
const pts = CATS.map((cat,i) => {
const r = DATA[g][cat] === 0 ? 4 : (DATA[g][cat]/MAX_VAL)*maxR;
return polar(i*step, r, cx, cy);
});
const d = pts.map((p,i) => `${i===0?'M':'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ') + ' Z';
const gId = g.replace('№','n');
return (
<g key={g} onClick={() => onSelect(isHl ? null : g)} style={{cursor:'pointer'}}>
<path d={d}
fill={isHl ? `url(#grad-${gId})` : col}
fillOpacity={isHl ? 1 : isOther ? 0.03 : 0.15}
stroke={col}
strokeWidth={isHl ? 2.5 : isOther ? 0.5 : 1.2}
strokeOpacity={isHl ? 1 : isOther ? 0.2 : 0.7}
style={{transition:'all 0.3s ease'}}
/>
{isHl && pts.map((p,i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={5}
fill={COLORS[CATS[i]]} stroke="#0a0a14" strokeWidth={2}
style={{filter:`drop-shadow(0 0 6px ${COLORS[CATS[i]]})`}}
/>
))}
</g>
);
})}
{/* axis labels */}
{CATS.map((cat,i) => {
const [x,y] = polar(i*step, maxR+26, cx, cy);
return (
<text key={cat} x={x} y={y} textAnchor="middle" dominantBaseline="middle"
fill={COLORS[cat]} fontSize={11} fontFamily="'DM Mono',monospace" fontWeight="600"
style={{filter:`drop-shadow(0 0 8px ${COLORS[cat]}80)`}}>
{LABELS[cat]}
</text>
);
})}
{/* ring numbers */}
{rings.map(r => {
const [x,y] = polar(0, (r/MAX_VAL)*maxR, cx, cy);
return <text key={r} x={cx+5} y={y+4} fill="rgba(255,255,255,0.25)" fontSize={9} fontFamily="monospace">{r}</text>;
})}
{/* center dot */}
<circle cx={cx} cy={cy} r={3} fill="rgba(255,255,255,0.3)"/>
</svg>
);
}
function StatBar({ g }) {
const total = CATS.reduce((s,c) => s+DATA[g][c], 0);
if (total === 0) return <div style={{fontSize:11,color:'rgba(255,255,255,0.2)',fontStyle:'italic'}}>нет активности</div>;
return (
<div style={{display:'flex',flexDirection:'column',gap:6,marginTop:8}}>
{CATS.map(cat => {
const v = DATA[g][cat];
const pct = total > 0 ? (v/MAX_VAL)*100 : 0;
return (
<div key={cat} style={{display:'flex',alignItems:'center',gap:8}}>
<div style={{width:70,fontSize:11,color:'rgba(255,255,255,0.5)',fontFamily:'DM Mono,monospace'}}>{LABELS[cat]}</div>
<div style={{flex:1,height:6,background:'rgba(255,255,255,0.06)',borderRadius:3,overflow:'hidden'}}>
<div style={{width:`${(v/MAX_VAL)*100}%`,height:'100%',background:COLORS[cat],
borderRadius:3,boxShadow:`0 0 8px ${COLORS[cat]}80`,transition:'width 0.5s ease'}}/>
</div>
<div style={{width:14,fontSize:12,color:COLORS[cat],fontWeight:700,textAlign:'right'}}>{v}</div>
</div>
);
})}
</div>
);
}
export default function App() {
const [highlighted, setHighlighted] = useState(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setTimeout(() => setMounted(true), 100);
}, []);
const totals = CATS.map(cat => ({
cat, val: GROUPS.reduce((s,g) => s+DATA[g][cat], 0)
}));
const maxTotal = Math.max(...totals.map(t=>t.val));
const hlData = highlighted ? DATA[highlighted] : null;
const hlTotal = hlData ? CATS.reduce((s,c)=>s+hlData[c],0) : 0;
return (
<div style={{
minHeight:'100vh', background:'#080810',
fontFamily:"'DM Mono', 'Courier New', monospace",
color:'#e8e8f0',
display:'flex', flexDirection:'column', alignItems:'center',
padding:'40px 20px',
backgroundImage:'radial-gradient(ellipse at 20% 20%, rgba(249,115,22,0.05) 0%, transparent 60%), radial-gradient(ellipse at 80% 80%, rgba(6,182,212,0.05) 0%, transparent 60%)',
}}>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;600&family=Syne:wght@700;800&display=swap" rel="stylesheet"/>
{/* Header */}
<div style={{textAlign:'center',marginBottom:36,opacity:mounted?1:0,transform:mounted?'translateY(0)':'translateY(-20px)',transition:'all 0.6s ease'}}>
<div style={{fontSize:10,letterSpacing:8,color:'rgba(255,255,255,0.7)',textTransform:'uppercase',marginBottom:10}}>
Групповой анализ
</div>
<h1 style={{margin:0,fontSize:32,fontWeight:800,fontFamily:"Syne,sans-serif",
background:'linear-gradient(135deg, #f97316, #06b6d4, #a3e635)',
WebkitBackgroundClip:'text',WebkitTextFillColor:'transparent',letterSpacing:1}}>
РОЗА АКТИВНОСТИ
</h1>
<div style={{marginTop:8,fontSize:11,color:'rgba(255,255,255,0.75)',letterSpacing:2}}>
1 11 · 4 НАПРАВЛЕНИЯ
</div>
</div>
{/* Legend */}
<div style={{display:'flex',gap:20,marginBottom:32,flexWrap:'wrap',justifyContent:'center',
opacity:mounted?1:0,transition:'opacity 0.8s ease 0.2s'}}>
{CATS.map(cat => (
<div key={cat} style={{display:'flex',alignItems:'center',gap:7}}>
<div style={{width:8,height:8,borderRadius:'50%',background:COLORS[cat],
boxShadow:`0 0 8px ${COLORS[cat]}`}}/>
<span style={{fontSize:11,color:'rgba(255,255,255,0.55)',letterSpacing:1}}>{LABELS[cat]}</span>
</div>
))}
</div>
<div style={{display:'flex',flexWrap:'wrap',gap:28,justifyContent:'center',alignItems:'flex-start',width:'100%',maxWidth:940}}>
{/* Radar */}
<div style={{
background:'rgba(255,255,255,0.02)',borderRadius:24,
border:'1px solid rgba(255,255,255,0.07)',padding:24,
backdropFilter:'blur(10px)',
opacity:mounted?1:0,transform:mounted?'scale(1)':'scale(0.95)',
transition:'all 0.7s ease 0.1s',
display:'flex',flexDirection:'column',alignItems:'center',gap:16,
}}>
<RadarChart highlighted={highlighted} onSelect={setHighlighted}/>
{/* Highlighted info */}
<div style={{
minHeight:80,width:'100%',maxWidth:320,
background: highlighted ? `rgba(${hexToRgb(COLORS[dominant(highlighted)])},0.08)` : 'rgba(255,255,255,0.03)',
borderRadius:14,border:`1px solid ${highlighted ? COLORS[dominant(highlighted)]+'40' : 'rgba(255,255,255,0.06)'}`,
padding:'12px 16px',transition:'all 0.3s',
}}>
{highlighted ? (
<>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{fontSize:16,fontWeight:700,fontFamily:'Syne,sans-serif',color:COLORS[dominant(highlighted)]}}>{highlighted}</span>
<span style={{fontSize:11,color:'rgba(255,255,255,0.4)'}}>сумма: {hlTotal}</span>
</div>
<StatBar g={highlighted}/>
</>
) : (
<div style={{color:'rgba(255,255,255,0.2)',fontSize:12,textAlign:'center',paddingTop:16}}>
Нажми на группу для деталей
</div>
)}
</div>
</div>
{/* Right panel */}
<div style={{display:'flex',flexDirection:'column',gap:10,minWidth:220,
opacity:mounted?1:0,transform:mounted?'translateX(0)':'translateX(20px)',
transition:'all 0.7s ease 0.3s'}}>
{/* Group list */}
<div style={{fontSize:9,letterSpacing:5,color:'rgba(255,255,255,0.75)',marginBottom:4,textTransform:'uppercase'}}>
Группы
</div>
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{GROUPS.map((g,idx) => {
const isHl = highlighted===g;
const dom = dominant(g);
const col = COLORS[dom];
const total = CATS.reduce((s,c)=>s+DATA[g][c],0);
const isEmpty = total === 0;
return (
<div key={g} onClick={()=>setHighlighted(isHl?null:g)}
style={{
display:'flex',alignItems:'center',gap:8,
padding:'7px 12px',borderRadius:10,cursor:'pointer',
background: isHl ? `rgba(${hexToRgb(col)},0.18)` : 'rgba(255,255,255,0.03)',
border:`1px solid ${isHl ? col : 'rgba(255,255,255,0.07)'}`,
opacity: highlighted&&!isHl ? 0.4 : isEmpty ? 0.4 : 1,
transition:'all 0.2s',
boxShadow: isHl ? `0 0 16px ${col}40` : 'none',
minWidth:70,
}}>
<div style={{width:7,height:7,borderRadius:'50%',
background: isEmpty ? 'rgba(255,255,255,0.15)' : col,
boxShadow: isHl ? `0 0 8px ${col}` : 'none',
flexShrink:0}}/>
<span style={{fontSize:13,fontWeight:isHl?700:400,flex:1}}>{g}</span>
<span style={{fontSize:10,color:'rgba(255,255,255,0.3)'}}>{total}</span>
</div>
);
})}
</div>
{/* Category totals */}
<div style={{marginTop:12,padding:'16px',borderRadius:16,
background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{fontSize:9,letterSpacing:5,color:'rgba(255,255,255,0.75)',marginBottom:12,textTransform:'uppercase'}}>
Итого по направлениям
</div>
{totals.map(({cat,val}) => (
<div key={cat} style={{display:'flex',alignItems:'center',gap:10,marginBottom:10}}>
<div style={{width:8,height:8,borderRadius:'50%',background:COLORS[cat],
boxShadow:`0 0 6px ${COLORS[cat]}`,flexShrink:0}}/>
<span style={{fontSize:11,flex:1,color:'rgba(255,255,255,0.6)'}}>{LABELS[cat]}</span>
<div style={{width:90,height:5,background:'rgba(255,255,255,0.06)',borderRadius:3,overflow:'hidden'}}>
<div style={{width:`${(val/maxTotal)*100}%`,height:'100%',
background:`linear-gradient(90deg,${COLORS[cat]},${COLORS[cat]}aa)`,
borderRadius:3,boxShadow:`0 0 6px ${COLORS[cat]}60`}}/>
</div>
<span style={{fontSize:12,color:COLORS[cat],fontWeight:700,minWidth:18,textAlign:'right'}}>{val}</span>
</div>
))}
</div>
{/* Heat summary */}
<div style={{padding:'14px 16px',borderRadius:16,
background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{fontSize:9,letterSpacing:5,color:'rgba(255,255,255,0.75)',marginBottom:10,textTransform:'uppercase'}}>
Тепловая карта
</div>
<div style={{display:'grid',gridTemplateColumns:'auto repeat(4, 1fr)',gap:3,fontSize:10}}>
<div/>
{CATS.map(c => (
<div key={c} style={{textAlign:'center',color:COLORS[c],fontSize:9,letterSpacing:1}}>{c.slice(0,3).toUpperCase()}</div>
))}
{GROUPS.map(g => (
<>
<div key={g+'-label'} style={{color:'rgba(255,255,255,0.4)',paddingRight:4,lineHeight:'20px'}}>{g}</div>
{CATS.map(cat => {
const v = DATA[g][cat];
const alpha = v/MAX_VAL;
return (
<div key={cat} onClick={()=>setHighlighted(highlighted===g?null:g)}
style={{
height:20,borderRadius:4,cursor:'pointer',
background: v>0 ? `rgba(${hexToRgb(COLORS[cat])},${0.15+alpha*0.75})` : 'rgba(255,255,255,0.04)',
border: highlighted===g ? `1px solid ${COLORS[cat]}80` : '1px solid transparent',
display:'flex',alignItems:'center',justifyContent:'center',
fontSize:10,color:v>0?'rgba(255,255,255,0.9)':'rgba(255,255,255,0.15)',
fontWeight:700,transition:'all 0.2s',
}}>
{v||'·'}
</div>
);
})}
</>
))}
</div>
</div>
</div>
</div>
<div style={{marginTop:28,fontSize:10,color:'rgba(255,255,255,0.5)',letterSpacing:2}}>
НАЖМИ НА ГРУППУ · ВЫДЕЛИ НА РАДАРЕ
</div>
</div>
);
}