// ════════════════════════════════════════════════════════════════
// 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 (
);
}
// ── 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 (
);
}
// 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 (
);
}
// ── 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 (
);
}
// ── exports to window ────────────────────────────────────────────
Object.assign(window, {
MicroSparkline,
MicroSimDot,
MicroDateDot,
CategoryPill,
MomentumPill,
NicheScatter,
CompetitorBeeswarm,
IdeaHorizon,
});