420 lines
14 KiB
Python
420 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
圐圙文化网络 - Web 可视化应用
|
|
使用 Flask + Neo4j + D3.js
|
|
"""
|
|
|
|
from flask import Flask, render_template, jsonify, request
|
|
from neo4j import GraphDatabase
|
|
import json
|
|
|
|
app = Flask(__name__)
|
|
|
|
class KulueNetworkAPI:
|
|
def __init__(self, uri="bolt://localhost:7687", user="neo4j", password="password"):
|
|
self.driver = GraphDatabase.driver(uri, auth=(user, password))
|
|
|
|
def close(self):
|
|
self.driver.close()
|
|
|
|
def get_network_data(self):
|
|
"""获取完整网络数据用于可视化"""
|
|
with self.driver.session() as session:
|
|
# 获取所有节点
|
|
nodes_result = session.run("""
|
|
MATCH (w:Word)
|
|
RETURN w.name as name, w.category as category,
|
|
w.meaning as meaning, w.region as region, w.dynasty as dynasty
|
|
""")
|
|
|
|
nodes = []
|
|
for record in nodes_result:
|
|
nodes.append({
|
|
'id': record['name'],
|
|
'name': record['name'],
|
|
'category': record['category'],
|
|
'meaning': record['meaning'],
|
|
'region': record['region'],
|
|
'dynasty': record['dynasty']
|
|
})
|
|
|
|
# 获取所有关系
|
|
links_result = session.run("""
|
|
MATCH (source:Word)-[r]-(target:Word)
|
|
RETURN source.name as source, target.name as target,
|
|
type(r) as type, r.type as subtype
|
|
""")
|
|
|
|
links = []
|
|
processed_pairs = set()
|
|
|
|
for record in links_result:
|
|
source = record['source']
|
|
target = record['target']
|
|
|
|
# 避免重复的无向边
|
|
pair = tuple(sorted([source, target]))
|
|
if pair not in processed_pairs:
|
|
processed_pairs.add(pair)
|
|
links.append({
|
|
'source': source,
|
|
'target': target,
|
|
'type': record['type'],
|
|
'subtype': record['subtype']
|
|
})
|
|
|
|
return {'nodes': nodes, 'links': links}
|
|
|
|
def search_word(self, word_name):
|
|
"""搜索特定词汇的关联"""
|
|
with self.driver.session() as session:
|
|
result = session.run("""
|
|
MATCH (center:Word {name: $word})-[r]-(connected:Word)
|
|
RETURN center, r, connected
|
|
""", word=word_name)
|
|
|
|
data = []
|
|
for record in result:
|
|
center = record['center']
|
|
relation = record['r']
|
|
connected = record['connected']
|
|
|
|
data.append({
|
|
'center': dict(center),
|
|
'relation': {
|
|
'type': relation.type,
|
|
'properties': dict(relation)
|
|
},
|
|
'connected': dict(connected)
|
|
})
|
|
|
|
return data
|
|
|
|
def get_categories_stats(self):
|
|
"""获取类别统计"""
|
|
with self.driver.session() as session:
|
|
result = session.run("""
|
|
MATCH (w:Word)
|
|
RETURN w.category as category, count(w) as count
|
|
ORDER BY count DESC
|
|
""")
|
|
|
|
return [{'category': record['category'], 'count': record['count']}
|
|
for record in result]
|
|
|
|
def get_sound_shift_paths(self, start_word):
|
|
"""获取音转路径"""
|
|
with self.driver.session() as session:
|
|
result = session.run("""
|
|
MATCH path = (start:Word {name: $start})-[:SOUND_SHIFT*1..3]-(end:Word)
|
|
RETURN [node in nodes(path) | node.name] as path_nodes,
|
|
length(path) as path_length
|
|
ORDER BY path_length
|
|
""", start=start_word)
|
|
|
|
return [{'path': record['path_nodes'], 'length': record['path_length']}
|
|
for record in result]
|
|
|
|
# 创建API实例
|
|
kulue_api = KulueNetworkAPI()
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""主页"""
|
|
return render_template('index.html')
|
|
|
|
@app.route('/api/network')
|
|
def get_network():
|
|
"""获取网络数据API"""
|
|
try:
|
|
data = kulue_api.get_network_data()
|
|
return jsonify(data)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/search/<word>')
|
|
def search_word(word):
|
|
"""搜索词汇API"""
|
|
try:
|
|
data = kulue_api.search_word(word)
|
|
return jsonify(data)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/stats/categories')
|
|
def get_categories():
|
|
"""获取类别统计API"""
|
|
try:
|
|
data = kulue_api.get_categories_stats()
|
|
return jsonify(data)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/sound-shift/<word>')
|
|
def get_sound_shift(word):
|
|
"""获取音转路径API"""
|
|
try:
|
|
data = kulue_api.get_sound_shift_paths(word)
|
|
return jsonify(data)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
# HTML 模板
|
|
html_template = '''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>圐圙文化网络</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
|
|
.container { max-width: 1200px; margin: 0 auto; }
|
|
.controls { margin-bottom: 20px; }
|
|
.network-container { border: 1px solid #ccc; }
|
|
.node { cursor: pointer; }
|
|
.link { stroke: #999; stroke-opacity: 0.6; }
|
|
.tooltip { position: absolute; background: rgba(0,0,0,0.8); color: white;
|
|
padding: 10px; border-radius: 5px; pointer-events: none; }
|
|
.legend { margin-top: 20px; }
|
|
.legend-item { display: inline-block; margin-right: 20px; }
|
|
.legend-color { width: 20px; height: 20px; display: inline-block; margin-right: 5px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>圐圙文化网络可视化</h1>
|
|
|
|
<div class="controls">
|
|
<input type="text" id="searchInput" placeholder="搜索词汇...">
|
|
<button onclick="searchWord()">搜索</button>
|
|
<button onclick="resetView()">重置</button>
|
|
</div>
|
|
|
|
<div id="network" class="network-container"></div>
|
|
|
|
<div class="legend" id="legend"></div>
|
|
|
|
<div id="info" style="margin-top: 20px;"></div>
|
|
</div>
|
|
|
|
<div id="tooltip" class="tooltip" style="display: none;"></div>
|
|
|
|
<script>
|
|
// 网络可视化代码
|
|
const width = 1160;
|
|
const height = 600;
|
|
|
|
const svg = d3.select("#network")
|
|
.append("svg")
|
|
.attr("width", width)
|
|
.attr("height", height);
|
|
|
|
const g = svg.append("g");
|
|
|
|
// 缩放功能
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.1, 3])
|
|
.on("zoom", (event) => {
|
|
g.attr("transform", event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// 颜色映射
|
|
const colorScale = d3.scaleOrdinal()
|
|
.domain(['地理', '器物', '政治', '文化', '核心'])
|
|
.range(['#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#1f77b4']);
|
|
|
|
let simulation, nodes, links;
|
|
|
|
// 加载网络数据
|
|
async function loadNetwork() {
|
|
try {
|
|
const response = await fetch('/api/network');
|
|
const data = await response.json();
|
|
|
|
nodes = data.nodes;
|
|
links = data.links;
|
|
|
|
createVisualization();
|
|
createLegend();
|
|
|
|
} catch (error) {
|
|
console.error('加载数据失败:', error);
|
|
}
|
|
}
|
|
|
|
function createVisualization() {
|
|
// 创建力导向图
|
|
simulation = d3.forceSimulation(nodes)
|
|
.force("link", d3.forceLink(links).id(d => d.id).distance(100))
|
|
.force("charge", d3.forceManyBody().strength(-300))
|
|
.force("center", d3.forceCenter(width / 2, height / 2));
|
|
|
|
// 绘制连线
|
|
const link = g.append("g")
|
|
.selectAll("line")
|
|
.data(links)
|
|
.enter().append("line")
|
|
.attr("class", "link")
|
|
.attr("stroke-width", d => d.type === 'SOUND_SHIFT' ? 3 : 1)
|
|
.attr("stroke", d => d.type === 'SOUND_SHIFT' ? '#ff0000' : '#999');
|
|
|
|
// 绘制节点
|
|
const node = g.append("g")
|
|
.selectAll("circle")
|
|
.data(nodes)
|
|
.enter().append("circle")
|
|
.attr("class", "node")
|
|
.attr("r", d => d.category === '核心' ? 15 : 10)
|
|
.attr("fill", d => colorScale(d.category))
|
|
.call(d3.drag()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended));
|
|
|
|
// 添加标签
|
|
const label = g.append("g")
|
|
.selectAll("text")
|
|
.data(nodes)
|
|
.enter().append("text")
|
|
.text(d => d.name)
|
|
.attr("font-size", "12px")
|
|
.attr("dx", 15)
|
|
.attr("dy", 4);
|
|
|
|
// 鼠标事件
|
|
node.on("mouseover", function(event, d) {
|
|
d3.select("#tooltip")
|
|
.style("display", "block")
|
|
.style("left", (event.pageX + 10) + "px")
|
|
.style("top", (event.pageY - 10) + "px")
|
|
.html(`<strong>${d.name}</strong><br/>
|
|
类别: ${d.category}<br/>
|
|
含义: ${d.meaning}<br/>
|
|
地区: ${d.region}<br/>
|
|
朝代: ${d.dynasty}`);
|
|
})
|
|
.on("mouseout", function() {
|
|
d3.select("#tooltip").style("display", "none");
|
|
})
|
|
.on("click", function(event, d) {
|
|
searchWord(d.name);
|
|
});
|
|
|
|
// 更新位置
|
|
simulation.on("tick", () => {
|
|
link
|
|
.attr("x1", d => d.source.x)
|
|
.attr("y1", d => d.source.y)
|
|
.attr("x2", d => d.target.x)
|
|
.attr("y2", d => d.target.y);
|
|
|
|
node
|
|
.attr("cx", d => d.x)
|
|
.attr("cy", d => d.y);
|
|
|
|
label
|
|
.attr("x", d => d.x)
|
|
.attr("y", d => d.y);
|
|
});
|
|
}
|
|
|
|
function createLegend() {
|
|
const legend = d3.select("#legend");
|
|
const categories = ['地理', '器物', '政治', '文化', '核心'];
|
|
|
|
categories.forEach(category => {
|
|
const item = legend.append("div").attr("class", "legend-item");
|
|
item.append("div")
|
|
.attr("class", "legend-color")
|
|
.style("background-color", colorScale(category));
|
|
item.append("span").text(category);
|
|
});
|
|
}
|
|
|
|
// 拖拽功能
|
|
function dragstarted(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
|
|
// 搜索功能
|
|
async function searchWord(word) {
|
|
if (!word) word = document.getElementById('searchInput').value;
|
|
if (!word) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/search/${word}`);
|
|
const data = await response.json();
|
|
|
|
// 高亮相关节点
|
|
d3.selectAll(".node")
|
|
.attr("stroke", d => {
|
|
const isRelated = data.some(item =>
|
|
item.center.name === d.name || item.connected.name === d.name
|
|
);
|
|
return isRelated ? "#000" : "none";
|
|
})
|
|
.attr("stroke-width", d => {
|
|
const isRelated = data.some(item =>
|
|
item.center.name === d.name || item.connected.name === d.name
|
|
);
|
|
return isRelated ? 3 : 0;
|
|
});
|
|
|
|
// 显示搜索结果
|
|
const info = d3.select("#info");
|
|
info.html(`<h3>搜索结果: ${word}</h3>`);
|
|
|
|
if (data.length > 0) {
|
|
const list = info.append("ul");
|
|
data.forEach(item => {
|
|
list.append("li")
|
|
.html(`${item.connected.name} (${item.relation.type}) - ${item.connected.meaning}`);
|
|
});
|
|
} else {
|
|
info.append("p").text("未找到相关词汇");
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('搜索失败:', error);
|
|
}
|
|
}
|
|
|
|
function resetView() {
|
|
d3.selectAll(".node")
|
|
.attr("stroke", "none")
|
|
.attr("stroke-width", 0);
|
|
|
|
d3.select("#info").html("");
|
|
document.getElementById('searchInput').value = "";
|
|
}
|
|
|
|
// 页面加载时初始化
|
|
loadNetwork();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
# 创建模板目录和文件
|
|
import os
|
|
os.makedirs('templates', exist_ok=True)
|
|
with open('templates/index.html', 'w', encoding='utf-8') as f:
|
|
f.write(html_template)
|
|
|
|
if __name__ == '__main__':
|
|
app.run(debug=True, host='0.0.0.0', port=5000) |