// ════════════════════════════════════════════════════════════════ // Symbiosis.DataHub — MicroViz primitives // 8 reusable inline-SVG components. No external libs. // All colors via var(--token), not hardcoded hex. // ════════════════════════════════════════════════════════════════ const { useMemo } = React; // ── hue helpers ────────────────────────────────────────────────── function hueVar(hue) { return `var(--hue-${hue})`; } function hueSubtle(hue) { return `var(--hue-${hue}-subtle)`; } // ── 1. MicroSparkline ──────────────────────────────────────────── function MicroSparkline({ data, width = 64, height = 14, color = 'var(--text-secondary)', accentLast = true, fill = false }) { if (!data || data.length < 2) return null; const values = data.map(d => typeof d === 'number' ? d : d.value); const max = Math.max(...values); const min = Math.min(...values); const range = max - min || 1; const pts = values.map((v, i) => { const x = (i / (values.length - 1)) * (width - 2) + 1; const y = height - 1 - ((v - min) / range) * (height - 2); return [x, y]; }); const poly = pts.map(p => p.join(',')).join(' '); const lastX = pts[pts.length - 1][0]; const lastY = pts[pts.length - 1][1]; const areaPath = `M ${pts[0][0]},${height} L ${pts.map(p => p.join(',')).join(' L ')} L ${lastX},${height} Z`; return ( ); } // ── 2. MicroSimDot — 0..1 on 3-zone gradient ───────────────────── function MicroSimDot({ value, width = 80, height = 10 }) { const v = Math.max(0, Math.min(1, value)); const x = 2 + v * (width - 4); const zone = v < 0.5 ? 'rose' : v < 0.75 ? 'amber' : 'emerald'; return ( ); } // ── 3. MicroDateDot — dot in N-day strip ───────────────────────── function MicroDateDot({ daysAgo, span = 30, width = 60, height = 10 }) { const d = Math.max(0, Math.min(span, daysAgo)); const x = (1 - d / span) * (width - 4) + 2; return ( {[0, 7, 14, 21].map(t => ( ))} ); } // ── 4. CategoryPill — coloured pill ────────────────────────────── function CategoryPill({ hue = 'info', children, small = false }) { return ( {children} ); } // ── 5. MomentumPill — rising / steady / cooling ────────────────── function MomentumPill({ momentum, growth }) { const config = { rising: { hue: 'emerald', icon: '↑', label: 'растёт' }, steady: { hue: 'info', icon: '→', label: 'стабильно' }, cooling: { hue: 'rose', icon: '↓', label: 'снижается' }, }; const c = config[momentum] || config.steady; const pct = growth != null ? `${growth > 0 ? '+' : ''}${Math.round(growth * 100)}%` : null; return ( {c.icon} {c.label} {pct && {pct}} ); } // ── 6. NicheScatter — interactive, with greedy label placement ─── function NicheScatter({ clusters, width = 880, height = 340, onSelect, activeId }) { const [hover, setHover] = React.useState(null); const pad = { l: 64, r: 40, t: 28, b: 56 }; const W = width - pad.l - pad.r; const H = height - pad.t - pad.b; const dots = useMemo(() => clusters.map(c => { const x = Math.max(0.02, Math.min(0.98, (c.growth + 0.4) / 0.8)); const y = Math.max(0.05, Math.min(0.95, c.count / 80)); const hue = c.momentum === 'rising' ? 'emerald' : c.momentum === 'cooling' ? 'rose' : 'info'; const r = 6 + Math.min(14, c.count / 8); return { ...c, x, y, hue, r, cx: pad.l + x * W, cy: pad.t + (1 - y) * H, }; }), [clusters, width, height]); const labels = useMemo(() => placeLabels(dots, pad.l + W, pad.l), [dots, pad.l, W]); const focused = hover || activeId; return ( {/* axes */} {/* center vertical (zero growth) */} {/* zone labels */} снижение рост {/* axis titles */} объём мало импульс кластера (∆ к предыдущей неделе) {/* dots */} {dots.map(d => { const isF = focused === d.id; const isDim = focused && !isF; return ( setHover(d.id)} onMouseLeave={() => setHover(null)} onClick={() => onSelect && onSelect(d.id)} style={{ cursor: 'pointer' }}> ); })} {/* labels (greedy-placed, with leader lines if offset) */} {labels.map(l => { if (!l.pos) return null; const isF = focused === l.dot.id; const isDim = focused && !isF; return ( {l.leader && ( )} {/* readability backdrop */} {l.dot.title} ); })} {/* Tooltip for focused dot whose label couldn't be placed, or always for hovered */} {hover && (() => { const d = dots.find(x => x.id === hover); if (!d) return null; const tipW = 200, tipH = 56; let tx = d.cx + d.r + 10; let ty = d.cy - tipH / 2; if (tx + tipW > width - 4) tx = d.cx - d.r - 10 - tipW; if (ty < pad.t + 4) ty = pad.t + 4; if (ty + tipH > pad.t + H - 4) ty = pad.t + H - 4 - tipH; const arrow = d.momentum === 'rising' ? '↑' : d.momentum === 'cooling' ? '↓' : '→'; const pct = `${d.growth > 0 ? '+' : ''}${Math.round(d.growth * 100)}%`; return ( {d.title.length > 28 ? d.title.slice(0, 26) + '…' : d.title} {d.count} стартапов {arrow} {pct} к прошлой неделе ); })()} ); } // Greedy label placement: tries 4 anchor positions per dot, skips on collision function placeLabels(dots, maxX, minX) { // priority: large dots first so they get prime spots const sorted = [...dots].sort((a, b) => b.r - a.r); const placed = []; const charW = 6.0; const charH = 13; // candidates: right, left, top, bottom, slight diagonals const candidates = (d) => [ { dx: d.r + 6, dy: 4, anchor: 'start', leader: false }, { dx: -d.r - 6, dy: 4, anchor: 'end', leader: false }, { dx: 0, dy: -d.r - 6, anchor: 'middle', leader: false }, { dx: 0, dy: d.r + 14, anchor: 'middle', leader: false }, { dx: d.r + 18, dy: -d.r - 4, anchor: 'start', leader: true }, { dx: -d.r - 18, dy: -d.r - 4, anchor: 'end', leader: true }, { dx: d.r + 18, dy: d.r + 14, anchor: 'start', leader: true }, { dx: -d.r - 18, dy: d.r + 14, anchor: 'end', leader: true }, ]; sorted.forEach(dot => { const w = dot.title.length * charW + 8; let chosen = null; for (const c of candidates(dot)) { const labelX = dot.cx + c.dx; const labelY = dot.cy + c.dy; const rectX = c.anchor === 'end' ? labelX - w + 2 : c.anchor === 'middle' ? labelX - w / 2 : labelX - 2; const rect = { x: rectX, y: labelY - charH + 3, w, h: charH }; // bounds check if (rect.x < minX - 8) continue; if (rect.x + rect.w > maxX + 8) continue; // collision with placed label rects let bad = placed.some(p => p.rect && rectsOverlap(rect, p.rect, 2)); // collision with any dot (including unplaced ones) if (!bad) bad = dots.some(other => other.id !== dot.id && circleRectOverlap(other, rect, 1)); if (bad) continue; chosen = { pos: c, rect, labelX, labelY, leader: null }; if (c.leader) { chosen.leader = { x1: dot.cx + Math.cos(Math.atan2(c.dy, c.dx)) * dot.r, y1: dot.cy + Math.sin(Math.atan2(c.dy, c.dx)) * dot.r, x2: c.anchor === 'start' ? rect.x : c.anchor === 'end' ? rect.x + rect.w : labelX, y2: labelY - 3, }; } break; } placed.push({ dot, ...(chosen || { pos: null }) }); }); return placed; } function rectsOverlap(a, b, pad = 0) { return !(a.x + a.w + pad < b.x || b.x + b.w + pad < a.x || a.y + a.h + pad < b.y || b.y + b.h + pad < a.y); } function circleRectOverlap(c, r, pad = 0) { const cx = Math.max(r.x, Math.min(c.cx, r.x + r.w)); const cy = Math.max(r.y, Math.min(c.cy, r.y + r.h)); const dx = c.cx - cx, dy = c.cy - cy; return (dx * dx + dy * dy) < ((c.r + pad) * (c.r + pad)); } // ── 7. CompetitorBeeswarm — 32px tall ──────────────────────────── function CompetitorBeeswarm({ verdicts, width = 220, height = 32 }) { // verdicts: { risky, validate, viable } — counts if (!verdicts) return null; const dotR = 4; const groups = [ { key: 'risky', count: verdicts.risky, hue: 'rose' }, { key: 'validate', count: verdicts.validate, hue: 'amber' }, { key: 'viable', count: verdicts.viable, hue: 'emerald' }, ]; const total = groups.reduce((s, g) => s + g.count, 0) || 1; const segW = width / 3; const dots = []; groups.forEach((g, gi) => { for (let i = 0; i < g.count; i++) { const cols = Math.max(1, Math.floor(segW / (dotR * 2 + 2))); const col = i % cols; const row = Math.floor(i / cols); const cx = gi * segW + (segW / 2) - (cols * (dotR * 2 + 2)) / 2 + col * (dotR * 2 + 2) + dotR + 1; const cy = height / 2 - row * (dotR * 2 + 1) + dotR; dots.push({ cx, cy, hue: g.hue, key: `${g.key}-${i}` }); } }); return ( {[1, 2].map(i => ( ))} {dots.map(d => ( ))} ); } // ── 8. IdeaHorizon — small horizon chart ───────────────────────── function IdeaHorizon({ series, width = 120, height = 22, hue = 'info' }) { if (!series || series.length < 2) return null; const values = series.map(d => typeof d === 'number' ? d : d.value); const max = Math.max(...values, 1); const bw = width / values.length; return ( {values.map((v, i) => { const h = (v / max) * height; return ; })} ); } // ── exports to window ──────────────────────────────────────────── Object.assign(window, { MicroSparkline, MicroSimDot, MicroDateDot, CategoryPill, MomentumPill, NicheScatter, CompetitorBeeswarm, IdeaHorizon, });