File size: 4,831 Bytes
518343a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { motion } from 'framer-motion';
import { Minus, TrendingDown, TrendingUp } from 'lucide-react';
import type { DomainData } from '../types';

interface EcosystemPulseProps {
  domains: DomainData[];
  compositeScore: number;
  compositeStatus: string;
}

function scoreToTrend(score: number): 'up' | 'down' | 'neutral' {
  if (score >= 80) return 'up';
  if (score <= 65) return 'down';
  return 'neutral';
}

function scoreToColor(score: number): string {
  if (score >= 80) return 'var(--color-low)';
  if (score >= 70) return 'var(--color-high)';
  return 'var(--color-critical)';
}

const GAUGE_CX = 70;
const GAUGE_CY = 72;
const GAUGE_R = 58;

const GAUGE_PATH = `M ${GAUGE_CX - GAUGE_R} ${GAUGE_CY} A ${GAUGE_R} ${GAUGE_R} 0 0 1 ${GAUGE_CX + GAUGE_R} ${GAUGE_CY}`;

function CompositeGauge({ score, status }: { score: number; status: string }) {
  const color = scoreToColor(score);

  return (
    <div className="flex flex-col items-center gap-2">
      <div className="relative">
        <svg width="140" height="80" viewBox="0 0 140 80" aria-label={`Composite health: ${score}`}>
          <path
            d={GAUGE_PATH}
            stroke="var(--color-surface-border)"
            strokeWidth={10}
            fill="none"
            strokeLinecap="round"
          />
          <motion.path
            key={score}
            d={GAUGE_PATH}
            stroke={color}
            strokeWidth={10}
            fill="none"
            strokeLinecap="round"
            initial={{ pathLength: 0 }}
            animate={{ pathLength: score / 100 }}
            transition={{ duration: 1.1, ease: 'easeOut' }}
          />
          <motion.text
            key={`label-${score}`}
            x={GAUGE_CX}
            y={GAUGE_CY - 4}
            textAnchor="middle"
            dominantBaseline="auto"
            fontSize="26"
            fontWeight="700"
            letterSpacing="-1"
            fill="var(--color-fg-primary)"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={{ delay: 0.3 }}
          >
            {score}
          </motion.text>
        </svg>
      </div>

      <span
        className="text-xs font-medium uppercase tracking-wider px-2 py-0.5 rounded border"
        style={{
          color,
          borderColor: color,
          backgroundColor: `color-mix(in srgb, ${color} 12%, transparent)`,
        }}
      >
        {status}
      </span>

      <h2
        className="text-[10px] font-bold tracking-widest uppercase"
        style={{ color: 'var(--color-fg-muted)' }}
      >
        Composite Health
      </h2>
    </div>
  );
}

export function EcosystemPulse({ domains, compositeScore, compositeStatus }: EcosystemPulseProps) {
  return (
    <div className="flex flex-col md:flex-row items-center justify-between gap-8 p-8 bg-[var(--color-surface-base)] border border-[var(--color-surface-border)] rounded-xl">
      <div className="shrink-0">
        <CompositeGauge score={compositeScore} status={compositeStatus} />
      </div>

      <div className="flex-1 grid grid-cols-2 md:grid-cols-3 gap-3 w-full">
        {domains.map((d, i) => (
          <motion.div
            key={d.id}
            initial={{ opacity: 0, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ delay: i * 0.06 }}
            className="flex flex-col gap-1 p-3 rounded-lg border"
            style={{
              backgroundColor: 'var(--color-bg-primary)',
              borderColor: 'var(--color-surface-border)',
            }}
          >
            <div className="flex items-center justify-between">
              <span className="text-xs font-semibold" style={{ color: d.color }}>
                {d.name}
              </span>
              <div className="flex items-center gap-1">
                <motion.span
                  key={d.score}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  className="text-sm font-bold"
                  style={{ color: 'var(--color-fg-primary)' }}
                >
                  {d.score}
                </motion.span>
                {scoreToTrend(d.score) === 'up' ? (
                  <TrendingUp className="w-3 h-3" style={{ color: 'var(--color-low)' }} />
                ) : scoreToTrend(d.score) === 'down' ? (
                  <TrendingDown className="w-3 h-3" style={{ color: 'var(--color-critical)' }} />
                ) : (
                  <Minus className="w-3 h-3" style={{ color: 'var(--color-fg-muted)' }} />
                )}
              </div>
            </div>
            <span className="text-[10px] truncate" style={{ color: 'var(--color-fg-muted)' }}>
              {d.status}
            </span>
          </motion.div>
        ))}
      </div>
    </div>
  );
}