File size: 13,047 Bytes
309320b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f756260
309320b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useLeaderboardData } from '@/composables/useLeaderboardData.js'
import * as echarts from 'echarts'

const chartRef = ref(null)
let chart = null

// 独立监听主题变化
const isDarkChart = ref(document.documentElement.classList.contains('dark'))

let themeObserver = null

const { leaderboard, selectedDataNameChart, modelTypeGroups, strtSymbolSeries } = useLeaderboardData()

// 接受一个可选 prop:autoShowSeries(数组),用于指定哪些模型类型在图表加载时自动显示。
// 例如:<Chart :autoShowSeries="['gemma-3','Hunyuan']" />
const props = defineProps({
  autoShowSeries: { type: Array, default: () => [] }
})

// 把 leaderboard 转成 ECharts 所需的 series 数组(每个 model_series 一条线)
function buildSeriesFromLeaderboard(rows = []) {
  const groups = {}
  rows.forEach(r => {
    // 按 model_type 分组;如果 model_type 未填则标记为 未知
    // 优化:如果行上有 _isMoE 标记,则把其归为 "<model_type> (MoE)" 系列,且保留 model_series_base
    const baseType = r.model_type || '未知'
    const isMoE = !!r._isMoE
    const seriesName = isMoE ? `${baseType} (MoE)` : baseType
    if (!groups[seriesName]) groups[seriesName] = []
    // 数据原本单位为 FLOPs (G),转换为 FLOPs(1 TFLOP = 1e12 FLOPs)用于对数尺度显示
    const xRaw = parseFloat(r.BF16_TFLOPs) || 0
    const x = xRaw > 0 ? xRaw * 1e12 / 1024 : 0
    // const x = xRaw 
    const y = parseFloat(r.ic) || 0
    const rank = Number(r.rank) || 0
    groups[seriesName].push({ x, y, rank })
  })

  return Object.keys(groups).map(name => {
    // 过滤掉 x<=0 的点(对数坐标轴不能显示 0 或负值),并按 x 轴值排序
    const data = groups[name]
      .filter(p => Number.isFinite(p.x) && p.x > 0 && Number.isFinite(p.y))
      .sort((a, b) => a.x - b.x)
      .map(d => [d.x, d.y])
    // 根据该系列的样本点数量适度放大 symbolSize,避免点太小难以点击
    const count = groups[name].length || 1
    const symbolSize = Math.min(18, 6 + Math.sqrt(count) * 1.6)
    return { name, rawCount: count, type: 'line', showSymbol: true, symbolSize, data }
  })
}

function getChartOption(series, isDark = false) {
  // 根据主题设置颜色
  const titleColor = isDark ? '#cfd8dc' : '#333333'
  const legendColor = isDark ? '#cfd8dc' : '#333333'
  const axisLineColor = isDark ? '#78909c' : '#666666'
  const axisLabelColor = isDark ? '#b0bec5' : '#666666'
  const splitLineColor = isDark ? '#37474f' : '#cccccc'
  // 计算数据范围以自动缩放坐标轴,避免留太多空白
  const allX = []
  const allY = []
  series.forEach(s => {
    if (Array.isArray(s.data)) {
      s.data.forEach(d => {
        const x = Number(d[0])
        const y = Number(d[1])
        if (Number.isFinite(x)) allX.push(x)
        if (Number.isFinite(y)) allY.push(y)
      })
    }
  })
  let xMin = allX.length ? Math.min(...allX) : undefined
  let xMax = allX.length ? Math.max(...allX) : undefined
  let yMin = allY.length ? Math.min(...allY) : undefined
  let yMax = allY.length ? Math.max(...allY) : undefined

  // 对数轴不能为 0 或负值,确保 min>0;并添加一些 padding
  if (xMin !== undefined && xMax !== undefined) {
    // 防止极值过于接近或相等
    if (xMin <= 0) xMin = Math.min(...allX.filter(v => v > 0)) || xMax
    if (xMin === xMax) {
      xMin = xMin / 10
      xMax = xMax * 10
    } else {
      xMin = xMin * 0.8
      xMax = xMax * 1.15
    }
  }

  if (yMin !== undefined && yMax !== undefined) {
    if (yMin === yMax) {
      // small symmetric padding
      // 如果只有单一数值,使用该值作为中心;但若该值为 0,则不要把区间提升到以 1 为中心(之前用 `yMin || 1` 会导致 0 -> 1)
      const v = (yMin !== undefined ? yMin : 1)
      if (v === 0) {
        // 为 0 时给一个小的对称 padding(之后如果原始数据全为正,会被下限为 0)
        const pad = 0.05
        yMin = -pad
        yMax = pad
      } else {
        yMin = v - Math.abs(v) * 0.05
        yMax = v + Math.abs(v) * 0.05
      }
    } else {
      const pad = (yMax - yMin) * 0.08
      yMin = yMin - pad
      yMax = yMax + pad
    }
    // 如果 yMin 变为负但原数据全为正,可以下限为 0
    if (yMin < 0 && Math.min(...allY) >= 0) yMin = 0
  }

  // helper: 将整数指数转换为 Unicode 上标字符,例如 9 -> '⁹', -3 -> '⁻³'
  function toSuperscript(n) {
    const map = { '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '-': '⁻' }
    const s = String(n)
    return s.split('').map(ch => map[ch] || ch).join('')
  }
  // 可循环使用的视觉样式(颜色/符号/线型)
  // 7 种颜色从红到紫,重复使用
  const colors = ['#1F77B4', '#ff7f0e', '#2CA02C', '#D62728', '#9467BD', '#8C564B', '#E377C2', '#7F7F7F', '#BCBD22', '#17BECF']
  const lineStyles = ['solid', 'solid', 'solid', 'solid', 'dash', 'solid', 'solid']

  // 先构建不带颜色/符号的基础 series(rawSeries),后面会在排序后按顺序分配颜色和符号
  const rawSeries = series.map(s => {
    const baseSize = Number(s.symbolSize) || 8
    return Object.assign({}, s, { symbolSize: baseSize, hoverAnimation: true })
  })

  // 构建 legend.selected 映射:只有 props.autoShowSeries 中的系列会默认显示,其他需手动点击 legend 打开
  const legendSelected = {}
  if (props.autoShowSeries.includes("*")) {
    rawSeries.forEach(s => {
      const name = String(s.name)
      legendSelected[name] = true
    })
  }
  else {
    rawSeries.forEach(s => {
      const name = String(s.name)
      legendSelected[name] = Array.isArray(props.autoShowSeries) && props.autoShowSeries.includes(name)
    })
  }


  // 按 modelTypeMapping 的定义顺序对 series 排序(使用来自 useLeaderboardData 的 modelTypeGroups)
  const preferredOrder = (modelTypeGroups && Array.isArray(modelTypeGroups.value)) ? modelTypeGroups.value : []
  const orderIndex = {}
  preferredOrder.forEach((n, i) => { orderIndex[String(n)] = i })
  // console.log('Preferred series order:', preferredOrder)
  const inOrder = []
  const rest = []
  rawSeries.forEach(s => {
    const name = String(s.name)
    if (Object.prototype.hasOwnProperty.call(orderIndex, name)) {
      inOrder.push(s)
    } else {
      rest.push(s)
    }
  })
  // 按映射顺序排序 inOrder
  inOrder.sort((a, b) => orderIndex[String(a.name)] - orderIndex[String(b.name)])
  // 其余的按名字字母序 (目前全部指定映射了)
  rest.sort((a, b) => String(a.name).localeCompare(String(b.name)))

  // console.log('Final series order:', inOrder.map(s => s.name), rest.map(s => s.name))

  const finalSeries = [...inOrder, ...rest]

  // 现在按 finalSeries 顺序分配颜色和符号,保证颜色顺序与 modelTypeMapping 一致
  const finalSeriesStyled = finalSeries.map((s, idx) => {
    const color = colors[idx % colors.length]
    // 如果 series 名称包含 (MoE) 则使用星形
    const isMoESeries = String(s.name).includes('(MoE)')
    const symbolShape = strtSymbolSeries.includes(String(s.name)) ? 
    'path://M341.5 45.1C337.4 37.1 329.1 32 320.1 32C311.1 32 302.8 37.1 298.7 45.1L225.1 189.3L65.2 214.7C56.3 216.1 48.9 222.4 46.1 231C43.3 239.6 45.6 249 51.9 255.4L166.3 369.9L141.1 529.8C139.7 538.7 143.4 547.7 150.7 553C158 558.3 167.6 559.1 175.7 555L320.1 481.6L464.4 555C472.4 559.1 482.1 558.3 489.4 553C496.7 547.7 500.4 538.8 499 529.8L473.7 369.9L588.1 255.4C594.5 249 596.7 239.6 593.9 231C591.1 222.4 583.8 216.1 574.8 214.7L415 189.3L341.5 45.1z'
    : 'circle'
    const lineType = symbolShape !== 'circle' ? 'dashed' : (lineStyles[idx % lineStyles.length] || 'solid')
    // console.log(`Series "${s.name}" uses symbol "${symbolShape}"`)
    const baseSize = Number(s.symbolSize) || 8
    const emphasisSize = Math.max(12, Math.round(baseSize * 1.6))
    // 如果是星形,增大尺寸以匹配视觉效果
    const adjustedBaseSize = symbolShape !== 'circle' ? baseSize * 1.5 : baseSize
    const adjustedEmphasisSize = symbolShape !== 'circle' ? emphasisSize * 1.5 : emphasisSize
    return Object.assign({}, s, {
      lineStyle: { width: 2, type: lineType, color },
      itemStyle: {
        borderColor: '#f3f3f3', // 高亮时拐点边框颜色
        borderWidth: symbolShape !== 'circle' ? 1 : 0, // 拐点边框宽度
        borderCap: 'round',
        borderRadius: 1,
        shadowBlur: 0, // 阴影大小
        shadowColor: '#45DB76'
      }, // 阴影颜色 },
      symbol: symbolShape,
      symbolSize: adjustedBaseSize,
      hoverAnimation: true,
      emphasis: {
        focus: 'series',
        symbolSize: adjustedEmphasisSize,
        itemStyle: { borderWidth: 2, borderColor: '#ffffff' }
      }
    })
  })
  const legendData = finalSeriesStyled.map(s => String(s.name))

  return {
    color: colors,
    tooltip: {
      trigger: 'item',
      formatter: params => {
        if (!params) return ''
        const x = params.data && Array.isArray(params.data) ? params.data[0] : ''
        const y = params.data && Array.isArray(params.data) ? params.data[1] : ''
        // 显示 X 为 mantissa×10^n,并把指数用上标显示
        let xStr = ''
        if (typeof x === 'number' && Number.isFinite(x) && x > 0) {
          const exp = Math.floor(Math.log10(x))
          const mant = x / Math.pow(10, exp)
          const mantStr = mant >= 10 ? mant.toFixed(0) : mant.toFixed(2)
          xStr = `${mantStr}×10${toSuperscript(exp)}`
        } else {
          xStr = String(x)
        }
        const yStr = (typeof y === 'number' && Number.isFinite(y)) ? Number(y).toFixed(4) : String(y)
        return `${params.seriesName}<br/>FLOPs: ${xStr}<br/>IC: ${yStr}`
      }
    },
    legend: Object.assign({
      orient: 'vertical',
      right: 10,
      top: '10%',
      align: 'left',
      itemWidth: 14,
      itemHeight: 10,
      textStyle: { color: legendColor },
      tooltip: { show: true }
    }, { selected: legendSelected, data: legendData }),
    grid: { left: '6%', right: '20%', bottom: '8%', containLabel: true },
    xAxis: {
      type: 'log',
      name: 'FLOPs',
      nameLocation: 'middle',
      nameGap: 30,
      axisLine: { lineStyle: { color: axisLineColor } },
      axisLabel: {
        color: axisLabelColor,
        formatter: value => {
          if (!Number.isFinite(value) || value <= 0) return String(value)
          const exp = Math.floor(Math.log10(value))
          const mant = value / Math.pow(10, exp)
          if (Math.abs(mant - 1) < 1e-6) return `10${toSuperscript(exp)}`
          return `${mant.toFixed(2)}×10${toSuperscript(exp)}`
        }
      },
      splitLine: { show: true, lineStyle: { type: 'dashed', color: splitLineColor } },
      min: xMin,
      max: xMax
    },
    yAxis: {
      type: 'value',
      name: 'Information capacity',
      axisLine: { lineStyle: { color: axisLineColor } },
      axisLabel: { color: axisLabelColor, formatter: v => (Number.isFinite(v) ? Number(v).toFixed(2) : String(v)) },
      splitLine: { show: true, lineStyle: { type: 'dashed', color: splitLineColor } },
      min: yMin,
      max: yMax
    },
    series: finalSeriesStyled
  }
}

let resizeHandler = null

function renderChart() {
  if (!chart) return
  let rows = leaderboard.value || []
  // 根据 selectedDataNameChart 过滤数据集(如果未选择或为 'all' 则使用全部)
  const sel = selectedDataNameChart && selectedDataNameChart.value ? String(selectedDataNameChart.value) : ''
  if (sel && sel !== 'all') {
    rows = rows.filter(r => String(r.data_name ?? '') === sel)
  }
  const series = buildSeriesFromLeaderboard(rows)
  const option = getChartOption(series, isDarkChart.value)
  chart.setOption(option, { notMerge: true })
}

onMounted(() => {
  isDarkChart.value = window.matchMedia("(prefers-color-scheme: dark)").matches  //true | false

  if (!chartRef.value) return
  chart = echarts.init(chartRef.value)
  // 首次渲染(如果数据尚未加载,后续会由 watcher 更新)
  renderChart()

  resizeHandler = () => chart && chart.resize()
  window.addEventListener('resize', resizeHandler)
})

// 当 leaderboard 变化时重新渲染(不受 visibleColumns 影响)
watch(leaderboard, () => {
  renderChart()
}, { deep: true })

// 当所选数据集变化时也要重新渲染图表
watch(selectedDataNameChart, () => {
  renderChart()
})

// 当主题变化时重新渲染图表
watch(isDarkChart, (newVal) => {
  console.log('isDarkChart changed to:', newVal)
  renderChart()
})

onBeforeUnmount(() => {
  if (resizeHandler) window.removeEventListener('resize', resizeHandler)
  if (themeObserver) themeObserver.disconnect()
  if (chart) { chart.dispose(); chart = null }
})
</script>

<template>
  <div ref="chartRef" class="w-2/5 h-[80vh] mx-auto"></div>
</template>

<style scoped></style>