This commit is contained in:
2026-02-28 09:21:29 +03:00
parent c0d1b32aa1
commit c3b07fa856
5 changed files with 399 additions and 240 deletions

View File

@@ -1,241 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>Роза показателей</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script> <title>rose-chart</title>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script> <script type="module" crossorigin src="/assets/index-BCX11ga0.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.2/babel.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d0d1a; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="text/babel">
const RAW_DATA = {
N1: { интер: 3, поток: 1, чист: 0, сеть: 1 },
N7: { интер: 0, поток: 1, чист: 0, сеть: 0 },
N9: { интер: 1, поток: 3, чист: 1, сеть: 0 },
N6: { интер: 2, поток: 3, чист: 2, сеть: 1 },
N3: { интер: 1, поток: 1, чист: 3, сеть: 0 },
N8: { интер: 0, поток: 2, чист: 2, сеть: 1 },
N2: { интер: 1, поток: 1, чист: 2, сеть: 0 },
N11: { интер: 1, поток: 1, чист: 2, сеть: 1 },
N5: { интер: 1, поток: 1, чист: 1, сеть: 2 },
N10: { интер: 2, поток: 1, чист: 0, сеть: 1 },
};
const CATEGORIES = ["интер", "поток", "чист", "сеть"];
const CAT_LABELS = { интер: "Интер", поток: "Поток", чист: "Чист трод", сеть: "Сеть" };
const CAT_COLORS = { интер: "#ff6b6b", поток: "#ffd93d", чист: "#6bcb77", сеть: "#4d96ff" };
const PARTICIPANTS = Object.keys(RAW_DATA);
function polarToXY(angle, r, cx, cy) {
const rad = (angle - 90) * (Math.PI / 180);
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
function RadarChart({ highlighted, onSelect }) {
const size = 340, cx = 170, cy = 170, maxR = 122, maxVal = 3;
const angleStep = 90;
const rings = [1, 2, 3];
const axes = CATEGORIES.map((cat, i) => {
const [x2, y2] = polarToXY(i * angleStep, maxR, cx, cy);
return { cat, i, x2, y2 };
});
return (
<svg width={size} height={size} style={{display:"block", cursor:"pointer"}}>
{rings.map(ring => {
const pts = CATEGORIES.map((_, i) => polarToXY(i * angleStep, (ring/maxVal)*maxR, cx, cy));
const d = pts.map((pt,i) => (i===0?`M${pt[0]},${pt[1]}`:`L${pt[0]},${pt[1]}`)).join(" ")+" Z";
return <path key={ring} d={d} fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth={1}/>;
})}
{axes.map(({cat,x2,y2}) => (
<line key={cat} x1={cx} y1={cy} x2={x2} y2={y2} stroke="rgba(255,255,255,0.15)" strokeWidth={1}/>
))}
{PARTICIPANTS.map(p => {
const isHl = highlighted === p;
const dominant = CATEGORIES.reduce((a,b) => RAW_DATA[p][a]>=RAW_DATA[p][b]?a:b);
const color = CAT_COLORS[dominant];
const pts = CATEGORIES.map((cat,i) => {
const r = (RAW_DATA[p][cat]/maxVal)*maxR;
return polarToXY(i*angleStep, r, cx, cy);
});
const d = pts.map((pt,i)=>(i===0?`M${pt[0]},${pt[1]}`:`L${pt[0]},${pt[1]}`)).join(" ")+" Z";
return (
<path key={p} d={d}
fill={color} fillOpacity={isHl?0.55:highlighted?0.04:0.18}
stroke={color} strokeWidth={isHl?2.5:highlighted?0.5:1}
strokeOpacity={isHl?1:highlighted?0.25:0.6}
onClick={()=>onSelect(isHl?null:p)}
style={{transition:"all 0.25s"}}
/>
);
})}
{highlighted && CATEGORIES.map((cat,i) => {
const r = (RAW_DATA[highlighted][cat]/maxVal)*maxR;
const [x,y] = polarToXY(i*angleStep, r, cx, cy);
return <circle key={i} cx={x} cy={y} r={5} fill={CAT_COLORS[cat]} stroke="#0d0d1a" strokeWidth={1.5}/>;
})}
{axes.map(({cat,i}) => {
const [lx,ly] = polarToXY(i*angleStep, maxR+26, cx, cy);
return (
<text key={cat} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle"
fill={CAT_COLORS[cat]} fontSize={12} fontFamily="'Courier New',monospace" fontWeight="bold">
{CAT_LABELS[cat]}
</text>
);
})}
{rings.map(ring => (
<text key={ring} x={cx+4} y={cy-(ring/maxVal)*maxR+4}
fill="rgba(255,255,255,0.3)" fontSize={9} fontFamily="monospace">{ring}</text>
))}
</svg>
);
}
function App() {
const [highlighted, setHighlighted] = React.useState(null);
const [view, setView] = React.useState("radar");
const totals = CATEGORIES.map(cat => ({
cat, val: PARTICIPANTS.reduce((s,p)=>s+RAW_DATA[p][cat],0)
}));
const maxTotal = Math.max(...totals.map(t=>t.val));
return (
<div style={{minHeight:"100vh",background:"#0d0d1a",fontFamily:"'Courier New',monospace",
color:"#e0e0f0",display:"flex",flexDirection:"column",alignItems:"center",padding:"32px 16px"}}>
<div style={{textAlign:"center",marginBottom:28}}>
<div style={{fontSize:11,letterSpacing:6,color:"#4d96ff",textTransform:"uppercase",marginBottom:6}}>
Анализ активности
</div>
<h1 style={{margin:0,fontSize:28,fontWeight:900,letterSpacing:2,color:"#fff"}}>
РОЗА ПОКАЗАТЕЛЕЙ
</h1>
<div style={{marginTop:8,fontSize:12,color:"rgba(255,255,255,0.4)"}}>N1 N11 · 4 категории</div>
</div>
<div style={{display:"flex",gap:0,marginBottom:28,border:"1px solid rgba(255,255,255,0.15)",
borderRadius:8,overflow:"hidden"}}>
{["radar","bar"].map(v => (
<button key={v} onClick={()=>setView(v)} style={{
padding:"8px 24px",background:view===v?"#4d96ff":"transparent",
border:"none",color:view===v?"#fff":"rgba(255,255,255,0.5)",
cursor:"pointer",fontFamily:"monospace",fontSize:13,
fontWeight:view===v?700:400,transition:"all 0.2s"}}>
{v==="radar"?"🕸 Радар":"📊 Столбцы"}
</button>
))}
</div>
<div style={{display:"flex",flexWrap:"wrap",gap:32,justifyContent:"center",
alignItems:"flex-start",width:"100%",maxWidth:900}}>
<div style={{background:"rgba(255,255,255,0.03)",borderRadius:20,
border:"1px solid rgba(255,255,255,0.08)",padding:20,
display:"flex",flexDirection:"column",alignItems:"center"}}>
{view==="radar" ? (
<RadarChart highlighted={highlighted} onSelect={setHighlighted}/>
) : (
<div style={{width:340,padding:"20px 10px"}}>
{PARTICIPANTS.map(p => {
const total = CATEGORIES.reduce((s,c)=>s+RAW_DATA[p][c],0);
const isHl = highlighted===p;
return (
<div key={p} onClick={()=>setHighlighted(isHl?null:p)}
style={{display:"flex",alignItems:"center",gap:10,marginBottom:10,
cursor:"pointer",opacity:highlighted&&!isHl?0.35:1,transition:"opacity 0.2s"}}>
<div style={{width:30,fontSize:11,color:isHl?"#fff":"rgba(255,255,255,0.6)",fontWeight:isHl?700:400}}>{p}</div>
<div style={{flex:1,display:"flex",height:18,borderRadius:4,overflow:"hidden"}}>
{CATEGORIES.map(cat => {
const w = total>0?(RAW_DATA[p][cat]/total)*100:0;
return <div key={cat} style={{width:`${w}%`,background:CAT_COLORS[cat],transition:"width 0.3s"}}
title={`${CAT_LABELS[cat]}: ${RAW_DATA[p][cat]}`}/>;
})}
</div>
<div style={{width:18,fontSize:11,color:"rgba(255,255,255,0.4)",textAlign:"right"}}>{total}</div>
</div>
);
})}
</div>
)}
<div style={{display:"flex",gap:14,marginTop:12,flexWrap:"wrap",justifyContent:"center"}}>
{CATEGORIES.map(cat => (
<div key={cat} style={{display:"flex",alignItems:"center",gap:5,fontSize:11}}>
<div style={{width:10,height:10,borderRadius:"50%",background:CAT_COLORS[cat]}}/>
<span style={{color:"rgba(255,255,255,0.6)"}}>{CAT_LABELS[cat]}</span>
</div>
))}
</div>
</div>
<div style={{display:"flex",flexDirection:"column",gap:6,minWidth:200}}>
<div style={{fontSize:10,letterSpacing:4,color:"rgba(255,255,255,0.3)",marginBottom:8,textTransform:"uppercase"}}>
Участники
</div>
{PARTICIPANTS.map(p => {
const total = CATEGORIES.reduce((s,c)=>s+RAW_DATA[p][c],0);
const dominant = CATEGORIES.reduce((a,b)=>RAW_DATA[p][a]>=RAW_DATA[p][b]?a:b);
const isHl = highlighted===p;
const col = CAT_COLORS[dominant];
return (
<div key={p} onClick={()=>setHighlighted(isHl?null:p)} style={{
display:"flex",alignItems:"center",gap:12,padding:"8px 14px",
borderRadius:10,cursor:"pointer",
background:isHl?`rgba(${parseInt(col.slice(1,3),16)},${parseInt(col.slice(3,5),16)},${parseInt(col.slice(5,7),16)},0.2)`:"rgba(255,255,255,0.03)",
border:`1px solid ${isHl?col:"rgba(255,255,255,0.06)"}`,
transition:"all 0.2s",opacity:highlighted&&!isHl?0.4:1}}>
<div style={{width:8,height:8,borderRadius:"50%",background:col,
boxShadow:isHl?`0 0 8px ${col}`:"none"}}/>
<span style={{fontWeight:isHl?700:400,fontSize:14,flex:1}}>{p}</span>
<div style={{display:"flex",gap:3,alignItems:"flex-end",height:28}}>
{CATEGORIES.map(cat => (
<div key={cat} style={{width:8,height:Math.max((RAW_DATA[p][cat]/3)*28,2),
background:CAT_COLORS[cat],borderRadius:2}}
title={`${CAT_LABELS[cat]}: ${RAW_DATA[p][cat]}`}/>
))}
</div>
<span style={{fontSize:11,color:"rgba(255,255,255,0.35)",minWidth:16,textAlign:"right"}}>{total}</span>
</div>
);
})}
<div style={{marginTop:16,padding:"12px 14px",borderRadius:10,
background:"rgba(255,255,255,0.04)",border:"1px solid rgba(255,255,255,0.08)"}}>
<div style={{fontSize:10,letterSpacing:3,color:"rgba(255,255,255,0.3)",marginBottom:8,textTransform:"uppercase"}}>
Итого по категориям
</div>
{totals.map(({cat,val}) => (
<div key={cat} style={{display:"flex",alignItems:"center",gap:8,marginBottom:6}}>
<div style={{width:8,height:8,borderRadius:"50%",background:CAT_COLORS[cat]}}/>
<span style={{fontSize:12,flex:1,color:"rgba(255,255,255,0.7)"}}>{CAT_LABELS[cat]}</span>
<div style={{width:80,height:6,background:"rgba(255,255,255,0.08)",borderRadius:3,overflow:"hidden"}}>
<div style={{width:`${(val/maxTotal)*100}%`,height:"100%",background:CAT_COLORS[cat],borderRadius:3}}/>
</div>
<span style={{fontSize:12,color:CAT_COLORS[cat],fontWeight:700,minWidth:20,textAlign:"right"}}>{val}</span>
</div>
))}
</div>
</div>
</div>
<div style={{marginTop:32,fontSize:11,color:"rgba(255,255,255,0.2)",textAlign:"center"}}>
Нажми на участника чтобы выделить · Переключай вид выше
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body> </body>
</html> </html>

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "rose-chart",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.3.1"
}
}

344
src/App.jsx Normal file
View File

@@ -0,0 +1,344 @@
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.25)',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.3)',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.2)',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.2)',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.2)',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.15)',letterSpacing:2}}>
НАЖМИ НА ГРУППУ · ВЫДЕЛИ НА РАДАРЕ
</div>
</div>
);
}

9
src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

7
vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})