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: