/
sweetebin
/
hackaton_26
Обзор
Документация
Войти
/
sweetebin
/
hackaton_26
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
web-form-project/src/components/SemanticMap.jsx
198 строк
7 KB
AKorostelev
web form project added
28 июл 2026, 10:57
28 июл 2026, 10:57
4339e77
Код
Авторство
О чём код?
import { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Chip, Paper, Stack, Typography } from '@mui/material'; import { clusterColor } from '../theme'; const W = 800; const H = 520; const PAD = 40; const MIN_ZOOM = 0.5; const MAX_ZOOM = 8; export default function SemanticMap({ signals, clusters, selectedClusterId, onSelectCluster, selectedSignalId, onSelectSignal, }) { const containerRef = useRef(null); const svgRef = useRef(null); const [view, setView] = useState({ k: 1, x: 0, y: 0 }); const [hover, setHover] = useState(null); // { signal, left, top } const drag = useRef(null); // Проекция координат эмбеддингов в пиксели SVG const points = useMemo(() => { if (!signals.length) return []; const xs = signals.map((s) => s.x); const ys = signals.map((s) => s.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const spanX = maxX - minX || 1; const spanY = maxY - minY || 1; return signals.map((s) => ({ signal: s, cx: PAD + ((s.x - minX) / spanX) * (W - 2 * PAD), cy: PAD + ((1 - (s.y - minY) / spanY) * (H - 2 * PAD)), })); }, [signals]); // Зум колесом — нативный обработчик, чтобы preventDefault не блокировался passive-слушателем useEffect(() => { const svg = svgRef.current; if (!svg) return undefined; const onWheel = (e) => { e.preventDefault(); const rect = svg.getBoundingClientRect(); const px = ((e.clientX - rect.left) / rect.width) * W; const py = ((e.clientY - rect.top) / rect.height) * H; setView((v) => { const k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.k * Math.exp(-e.deltaY * 0.002))); const ratio = k / v.k; return { k, x: px - (px - v.x) * ratio, y: py - (py - v.y) * ratio }; }); }; svg.addEventListener('wheel', onWheel, { passive: false }); return () => svg.removeEventListener('wheel', onWheel); }, []); const toSvgUnits = (e) => { const rect = svgRef.current.getBoundingClientRect(); return { px: ((e.clientX - rect.left) / rect.width) * W, py: ((e.clientY - rect.top) / rect.height) * H, }; }; const onPointerDown = (e) => { const { px, py } = toSvgUnits(e); drag.current = { px, py, moved: false }; e.currentTarget.setPointerCapture(e.pointerId); }; const onPointerMove = (e) => { if (!drag.current) return; const { px, py } = toSvgUnits(e); const dx = px - drag.current.px; const dy = py - drag.current.py; if (Math.abs(dx) + Math.abs(dy) > 2) drag.current.moved = true; drag.current.px = px; drag.current.py = py; setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy })); }; const onPointerUp = () => { drag.current = null; }; const showTooltip = (e, signal) => { const rect = containerRef.current.getBoundingClientRect(); setHover({ signal, left: e.clientX - rect.left + 12, top: e.clientY - rect.top + 12 }); }; return ( <Paper elevation={0} variant="outlined" sx={{ p: 2 }}> <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ mb: 1.5 }}> {clusters.map((c) => { const color = clusterColor(clusters, c.id); const selected = selectedClusterId === c.id; return ( <Chip key={c.id} size="small" variant={selected ? 'filled' : 'outlined'} onClick={() => onSelectCluster(selected ? null : c.id)} icon={ <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: color, ml: '6px !important' }} /> } label={`${c.name} (${c.signalCount})`} /> ); })} </Stack> <Box ref={containerRef} sx={{ position: 'relative' }}> <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', display: 'block', background: '#fcfcfb', borderRadius: 8, cursor: drag.current ? 'grabbing' : 'grab', touchAction: 'none', }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onDoubleClick={() => setView({ k: 1, x: 0, y: 0 })} > <g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}> {points.map(({ signal, cx, cy }) => { const color = clusterColor(clusters, signal.clusterId); const dimmed = selectedClusterId && signal.clusterId !== selectedClusterId; const isSelected = selectedSignalId === signal.id; return ( <circle key={signal.id} cx={cx} cy={cy} r={(isSelected ? 8 : 6) / view.k} fill={color} stroke={isSelected ? '#0b0b0b' : '#ffffff'} strokeWidth={2 / view.k} opacity={dimmed ? 0.15 : 0.9} style={{ cursor: 'pointer' }} onPointerEnter={(e) => showTooltip(e, signal)} onPointerMove={(e) => showTooltip(e, signal)} onPointerLeave={() => setHover(null)} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onSelectSignal(isSelected ? null : signal.id); }} /> ); })} </g> </svg> {hover && ( <Box sx={{ position: 'absolute', left: Math.min(hover.left, (containerRef.current?.clientWidth || W) - 280), top: hover.top, maxWidth: 260, p: 1.25, bgcolor: 'rgba(11,11,11,0.92)', color: '#fff', borderRadius: 1, pointerEvents: 'none', zIndex: 2, fontSize: 13, lineHeight: 1.4, }} > {hover.signal.text} <Typography component="div" variant="caption" sx={{ color: '#c3c2b7', mt: 0.5 }}> {clusters.find((c) => c.id === hover.signal.clusterId)?.name} · {hover.signal.source} </Typography> </Box> )} </Box> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}> Координаты — 2D-проекция эмбеддингов сигналов; близкие по смыслу сигналы расположены рядом. Колесо — масштаб, перетаскивание — сдвиг, двойной клик — сброс. Клик по точке — детали сигнала. </Typography> </Paper> ); }