优化社交关系图可读性并突出连接人数

This commit is contained in:
liuwei
2026-04-27 09:38:39 +08:00
parent 4dbf390c65
commit b62d313690
2 changed files with 145 additions and 28 deletions

View File

@@ -1295,6 +1295,73 @@ class ValueRankPlugin(MessagePluginInterface):
if not selected_edges:
return None
# 关系图改为“全员 + 主要边”策略:
# 1. 用户希望不要限制图上的人数,因此节点仍尽量保留全量;
# 2. 真正导致看不清的不是人数,而是边过多、边标签过多;
# 3. 这里对展示边做一层稀疏化,只保留每个人的主要关系和全图最强的若干边。
full_selected_edges = selected_edges
display_edge_cap = max(18, int(len(selected_nodes) * 1.18))
display_edge_cap = min(len(full_selected_edges), display_edge_cap)
if len(full_selected_edges) > display_edge_cap:
core_node_count = max(3, min(6, len(selected_nodes) // 8 + 2))
core_nodes = set(selected_nodes[:core_node_count])
node_degree_cap: Dict[str, int] = {
uid: (6 if uid in core_nodes else 3)
for uid in selected_nodes
}
edge_degree_map: Dict[str, int] = {uid: 0 for uid in selected_nodes}
strongest_edge_by_node: Dict[str, Tuple[str, str, float, float, int, int]] = {}
sorted_edges = sorted(
full_selected_edges,
key=lambda item: (
-float(item[3]),
-float(item[2]),
-min(len(partner_map.get(item[0], set())), len(partner_map.get(item[1], set()))),
item[0],
item[1],
),
)
for edge in sorted_edges:
a, b = edge[0], edge[1]
if a not in strongest_edge_by_node:
strongest_edge_by_node[a] = edge
if b not in strongest_edge_by_node:
strongest_edge_by_node[b] = edge
kept_edge_keys = set()
pruned_edges: List[Tuple[str, str, float, float, int, int]] = []
def try_append_edge(edge_item: Tuple[str, str, float, float, int, int], ignore_degree_cap: bool = False) -> bool:
edge_a, edge_b = edge_item[0], edge_item[1]
edge_key = (edge_a, edge_b)
if edge_key in kept_edge_keys:
return False
if not ignore_degree_cap:
if edge_degree_map.get(edge_a, 0) >= node_degree_cap.get(edge_a, 3):
return False
if edge_degree_map.get(edge_b, 0) >= node_degree_cap.get(edge_b, 3):
return False
kept_edge_keys.add(edge_key)
pruned_edges.append(edge_item)
edge_degree_map[edge_a] = edge_degree_map.get(edge_a, 0) + 1
edge_degree_map[edge_b] = edge_degree_map.get(edge_b, 0) + 1
return True
# 先保证每个人至少能挂上一条自己最强的关系线,避免外围节点变成“孤点”。
for uid in selected_nodes:
edge = strongest_edge_by_node.get(uid)
if edge:
try_append_edge(edge, ignore_degree_cap=True)
# 再按全图强度补满,兼顾整体关系结构,但限制每个节点的展示边数。
for edge in sorted_edges:
if len(pruned_edges) >= display_edge_cap:
break
try_append_edge(edge, ignore_degree_cap=False)
selected_edges = pruned_edges
html_content = self._build_social_graph_html(group_id, selected_nodes, selected_edges, partner_map, node_score_map)
if not html_content:
return None
@@ -1325,8 +1392,8 @@ class ValueRankPlugin(MessagePluginInterface):
import html
width = 1460
height = 1040
width = 1540
height = 1120
cx, cy = width // 2, height // 2 + 24
node_count = len(selected_nodes)
@@ -1338,9 +1405,9 @@ class ValueRankPlugin(MessagePluginInterface):
# 1. 核心节点(连接人数更多)放在内圈,外围节点逐层向外扩散;
# 2. 每圈容量递增,避免所有人都挤在一个圆上;
# 3. 外圈增加轻微抖动和椭圆拉伸,让大图下更分散,不会像等分钟表一样拥挤。
inner_margin = 150.0
outer_margin_x = 120.0
outer_margin_y = 110.0
inner_margin = 188.0
outer_margin_x = 108.0
outer_margin_y = 102.0
max_rx = width * 0.5 - outer_margin_x
max_ry = height * 0.5 - outer_margin_y
ring_capacities: List[int] = []
@@ -1348,9 +1415,9 @@ class ValueRankPlugin(MessagePluginInterface):
ring_index = 0
while placed < node_count:
if ring_index == 0:
capacity = min(4, node_count)
capacity = min(3, node_count)
elif ring_index == 1:
capacity = 8
capacity = 7
else:
capacity = 12 + ring_index * 6
ring_capacities.append(capacity)
@@ -1393,20 +1460,42 @@ class ValueRankPlugin(MessagePluginInterface):
cm = ContactManager.get_instance()
edge_svg_parts: List[str] = []
# 双向@标签层
# 1. 每条边只保留双向次数数字,避免标签信息量过大把图面压得太满
# 2. 仍保留白底半透明标签,确保在密集连线里也能看清数值。
# 边数字标签只给“最重要的少数边”
# 1. 全边标数字时,图面会被 0/1、1/0 这类噪声淹没
# 2. 这里保留最强的一小部分边标签,其余只看粗细即可;
# 3. 这样既能保留方向信息,也不至于牺牲整体结构可读性。
edge_label_parts: List[str] = []
max_labeled_edges = max(6, min(12, len(selected_nodes) // 4 + 3))
labeled_edge_keys = {
(edge[0], edge[1])
for edge in sorted(
selected_edges,
key=lambda item: (-float(item[3]), -float(item[2]), item[0], item[1]),
)[:max_labeled_edges]
}
def _shorten_graph_nick(raw_name: str, max_len: int = 8) -> str:
text = str(raw_name or "").strip()
return text if len(text) <= max_len else f"{text[:max_len]}"
def _estimate_label_units(raw_text: str) -> float:
units = 0.0
for ch in str(raw_text or ""):
units += 1.0 if ord(ch) < 128 else 1.75
return units
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]
bx, by = pos_map[b]
normalized = max(0.12, min(score / max_edge_score, 1.0))
stroke_width = 1.0 + 7.0 * normalized
opacity = 0.20 + 0.55 * normalized
stroke_width = 1.2 + 6.2 * normalized
opacity = 0.14 + 0.42 * normalized
edge_svg_parts.append(
f'<line x1="{ax:.1f}" y1="{ay:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />'
)
if (a, b) not in labeled_edge_keys:
continue
# 通过字典序固定方向,确保同一条边每次渲染文案方向一致。
# 当前标签只保留双向互动数字,同时继续沿法线方向偏移,避免数字压在线条上。
mx, my = (ax + bx) / 2.0, (ay + by) / 2.0
@@ -1427,12 +1516,12 @@ class ValueRankPlugin(MessagePluginInterface):
angle_deg += 180
label_text = f"{int(a_to_b_count)}/{int(b_to_a_count)}"
safe_label = html.escape(label_text)
label_width = max(52.0, min(92.0, 10.0 * len(label_text) + 24.0))
label_width = max(50.0, min(88.0, 10.0 * len(label_text) + 20.0))
edge_label_parts.append(
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'rx="7" ry="7" fill="rgba(255,255,255,0.84)"></rect>'
f'<text x="0" y="4" text-anchor="middle" font-size="11" fill="#4E5F7D">{safe_label}</text>'
f'</g>'
)
@@ -1450,7 +1539,8 @@ class ValueRankPlugin(MessagePluginInterface):
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))
display_nick = _shorten_graph_nick(str(nick), 8)
safe_nick = html.escape(display_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))
@@ -1486,30 +1576,57 @@ class ValueRankPlugin(MessagePluginInterface):
f'{html.escape(str(nick)[:1] or "?")}</text>'
)
# 节点文案继续放在头像外侧,但整体收紧一圈
# 1. 用户反馈昵称和头像分得太开,这里缩短外偏移距离
# 2. 昵称字号调小,减少节点一多时的横向挤压
# 3. 统计行与昵称保持较近距离,让“头像-昵称-数据”看起来是一组
# “连接人数”改成贴在头像上的数字徽标
# 1. 用户反馈原先那行“连接x · 分数y”混在线条里看不清
# 2. 数字徽标直接挂在节点边缘,读图时更像“这个人连了多少人”
# 3. 图里移除分数展示,只保留更关键的连接人数,减少认知负担
badge_text = str(int(partner_count))
badge_font_size = 11 if len(badge_text) <= 2 else 10
badge_width = 16.0 + len(badge_text) * 7.5
badge_height = 20.0
badge_x = x + node_radius * 0.58 - badge_width / 2.0
badge_y = y + node_radius * 0.55 - badge_height / 2.0
node_svg_parts.append(
f'<rect x="{badge_x:.1f}" y="{badge_y:.1f}" width="{badge_width:.1f}" height="{badge_height:.1f}" '
f'rx="10" ry="10" fill="rgba(255,255,255,0.95)" stroke="{ring_color}" stroke-width="1.9"></rect>'
)
node_svg_parts.append(
f'<text x="{badge_x + badge_width / 2.0:.1f}" y="{badge_y + 13.2:.1f}" text-anchor="middle" '
f'font-size="{badge_font_size}" fill="#2F3B52" font-weight="700">{badge_text}</text>'
)
# 节点文案只保留昵称,并加半透明底板:
# 1. 去掉第二行数值后,名字可以更干净地贴在节点旁边;
# 2. 底板能把名字从复杂连线背景里“托”出来;
# 3. 长昵称做截断,避免外围节点之间互相顶住。
outward_dx = math.cos(angle)
outward_dy = math.sin(angle)
label_gap = node_radius + (11.0 if ring_idx == 0 else 17.0 + ring_idx * 2.0)
label_gap = node_radius + (14.0 if ring_idx == 0 else 18.0 + ring_idx * 2.0)
title_x = x + outward_dx * label_gap
title_y = y + outward_dy * label_gap
info_x = x + outward_dx * (label_gap + 12.0)
info_y = y + outward_dy * (label_gap + 12.0) + 1.5
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_width = max(44.0, min(156.0, 10.0 * label_units + 18.0))
label_height = 24.0
if anchor == "middle":
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
node_svg_parts.append(
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
f'font-size="16" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
f'<rect x="{label_box_x:.1f}" y="{label_box_y:.1f}" width="{label_width:.1f}" height="{label_height:.1f}" '
f'rx="12" ry="12" fill="rgba(255,255,255,0.78)"></rect>'
)
node_svg_parts.append(
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
f'font-size="12" fill="#6C7A96">连接{partner_count} · {score:.1f}</text>'
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
f'font-size="14.5" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
)
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)

View File

@@ -58,7 +58,7 @@
</svg>
</div>
<div class="legend">
说明:节点越大代表连接群友越多;连线越粗代表互动越强。节点优先显示成员头像,缺失头像时自动回退昵称首字
说明:节点越大代表连接群友越多;头像右下角数字是连接人数;连线越粗代表互动越强。为保证可读性,图中仅保留主要关系线,边上的数字只标最强的一小部分关系
</div>
</div>
</body>