feat: implement data service, radar utilities, and visualization components for futures foresight dashboard
This commit is contained in:
parent
1f69cb9e7d
commit
2988788630
|
|
@ -23,8 +23,8 @@ const LABEL_ANCHOR: Record<Category, { x: number; y: number }> = {
|
|||
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<FuturesRadarProps> = ({
|
||||
items,
|
||||
|
|
@ -228,30 +228,44 @@ export const FuturesRadar: React.FC<FuturesRadarProps> = ({
|
|||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={26}
|
||||
fill="var(--primary)"
|
||||
fillOpacity="0.1"
|
||||
className="anim-pulse"
|
||||
r={34}
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
className="shadow-xs"
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={16}
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="1.5"
|
||||
r={26}
|
||||
fill="var(--primary)"
|
||||
fillOpacity="0.08"
|
||||
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
|
||||
x={center}
|
||||
y={center + 33}
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="10.5"
|
||||
fontWeight="600"
|
||||
y={center - 4}
|
||||
fill="var(--foreground)"
|
||||
fontSize="9.5"
|
||||
fontWeight="800"
|
||||
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>
|
||||
|
||||
{/* Blips */}
|
||||
|
|
|
|||
|
|
@ -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<ImpactNetworkProps> = ({
|
||||
items,
|
||||
dimensions,
|
||||
|
|
@ -85,9 +91,9 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
|
||||
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<ImpactNetworkProps> = ({
|
|||
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<ImpactNetworkProps> = ({
|
|||
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<ImpactNetworkProps> = ({
|
|||
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<ImpactNetworkProps> = ({
|
|||
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<Layer, Node[]> = { item: [], skill: [], job: [], dimension: [] };
|
||||
|
|
@ -240,11 +320,11 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start">
|
||||
<div className="relative min-w-0 flex-1 select-none">
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start">
|
||||
<div className="relative min-w-0 flex-1 select-none flex flex-col items-center">
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="h-auto w-full overflow-visible"
|
||||
className="h-auto w-full max-w-[840px] overflow-visible"
|
||||
role="img"
|
||||
aria-label="شبکه روابط: از پیشران بیرونی تا شکاف قابلیت دانشگاه"
|
||||
onClick={() => setPinned(null)}
|
||||
|
|
@ -254,13 +334,17 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.14" />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||
</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>
|
||||
|
||||
<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) => {
|
||||
const r = maxRadius * layer.ratio;
|
||||
const labelY = center - r;
|
||||
return (
|
||||
<g key={layer.key}>
|
||||
<circle
|
||||
|
|
@ -269,25 +353,37 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
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 */}
|
||||
<rect
|
||||
x={center - r - 40}
|
||||
y={center - 9}
|
||||
width="80"
|
||||
height="18"
|
||||
rx="5"
|
||||
x={center - 44}
|
||||
y={labelY - 10}
|
||||
width="88"
|
||||
height="20"
|
||||
rx="10"
|
||||
fill="var(--card)"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="1"
|
||||
className="shadow-sm"
|
||||
/>
|
||||
<circle
|
||||
cx={center - 32}
|
||||
cy={labelY}
|
||||
r="3.5"
|
||||
fill={LAYER_COLOR[layer.key]}
|
||||
/>
|
||||
<text
|
||||
x={center - r}
|
||||
y={center + 4}
|
||||
x={center + 6}
|
||||
y={labelY + 3.5}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontWeight="600"
|
||||
fill="var(--muted-foreground)"
|
||||
className="pointer-events-none"
|
||||
fontWeight="700"
|
||||
fill="var(--foreground)"
|
||||
className="pointer-events-none select-none"
|
||||
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
|
||||
>
|
||||
{layer.label}
|
||||
</text>
|
||||
|
|
@ -307,8 +403,8 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
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<ImpactNetworkProps> = ({
|
|||
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<ImpactNetworkProps> = ({
|
|||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.size + 9}
|
||||
r={node.size + 10}
|
||||
fill={node.color}
|
||||
fillOpacity="0.2"
|
||||
fillOpacity="0.25"
|
||||
className="anim-pulse"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={isActive ? node.size + 2.5 : node.size}
|
||||
r={isActive ? node.size + 3 : node.size}
|
||||
fill={node.color}
|
||||
fillOpacity={lit ? 1 : 0.14}
|
||||
stroke={isPinned ? 'var(--foreground)' : 'var(--card)'}
|
||||
strokeWidth={isPinned ? 2.5 : 1.5}
|
||||
fillOpacity={lit ? 1 : 0.15}
|
||||
stroke={isPinned ? '#0f172a' : 'var(--card)'}
|
||||
strokeWidth={isPinned ? 3 : 1.75}
|
||||
style={{ transition: 'fill-opacity 200ms ease' }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<g key={`lbl-${node.id}`} className="pointer-events-none net-label">
|
||||
<rect
|
||||
x={onLeft ? lx - w : lx}
|
||||
y={ly - 9}
|
||||
width={w}
|
||||
height="18"
|
||||
rx="5"
|
||||
fill="var(--popover)"
|
||||
stroke="var(--border)"
|
||||
<g key={`lbl-${node.id}`} className="pointer-events-none transition-all duration-200">
|
||||
{/* Connector line between node and label box */}
|
||||
<line
|
||||
x1={node.x}
|
||||
y1={node.y}
|
||||
x2={x + boxW / 2}
|
||||
y2={y + boxH / 2}
|
||||
stroke={isMain ? '#0f172a' : node.color}
|
||||
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
|
||||
x={onLeft ? lx - w / 2 : lx + w / 2}
|
||||
y={ly + 4}
|
||||
x={isMain ? x + boxW / 2 : x + (boxW + 10) / 2}
|
||||
y={y + boxH / 2 + 3.5}
|
||||
textAnchor="middle"
|
||||
fontSize="10.5"
|
||||
fontWeight={node.id === active ? 700 : 500}
|
||||
fill="var(--popover-foreground)"
|
||||
fontSize={isMain ? 11 : 10}
|
||||
fontWeight={isMain ? 800 : 600}
|
||||
fill={isMain ? '#ffffff' : 'var(--foreground)'}
|
||||
className="select-none pointer-events-none"
|
||||
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
|
|
@ -410,72 +528,86 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Centre */}
|
||||
{/* Central Hub */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={32}
|
||||
r={35}
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="2.5"
|
||||
filter="url(#labelShadow)"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={center + 4}
|
||||
y={center - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontWeight="700"
|
||||
fill="var(--primary)"
|
||||
className="pointer-events-none"
|
||||
fontSize="11.5"
|
||||
fontWeight="800"
|
||||
fill="var(--foreground)"
|
||||
className="pointer-events-none select-none"
|
||||
style={{ fontFamily: 'Vazirmatn, sans-serif' }}
|
||||
>
|
||||
دانشگاه
|
||||
</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>
|
||||
|
||||
<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()
|
||||
.reverse()
|
||||
.map((l) => (
|
||||
<span key={l.key} className="flex items-center gap-1.5">
|
||||
<span key={l.key} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
className="inline-block size-2.5 rounded-full shadow-xs"
|
||||
style={{ background: LAYER_COLOR[l.key] }}
|
||||
/>
|
||||
{l.label}
|
||||
<span className="font-semibold">{l.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-page detail — nothing here navigates unless you ask it to. */}
|
||||
<aside className="w-full shrink-0 xl:w-72">
|
||||
{/* In-page detail side panel */}
|
||||
<aside className="w-full shrink-0 xl:w-80">
|
||||
{!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" />
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border 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="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 border-slate-100 pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
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]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] font-bold leading-snug">
|
||||
<p className="text-[13px] font-black text-slate-900 leading-snug">
|
||||
{activeNode.label}
|
||||
</p>
|
||||
<p className="tnum mt-1 text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(related.length)} پیوند
|
||||
<p className="tnum mt-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{toPersianDigits(related.length)} پیوند فعال در شبکه
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -483,39 +615,41 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
className="size-7 shrink-0 text-slate-400 hover:text-slate-700"
|
||||
aria-label="بستن"
|
||||
onClick={() => setPinned(null)}
|
||||
>
|
||||
<X />
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</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) => {
|
||||
const list = grouped[layer];
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<div key={layer} className="mb-3 last:mb-0">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
|
||||
<div key={layer} className="p-2.5 bg-slate-50 border border-slate-100 rounded-xl">
|
||||
<div className="mb-1.5 flex items-center justify-between text-[11px] font-bold text-slate-700">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-1.5 rounded-full"
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
<ul className="flex flex-col gap-1 mt-1">
|
||||
{list.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
onClick={() => setPinned(n.id)}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
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}
|
||||
</button>
|
||||
|
|
@ -528,10 +662,10 @@ export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
|||
</div>
|
||||
|
||||
{(activeNode.layer === 'item' || activeNode.layer === 'dimension') && (
|
||||
<div className="border-t p-3">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={openActive}>
|
||||
<div className="border-t border-slate-100 pt-3 mt-3">
|
||||
<Button variant="outline" size="sm" className="w-full font-bold text-xs" onClick={openActive}>
|
||||
باز کردن پرونده کامل
|
||||
<ArrowLeft />
|
||||
<ArrowLeft className="size-3.5 mr-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,248 +1,469 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { FuturesForce, ForceType } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkles, TrendingUp, ShieldAlert, ArrowLeft, Layers } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface TriangleViewProps {
|
||||
forces: FuturesForce[];
|
||||
onSelectForce?: (force: FuturesForce) => void;
|
||||
}
|
||||
|
||||
/** The three vertices, worded and ordered as in the brief. */
|
||||
const GROUPS: {
|
||||
interface GroupConfig {
|
||||
type: ForceType;
|
||||
label: string;
|
||||
latin: string;
|
||||
hint: string;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
soft: string;
|
||||
}[] = [
|
||||
border: string;
|
||||
bgSoft: string;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const GROUPS: GroupConfig[] = [
|
||||
{
|
||||
type: 'pull',
|
||||
label: 'کشش آینده',
|
||||
latin: 'PULL',
|
||||
hint: 'تصویر مطلوبی که دانشگاه را به جلو میکشد.',
|
||||
color: 'var(--cat-tech)',
|
||||
soft: 'var(--cat-tech-soft)'
|
||||
subtitle: 'تصویر مطلوب، دانشگاه نسل پنجم و الگوهای نو',
|
||||
color: '#8b5cf6',
|
||||
border: 'border-purple-200',
|
||||
bgSoft: 'bg-purple-50/50',
|
||||
badgeBg: 'bg-purple-100',
|
||||
badgeText: 'text-purple-800',
|
||||
icon: <Sparkles size={16} className="text-purple-600" />
|
||||
},
|
||||
{
|
||||
type: 'push',
|
||||
label: 'فشارهای حال',
|
||||
latin: 'PUSH',
|
||||
hint: 'نیروهایی که همین امروز تغییر را تحمیل میکنند.',
|
||||
color: 'var(--cat-trend)',
|
||||
soft: 'var(--cat-trend-soft)'
|
||||
label: 'فشارهای زمان حال',
|
||||
subtitle: 'شتاب هوش مصنوعی، تحولات جاری و روندهای دگرگونساز',
|
||||
color: '#0284c7',
|
||||
border: 'border-sky-200',
|
||||
bgSoft: 'bg-sky-50/50',
|
||||
badgeBg: 'bg-sky-100',
|
||||
badgeText: 'text-sky-800',
|
||||
icon: <TrendingUp size={16} className="text-sky-600" />
|
||||
},
|
||||
{
|
||||
type: 'weight',
|
||||
label: 'وزن گذشته',
|
||||
latin: 'WEIGHT',
|
||||
hint: 'ساختارها و عادتهایی که حرکت را کند میکنند.',
|
||||
color: 'var(--cat-policy)',
|
||||
soft: 'var(--cat-policy-soft)'
|
||||
subtitle: 'موانع ساختاری، آییننامههای کهنه و لختی سازمانی',
|
||||
color: '#f43f5e',
|
||||
border: 'border-rose-200',
|
||||
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.
|
||||
const APEX = { x: 320, y: 46 };
|
||||
const RIGHT = { x: 588, y: 414 };
|
||||
const LEFT = { x: 52, y: 414 };
|
||||
/** Strip redundant Latin parentheticals from title for pure, clean Persian presentation */
|
||||
const cleanTitle = (title: string) => {
|
||||
if (!title) return '';
|
||||
return title.replace(/\s*\([A-Za-z\s&-]+\)/g, '').trim();
|
||||
};
|
||||
|
||||
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(
|
||||
() => ({
|
||||
pull: forces.filter((f) => f.forceType === 'pull'),
|
||||
push: forces.filter((f) => f.forceType === 'push'),
|
||||
weight: forces.filter((f) => f.forceType === 'weight')
|
||||
}),
|
||||
[forces]
|
||||
);
|
||||
const pullForces = forces.filter((f) => f.forceType === 'pull');
|
||||
const pushForces = forces.filter((f) => f.forceType === 'push');
|
||||
const weightForces = forces.filter((f) => f.forceType === 'weight');
|
||||
|
||||
const strength = (type: ForceType) => {
|
||||
const list = grouped[type];
|
||||
const getAvg = (list: FuturesForce[]) => {
|
||||
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) => {
|
||||
setSelected(force);
|
||||
onSelectForce?.(force);
|
||||
const pullAvg = getAvg(pullForces);
|
||||
const pushAvg = getAvg(pushForces);
|
||||
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
|
||||
? GROUPS.find((g) => g.type === selected.forceType)!
|
||||
: GROUPS[0];
|
||||
const currentGroup = GROUPS.find((g) => g.type === selectedForce?.forceType) ?? GROUPS[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card data-card>
|
||||
<CardContent className="p-3 sm:p-6">
|
||||
<svg
|
||||
viewBox="0 0 640 470"
|
||||
className="mx-auto h-auto w-full max-w-[620px]"
|
||||
role="img"
|
||||
aria-label="مثلث آینده: کشش آینده، فشارهای حال و وزن گذشته"
|
||||
>
|
||||
{/* The triangle itself */}
|
||||
<polygon
|
||||
points={`${APEX.x},${APEX.y} ${RIGHT.x},${RIGHT.y} ${LEFT.x},${LEFT.y}`}
|
||||
fill="var(--muted)"
|
||||
fillOpacity="0.5"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<div className="flex flex-col gap-6 select-none animate-in fade-in duration-300">
|
||||
{/* 1. VISUAL TRIANGLE CANVAS (Sleek, Dynamic & Equilateral) */}
|
||||
<Card data-card className="relative overflow-hidden border border-border/80 p-6 lg:p-8 bg-card shadow-sm">
|
||||
{/* Subtle Ambient Background Lighting */}
|
||||
<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" />
|
||||
<div className="pointer-events-none absolute bottom-12 right-12 w-60 h-60 bg-sky-500/5 rounded-full blur-3xl" />
|
||||
<div className="pointer-events-none absolute bottom-12 left-12 w-60 h-60 bg-rose-500/5 rounded-full blur-3xl" />
|
||||
|
||||
{/* 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}>
|
||||
{/* Top Vertex Header (Pull) */}
|
||||
<div className="flex flex-col items-center justify-center mb-1 z-10">
|
||||
<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'
|
||||
)}
|
||||
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"
|
||||
>
|
||||
<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)}
|
||||
<Sparkles size={15} className="text-purple-600" />
|
||||
<span>کشش آینده — تصویر آرمانی دانشگاه</span>
|
||||
<span className="text-[11px] bg-purple-200/80 text-purple-900 px-2 py-0.2 rounded-full font-bold">
|
||||
{toPersianDigits(pullForces.length)} نیرو · میانگین {toPersianDigits(pullAvg.toFixed(1))}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SVG Triangle Graphic */}
|
||||
<div className="relative w-full max-w-[700px] mx-auto select-none my-1">
|
||||
<svg
|
||||
viewBox="0 0 800 550"
|
||||
className="w-full h-auto drop-shadow-sm overflow-visible"
|
||||
role="img"
|
||||
aria-label="مثلث هوشمندی آینده دانشگاه اصفهان"
|
||||
>
|
||||
<defs>
|
||||
{/* Dynamic Gradient Fills */}
|
||||
<linearGradient id="futuresTriangleGrad" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.12" />
|
||||
<stop offset="60%" stopColor="#0284c7" stopOpacity="0.07" />
|
||||
<stop offset="100%" stopColor="#f43f5e" stopOpacity="0.12" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Edge Gradient: Left (PULL -> WEIGHT) */}
|
||||
<linearGradient id="pullToWeightGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#8b5cf6" />
|
||||
<stop offset="100%" stopColor="#f43f5e" />
|
||||
</linearGradient>
|
||||
|
||||
{/* 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>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Detail of the selected force */}
|
||||
{selected && (
|
||||
<Card data-card className="xl:col-span-5">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 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="rounded px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||
style={{ background: selectedGroup.soft, color: selectedGroup.color }}
|
||||
>
|
||||
{selectedGroup.label}
|
||||
</span>
|
||||
<span className="tnum text-[11px] text-muted-foreground">
|
||||
شدت {toPersianDigits(selected.strength)} از ۱۰
|
||||
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>
|
||||
<CardTitle className="mt-1.5 text-[15px] leading-relaxed">
|
||||
{selected.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3 pt-4">
|
||||
<DetailBlock title="شواهد" body={selected.evidence} />
|
||||
<DetailBlock title="اثر" body={selected.impact} />
|
||||
<DetailBlock title="جهت حرکت" body={selected.direction} />
|
||||
<DetailBlock title="دلالت راهبردی" body={selected.strategicImplication} />
|
||||
</CardContent>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -398,7 +398,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
|
|||
category: 'trend',
|
||||
executiveSummary: 'کاهش نرخ زادآوری دهههای اخیر و انقباض شدید جامعه داوطلبان کنکور سراسری، به ویژه در استان اصفهان و مناطق پیرامونی.',
|
||||
whyItMatters: 'دانشگاه اصفهان با خطر مازاد ظرفیت کالبدی و انسانی در برخی دانشکدهها مواجه خواهد شد و باید مدل جذب خود را به سمت دانشجویان بینالملل و آموزش بزرگسالان تغییر دهد.',
|
||||
horizon: 'now',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -503,7 +503,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
|
|||
category: 'trend',
|
||||
executiveSummary: 'فشار جامعه و دولتها برای تبدیل خروجیهای پژوهشی به راهحلهای ملموس برای بحرانهای محلی نظیر آب، انرژی، محیط زیست و سلامت روان.',
|
||||
whyItMatters: 'دانشگاه اصفهان در استانی واقع شده که با چالش بحران زایندهرود و فرونشست مواجه است؛ بیپاسخ گذاشتن این مسائل اعتبار اجتماعی دانشگاه را تضعیف میکند.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 5,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -659,7 +659,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
|
|||
category: 'trend',
|
||||
executiveSummary: 'گذار صنایع سنگین اصفهان (فولاد، پتروشیمی، نساجی) به سوی اتوماسیون، کاهش مصرف آب و هوشمندسازی، و نیاز مبرم به مهارتهای تحول دیجیتال.',
|
||||
whyItMatters: 'رشتههای دانشگاه اصفهان باید فارغالتحصیلانی تربیت کنند که بتوانند بحران بهرهوری آب و انرژی را در صنایع کلیدی استان حل کنند.',
|
||||
horizon: 'near',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -711,7 +711,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
|
|||
category: 'trend',
|
||||
executiveSummary: 'پایان دوران انحصار ناشران تجاری بزرگ (الزویر، اشپرینگر) و فراگیر شدن انتشار دسترسی باز (Open Access) با مخازن دادههای پژوهشی باز.',
|
||||
whyItMatters: 'شفافیت پژوهشها، تکرارپذیری یافتههای علمی و ارتقای شانس دیدهشدن دستاوردهای دانشمندان دانشگاه اصفهان در سطح جهانی.',
|
||||
horizon: 'near',
|
||||
horizon: 'long',
|
||||
impactScore: 3,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -763,7 +763,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
|
|||
category: 'trend',
|
||||
executiveSummary: 'گرمایش زمین، تنش آبی کمسابقه در اصفهان و ناترازیهای برق و گاز که فعالیت مستمر دانشگاهها را در فصول گرما و سرما تهدید میکند.',
|
||||
whyItMatters: 'تعطیلیهای پیدرپی ناشی از آلودگی و ناترازی انرژی تقویم آموزشی را مختل کرده و ضرورت تبدیل دانشگاه به الگوی کربنخنثی را ایجاب میکند.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -815,7 +815,7 @@ export const INITIAL_TRENDS: IntelligenceItem[] = [
|
|||
category: 'trend',
|
||||
executiveSummary: 'بازنشستگی موج بزرگی از اساتید استخدامشده دهههای ۶۰ و ۷۰ و ورود نسل اساتید جوان دیجیتالمحور با مطالبات و سبک زندگی متفاوت.',
|
||||
whyItMatters: 'فرصت بینظیر برای پوستاندازی فکری، دیجیتالسازی دروس و تغییر نگرش پژوهشی دانشگاه اصفهان به شرط جذب شایستهسالارانه.',
|
||||
horizon: 'near',
|
||||
horizon: 'long',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -1240,7 +1240,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
|
|||
category: 'technology',
|
||||
executiveSummary: 'دفتر کل توزیعشده برای ثبت دانشنامهها، ریزنمرات و گواهینامههای مهارتی بدون امکان جعل و با اعتبارسنجی آنی در سطح جهانی.',
|
||||
whyItMatters: 'حذف کامل جعل مدارک دانشگاهی، تسریع استخدام بینالمللی فارغالتحصیلان دانشگاه اصفهان و تسهیل انتقال اعتبار آموزشی.',
|
||||
horizon: 'near',
|
||||
horizon: 'mid',
|
||||
impactScore: 3,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -1292,7 +1292,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
|
|||
category: 'technology',
|
||||
executiveSummary: 'استقرار سنسورهای متصل در شبکههای هوای پاک، چاههای آب، ایستگاههای هواشناسی و کلاسهای درس با پردازش سریع محلی.',
|
||||
whyItMatters: 'مدیریت بهینه منابع در بحران آب اصفهان و تنظیم خودکار روشنایی و تهویه برای به حداقل رساندن هزینههای قبوض مصرفی دانشگاه.',
|
||||
horizon: 'now',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -1397,7 +1397,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
|
|||
category: 'technology',
|
||||
executiveSummary: 'اشتراک منابع محاسباتی و دادهای میان دانشگاههای استان و کشور بدون نیاز به انتقال دادههای خام محرمانه، با حفظ حریم خصوصی.',
|
||||
whyItMatters: 'دسترسی پژوهشگران دانشگاه اصفهان به توان پردازشی عظیم اشتراکی دانشگاه صنعتی اصفهان و تهران بدون هزینه مضاعف خرید سختافزار.',
|
||||
horizon: 'near',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -1553,7 +1553,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
|
|||
category: 'technology',
|
||||
executiveSummary: 'ارزیابیهای آنلاین تعاملی که به جای تکیه بر دوربین مداربسته، سبک حل مسئله، فرآیند گامبهگام استدلال و دفاع شفاهی را ارزیابی میکنند.',
|
||||
whyItMatters: 'جایگزینی امتحان سنتی با ارزیابی فرآیندی جهت حفظ اعتبار نمرات در دورههای ترکیبی و مجازی.',
|
||||
horizon: 'now',
|
||||
horizon: 'long',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -1605,7 +1605,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
|
|||
category: 'technology',
|
||||
executiveSummary: 'تحلیل تصاویر راداری ماهوارهای (InSAR) با هوش مصنوعی برای پایش میلیمتر به میلیمتر فرونشست زمین در دشت اصفهان و ترکخوردگی ابنیه.',
|
||||
whyItMatters: 'هشدار زودهنگام در مورد ایمنی ساختمانهای تاریخی و آزمایشگاههای پیشرفته پردیس و ارائه نقشههای راهبردی به مدیریت بحران استان.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 5,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -1657,7 +1657,7 @@ export const INITIAL_TECHNOLOGIES: IntelligenceItem[] = [
|
|||
category: 'technology',
|
||||
executiveSummary: 'غشاهای پلیمری نانوساختار و بیوراکتورهای غشایی پیشرفته برای تصفیه فاضلاب شهری و صنعتی و تبدیل آن به آب فوق خالص صنعتی و کشاورزی.',
|
||||
whyItMatters: 'ارائه راهحل پایدار برای صنایع فولاد و پالایشگاه اصفهان جهت قطع کامل برداشت از زایندهرود و تثبیت آب دانشگاه.',
|
||||
horizon: 'near',
|
||||
horizon: 'mid',
|
||||
impactScore: 5,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -1871,7 +1871,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'امکان تهاتر ۱۰۰٪ مالیات عملکرد شرکتهای صنعتی با هزینههای پژوهشی قراردادهای منعقده با دانشگاههای دولتی.',
|
||||
whyItMatters: 'فرصت طلایی جذب هزاران میلیارد تومان سرمایه صنعتی از صنایع فولاد، نفت و داروسازی اصفهان به سوی دانشگاه.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 5,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -1923,7 +1923,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'دستورالعمل جامع تعیین مرزهای مجاز و غیرمجاز استفاده از AI در پایاننامهها، مقالات و آزمونها و مجازات تقلب الگوریتمی.',
|
||||
whyItMatters: 'حفظ اعتبار دانشنامههای دانشگاه اصفهان و جلوگیری از بیاعتباری بینالمللی پژوهشهای دانشگاه.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2079,7 +2079,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'کاهش تشریفات صدور ویزای تحصیلی، امکان افتتاح حساب بانکی بدون محدودیت و مجوز کار پارهوقت دانشجویی برای اتباع خارجی.',
|
||||
whyItMatters: 'رشد سریع جمعیت دانشجویان بینالملل و تسهیل جذب پژوهشگران پسادکتری خارجی در دانشگاه اصفهان.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2131,7 +2131,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'اختیار کامل دانشگاه اصفهان در تغییر تا ۴۰٪ محتوای دروس سرفصلهای مصوب شورای تحول بدون نیاز به استعلام از وزارتخانه.',
|
||||
whyItMatters: 'امکان انطباق فوری برنامههای درسی با نیازهای مهارتی بازار کار اصفهان بدون معطلیهای چندساله اداری.',
|
||||
horizon: 'now',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -2183,7 +2183,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'تاکید بر حل مسائل عینی جامعه، سیاستگذاری عمومی، اخلاق کاربردی و پیوند علوم انسانی با فناوریهای نوظهور.',
|
||||
whyItMatters: 'دانشکده ادبیات و علوم انسانی دانشگاه اصفهان با بیش از ۵۰ سال سابقه، نقشی بیبدیل در بازآفرینی انسانگرایانه عصر دیجیتال دارد.',
|
||||
horizon: 'near',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2287,7 +2287,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'تأکید بر همکاریهای پژوهشی، فرصتهای مطالعاتی و انتشار مقالات مشترک با کشورهای بریکس، شانگهای و جهان اسلام.',
|
||||
whyItMatters: 'گشایش مسیرهای جدید بینالمللی با دانشگاههای چین، روسیه، هند و برزیل به عنوان جایگزین همکاریهای محدودشده با غرب.',
|
||||
horizon: 'near',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2339,7 +2339,7 @@ export const INITIAL_POLICIES: IntelligenceItem[] = [
|
|||
category: 'policy',
|
||||
executiveSummary: 'دستورالعمل قانونی تقسیم دوره کارشناسی به دورههای متناوب ۴ ماه آموزش در پردیس و ۴ ماه کار موظف و حقوقبگیر در صنعت.',
|
||||
whyItMatters: 'تضمین اشتغال ۱۰۰٪ فارغالتحصیلان، رفع کمبود تکنسین ماهر در صنایع استان و دگرگونی بنیادین انگیزه دانشجویان.',
|
||||
horizon: 'now',
|
||||
horizon: 'long',
|
||||
impactScore: 5,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -2397,7 +2397,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'یک کالج آزمایشی در اسکاندیناوی آموزش را بر پایه یادگیری همتا-به-همتا، هوش مصنوعی شخصیساز و پروژههای واقعی بدون حضور عضو هیئت علمی رسمی بنا نهاده است.',
|
||||
whyItMatters: 'سیگنال هشداری درباره امکان حذف واسطه استاد در انتقال دانش و بازتعریف ضرورت دانشگاه فیزیکی.',
|
||||
horizon: 'long',
|
||||
horizon: 'now',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 5,
|
||||
confidence: 'medium',
|
||||
|
|
@ -2478,7 +2478,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'داوطلبان علوم ریاضی و فنی به کمترین تعداد در تاریخ آزمون سراسری رسیدهاند و متقاضیان رشتههای پایه به شدت افت کردهاند.',
|
||||
whyItMatters: 'تهدید شالوده پژوهشهای بنیادین کشور و خالی ماندن صندلیهای تاریخیترین دانشکدههای دانشگاه اصفهان.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 5,
|
||||
uncertaintyScore: 1,
|
||||
confidence: 'high',
|
||||
|
|
@ -2505,7 +2505,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'یک گروه تحقیقاتی ژاپنی با استفاده از یک سیستم ایجنتی، مقالهای کامل شامل فرضیه، آزمایش کد و پیشنویس تولید و بدون تغییر در ژورنال داوری همتا چاپ کردند.',
|
||||
whyItMatters: 'زنگ خطری جدی برای فرآیند پژوهش؛ ارزش افزوده محقق در دهه آینده نه اجرای آزمایش بلکه طرح سوال اصیل خواهد بود.',
|
||||
horizon: 'mid',
|
||||
horizon: 'near',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 3,
|
||||
confidence: 'high',
|
||||
|
|
@ -2559,7 +2559,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'جمعی از دانشجویان دانشکده ادبیات دانشگاه اصفهان با ابزارهای هوش مصنوعی اقدام به بازآفرینی شاهنامه و متون تاریخی در قالب مینیسریالهای تعاملی کردند.',
|
||||
whyItMatters: 'نشانهای از زایش کارآفرینی دیجیتال در دل علوم انسانی سنتی و ظرفیت تجاریسازی میراث ادبی.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 3,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2586,7 +2586,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'یکی از صنعتگران سرشناس اصفهان اعلام کرده حاضر است سرمایه وقفی خود را به جای ساخت خوابگاه، صرفاً برای تجهیز سوپرکامپیوتر دانشگاه اختصاص دهد.',
|
||||
whyItMatters: 'تحول در سنت خیرین دانشگاهی از وقف آجر و ساختمان به وقف فناوری و دانش پیشرفته.',
|
||||
horizon: 'now',
|
||||
horizon: 'long',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2667,7 +2667,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'بیش از ۶۰ درصد نخبگان فارغالتحصیل دکتری دانشگاه اصفهان به دلیل تفاوت دستمزدها به جای عضویت هیئت علمی در اصفهان جذب پلتفرمهای خصوصی پایتخت میشوند.',
|
||||
whyItMatters: 'فرسایش سرمایه انسانی و دشواری جذب اساتید تراز اول جوان در گروههای فنی و مدیریتی دانشگاه اصفهان.',
|
||||
horizon: 'now',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2694,7 +2694,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'تشکیل هستههای دانشجویی چندرشتهای (شامل مهندسی، هنر، حقوق و اقتصاد) خارج از برنامههای درسی رسمی برای توسعه پروژههای غیرمتمرکز.',
|
||||
whyItMatters: 'نشانهای از سبقت دانشجویان از بدنه رسمی آموزشی و آمادگی نسل جوان برای مفاهیم فرارشتهای آینده.',
|
||||
horizon: 'near',
|
||||
horizon: 'long',
|
||||
impactScore: 3,
|
||||
uncertaintyScore: 3,
|
||||
confidence: 'medium',
|
||||
|
|
@ -2748,7 +2748,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'دانشگاههای منطقه با اعطای بورسهای تحصیلی و زبانآموزی رایگان در حال ربودن بازار داوطلبان عراقی از دانشگاههای اصفهان و تهران هستند.',
|
||||
whyItMatters: 'سیگنال هشداردهنده از دست رفتن موقعیت ترجیحی دانشگاه اصفهان در دیپلماسی علمی عراق در غیاب خدمات رقابتی.',
|
||||
horizon: 'now',
|
||||
horizon: 'mid',
|
||||
impactScore: 4,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2775,7 +2775,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'تیم متشکل از ۳ دانشجوی مهندسی کامپیوتر دانشگاه اصفهان موفق به جذب ۵۰۰ هزار دلار سرمایه بذری از یک صندوق بینالمللی در دبی شدند.',
|
||||
whyItMatters: 'اثبات توانمندی جهانی استعدادهای دانشگاه اصفهان و امکان بازگشت سرمایه نمادین و ارتباط با هابهای بینالمللی.',
|
||||
horizon: 'now',
|
||||
horizon: 'near',
|
||||
impactScore: 3,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
@ -2802,7 +2802,7 @@ export const INITIAL_SIGNALS: IntelligenceItem[] = [
|
|||
category: 'weak_signal',
|
||||
executiveSummary: 'نصب تابلوهای داوطلبانه «محیط بدون تلفن همراه و اینترنت» در بخشهایی از کتابخانه مرکزی برای تمرکز عمیق ذهنی و گفتگوی چهره به چهره.',
|
||||
whyItMatters: 'احساس خستگی دانشجویان از بمباران اطلاعاتی مداوم و تمایل به احیای فضاهای تفکر صبورانه و ارتباط اصیل انسانی.',
|
||||
horizon: 'now',
|
||||
horizon: 'long',
|
||||
impactScore: 3,
|
||||
uncertaintyScore: 2,
|
||||
confidence: 'high',
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from '../data/seedData';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
ITEMS: 'ufr_intelligence_items_v1',
|
||||
ITEMS: 'ufr_intelligence_items_v2',
|
||||
DIMENSIONS: 'ufr_dimensions_v1',
|
||||
JOBS: 'ufr_jobs_v1',
|
||||
SKILLS: 'ufr_skills_v1',
|
||||
|
|
|
|||
|
|
@ -11,53 +11,53 @@ export interface RadarPoint {
|
|||
export const getHorizonRadiusRatio = (horizon: Horizon): number => {
|
||||
switch (horizon) {
|
||||
case 'now':
|
||||
return 0.28; // حلقه اول: اکنون (۰-۲ سال)
|
||||
return 0.35; // حلقه اول: اکنون (۰-۲ سال)
|
||||
case 'near':
|
||||
return 0.52; // حلقه دوم: نزدیک (۲-۵ سال)
|
||||
return 0.56; // حلقه دوم: نزدیک (۲-۵ سال)
|
||||
case 'mid':
|
||||
return 0.74; // حلقه سوم: میانمدت (۵-۸ سال)
|
||||
return 0.76; // حلقه سوم: میانمدت (۵-۸ سال)
|
||||
case 'long':
|
||||
return 0.93; // حلقه چهارم: بلندمدت (۸-۱۲ سال)
|
||||
default:
|
||||
return 0.5;
|
||||
return 0.55;
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
// Top-Right: Trends (270° to 360° / -90° to 0°)
|
||||
// Top-Left: Technologies (180° to 270°)
|
||||
// Angle in radians (0 = 3 o'clock, PI/2 = 6 o'clock, PI = 9 o'clock, -PI/2 = 12 o'clock)
|
||||
// Top-Right: Trends (-90° to 0°)
|
||||
// Top-Left: Technologies (-180° to -90°)
|
||||
// Bottom-Left: Weak Signals (90° to 180°)
|
||||
// Bottom-Right: Policies (0° to 90°)
|
||||
// Safe margin of 0.08 rad (~4.5°) ensures generous angular distribution
|
||||
switch (category) {
|
||||
case 'trend':
|
||||
return { startAngle: -Math.PI / 2 + 0.15, endAngle: -0.15 };
|
||||
return { startAngle: -Math.PI / 2 + 0.08, endAngle: -0.08 };
|
||||
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':
|
||||
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':
|
||||
return { startAngle: 0.15, endAngle: Math.PI / 2 - 0.15 };
|
||||
return { startAngle: 0.08, endAngle: Math.PI / 2 - 0.08 };
|
||||
default:
|
||||
return { startAngle: 0, endAngle: Math.PI / 2 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Radial band each horizon occupies, as a fraction of the plot radius.
|
||||
* Items are packed *inside* their band rather than pinned to a single ring,
|
||||
* which is what made same-horizon dots stack on top of each other.
|
||||
* Radial band each horizon occupies, calibrated to scale with circle area.
|
||||
* Generous inner threshold (0.24) preserves a clean moat around the university hub.
|
||||
*/
|
||||
export const getHorizonBand = (horizon: Horizon): { inner: number; outer: number } => {
|
||||
switch (horizon) {
|
||||
case 'now':
|
||||
return { inner: 0.17, outer: 0.36 };
|
||||
return { inner: 0.24, outer: 0.45 };
|
||||
case 'near':
|
||||
return { inner: 0.4, outer: 0.58 };
|
||||
return { inner: 0.48, outer: 0.66 };
|
||||
case 'mid':
|
||||
return { inner: 0.62, outer: 0.79 };
|
||||
return { inner: 0.69, outer: 0.84 };
|
||||
case 'long':
|
||||
return { inner: 0.83, outer: 0.98 };
|
||||
return { inner: 0.86, outer: 0.98 };
|
||||
default:
|
||||
return { inner: 0.4, outer: 0.6 };
|
||||
}
|
||||
|
|
@ -72,11 +72,9 @@ export interface RadarPlacement<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Lay every item out so no two dots overlap.
|
||||
*
|
||||
* Items are bucketed by (category, horizon). Each bucket owns one quadrant
|
||||
* 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.
|
||||
* High-performance deterministic force-directed anti-overlap layout.
|
||||
* Items are staggered into concentric rows with hexagonal offset,
|
||||
* then relaxed with physical repulsion to guarantee zero overlaps.
|
||||
*/
|
||||
export function layoutRadarItems<T>(
|
||||
items: T[],
|
||||
|
|
@ -86,7 +84,8 @@ export function layoutRadarItems<T>(
|
|||
center: number,
|
||||
maxRadius: number
|
||||
): RadarPlacement<T>[] {
|
||||
const PADDING = 3;
|
||||
const PADDING = 6;
|
||||
const MIN_CENTER_MOAT = maxRadius * 0.22; // Keep clear of the center hub
|
||||
|
||||
type Node = {
|
||||
item: T;
|
||||
|
|
@ -109,21 +108,21 @@ export function layoutRadarItems<T>(
|
|||
|
||||
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) {
|
||||
const [category, horizon] = key.split('|') as [Category, Horizon];
|
||||
const { startAngle, endAngle } = getCategoryQuadrant(category);
|
||||
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 span = endAngle - startAngle;
|
||||
|
||||
const maxDot = Math.max(...bucket.map(getDotRadius));
|
||||
const gap = maxDot * 2 + PADDING;
|
||||
|
||||
// Rows are limited by radial depth as well as by arc length.
|
||||
const byArc = Math.max(1, Math.floor((span * innerR) / gap));
|
||||
// Determine optimal rows and columns based on arc and radial depth
|
||||
const byArc = Math.max(1, Math.floor((span * ((innerR + outerR) / 2)) / gap));
|
||||
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 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 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);
|
||||
nodes.push({
|
||||
|
|
@ -152,27 +154,25 @@ export function layoutRadarItems<T>(
|
|||
});
|
||||
}
|
||||
|
||||
// --- 2. Relax: push overlapping dots apart, then pull them back into
|
||||
// their own quadrant and band. Deterministic, so renders are stable.
|
||||
// --- 2. Relax: physical repulsion & quadrant boundary constraints -------
|
||||
const clamp = (n: Node) => {
|
||||
const dx = n.x - center;
|
||||
const dy = n.y - center;
|
||||
let angle = Math.atan2(dy, dx);
|
||||
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.maxAngle + Math.PI) angle -= 2 * Math.PI;
|
||||
|
||||
angle = Math.min(n.maxAngle, Math.max(n.minAngle, angle));
|
||||
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.y = center + radius * Math.sin(angle);
|
||||
};
|
||||
|
||||
for (let pass = 0; pass < 160; pass++) {
|
||||
for (let pass = 0; pass < 120; pass++) {
|
||||
let moved = false;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
|
|
@ -215,14 +215,13 @@ export function layoutRadarItems<T>(
|
|||
}
|
||||
|
||||
export const getSpiderCoordinates = (
|
||||
values: number[], // 10 scores from 1 to 10
|
||||
maxValue: number, // 10
|
||||
values: number[],
|
||||
maxValue: number,
|
||||
center: number,
|
||||
maxRadius: number
|
||||
): { x: number; y: number }[] => {
|
||||
const numAxes = values.length;
|
||||
return values.map((val, idx) => {
|
||||
// start from top (-PI / 2) and go clockwise
|
||||
const angle = (2 * Math.PI * idx) / numAxes - Math.PI / 2;
|
||||
const distance = (val / maxValue) * maxRadius;
|
||||
const x = center + distance * Math.cos(angle);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue