Showing
3 changed files
with
351 additions
and
139 deletions
| @@ -167,6 +167,11 @@ class ChartToSVGConverter: | @@ -167,6 +167,11 @@ class ChartToSVGConverter: | ||
| 167 | return None | 167 | return None |
| 168 | 168 | ||
| 169 | # 根据图表类型调用相应的渲染方法 | 169 | # 根据图表类型调用相应的渲染方法 |
| 170 | + if 'wordcloud' in str(chart_type).lower(): | ||
| 171 | + # 词云由专用渲染逻辑处理,这里跳过SVG转换以避免告警 | ||
| 172 | + logger.debug("检测到词云图表,跳过chart_to_svg转换") | ||
| 173 | + return None | ||
| 174 | + | ||
| 170 | render_method = getattr(self, f'_render_{chart_type}', None) | 175 | render_method = getattr(self, f'_render_{chart_type}', None) |
| 171 | if not render_method: | 176 | if not render_method: |
| 172 | logger.warning(f"不支持的图表类型: {chart_type}") | 177 | logger.warning(f"不支持的图表类型: {chart_type}") |
| @@ -1628,56 +1628,60 @@ class HTMLRenderer: | @@ -1628,56 +1628,60 @@ class HTMLRenderer: | ||
| 1628 | if is_chart: | 1628 | if is_chart: |
| 1629 | self.chart_validation_stats['total'] += 1 | 1629 | self.chart_validation_stats['total'] += 1 |
| 1630 | 1630 | ||
| 1631 | - # 如果此前已记录失败,直接使用占位提示,避免重复修复 | ||
| 1632 | - has_failed, cached_reason = self._has_chart_failure(block) | ||
| 1633 | - if has_failed: | ||
| 1634 | - self._record_chart_failure_stat(cache_key) | ||
| 1635 | - reason = cached_reason or "LLM返回的图表信息格式有误,无法正常显示" | ||
| 1636 | - return self._render_chart_error_placeholder(display_title, reason, widget_id) | ||
| 1637 | - | ||
| 1638 | - # 验证图表数据 | ||
| 1639 | - validation_result = self.chart_validator.validate(block) | ||
| 1640 | - | ||
| 1641 | - if not validation_result.is_valid: | ||
| 1642 | - logger.warning( | ||
| 1643 | - f"图表 {block.get('widgetId', 'unknown')} 验证失败: {validation_result.errors}" | ||
| 1644 | - ) | 1631 | + # 词云使用专用渲染逻辑,不按Chart.js规则验证,直接跳过防止误判 |
| 1632 | + if is_wordcloud: | ||
| 1633 | + self.chart_validation_stats['valid'] += 1 | ||
| 1634 | + else: | ||
| 1635 | + # 如果此前已记录失败,直接使用占位提示,避免重复修复 | ||
| 1636 | + has_failed, cached_reason = self._has_chart_failure(block) | ||
| 1637 | + if has_failed: | ||
| 1638 | + self._record_chart_failure_stat(cache_key) | ||
| 1639 | + reason = cached_reason or "LLM返回的图表信息格式有误,无法正常显示" | ||
| 1640 | + return self._render_chart_error_placeholder(display_title, reason, widget_id) | ||
| 1645 | 1641 | ||
| 1646 | - # 尝试修复 | ||
| 1647 | - repair_result = self.chart_repairer.repair(block, validation_result) | 1642 | + # 验证图表数据 |
| 1643 | + validation_result = self.chart_validator.validate(block) | ||
| 1648 | 1644 | ||
| 1649 | - if repair_result.success and repair_result.repaired_block: | ||
| 1650 | - # 修复成功,使用修复后的数据 | ||
| 1651 | - block = repair_result.repaired_block | ||
| 1652 | - logger.info( | ||
| 1653 | - f"图表 {block.get('widgetId', 'unknown')} 修复成功 " | ||
| 1654 | - f"(方法: {repair_result.method}): {repair_result.changes}" | 1645 | + if not validation_result.is_valid: |
| 1646 | + logger.warning( | ||
| 1647 | + f"图表 {block.get('widgetId', 'unknown')} 验证失败: {validation_result.errors}" | ||
| 1655 | ) | 1648 | ) |
| 1656 | 1649 | ||
| 1657 | - # 更新统计 | ||
| 1658 | - if repair_result.method == 'local': | ||
| 1659 | - self.chart_validation_stats['repaired_locally'] += 1 | ||
| 1660 | - elif repair_result.method == 'api': | ||
| 1661 | - self.chart_validation_stats['repaired_api'] += 1 | 1650 | + # 尝试修复 |
| 1651 | + repair_result = self.chart_repairer.repair(block, validation_result) | ||
| 1652 | + | ||
| 1653 | + if repair_result.success and repair_result.repaired_block: | ||
| 1654 | + # 修复成功,使用修复后的数据 | ||
| 1655 | + block = repair_result.repaired_block | ||
| 1656 | + logger.info( | ||
| 1657 | + f"图表 {block.get('widgetId', 'unknown')} 修复成功 " | ||
| 1658 | + f"(方法: {repair_result.method}): {repair_result.changes}" | ||
| 1659 | + ) | ||
| 1660 | + | ||
| 1661 | + # 更新统计 | ||
| 1662 | + if repair_result.method == 'local': | ||
| 1663 | + self.chart_validation_stats['repaired_locally'] += 1 | ||
| 1664 | + elif repair_result.method == 'api': | ||
| 1665 | + self.chart_validation_stats['repaired_api'] += 1 | ||
| 1666 | + else: | ||
| 1667 | + # 修复失败,记录失败并输出占位提示 | ||
| 1668 | + fail_reason = self._format_chart_error_reason(validation_result) | ||
| 1669 | + block["_chart_renderable"] = False | ||
| 1670 | + block["_chart_error_reason"] = fail_reason | ||
| 1671 | + self._note_chart_failure(cache_key, fail_reason) | ||
| 1672 | + self._record_chart_failure_stat(cache_key) | ||
| 1673 | + logger.warning( | ||
| 1674 | + f"图表 {block.get('widgetId', 'unknown')} 修复失败,已跳过渲染: {fail_reason}" | ||
| 1675 | + ) | ||
| 1676 | + return self._render_chart_error_placeholder(display_title, fail_reason, widget_id) | ||
| 1662 | else: | 1677 | else: |
| 1663 | - # 修复失败,记录失败并输出占位提示 | ||
| 1664 | - fail_reason = self._format_chart_error_reason(validation_result) | ||
| 1665 | - block["_chart_renderable"] = False | ||
| 1666 | - block["_chart_error_reason"] = fail_reason | ||
| 1667 | - self._note_chart_failure(cache_key, fail_reason) | ||
| 1668 | - self._record_chart_failure_stat(cache_key) | ||
| 1669 | - logger.warning( | ||
| 1670 | - f"图表 {block.get('widgetId', 'unknown')} 修复失败,已跳过渲染: {fail_reason}" | ||
| 1671 | - ) | ||
| 1672 | - return self._render_chart_error_placeholder(display_title, fail_reason, widget_id) | ||
| 1673 | - else: | ||
| 1674 | - # 验证通过 | ||
| 1675 | - self.chart_validation_stats['valid'] += 1 | ||
| 1676 | - if validation_result.warnings: | ||
| 1677 | - logger.info( | ||
| 1678 | - f"图表 {block.get('widgetId', 'unknown')} 验证通过," | ||
| 1679 | - f"但有警告: {validation_result.warnings}" | ||
| 1680 | - ) | 1678 | + # 验证通过 |
| 1679 | + self.chart_validation_stats['valid'] += 1 | ||
| 1680 | + if validation_result.warnings: | ||
| 1681 | + logger.info( | ||
| 1682 | + f"图表 {block.get('widgetId', 'unknown')} 验证通过," | ||
| 1683 | + f"但有警告: {validation_result.warnings}" | ||
| 1684 | + ) | ||
| 1681 | 1685 | ||
| 1682 | # 渲染图表HTML | 1686 | # 渲染图表HTML |
| 1683 | self.chart_counter += 1 | 1687 | self.chart_counter += 1 |
| @@ -1700,7 +1704,7 @@ class HTMLRenderer: | @@ -1700,7 +1704,7 @@ class HTMLRenderer: | ||
| 1700 | title = props.get("title") | 1704 | title = props.get("title") |
| 1701 | title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" | 1705 | title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" |
| 1702 | fallback_html = ( | 1706 | fallback_html = ( |
| 1703 | - self._render_wordcloud_fallback(props, block.get("widgetId")) | 1707 | + self._render_wordcloud_fallback(props, block.get("widgetId"), block.get("data")) |
| 1704 | if is_wordcloud | 1708 | if is_wordcloud |
| 1705 | else self._render_widget_fallback(normalized_data, block.get("widgetId")) | 1709 | else self._render_widget_fallback(normalized_data, block.get("widgetId")) |
| 1706 | ) | 1710 | ) |
| @@ -1750,20 +1754,57 @@ class HTMLRenderer: | @@ -1750,20 +1754,57 @@ class HTMLRenderer: | ||
| 1750 | """ | 1754 | """ |
| 1751 | return table_html | 1755 | return table_html |
| 1752 | 1756 | ||
| 1753 | - def _render_wordcloud_fallback(self, props: Dict[str, Any] | None, widget_id: str | None = None) -> str: | 1757 | + def _render_wordcloud_fallback( |
| 1758 | + self, | ||
| 1759 | + props: Dict[str, Any] | None, | ||
| 1760 | + widget_id: str | None = None, | ||
| 1761 | + block_data: Any | None = None, | ||
| 1762 | + ) -> str: | ||
| 1754 | """为词云提供表格兜底,避免WordCloud渲染失败后页面空白""" | 1763 | """为词云提供表格兜底,避免WordCloud渲染失败后页面空白""" |
| 1755 | - words = [] | ||
| 1756 | - if isinstance(props, dict): | ||
| 1757 | - raw = props.get("data") | 1764 | + def _collect_items(raw: Any) -> list[dict]: |
| 1765 | + collected: list[dict] = [] | ||
| 1758 | if isinstance(raw, list): | 1766 | if isinstance(raw, list): |
| 1759 | for item in raw: | 1767 | for item in raw: |
| 1760 | - if not isinstance(item, dict): | ||
| 1761 | - continue | ||
| 1762 | - text = item.get("word") or item.get("text") or item.get("label") | ||
| 1763 | - weight = item.get("weight") | ||
| 1764 | - category = item.get("category") or "" | ||
| 1765 | - if text: | ||
| 1766 | - words.append({"word": str(text), "weight": weight, "category": str(category)}) | 1768 | + if isinstance(item, dict): |
| 1769 | + text = item.get("word") or item.get("text") or item.get("label") | ||
| 1770 | + weight = item.get("weight") | ||
| 1771 | + category = item.get("category") or "" | ||
| 1772 | + if text: | ||
| 1773 | + collected.append({"word": str(text), "weight": weight, "category": str(category)}) | ||
| 1774 | + elif isinstance(item, (list, tuple)) and item: | ||
| 1775 | + text = item[0] | ||
| 1776 | + weight = item[1] if len(item) > 1 else None | ||
| 1777 | + category = item[2] if len(item) > 2 else "" | ||
| 1778 | + if text: | ||
| 1779 | + collected.append({"word": str(text), "weight": weight, "category": str(category)}) | ||
| 1780 | + elif isinstance(item, str): | ||
| 1781 | + collected.append({"word": item, "weight": 1.0, "category": ""}) | ||
| 1782 | + elif isinstance(raw, dict): | ||
| 1783 | + if not {"labels", "datasets"}.intersection(raw.keys()): | ||
| 1784 | + for text, weight in raw.items(): | ||
| 1785 | + collected.append({"word": str(text), "weight": weight, "category": ""}) | ||
| 1786 | + return collected | ||
| 1787 | + | ||
| 1788 | + words: list[dict] = [] | ||
| 1789 | + seen: set[str] = set() | ||
| 1790 | + candidates = [] | ||
| 1791 | + if isinstance(props, dict): | ||
| 1792 | + for key in ("data", "items", "words"): | ||
| 1793 | + if key in props: | ||
| 1794 | + candidates.append(props[key]) | ||
| 1795 | + candidates.append((props or {}).get("sourceData")) | ||
| 1796 | + | ||
| 1797 | + # 允许使用block.data兜底,避免缺失props时出现空白 | ||
| 1798 | + if block_data is not None: | ||
| 1799 | + candidates.append(block_data) | ||
| 1800 | + | ||
| 1801 | + for raw in candidates: | ||
| 1802 | + for item in _collect_items(raw): | ||
| 1803 | + key = f"{item['word']}::{item.get('category','')}" | ||
| 1804 | + if key in seen: | ||
| 1805 | + continue | ||
| 1806 | + seen.add(key) | ||
| 1807 | + words.append(item) | ||
| 1767 | 1808 | ||
| 1768 | if not words: | 1809 | if not words: |
| 1769 | return "" | 1810 | return "" |
| @@ -2712,11 +2753,11 @@ table th {{ | @@ -2712,11 +2753,11 @@ table th {{ | ||
| 2712 | line-height: 1.6; | 2753 | line-height: 1.6; |
| 2713 | }} | 2754 | }} |
| 2714 | .chart-card.wordcloud-card .chart-container {{ | 2755 | .chart-card.wordcloud-card .chart-container {{ |
| 2715 | - min-height: 260px; | 2756 | + min-height: 180px; |
| 2716 | }} | 2757 | }} |
| 2717 | .chart-container {{ | 2758 | .chart-container {{ |
| 2718 | position: relative; | 2759 | position: relative; |
| 2719 | - min-height: 320px; | 2760 | + min-height: 220px; |
| 2720 | }} | 2761 | }} |
| 2721 | .chart-fallback {{ | 2762 | .chart-fallback {{ |
| 2722 | display: none; | 2763 | display: none; |
| @@ -2743,6 +2784,31 @@ table th {{ | @@ -2743,6 +2784,31 @@ table th {{ | ||
| 2743 | .chart-fallback th {{ | 2784 | .chart-fallback th {{ |
| 2744 | background: rgba(0,0,0,0.04); | 2785 | background: rgba(0,0,0,0.04); |
| 2745 | }} | 2786 | }} |
| 2787 | +.wordcloud-fallback .wordcloud-badges {{ | ||
| 2788 | + display: flex; | ||
| 2789 | + flex-wrap: wrap; | ||
| 2790 | + gap: 6px; | ||
| 2791 | + margin-top: 6px; | ||
| 2792 | +}} | ||
| 2793 | +.wordcloud-badge {{ | ||
| 2794 | + display: inline-flex; | ||
| 2795 | + align-items: center; | ||
| 2796 | + gap: 4px; | ||
| 2797 | + padding: 4px 8px; | ||
| 2798 | + border-radius: 999px; | ||
| 2799 | + border: 1px solid rgba(74, 144, 226, 0.35); | ||
| 2800 | + color: var(--text-color); | ||
| 2801 | + background: linear-gradient(135deg, rgba(74, 144, 226, 0.14) 0%, rgba(74, 144, 226, 0.24) 100%); | ||
| 2802 | + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.06); | ||
| 2803 | +}} | ||
| 2804 | +.dark-mode .wordcloud-badge {{ | ||
| 2805 | + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); | ||
| 2806 | +}} | ||
| 2807 | +.wordcloud-badge small {{ | ||
| 2808 | + color: var(--secondary-color); | ||
| 2809 | + font-weight: 600; | ||
| 2810 | + font-size: 0.75rem; | ||
| 2811 | +}} | ||
| 2746 | .chart-note {{ | 2812 | .chart-note {{ |
| 2747 | margin-top: 8px; | 2813 | margin-top: 8px; |
| 2748 | font-size: 0.85rem; | 2814 | font-size: 0.85rem; |
| @@ -3039,6 +3105,45 @@ function liftDarkColor(color) { | @@ -3039,6 +3105,45 @@ function liftDarkColor(color) { | ||
| 3039 | return normalized; | 3105 | return normalized; |
| 3040 | } | 3106 | } |
| 3041 | 3107 | ||
| 3108 | +function mixColors(colorA, colorB, amount) { | ||
| 3109 | + const rgbA = rgbFromColor(colorA); | ||
| 3110 | + const rgbB = rgbFromColor(colorB); | ||
| 3111 | + if (!rgbA && !rgbB) return colorA || colorB; | ||
| 3112 | + if (!rgbA) return colorB; | ||
| 3113 | + if (!rgbB) return colorA; | ||
| 3114 | + const t = Math.min(1, Math.max(0, amount || 0)); | ||
| 3115 | + const mixed = rgbA.map((v, idx) => Math.round(v * (1 - t) + rgbB[idx] * t)); | ||
| 3116 | + return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`; | ||
| 3117 | +} | ||
| 3118 | + | ||
| 3119 | +function pickComputedColor(keys, fallback, styles) { | ||
| 3120 | + const styleRef = styles || getComputedStyle(document.body); | ||
| 3121 | + for (const key of keys) { | ||
| 3122 | + const val = styleRef.getPropertyValue(key); | ||
| 3123 | + if (val && val.trim()) { | ||
| 3124 | + const normalized = normalizeColorToken(val.trim()); | ||
| 3125 | + if (normalized) return normalized; | ||
| 3126 | + } | ||
| 3127 | + } | ||
| 3128 | + return fallback; | ||
| 3129 | +} | ||
| 3130 | + | ||
| 3131 | +function resolveWordcloudTheme() { | ||
| 3132 | + const styles = getComputedStyle(document.body); | ||
| 3133 | + const isDark = document.body.classList.contains('dark-mode'); | ||
| 3134 | + const text = pickComputedColor(['--text-color'], isDark ? '#e5e7eb' : '#111827', styles); | ||
| 3135 | + const secondary = pickComputedColor(['--secondary-color', '--color-text-secondary'], isDark ? '#cbd5e1' : '#475569', styles); | ||
| 3136 | + const accent = liftDarkColor( | ||
| 3137 | + pickComputedColor(['--primary-color', '--color-accent', '--re-accent-color'], '#4A90E2', styles) | ||
| 3138 | + ); | ||
| 3139 | + const cardBg = pickComputedColor( | ||
| 3140 | + ['--card-bg', '--paper-bg', '--bg', '--bg-color', '--background', '--page-bg'], | ||
| 3141 | + isDark ? '#0f172a' : '#ffffff', | ||
| 3142 | + styles | ||
| 3143 | + ); | ||
| 3144 | + return { text, secondary, accent, cardBg, isDark }; | ||
| 3145 | +} | ||
| 3146 | + | ||
| 3042 | function normalizeDatasetColors(payload, chartType) { | 3147 | function normalizeDatasetColors(payload, chartType) { |
| 3043 | const changes = []; | 3148 | const changes = []; |
| 3044 | const data = payload && payload.data; | 3149 | const data = payload && payload.data; |
| @@ -3246,29 +3351,79 @@ function isWordCloudWidget(payload) { | @@ -3246,29 +3351,79 @@ function isWordCloudWidget(payload) { | ||
| 3246 | return typeof type === 'string' && type.toLowerCase().includes('wordcloud'); | 3351 | return typeof type === 'string' && type.toLowerCase().includes('wordcloud'); |
| 3247 | } | 3352 | } |
| 3248 | 3353 | ||
| 3354 | +function hashString(str) { | ||
| 3355 | + let h = 0; | ||
| 3356 | + if (!str) return h; | ||
| 3357 | + for (let i = 0; i < str.length; i++) { | ||
| 3358 | + h = (h << 5) - h + str.charCodeAt(i); | ||
| 3359 | + h |= 0; | ||
| 3360 | + } | ||
| 3361 | + return h; | ||
| 3362 | +} | ||
| 3363 | + | ||
| 3249 | function normalizeWordcloudItems(payload) { | 3364 | function normalizeWordcloudItems(payload) { |
| 3250 | - const source = payload && payload.props && payload.props.data; | ||
| 3251 | - if (!Array.isArray(source)) return []; | ||
| 3252 | - return source.map(item => { | ||
| 3253 | - if (!item || typeof item !== 'object') return null; | ||
| 3254 | - const word = item.word || item.text || item.label; | ||
| 3255 | - if (!word) return null; | ||
| 3256 | - const rawWeight = item.weight; | ||
| 3257 | - let weight = 0; | ||
| 3258 | - if (typeof rawWeight === 'number' && !Number.isNaN(rawWeight)) { | ||
| 3259 | - weight = rawWeight; | ||
| 3260 | - } else if (typeof rawWeight === 'string') { | ||
| 3261 | - const parsed = parseFloat(rawWeight); | ||
| 3262 | - weight = Number.isNaN(parsed) ? 0 : parsed; | 3365 | + const sources = []; |
| 3366 | + const props = payload && payload.props; | ||
| 3367 | + const dataField = payload && payload.data; | ||
| 3368 | + if (props) { | ||
| 3369 | + ['data', 'items', 'words', 'sourceData'].forEach(key => { | ||
| 3370 | + if (props[key]) sources.push(props[key]); | ||
| 3371 | + }); | ||
| 3372 | + } | ||
| 3373 | + if (dataField) { | ||
| 3374 | + sources.push(dataField); | ||
| 3375 | + } | ||
| 3376 | + | ||
| 3377 | + const seen = new Map(); | ||
| 3378 | + const pushItem = (word, weight, category) => { | ||
| 3379 | + if (!word) return; | ||
| 3380 | + let numeric = 1; | ||
| 3381 | + if (typeof weight === 'number' && Number.isFinite(weight)) { | ||
| 3382 | + numeric = weight; | ||
| 3383 | + } else if (typeof weight === 'string') { | ||
| 3384 | + const parsed = parseFloat(weight); | ||
| 3385 | + numeric = Number.isFinite(parsed) ? parsed : 1; | ||
| 3386 | + } | ||
| 3387 | + if (!(numeric > 0)) numeric = 1; | ||
| 3388 | + const cat = (category || '').toString().toLowerCase(); | ||
| 3389 | + const key = `${word}__${cat}`; | ||
| 3390 | + const existing = seen.get(key); | ||
| 3391 | + const payloadItem = { word: String(word), weight: numeric, category: cat }; | ||
| 3392 | + if (!existing || numeric > existing.weight) { | ||
| 3393 | + seen.set(key, payloadItem); | ||
| 3263 | } | 3394 | } |
| 3264 | - const category = (item.category || '').toString().toLowerCase(); | ||
| 3265 | - return { word: String(word), weight, category }; | ||
| 3266 | - }).filter(Boolean); | 3395 | + }; |
| 3396 | + | ||
| 3397 | + const consume = (raw) => { | ||
| 3398 | + if (!raw) return; | ||
| 3399 | + if (Array.isArray(raw)) { | ||
| 3400 | + raw.forEach(item => { | ||
| 3401 | + if (!item) return; | ||
| 3402 | + if (Array.isArray(item)) { | ||
| 3403 | + pushItem(item[0], item[1], item[2]); | ||
| 3404 | + } else if (typeof item === 'object') { | ||
| 3405 | + pushItem(item.word || item.text || item.label, item.weight, item.category); | ||
| 3406 | + } else if (typeof item === 'string') { | ||
| 3407 | + pushItem(item, 1, ''); | ||
| 3408 | + } | ||
| 3409 | + }); | ||
| 3410 | + } else if (typeof raw === 'object') { | ||
| 3411 | + Object.entries(raw).forEach(([word, weight]) => pushItem(word, weight, '')); | ||
| 3412 | + } | ||
| 3413 | + }; | ||
| 3414 | + | ||
| 3415 | + sources.forEach(consume); | ||
| 3416 | + | ||
| 3417 | + const items = Array.from(seen.values()); | ||
| 3418 | + items.sort((a, b) => (b.weight || 0) - (a.weight || 0)); | ||
| 3419 | + return items.slice(0, 150); | ||
| 3267 | } | 3420 | } |
| 3268 | 3421 | ||
| 3269 | function wordcloudColor(category) { | 3422 | function wordcloudColor(category) { |
| 3270 | const key = typeof category === 'string' ? category.toLowerCase() : ''; | 3423 | const key = typeof category === 'string' ? category.toLowerCase() : ''; |
| 3271 | - return WORDCLOUD_CATEGORY_COLORS[key] || '#334155'; | 3424 | + const palette = resolveWordcloudTheme(); |
| 3425 | + const base = WORDCLOUD_CATEGORY_COLORS[key] || palette.accent || palette.secondary || '#334155'; | ||
| 3426 | + return liftDarkColor(base); | ||
| 3272 | } | 3427 | } |
| 3273 | 3428 | ||
| 3274 | function renderWordCloudFallback(canvas, items, reason) { | 3429 | function renderWordCloudFallback(canvas, items, reason) { |
| @@ -3282,26 +3437,44 @@ function renderWordCloudFallback(canvas, items, reason) { | @@ -3282,26 +3437,44 @@ function renderWordCloudFallback(canvas, items, reason) { | ||
| 3282 | } else { | 3437 | } else { |
| 3283 | canvas.style.display = 'none'; | 3438 | canvas.style.display = 'none'; |
| 3284 | } | 3439 | } |
| 3285 | - let fallback = card.querySelector('.chart-fallback'); | 3440 | + let fallback = card.querySelector('.chart-fallback[data-dynamic="true"]'); |
| 3441 | + if (!fallback) { | ||
| 3442 | + fallback = card.querySelector('.chart-fallback'); | ||
| 3443 | + } | ||
| 3286 | if (!fallback) { | 3444 | if (!fallback) { |
| 3287 | fallback = document.createElement('div'); | 3445 | fallback = document.createElement('div'); |
| 3288 | - fallback.className = 'chart-fallback wordcloud-fallback'; | ||
| 3289 | - fallback.setAttribute('data-dynamic', 'true'); | ||
| 3290 | card.appendChild(fallback); | 3446 | card.appendChild(fallback); |
| 3291 | } | 3447 | } |
| 3448 | + fallback.className = 'chart-fallback wordcloud-fallback'; | ||
| 3449 | + fallback.setAttribute('data-dynamic', 'true'); | ||
| 3292 | fallback.style.display = 'block'; | 3450 | fallback.style.display = 'block'; |
| 3451 | + fallback.innerHTML = ''; | ||
| 3293 | card.setAttribute('data-chart-state', 'fallback'); | 3452 | card.setAttribute('data-chart-state', 'fallback'); |
| 3294 | - if (reason) { | ||
| 3295 | - let notice = fallback.querySelector('.chart-fallback__notice'); | ||
| 3296 | - if (!notice) { | ||
| 3297 | - notice = document.createElement('p'); | ||
| 3298 | - notice.className = 'chart-fallback__notice'; | ||
| 3299 | - fallback.insertBefore(notice, fallback.firstChild || null); | 3453 | + const buildBadge = (item, maxWeight) => { |
| 3454 | + const badge = document.createElement('span'); | ||
| 3455 | + badge.className = 'wordcloud-badge'; | ||
| 3456 | + const clampedWeight = Math.max(0.5, (item.weight || 1)); | ||
| 3457 | + const normalized = Math.min(1, clampedWeight / (maxWeight || 1)); | ||
| 3458 | + const fontSize = 0.85 + normalized * 0.9; | ||
| 3459 | + badge.style.fontSize = `${fontSize}rem`; | ||
| 3460 | + badge.style.background = `linear-gradient(135deg, ${lightenColor(wordcloudColor(item.category), 0.05)} 0%, ${lightenColor(wordcloudColor(item.category), 0.15)} 100%)`; | ||
| 3461 | + badge.style.borderColor = lightenColor(wordcloudColor(item.category), 0.25); | ||
| 3462 | + badge.textContent = item.word; | ||
| 3463 | + if (item.weight !== undefined && item.weight !== null) { | ||
| 3464 | + const meta = document.createElement('small'); | ||
| 3465 | + meta.textContent = item.weight >= 0 && item.weight <= 1.5 | ||
| 3466 | + ? `${(item.weight * 100).toFixed(0)}%` | ||
| 3467 | + : item.weight.toFixed(1).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, ''); | ||
| 3468 | + badge.appendChild(meta); | ||
| 3300 | } | 3469 | } |
| 3301 | - notice.textContent = `词云未能渲染${reason ? `(${reason})` : ''},已展示数据表。`; | ||
| 3302 | - } | ||
| 3303 | - if (fallback.querySelector('table')) { | ||
| 3304 | - return; | 3470 | + return badge; |
| 3471 | + }; | ||
| 3472 | + | ||
| 3473 | + if (reason) { | ||
| 3474 | + const notice = document.createElement('p'); | ||
| 3475 | + notice.className = 'chart-fallback__notice'; | ||
| 3476 | + notice.textContent = `词云未能渲染${reason ? `(${reason})` : ''},已展示关键词列表。`; | ||
| 3477 | + fallback.appendChild(notice); | ||
| 3305 | } | 3478 | } |
| 3306 | if (!items || !items.length) { | 3479 | if (!items || !items.length) { |
| 3307 | const empty = document.createElement('p'); | 3480 | const empty = document.createElement('p'); |
| @@ -3309,39 +3482,13 @@ function renderWordCloudFallback(canvas, items, reason) { | @@ -3309,39 +3482,13 @@ function renderWordCloudFallback(canvas, items, reason) { | ||
| 3309 | fallback.appendChild(empty); | 3482 | fallback.appendChild(empty); |
| 3310 | return; | 3483 | return; |
| 3311 | } | 3484 | } |
| 3312 | - const table = document.createElement('table'); | ||
| 3313 | - const thead = document.createElement('thead'); | ||
| 3314 | - const headRow = document.createElement('tr'); | ||
| 3315 | - ['关键词', '权重', '类别'].forEach(text => { | ||
| 3316 | - const th = document.createElement('th'); | ||
| 3317 | - th.textContent = text; | ||
| 3318 | - headRow.appendChild(th); | ||
| 3319 | - }); | ||
| 3320 | - thead.appendChild(headRow); | ||
| 3321 | - table.appendChild(thead); | ||
| 3322 | - const tbody = document.createElement('tbody'); | 3485 | + const badges = document.createElement('div'); |
| 3486 | + badges.className = 'wordcloud-badges'; | ||
| 3487 | + const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 1); | ||
| 3323 | items.forEach(item => { | 3488 | items.forEach(item => { |
| 3324 | - const row = document.createElement('tr'); | ||
| 3325 | - const wordCell = document.createElement('td'); | ||
| 3326 | - wordCell.textContent = item.word; | ||
| 3327 | - const weightCell = document.createElement('td'); | ||
| 3328 | - if (typeof item.weight === 'number' && !Number.isNaN(item.weight)) { | ||
| 3329 | - weightCell.textContent = item.weight >= 0 && item.weight <= 1.5 | ||
| 3330 | - ? `${(item.weight * 100).toFixed(1)}%` | ||
| 3331 | - : item.weight.toFixed(2).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, ''); | ||
| 3332 | - } else { | ||
| 3333 | - weightCell.textContent = item.weight !== undefined && item.weight !== null ? String(item.weight) : '—'; | ||
| 3334 | - } | ||
| 3335 | - const categoryCell = document.createElement('td'); | ||
| 3336 | - categoryCell.textContent = item.category || '—'; | ||
| 3337 | - categoryCell.style.color = wordcloudColor(item.category); | ||
| 3338 | - row.appendChild(wordCell); | ||
| 3339 | - row.appendChild(weightCell); | ||
| 3340 | - row.appendChild(categoryCell); | ||
| 3341 | - tbody.appendChild(row); | 3489 | + badges.appendChild(buildBadge(item, maxWeight)); |
| 3342 | }); | 3490 | }); |
| 3343 | - table.appendChild(tbody); | ||
| 3344 | - fallback.appendChild(table); | 3491 | + fallback.appendChild(badges); |
| 3345 | } | 3492 | } |
| 3346 | 3493 | ||
| 3347 | function renderWordCloud(canvas, payload, skipRegistry) { | 3494 | function renderWordCloud(canvas, payload, skipRegistry) { |
| @@ -3358,38 +3505,82 @@ function renderWordCloud(canvas, payload, skipRegistry) { | @@ -3358,38 +3505,82 @@ function renderWordCloud(canvas, payload, skipRegistry) { | ||
| 3358 | renderWordCloudFallback(canvas, items, '词云依赖未加载'); | 3505 | renderWordCloudFallback(canvas, items, '词云依赖未加载'); |
| 3359 | return; | 3506 | return; |
| 3360 | } | 3507 | } |
| 3508 | + const theme = resolveWordcloudTheme(); | ||
| 3361 | const dpr = Math.max(1, window.devicePixelRatio || 1); | 3509 | const dpr = Math.max(1, window.devicePixelRatio || 1); |
| 3362 | - const width = Math.max(240, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320)); | ||
| 3363 | - const height = Math.max(180, Math.round(width * 0.62)); | 3510 | + const width = Math.max(260, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320)); |
| 3511 | + const height = Math.max(120, Math.round(width / 5)); // 5:1 宽高比 | ||
| 3364 | canvas.width = Math.round(width * dpr); | 3512 | canvas.width = Math.round(width * dpr); |
| 3365 | canvas.height = Math.round(height * dpr); | 3513 | canvas.height = Math.round(height * dpr); |
| 3366 | canvas.style.width = `${width}px`; | 3514 | canvas.style.width = `${width}px`; |
| 3367 | canvas.style.height = `${height}px`; | 3515 | canvas.style.height = `${height}px`; |
| 3516 | + canvas.style.backgroundColor = 'transparent'; | ||
| 3517 | + | ||
| 3518 | + const resolveBgColor = () => { | ||
| 3519 | + const cardEl = card || container || document.body; | ||
| 3520 | + const style = getComputedStyle(cardEl); | ||
| 3521 | + const tokens = ['--card-bg', '--panel-bg', '--paper-bg', '--bg', '--background', '--page-bg']; | ||
| 3522 | + for (const key of tokens) { | ||
| 3523 | + const val = style.getPropertyValue(key); | ||
| 3524 | + if (val && val.trim() && val.trim() !== 'transparent') return val.trim(); | ||
| 3525 | + } | ||
| 3526 | + if (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') return style.backgroundColor; | ||
| 3527 | + const bodyStyle = getComputedStyle(document.body); | ||
| 3528 | + for (const key of tokens) { | ||
| 3529 | + const val = bodyStyle.getPropertyValue(key); | ||
| 3530 | + if (val && val.trim() && val.trim() !== 'transparent') return val.trim(); | ||
| 3531 | + } | ||
| 3532 | + if (bodyStyle.backgroundColor && bodyStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') { | ||
| 3533 | + return bodyStyle.backgroundColor; | ||
| 3534 | + } | ||
| 3535 | + return 'transparent'; | ||
| 3536 | + }; | ||
| 3537 | + const bgColor = resolveBgColor() || theme.cardBg || 'transparent'; | ||
| 3368 | 3538 | ||
| 3369 | const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1; | 3539 | const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1; |
| 3540 | + const weightLookup = new Map(); | ||
| 3541 | + const categoryLookup = new Map(); | ||
| 3542 | + items.forEach(it => { | ||
| 3543 | + weightLookup.set(it.word, it.weight || 1); | ||
| 3544 | + categoryLookup.set(it.word, it.category || ''); | ||
| 3545 | + }); | ||
| 3370 | const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]); | 3546 | const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]); |
| 3371 | try { | 3547 | try { |
| 3372 | WordCloud(canvas, { | 3548 | WordCloud(canvas, { |
| 3373 | list, | 3549 | list, |
| 3374 | - gridSize: Math.max(8, Math.floor(Math.sqrt(canvas.width * canvas.height) / 80)), | 3550 | + gridSize: Math.max(3, Math.floor(Math.sqrt(canvas.width * canvas.height) / 170)), |
| 3375 | weightFactor: (val) => { | 3551 | weightFactor: (val) => { |
| 3376 | const normalized = Math.max(0, val) / maxWeight; | 3552 | const normalized = Math.max(0, val) / maxWeight; |
| 3377 | - const size = 16 + normalized * 32; | 3553 | + const cap = Math.min(width, height); |
| 3554 | + const base = Math.max(9, cap / 5.5); | ||
| 3555 | + const size = base * (0.8 + normalized * 1.3); | ||
| 3378 | return size * dpr; | 3556 | return size * dpr; |
| 3379 | }, | 3557 | }, |
| 3380 | color: (word) => { | 3558 | color: (word) => { |
| 3381 | - const found = items.find(entry => entry.word === word); | ||
| 3382 | - return lightenColor(wordcloudColor(found && found.category), 0.05); | 3559 | + const w = weightLookup.get(word) || 1; |
| 3560 | + const ratio = Math.max(0, Math.min(1, w / (maxWeight || 1))); | ||
| 3561 | + const category = categoryLookup.get(word) || ''; | ||
| 3562 | + const base = wordcloudColor(category); | ||
| 3563 | + const target = theme.isDark ? '#ffffff' : (theme.text || '#111827'); | ||
| 3564 | + const mixAmount = theme.isDark | ||
| 3565 | + ? 0.28 + (1 - ratio) * 0.22 | ||
| 3566 | + : 0.12 + (1 - ratio) * 0.35; | ||
| 3567 | + const mixed = mixColors(base, target, mixAmount); | ||
| 3568 | + return ensureAlpha(mixed || base, theme.isDark ? 0.95 : 1); | ||
| 3383 | }, | 3569 | }, |
| 3384 | - rotateRatio: 0.15, | 3570 | + rotateRatio: 0, |
| 3571 | + rotationSteps: 0, | ||
| 3385 | shuffle: false, | 3572 | shuffle: false, |
| 3386 | shrinkToFit: true, | 3573 | shrinkToFit: true, |
| 3387 | drawOutOfBound: false, | 3574 | drawOutOfBound: false, |
| 3388 | - backgroundColor: getComputedStyle(document.body).getPropertyValue('--card-bg').trim() || '#fff' | 3575 | + shape: 'square', |
| 3576 | + ellipticity: 0.45, | ||
| 3577 | + clearCanvas: true, | ||
| 3578 | + backgroundColor: bgColor | ||
| 3389 | }); | 3579 | }); |
| 3390 | if (container) { | 3580 | if (container) { |
| 3391 | container.style.display = ''; | 3581 | container.style.display = ''; |
| 3392 | container.style.minHeight = `${height}px`; | 3582 | container.style.minHeight = `${height}px`; |
| 3583 | + container.style.background = 'transparent'; | ||
| 3393 | } | 3584 | } |
| 3394 | const fallback = card && card.querySelector('.chart-fallback'); | 3585 | const fallback = card && card.querySelector('.chart-fallback'); |
| 3395 | if (fallback) { | 3586 | if (fallback) { |
| @@ -3870,11 +4061,19 @@ function exportPdf() { | @@ -3870,11 +4061,19 @@ function exportPdf() { | ||
| 3870 | } | 4061 | } |
| 3871 | 4062 | ||
| 3872 | document.addEventListener('DOMContentLoaded', () => { | 4063 | document.addEventListener('DOMContentLoaded', () => { |
| 4064 | + const rerenderWordclouds = debounce(() => { | ||
| 4065 | + wordCloudRegistry.forEach(fn => { | ||
| 4066 | + if (typeof fn === 'function') { | ||
| 4067 | + fn(); | ||
| 4068 | + } | ||
| 4069 | + }); | ||
| 4070 | + }, 260); | ||
| 3873 | const themeBtn = document.getElementById('theme-toggle'); | 4071 | const themeBtn = document.getElementById('theme-toggle'); |
| 3874 | if (themeBtn) { | 4072 | if (themeBtn) { |
| 3875 | themeBtn.addEventListener('click', () => { | 4073 | themeBtn.addEventListener('click', () => { |
| 3876 | document.body.classList.toggle('dark-mode'); | 4074 | document.body.classList.toggle('dark-mode'); |
| 3877 | chartRegistry.forEach(applyChartTheme); | 4075 | chartRegistry.forEach(applyChartTheme); |
| 4076 | + rerenderWordclouds(); | ||
| 3878 | }); | 4077 | }); |
| 3879 | } | 4078 | } |
| 3880 | const printBtn = document.getElementById('print-btn'); | 4079 | const printBtn = document.getElementById('print-btn'); |
| @@ -3885,13 +4084,6 @@ document.addEventListener('DOMContentLoaded', () => { | @@ -3885,13 +4084,6 @@ document.addEventListener('DOMContentLoaded', () => { | ||
| 3885 | if (exportBtn) { | 4084 | if (exportBtn) { |
| 3886 | exportBtn.addEventListener('click', exportPdf); | 4085 | exportBtn.addEventListener('click', exportPdf); |
| 3887 | } | 4086 | } |
| 3888 | - const rerenderWordclouds = debounce(() => { | ||
| 3889 | - wordCloudRegistry.forEach(fn => { | ||
| 3890 | - if (typeof fn === 'function') { | ||
| 3891 | - fn(); | ||
| 3892 | - } | ||
| 3893 | - }); | ||
| 3894 | - }, 260); | ||
| 3895 | window.addEventListener('resize', rerenderWordclouds); | 4087 | window.addEventListener('resize', rerenderWordclouds); |
| 3896 | hydrateCharts(); | 4088 | hydrateCharts(); |
| 3897 | }); | 4089 | }); |
| @@ -331,6 +331,13 @@ class PDFRenderer: | @@ -331,6 +331,13 @@ class PDFRenderer: | ||
| 331 | 331 | ||
| 332 | # 只处理chart.js类型的widget | 332 | # 只处理chart.js类型的widget |
| 333 | if widget_id and widget_type.startswith('chart.js'): | 333 | if widget_id and widget_type.startswith('chart.js'): |
| 334 | + widget_type_lower = widget_type.lower() | ||
| 335 | + props = block.get('props') | ||
| 336 | + props_type = str(props.get('type') or '').lower() if isinstance(props, dict) else '' | ||
| 337 | + if 'wordcloud' in widget_type_lower or 'wordcloud' in props_type: | ||
| 338 | + logger.debug(f"检测到词云 {widget_id},跳过SVG转换并使用图片注入流程") | ||
| 339 | + continue | ||
| 340 | + | ||
| 334 | failed, fail_reason = self.html_renderer._has_chart_failure(block) | 341 | failed, fail_reason = self.html_renderer._has_chart_failure(block) |
| 335 | if block.get("_chart_renderable") is False or failed: | 342 | if block.get("_chart_renderable") is False or failed: |
| 336 | logger.debug( | 343 | logger.debug( |
| @@ -392,7 +399,13 @@ class PDFRenderer: | @@ -392,7 +399,13 @@ class PDFRenderer: | ||
| 392 | widget_id = block.get('widgetId') | 399 | widget_id = block.get('widgetId') |
| 393 | widget_type = block.get('widgetType', '') | 400 | widget_type = block.get('widgetType', '') |
| 394 | 401 | ||
| 395 | - if widget_id and isinstance(widget_type, str) and 'wordcloud' in widget_type.lower(): | 402 | + props = block.get('props') |
| 403 | + props_type = str(props.get('type') or '') if isinstance(props, dict) else '' | ||
| 404 | + is_wordcloud = ( | ||
| 405 | + isinstance(widget_type, str) and 'wordcloud' in widget_type.lower() | ||
| 406 | + ) or ('wordcloud' in props_type.lower()) | ||
| 407 | + | ||
| 408 | + if widget_id and is_wordcloud: | ||
| 396 | try: | 409 | try: |
| 397 | data_uri = self._generate_wordcloud_image(block) | 410 | data_uri = self._generate_wordcloud_image(block) |
| 398 | if data_uri: | 411 | if data_uri: |
| @@ -464,12 +477,14 @@ class PDFRenderer: | @@ -464,12 +477,14 @@ class PDFRenderer: | ||
| 464 | 477 | ||
| 465 | font_path = str(self._get_font_path()) | 478 | font_path = str(self._get_font_path()) |
| 466 | wc = WordCloud( | 479 | wc = WordCloud( |
| 467 | - width=900, | ||
| 468 | - height=520, | 480 | + width=1000, |
| 481 | + height=360, | ||
| 469 | background_color="white", | 482 | background_color="white", |
| 470 | font_path=font_path, | 483 | font_path=font_path, |
| 471 | - prefer_horizontal=0.9, | 484 | + prefer_horizontal=0.98, |
| 472 | random_state=42, | 485 | random_state=42, |
| 486 | + max_words=180, | ||
| 487 | + collocations=False, | ||
| 473 | ) | 488 | ) |
| 474 | wc.generate_from_frequencies(frequencies) | 489 | wc.generate_from_frequencies(frequencies) |
| 475 | 490 |
-
Please register or login to post a comment