From 29887886306d2ce7668245b94f122be355a51b9e Mon Sep 17 00:00:00 2001 From: alireza Date: Thu, 27 Aug 2026 11:08:05 +0330 Subject: [PATCH] feat: implement data service, radar utilities, and visualization components for futures foresight dashboard --- src/components/visual/FuturesRadar.tsx | 48 +- src/components/visual/ImpactNetwork.tsx | 340 +++++++++---- src/components/visual/TriangleView.tsx | 629 ++++++++++++++++-------- src/data/seedData.ts | 58 +-- src/services/dataService.ts | 2 +- src/utils/radarMath.ts | 73 ++- vite.config.ts | 1 + 7 files changed, 760 insertions(+), 391 deletions(-) diff --git a/src/components/visual/FuturesRadar.tsx b/src/components/visual/FuturesRadar.tsx index 903ed9c..becf52c 100644 --- a/src/components/visual/FuturesRadar.tsx +++ b/src/components/visual/FuturesRadar.tsx @@ -23,8 +23,8 @@ const LABEL_ANCHOR: Record = { weak_signal: { x: -0.62, y: 0.78 } }; -/** Dot size encodes impact (1–5). */ -const dotRadius = (item: IntelligenceItem) => 6 + item.impactScore * 1.6; +/** Dot size encodes impact (1–5). Calibrated for generous spacing. */ +const dotRadius = (item: IntelligenceItem) => 5.5 + item.impactScore * 1.2; export const FuturesRadar: React.FC = ({ items, @@ -228,30 +228,44 @@ export const FuturesRadar: React.FC = ({ - + - دانشگاه اصفهان + دانشگاه + + + اصفهان ۱۴۱۵ {/* Blips */} diff --git a/src/components/visual/ImpactNetwork.tsx b/src/components/visual/ImpactNetwork.tsx index f6196dd..d3b92ca 100644 --- a/src/components/visual/ImpactNetwork.tsx +++ b/src/components/visual/ImpactNetwork.tsx @@ -70,6 +70,12 @@ const curve = (ax: number, ay: number, bx: number, by: number, cx: number, cy: n return `M ${ax} ${ay} Q ${qx} ${qy} ${bx} ${by}`; }; +/** Truncate label cleanly with ellipsis */ +const formatLabel = (text: string, maxChars = 24) => { + if (!text) return ''; + return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text; +}; + export const ImpactNetwork: React.FC = ({ items, dimensions, @@ -85,9 +91,9 @@ export const ImpactNetwork: React.FC = ({ const active = hovered ?? pinned; - const size = 860; + const size = 920; const center = size / 2; - const maxRadius = size / 2 - 108; + const maxRadius = 310; const { nodes, links, nodeById, neighbours } = useMemo(() => { const links: Link[] = []; @@ -143,10 +149,13 @@ export const ImpactNetwork: React.FC = ({ const ratio = LAYERS.find((l) => l.key === layer)!.ratio; const radius = maxRadius * ratio; const n = entries.length || 1; - // Leave a wedge at due west free for the ring labels. - const arc = 2 * Math.PI - 0.34; + + // Reserve an angular opening at 12 o'clock (-PI/2) for clean vertical ring headers + const gap = 0.44; // ~25 degree opening at 12 o'clock + const arc = 2 * Math.PI - gap; + entries.forEach((entry, i) => { - const angle = -Math.PI / 2 + 0.17 + (arc * i) / n; + const angle = -Math.PI / 2 + gap / 2 + (arc * (i + 0.5)) / n; built.push({ ...entry, layer, @@ -163,21 +172,21 @@ export const ImpactNetwork: React.FC = ({ dimensions .filter((d) => usedDims.has(d.id)) .sort(byAnchor) - .map((d) => ({ id: d.id, label: d.shortTag, color: d.pillarColor, size: 7 })) + .map((d) => ({ id: d.id, label: d.shortTag, color: d.pillarColor, size: 7.5 })) ); place( 'job', jobs .filter((j) => usedJobs.has(j.id)) .sort(byAnchor) - .map((j) => ({ id: j.id, label: j.title, color: LAYER_COLOR.job, size: 4.5 })) + .map((j) => ({ id: j.id, label: j.title, color: LAYER_COLOR.job, size: 5 })) ); place( 'skill', skills .filter((s) => usedSkills.has(s.id)) .sort(byAnchor) - .map((s) => ({ id: s.id, label: s.title, color: LAYER_COLOR.skill, size: 4.5 })) + .map((s) => ({ id: s.id, label: s.title, color: LAYER_COLOR.skill, size: 5 })) ); place( 'item', @@ -188,7 +197,7 @@ export const ImpactNetwork: React.FC = ({ id: i.id, label: i.title, color: CATEGORY_META[i.category].color, - size: 3.5 + i.impactScore * 0.9 + size: 4 + i.impactScore * 0.9 })) ); @@ -214,10 +223,81 @@ export const ImpactNetwork: React.FC = ({ const isLit = (id: string) => !active || id === active || relatedSet.has(id); const linkLit = (l: Link) => !active || l.from === active || l.to === active; - /** Labels drawn on the canvas: the active node plus everything it touches. */ - const labelled = activeNode - ? [activeNode, ...related.map((id) => nodeById.get(id)!).filter(Boolean)] - : []; + /** Connected nodes to label on the canvas */ + const labelled = useMemo(() => { + if (!activeNode) return []; + return [activeNode, ...related.map((id) => nodeById.get(id)!).filter(Boolean)]; + }, [activeNode, related, nodeById]); + + /** Collision-free label positioning algorithm */ + const positionedLabels = useMemo(() => { + if (!labelled.length) return []; + + const list = labelled.map((node) => { + const isMain = node.id === active; + const maxChars = isMain ? 28 : 22; + const text = formatLabel(node.label, maxChars); + const boxW = Math.min(Math.max(text.length * 7.4 + 26, 80), 220); + const boxH = isMain ? 26 : 22; + + const angle = node.angle; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const gap = node.size + 10; + + let x: number; + let y: number; + + if (Math.abs(cos) < 0.35) { + // Vertical axis (near 12 or 6 o'clock) + x = node.x - boxW / 2; + if (sin < 0) { + y = node.y - gap - boxH; + } else { + y = node.y + gap; + } + } else if (cos > 0) { + // Right side: place to the right of the node + x = node.x + gap; + y = node.y - boxH / 2; + } else { + // Left side: place to the left of the node + x = node.x - gap - boxW; + y = node.y - boxH / 2; + } + + // Clamp within SVG viewBox with safe margin + x = Math.max(16, Math.min(size - boxW - 16, x)); + y = Math.max(16, Math.min(size - boxH - 16, y)); + + return { node, text, boxW, boxH, x, y, isMain }; + }); + + // Relax vertical overlaps between labels on the same side + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + const a = list[i]; + const b = list[j]; + const xOverlap = + Math.abs(a.x + a.boxW / 2 - (b.x + b.boxW / 2)) < (a.boxW + b.boxW) / 2; + if (xOverlap) { + const yDist = b.y - a.y; + if (Math.abs(yDist) < 28) { + const shift = (28 - Math.abs(yDist)) / 2 + 2; + if (yDist >= 0) { + b.y = Math.min(size - b.boxH - 16, b.y + shift); + a.y = Math.max(16, a.y - shift); + } else { + a.y = Math.min(size - a.boxH - 16, a.y + shift); + b.y = Math.max(16, b.y - shift); + } + } + } + } + } + + return list; + }, [labelled, active, size]); const grouped = useMemo(() => { const out: Record = { item: [], skill: [], job: [], dimension: [] }; @@ -240,11 +320,11 @@ export const ImpactNetwork: React.FC = ({ }; return ( -
-
+
+
setPinned(null)} @@ -254,13 +334,17 @@ export const ImpactNetwork: React.FC = ({ + + + - {/* Rings. Labels sit in the reserved wedge at due west. */} + {/* Concentric Rings with clean vertical top header tags */} {LAYERS.map((layer) => { const r = maxRadius * layer.ratio; + const labelY = center - r; return ( = ({ r={r} fill="none" stroke="var(--border)" - strokeDasharray="2 7" + strokeWidth="1.2" + strokeDasharray="3 6" + opacity="0.8" /> + {/* Ring Tag Badge placed at 12 o'clock in the reserved corridor */} + {layer.label} @@ -307,8 +403,8 @@ export const ImpactNetwork: React.FC = ({ key={`${l.from}-${l.to}-${i}`} d={curve(a.x, a.y, b.x, b.y, center, center)} stroke={lit ? a.color : 'var(--border)'} - strokeWidth={active && lit ? 1.8 : 0.7} - strokeOpacity={lit ? (active ? 0.9 : 0.14) : 0.04} + strokeWidth={active && lit ? 2 : 0.75} + strokeOpacity={lit ? (active ? 0.9 : 0.16) : 0.04} className="net-link" style={{ animationDelay: `${Math.min(i * 3, 600)}ms`, @@ -334,7 +430,6 @@ export const ImpactNetwork: React.FC = ({ onFocus={() => setHovered(node.id)} onBlur={() => setHovered(null)} onClick={(e) => { - // Selecting a node never navigates — it reveals its chain. e.stopPropagation(); setHovered(null); setPinned((cur) => (cur === node.id ? null : node.id)); @@ -354,55 +449,78 @@ export const ImpactNetwork: React.FC = ({ )} ); })} - {/* Names for the active node and everything it touches. */} - {labelled.map((node) => { - const outward = node.layer === 'item' ? 1 : -1; - const dist = node.radius + outward * 16; - const lx = center + dist * Math.cos(node.angle); - const ly = center + dist * Math.sin(node.angle); - const onLeft = Math.cos(node.angle) < 0; - const text = - node.label.length > 26 ? `${node.label.slice(0, 25)}…` : node.label; - const w = text.length * 6.2 + 14; + {/* Leader Lines & Collision-Free Node Labels */} + {positionedLabels.map(({ node, text, boxW, boxH, x, y, isMain }) => { + const lit = isLit(node.id); + if (!lit) return null; return ( - - + {/* Connector line between node and label box */} + + + {/* Background Box */} + + + {/* Category Indicator Dot for neighbor labels */} + {!isMain && ( + + )} + + {/* Text */} {text} @@ -410,72 +528,86 @@ export const ImpactNetwork: React.FC = ({ ); })} - {/* Centre */} + {/* Central Hub */} دانشگاه + + اصفهان ۱۴۱۵ + -
+ {/* Bottom Legend */} +
{LAYERS.slice() .reverse() .map((l) => ( - + - {l.label} + {l.label} ))}
- {/* In-page detail — nothing here navigates unless you ask it to. */} -