diff --git a/static/index.html b/static/index.html
index bf9873b..6211f04 100644
--- a/static/index.html
+++ b/static/index.html
@@ -526,6 +526,7 @@
研究主题(调整后)
@@ -536,6 +537,7 @@
研究领域(调整后)
@@ -1018,6 +1020,11 @@ 词频明细
topic_orig:{ treemapId:'treemap_topic_orig', treemapKey:'chart_topic_orig', barId:'barchart_topic_orig', barKey:'bar_topic_orig', kind:'topic' },
field_orig:{ treemapId:'treemap_field_orig', treemapKey:'chart_field_orig', barId:'barchart_field_orig', barKey:'bar_field_orig', kind:'field' }
};
+const CATEGORY_BAR_TARGETS={
+ topic_adj:{ containerId:'category_bar_topic_adj', dimension:'topic_adj' },
+ field_adj:{ containerId:'category_bar_field_adj', dimension:'field_adj' }
+};
+const CATEGORY_BAR_CACHE={};
let RANK_VIS_EXPANDED=false;
const SCATTER_DEFAULT_HEIGHT=420;
const SCATTER_STATE={
@@ -1661,6 +1668,9 @@ 词频明细
if(!meta){
resetSamplingState();
setJournalOverview(null);
+ clearCategoryBarCache();
+ setCategoryBarPlaceholder('topic_adj','请先载入数据集');
+ setCategoryBarPlaceholder('field_adj','请先载入数据集');
primary.textContent = '暂无会话';
secondary.textContent = '请上传或载入数据集';
LAST_EXPORT_URL = '';
@@ -4305,6 +4315,117 @@ Top 5 期刊明细
renderActiveCategoryChart(category);
}
+function getCategoryBarTarget(key){
+ return CATEGORY_BAR_TARGETS[key] || null;
+}
+
+function clearCategoryBarCache(){
+ Object.keys(CATEGORY_BAR_CACHE).forEach(key=>{ delete CATEGORY_BAR_CACHE[key]; });
+}
+
+function setCategoryBarPlaceholder(key, message){
+ const target=getCategoryBarTarget(key);
+ if(!target) return;
+ const container=qs(target.containerId);
+ if(!container) return;
+ const text=message===undefined? '暂无数据' : String(message||'');
+ container.innerHTML=`${escapeHtml(text)}
`;
+}
+
+function applyCategoryBarImage(key, payload){
+ const target=getCategoryBarTarget(key);
+ if(!target) return;
+ const container=qs(target.containerId);
+ if(!container) return;
+ const pngUrl=payload.png_data_url||payload.png||'';
+ if(!pngUrl){
+ setCategoryBarPlaceholder(key, '暂无图表数据');
+ return;
+ }
+ const title=payload.title || (key==='field_adj'?'Area Distribution':'Topic Distribution');
+ container.innerHTML='';
+ const wrapper=document.createElement('div');
+ wrapper.className='category-bar-image-wrapper';
+ const img=document.createElement('img');
+ img.className='category-bar-image';
+ img.src=pngUrl;
+ img.alt=title;
+ img.loading='lazy';
+ wrapper.appendChild(img);
+ container.appendChild(wrapper);
+
+ const downloads=document.createElement('div');
+ downloads.className='category-bar-downloads';
+ const pngLink=document.createElement('a');
+ pngLink.className='category-bar-download';
+ pngLink.href=pngUrl;
+ pngLink.download=payload.png_filename || `${key}_distribution.png`;
+ pngLink.textContent='Download PNG (300 DPI)';
+ downloads.appendChild(pngLink);
+ if(payload.svg_data_url){
+ const svgLink=document.createElement('a');
+ svgLink.className='category-bar-download';
+ svgLink.href=payload.svg_data_url;
+ svgLink.download=payload.svg_filename || `${key}_distribution.svg`;
+ svgLink.textContent='Download SVG';
+ downloads.appendChild(svgLink);
+ }
+ container.appendChild(downloads);
+}
+
+async function renderCategoryBar(key){
+ const target=getCategoryBarTarget(key);
+ if(!target) return;
+ const container=qs(target.containerId);
+ if(!container) return;
+ if(!SESSION_ID){
+ delete CATEGORY_BAR_CACHE[key];
+ setCategoryBarPlaceholder(key, '请先载入数据集');
+ return;
+ }
+ const cached=CATEGORY_BAR_CACHE[key];
+ if(cached && cached.sessionId===SESSION_ID){
+ applyCategoryBarImage(key, cached);
+ return;
+ }
+ setCategoryBarPlaceholder(key, '图表生成中…');
+ const requestSession=SESSION_ID;
+ try{
+ const url=`/category_bar_chart?session_id=${encodeURIComponent(requestSession)}&dimension=${encodeURIComponent(target.dimension)}&_=${Date.now()}`;
+ const resp=await fetch(url,{cache:'no-store'});
+ if(requestSession!==SESSION_ID) return;
+ if(!resp.ok){
+ const text=await resp.text();
+ setCategoryBarPlaceholder(key, text||'图表生成失败');
+ delete CATEGORY_BAR_CACHE[key];
+ return;
+ }
+ const data=await resp.json();
+ if(requestSession!==SESSION_ID) return;
+ if(!data || data.ok===false || !data.png_data_url){
+ const msg=data && data.message ? data.message : '暂无分类数据';
+ setCategoryBarPlaceholder(key, msg);
+ delete CATEGORY_BAR_CACHE[key];
+ return;
+ }
+ const payload={
+ sessionId: requestSession,
+ png_data_url: data.png_data_url,
+ svg_data_url: data.svg_data_url||'',
+ png_filename: data.png_filename||'',
+ svg_filename: data.svg_filename||'',
+ title: data.title||'',
+ };
+ CATEGORY_BAR_CACHE[key]=payload;
+ applyCategoryBarImage(key, payload);
+ }catch(err){
+ if(requestSession!==SESSION_ID) return;
+ setCategoryBarPlaceholder(key, err?.message ? `图表生成失败:${err.message}` : '图表生成失败');
+ delete CATEGORY_BAR_CACHE[key];
+ }
+}
+
+
function resetTimelineLineLabels(){
if(!TIMELINE_LINE_LABEL_LAYER) return;
TIMELINE_LINE_LABEL_LAYER.innerHTML='';
@@ -5982,7 +6103,13 @@ ${safeTitle}
async function refreshStats(){
if(!SESSION_ID) return;
const bin=Number.isFinite(TIMELINE_BIN_SIZE)?TIMELINE_BIN_SIZE:5;
- const r=await fetch(`/stats?session_id=${encodeURIComponent(SESSION_ID)}&bin_years=${encodeURIComponent(bin)}`,{cache:"no-store"}); if(!r.ok) return;
+ const r=await fetch(`/stats?session_id=${encodeURIComponent(SESSION_ID)}&bin_years=${encodeURIComponent(bin)}`,{cache:"no-store"});
+ if(!r.ok){
+ clearCategoryBarCache();
+ setCategoryBarPlaceholder('topic_adj','图表加载失败');
+ setCategoryBarPlaceholder('field_adj','图表加载失败');
+ return;
+ }
const d=await r.json();
renderChips("chips_topic_adj", d.topic_adj, "topic_adj", { primaryLabel:'篇数' });
renderChips("chips_field_adj", d.field_adj, "field_adj", { primaryLabel:'篇数' });
@@ -6011,6 +6138,10 @@ ${safeTitle}
populateFilterOptions();
await fetchTopicVisuals({force:true});
setVisualTab(VISUAL_TAB);
+ await Promise.all([
+ renderCategoryBar('topic_adj'),
+ renderCategoryBar('field_adj')
+ ]);
}
function getRankPlotElement(){
diff --git a/static/style.css b/static/style.css
index 9ef174d..ad01f42 100644
--- a/static/style.css
+++ b/static/style.css
@@ -10,6 +10,22 @@ button.is-disabled{ cursor:not-allowed; }
.bar-chart-box{ position:relative; width:100%; max-width:100%; min-height:260px; border-radius:1rem; background:rgba(248,250,252,0.6);
border:1px solid rgba(148,163,184,0.28); padding:0.75rem; overflow:hidden; }
@media (max-width:768px){ .bar-chart-box{ min-height:220px; } }
+.category-bar-box{ position:relative; width:100%; max-width:100%; min-height:260px; border-radius:1rem; background:rgba(248,250,252,0.6);
+ border:1px solid rgba(148,163,184,0.28); padding:0.75rem; display:flex; flex-direction:column; gap:0.75rem; justify-content:flex-start; }
+@media (max-width:768px){ .category-bar-box{ min-height:220px; } }
+.category-bar-placeholder{ display:flex; align-items:center; justify-content:center; min-height:220px; border:1px dashed rgba(148,163,184,0.45);
+ border-radius:0.9rem; background:rgba(241,245,249,0.7); color:#64748b; font-size:0.85rem; text-align:center; padding:1rem; }
+.category-bar-image-wrapper{ flex:1; display:flex; align-items:center; justify-content:center; border:1px solid rgba(203,213,225,0.6);
+ border-radius:0.95rem; background:#ffffff; padding:0.75rem; min-height:220px; box-shadow:0 14px 32px -28px rgba(15,23,42,0.45); }
+.category-bar-image{ max-width:100%; height:auto; object-fit:contain; filter:drop-shadow(0 12px 28px rgba(15,23,42,0.18)); border-radius:0.65rem; }
+.category-bar-downloads{ display:flex; flex-wrap:wrap; justify-content:flex-end; gap:0.5rem; }
+.category-bar-download{ display:inline-flex; align-items:center; gap:0.35rem; padding:0.42rem 0.9rem; border-radius:9999px; font-size:0.78rem;
+ font-weight:600; text-decoration:none; color:#1e293b; background:rgba(191,219,254,0.5); border:1px solid rgba(148,163,184,0.32);
+ transition:background-color .15s ease, box-shadow .15s ease, color .15s ease; }
+.category-bar-download:hover{ background:rgba(191,219,254,0.75); box-shadow:0 10px 22px -16px rgba(37,99,235,0.45); color:#1e3a8a; }
+.category-bar-download:focus-visible{ outline:2px solid rgba(37,99,235,0.45); outline-offset:2px; }
+.category-bar-download[aria-disabled="true"]{ color:rgba(100,116,139,0.7); background:rgba(226,232,240,0.65); border-color:rgba(203,213,225,0.6);
+ box-shadow:none; cursor:not-allowed; pointer-events:none; }
.treemap-empty{ display:flex; align-items:center; justify-content:center; min-height:220px; font-size:0.875rem; color:#64748b; background:rgba(248,250,252,0.8); border:1px dashed rgba(148,163,184,0.45); border-radius:1rem; padding:1.25rem; text-align:center; }
.label-mapping-table{ display:flex; flex-direction:column; gap:0.5rem; border:1px solid rgba(226,232,240,0.9); border-radius:0.75rem; padding:0.75rem; background:rgba(248,250,252,0.75); max-height:280px; overflow:auto; }
.label-mapping-empty{ font-size:0.75rem; color:#94a3b8; }
diff --git "a/\351\207\215\345\210\206\347\261\273gui.py" "b/\351\207\215\345\210\206\347\261\273gui.py"
index 28441b3..dc29653 100644
--- "a/\351\207\215\345\210\206\347\261\273gui.py"
+++ "b/\351\207\215\345\210\206\347\261\273gui.py"
@@ -573,6 +573,33 @@ def _ensure_session_loaded(session_id: str) -> pd.DataFrame:
TOPIC_LABEL_MAP: Dict[str, str] = {}
FIELD_LABEL_MAP: Dict[str, str] = {}
+CATEGORY_BAR_CONFIG = {
+ "topic_adj": {
+ "column": ADJUST_TOPIC_COL,
+ "axis": "Topic",
+ "title": "Topic Distribution (Adjusted)",
+ "kind": "topic",
+ },
+ "field_adj": {
+ "column": ADJUST_FIELD_COL,
+ "axis": "Area",
+ "title": "Area Distribution (Adjusted)",
+ "kind": "field",
+ },
+ "topic_orig": {
+ "column": "研究主题(议题)分类",
+ "axis": "Topic",
+ "title": "Topic Distribution (Original)",
+ "kind": "topic",
+ },
+ "field_orig": {
+ "column": "研究领域分类",
+ "axis": "Area",
+ "title": "Area Distribution (Original)",
+ "kind": "field",
+ },
+}
+
def _sanitize_label_mapping_payload(payload: Any) -> Dict[str, str]:
if not payload:
@@ -1255,6 +1282,125 @@ def _generate_keyword_bar_assets(
}
+def _generate_category_bar_assets(
+ entries: List[Dict[str, Any]],
+ *,
+ dimension: str,
+ axis_label: str,
+ title: str,
+) -> Dict[str, Any]:
+ filtered: List[Tuple[str, str, int]] = []
+ for item in entries:
+ if not isinstance(item, dict):
+ continue
+ try:
+ count = int(item.get("count", 0) or 0)
+ except (TypeError, ValueError):
+ count = 0
+ if count <= 0:
+ continue
+ label = str(item.get("label", "")).strip()
+ display = str(item.get("display_label") or label or "").strip()
+ if not display:
+ display = label or "Unlabeled"
+ filtered.append((label or display, display, count))
+
+ if not filtered:
+ raise ValueError("no category counts")
+
+ filtered.sort(key=lambda row: (-row[2], row[1]))
+
+ labels = [display for _, display, _ in filtered]
+ counts = [count for _, _, count in filtered]
+
+ width = 6.2
+ height = max(3.2, 0.45 * len(labels) + 1.6)
+ max_dim = max(width, height)
+ if max_dim > 8.0:
+ scale = 8.0 / max_dim
+ width *= scale
+ height *= scale
+
+ cmap = matplotlib.colormaps.get("viridis")
+ if cmap is None:
+ colors = ["#0f172a" for _ in labels]
+ else:
+ colors = [cmap(0.2 + 0.6 * (idx / max(1, len(labels) - 1))) for idx in range(len(labels))]
+
+ fig, ax = plt.subplots(figsize=(width, height))
+ fig.patch.set_facecolor("white")
+ ax.set_facecolor("white")
+
+ positions = np.arange(len(labels))
+ bars = ax.barh(positions, counts, color=colors, alpha=0.9, edgecolor="white", linewidth=1.0)
+
+ ax.set_yticks(positions)
+ ax.set_yticklabels(labels, fontname="Times New Roman", fontsize=11)
+ ax.set_xlabel("Document Count", fontname="Times New Roman", fontsize=12)
+ ax.set_ylabel(axis_label, fontname="Times New Roman", fontsize=12)
+ ax.set_title(title, fontname="Times New Roman", fontsize=14, color="#111827", pad=14)
+ ax.invert_yaxis()
+
+ ax.set_axisbelow(True)
+ ax.grid(False)
+
+ for spine_name, spine in ax.spines.items():
+ if spine_name in ("left", "bottom"):
+ spine.set_visible(True)
+ spine.set_color("#111827")
+ spine.set_linewidth(1.1)
+ else:
+ spine.set_visible(False)
+
+ ax.tick_params(axis="x", labelsize=11, direction="out", length=4, width=0.8, colors="#111827")
+ ax.tick_params(axis="y", labelsize=11, direction="out", length=4, width=0.8, colors="#111827")
+ for label in ax.get_xticklabels():
+ label.set_fontname("Times New Roman")
+
+ ax.bar_label(
+ bars,
+ labels=[f"{value:,}" for value in counts],
+ padding=6,
+ fontsize=10,
+ color="#111827",
+ fontname="Times New Roman",
+ )
+
+ fig.tight_layout()
+
+ png_buffer = io.BytesIO()
+ svg_buffer = io.BytesIO()
+ fig.savefig(
+ png_buffer,
+ format="png",
+ dpi=300,
+ bbox_inches="tight",
+ facecolor="white",
+ )
+ fig.savefig(
+ svg_buffer,
+ format="svg",
+ bbox_inches="tight",
+ facecolor="white",
+ )
+ plt.close(fig)
+
+ png_data = base64.b64encode(png_buffer.getvalue()).decode("ascii")
+ svg_data = base64.b64encode(svg_buffer.getvalue()).decode("ascii")
+ png_buffer.close()
+ svg_buffer.close()
+
+ slug = _slugify_filename(f"{dimension}_distribution") or "distribution"
+
+ return {
+ "png_data_url": f"data:image/png;base64,{png_data}",
+ "svg_data_url": f"data:image/svg+xml;base64,{svg_data}",
+ "png_filename": f"{slug}.png",
+ "svg_filename": f"{slug}.svg",
+ "title": title,
+ }
+
+
def _top_sources_by_category(
df: pd.DataFrame,
category_col: str,
@@ -2373,6 +2519,80 @@ def _render_timeline_line_figure(labels: List[str], counts: List[int]):
return fig
+@app.get("/category_bar_chart")
+def category_bar_chart(
+ session_id: str = Query(...),
+ dimension: str = Query("topic_adj"),
+ format: Optional[str] = Query(None),
+ download: bool = Query(False),
+):
+ dim_key = (dimension or "").strip().lower()
+ config = CATEGORY_BAR_CONFIG.get(dim_key)
+ if not config:
+ return PlainTextResponse(
+ "dimension must be one of topic_adj, field_adj, topic_orig, field_orig",
+ status_code=400,
+ )
+
+ try:
+ df = _ensure_session_loaded(session_id)
+ except ValueError:
+ return PlainTextResponse("无效 session_id", status_code=400)
+
+ column = config["column"]
+ if column not in df.columns:
+ message = f"数据集中缺少 {column} 列,无法生成图表。"
+ if format:
+ return PlainTextResponse(message, status_code=404)
+ return JSONResponse({"ok": False, "message": message})
+
+ mapping = TOPIC_LABEL_MAP if config["kind"] == "topic" else FIELD_LABEL_MAP
+ counts = _counts(df[column], mapping)
+
+ try:
+ assets = _generate_category_bar_assets(
+ counts,
+ dimension=dim_key,
+ axis_label=config["axis"],
+ title=config["title"],
+ )
+ except ValueError:
+ if format:
+ return PlainTextResponse("暂无分类数据", status_code=404)
+ return JSONResponse({"ok": False, "message": "暂无分类数据"})
+
+ fmt = (format or "").strip().lower()
+ if fmt:
+ if fmt not in {"png", "svg"}:
+ return PlainTextResponse("format must be png or svg", status_code=400)
+ data_url = assets.get(f"{fmt}_data_url")
+ if not data_url:
+ return PlainTextResponse("暂无图表数据", status_code=404)
+ try:
+ _, encoded = data_url.split(",", 1)
+ binary = base64.b64decode(encoded)
+ except Exception:
+ return PlainTextResponse("图表生成失败", status_code=500)
+ mime = "image/png" if fmt == "png" else "image/svg+xml"
+ filename = assets.get(f"{fmt}_filename") or f"{dim_key}_distribution.{fmt}"
+ headers = {}
+ if download:
+ headers["Content-Disposition"] = f"attachment; filename={filename}"
+ return Response(content=binary, media_type=mime, headers=headers)
+
+ total_docs = int(sum(int(item.get("count", 0) or 0) for item in counts))
+ payload = {
+ "ok": True,
+ "dimension": dim_key,
+ "column": column,
+ "title": assets.get("title", config["title"]),
+ "total_documents": total_docs,
+ "entries": counts,
+ }
+ payload.update(assets)
+ return JSONResponse(payload)
+
+
@app.get("/stats")
def stats(session_id: str = Query(...), bin_years: int = Query(5, ge=1, le=50)):
if session_id not in DATASTORE: