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 }
|
weak_signal: { x: -0.62, y: 0.78 }
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Dot size encodes impact (1–5). */
|
/** Dot size encodes impact (1–5). 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 */}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="inline-block size-1.5 rounded-full"
|
className="inline-block size-2 rounded-full"
|
||||||
style={{ background: LAYER_COLOR[layer] }}
|
style={{ background: LAYER_COLOR[layer] }}
|
||||||
/>
|
/>
|
||||||
{LAYER_LABEL[layer]}
|
<span>{LAYER_LABEL[layer]}</span>
|
||||||
<span className="tnum opacity-70">
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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="مثلث آینده: کشش آینده، فشارهای حال و وزن گذشته"
|
|
||||||
>
|
|
||||||
{/* 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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Each vertex is scaled by the average strength of its group,
|
{/* Top Vertex Header (Pull) */}
|
||||||
so the shape shows which way the university is being pulled. */}
|
<div className="flex flex-col items-center justify-center mb-1 z-10">
|
||||||
{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
|
<button
|
||||||
onClick={() => pick(force)}
|
onClick={() => pullForces[0] && handleSelect(pullForces[0])}
|
||||||
className={cn(
|
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"
|
||||||
'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>
|
<Sparkles size={15} className="text-purple-600" />
|
||||||
<span
|
<span>کشش آینده — تصویر آرمانی دانشگاه</span>
|
||||||
className="tnum shrink-0 rounded px-1 py-0.5 text-[10.5px] font-semibold"
|
<span className="text-[11px] bg-purple-200/80 text-purple-900 px-2 py-0.2 rounded-full font-bold">
|
||||||
style={{ background: group.soft, color: group.color }}
|
{toPersianDigits(pullForces.length)} نیرو · میانگین {toPersianDigits(pullAvg.toFixed(1))}
|
||||||
>
|
</span>
|
||||||
{toPersianDigits(force.strength)}
|
</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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail of the selected force */}
|
{/* 3. SELECTED FORCE DETAILED INTELLIGENCE DOSSIER CARD */}
|
||||||
{selected && (
|
{selectedForce && (
|
||||||
<Card data-card className="xl:col-span-5">
|
<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">
|
||||||
<CardHeader className="border-b pb-3">
|
{/* Header Bar */}
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<span
|
||||||
className="rounded px-1.5 py-0.5 text-[10.5px] font-semibold"
|
className="w-3.5 h-3.5 rounded-full shadow-xs shrink-0"
|
||||||
style={{ background: selectedGroup.soft, color: selectedGroup.color }}
|
style={{ backgroundColor: currentGroup.color }}
|
||||||
>
|
/>
|
||||||
{selectedGroup.label}
|
<h3 className="text-slate-900 font-black text-base lg:text-lg">
|
||||||
</span>
|
{cleanTitle(selectedForce.title)}
|
||||||
<span className="tnum text-[11px] text-muted-foreground">
|
</h3>
|
||||||
شدت {toPersianDigits(selected.strength)} از ۱۰
|
<span className={`text-xs px-3 py-1 rounded-full font-bold ${currentGroup.badgeBg} ${currentGroup.badgeText}`}>
|
||||||
|
{currentGroup.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="mt-1.5 text-[15px] leading-relaxed">
|
|
||||||
{selected.title}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-3 pt-4">
|
<div className="flex items-center gap-2">
|
||||||
<DetailBlock title="شواهد" body={selected.evidence} />
|
<span className="text-xs text-slate-500 font-bold">شدت اثر نیرو:</span>
|
||||||
<DetailBlock title="اثر" body={selected.impact} />
|
<span className="text-base font-black text-slate-900 bg-slate-100 px-3 py-0.5 rounded-full border border-slate-200">
|
||||||
<DetailBlock title="جهت حرکت" body={selected.direction} />
|
{toPersianDigits(selectedForce.strength)} از ۱۰
|
||||||
<DetailBlock title="دلالت راهبردی" body={selected.strategicImplication} />
|
</span>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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',
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue