feat: implement data service, radar utilities, and visualization components for futures foresight dashboard

This commit is contained in:
alireza 2026-08-27 11:08:05 +03:30
parent 1f69cb9e7d
commit 2988788630
7 changed files with 760 additions and 391 deletions

View File

@ -23,8 +23,8 @@ const LABEL_ANCHOR: Record<Category, { x: number; y: number }> = {
weak_signal: { x: -0.62, y: 0.78 } weak_signal: { x: -0.62, y: 0.78 }
}; };
/** Dot size encodes impact (15). */ /** Dot size encodes impact (15). Calibrated for generous spacing. */
const dotRadius = (item: IntelligenceItem) => 6 + item.impactScore * 1.6; const dotRadius = (item: IntelligenceItem) => 5.5 + item.impactScore * 1.2;
export const FuturesRadar: React.FC<FuturesRadarProps> = ({ export const FuturesRadar: React.FC<FuturesRadarProps> = ({
items, items,
@ -228,30 +228,44 @@ export const FuturesRadar: React.FC<FuturesRadarProps> = ({
<circle <circle
cx={center} cx={center}
cy={center} cy={center}
r={26} r={34}
fill="var(--primary)" fill="var(--card)"
fillOpacity="0.1" stroke="var(--primary)"
className="anim-pulse" strokeWidth="2"
className="shadow-xs"
/> />
<circle <circle
cx={center} cx={center}
cy={center} cy={center}
r={16} r={26}
fill="var(--card)" fill="var(--primary)"
stroke="var(--primary)" fillOpacity="0.08"
strokeWidth="1.5" className="anim-pulse"
/> />
<circle cx={center} cy={center} r={5} fill="var(--primary)" /> <circle cx={center} cy={center} r={4.5} fill="var(--primary)" />
<text <text
x={center} x={center}
y={center + 33} y={center - 4}
fill="var(--muted-foreground)" fill="var(--foreground)"
fontSize="10.5" fontSize="9.5"
fontWeight="600" fontWeight="800"
textAnchor="middle" textAnchor="middle"
className="pointer-events-none" className="pointer-events-none select-none"
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
> >
دانشگاه اصفهان دانشگاه
</text>
<text
x={center}
y={center + 10}
fill="var(--primary)"
fontSize="8.5"
fontWeight="700"
textAnchor="middle"
className="pointer-events-none select-none"
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
>
اصفهان ۱۴۱۵
</text> </text>
{/* Blips */} {/* Blips */}

View File

@ -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}`; 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<ImpactNetworkProps> = ({ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
items, items,
dimensions, dimensions,
@ -85,9 +91,9 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
const active = hovered ?? pinned; const active = hovered ?? pinned;
const size = 860; const size = 920;
const center = size / 2; const center = size / 2;
const maxRadius = size / 2 - 108; const maxRadius = 310;
const { nodes, links, nodeById, neighbours } = useMemo(() => { const { nodes, links, nodeById, neighbours } = useMemo(() => {
const links: Link[] = []; const links: Link[] = [];
@ -143,10 +149,13 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
const ratio = LAYERS.find((l) => l.key === layer)!.ratio; const ratio = LAYERS.find((l) => l.key === layer)!.ratio;
const radius = maxRadius * ratio; const radius = maxRadius * ratio;
const n = entries.length || 1; 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) => { 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({ built.push({
...entry, ...entry,
layer, layer,
@ -163,21 +172,21 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
dimensions dimensions
.filter((d) => usedDims.has(d.id)) .filter((d) => usedDims.has(d.id))
.sort(byAnchor) .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( place(
'job', 'job',
jobs jobs
.filter((j) => usedJobs.has(j.id)) .filter((j) => usedJobs.has(j.id))
.sort(byAnchor) .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( place(
'skill', 'skill',
skills skills
.filter((s) => usedSkills.has(s.id)) .filter((s) => usedSkills.has(s.id))
.sort(byAnchor) .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( place(
'item', 'item',
@ -188,7 +197,7 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
id: i.id, id: i.id,
label: i.title, label: i.title,
color: CATEGORY_META[i.category].color, 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<ImpactNetworkProps> = ({
const isLit = (id: string) => !active || id === active || relatedSet.has(id); const isLit = (id: string) => !active || id === active || relatedSet.has(id);
const linkLit = (l: Link) => !active || l.from === active || l.to === active; const linkLit = (l: Link) => !active || l.from === active || l.to === active;
/** Labels drawn on the canvas: the active node plus everything it touches. */ /** Connected nodes to label on the canvas */
const labelled = activeNode const labelled = useMemo(() => {
? [activeNode, ...related.map((id) => nodeById.get(id)!).filter(Boolean)] 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 grouped = useMemo(() => {
const out: Record<Layer, Node[]> = { item: [], skill: [], job: [], dimension: [] }; const out: Record<Layer, Node[]> = { item: [], skill: [], job: [], dimension: [] };
@ -240,11 +320,11 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
}; };
return ( return (
<div className="flex flex-col gap-4 xl:flex-row xl:items-start"> <div className="flex flex-col gap-6 xl:flex-row xl:items-start">
<div className="relative min-w-0 flex-1 select-none"> <div className="relative min-w-0 flex-1 select-none flex flex-col items-center">
<svg <svg
viewBox={`0 0 ${size} ${size}`} viewBox={`0 0 ${size} ${size}`}
className="h-auto w-full overflow-visible" className="h-auto w-full max-w-[840px] overflow-visible"
role="img" role="img"
aria-label="شبکه روابط: از پیشران بیرونی تا شکاف قابلیت دانشگاه" aria-label="شبکه روابط: از پیشران بیرونی تا شکاف قابلیت دانشگاه"
onClick={() => setPinned(null)} onClick={() => setPinned(null)}
@ -254,13 +334,17 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.14" /> <stop offset="0%" stopColor="var(--primary)" stopOpacity="0.14" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" /> <stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
</radialGradient> </radialGradient>
<filter id="labelShadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#0f172a" floodOpacity="0.12" />
</filter>
</defs> </defs>
<circle cx={center} cy={center} r={maxRadius * 0.44} fill="url(#netCore)" /> <circle cx={center} cy={center} r={maxRadius * 0.44} fill="url(#netCore)" />
{/* Rings. Labels sit in the reserved wedge at due west. */} {/* Concentric Rings with clean vertical top header tags */}
{LAYERS.map((layer) => { {LAYERS.map((layer) => {
const r = maxRadius * layer.ratio; const r = maxRadius * layer.ratio;
const labelY = center - r;
return ( return (
<g key={layer.key}> <g key={layer.key}>
<circle <circle
@ -269,25 +353,37 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
r={r} r={r}
fill="none" fill="none"
stroke="var(--border)" 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 */}
<rect <rect
x={center - r - 40} x={center - 44}
y={center - 9} y={labelY - 10}
width="80" width="88"
height="18" height="20"
rx="5" rx="10"
fill="var(--card)" fill="var(--card)"
stroke="var(--border)" stroke="var(--border)"
strokeWidth="1"
className="shadow-sm"
/>
<circle
cx={center - 32}
cy={labelY}
r="3.5"
fill={LAYER_COLOR[layer.key]}
/> />
<text <text
x={center - r} x={center + 6}
y={center + 4} y={labelY + 3.5}
textAnchor="middle" textAnchor="middle"
fontSize="10" fontSize="10"
fontWeight="600" fontWeight="700"
fill="var(--muted-foreground)" fill="var(--foreground)"
className="pointer-events-none" className="pointer-events-none select-none"
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
> >
{layer.label} {layer.label}
</text> </text>
@ -307,8 +403,8 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
key={`${l.from}-${l.to}-${i}`} key={`${l.from}-${l.to}-${i}`}
d={curve(a.x, a.y, b.x, b.y, center, center)} d={curve(a.x, a.y, b.x, b.y, center, center)}
stroke={lit ? a.color : 'var(--border)'} stroke={lit ? a.color : 'var(--border)'}
strokeWidth={active && lit ? 1.8 : 0.7} strokeWidth={active && lit ? 2 : 0.75}
strokeOpacity={lit ? (active ? 0.9 : 0.14) : 0.04} strokeOpacity={lit ? (active ? 0.9 : 0.16) : 0.04}
className="net-link" className="net-link"
style={{ style={{
animationDelay: `${Math.min(i * 3, 600)}ms`, animationDelay: `${Math.min(i * 3, 600)}ms`,
@ -334,7 +430,6 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
onFocus={() => setHovered(node.id)} onFocus={() => setHovered(node.id)}
onBlur={() => setHovered(null)} onBlur={() => setHovered(null)}
onClick={(e) => { onClick={(e) => {
// Selecting a node never navigates — it reveals its chain.
e.stopPropagation(); e.stopPropagation();
setHovered(null); setHovered(null);
setPinned((cur) => (cur === node.id ? null : node.id)); setPinned((cur) => (cur === node.id ? null : node.id));
@ -354,55 +449,78 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
<circle <circle
cx={node.x} cx={node.x}
cy={node.y} cy={node.y}
r={node.size + 9} r={node.size + 10}
fill={node.color} fill={node.color}
fillOpacity="0.2" fillOpacity="0.25"
className="anim-pulse" className="anim-pulse"
/> />
)} )}
<circle <circle
cx={node.x} cx={node.x}
cy={node.y} cy={node.y}
r={isActive ? node.size + 2.5 : node.size} r={isActive ? node.size + 3 : node.size}
fill={node.color} fill={node.color}
fillOpacity={lit ? 1 : 0.14} fillOpacity={lit ? 1 : 0.15}
stroke={isPinned ? 'var(--foreground)' : 'var(--card)'} stroke={isPinned ? '#0f172a' : 'var(--card)'}
strokeWidth={isPinned ? 2.5 : 1.5} strokeWidth={isPinned ? 3 : 1.75}
style={{ transition: 'fill-opacity 200ms ease' }} style={{ transition: 'fill-opacity 200ms ease' }}
/> />
</g> </g>
); );
})} })}
{/* Names for the active node and everything it touches. */} {/* Leader Lines & Collision-Free Node Labels */}
{labelled.map((node) => { {positionedLabels.map(({ node, text, boxW, boxH, x, y, isMain }) => {
const outward = node.layer === 'item' ? 1 : -1; const lit = isLit(node.id);
const dist = node.radius + outward * 16; if (!lit) return null;
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;
return ( return (
<g key={`lbl-${node.id}`} className="pointer-events-none net-label"> <g key={`lbl-${node.id}`} className="pointer-events-none transition-all duration-200">
<rect {/* Connector line between node and label box */}
x={onLeft ? lx - w : lx} <line
y={ly - 9} x1={node.x}
width={w} y1={node.y}
height="18" x2={x + boxW / 2}
rx="5" y2={y + boxH / 2}
fill="var(--popover)" stroke={isMain ? '#0f172a' : node.color}
stroke="var(--border)" strokeWidth={isMain ? 1.5 : 1}
strokeDasharray={isMain ? 'none' : '2 3'}
strokeOpacity={isMain ? 0.7 : 0.35}
/> />
{/* Background Box */}
<rect
x={x}
y={y}
width={boxW}
height={boxH}
rx={boxH / 2}
fill={isMain ? '#0f172a' : 'var(--card)'}
stroke={isMain ? '#38bdf8' : node.color}
strokeWidth={isMain ? 1.5 : 1.2}
filter="url(#labelShadow)"
/>
{/* Category Indicator Dot for neighbor labels */}
{!isMain && (
<circle
cx={x + 11}
cy={y + boxH / 2}
r={3.5}
fill={node.color}
/>
)}
{/* Text */}
<text <text
x={onLeft ? lx - w / 2 : lx + w / 2} x={isMain ? x + boxW / 2 : x + (boxW + 10) / 2}
y={ly + 4} y={y + boxH / 2 + 3.5}
textAnchor="middle" textAnchor="middle"
fontSize="10.5" fontSize={isMain ? 11 : 10}
fontWeight={node.id === active ? 700 : 500} fontWeight={isMain ? 800 : 600}
fill="var(--popover-foreground)" fill={isMain ? '#ffffff' : 'var(--foreground)'}
className="select-none pointer-events-none"
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
> >
{text} {text}
</text> </text>
@ -410,72 +528,86 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
); );
})} })}
{/* Centre */} {/* Central Hub */}
<circle <circle
cx={center} cx={center}
cy={center} cy={center}
r={32} r={35}
fill="var(--card)" fill="var(--card)"
stroke="var(--primary)" stroke="var(--primary)"
strokeWidth="1.5" strokeWidth="2.5"
filter="url(#labelShadow)"
/> />
<text <text
x={center} x={center}
y={center + 4} y={center - 4}
textAnchor="middle" textAnchor="middle"
fontSize="11" fontSize="11.5"
fontWeight="700" fontWeight="800"
fill="var(--primary)" fill="var(--foreground)"
className="pointer-events-none" className="pointer-events-none select-none"
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
> >
دانشگاه دانشگاه
</text> </text>
<text
x={center}
y={center + 12}
textAnchor="middle"
fontSize="9"
fontWeight="700"
fill="var(--primary)"
className="pointer-events-none select-none"
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
>
اصفهان ۱۴۱۵
</text>
</svg> </svg>
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-[11.5px] text-muted-foreground"> {/* Bottom Legend */}
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-muted-foreground">
{LAYERS.slice() {LAYERS.slice()
.reverse() .reverse()
.map((l) => ( .map((l) => (
<span key={l.key} className="flex items-center gap-1.5"> <span key={l.key} className="flex items-center gap-2">
<span <span
className="inline-block size-2 rounded-full" className="inline-block size-2.5 rounded-full shadow-xs"
style={{ background: LAYER_COLOR[l.key] }} style={{ background: LAYER_COLOR[l.key] }}
/> />
{l.label} <span className="font-semibold">{l.label}</span>
</span> </span>
))} ))}
</div> </div>
</div> </div>
{/* In-page detail — nothing here navigates unless you ask it to. */} {/* In-page detail side panel */}
<aside className="w-full shrink-0 xl:w-72"> <aside className="w-full shrink-0 xl:w-80">
{!activeNode ? ( {!activeNode ? (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-center"> <div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-slate-50/50 p-6 text-center">
<MousePointerClick className="size-5 text-muted-foreground" /> <MousePointerClick className="size-5 text-muted-foreground" />
<p className="text-[12.5px] font-medium">یک گره را انتخاب کنید</p> <p className="text-[12.5px] font-bold text-slate-800">یک گره را انتخاب کنید</p>
<p className="text-[11.5px] leading-relaxed text-muted-foreground"> <p className="text-[11.5px] leading-relaxed text-muted-foreground">
با انتخاب هر گره، زنجیره آن روشن میشود و نام موارد مرتبط همینجا با کلیک یا قرار دادن نشانگر روی هر گره، زنجیره اثرگذاری آن روشن میشود و جزئیات پیوندها نمایش داده میشود.
نمایش داده میشود.
</p> </p>
</div> </div>
) : ( ) : (
<div className="rounded-lg border animate-in fade-in slide-in-from-bottom-1 duration-200"> <div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm animate-in fade-in slide-in-from-bottom-1 duration-200">
<div className="flex items-start justify-between gap-2 border-b p-3.5"> <div className="flex items-start justify-between gap-2 border-b border-slate-100 pb-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 mb-1">
<span <span
className="size-2 shrink-0 rounded-full" className="size-2.5 shrink-0 rounded-full"
style={{ background: activeNode.color }} style={{ background: activeNode.color }}
/> />
<span className="text-[10.5px] text-muted-foreground"> <span className="text-[11px] font-bold text-muted-foreground">
{LAYER_LABEL[activeNode.layer]} {LAYER_LABEL[activeNode.layer]}
</span> </span>
</div> </div>
<p className="mt-1 text-[13px] font-bold leading-snug"> <p className="text-[13px] font-black text-slate-900 leading-snug">
{activeNode.label} {activeNode.label}
</p> </p>
<p className="tnum mt-1 text-[11px] text-muted-foreground"> <p className="tnum mt-1 text-[11px] font-semibold text-muted-foreground">
{toPersianDigits(related.length)} پیوند {toPersianDigits(related.length)} پیوند فعال در شبکه
</p> </p>
</div> </div>
@ -483,39 +615,41 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 shrink-0" className="size-7 shrink-0 text-slate-400 hover:text-slate-700"
aria-label="بستن" aria-label="بستن"
onClick={() => setPinned(null)} onClick={() => setPinned(null)}
> >
<X /> <X className="size-4" />
</Button> </Button>
)} )}
</div> </div>
<div className="max-h-[26rem] overflow-y-auto p-3.5"> <div className="max-h-[28rem] overflow-y-auto pt-3 space-y-3">
{(['dimension', 'job', 'skill', 'item'] as Layer[]).map((layer) => { {(['dimension', 'job', 'skill', 'item'] as Layer[]).map((layer) => {
const list = grouped[layer]; const list = grouped[layer];
if (!list.length) return null; if (!list.length) return null;
return ( return (
<div key={layer} className="mb-3 last:mb-0"> <div key={layer} className="p-2.5 bg-slate-50 border border-slate-100 rounded-xl">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground"> <div className="mb-1.5 flex items-center justify-between text-[11px] font-bold text-slate-700">
<span <div className="flex items-center gap-1.5">
className="inline-block size-1.5 rounded-full" <span
style={{ background: LAYER_COLOR[layer] }} className="inline-block size-2 rounded-full"
/> style={{ background: LAYER_COLOR[layer] }}
{LAYER_LABEL[layer]} />
<span className="tnum opacity-70"> <span>{LAYER_LABEL[layer]}</span>
</div>
<span className="text-[10.5px] bg-slate-200 text-slate-700 px-1.5 py-0.2 rounded-full font-bold">
{toPersianDigits(list.length)} {toPersianDigits(list.length)}
</span> </span>
</div> </div>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1 mt-1">
{list.map((n) => ( {list.map((n) => (
<li key={n.id}> <li key={n.id}>
<button <button
onClick={() => setPinned(n.id)} onClick={() => setPinned(n.id)}
onMouseEnter={() => setHovered(n.id)} onMouseEnter={() => setHovered(n.id)}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
className="w-full rounded-md px-2 py-1 text-right text-[12px] leading-relaxed transition-colors hover:bg-muted" className="w-full rounded-lg px-2 py-1 text-right text-[11.5px] font-medium text-slate-600 leading-relaxed transition-colors hover:bg-white hover:text-slate-900 hover:shadow-xs"
> >
{n.label} {n.label}
</button> </button>
@ -528,10 +662,10 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
</div> </div>
{(activeNode.layer === 'item' || activeNode.layer === 'dimension') && ( {(activeNode.layer === 'item' || activeNode.layer === 'dimension') && (
<div className="border-t p-3"> <div className="border-t border-slate-100 pt-3 mt-3">
<Button variant="outline" size="sm" className="w-full" onClick={openActive}> <Button variant="outline" size="sm" className="w-full font-bold text-xs" onClick={openActive}>
باز کردن پرونده کامل باز کردن پرونده کامل
<ArrowLeft /> <ArrowLeft className="size-3.5 mr-1" />
</Button> </Button>
</div> </div>
)} )}

View File

@ -1,248 +1,469 @@
import React, { useMemo, useState } from 'react'; import React, { useState } from 'react';
import { FuturesForce, ForceType } from '../../types/futures'; import { FuturesForce, ForceType } from '../../types/futures';
import { toPersianDigits } from '../../utils/persianNumbers'; import { toPersianDigits } from '../../utils/persianNumbers';
import { Sparkles, TrendingUp, ShieldAlert, ArrowLeft, Layers } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface TriangleViewProps { interface TriangleViewProps {
forces: FuturesForce[]; forces: FuturesForce[];
onSelectForce?: (force: FuturesForce) => void; onSelectForce?: (force: FuturesForce) => void;
} }
/** The three vertices, worded and ordered as in the brief. */ interface GroupConfig {
const GROUPS: {
type: ForceType; type: ForceType;
label: string; label: string;
latin: string; subtitle: string;
hint: string;
color: string; color: string;
soft: string; border: string;
}[] = [ bgSoft: string;
badgeBg: string;
badgeText: string;
icon: React.ReactNode;
}
const GROUPS: GroupConfig[] = [
{ {
type: 'pull', type: 'pull',
label: 'کشش آینده', label: 'کشش آینده',
latin: 'PULL', subtitle: 'تصویر مطلوب، دانشگاه نسل پنجم و الگوهای نو',
hint: 'تصویر مطلوبی که دانشگاه را به جلو می‌کشد.', color: '#8b5cf6',
color: 'var(--cat-tech)', border: 'border-purple-200',
soft: 'var(--cat-tech-soft)' bgSoft: 'bg-purple-50/50',
badgeBg: 'bg-purple-100',
badgeText: 'text-purple-800',
icon: <Sparkles size={16} className="text-purple-600" />
}, },
{ {
type: 'push', type: 'push',
label: 'فشارهای حال', label: 'فشارهای زمان حال',
latin: 'PUSH', subtitle: 'شتاب هوش مصنوعی، تحولات جاری و روندهای دگرگون‌ساز',
hint: 'نیروهایی که همین امروز تغییر را تحمیل می‌کنند.', color: '#0284c7',
color: 'var(--cat-trend)', border: 'border-sky-200',
soft: 'var(--cat-trend-soft)' bgSoft: 'bg-sky-50/50',
badgeBg: 'bg-sky-100',
badgeText: 'text-sky-800',
icon: <TrendingUp size={16} className="text-sky-600" />
}, },
{ {
type: 'weight', type: 'weight',
label: 'وزن گذشته', label: 'وزن گذشته',
latin: 'WEIGHT', subtitle: 'موانع ساختاری، آیین‌نامه‌های کهنه و لختی سازمانی',
hint: 'ساختارها و عادت‌هایی که حرکت را کند می‌کنند.', color: '#f43f5e',
color: 'var(--cat-policy)', border: 'border-rose-200',
soft: 'var(--cat-policy-soft)' bgSoft: 'bg-rose-50/50',
badgeBg: 'bg-rose-100',
badgeText: 'text-rose-800',
icon: <ShieldAlert size={16} className="text-rose-600" />
} }
]; ];
// Triangle geometry in a 640×470 viewBox. /** Strip redundant Latin parentheticals from title for pure, clean Persian presentation */
const APEX = { x: 320, y: 46 }; const cleanTitle = (title: string) => {
const RIGHT = { x: 588, y: 414 }; if (!title) return '';
const LEFT = { x: 52, y: 414 }; return title.replace(/\s*\([A-Za-z\s&-]+\)/g, '').trim();
};
export const TriangleView: React.FC<TriangleViewProps> = ({ forces, onSelectForce }) => { export const TriangleView: React.FC<TriangleViewProps> = ({ forces, onSelectForce }) => {
const [selected, setSelected] = useState<FuturesForce | null>(forces[0] ?? null); const [selectedForce, setSelectedForce] = useState<FuturesForce>(forces[0] ?? null);
const grouped = useMemo( const pullForces = forces.filter((f) => f.forceType === 'pull');
() => ({ const pushForces = forces.filter((f) => f.forceType === 'push');
pull: forces.filter((f) => f.forceType === 'pull'), const weightForces = forces.filter((f) => f.forceType === 'weight');
push: forces.filter((f) => f.forceType === 'push'),
weight: forces.filter((f) => f.forceType === 'weight')
}),
[forces]
);
const strength = (type: ForceType) => { const getAvg = (list: FuturesForce[]) => {
const list = grouped[type];
if (!list.length) return 0; if (!list.length) return 0;
return list.reduce((sum, f) => sum + f.strength, 0) / list.length; return list.reduce((acc, cur) => acc + cur.strength, 0) / list.length;
}; };
const pick = (force: FuturesForce) => { const pullAvg = getAvg(pullForces);
setSelected(force); const pushAvg = getAvg(pushForces);
onSelectForce?.(force); const weightAvg = getAvg(weightForces);
const handleSelect = (f: FuturesForce) => {
setSelectedForce(f);
onSelectForce?.(f);
}; };
if (!forces.length) return null; // Sleek Equilateral Geometry in 800x560 coordinate space
// Base width: 650 - 150 = 500, Height: 480 - 50 = 430 (Proportion ~0.86, perfectly sleek and tall)
const apex = { x: 400, y: 55 };
const rightCorner = { x: 650, y: 480 };
const leftCorner = { x: 150, y: 480 };
const centroid = { x: 400, y: 338 };
const selectedGroup = selected const currentGroup = GROUPS.find((g) => g.type === selectedForce?.forceType) ?? GROUPS[0];
? GROUPS.find((g) => g.type === selected.forceType)!
: GROUPS[0];
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-6 select-none animate-in fade-in duration-300">
<Card data-card> {/* 1. VISUAL TRIANGLE CANVAS (Sleek, Dynamic & Equilateral) */}
<CardContent className="p-3 sm:p-6"> <Card data-card className="relative overflow-hidden border border-border/80 p-6 lg:p-8 bg-card shadow-sm">
<svg {/* Subtle Ambient Background Lighting */}
viewBox="0 0 640 470" <div className="pointer-events-none absolute top-4 left-1/2 -translate-x-1/2 w-[420px] h-[180px] bg-purple-500/5 rounded-full blur-3xl" />
className="mx-auto h-auto w-full max-w-[620px]" <div className="pointer-events-none absolute bottom-12 right-12 w-60 h-60 bg-sky-500/5 rounded-full blur-3xl" />
role="img" <div className="pointer-events-none absolute bottom-12 left-12 w-60 h-60 bg-rose-500/5 rounded-full blur-3xl" />
aria-label="مثلث آینده: کشش آینده، فشارهای حال و وزن گذشته"
{/* Top Vertex Header (Pull) */}
<div className="flex flex-col items-center justify-center mb-1 z-10">
<button
onClick={() => pullForces[0] && handleSelect(pullForces[0])}
className="flex items-center gap-2 bg-purple-50 border border-purple-200 px-4 py-1.5 rounded-full shadow-xs text-purple-950 font-bold text-xs hover:bg-purple-100 transition-colors"
> >
{/* The triangle itself */} <Sparkles size={15} className="text-purple-600" />
<polygon <span>کشش آینده تصویر آرمانی دانشگاه</span>
points={`${APEX.x},${APEX.y} ${RIGHT.x},${RIGHT.y} ${LEFT.x},${LEFT.y}`} <span className="text-[11px] bg-purple-200/80 text-purple-900 px-2 py-0.2 rounded-full font-bold">
fill="var(--muted)" {toPersianDigits(pullForces.length)} نیرو · میانگین {toPersianDigits(pullAvg.toFixed(1))}
fillOpacity="0.5" </span>
stroke="var(--border)" </button>
strokeWidth="1.5"
/>
{/* Each vertex is scaled by the average strength of its group,
so the shape shows which way the university is being pulled. */}
{GROUPS.map((group, i) => {
const vertex = [APEX, RIGHT, LEFT][i];
const avg = strength(group.type);
const r = 26 + avg * 2.6;
const count = grouped[group.type].length;
return (
<g key={group.type}>
<circle
cx={vertex.x}
cy={vertex.y}
r={r}
fill={group.color}
fillOpacity="0.12"
stroke={group.color}
strokeWidth="1.5"
/>
<text
x={vertex.x}
y={vertex.y - 4}
textAnchor="middle"
fontSize="12"
fontWeight="700"
fill={group.color}
>
{group.label}
</text>
<text
x={vertex.x}
y={vertex.y + 13}
textAnchor="middle"
fontSize="11"
fill="var(--muted-foreground)"
>
{toPersianDigits(count)} نیرو · {toPersianDigits(avg.toFixed(1))}
</text>
</g>
);
})}
{/* The university sits at the centroid, between the three pulls. */}
<circle cx="320" cy="292" r="5" fill="var(--primary)" />
<text
x="320"
y="313"
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="var(--muted-foreground)"
>
دانشگاه اصفهان
</text>
</svg>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
{/* The three force lists */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 xl:col-span-7">
{GROUPS.map((group) => (
<Card data-card key={group.type} className="gap-0 p-0">
<CardHeader className="gap-0.5 border-b px-3.5 py-3">
<CardTitle className="flex items-center gap-2 text-[13.5px]">
<span
className="inline-block size-2 rounded-full"
style={{ background: group.color }}
/>
{group.label}
<span className="text-[10.5px] font-medium tracking-wide text-muted-foreground">
{group.latin}
</span>
</CardTitle>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{group.hint}
</p>
</CardHeader>
<CardContent className="p-1.5">
<ul className="flex flex-col gap-0.5">
{grouped[group.type].map((force) => {
const active = selected?.id === force.id;
return (
<li key={force.id}>
<button
onClick={() => pick(force)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-right text-[12px] transition-colors',
active ? 'bg-muted font-semibold' : 'hover:bg-muted/60'
)}
>
<span className="min-w-0 flex-1 truncate">{force.title}</span>
<span
className="tnum shrink-0 rounded px-1 py-0.5 text-[10.5px] font-semibold"
style={{ background: group.soft, color: group.color }}
>
{toPersianDigits(force.strength)}
</span>
</button>
</li>
);
})}
</ul>
</CardContent>
</Card>
))}
</div> </div>
{/* Detail of the selected force */} {/* SVG Triangle Graphic */}
{selected && ( <div className="relative w-full max-w-[700px] mx-auto select-none my-1">
<Card data-card className="xl:col-span-5"> <svg
<CardHeader className="border-b pb-3"> viewBox="0 0 800 550"
<div className="flex items-center gap-2"> className="w-full h-auto drop-shadow-sm overflow-visible"
<span role="img"
className="rounded px-1.5 py-0.5 text-[10.5px] font-semibold" aria-label="مثلث هوشمندی آینده دانشگاه اصفهان"
style={{ background: selectedGroup.soft, color: selectedGroup.color }} >
> <defs>
{selectedGroup.label} {/* Dynamic Gradient Fills */}
</span> <linearGradient id="futuresTriangleGrad" x1="50%" y1="0%" x2="50%" y2="100%">
<span className="tnum text-[11px] text-muted-foreground"> <stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.12" />
شدت {toPersianDigits(selected.strength)} از ۱۰ <stop offset="60%" stopColor="#0284c7" stopOpacity="0.07" />
</span> <stop offset="100%" stopColor="#f43f5e" stopOpacity="0.12" />
</div> </linearGradient>
<CardTitle className="mt-1.5 text-[15px] leading-relaxed">
{selected.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 pt-4"> {/* Edge Gradient: Left (PULL -> WEIGHT) */}
<DetailBlock title="شواهد" body={selected.evidence} /> <linearGradient id="pullToWeightGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<DetailBlock title="اثر" body={selected.impact} /> <stop offset="0%" stopColor="#8b5cf6" />
<DetailBlock title="جهت حرکت" body={selected.direction} /> <stop offset="100%" stopColor="#f43f5e" />
<DetailBlock title="دلالت راهبردی" body={selected.strategicImplication} /> </linearGradient>
</CardContent>
</Card> {/* Edge Gradient: Right (PULL -> PUSH) */}
)} <linearGradient id="pullToPushGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#0284c7" />
</linearGradient>
{/* Edge Gradient: Bottom (WEIGHT <-> PUSH) */}
<linearGradient id="weightToPushGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#f43f5e" />
<stop offset="100%" stopColor="#0284c7" />
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#0f172a" floodOpacity="0.12" />
</filter>
</defs>
{/* REAL SOLID SLEEK GEOMETRIC TRIANGLE */}
<polygon
points={`${apex.x},${apex.y} ${rightCorner.x},${rightCorner.y} ${leftCorner.x},${leftCorner.y}`}
fill="url(#futuresTriangleGrad)"
stroke="#cbd5e1"
strokeWidth="2.5"
strokeLinejoin="round"
/>
{/* INNER EQUILIBRIUM DASHED TRIANGLE */}
<polygon
points={`${apex.x},${apex.y + 65} ${rightCorner.x - 55},${rightCorner.y - 35} ${leftCorner.x + 55},${leftCorner.y - 35}`}
fill="none"
stroke="#94a3b8"
strokeWidth="1.2"
strokeDasharray="5,5"
opacity="0.65"
/>
{/* CENTROID TETHERS */}
<line
x1={centroid.x}
y1={centroid.y}
x2={apex.x}
y2={apex.y}
stroke="#8b5cf6"
strokeWidth="2"
strokeDasharray="4,4"
opacity="0.75"
/>
<line
x1={centroid.x}
y1={centroid.y}
x2={rightCorner.x}
y2={rightCorner.y}
stroke="#0284c7"
strokeWidth="2"
strokeDasharray="4,4"
opacity="0.75"
/>
<line
x1={centroid.x}
y1={centroid.y}
x2={leftCorner.x}
y2={leftCorner.y}
stroke="#f43f5e"
strokeWidth="2"
strokeDasharray="4,4"
opacity="0.75"
/>
{/* PROMINENT SLEEK EDGES */}
{/* Right Edge: PULL to PUSH */}
<line
x1={apex.x}
y1={apex.y}
x2={rightCorner.x}
y2={rightCorner.y}
stroke="url(#pullToPushGrad)"
strokeWidth="3.5"
strokeLinecap="round"
/>
{/* Left Edge: PULL to WEIGHT */}
<line
x1={apex.x}
y1={apex.y}
x2={leftCorner.x}
y2={leftCorner.y}
stroke="url(#pullToWeightGrad)"
strokeWidth="3.5"
strokeLinecap="round"
/>
{/* Bottom Edge: WEIGHT to PUSH */}
<line
x1={leftCorner.x}
y1={leftCorner.y}
x2={rightCorner.x}
y2={rightCorner.y}
stroke="url(#weightToPushGrad)"
strokeWidth="3.5"
strokeLinecap="round"
/>
{/* Rotated Edge Vector Midpoint Badges */}
{/* Right edge midpoint */}
<g transform={`translate(${(apex.x + rightCorner.x) / 2 + 35}, ${(apex.y + rightCorner.y) / 2}) rotate(60)`}>
<rect x="-68" y="-12" width="136" height="24" rx="12" fill="#ffffff" stroke="#bae6fd" strokeWidth="1" filter="url(#softShadow)" />
<text textAnchor="middle" y="4.5" fill="#0284c7" fontSize="10.5" fontWeight="800" style={{ fontFamily: 'Vazirmatn, sans-serif' }}>
پویش روندهای زمان حال
</text>
</g>
{/* Left edge midpoint */}
<g transform={`translate(${(apex.x + leftCorner.x) / 2 - 35}, ${(apex.y + leftCorner.y) / 2}) rotate(-60)`}>
<rect x="-68" y="-12" width="136" height="24" rx="12" fill="#ffffff" stroke="#fecdd3" strokeWidth="1" filter="url(#softShadow)" />
<text textAnchor="middle" y="4.5" fill="#e11d48" fontSize="10.5" fontWeight="800" style={{ fontFamily: 'Vazirmatn, sans-serif' }}>
مهار توسط لنگرهای گذشته
</text>
</g>
{/* Bottom edge midpoint */}
<g transform={`translate(${centroid.x}, ${leftCorner.y + 24})`}>
<rect x="-95" y="-13" width="190" height="26" rx="13" fill="#ffffff" stroke="#e2e8f0" strokeWidth="1" filter="url(#softShadow)" />
<text textAnchor="middle" y="4.5" fill="#334155" fontSize="11" fontWeight="800" style={{ fontFamily: 'Vazirmatn, sans-serif' }}>
تعارض سنت ساختاری و شتاب تحول
</text>
</g>
{/* CENTRAL HUB: دانشگاه اصفهان (At Centroid) */}
<g transform={`translate(${centroid.x}, ${centroid.y})`}>
<circle r="44" fill="#ffffff" stroke="#00b894" strokeWidth="3" filter="url(#softShadow)" />
<circle r="36" fill="#e6f7f3" />
<circle r="12" fill="#00b894" className="anim-pulse" opacity="0.3" />
<circle r="6" fill="#00b894" />
<text textAnchor="middle" y="-11" fill="#0f172a" fontSize="11" fontWeight="900" style={{ fontFamily: 'Vazirmatn, sans-serif' }}>
دانشگاه اصفهان
</text>
<text textAnchor="middle" y="24" fill="#008f73" fontSize="9.5" fontWeight="800" style={{ fontFamily: 'Vazirmatn, sans-serif' }}>
افق ۱۴۱۵
</text>
</g>
{/* VERTEX CORNER NODES */}
{/* Top Vertex (PULL) */}
<g transform={`translate(${apex.x}, ${apex.y})`} className="cursor-pointer" onClick={() => pullForces[0] && handleSelect(pullForces[0])}>
<circle r="22" fill="#ffffff" stroke="#8b5cf6" strokeWidth="3.5" filter="url(#softShadow)" />
<circle r="9" fill="#8b5cf6" />
</g>
{/* Bottom-Right Vertex (PUSH) */}
<g transform={`translate(${rightCorner.x}, ${rightCorner.y})`} className="cursor-pointer" onClick={() => pushForces[0] && handleSelect(pushForces[0])}>
<circle r="22" fill="#ffffff" stroke="#0284c7" strokeWidth="3.5" filter="url(#softShadow)" />
<circle r="9" fill="#0284c7" />
</g>
{/* Bottom-Left Vertex (WEIGHT) */}
<g transform={`translate(${leftCorner.x}, ${leftCorner.y})`} className="cursor-pointer" onClick={() => weightForces[0] && handleSelect(weightForces[0])}>
<circle r="22" fill="#ffffff" stroke="#f43f5e" strokeWidth="3.5" filter="url(#softShadow)" />
<circle r="9" fill="#f43f5e" />
</g>
</svg>
</div>
{/* Bottom Vertex Tags (Push & Weight) */}
<div className="w-full flex items-center justify-between pt-2 px-2 z-10">
{/* Bottom-Left Tag (Weight) */}
<button
onClick={() => weightForces[0] && handleSelect(weightForces[0])}
className="flex items-center gap-2 bg-rose-50 border border-rose-200 px-3.5 py-1.5 rounded-full shadow-xs text-rose-950 font-bold text-xs hover:bg-rose-100 transition-colors"
>
<ShieldAlert size={15} className="text-rose-600" />
<span>وزن گذشته</span>
<span className="text-[10.5px] bg-rose-200/80 text-rose-900 px-2 py-0.2 rounded-full font-bold">
{toPersianDigits(weightForces.length)} نیرو · {toPersianDigits(weightAvg.toFixed(1))}
</span>
</button>
{/* Bottom-Right Tag (Push) */}
<button
onClick={() => pushForces[0] && handleSelect(pushForces[0])}
className="flex items-center gap-2 bg-sky-50 border border-sky-200 px-3.5 py-1.5 rounded-full shadow-xs text-sky-950 font-bold text-xs hover:bg-sky-100 transition-colors"
>
<TrendingUp size={15} className="text-sky-600" />
<span>فشارهای زمان حال</span>
<span className="text-[10.5px] bg-sky-200/80 text-sky-900 px-2 py-0.2 rounded-full font-bold">
{toPersianDigits(pushForces.length)} نیرو · {toPersianDigits(pushAvg.toFixed(1))}
</span>
</button>
</div>
</Card>
{/* 2. THREE FULL-WIDTH CATEGORY BENTO CARDS (Pull, Push, Weight) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{GROUPS.map((grp) => {
const list =
grp.type === 'pull'
? pullForces
: grp.type === 'push'
? pushForces
: weightForces;
const avg = grp.type === 'pull' ? pullAvg : grp.type === 'push' ? pushAvg : weightAvg;
return (
<Card
data-card
key={grp.type}
className={`border ${grp.border} ${grp.bgSoft} p-4 flex flex-col justify-between shadow-xs transition-all`}
>
{/* Card Header */}
<div className="pb-3 border-b border-slate-200/70">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 font-black text-sm text-slate-900">
{grp.icon}
<span>{grp.label}</span>
</div>
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-full ${grp.badgeBg} ${grp.badgeText}`}>
میانگین: {toPersianDigits(avg.toFixed(1))}
</span>
</div>
<p className="text-[11px] text-slate-500 font-medium leading-relaxed">
{grp.subtitle}
</p>
</div>
{/* Force Item Rows */}
<div className="flex flex-col gap-1.5 mt-3">
{list.map((f) => {
const isSelected = selectedForce?.id === f.id;
return (
<button
key={f.id}
onClick={() => handleSelect(f)}
className={`w-full flex items-center justify-between p-2 rounded-xl text-xs font-bold transition-all text-right ${
isSelected
? 'bg-slate-900 text-white shadow-sm'
: 'bg-white/80 hover:bg-white text-slate-800 border border-slate-200/60 shadow-2xs'
}`}
>
<span className="truncate flex-1 pl-2">{cleanTitle(f.title)}</span>
<span
className={`text-[10.5px] px-2 py-0.5 rounded-full font-black shrink-0 ${
isSelected
? 'bg-slate-800 text-teal-300'
: `${grp.badgeBg} ${grp.badgeText}`
}`}
>
{toPersianDigits(f.strength)}
</span>
</button>
);
})}
</div>
</Card>
);
})}
</div> </div>
{/* 3. SELECTED FORCE DETAILED INTELLIGENCE DOSSIER CARD */}
{selectedForce && (
<Card data-card className="border border-border bg-white rounded-3xl p-6 shadow-sm flex flex-col gap-4 animate-in fade-in duration-200">
{/* Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3.5 border-b border-slate-100">
<div className="flex items-center gap-3">
<span
className="w-3.5 h-3.5 rounded-full shadow-xs shrink-0"
style={{ backgroundColor: currentGroup.color }}
/>
<h3 className="text-slate-900 font-black text-base lg:text-lg">
{cleanTitle(selectedForce.title)}
</h3>
<span className={`text-xs px-3 py-1 rounded-full font-bold ${currentGroup.badgeBg} ${currentGroup.badgeText}`}>
{currentGroup.label}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 font-bold">شدت اثر نیرو:</span>
<span className="text-base font-black text-slate-900 bg-slate-100 px-3 py-0.5 rounded-full border border-slate-200">
{toPersianDigits(selectedForce.strength)} از ۱۰
</span>
</div>
</div>
{/* 4 Analytical Dossier Quadrants */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
{/* Quadrant 1: Evidence */}
<div className="p-4 bg-slate-50 border border-slate-200/80 rounded-2xl flex flex-col gap-1">
<span className="text-slate-900 font-black text-xs block mb-1">
شواهد و مؤیدهای تجربی:
</span>
<p className="text-slate-600 leading-relaxed text-[11.5px] font-medium">
{selectedForce.evidence}
</p>
</div>
{/* Quadrant 2: Strategic Impact */}
<div className="p-4 bg-slate-50 border border-slate-200/80 rounded-2xl flex flex-col gap-1">
<span className="text-slate-900 font-black text-xs block mb-1">
پیامد راهبردی برای دانشگاه اصفهان:
</span>
<p className="text-slate-600 leading-relaxed text-[11.5px] font-medium">
{selectedForce.impact}
</p>
</div>
{/* Quadrant 3: Direction of Movement */}
<div className="p-4 bg-slate-50 border border-slate-200/80 rounded-2xl flex flex-col gap-1">
<span className="text-slate-900 font-black text-xs block mb-1">
جهت و بردار حرکت نیرو:
</span>
<p className="text-slate-600 leading-relaxed text-[11.5px] font-medium">
{selectedForce.direction}
</p>
</div>
{/* Quadrant 4: Strategic Action Recommendation */}
<div className="p-4 bg-teal-50/80 border border-teal-200 rounded-2xl flex flex-col gap-1">
<span className="text-teal-950 font-black text-xs block mb-1">
اقدام راهبردی جهت غلبه / همافزایی:
</span>
<p className="text-teal-900 leading-relaxed text-[11.5px] font-semibold">
{selectedForce.strategicImplication}
</p>
</div>
</div>
</Card>
)}
</div> </div>
); );
}; };
const DetailBlock: React.FC<{ title: string; body: string }> = ({ title, body }) => (
<div>
<div className="mb-1 text-[11px] font-semibold text-muted-foreground">{title}</div>
<p className="text-[12.5px] leading-relaxed">{body}</p>
</div>
);

View File

@ -398,7 +398,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
category: 'trend', category: 'trend',
executiveSummary: 'کاهش نرخ زادآوری دهه‌های اخیر و انقباض شدید جامعه داوطلبان کنکور سراسری، به ویژه در استان اصفهان و مناطق پیرامونی.', executiveSummary: 'کاهش نرخ زادآوری دهه‌های اخیر و انقباض شدید جامعه داوطلبان کنکور سراسری، به ویژه در استان اصفهان و مناطق پیرامونی.',
whyItMatters: 'دانشگاه اصفهان با خطر مازاد ظرفیت کالبدی و انسانی در برخی دانشکده‌ها مواجه خواهد شد و باید مدل جذب خود را به سمت دانشجویان بین‌الملل و آموزش بزرگسالان تغییر دهد.', whyItMatters: 'دانشگاه اصفهان با خطر مازاد ظرفیت کالبدی و انسانی در برخی دانشکده‌ها مواجه خواهد شد و باید مدل جذب خود را به سمت دانشجویان بین‌الملل و آموزش بزرگسالان تغییر دهد.',
horizon: 'now', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -503,7 +503,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
category: 'trend', category: 'trend',
executiveSummary: 'فشار جامعه و دولت‌ها برای تبدیل خروجی‌های پژوهشی به راه‌حل‌های ملموس برای بحران‌های محلی نظیر آب، انرژی، محیط زیست و سلامت روان.', executiveSummary: 'فشار جامعه و دولت‌ها برای تبدیل خروجی‌های پژوهشی به راه‌حل‌های ملموس برای بحران‌های محلی نظیر آب، انرژی، محیط زیست و سلامت روان.',
whyItMatters: 'دانشگاه اصفهان در استانی واقع شده که با چالش بحران زاینده‌رود و فرونشست مواجه است؛ بی‌پاسخ گذاشتن این مسائل اعتبار اجتماعی دانشگاه را تضعیف می‌کند.', whyItMatters: 'دانشگاه اصفهان در استانی واقع شده که با چالش بحران زاینده‌رود و فرونشست مواجه است؛ بی‌پاسخ گذاشتن این مسائل اعتبار اجتماعی دانشگاه را تضعیف می‌کند.',
horizon: 'now', horizon: 'near',
impactScore: 5, impactScore: 5,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -659,7 +659,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
category: 'trend', category: 'trend',
executiveSummary: 'گذار صنایع سنگین اصفهان (فولاد، پتروشیمی، نساجی) به سوی اتوماسیون، کاهش مصرف آب و هوشمندسازی، و نیاز مبرم به مهارت‌های تحول دیجیتال.', executiveSummary: 'گذار صنایع سنگین اصفهان (فولاد، پتروشیمی، نساجی) به سوی اتوماسیون، کاهش مصرف آب و هوشمندسازی، و نیاز مبرم به مهارت‌های تحول دیجیتال.',
whyItMatters: 'رشته‌های دانشگاه اصفهان باید فارغ‌التحصیلانی تربیت کنند که بتوانند بحران بهره‌وری آب و انرژی را در صنایع کلیدی استان حل کنند.', whyItMatters: 'رشته‌های دانشگاه اصفهان باید فارغ‌التحصیلانی تربیت کنند که بتوانند بحران بهره‌وری آب و انرژی را در صنایع کلیدی استان حل کنند.',
horizon: 'near', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -711,7 +711,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
category: 'trend', category: 'trend',
executiveSummary: 'پایان دوران انحصار ناشران تجاری بزرگ (الزویر، اشپرینگر) و فراگیر شدن انتشار دسترسی باز (Open Access) با مخازن داده‌های پژوهشی باز.', executiveSummary: 'پایان دوران انحصار ناشران تجاری بزرگ (الزویر، اشپرینگر) و فراگیر شدن انتشار دسترسی باز (Open Access) با مخازن داده‌های پژوهشی باز.',
whyItMatters: 'شفافیت پژوهش‌ها، تکرارپذیری یافته‌های علمی و ارتقای شانس دیده‌شدن دستاوردهای دانشمندان دانشگاه اصفهان در سطح جهانی.', whyItMatters: 'شفافیت پژوهش‌ها، تکرارپذیری یافته‌های علمی و ارتقای شانس دیده‌شدن دستاوردهای دانشمندان دانشگاه اصفهان در سطح جهانی.',
horizon: 'near', horizon: 'long',
impactScore: 3, impactScore: 3,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -763,7 +763,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
category: 'trend', category: 'trend',
executiveSummary: 'گرمایش زمین، تنش آبی کم‌سابقه در اصفهان و ناترازی‌های برق و گاز که فعالیت مستمر دانشگاه‌ها را در فصول گرما و سرما تهدید می‌کند.', executiveSummary: 'گرمایش زمین، تنش آبی کم‌سابقه در اصفهان و ناترازی‌های برق و گاز که فعالیت مستمر دانشگاه‌ها را در فصول گرما و سرما تهدید می‌کند.',
whyItMatters: 'تعطیلی‌های پی‌درپی ناشی از آلودگی و ناترازی انرژی تقویم آموزشی را مختل کرده و ضرورت تبدیل دانشگاه به الگوی کربن‌خنثی را ایجاب می‌کند.', whyItMatters: 'تعطیلی‌های پی‌درپی ناشی از آلودگی و ناترازی انرژی تقویم آموزشی را مختل کرده و ضرورت تبدیل دانشگاه به الگوی کربن‌خنثی را ایجاب می‌کند.',
horizon: 'now', horizon: 'near',
impactScore: 4, impactScore: 4,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -815,7 +815,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
category: 'trend', category: 'trend',
executiveSummary: 'بازنشستگی موج بزرگی از اساتید استخدام‌شده دهه‌های ۶۰ و ۷۰ و ورود نسل اساتید جوان دیجیتال‌محور با مطالبات و سبک زندگی متفاوت.', executiveSummary: 'بازنشستگی موج بزرگی از اساتید استخدام‌شده دهه‌های ۶۰ و ۷۰ و ورود نسل اساتید جوان دیجیتال‌محور با مطالبات و سبک زندگی متفاوت.',
whyItMatters: 'فرصت بی‌نظیر برای پوست‌اندازی فکری، دیجیتال‌سازی دروس و تغییر نگرش پژوهشی دانشگاه اصفهان به شرط جذب شایسته‌سالارانه.', whyItMatters: 'فرصت بی‌نظیر برای پوست‌اندازی فکری، دیجیتال‌سازی دروس و تغییر نگرش پژوهشی دانشگاه اصفهان به شرط جذب شایسته‌سالارانه.',
horizon: 'near', horizon: 'long',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -1240,7 +1240,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
category: 'technology', category: 'technology',
executiveSummary: 'دفتر کل توزیع‌شده برای ثبت دانشنامه‌ها، ریزنمرات و گواهینامه‌های مهارتی بدون امکان جعل و با اعتبارسنجی آنی در سطح جهانی.', executiveSummary: 'دفتر کل توزیع‌شده برای ثبت دانشنامه‌ها، ریزنمرات و گواهینامه‌های مهارتی بدون امکان جعل و با اعتبارسنجی آنی در سطح جهانی.',
whyItMatters: 'حذف کامل جعل مدارک دانشگاهی، تسریع استخدام بین‌المللی فارغ‌التحصیلان دانشگاه اصفهان و تسهیل انتقال اعتبار آموزشی.', whyItMatters: 'حذف کامل جعل مدارک دانشگاهی، تسریع استخدام بین‌المللی فارغ‌التحصیلان دانشگاه اصفهان و تسهیل انتقال اعتبار آموزشی.',
horizon: 'near', horizon: 'mid',
impactScore: 3, impactScore: 3,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -1292,7 +1292,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
category: 'technology', category: 'technology',
executiveSummary: 'استقرار سنسورهای متصل در شبکه‌های هوای پاک، چاه‌های آب، ایستگاه‌های هواشناسی و کلاس‌های درس با پردازش سریع محلی.', executiveSummary: 'استقرار سنسورهای متصل در شبکه‌های هوای پاک، چاه‌های آب، ایستگاه‌های هواشناسی و کلاس‌های درس با پردازش سریع محلی.',
whyItMatters: 'مدیریت بهینه منابع در بحران آب اصفهان و تنظیم خودکار روشنایی و تهویه برای به حداقل رساندن هزینه‌های قبوض مصرفی دانشگاه.', whyItMatters: 'مدیریت بهینه منابع در بحران آب اصفهان و تنظیم خودکار روشنایی و تهویه برای به حداقل رساندن هزینه‌های قبوض مصرفی دانشگاه.',
horizon: 'now', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -1397,7 +1397,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
category: 'technology', category: 'technology',
executiveSummary: 'اشتراک منابع محاسباتی و داده‌ای میان دانشگاه‌های استان و کشور بدون نیاز به انتقال داده‌های خام محرمانه، با حفظ حریم خصوصی.', executiveSummary: 'اشتراک منابع محاسباتی و داده‌ای میان دانشگاه‌های استان و کشور بدون نیاز به انتقال داده‌های خام محرمانه، با حفظ حریم خصوصی.',
whyItMatters: 'دسترسی پژوهشگران دانشگاه اصفهان به توان پردازشی عظیم اشتراکی دانشگاه صنعتی اصفهان و تهران بدون هزینه مضاعف خرید سخت‌افزار.', whyItMatters: 'دسترسی پژوهشگران دانشگاه اصفهان به توان پردازشی عظیم اشتراکی دانشگاه صنعتی اصفهان و تهران بدون هزینه مضاعف خرید سخت‌افزار.',
horizon: 'near', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -1553,7 +1553,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
category: 'technology', category: 'technology',
executiveSummary: 'ارزیابی‌های آنلاین تعاملی که به جای تکیه بر دوربین مداربسته، سبک حل مسئله، فرآیند گام‌به‌گام استدلال و دفاع شفاهی را ارزیابی می‌کنند.', executiveSummary: 'ارزیابی‌های آنلاین تعاملی که به جای تکیه بر دوربین مداربسته، سبک حل مسئله، فرآیند گام‌به‌گام استدلال و دفاع شفاهی را ارزیابی می‌کنند.',
whyItMatters: 'جایگزینی امتحان سنتی با ارزیابی فرآیندی جهت حفظ اعتبار نمرات در دوره‌های ترکیبی و مجازی.', whyItMatters: 'جایگزینی امتحان سنتی با ارزیابی فرآیندی جهت حفظ اعتبار نمرات در دوره‌های ترکیبی و مجازی.',
horizon: 'now', horizon: 'long',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -1605,7 +1605,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
category: 'technology', category: 'technology',
executiveSummary: 'تحلیل تصاویر راداری ماهواره‌ای (InSAR) با هوش مصنوعی برای پایش میلیمتر به میلیمتر فرونشست زمین در دشت اصفهان و ترک‌خوردگی ابنیه.', executiveSummary: 'تحلیل تصاویر راداری ماهواره‌ای (InSAR) با هوش مصنوعی برای پایش میلیمتر به میلیمتر فرونشست زمین در دشت اصفهان و ترک‌خوردگی ابنیه.',
whyItMatters: 'هشدار زودهنگام در مورد ایمنی ساختمان‌های تاریخی و آزمایشگاه‌های پیشرفته پردیس و ارائه نقشه‌های راهبردی به مدیریت بحران استان.', whyItMatters: 'هشدار زودهنگام در مورد ایمنی ساختمان‌های تاریخی و آزمایشگاه‌های پیشرفته پردیس و ارائه نقشه‌های راهبردی به مدیریت بحران استان.',
horizon: 'now', horizon: 'near',
impactScore: 5, impactScore: 5,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -1657,7 +1657,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
category: 'technology', category: 'technology',
executiveSummary: 'غشاهای پلیمری نانوساختار و بیوراکتورهای غشایی پیشرفته برای تصفیه فاضلاب شهری و صنعتی و تبدیل آن به آب فوق خالص صنعتی و کشاورزی.', executiveSummary: 'غشاهای پلیمری نانوساختار و بیوراکتورهای غشایی پیشرفته برای تصفیه فاضلاب شهری و صنعتی و تبدیل آن به آب فوق خالص صنعتی و کشاورزی.',
whyItMatters: 'ارائه راه‌حل پایدار برای صنایع فولاد و پالایشگاه اصفهان جهت قطع کامل برداشت از زاینده‌رود و تثبیت آب دانشگاه.', whyItMatters: 'ارائه راه‌حل پایدار برای صنایع فولاد و پالایشگاه اصفهان جهت قطع کامل برداشت از زاینده‌رود و تثبیت آب دانشگاه.',
horizon: 'near', horizon: 'mid',
impactScore: 5, impactScore: 5,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -1871,7 +1871,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'امکان تهاتر ۱۰۰٪ مالیات عملکرد شرکت‌های صنعتی با هزینه‌های پژوهشی قراردادهای منعقده با دانشگاه‌های دولتی.', executiveSummary: 'امکان تهاتر ۱۰۰٪ مالیات عملکرد شرکت‌های صنعتی با هزینه‌های پژوهشی قراردادهای منعقده با دانشگاه‌های دولتی.',
whyItMatters: 'فرصت طلایی جذب هزاران میلیارد تومان سرمایه صنعتی از صنایع فولاد، نفت و داروسازی اصفهان به سوی دانشگاه.', whyItMatters: 'فرصت طلایی جذب هزاران میلیارد تومان سرمایه صنعتی از صنایع فولاد، نفت و داروسازی اصفهان به سوی دانشگاه.',
horizon: 'now', horizon: 'near',
impactScore: 5, impactScore: 5,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -1923,7 +1923,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'دستورالعمل جامع تعیین مرزهای مجاز و غیرمجاز استفاده از AI در پایان‌نامه‌ها، مقالات و آزمون‌ها و مجازات تقلب الگوریتمی.', executiveSummary: 'دستورالعمل جامع تعیین مرزهای مجاز و غیرمجاز استفاده از AI در پایان‌نامه‌ها، مقالات و آزمون‌ها و مجازات تقلب الگوریتمی.',
whyItMatters: 'حفظ اعتبار دانشنامه‌های دانشگاه اصفهان و جلوگیری از بی‌اعتباری بین‌المللی پژوهش‌های دانشگاه.', whyItMatters: 'حفظ اعتبار دانشنامه‌های دانشگاه اصفهان و جلوگیری از بی‌اعتباری بین‌المللی پژوهش‌های دانشگاه.',
horizon: 'now', horizon: 'near',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2079,7 +2079,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'کاهش تشریفات صدور ویزای تحصیلی، امکان افتتاح حساب بانکی بدون محدودیت و مجوز کار پاره‌وقت دانشجویی برای اتباع خارجی.', executiveSummary: 'کاهش تشریفات صدور ویزای تحصیلی، امکان افتتاح حساب بانکی بدون محدودیت و مجوز کار پاره‌وقت دانشجویی برای اتباع خارجی.',
whyItMatters: 'رشد سریع جمعیت دانشجویان بین‌الملل و تسهیل جذب پژوهشگران پسادکتری خارجی در دانشگاه اصفهان.', whyItMatters: 'رشد سریع جمعیت دانشجویان بین‌الملل و تسهیل جذب پژوهشگران پسادکتری خارجی در دانشگاه اصفهان.',
horizon: 'now', horizon: 'near',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2131,7 +2131,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'اختیار کامل دانشگاه اصفهان در تغییر تا ۴۰٪ محتوای دروس سرفصل‌های مصوب شورای تحول بدون نیاز به استعلام از وزارتخانه.', executiveSummary: 'اختیار کامل دانشگاه اصفهان در تغییر تا ۴۰٪ محتوای دروس سرفصل‌های مصوب شورای تحول بدون نیاز به استعلام از وزارتخانه.',
whyItMatters: 'امکان انطباق فوری برنامه‌های درسی با نیازهای مهارتی بازار کار اصفهان بدون معطلی‌های چندساله اداری.', whyItMatters: 'امکان انطباق فوری برنامه‌های درسی با نیازهای مهارتی بازار کار اصفهان بدون معطلی‌های چندساله اداری.',
horizon: 'now', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -2183,7 +2183,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'تاکید بر حل مسائل عینی جامعه، سیاست‌گذاری عمومی، اخلاق کاربردی و پیوند علوم انسانی با فناوری‌های نوظهور.', executiveSummary: 'تاکید بر حل مسائل عینی جامعه، سیاست‌گذاری عمومی، اخلاق کاربردی و پیوند علوم انسانی با فناوری‌های نوظهور.',
whyItMatters: 'دانشکده ادبیات و علوم انسانی دانشگاه اصفهان با بیش از ۵۰ سال سابقه، نقشی بی‌بدیل در بازآفرینی انسان‌گرایانه عصر دیجیتال دارد.', whyItMatters: 'دانشکده ادبیات و علوم انسانی دانشگاه اصفهان با بیش از ۵۰ سال سابقه، نقشی بی‌بدیل در بازآفرینی انسان‌گرایانه عصر دیجیتال دارد.',
horizon: 'near', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2287,7 +2287,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'تأکید بر همکاری‌های پژوهشی، فرصت‌های مطالعاتی و انتشار مقالات مشترک با کشورهای بریکس، شانگهای و جهان اسلام.', executiveSummary: 'تأکید بر همکاری‌های پژوهشی، فرصت‌های مطالعاتی و انتشار مقالات مشترک با کشورهای بریکس، شانگهای و جهان اسلام.',
whyItMatters: 'گشایش مسیرهای جدید بین‌المللی با دانشگاه‌های چین، روسیه، هند و برزیل به عنوان جایگزین همکاری‌های محدودشده با غرب.', whyItMatters: 'گشایش مسیرهای جدید بین‌المللی با دانشگاه‌های چین، روسیه، هند و برزیل به عنوان جایگزین همکاری‌های محدودشده با غرب.',
horizon: 'near', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2339,7 +2339,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
category: 'policy', category: 'policy',
executiveSummary: 'دستورالعمل قانونی تقسیم دوره کارشناسی به دوره‌های متناوب ۴ ماه آموزش در پردیس و ۴ ماه کار موظف و حقوق‌بگیر در صنعت.', executiveSummary: 'دستورالعمل قانونی تقسیم دوره کارشناسی به دوره‌های متناوب ۴ ماه آموزش در پردیس و ۴ ماه کار موظف و حقوق‌بگیر در صنعت.',
whyItMatters: 'تضمین اشتغال ۱۰۰٪ فارغ‌التحصیلان، رفع کمبود تکنسین ماهر در صنایع استان و دگرگونی بنیادین انگیزه دانشجویان.', whyItMatters: 'تضمین اشتغال ۱۰۰٪ فارغ‌التحصیلان، رفع کمبود تکنسین ماهر در صنایع استان و دگرگونی بنیادین انگیزه دانشجویان.',
horizon: 'now', horizon: 'long',
impactScore: 5, impactScore: 5,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -2397,7 +2397,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'یک کالج آزمایشی در اسکاندیناوی آموزش را بر پایه یادگیری همتا-به-همتا، هوش مصنوعی شخصی‌ساز و پروژه‌های واقعی بدون حضور عضو هیئت علمی رسمی بنا نهاده است.', executiveSummary: 'یک کالج آزمایشی در اسکاندیناوی آموزش را بر پایه یادگیری همتا-به-همتا، هوش مصنوعی شخصی‌ساز و پروژه‌های واقعی بدون حضور عضو هیئت علمی رسمی بنا نهاده است.',
whyItMatters: 'سیگنال هشداری درباره امکان حذف واسطه استاد در انتقال دانش و بازتعریف ضرورت دانشگاه فیزیکی.', whyItMatters: 'سیگنال هشداری درباره امکان حذف واسطه استاد در انتقال دانش و بازتعریف ضرورت دانشگاه فیزیکی.',
horizon: 'long', horizon: 'now',
impactScore: 4, impactScore: 4,
uncertaintyScore: 5, uncertaintyScore: 5,
confidence: 'medium', confidence: 'medium',
@ -2478,7 +2478,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'داوطلبان علوم ریاضی و فنی به کمترین تعداد در تاریخ آزمون سراسری رسیده‌اند و متقاضیان رشته‌های پایه به شدت افت کرده‌اند.', executiveSummary: 'داوطلبان علوم ریاضی و فنی به کمترین تعداد در تاریخ آزمون سراسری رسیده‌اند و متقاضیان رشته‌های پایه به شدت افت کرده‌اند.',
whyItMatters: 'تهدید شالوده پژوهش‌های بنیادین کشور و خالی ماندن صندلی‌های تاریخی‌ترین دانشکده‌های دانشگاه اصفهان.', whyItMatters: 'تهدید شالوده پژوهش‌های بنیادین کشور و خالی ماندن صندلی‌های تاریخی‌ترین دانشکده‌های دانشگاه اصفهان.',
horizon: 'now', horizon: 'near',
impactScore: 5, impactScore: 5,
uncertaintyScore: 1, uncertaintyScore: 1,
confidence: 'high', confidence: 'high',
@ -2505,7 +2505,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'یک گروه تحقیقاتی ژاپنی با استفاده از یک سیستم ایجنتی، مقاله‌ای کامل شامل فرضیه، آزمایش کد و پیش‌نویس تولید و بدون تغییر در ژورنال داوری همتا چاپ کردند.', executiveSummary: 'یک گروه تحقیقاتی ژاپنی با استفاده از یک سیستم ایجنتی، مقاله‌ای کامل شامل فرضیه، آزمایش کد و پیش‌نویس تولید و بدون تغییر در ژورنال داوری همتا چاپ کردند.',
whyItMatters: 'زنگ خطری جدی برای فرآیند پژوهش؛ ارزش افزوده محقق در دهه آینده نه اجرای آزمایش بلکه طرح سوال اصیل خواهد بود.', whyItMatters: 'زنگ خطری جدی برای فرآیند پژوهش؛ ارزش افزوده محقق در دهه آینده نه اجرای آزمایش بلکه طرح سوال اصیل خواهد بود.',
horizon: 'mid', horizon: 'near',
impactScore: 4, impactScore: 4,
uncertaintyScore: 3, uncertaintyScore: 3,
confidence: 'high', confidence: 'high',
@ -2559,7 +2559,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'جمعی از دانشجویان دانشکده ادبیات دانشگاه اصفهان با ابزارهای هوش مصنوعی اقدام به بازآفرینی شاهنامه و متون تاریخی در قالب مینی‌سریال‌های تعاملی کردند.', executiveSummary: 'جمعی از دانشجویان دانشکده ادبیات دانشگاه اصفهان با ابزارهای هوش مصنوعی اقدام به بازآفرینی شاهنامه و متون تاریخی در قالب مینی‌سریال‌های تعاملی کردند.',
whyItMatters: 'نشانه‌ای از زایش کارآفرینی دیجیتال در دل علوم انسانی سنتی و ظرفیت تجاری‌سازی میراث ادبی.', whyItMatters: 'نشانه‌ای از زایش کارآفرینی دیجیتال در دل علوم انسانی سنتی و ظرفیت تجاری‌سازی میراث ادبی.',
horizon: 'now', horizon: 'near',
impactScore: 3, impactScore: 3,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2586,7 +2586,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'یکی از صنعتگران سرشناس اصفهان اعلام کرده حاضر است سرمایه وقفی خود را به جای ساخت خوابگاه، صرفاً برای تجهیز سوپرکامپیوتر دانشگاه اختصاص دهد.', executiveSummary: 'یکی از صنعتگران سرشناس اصفهان اعلام کرده حاضر است سرمایه وقفی خود را به جای ساخت خوابگاه، صرفاً برای تجهیز سوپرکامپیوتر دانشگاه اختصاص دهد.',
whyItMatters: 'تحول در سنت خیرین دانشگاهی از وقف آجر و ساختمان به وقف فناوری و دانش پیشرفته.', whyItMatters: 'تحول در سنت خیرین دانشگاهی از وقف آجر و ساختمان به وقف فناوری و دانش پیشرفته.',
horizon: 'now', horizon: 'long',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2667,7 +2667,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'بیش از ۶۰ درصد نخبگان فارغ‌التحصیل دکتری دانشگاه اصفهان به دلیل تفاوت دستمزدها به جای عضویت هیئت علمی در اصفهان جذب پلتفرم‌های خصوصی پایتخت می‌شوند.', executiveSummary: 'بیش از ۶۰ درصد نخبگان فارغ‌التحصیل دکتری دانشگاه اصفهان به دلیل تفاوت دستمزدها به جای عضویت هیئت علمی در اصفهان جذب پلتفرم‌های خصوصی پایتخت می‌شوند.',
whyItMatters: 'فرسایش سرمایه انسانی و دشواری جذب اساتید تراز اول جوان در گروه‌های فنی و مدیریتی دانشگاه اصفهان.', whyItMatters: 'فرسایش سرمایه انسانی و دشواری جذب اساتید تراز اول جوان در گروه‌های فنی و مدیریتی دانشگاه اصفهان.',
horizon: 'now', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2694,7 +2694,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'تشکیل هسته‌های دانشجویی چندرشته‌ای (شامل مهندسی، هنر، حقوق و اقتصاد) خارج از برنامه‌های درسی رسمی برای توسعه پروژه‌های غیرمتمرکز.', executiveSummary: 'تشکیل هسته‌های دانشجویی چندرشته‌ای (شامل مهندسی، هنر، حقوق و اقتصاد) خارج از برنامه‌های درسی رسمی برای توسعه پروژه‌های غیرمتمرکز.',
whyItMatters: 'نشانه‌ای از سبقت دانشجویان از بدنه رسمی آموزشی و آمادگی نسل جوان برای مفاهیم فرارشته‌ای آینده.', whyItMatters: 'نشانه‌ای از سبقت دانشجویان از بدنه رسمی آموزشی و آمادگی نسل جوان برای مفاهیم فرارشته‌ای آینده.',
horizon: 'near', horizon: 'long',
impactScore: 3, impactScore: 3,
uncertaintyScore: 3, uncertaintyScore: 3,
confidence: 'medium', confidence: 'medium',
@ -2748,7 +2748,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'دانشگاه‌های منطقه با اعطای بورس‌های تحصیلی و زبان‌آموزی رایگان در حال ربودن بازار داوطلبان عراقی از دانشگاه‌های اصفهان و تهران هستند.', executiveSummary: 'دانشگاه‌های منطقه با اعطای بورس‌های تحصیلی و زبان‌آموزی رایگان در حال ربودن بازار داوطلبان عراقی از دانشگاه‌های اصفهان و تهران هستند.',
whyItMatters: 'سیگنال هشداردهنده از دست رفتن موقعیت ترجیحی دانشگاه اصفهان در دیپلماسی علمی عراق در غیاب خدمات رقابتی.', whyItMatters: 'سیگنال هشداردهنده از دست رفتن موقعیت ترجیحی دانشگاه اصفهان در دیپلماسی علمی عراق در غیاب خدمات رقابتی.',
horizon: 'now', horizon: 'mid',
impactScore: 4, impactScore: 4,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2775,7 +2775,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'تیم متشکل از ۳ دانشجوی مهندسی کامپیوتر دانشگاه اصفهان موفق به جذب ۵۰۰ هزار دلار سرمایه بذری از یک صندوق بین‌المللی در دبی شدند.', executiveSummary: 'تیم متشکل از ۳ دانشجوی مهندسی کامپیوتر دانشگاه اصفهان موفق به جذب ۵۰۰ هزار دلار سرمایه بذری از یک صندوق بین‌المللی در دبی شدند.',
whyItMatters: 'اثبات توانمندی جهانی استعدادهای دانشگاه اصفهان و امکان بازگشت سرمایه نمادین و ارتباط با هاب‌های بین‌المللی.', whyItMatters: 'اثبات توانمندی جهانی استعدادهای دانشگاه اصفهان و امکان بازگشت سرمایه نمادین و ارتباط با هاب‌های بین‌المللی.',
horizon: 'now', horizon: 'near',
impactScore: 3, impactScore: 3,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',
@ -2802,7 +2802,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
category: 'weak_signal', category: 'weak_signal',
executiveSummary: 'نصب تابلوهای داوطلبانه «محیط بدون تلفن همراه و اینترنت» در بخش‌هایی از کتابخانه مرکزی برای تمرکز عمیق ذهنی و گفتگوی چهره به چهره.', executiveSummary: 'نصب تابلوهای داوطلبانه «محیط بدون تلفن همراه و اینترنت» در بخش‌هایی از کتابخانه مرکزی برای تمرکز عمیق ذهنی و گفتگوی چهره به چهره.',
whyItMatters: 'احساس خستگی دانشجویان از بمباران اطلاعاتی مداوم و تمایل به احیای فضاهای تفکر صبورانه و ارتباط اصیل انسانی.', whyItMatters: 'احساس خستگی دانشجویان از بمباران اطلاعاتی مداوم و تمایل به احیای فضاهای تفکر صبورانه و ارتباط اصیل انسانی.',
horizon: 'now', horizon: 'long',
impactScore: 3, impactScore: 3,
uncertaintyScore: 2, uncertaintyScore: 2,
confidence: 'high', confidence: 'high',

