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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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