- {/* Radial radar background gradient for Neo-Clean Bento canvas */}
-
-
-
-
+ {/* Plot backdrop: brighter at the centre so depth reads outward. */}
+
+
+
+
- {/* Sweeping radar scan gradient */}
-
-
-
-
+ {/* Sweep: a trailing wedge that fades away from the leading edge. */}
+
+
+
+
- {/* Glow Filters for high-impact dots */}
-
-
+ {/* Soft halo behind each dot. */}
+
+
+
+
+
+
+
+ {/* Keeps the sweep inside the plot circle. */}
+
+
+
- {/* Outer Circular Bounds */}
+
- {/* Quadrant Sector Pastel Tint Washes */}
- {/* Top-Right: Trends (Soft Sky) */}
-
- {/* Top-Left: Technologies (Soft Mint) */}
-
- {/* Bottom-Left: Weak Signals (Soft Coral/Rose) */}
-
- {/* Bottom-Right: Policies (Soft Amber) */}
-
+ {/* One faint wash per quadrant so each category owns a region. */}
+
+ {CATEGORY_ORDER.map((cat, i) => {
+ const a0 = (i * Math.PI) / 2;
+ const a1 = a0 + Math.PI / 2;
+ return (
+
+ );
+ })}
- {/* Sweeping Radar Scan Line */}
-
-
-
+
+
+
+
- {/* Concentric Horizon Rings */}
- {rings.map((ring, idx) => {
- const r = maxRadius * ring.ratio;
- const isHighlighted = selectedHorizon === ring.horizon;
+ {/* Horizon rings, labelled with their year band. */}
+ {HORIZONS.map((horizon, idx) => {
+ const band = getHorizonBand(horizon);
+ const r = maxRadius * band.outer;
+ const active = selectedHorizon === horizon;
return (
-
+
- {/* Ring Label (Horizon text on vertical axis) */}
- {ring.label}
+ {horizonRangeFa(horizon)}
);
})}
- {/* Quadrant Divider Axes */}
+ {/* Quadrant dividers */}
- {/* Center Institutional Hub */}
-
-
+ {/* Quadrant names, set outside the plot so they never sit under dots. */}
+ {CATEGORY_ORDER.map((cat) => {
+ const meta = CATEGORY_META[cat];
+ const a = LABEL_ANCHOR[cat];
+ return (
+
+ {meta.label}
+
+ );
+ })}
+
+ {/* The university sits at the centre; distance from it is time. */}
+
+
+
دانشگاه اصفهان
- {/* Quadrant Corner Badges (Modern Bento Pills) */}
- {/* Top-Right: Trends */}
-
-
- روندها (Trends)
-
-
- {/* Top-Left: Technologies */}
-
-
- فناوریها (Tech)
-
-
- {/* Bottom-Right: Policies */}
-
-
- سیاستها (Policies)
-
-
- {/* Bottom-Left: Weak Signals */}
-
-
- سیگنالهای آینده (Signals)
-
-
- {/* Radar Item Blips */}
- {itemsWithCoords.map(({ item, x, y }) => {
- const color = getCategoryColor(item.category);
- // Dot radius based on impactScore (1 to 5)
- const dotR = 6 + item.impactScore * 2;
- const isCritical = item.status === 'critical' || item.impactScore === 5;
- const isHovered = hoveredItem?.item.id === item.id;
+ {/* Blips */}
+ {placed.map(({ item, x, y }, idx) => {
+ const color = CATEGORY_META[item.category].color;
+ const r = dotRadius(item);
+ const isCritical = item.status === 'critical';
+ const isHovered = hovered?.item.id === item.id;
return (
onSelectItem(item)}
- onMouseEnter={() => setHoveredItem({ item, x, y })}
- onMouseLeave={() => setHoveredItem(null)}
+ onMouseEnter={() => setHovered({ item, x, y })}
+ onMouseLeave={() => setHovered(null)}
+ role="button"
+ tabIndex={0}
+ aria-label={item.title}
+ onFocus={() => setHovered({ item, x, y })}
+ onBlur={() => setHovered(null)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelectItem(item);
+ }
+ }}
>
- {/* Pulsing outer aura for critical signals */}
+
+ {/* Critical items keep a slow pulse so they read at a glance. */}
{isCritical && (
)}
- {/* Hover Highlight Ring */}
- {isHovered && (
-
- )}
-
- {/* Main Core Dot (Vibrant color with crisp white border) */}
- {/* Impact Number */}
10 ? '9.5' : '8.5'}
- fontWeight="900"
+ fontSize={r > 11 ? '10.5' : '9.5'}
+ fontWeight="700"
+ fill="#fff"
className="pointer-events-none"
>
{toPersianDigits(item.impactScore)}
+
);
})}
- {/* Floating Hover Tooltip Card (Bento Light Style) */}
- {hoveredItem && (
+ {hovered && (
-
+
- {getCategoryLabelFa(hoveredItem.item.category)}
+
+ {CATEGORY_META[hovered.item.category].label}
-
- {getHorizonLabelFa(hoveredItem.item.horizon)}
+
+ {getHorizonLabelFa(hovered.item.horizon)}
-
- {hoveredItem.item.title}
-
-
-
- {hoveredItem.item.whyItMatters}
+
{hovered.item.title}
+
+ {hovered.item.whyItMatters}
+
+
+ شدت اثر {toPersianDigits(hovered.item.impactScore)} از ۵
-
-
-
-
- شدت اثر: {toPersianDigits(hoveredItem.item.impactScore)} از ۵
-
-
- کلیک جهت مشاهده پرونده ↗
-
-
)}
diff --git a/src/components/visual/GlobalOutlook.tsx b/src/components/visual/GlobalOutlook.tsx
new file mode 100644
index 0000000..a53a81b
--- /dev/null
+++ b/src/components/visual/GlobalOutlook.tsx
@@ -0,0 +1,338 @@
+import React from 'react';
+import { toPersianDigits } from '../../utils/persianNumbers';
+import {
+ WEF_HEADLINES,
+ GROWING_ROLES,
+ DECLINING_ROLES,
+ NEWLY_DECLINING,
+ RISING_SKILLS,
+ DECLINING_SKILLS,
+ BEST_MAJORS,
+ WORST_MAJORS,
+ GRADUATE_CONTEXT,
+ PAY_PARADOX,
+ SOURCE_WEF,
+ SOURCE_NYFED,
+ type GlobalRole,
+ type SourceRef
+} from '../../data/globalLaborMarket';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ExternalLink, TrendingUp, TrendingDown, Info } from 'lucide-react';
+
+/** Renders a figure's provenance under the block it belongs to. */
+const Source: React.FC<{ source: SourceRef }> = ({ source }) => (
+
+ منبع:
+
+ {source.label}
+
+
+ · {source.asOf}
+
+);
+
+/**
+ * Diverging bar: growth runs one way from the centre line, decline the other.
+ * Both sides share a scale so a +40% and a −40% bar are the same length.
+ */
+const DivergingRow: React.FC<{
+ role: GlobalRole;
+ scale: number;
+ direction: 'up' | 'down';
+}> = ({ role, scale, direction }) => {
+ const pct = role.changePercent;
+ const known = pct !== null;
+ const width = known ? (Math.abs(pct) / scale) * 100 : 0;
+ const color = direction === 'up' ? 'var(--positive)' : 'var(--negative)';
+
+ return (
+
+
+ {toPersianDigits(role.rank)}
+
+
+
+ {role.title}
+
+
+
+ {known ? (
+
+ ) : (
+ // The source ranks this role but publishes no figure — say so
+ // rather than inventing a bar.
+
+ )}
+
+
+
+ {known ? `${pct > 0 ? '+' : '−'}${toPersianDigits(Math.abs(pct))}٪` : '—'}
+
+
+ );
+};
+
+const MajorRow: React.FC<{
+ title: string;
+ value: number;
+ scale: number;
+ color: string;
+}> = ({ title, value, scale, color }) => (
+
+ {title}
+
+
+
+
+ {toPersianDigits(value.toFixed(1))}٪
+
+
+);
+
+export const GlobalOutlook: React.FC = () => {
+ const roleScale = Math.max(
+ ...GROWING_ROLES.map((r) => Math.abs(r.changePercent ?? 0)),
+ ...DECLINING_ROLES.map((r) => Math.abs(r.changePercent ?? 0))
+ );
+ const majorScale = Math.max(...WORST_MAJORS.map((m) => m.unemployment));
+
+ return (
+
+ {/* Headline figures */}
+
+ {WEF_HEADLINES.map((stat) => (
+
+ {stat.label}
+
+ {stat.value}
+
+ {stat.note}
+
+ ))}
+
+
+ {/* Occupations */}
+
+
+
+
+
+ پرشتابترین مشاغل رو به رشد جهان
+
+
+ تغییر پیشبینیشده تعداد شاغلان، ۲۰۲۵ تا ۲۰۳۰.
+
+
+
+
+ {GROWING_ROLES.map((role) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ پرشتابترین مشاغل رو به کاهش جهان
+
+
+ رتبهبندی گزارش؛ درصد فقط برای سه مورد نخست منتشر شده است.
+
+
+
+
+ {DECLINING_ROLES.map((role) => (
+
+ ))}
+
+
+
+
+
+ برای نخستین بار{' '}
+
+ {NEWLY_DECLINING.join(' و ')}
+ {' '}
+ وارد فهرست مشاغل رو به کاهش شدهاند؛ نشانه رسیدن هوش مصنوعی مولد به
+ کارهای دانشی.
+
+
+
+
+
+
+
+
+ {/* Majors */}
+
+
+
+ نرخ بیکاری فارغالتحصیلان تازه، بر حسب رشته
+
+
+ فارغالتحصیلان ۲۲ تا ۲۷ ساله، در {toPersianDigits(GRADUATE_CONTEXT.majorsTracked)}{' '}
+ رشته. میانگین کل: بیکاری {GRADUATE_CONTEXT.unemployment} و اشتغال
+ نامتناسب {GRADUATE_CONTEXT.underemployment}.
+
+
+
+
+
+
+
+ کمترین بیکاری
+
+
+ {BEST_MAJORS.map((m) => (
+
+ ))}
+
+
+
+
+
+ بیشترین بیکاری
+
+
+ {WORST_MAJORS.map((m) => (
+
+ ))}
+
+
+
+
+ {/* The finding that matters for curriculum planning. */}
+
+
+ نکتهای که برای بازنگری رشتهها اهمیت دارد
+
+
+ پردرآمدترین رشته،{' '}
+
+ {PAY_PARADOX.topPayingMajor}
+ {' '}
+ با درآمد میانه{' '}
+
+ {PAY_PARADOX.topPayingWage}
+
+ ، همزمان یکی از بالاترین نرخهای بیکاری را دارد (
+
+ {toPersianDigits(PAY_PARADOX.topPayingUnemployment)}٪
+
+ ). در مقابل، کمریسکترین رشتهها — عمدتاً تعلیم و تربیت — کمترین درآمد
+ اولیه را دارند. امنیت شغلی و درآمد در دادههای امسال همجهت نیستند.
+
+
+
+
+
+
+
+ {/* Skills */}
+
+
+
+ مهارتهای رو به رشد تا ۲۰۳۰
+
+ به ترتیب اهمیتی که کارفرمایان اعلام کردهاند.
+
+
+
+
+ {RISING_SKILLS.map((skill, i) => (
+
+
+ {toPersianDigits(i + 1)}
+
+ {skill.title}
+
+ {skill.kind}
+
+
+ ))}
+
+
+
+
+
+
+
+ مهارتهای رو به کاهش
+
+ مهارتهایی که کارفرمایان کاهش اهمیت آنها را پیشبینی میکنند.
+
+
+
+
+ {DECLINING_SKILLS.map((skill) => (
+
+ {skill.title}
+
+ {skill.note}
+
+
+ ))}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/visual/ImpactGauges.tsx b/src/components/visual/ImpactGauges.tsx
index 03e0fa0..29e063e 100644
--- a/src/components/visual/ImpactGauges.tsx
+++ b/src/components/visual/ImpactGauges.tsx
@@ -4,131 +4,67 @@ import { toPersianDigits } from '../../utils/persianNumbers';
import {
GraduationCap,
Microscope,
- Cpu,
+ Settings2,
Users,
+ UserCog,
Briefcase,
- Target,
- Sparkles
+ Target
} from 'lucide-react';
interface ImpactGaugesProps {
impact: ImpactAssessment;
}
-interface DimensionConfig {
+const AREAS: {
key: keyof ImpactAssessment;
title: string;
- desc: string;
- icon: React.ReactNode;
- color: string;
-}
+ icon: React.ElementType;
+}[] = [
+ { key: 'education', title: 'آموزش و برنامه درسی', icon: GraduationCap },
+ { key: 'research', title: 'پژوهش', icon: Microscope },
+ { key: 'operations', title: 'فرآیندهای اداری', icon: Settings2 },
+ { key: 'students', title: 'دانشجویان', icon: Users },
+ { key: 'faculty', title: 'اعضای هیئت علمی', icon: UserCog },
+ { key: 'laborMarket', title: 'بازار کار', icon: Briefcase },
+ { key: 'strategicImportance', title: 'اهمیت راهبردی', icon: Target }
+];
-export const ImpactGauges: React.FC
= ({ impact }) => {
- const dimensions: DimensionConfig[] = [
- {
- key: 'education',
- title: 'آموزش و برنامههای درسی',
- desc: 'میزان دگرگونی در شیوههای تدریس، یادگیری و امتحانات',
- icon: ,
- color: '#38bdf8'
- },
- {
- key: 'research',
- title: 'پژوهش و تولید علم',
- desc: 'تأثیر بر سرعت تحقیقات، روششناسی و آزمایشگاهها',
- icon: ,
- color: '#a855f7'
- },
- {
- key: 'operations',
- title: 'عملیات و فرآیندهای اداری',
- desc: 'تغییر در خدمات دانشجویی، مالی و مدیریت پردیس',
- icon: ,
- color: '#00d4aa'
- },
- {
- key: 'students',
- title: 'تجربه و آینده دانشجویان',
- desc: 'اثر بر مهارتها، انگیزه، سلامت روان و هویت دانشجو',
- icon: ,
- color: '#fbbf24'
- },
- {
- key: 'faculty',
- title: 'اعضای هیئت علمی',
- desc: 'تحول در نقش استاد، بار کاری و معیارهای ارتقا',
- icon: ,
- color: '#ec4899'
- },
- {
- key: 'laborMarket',
- title: 'بازار کار و اشتغالپذیری',
- desc: 'تغییر در تقاضای کارفرمایان و مهارتهای شغلی موردنیاز',
- icon: ,
- color: '#f97316'
- },
- {
- key: 'strategicImportance',
- title: 'اهمیت راهبردی برای دانشگاه اصفهان',
- desc: 'وزن در تصمیمگیری هیئت رئیسه و اولویتبندی بودجه',
- icon: ,
- color: '#f43f5e'
- }
- ];
+const MAX = 5;
- return (
-
- {dimensions.map((dim) => {
- const score = impact[dim.key] || 1;
- const percent = (score / 5) * 100;
+/**
+ * Seven areas on one shared 1–5 scale. A segmented meter rather than a bar,
+ * so the reader can count the score without reading the number.
+ */
+export const ImpactGauges: React.FC
= ({ impact }) => (
+
+ {AREAS.map((area) => {
+ const score = impact[area.key] || 1;
+ const Icon = area.icon;
- return (
-
-
-
-
- {dim.icon}
-
- {dim.title}
-
-
-
- {toPersianDigits(score)}
-
- از ۵
-
-
+ return (
+
+
- {dim.desc}
+ {area.title}
- {/* Score Bar */}
-
-
+ {Array.from({ length: MAX }, (_, i) => (
+
-
+ />
+ ))}
+
- {/* 5 Dots Indicator */}
-
- خیلی کم (۱)
- کم (۲)
- متوسط (۳)
- زیاد (۴)
- بسیار زیاد (۵)
-
-
- );
- })}
-
- );
-};
+
+ {toPersianDigits(score)}
+ /۵
+
+
+ );
+ })}
+
+);
diff --git a/src/components/visual/ImpactNetwork.tsx b/src/components/visual/ImpactNetwork.tsx
new file mode 100644
index 0000000..f6196dd
--- /dev/null
+++ b/src/components/visual/ImpactNetwork.tsx
@@ -0,0 +1,543 @@
+import React, { useMemo, useState } from 'react';
+import {
+ IntelligenceItem,
+ University1415Dimension,
+ JobEntity,
+ SkillEntity
+} from '../../types/futures';
+import { toPersianDigits } from '../../utils/persianNumbers';
+import { CATEGORY_META } from '../../utils/categories';
+import { HORIZON_END } from '../../utils/horizon';
+
+import { Button } from '@/components/ui/button';
+import { X, ArrowLeft, MousePointerClick } from 'lucide-react';
+
+interface ImpactNetworkProps {
+ items: IntelligenceItem[];
+ dimensions: University1415Dimension[];
+ jobs: JobEntity[];
+ skills: SkillEntity[];
+ onOpenItem: (item: IntelligenceItem) => void;
+ onOpenDimension: (dim: University1415Dimension) => void;
+}
+
+type Layer = 'item' | 'skill' | 'job' | 'dimension';
+
+interface Node {
+ id: string;
+ layer: Layer;
+ label: string;
+ color: string;
+ angle: number;
+ radius: number;
+ x: number;
+ y: number;
+ size: number;
+}
+
+interface Link {
+ from: string;
+ to: string;
+}
+
+const LAYERS: { key: Layer; label: string; ratio: number }[] = [
+ { key: 'dimension', label: `ابعاد ${toPersianDigits(HORIZON_END)}`, ratio: 0.32 },
+ { key: 'job', label: 'مشاغل', ratio: 0.56 },
+ { key: 'skill', label: 'مهارتها', ratio: 0.79 },
+ { key: 'item', label: 'پیشرانها', ratio: 1 }
+];
+
+const LAYER_COLOR: Record = {
+ dimension: 'var(--primary)',
+ job: 'var(--cat-policy)',
+ skill: 'var(--cat-trend)',
+ item: 'var(--muted-foreground)'
+};
+
+const LAYER_LABEL: Record = {
+ dimension: `ابعاد ${toPersianDigits(HORIZON_END)}`,
+ job: 'مشاغل',
+ skill: 'مهارتها',
+ item: 'پیشرانها'
+};
+
+/** Quadratic curve bowed toward the centre, so links read as flowing inward. */
+const curve = (ax: number, ay: number, bx: number, by: number, cx: number, cy: number) => {
+ const mx = (ax + bx) / 2;
+ const my = (ay + by) / 2;
+ const qx = mx + (cx - mx) * 0.33;
+ const qy = my + (cy - my) * 0.33;
+ return `M ${ax} ${ay} Q ${qx} ${qy} ${bx} ${by}`;
+};
+
+export const ImpactNetwork: React.FC = ({
+ items,
+ dimensions,
+ jobs,
+ skills,
+ onOpenItem,
+ onOpenDimension
+}) => {
+ /** Pinned by click — survives the pointer leaving, so touch works. */
+ const [pinned, setPinned] = useState(null);
+ /** Transient hover, desktop only. */
+ const [hovered, setHovered] = useState(null);
+
+ const active = hovered ?? pinned;
+
+ const size = 860;
+ const center = size / 2;
+ const maxRadius = size / 2 - 108;
+
+ const { nodes, links, nodeById, neighbours } = useMemo(() => {
+ const links: Link[] = [];
+ const dimIndex = new Map(dimensions.map((d, i) => [d.id, i]));
+
+ const usedSkills = new Set();
+ const usedJobs = new Set();
+ const usedDims = new Set();
+ const usedItems = new Set();
+
+ // Which dimension each entity ultimately feeds. Used purely for ordering:
+ // placing related nodes at similar angles turns a hairball into spokes.
+ const anchor = new Map();
+
+ for (const item of items) {
+ const s = (item.relatedSkillIds ?? []).filter((id) => skills.some((x) => x.id === id));
+ const j = (item.relatedJobIds ?? []).filter((id) => jobs.some((x) => x.id === id));
+ const d = (item.relatedDimensionIds ?? []).filter((id) =>
+ dimensions.some((x) => x.id === id)
+ );
+ if (!s.length && !j.length && !d.length) continue;
+
+ usedItems.add(item.id);
+ const primary = d.length ? (dimIndex.get(d[0]) ?? 99) : 99;
+ if (!anchor.has(item.id)) anchor.set(item.id, primary);
+
+ s.forEach((id) => {
+ usedSkills.add(id);
+ if (!anchor.has(id)) anchor.set(id, primary);
+ links.push({ from: item.id, to: id });
+ });
+ j.forEach((id) => {
+ usedJobs.add(id);
+ if (!anchor.has(id)) anchor.set(id, primary);
+ links.push({ from: item.id, to: id });
+ });
+ d.forEach((id) => {
+ usedDims.add(id);
+ anchor.set(id, dimIndex.get(id) ?? 99);
+ links.push({ from: item.id, to: id });
+ });
+ }
+
+ const byAnchor = (a: { id: string }, b: { id: string }) =>
+ (anchor.get(a.id) ?? 99) - (anchor.get(b.id) ?? 99) || a.id.localeCompare(b.id);
+
+ const built: Node[] = [];
+
+ const place = (
+ layer: Layer,
+ entries: { id: string; label: string; color: string; size: number }[]
+ ) => {
+ const ratio = LAYERS.find((l) => l.key === layer)!.ratio;
+ const radius = maxRadius * ratio;
+ const n = entries.length || 1;
+ // Leave a wedge at due west free for the ring labels.
+ const arc = 2 * Math.PI - 0.34;
+ entries.forEach((entry, i) => {
+ const angle = -Math.PI / 2 + 0.17 + (arc * i) / n;
+ built.push({
+ ...entry,
+ layer,
+ angle,
+ radius,
+ x: center + radius * Math.cos(angle),
+ y: center + radius * Math.sin(angle)
+ });
+ });
+ };
+
+ place(
+ 'dimension',
+ dimensions
+ .filter((d) => usedDims.has(d.id))
+ .sort(byAnchor)
+ .map((d) => ({ id: d.id, label: d.shortTag, color: d.pillarColor, size: 7 }))
+ );
+ place(
+ 'job',
+ jobs
+ .filter((j) => usedJobs.has(j.id))
+ .sort(byAnchor)
+ .map((j) => ({ id: j.id, label: j.title, color: LAYER_COLOR.job, size: 4.5 }))
+ );
+ place(
+ 'skill',
+ skills
+ .filter((s) => usedSkills.has(s.id))
+ .sort(byAnchor)
+ .map((s) => ({ id: s.id, label: s.title, color: LAYER_COLOR.skill, size: 4.5 }))
+ );
+ place(
+ 'item',
+ items
+ .filter((i) => usedItems.has(i.id))
+ .sort(byAnchor)
+ .map((i) => ({
+ id: i.id,
+ label: i.title,
+ color: CATEGORY_META[i.category].color,
+ size: 3.5 + i.impactScore * 0.9
+ }))
+ );
+
+ const nodeById = new Map(built.map((n) => [n.id, n]));
+
+ const neighbours = new Map>();
+ const connect = (a: string, b: string) => {
+ if (!neighbours.has(a)) neighbours.set(a, new Set());
+ neighbours.get(a)!.add(b);
+ };
+ for (const l of links) {
+ connect(l.from, l.to);
+ connect(l.to, l.from);
+ }
+
+ return { nodes: built, links, nodeById, neighbours };
+ }, [items, dimensions, jobs, skills, center, maxRadius]);
+
+ const activeNode = active ? nodeById.get(active) : null;
+ const related = active ? [...(neighbours.get(active) ?? [])] : [];
+ const relatedSet = new Set(related);
+
+ const isLit = (id: string) => !active || id === active || relatedSet.has(id);
+ const linkLit = (l: Link) => !active || l.from === active || l.to === active;
+
+ /** Labels drawn on the canvas: the active node plus everything it touches. */
+ const labelled = activeNode
+ ? [activeNode, ...related.map((id) => nodeById.get(id)!).filter(Boolean)]
+ : [];
+
+ const grouped = useMemo(() => {
+ const out: Record = { item: [], skill: [], job: [], dimension: [] };
+ related.forEach((id) => {
+ const n = nodeById.get(id);
+ if (n) out[n.layer].push(n);
+ });
+ return out;
+ }, [related, nodeById]);
+
+ const openActive = () => {
+ if (!activeNode) return;
+ if (activeNode.layer === 'item') {
+ const item = items.find((i) => i.id === activeNode.id);
+ if (item) onOpenItem(item);
+ } else if (activeNode.layer === 'dimension') {
+ const dim = dimensions.find((d) => d.id === activeNode.id);
+ if (dim) onOpenDimension(dim);
+ }
+ };
+
+ return (
+
+
+
setPinned(null)}
+ >
+
+
+
+
+
+
+
+
+
+ {/* Rings. Labels sit in the reserved wedge at due west. */}
+ {LAYERS.map((layer) => {
+ const r = maxRadius * layer.ratio;
+ return (
+
+
+
+
+ {layer.label}
+
+
+ );
+ })}
+
+ {/* Links */}
+
+ {links.map((l, i) => {
+ const a = nodeById.get(l.from);
+ const b = nodeById.get(l.to);
+ if (!a || !b) return null;
+ const lit = linkLit(l);
+ return (
+
+ );
+ })}
+
+
+ {/* Nodes */}
+ {nodes.map((node, i) => {
+ const lit = isLit(node.id);
+ const isActive = active === node.id;
+ const isPinned = pinned === node.id;
+ return (
+ setHovered(node.id)}
+ onMouseLeave={() => setHovered(null)}
+ onFocus={() => setHovered(node.id)}
+ onBlur={() => setHovered(null)}
+ onClick={(e) => {
+ // Selecting a node never navigates — it reveals its chain.
+ e.stopPropagation();
+ setHovered(null);
+ setPinned((cur) => (cur === node.id ? null : node.id));
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setPinned((cur) => (cur === node.id ? null : node.id));
+ }
+ }}
+ tabIndex={0}
+ role="button"
+ aria-pressed={isPinned}
+ aria-label={`${node.label} — ${LAYER_LABEL[node.layer]}`}
+ >
+ {isActive && (
+
+ )}
+
+
+ );
+ })}
+
+ {/* Names for the active node and everything it touches. */}
+ {labelled.map((node) => {
+ const outward = node.layer === 'item' ? 1 : -1;
+ const dist = node.radius + outward * 16;
+ const lx = center + dist * Math.cos(node.angle);
+ const ly = center + dist * Math.sin(node.angle);
+ const onLeft = Math.cos(node.angle) < 0;
+ const text =
+ node.label.length > 26 ? `${node.label.slice(0, 25)}…` : node.label;
+ const w = text.length * 6.2 + 14;
+
+ return (
+
+
+
+ {text}
+
+
+ );
+ })}
+
+ {/* Centre */}
+
+
+ دانشگاه
+
+
+
+
+ {LAYERS.slice()
+ .reverse()
+ .map((l) => (
+
+
+ {l.label}
+
+ ))}
+
+
+
+ {/* In-page detail — nothing here navigates unless you ask it to. */}
+
+
+ );
+};
diff --git a/src/components/visual/LaborCharts.tsx b/src/components/visual/LaborCharts.tsx
index 74323c8..468f0df 100644
--- a/src/components/visual/LaborCharts.tsx
+++ b/src/components/visual/LaborCharts.tsx
@@ -1,158 +1,179 @@
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { JobEntity, SkillEntity } from '../../types/futures';
import { toPersianDigits } from '../../utils/persianNumbers';
-import { TrendingUp, AlertTriangle, Brain, Sparkles, Code, Users } from 'lucide-react';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Brain, Code, Users, Wrench } from 'lucide-react';
interface LaborChartsProps {
jobs: JobEntity[];
skills: SkillEntity[];
}
+const SKILL_CATEGORIES = [
+ { key: 'cognitive', title: 'شناختی', icon: Brain },
+ { key: 'digital', title: 'دیجیتال', icon: Code },
+ { key: 'social', title: 'اجتماعی', icon: Users },
+ { key: 'technical', title: 'فنی', icon: Wrench }
+] as const;
+
+/** Horizontal bar with the value written at the end of the row. */
+const BarRow: React.FC<{
+ title: string;
+ subtitle: string;
+ value: string;
+ ratio: number;
+ color: string;
+}> = ({ title, subtitle, value, ratio, color }) => (
+
+
+ {title}
+
+ {value}
+
+
+
+ {subtitle}
+
+);
+
export const LaborCharts: React.FC = ({ jobs, skills }) => {
- const [activeTab, setActiveTab] = useState<'jobs' | 'skills'>('jobs');
+ const [tab, setTab] = useState<'jobs' | 'skills'>('jobs');
- const growingJobs = jobs.filter((j) => j.type === 'growing').slice(0, 8);
- const transformingJobs = jobs.filter((j) => j.type === 'transforming').slice(0, 8);
+ const topGrowing = useMemo(
+ () =>
+ jobs
+ .filter((j) => j.type === 'growing')
+ .sort((a, b) => b.growthRatePercent - a.growthRatePercent)
+ .slice(0, 6),
+ [jobs]
+ );
- const skillCategories = [
- { key: 'cognitive', title: 'شناختی و تحلیلی', icon: , color: '#8b5cf6', count: 10 },
- { key: 'digital', title: 'دیجیتال و هوش مصنوعی', icon: , color: '#00b894', count: 12 },
- { key: 'social', title: 'اجتماعی و انسانی', icon: , color: '#f59e0b', count: 8 },
- { key: 'technical', title: 'فنی و مهندسی', icon: , color: '#0284c7', count: 6 }
- ];
+ const topAtRisk = useMemo(
+ () =>
+ jobs
+ .filter((j) => j.type === 'transforming')
+ .sort((a, b) => b.automationRiskPercent - a.automationRiskPercent)
+ .slice(0, 6),
+ [jobs]
+ );
+
+ // Counted from the data rather than hard-coded, so it stays true as the
+ // dataset grows.
+ const skillBreakdown = useMemo(
+ () =>
+ SKILL_CATEGORIES.map((cat) => {
+ const matching = skills.filter((s) => s.category === cat.key);
+ return {
+ ...cat,
+ total: matching.length,
+ growing: matching.filter((s) => s.type === 'growing').length
+ };
+ }),
+ [skills]
+ );
+
+ const maxGrowth = topGrowing[0]?.growthRatePercent || 1;
+ const maxSkillCount = Math.max(1, ...skillBreakdown.map((c) => c.total));
return (
-
- {/* Tab Switcher (Pill Style) */}
-
-
- setActiveTab('jobs')}
- className={`py-1.5 px-4 rounded-full font-bold flex items-center gap-1.5 transition-all ${
- activeTab === 'jobs'
- ? 'bg-slate-900 text-white shadow-sm'
- : 'text-slate-600 hover:text-slate-900'
- }`}
- >
-
- نقشه رشد و ریسک اتوماسیون مشاغل
-
- setActiveTab('skills')}
- className={`py-1.5 px-4 rounded-full font-bold flex items-center gap-1.5 transition-all ${
- activeTab === 'skills'
- ? 'bg-slate-900 text-white shadow-sm'
- : 'text-slate-600 hover:text-slate-900'
- }`}
- >
-
- توزیع شایستگیهای نوظهور و افول
-
-
+
+ setTab(v as typeof tab)}>
+
+ مشاغل
+ مهارتها
+
+
-
- تحلیل انطباق با زیستبوم صنعتی و فناوری استان اصفهان
-
-
+ {tab === 'jobs' && (
+
+
+
+ بیشترین رشد تقاضا
+
+ نرخ رشد پیشبینیشده تا ۱۴۱۵
+
+
+
+
+ {topGrowing.map((j) => (
+
+ ))}
+
+
+
- {activeTab === 'jobs' && (
-
- {/* Top Growing Jobs Ranking */}
-
-
-
-
- بیشترین نرخ رشد تقاضای شغلی (افق تا ۱۴۱۵)
-
- رشد تصاعدی
-
-
-
- {growingJobs.map((j) => (
-
-
- {j.title}
-
- +{toPersianDigits(j.growthRatePercent)}٪
-
-
-
- رشته مرتبط: {j.discipline}
-
- ریسک اتوماسیون: {toPersianDigits(j.automationRiskPercent)}٪
-
-
-
-
- ))}
-
-
-
- {/* Top Transforming Jobs & Automation Vulnerability */}
-
-
-
-
- بیشترین ریسک اتوماسیون و نیاز به بازآفرینی
-
-
در معرض تغییر
-
-
-
- {transformingJobs.map((j) => (
-
-
- {j.title}
-
- {toPersianDigits(j.automationRiskPercent)}٪ خطر اتوماسیون
-
-
-
- حوزه: {j.discipline}
- نیاز مبرم به بازآموزی
-
-
-
- ))}
-
-
+
+
+ بیشترین ریسک اتوماسیون
+
+ سهم وظایف قابل خودکارسازی
+
+
+
+
+ {topAtRisk.map((j) => (
+
+ ))}
+
+
+
)}
- {activeTab === 'skills' && (
-
- {skillCategories.map((cat) => (
-
-
- {cat.title}
-
- {cat.icon}
-
-
-
-
- {toPersianDigits(cat.count)}
-
- مهارت کلیدی
-
-
- تأکید در کوریکولوم دانشگاه اصفهان
-
-
- ))}
+ {tab === 'skills' && (
+
+ {skillBreakdown.map((cat) => {
+ const Icon = cat.icon;
+ return (
+
+
+
+ {cat.title}
+
+
+
+
+
+
+ {toPersianDigits(cat.total)}
+
+ مهارت
+
+
+
+
+
+ {toPersianDigits(cat.growing)} مورد رو به رشد
+
+
+ );
+ })}
)}
diff --git a/src/components/visual/SpiderChart.tsx b/src/components/visual/SpiderChart.tsx
index 7eaf164..cb2c665 100644
--- a/src/components/visual/SpiderChart.tsx
+++ b/src/components/visual/SpiderChart.tsx
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { University1415Dimension } from '../../types/futures';
import { getSpiderCoordinates, pointsToSvgPath } from '../../utils/radarMath';
import { toPersianDigits } from '../../utils/persianNumbers';
+import { HORIZON_END } from '../../utils/horizon';
interface SpiderChartProps {
dimensions: University1415Dimension[];
@@ -9,89 +10,90 @@ interface SpiderChartProps {
height?: number;
}
+const GRID_LEVELS = [2, 4, 6, 8, 10];
+const MAX_SCORE = 10;
+
+/**
+ * Current state vs. the ۱۴۱۵ target across the ten characteristics.
+ * The gap between the two rings is the whole point of the chart, so the
+ * target is drawn as an outline and the current state as the filled shape —
+ * the unfilled band between them *is* the shortfall.
+ */
export const SpiderChart: React.FC
= ({
dimensions,
onSelectDimension,
- height = 580
+ height = 560
}) => {
- const [hoveredIdx, setHoveredIdx] = useState(null);
+ const [hovered, setHovered] = useState(null);
const size = 640;
const center = size / 2;
- const maxRadius = size / 2 - 70;
- const numAxes = dimensions.length; // 10
+ const maxRadius = size / 2 - 76;
+ const axes = dimensions.length;
- const currentScores = dimensions.map((d) => d.currentScore);
- const targetScores = dimensions.map((d) => d.targetScore);
+ if (!axes) return null;
- const currentPoints = getSpiderCoordinates(currentScores, 10, center, maxRadius);
- const targetPoints = getSpiderCoordinates(targetScores, 10, center, maxRadius);
-
- const currentPath = pointsToSvgPath(currentPoints);
- const targetPath = pointsToSvgPath(targetPoints);
-
- // Concentric polygon grids (2, 4, 6, 8, 10)
- const gridLevels = [2, 4, 6, 8, 10];
+ const currentPoints = getSpiderCoordinates(
+ dimensions.map((d) => d.currentScore),
+ MAX_SCORE,
+ center,
+ maxRadius
+ );
+ const targetPoints = getSpiderCoordinates(
+ dimensions.map((d) => d.targetScore),
+ MAX_SCORE,
+ center,
+ maxRadius
+ );
return (
-
+
-
- {/* Gradients */}
-
-
-
-
-
-
-
-
-
-
-
- {/* Concentric Web Grids */}
- {gridLevels.map((lvl) => {
- const gridValues = Array(numAxes).fill(lvl);
- const pts = getSpiderCoordinates(gridValues, 10, center, maxRadius);
- const path = pointsToSvgPath(pts);
+ {/* Web */}
+ {GRID_LEVELS.map((level) => {
+ const path = pointsToSvgPath(
+ getSpiderCoordinates(Array(axes).fill(level), MAX_SCORE, center, maxRadius)
+ );
+ const isOuter = level === MAX_SCORE;
return (
-
+
- {toPersianDigits(lvl)}
+ {toPersianDigits(level)}
);
})}
- {/* Radial Axis Lines */}
+ {/* Axes + labels */}
{dimensions.map((dim, idx) => {
- const angle = (2 * Math.PI * idx) / numAxes - Math.PI / 2;
+ const angle = (2 * Math.PI * idx) / axes - Math.PI / 2;
const endX = center + maxRadius * Math.cos(angle);
const endY = center + maxRadius * Math.sin(angle);
-
- // Label positions (slightly outside maxRadius)
- const labelDist = maxRadius + 36;
+ const labelDist = maxRadius + 34;
const lx = center + labelDist * Math.cos(angle);
const ly = center + labelDist * Math.sin(angle);
-
- const isHovered = hoveredIdx === idx;
+ const isHovered = hovered === idx;
return (
@@ -100,35 +102,30 @@ export const SpiderChart: React.FC = ({
y1={center}
x2={endX}
y2={endY}
- stroke="#e2e8f0"
- strokeWidth="1.2"
+ stroke="var(--border)"
/>
-
- {/* Interactive Label on Axis End */}
onSelectDimension && onSelectDimension(dim)}
- onMouseEnter={() => setHoveredIdx(idx)}
- onMouseLeave={() => setHoveredIdx(null)}
+ onClick={() => onSelectDimension?.(dim)}
+ onMouseEnter={() => setHovered(idx)}
+ onMouseLeave={() => setHovered(null)}
>
{dim.shortTag}
@@ -137,104 +134,96 @@ export const SpiderChart: React.FC = ({
);
})}
- {/* Target 1415 Polygon (دانشگاه اصفهان ۱۴۱۵) */}
+ {/* Target ring — outline only. */}
- {/* Current State Polygon (وضع موجود) */}
+ {/* Current state — filled, so the shortfall reads as empty space. */}
- {/* Target 1415 Vertex Dots */}
{targetPoints.map((pt, idx) => (
setHoveredIdx(idx)}
- onMouseLeave={() => setHoveredIdx(null)}
+ className="cursor-pointer"
+ onMouseEnter={() => setHovered(idx)}
+ onMouseLeave={() => setHovered(null)}
/>
))}
- {/* Current State Vertex Dots */}
{currentPoints.map((pt, idx) => (
setHoveredIdx(idx)}
- onMouseLeave={() => setHoveredIdx(null)}
+ className="cursor-pointer"
+ onMouseEnter={() => setHovered(idx)}
+ onMouseLeave={() => setHovered(null)}
/>
))}
-
- {/* Center Hub Marker */}
-
- {/* Interactive Hover Tooltip (Light Bento Card) */}
- {hoveredIdx !== null && (
-
-
-
{dimensions[hoveredIdx].title}
-
- آمادگی: {toPersianDigits(dimensions[hoveredIdx].readinessPercentage)}٪
+ {hovered !== null && (
+
+
{dimensions[hovered].title}
+
+ {dimensions[hovered].titleEn}
+
+
+
+
+ موجود{' '}
+
+ {toPersianDigits(dimensions[hovered].currentScore)}
+
+
+
+ هدف{' '}
+
+ {toPersianDigits(dimensions[hovered].targetScore)}
+
+
+
+ شکاف{' '}
+ {toPersianDigits(dimensions[hovered].gap)}
-
-
- وضع موجود: {toPersianDigits(dimensions[hoveredIdx].currentScore)}
-
-
- هدف ۱۴۱۵: {toPersianDigits(dimensions[hoveredIdx].targetScore)}
-
-
- شکاف: {toPersianDigits(dimensions[hoveredIdx].gap)}
-
-
-
-
- {dimensions[hoveredIdx].vision}
+
+ {dimensions[hovered].vision}
)}
- {/* Spider Chart Legend */}
-
-
-
- هدف دانشگاه اصفهان ۱۴۱۵ (وضع مطلوب)
-
-
-
- وضعیت موجود دانشگاه (مبنای سنجش)
-
+
+
+
+ وضع موجود
+
+
+
+ هدف {toPersianDigits(HORIZON_END)}
+
);
diff --git a/src/components/visual/StrategicGraph.tsx b/src/components/visual/StrategicGraph.tsx
index f016dd2..934d87f 100644
--- a/src/components/visual/StrategicGraph.tsx
+++ b/src/components/visual/StrategicGraph.tsx
@@ -1,7 +1,16 @@
-import React, { useState } from 'react';
-import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../../types/futures';
-import { toPersianDigits } from '../../utils/persianNumbers';
-import { ArrowLeft, GitFork, Cpu, GraduationCap, Briefcase, Target } from 'lucide-react';
+import React, { useMemo, useState } from 'react';
+import {
+ IntelligenceItem,
+ University1415Dimension,
+ JobEntity,
+ SkillEntity
+} from '../../types/futures';
+import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
+import { CATEGORY_META } from '../../utils/categories';
+import { HORIZON_END } from '../../utils/horizon';
+
+import { Card } from '@/components/ui/card';
+import { ChevronLeft, Cpu, Brain, Briefcase, Target } from 'lucide-react';
interface StrategicGraphProps {
items: IntelligenceItem[];
@@ -11,6 +20,13 @@ interface StrategicGraphProps {
onSelectEntity?: (type: string, id: string) => void;
}
+type Chain = {
+ item: IntelligenceItem;
+ skill?: SkillEntity;
+ job?: JobEntity;
+ dimension?: University1415Dimension;
+};
+
export const StrategicGraph: React.FC = ({
items,
dimensions,
@@ -18,221 +34,183 @@ export const StrategicGraph: React.FC = ({
skills,
onSelectEntity
}) => {
- // Pre-configured end-to-end strategic disruption pathways for demonstration
- const scenarios = [
- {
- id: 'sc-1',
- title: 'زنجیره هوش مصنوعی مولد و دانشگاه هوشمند (AI-Native Disruption)',
- tech: items.find((i) => i.id === 'tc-1') || items[0],
- trend: items.find((i) => i.id === 'tr-1') || items[0],
- skill: skills.find((s) => s.id === 'sk-1') || skills[0],
- job: jobs.find((j) => j.id === 'job-1') || jobs[0],
- dimension: dimensions.find((d) => d.id === 'dim-1') || dimensions[0],
- gapAnalysis: 'شکاف ۵.۳ نمرهای در آمادگی زیرساخت هوش مصنوعی و نیاز به ۳۰ سرور پردازش گرافیکی بومی.'
- },
- {
- id: 'sc-2',
- title: 'زنجیره بحران آب، فرونشست زمین و دانشگاه مسئلهمحور (Water & Subsidence Mission)',
- tech: items.find((i) => i.id === 'tc-12') || items[1],
- trend: items.find((i) => i.id === 'tr-10') || items[1],
- skill: skills.find((s) => s.id === 'sk-4') || skills[1],
- job: jobs.find((j) => j.id === 'job-16') || jobs[1],
- dimension: dimensions.find((d) => d.id === 'dim-10') || dimensions[1],
- gapAnalysis: 'شکاف ۴.۴ نمرهای در پژوهشهای مأموریتگرا و فقدان آزمایشگاه متمرکز پایش فرونشست.'
- },
- {
- id: 'sc-3',
- title: 'زنجیره ریزمدرکها، دورههای صنعتی Co-op و دانشگاه مهارتمحور (Skills & Co-op Pathway)',
- tech: items.find((i) => i.id === 'tc-5') || items[2],
- trend: items.find((i) => i.id === 'tr-2') || items[2],
- skill: skills.find((s) => s.id === 'sk-8') || skills[2],
- job: jobs.find((j) => j.id === 'job-2') || jobs[2],
- dimension: dimensions.find((d) => d.id === 'dim-3') || dimensions[2],
- gapAnalysis: 'شکاف ۴.۶ نمرهای در آموزشهای مبتنی بر شایستگی و مقاومت در حذف دروس تئوری غیرکاربردی.'
- }
- ];
+ // Chains are built from the links stored on each item, not hard-coded, so
+ // they stay correct when the dataset changes.
+ const chains: Chain[] = useMemo(() => {
+ return items
+ .filter(
+ (item) =>
+ item.relatedDimensionIds?.length &&
+ (item.relatedSkillIds?.length || item.relatedJobIds?.length)
+ )
+ .sort((a, b) => b.impactScore - a.impactScore)
+ .slice(0, 6)
+ .map((item) => ({
+ item,
+ skill: skills.find((s) => item.relatedSkillIds?.includes(s.id)),
+ job: jobs.find((j) => item.relatedJobIds?.includes(j.id)),
+ dimension: dimensions.find((d) => item.relatedDimensionIds?.includes(d.id))
+ }));
+ }, [items, skills, jobs, dimensions]);
- const [activeScenarioIdx, setActiveScenarioIdx] = useState(0);
- const activeScenario = scenarios[activeScenarioIdx];
+ const [activeIdx, setActiveIdx] = useState(0);
+ const active = chains[activeIdx];
+
+ if (!active) {
+ return (
+
+ زنجیرهای برای نمایش وجود ندارد.
+
+ );
+ }
+
+ const meta = CATEGORY_META[active.item.category];
return (
-
- {/* Scenario Selector Header */}
-
-
-
- زنجیره ارزش تحول راهبردی:
-
-
-
- {scenarios.map((sc, idx) => (
+
+ {/* Chain picker */}
+
+
+ {chains.map((chain, idx) => (
setActiveScenarioIdx(idx)}
- className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
- activeScenarioIdx === idx
- ? 'bg-teal-500 text-slate-950 shadow-md shadow-teal-500/20 font-bold'
- : 'bg-slate-800 text-slate-300 hover:bg-slate-700'
+ key={chain.item.id}
+ onClick={() => setActiveIdx(idx)}
+ className={`inline-flex max-w-[16rem] shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors ${
+ activeIdx === idx
+ ? 'border-transparent bg-foreground text-background'
+ : 'bg-background hover:bg-muted'
}`}
>
- {sc.title.split(' ')[1]} {sc.title.split(' ')[2]}
+
+ {chain.item.title}
))}
- {/* Disruption Chain Visual Flow */}
-
-
-
- {activeScenario.title}
-
-
- ردگیری مسیر تبدیل سیگنال/فناوری بیرونی به شکاف قابلیت در دانشگاه اصفهان
-
-
+ {/* The chain itself */}
+
+
onSelectEntity?.('item', active.item.id)}
+ />
- {/* Linear Step Flow Cards */}
-
- {/* Step 1: Technology / Signal */}
-
onSelectEntity && onSelectEntity('item', activeScenario.tech.id)}
- >
-
-
-
-
- ۱. پیشران فناورانه
-
- محرک بیرونی
-
-
- {activeScenario.tech.title}
-
-
- {activeScenario.tech.executiveSummary}
-
-
-
- افق: {toPersianDigits(activeScenario.tech.horizon)} | تأثیر: {toPersianDigits(activeScenario.tech.impactScore)} از ۵
-
-
+
- {/* Step 2: Educational Disruption */}
-
onSelectEntity && onSelectEntity('item', activeScenario.trend.id)}
- >
-
-
-
-
- ۲. اختلال آموزشی و پژوهشی
-
- روند تغییر
-
-
- {activeScenario.trend.title}
-
-
- {activeScenario.trend.whyItMatters}
-
-
-
- تغییر مدل آموزش و ارزشیابی
-
-
+
- {/* Step 3: Required Skills */}
-
-
-
-
-
- ۳. شایستگی نوظهور
-
-
مهارت الزامی
-
-
- {activeScenario.skill.title}
-
-
- {activeScenario.skill.description}
-
-
-
- رده: {activeScenario.skill.category}
-
-
-
- {/* Step 4: Transformed Jobs */}
-
-
-
-
-
- ۴. بازار کار آینده
-
- مشاغل هدف
-
-
- {activeScenario.job.title}
-
-
- {activeScenario.job.description}
-
-
-
- رشته: {activeScenario.job.discipline}
-
-
-
- {/* Step 5: University 1415 Capability Gap */}
-
onSelectEntity && onSelectEntity('dimension', activeScenario.dimension.id)}
- >
-
-
-
-
- ۵. شکاف قابلیت ۱۴۱۵
-
- نقطه مداخله
-
-
- {activeScenario.dimension.title}
-
-
- {activeScenario.gapAnalysis}
-
-
-
- وضع موجود: {toPersianDigits(activeScenario.dimension.currentScore)}
- هدف: {toPersianDigits(activeScenario.dimension.targetScore)}
-
-
-
-
- {/* Strategic Takeaway Memo Box */}
-
-
-
- نتیجهگیری سیاستی برای هیئت رئیسه دانشگاه اصفهان:
-
-
- این پیشران بیرونی در صورت انفعال دانشگاه، به تهدیدی برای تعطیلی گرایشهای مرتبط و افت جذب دانشجو تبدیل خواهد شد؛ اما با بازنگری هدفمند سرفصلها و تجهیز آزمایشگاهها میتواند به مزیت رقابتی دانشگاه اصفهان در منطقه مرکزی تبدیل شود.
-
-
-
onSelectEntity && onSelectEntity('dimension', activeScenario.dimension.id)}
- className="btn btn-primary text-xs py-2 px-3.5 self-end md:self-auto shrink-0"
- >
- مشاهده برنامه اقدام بعد ۱۴۱۵
-
-
+ onSelectEntity?.('dimension', active.dimension!.id)
+ : undefined
+ }
+ isLast
+ />
);
};
+
+const ChainStep: React.FC<{
+ step: number;
+ label: string;
+ icon: React.ElementType;
+ color: string;
+ title: string;
+ body: string;
+ footer?: string;
+ onClick?: () => void;
+ isLast?: boolean;
+}> = ({ step, label, icon: Icon, color, title, body, footer, onClick, isLast }) => (
+
+
+
+
+
+ {label}
+
+
+ {toPersianDigits(step)}
+
+
+
+ {title}
+
+
+ {body}
+
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+ {/* Connector arrow — RTL, so the flow runs right to left. */}
+ {!isLast && (
+
+ )}
+
+);
diff --git a/src/components/visual/TriangleView.tsx b/src/components/visual/TriangleView.tsx
index c6d2497..829ba07 100644
--- a/src/components/visual/TriangleView.tsx
+++ b/src/components/visual/TriangleView.tsx
@@ -1,404 +1,248 @@
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { FuturesForce, ForceType } from '../../types/futures';
import { toPersianDigits } from '../../utils/persianNumbers';
-import {
- Compass,
- ArrowUpRight,
- ShieldAlert,
- Sparkles,
- TrendingUp,
- Target
-} from 'lucide-react';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
interface TriangleViewProps {
forces: FuturesForce[];
onSelectForce?: (force: FuturesForce) => void;
}
+/** The three vertices, worded and ordered as in the brief. */
+const GROUPS: {
+ type: ForceType;
+ label: string;
+ latin: string;
+ hint: string;
+ color: string;
+ soft: string;
+}[] = [
+ {
+ type: 'pull',
+ label: 'کشش آینده',
+ latin: 'PULL',
+ hint: 'تصویر مطلوبی که دانشگاه را به جلو میکشد.',
+ color: 'var(--cat-tech)',
+ soft: 'var(--cat-tech-soft)'
+ },
+ {
+ type: 'push',
+ label: 'فشارهای حال',
+ latin: 'PUSH',
+ hint: 'نیروهایی که همین امروز تغییر را تحمیل میکنند.',
+ color: 'var(--cat-trend)',
+ soft: 'var(--cat-trend-soft)'
+ },
+ {
+ type: 'weight',
+ label: 'وزن گذشته',
+ latin: 'WEIGHT',
+ hint: 'ساختارها و عادتهایی که حرکت را کند میکنند.',
+ color: 'var(--cat-policy)',
+ soft: 'var(--cat-policy-soft)'
+ }
+];
+
+// Triangle geometry in a 640×470 viewBox.
+const APEX = { x: 320, y: 46 };
+const RIGHT = { x: 588, y: 414 };
+const LEFT = { x: 52, y: 414 };
+
export const TriangleView: React.FC
= ({ forces, onSelectForce }) => {
- const [selectedForce, setSelectedForce] = useState(forces[0]);
- const [activeTab, setActiveTab] = useState('pull');
+ const [selected, setSelected] = useState(forces[0] ?? null);
- const pullForces = forces.filter((f) => f.forceType === 'pull');
- const pushForces = forces.filter((f) => f.forceType === 'push');
- const weightForces = forces.filter((f) => f.forceType === 'weight');
+ const grouped = useMemo(
+ () => ({
+ pull: forces.filter((f) => f.forceType === 'pull'),
+ push: forces.filter((f) => f.forceType === 'push'),
+ weight: forces.filter((f) => f.forceType === 'weight')
+ }),
+ [forces]
+ );
- const handleForceClick = (f: FuturesForce) => {
- setSelectedForce(f);
- setActiveTab(f.forceType);
- if (onSelectForce) onSelectForce(f);
+ const strength = (type: ForceType) => {
+ const list = grouped[type];
+ if (!list.length) return 0;
+ return list.reduce((sum, f) => sum + f.strength, 0) / list.length;
};
- // Sleek Equilateral Geometry in 800x560 coordinate space
- // Base: 650 - 150 = 500, Height: 480 - 50 = 430 (Proportion ~0.86, perfectly sleek)
- const apex = { x: 400, y: 50 };
- const rightCorner = { x: 650, y: 480 };
- const leftCorner = { x: 150, y: 480 };
- const centroid = { x: 400, y: 336 };
+ const pick = (force: FuturesForce) => {
+ setSelected(force);
+ onSelectForce?.(force);
+ };
+
+ if (!forces.length) return null;
+
+ const selectedGroup = selected
+ ? GROUPS.find((g) => g.type === selected.forceType)!
+ : GROUPS[0];
return (
-
- {/* Visual Triangle Composition Canvas */}
-
- {/* Subtle Ambient Background Gradients */}
-
-
-
+
+
+
+
+ {/* The triangle itself */}
+
- {/* 1. TOP VERTEX APEX CLUSTER: PULL OF THE FUTURE (کشش آینده) */}
-
-
-
- کشش آینده — تصویر آرمانی دانشگاه
-
-
- جاذبهها، چشماندازهای نو و الگوهای مطلوب آینده
-
+ {/* Each vertex is scaled by the average strength of its group,
+ so the shape shows which way the university is being pulled. */}
+ {GROUPS.map((group, i) => {
+ const vertex = [APEX, RIGHT, LEFT][i];
+ const avg = strength(group.type);
+ const r = 26 + avg * 2.6;
+ const count = grouped[group.type].length;
- {/* Pull Force Pills */}
-
- {pullForces.map((f) => {
- const isSelected = selectedForce.id === f.id;
return (
- handleForceClick(f)}
- className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
- isSelected
- ? 'bg-slate-900 text-white shadow-md scale-105'
- : 'bg-white text-slate-800 hover:bg-purple-50 hover:text-purple-900 border border-purple-200'
- }`}
- >
- {f.title}
-
- {toPersianDigits(f.strength)}
-
-
+
+
+
+ {group.label}
+
+
+ {toPersianDigits(count)} نیرو · {toPersianDigits(avg.toFixed(1))}
+
+
);
})}
-
-
- {/* 2. SLEEK PROPORTIONAL GEOMETRIC TRIANGLE SVG CANVAS */}
-
-
-
- {/* Triangular Gradient Fill */}
-
-
-
-
-
-
- {/* Edge Gradient: Left (PULL -> WEIGHT) */}
-
-
-
-
-
- {/* Edge Gradient: Right (PULL -> PUSH) */}
-
-
-
-
-
- {/* Edge Gradient: Bottom (WEIGHT <-> PUSH) */}
-
-
-
-
-
- {/* Glow filter */}
-
-
-
-
-
- {/* REAL SOLID SLEEK GEOMETRIC TRIANGLE */}
-
-
- {/* INNER EQUILIBRIUM DASHED TRIANGLE */}
-
-
- {/* CENTROID TETHERS (Lines from centroid to 3 corners) */}
-
-
-
-
- {/* PROMINENT TRIANGLE EDGES (SLEEK & SOLID) */}
- {/* Right Edge: PULL to PUSH */}
-
-
- {/* Left Edge: PULL to WEIGHT */}
-
-
- {/* Bottom Edge: WEIGHT to PUSH */}
-
-
- {/* Edge Vector Dynamics Badges */}
- {/* Right edge midpoint */}
-
-
-
- پویش روندهای زمان حال ←
-
-
-
- {/* Left edge midpoint */}
-
-
-
- → مهار توسط لنگرهای گذشته
-
-
-
- {/* Bottom edge midpoint */}
-
-
-
- تعارض لختی سنتی و شتاب فناوری
-
-
-
- {/* CENTRAL HUB: آینده دانشگاه اصفهان (At Centroid) */}
-
-
-
-
-
-
- دانشگاه اصفهان
-
-
- افق ۱۴۱۵
-
-
-
- {/* VERTEX CORNER NODES */}
- {/* Top Vertex (PULL) */}
-
-
-
-
-
- {/* Bottom-Right Vertex (PUSH) */}
-
-
-
-
-
- {/* Bottom-Left Vertex (WEIGHT) */}
-
-
-
-
+ {/* The university sits at the centroid, between the three pulls. */}
+
+
+ دانشگاه اصفهان
+
+
+
+
+
+ {/* The three force lists */}
+
+ {GROUPS.map((group) => (
+
+
+
+
+ {group.label}
+
+ {group.latin}
+
+
+
+ {group.hint}
+
+
+
+
+
+ {grouped[group.type].map((force) => {
+ const active = selected?.id === force.id;
+ return (
+
+ pick(force)}
+ className={cn(
+ 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-right text-[12px] transition-colors',
+ active ? 'bg-muted font-semibold' : 'hover:bg-muted/60'
+ )}
+ >
+ {force.title}
+
+ {toPersianDigits(force.strength)}
+
+
+
+ );
+ })}
+
+
+
+ ))}
- {/* 3. BOTTOM TWO CORNER PANELS (PUSH & WEIGHT) */}
-
- {/* PUSH PANEL (Bottom-Right Vertex) */}
-
-
-
- فشارهای زمان حال
-
-
- روندها، فناوریهای فعال و تحولات جاری
-
+ {/* Detail of the selected force */}
+ {selected && (
+
+
+
+
+ {selectedGroup.label}
+
+
+ شدت {toPersianDigits(selected.strength)} از ۱۰
+
+
+
+ {selected.title}
+
+
-
- {pushForces.map((f) => {
- const isSelected = selectedForce.id === f.id;
- return (
- handleForceClick(f)}
- className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
- isSelected
- ? 'bg-slate-900 text-white shadow-md scale-105'
- : 'bg-white text-slate-800 hover:bg-sky-100 hover:text-sky-900 border border-sky-200'
- }`}
- >
- {f.title}
-
- {toPersianDigits(f.strength)}
-
-
- );
- })}
-
-
-
- {/* WEIGHT PANEL (Bottom-Left Vertex) */}
-
-
-
- وزن گذشته
-
-
- موانع ساختاری، آییننامهها و لختی سازمانی
-
-
-
- {weightForces.map((f) => {
- const isSelected = selectedForce.id === f.id;
- return (
- handleForceClick(f)}
- className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
- isSelected
- ? 'bg-slate-900 text-white shadow-md scale-105'
- : 'bg-white text-slate-800 hover:bg-rose-100 hover:text-rose-900 border border-rose-200'
- }`}
- >
- {f.title}
-
- {toPersianDigits(f.strength)}
-
-
- );
- })}
-
-
-
+
+
+
+
+
+
+
+ )}
-
- {/* Selected Force Intelligence Dossier Card */}
- {selectedForce && (
-
-
-
-
-
{selectedForce.title}
-
- {selectedForce.forceType === 'pull'
- ? 'کشش آینده'
- : selectedForce.forceType === 'push'
- ? 'فشار زمان حال'
- : 'وزن گذشته'}
-
-
-
-
- شدت اثر نیرو:
-
- {toPersianDigits(selectedForce.strength)} از ۱۰
-
-
-
-
-
- شواهد و مؤیدهای تجربی:
- {selectedForce.evidence}
-
-
-
-
-
پیامد راهبردی برای دانشگاه اصفهان:
-
{selectedForce.impact}
-
-
-
-
اقدام پیشنهادی جهت غلبه / همافزایی:
-
{selectedForce.strategicImplication}
-
-
-
- )}
);
};
+
+const DetailBlock: React.FC<{ title: string; body: string }> = ({ title, body }) => (
+
+);
diff --git a/src/data/globalLaborMarket.ts b/src/data/globalLaborMarket.ts
new file mode 100644
index 0000000..a4d95b5
--- /dev/null
+++ b/src/data/globalLaborMarket.ts
@@ -0,0 +1,213 @@
+/**
+ * Global labour-market reference data.
+ *
+ * Every figure here is transcribed from a published source and carries that
+ * source with it, so the UI can always show where a number came from. Nothing
+ * in this file is estimated or interpolated — where a source does not publish
+ * a figure, the field is `null` and the UI says so.
+ */
+
+export interface SourceRef {
+ /** Short label shown next to the figure. */
+ label: string;
+ url: string;
+ /** Vintage of the underlying data, in Persian. */
+ asOf: string;
+}
+
+export const SOURCE_WEF: SourceRef = {
+ label: 'مجمع جهانی اقتصاد — گزارش آینده مشاغل ۲۰۲۵',
+ url: 'https://www.weforum.org/publications/the-future-of-jobs-report-2025/',
+ asOf: 'ژانویه ۲۰۲۵ — نظرسنجی از ۱۰۴۳ کارفرما در ۵۵ کشور'
+};
+
+export const SOURCE_NYFED: SourceRef = {
+ label: 'بانک فدرال رزرو نیویورک — بازار کار فارغالتحصیلان',
+ url: 'https://www.newyorkfed.org/research/college-labor-market',
+ asOf: 'انتشار بهمن ۱۴۰۴ (فوریه ۲۰۲۶) بر پایه سرشماری ۲۰۲۴'
+};
+
+/* ------------------------------------------------------------------ */
+/* Headline figures */
+/* ------------------------------------------------------------------ */
+
+export interface HeadlineStat {
+ key: string;
+ label: string;
+ value: string;
+ note: string;
+ tone: 'positive' | 'negative' | 'neutral';
+}
+
+export const WEF_HEADLINES: HeadlineStat[] = [
+ {
+ key: 'created',
+ label: 'شغل ایجادشده تا ۲۰۳۰',
+ value: '۱۷۰ میلیون',
+ note: 'معادل ۱۴٪ اشتغال امروز',
+ tone: 'positive'
+ },
+ {
+ key: 'displaced',
+ label: 'شغل ازدسترفته',
+ value: '۹۲ میلیون',
+ note: 'جابهجایی ناشی از فناوری',
+ tone: 'negative'
+ },
+ {
+ key: 'net',
+ label: 'خالص تغییر',
+ value: '+۷۸ میلیون',
+ note: 'برآیند ایجاد و حذف',
+ tone: 'positive'
+ },
+ {
+ key: 'skills',
+ label: 'مهارتهای دگرگونشونده',
+ value: '۳۹٪',
+ note: 'سهم مهارتهای اصلی تا ۲۰۳۰',
+ tone: 'neutral'
+ }
+];
+
+/* ------------------------------------------------------------------ */
+/* Occupations — global, not university-specific */
+/* ------------------------------------------------------------------ */
+
+export interface GlobalRole {
+ rank: number;
+ title: string;
+ /** Percentage change 2025–2030. `null` where the source publishes only a rank. */
+ changePercent: number | null;
+ /** Broad family, used for grouping and colour. */
+ family: 'فناوری' | 'انرژی و محیط زیست' | 'خدمات' | 'اداری' | 'مالی' | 'خردهفروشی' | 'طراحی';
+}
+
+/** Fastest-growing roles by percentage, WEF Future of Jobs 2025. */
+export const GROWING_ROLES: GlobalRole[] = [
+ { rank: 1, title: 'متخصص کلانداده', changePercent: 113, family: 'فناوری' },
+ { rank: 2, title: 'مهندس فینتک', changePercent: 93, family: 'مالی' },
+ { rank: 3, title: 'متخصص هوش مصنوعی و یادگیری ماشین', changePercent: 82, family: 'فناوری' },
+ { rank: 4, title: 'توسعهدهنده نرمافزار و اپلیکیشن', changePercent: 57, family: 'فناوری' },
+ { rank: 5, title: 'متخصص مدیریت امنیت', changePercent: 53, family: 'فناوری' },
+ { rank: 6, title: 'متخصص انبار داده', changePercent: 49, family: 'فناوری' },
+ { rank: 7, title: 'متخصص خودروی خودران و برقی', changePercent: 48, family: 'انرژی و محیط زیست' },
+ { rank: 8, title: 'طراح رابط و تجربه کاربری', changePercent: 48, family: 'طراحی' },
+ { rank: 9, title: 'راننده وانت و خدمات تحویل', changePercent: 46, family: 'خدمات' },
+ { rank: 10, title: 'تحلیلگر امنیت اطلاعات', changePercent: 40, family: 'فناوری' },
+ { rank: 11, title: 'مهندس انرژی تجدیدپذیر', changePercent: 40, family: 'انرژی و محیط زیست' },
+ { rank: 12, title: 'مهندس محیط زیست', changePercent: 40, family: 'انرژی و محیط زیست' }
+];
+
+/**
+ * Fastest-declining roles, WEF Future of Jobs 2025.
+ * The report publishes the full ranking; percentage figures are only quoted
+ * consistently for the top three, so the rest carry `null`.
+ */
+export const DECLINING_ROLES: GlobalRole[] = [
+ { rank: 1, title: 'متصدی خدمات پستی', changePercent: -40, family: 'اداری' },
+ { rank: 2, title: 'تحویلدار بانک', changePercent: -35, family: 'مالی' },
+ { rank: 3, title: 'اپراتور ورود داده', changePercent: -34, family: 'اداری' },
+ { rank: 4, title: 'صندوقدار و فروشنده بلیت', changePercent: null, family: 'خردهفروشی' },
+ { rank: 5, title: 'منشی اجرایی و دستیار اداری', changePercent: null, family: 'اداری' },
+ { rank: 6, title: 'کارگر صنعت چاپ', changePercent: null, family: 'خدمات' },
+ { rank: 7, title: 'کارمند حسابداری و حقوق و دستمزد', changePercent: null, family: 'مالی' },
+ { rank: 8, title: 'متصدی انبار و ثبت کالا', changePercent: null, family: 'اداری' },
+ { rank: 9, title: 'مأمور و مهماندار حملونقل', changePercent: null, family: 'خدمات' },
+ { rank: 10, title: 'فروشنده دورهگرد', changePercent: null, family: 'خردهفروشی' }
+];
+
+/**
+ * Roles that entered the declining list for the first time in the 2025
+ * edition — the report attributes this to generative AI reaching knowledge
+ * work, which is directly relevant to curriculum planning.
+ */
+export const NEWLY_DECLINING: string[] = ['طراح گرافیک', 'منشی حقوقی'];
+
+/* ------------------------------------------------------------------ */
+/* Skills */
+/* ------------------------------------------------------------------ */
+
+export interface SkillTrend {
+ title: string;
+ kind: 'فناورانه' | 'شناختی' | 'انسانی' | 'پایداری' | 'جسمی' | 'پایه';
+}
+
+/** Top core skills employers expect to matter in 2030, in the report's order. */
+export const RISING_SKILLS: SkillTrend[] = [
+ { title: 'تفکر تحلیلی', kind: 'شناختی' },
+ { title: 'تابآوری، انعطاف و چابکی', kind: 'انسانی' },
+ { title: 'رهبری و نفوذ اجتماعی', kind: 'انسانی' },
+ { title: 'تفکر خلاق', kind: 'شناختی' },
+ { title: 'انگیزه و خودآگاهی', kind: 'انسانی' },
+ { title: 'سواد فناورانه', kind: 'فناورانه' },
+ { title: 'هوش مصنوعی و کلانداده', kind: 'فناورانه' },
+ { title: 'شبکه و امنیت سایبری', kind: 'فناورانه' },
+ { title: 'مدیریت زیستمحیطی', kind: 'پایداری' }
+];
+
+export const DECLINING_SKILLS: { title: string; note: string }[] = [
+ {
+ title: 'مهارت دستی، دقت و استقامت جسمی',
+ note: '۲۴٪ کارفرمایان کاهش اهمیت را پیشبینی میکنند'
+ },
+ {
+ title: 'خواندن، نوشتن و ریاضیات پایه',
+ note: 'کاهش خالص اندک در اهمیت نسبی'
+ }
+];
+
+/* ------------------------------------------------------------------ */
+/* University majors — outcomes for recent graduates */
+/* ------------------------------------------------------------------ */
+
+export interface MajorOutcome {
+ title: string;
+ /** Unemployment rate for graduates aged 22–27, percent. */
+ unemployment: number;
+}
+
+/** Majors with the lowest recent-graduate unemployment. */
+export const BEST_MAJORS: MajorOutcome[] = [
+ { title: 'آموزش استثنایی', unemployment: 0.7 },
+ { title: 'سایر رشتههای تعلیم و تربیت', unemployment: 1.1 },
+ { title: 'آموزش ابتدایی', unemployment: 1.2 },
+ { title: 'کشاورزی', unemployment: 1.4 },
+ { title: 'زبانهای خارجی', unemployment: 1.6 },
+ { title: 'جغرافیا', unemployment: 1.6 },
+ { title: 'فناوریهای مهندسی', unemployment: 1.7 },
+ { title: 'خدمات اجتماعی', unemployment: 1.9 },
+ { title: 'پرستاری', unemployment: 2.1 },
+ { title: 'آموزش متوسطه', unemployment: 2.1 }
+];
+
+/** Majors with the highest recent-graduate unemployment. */
+export const WORST_MAJORS: MajorOutcome[] = [
+ { title: 'انسانشناسی', unemployment: 7.9 },
+ { title: 'مهندسی کامپیوتر', unemployment: 7.8 },
+ { title: 'هنرهای تجسمی', unemployment: 7.7 },
+ { title: 'هنرهای نمایشی', unemployment: 7.0 },
+ { title: 'علوم کامپیوتر', unemployment: 7.0 },
+ { title: 'معماری', unemployment: 6.8 }
+];
+
+/** Context figures for the majors panel. */
+export const GRADUATE_CONTEXT = {
+ unemployment: '۵٫۶٪',
+ underemployment: '۴۲٪',
+ medianEarlyWage: '۵۸٬۰۰۰ دلار',
+ majorsTracked: '۷۳'
+};
+
+/**
+ * The finding worth putting in front of a curriculum committee: the highest
+ * paying major is also among the highest for unemployment, and the safest
+ * majors are among the lowest paid. Both figures are from the same release.
+ */
+export const PAY_PARADOX = {
+ topPayingMajor: 'مهندسی کامپیوتر',
+ topPayingWage: '۹۰٬۰۰۰ دلار',
+ topPayingUnemployment: 7.8,
+ safestMajor: 'آموزش استثنایی',
+ safestUnemployment: 0.7
+};
diff --git a/src/hooks/use-count-up.ts b/src/hooks/use-count-up.ts
new file mode 100644
index 0000000..6d79008
--- /dev/null
+++ b/src/hooks/use-count-up.ts
@@ -0,0 +1,48 @@
+import * as React from 'react';
+
+const prefersReducedMotion = () =>
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+/**
+ * Counts from 0 up to `target` once, whenever `target` changes.
+ *
+ * Correctness beats the flourish: some environments throttle
+ * requestAnimationFrame to zero (background tabs, embedded webviews), which
+ * would otherwise strand the figure at 0. A timer always settles the final
+ * value even if not a single frame is ever served.
+ */
+export function useCountUp(target: number, durationMs = 900, decimals = 0) {
+ const [value, setValue] = React.useState(() =>
+ prefersReducedMotion() ? target : 0
+ );
+
+ React.useEffect(() => {
+ if (prefersReducedMotion() || durationMs <= 0) {
+ setValue(target);
+ return;
+ }
+
+ let frame = 0;
+ const start = performance.now();
+
+ const tick = (now: number) => {
+ const t = Math.min(1, (now - start) / durationMs);
+ // easeOutCubic — quick off the mark, settles gently on the final figure.
+ const eased = 1 - Math.pow(1 - t, 3);
+ setValue(Number((target * eased).toFixed(decimals)));
+ if (t < 1) frame = requestAnimationFrame(tick);
+ };
+
+ frame = requestAnimationFrame(tick);
+ const settle = window.setTimeout(() => setValue(target), durationMs + 120);
+
+ return () => {
+ cancelAnimationFrame(frame);
+ window.clearTimeout(settle);
+ };
+ }, [target, durationMs, decimals]);
+
+ return value;
+}
diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts
new file mode 100644
index 0000000..8f26065
--- /dev/null
+++ b/src/hooks/use-mobile.ts
@@ -0,0 +1,18 @@
+import * as React from 'react'
+
+const MOBILE_BREAKPOINT = 768
+
+/** True while the viewport is narrower than the `md` breakpoint. */
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ mql.addEventListener('change', onChange)
+ onChange()
+ return () => mql.removeEventListener('change', onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts
new file mode 100644
index 0000000..e0dbbcc
--- /dev/null
+++ b/src/hooks/use-theme.ts
@@ -0,0 +1,55 @@
+import * as React from 'react';
+
+export type Theme = 'light' | 'dark';
+
+const STORAGE_KEY = 'uniradar-theme';
+
+const readStoredTheme = (): Theme => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved === 'light' || saved === 'dark') return saved;
+ } catch {
+ // Private mode / blocked storage — fall through to the system preference.
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light';
+};
+
+const applyTheme = (theme: Theme) => {
+ const root = document.documentElement;
+
+ // Every themed colour is a CSS variable, so flipping the theme would
+ // otherwise animate any element that transitions colour — leaving cards
+ // mid-interpolation. Suppress transitions for the duration of the swap.
+ root.setAttribute('data-theme-switching', '');
+ root.classList.toggle('dark', theme === 'dark');
+ root.style.colorScheme = theme;
+
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
+ root.removeAttribute('data-theme-switching');
+ });
+ });
+};
+
+/** Light/dark preference, persisted per browser. */
+export function useTheme() {
+ const [theme, setTheme] = React.useState(readStoredTheme);
+
+ React.useEffect(() => {
+ applyTheme(theme);
+ try {
+ localStorage.setItem(STORAGE_KEY, theme);
+ } catch {
+ // Not being able to remember the choice is not worth breaking over.
+ }
+ }, [theme]);
+
+ const toggleTheme = React.useCallback(
+ () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
+ []
+ );
+
+ return { theme, setTheme, toggleTheme };
+}
diff --git a/src/index.css b/src/index.css
index 0529b9f..1605417 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,892 +1,504 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
/* =========================================================================
- University Futures Radar — Design System (Bento & Neo-Clean Light Theme)
- Inspired by modern Dribbble & Bento institutional foresight dashboards
- University of Isfahan (دانشگاه اصفهان)
+ رادار آینده دانشگاه اصفهان — Design System
+ افق ۱۴۰۵ → ۱۴۱۵
+
+ One accent (teal) carries brand and action. The four categorical hues are
+ reserved for meaning — the four radar categories — and never used as
+ decoration. Type is Vazirmatn on a single scale; nothing above weight 700,
+ because heavy weights close Persian counters and flatten hierarchy.
========================================================================= */
:root {
- /* Canvas & Backgrounds */
- --bg-canvas: #f4f6fb;
- --bg-card: #ffffff;
- --bg-card-subtle: #f8fafc;
- --bg-sidebar: #ffffff;
- --bg-header: rgba(255, 255, 255, 0.88);
+ --radius: 0.7rem;
- /* Borders & Dividers */
- --border-card: #e5eaf2;
- --border-subtle: #edf2f7;
- --border-focus: #cbd5e1;
+ /* -- Surfaces ---------------------------------------------------------- */
+ --background: oklch(0.977 0.002 264);
+ --foreground: oklch(0.21 0.02 265);
- /* Typography Colors */
- --text-main: #0f172a;
- --text-heading: #1e293b;
- --text-body: #475569;
- --text-muted: #64748b;
- --text-dim: #94a3b8;
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.21 0.02 265);
- /* Pastel Card Accent Tints (matching inspiration images) */
- --pastel-mint-bg: #e6f6f1;
- --pastel-mint-border: #c4ecdf;
- --pastel-mint-text: #059669;
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.21 0.02 265);
- --pastel-pink-bg: #fdecf2;
- --pastel-pink-border: #f9cbd9;
- --pastel-pink-text: #e11d48;
+ --muted: oklch(0.968 0.004 265);
+ --muted-foreground: oklch(0.53 0.024 263);
- --pastel-yellow-bg: #fef7e6;
- --pastel-yellow-border: #fde8b8;
- --pastel-yellow-text: #d97706;
+ --accent: oklch(0.968 0.004 265);
+ --accent-foreground: oklch(0.28 0.024 265);
- --pastel-purple-bg: #f3effe;
- --pastel-purple-border: #ded2fd;
- --pastel-purple-text: #7c3aed;
+ /* -- Brand ------------------------------------------------------------- */
+ --primary: oklch(0.545 0.094 183);
+ --primary-foreground: oklch(0.985 0.004 180);
- --pastel-blue-bg: #eaf3fe;
- --pastel-blue-border: #c8e0fd;
- --pastel-blue-text: #0284c7;
+ --secondary: oklch(0.968 0.004 265);
+ --secondary-foreground: oklch(0.28 0.024 265);
- /* Brand Accents */
- --accent-teal: #00b894;
- --accent-teal-dark: #008f73;
- --accent-dark-pill: #0f172a;
+ --destructive: oklch(0.55 0.181 17);
+ --destructive-foreground: oklch(0.985 0.004 180);
- /* Radii */
- --radius-xs: 6px;
- --radius-sm: 10px;
- --radius-md: 14px;
- --radius-lg: 20px;
- --radius-xl: 26px;
- --radius-2xl: 32px;
+ /* -- Lines & focus ----------------------------------------------------- */
+ --border: oklch(0.923 0.006 265);
+ --input: oklch(0.905 0.008 265);
+ --ring: oklch(0.545 0.094 183);
- /* Shadows */
- --shadow-subtle: 0 2px 8px -2px rgba(15, 23, 42, 0.04);
- --shadow-bento: 0 10px 30px -4px rgba(15, 23, 42, 0.05), 0 2px 6px -1px rgba(15, 23, 42, 0.02);
- --shadow-elevated: 0 18px 40px -6px rgba(15, 23, 42, 0.08), 0 4px 12px -2px rgba(15, 23, 42, 0.03);
- --shadow-dropdown: 0 20px 45px -8px rgba(15, 23, 42, 0.14);
+ /* -- Sidebar ----------------------------------------------------------- */
+ --sidebar: oklch(1 0 0);
+ --sidebar-foreground: oklch(0.32 0.022 265);
+ --sidebar-primary: oklch(0.545 0.094 183);
+ --sidebar-primary-foreground: oklch(0.985 0.004 180);
+ --sidebar-accent: oklch(0.962 0.018 180);
+ --sidebar-accent-foreground: oklch(0.42 0.075 183);
+ --sidebar-border: oklch(0.928 0.006 265);
+ --sidebar-ring: oklch(0.545 0.094 183);
+
+ /* -- Categories: the four radar streams -------------------------------- */
+ --cat-trend: oklch(0.53 0.145 250);
+ --cat-trend-soft: oklch(0.958 0.022 250);
+ --cat-tech: oklch(0.52 0.098 180);
+ --cat-tech-soft: oklch(0.958 0.026 180);
+ --cat-policy: oklch(0.54 0.125 72);
+ --cat-policy-soft: oklch(0.962 0.03 78);
+ --cat-signal: oklch(0.55 0.16 12);
+ --cat-signal-soft: oklch(0.958 0.022 12);
+
+ /* -- Directional semantics --------------------------------------------- */
+ --positive: oklch(0.52 0.115 155);
+ --positive-soft: oklch(0.958 0.03 155);
+ --negative: oklch(0.55 0.16 12);
+ --negative-soft: oklch(0.958 0.022 12);
+
+ /* -- Charts ------------------------------------------------------------ */
+ --chart-1: var(--cat-trend);
+ --chart-2: var(--cat-tech);
+ --chart-3: var(--cat-policy);
+ --chart-4: var(--cat-signal);
+ --chart-5: oklch(0.58 0.13 300);
}
-*, ::before, ::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif !important;
+.dark {
+ --background: oklch(0.185 0.016 265);
+ --foreground: oklch(0.955 0.006 265);
+
+ --card: oklch(0.225 0.018 265);
+ --card-foreground: oklch(0.955 0.006 265);
+
+ --popover: oklch(0.235 0.018 265);
+ --popover-foreground: oklch(0.955 0.006 265);
+
+ --muted: oklch(0.272 0.02 265);
+ --muted-foreground: oklch(0.7 0.018 265);
+
+ --accent: oklch(0.288 0.022 265);
+ --accent-foreground: oklch(0.955 0.006 265);
+
+ --primary: oklch(0.68 0.105 180);
+ --primary-foreground: oklch(0.19 0.03 183);
+
+ --secondary: oklch(0.272 0.02 265);
+ --secondary-foreground: oklch(0.955 0.006 265);
+
+ --destructive: oklch(0.66 0.17 18);
+ --destructive-foreground: oklch(0.16 0.03 18);
+
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 16%);
+ --ring: oklch(0.68 0.105 180);
+
+ --sidebar: oklch(0.21 0.018 265);
+ --sidebar-foreground: oklch(0.92 0.008 265);
+ --sidebar-primary: oklch(0.68 0.105 180);
+ --sidebar-primary-foreground: oklch(0.19 0.03 183);
+ --sidebar-accent: oklch(0.3 0.035 183);
+ --sidebar-accent-foreground: oklch(0.88 0.06 180);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.68 0.105 180);
+
+ --cat-trend: oklch(0.75 0.13 250);
+ --cat-trend-soft: oklch(0.32 0.055 250);
+ --cat-tech: oklch(0.78 0.1 180);
+ --cat-tech-soft: oklch(0.31 0.045 180);
+ --cat-policy: oklch(0.8 0.125 78);
+ --cat-policy-soft: oklch(0.33 0.05 72);
+ --cat-signal: oklch(0.75 0.15 14);
+ --cat-signal-soft: oklch(0.32 0.06 14);
+
+ --positive: oklch(0.78 0.13 155);
+ --positive-soft: oklch(0.31 0.05 155);
+ --negative: oklch(0.75 0.15 14);
+ --negative-soft: oklch(0.32 0.06 14);
}
-html, body {
- width: 100%;
- min-height: 100%;
- background-color: var(--bg-canvas);
- color: #0f172a;
- font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif !important;
- direction: rtl;
- text-align: right;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- line-height: 1.6;
+@theme inline {
+ --font-sans: 'Vazirmatn', ui-sans-serif, -apple-system, 'Segoe UI', Tahoma,
+ sans-serif;
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ --color-cat-trend: var(--cat-trend);
+ --color-cat-trend-soft: var(--cat-trend-soft);
+ --color-cat-tech: var(--cat-tech);
+ --color-cat-tech-soft: var(--cat-tech-soft);
+ --color-cat-policy: var(--cat-policy);
+ --color-cat-policy-soft: var(--cat-policy-soft);
+ --color-cat-signal: var(--cat-signal);
+ --color-cat-signal-soft: var(--cat-signal-soft);
+
+ --color-positive: var(--positive);
+ --color-positive-soft: var(--positive-soft);
+ --color-negative: var(--negative);
+ --color-negative-soft: var(--negative-soft);
+
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 10px);
}
-/* Strict Heading & Typography Hierarchy */
-h1, h2, h3, h4, .heading-1, .heading-2, .heading-3, .heading-4, .page-title {
- color: #0f172a !important;
- font-weight: 800;
- letter-spacing: -0.015em;
-}
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
-h1, .heading-1, .page-title {
- font-size: 1.75rem !important;
- font-weight: 900 !important;
- line-height: 1.3 !important;
-}
+ html {
+ -webkit-text-size-adjust: 100%;
+ }
-h2, .heading-2 {
- font-size: 1.25rem !important;
- font-weight: 800 !important;
- line-height: 1.35 !important;
-}
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-sans);
+ font-size: 13.5px;
+ /* Persian needs more leading than Latin at the same size. */
+ line-height: 1.75;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ /* Vazirmatn's alternate Persian digits — rounder, better on screen. */
+ font-feature-settings: 'ss01' 1;
+ }
-h3, .heading-3 {
- font-size: 1.05rem !important;
- font-weight: 800 !important;
- line-height: 1.4 !important;
-}
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ @apply text-foreground;
+ font-weight: 700;
+ line-height: 1.5;
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+ }
-h4, .heading-4 {
- font-size: 0.9rem !important;
- font-weight: 700 !important;
- line-height: 1.45 !important;
-}
+ p {
+ line-height: 1.8;
+ text-wrap: pretty;
+ }
-.text-display {
- font-size: 2.25rem !important;
- font-weight: 900 !important;
- color: #0f172a !important;
-}
+ /* Figures read as data: tabular, tight, never heavier than the headings. */
+ .tnum,
+ .kpi-number {
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: 'ss01' 1, 'tnum' 1;
+ letter-spacing: -0.02em;
+ }
-.body-main {
- font-size: 0.8125rem !important;
- font-weight: 500 !important;
- color: #334155 !important;
- line-height: 1.6 !important;
-}
+ ::selection {
+ background: color-mix(in oklab, var(--primary) 22%, transparent);
+ }
-.caption {
- font-size: 0.75rem !important;
- font-weight: 600 !important;
- color: #64748b !important;
-}
+ /* The default Windows scrollbar is visually loud next to this much white. */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--foreground) 18%, transparent)
+ transparent;
+ }
+ ::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: color-mix(in oklab, var(--foreground) 18%, transparent);
+ border-radius: 9999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in oklab, var(--foreground) 32%, transparent);
+ background-clip: content-box;
+ }
-/* Numerals & Mono overrides to Vazirmatn */
-.font-mono,
-code,
-kbd,
-pre,
-.kpi-number {
- font-family: 'Vazirmatn', sans-serif !important;
- font-feature-settings: 'ss01' 1;
-}
+ /* Set while the theme is being swapped — see use-theme.ts. */
+ [data-theme-switching],
+ [data-theme-switching] *,
+ [data-theme-switching] *::before,
+ [data-theme-switching] *::after {
+ transition: none !important;
+ }
-/* =========================================================================
- APP CONTAINER & SHELL LAYOUT (Bento Card Aesthetic)
- ========================================================================= */
-
-.app-container {
- display: grid;
- grid-template-columns: 260px 1fr;
- min-height: 100vh;
- width: 100%;
- background: radial-gradient(circle at 90% 10%, rgba(0, 184, 148, 0.06) 0%, transparent 40%),
- radial-gradient(circle at 10% 90%, rgba(2, 132, 199, 0.05) 0%, transparent 40%),
- var(--bg-canvas);
-}
-
-@media (max-width: 1024px) {
- .app-container {
- grid-template-columns: 1fr;
+ @media (prefers-reduced-motion: reduce) {
+ *,
+ ::before,
+ ::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
}
}
-/* Sidebar */
-aside {
- width: 260px;
- height: 100vh;
- position: sticky;
- top: 0;
- background: var(--bg-sidebar);
- border-left: 1px solid var(--border-card);
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- padding: 1.5rem 1.15rem;
- overflow-y: auto;
- z-index: 30;
-}
+@layer components {
+ /* Page chrome ---------------------------------------------------------- */
+ .page-shell {
+ @apply mx-auto w-full max-w-[1600px] px-4 pb-16 pt-5 sm:px-6 lg:px-8;
+ }
-@media (max-width: 1024px) {
- aside {
+ .page-title {
+ @apply text-xl font-bold tracking-tight text-foreground sm:text-[1.375rem];
+ }
+
+ .page-subtitle {
+ @apply mt-1 text-[13px] leading-relaxed text-muted-foreground;
+ }
+
+ /* A small, quiet label above a block of content. */
+ .eyebrow {
+ @apply text-[11px] font-semibold tracking-wide text-muted-foreground;
+ }
+
+ /* Category dot used in legends, lists and the radar key. */
+ .cat-dot {
+ @apply inline-block size-1.5 shrink-0 rounded-full;
+ }
+
+ /* Horizontal scroller for tab strips and tables on small screens. */
+ .scroll-x {
+ @apply -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0;
+ scrollbar-width: none;
+ }
+ .scroll-x::-webkit-scrollbar {
display: none;
}
}
-/* Main Content Area */
-.main-content {
- display: flex;
- flex-direction: column;
- min-width: 0;
- overflow-x: hidden;
-}
-
-/* Header */
-header {
- height: 72px;
- position: sticky;
- top: 0;
- z-index: 40;
- background: var(--bg-header);
- backdrop-filter: blur(16px);
- border-bottom: 1px solid var(--border-card);
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 2.25rem;
-}
-
-@media (max-width: 768px) {
- header {
- padding: 0 1.25rem;
- height: 64px;
- }
-}
-
-/* Page Wrapper */
-.page-wrapper {
- flex: 1;
- padding: 2rem 2.25rem 3.5rem 2.25rem;
- max-width: 1600px;
- width: 100%;
- margin: 0 auto;
-}
-
-@media (max-width: 768px) {
- .page-wrapper {
- padding: 1.25rem 1rem 2.5rem 1rem;
- }
-}
-
-/* =========================================================================
- BENTO BOX & CARD DESIGN (Pure White & Pastel Tints)
- ========================================================================= */
-
-.intel-card {
- background-color: var(--bg-card);
- border: 1px solid var(--border-card);
- border-radius: var(--radius-xl);
- padding: 1.5rem;
- box-shadow: var(--shadow-bento);
- position: relative;
- transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.intel-card:hover {
- box-shadow: var(--shadow-elevated);
- border-color: #d1d9e6;
-}
-
-.intel-card.interactive {
- cursor: pointer;
-}
-.intel-card.interactive:hover {
- transform: translateY(-3px);
- border-color: #cbd5e1;
-}
-
-/* Bento Pastel Card Variants (Images 1 & 5) */
-.bento-card-mint {
- background-color: var(--pastel-mint-bg) !important;
- border-color: var(--pastel-mint-border) !important;
-}
-
-.bento-card-pink {
- background-color: var(--pastel-pink-bg) !important;
- border-color: var(--pastel-pink-border) !important;
-}
-
-.bento-card-yellow {
- background-color: var(--pastel-yellow-bg) !important;
- border-color: var(--pastel-yellow-border) !important;
-}
-
-.bento-card-purple {
- background-color: var(--pastel-purple-bg) !important;
- border-color: var(--pastel-purple-border) !important;
-}
-
-.bento-card-blue {
- background-color: var(--pastel-blue-bg) !important;
- border-color: var(--pastel-blue-border) !important;
-}
-
-/* Circular Arrow Action Button (↗) - Core signature element from inspirations */
-.circle-action-btn {
- width: 38px;
- height: 38px;
- border-radius: 9999px;
- background: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 3px 8px rgba(0, 0, 0, 0.06);
- border: 1px solid rgba(0, 0, 0, 0.04);
- color: var(--text-main);
- transition: all 0.2s ease;
- font-size: 1.15rem;
- font-weight: 700;
- shrink: 0;
-}
-
-.circle-action-btn:hover {
- transform: scale(1.08) translate(-1px, -1px);
- box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
- color: var(--accent-teal);
-}
-
-/* Dark Pill Toggle (Image 4 & 5 style) */
-.pill-tab-group {
- display: inline-flex;
- align-items: center;
- background: #eef2f7;
- padding: 4px;
- border-radius: 9999px;
- gap: 4px;
-}
-
-.pill-tab-btn {
- padding: 6px 16px;
- border-radius: 9999px;
- font-size: 0.8rem;
- font-weight: 600;
- color: var(--text-muted);
- transition: all 0.2s ease;
-}
-
-.pill-tab-btn.active {
- background: #0f172a;
- color: #ffffff;
- box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2);
-}
-
-.pill-tab-btn:hover:not(.active) {
- color: var(--text-main);
-}
-
-/* Typography */
-.page-title {
- font-size: 1.75rem;
- font-weight: 800;
- color: var(--text-heading);
- letter-spacing: -0.02em;
- display: flex;
- align-items: center;
- gap: 0.75rem;
-}
-
-.page-subtitle {
- font-size: 0.92rem;
- color: var(--text-muted);
- margin-top: 0.25rem;
-}
-
-/* Modern Badges */
-.badge {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- padding: 0.3rem 0.75rem;
- border-radius: 9999px;
- font-size: 0.75rem;
- font-weight: 700;
- line-height: 1;
- white-space: nowrap;
-}
-
-.badge-teal {
- background: #e6f7f3;
- color: #009975;
- border: 1px solid #b3ebd9;
-}
-
-.badge-blue {
- background: #e9f3fe;
- color: #0284c7;
- border: 1px solid #c2defd;
-}
-
-.badge-amber {
- background: #fef7e6;
- color: #d97706;
- border: 1px solid #fce8b8;
-}
-
-.badge-rose {
- background: #feeef2;
- color: #e11d48;
- border: 1px solid #fdc8d5;
-}
-
-.badge-purple {
- background: #f3effe;
- color: #7c3aed;
- border: 1px solid #ded2fd;
-}
-
-.badge-gray {
- background: #f1f5f9;
- color: #64748b;
- border: 1px solid #e2e8f0;
-}
-
-.kpi-number {
- font-family: 'Outfit', 'Vazirmatn', sans-serif;
- font-weight: 800;
- letter-spacing: -0.03em;
- color: var(--text-heading);
-}
-
-/* Buttons */
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- padding: 0.6rem 1.25rem;
- border-radius: var(--radius-md);
- font-size: 0.88rem;
- font-weight: 700;
- transition: all 0.2s ease;
- white-space: nowrap;
-}
-
-.btn-primary {
- background: #0f172a;
- color: #ffffff;
- box-shadow: 0 4px 14px rgba(15, 23, 42, 0.18);
-}
-.btn-primary:hover {
- background: #1e293b;
- transform: translateY(-1px);
- box-shadow: 0 6px 18px rgba(15, 23, 42, 0.25);
-}
-
-.btn-secondary {
- background: #ffffff;
- border: 1px solid var(--border-card);
- color: var(--text-heading);
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
-}
-.btn-secondary:hover {
- background: #f8fafc;
- border-color: #cbd5e1;
-}
-
-.btn-teal {
- background: linear-gradient(135deg, #00b894 0%, #0284c7 100%);
- color: #ffffff;
- box-shadow: 0 4px 14px rgba(0, 184, 148, 0.25);
-}
-.btn-teal:hover {
- transform: translateY(-1px);
- box-shadow: 0 6px 18px rgba(0, 184, 148, 0.35);
-}
-
-.btn-ghost {
- color: var(--text-muted);
-}
-.btn-ghost:hover {
- color: var(--text-heading);
- background: #f1f5f9;
-}
-
-/* Modals */
-.modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(15, 23, 42, 0.45);
- backdrop-filter: blur(8px);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- padding: 1.5rem;
-}
-
-.modal-content {
- background: #ffffff;
- border: 1px solid var(--border-card);
- border-radius: var(--radius-2xl);
- max-width: 720px;
- width: 100%;
- max-height: 90vh;
- overflow-y: auto;
- box-shadow: var(--shadow-dropdown);
-}
-
-/* Radar Animation */
+/* Radar sweep + emphasis pulses (used by the SVG visualisations). */
@keyframes radar-scan {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
}
@keyframes pulse-glow {
- 0%, 100% { opacity: 0.4; transform: scale(1); }
- 50% { opacity: 0.85; transform: scale(1.08); }
+ 0%,
+ 100% {
+ opacity: 0.35;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.8;
+ transform: scale(1.06);
+ }
}
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(6px); }
- to { opacity: 1; transform: translateY(0); }
+/* Blips settle into place on mount, staggered by index. */
+@keyframes radar-blip-in {
+ from {
+ opacity: 0;
+ transform: scale(0.4);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
}
-.anim-pulse { animation: pulse-glow 3s infinite ease-in-out; }
-.radar-sweep-line { transform-origin: center center; animation: radar-scan 14s infinite linear; }
-.animate-fadeIn { animation: fadeIn 0.25s ease-out forwards; }
-
-/* Line Clamp */
-.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
-.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
-.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
-
-/* =========================================================================
- RESPONSIVE UTILITY SUITE
- ========================================================================= */
-
-.flex { display: flex; }
-.inline-flex { display: inline-flex; }
-.flex-col { flex-direction: column; }
-.flex-row { flex-direction: row; }
-.flex-wrap { flex-wrap: wrap; }
-.items-center { align-items: center; }
-.items-start { align-items: flex-start; }
-.items-end { align-items: flex-end; }
-.justify-between { justify-content: space-between; }
-.justify-center { justify-content: center; }
-.justify-end { justify-content: flex-end; }
-.flex-1 { flex: 1 1 0%; }
-.shrink-0 { flex-shrink: 0; }
-.self-start { align-self: flex-start; }
-.self-end { align-self: flex-end; }
-.self-auto { align-self: auto; }
-
-.grid { display: grid; }
-.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
-.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
-.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
-.grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
-.grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
-
-@media (min-width: 640px) {
- .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .sm\:inline { display: inline; }
- .sm\:inline-block { display: inline-block; }
- .sm\:w-64 { width: 16rem; }
+.anim-pulse {
+ animation: pulse-glow 3.6s infinite ease-in-out;
}
-@media (min-width: 768px) {
- .md\:flex { display: flex; }
- .md\:hidden { display: none; }
- .md\:block { display: block; }
- .md\:inline { display: inline; }
- .md\:flex-row { flex-direction: row; }
- .md\:items-center { align-items: center; }
- .md\:items-start { align-items: flex-start; }
- .md\:items-end { align-items: flex-end; }
- .md\:self-auto { align-self: auto; }
- .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .md\:text-right { text-align: right; }
+.radar-sweep-line {
+ transform-origin: center center;
+ animation: radar-scan 22s infinite linear;
}
-@media (min-width: 1024px) {
- .lg\:flex { display: flex; }
- .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
- .lg\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
- .lg\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
- .lg\:col-span-7 { grid-column: span 7 / span 7; }
- .lg\:col-span-5 { grid-column: span 5 / span 5; }
- .lg\:text-2xl { font-size: 1.5rem; line-height: 2rem; }
- .lg\:text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
- .lg\:p-8 { padding: 2rem; }
- .lg\:p-10 { padding: 2.5rem; }
- .lg\:p-12 { padding: 3rem; }
- .lg\:w-72 { width: 18rem; }
+.radar-blip {
+ animation: radar-blip-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
-@media (min-width: 1280px) {
- .xl\:grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }
- .xl\:col-span-7 { grid-column: span 7 / span 7; }
- .xl\:col-span-8 { grid-column: span 8 / span 8; }
- .xl\:col-span-5 { grid-column: span 5 / span 5; }
- .xl\:col-span-4 { grid-column: span 4 / span 4; }
- .xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
- .xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+/* ---- دانشگاه اصفهان ۱۴۱۵ ------------------------------------------------ */
+
+/* Petals bloom outward from the core, one after another. */
+@keyframes bloom-petal-in {
+ from {
+ opacity: 0;
+ transform: scale(0.55) rotate(-6deg);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) rotate(0deg);
+ }
}
-/* Gaps */
-.gap-1 { gap: 0.25rem; }
-.gap-1\.5 { gap: 0.375rem; }
-.gap-2 { gap: 0.5rem; }
-.gap-2\.5 { gap: 0.625rem; }
-.gap-3 { gap: 0.75rem; }
-.gap-3\.5 { gap: 0.875rem; }
-.gap-4 { gap: 1rem; }
-.gap-5 { gap: 1.25rem; }
-.gap-6 { gap: 1.5rem; }
-.gap-8 { gap: 2rem; }
+.bloom-petal {
+ animation: bloom-petal-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
-/* Dimensions */
-.w-full { width: 100%; }
-.w-auto { width: auto; }
-.w-5 { width: 1.25rem; }
-.h-5 { height: 1.25rem; }
-.w-6 { width: 1.5rem; }
-.h-6 { height: 1.5rem; }
-.w-8 { width: 2rem; }
-.h-8 { height: 2rem; }
-.w-10 { width: 2.5rem; }
-.h-10 { height: 2.5rem; }
-.w-12 { width: 3rem; }
-.h-12 { height: 3rem; }
-.w-14 { width: 3.5rem; }
-.h-14 { height: 3.5rem; }
-.w-16 { width: 4rem; }
-.w-24 { width: 6rem; }
-.w-28 { width: 7rem; }
-.w-64 { width: 16rem; }
-.w-80 { width: 20rem; }
-.h-1\.5 { height: 0.375rem; }
-.h-2 { height: 0.5rem; }
-.h-2\.5 { height: 0.625rem; }
-.h-full { height: 100%; }
-.h-auto { height: auto; }
-.min-h-screen { min-height: 100vh; }
+.bloom-petal:focus-visible {
+ outline: none;
+}
+.bloom-petal:focus-visible path:nth-of-type(2) {
+ stroke: var(--ring);
+ stroke-width: 3;
+}
-.max-w-xs { max-width: 20rem; }
-.max-w-sm { max-width: 24rem; }
-.max-w-md { max-width: 28rem; }
-.max-w-2xl { max-width: 42rem; }
-.max-w-3xl { max-width: 48rem; }
-.max-w-4xl { max-width: 56rem; }
-.max-w-5xl { max-width: 64rem; }
+/* Bars grow from the start edge; markers pop in once the bar arrives. */
+@keyframes gap-track-grow {
+ from {
+ transform: scaleX(0);
+ }
+ to {
+ transform: scaleX(1);
+ }
+}
-/* Spacing */
-.p-0 { padding: 0; }
-.p-1 { padding: 0.25rem; }
-.p-1\.5 { padding: 0.375rem; }
-.p-2 { padding: 0.5rem; }
-.p-2\.5 { padding: 0.625rem; }
-.p-3 { padding: 0.75rem; }
-.p-3\.5 { padding: 0.875rem; }
-.p-4 { padding: 1rem; }
-.p-5 { padding: 1.25rem; }
-.p-6 { padding: 1.5rem; }
-.p-8 { padding: 2rem; }
-.p-10 { padding: 2.5rem; }
-.p-12 { padding: 3rem; }
+@keyframes gap-track-pop {
+ from {
+ opacity: 0;
+ transform: translateY(-50%) scale(0.2);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(-50%) scale(1);
+ }
+}
-.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
-.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
-.px-2\.5 { padding-left: 0.625rem; padding-right: 0.625rem; }
-.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
-.px-3\.5 { padding-left: 0.875rem; padding-right: 0.875rem; }
-.px-4 { padding-left: 1rem; padding-right: 1rem; }
-.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }
-.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
+.gap-track-fill {
+ transform-origin: inline-start center;
+ animation: gap-track-grow 0.65s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
-.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
-.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
-.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
-.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
-.py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; }
-.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
-.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
-.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
-.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
+.gap-track-dot {
+ animation: gap-track-pop 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
-.pt-1 { padding-top: 0.25rem; }
-.pt-1\.5 { padding-top: 0.375rem; }
-.pt-2 { padding-top: 0.5rem; }
-.pt-3 { padding-top: 0.75rem; }
-.pt-4 { padding-top: 1rem; }
-.pt-6 { padding-top: 1.5rem; }
-.pb-2 { padding-bottom: 0.5rem; }
-.pb-2\.5 { padding-bottom: 0.625rem; }
-.pb-3 { padding-bottom: 0.75rem; }
-.pb-4 { padding-bottom: 1rem; }
-.pb-6 { padding-bottom: 1.5rem; }
-.pr-1 { padding-right: 0.25rem; }
-.pr-2 { padding-right: 0.5rem; }
-.pr-3 { padding-right: 0.75rem; }
-.pr-8 { padding-right: 2rem; }
-.pl-2 { padding-left: 0.5rem; }
-.pl-3 { padding-left: 0.75rem; }
-.pl-8 { padding-left: 2rem; }
+/* ---- شبکه روابط -------------------------------------------------------- */
-.mt-0\.5 { margin-top: 0.125rem; }
-.mt-1 { margin-top: 0.25rem; }
-.mt-2 { margin-top: 0.5rem; }
-.mt-3 { margin-top: 0.75rem; }
-.mt-4 { margin-top: 1rem; }
-.mt-6 { margin-top: 1.5rem; }
-.mb-1 { margin-bottom: 0.25rem; }
-.mb-1\.5 { margin-bottom: 0.375rem; }
-.mb-2 { margin-bottom: 0.5rem; }
-.mb-2\.5 { margin-bottom: 0.625rem; }
-.mb-3 { margin-bottom: 0.75rem; }
-.mb-4 { margin-bottom: 1rem; }
-.mb-5 { margin-bottom: 1.25rem; }
-.mb-6 { margin-bottom: 1.5rem; }
-.ml-1 { margin-left: 0.25rem; }
-.ml-2 { margin-left: 0.5rem; }
-.mr-6 { margin-right: 1.5rem; }
-.mx-auto { margin-left: auto; margin-right: auto; }
-.mx-4 { margin-left: 1rem; margin-right: 1rem; }
+@keyframes net-link-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
-/* Colors */
-.text-white { color: #ffffff; }
-.text-slate-900 { color: #0f172a; }
-.text-slate-800 { color: #1e293b; }
-.text-slate-700 { color: #334155; }
-.text-slate-600 { color: #475569; }
-.text-slate-500 { color: #64748b; }
-.text-slate-400 { color: #94a3b8; }
-.text-slate-300 { color: #cbd5e1; }
-.text-slate-200 { color: #e2e8f0; }
+@keyframes net-node-in {
+ from {
+ opacity: 0;
+ transform: scale(0.3);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
-.text-teal-600 { color: #059669; }
-.text-teal-500 { color: #00b894; }
-.text-teal-400 { color: #10b981; }
-.text-sky-600 { color: #0284c7; }
-.text-sky-500 { color: #0ea5e9; }
-.text-amber-600 { color: #d97706; }
-.text-amber-500 { color: #f59e0b; }
-.text-rose-600 { color: #e11d48; }
-.text-rose-500 { color: #f43f5e; }
-.text-purple-600 { color: #7c3aed; }
-.text-purple-500 { color: #8b5cf6; }
-.text-emerald-600 { color: #059669; }
+.net-link {
+ animation: net-link-in 0.6s ease-out both;
+}
-/* Backgrounds */
-.bg-white { background-color: #ffffff; }
-.bg-slate-50 { background-color: #f8fafc; }
-.bg-slate-100 { background-color: #f1f5f9; }
-.bg-slate-200 { background-color: #e2e8f0; }
-.bg-slate-800 { background-color: #1e293b; }
-.bg-slate-900 { background-color: #0f172a; }
+.net-node {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: net-node-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
-.bg-teal-50 { background-color: #e6f7f3; }
-.bg-teal-100 { background-color: #c4f1e5; }
-.bg-teal-500 { background-color: #00b894; }
-.bg-sky-50 { background-color: #e0f2fe; }
-.bg-sky-100 { background-color: #bae6fd; }
-.bg-sky-500 { background-color: #0284c7; }
-.bg-amber-50 { background-color: #fef3c7; }
-.bg-amber-100 { background-color: #fde68a; }
-.bg-amber-500 { background-color: #f59e0b; }
-.bg-rose-50 { background-color: #fee2e2; }
-.bg-rose-100 { background-color: #fecdd3; }
-.bg-rose-500 { background-color: #f43f5e; }
-.bg-purple-50 { background-color: #f3e8ff; }
-.bg-purple-100 { background-color: #e9d5ff; }
-.bg-purple-500 { background-color: #8b5cf6; }
+.net-node:focus-visible {
+ outline: none;
+}
+.net-node:focus-visible circle {
+ stroke: var(--ring);
+ stroke-width: 2.5;
+}
-/* Borders */
-.border { border: 1px solid var(--border-card); }
-.border-2 { border: 2px solid var(--border-card); }
-.border-r-4 { border-right: 4px solid; }
-.border-t { border-top: 1px solid var(--border-card); }
-.border-b { border-bottom: 1px solid var(--border-card); }
-.border-r { border-right: 1px solid var(--border-card); }
-.border-l { border-left: 1px solid var(--border-card); }
-.border-none { border: none; }
+@keyframes net-label-in {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
-.border-slate-100 { border-color: #f1f5f9; }
-.border-slate-200 { border-color: #e2e8f0; }
-.border-slate-300 { border-color: #cbd5e1; }
-.border-teal-200 { border-color: #a7f3d0; }
-.border-teal-300 { border-color: #6ee7b7; }
-.border-teal-500 { border-color: #00b894; }
-.border-sky-200 { border-color: #bae6fd; }
-.border-amber-200 { border-color: #fde68a; }
-.border-rose-200 { border-color: #fecdd3; }
-.border-purple-200 { border-color: #e9d5ff; }
+.net-label {
+ animation: net-label-in 0.22s ease-out both;
+}
-/* Radii */
-.rounded { border-radius: 0.375rem; }
-.rounded-md { border-radius: 0.5rem; }
-.rounded-lg { border-radius: 0.75rem; }
-.rounded-xl { border-radius: 1rem; }
-.rounded-2xl { border-radius: 1.5rem; }
-.rounded-full { border-radius: 9999px; }
-
-/* Typography */
-.font-medium { font-weight: 500; }
-.font-semibold { font-weight: 600; }
-.font-bold { font-weight: 700; }
-.font-extrabold { font-weight: 800; }
-.font-black { font-weight: 900; }
-.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
-
-.text-\[10px\] { font-size: 10px; }
-.text-\[11px\] { font-size: 11px; }
-.text-\[11\.5px\] { font-size: 11.5px; }
-.text-\[12px\] { font-size: 12px; }
-.text-\[13px\] { font-size: 13px; }
-.text-xs { font-size: 0.75rem; line-height: 1rem; }
-.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
-.text-base { font-size: 1rem; line-height: 1.5rem; }
-.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
-.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
-.text-2xl { font-size: 1.5rem; line-height: 2rem; }
-.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
-
-.text-right { text-align: right; }
-.text-center { text-align: center; }
-.text-left { text-align: left; }
-.italic { font-style: italic; }
-.leading-none { line-height: 1; }
-.leading-tight { line-height: 1.25; }
-.leading-snug { line-height: 1.375; }
-.leading-relaxed { line-height: 1.625; }
-.tracking-tight { letter-spacing: -0.025em; }
-.select-none { user-select: none; }
-
-.relative { position: relative; }
-.absolute { position: absolute; }
-.sticky { position: sticky; }
-.fixed { position: fixed; }
-.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
-.top-0 { top: 0; }
-.right-0 { right: 0; }
-.left-0 { left: 0; }
-.bottom-0 { bottom: 0; }
-.top-2 { top: 0.5rem; }
-.top-2\.5 { top: 0.625rem; }
-.right-2\.5 { right: 0.625rem; }
-.right-3 { right: 0.75rem; }
-.z-10 { z-index: 10; }
-.z-30 { z-index: 30; }
-.z-40 { z-index: 40; }
-.z-50 { z-index: 50; }
-
-.cursor-pointer { cursor: pointer; }
-.outline-none { outline: none; }
-.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
-.shadow-md { box-shadow: var(--shadow-bento); }
-.shadow-lg { box-shadow: var(--shadow-elevated); }
-
-.overflow-hidden { overflow: hidden; }
-.overflow-y-auto { overflow-y: auto; }
-.overflow-x-auto { overflow-x: auto; }
-
-.border-collapse { border-collapse: collapse; }
-.divide-y > * + * { border-top: 1px solid #f1f5f9; }
-
-.list-disc { list-style-type: disc; }
-.list-decimal { list-style-type: decimal; }
-.list-inside { list-style-position: inside; }
-
-.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 200ms; }
-.transition-colors { transition-property: color, background-color, border-color; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
-
-.hover\:bg-slate-50:hover { background-color: #f8fafc; }
-.hover\:bg-slate-100:hover { background-color: #f1f5f9; }
-.hover\:border-slate-400:hover { border-color: #94a3b8; }
-.hover\:text-teal-600:hover { color: #059669; }
-.hover\:text-slate-900:hover { color: #0f172a; }
-.hover\:underline:hover { text-decoration: underline; }
-.focus\:border-teal-500:focus { border-color: #00b894; }
-
-/* Printable View */
+/* The briefing screens are meant to be printed and handed round a table. */
@media print {
- body {
- background: #ffffff !important;
- color: #0f172a !important;
+ :root {
+ --background: white;
}
.no-print {
display: none !important;
}
- .app-container {
- display: block !important;
+ body {
+ font-size: 10.5pt;
}
- .page-wrapper {
- max-width: 100% !important;
- padding: 0 !important;
+ .page-shell {
+ max-width: 100%;
+ padding: 0;
}
- .intel-card {
- border: 1px solid #cbd5e1 !important;
- background: #ffffff !important;
- box-shadow: none !important;
+ [data-card] {
+ border: 1px solid #cbd5e1;
+ box-shadow: none;
+ break-inside: avoid;
}
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..efea5b6
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,7 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+/** Merge conditional class names, letting later Tailwind utilities win. */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx
deleted file mode 100644
index 0b74831..0000000
--- a/src/pages/AdminPage.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-import React, { useState } from 'react';
-import {
- IntelligenceItem,
- University1415Dimension,
- JobEntity,
- SkillEntity,
- Category
-} from '../types/futures';
-import { dataService } from '../services/dataService';
-import { EntityEditorModal } from '../components/modals/EntityEditorModal';
-import {
- toPersianDigits,
- getCategoryLabelFa,
- getCategoryColor
-} from '../utils/persianNumbers';
-import {
- Database,
- Plus,
- Edit2,
- Trash2,
- RotateCcw,
- Download,
- Upload,
- Search,
- CheckCircle2,
- Target
-} from 'lucide-react';
-
-interface AdminPageProps {
- items: IntelligenceItem[];
- dimensions: University1415Dimension[];
- jobs: JobEntity[];
- skills: SkillEntity[];
- onRefreshData: () => void;
- onSelectItem: (item: IntelligenceItem) => void;
-}
-
-export const AdminPage: React.FC = ({
- items,
- dimensions,
- jobs,
- skills,
- onRefreshData,
- onSelectItem
-}) => {
- const [activeTab, setActiveTab] = useState<'items' | 'dimensions' | 'jobs'>('items');
- const [selectedCategory, setSelectedCategory] = useState('all');
- const [searchQuery, setSearchQuery] = useState('');
- const [isEditorOpen, setIsEditorOpen] = useState(false);
- const [editingItem, setEditingItem] = useState(null);
- const [successMsg, setSuccessMsg] = useState('');
-
- const filteredItems = items.filter((item) => {
- if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
- if (searchQuery.trim() !== '') {
- const q = searchQuery.toLowerCase().trim();
- const inTitle = item.title.toLowerCase().includes(q);
- const inSummary = item.executiveSummary.toLowerCase().includes(q);
- if (!inTitle && !inSummary) return false;
- }
- return true;
- });
-
- const handleCreateNew = () => {
- setEditingItem(null);
- setIsEditorOpen(true);
- };
-
- const handleEdit = (item: IntelligenceItem) => {
- setEditingItem(item);
- setIsEditorOpen(true);
- };
-
- const handleDelete = (id: string) => {
- if (window.confirm('آیا از حذف این رکورد از پایگاه داده رادار اطمینان دارید؟')) {
- dataService.deleteItem(id);
- onRefreshData();
- showSuccess('رکورد با موفقیت حذف شد');
- }
- };
-
- const handleSaveItem = (item: IntelligenceItem) => {
- if (editingItem) {
- dataService.updateItem(item);
- showSuccess('تغییرات با موفقیت ذخیره شد');
- } else {
- dataService.createItem(item);
- showSuccess('رکورد جدید با موفقیت به رادار اضافه شد');
- }
- onRefreshData();
- };
-
- const handleResetData = () => {
- if (
- window.confirm(
- 'آیا میخواهید دادههای پایگاه را به دادههای اولیه سازمانی دانشگاه اصفهان بازگردانید؟ تمامی تغییرات اختصاصی بازنشانی خواهند شد.'
- )
- ) {
- dataService.resetToDefault();
- onRefreshData();
- showSuccess('پایگاه داده با موفقیت به دادههای معیار دانشگاه اصفهان بازنشانی شد');
- }
- };
-
- const showSuccess = (msg: string) => {
- setSuccessMsg(msg);
- setTimeout(() => setSuccessMsg(''), 3000);
- };
-
- return (
-
- {/* Header */}
-
-
-
- پنل مدیریت محتوا و دادهها
- عملیات CRUD و کالیبراسیون شاخصها
-
-
- مدیریت پایگاه داده هوشمندی (Intelligence Database Studio)
-
-
- ثبت، ویرایش و پایش روندها، فناوریها، سیاستها، سیگنالها، مشاغل آینده و تنظیم نمرات دانشگاه ۱۴۱۵
-
-
-
-
-
-
- افزودن سیگنال / روند جدید
-
-
-
- بازنشانی دادهها
-
-
-
-
- {successMsg && (
-
-
- {successMsg}
-
- )}
-
- {/* Tabs */}
-
-
- setActiveTab('items')}
- className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
- activeTab === 'items'
- ? 'bg-teal-500 text-slate-950'
- : 'text-slate-400 hover:text-white bg-[#091122]'
- }`}
- >
- موجودیتهای رادار ({toPersianDigits(items.length)})
-
- setActiveTab('dimensions')}
- className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
- activeTab === 'dimensions'
- ? 'bg-teal-500 text-slate-950'
- : 'text-slate-400 hover:text-white bg-[#091122]'
- }`}
- >
- ابعاد دانشگاه ۱۴۱۵ ({toPersianDigits(dimensions.length)})
-
- setActiveTab('jobs')}
- className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
- activeTab === 'jobs'
- ? 'bg-teal-500 text-slate-950'
- : 'text-slate-400 hover:text-white bg-[#091122]'
- }`}
- >
- مشاغل و مهارتها ({toPersianDigits(jobs.length + skills.length)})
-
-
-
-
- {/* Tab 1: Intelligence Items CRUD Table */}
- {activeTab === 'items' && (
-
- {/* Table Filters */}
-
-
- فیلتر رده:
- {(['all', 'trend', 'technology', 'policy', 'weak_signal'] as const).map((cat) => (
- setSelectedCategory(cat)}
- className={`px-2.5 py-1 rounded-md transition-all ${
- selectedCategory === cat
- ? 'bg-teal-500 text-slate-950 font-bold'
- : 'bg-[#101c36] text-slate-300 hover:bg-[#16274a]'
- }`}
- >
- {cat === 'all' ? 'همه' : getCategoryLabelFa(cat)}
-
- ))}
-
-
-
- setSearchQuery(e.target.value)}
- placeholder="جستجو در عناوین..."
- className="w-full bg-[#081020] border border-slate-800 rounded-lg py-1.5 pr-8 pl-2 text-xs text-white outline-none focus:border-teal-500"
- />
-
-
-
-
- {/* Table */}
-
-
-
-
- عنوان
- دستهبندی
- افق زمانی
- شدت اثر
- دامنه
- وضعیت
- عملیات مدیریت
-
-
-
- {filteredItems.map((item) => (
-
- {item.title}
-
-
- {getCategoryLabelFa(item.category)}
-
-
- {item.horizon}
- {toPersianDigits(item.impactScore)}/۵
- {item.geographicScope}
-
- {item.status}
-
-
-
- handleEdit(item)}
- className="p-1.5 text-sky-400 hover:text-white bg-[#0e1c3a] hover:bg-sky-900/40 rounded-lg transition-colors"
- title="ویرایش"
- >
-
-
- handleDelete(item.id)}
- className="p-1.5 text-rose-400 hover:text-white bg-[#1a0e1c] hover:bg-rose-950/40 rounded-lg transition-colors"
- title="حذف"
- >
-
-
-
-
-
- ))}
-
-
-
-
- )}
-
- {/* Tab 2: 1415 Dimensions Table */}
- {activeTab === 'dimensions' && (
-
-
-
-
- کلانویژگی دانشگاه ۱۴۱۵
- نمره وضع موجود (۱-۱۰)
- نمره هدف ۱۴۱۵ (۱-۱۰)
- شکاف راهبردی
- درصد آمادگی
- تعداد شواهد
-
-
-
- {dimensions.map((dim) => (
-
- {dim.title}
- {toPersianDigits(dim.currentScore)}
- {toPersianDigits(dim.targetScore)}
- {toPersianDigits(dim.gap)}
- {toPersianDigits(dim.readinessPercentage)}٪
- {toPersianDigits(dim.supportingEvidence.length)} مورد
-
- ))}
-
-
-
- )}
-
- {/* Tab 3: Jobs & Skills Table */}
- {activeTab === 'jobs' && (
-
-
-
-
- عنوان شغل
- نوع تحول
- نرخ رشد
- خطر اتوماسیون
- دانشکده مرتبط در دانشگاه اصفهان
-
-
-
- {jobs.map((job) => (
-
- {job.title}
-
-
- {job.type === 'growing' ? 'رو به رشد' : 'در معرض تغییر'}
-
-
- +{toPersianDigits(job.growthRatePercent)}٪
- {toPersianDigits(job.automationRiskPercent)}٪
- {job.discipline}
-
- ))}
-
-
-
- )}
-
-
setIsEditorOpen(false)}
- onSave={handleSaveItem}
- initialItem={editingItem}
- />
-
- );
-};
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 70927fe..c154189 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -1,27 +1,23 @@
import React, { useState } from 'react';
-import { IntelligenceItem, University1415Dimension, Category, Horizon, UserSession } from '../types/futures';
-import { dataService } from '../services/dataService';
-import { FuturesRadar } from '../components/visual/FuturesRadar';
-import { PersonaBanner } from '../components/layout/PersonaBanner';
-import { toPersianDigits } from '../utils/persianNumbers';
import {
- Sparkles,
- TrendingUp,
- Cpu,
- BookOpen,
- Briefcase,
- Target,
- ArrowUpRight,
- ArrowLeft,
- AlertTriangle,
- Lightbulb,
- CheckCircle2,
- Calendar,
- Layers,
- Triangle,
- GitFork,
- FileText
-} from 'lucide-react';
+ IntelligenceItem,
+ University1415Dimension,
+ Category,
+ Horizon,
+ UserSession
+} from '../types/futures';
+import { FuturesRadar } from '../components/visual/FuturesRadar';
+import { PageHeader } from '../components/common/PageHeader';
+import { StatCard } from '../components/common/StatCard';
+import { toPersianDigits } from '../utils/persianNumbers';
+import { CATEGORY_META, CATEGORY_ORDER } from '../utils/categories';
+import { HORIZON_START, HORIZON_END } from '../utils/horizon';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Progress } from '@/components/ui/progress';
+import { ArrowLeft, FileText, Triangle, GitFork, Target } from 'lucide-react';
interface DashboardProps {
currentSession: UserSession | null;
@@ -33,7 +29,6 @@ interface DashboardProps {
}
export const Dashboard: React.FC = ({
- currentSession,
items,
dimensions,
onSelectItem,
@@ -41,463 +36,308 @@ export const Dashboard: React.FC = ({
onNavigate
}) => {
const [radarCategory, setRadarCategory] = useState('all');
- const [radarHorizon, setRadarHorizon] = useState('all');
+ const [radarHorizon] = useState('all');
- const kpis = dataService.getExecutiveKpis();
-
- // Top critical items
const criticalItems = items
.filter((i) => i.status === 'critical' || i.impactScore === 5)
- .slice(0, 4);
+ .slice(0, 5);
- // Top gap dimensions
- const topGaps = [...dimensions].sort((a, b) => b.gap - a.gap).slice(0, 3);
+ const topGaps = [...dimensions].sort((a, b) => b.gap - a.gap).slice(0, 4);
- // Average readiness
- const avgReadiness = Math.round(
- dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
- );
+ const avgReadiness = dimensions.length
+ ? Math.round(
+ dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) /
+ dimensions.length
+ )
+ : 0;
+
+ const countFor = (cat: Category) => items.filter((i) => i.category === cat).length;
return (
-
- {/* Persona Context Reminder */}
-
+
+
+ onNavigate('/reports')}>
+
+ گزارشها
+
+ onNavigate('/gap-analysis')}>
+ تحلیل شکاف
+
+
+ >
+ }
+ />
- {/* Top Welcome & Foresight Mission Bar */}
-
-
-
- 👋
-
- رادار آینده دانشگاه اصفهان
-
- سال تحصیلی ۱۴۰۳-۱۴۰۴
-
-
- دیدهبانی مستمر پیشرانها، فناوریها، سیگنالهای ضعیف و سنجش آمادگی دانشگاه در افق ۱۴۱۵
-
-
+ {/* The four observation streams, plus the readiness index. */}
+
+ {CATEGORY_ORDER.map((cat) => {
+ const meta = CATEGORY_META[cat];
+ return (
+
onNavigate(`/radar?category=${cat}`)}
+ />
+ );
+ })}
-
-
onNavigate('/reports')}
- className="btn btn-secondary text-xs flex items-center gap-2 py-2 px-4 shadow-sm"
- >
-
- بولتن مدیریتی هیئت رئیسه
-
-
onNavigate('/gap-analysis')}
- className="btn btn-primary text-xs flex items-center gap-2 py-2 px-4 shadow-sm"
- >
- تحلیل جامع شکاف ۱۴۱۵
-
-
-
-
-
- {/* 6 Bento Pastel KPI Cards (Inspired by Images 1, 2, 3, 5) */}
-
- {/* KPI 1: Active Signals (Blush Pink Card) */}
-
onNavigate('/radar?category=weak_signal')}
- className="intel-card interactive bento-card-pink p-4 flex flex-col justify-between"
- >
-
-
-
- {toPersianDigits(kpis.totalSignals)}
-
-
-
- ۹ سیگنال فعال
-
-
-
-
- {/* KPI 2: Emerging Trends (Soft Sky Blue Card) */}
-
onNavigate('/radar?category=trend')}
- className="intel-card interactive bento-card-blue p-4 flex flex-col justify-between"
- >
-
-
-
- {toPersianDigits(kpis.emergingTrends)}
-
-
-
- رانندههای پداگوژی
-
-
-
-
- {/* KPI 3: Key Technologies (Soft Mint Card) */}
-
onNavigate('/radar?category=technology')}
- className="intel-card interactive bento-card-mint p-4 flex flex-col justify-between"
- >
-
-
-
- {toPersianDigits(kpis.criticalTech)}
-
-
-
- اثرگذاری اخلالگر
-
-
-
-
- {/* KPI 4: Policy Shifts (Soft Buttery Yellow Card) */}
-
onNavigate('/radar?category=policy')}
- className="intel-card interactive bento-card-yellow p-4 flex flex-col justify-between"
- >
-
-
-
- {toPersianDigits(kpis.policyChanges)}
-
-
-
- آییننامهها و مالیات
-
-
-
-
- {/* KPI 5: Labor Market Disruption (Soft Lilac Purple Card) */}
-
onNavigate('/labor-market')}
- className="intel-card interactive bento-card-purple p-4 flex flex-col justify-between"
- >
-
-
-
- {toPersianDigits(kpis.highImpactLabor)}
-
-
-
- شغل در دگرگونی
-
-
-
-
- {/* KPI 6: Average 1415 Readiness (Crisp White Card with Mini Arc/Bar) */}
-
onNavigate('/gap-analysis')}
- className="intel-card interactive p-4 flex flex-col justify-between border-teal-200"
- >
-
-
-
- {toPersianDigits(avgReadiness)}٪
-
-
-
-
فاصله تا وضع مطلوب: ۵۰٪
-
+ className="col-span-2 lg:col-span-1"
+ />
- {/* Main Centerpiece: Split Layout (Futures Radar on Left + Readiness & Alerts on Right) */}
-
- {/* Main Left Column: Interactive Futures Radar Bento Card (7 Cols) */}
-
-
-
-
-
- رادار تعاملی آیندهپژوهی آموزش عالی
-
-
- موقعیتیابی بر اساس ۴ افق زمانی، شدت اثر و دستهبندی موضوعی
+
+ {/* Radar */}
+
+
+
+
رادار آینده
+
+ چهار افق زمانی؛ فاصله از مرکز بر اساس دوری زمانی و اندازه نقطه بر
+ اساس شدت اثر.
- {/* Pill Filter Switcher (Inspired by Image 4 Top Nav Pills) */}
-
-
setRadarCategory('all')}
- className={`px-3 py-1 rounded-full font-bold transition-all ${
- radarCategory === 'all'
- ? 'bg-slate-900 text-white shadow-sm'
- : 'text-slate-600 hover:text-slate-900'
- }`}
+
+ setRadarCategory(v as Category | 'all')}
>
- همه ({toPersianDigits(items.length)})
-
- setRadarCategory('trend')}
- className={`px-3 py-1 rounded-full font-bold transition-all ${
- radarCategory === 'trend'
- ? 'bg-sky-600 text-white shadow-sm'
- : 'text-sky-700 hover:text-sky-900'
- }`}
- >
- روندها
-
- setRadarCategory('technology')}
- className={`px-3 py-1 rounded-full font-bold transition-all ${
- radarCategory === 'technology'
- ? 'bg-teal-600 text-white shadow-sm'
- : 'text-teal-700 hover:text-teal-900'
- }`}
- >
- فناوریها
-
- setRadarCategory('policy')}
- className={`px-3 py-1 rounded-full font-bold transition-all ${
- radarCategory === 'policy'
- ? 'bg-amber-600 text-white shadow-sm'
- : 'text-amber-700 hover:text-amber-900'
- }`}
- >
- سیاستها
-
- setRadarCategory('weak_signal')}
- className={`px-3 py-1 rounded-full font-bold transition-all ${
- radarCategory === 'weak_signal'
- ? 'bg-rose-600 text-white shadow-sm'
- : 'text-rose-700 hover:text-rose-900'
- }`}
- >
- سیگنالها
-
+
+
+ همه
+
+ {CATEGORY_ORDER.map((cat) => (
+
+ {CATEGORY_META[cat].label}
+
+ ))}
+
+
-
+
- {/* Radar Visualization Canvas */}
-
+
-
- {/* Radar Action Footer */}
-
-
برای بررسی پرونده تحلیلی، روی هر نقطه کلیک کنید.
-
onNavigate('/radar')}
- className="text-teal-600 font-bold hover:underline flex items-center gap-1"
- >
- نمایش تمامصفحه رادار با ماتریس تحلیلی
-
-
-
-
-
- {/* Right Column: Readiness Arc Meter & Strategic Schedule (5 Cols) */}
-
- {/* Card 1: 1415 Readiness Arc & Donut Meter (Inspired by Image 2 TVL & Image 3 Customer Segments) */}
-
-
-
-
-
- شاخص بلوغ و آمادگی دانشگاه در سال ۱۴۱۵
-
-
ارزیابی ۱۰ کلانویژگی مصوب چشمانداز
-
-
وضع موجود
+
+
+ برای دیدن پرونده، روی هر نقطه کلیک کنید.
+
+
onNavigate('/radar')}
+ className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
+ >
+ نمای کامل
+
+
+
+
- {/* Arc Progress Visualization */}
-
-
- {/* SVG Semi-Circle Arc Meter */}
-
-
-
-
-
- {toPersianDigits(avgReadiness)}٪
- کل آمادگی
-
-
+ {/* Readiness + critical items */}
+
+
+
+ آمادگی افق ۱۴۱۵
+
+ نسبت وضع موجود به وضع مطلوب در ۱۰ ویژگی دانشگاه.
+
+
- {/* Top Priority Gaps breakdown */}
-
-
بزرگترین شکافهای نیازمند مداخله:
- {topGaps.map((dim) => (
-
onSelectDimension(dim)}
- className="p-2 bg-slate-50 hover:bg-slate-100 rounded-xl cursor-pointer transition-colors flex items-center justify-between text-xs border border-slate-200/70"
- >
-
{dim.title}
-
- شکاف: {toPersianDigits(dim.gap)}
-
+
+
+
+
+
+ بزرگترین شکافها
- ))}
-
-
-
- onNavigate('/gap-analysis')}
- className="w-full py-2 bg-slate-100 hover:bg-slate-200/80 text-slate-800 font-bold rounded-xl text-xs flex items-center justify-center gap-1.5 transition-colors"
- >
- مشاهده نمودار عنکبوتی ۱۰ بعدی و جزئیات
-
-
-
-
- {/* Card 2: Immediate Foresight Alerts List */}
-
-
-
-
-
-
پیشرانهای با فوریت بحرانی (افق اکنون)
- نیازمند تصمیم در شورای دانشگاه
-
-
-
فوری
-
-
- {/* List of Critical Items as Pastel Pill Rows */}
-
- {criticalItems.map((item) => (
-
onSelectItem(item)}
- className="p-3 bg-slate-50 hover:bg-white hover:border-teal-300 border border-slate-200/80 rounded-2xl cursor-pointer transition-all flex flex-col gap-1 shadow-sm"
- >
-
-
{item.title}
-
- تأثیر: {toPersianDigits(item.impactScore)}/۵
-
+
+ {topGaps.map((dim) => (
+
onSelectDimension(dim)}
+ className="group flex items-center gap-2 text-right"
+ >
+
+ {dim.shortTag}
+
+
+
+ {toPersianDigits(dim.gap)}
+
+
+ ))}
-
- {item.whyItMatters}
-
- ))}
-
-
+
+
+
onNavigate('/gap-analysis')}
+ >
+ نمودار عنکبوتی ۱۰ بعدی
+
+
+
+
+
+
+
+ نیازمند تصمیم
+
+ مواردی با بیشترین شدت اثر در افق اکنون.
+
+
+
+
+
+ {criticalItems.map((item) => {
+ const meta = CATEGORY_META[item.category];
+ return (
+
+ onSelectItem(item)}
+ className="group w-full py-2.5 text-right"
+ >
+
+
+ {item.title}
+
+
+ {toPersianDigits(item.impactScore)}/۵
+
+
+
+ {item.whyItMatters}
+
+
+
+ );
+ })}
+
+
+
- {/* Bottom Bento Row: Futures Triangle + Strategic Impact Value Chain */}
-
- {/* Futures Triangle Teaser */}
-
+
onNavigate('/futures-triangle')}
- className="intel-card interactive bento-card-purple p-6 flex flex-col justify-between"
- >
-
-
-
-
-
-
- پویایی تعادل نیروها
-
مثلث هوشمندی آینده دانشگاه اصفهان
-
-
-
-
-
-
- سنجش برآیند کشش آینده (دانشگاه هوشمند و مادامالعمر)، فشارهای حال (شتاب هوش مصنوعی و بحران آب اصفهان) و وزن گذشته (دیوانسالاری کهنه و ساختار سنتی)
-
-
-
-
کاوش تعاملی ۱۵ نیروی کلیدی در مثلث آینده
-
-
-
-
- {/* Strategic Disruption Chain Teaser */}
-
+
onNavigate('/network')}
- className="intel-card interactive bento-card-mint p-6 flex flex-col justify-between"
- >
-
-
-
-
-
-
- زنجیره ارزش و سناریوسازی
-
شبکه روابط راهبردی: از سیگنال بیرونی تا شکاف قابلیت
-
-
-
-
-
-
- ردگیری زنجیره اخلال: فناوری نوین ← دگرگونی آموزش ← مهارتهای موردنیاز بازار کار ← بازآفرینی مشاغل ← بعد دانشگاه اصفهان ۱۴۱۵ ← شکاف عملکردی
-
-
-
-
مشاهده گراف شبکه و روابط متقابل
-
-
-
+ />
);
};
+
+/** Circular readiness gauge. */
+const ReadinessDial: React.FC<{ value: number }> = ({ value }) => {
+ const r = 40;
+ const circumference = 2 * Math.PI * r;
+
+ return (
+
+
+
+
+
+
+
+ {toPersianDigits(value)}٪
+
+ آمادگی
+
+
+ );
+};
+
+const NavCard: React.FC<{
+ icon: React.ElementType;
+ title: string;
+ description: string;
+ onClick: () => void;
+}> = ({ icon: Icon, title, description, onClick }) => (
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ className="group cursor-pointer p-4 transition-[box-shadow,border-color,transform] duration-200 hover:border-foreground/15 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring"
+ >
+
+
+);
diff --git a/src/pages/FuturesTrianglePage.tsx b/src/pages/FuturesTrianglePage.tsx
index 99968d0..0134158 100644
--- a/src/pages/FuturesTrianglePage.tsx
+++ b/src/pages/FuturesTrianglePage.tsx
@@ -1,72 +1,25 @@
import React from 'react';
import { FuturesForce } from '../types/futures';
import { TriangleView } from '../components/visual/TriangleView';
+import { PageHeader } from '../components/common/PageHeader';
import { toPersianDigits } from '../utils/persianNumbers';
-import { Sparkles, TrendingUp, ShieldAlert } from 'lucide-react';
interface FuturesTrianglePageProps {
forces: FuturesForce[];
}
-export const FuturesTrianglePage: React.FC
= ({ forces }) => {
- return (
-
- {/* Clean Executive Header (Zero AI Slop) */}
-
-
-
- برنامهریزی راهبردی / تحلیل تعادل نیروها
-
-
- مثلث هوشمندی آینده دانشگاه اصفهان
-
-
- سنجش برآیند کشش آینده، فشارهای جاری زمان حال و وزن لنگرهای ساختاری گذشته
-
-
+export const FuturesTrianglePage: React.FC
= ({ forces }) => (
+
+
+ {toPersianDigits(forces.length)} نیرو
+
+ }
+ />
-
-
- {toPersianDigits(forces.length)} نیروی کلیدی در تعامل
-
-
-
-
- {/* 3 Executive Pastel Bento Cards */}
-
-
-
-
- کشش آینده — ۵ نیرو
-
-
- تصویر مطلوب، دانشگاههای نسل پنجم، آموزش هوشمند و یادگیری مادامالعمر به عنوان نیروهای جاذب.
-
-
-
-
-
-
- فشارهای زمان حال — ۵ نیرو
-
-
- شتاب هوش مصنوعی، انقباض هرم جمعیتی، بحران منابع و آب اصفهان و کسری بودجه عمومی.
-
-
-
-
-
-
- وزن گذشته — ۵ نیرو
-
-
- لختی دیوانسالاری، آییننامههای سنتی ارتقا، ابنیه فرسوده و مقاومت فرهنگی در برابر دگرگونی.
-
-
-
-
- {/* Sleek Geometric Triangle Visualizer */}
-
-
- );
-};
+
+
+);
diff --git a/src/pages/GapAnalysisPage.tsx b/src/pages/GapAnalysisPage.tsx
index 9c37c97..54582f9 100644
--- a/src/pages/GapAnalysisPage.tsx
+++ b/src/pages/GapAnalysisPage.tsx
@@ -1,16 +1,23 @@
import React, { useState } from 'react';
import { University1415Dimension } from '../types/futures';
import { SpiderChart } from '../components/visual/SpiderChart';
+import { PageHeader } from '../components/common/PageHeader';
+import { StatCard } from '../components/common/StatCard';
import { toPersianDigits } from '../utils/persianNumbers';
+import { HORIZON_END } from '../utils/horizon';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Progress } from '@/components/ui/progress';
import {
- Target,
- ArrowLeft,
- AlertTriangle,
- Award,
- TrendingUp,
- Lightbulb,
- ArrowUpRight
-} from 'lucide-react';
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from '@/components/ui/table';
+import { Target, AlertTriangle, Award } from 'lucide-react';
interface GapAnalysisPageProps {
dimensions: University1415Dimension[];
@@ -21,254 +28,186 @@ export const GapAnalysisPage: React.FC = ({
dimensions,
onSelectDimension
}) => {
- const [activeTab, setActiveTab] = useState<'spider' | 'breakdown'>('spider');
+ const [tab, setTab] = useState<'spider' | 'table'>('spider');
- // Calculations
- const sortedByGap = [...dimensions].sort((a, b) => b.gap - a.gap);
- const sortedByScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
+ if (!dimensions.length) return null;
- const largestGaps = sortedByGap.slice(0, 4);
- const strongestDims = sortedByScore.slice(0, 3);
+ const byGap = [...dimensions].sort((a, b) => b.gap - a.gap);
+ const byScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
const avgReadiness = Math.round(
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
);
+ const widestGap = byGap[0];
+ const strongest = byScore[0];
+
return (
-
- {/* Header */}
-
-
-
- سنجش بلوغ سازمانی
- ارزیابی شکاف آمادگی افق ۱۴۱۵
-
-
- تحلیل جامع شکاف راهبردی (Gap Analysis)
-
-
- مقایسه چندبُعدی وضعیت موجود دانشگاه اصفهان با اهداف آرمانی دانشگاه آینده در سال ۱۴۱۵
-
-
+
+
setTab(v as typeof tab)}>
+
+ نمودار عنکبوتی
+ جدول
+
+
+ }
+ />
- {/* Pill Tab Switcher */}
-
- setActiveTab('spider')}
- className={`px-4 py-1.5 rounded-full transition-all font-bold ${
- activeTab === 'spider'
- ? 'bg-slate-900 text-white shadow-sm'
- : 'text-slate-600 hover:text-slate-900'
- }`}
- >
- نمودار عنکبوتی ۱۰ بعدی
-
- setActiveTab('breakdown')}
- className={`px-4 py-1.5 rounded-full transition-all font-bold ${
- activeTab === 'breakdown'
- ? 'bg-slate-900 text-white shadow-sm'
- : 'text-slate-600 hover:text-slate-900'
- }`}
- >
- ماتریس اولویتبندی مداخله
-
-
+
+
+ onSelectDimension(widestGap)}
+ />
+ onSelectDimension(byGap[1])}
+ />
+ onSelectDimension(strongest)}
+ />
- {/* KPI Cards Row (Bento Pastel Cards matching Image 1 & 3) */}
-
- {/* Overall Readiness (Mint) */}
-
-
- شاخص کل آمادگی دانشگاه
-
-
-
- {toPersianDigits(avgReadiness)}٪
-
-
-
فاصله تا وضع مطلوب: ۵۴٪
-
+ {tab === 'spider' && (
+
+
+
+
+
+
- {/* Largest Gap (Pink) */}
-
-
-
بزرگترین شکاف عملکردی
-
-
-
- {largestGaps[0].title}
-
-
- شکاف: {toPersianDigits(largestGaps[0].gap)} نمره
-
-
- آمادگی: {toPersianDigits(largestGaps[0].readinessPercentage)}٪
-
-
-
- {/* Second Largest Gap (Yellow) */}
-
-
- دومین اولویت مداخله فوری
-
-
-
- {largestGaps[1].title}
-
-
- شکاف: {toPersianDigits(largestGaps[1].gap)} نمره
-
-
- آمادگی: {toPersianDigits(largestGaps[1].readinessPercentage)}٪
-
-
-
- {/* Strongest Dimension (Purple) */}
-
-
-
نقطه قوت و مزیت کنونی
-
-
-
- {strongestDims[0].title}
-
-
- نمره: {toPersianDigits(strongestDims[0].currentScore)} از ۱۰
-
-
- آمادگی: {toPersianDigits(strongestDims[0].readinessPercentage)}٪
-
-
-
-
- {/* Main Spider Chart Visualization Container */}
- {activeTab === 'spider' && (
-
-
-
-
-
- {/* Right Column: Strategic Gap Diagnosis & Recommendations */}
-
-
-
- رتبهبندی شکافهای دهگانه:
-
-
-
- {sortedByGap.map((dim, idx) => (
-
onSelectDimension(dim)}
- className="bg-slate-50 hover:bg-slate-100 border border-slate-200 p-2.5 rounded-2xl cursor-pointer transition-all flex items-center justify-between text-xs"
- >
-
-
+
+
+ رتبهبندی شکافها
+
+ از بیشترین فاصله تا کمترین.
+
+
+
+
+ {byGap.map((dim, idx) => (
+
+ onSelectDimension(dim)}
+ className="group flex w-full items-center gap-2.5 py-2 text-right"
+ >
+
{toPersianDigits(idx + 1)}
- {dim.shortTag}
-
-
-
-
- {toPersianDigits(dim.currentScore)}/۱۰
+
+
+ {dim.shortTag}
+
+
-
- شکاف: {toPersianDigits(dim.gap)}
+
+ {toPersianDigits(dim.gap)}
-
-
+
+
))}
-
-
-
-
-
-
- فرمان اجرایی اولویتدار:
-
-
- برای کاهش شکاف ۵.۳ نمرهای در بعد هوشمصنوعیبنیان، نیاز به خرید ۳۰ سرور پردازشی GPU بومی و تغییر نظام ارزیابی امتحانات در ۳۰ گروه آموزشی تا بهمن ۱۴۰۳ است.
-
-
-
+
+
+
)}
- {/* Tab 2: Detailed Matrix Table Breakdown */}
- {activeTab === 'breakdown' && (
-
-
-
-
- کلانویژگی دانشگاه ۱۴۱۵
- وضع موجود (۱-۱۰)
- هدف ۱۴۱۵ (۱-۱۰)
- میزان شکاف
- درصد آمادگی
- اقدام راهبردی اولویتدار
- اقدام
-
-
-
- {dimensions.map((dim) => (
- onSelectDimension(dim)}
- className="hover:bg-slate-50 cursor-pointer transition-colors"
- >
-
-
-
- {dim.title}
-
-
- {toPersianDigits(dim.currentScore)}
- {toPersianDigits(dim.targetScore)}
-
- {toPersianDigits(dim.gap)}
-
-
-
-
-
{toPersianDigits(dim.readinessPercentage)}٪
-
-
-
- {dim.strategicRecommendations[0]}
-
-
-
- جزئیات بعد ↗
-
-
-
- ))}
-
-
-
+ {tab === 'table' && (
+
+
+
+
+
+ ویژگی
+ موجود
+ هدف
+ شکاف
+ آمادگی
+ اقدام پیشنهادی
+
+
+
+ {dimensions.map((dim) => (
+ onSelectDimension(dim)}
+ className="cursor-pointer"
+ >
+
+
+
+ {dim.title}
+
+
+
+ {toPersianDigits(dim.currentScore)}
+
+
+ {toPersianDigits(dim.targetScore)}
+
+
+ {toPersianDigits(dim.gap)}
+
+
+
+
+
+ {toPersianDigits(dim.readinessPercentage)}٪
+
+
+
+
+
+ {dim.strategicRecommendations[0]}
+
+
+
+ ))}
+
+
+
+
)}
);
diff --git a/src/pages/ItemDetailPage.tsx b/src/pages/ItemDetailPage.tsx
index 90b65d6..16e61d3 100644
--- a/src/pages/ItemDetailPage.tsx
+++ b/src/pages/ItemDetailPage.tsx
@@ -6,30 +6,19 @@ import {
SkillEntity
} from '../types/futures';
import { ImpactGauges } from '../components/visual/ImpactGauges';
-import {
- toPersianDigits,
- getCategoryLabelFa,
- getCategoryColor,
- getHorizonLabelFa
-} from '../utils/persianNumbers';
+import { toPersianDigits, getHorizonLabelFa } from '../utils/persianNumbers';
+import { CATEGORY_META } from '../utils/categories';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
import {
ArrowRight,
- Clock,
- Zap,
- Globe,
- HelpCircle,
- FileText,
- Building,
+ CheckCircle2,
AlertOctagon,
ShieldCheck,
- CheckCircle2,
- AlertTriangle,
- Lightbulb,
Target,
Briefcase,
- Brain,
- Layers,
- ArrowUpRight
+ Brain
} from 'lucide-react';
interface ItemDetailPageProps {
@@ -43,6 +32,12 @@ interface ItemDetailPageProps {
onSelectDimension: (dim: University1415Dimension) => void;
}
+const CONFIDENCE_LABELS: Record = {
+ high: 'بالا',
+ medium: 'متوسط',
+ low: 'پایین'
+};
+
export const ItemDetailPage: React.FC = ({
item,
allItems,
@@ -53,323 +48,324 @@ export const ItemDetailPage: React.FC = ({
onSelectItem,
onSelectDimension
}) => {
- const catColor = getCategoryColor(item.category);
+ const meta = CATEGORY_META[item.category];
- // Trace related items
const relatedItems = allItems.filter((i) => item.relatedItemIds?.includes(i.id));
- const relatedDimensions = dimensions.filter((d) => item.relatedDimensionIds?.includes(d.id));
+ const relatedDimensions = dimensions.filter((d) =>
+ item.relatedDimensionIds?.includes(d.id)
+ );
const relatedJobs = jobs.filter((j) => item.relatedJobIds?.includes(j.id));
const relatedSkills = skills.filter((s) => item.relatedSkillIds?.includes(s.id));
return (
-
- {/* Back Button & Top Navigation */}
-
-
-
- بازگشت به رادار هوشمندی
-
+
+
+
+ بازگشت
+
-
-
- {getCategoryLabelFa(item.category)}
-
-
شناسه: {item.id}
+ {/* Overview */}
+
+
+
+
+
+
+ {meta.label}
+
+
+ شدت اثر {toPersianDigits(item.impactScore)}/۵
+
+ {getHorizonLabelFa(item.horizon)}
+ {item.geographicScope}
+
+ عدم قطعیت {toPersianDigits(item.uncertaintyScore)}/۵
+
+
+
+
{item.title}
+
+
+ {item.executiveSummary}
+
-
+
- {/* 1. OVERVIEW SECTION (Bento Card) */}
-
-
-
-
- شدت اثر کلی: {toPersianDigits(item.impactScore)} از ۵
-
- •
-
-
- افق زمانی: {getHorizonLabelFa(item.horizon)}
-
- •
-
-
- دامنه جغرافیایی: {item.geographicScope}
-
- •
-
-
- عدم قطعیت: {toPersianDigits(item.uncertaintyScore)} از ۵
-
-
-
-
- {item.title}
-
-
-
- خلاصه مدیریتی (Executive Summary):
- {item.executiveSummary}
-
-
-
- {/* 2. WHY DOES IT MATTER & RELEVANCE */}
-
-
-
-
-
- چرا برای آموزش عالی اهمیت حیاتی دارد؟ (Why It Matters)
-
-
+
+
+
+ چرا اهمیت دارد؟
+
+
+
{item.whyItMatters}
-
-
- نقطه عطف: این پدیده ساختارهای یادگیری و سنجش را دگرگون ساخته و انفعال در برابر آن موجب افت تقاضای داوطلبان خواهد شد.
-
-
+
+
-
-
-
-
- ارتباط مستقیم با وضعیت دانشگاه اصفهان
-
-
+
+
+ ارتباط با دانشگاه اصفهان
+
+
+
{item.relevanceToUniversity}
-
-
- دانشکدهها و واحدهای درگیر: {item.implications.affectedDepartments.join('، ')}
-
-
-
-
- {/* 3. MULTI-DIMENSIONAL IMPACT MODEL & TIME HORIZON */}
-
- {/* Impact Model (7 Dimensions) */}
-
-
-
-
- مدل تحلیلی اثرگذاری چندبُعدی بر ارکان دانشگاه اصفهان
-
- مقیاس ۱ تا ۵
-
-
-
-
-
- {/* Time Horizon & Uncertainty Diagnosis */}
-
-
-
-
- افق زمانی بلوغ و درجه عدم قطعیت
-
-
-
-
-
- افق تحقق پیشبینیشده:
- {getHorizonLabelFa(item.horizon)}
-
-
- شاخص عدم قطعیت:
- {toPersianDigits(item.uncertaintyScore)} از ۵
-
-
- درجه اطمینان شواهد:
-
- {item.confidence === 'high' ? 'بالا (مبتنی بر اسناد موثق)' : 'متوسط'}
-
-
-
-
- {/* Strategic Implications Grid (SWOT Bento Style) */}
-
-
تحلیل پیامدهای راهبردی (SWOT/Implications):
-
- {/* Opportunities */}
-
-
-
- فرصتهای پیشرو برای دانشگاه اصفهان:
-
-
- {item.implications.opportunities.map((opp, idx) => (
- {opp}
+ {item.implications.affectedDepartments.length > 0 && (
+
+ {item.implications.affectedDepartments.map((dept, i) => (
+
+ {dept}
+
))}
-
-
-
- {/* Risks & Threats */}
-
-
-