/* ════════════════════════════════════════════════════════ * page-monthly-summary.js — Turn C * ──────────────────────────────────────────────────────── * monthly_summary_analysis.html 的 ECharts + DataTable 邏輯 * * 注意:依賴 analysis-chart-theme.js 對 echarts.init 的攔截, * 它會自動把 option.color 換成 .momo-app[data-page-group] 的 * --momo-page-chart-* 暖色調 palette,因此本檔保留原 itemStyle.color * 邏輯時,會被自動覆寫;分類/條件配色(如 BCG 象限)保持顯式指定。 * ════════════════════════════════════════════════════════ */ (function () { 'use strict'; let table; let compareChart, yoyTrendChart, vendorRankingChart, divisionDistChart, priceRangeChart; let bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart; let specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart; let currentFilters = { year: '', month: '', area: '', vendor: '', trade: '' }; const compactMediaQuery = window.matchMedia('(max-width: 768px)'); function isCompactViewport() { return compactMediaQuery.matches; } $(function () { compareChart = echarts.init(document.getElementById('compareChart')); yoyTrendChart = echarts.init(document.getElementById('yoyTrendChart')); vendorRankingChart = echarts.init(document.getElementById('vendorRankingChart')); divisionDistChart = echarts.init(document.getElementById('divisionDistChart')); priceRangeChart = echarts.init(document.getElementById('priceRangeChart')); bcgMatrixChart = echarts.init(document.getElementById('bcgMatrixChart')); priceVolumeScatterChart = echarts.init(document.getElementById('priceVolumeScatterChart')); seasonalityHeatmapChart = echarts.init(document.getElementById('seasonalityHeatmapChart')); areaRankingChart = echarts.init(document.getElementById('areaRankingChart')); specialChart = echarts.init(document.getElementById('specialChart')); bodyCareChart = echarts.init(document.getElementById('bodyCareChart')); makeupFragranceChart = echarts.init(document.getElementById('makeupFragranceChart')); privacyInfantChart = echarts.init(document.getElementById('privacyInfantChart')); table = $('#summaryTable').DataTable({ language: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' }, autoWidth: false, deferRender: true, pageLength: 20, order: [[0, 'desc'], [7, 'desc']], columnDefs: [ { targets: [7], className: 'text-end fw-bold' }, { targets: 8, className: 'text-center' } ] }); window.addEventListener('resize', () => { [compareChart, yoyTrendChart, vendorRankingChart, divisionDistChart, priceRangeChart, bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart, specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart] .forEach(c => c && c.resize()); }); const refreshForViewport = () => fetchData(); if (compactMediaQuery.addEventListener) { compactMediaQuery.addEventListener('change', refreshForViewport); } else if (compactMediaQuery.addListener) { compactMediaQuery.addListener(refreshForViewport); } fetchData(); }); // ── API ─────────────────────────────────────────────── function fetchData() { $('#loadingOverlay').css('display', 'flex'); let url = `/api/monthly_summary_data?limit=2000`; if (currentFilters.year) url += `&year=${currentFilters.year}`; if (currentFilters.month) url += `&month=${currentFilters.month}`; if (currentFilters.area) url += `&area_name=${encodeURIComponent(currentFilters.area)}`; if (currentFilters.vendor) url += `&vendor=${encodeURIComponent(currentFilters.vendor)}`; if (currentFilters.trade) url += `&trade_type=${encodeURIComponent(currentFilters.trade)}`; fetch(url).then(r => r.json()).then(data => { if (data.status === 'success') { updateUI(data); updateFilters(data.filters); } $('#loadingOverlay').hide(); }).catch(err => { console.error(err); $('#loadingOverlay').hide(); }); fetchSpecialChartData(); } function fetchSpecialChartData() { const pairs = [ ['開架保養,臉部清潔', specialChart, 'specialChartTableBody'], ['身體保養', bodyCareChart, 'bodyCareChartTableBody'], ['彩妝/指彩,精油擴香', makeupFragranceChart, 'makeupFragranceChartTableBody'], ['私密保養,嬰幼洗沐', privacyInfantChart, 'privacyInfantChartTableBody'] ]; pairs.forEach(([area, chart, tbody]) => { fetch(`/api/monthly_summary_trend?area_name=${encodeURIComponent(area)}`) .then(r => r.json()) .then(d => { if (d.status === 'success') renderExcelChart(chart, tbody, d.trend); }) .catch(err => console.error('monthly special trend failed', err)); }); } // ── Filter ──────────────────────────────────────────── function selectFilter(type, value) { currentFilters[type] = value; const btnMap = { year:'btnYear', month:'btnMonth', area:'btnArea', vendor:'btnVendor', trade:'btnTrade' }; const defaultMap = { year:'選擇年份', month:'選擇月份', area:'所有區域', vendor:'所有廠商', trade:'所有類別' }; $(`#${btnMap[type]}`).text(value || defaultMap[type]); fetchData(); } window.selectFilter = selectFilter; function filterList(input) { const filter = input.value.toLowerCase(); const list = input.closest('ul').querySelectorAll('li:not(:first-child)'); list.forEach(li => { const t = (li.textContent || li.innerText).toLowerCase(); li.style.display = t.indexOf(filter) > -1 ? '' : 'none'; }); } window.filterList = filterList; function updateFilters(f) { const populate = (id, list, type) => { const el = $(id); if (el.children().length > 1) return; list.forEach(v => el.append(`
  • ${v}
  • `)); }; populate('#listYear', f.years, 'year'); populate('#listMonth', f.months.map(String).sort((a,b) => a - b), 'month'); populate('#listArea', f.areas, 'area'); populate('#listTrade', f.trades, 'trade'); populate('#listVendor', f.vendors, 'vendor'); } // ── UI updates ──────────────────────────────────────── function updateUI(data) { $('#totalRows').text(data.total_rows.toLocaleString()); $('#totalMonths').text(data.total_months); const k = data.kpis || {}; $('#kpiSales').text('$' + (k.sales || 0).toLocaleString()); $('#kpiProfit').text('$' + (k.profit || 0).toLocaleString()); $('#kpiMargin').text(k.margin || '-'); $('#kpiVol').text((k.vol || 0).toLocaleString()); if (k.sales_yoa > 0) { const yoy = ((k.sales - k.sales_yoa) / k.sales_yoa * 100).toFixed(1); const cls = yoy >= 0 ? 'text-danger' : 'text-success'; $('#kpiSalesYoY').html(` ${yoy}% vs 去年同期`); } else { $('#kpiSalesYoY').text('-'); } table.clear(); (data.rows || []).forEach(row => { const sales = row.sales_amt_curr || 0; const prevYear = row.sales_amt_yoa || 0; let yoyHtml = '-'; if (prevYear > 0) { const yoy = ((sales - prevYear) / prevYear * 100).toFixed(1); const cls = yoy >= 0 ? 'text-danger' : 'text-success'; yoyHtml = `${yoy > 0 ? '+' : ''}${yoy}%`; } table.row.add([ `${row.year}/${row.month}`, row.division, row.area_name || '-', row.pm_name || '-', `${row.brand_name}`, `${row.vendor_name}`, `${row.trade_type || '-'}`, sales.toLocaleString(), yoyHtml ]); }); table.draw(); renderExcelChart(compareChart, 'compareChartTableBody', data.trend); updateHighlights(data.highlights); renderYoYTrendChart(yoyTrendChart, 'yoyTrendChartTableBody', data.yoy_trend); renderVendorRankingChart(vendorRankingChart, 'vendorRankingChartTableBody', data.vendor_ranking); renderDivisionDistChart(divisionDistChart, 'divisionDistChartTableBody', data.division_dist); renderPriceRangeChart(priceRangeChart, 'priceRangeChartTableBody', data.price_contribution); renderBCGMatrixChart(bcgMatrixChart, data.bcg_data); renderScatterChart(priceVolumeScatterChart, data.bcg_data); renderSeasonalityHeatmap(seasonalityHeatmapChart, data.heatmap_data); renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking); } // ── Highlights (Top 3) ──────────────────────────────── function updateHighlights(h) { if (!h || (!h.rev_top.length && !h.profit_top.length)) { $('#highlightsRow').hide(); return; } $('#highlightsRow').show(); const r = (id, list, prefix='') => { const t = $(`#${id}`); t.empty(); list.forEach((it, i) => t.append(`${i+1}${it.name}${prefix}${it.value.toLocaleString()}`)); }; r('revHighlightsBody', h.rev_top, '$'); r('profitHighlightsBody', h.profit_top, '$'); r('volHighlightsBody', h.vol_top, ''); } // ── Excel-style 主對比圖 ───────────────────────────── function renderExcelChart(chart, tableBodyId, trend) { if (!trend) return; const compact = isCompactViewport(); const years = [...new Set(trend.map(t => t.date.split('/')[0]))].sort().reverse(); const currYear = years[0] || String(new Date().getFullYear()); const prevYear = String(parseInt(currYear) - 1); const months = Array.from({ length: 12 }, (_, i) => String(i + 1)); const currData = new Array(12).fill(0); const prevData = new Array(12).fill(0); const yoyData = new Array(12).fill(0); trend.forEach(t => { const [y, m] = t.date.split('/'); if (y === currYear) currData[parseInt(m) - 1] = t.sales; if (y === prevYear) prevData[parseInt(m) - 1] = t.sales; }); for (let i = 0; i < 12; i++) { if (prevData[i] > 0) yoyData[i] = parseFloat(((currData[i] - prevData[i]) / prevData[i] * 100).toFixed(1)); } const C1 = 'var(--momo-warm-caramel, #c96442)'; const C2 = 'var(--momo-warm-olive, #6f7a4a)'; const CM = 'var(--momo-text-tertiary, #94a3b8)'; const tableHtml = ` 月份${months.map(m => `${m}`).join('')} ${currYear}${currData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} ${prevYear}${prevData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} YOY${yoyData.map((v, i) => `${(currData[i] > 0 || prevData[i] > 0) ? v + '%' : '-'}`).join('')}`; $(`#${tableBodyId}`).html(tableHtml); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: [currYear, prevYear, 'YOY'], bottom: 0, show: false }, grid: { left: compact ? '42' : '80', right: compact ? '16' : '50', top: compact ? '26' : '50', bottom: compact ? '28' : '20' }, xAxis: { type: 'category', data: months, axisLabel: { show: compact, interval: 0, fontSize: 10 }, axisTick: { show: true } }, yAxis: [ { type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, { type: 'value', name: compact ? '' : 'YoY', axisLabel: { formatter: '{value}%', fontSize: compact ? 10 : 12 }, splitLine: { show: false } } ], series: [ { name: currYear, type: 'bar', data: currData, barMaxWidth: compact ? 12 : 20, label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } }, { name: prevYear, type: 'bar', data: prevData, barMaxWidth: compact ? 12 : 20, label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } }, { name: 'YOY', type: 'line', yAxisIndex: 1, data: yoyData, smooth: true, lineStyle: { width: compact ? 1.5 : 2 }, symbol: 'circle', symbolSize: compact ? 4 : 6, label: { show: !compact, position: 'bottom', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? p.value + '%' : '' } } ] }); } // ── YoY Trend ───────────────────────────────────────── function renderYoYTrendChart(chart, tableId, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const dates = data.map(d => d.date.split('/')[1] + '月'); const currData = data.map(d => d.curr); const yoaData = data.map(d => d.yoa); const years = [...new Set(data.map(d => d.date.split('/')[0]))]; const currYearStr = years[0] || '本期'; let html = ` 月份${dates.map(m => `${m}`).join('')} ${currYearStr}${currData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} 去年同期${yoaData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')}`; $(`#${tableId}`).html(html); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: compact ? '42' : '60', right: compact ? '14' : '30', top: compact ? '24' : '40', bottom: compact ? '26' : '20' }, legend: { data: [currYearStr, '去年同期'], bottom: 0, show: !compact }, xAxis: { type: 'category', data: dates, axisLabel: { fontSize: compact ? 10 : 12 } }, yAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } }, series: [ { name: currYearStr, type: 'line', data: currData, lineStyle: { width: 3 }, showSymbol: false, areaStyle: { opacity: 0.12 } }, { name: '去年同期', type: 'line', data: yoaData, lineStyle: { type: 'dashed' }, showSymbol: false } ] }); } // ── 類別分佈 (雙圓餅) ───────────────────────────────── function renderDivisionDistChart(chart, _tableId, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const pie = key => data.map(d => ({ name: d.name, value: d[key] || 0 })).filter(d => d.value > 0); chart.setOption({ title: [ { text: '2024 年', left: '23%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } }, { text: '2025 年', left: '73%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } } ], tooltip: { trigger: 'item', formatter: p => `${p.name}
    業績: ${(p.value/10000).toFixed(0)}萬
    佔比: ${p.percent.toFixed(1)}%` }, legend: { type: 'scroll', orient: 'horizontal', bottom: 0, show: !compact, textStyle: { fontSize: 12, fontWeight: 'bold' } }, series: [ { name: '2024 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['25%', compact ? '53%' : '55%'], data: pie('sales_2024'), label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } }, { name: '2025 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['75%', compact ? '53%' : '55%'], data: pie('sales_2025'), label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } } ] }); } // ── 價格帶 ──────────────────────────────────────────── function renderPriceRangeChart(chart, tableId, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const order = ['0-499','500-999','1,000-1,999','2,000-4,999','5,000-9,999','10,000+']; data.sort((a,b) => order.indexOf(a.range) - order.indexOf(b.range)); const t24 = data.reduce((s, x) => s + (x.sales_2024 || 0), 0); const t25 = data.reduce((s, x) => s + (x.sales_2025 || 0), 0); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: ps => ps[0].name + '
    ' + ps.map(p => { const v = p.value || 0; const total = p.seriesName.includes('2024') ? t24 : t25; const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : '0.0%'; return `${p.marker} ${p.seriesName}: ${v.toLocaleString()} (${pct})`; }).join('
    ') }, legend: { data: ['2024 銷售', '2025 銷售'], bottom: 0, show: !compact, textStyle: { fontSize: 13, fontWeight: 'bold' } }, grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '18%' : '12%', top: compact ? '8%' : '15%', containLabel: true }, xAxis: { type: 'category', data: data.map(d => d.range), axisLabel: { interval: 0, rotate: compact ? 32 : 0, fontSize: compact ? 10 : 13, fontWeight: 'bold' } }, yAxis: { type: 'value', name: compact ? '' : '銷售額', nameTextStyle: { fontSize: 12, fontWeight: 'bold' }, axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } }, series: ['2024','2025'].map((y) => ({ name: y + ' 銷售', type: 'bar', barWidth: compact ? 13 : 30, data: data.map(d => d['sales_' + y] || 0), label: { show: !compact, position: 'top', fontSize: 12, fontWeight: 'bold', formatter: p => { const v = p.value || 0; const total = y === '2024' ? t24 : t25; const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : ''; return pct + '\n' + (v/10000).toFixed(0) + '萬'; } } })) }); $(`#${tableId}`).parent().html(''); } // ── BCG 矩陣(4 象限顯式配色) ─────────────────────── function renderBCGMatrixChart(chart, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const C_STAR = 'var(--momo-warm-olive, #6f7a4a)'; const C_COW = 'var(--momo-warm-honey, #c89043)'; const C_QUESTION = 'var(--momo-warm-caramel, #c96442)'; const C_DOG = 'var(--momo-warm-mahogany, #7a3b2c)'; chart.setOption({ tooltip: { formatter: p => `
    ${p.value[3]}
    毛利率: ${p.value[0]}%
    銷售額: $${p.value[1].toLocaleString()}
    銷量: ${p.value[2].toLocaleString()}
    ` }, grid: { left: compact ? '46' : '80', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '34' : '30' }, xAxis: { name: compact ? '' : '毛利率(%)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, yAxis: { name: compact ? '' : '業績($)', type: 'value', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, series: [{ type: 'scatter', data: data.map(d => [d.margin, d.sales, d.qty, d.name]), symbolSize: d => Math.min(compact ? 52 : 96, Math.max(compact ? 8 : 10, Math.sqrt(Math.max(d[2], 1)) * (compact ? 0.35 : 0.75))), itemStyle: { color: p => { if (p.value[0] >= 30 && p.value[1] >= 500000) return C_STAR; if (p.value[0] < 30 && p.value[1] >= 500000) return C_COW; if (p.value[0] >= 30 && p.value[1] < 500000) return C_QUESTION; return C_DOG; }, opacity: 0.78, borderColor: '#fff8ef', borderWidth: 1 }, markLine: { silent: true, data: [ { xAxis: 30, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高獲利線 (30%)' } }, { yAxis: 500000, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高營收線 ($50萬)' } } ] } }] }); } // ── 散佈圖(價/量) ────────────────────────────────── function renderScatterChart(chart, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const series = data.map(d => { const avg = d.qty > 0 ? Math.round(d.sales / d.qty) : 0; return [avg, d.qty, d.sales, d.name]; }); chart.setOption({ tooltip: { formatter: p => `
    ${p.value[3]}
    均價: $${p.value[0]}
    銷量: ${p.value[1].toLocaleString()}
    業績: $${p.value[2].toLocaleString()}
    ` }, grid: { left: compact ? '42' : '60', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '32' : '30' }, xAxis: { name: compact ? '' : '均價($)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { show: false } }, yAxis: { name: compact ? '' : '銷量', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, series: [{ type: 'scatter', data: series, symbolSize: d => Math.min(compact ? 36 : 64, Math.max(compact ? 6 : 8, Math.log(Math.max(d[2],1)) * (compact ? 1.35 : 2))), itemStyle: { opacity: 0.65 } }] }); } // ── 廠商排行 ────────────────────────────────────────── function renderVendorRankingChart(chart, tableId, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const cd = data.slice().reverse(); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: ['2024 銷售額', '2025 銷售額'], bottom: 0, show: !compact, textStyle: { fontSize: 15, fontWeight: 'bold' } }, grid: { left: compact ? '86' : '160', right: compact ? '18' : '110', top: compact ? '10' : '20', bottom: compact ? '28' : '45' }, xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'category', data: cd.map(d => d.name), axisLabel: { width: compact ? 78 : 150, overflow: 'truncate', interval: 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, series: [ { name: '2024 銷售額', type: 'bar', data: cd.map(d => d.sales_2024 || 0), barWidth: compact ? 10 : 18, barGap: '0%', barCategoryGap: compact ? '70%' : '75%', label: { show: !compact, position: 'insideRight', fontSize: 13, fontWeight: 'bold', color: '#fff8ef', textShadowColor: 'rgba(42,37,32,0.5)', textShadowBlur: 2, formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } }, { name: '2025 銷售額', type: 'bar', data: cd.map(d => d.sales_2025 || 0), barWidth: compact ? 10 : 18, label: { show: !compact, position: 'right', fontSize: 14, fontWeight: 'bold', formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } } ] }); let html = ` 排名廠商名稱總銷售額銷售額 YoY 2024 銷售額2025 銷售額 總毛利額毛利額 YoY 2024 毛利額2025 毛利額`; data.forEach((d, i) => { const s24 = d.sales_2024 || 0, s25 = d.sales_2025 || 0, p24 = d.profit_2024 || 0, p25 = d.profit_2025 || 0; const sYoY = s24 > 0 ? ((s25-s24)/s24*100).toFixed(1)+'%' : '-'; const pYoY = p24 > 0 ? ((p25-p24)/p24*100).toFixed(1)+'%' : '-'; const sCls = s24 > 0 && (s25-s24) >= 0 ? 'text-danger' : (s24 > 0 ? 'text-success' : ''); const pCls = p24 > 0 && (p25-p24) >= 0 ? 'text-danger' : (p24 > 0 ? 'text-success' : ''); html += ` ${i+1} ${d.name} ${d.sales.toLocaleString()} ${sYoY} ${s24.toLocaleString()} ${s25.toLocaleString()} ${d.profit.toLocaleString()} ${pYoY} ${p24.toLocaleString()} ${p25.toLocaleString()}`; }); html += ``; $(`#${tableId}`).parent().html(`${html}
    `); } // ── 季節熱力圖 ──────────────────────────────────────── function renderSeasonalityHeatmap(chart, data) { if (!data || !data.length) return; const compact = isCompactViewport(); const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']; const areaSales = {}; data.forEach(d => { areaSales[d.category] = (areaSales[d.category] || 0) + d.sales; }); const areas = Object.keys(areaSales).sort((a,b) => areaSales[b] - areaSales[a]); const yLabels = []; [2025, 2024].forEach(y => areas.forEach(a => yLabels.push(`${y} ${a}`))); const heatmap = []; data.forEach(d => { const xi = d.month - 1, yi = yLabels.indexOf(`${d.year} ${d.category}`); if (xi >= 0 && xi < 12 && yi >= 0) heatmap.push([xi, yi, d.sales]); }); const max = Math.max(...data.map(d => d.sales), 1); chart.setOption({ tooltip: { position: 'top', formatter: p => `${yLabels[p.value[1]]}
    ${months[p.value[0]]}
    業績: ${(p.value[2]/10000).toFixed(0)}萬` }, grid: { height: compact ? '72%' : '75%', top: compact ? '3%' : '5%', bottom: compact ? '20%' : '18%', left: compact ? '72' : '15%', right: compact ? '5' : '3%' }, xAxis: { type: 'category', data: months, splitArea: { show: true }, axisLabel: { fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'category', data: yLabels, splitArea: { show: true }, axisLabel: { width: compact ? 66 : 120, overflow: 'truncate', fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, visualMap: { min: 0, max, calculable: true, orient: 'horizontal', left: 'center', bottom: '0%', textStyle: { fontSize: compact ? 10 : 12 }, inRange: { color: ['#fef3c7', '#f5c98a', '#e3a560', '#c96442', '#7a3b2c'] } }, series: [{ type: 'heatmap', data: heatmap, label: { show: !compact, fontSize: 12, fontWeight: 'bold', formatter: p => (p.value[2]/10000).toFixed(0) + '萬' }, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.45)' } } }] }); } // ── 區域排行 ────────────────────────────────────────── function renderAreaRankingChart(chart, tableId, data) { if (!data || !data.length) return; const compact = isCompactViewport(); data.sort((a,b) => b.sales - a.sales); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: ps => ps[0].name + '
    ' + ps.map(p => `${p.marker} ${p.seriesName}: ${(p.value || 0).toLocaleString()}`).join('
    ') }, legend: { data: ['2024 業績', '2025 業績'], bottom: 0, show: !compact, textStyle: { fontSize: 14 } }, grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '20%' : '10%', top: compact ? '8%' : '10%', containLabel: true }, xAxis: { type: 'category', data: data.map(d => d.name), axisLabel: { interval: 0, rotate: compact ? 28 : 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 13 } }, series: ['2024','2025'].map(y => ({ name: y + ' 業績', type: 'bar', data: data.map(d => d['sales_' + y] || 0), label: { show: !compact, position: 'top', fontSize: 14, fontWeight: 'bold', formatter: p => (p.value/10000).toFixed(0) + '萬' } })) }); let html = `排名區名稱業績`; data.forEach((d, i) => { html += `${i+1}${d.name}$${d.sales.toLocaleString()}`; }); html += ``; $(`#${tableId}`).parent().html(`${html}
    `); } // 公開 fetchData 給 onclick 使用 window.fetchData = fetchData; })();