From cda6f1aabec021c929b7ab6a62617397752412ba Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Wed, 8 Jul 2026 13:33:25 +0800 Subject: [PATCH] Upgrade atlas graph and desktop reader layout --- web/README.md | 8 + web/static/app.js | 312 +++++++++++++++++++++++++- web/static/index.html | 7 +- web/static/styles.css | 137 ++++++++++- web/static/vendor/echarts.LICENSE.txt | 222 ++++++++++++++++++ web/static/vendor/echarts.NOTICE.txt | 5 + web/static/vendor/echarts.min.js | 45 ++++ 7 files changed, 715 insertions(+), 21 deletions(-) create mode 100644 web/static/vendor/echarts.LICENSE.txt create mode 100644 web/static/vendor/echarts.NOTICE.txt create mode 100644 web/static/vendor/echarts.min.js diff --git a/web/README.md b/web/README.md index b612484..77a83e7 100644 --- a/web/README.md +++ b/web/README.md @@ -20,6 +20,14 @@ OLLAMA_URL=http://192.168.1.10:11434 http://100.114.68.27:18080 ``` +映射域名: + +```text +https://lab.k1412.top/ +``` + +图谱增强渲染懒加载本地 vendored `ECharts 6.1.0`,文件位于 `web/static/vendor/`。首屏先显示内置 SVG 图谱;ECharts 加载成功后自动升级为 Canvas 交互图谱。 + ## AI Policy - 默认摘要模型:`ChatGPT-5.6:fast` diff --git a/web/static/app.js b/web/static/app.js index f8db2bd..4e298ce 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -9,15 +9,18 @@ const state = { searchItems: [], searchTotal: 0, aiBusy: false, + atlasChart: null, }; const $ = (selector) => document.querySelector(selector); const elements = { + app: $(".atlas-app"), healthLine: $("#healthLine"), searchInput: $("#searchInput"), searchBtn: $("#searchBtn"), mapTitle: $("#mapTitle"), + atlasChart: $("#atlasChart"), atlasSvg: $("#atlasSvg"), routeList: $("#routeList"), timeline: $("#timeline"), @@ -61,16 +64,74 @@ async function api(path, options = {}) { return payload; } +function loadChartEngine() { + if (window.echarts) { + if (state.atlas) renderAtlasMap(); + return; + } + if (document.querySelector("script[data-chart-engine='echarts']")) return; + + const script = document.createElement("script"); + script.src = "/static/vendor/echarts.min.js?v=6.1.0"; + script.async = true; + script.dataset.chartEngine = "echarts"; + script.onload = () => { + if (state.atlas) renderAtlasMap(); + }; + script.onerror = () => { + console.warn("ECharts atlas enhancement unavailable; SVG fallback remains active."); + }; + document.head.appendChild(script); +} + function topicLabel(topic) { const found = state.atlas?.topics?.find((item) => item.id === topic); return found?.label || topic.replaceAll("-", " "); } function colorFor(index) { - const colors = ["#0d766f", "#ba5644", "#aa7419", "#527b46", "#665d9f", "#236092", "#8a5a25"]; + const colors = ["#0b7a75", "#c95745", "#b37a22", "#4d7f52", "#5f63b5", "#22699a", "#9a5d2f", "#7b4aa0"]; return colors[index % colors.length]; } +function hexToRgb(hex) { + const clean = String(hex || "").replace("#", ""); + const value = Number.parseInt(clean.length === 3 ? clean.replace(/(.)/g, "$1$1") : clean, 16); + return { + r: (value >> 16) & 255, + g: (value >> 8) & 255, + b: value & 255, + }; +} + +function hexToRgba(hex, alpha) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +function mixColor(hex, target, amount) { + const base = hexToRgb(hex); + const next = hexToRgb(target); + const mix = (left, right) => Math.round(left + (right - left) * amount); + return `rgb(${mix(base.r, next.r)}, ${mix(base.g, next.g)}, ${mix(base.b, next.b)})`; +} + +function chartGradient(color) { + if (!window.echarts?.graphic?.RadialGradient) return color; + return new window.echarts.graphic.RadialGradient(0.34, 0.28, 0.86, [ + { offset: 0, color: "#ffffff" }, + { offset: 0.58, color: mixColor(color, "#ffffff", 0.64) }, + { offset: 1, color }, + ]); +} + +function setReaderMode(enabled) { + const changed = elements.app.classList.toggle("reader-mode", Boolean(enabled)); + if (changed && state.atlas) { + window.requestAnimationFrame(() => renderAtlasMap()); + } +} + async function loadHealth() { try { const health = await api("/api/health"); @@ -90,11 +151,234 @@ async function loadAtlas() { renderOverview(); } +function atlasTopics() { + return state.atlas.topics.filter((topic) => topic.count >= 30).slice(0, 13); +} + +function setAtlasRenderer(useChart) { + if (elements.atlasChart) elements.atlasChart.hidden = !useChart; + if (elements.atlasSvg) elements.atlasSvg.hidden = useChart; +} + function renderAtlasMap() { + if (window.echarts && elements.atlasChart) { + try { + renderEchartsAtlasMap(); + return; + } catch (error) { + console.warn("ECharts atlas failed, falling back to SVG", error); + } + } + renderSvgAtlasMap(); +} + +function renderEchartsAtlasMap() { + const isMobile = window.matchMedia("(max-width: 760px)").matches; + const readerMode = elements.app.classList.contains("reader-mode"); + let topics = atlasTopics(); + if (readerMode && !isMobile) { + topics = topics.slice(0, 10); + } + const topicMap = new Map(topics.map((topic, index) => [topic.id, { ...topic, index }])); + const maxCount = Math.max(...topics.map((topic) => topic.count), 1); + setAtlasRenderer(true); + + if (!state.atlasChart) { + state.atlasChart = window.echarts.init(elements.atlasChart, null, { + renderer: "canvas", + useDirtyRect: true, + }); + } + + const width = elements.atlasChart.clientWidth || (isMobile ? 360 : 780); + const height = elements.atlasChart.clientHeight || (isMobile ? 360 : 460); + const isCompact = isMobile || width < 690 || elements.app.classList.contains("reader-mode"); + const padding = isCompact + ? { left: 46, right: 86, top: 42, bottom: 54 } + : { left: 92, right: 92, top: 64, bottom: 74 }; + const layoutX = (topic) => (isCompact ? topic.x - Math.max(0, topic.x - 64) * 0.65 : topic.x); + const xs = topics.map(layoutX); + const ys = topics.map((topic) => topic.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const xSpan = Math.max(maxX - minX, 1); + const ySpan = Math.max(maxY - minY, 1); + const plotWidth = Math.max(width - padding.left - padding.right, 260); + const plotHeight = Math.max(height - padding.top - padding.bottom, 230); + const sizeBase = isCompact ? (isMobile ? 24 : 36) : 48; + const sizeRange = isCompact ? (isMobile ? 30 : 42) : 62; + + const nodes = topics.map((topic, index) => { + const color = colorFor(index); + const active = topic.id === state.activeTopic; + const symbolSize = sizeBase + sizeRange * Math.sqrt(topic.count / maxCount); + return { + id: topic.id, + name: topic.label, + value: topic.count, + x: padding.left + ((layoutX(topic) - minX) / xSpan) * plotWidth, + y: padding.top + ((topic.y - minY) / ySpan) * plotHeight, + symbolSize, + count: topic.count, + recent_count: topic.recent_count, + heat: topic.heat, + question: topic.question, + stance: topic.stance, + itemStyle: { + color: chartGradient(color), + borderColor: active ? "#17201c" : mixColor(color, "#101614", 0.08), + borderWidth: active ? 3 : 1.4, + shadowBlur: active ? 30 : 18, + shadowColor: hexToRgba(color, active ? 0.34 : 0.2), + }, + label: { + color: active ? "#0f1714" : "#18211d", + }, + }; + }); + + const links = state.atlas.edges + .filter((edge) => topicMap.has(edge.source) && topicMap.has(edge.target)) + .map((edge) => ({ + source: edge.source, + target: edge.target, + value: edge.count, + lineStyle: { + width: Math.max(1, 1.2 + edge.strength * 5.4), + opacity: Math.min(0.58, 0.13 + edge.strength * 0.5), + color: hexToRgba(colorFor(topicMap.get(edge.source).index), 0.48), + curveness: 0.08, + }, + })); + + state.atlasChart.setOption( + { + backgroundColor: "transparent", + animationDuration: 850, + animationDurationUpdate: 450, + animationEasing: "cubicOut", + tooltip: { + trigger: "item", + confine: true, + appendToBody: true, + backgroundColor: "rgba(252, 253, 249, 0.97)", + borderColor: "rgba(31, 37, 33, 0.18)", + borderWidth: 1, + padding: [10, 12], + textStyle: { + color: "#151b18", + fontSize: 12, + lineHeight: 18, + }, + extraCssText: "box-shadow:0 16px 42px rgba(24,31,28,.16);border-radius:8px;", + formatter(params) { + if (params.dataType === "edge") { + return ` + ${escapeHtml(topicLabel(params.data.source))} ↔ ${escapeHtml(topicLabel(params.data.target))} + 共现证据:${escapeHtml(params.data.value)} + `; + } + const item = params.data; + return ` + ${escapeHtml(item.name)} + ${escapeHtml(item.count)} papers · recent +${escapeHtml(item.recent_count)} + ${escapeHtml(item.question)} + `; + }, + }, + series: [ + { + type: "graph", + layout: "none", + data: nodes, + links, + roam: true, + draggable: true, + center: isCompact ? (isMobile ? ["80%", "50%"] : ["120%", "50%"]) : ["50%", "50%"], + zoom: isCompact ? (isMobile ? 0.64 : 0.72) : 1, + nodeScaleRatio: isCompact ? 0.24 : 0.5, + cursor: "pointer", + selectedMode: "single", + scaleLimit: { min: 0.45, max: 3.2 }, + label: { + show: true, + position: "inside", + overflow: "truncate", + formatter(params) { + return `{name|${params.data.name}}\n{meta|${params.data.count} · +${params.data.recent_count}}`; + }, + rich: { + name: { + fontSize: isCompact ? (isMobile ? 9 : 10) : 12, + fontWeight: 850, + lineHeight: isCompact ? (isMobile ? 12 : 13) : 16, + }, + meta: { + color: "#59625d", + fontSize: isCompact ? (isMobile ? 8 : 9) : 10, + fontWeight: 700, + lineHeight: isCompact ? (isMobile ? 10 : 11) : 13, + }, + }, + }, + lineStyle: { + color: "source", + cap: "round", + }, + emphasis: { + focus: "adjacency", + scale: 1.08, + itemStyle: { + shadowBlur: 36, + }, + lineStyle: { + opacity: 0.82, + width: 4, + }, + label: { + color: "#0b1411", + }, + }, + blur: { + itemStyle: { + opacity: 0.28, + }, + lineStyle: { + opacity: 0.06, + }, + label: { + opacity: 0.35, + }, + }, + select: { + itemStyle: { + borderColor: "#111714", + borderWidth: 3, + shadowBlur: 34, + }, + }, + }, + ], + }, + true, + ); + + state.atlasChart.off("click"); + state.atlasChart.on("click", async (params) => { + if (params.dataType !== "node" || !params.data?.id) return; + await selectTopic(params.data.id); + scrollLensIntoView(); + }); +} + +function renderSvgAtlasMap() { const topics = state.atlas.topics.filter((topic) => topic.count >= 30).slice(0, 13); const topicMap = new Map(topics.map((topic, index) => [topic.id, { ...topic, index }])); const maxCount = Math.max(...topics.map((topic) => topic.count), 1); const isMobile = window.matchMedia("(max-width: 760px)").matches; + setAtlasRenderer(false); elements.atlasSvg.setAttribute("viewBox", isMobile ? "16 -4 78 86" : "0 0 170 100"); const point = (topic) => ({ x: isMobile ? topic.x : 14 + topic.x * 1.58, @@ -229,6 +513,7 @@ function routeButtons(routes) { } function renderOverview(aiHtml = "") { + setReaderMode(false); state.view = "atlas"; state.selectedPaper = null; elements.lensEyebrow.textContent = "Atlas"; @@ -296,6 +581,7 @@ async function selectTopic(topic) { } function renderTopic(focus, aiHtml = "") { + setReaderMode(false); state.view = "atlas"; elements.lensEyebrow.textContent = "Topic"; elements.lensTitle.textContent = focus.label; @@ -366,6 +652,7 @@ function renderTopic(focus, aiHtml = "") { function renderRoute(routeId) { const route = state.atlas.routes.find((item) => item.id === routeId); if (!route) return; + setReaderMode(false); state.selectedPaper = null; elements.lensEyebrow.textContent = "Route"; elements.lensTitle.textContent = route.title; @@ -397,6 +684,7 @@ async function runSearch() { renderOverview(); return; } + setReaderMode(false); state.view = "search"; setModeButtons("search"); elements.lensEyebrow.textContent = "Search"; @@ -422,6 +710,7 @@ async function runSearch() { } function renderReading() { + setReaderMode(false); state.view = "reading"; setModeButtons("reading"); elements.lensEyebrow.textContent = "Reading"; @@ -465,6 +754,7 @@ async function selectPaper(id) { function renderPaperReader(aiHtml = "", abstractHtml = "") { const paper = state.selectedPaper; if (!paper) return; + setReaderMode(true); elements.lensEyebrow.textContent = "Paper"; elements.lensTitle.textContent = "研读"; elements.lensBody.innerHTML = ` @@ -483,6 +773,7 @@ function renderPaperReader(aiHtml = "", abstractHtml = "") { arXiv + 回到图谱 摘要 中文摘要 翻译 @@ -580,13 +871,6 @@ function setModeButtons(view) { document.querySelectorAll("[data-view]").forEach((button) => { button.classList.toggle("active", button.dataset.view === view); }); - - window.addEventListener( - "resize", - debounce(() => { - if (state.atlas) renderAtlasMap(); - }, 150), - ); } function scrollLensIntoView() { @@ -619,6 +903,7 @@ function bindEvents() { const topicAi = event.target.closest("[data-topic-ai]"); const compareButton = event.target.closest("[data-compare-topic]"); const paperAction = event.target.closest("[data-paper-action]"); + const readerAction = event.target.closest("[data-reader-action]"); if (topicButton) { await selectTopic(topicButton.dataset.topic); @@ -638,6 +923,9 @@ function bindEvents() { const action = paperAction.dataset.paperAction; if (action === "abstract") await loadAbstract(); else await runPaperAi(action); + } else if (readerAction) { + if (state.topicFocus) renderTopic(state.topicFocus); + else renderOverview(); } }); @@ -669,12 +957,20 @@ function bindEvents() { scrollLensIntoView(); }); }); + + window.addEventListener( + "resize", + debounce(() => { + if (state.atlas) renderAtlasMap(); + }, 150), + ); } async function boot() { bindEvents(); await loadHealth(); await loadAtlas(); + loadChartEngine(); } boot(); diff --git a/web/static/index.html b/web/static/index.html index 1300bae..9a0a214 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -5,7 +5,7 @@ Agent Knowledge Atlas - + @@ -44,7 +44,8 @@ - + + @@ -78,6 +79,6 @@ - +