/
sweetebin
/
hackaton_26
Обзор
Документация
Войти
/
sweetebin
/
hackaton_26
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
web-form-project/src/components/ClustersView.jsx
169 строк
7 KB
sweetebin
Трассировка до первоисточника в UI + CLI-инструментарий решений для агента
28 июл 2026, 17:22
28 июл 2026, 17:22
b4de763
Код
Авторство
О чём код?
import { useState } from 'react'; import { Box, Card, CardActionArea, CardContent, Chip, Collapse, Paper, Stack, Tooltip, Typography, } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import { SENTIMENT, clusterColor } from '../theme'; const KEYWORD_HINTS = [ [/^score /, 'Приоритет потребности: частота × эмоция × охват сегментов, нормирован к 100 у лидера.'], [/^оба источника$/, 'Потребность подтверждена и интервью, и обращениями в поддержку — самый сильный сигнал.'], ]; // Кластер из 1–2 сигналов — «хвост»: после смягчения промптов их стало много, // карточками они топят крупные потребности. Показываем компактным списком. const MINOR_MAX_SIGNALS = 2; const MIN_MINORS_TO_GROUP = 4; function keywordHint(kw) { const found = KEYWORD_HINTS.find(([re]) => re.test(kw)); return found ? found[1] : ''; } function ClusterCard({ cluster, clusters, selected, dimmed, onSelectCluster }) { const color = clusterColor(clusters, cluster.id); const sentiment = SENTIMENT[cluster.sentiment] || SENTIMENT.neutral; return ( <Card variant="outlined" sx={{ borderColor: selected ? color : 'divider', borderWidth: selected ? 2 : 1, opacity: dimmed ? 0.55 : 1, }} > <CardActionArea title={selected ? 'Снять выделение' : 'Выделить сигналы кластера на семантической карте'} onClick={() => onSelectCluster(selected ? null : cluster.id)} sx={{ height: '100%', alignItems: 'stretch' }} > <CardContent> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> <Box sx={{ width: 12, height: 12, borderRadius: '50%', bgcolor: color, flexShrink: 0, }} /> <Typography variant="subtitle1" fontWeight={600}> {cluster.name} </Typography> </Stack> <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> {cluster.summary} </Typography> <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap"> <Tooltip title="Сколько сигналов обратной связи склеилось в эту потребность."> <Chip size="small" label={`Сигналов: ${cluster.signalCount}`} /> </Tooltip> <Tooltip title="Тональность по средней эмоции сигналов кластера."> <Chip size="small" color={sentiment.chip} label={sentiment.label} /> </Tooltip> {cluster.keywords.map((kw) => ( <Tooltip key={kw} title={keywordHint(kw)}> <Chip size="small" variant="outlined" label={kw} /> </Tooltip> ))} </Stack> </CardContent> </CardActionArea> </Card> ); } export default function ClustersView({ clusters, selectedClusterId, onSelectCluster }) { const [minorsOpen, setMinorsOpen] = useState(false); const minorsRaw = clusters.filter((c) => c.signalCount <= MINOR_MAX_SIGNALS); const grouped = minorsRaw.length >= MIN_MINORS_TO_GROUP; const majors = grouped ? clusters.filter((c) => c.signalCount > MINOR_MAX_SIGNALS) : clusters; const minors = grouped ? minorsRaw : []; // выбранный на карте мелкий кластер не должен прятаться в свёрнутом хвосте const expanded = minorsOpen || minors.some((c) => c.id === selectedClusterId); return ( <Box> <Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, }} > {majors.map((cluster) => ( <ClusterCard key={cluster.id} cluster={cluster} clusters={clusters} selected={selectedClusterId === cluster.id} dimmed={selectedClusterId && selectedClusterId !== cluster.id} onSelectCluster={onSelectCluster} /> ))} </Box> {minors.length > 0 && ( <Paper variant="outlined" sx={{ mt: 2 }}> <Stack direction="row" alignItems="center" spacing={1} onClick={() => setMinorsOpen(!expanded)} sx={{ px: 2, py: 1.2, cursor: 'pointer', userSelect: 'none' }} > {expanded ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} <Typography variant="subtitle2" sx={{ flexGrow: 1 }}> Мелкие потребности: {minors.length} </Typography> <Tooltip title="Кластеры из 1–2 сигналов: единичные упоминания, до топа приоритета не дотягивают, но не потеряны — каждый кликабелен и виден на карте."> <Typography variant="caption" color="text.secondary" sx={{ cursor: 'help' }}> по 1–2 сигнала </Typography> </Tooltip> </Stack> <Collapse in={expanded} unmountOnExit> <Box sx={{ px: 2, pb: 1.5, display: 'flex', flexWrap: 'wrap', gap: 1 }}> {minors.map((cluster) => { const selected = selectedClusterId === cluster.id; const color = clusterColor(clusters, cluster.id); return ( <Tooltip key={cluster.id} title={cluster.summary || ''}> <Chip variant={selected ? 'filled' : 'outlined'} onClick={() => onSelectCluster(selected ? null : cluster.id)} icon={ <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, ml: '8px !important', }} /> } label={`${cluster.name} · ${cluster.signalCount}`} sx={{ borderColor: selected ? color : undefined }} /> </Tooltip> ); })} </Box> </Collapse> </Paper> )} </Box> ); }