# -*- coding: utf-8 -*-
"""
ECharts 图表构建器:生成内联 ECharts JS 代码和静态库嵌入。
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("voc.echarts")
# ECharts CDN(用于下载内联)
ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"
CACHE_FILE = Path(__file__).resolve().parent / ".echarts_cache.js"
def get_echarts_script(inline: bool = True) -> str:
"""返回 ECharts 库的 '
# 内联模式:从缓存或下载
if CACHE_FILE.is_file():
js = CACHE_FILE.read_text(encoding="utf-8")
logger.info("使用缓存的 ECharts (%s KB)", len(js) // 1024)
return f""
# 下载
logger.info("下载 ECharts 库...")
import urllib.request
try:
with urllib.request.urlopen(ECHARTS_CDN, timeout=30) as resp:
js = resp.read().decode("utf-8")
CACHE_FILE.write_text(js, encoding="utf-8")
logger.info("ECharts 已缓存 (%s KB)", len(js) // 1024)
return f""
except Exception as e:
logger.warning("下载 ECharts 失败: %s,降级到 CDN", e)
return f''
# ── 图表 JS 生成 ──
ASIN_COLORS_BAR = ["#ef4444", "#f97316", "#eab308", "#8b5cf6", "#3b82f6"]
ASIN_COLORS_POS = ["#22c55e", "#86efac", "#4ade80", "#2dd4bf", "#60a5fa"]
def build_star_dist_chart(asins: List[str], star_data: Dict[str, Dict[int, int]]) -> str:
"""评分分布堆叠柱状图。"""
datasets = []
colors = ["#22c55e", "#86efac", "#d1d5db", "#fbbf24", "#ef4444"]
for star in [5, 4, 3, 2, 1]:
datasets.append({
"name": f"{star}★",
"type": "bar",
"stack": "total",
"data": [star_data.get(a, {}).get(star, 0) for a in asins],
"itemStyle": {"color": colors[5 - star]},
})
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"bottom": 0, "textStyle": {"fontSize": 11}},
"grid": {"left": 50, "right": 20, "top": 20, "bottom": 40},
"xAxis": {"type": "category", "data": asins, "axisLabel": {"fontSize": 10}},
"yAxis": {"type": "value"},
"series": datasets,
}
return f"""new Chart(echarts.init(document.getElementById('starDistChart')), {{
type: 'bar',
data: {json.dumps(asins)},
datasets: {json.dumps(datasets, ensure_ascii=False)},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ position: 'bottom', labels: {{ font: {{ size: 11 }} }} }} }},
scales: {{ x: {{ stacked: true }}, y: {{ stacked: true, beginAtZero: true }} }}
}}
}});
// ECharts 版本:
(function() {{
var dom = document.getElementById('starDistChart');
var chart = echarts.init(dom);
chart.setOption({json.dumps(option, ensure_ascii=False)});
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def build_horizontal_bar_chart(
element_id: str,
labels: List[str],
data: List[int],
colors: List[str] | None = None,
height: int = 360,
*,
asin_category_axis: Optional[str] = None,
) -> str:
"""水平柱状图。asin_category_axis='y' 时 Y 轴短码 tooltip 显示 ASIN 链接。"""
if colors is None:
colors = ["#ef4444"] * len(labels)
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"grid": {"left": 150, "right": 40, "top": 10, "bottom": 20},
"xAxis": {"type": "value"},
"yAxis": {"type": "category", "data": labels, "inverse": True,
"axisLabel": {"fontSize": 11}},
"series": [{
"type": "bar",
"data": [{"value": v, "itemStyle": {"color": c}} for v, c in zip(data, colors)],
"label": {"show": True, "position": "right", "fontSize": 10},
}],
}
return _echarts_init(element_id, option, height=height, asin_category_axis=asin_category_axis)
def build_asin_mini_chart(
element_id: str,
labels: List[str],
data: List[int],
color: str = "#ef4444",
) -> str:
"""单个 ASIN 的迷你柱状图。"""
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"grid": {"left": 100, "right": 30, "top": 5, "bottom": 15},
"xAxis": {"type": "value", "axisLabel": {"fontSize": 9}},
"yAxis": {"type": "category", "data": labels, "inverse": True,
"axisLabel": {"fontSize": 9}},
"series": [{
"type": "bar",
"data": [{"value": v, "itemStyle": {"color": color}} for v in data],
}],
}
return f"""(function() {{
var dom = document.getElementById('{element_id}');
var chart = echarts.init(dom);
chart.setOption({json.dumps(option, ensure_ascii=False)});
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def build_grouped_bar_chart(
element_id: str,
labels: List[str],
datasets: List[Dict[str, Any]],
) -> str:
"""分组柱状图(多 ASIN 对比)。"""
series = [
{
"name": ds["label"],
"type": "bar",
"data": ds["data"],
"itemStyle": {"color": ds.get("color", "#3b82f6")},
}
for ds in datasets
]
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"bottom": 0, "textStyle": {"fontSize": 10}},
"grid": {"left": 50, "right": 20, "top": 20, "bottom": 40},
"xAxis": {"type": "category", "data": labels, "axisLabel": {"fontSize": 10, "rotate": 20}},
"yAxis": {"type": "value"},
"series": series,
}
return f"""(function() {{
var dom = document.getElementById('{element_id}');
var chart = echarts.init(dom);
chart.setOption({json.dumps(option, ensure_ascii=False)});
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
# ── 布局与图表类型阈值 ──
THEME_COLORS_NEG = ["#ef4444", "#f97316", "#eab308", "#dc2626", "#b91c1c", "#fb923c"]
THEME_COLORS_POS = ["#22c55e", "#86efac", "#4ade80", "#2dd4bf", "#60a5fa", "#16a34a"]
def get_chart_layout(stats: Any, cfg: Optional[dict] = None) -> Dict[str, Any]:
"""根据 ASIN 数量计算图表布局参数。"""
cfg = cfg or {}
n = len(getattr(stats, "asins", []) or [])
large_th = int(cfg.get("large_asin_threshold", 12))
heatmap_th = int(cfg.get("heatmap_asin_threshold", 30))
large = n > large_th
heatmap = n > heatmap_th
return {
"asin_count": n,
"large_market": large,
"use_heatmap": heatmap,
"star_chart_height": max(320, n * 22) if large else 300,
"star_summary_height": 280,
"show_star_summary": large and n > 15,
"neg_theme_height": 480 if heatmap else (420 if large else 360),
"pos_theme_height": 480 if heatmap else (420 if large else 360),
}
# ── ASIN 短码 tooltip(悬浮显示代称 + 可点击 ASIN)──
def build_asin_tooltip_map(asin_short_codes: Dict[str, str]) -> Dict[str, Dict[str, str]]:
"""ASIN 短码 A → {asin, url}。"""
from data_loader import build_amazon_url
return {
short: {"asin": asin, "url": build_amazon_url(asin)}
for asin, short in asin_short_codes.items()
}
def build_asin_tooltip_bootstrap(asin_short_codes: Dict[str, str]) -> str:
"""注入全局 VOC_ASIN_MAP 与 axis tooltip formatter。"""
map_json = json.dumps(build_asin_tooltip_map(asin_short_codes), ensure_ascii=False)
return f"""// ASIN 短码 tooltip(悬浮:A · B0XXX 可点击跳转亚马逊)
window.VOC_ASIN_MAP = {map_json};
window.vocAsinTooltipHeader = function(code) {{
if (!code || String(code).indexOf('其余') === 0) return code;
var info = (window.VOC_ASIN_MAP || {{}})[code];
if (info && info.asin) {{
return code + ' · ' + info.asin + '';
}}
return code;
}};
window.vocApplyAsinAxisTooltip = function(opt, axisKey) {{
axisKey = axisKey || 'x';
var axis = axisKey === 'y' ? opt.yAxis : opt.xAxis;
if (!axis || axis.type !== 'category') return;
opt.tooltip = opt.tooltip || {{}};
opt.tooltip.trigger = 'axis';
opt.tooltip.enterable = true;
opt.tooltip.confine = true;
opt.tooltip.axisPointer = opt.tooltip.axisPointer || {{type: 'shadow'}};
opt.tooltip.formatter = function(params) {{
var items = Array.isArray(params) ? params : [params];
if (!items.length) return '';
var code = items[0].axisValue != null ? items[0].axisValue : (items[0].name || '');
var lines = [window.vocAsinTooltipHeader(code)];
items.forEach(function(p) {{
if (p.seriesName == null || p.value == null) return;
var v = p.value;
if (Array.isArray(v)) v = v[v.length - 1];
if (v && typeof v === 'object' && v.value != null) v = v.value;
lines.push((p.marker || '') + p.seriesName + ': ' + v);
}});
return lines.join('
');
}};
}};"""
def _echarts_init(
element_id: str,
option: dict,
height: Optional[int] = None,
*,
asin_category_axis: Optional[str] = None,
) -> str:
h_js = f"dom.style.height='{height}px';" if height else ""
apply_js = ""
if asin_category_axis:
apply_js = f"if (window.vocApplyAsinAxisTooltip) window.vocApplyAsinAxisTooltip(opt, '{asin_category_axis}');"
return f"""(function() {{
var dom = document.getElementById('{element_id}');
if (!dom) return;
{h_js}
var opt = {json.dumps(option, ensure_ascii=False)};
{apply_js}
var chart = echarts.init(dom);
chart.setOption(opt);
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def _aggregate_star_dist(asin_list: List[str], star_data: Dict[str, Dict[int, int]]) -> Dict[int, int]:
agg: Dict[int, int] = {s: 0 for s in (5, 4, 3, 2, 1)}
for asin in asin_list:
sd = star_data.get(asin, {})
for s in agg:
agg[s] += sd.get(s, 0)
return agg
def build_overall_charts(
neg_freq: dict,
pos_freq: dict,
neg_themes: list,
) -> str:
"""生成主题频次画像区的全市场图表(negChartOverall / posChartOverall)。"""
priority_color = {"P0": "#ef4444", "P1": "#f97316", "P2": "#eab308"}
theme_priority = {t["name"]: t.get("priority", "P2") for t in neg_themes}
neg_sorted = sorted(neg_freq.items(), key=lambda x: x[1], reverse=True)
pos_sorted = sorted(pos_freq.items(), key=lambda x: x[1], reverse=True)
neg_labels = [n for n, _ in neg_sorted]
neg_data = [c for _, c in neg_sorted]
pos_labels = [n for n, _ in pos_sorted]
pos_data = [c for _, c in pos_sorted]
neg_colors = [priority_color.get(theme_priority.get(n, "P2"), "#94a3b8") for n in neg_labels]
parts = []
parts.append("// 整体主题频次 - 差评")
parts.append(build_horizontal_bar_chart("negChartOverall", neg_labels, neg_data, neg_colors))
parts.append("// 整体主题频次 - 好评")
parts.append(build_horizontal_bar_chart("posChartOverall", pos_labels, pos_data, ["#22c55e"] * len(pos_labels)))
return "\n\n".join(parts)
def build_theme_mini_charts_html(
prefix: str,
theme_labels: List[str],
asin_count: int,
*,
large_threshold: int = 30,
) -> str:
"""大品类:每主题 Top N ASIN 小 multiples 的 HTML 容器。"""
if not theme_labels or asin_count <= large_threshold:
return ""
blocks = ['