Improved Support for Word Clouds
This commit is contained in:
@@ -167,6 +167,11 @@ class ChartToSVGConverter:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# 根据图表类型调用相应的渲染方法
|
# 根据图表类型调用相应的渲染方法
|
||||||
|
if 'wordcloud' in str(chart_type).lower():
|
||||||
|
# 词云由专用渲染逻辑处理,这里跳过SVG转换以避免告警
|
||||||
|
logger.debug("检测到词云图表,跳过chart_to_svg转换")
|
||||||
|
return None
|
||||||
|
|
||||||
render_method = getattr(self, f'_render_{chart_type}', None)
|
render_method = getattr(self, f'_render_{chart_type}', None)
|
||||||
if not render_method:
|
if not render_method:
|
||||||
logger.warning(f"不支持的图表类型: {chart_type}")
|
logger.warning(f"不支持的图表类型: {chart_type}")
|
||||||
|
|||||||
@@ -1628,57 +1628,61 @@ class HTMLRenderer:
|
|||||||
if is_chart:
|
if is_chart:
|
||||||
self.chart_validation_stats['total'] += 1
|
self.chart_validation_stats['total'] += 1
|
||||||
|
|
||||||
# 如果此前已记录失败,直接使用占位提示,避免重复修复
|
# 词云使用专用渲染逻辑,不按Chart.js规则验证,直接跳过防止误判
|
||||||
has_failed, cached_reason = self._has_chart_failure(block)
|
if is_wordcloud:
|
||||||
if has_failed:
|
|
||||||
self._record_chart_failure_stat(cache_key)
|
|
||||||
reason = cached_reason or "LLM返回的图表信息格式有误,无法正常显示"
|
|
||||||
return self._render_chart_error_placeholder(display_title, reason, widget_id)
|
|
||||||
|
|
||||||
# 验证图表数据
|
|
||||||
validation_result = self.chart_validator.validate(block)
|
|
||||||
|
|
||||||
if not validation_result.is_valid:
|
|
||||||
logger.warning(
|
|
||||||
f"图表 {block.get('widgetId', 'unknown')} 验证失败: {validation_result.errors}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 尝试修复
|
|
||||||
repair_result = self.chart_repairer.repair(block, validation_result)
|
|
||||||
|
|
||||||
if repair_result.success and repair_result.repaired_block:
|
|
||||||
# 修复成功,使用修复后的数据
|
|
||||||
block = repair_result.repaired_block
|
|
||||||
logger.info(
|
|
||||||
f"图表 {block.get('widgetId', 'unknown')} 修复成功 "
|
|
||||||
f"(方法: {repair_result.method}): {repair_result.changes}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新统计
|
|
||||||
if repair_result.method == 'local':
|
|
||||||
self.chart_validation_stats['repaired_locally'] += 1
|
|
||||||
elif repair_result.method == 'api':
|
|
||||||
self.chart_validation_stats['repaired_api'] += 1
|
|
||||||
else:
|
|
||||||
# 修复失败,记录失败并输出占位提示
|
|
||||||
fail_reason = self._format_chart_error_reason(validation_result)
|
|
||||||
block["_chart_renderable"] = False
|
|
||||||
block["_chart_error_reason"] = fail_reason
|
|
||||||
self._note_chart_failure(cache_key, fail_reason)
|
|
||||||
self._record_chart_failure_stat(cache_key)
|
|
||||||
logger.warning(
|
|
||||||
f"图表 {block.get('widgetId', 'unknown')} 修复失败,已跳过渲染: {fail_reason}"
|
|
||||||
)
|
|
||||||
return self._render_chart_error_placeholder(display_title, fail_reason, widget_id)
|
|
||||||
else:
|
|
||||||
# 验证通过
|
|
||||||
self.chart_validation_stats['valid'] += 1
|
self.chart_validation_stats['valid'] += 1
|
||||||
if validation_result.warnings:
|
else:
|
||||||
logger.info(
|
# 如果此前已记录失败,直接使用占位提示,避免重复修复
|
||||||
f"图表 {block.get('widgetId', 'unknown')} 验证通过,"
|
has_failed, cached_reason = self._has_chart_failure(block)
|
||||||
f"但有警告: {validation_result.warnings}"
|
if has_failed:
|
||||||
|
self._record_chart_failure_stat(cache_key)
|
||||||
|
reason = cached_reason or "LLM返回的图表信息格式有误,无法正常显示"
|
||||||
|
return self._render_chart_error_placeholder(display_title, reason, widget_id)
|
||||||
|
|
||||||
|
# 验证图表数据
|
||||||
|
validation_result = self.chart_validator.validate(block)
|
||||||
|
|
||||||
|
if not validation_result.is_valid:
|
||||||
|
logger.warning(
|
||||||
|
f"图表 {block.get('widgetId', 'unknown')} 验证失败: {validation_result.errors}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 尝试修复
|
||||||
|
repair_result = self.chart_repairer.repair(block, validation_result)
|
||||||
|
|
||||||
|
if repair_result.success and repair_result.repaired_block:
|
||||||
|
# 修复成功,使用修复后的数据
|
||||||
|
block = repair_result.repaired_block
|
||||||
|
logger.info(
|
||||||
|
f"图表 {block.get('widgetId', 'unknown')} 修复成功 "
|
||||||
|
f"(方法: {repair_result.method}): {repair_result.changes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新统计
|
||||||
|
if repair_result.method == 'local':
|
||||||
|
self.chart_validation_stats['repaired_locally'] += 1
|
||||||
|
elif repair_result.method == 'api':
|
||||||
|
self.chart_validation_stats['repaired_api'] += 1
|
||||||
|
else:
|
||||||
|
# 修复失败,记录失败并输出占位提示
|
||||||
|
fail_reason = self._format_chart_error_reason(validation_result)
|
||||||
|
block["_chart_renderable"] = False
|
||||||
|
block["_chart_error_reason"] = fail_reason
|
||||||
|
self._note_chart_failure(cache_key, fail_reason)
|
||||||
|
self._record_chart_failure_stat(cache_key)
|
||||||
|
logger.warning(
|
||||||
|
f"图表 {block.get('widgetId', 'unknown')} 修复失败,已跳过渲染: {fail_reason}"
|
||||||
|
)
|
||||||
|
return self._render_chart_error_placeholder(display_title, fail_reason, widget_id)
|
||||||
|
else:
|
||||||
|
# 验证通过
|
||||||
|
self.chart_validation_stats['valid'] += 1
|
||||||
|
if validation_result.warnings:
|
||||||
|
logger.info(
|
||||||
|
f"图表 {block.get('widgetId', 'unknown')} 验证通过,"
|
||||||
|
f"但有警告: {validation_result.warnings}"
|
||||||
|
)
|
||||||
|
|
||||||
# 渲染图表HTML
|
# 渲染图表HTML
|
||||||
self.chart_counter += 1
|
self.chart_counter += 1
|
||||||
canvas_id = f"chart-{self.chart_counter}"
|
canvas_id = f"chart-{self.chart_counter}"
|
||||||
@@ -1700,7 +1704,7 @@ class HTMLRenderer:
|
|||||||
title = props.get("title")
|
title = props.get("title")
|
||||||
title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else ""
|
title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else ""
|
||||||
fallback_html = (
|
fallback_html = (
|
||||||
self._render_wordcloud_fallback(props, block.get("widgetId"))
|
self._render_wordcloud_fallback(props, block.get("widgetId"), block.get("data"))
|
||||||
if is_wordcloud
|
if is_wordcloud
|
||||||
else self._render_widget_fallback(normalized_data, block.get("widgetId"))
|
else self._render_widget_fallback(normalized_data, block.get("widgetId"))
|
||||||
)
|
)
|
||||||
@@ -1750,20 +1754,57 @@ class HTMLRenderer:
|
|||||||
"""
|
"""
|
||||||
return table_html
|
return table_html
|
||||||
|
|
||||||
def _render_wordcloud_fallback(self, props: Dict[str, Any] | None, widget_id: str | None = None) -> str:
|
def _render_wordcloud_fallback(
|
||||||
|
self,
|
||||||
|
props: Dict[str, Any] | None,
|
||||||
|
widget_id: str | None = None,
|
||||||
|
block_data: Any | None = None,
|
||||||
|
) -> str:
|
||||||
"""为词云提供表格兜底,避免WordCloud渲染失败后页面空白"""
|
"""为词云提供表格兜底,避免WordCloud渲染失败后页面空白"""
|
||||||
words = []
|
def _collect_items(raw: Any) -> list[dict]:
|
||||||
if isinstance(props, dict):
|
collected: list[dict] = []
|
||||||
raw = props.get("data")
|
|
||||||
if isinstance(raw, list):
|
if isinstance(raw, list):
|
||||||
for item in raw:
|
for item in raw:
|
||||||
if not isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
continue
|
text = item.get("word") or item.get("text") or item.get("label")
|
||||||
text = item.get("word") or item.get("text") or item.get("label")
|
weight = item.get("weight")
|
||||||
weight = item.get("weight")
|
category = item.get("category") or ""
|
||||||
category = item.get("category") or ""
|
if text:
|
||||||
if text:
|
collected.append({"word": str(text), "weight": weight, "category": str(category)})
|
||||||
words.append({"word": str(text), "weight": weight, "category": str(category)})
|
elif isinstance(item, (list, tuple)) and item:
|
||||||
|
text = item[0]
|
||||||
|
weight = item[1] if len(item) > 1 else None
|
||||||
|
category = item[2] if len(item) > 2 else ""
|
||||||
|
if text:
|
||||||
|
collected.append({"word": str(text), "weight": weight, "category": str(category)})
|
||||||
|
elif isinstance(item, str):
|
||||||
|
collected.append({"word": item, "weight": 1.0, "category": ""})
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
if not {"labels", "datasets"}.intersection(raw.keys()):
|
||||||
|
for text, weight in raw.items():
|
||||||
|
collected.append({"word": str(text), "weight": weight, "category": ""})
|
||||||
|
return collected
|
||||||
|
|
||||||
|
words: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
candidates = []
|
||||||
|
if isinstance(props, dict):
|
||||||
|
for key in ("data", "items", "words"):
|
||||||
|
if key in props:
|
||||||
|
candidates.append(props[key])
|
||||||
|
candidates.append((props or {}).get("sourceData"))
|
||||||
|
|
||||||
|
# 允许使用block.data兜底,避免缺失props时出现空白
|
||||||
|
if block_data is not None:
|
||||||
|
candidates.append(block_data)
|
||||||
|
|
||||||
|
for raw in candidates:
|
||||||
|
for item in _collect_items(raw):
|
||||||
|
key = f"{item['word']}::{item.get('category','')}"
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
words.append(item)
|
||||||
|
|
||||||
if not words:
|
if not words:
|
||||||
return ""
|
return ""
|
||||||
@@ -2712,11 +2753,11 @@ table th {{
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}}
|
}}
|
||||||
.chart-card.wordcloud-card .chart-container {{
|
.chart-card.wordcloud-card .chart-container {{
|
||||||
min-height: 260px;
|
min-height: 180px;
|
||||||
}}
|
}}
|
||||||
.chart-container {{
|
.chart-container {{
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 320px;
|
min-height: 220px;
|
||||||
}}
|
}}
|
||||||
.chart-fallback {{
|
.chart-fallback {{
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2743,6 +2784,31 @@ table th {{
|
|||||||
.chart-fallback th {{
|
.chart-fallback th {{
|
||||||
background: rgba(0,0,0,0.04);
|
background: rgba(0,0,0,0.04);
|
||||||
}}
|
}}
|
||||||
|
.wordcloud-fallback .wordcloud-badges {{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}}
|
||||||
|
.wordcloud-badge {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(74, 144, 226, 0.35);
|
||||||
|
color: var(--text-color);
|
||||||
|
background: linear-gradient(135deg, rgba(74, 144, 226, 0.14) 0%, rgba(74, 144, 226, 0.24) 100%);
|
||||||
|
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.06);
|
||||||
|
}}
|
||||||
|
.dark-mode .wordcloud-badge {{
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
}}
|
||||||
|
.wordcloud-badge small {{
|
||||||
|
color: var(--secondary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}}
|
||||||
.chart-note {{
|
.chart-note {{
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -3039,6 +3105,45 @@ function liftDarkColor(color) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mixColors(colorA, colorB, amount) {
|
||||||
|
const rgbA = rgbFromColor(colorA);
|
||||||
|
const rgbB = rgbFromColor(colorB);
|
||||||
|
if (!rgbA && !rgbB) return colorA || colorB;
|
||||||
|
if (!rgbA) return colorB;
|
||||||
|
if (!rgbB) return colorA;
|
||||||
|
const t = Math.min(1, Math.max(0, amount || 0));
|
||||||
|
const mixed = rgbA.map((v, idx) => Math.round(v * (1 - t) + rgbB[idx] * t));
|
||||||
|
return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickComputedColor(keys, fallback, styles) {
|
||||||
|
const styleRef = styles || getComputedStyle(document.body);
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = styleRef.getPropertyValue(key);
|
||||||
|
if (val && val.trim()) {
|
||||||
|
const normalized = normalizeColorToken(val.trim());
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWordcloudTheme() {
|
||||||
|
const styles = getComputedStyle(document.body);
|
||||||
|
const isDark = document.body.classList.contains('dark-mode');
|
||||||
|
const text = pickComputedColor(['--text-color'], isDark ? '#e5e7eb' : '#111827', styles);
|
||||||
|
const secondary = pickComputedColor(['--secondary-color', '--color-text-secondary'], isDark ? '#cbd5e1' : '#475569', styles);
|
||||||
|
const accent = liftDarkColor(
|
||||||
|
pickComputedColor(['--primary-color', '--color-accent', '--re-accent-color'], '#4A90E2', styles)
|
||||||
|
);
|
||||||
|
const cardBg = pickComputedColor(
|
||||||
|
['--card-bg', '--paper-bg', '--bg', '--bg-color', '--background', '--page-bg'],
|
||||||
|
isDark ? '#0f172a' : '#ffffff',
|
||||||
|
styles
|
||||||
|
);
|
||||||
|
return { text, secondary, accent, cardBg, isDark };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDatasetColors(payload, chartType) {
|
function normalizeDatasetColors(payload, chartType) {
|
||||||
const changes = [];
|
const changes = [];
|
||||||
const data = payload && payload.data;
|
const data = payload && payload.data;
|
||||||
@@ -3246,29 +3351,79 @@ function isWordCloudWidget(payload) {
|
|||||||
return typeof type === 'string' && type.toLowerCase().includes('wordcloud');
|
return typeof type === 'string' && type.toLowerCase().includes('wordcloud');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashString(str) {
|
||||||
|
let h = 0;
|
||||||
|
if (!str) return h;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h = (h << 5) - h + str.charCodeAt(i);
|
||||||
|
h |= 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWordcloudItems(payload) {
|
function normalizeWordcloudItems(payload) {
|
||||||
const source = payload && payload.props && payload.props.data;
|
const sources = [];
|
||||||
if (!Array.isArray(source)) return [];
|
const props = payload && payload.props;
|
||||||
return source.map(item => {
|
const dataField = payload && payload.data;
|
||||||
if (!item || typeof item !== 'object') return null;
|
if (props) {
|
||||||
const word = item.word || item.text || item.label;
|
['data', 'items', 'words', 'sourceData'].forEach(key => {
|
||||||
if (!word) return null;
|
if (props[key]) sources.push(props[key]);
|
||||||
const rawWeight = item.weight;
|
});
|
||||||
let weight = 0;
|
}
|
||||||
if (typeof rawWeight === 'number' && !Number.isNaN(rawWeight)) {
|
if (dataField) {
|
||||||
weight = rawWeight;
|
sources.push(dataField);
|
||||||
} else if (typeof rawWeight === 'string') {
|
}
|
||||||
const parsed = parseFloat(rawWeight);
|
|
||||||
weight = Number.isNaN(parsed) ? 0 : parsed;
|
const seen = new Map();
|
||||||
|
const pushItem = (word, weight, category) => {
|
||||||
|
if (!word) return;
|
||||||
|
let numeric = 1;
|
||||||
|
if (typeof weight === 'number' && Number.isFinite(weight)) {
|
||||||
|
numeric = weight;
|
||||||
|
} else if (typeof weight === 'string') {
|
||||||
|
const parsed = parseFloat(weight);
|
||||||
|
numeric = Number.isFinite(parsed) ? parsed : 1;
|
||||||
}
|
}
|
||||||
const category = (item.category || '').toString().toLowerCase();
|
if (!(numeric > 0)) numeric = 1;
|
||||||
return { word: String(word), weight, category };
|
const cat = (category || '').toString().toLowerCase();
|
||||||
}).filter(Boolean);
|
const key = `${word}__${cat}`;
|
||||||
|
const existing = seen.get(key);
|
||||||
|
const payloadItem = { word: String(word), weight: numeric, category: cat };
|
||||||
|
if (!existing || numeric > existing.weight) {
|
||||||
|
seen.set(key, payloadItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const consume = (raw) => {
|
||||||
|
if (!raw) return;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
raw.forEach(item => {
|
||||||
|
if (!item) return;
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
pushItem(item[0], item[1], item[2]);
|
||||||
|
} else if (typeof item === 'object') {
|
||||||
|
pushItem(item.word || item.text || item.label, item.weight, item.category);
|
||||||
|
} else if (typeof item === 'string') {
|
||||||
|
pushItem(item, 1, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (typeof raw === 'object') {
|
||||||
|
Object.entries(raw).forEach(([word, weight]) => pushItem(word, weight, ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sources.forEach(consume);
|
||||||
|
|
||||||
|
const items = Array.from(seen.values());
|
||||||
|
items.sort((a, b) => (b.weight || 0) - (a.weight || 0));
|
||||||
|
return items.slice(0, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordcloudColor(category) {
|
function wordcloudColor(category) {
|
||||||
const key = typeof category === 'string' ? category.toLowerCase() : '';
|
const key = typeof category === 'string' ? category.toLowerCase() : '';
|
||||||
return WORDCLOUD_CATEGORY_COLORS[key] || '#334155';
|
const palette = resolveWordcloudTheme();
|
||||||
|
const base = WORDCLOUD_CATEGORY_COLORS[key] || palette.accent || palette.secondary || '#334155';
|
||||||
|
return liftDarkColor(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWordCloudFallback(canvas, items, reason) {
|
function renderWordCloudFallback(canvas, items, reason) {
|
||||||
@@ -3282,26 +3437,44 @@ function renderWordCloudFallback(canvas, items, reason) {
|
|||||||
} else {
|
} else {
|
||||||
canvas.style.display = 'none';
|
canvas.style.display = 'none';
|
||||||
}
|
}
|
||||||
let fallback = card.querySelector('.chart-fallback');
|
let fallback = card.querySelector('.chart-fallback[data-dynamic="true"]');
|
||||||
|
if (!fallback) {
|
||||||
|
fallback = card.querySelector('.chart-fallback');
|
||||||
|
}
|
||||||
if (!fallback) {
|
if (!fallback) {
|
||||||
fallback = document.createElement('div');
|
fallback = document.createElement('div');
|
||||||
fallback.className = 'chart-fallback wordcloud-fallback';
|
|
||||||
fallback.setAttribute('data-dynamic', 'true');
|
|
||||||
card.appendChild(fallback);
|
card.appendChild(fallback);
|
||||||
}
|
}
|
||||||
|
fallback.className = 'chart-fallback wordcloud-fallback';
|
||||||
|
fallback.setAttribute('data-dynamic', 'true');
|
||||||
fallback.style.display = 'block';
|
fallback.style.display = 'block';
|
||||||
|
fallback.innerHTML = '';
|
||||||
card.setAttribute('data-chart-state', 'fallback');
|
card.setAttribute('data-chart-state', 'fallback');
|
||||||
if (reason) {
|
const buildBadge = (item, maxWeight) => {
|
||||||
let notice = fallback.querySelector('.chart-fallback__notice');
|
const badge = document.createElement('span');
|
||||||
if (!notice) {
|
badge.className = 'wordcloud-badge';
|
||||||
notice = document.createElement('p');
|
const clampedWeight = Math.max(0.5, (item.weight || 1));
|
||||||
notice.className = 'chart-fallback__notice';
|
const normalized = Math.min(1, clampedWeight / (maxWeight || 1));
|
||||||
fallback.insertBefore(notice, fallback.firstChild || null);
|
const fontSize = 0.85 + normalized * 0.9;
|
||||||
|
badge.style.fontSize = `${fontSize}rem`;
|
||||||
|
badge.style.background = `linear-gradient(135deg, ${lightenColor(wordcloudColor(item.category), 0.05)} 0%, ${lightenColor(wordcloudColor(item.category), 0.15)} 100%)`;
|
||||||
|
badge.style.borderColor = lightenColor(wordcloudColor(item.category), 0.25);
|
||||||
|
badge.textContent = item.word;
|
||||||
|
if (item.weight !== undefined && item.weight !== null) {
|
||||||
|
const meta = document.createElement('small');
|
||||||
|
meta.textContent = item.weight >= 0 && item.weight <= 1.5
|
||||||
|
? `${(item.weight * 100).toFixed(0)}%`
|
||||||
|
: item.weight.toFixed(1).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
badge.appendChild(meta);
|
||||||
}
|
}
|
||||||
notice.textContent = `词云未能渲染${reason ? `(${reason})` : ''},已展示数据表。`;
|
return badge;
|
||||||
}
|
};
|
||||||
if (fallback.querySelector('table')) {
|
|
||||||
return;
|
if (reason) {
|
||||||
|
const notice = document.createElement('p');
|
||||||
|
notice.className = 'chart-fallback__notice';
|
||||||
|
notice.textContent = `词云未能渲染${reason ? `(${reason})` : ''},已展示关键词列表。`;
|
||||||
|
fallback.appendChild(notice);
|
||||||
}
|
}
|
||||||
if (!items || !items.length) {
|
if (!items || !items.length) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
@@ -3309,39 +3482,13 @@ function renderWordCloudFallback(canvas, items, reason) {
|
|||||||
fallback.appendChild(empty);
|
fallback.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const table = document.createElement('table');
|
const badges = document.createElement('div');
|
||||||
const thead = document.createElement('thead');
|
badges.className = 'wordcloud-badges';
|
||||||
const headRow = document.createElement('tr');
|
const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 1);
|
||||||
['关键词', '权重', '类别'].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 => {
|
items.forEach(item => {
|
||||||
const row = document.createElement('tr');
|
badges.appendChild(buildBadge(item, maxWeight));
|
||||||
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(badges);
|
||||||
fallback.appendChild(table);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWordCloud(canvas, payload, skipRegistry) {
|
function renderWordCloud(canvas, payload, skipRegistry) {
|
||||||
@@ -3358,38 +3505,82 @@ function renderWordCloud(canvas, payload, skipRegistry) {
|
|||||||
renderWordCloudFallback(canvas, items, '词云依赖未加载');
|
renderWordCloudFallback(canvas, items, '词云依赖未加载');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const theme = resolveWordcloudTheme();
|
||||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
const width = Math.max(240, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320));
|
const width = Math.max(260, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320));
|
||||||
const height = Math.max(180, Math.round(width * 0.62));
|
const height = Math.max(120, Math.round(width / 5)); // 5:1 宽高比
|
||||||
canvas.width = Math.round(width * dpr);
|
canvas.width = Math.round(width * dpr);
|
||||||
canvas.height = Math.round(height * dpr);
|
canvas.height = Math.round(height * dpr);
|
||||||
canvas.style.width = `${width}px`;
|
canvas.style.width = `${width}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
|
canvas.style.backgroundColor = 'transparent';
|
||||||
|
|
||||||
|
const resolveBgColor = () => {
|
||||||
|
const cardEl = card || container || document.body;
|
||||||
|
const style = getComputedStyle(cardEl);
|
||||||
|
const tokens = ['--card-bg', '--panel-bg', '--paper-bg', '--bg', '--background', '--page-bg'];
|
||||||
|
for (const key of tokens) {
|
||||||
|
const val = style.getPropertyValue(key);
|
||||||
|
if (val && val.trim() && val.trim() !== 'transparent') return val.trim();
|
||||||
|
}
|
||||||
|
if (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') return style.backgroundColor;
|
||||||
|
const bodyStyle = getComputedStyle(document.body);
|
||||||
|
for (const key of tokens) {
|
||||||
|
const val = bodyStyle.getPropertyValue(key);
|
||||||
|
if (val && val.trim() && val.trim() !== 'transparent') return val.trim();
|
||||||
|
}
|
||||||
|
if (bodyStyle.backgroundColor && bodyStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
||||||
|
return bodyStyle.backgroundColor;
|
||||||
|
}
|
||||||
|
return 'transparent';
|
||||||
|
};
|
||||||
|
const bgColor = resolveBgColor() || theme.cardBg || 'transparent';
|
||||||
|
|
||||||
const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1;
|
const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1;
|
||||||
|
const weightLookup = new Map();
|
||||||
|
const categoryLookup = new Map();
|
||||||
|
items.forEach(it => {
|
||||||
|
weightLookup.set(it.word, it.weight || 1);
|
||||||
|
categoryLookup.set(it.word, it.category || '');
|
||||||
|
});
|
||||||
const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]);
|
const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]);
|
||||||
try {
|
try {
|
||||||
WordCloud(canvas, {
|
WordCloud(canvas, {
|
||||||
list,
|
list,
|
||||||
gridSize: Math.max(8, Math.floor(Math.sqrt(canvas.width * canvas.height) / 80)),
|
gridSize: Math.max(3, Math.floor(Math.sqrt(canvas.width * canvas.height) / 170)),
|
||||||
weightFactor: (val) => {
|
weightFactor: (val) => {
|
||||||
const normalized = Math.max(0, val) / maxWeight;
|
const normalized = Math.max(0, val) / maxWeight;
|
||||||
const size = 16 + normalized * 32;
|
const cap = Math.min(width, height);
|
||||||
|
const base = Math.max(9, cap / 5.5);
|
||||||
|
const size = base * (0.8 + normalized * 1.3);
|
||||||
return size * dpr;
|
return size * dpr;
|
||||||
},
|
},
|
||||||
color: (word) => {
|
color: (word) => {
|
||||||
const found = items.find(entry => entry.word === word);
|
const w = weightLookup.get(word) || 1;
|
||||||
return lightenColor(wordcloudColor(found && found.category), 0.05);
|
const ratio = Math.max(0, Math.min(1, w / (maxWeight || 1)));
|
||||||
|
const category = categoryLookup.get(word) || '';
|
||||||
|
const base = wordcloudColor(category);
|
||||||
|
const target = theme.isDark ? '#ffffff' : (theme.text || '#111827');
|
||||||
|
const mixAmount = theme.isDark
|
||||||
|
? 0.28 + (1 - ratio) * 0.22
|
||||||
|
: 0.12 + (1 - ratio) * 0.35;
|
||||||
|
const mixed = mixColors(base, target, mixAmount);
|
||||||
|
return ensureAlpha(mixed || base, theme.isDark ? 0.95 : 1);
|
||||||
},
|
},
|
||||||
rotateRatio: 0.15,
|
rotateRatio: 0,
|
||||||
|
rotationSteps: 0,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
shrinkToFit: true,
|
shrinkToFit: true,
|
||||||
drawOutOfBound: false,
|
drawOutOfBound: false,
|
||||||
backgroundColor: getComputedStyle(document.body).getPropertyValue('--card-bg').trim() || '#fff'
|
shape: 'square',
|
||||||
|
ellipticity: 0.45,
|
||||||
|
clearCanvas: true,
|
||||||
|
backgroundColor: bgColor
|
||||||
});
|
});
|
||||||
if (container) {
|
if (container) {
|
||||||
container.style.display = '';
|
container.style.display = '';
|
||||||
container.style.minHeight = `${height}px`;
|
container.style.minHeight = `${height}px`;
|
||||||
|
container.style.background = 'transparent';
|
||||||
}
|
}
|
||||||
const fallback = card && card.querySelector('.chart-fallback');
|
const fallback = card && card.querySelector('.chart-fallback');
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
@@ -3870,11 +4061,19 @@ function exportPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const rerenderWordclouds = debounce(() => {
|
||||||
|
wordCloudRegistry.forEach(fn => {
|
||||||
|
if (typeof fn === 'function') {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 260);
|
||||||
const themeBtn = document.getElementById('theme-toggle');
|
const themeBtn = document.getElementById('theme-toggle');
|
||||||
if (themeBtn) {
|
if (themeBtn) {
|
||||||
themeBtn.addEventListener('click', () => {
|
themeBtn.addEventListener('click', () => {
|
||||||
document.body.classList.toggle('dark-mode');
|
document.body.classList.toggle('dark-mode');
|
||||||
chartRegistry.forEach(applyChartTheme);
|
chartRegistry.forEach(applyChartTheme);
|
||||||
|
rerenderWordclouds();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const printBtn = document.getElementById('print-btn');
|
const printBtn = document.getElementById('print-btn');
|
||||||
@@ -3885,13 +4084,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (exportBtn) {
|
if (exportBtn) {
|
||||||
exportBtn.addEventListener('click', exportPdf);
|
exportBtn.addEventListener('click', exportPdf);
|
||||||
}
|
}
|
||||||
const rerenderWordclouds = debounce(() => {
|
|
||||||
wordCloudRegistry.forEach(fn => {
|
|
||||||
if (typeof fn === 'function') {
|
|
||||||
fn();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 260);
|
|
||||||
window.addEventListener('resize', rerenderWordclouds);
|
window.addEventListener('resize', rerenderWordclouds);
|
||||||
hydrateCharts();
|
hydrateCharts();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -331,6 +331,13 @@ class PDFRenderer:
|
|||||||
|
|
||||||
# 只处理chart.js类型的widget
|
# 只处理chart.js类型的widget
|
||||||
if widget_id and widget_type.startswith('chart.js'):
|
if widget_id and widget_type.startswith('chart.js'):
|
||||||
|
widget_type_lower = widget_type.lower()
|
||||||
|
props = block.get('props')
|
||||||
|
props_type = str(props.get('type') or '').lower() if isinstance(props, dict) else ''
|
||||||
|
if 'wordcloud' in widget_type_lower or 'wordcloud' in props_type:
|
||||||
|
logger.debug(f"检测到词云 {widget_id},跳过SVG转换并使用图片注入流程")
|
||||||
|
continue
|
||||||
|
|
||||||
failed, fail_reason = self.html_renderer._has_chart_failure(block)
|
failed, fail_reason = self.html_renderer._has_chart_failure(block)
|
||||||
if block.get("_chart_renderable") is False or failed:
|
if block.get("_chart_renderable") is False or failed:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -392,7 +399,13 @@ class PDFRenderer:
|
|||||||
widget_id = block.get('widgetId')
|
widget_id = block.get('widgetId')
|
||||||
widget_type = block.get('widgetType', '')
|
widget_type = block.get('widgetType', '')
|
||||||
|
|
||||||
if widget_id and isinstance(widget_type, str) and 'wordcloud' in widget_type.lower():
|
props = block.get('props')
|
||||||
|
props_type = str(props.get('type') or '') if isinstance(props, dict) else ''
|
||||||
|
is_wordcloud = (
|
||||||
|
isinstance(widget_type, str) and 'wordcloud' in widget_type.lower()
|
||||||
|
) or ('wordcloud' in props_type.lower())
|
||||||
|
|
||||||
|
if widget_id and is_wordcloud:
|
||||||
try:
|
try:
|
||||||
data_uri = self._generate_wordcloud_image(block)
|
data_uri = self._generate_wordcloud_image(block)
|
||||||
if data_uri:
|
if data_uri:
|
||||||
@@ -464,12 +477,14 @@ class PDFRenderer:
|
|||||||
|
|
||||||
font_path = str(self._get_font_path())
|
font_path = str(self._get_font_path())
|
||||||
wc = WordCloud(
|
wc = WordCloud(
|
||||||
width=900,
|
width=1000,
|
||||||
height=520,
|
height=360,
|
||||||
background_color="white",
|
background_color="white",
|
||||||
font_path=font_path,
|
font_path=font_path,
|
||||||
prefer_horizontal=0.9,
|
prefer_horizontal=0.98,
|
||||||
random_state=42,
|
random_state=42,
|
||||||
|
max_words=180,
|
||||||
|
collocations=False,
|
||||||
)
|
)
|
||||||
wc.generate_from_frequencies(frequencies)
|
wc.generate_from_frequencies(frequencies)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user