diff --git a/index.html b/index.html index 5fda17c..5fc3e6c 100644 --- a/index.html +++ b/index.html @@ -1,241 +1,13 @@ - - - - - -Роза показателей - - - - - - -
- - + + + + + + + rose-chart + + + +
+ diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a2a807 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..f7a5c4e --- /dev/null +++ b/src/App.jsx @@ -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 ( + + + {GROUPS.map(g => { + const dom = dominant(g); + const c = COLORS[dom]; + return ( + + + + + ); + })} + + + {/* 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 ; + })} + + {/* axes */} + {CATS.map((cat,i) => { + const [x,y] = polar(i*step, maxR, cx, cy); + return ; + })} + + {/* 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 ( + onSelect(isHl ? null : g)} style={{cursor:'pointer'}}> + + {isHl && pts.map((p,i) => ( + + ))} + + ); + })} + + {/* axis labels */} + {CATS.map((cat,i) => { + const [x,y] = polar(i*step, maxR+26, cx, cy); + return ( + + {LABELS[cat]} + + ); + })} + + {/* ring numbers */} + {rings.map(r => { + const [x,y] = polar(0, (r/MAX_VAL)*maxR, cx, cy); + return {r}; + })} + + {/* center dot */} + + + ); +} + +function StatBar({ g }) { + const total = CATS.reduce((s,c) => s+DATA[g][c], 0); + if (total === 0) return
нет активности
; + return ( +
+ {CATS.map(cat => { + const v = DATA[g][cat]; + const pct = total > 0 ? (v/MAX_VAL)*100 : 0; + return ( +
+
{LABELS[cat]}
+
+
+
+
{v}
+
+ ); + })} +
+ ); +} + +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 ( +
+ + + {/* Header */} +
+
+ Групповой анализ +
+

+ РОЗА АКТИВНОСТИ +

+
+ №1 – №11 · 4 НАПРАВЛЕНИЯ +
+
+ + {/* Legend */} +
+ {CATS.map(cat => ( +
+
+ {LABELS[cat]} +
+ ))} +
+ +
+ + {/* Radar */} +
+ + + {/* Highlighted info */} +
+ {highlighted ? ( + <> +
+ {highlighted} + сумма: {hlTotal} +
+ + + ) : ( +
+ Нажми на группу для деталей +
+ )} +
+
+ + {/* Right panel */} +
+ + {/* Group list */} +
+ Группы +
+
+ {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 ( +
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, + }}> +
+ {g} + {total} +
+ ); + })} +
+ + {/* Category totals */} +
+
+ Итого по направлениям +
+ {totals.map(({cat,val}) => ( +
+
+ {LABELS[cat]} +
+
+
+ {val} +
+ ))} +
+ + {/* Heat summary */} +
+
+ Тепловая карта +
+
+
+ {CATS.map(c => ( +
{c.slice(0,3).toUpperCase()}
+ ))} + {GROUPS.map(g => ( + <> +
{g}
+ {CATS.map(cat => { + const v = DATA[g][cat]; + const alpha = v/MAX_VAL; + return ( +
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||'·'} +
+ ); + })} + + ))} +
+
+
+
+ +
+ НАЖМИ НА ГРУППУ · ВЫДЕЛИ НА РАДАРЕ +
+
+ ); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..3d9da8a --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})