View File

@ -19,7 +19,7 @@ import {
} from '../data/seedData'; } from '../data/seedData';
const STORAGE_KEYS = { const STORAGE_KEYS = {
ITEMS: 'ufr_intelligence_items_v1', ITEMS: 'ufr_intelligence_items_v2',
DIMENSIONS: 'ufr_dimensions_v1', DIMENSIONS: 'ufr_dimensions_v1',
JOBS: 'ufr_jobs_v1', JOBS: 'ufr_jobs_v1',
SKILLS: 'ufr_skills_v1', SKILLS: 'ufr_skills_v1',

View File

@ -11,53 +11,53 @@ export interface RadarPoint {
export const getHorizonRadiusRatio = (horizon: Horizon): number => { export const getHorizonRadiusRatio = (horizon: Horizon): number => {
switch (horizon) { switch (horizon) {
case 'now': case 'now':
return 0.28; // حلقه اول: اکنون (۰-۲ سال) return 0.35; // حلقه اول: اکنون (۰-۲ سال)
case 'near': case 'near':
return 0.52; // حلقه دوم: نزدیک (۲-۵ سال) return 0.56; // حلقه دوم: نزدیک (۲-۵ سال)
case 'mid': case 'mid':
return 0.74; // حلقه سوم: میان‌مدت (۵-۸ سال) return 0.76; // حلقه سوم: میان‌مدت (۵-۸ سال)
case 'long': case 'long':
return 0.93; // حلقه چهارم: بلندمدت (۸-۱۲ سال) return 0.93; // حلقه چهارم: بلندمدت (۸-۱۲ سال)
default: default:
return 0.5; return 0.55;
} }
}; };
export const getCategoryQuadrant = (category: Category): { startAngle: number; endAngle: number } => { export const getCategoryQuadrant = (category: Category): { startAngle: number; endAngle: number } => {
// Angle in radians (0 = 3 o'clock, PI/2 = 6 o'clock, PI = 9 o'clock, 3PI/2 = 12 o'clock) // Angle in radians (0 = 3 o'clock, PI/2 = 6 o'clock, PI = 9 o'clock, -PI/2 = 12 o'clock)
// Top-Right: Trends (270° to 360° / -90° to 0°) // Top-Right: Trends (-90° to 0°)
// Top-Left: Technologies (180° to 270°) // Top-Left: Technologies (-180° to -90°)
// Bottom-Left: Weak Signals (90° to 180°) // Bottom-Left: Weak Signals (90° to 180°)
// Bottom-Right: Policies (0° to 90°) // Bottom-Right: Policies (0° to 90°)
// Safe margin of 0.08 rad (~4.5°) ensures generous angular distribution
switch (category) { switch (category) {
case 'trend': case 'trend':
return { startAngle: -Math.PI / 2 + 0.15, endAngle: -0.15 }; return { startAngle: -Math.PI / 2 + 0.08, endAngle: -0.08 };
case 'technology': case 'technology':
return { startAngle: -Math.PI + 0.15, endAngle: -Math.PI / 2 - 0.15 }; return { startAngle: -Math.PI + 0.08, endAngle: -Math.PI / 2 - 0.08 };
case 'weak_signal': case 'weak_signal':
return { startAngle: Math.PI / 2 + 0.15, endAngle: Math.PI - 0.15 }; return { startAngle: Math.PI / 2 + 0.08, endAngle: Math.PI - 0.08 };
case 'policy': case 'policy':
return { startAngle: 0.15, endAngle: Math.PI / 2 - 0.15 }; return { startAngle: 0.08, endAngle: Math.PI / 2 - 0.08 };
default: default:
return { startAngle: 0, endAngle: Math.PI / 2 }; return { startAngle: 0, endAngle: Math.PI / 2 };
} }
}; };
/** /**
* Radial band each horizon occupies, as a fraction of the plot radius. * Radial band each horizon occupies, calibrated to scale with circle area.
* Items are packed *inside* their band rather than pinned to a single ring, * Generous inner threshold (0.24) preserves a clean moat around the university hub.
* which is what made same-horizon dots stack on top of each other.
*/ */
export const getHorizonBand = (horizon: Horizon): { inner: number; outer: number } => { export const getHorizonBand = (horizon: Horizon): { inner: number; outer: number } => {
switch (horizon) { switch (horizon) {
case 'now': case 'now':
return { inner: 0.17, outer: 0.36 }; return { inner: 0.24, outer: 0.45 };
case 'near': case 'near':
return { inner: 0.4, outer: 0.58 }; return { inner: 0.48, outer: 0.66 };
case 'mid': case 'mid':
return { inner: 0.62, outer: 0.79 }; return { inner: 0.69, outer: 0.84 };
case 'long': case 'long':
return { inner: 0.83, outer: 0.98 }; return { inner: 0.86, outer: 0.98 };
default: default:
return { inner: 0.4, outer: 0.6 }; return { inner: 0.4, outer: 0.6 };
} }
@ -72,11 +72,9 @@ export interface RadarPlacement<T> {
} }
/** /**
* Lay every item out so no two dots overlap. * High-performance deterministic force-directed anti-overlap layout.
* * Items are staggered into concentric rows with hexagonal offset,
* Items are bucketed by (category, horizon). Each bucket owns one quadrant * then relaxed with physical repulsion to guarantee zero overlaps.
* and one radial band; within it, dots are packed into as many concentric
* rows as the arc length needs, then spread evenly along each row.
*/ */
export function layoutRadarItems<T>( export function layoutRadarItems<T>(
items: T[], items: T[],
@ -86,7 +84,8 @@ export function layoutRadarItems<T>(
center: number, center: number,
maxRadius: number maxRadius: number
): RadarPlacement<T>[] { ): RadarPlacement<T>[] {
const PADDING = 3; const PADDING = 6;
const MIN_CENTER_MOAT = maxRadius * 0.22; // Keep clear of the center hub
type Node = { type Node = {
item: T; item: T;
@ -109,21 +108,21 @@ export function layoutRadarItems<T>(
const nodes: Node[] = []; const nodes: Node[] = [];
// --- 1. Seed: spread each bucket over its quadrant and radial band ------- // --- 1. Seed: spread each bucket with hexagonal angular staggering -------
for (const [key, bucket] of buckets) { for (const [key, bucket] of buckets) {
const [category, horizon] = key.split('|') as [Category, Horizon]; const [category, horizon] = key.split('|') as [Category, Horizon];
const { startAngle, endAngle } = getCategoryQuadrant(category); const { startAngle, endAngle } = getCategoryQuadrant(category);
const band = getHorizonBand(horizon); const band = getHorizonBand(horizon);
const innerR = maxRadius * band.inner; const innerR = Math.max(MIN_CENTER_MOAT, maxRadius * band.inner);
const outerR = maxRadius * band.outer; const outerR = maxRadius * band.outer;
const span = endAngle - startAngle; const span = endAngle - startAngle;
const maxDot = Math.max(...bucket.map(getDotRadius)); const maxDot = Math.max(...bucket.map(getDotRadius));
const gap = maxDot * 2 + PADDING; const gap = maxDot * 2 + PADDING;
// Rows are limited by radial depth as well as by arc length. // Determine optimal rows and columns based on arc and radial depth
const byArc = Math.max(1, Math.floor((span * innerR) / gap)); const byArc = Math.max(1, Math.floor((span * ((innerR + outerR) / 2)) / gap));
const byDepth = Math.max(1, Math.floor((outerR - innerR) / gap) + 1); const byDepth = Math.max(1, Math.floor((outerR - innerR) / gap) + 1);
const rows = Math.min(byDepth, Math.max(1, Math.ceil(bucket.length / byArc))); const rows = Math.min(byDepth, Math.max(1, Math.ceil(bucket.length / byArc)));
const perRow = Math.ceil(bucket.length / rows); const perRow = Math.ceil(bucket.length / rows);
@ -136,7 +135,10 @@ export function layoutRadarItems<T>(
const radius = rows > 1 ? innerR + row * rowStep : (innerR + outerR) / 2; const radius = rows > 1 ? innerR + row * rowStep : (innerR + outerR) / 2;
const step = span / (inThisRow + 1); const step = span / (inThisRow + 1);
const angle = startAngle + step * (indexInRow + 1);
// Hexagonal alternating stagger on successive rows to prevent column-stacking
const stagger = rows > 1 && row % 2 === 1 ? step * 0.35 : 0;
const angle = startAngle + step * (indexInRow + 1) + stagger;
const r = getDotRadius(item); const r = getDotRadius(item);
nodes.push({ nodes.push({
@ -152,27 +154,25 @@ export function layoutRadarItems<T>(
}); });
} }
// --- 2. Relax: push overlapping dots apart, then pull them back into // --- 2. Relax: physical repulsion & quadrant boundary constraints -------
// their own quadrant and band. Deterministic, so renders are stable.
const clamp = (n: Node) => { const clamp = (n: Node) => {
const dx = n.x - center; const dx = n.x - center;
const dy = n.y - center; const dy = n.y - center;
let angle = Math.atan2(dy, dx); let angle = Math.atan2(dy, dx);
let radius = Math.hypot(dx, dy); let radius = Math.hypot(dx, dy);
// atan2 returns (-PI, PI]; quadrant ranges can start below -PI/2, so
// compare in the same revolution as the range.
if (angle < n.minAngle - Math.PI) angle += 2 * Math.PI; if (angle < n.minAngle - Math.PI) angle += 2 * Math.PI;
if (angle > n.maxAngle + Math.PI) angle -= 2 * Math.PI; if (angle > n.maxAngle + Math.PI) angle -= 2 * Math.PI;
angle = Math.min(n.maxAngle, Math.max(n.minAngle, angle)); angle = Math.min(n.maxAngle, Math.max(n.minAngle, angle));
radius = Math.min(n.maxRadius, Math.max(n.minRadius, radius)); radius = Math.min(n.maxRadius, Math.max(n.minRadius, radius));
radius = Math.max(MIN_CENTER_MOAT, radius);
n.x = center + radius * Math.cos(angle); n.x = center + radius * Math.cos(angle);
n.y = center + radius * Math.sin(angle); n.y = center + radius * Math.sin(angle);
}; };
for (let pass = 0; pass < 160; pass++) { for (let pass = 0; pass < 120; pass++) {
let moved = false; let moved = false;
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
@ -215,14 +215,13 @@ export function layoutRadarItems<T>(
} }
export const getSpiderCoordinates = ( export const getSpiderCoordinates = (
values: number[], // 10 scores from 1 to 10 values: number[],
maxValue: number, // 10 maxValue: number,
center: number, center: number,
maxRadius: number maxRadius: number
): { x: number; y: number }[] => { ): { x: number; y: number }[] => {
const numAxes = values.length; const numAxes = values.length;
return values.map((val, idx) => { return values.map((val, idx) => {
// start from top (-PI / 2) and go clockwise
const angle = (2 * Math.PI * idx) / numAxes - Math.PI / 2; const angle = (2 * Math.PI * idx) / numAxes - Math.PI / 2;
const distance = (val / maxValue) * maxRadius; const distance = (val / maxValue) * maxRadius;
const x = center + distance * Math.cos(angle); const x = center + distance * Math.cos(angle);

View File

@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
base: './',
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {