西藏巴青项目

chartOptions.js 39KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482
  1. /** 大屏首页 ECharts 配置(深色主题,与 screen-theme 一致) */
  2. const AXIS_LABEL = { color: '#9fb0c3', fontSize: 10 }
  3. /** 12 个月横轴:不旋转,略缩小字号以适配窄图 */
  4. const MONTH_AXIS_LABEL = { color: '#9fb0c3', fontSize: 9, margin: 6 }
  5. const AXIS_LINE = { lineStyle: { color: 'rgba(61, 217, 176, 0.35)' } }
  6. const SPLIT_LINE = { lineStyle: { color: 'rgba(61, 217, 176, 0.12)' } }
  7. const GRID = { left: 22, right: 8, top: 24, bottom: 6, containLabel: true }
  8. function emptyOption(text = '暂无数据') {
  9. return {
  10. title: {
  11. text,
  12. left: 'center',
  13. top: 'middle',
  14. textStyle: { color: '#6a9f90', fontSize: 13, fontWeight: 'normal' }
  15. }
  16. }
  17. }
  18. function pickNumber(row, fields) {
  19. for (const key of fields) {
  20. const raw = row?.[key]
  21. if (raw != null && raw !== '') {
  22. const n = Number(raw)
  23. if (!Number.isNaN(n)) {
  24. return n
  25. }
  26. }
  27. }
  28. return 0
  29. }
  30. /** 滚动最近 12 月:按 statYear、month 升序 */
  31. function sortRollingMonths(list) {
  32. return [...(list || [])].sort((a, b) => {
  33. const ak = (Number(a.statYear) || 0) * 100 + (Number(a.month) || 0)
  34. const bk = (Number(b.statYear) || 0) * 100 + (Number(b.month) || 0)
  35. return ak - bk
  36. })
  37. }
  38. function rollingMonthLabel(row) {
  39. return `${Number(row.month)}月`
  40. }
  41. function rollingTooltipTitle(row) {
  42. if (!row) {
  43. return ''
  44. }
  45. return `${row.statYear}年${Number(row.month)}月`
  46. }
  47. function buildRollingAxis(list) {
  48. const rows = sortRollingMonths(list)
  49. return {
  50. rows,
  51. categories: rows.map(rollingMonthLabel),
  52. titleAt: (dataIndex) => rollingTooltipTitle(rows[dataIndex])
  53. }
  54. }
  55. function rollingSeries(list, field, altFields = []) {
  56. const fields = [field, ...altFields]
  57. return sortRollingMonths(list).map((row) => pickNumber(row, fields))
  58. }
  59. /** 牦牛存栏变动:公牛 / 母牛折线(滚动 12 月) */
  60. export function buildInventoryTrendOption(monthlyTrend) {
  61. if (!monthlyTrend?.length) {
  62. return emptyOption()
  63. }
  64. const axis = buildRollingAxis(monthlyTrend)
  65. return {
  66. color: ['#5ef0c8', '#ecd27b'],
  67. tooltip: {
  68. trigger: 'axis',
  69. formatter: (params) => {
  70. if (!Array.isArray(params) || !params.length) {
  71. return ''
  72. }
  73. const title = axis.titleAt(params[0].dataIndex)
  74. const lines = params.map((p) => `${p.marker}${p.seriesName} ${p.value} 头`)
  75. return `${title}<br/>${lines.join('<br/>')}`
  76. }
  77. },
  78. legend: {
  79. top: 0,
  80. right: 8,
  81. textStyle: { color: '#a8d4c8', fontSize: 10 },
  82. itemWidth: 12,
  83. itemHeight: 8
  84. },
  85. grid: GRID,
  86. xAxis: {
  87. type: 'category',
  88. data: axis.categories,
  89. axisLabel: { ...MONTH_AXIS_LABEL, interval: 0 },
  90. axisLine: AXIS_LINE,
  91. axisTick: { show: false }
  92. },
  93. yAxis: {
  94. type: 'value',
  95. axisLabel: AXIS_LABEL,
  96. axisLine: AXIS_LINE,
  97. splitLine: SPLIT_LINE
  98. },
  99. series: [
  100. {
  101. name: '公牛',
  102. type: 'line',
  103. smooth: true,
  104. symbol: 'circle',
  105. symbolSize: 4,
  106. data: rollingSeries(monthlyTrend, 'bullCount')
  107. },
  108. {
  109. name: '母牛',
  110. type: 'line',
  111. smooth: true,
  112. symbol: 'circle',
  113. symbolSize: 4,
  114. data: rollingSeries(monthlyTrend, 'cowCount')
  115. }
  116. ]
  117. }
  118. }
  119. /** 年龄四档配色(母牛分龄:1岁以下 / 1~3岁 / 3~12岁 / 12岁以上) */
  120. const AGE_BAND_COLOR = {
  121. COW_U1: '#45c997',
  122. COW_1_3: '#6eb5ff',
  123. COW_3_12: '#547233',
  124. COW_O12: '#ecd27b'
  125. }
  126. const AGE_BAND_ORDER = ['COW_U1', 'COW_1_3', 'COW_3_12', 'COW_O12']
  127. function sortAgeBands(ageStructure) {
  128. const map = new Map((ageStructure || []).map((item) => [item.bandCode, item]))
  129. const ordered = AGE_BAND_ORDER.map((code) => map.get(code)).filter(Boolean)
  130. return ordered.length ? ordered : [...(ageStructure || [])]
  131. }
  132. function formatAgeRatio(ratio, percent) {
  133. if (ratio != null && ratio !== '') {
  134. return `${Number(ratio).toFixed(0)}%`
  135. }
  136. return `${Math.round(percent)}%`
  137. }
  138. /** 牦牛年龄结构(环形图 + 右侧图例,固定四档) */
  139. export function buildAgeStructureOption(ageStructure) {
  140. if (!ageStructure?.length) {
  141. return emptyOption()
  142. }
  143. const bands = sortAgeBands(ageStructure)
  144. const data = bands.map((item) => ({
  145. name: item.bandLabel || item.bandCode,
  146. value: pickNumber(item, ['count']),
  147. ratio: item.ratio,
  148. itemStyle: {
  149. color: AGE_BAND_COLOR[item.bandCode] || '#45f0b8',
  150. shadowBlur: 8,
  151. shadowColor: 'rgba(0, 0, 0, 0.45)',
  152. shadowOffsetY: 2
  153. }
  154. }))
  155. const hasSlice = data.some((d) => d.value > 0)
  156. const centerGraphic = hasSlice
  157. ? []
  158. : [
  159. {
  160. type: 'text',
  161. left: '26%',
  162. top: '46%',
  163. style: {
  164. text: '暂无存栏',
  165. fill: '#6a9f90',
  166. fontSize: 11,
  167. textAlign: 'center'
  168. }
  169. }
  170. ]
  171. return {
  172. color: Object.values(AGE_BAND_COLOR),
  173. tooltip: {
  174. trigger: 'item',
  175. formatter: (p) => {
  176. const row = bands[p.dataIndex]
  177. const ratio =
  178. row?.ratio != null ? `${Number(row.ratio).toFixed(1)}%` : `${p.percent?.toFixed(1)}%`
  179. return `${p.name}<br/>${p.value} 头 (${ratio})`
  180. }
  181. },
  182. legend: {
  183. orient: 'vertical',
  184. right: '2%',
  185. top: 'middle',
  186. icon: 'circle',
  187. itemWidth: 8,
  188. itemHeight: 8,
  189. itemGap: 10,
  190. textStyle: { color: '#e8eef5', fontSize: 10 }
  191. },
  192. graphic: [
  193. {
  194. type: 'circle',
  195. left: '22%',
  196. top: '38%',
  197. shape: { r: 52 },
  198. style: {
  199. fill: 'transparent',
  200. stroke: 'rgba(61, 217, 176, 0.22)',
  201. lineWidth: 1
  202. },
  203. z: 0
  204. },
  205. {
  206. type: 'circle',
  207. left: '20%',
  208. top: '36%',
  209. shape: { r: 58 },
  210. style: {
  211. fill: 'transparent',
  212. stroke: 'rgba(61, 217, 176, 0.12)',
  213. lineWidth: 1
  214. },
  215. z: 0
  216. },
  217. ...centerGraphic
  218. ],
  219. series: [
  220. {
  221. type: 'pie',
  222. radius: ['42%', '68%'],
  223. center: ['34%', '54%'],
  224. avoidLabelOverlap: true,
  225. stillShowZeroSum: false,
  226. itemStyle: {
  227. borderColor: 'rgba(2, 22, 18, 0.9)',
  228. borderWidth: 2
  229. },
  230. label: {
  231. show: hasSlice,
  232. position: 'inside',
  233. color: '#fff',
  234. fontSize: 10,
  235. fontWeight: 'bold',
  236. formatter: (p) => formatAgeRatio(p.data.ratio, p.percent)
  237. },
  238. labelLine: { show: false },
  239. emphasis: {
  240. scale: true,
  241. scaleSize: 5
  242. },
  243. data: hasSlice ? data : []
  244. }
  245. ]
  246. }
  247. }
  248. /** 牦牛存栏结构 — 内环大类配色 */
  249. const YAK_CATEGORY_COLOR = {
  250. BULL: '#5ef0c8',
  251. COW: '#6eb5ff',
  252. INSURED: '#ecd27b'
  253. }
  254. /** 牦牛存栏结构 — 外环细分配色(对齐科技特派员双层饼风格) */
  255. const YAK_DETAIL_COLOR = {
  256. BREEDING_BULL: '#5ef0c8',
  257. BULL_CASTRATED: '#3b9e84',
  258. COW_YOUNG: '#6eb5ff',
  259. COW_ADULT: '#98e9aa',
  260. INSURED: '#ecd27b'
  261. }
  262. /**
  263. * 牦牛存栏结构(内外双层饼,样式对齐畜牧资源「科技特派员」):
  264. * 内环大类 BULL/COW/INSURED,外环 details 细分。
  265. * @param {Array} structure yakInventoryStructure[]
  266. */
  267. export function buildYakInventoryStructureOption(structure) {
  268. if (!structure?.length) {
  269. return emptyOption()
  270. }
  271. const categoryTotal = structure.reduce((sum, item) => sum + pickNumber(item, ['count']), 0)
  272. const detailRows = []
  273. for (const cat of structure) {
  274. const categoryLabel = cat?.categoryLabel || cat?.categoryCode || ''
  275. const details = cat?.details
  276. if (details?.length) {
  277. for (const d of details) {
  278. detailRows.push({
  279. ...d,
  280. categoryLabel
  281. })
  282. }
  283. } else if (cat) {
  284. detailRows.push({
  285. detailCode: cat.categoryCode,
  286. detailLabel: cat.categoryLabel,
  287. count: cat.count,
  288. ratio: cat.ratio,
  289. categoryLabel
  290. })
  291. }
  292. }
  293. const detailTotal = detailRows.reduce((sum, item) => sum + pickNumber(item, ['count']), 0)
  294. if (categoryTotal <= 0 && detailTotal <= 0) {
  295. return emptyOption()
  296. }
  297. const fallbackColors = ['#5ef0c8', '#6eb5ff', '#ecd27b', '#98e9aa', '#3b9e84']
  298. const innerData = structure.map((item, i) => {
  299. const code = item.categoryCode || ''
  300. const color = YAK_CATEGORY_COLOR[code] || fallbackColors[i % fallbackColors.length]
  301. return {
  302. name: item.categoryLabel || code || '未知',
  303. value: pickNumber(item, ['count']),
  304. ratio: item.ratio,
  305. itemStyle: { color }
  306. }
  307. })
  308. const outerData = detailRows.map((item, i) => {
  309. const code = item.detailCode || ''
  310. const color = YAK_DETAIL_COLOR[code] || fallbackColors[i % fallbackColors.length]
  311. return {
  312. name: item.detailLabel || code || '未知',
  313. value: pickNumber(item, ['count']),
  314. ratio: item.ratio,
  315. categoryLabel: item.categoryLabel || '',
  316. itemStyle: { color },
  317. label: { color },
  318. labelLine: { lineStyle: { color } }
  319. }
  320. })
  321. const pieBorder = {
  322. borderColor: 'rgba(8, 48, 42, 0.88)',
  323. borderWidth: 2
  324. }
  325. return {
  326. title: { show: false },
  327. tooltip: {
  328. trigger: 'item',
  329. formatter: (p) => {
  330. const ratio =
  331. p.data?.ratio != null ? `${Number(p.data.ratio).toFixed(1)}%` : `${p.percent?.toFixed(1)}%`
  332. const seriesName =
  333. p.seriesName === '存栏细分' && p.data?.categoryLabel
  334. ? `${p.seriesName}(${p.data.categoryLabel})`
  335. : p.seriesName
  336. return `${seriesName}<br/>${p.marker}${p.name}: ${p.value ?? 0} 头 (${ratio})`
  337. }
  338. },
  339. legend: {
  340. show: false
  341. },
  342. series: [
  343. // 外环:细分(种公牛 / 阉畜 / 幼畜 / 成畜 / 保险)
  344. {
  345. name: '存栏细分',
  346. type: 'pie',
  347. radius: ['58%', '74%'],
  348. center: ['50%', '52%'],
  349. avoidLabelOverlap: true,
  350. itemStyle: pieBorder,
  351. label: {
  352. show: true,
  353. fontSize: 11,
  354. formatter: (p) => p.name
  355. },
  356. labelLine: {
  357. show: true,
  358. length: 10,
  359. length2: 12
  360. },
  361. data: outerData
  362. },
  363. // 内饼:公牛 / 母牛 / 保险牛
  364. {
  365. name: '存栏大类',
  366. type: 'pie',
  367. radius: [0, '46%'],
  368. center: ['50%', '52%'],
  369. avoidLabelOverlap: true,
  370. itemStyle: pieBorder,
  371. label: {
  372. show: true,
  373. position: 'inside',
  374. color: '#fff',
  375. fontSize: 12,
  376. fontWeight: 600,
  377. formatter: (p) => p.name
  378. },
  379. labelLine: { show: false },
  380. data: innerData
  381. }
  382. ]
  383. }
  384. }
  385. /** 出栏柱图金色渐变 */
  386. const OUTBOUND_BAR_GRADIENT = {
  387. type: 'linear',
  388. x: 0,
  389. y: 1,
  390. x2: 0,
  391. y2: 0,
  392. colorStops: [
  393. { offset: 0, color: '#6b4f18' },
  394. { offset: 0.45, color: '#b8923a' },
  395. { offset: 1, color: '#f7d547' }
  396. ]
  397. }
  398. const OUTBOUND_GRID = { left: 36, right: 8, top: 28, bottom: 6, containLabel: true }
  399. const OUTBOUND_SPLIT_LINE = {
  400. lineStyle: { color: 'rgba(120, 140, 160, 0.28)', type: 'dashed' }
  401. }
  402. /** 牦牛出栏按月(纯柱状图,滚动 12 月) */
  403. export function buildOutboundOption(outboundMonthly) {
  404. if (!outboundMonthly?.length) {
  405. return emptyOption()
  406. }
  407. const axis = buildRollingAxis(outboundMonthly)
  408. const values = rollingSeries(outboundMonthly, 'outboundCount', ['outbound_count', 'count'])
  409. return {
  410. tooltip: {
  411. trigger: 'axis',
  412. axisPointer: { type: 'shadow' },
  413. formatter: (params) => {
  414. const p = Array.isArray(params) ? params[0] : params
  415. if (!p) {
  416. return ''
  417. }
  418. const title = axis.titleAt(p.dataIndex)
  419. return `${title}<br/>出栏 ${p.value} 头`
  420. }
  421. },
  422. grid: OUTBOUND_GRID,
  423. xAxis: {
  424. type: 'category',
  425. data: axis.categories,
  426. axisLabel: { ...MONTH_AXIS_LABEL, interval: 0 },
  427. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  428. axisTick: { show: false }
  429. },
  430. yAxis: {
  431. type: 'value',
  432. minInterval: 1,
  433. axisLabel: AXIS_LABEL,
  434. axisLine: { show: false },
  435. axisTick: { show: false },
  436. splitLine: OUTBOUND_SPLIT_LINE
  437. },
  438. series: [
  439. {
  440. name: '出栏',
  441. type: 'bar',
  442. barWidth: '46%',
  443. itemStyle: {
  444. color: OUTBOUND_BAR_GRADIENT,
  445. borderRadius: [10, 10, 10, 10]
  446. },
  447. data: values
  448. }
  449. ]
  450. }
  451. }
  452. /** 牲畜出栏六畜配色(偏暗,保证白色文字可读) */
  453. const LIVESTOCK_OUTBOUND_COLOR = {
  454. YAK: '#2a9d7a',
  455. CATTLE: '#b8923a',
  456. SHEEP: '#3d7ab8',
  457. GOAT: '#2f4f9e',
  458. HORSE_MULE: '#c47a1a',
  459. DONKEY: '#2f8a6a'
  460. }
  461. /** 树图过小扇区的布局下限:至少占总量约 4%,保证 1 头也能看见 */
  462. const LIVESTOCK_TREEMAP_MIN_SHARE = 0.04
  463. /**
  464. * 牲畜出栏统计(矩形树图):livestockOutboundStructure[]
  465. * 项含 speciesCode / speciesLabel / count / ratio / unit
  466. */
  467. export function buildLivestockOutboundStructureOption(structure) {
  468. if (!structure?.length) {
  469. return emptyOption()
  470. }
  471. const rows = structure
  472. .map((item, i) => {
  473. const code = item.speciesCode || ''
  474. const count = pickNumber(item, ['count'])
  475. return {
  476. name: item.speciesLabel || code || '未知',
  477. realCount: count,
  478. ratio: item.ratio,
  479. unit: item.unit || '头',
  480. itemStyle: {
  481. color: LIVESTOCK_OUTBOUND_COLOR[code] || Object.values(LIVESTOCK_OUTBOUND_COLOR)[i % 6],
  482. borderColor: 'rgba(2, 22, 18, 0.85)',
  483. borderWidth: 2,
  484. borderRadius: 4
  485. }
  486. }
  487. })
  488. .filter((d) => d.realCount > 0)
  489. if (!rows.length) {
  490. return emptyOption()
  491. }
  492. const total = rows.reduce((sum, d) => sum + d.realCount, 0)
  493. const minVisual = Math.max(total * LIVESTOCK_TREEMAP_MIN_SHARE, 1)
  494. const data = rows.map((d) => ({
  495. ...d,
  496. value: Math.max(d.realCount, minVisual)
  497. }))
  498. return {
  499. tooltip: {
  500. trigger: 'item',
  501. formatter: (p) => {
  502. const unit = p.data?.unit || '头'
  503. const count = p.data?.realCount ?? p.value
  504. const ratio =
  505. p.data?.ratio != null
  506. ? `${Number(p.data.ratio).toFixed(1)}%`
  507. : total > 0
  508. ? `${((Number(count) / total) * 100).toFixed(1)}%`
  509. : '-'
  510. return `${p.name}<br/>${count}${unit} (${ratio})`
  511. }
  512. },
  513. series: [
  514. {
  515. type: 'treemap',
  516. width: '96%',
  517. height: '92%',
  518. left: '2%',
  519. top: '4%',
  520. roam: false,
  521. nodeClick: false,
  522. breadcrumb: { show: false },
  523. squareRatio: 0.75 * (1 + Math.sqrt(5)),
  524. label: {
  525. show: true,
  526. formatter: (p) => {
  527. const unit = p.data?.unit || '头'
  528. const count = p.data?.realCount ?? p.value
  529. const ratioText =
  530. p.data?.ratio != null
  531. ? `${Number(p.data.ratio).toFixed(0)}%`
  532. : ''
  533. return ratioText
  534. ? `{name|${p.name}}\n{val|${count}${unit}}\n{pct|${ratioText}}`
  535. : `{name|${p.name}}\n{val|${count}${unit}}`
  536. },
  537. rich: {
  538. name: { color: '#ffffff', fontSize: 11, fontWeight: 600, lineHeight: 16 },
  539. val: { color: 'rgba(255,255,255,0.92)', fontSize: 10, lineHeight: 14 },
  540. pct: { color: 'rgba(232,238,245,0.85)', fontSize: 10, lineHeight: 14 }
  541. }
  542. },
  543. upperLabel: { show: false },
  544. itemStyle: {
  545. gapWidth: 3,
  546. borderColor: 'rgba(2, 22, 18, 0.9)'
  547. },
  548. emphasis: {
  549. itemStyle: {
  550. shadowBlur: 12,
  551. shadowColor: 'rgba(0, 0, 0, 0.45)'
  552. }
  553. },
  554. levels: [
  555. {
  556. itemStyle: {
  557. borderWidth: 0,
  558. gapWidth: 3
  559. }
  560. }
  561. ],
  562. data
  563. }
  564. ]
  565. }
  566. }
  567. /** 自食情况柱图:上绿下蓝渐变,顶部圆角 */
  568. const SELF_CONSUMPTION_BAR_GRADIENT = {
  569. type: 'linear',
  570. x: 0,
  571. y: 0,
  572. x2: 0,
  573. y2: 1,
  574. colorStops: [
  575. { offset: 0, color: '#3ecf9a' },
  576. { offset: 0.45, color: '#2a9d9a' },
  577. { offset: 1, color: '#2f5fad' }
  578. ]
  579. }
  580. /**
  581. * 自食情况统计(柱状图):selfConsumption.items[]
  582. * 项含 itemCode / itemLabel / count,单位:头
  583. */
  584. export function buildSelfConsumptionOption(selfConsumption) {
  585. const items = selfConsumption?.items
  586. if (!items?.length) {
  587. return emptyOption()
  588. }
  589. const categories = items.map((item) => item.itemLabel || item.itemCode || '')
  590. const values = items.map((item) => pickNumber(item, ['count']))
  591. if (!values.some((v) => v > 0)) {
  592. return emptyOption()
  593. }
  594. return {
  595. tooltip: {
  596. trigger: 'axis',
  597. axisPointer: { type: 'shadow' },
  598. formatter: (params) => {
  599. const p = Array.isArray(params) ? params[0] : params
  600. if (!p) {
  601. return ''
  602. }
  603. return `${p.name}<br/>自食 ${p.value} 头`
  604. }
  605. },
  606. grid: { left: 36, right: 10, top: 28, bottom: 8, containLabel: true },
  607. xAxis: {
  608. type: 'category',
  609. data: categories,
  610. axisLabel: { color: '#9fb0c3', fontSize: 11 },
  611. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  612. axisTick: { show: false }
  613. },
  614. yAxis: {
  615. type: 'value',
  616. minInterval: 1,
  617. name: '头',
  618. nameTextStyle: { color: '#9fb0c3', fontSize: 10, padding: [0, 28, 0, 0] },
  619. axisLabel: AXIS_LABEL,
  620. axisLine: { show: false },
  621. axisTick: { show: false },
  622. splitLine: {
  623. lineStyle: { color: 'rgba(120, 140, 160, 0.28)', type: 'dashed' }
  624. }
  625. },
  626. series: [
  627. {
  628. name: '自食',
  629. type: 'bar',
  630. barWidth: '28%',
  631. itemStyle: {
  632. color: SELF_CONSUMPTION_BAR_GRADIENT,
  633. borderRadius: [10, 10, 0, 0]
  634. },
  635. label: {
  636. show: true,
  637. position: 'top',
  638. color: '#c8e6d8',
  639. fontSize: 10,
  640. formatter: '{c}'
  641. },
  642. data: values
  643. }
  644. ]
  645. }
  646. }
  647. /** 农牧户分类柱图:上金下青渐变,顶部圆角(区别于自食绿蓝) */
  648. const FARMER_CLASS_BAR_GRADIENT = {
  649. type: 'linear',
  650. x: 0,
  651. y: 0,
  652. x2: 0,
  653. y2: 1,
  654. colorStops: [
  655. { offset: 0, color: '#e0b84a' },
  656. { offset: 0.45, color: '#3aa8a0' },
  657. { offset: 1, color: '#1e6a9e' }
  658. ]
  659. }
  660. /**
  661. * 农牧户分类(柱状图):farmerHouseholdClassification[]
  662. * 项含 itemCode / itemLabel / count,单位:个
  663. */
  664. export function buildFarmerHouseholdClassificationOption(structure) {
  665. if (!structure?.length) {
  666. return emptyOption()
  667. }
  668. const categories = structure.map((item) => item.itemLabel || item.itemCode || '')
  669. const values = structure.map((item) => pickNumber(item, ['count']))
  670. if (!values.some((v) => v > 0)) {
  671. return emptyOption()
  672. }
  673. return {
  674. tooltip: {
  675. trigger: 'axis',
  676. axisPointer: { type: 'shadow' },
  677. formatter: (params) => {
  678. const p = Array.isArray(params) ? params[0] : params
  679. if (!p) {
  680. return ''
  681. }
  682. return `${p.name}<br/>${p.value} 个`
  683. }
  684. },
  685. grid: { left: 28, right: 8, top: 28, bottom: 8, containLabel: true },
  686. xAxis: {
  687. type: 'category',
  688. data: categories,
  689. axisLabel: {
  690. color: '#9fb0c3',
  691. fontSize: 9,
  692. interval: 0,
  693. hideOverlap: false
  694. },
  695. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  696. axisTick: { show: false }
  697. },
  698. yAxis: {
  699. type: 'value',
  700. minInterval: 1,
  701. name: '个',
  702. nameTextStyle: { color: '#9fb0c3', fontSize: 10, padding: [0, 28, 0, 0] },
  703. axisLabel: AXIS_LABEL,
  704. axisLine: { show: false },
  705. axisTick: { show: false },
  706. splitLine: {
  707. lineStyle: { color: 'rgba(120, 140, 160, 0.28)', type: 'dashed' }
  708. }
  709. },
  710. series: [
  711. {
  712. name: '补助奖励资金',
  713. type: 'bar',
  714. barWidth: '36%',
  715. itemStyle: {
  716. color: FARMER_CLASS_BAR_GRADIENT,
  717. borderRadius: [10, 10, 0, 0]
  718. },
  719. label: {
  720. show: true,
  721. position: 'top',
  722. color: '#d8e8f0',
  723. fontSize: 9,
  724. formatter: '{c}'
  725. },
  726. data: values
  727. }
  728. ]
  729. }
  730. }
  731. /** 企业财务柱图配色(偏暗,白字可读) */
  732. const ENTERPRISE_FINANCE_BAR_COLOR = {
  733. INCOME: '#2a9d7a',
  734. EXPENSE: '#3d7ab8',
  735. PROFIT: '#b8923a',
  736. SUBSIDY: '#2f5fad'
  737. }
  738. /**
  739. * 企业年度收入与补助(柱状图):familyRanchFinance / largeLivestockFarmerFinance
  740. * items[] 含 itemCode / itemLabel / amountWan,单位:万元
  741. * @param {object|null} finance
  742. * @param {string} axisName 横轴下方名称(家庭牧场 / 养殖大户)
  743. */
  744. export function buildEnterpriseFinanceOption(finance, axisName) {
  745. const items = finance?.items
  746. if (!items?.length) {
  747. return emptyOption()
  748. }
  749. const categories = items.map((item) => item.itemLabel || item.itemCode || '')
  750. const data = items.map((item, i) => {
  751. const code = item.itemCode || ''
  752. return {
  753. value: pickNumber(item, ['amountWan', 'amount_wan', 'amount']),
  754. itemStyle: {
  755. color:
  756. ENTERPRISE_FINANCE_BAR_COLOR[code] ||
  757. Object.values(ENTERPRISE_FINANCE_BAR_COLOR)[i % 4],
  758. borderRadius: [6, 6, 0, 0]
  759. }
  760. }
  761. })
  762. return {
  763. title: {
  764. text: axisName || '',
  765. left: 'center',
  766. top: 0,
  767. textStyle: { color: '#89D9AD', fontSize: 11, fontWeight: 600 }
  768. },
  769. tooltip: {
  770. trigger: 'axis',
  771. axisPointer: { type: 'shadow' },
  772. formatter: (params) => {
  773. const p = Array.isArray(params) ? params[0] : params
  774. if (!p) {
  775. return ''
  776. }
  777. const prefix = axisName ? `${axisName}<br/>` : ''
  778. return `${prefix}${p.name}:${p.value} 万元`
  779. }
  780. },
  781. grid: { left: 8, right: 8, top: 28, bottom: 8, containLabel: true },
  782. xAxis: {
  783. type: 'category',
  784. data: categories,
  785. axisLabel: {
  786. color: '#9fb0c3',
  787. fontSize: 9,
  788. interval: 0,
  789. hideOverlap: false
  790. },
  791. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  792. axisTick: { show: false }
  793. },
  794. yAxis: {
  795. type: 'value',
  796. name: '万元',
  797. nameTextStyle: { color: '#9fb0c3', fontSize: 9, padding: [0, 0, 0, 0] },
  798. axisLabel: { color: '#9fb0c3', fontSize: 9 },
  799. axisLine: { show: false },
  800. axisTick: { show: false },
  801. splitLine: {
  802. lineStyle: { color: 'rgba(120, 140, 160, 0.28)', type: 'dashed' }
  803. }
  804. },
  805. series: [
  806. {
  807. name: axisName || '金额',
  808. type: 'bar',
  809. barWidth: '36%',
  810. data
  811. }
  812. ]
  813. }
  814. }
  815. /** 合作社经营模式配色(偏暗) */
  816. const COOP_MODEL_COLOR = {
  817. WHOLE_VILLAGE: '#2a9d7a',
  818. SHARE: '#3d7ab8',
  819. JOINT: '#b8923a',
  820. MULTI_BUSINESS: '#2f5fad',
  821. COMPANY_TYPE: '#5a8a4a',
  822. OTHER: '#6a7a8a'
  823. }
  824. /**
  825. * 合作社经营模式分类(饼图):cooperativeBusinessModel[]
  826. * 项含 itemCode / itemLabel / count / ratio,单位:个
  827. */
  828. export function buildCooperativeBusinessModelOption(structure) {
  829. if (!structure?.length) {
  830. return emptyOption()
  831. }
  832. const hasData = structure.some((item) => pickNumber(item, ['count']) > 0)
  833. if (!hasData) {
  834. return emptyOption()
  835. }
  836. const data = structure.map((item, i) => {
  837. const code = item.itemCode || ''
  838. return {
  839. name: item.itemLabel || code || '未知',
  840. value: pickNumber(item, ['count']),
  841. ratio: item.ratio,
  842. itemStyle: {
  843. color: COOP_MODEL_COLOR[code] || Object.values(COOP_MODEL_COLOR)[i % 6],
  844. shadowBlur: 8,
  845. shadowColor: 'rgba(0, 0, 0, 0.4)',
  846. shadowOffsetY: 2
  847. }
  848. }
  849. })
  850. return {
  851. color: Object.values(COOP_MODEL_COLOR),
  852. tooltip: {
  853. trigger: 'item',
  854. formatter: (p) => {
  855. const row = structure[p.dataIndex]
  856. const ratio =
  857. row?.ratio != null ? `${Number(row.ratio).toFixed(1)}%` : `${p.percent?.toFixed(1)}%`
  858. return `${p.name}<br/>${p.value} 个 (${ratio})`
  859. }
  860. },
  861. legend: {
  862. orient: 'vertical',
  863. right: '1%',
  864. top: 'middle',
  865. icon: 'circle',
  866. itemWidth: 8,
  867. itemHeight: 8,
  868. itemGap: 6,
  869. textStyle: { color: '#e8eef5', fontSize: 9 }
  870. },
  871. series: [
  872. {
  873. type: 'pie',
  874. radius: '64%',
  875. center: ['32%', '52%'],
  876. avoidLabelOverlap: true,
  877. itemStyle: {
  878. borderColor: 'rgba(2, 22, 18, 0.9)',
  879. borderWidth: 2
  880. },
  881. label: {
  882. show: true,
  883. position: 'inside',
  884. color: '#fff',
  885. fontSize: 9,
  886. fontWeight: 'bold',
  887. formatter: (p) => {
  888. if (p.data.ratio != null && p.data.ratio !== '') {
  889. return `${Number(p.data.ratio).toFixed(0)}%`
  890. }
  891. return `${Math.round(p.percent)}%`
  892. }
  893. },
  894. labelLine: { show: false },
  895. emphasis: {
  896. scale: true,
  897. scaleSize: 4,
  898. itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0, 0, 0, 0.5)' }
  899. },
  900. data
  901. }
  902. ]
  903. }
  904. }
  905. /** 草场退化五档配色 */
  906. const GRASSLAND_DEGRADATION_COLOR = {
  907. 1: '#3b66d1',
  908. 2: '#5dc3a1',
  909. 3: '#547233',
  910. 4: '#f7b538',
  911. 5: '#3e5388'
  912. }
  913. /** 畜群存栏结构物种配色 */
  914. const HERD_SPECIES_COLOR = {
  915. YAK: '#5ef0c8',
  916. DAIRY: '#ecd27b',
  917. SHEEP: '#3b66d1'
  918. }
  919. function formatGrasslandRatio(ratio, percent) {
  920. if (ratio != null && ratio !== '') {
  921. return `${Number(ratio).toFixed(0)}%`
  922. }
  923. return `${Math.round(percent)}%`
  924. }
  925. /**
  926. * 畜群存栏结构(环形饼图):herdInventoryStructure[]
  927. * 项含 speciesCode / speciesLabel / count / ratio
  928. */
  929. export function buildHerdInventoryStructureOption(structure) {
  930. if (!structure?.length) {
  931. return emptyOption()
  932. }
  933. const hasData = structure.some(
  934. (item) => pickNumber(item, ['count']) > 0 || pickNumber(item, ['ratio']) > 0
  935. )
  936. if (!hasData) {
  937. return emptyOption()
  938. }
  939. const data = structure.map((item) => {
  940. const code = item.speciesCode || ''
  941. return {
  942. name: item.speciesLabel || code || '未知',
  943. value: pickNumber(item, ['count']),
  944. ratio: item.ratio,
  945. itemStyle: {
  946. color: HERD_SPECIES_COLOR[code] || '#45f0b8',
  947. shadowBlur: 10,
  948. shadowColor: 'rgba(0, 0, 0, 0.5)',
  949. shadowOffsetY: 3
  950. }
  951. }
  952. })
  953. return {
  954. color: Object.values(HERD_SPECIES_COLOR),
  955. tooltip: {
  956. trigger: 'item',
  957. formatter: (p) => {
  958. const row = structure[p.dataIndex]
  959. const ratio =
  960. row?.ratio != null ? `${Number(row.ratio).toFixed(1)}%` : `${p.percent?.toFixed(1)}%`
  961. return `${p.name}<br/>${p.value}头 (${ratio})`
  962. }
  963. },
  964. legend: {
  965. orient: 'vertical',
  966. right: '2%',
  967. top: 'middle',
  968. icon: 'circle',
  969. itemWidth: 8,
  970. itemHeight: 8,
  971. itemGap: 10,
  972. textStyle: { color: '#e8eef5', fontSize: 10 }
  973. },
  974. series: [
  975. {
  976. type: 'pie',
  977. radius: ['42%', '68%'],
  978. center: ['34%', '54%'],
  979. avoidLabelOverlap: true,
  980. itemStyle: {
  981. borderColor: 'rgba(2, 22, 18, 0.9)',
  982. borderWidth: 2
  983. },
  984. label: {
  985. show: true,
  986. position: 'inside',
  987. color: '#fff',
  988. fontSize: 10,
  989. fontWeight: 'bold',
  990. formatter: (p) => formatGrasslandRatio(p.data.ratio, p.percent)
  991. },
  992. labelLine: { show: false },
  993. emphasis: {
  994. scale: true,
  995. scaleSize: 5,
  996. itemStyle: { shadowBlur: 14, shadowColor: 'rgba(0, 0, 0, 0.55)' }
  997. },
  998. data
  999. }
  1000. ]
  1001. }
  1002. }
  1003. /** 草场退化占比(环形图 + 右侧图例 + 扇区内占比) */
  1004. export function buildGrasslandOption(grassland) {
  1005. const degradationStats = grassland?.degradationStats
  1006. if (!degradationStats?.length) {
  1007. return emptyOption()
  1008. }
  1009. const hasData = degradationStats.some(
  1010. (item) => pickNumber(item, ['count']) > 0 || pickNumber(item, ['ratio']) > 0
  1011. )
  1012. if (!hasData) {
  1013. return emptyOption()
  1014. }
  1015. const data = degradationStats.map((item) => ({
  1016. name: item.degradationLabel || `等级${item.degradationLevel}`,
  1017. value: pickNumber(item, ['count']),
  1018. ratio: item.ratio,
  1019. itemStyle: {
  1020. color: GRASSLAND_DEGRADATION_COLOR[item.degradationLevel] || '#45f0b8',
  1021. shadowBlur: 10,
  1022. shadowColor: 'rgba(0, 0, 0, 0.5)',
  1023. shadowOffsetY: 3
  1024. }
  1025. }))
  1026. return {
  1027. color: Object.values(GRASSLAND_DEGRADATION_COLOR),
  1028. tooltip: {
  1029. trigger: 'item',
  1030. formatter: (p) => {
  1031. const row = degradationStats[p.dataIndex]
  1032. const ratio =
  1033. row?.ratio != null ? `${Number(row.ratio).toFixed(1)}%` : `${p.percent?.toFixed(1)}%`
  1034. return `${p.name}<br/>${p.value}个 (${ratio})`
  1035. }
  1036. },
  1037. legend: {
  1038. orient: 'vertical',
  1039. right: '2%',
  1040. top: 'middle',
  1041. icon: 'circle',
  1042. itemWidth: 8,
  1043. itemHeight: 8,
  1044. itemGap: 10,
  1045. textStyle: { color: '#e8eef5', fontSize: 10 }
  1046. },
  1047. series: [
  1048. {
  1049. type: 'pie',
  1050. radius: ['42%', '68%'],
  1051. center: ['34%', '54%'],
  1052. avoidLabelOverlap: true,
  1053. itemStyle: {
  1054. borderColor: 'rgba(2, 22, 18, 0.9)',
  1055. borderWidth: 2
  1056. },
  1057. label: {
  1058. show: true,
  1059. position: 'inside',
  1060. color: '#fff',
  1061. fontSize: 10,
  1062. fontWeight: 'bold',
  1063. formatter: (p) => formatGrasslandRatio(p.data.ratio, p.percent)
  1064. },
  1065. labelLine: { show: false },
  1066. emphasis: {
  1067. scale: true,
  1068. scaleSize: 5,
  1069. itemStyle: { shadowBlur: 14, shadowColor: 'rgba(0, 0, 0, 0.55)' }
  1070. },
  1071. data
  1072. }
  1073. ]
  1074. }
  1075. }
  1076. const APPOINTMENT_ORG_GRADIENT = {
  1077. type: 'linear',
  1078. x: 0,
  1079. y: 1,
  1080. x2: 0,
  1081. y2: 0,
  1082. colorStops: [
  1083. { offset: 0, color: '#1e479b' },
  1084. { offset: 1, color: '#3a6bc4' }
  1085. ]
  1086. }
  1087. const APPOINTMENT_EXPERT_GRADIENT = {
  1088. type: 'linear',
  1089. x: 0,
  1090. y: 1,
  1091. x2: 0,
  1092. y2: 0,
  1093. colorStops: [
  1094. { offset: 0, color: '#8e763d' },
  1095. { offset: 1, color: '#d4a84a' }
  1096. ]
  1097. }
  1098. const APPOINTMENT_GRID = { left: 44, right: 8, top: 26, bottom: 6, containLabel: true }
  1099. const APPOINTMENT_SPLIT_LINE = {
  1100. lineStyle: { color: 'rgba(120, 140, 160, 0.28)', type: 'dashed' }
  1101. }
  1102. /** 线下服务预约堆叠柱(滚动 12 月) */
  1103. export function buildAppointmentStackedOption(monthlyStacked) {
  1104. if (!monthlyStacked?.length) {
  1105. return emptyOption()
  1106. }
  1107. const axis = buildRollingAxis(monthlyStacked)
  1108. return {
  1109. tooltip: {
  1110. trigger: 'axis',
  1111. axisPointer: { type: 'shadow' },
  1112. formatter: (params) => {
  1113. if (!Array.isArray(params) || !params.length) {
  1114. return ''
  1115. }
  1116. const title = axis.titleAt(params[0].dataIndex)
  1117. const lines = params.map((p) => `${p.marker}${p.seriesName} ${p.value} 人次`)
  1118. return `${title}<br/>${lines.join('<br/>')}`
  1119. }
  1120. },
  1121. legend: {
  1122. top: 4,
  1123. right: 8,
  1124. itemWidth: 10,
  1125. itemHeight: 8,
  1126. textStyle: { color: '#e8eef5', fontSize: 10 },
  1127. data: ['诊疗机构', '兽医', '专家']
  1128. },
  1129. grid: APPOINTMENT_GRID,
  1130. xAxis: {
  1131. type: 'category',
  1132. data: axis.categories,
  1133. axisLabel: { ...MONTH_AXIS_LABEL, interval: 0 },
  1134. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  1135. axisTick: { show: false }
  1136. },
  1137. yAxis: {
  1138. type: 'value',
  1139. minInterval: 1,
  1140. axisLabel: AXIS_LABEL,
  1141. axisLine: { show: false },
  1142. axisTick: { show: false },
  1143. splitLine: APPOINTMENT_SPLIT_LINE
  1144. },
  1145. series: [
  1146. {
  1147. name: '诊疗机构',
  1148. type: 'bar',
  1149. stack: 'apt',
  1150. barMaxWidth: 10,
  1151. itemStyle: { color: APPOINTMENT_ORG_GRADIENT },
  1152. data: rollingSeries(monthlyStacked, 'orgCount', ['org_count'])
  1153. },
  1154. {
  1155. name: '兽医',
  1156. type: 'bar',
  1157. stack: 'apt',
  1158. barMaxWidth: 10,
  1159. itemStyle: { color: '#52b89f' },
  1160. data: rollingSeries(monthlyStacked, 'vetCount', ['vet_count'])
  1161. },
  1162. {
  1163. name: '专家',
  1164. type: 'bar',
  1165. stack: 'apt',
  1166. barMaxWidth: 10,
  1167. itemStyle: { color: APPOINTMENT_EXPERT_GRADIENT },
  1168. data: rollingSeries(monthlyStacked, 'expertCount', ['expert_count'])
  1169. }
  1170. ]
  1171. }
  1172. }
  1173. /** 三类柱图配色 */
  1174. const STACK_BAR_COLORS = [APPOINTMENT_ORG_GRADIENT, '#52b89f', APPOINTMENT_EXPERT_GRADIENT]
  1175. /** 惠农补贴(万元) */
  1176. export function buildSubsidyOption(typeStats, hasSubsidyData) {
  1177. if (!hasSubsidyData || !typeStats?.length) {
  1178. return emptyOption('暂无补贴数据')
  1179. }
  1180. const categories = typeStats.map(
  1181. (item) => item.subsidyTypeName || `类型${item.subsidyType}`
  1182. )
  1183. return {
  1184. tooltip: {
  1185. trigger: 'axis',
  1186. axisPointer: { type: 'shadow' },
  1187. formatter: (params) => {
  1188. const p = Array.isArray(params) ? params[0] : params
  1189. if (!p) {
  1190. return ''
  1191. }
  1192. const val = p.value != null ? Number(p.value) : 0
  1193. return `${p.name}<br/>补贴金额 ${val.toLocaleString('zh-CN', {
  1194. minimumFractionDigits: 2,
  1195. maximumFractionDigits: 2
  1196. })} 万元`
  1197. }
  1198. },
  1199. grid: GRID,
  1200. xAxis: {
  1201. type: 'category',
  1202. data: categories,
  1203. axisLabel: { ...AXIS_LABEL, interval: 0, fontSize: 9, margin: 6 },
  1204. axisLine: AXIS_LINE,
  1205. axisTick: { show: false }
  1206. },
  1207. yAxis: {
  1208. type: 'value',
  1209. name: '万元',
  1210. nameTextStyle: { color: '#6a9f90', fontSize: 10 },
  1211. axisLabel: AXIS_LABEL,
  1212. axisLine: AXIS_LINE,
  1213. splitLine: SPLIT_LINE
  1214. },
  1215. series: [
  1216. {
  1217. name: '补贴金额',
  1218. type: 'bar',
  1219. barMaxWidth: 28,
  1220. data: typeStats.map((item, index) => ({
  1221. value: Number(item.amountWan ?? 0),
  1222. itemStyle: { color: STACK_BAR_COLORS[index % STACK_BAR_COLORS.length] }
  1223. }))
  1224. }
  1225. ]
  1226. }
  1227. }
  1228. const TRAINING_ACTIVITY_LINE = '#ff9f43'
  1229. const TRAINING_ACTIVITY_AREA = {
  1230. type: 'linear',
  1231. x: 0,
  1232. y: 0,
  1233. x2: 0,
  1234. y2: 1,
  1235. colorStops: [
  1236. { offset: 0, color: 'rgba(255, 159, 67, 0.55)' },
  1237. { offset: 1, color: 'rgba(255, 159, 67, 0.04)' }
  1238. ]
  1239. }
  1240. const TRAINING_ENROLL_LINE = '#40e0d0'
  1241. const TRAINING_ENROLL_AREA = {
  1242. type: 'linear',
  1243. x: 0,
  1244. y: 0,
  1245. x2: 0,
  1246. y2: 1,
  1247. colorStops: [
  1248. { offset: 0, color: 'rgba(64, 224, 208, 0.5)' },
  1249. { offset: 1, color: 'rgba(64, 224, 208, 0.04)' }
  1250. ]
  1251. }
  1252. const TRAINING_GRID = { left: 36, right: 36, top: 30, bottom: 6, containLabel: true }
  1253. const TRAINING_SPLIT_LINE = {
  1254. lineStyle: { color: 'rgba(120, 140, 160, 0.28)', type: 'dashed' }
  1255. }
  1256. /**
  1257. * 人均收入(双折线):perCapitaIncomeTrend[]
  1258. * 项含 statYear / ruralDisposableIncome / povertyNetIncome,单位:元
  1259. */
  1260. export function buildPerCapitaIncomeTrendOption(trend) {
  1261. if (!trend?.length) {
  1262. return emptyOption()
  1263. }
  1264. const years = trend.map((item) => String(item.statYear ?? ''))
  1265. const rural = trend.map((item) =>
  1266. pickNumber(item, ['ruralDisposableIncome', 'rural_disposable_income'])
  1267. )
  1268. const poverty = trend.map((item) =>
  1269. pickNumber(item, ['povertyNetIncome', 'poverty_net_income'])
  1270. )
  1271. if (!rural.some((v) => v > 0) && !poverty.some((v) => v > 0)) {
  1272. return emptyOption()
  1273. }
  1274. return {
  1275. color: ['#3ecf9a', '#e0b84a'],
  1276. tooltip: {
  1277. trigger: 'axis',
  1278. formatter: (params) => {
  1279. if (!Array.isArray(params) || !params.length) {
  1280. return ''
  1281. }
  1282. const title = `${params[0].axisValue}年`
  1283. const lines = params.map((p) => `${p.marker}${p.seriesName} ${p.value ?? 0} 元`)
  1284. return `${title}<br/>${lines.join('<br/>')}`
  1285. }
  1286. },
  1287. legend: {
  1288. top: 2,
  1289. left: 'center',
  1290. itemWidth: 14,
  1291. itemHeight: 4,
  1292. textStyle: { color: '#e8eef5', fontSize: 10 }
  1293. },
  1294. grid: { left: 12, right: 12, top: 32, bottom: 6, containLabel: true },
  1295. xAxis: {
  1296. type: 'category',
  1297. data: years,
  1298. boundaryGap: false,
  1299. axisLabel: { color: '#9fb0c3', fontSize: 10 },
  1300. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  1301. axisTick: { show: false }
  1302. },
  1303. yAxis: {
  1304. type: 'value',
  1305. name: '元',
  1306. nameTextStyle: { color: '#9fb0c3', fontSize: 10 },
  1307. axisLabel: AXIS_LABEL,
  1308. axisLine: { show: false },
  1309. axisTick: { show: false },
  1310. splitLine: TRAINING_SPLIT_LINE
  1311. },
  1312. series: [
  1313. {
  1314. name: '农村居民人均可支配收入',
  1315. type: 'line',
  1316. smooth: true,
  1317. symbol: 'circle',
  1318. symbolSize: 5,
  1319. lineStyle: { width: 2, color: '#3ecf9a' },
  1320. itemStyle: { color: '#3ecf9a' },
  1321. data: rural
  1322. },
  1323. {
  1324. name: '脱贫人口人均纯收入',
  1325. type: 'line',
  1326. smooth: true,
  1327. symbol: 'circle',
  1328. symbolSize: 5,
  1329. lineStyle: { width: 2, color: '#e0b84a' },
  1330. itemStyle: { color: '#e0b84a' },
  1331. data: poverty
  1332. }
  1333. ]
  1334. }
  1335. }
  1336. /** 实战培训报名趋势(双面积折线,滚动 12 月) */
  1337. export function buildTrainingOption(trainingTrend) {
  1338. if (!trainingTrend?.hasTrainingData) {
  1339. return emptyOption('暂无培训数据')
  1340. }
  1341. const monthly = trainingTrend.monthly || []
  1342. if (!monthly.length) {
  1343. return emptyOption('暂无培训数据')
  1344. }
  1345. const axis = buildRollingAxis(monthly)
  1346. return {
  1347. tooltip: {
  1348. trigger: 'axis',
  1349. formatter: (params) => {
  1350. if (!Array.isArray(params) || !params.length) {
  1351. return ''
  1352. }
  1353. const title = axis.titleAt(params[0].dataIndex)
  1354. const lines = params.map((p) => {
  1355. const unit = p.seriesName === '活动数' ? '个' : '人'
  1356. return `${p.marker}${p.seriesName} ${p.value ?? 0} ${unit}`
  1357. })
  1358. return `${title}<br/>${lines.join('<br/>')}`
  1359. }
  1360. },
  1361. legend: {
  1362. top: 4,
  1363. left: 'center',
  1364. itemWidth: 14,
  1365. itemHeight: 4,
  1366. textStyle: { color: '#e8eef5', fontSize: 10 }
  1367. },
  1368. grid: TRAINING_GRID,
  1369. xAxis: {
  1370. type: 'category',
  1371. data: axis.categories,
  1372. axisLabel: { ...MONTH_AXIS_LABEL, interval: 0, color: '#e8eef5' },
  1373. axisLine: { lineStyle: { color: 'rgba(150, 170, 190, 0.45)' } },
  1374. axisTick: { show: false }
  1375. },
  1376. yAxis: [
  1377. {
  1378. type: 'value',
  1379. name: '个',
  1380. nameTextStyle: { color: '#9fb0c3', fontSize: 10 },
  1381. minInterval: 1,
  1382. axisLabel: { ...AXIS_LABEL, color: '#9fb0c3' },
  1383. axisLine: { show: false },
  1384. axisTick: { show: false },
  1385. splitLine: TRAINING_SPLIT_LINE
  1386. },
  1387. {
  1388. type: 'value',
  1389. name: '人',
  1390. nameTextStyle: { color: '#9fb0c3', fontSize: 10 },
  1391. minInterval: 1,
  1392. axisLabel: { ...AXIS_LABEL, color: '#9fb0c3' },
  1393. axisLine: { show: false },
  1394. axisTick: { show: false },
  1395. splitLine: { show: false }
  1396. }
  1397. ],
  1398. series: [
  1399. {
  1400. name: '活动数',
  1401. type: 'line',
  1402. smooth: true,
  1403. symbol: 'none',
  1404. yAxisIndex: 0,
  1405. lineStyle: {
  1406. color: TRAINING_ACTIVITY_LINE,
  1407. width: 2,
  1408. shadowColor: 'rgba(255, 159, 67, 0.65)',
  1409. shadowBlur: 10
  1410. },
  1411. areaStyle: { color: TRAINING_ACTIVITY_AREA },
  1412. data: rollingSeries(monthly, 'activityCount', ['activity_count'])
  1413. },
  1414. {
  1415. name: '报名人数',
  1416. type: 'line',
  1417. yAxisIndex: 1,
  1418. smooth: true,
  1419. symbol: 'none',
  1420. lineStyle: {
  1421. color: TRAINING_ENROLL_LINE,
  1422. width: 2,
  1423. shadowColor: 'rgba(64, 224, 208, 0.65)',
  1424. shadowBlur: 10
  1425. },
  1426. areaStyle: { color: TRAINING_ENROLL_AREA },
  1427. data: rollingSeries(monthly, 'enrolledCount', ['enrolled_count'])
  1428. }
  1429. ]
  1430. }
  1431. }