voc-scripts.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. // VOC 报告 inline JS · PPT 式 deck 驱动
  2. // 特性:
  3. // 1) IntersectionObserver 驱动的 fade-up / rise-in / headline-anim
  4. // 2) 键盘导航(↑↓ PgUp PgDn Home End j k)逐张切换
  5. // 3) slide counter (n/N + 进度条)
  6. // 4) ambient 光晕跟随当前 slide 偏移
  7. // 5) 首次滚动后 5s 自动隐藏键盘提示
  8. // 6) ECharts 各图按需初始化(首次进入视口时才渲染)
  9. module.exports = `
  10. (function(){
  11. 'use strict';
  12. var slides = [];
  13. var currentSlide = 0;
  14. var firstScrolled = false;
  15. // —— 1) 分片标题动画:把每个 .headline-anim 文本切成 .char 逐字入场 ——
  16. function splitHeadlines(){
  17. var els = document.querySelectorAll('.headline-anim');
  18. els.forEach(function(el){
  19. if (el.dataset.split === '1') return;
  20. el.dataset.split = '1';
  21. var text = el.textContent;
  22. el.textContent = '';
  23. var chars = Array.from(text);
  24. chars.forEach(function(ch, i){
  25. var span = document.createElement('span');
  26. span.className = 'char';
  27. span.textContent = ch === ' ' ? '\\u00A0' : ch;
  28. span.style.transitionDelay = (i * 28) + 'ms';
  29. el.appendChild(span);
  30. });
  31. });
  32. }
  33. // —— 2) Fade / Rise / Headline 统一的 IntersectionObserver ——
  34. function initReveal(){
  35. var selectors = ['.fade-up', '.rise-in', '.headline-anim'];
  36. var els = document.querySelectorAll(selectors.join(','));
  37. if (!('IntersectionObserver' in window)){
  38. els.forEach(function(el){ el.classList.add('is-visible'); });
  39. return;
  40. }
  41. var io = new IntersectionObserver(function(entries){
  42. entries.forEach(function(e){
  43. if (e.isIntersecting){
  44. e.target.classList.add('is-visible');
  45. io.unobserve(e.target);
  46. }
  47. });
  48. }, { threshold: 0.08, rootMargin: '0px 0px -5% 0px' });
  49. els.forEach(function(el){ io.observe(el); });
  50. // 首屏兜底
  51. setTimeout(function(){
  52. els.forEach(function(el){
  53. var r = el.getBoundingClientRect();
  54. if (r.top < window.innerHeight && r.bottom > 0) el.classList.add('is-visible');
  55. });
  56. }, 100);
  57. // 终极兜底
  58. setTimeout(function(){
  59. document.querySelectorAll('.fade-up:not(.is-visible),.rise-in:not(.is-visible),.headline-anim:not(.is-visible)').forEach(function(el){
  60. el.classList.add('is-visible');
  61. });
  62. }, 4500);
  63. }
  64. // —— 3) 数字计数动画 ——
  65. function initCountUp(){
  66. var els = document.querySelectorAll('.metric-value[data-count]');
  67. if (!('IntersectionObserver' in window)){
  68. els.forEach(function(el){ el.textContent = el.dataset.count; });
  69. return;
  70. }
  71. var io = new IntersectionObserver(function(entries){
  72. entries.forEach(function(e){
  73. if (!e.isIntersecting) return;
  74. var el = e.target;
  75. var target = parseFloat(el.dataset.count);
  76. if (isNaN(target)) return;
  77. var isFloat = !Number.isInteger(target);
  78. var start = 0, duration = 1400, t0 = performance.now();
  79. function tick(now){
  80. var p = Math.min(1, (now - t0) / duration);
  81. var eased = 1 - Math.pow(1 - p, 3);
  82. var v = start + (target - start) * eased;
  83. el.textContent = isFloat ? v.toFixed(1) : Math.floor(v).toLocaleString('en-US');
  84. if (p < 1) requestAnimationFrame(tick);
  85. else el.textContent = isFloat ? target.toFixed(1) : target.toLocaleString('en-US');
  86. }
  87. requestAnimationFrame(tick);
  88. io.unobserve(el);
  89. });
  90. }, { threshold: 0.4 });
  91. els.forEach(function(el){ io.observe(el); });
  92. }
  93. // —— 4) Slide 进度 · nav 高亮 · ambient 光晕跟随 · slide counter ——
  94. function initDeck(){
  95. slides = Array.prototype.slice.call(document.querySelectorAll('section.report-section'));
  96. var progressLine = document.getElementById('progress-line');
  97. var navDots = document.querySelectorAll('.nav-dot');
  98. var counter = document.querySelector('.slide-counter');
  99. var counterNow = counter && counter.querySelector('.now');
  100. var counterTotal = counter && counter.querySelector('.total');
  101. var counterBar = counter && counter.querySelector('.bar');
  102. if (counterTotal) counterTotal.textContent = String(slides.length).padStart(2, '0');
  103. // 预设每张 slide 的 ambient 偏移(用 CSS 变量驱动)
  104. var ambientPresets = [
  105. { x: '15%', y: '25%' },
  106. { x: '75%', y: '30%' },
  107. { x: '25%', y: '70%' },
  108. { x: '80%', y: '65%' },
  109. { x: '45%', y: '20%' },
  110. { x: '55%', y: '75%' },
  111. { x: '20%', y: '50%' },
  112. { x: '85%', y: '50%' },
  113. { x: '50%', y: '40%' },
  114. { x: '30%', y: '30%' },
  115. { x: '70%', y: '70%' },
  116. { x: '40%', y: '60%' }
  117. ];
  118. function setActive(idx){
  119. if (idx < 0) idx = 0;
  120. if (idx >= slides.length) idx = slides.length - 1;
  121. currentSlide = idx;
  122. slides.forEach(function(s, i){
  123. if (i === idx) s.classList.add('in-view');
  124. else s.classList.remove('in-view');
  125. });
  126. for (var j = 0; j < navDots.length; j++){
  127. if (j === idx) navDots[j].classList.add('active');
  128. else navDots[j].classList.remove('active');
  129. }
  130. if (counterNow) counterNow.textContent = String(idx + 1).padStart(2, '0');
  131. if (counterBar) {
  132. var pct = slides.length > 1 ? ((idx + 1) / slides.length) * 100 : 100;
  133. counterBar.style.setProperty('--pct', pct.toFixed(1) + '%');
  134. }
  135. // 根据当前 slide 切换 ambient 位置
  136. var p = ambientPresets[idx % ambientPresets.length];
  137. document.body.style.setProperty('--amb-x', p.x);
  138. document.body.style.setProperty('--amb-y', p.y);
  139. }
  140. function onScroll(){
  141. if (!firstScrolled && window.scrollY > 20){
  142. firstScrolled = true;
  143. setTimeout(function(){
  144. var hint = document.querySelector('.kbd-hint');
  145. if (hint) hint.classList.add('fade');
  146. }, 4000);
  147. }
  148. var h = document.documentElement.scrollHeight - window.innerHeight;
  149. var p = h > 0 ? (window.scrollY / h) * 100 : 0;
  150. if (progressLine) progressLine.style.width = p + '%';
  151. // 找出离视口中心最近的 slide
  152. var mid = window.innerHeight * 0.45;
  153. var bestIdx = 0, bestDist = Infinity;
  154. for (var i = 0; i < slides.length; i++){
  155. var r = slides[i].getBoundingClientRect();
  156. var center = (r.top + r.bottom) / 2;
  157. var d = Math.abs(center - mid);
  158. if (d < bestDist){ bestDist = d; bestIdx = i; }
  159. }
  160. if (bestIdx !== currentSlide) setActive(bestIdx);
  161. }
  162. window.addEventListener('scroll', onScroll, { passive: true });
  163. onScroll();
  164. navDots.forEach(function(d, i){
  165. d.addEventListener('click', function(e){
  166. e.preventDefault();
  167. var tid = d.dataset.target;
  168. var t = tid ? document.getElementById(tid) : slides[i];
  169. if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' });
  170. });
  171. });
  172. // —— 键盘导航 ——
  173. function goto(idx){
  174. idx = Math.max(0, Math.min(slides.length - 1, idx));
  175. slides[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
  176. }
  177. window.addEventListener('keydown', function(e){
  178. // 不在输入框里
  179. var tag = (e.target && e.target.tagName || '').toLowerCase();
  180. if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
  181. if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === 'j'){ e.preventDefault(); goto(currentSlide + 1); }
  182. else if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'k'){ e.preventDefault(); goto(currentSlide - 1); }
  183. else if (e.key === 'Home'){ e.preventDefault(); goto(0); }
  184. else if (e.key === 'End'){ e.preventDefault(); goto(slides.length - 1); }
  185. else if (e.key === ' '){ e.preventDefault(); goto(currentSlide + (e.shiftKey ? -1 : 1)); }
  186. });
  187. }
  188. // —— 5) ECharts 懒加载(slide 进入视口才渲染,避免首屏卡顿)——
  189. var COMMON = {
  190. backgroundColor: 'transparent',
  191. textStyle: { fontFamily: 'Inter, Noto Sans SC, system-ui', color: '#A8A8A8' },
  192. grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true }
  193. };
  194. var AMBER='#F5A623', GREEN='#00DC82', PURPLE='#8B5CF6', ROSE='#FF4D8D', BLUE='#3B82F6';
  195. var PALETTE = [AMBER, GREEN, PURPLE, ROSE, BLUE, '#EF4444', '#06B6D4', '#F59E0B'];
  196. var CHART_REGISTRY = [];
  197. function initChart(id, cb){
  198. var el = document.getElementById(id);
  199. if (!el) return;
  200. CHART_REGISTRY.push({ id: id, el: el, cb: cb, rendered: false });
  201. }
  202. function renderChart(item){
  203. if (item.rendered || !window.echarts) return;
  204. var chart = window.echarts.init(item.el, null, { renderer: 'canvas' });
  205. try { item.cb(chart); item.rendered = true; } catch(e){ console.error(item.id, e); }
  206. window.addEventListener('resize', function(){ chart.resize(); });
  207. }
  208. function bootCharts(){
  209. if (!window.echarts) { setTimeout(bootCharts, 300); return; }
  210. if (!('IntersectionObserver' in window)){
  211. CHART_REGISTRY.forEach(renderChart);
  212. return;
  213. }
  214. var io = new IntersectionObserver(function(entries){
  215. entries.forEach(function(e){
  216. if (!e.isIntersecting) return;
  217. var item = CHART_REGISTRY.find(function(x){ return x.el === e.target; });
  218. if (item) renderChart(item);
  219. io.unobserve(e.target);
  220. });
  221. }, { threshold: 0.1, rootMargin: '200px 0px' });
  222. CHART_REGISTRY.forEach(function(item){ io.observe(item.el); });
  223. }
  224. function initCharts(){
  225. var D = window.__CHART_DATA__ || {};
  226. // 样本分布
  227. initChart('chartSample', function(chart){
  228. var data = (D.noteHeatmap || []).slice(0, 5).map(function(n, i){
  229. return {
  230. value: (n.liked || 0) + (n.comments || 0) + (n.collected || 0),
  231. name: (n.title || '').slice(0, 14) || '笔记' + (i + 1),
  232. itemStyle: { color: PALETTE[i % PALETTE.length] }
  233. };
  234. });
  235. chart.setOption(Object.assign({}, COMMON, {
  236. tooltip: { trigger: 'item', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' } },
  237. legend: { bottom: 0, textStyle: { color: '#A8A8A8', fontSize: 11 } },
  238. animationDuration: 1000, animationEasing: 'cubicOut',
  239. series: [{
  240. type: 'pie', radius: ['52%', '76%'], center: ['50%', '45%'],
  241. label: { color: '#F5F5F5', fontSize: 11, formatter: '{b}\\n{c}' },
  242. itemStyle: { borderColor: '#0A0A0A', borderWidth: 2 },
  243. data: data
  244. }]
  245. }));
  246. });
  247. // IP 地域
  248. initChart('chartGeo', function(chart){
  249. var ips = (D.topIPs || []).slice(0, 10).reverse();
  250. chart.setOption(Object.assign({}, COMMON, {
  251. tooltip: { trigger: 'axis', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' } },
  252. animationDuration: 1200, animationEasing: 'cubicOut',
  253. xAxis: { type: 'value', axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  254. yAxis: { type: 'category', data: ips.map(function(x){return x[0];}), axisLine: { lineStyle: { color: '#252525' } }, axisLabel: { color: '#A8A8A8', fontSize: 12 } },
  255. series: [{
  256. type: 'bar', data: ips.map(function(x){return x[1];}),
  257. itemStyle: { color: AMBER, borderRadius: [0, 3, 3, 0] }, barWidth: '60%',
  258. label: { show: true, position: 'right', color: '#F5A623', fontFamily: 'JetBrains Mono', fontSize: 11 }
  259. }]
  260. }));
  261. });
  262. // 笔记热力散点
  263. initChart('chartNoteHeatmap', function(chart){
  264. chart.setOption(Object.assign({}, COMMON, {
  265. tooltip: {
  266. trigger: 'item', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' },
  267. formatter: function(p){
  268. return '<div style="max-width:260px;"><strong style="color:#F5A623;">' + p.data[3] +
  269. '</strong><br>点赞 ' + p.data[0] + ' · 评论 ' + p.data[1] + ' · 收藏 ' + p.data[2] + '</div>';
  270. }
  271. },
  272. animationDuration: 1400, animationDelay: function(i){ return i * 30; },
  273. xAxis: { type: 'value', name: '点赞数', nameTextStyle: { color: '#A8A8A8' }, axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  274. yAxis: { type: 'value', name: '评论数', nameTextStyle: { color: '#A8A8A8' }, axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  275. series: [{
  276. type: 'scatter',
  277. data: (D.noteHeatmap || []).map(function(n){ return [n.liked, n.comments, n.collected, n.title]; }),
  278. symbolSize: function(v){ return Math.max(10, Math.min(56, Math.sqrt(v[2] || 1) * 3.5)); },
  279. itemStyle: {
  280. color: new echarts.graphic.RadialGradient(0.4, 0.4, 0.6, [
  281. { offset: 0, color: 'rgba(245,166,35,0.9)' },
  282. { offset: 1, color: 'rgba(245,166,35,0.15)' }
  283. ]),
  284. borderColor: '#F5A623', borderWidth: 1
  285. },
  286. emphasis: { itemStyle: { borderWidth: 2, shadowBlur: 16, shadowColor: 'rgba(245,166,35,0.5)' } }
  287. }]
  288. }));
  289. });
  290. // 价格带
  291. initChart('chartPrice', function(chart){
  292. var keys = Object.keys(D.priceBuckets || {});
  293. var vals = keys.map(function(k){ return D.priceBuckets[k]; });
  294. chart.setOption(Object.assign({}, COMMON, {
  295. tooltip: { trigger: 'axis', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' } },
  296. animationDuration: 1200,
  297. xAxis: { type: 'category', data: keys, axisLine: { lineStyle: { color: '#252525' } }, axisLabel: { color: '#A8A8A8', fontFamily: 'JetBrains Mono' } },
  298. yAxis: { type: 'value', axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  299. series: [{
  300. type: 'bar', data: vals,
  301. itemStyle: {
  302. color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
  303. colorStops: [{ offset: 0, color: '#F5A623' }, { offset: 1, color: 'rgba(245,166,35,0.3)' }] },
  304. borderRadius: [4, 4, 0, 0]
  305. },
  306. barWidth: '56%',
  307. label: { show: true, position: 'top', color: '#F5A623', fontFamily: 'JetBrains Mono', fontWeight: 600 }
  308. }]
  309. }));
  310. });
  311. // 品牌月销(横条,兼容)
  312. initChart('chartBrands', function(chart){
  313. var brands = (D.topBrands || []).slice().reverse();
  314. chart.setOption(Object.assign({}, COMMON, {
  315. tooltip: { trigger: 'axis', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' },
  316. formatter: function(p){ return p.map(function(x){ return x.name + ': $' + (x.value/100).toLocaleString('en-US',{maximumFractionDigits:0}); }).join('<br>'); }
  317. },
  318. animationDuration: 1200,
  319. xAxis: { type: 'value', axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B', formatter: function(v){ return '$' + (v/100000).toFixed(0) + 'k'; } } },
  320. yAxis: { type: 'category', data: brands.map(function(x){return x[0];}), axisLine: { lineStyle: { color: '#252525' } }, axisLabel: { color: '#A8A8A8', fontSize: 12 } },
  321. series: [{
  322. type: 'bar', data: brands.map(function(x){return x[1];}),
  323. itemStyle: {
  324. color: function(params){ return PALETTE[params.dataIndex % PALETTE.length]; },
  325. borderRadius: [0, 3, 3, 0]
  326. },
  327. barWidth: '58%',
  328. label: { show: true, position: 'right', color: '#A8A8A8', fontFamily: 'JetBrains Mono', fontSize: 10, formatter: function(p){ return '$' + (p.value/100000).toFixed(0) + 'k'; } }
  329. }]
  330. }));
  331. });
  332. // 评分分布
  333. initChart('chartRatingDist', function(chart){
  334. var data = D.ratingBuckets || {};
  335. var keys = Object.keys(data);
  336. var vals = keys.map(function(k){ return data[k]; });
  337. var colors = ['#EF4444','#F59E0B','#F5A623','#06B6D4','#00DC82'];
  338. chart.setOption(Object.assign({}, COMMON, {
  339. tooltip: { trigger: 'axis', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' },
  340. formatter: function(p){ return p.map(function(x){ return x.name + ': ' + x.value + ' SKU'; }).join('<br>'); }
  341. },
  342. animationDuration: 1200,
  343. xAxis: { type: 'category', data: keys, axisLine: { lineStyle: { color: '#252525' } }, axisLabel: { color: '#A8A8A8', fontFamily: 'JetBrains Mono', fontSize: 11 } },
  344. yAxis: { type: 'value', axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  345. series: [{
  346. type: 'bar', data: vals.map(function(v, i){ return { value: v, itemStyle: { color: colors[i] || GREEN, borderRadius: [4, 4, 0, 0] } }; }),
  347. barWidth: '54%',
  348. label: { show: true, position: 'top', color: '#F5F5F5', fontFamily: 'JetBrains Mono', fontWeight: 600 }
  349. }]
  350. }));
  351. });
  352. // 品牌份额(donut)
  353. initChart('chartBrandShare', function(chart){
  354. var data = (D.topBrandShares || []).map(function(b, i){
  355. return { value: b.value, name: b.name, itemStyle: { color: PALETTE[i % PALETTE.length] }, _sales: b.sales, _count: b.count };
  356. });
  357. chart.setOption(Object.assign({}, COMMON, {
  358. tooltip: { trigger: 'item', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' },
  359. formatter: function(p){ return '<strong>'+p.name+'</strong><br>' + p.value.toFixed(1) + '%<br>SKU '+(p.data._count||0)+' · 月销 $'+((p.data._sales||0)/100).toLocaleString('en-US',{maximumFractionDigits:0}); }
  360. },
  361. legend: { bottom: 0, textStyle: { color: '#A8A8A8', fontSize: 10 }, itemWidth: 8, itemHeight: 8 },
  362. animationDuration: 1400, animationEasing: 'cubicOut',
  363. series: [{
  364. type: 'pie', radius: ['48%', '72%'], center: ['50%', '45%'],
  365. label: { color: '#F5F5F5', fontSize: 10, formatter: function(p){ return p.value > 3 ? p.name + '\\n' + p.value.toFixed(1) + '%' : ''; } },
  366. labelLine: { show: true, length: 8, length2: 8 },
  367. itemStyle: { borderColor: '#0A0A0A', borderWidth: 2 },
  368. data: data
  369. }]
  370. }));
  371. });
  372. // 评论数分布(横条)
  373. initChart('chartReviewCount', function(chart){
  374. var data = D.reviewCountBuckets || {};
  375. var keys = Object.keys(data);
  376. var vals = keys.map(function(k){ return data[k]; });
  377. chart.setOption(Object.assign({}, COMMON, {
  378. tooltip: { trigger: 'axis', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' } },
  379. animationDuration: 1200,
  380. xAxis: { type: 'value', axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  381. yAxis: { type: 'category', data: keys, axisLine: { lineStyle: { color: '#252525' } }, axisLabel: { color: '#A8A8A8', fontFamily: 'JetBrains Mono', fontSize: 11 } },
  382. series: [{
  383. type: 'bar', data: vals,
  384. itemStyle: {
  385. color: { type: 'linear', x: 0, y: 0, x2: 1, y2: 0,
  386. colorStops: [{ offset: 0, color: 'rgba(139,92,246,0.2)' }, { offset: 1, color: '#8B5CF6' }] },
  387. borderRadius: [0, 3, 3, 0]
  388. },
  389. barWidth: '56%',
  390. label: { show: true, position: 'right', color: '#A8A8A8', fontFamily: 'JetBrains Mono', fontSize: 11 }
  391. }]
  392. }));
  393. });
  394. // 星级分布
  395. initChart('chartStarDist', function(chart){
  396. var products = D.starDistProducts || [];
  397. if (!products.length) return;
  398. var categories = products.map(function(p){ return p.brand + ' · ' + p.asin; });
  399. var colors = ['#00DC82','#06B6D4','#F5A623','#F59E0B','#EF4444'];
  400. var labels = ['5★','4★','3★','2★','1★'];
  401. var series = [0,1,2,3,4].map(function(i){
  402. return {
  403. name: labels[i], type: 'bar', stack: 'stars',
  404. data: products.map(function(p){ return p.stars[i]; }),
  405. itemStyle: { color: colors[i] }
  406. };
  407. });
  408. chart.setOption(Object.assign({}, COMMON, {
  409. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' } },
  410. legend: { bottom: 0, textStyle: { color: '#A8A8A8', fontSize: 10 }, itemWidth: 10, itemHeight: 10 },
  411. animationDuration: 1400,
  412. grid: { left: '3%', right: '4%', bottom: '16%', top: '6%', containLabel: true },
  413. xAxis: { type: 'value', axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  414. yAxis: { type: 'category', data: categories, axisLine: { lineStyle: { color: '#252525' } }, axisLabel: { color: '#A8A8A8', fontSize: 11 } },
  415. series: series
  416. }));
  417. });
  418. // 抖音账号作品热度散点
  419. initChart('chartDyPosts', function(chart){
  420. var posts = (D.douyinPosts || []);
  421. if (!posts.length) return;
  422. chart.setOption(Object.assign({}, COMMON, {
  423. tooltip: { trigger: 'item', backgroundColor: '#1A1A1A', borderColor: '#252525', textStyle: { color: '#F5F5F5' },
  424. formatter: function(p){
  425. return '<div style="max-width:280px;"><strong style="color:#F5A623;">' + (p.data[3]||'').slice(0,60) +
  426. '</strong><br>点赞 ' + p.data[0].toLocaleString() + ' · 评论 ' + p.data[1] + ' · 转发 ' + p.data[2] + '</div>';
  427. }
  428. },
  429. animationDuration: 1200,
  430. xAxis: { type: 'value', name: '点赞', nameTextStyle: { color: '#A8A8A8' }, axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B', formatter: function(v){ return v >= 10000 ? (v/10000).toFixed(1) + 'w' : v; } } },
  431. yAxis: { type: 'value', name: '评论', nameTextStyle: { color: '#A8A8A8' }, axisLine: { lineStyle: { color: '#252525' } }, splitLine: { lineStyle: { color: '#1A1A1A' } }, axisLabel: { color: '#6B6B6B' } },
  432. series: [{
  433. type: 'scatter',
  434. data: posts.map(function(p){ return [p.digg||0, p.comment||0, p.share||0, p.desc||'']; }),
  435. symbolSize: function(v){ return Math.max(12, Math.min(60, Math.sqrt((v[2]||0) + 1) * 4)); },
  436. itemStyle: {
  437. color: new echarts.graphic.RadialGradient(0.4, 0.4, 0.6, [
  438. { offset: 0, color: 'rgba(255,77,141,0.9)' },
  439. { offset: 1, color: 'rgba(255,77,141,0.2)' }
  440. ]),
  441. borderColor: '#FF4D8D', borderWidth: 1
  442. },
  443. emphasis: { itemStyle: { borderWidth: 2, shadowBlur: 16, shadowColor: 'rgba(255,77,141,0.5)' } }
  444. }]
  445. }));
  446. });
  447. }
  448. // —— Boot ——
  449. function boot(){
  450. try { splitHeadlines(); } catch(e){ console.error('split', e); }
  451. try { initReveal(); } catch(e){ console.error('reveal', e); }
  452. try { initCountUp(); } catch(e){ console.error('countup', e); }
  453. try { initDeck(); } catch(e){ console.error('deck', e); }
  454. try { initCharts(); bootCharts(); } catch(e){ console.error('charts', e); }
  455. }
  456. if (document.readyState === 'loading'){
  457. document.addEventListener('DOMContentLoaded', boot);
  458. } else {
  459. boot();
  460. }
  461. })();
  462. `;