| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- 构建创作思维路径可视化 V2
- 新版特性:
- - 同一个发现编号的节点使用相同的填充颜色
- - 边框颜色代表节点类型(灵感点、目的点、关键点)
- 读取 creation_pattern 目录下的 JSON 数据,输出单文件 HTML
- """
- import json
- import sys
- from pathlib import Path
- # 项目路径
- project_root = Path(__file__).parent.parent.parent
- sys.path.insert(0, str(project_root))
- from script.data_processing.path_config import PathConfig
- HTML_TEMPLATE = '''<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>创作思维路径可视化</title>
- <script src="https://d3js.org/d3.v7.min.js"></script>
- <style>
- * { margin: 0; padding: 0; box-sizing: border-box; }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- background: #1a1a2e;
- color: #eee;
- overflow: hidden;
- }
- .container { display: flex; flex-direction: column; height: 100vh; }
- header {
- padding: 12px 20px;
- background: #16213e;
- border-bottom: 1px solid #0f3460;
- display: flex;
- align-items: center;
- gap: 20px;
- }
- h1 { font-size: 16px; font-weight: 500; color: #e94560; }
- .controls { display: flex; align-items: center; gap: 12px; flex: 1; }
- .slider-container {
- display: flex;
- align-items: center;
- gap: 8px;
- background: #0f3460;
- padding: 6px 12px;
- border-radius: 6px;
- }
- .slider-container label { font-size: 13px; color: #aaa; }
- #stepSlider { width: 200px; cursor: pointer; }
- #stepDisplay { font-size: 14px; font-weight: 600; color: #e94560; min-width: 60px; }
- .play-btn {
- background: #e94560;
- border: none;
- color: white;
- padding: 6px 14px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 13px;
- }
- .play-btn:hover { background: #ff6b6b; }
- /* 图例 - 两行 */
- .legend {
- display: flex;
- flex-direction: column;
- gap: 6px;
- font-size: 11px;
- color: #888;
- margin-left: auto;
- background: #0f3460;
- padding: 8px 12px;
- border-radius: 6px;
- }
- .legend-row { display: flex; gap: 12px; align-items: center; }
- .legend-label { color: #666; font-size: 10px; min-width: 50px; }
- .legend-item { display: flex; align-items: center; gap: 4px; }
- .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
- .legend-dot-border {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: transparent;
- border: 2px solid;
- }
- .legend-line { width: 20px; height: 2px; }
- /* 主内容区:左右布局 */
- main { flex: 1; display: flex; overflow: hidden; }
- .graph-area { flex: 1; position: relative; }
- .graph-area svg { width: 100%; height: 100%; }
- /* 右侧面板 */
- .right-panel {
- width: 360px;
- background: #16213e;
- border-left: 1px solid #0f3460;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- /* 帖子摘要区 */
- .post-summary {
- padding: 16px;
- border-bottom: 1px solid #0f3460;
- max-height: 200px;
- overflow-y: auto;
- }
- .post-title {
- font-size: 14px;
- font-weight: 600;
- color: #e94560;
- margin-bottom: 8px;
- line-height: 1.4;
- }
- .post-body {
- font-size: 12px;
- color: #aaa;
- line-height: 1.6;
- max-height: 100px;
- overflow-y: auto;
- }
- .post-meta {
- margin-top: 8px;
- font-size: 11px;
- color: #666;
- display: flex;
- gap: 12px;
- }
- /* 步骤时间轴 */
- .timeline {
- flex: 1;
- overflow-y: auto;
- padding: 16px;
- }
- .timeline-title {
- font-size: 13px;
- font-weight: 600;
- color: #888;
- margin-bottom: 12px;
- }
- .timeline-item {
- position: relative;
- padding-left: 24px;
- padding-bottom: 16px;
- border-left: 2px solid #0f3460;
- margin-left: 8px;
- cursor: pointer;
- transition: all 0.2s;
- }
- .timeline-item:hover {
- background: rgba(233, 69, 96, 0.1);
- margin-left: 6px;
- padding-left: 26px;
- }
- .timeline-item:last-child { border-left-color: transparent; }
- .timeline-item.active { border-left-color: #e94560; }
- .timeline-item.future { opacity: 0.4; }
- .timeline-dot {
- position: absolute;
- left: -7px;
- top: 0;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- border: 2px solid #0f3460;
- background: #1a1a2e;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 8px;
- font-weight: bold;
- color: white;
- }
- .timeline-item.active .timeline-dot {
- border-color: #e94560;
- background: #e94560;
- }
- .timeline-node-name {
- font-size: 13px;
- font-weight: 500;
- color: #eee;
- margin-bottom: 4px;
- }
- .timeline-node-dim {
- font-size: 11px;
- padding: 2px 6px;
- border-radius: 3px;
- display: inline-block;
- margin-bottom: 6px;
- }
- .timeline-node-dim.灵感点 { background: rgba(255,107,107,0.2); color: #ff6b6b; }
- .timeline-node-dim.目的点 { background: rgba(78,205,196,0.2); color: #4ecdc4; }
- .timeline-node-dim.关键点 { background: rgba(255,230,109,0.2); color: #ffe66d; }
- .timeline-reason {
- font-size: 11px;
- color: #888;
- line-height: 1.5;
- background: #0f3460;
- padding: 8px;
- border-radius: 4px;
- }
- .timeline-from {
- font-size: 11px;
- color: #666;
- margin-bottom: 4px;
- }
- .timeline-from span { color: #e94560; }
- /* 节点样式 */
- .node { cursor: pointer; }
- .node circle { stroke-width: 3px; transition: all 0.3s; }
- .node.unknown circle { fill: #2a2a4a !important; stroke: #444 !important; opacity: 0.4; }
- .node.known circle { filter: drop-shadow(0 0 6px currentColor); }
- .node.highlight circle { stroke-width: 5px; filter: drop-shadow(0 0 12px currentColor); }
- .node text { font-size: 11px; fill: #ccc; text-anchor: middle; pointer-events: none; }
- .node.unknown text { fill: #555; }
- .node .order-badge { font-size: 10px; font-weight: bold; fill: white; }
- .link { stroke-opacity: 0.6; transition: all 0.3s; }
- .link.hidden { stroke-opacity: 0 !important; }
- .link.highlight { stroke-opacity: 1; stroke-width: 3px; }
- .link-label { font-size: 9px; fill: #888; pointer-events: none; }
- .link-label.hidden { opacity: 0; }
- </style>
- </head>
- <body>
- <div class="container">
- <header>
- <h1>创作思维路径</h1>
- <div class="controls">
- <div class="slider-container">
- <label>步骤:</label>
- <input type="range" id="stepSlider" min="0" max="9" value="9">
- <span id="stepDisplay">9/9</span>
- </div>
- <button class="play-btn" id="playBtn">▶ 播放</button>
- </div>
- <div class="legend">
- <div class="legend-row">
- <span class="legend-label">边框类型:</span>
- <div class="legend-item"><span class="legend-dot-border" style="border-color: #ff6b6b;"></span><span>灵感点</span></div>
- <div class="legend-item"><span class="legend-dot-border" style="border-color: #4ecdc4;"></span><span>目的点</span></div>
- <div class="legend-item"><span class="legend-dot-border" style="border-color: #ffe66d;"></span><span>关键点</span></div>
- </div>
- <div class="legend-row">
- <span class="legend-label">填充步骤:</span>
- <div class="legend-item"><span class="legend-dot" style="background: #e94560;"></span><span>步骤1</span></div>
- <div class="legend-item"><span class="legend-dot" style="background: #9b59b6;"></span><span>步骤2</span></div>
- <div class="legend-item"><span class="legend-dot" style="background: #3498db;"></span><span>步骤3...</span></div>
- </div>
- </div>
- </header>
- <main>
- <div class="graph-area">
- <svg id="graph"></svg>
- </div>
- <div class="right-panel">
- <div class="post-summary" id="postSummary"></div>
- <div class="timeline" id="timeline">
- <div class="timeline-title">推导过程</div>
- </div>
- </div>
- </main>
- </div>
- <script>
- const DATA = __DATA_PLACEHOLDER__;
- // 维度颜色(用于边框)
- const dimColors = { '灵感点': '#ff6b6b', '目的点': '#4ecdc4', '关键点': '#ffe66d' };
- // 步骤颜色调色板(用于填充,同一编号同一颜色)
- const stepColors = [
- '#e94560', // 1 - 红色
- '#9b59b6', // 2 - 紫色
- '#3498db', // 3 - 蓝色
- '#1abc9c', // 4 - 青色
- '#2ecc71', // 5 - 绿色
- '#f39c12', // 6 - 橙色
- '#e74c3c', // 7 - 深红
- '#8e44ad', // 8 - 深紫
- '#2980b9', // 9 - 深蓝
- '#16a085', // 10 - 深青
- '#27ae60', // 11 - 深绿
- '#d35400', // 12 - 深橙
- '#c0392b', // 13
- '#7b1fa2', // 14
- '#1565c0', // 15
- ];
- function getStepColor(order) {
- if (!order || order <= 0) return '#2a2a4a';
- return stepColors[(order - 1) % stepColors.length];
- }
- // 提取数据
- const postDetail = DATA['帖子详情'];
- const steps = DATA['步骤列表'];
- const lastStep = steps[steps.length - 1];
- const nodes = lastStep['输出']['节点列表'];
- const edges = lastStep['输出']['边列表'].filter(e => e['关系类型'] === 'AI推导');
- const maxOrder = Math.max(...nodes.map(n => n['发现编号'] || 0));
- // 构建边的查找表(目标节点 -> 边)
- const edgeByTarget = {};
- edges.forEach(e => { edgeByTarget[e['目标']] = e; });
- const nodeById = {};
- const graphNodes = nodes.map(n => {
- const node = {
- id: n['节点ID'],
- name: n['节点名称'],
- dimension: n['节点维度'],
- category: n['节点分类'],
- description: n['节点描述'],
- order: n['发现编号'],
- isKnown: n['是否已知']
- };
- nodeById[node.id] = node;
- return node;
- });
- const graphLinks = edges.map(e => ({
- source: e['来源'],
- target: e['目标'],
- type: e['关系类型'],
- score: e['可能性分数'],
- reason: e['推理说明']
- }));
- // 渲染帖子摘要
- function renderPostSummary() {
- const container = document.getElementById('postSummary');
- const bodyText = postDetail.body_text || '';
- const shortBody = bodyText.length > 150 ? bodyText.slice(0, 150) + '...' : bodyText;
- container.innerHTML = `
- <div class="post-title">${postDetail.postTitle || '无标题'}</div>
- <div class="post-body">${shortBody}</div>
- <div class="post-meta">
- <span>❤️ ${postDetail.like_count || 0}</span>
- <span>⭐ ${postDetail.collect_count || 0}</span>
- <span>${postDetail.publish_time || ''}</span>
- </div>
- `;
- }
- // 渲染时间轴
- function renderTimeline() {
- const container = document.getElementById('timeline');
- // 按发现顺序排序节点
- const sortedNodes = [...graphNodes].filter(n => n.order).sort((a, b) => a.order - b.order);
- let html = '<div class="timeline-title">推导过程</div>';
- sortedNodes.forEach(n => {
- const edge = edgeByTarget[n.id];
- const fromNode = edge ? nodeById[edge.source] : null;
- const stepColor = getStepColor(n.order);
- html += `
- <div class="timeline-item" data-order="${n.order}" data-node-id="${n.id}">
- <div class="timeline-dot" style="background: ${stepColor}; border-color: ${stepColor};">${n.order}</div>
- <div class="timeline-node-name">${n.name}</div>
- <div class="timeline-node-dim ${n.dimension}">${n.dimension}</div>
- ${fromNode ? `<div class="timeline-from">← 从 <span>${fromNode.name}</span> 推导</div>` : '<div class="timeline-from">起点</div>'}
- ${edge ? `<div class="timeline-reason">${edge.reason || ''}</div>` : ''}
- </div>
- `;
- });
- container.innerHTML = html;
- // 绑定点击事件
- container.querySelectorAll('.timeline-item').forEach(item => {
- item.addEventListener('click', () => {
- const order = parseInt(item.dataset.order);
- const nodeId = item.dataset.nodeId;
- slider.value = order;
- updateDisplay(order);
- highlightNode(nodeId);
- });
- item.addEventListener('mouseenter', () => {
- const nodeId = item.dataset.nodeId;
- highlightNode(nodeId);
- });
- item.addEventListener('mouseleave', () => {
- clearHighlight();
- });
- });
- }
- let currentStep = maxOrder;
- const slider = document.getElementById('stepSlider');
- const stepDisplay = document.getElementById('stepDisplay');
- const playBtn = document.getElementById('playBtn');
- slider.max = maxOrder;
- slider.value = maxOrder;
- const svg = d3.select('#graph');
- const graphArea = document.querySelector('.graph-area');
- const width = graphArea.clientWidth;
- const height = graphArea.clientHeight;
- const g = svg.append('g');
- const zoom = d3.zoom()
- .scaleExtent([0.2, 3])
- .on('zoom', (event) => g.attr('transform', event.transform));
- svg.call(zoom);
- svg.append('defs').append('marker')
- .attr('id', 'arrow')
- .attr('viewBox', '0 -5 10 10')
- .attr('refX', 20)
- .attr('refY', 0)
- .attr('markerWidth', 6)
- .attr('markerHeight', 6)
- .attr('orient', 'auto')
- .append('path')
- .attr('d', 'M0,-5L10,0L0,5')
- .attr('fill', '#e94560');
- const simulation = d3.forceSimulation(graphNodes)
- .force('link', d3.forceLink(graphLinks).id(d => d.id).distance(120))
- .force('charge', d3.forceManyBody().strength(-400))
- .force('center', d3.forceCenter(width / 2, height / 2))
- .force('collision', d3.forceCollide().radius(50));
- const link = g.append('g')
- .selectAll('line')
- .data(graphLinks)
- .join('line')
- .attr('class', 'link')
- .attr('stroke', '#e94560')
- .attr('stroke-width', 2)
- .attr('marker-end', 'url(#arrow)');
- const linkLabel = g.append('g')
- .selectAll('text')
- .data(graphLinks)
- .join('text')
- .attr('class', 'link-label')
- .text(d => d.score?.toFixed(2) || '');
- const node = g.append('g')
- .selectAll('g')
- .data(graphNodes)
- .join('g')
- .attr('class', d => `node dim-${d.dimension}`)
- .call(d3.drag()
- .on('start', dragstarted)
- .on('drag', dragged)
- .on('end', dragended))
- .on('click', (event, d) => {
- slider.value = d.order || maxOrder;
- updateDisplay(d.order || maxOrder);
- })
- .on('mouseenter', (event, d) => highlightNode(d.id))
- .on('mouseleave', () => clearHighlight());
- // 节点圆形:填充色按步骤编号,边框色按维度类型
- node.append('circle')
- .attr('r', 18)
- .attr('fill', d => getStepColor(d.order)) // 填充色按编号
- .attr('stroke', d => dimColors[d.dimension] || '#888'); // 边框色按类型
- node.append('text')
- .attr('class', 'order-badge')
- .attr('dy', 4)
- .text(d => d.order || '');
- node.append('text')
- .attr('dy', 35)
- .text(d => 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);
- linkLabel.attr('x', d => (d.source.x + d.target.x) / 2)
- .attr('y', d => (d.source.y + d.target.y) / 2 - 8);
- node.attr('transform', d => `translate(${d.x},${d.y})`);
- });
- 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;
- }
- // 高亮节点
- function highlightNode(nodeId) {
- node.classed('highlight', d => d.id === nodeId);
- // 高亮相关边
- link.classed('highlight', d => d.target.id === nodeId || d.target === nodeId);
- // 高亮时间轴项
- document.querySelectorAll('.timeline-item').forEach(item => {
- item.classList.toggle('active', item.dataset.nodeId === nodeId);
- });
- }
- function clearHighlight() {
- node.classed('highlight', false);
- link.classed('highlight', false);
- }
- function updateDisplay(step) {
- currentStep = step;
- stepDisplay.textContent = `${step}/${maxOrder}`;
- node.classed('known', d => d.order && d.order <= step);
- node.classed('unknown', d => !d.order || d.order > step);
- link.classed('hidden', d => {
- const srcKnown = nodeById[d.source.id || d.source]?.order <= step;
- const tgtKnown = nodeById[d.target.id || d.target]?.order <= step;
- return !srcKnown || !tgtKnown;
- });
- linkLabel.classed('hidden', d => {
- const srcKnown = nodeById[d.source.id || d.source]?.order <= step;
- const tgtKnown = nodeById[d.target.id || d.target]?.order <= step;
- return !srcKnown || !tgtKnown;
- });
- // 更新时间轴状态
- document.querySelectorAll('.timeline-item').forEach(item => {
- const order = parseInt(item.dataset.order);
- item.classList.toggle('future', order > step);
- });
- }
- slider.addEventListener('input', (e) => updateDisplay(parseInt(e.target.value)));
- let playing = false, playInterval = null;
- playBtn.addEventListener('click', () => {
- if (playing) {
- clearInterval(playInterval);
- playBtn.textContent = '▶ 播放';
- playing = false;
- } else {
- if (currentStep >= maxOrder) { currentStep = 0; slider.value = 0; updateDisplay(0); }
- playing = true;
- playBtn.textContent = '⏸ 暂停';
- playInterval = setInterval(() => {
- currentStep++;
- slider.value = currentStep;
- updateDisplay(currentStep);
- // 滚动时间轴到当前项
- const currentItem = document.querySelector(`.timeline-item[data-order="${currentStep}"]`);
- if (currentItem) currentItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
- if (currentStep >= maxOrder) {
- clearInterval(playInterval);
- playBtn.textContent = '▶ 播放';
- playing = false;
- }
- }, 1200);
- }
- });
- // 初始化
- renderPostSummary();
- renderTimeline();
- updateDisplay(maxOrder);
- setTimeout(() => {
- const bounds = g.node().getBBox();
- const scale = Math.min((width - 60) / bounds.width, (height - 60) / bounds.height, 1.2);
- const tx = (width - bounds.width * scale) / 2 - bounds.x * scale;
- const ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
- svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
- }, 1000);
- </script>
- </body>
- </html>
- '''
- def main():
- import argparse
- parser = argparse.ArgumentParser(description='构建创作思维路径可视化 V2')
- parser.add_argument('input_dir', nargs='?', help='输入目录路径(可选,默认使用 PathConfig)')
- parser.add_argument('--version', '-v', type=str, default='v4',
- help='creation_pattern 版本 (v2/v3/v4),默认 v4')
- args = parser.parse_args()
- if args.input_dir:
- input_dir = Path(args.input_dir)
- print(f"输入目录: {input_dir}")
- else:
- config = PathConfig()
- input_dir = config.intermediate_dir / f"creation_pattern_{args.version}"
- print(f"账号: {config.account_name}")
- print(f"输入目录: {input_dir}")
- if not input_dir.exists():
- print(f"错误: 目录不存在!")
- sys.exit(1)
- # 查找所有创作模式文件
- pattern_files = sorted(input_dir.glob("*_创作模式.json"))
- print(f"找到 {len(pattern_files)} 个创作模式文件")
- if not pattern_files:
- print("错误: 没有找到创作模式文件!")
- sys.exit(1)
- # 为每个文件生成 HTML
- for pattern_file in pattern_files:
- post_id = pattern_file.stem.replace("_创作模式", "")
- print(f"\n处理: {post_id}")
- # 读取数据
- with open(pattern_file, "r", encoding="utf-8") as f:
- data = json.load(f)
- # 生成 HTML
- data_json = json.dumps(data, ensure_ascii=False)
- html_content = HTML_TEMPLATE.replace("__DATA_PLACEHOLDER__", data_json)
- # 输出文件
- output_file = input_dir / f"{post_id}_创作思维路径.html"
- with open(output_file, "w", encoding="utf-8") as f:
- f.write(html_content)
- print(f"输出: {output_file}")
- print("\n完成!")
- if __name__ == "__main__":
- main()
|