Add HTML Support for Word Clouds

This commit is contained in:
马一丁
2025-11-22 01:47:17 +08:00
parent f67af71e96
commit b4b749b4c7
2 changed files with 266 additions and 2 deletions
+265 -2
View File
@@ -329,6 +329,7 @@ class HTMLRenderer:
html2canvas = self._load_lib("html2canvas.min.js")
jspdf = self._load_lib("jspdf.umd.min.js")
mathjax = self._load_lib("mathjax.js")
wordcloud2 = self._load_lib("wordcloud2.min.js")
# 生成嵌入式script标签,并为每个库添加CDN fallback机制
# Chart.js - 主要图表库
@@ -347,6 +348,14 @@ class HTMLRenderer:
lib_name="chartjs-chart-sankey"
)
# wordcloud2 - 词云渲染
wordcloud_tag = self._build_script_with_fallback(
inline_code=wordcloud2,
cdn_url="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.2.2/wordcloud2.min.js",
check_expression="typeof WordCloud !== 'undefined'",
lib_name="wordcloud2"
)
# html2canvas - 用于截图
html2canvas_tag = self._build_script_with_fallback(
inline_code=html2canvas,
@@ -383,6 +392,7 @@ class HTMLRenderer:
<title>{self._escape_html(title)}</title>
{chartjs_tag}
{sankey_tag}
{wordcloud_tag}
{html2canvas_tag}
{jspdf_tag}
<script>
@@ -1526,6 +1536,7 @@ class HTMLRenderer:
# 统计
widget_type = block.get('widgetType', '')
is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js')
is_wordcloud = isinstance(widget_type, str) and 'wordcloud' in widget_type.lower()
if is_chart:
self.chart_validation_stats['total'] += 1
@@ -1590,9 +1601,13 @@ class HTMLRenderer:
title = props.get("title")
title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else ""
fallback_html = self._render_widget_fallback(normalized_data, block.get("widgetId"))
fallback_html = (
self._render_wordcloud_fallback(props, block.get("widgetId"))
if is_wordcloud
else self._render_widget_fallback(normalized_data, block.get("widgetId"))
)
return f"""
<div class="chart-card">
<div class="chart-card{' wordcloud-card' if is_wordcloud else ''}">
{title_html}
<div class="chart-container">
<canvas id="{canvas_id}" data-config-id="{config_id}"></canvas>
@@ -1637,6 +1652,51 @@ class HTMLRenderer:
"""
return table_html
def _render_wordcloud_fallback(self, props: Dict[str, Any] | None, widget_id: str | None = None) -> str:
"""为词云提供表格兜底,避免WordCloud渲染失败后页面空白"""
words = []
if isinstance(props, dict):
raw = props.get("data")
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
text = item.get("word") or item.get("text") or item.get("label")
weight = item.get("weight")
category = item.get("category") or ""
if text:
words.append({"word": str(text), "weight": weight, "category": str(category)})
if not words:
return ""
def _format_weight(value: Any) -> str:
if isinstance(value, (int, float)) and not isinstance(value, bool):
if 0 <= value <= 1.5:
return f"{value * 100:.1f}%"
return f"{value:.2f}".rstrip("0").rstrip(".")
return str(value)
widget_attr = f' data-widget-id="{self._escape_attr(widget_id)}"' if widget_id else ""
rows = "".join(
f"<tr><td>{self._escape_html(item['word'])}</td>"
f"<td>{self._escape_html(_format_weight(item['weight']))}</td>"
f"<td>{self._escape_html(item['category'] or '-')}</td></tr>"
for item in words
)
return f"""
<div class="chart-fallback" data-prebuilt="true"{widget_attr}>
<table>
<thead>
<tr><th>关键词</th><th>权重</th><th>类别</th></tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
"""
def _log_chart_validation_stats(self):
"""输出图表验证统计信息"""
stats = self.chart_validation_stats
@@ -2498,6 +2558,9 @@ table th {{
border-radius: 12px;
background: rgba(0,0,0,0.01);
}}
.chart-card.wordcloud-card .chart-container {{
min-height: 260px;
}}
.chart-container {{
position: relative;
min-height: 320px;
@@ -2652,6 +2715,7 @@ document.documentElement.classList.remove('no-js');
document.documentElement.classList.add('js-ready');
const chartRegistry = [];
const wordCloudRegistry = new Map();
const STABLE_CHART_TYPES = ['line', 'bar'];
const CHART_TYPE_LABELS = {
line: '折线图',
@@ -2684,6 +2748,12 @@ const CSS_VAR_COLOR_MAP = {
'var(--color-primary)': '#3498DB',
'var(--color-secondary)': '#95A5A6'
};
const WORDCLOUD_CATEGORY_COLORS = {
positive: '#10b981',
negative: '#ef4444',
neutral: '#6b7280',
controversial: '#f59e0b'
};
function normalizeColorToken(color) {
if (typeof color !== 'string') return color;
@@ -2939,6 +3009,170 @@ function clearChartDegradeNote(card) {
}
}
function isWordCloudWidget(payload) {
const type = payload && payload.widgetType;
return typeof type === 'string' && type.toLowerCase().includes('wordcloud');
}
function normalizeWordcloudItems(payload) {
const source = payload && payload.props && payload.props.data;
if (!Array.isArray(source)) return [];
return source.map(item => {
if (!item || typeof item !== 'object') return null;
const word = item.word || item.text || item.label;
if (!word) return null;
const rawWeight = item.weight;
let weight = 0;
if (typeof rawWeight === 'number' && !Number.isNaN(rawWeight)) {
weight = rawWeight;
} else if (typeof rawWeight === 'string') {
const parsed = parseFloat(rawWeight);
weight = Number.isNaN(parsed) ? 0 : parsed;
}
const category = (item.category || '').toString().toLowerCase();
return { word: String(word), weight, category };
}).filter(Boolean);
}
function wordcloudColor(category) {
const key = typeof category === 'string' ? category.toLowerCase() : '';
return WORDCLOUD_CATEGORY_COLORS[key] || '#334155';
}
function renderWordCloudFallback(canvas, items, reason) {
const card = canvas.closest('.chart-card') || canvas.parentElement;
if (!card) return;
const wrapper = canvas.parentElement && canvas.parentElement.classList && canvas.parentElement.classList.contains('chart-container')
? canvas.parentElement
: null;
if (wrapper) {
wrapper.style.display = 'none';
} else {
canvas.style.display = 'none';
}
let fallback = card.querySelector('.chart-fallback');
if (!fallback) {
fallback = document.createElement('div');
fallback.className = 'chart-fallback wordcloud-fallback';
fallback.setAttribute('data-dynamic', 'true');
card.appendChild(fallback);
}
fallback.style.display = 'block';
card.setAttribute('data-chart-state', 'fallback');
if (reason) {
let notice = fallback.querySelector('.chart-fallback__notice');
if (!notice) {
notice = document.createElement('p');
notice.className = 'chart-fallback__notice';
fallback.insertBefore(notice, fallback.firstChild || null);
}
notice.textContent = `词云未能渲染${reason ? `${reason}` : ''},已展示数据表。`;
}
if (fallback.querySelector('table')) {
return;
}
if (!items || !items.length) {
const empty = document.createElement('p');
empty.textContent = '暂无可用数据。';
fallback.appendChild(empty);
return;
}
const table = document.createElement('table');
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['关键词', '权重', '类别'].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
items.forEach(item => {
const row = document.createElement('tr');
const wordCell = document.createElement('td');
wordCell.textContent = item.word;
const weightCell = document.createElement('td');
if (typeof item.weight === 'number' && !Number.isNaN(item.weight)) {
weightCell.textContent = item.weight >= 0 && item.weight <= 1.5
? `${(item.weight * 100).toFixed(1)}%`
: item.weight.toFixed(2).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, '');
} else {
weightCell.textContent = item.weight !== undefined && item.weight !== null ? String(item.weight) : '';
}
const categoryCell = document.createElement('td');
categoryCell.textContent = item.category || '';
categoryCell.style.color = wordcloudColor(item.category);
row.appendChild(wordCell);
row.appendChild(weightCell);
row.appendChild(categoryCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
fallback.appendChild(table);
}
function renderWordCloud(canvas, payload, skipRegistry) {
const items = normalizeWordcloudItems(payload);
const card = canvas.closest('.chart-card') || canvas.parentElement;
const container = canvas.parentElement && canvas.parentElement.classList && canvas.parentElement.classList.contains('chart-container')
? canvas.parentElement
: null;
if (!items.length) {
renderWordCloudFallback(canvas, items, '无有效数据');
return;
}
if (typeof WordCloud === 'undefined') {
renderWordCloudFallback(canvas, items, '词云依赖未加载');
return;
}
const dpr = Math.max(1, window.devicePixelRatio || 1);
const width = Math.max(240, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320));
const height = Math.max(180, Math.round(width * 0.62));
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1;
const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]);
try {
WordCloud(canvas, {
list,
gridSize: Math.max(8, Math.floor(Math.sqrt(canvas.width * canvas.height) / 80)),
weightFactor: (val) => {
const normalized = Math.max(0, val) / maxWeight;
const size = 16 + normalized * 32;
return size * dpr;
},
color: (word) => {
const found = items.find(entry => entry.word === word);
return lightenColor(wordcloudColor(found && found.category), 0.05);
},
rotateRatio: 0.15,
shuffle: false,
shrinkToFit: true,
drawOutOfBound: false,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--card-bg').trim() || '#fff'
});
if (container) {
container.style.display = '';
container.style.minHeight = `${height}px`;
}
const fallback = card && card.querySelector('.chart-fallback');
if (fallback) {
fallback.style.display = 'none';
}
card && card.removeAttribute('data-chart-state');
if (!skipRegistry) {
wordCloudRegistry.set(canvas, () => renderWordCloud(canvas, payload, true));
}
} catch (err) {
console.error('WordCloud 渲染失败', err);
renderWordCloudFallback(canvas, items, err && err.message ? err.message : '');
}
}
function createFallbackTable(labels, datasets) {
if (!Array.isArray(datasets) || !datasets.length) {
return null;
@@ -3168,6 +3402,14 @@ function instantiateChart(ctx, payload, optionsTemplate, type) {
return new Chart(ctx, config);
}
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(null, args), wait || 200);
};
}
function hydrateCharts() {
document.querySelectorAll('canvas[data-config-id]').forEach(canvas => {
const configScript = document.getElementById(canvas.dataset.configId);
@@ -3180,6 +3422,10 @@ function hydrateCharts() {
renderChartFallback(canvas, { widgetId: canvas.dataset.configId }, '配置解析失败');
return;
}
if (isWordCloudWidget(payload)) {
renderWordCloud(canvas, payload);
return;
}
if (typeof Chart === 'undefined') {
renderChartFallback(canvas, payload, 'Chart.js 未加载');
return;
@@ -3326,6 +3572,15 @@ function exportPdf() {
chart.resize();
}
});
wordCloudRegistry.forEach(fn => {
if (typeof fn === 'function') {
try {
fn();
} catch (err) {
console.error('词云重新渲染失败', err);
}
}
});
renderTask = pdf.html(target, {
x: 8,
y: 12,
@@ -3398,6 +3653,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (exportBtn) {
exportBtn.addEventListener('click', exportPdf);
}
const rerenderWordclouds = debounce(() => {
wordCloudRegistry.forEach(fn => {
if (typeof fn === 'function') {
fn();
}
});
}, 260);
window.addEventListener('resize', rerenderWordclouds);
hydrateCharts();
});
</script>