优化身价功能社交关系图布局与展示策略

This commit is contained in:
liuwei
2026-04-24 17:56:01 +08:00
parent 2ac7704d3b
commit 0636e0453f
2 changed files with 135 additions and 30 deletions

View File

@@ -31,8 +31,12 @@ base_score_scale = 1000
# 排行默认展示数量
default_rank_limit = 10
max_rank_limit = 50
default_graph_nodes = 12
max_graph_nodes = 24
# 社交关系图默认按全量节点绘制:
# 1. 0 表示不截断,尽量展示所有关系节点;
# 2. 用户若显式传人数参数,仍可只看局部;
# 3. max_graph_nodes 保留为兼容字段0 表示不再设置硬上限。
default_graph_nodes = 0
max_graph_nodes = 0
graph_edge_pool_limit = 300
social_graph_template_path = "plugins/value_rank/templates/social_graph.html"
text_auto_revoke_seconds = 80

View File

@@ -555,8 +555,12 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = 10
self.max_rank_limit = 50
self.default_graph_nodes = 12
self.max_graph_nodes = 24
# 社交关系图默认改成“尽量全量展示”:
# 1. 用户希望图上不再限制人数;
# 2. 这里用 0 表示“不截断”,后续解析层统一识别;
# 3. 如果用户手动传了人数参数,仍然允许看局部子图。
self.default_graph_nodes = 0
self.max_graph_nodes = 0
self.graph_edge_pool_limit = 300
self.social_graph_template_path = "plugins/value_rank/templates/social_graph.html"
self.text_auto_revoke_seconds = 80
@@ -1194,7 +1198,7 @@ class ValueRankPlugin(MessagePluginInterface):
设计说明:
1. 数据层使用近窗口期社交边聚合结果,避免扫全量消息;
2. 先做无向边合并,再筛选“连接度高”的核心节点,控制图复杂度
2. 先做无向边合并,再“连接度高优先”构建分层图,让核心节点更靠近中心
3. 通过 markdown2image 的 HTML 截图能力输出图片,统一渲染链路。
"""
if not self.db:
@@ -1233,7 +1237,10 @@ class ValueRankPlugin(MessagePluginInterface):
if not edge_map:
return None
# 使用“伙伴去重数 + 互动分”混合排序选核心节点,让图既看覆盖面也看互动强度。
# 使用“伙伴去重数 + 互动分”混合排序
# 1. 连接人数越多,说明越像群里的桥梁节点;
# 2. 同连接数下再用互动分排序,避免只看“浅连接”;
# 3. 后续布局时也复用这份排序,让高连接节点自然更靠近中心。
sorted_nodes = sorted(
list(partner_map.keys()),
key=lambda uid: (
@@ -1242,7 +1249,14 @@ class ValueRankPlugin(MessagePluginInterface):
uid,
),
)
selected_nodes = sorted_nodes[:max_nodes]
# 不再对关系图人数做硬截断:
# 1. 用户明确希望“不要限制图上人数”;
# 2. max_nodes 现在只作为“用户请求值”,当传 0 或很大时都允许尽量展示全量;
# 3. 若请求人数小于全量,则仍尊重用户显式指定,方便只看局部图。
if max_nodes > 0:
selected_nodes = sorted_nodes[:max_nodes]
else:
selected_nodes = sorted_nodes
selected_set = set(selected_nodes)
if len(selected_nodes) < 2:
return None
@@ -1311,22 +1325,71 @@ class ValueRankPlugin(MessagePluginInterface):
import html
width = 1300
height = 900
cx, cy = width // 2, height // 2 + 10
radius = min(width, height) * 0.33
width = 1460
height = 1040
cx, cy = width // 2, height // 2 + 24
node_count = len(selected_nodes)
# 计算节点坐标:使用圆形布局,稳定且不依赖额外图算法库。
pos_map: Dict[str, Tuple[float, float]] = {}
for idx, uid in enumerate(selected_nodes):
angle = (2.0 * math.pi * idx) / max(node_count, 1)
x = cx + radius * math.cos(angle)
y = cy + radius * math.sin(angle)
pos_map[uid] = (x, y)
max_edge_score = max([edge[3] for edge in selected_edges] + [1.0])
max_partner_count = max([len(partner_map.get(uid, set())) for uid in selected_nodes] + [1])
max_node_score = max([float(node_score_map.get(uid, 0.0)) for uid in selected_nodes] + [1.0])
# 多环分层布局:
# 1. 核心节点(连接人数更多)放在内圈,外围节点逐层向外扩散;
# 2. 每圈容量递增,避免所有人都挤在一个圆上;
# 3. 外圈增加轻微抖动和椭圆拉伸,让大图下更分散,不会像等分钟表一样拥挤。
inner_margin = 150.0
outer_margin_x = 120.0
outer_margin_y = 110.0
max_rx = width * 0.5 - outer_margin_x
max_ry = height * 0.5 - outer_margin_y
ring_capacities: List[int] = []
placed = 0
ring_index = 0
while placed < node_count:
if ring_index == 0:
capacity = min(4, node_count)
elif ring_index == 1:
capacity = 8
else:
capacity = 12 + ring_index * 6
ring_capacities.append(capacity)
placed += capacity
ring_index += 1
ring_count = len(ring_capacities)
if ring_count <= 1:
ring_radii = [(inner_margin, inner_margin * 0.82)]
else:
ring_radii = []
for idx in range(ring_count):
progress = idx / max(ring_count - 1, 1)
rx = inner_margin + (max_rx - inner_margin) * progress
ry = inner_margin * 0.82 + (max_ry - inner_margin * 0.82) * progress
ring_radii.append((rx, ry))
pos_map: Dict[str, Tuple[float, float]] = {}
node_meta_map: Dict[str, Dict[str, float]] = {}
cursor = 0
for ring_idx, capacity in enumerate(ring_capacities):
ring_nodes = selected_nodes[cursor: cursor + capacity]
cursor += len(ring_nodes)
if not ring_nodes:
continue
rx, ry = ring_radii[min(ring_idx, len(ring_radii) - 1)]
angle_offset = (ring_idx % 2) * (math.pi / max(len(ring_nodes), 3))
for idx, uid in enumerate(ring_nodes):
angle = angle_offset + (2.0 * math.pi * idx) / max(len(ring_nodes), 1)
# 外圈轻微抖动,避免文本和头像在同一环上严格对齐后互相压住。
wobble = 1.0 + (0.03 * ((idx % 3) - 1) if ring_idx >= 1 else 0.0)
x = cx + rx * wobble * math.cos(angle)
y = cy + ry * wobble * math.sin(angle)
pos_map[uid] = (x, y)
node_meta_map[uid] = {
"ring_idx": float(ring_idx),
"angle": angle,
"rx": rx,
"ry": ry,
}
cm = ContactManager.get_instance()
edge_svg_parts: List[str] = []
@@ -1352,12 +1415,17 @@ class ValueRankPlugin(MessagePluginInterface):
edge_len = max(math.hypot(dx, dy), 1.0)
# 线段法线单位向量逆时针旋转90度
nx, ny = (-dy / edge_len), (dx / edge_len)
# 交错方向,减少多条边标签都挤在同一侧导致重叠。
# 标签改为“沿边平行显示”:
# 1. 旋转到与连线同方向,文本阅读会比水平浮在中点附近更顺;
# 2. 仍沿法线做少量偏移,防止字直接压在线条上;
# 3. 交错放在边两侧,减少多条边平行时的标签重叠。
side_sign = 1.0 if (edge_idx % 2 == 0) else -1.0
# 偏移距离与线宽相关,至少 22 像素,保证不贴线。
offset = max(22.0, 14.0 + stroke_width * 2.4)
offset = max(18.0, 10.0 + stroke_width * 2.0)
label_x = mx + nx * offset * side_sign
label_y = my + ny * offset * side_sign
angle_deg = math.degrees(math.atan2(dy, dx))
if angle_deg > 90 or angle_deg < -90:
angle_deg += 180
nick_a = cm.get_group_name(group_id, a) or a
nick_b = cm.get_group_name(group_id, b) or b
@@ -1376,10 +1444,12 @@ class ValueRankPlugin(MessagePluginInterface):
label_text = f"{nick_a}{nick_b} {a_to_b_count}/{b_to_a_count} {relation_tag}"
safe_label = html.escape(label_text)
label_width = max(140.0, min(360.0, 8.0 * len(label_text) + 34.0))
edge_label_parts.append(
f'<g transform="translate({label_x:.1f},{label_y:.1f})">'
f'<rect x="-170" y="-12" width="340" height="24" rx="8" ry="8" fill="rgba(255,255,255,0.82)"></rect>'
f'<text x="0" y="5" text-anchor="middle" font-size="12" fill="#4E5F7D">{safe_label}</text>'
f'<g transform="translate({label_x:.1f},{label_y:.1f}) rotate({angle_deg:.1f})">'
f'<rect x="-{label_width / 2:.1f}" y="-11" width="{label_width:.1f}" height="22" '
f'rx="7" ry="7" fill="rgba(255,255,255,0.78)"></rect>'
f'<text x="0" y="4" text-anchor="middle" font-size="11.5" fill="#4E5F7D">{safe_label}</text>'
f'</g>'
)
@@ -1392,11 +1462,15 @@ class ValueRankPlugin(MessagePluginInterface):
x, y = pos_map[uid]
partner_count = len(partner_map.get(uid, set()))
score = float(node_score_map.get(uid, 0.0))
# 节点大小继续体现连接人数,但收一点上限,避免大节点把标签区挤爆。
size_norm = max(0.15, min(partner_count / max_partner_count, 1.0))
node_radius = 18.0 + 24.0 * size_norm
score_norm = max(0.10, min(score / max(max_node_score, 1.0), 1.0))
node_radius = 18.0 + 18.0 * size_norm + 6.0 * score_norm
nick = cm.get_group_name(group_id, uid) or uid
safe_nick = html.escape(str(nick))
avatar_url = str(cm.get_head_image(uid) or "").strip()
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)"
@@ -1429,12 +1503,29 @@ class ValueRankPlugin(MessagePluginInterface):
f'{html.escape(str(nick)[:1] or "?")}</text>'
)
# 节点文案放到节点外侧:
# 1. 外圈节点文本沿“从中心指向外部”的方向偏移,减少互相压住;
# 2. 内圈节点仍保持较近距离,保证中心区域不会炸开;
# 3. 文本锚点根据左右半区自动切换,让阅读方向更自然。
outward_dx = math.cos(angle)
outward_dy = math.sin(angle)
label_gap = node_radius + (18.0 if ring_idx == 0 else 28.0 + ring_idx * 4.0)
title_x = x + outward_dx * label_gap
title_y = y + outward_dy * label_gap
info_x = x + outward_dx * (label_gap + 22.0)
info_y = y + outward_dy * (label_gap + 22.0)
if abs(outward_dx) < 0.20:
anchor = "middle"
elif outward_dx > 0:
anchor = "start"
else:
anchor = "end"
node_svg_parts.append(
f'<text x="{x:.1f}" y="{y + node_radius + 22:.1f}" text-anchor="middle" '
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
f'font-size="19" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
)
node_svg_parts.append(
f'<text x="{x:.1f}" y="{y + node_radius + 44:.1f}" text-anchor="middle" '
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
f'font-size="14" fill="#6C7A96">连接{partner_count}人 · 互动{score:.1f}</text>'
)
@@ -1762,7 +1853,13 @@ class ValueRankPlugin(MessagePluginInterface):
return min(days, self.max_trend_days)
def _parse_graph_nodes(self, content: str) -> int:
"""解析社交关系图节点数量参数。"""
"""解析社交关系图节点数量参数。
规则说明:
1. 不传人数时,默认返回 0表示不截断、尽量全量展示
2. 传人数时仍允许只看局部,但不再强制套 max_graph_nodes 上限;
3. 最小保留 6避免用户传太小导致图失去可读性。
"""
parts = content.split()
if len(parts) < 2:
return self.default_graph_nodes
@@ -1770,8 +1867,12 @@ class ValueRankPlugin(MessagePluginInterface):
node_count = int(parts[1])
except Exception:
return self.default_graph_nodes
if node_count <= 0:
return 0
node_count = max(6, node_count)
return min(node_count, self.max_graph_nodes)
if self.max_graph_nodes > 0:
return min(node_count, self.max_graph_nodes)
return node_count
@staticmethod
def _normalize_linear(value: float, ceiling: float) -> float: