修复关系图头像缺失并调整名字与核心标识

This commit is contained in:
liuwei
2026-04-27 09:44:32 +08:00
parent b62d313690
commit f7a5096b3d

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import base64
import json import json
import math import math
import mimetypes
import re import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -1459,6 +1461,7 @@ class ValueRankPlugin(MessagePluginInterface):
} }
cm = ContactManager.get_instance() cm = ContactManager.get_instance()
strongest_uid = selected_nodes[0] if selected_nodes else ""
edge_svg_parts: List[str] = [] edge_svg_parts: List[str] = []
# 边数字标签只给“最重要的少数边”: # 边数字标签只给“最重要的少数边”:
# 1. 全边标数字时,图面会被 0/1、1/0 这类噪声淹没; # 1. 全边标数字时,图面会被 0/1、1/0 这类噪声淹没;
@@ -1484,6 +1487,24 @@ class ValueRankPlugin(MessagePluginInterface):
units += 1.0 if ord(ch) < 128 else 1.75 units += 1.0 if ord(ch) < 128 else 1.75
return units return units
def _build_avatar_data_url(raw_avatar_url: str, wxid: str) -> str:
"""优先读取本地缓存头像并转 data URL减少截图时依赖远端头像可用性。"""
# 社交图渲染发生在 Playwright 截图流程中:
# 1. 如果直接给远端头像 URL链接过期、网络抖动或防盗链都可能导致头像空白
# 2. 这里优先确保头像已缓存到本地,再内联为 data URL截图时最稳定
# 3. 如果本地缓存仍失败,再回退到原始 URL尽量不影响图谱整体生成。
local_avatar_path = str(cm.ensure_head_image_cached(wxid) or "").strip()
if local_avatar_path:
try:
avatar_path = Path(local_avatar_path)
image_bytes = avatar_path.read_bytes()
mime_type = mimetypes.guess_type(str(avatar_path))[0] or "image/jpeg"
base64_str = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime_type};base64,{base64_str}"
except Exception as exc:
self.LOG.debug(f"[{self.name}] 头像转 data url 失败: wxid={wxid}, error={exc}")
return str(raw_avatar_url or "").strip()
for edge_idx, (a, b, mention_count, score, a_to_b_count, b_to_a_count) in enumerate(selected_edges, start=1): for edge_idx, (a, b, mention_count, score, a_to_b_count, b_to_a_count) in enumerate(selected_edges, start=1):
ax, ay = pos_map[a] ax, ay = pos_map[a]
bx, by = pos_map[b] bx, by = pos_map[b]
@@ -1541,9 +1562,8 @@ class ValueRankPlugin(MessagePluginInterface):
nick = cm.get_group_name(group_id, uid) or uid nick = cm.get_group_name(group_id, uid) or uid
display_nick = _shorten_graph_nick(str(nick), 8) display_nick = _shorten_graph_nick(str(nick), 8)
safe_nick = html.escape(display_nick) safe_nick = html.escape(display_nick)
avatar_url = str(cm.get_head_image(uid) or "").strip() avatar_url = _build_avatar_data_url(str(cm.get_head_image(uid) or "").strip(), uid)
ring_idx = int(node_meta_map.get(uid, {}).get("ring_idx", 0)) ring_idx = int(node_meta_map.get(uid, {}).get("ring_idx", 0))
angle = float(node_meta_map.get(uid, {}).get("angle", 0.0))
# 使用“连接度越高环线越偏暖”的视觉策略,帮助快速识别核心节点。 # 使用“连接度越高环线越偏暖”的视觉策略,帮助快速识别核心节点。
ring_color = "rgba(255, 152, 0, 0.95)" if size_norm >= 0.6 else "rgba(79, 123, 201, 0.95)" ring_color = "rgba(255, 152, 0, 0.95)" if size_norm >= 0.6 else "rgba(79, 123, 201, 0.95)"
@@ -1576,6 +1596,34 @@ class ValueRankPlugin(MessagePluginInterface):
f'{html.escape(str(nick)[:1] or "?")}</text>' f'{html.escape(str(nick)[:1] or "?")}</text>'
) )
# 最强核心节点补一个皇冠标记:
# 1. strongest_uid 取当前排序后的第一名,代表群里最强的桥梁/核心节点;
# 2. 皇冠放在头像上方,视觉上比文字提示更直观;
# 3. 使用简单 SVG 形状,避免依赖字体里的 emoji / 特殊字符。
if uid == strongest_uid:
crown_y = y - node_radius - 16.0
crown_points = [
(x - 15.0, crown_y + 18.0),
(x - 10.0, crown_y + 6.0),
(x - 2.5, crown_y + 14.0),
(x + 0.0, crown_y + 2.0),
(x + 2.5, crown_y + 14.0),
(x + 10.0, crown_y + 6.0),
(x + 15.0, crown_y + 18.0),
]
crown_points_text = " ".join([f"{px:.1f},{py:.1f}" for px, py in crown_points])
node_svg_parts.append(
f'<g>'
f'<rect x="{x - 15.0:.1f}" y="{crown_y + 18.0:.1f}" width="30.0" height="5.5" rx="2.8" ry="2.8" '
f'fill="rgba(255, 183, 3, 0.98)" stroke="rgba(168, 104, 0, 0.85)" stroke-width="1.0"></rect>'
f'<polygon points="{crown_points_text}" fill="rgba(255, 202, 40, 0.98)" '
f'stroke="rgba(168, 104, 0, 0.85)" stroke-width="1.2"></polygon>'
f'<circle cx="{x - 10.0:.1f}" cy="{crown_y + 6.0:.1f}" r="2.2" fill="rgba(255,255,255,0.96)"></circle>'
f'<circle cx="{x + 0.0:.1f}" cy="{crown_y + 2.0:.1f}" r="2.4" fill="rgba(255,255,255,0.96)"></circle>'
f'<circle cx="{x + 10.0:.1f}" cy="{crown_y + 6.0:.1f}" r="2.2" fill="rgba(255,255,255,0.96)"></circle>'
f'</g>'
)
# “连接人数”改成贴在头像上的数字徽标: # “连接人数”改成贴在头像上的数字徽标:
# 1. 用户反馈原先那行“连接x · 分数y”混在线条里看不清 # 1. 用户反馈原先那行“连接x · 分数y”混在线条里看不清
# 2. 数字徽标直接挂在节点边缘,读图时更像“这个人连了多少人”; # 2. 数字徽标直接挂在节点边缘,读图时更像“这个人连了多少人”;
@@ -1595,30 +1643,17 @@ class ValueRankPlugin(MessagePluginInterface):
f'font-size="{badge_font_size}" fill="#2F3B52" font-weight="700">{badge_text}</text>' f'font-size="{badge_font_size}" fill="#2F3B52" font-weight="700">{badge_text}</text>'
) )
# 节点文案只保留昵称,并加半透明底板 # 名字固定放在头像正下方,避免沿径向排布带来的“漂移感”
# 1. 去掉第二行数值后,名字可以更干净地贴在节点旁边 # 1. 用户反馈名字位置飘忽,说明相对角度布局不利于快速扫图
# 2. 底板能把名字从复杂连线背景里“托”出来 # 2. 统一放在头像下方后,读图路径会稳定很多
# 3. 长昵称做截断,避免外围节点之间互相顶住 # 3. 底板保留,继续提升名字在复杂线条背景上的可读性
outward_dx = math.cos(angle) title_x = x
outward_dy = math.sin(angle) title_y = y + node_radius + 24.0
label_gap = node_radius + (14.0 if ring_idx == 0 else 18.0 + ring_idx * 2.0) anchor = "middle"
title_x = x + outward_dx * label_gap
title_y = y + outward_dy * label_gap
if abs(outward_dx) < 0.20:
anchor = "middle"
elif outward_dx > 0:
anchor = "start"
else:
anchor = "end"
label_units = _estimate_label_units(display_nick) label_units = _estimate_label_units(display_nick)
label_width = max(44.0, min(156.0, 10.0 * label_units + 18.0)) label_width = max(44.0, min(156.0, 10.0 * label_units + 18.0))
label_height = 24.0 label_height = 24.0
if anchor == "middle": label_box_x = title_x - label_width / 2.0
label_box_x = title_x - label_width / 2.0
elif anchor == "start":
label_box_x = title_x - 8.0
else:
label_box_x = title_x - label_width + 8.0
label_box_y = title_y - 18.0 label_box_y = title_y - 18.0
node_svg_parts.append( node_svg_parts.append(
f'<rect x="{label_box_x:.1f}" y="{label_box_y:.1f}" width="{label_width:.1f}" height="{label_height:.1f}" ' f'<rect x="{label_box_x:.1f}" y="{label_box_y:.1f}" width="{label_width:.1f}" height="{label_height:.1f}" '