elfsong commited on
Commit
e218ebb
·
1 Parent(s): a6af448

fix: restore compact lifecycle layout

Browse files
src/README.md CHANGED
@@ -111,10 +111,9 @@ from all available paper data, and pushes to `Elfsong/hf_paper_lifecycle`.
111
  This step uses local statistics and does not call Codex.
112
 
113
  The Streamlit Lifecycle view renders snapshots with `lifecycle_figure.py`:
114
- a numbered curve above five phase topic lists. **Topics per phase** selects
115
- 5, 10 (default), 15, or 20 topics. Each list shows its displayed/available count;
116
- sparse phases show all their topics. Innovation ranks by recent share, and other
117
- phases rank by total papers. Existing snapshots work without regeneration.
118
 
119
  ```bash
120
  uv run python src/lifecycle_retrieve.py # latest bimonthly snapshot
 
111
  This step uses local statistics and does not call Codex.
112
 
113
  The Streamlit Lifecycle view renders snapshots with `lifecycle_figure.py`:
114
+ a compact curve with topic names labeled directly on it. The five phases show
115
+ up to 3, 4, 4, 3, and 2 topics respectively. Innovation ranks by recent share,
116
+ and other phases rank by total papers. Existing snapshots work without regeneration.
 
117
 
118
  ```bash
119
  uv run python src/lifecycle_retrieve.py # latest bimonthly snapshot
src/lifecycle_figure.py CHANGED
@@ -1,6 +1,9 @@
1
- """Lifecycle curve and phase topic lists, independent of the Streamlit app."""
 
 
2
 
3
  import matplotlib
 
4
  import numpy as np
5
  from matplotlib import font_manager
6
  from matplotlib.backends.backend_agg import FigureCanvasAgg
@@ -15,13 +18,6 @@ PHASES_ORDER = [
15
  "Slope of Enlightenment",
16
  "Plateau of Productivity",
17
  ]
18
- _COLORS = ["#16a34a", "#dc2626", "#2563eb", "#d97706", "#6b7280"]
19
- _LABELS_EN = [
20
- "Innovation\nTrigger", "Peak of Inflated\nExpectations",
21
- "Trough of\nDisillusionment", "Slope of\nEnlightenment",
22
- "Plateau of\nProductivity",
23
- ]
24
- _LABELS_ZH = ["技术萌芽期", "期望膨胀期", "泡沫破裂期", "稳步爬升期", "生产成熟期"]
25
 
26
 
27
  def _font_settings(lang: bool) -> dict:
@@ -44,142 +40,125 @@ def _font_settings(lang: bool) -> dict:
44
  return settings
45
 
46
 
47
- def _wrap_topic(topic, width, renderer, font):
48
- """Wrap using actual glyph widths, including CJK and unbroken names."""
49
- remaining = " ".join(topic.split())
50
- lines = []
51
- while remaining:
52
- end = 1
53
- while end <= len(remaining):
54
- measured, _, _ = renderer.get_text_width_height_descent(
55
- remaining[:end], font, ismath=False,
56
- )
57
- if measured > width:
58
- break
59
- end += 1
60
- if end > len(remaining):
61
- lines.append(remaining)
62
- break
63
- end = max(1, end - 1)
64
- space = remaining.rfind(" ", 0, end + 1)
65
- if space > 0:
66
- end = space
67
- lines.append(remaining[:end].rstrip())
68
- remaining = remaining[end:].lstrip()
69
- return "\n".join(lines) or " "
70
-
71
-
72
- def render_hype_cycle(lifecycle_data: dict, lang: bool, topics_per_phase: int = 10):
73
- """Show ranked topics on a schematic curve and in five readable columns.
74
-
75
- Innovation ranks by current share; other phases rank by total paper count.
76
- The curve's positions illustrate phase membership, not measured visibility.
77
- """
78
- if topics_per_phase < 1:
79
- raise ValueError("topics_per_phase must be positive")
80
-
81
- groups = {phase: [] for phase in PHASES_ORDER}
82
- for topic in lifecycle_data.values():
83
- if topic["phase"] in groups:
84
- groups[topic["phase"]].append(topic)
85
- if not any(groups.values()):
86
- return None
87
- for phase, topics in groups.items():
88
- metric = "current_avg" if phase == PHASES_ORDER[0] else "total_count"
89
- topics.sort(key=lambda topic: (-topic[metric], topic["topic"]))
90
- selected = [groups[phase][:topics_per_phase] for phase in PHASES_ORDER]
91
-
92
  with matplotlib.rc_context(_font_settings(lang)):
93
- fig = Figure(figsize=(16, 6), facecolor="white")
94
- canvas = FigureCanvasAgg(fig)
95
- renderer = canvas.get_renderer()
96
- font = font_manager.FontProperties(size=9.5)
97
- left, right = 0.055, 0.98
98
- column_width = (right - left) / 5
99
- # Reserve space for the rank at left and paper count at right.
100
- text_width = fig.get_figwidth() * fig.dpi * (column_width - .01) * 0.64
101
- wrapped = [
102
- [_wrap_topic(topic["topic"], text_width, renderer, font) for topic in topics]
103
- for topics in selected
104
- ]
105
- row_heights = [
106
- [max(0.34, 0.18 * (label.count("\n") + 1) + 0.13) for label in labels]
107
- for labels in wrapped
108
- ]
109
- panel_height = 1.15 + max(sum(heights) for heights in row_heights)
110
- curve_height = 3.65
111
- height = panel_height + curve_height + 0.45
112
- fig.set_size_inches(16, height)
113
- ax = fig.add_axes([left, (panel_height + 0.4) / height,
114
- right - left, curve_height / height])
115
-
116
- # Equal-width phases give even space to topics in every stage.
117
- original_x = [0, .5, 1, 1.5, 2, 2.3, 2.8, 3.2, 3.8, 4.5, 5.5, 6.5, 7.5, 8.5, 10]
118
- ctrl_x = np.interp(original_x, [0, 1.35, 2.7, 4.55, 7.35, 10], np.arange(6))
119
- ctrl_y = [.02, .08, .22, .58, .98, .78, .38, .16, .10, .15, .26, .36, .42, .45, .47]
120
- curve = CubicSpline(ctrl_x, ctrl_y)
121
- curve_x = np.linspace(0, 5, 600)
122
- ax.plot(curve_x, curve(curve_x), color="#cbd5e1", linewidth=3,
123
- zorder=1, solid_capstyle="round")
124
-
125
- for index, (topics, color) in enumerate(zip(selected, _COLORS)):
126
- ax.axvspan(index, index + 1, color=color, alpha=0.035, zorder=0)
127
- if index:
128
- ax.axvline(index, color="#e5e7eb", linewidth=0.8, linestyle="--")
129
- x_values = np.linspace(index + .08, index + .92, len(topics) + 2)[1:-1]
130
- for rank, (topic, x) in enumerate(zip(topics, x_values), start=1):
131
- y = float(curve(x))
132
- size = max(4, min(9, topic["total_count"] / 30))
133
- ax.plot(x, y, "o", color=color, markersize=size, zorder=3,
134
- markeredgecolor="white", markeredgewidth=.7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  ax.annotate(
136
- str(rank), xy=(x, y), xytext=(x, y + (.09 if rank % 2 else -.12)),
137
- ha="center", va="bottom", fontsize=7, fontweight="bold", color=color,
138
- bbox={"facecolor": "white", "edgecolor": "none", "pad": .6},
139
- arrowprops={"arrowstyle": "-", "color": color, "alpha": .3, "lw": .6},
140
  )
141
- ax.text(index + .5, -.095, f"0{index + 1}", color=color,
142
- ha="center", va="center", fontsize=11, fontweight="bold")
143
-
144
- ax.set(xlim=(0, 5), ylim=(-.16, 1.25), xticks=[], yticks=[])
145
- ax.set_ylabel("关注度" if lang else "Visibility", fontsize=10, color="#475569")
146
- for spine in ax.spines.values():
147
- spine.set_visible(False)
148
- ax.annotate("成熟度" if lang else "Maturity", xy=(5, -.035), xytext=(4.4, -.035),
149
- fontsize=9, color="#64748b", va="center",
150
- arrowprops={"arrowstyle": "->", "color": "#64748b", "lw": 1})
151
-
152
- labels = _LABELS_ZH if lang else _LABELS_EN
153
- for index, (phase, topics, color) in enumerate(zip(PHASES_ORDER, selected, _COLORS)):
154
- panel = fig.add_axes([
155
- left + index * column_width + .005, .3 / height,
156
- column_width - .01, panel_height / height,
157
- ])
158
- panel.set(xlim=(0, 1), ylim=(panel_height, 0))
159
- panel.axis("off")
160
- panel.plot([0, 1], [.03, .03], color=color, lw=2)
161
- panel.text(.025, .13, f"0{index + 1} {labels[index]}", fontsize=10,
162
- color=color, fontweight="bold", va="top", linespacing=1.3)
163
- count = (
164
- f"显示 {len(topics)} / {len(groups[phase])} 个主题" if lang else
165
- f"Showing {len(topics)} / {len(groups[phase])} topics"
166
- )
167
- panel.text(.025, .64, count, fontsize=8, color="#64748b", va="top")
168
- panel.text(.97, .83, "论文" if lang else "Papers", fontsize=7.5,
169
- color="#64748b", ha="right", va="top")
170
- y = 1.04
171
- if not topics:
172
- panel.text(.025, y, "该阶段暂无主题" if lang else "No topics in this phase",
173
- fontsize=9, color="#94a3b8", va="top")
174
- for rank, (topic, label, row_height) in enumerate(
175
- zip(topics, wrapped[index], row_heights[index]), start=1,
176
- ):
177
- panel.text(.025, y, f"{rank:02}", fontsize=8.5, color=color,
178
- fontweight="bold", va="top")
179
- panel.text(.14, y, label, fontproperties=font, color="#1f2937",
180
- va="top", linespacing=1.3, gid="topic-label")
181
- panel.text(.97, y, f"{topic['total_count']:,}", fontsize=8,
182
- color="#64748b", ha="right", va="top")
183
- y += row_height
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  return fig
 
1
+ """Compact lifecycle curve, independent of the Streamlit app."""
2
+
3
+ from collections import defaultdict
4
 
5
  import matplotlib
6
+ import matplotlib.patheffects as pe
7
  import numpy as np
8
  from matplotlib import font_manager
9
  from matplotlib.backends.backend_agg import FigureCanvasAgg
 
18
  "Slope of Enlightenment",
19
  "Plateau of Productivity",
20
  ]
 
 
 
 
 
 
 
21
 
22
 
23
  def _font_settings(lang: bool) -> dict:
 
40
  return settings
41
 
42
 
43
+ def render_hype_cycle(lifecycle_data: dict, lang: bool):
44
+ """Render the original hype cycle with up to 16 directly labeled topics."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  with matplotlib.rc_context(_font_settings(lang)):
46
+ phase_groups: dict[str, list] = defaultdict(list)
47
+ for lc in lifecycle_data.values():
48
+ phase_groups[lc["phase"]].append(lc)
49
+
50
+ sort_keys = {
51
+ "Innovation Trigger": lambda x: -x["current_avg"],
52
+ "Peak of Inflated Expectations": lambda x: -x["total_count"],
53
+ "Trough of Disillusionment": lambda x: -x["total_count"],
54
+ "Slope of Enlightenment": lambda x: -x["total_count"],
55
+ "Plateau of Productivity": lambda x: -x["total_count"],
56
+ }
57
+ for phase, key_fn in sort_keys.items():
58
+ if phase in phase_groups:
59
+ phase_groups[phase].sort(key=key_fn)
60
+
61
+ max_per_phase = {
62
+ "Innovation Trigger": 3,
63
+ "Peak of Inflated Expectations": 4,
64
+ "Trough of Disillusionment": 4,
65
+ "Slope of Enlightenment": 3,
66
+ "Plateau of Productivity": 2,
67
+ }
68
+ selected = []
69
+ for phase in PHASES_ORDER:
70
+ selected.extend(phase_groups.get(phase, [])[:max_per_phase[phase]])
71
+
72
+ if not selected:
73
+ return None
74
+
75
+ # Hype cycle curve (cubic spline through control points)
76
+ ctrl_x = np.array([0, 0.5, 1, 1.5, 2, 2.3, 2.8, 3.2, 3.8, 4.5, 5.5, 6.5, 7.5, 8.5, 10])
77
+ ctrl_y = np.array([.02, .08, .22, .58, .98, .78, .38, .16, .10, .15, .26, .36, .42, .45, .47])
78
+ cs = CubicSpline(ctrl_x, ctrl_y)
79
+ curve_x = np.linspace(0, 10, 500)
80
+ curve_y = cs(curve_x)
81
+
82
+ phase_ranges = {
83
+ "Innovation Trigger": (0.3, 1.3),
84
+ "Peak of Inflated Expectations": (1.4, 2.6),
85
+ "Trough of Disillusionment": (2.8, 4.3),
86
+ "Slope of Enlightenment": (4.8, 7.2),
87
+ "Plateau of Productivity": (7.5, 9.5),
88
+ }
89
+ phase_colors = {
90
+ "Innovation Trigger": "#16a34a",
91
+ "Peak of Inflated Expectations": "#dc2626",
92
+ "Trough of Disillusionment": "#2563eb",
93
+ "Slope of Enlightenment": "#d97706",
94
+ "Plateau of Productivity": "#6b7280",
95
+ }
96
+ phase_labels = {
97
+ "Innovation Trigger": "技术\n萌芽期" if lang else "Innovation\nTrigger",
98
+ "Peak of Inflated Expectations": "期望\n膨胀期" if lang else "Peak of Inflated\nExpectations",
99
+ "Trough of Disillusionment": "泡沫\n破裂期" if lang else "Trough of\nDisillusionment",
100
+ "Slope of Enlightenment": "稳步\n爬升期" if lang else "Slope of\nEnlightenment",
101
+ "Plateau of Productivity": "生产\n成熟期" if lang else "Plateau of\nProductivity",
102
+ }
103
+ offset_patterns = {
104
+ "Innovation Trigger": [(0.10, "bottom"), (-0.08, "top"), (0.15, "bottom")],
105
+ "Peak of Inflated Expectations": [(0.16, "bottom"), (-0.13, "top"), (0.10, "bottom"), (-0.09, "top")],
106
+ "Trough of Disillusionment": [(0.10, "bottom"), (-0.07, "top"), (0.14, "bottom"), (-0.10, "top")],
107
+ "Slope of Enlightenment": [(0.10, "bottom"), (-0.08, "top"), (0.14, "bottom")],
108
+ "Plateau of Productivity": [(0.10, "bottom"), (-0.08, "top")],
109
+ }
110
+
111
+ fig = Figure(figsize=(10, 5))
112
+ FigureCanvasAgg(fig)
113
+ ax = fig.subplots()
114
+ ax.plot(curve_x, curve_y, color="#d1d5db", linewidth=3.5, zorder=1, solid_capstyle="round")
115
+ ax.fill_between(curve_x, 0, curve_y, alpha=0.03, color="#9ca3af")
116
+
117
+ for bx in [1.35, 2.7, 4.55, 7.35]:
118
+ ax.axvline(bx, color="#e5e7eb", linewidth=0.6, linestyle="--", zorder=0)
119
+
120
+ for phase in PHASES_ORDER:
121
+ x_lo, x_hi = phase_ranges[phase]
122
+ pts = [lc for lc in selected if lc["phase"] == phase]
123
+ if not pts:
124
+ continue
125
+ x_positions = np.linspace(x_lo, x_hi, len(pts) + 2)[1:-1]
126
+ color = phase_colors[phase]
127
+ offsets = offset_patterns[phase]
128
+
129
+ for i, lc in enumerate(pts):
130
+ xp = x_positions[i]
131
+ yp = float(cs(xp))
132
+ dot_size = max(4, min(10, lc["total_count"] / 30))
133
+ ax.plot(xp, yp, "o", color=color, markersize=dot_size, zorder=3,
134
+ markeredgecolor="white", markeredgewidth=0.6)
135
+ offset_y, va = offsets[i % len(offsets)]
136
  ax.annotate(
137
+ lc["topic"], xy=(xp, yp), xytext=(xp, yp + offset_y),
138
+ fontsize=7, color=color, fontweight="bold", ha="center", va=va,
139
+ arrowprops=dict(arrowstyle="-", color=color, alpha=0.3, lw=0.5),
140
+ path_effects=[pe.withStroke(linewidth=2.5, foreground="white")],
141
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
+ for phase in PHASES_ORDER:
144
+ x_lo, x_hi = phase_ranges[phase]
145
+ ax.text((x_lo + x_hi) / 2, -0.10, phase_labels[phase],
146
+ fontsize=7, ha="center", va="top",
147
+ color=phase_colors[phase], fontweight="bold", style="italic")
148
+
149
+ ax.set_xlim(-0.3, 10.3)
150
+ ax.set_ylim(-0.22, 1.20)
151
+ ax.set_ylabel("关注度" if lang else "Visibility", fontsize=10)
152
+ ax.annotate(
153
+ "成熟度" if lang else "Maturity",
154
+ xy=(10.2, -0.03), xytext=(8.5, -0.03), fontsize=9,
155
+ arrowprops=dict(arrowstyle="->", color="#6b7280", lw=1.2),
156
+ color="#6b7280", va="center",
157
+ )
158
+ ax.spines["top"].set_visible(False)
159
+ ax.spines["right"].set_visible(False)
160
+ ax.spines["bottom"].set_visible(False)
161
+ ax.set_xticks([])
162
+ ax.set_yticks([])
163
+ fig.tight_layout()
164
  return fig
src/streamlit_app.py CHANGED
@@ -3023,11 +3023,6 @@ elif active_tab == "Lifecycle":
3023
  label_visibility="collapsed", key="lifecycle_select",
3024
  )
3025
 
3026
- _render_view_heading(
3027
- "主题生命周期" if lang else "Topic lifecycle",
3028
- "观察研究主题从萌芽到成熟的阶段变化。" if lang else
3029
- "Explore how research topics move from early ideas to established practice.",
3030
- )
3031
  _lc_cache_key = f"lifecycle_{selected_snapshot}"
3032
  lc_raw = None
3033
  if _lc_cache_key in st.session_state:
@@ -3043,10 +3038,7 @@ elif active_tab == "Lifecycle":
3043
  lc_data = lc_raw["lifecycle_data_zh"] if lang else lc_raw["lifecycle_data"]
3044
  sorted_months = lc_raw["sorted_months"]
3045
 
3046
- summary_columns = st.columns(3)
3047
- summary_columns[0].metric("论文" if lang else "Papers", f"{lc_raw['n_papers']:,}")
3048
- summary_columns[1].metric("主题" if lang else "Topics", len(lc_data))
3049
- summary_columns[2].metric("覆盖月数" if lang else "Months of research", lc_raw["n_months"])
3050
  if sorted_months:
3051
  st.caption(
3052
  f"{lc_raw['n_months']} months ({sorted_months[0]} → {sorted_months[-1]})"
@@ -3055,33 +3047,11 @@ elif active_tab == "Lifecycle":
3055
  if not lc_data:
3056
  st.warning("Not enough data for lifecycle analysis.")
3057
  else:
3058
- with st.container(key="lifecycle_chart"):
3059
- controls, explanation = st.columns([1, 3], vertical_alignment="bottom")
3060
- with controls:
3061
- topics_per_phase = st.select_slider(
3062
- "每阶段主题数" if lang else "Topics per phase",
3063
- options=[5, 10, 15, 20], value=10,
3064
- key="lifecycle_topics_per_phase",
3065
- )
3066
- with explanation:
3067
- st.caption(
3068
- "萌芽期按近期占比排序,其余阶段按累计论文数排序;数量不足时显示全部。"
3069
- if lang else
3070
- "Innovation ranks by recent share; other phases by total papers. "
3071
- "Phases with fewer topics show all available."
3072
- )
3073
- fig = _render_hype_cycle(lc_data, lang, topics_per_phase=topics_per_phase)
3074
- if fig:
3075
- import matplotlib.pyplot as plt
3076
- st.pyplot(fig, use_container_width=True)
3077
- plt.close(fig)
3078
- st.caption(
3079
- "曲线为阶段示意,点位不代表实际关注度或成熟度。"
3080
- "编号对应下方主题,点大小表示累计论文数。"
3081
- if lang else
3082
- "The curve illustrates lifecycle stages, not measured visibility or maturity. "
3083
- "Numbers match the topic lists; dot size reflects total papers."
3084
- )
3085
 
3086
  # --- Topic selector & time-series chart ---
3087
  tbm = lc_raw.get("topics_by_month_zh" if lang else "topics_by_month") or {}
 
3023
  label_visibility="collapsed", key="lifecycle_select",
3024
  )
3025
 
 
 
 
 
 
3026
  _lc_cache_key = f"lifecycle_{selected_snapshot}"
3027
  lc_raw = None
3028
  if _lc_cache_key in st.session_state:
 
3038
  lc_data = lc_raw["lifecycle_data_zh"] if lang else lc_raw["lifecycle_data"]
3039
  sorted_months = lc_raw["sorted_months"]
3040
 
3041
+ st.metric("Papers", f"{lc_raw['n_papers']:,}")
 
 
 
3042
  if sorted_months:
3043
  st.caption(
3044
  f"{lc_raw['n_months']} months ({sorted_months[0]} → {sorted_months[-1]})"
 
3047
  if not lc_data:
3048
  st.warning("Not enough data for lifecycle analysis.")
3049
  else:
3050
+ fig = _render_hype_cycle(lc_data, lang)
3051
+ if fig:
3052
+ import matplotlib.pyplot as plt
3053
+ st.pyplot(fig, use_container_width=True)
3054
+ plt.close(fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3055
 
3056
  # --- Topic selector & time-series chart ---
3057
  tbm = lc_raw.get("topics_by_month_zh" if lang else "topics_by_month") or {}
src/styles/app.css CHANGED
@@ -1,2 +1,2 @@
1
  /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--tw-font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;--tw-color-amber-50:oklch(98.7% .022 95.277);--tw-color-amber-100:oklch(96.2% .059 95.617);--tw-color-amber-800:oklch(47.3% .137 46.201);--tw-color-emerald-50:oklch(97.9% .021 166.113);--tw-color-emerald-700:oklch(50.8% .118 165.612);--tw-color-blue-50:oklch(97% .014 254.604);--tw-color-blue-100:oklch(93.2% .032 255.585);--tw-color-blue-200:oklch(88.2% .059 254.128);--tw-color-blue-300:oklch(80.9% .105 251.813);--tw-color-blue-500:oklch(62.3% .214 259.815);--tw-color-blue-600:oklch(54.6% .245 262.881);--tw-color-blue-700:oklch(48.8% .243 264.376);--tw-color-rose-50:oklch(96.9% .015 12.422);--tw-color-rose-700:oklch(51.4% .222 16.935);--tw-color-slate-50:oklch(98.4% .003 247.858);--tw-color-slate-100:oklch(96.8% .007 247.896);--tw-color-slate-200:oklch(92.9% .013 255.508);--tw-color-slate-400:oklch(70.4% .04 256.788);--tw-color-slate-500:oklch(55.4% .046 257.417);--tw-color-slate-600:oklch(44.6% .043 257.281);--tw-color-slate-700:oklch(37.2% .044 257.287);--tw-color-slate-800:oklch(27.9% .041 260.031);--tw-color-slate-900:oklch(20.8% .042 265.755);--tw-color-white:#fff;--tw-spacing:.25rem;--tw-text-xs:.75rem;--tw-text-xs--line-height:calc(1 / .75);--tw-text-sm:.875rem;--tw-text-sm--line-height:calc(1.25 / .875);--tw-text-2xl:1.5rem;--tw-text-2xl--line-height:calc(2 / 1.5);--tw-font-weight-normal:400;--tw-font-weight-medium:500;--tw-font-weight-semibold:600;--tw-font-weight-bold:700;--tw-tracking-tight:-.025em;--tw-radius-md:.375rem;--tw-radius-lg:.5rem;--tw-radius-xl:.75rem;--tw-radius-2xl:1rem}}@layer base,components;@layer utilities{.tw\:mt-2{margin-top:calc(var(--tw-spacing) * 2)}.tw\:mt-4{margin-top:calc(var(--tw-spacing) * 4)}.tw\:mb-3{margin-bottom:calc(var(--tw-spacing) * 3)}.tw\:mb-4{margin-bottom:calc(var(--tw-spacing) * 4)}.tw\:flex{display:flex}.tw\:inline-flex{display:inline-flex}.tw\:size-11{width:calc(var(--tw-spacing) * 11);height:calc(var(--tw-spacing) * 11)}.tw\:flex-wrap{flex-wrap:wrap}.tw\:items-center{align-items:center}.tw\:justify-center{justify-content:center}.tw\:gap-2{gap:calc(var(--tw-spacing) * 2)}.tw\:gap-3{gap:calc(var(--tw-spacing) * 3)}.tw\:rounded-xl{border-radius:var(--tw-radius-xl)}.tw\:bg-blue-50{background-color:var(--tw-color-blue-50)}.tw\:text-2xl{font-size:var(--tw-text-2xl);line-height:var(--tw-leading,var(--tw-text-2xl--line-height))}.tw\:text-sm{font-size:var(--tw-text-sm);line-height:var(--tw-leading,var(--tw-text-sm--line-height))}.tw\:text-xs{font-size:var(--tw-text-xs);line-height:var(--tw-leading,var(--tw-text-xs--line-height))}.tw\:font-bold{--tw-font-weight:var(--tw-font-weight-bold);font-weight:var(--tw-font-weight-bold)}.tw\:font-normal{--tw-font-weight:var(--tw-font-weight-normal);font-weight:var(--tw-font-weight-normal)}.tw\:font-semibold{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold)}.tw\:tracking-tight{--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight)}.tw\:text-blue-700{color:var(--tw-color-blue-700)}.tw\:text-slate-400{color:var(--tw-color-slate-400)}.tw\:text-slate-500{color:var(--tw-color-slate-500)}.tw\:text-slate-900{color:var(--tw-color-slate-900)}}[data-testid=stAppViewContainer]{background-color:var(--tw-color-slate-50);color:var(--tw-color-slate-800);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:var(--tw-font-sans)}[data-testid=stHeader],[data-testid=stToolbar]{pointer-events:none}[data-testid=stHeader]{background:0 0}[data-testid=stHeader] :is(button,a){pointer-events:auto}.stMainBlockContainer{max-width:1480px;padding:2.25rem 2.5rem 4rem}[data-testid=stMarkdownContainer] :is(h1,h2,h3,h4){--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight);color:var(--tw-color-slate-900)}[data-testid=stCaptionContainer],[data-testid=stCaptionContainer] p{color:var(--tw-color-slate-500);line-height:1.65}.stDeployButton,[data-testid=stAppDeployButton],footer,#MainMenu,[data-testid=stSidebar],[data-testid=collapsedControl]{display:none}.st-key-app_header{border-bottom:1px solid var(--tw-color-slate-200);margin-bottom:.5rem;padding-bottom:1.4rem}.st-key-app_header [data-testid=stMarkdownContainer]{margin-bottom:0}.paper-brand{min-height:52px}.paper-brand-name{white-space:nowrap;font-size:1.2rem;line-height:1.35}.paper-brand-tagline{letter-spacing:.015em;margin-top:.125rem;font-size:.72rem}.st-key-app_header :is([data-testid=stToggle],[data-testid=stCheckbox]){white-space:nowrap}.st-key-app_header :is([data-testid=stToggle],[data-testid=stCheckbox]) p{font-size:.875rem}[data-testid=stSelectbox] [data-baseweb=select]>div,[data-testid=stMultiSelect] [data-baseweb=select]>div,[data-testid=stDateInput] [data-baseweb=input]{border-radius:var(--tw-radius-xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);min-height:44px;box-shadow:0 1px 2px #0f172a06}[data-testid=stSelectbox] [data-baseweb=select]>div:hover,[data-testid=stDateInput] [data-baseweb=input]:hover{border-color:var(--tw-color-slate-400)}[data-testid=stSelectbox] [data-baseweb=select]>div:focus-within,[data-testid=stMultiSelect] [data-baseweb=select]>div:focus-within,[data-testid=stDateInput] [data-baseweb=input]:focus-within{border-color:var(--tw-color-blue-500);box-shadow:0 0 0 3px var(--tw-color-blue-100)}[data-testid=stWidgetLabel] p{--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-slate-600);font-size:.8rem}.view-heading{margin:.25rem 0 .6rem}[data-testid=stMarkdownContainer] .view-title{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold);--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight);color:var(--tw-color-slate-900);margin:0;padding:0;font-size:clamp(1.5rem,2.3vw,2rem);line-height:1.25}.view-description{color:var(--tw-color-slate-500);margin:.5rem 0 0;font-size:.9rem}[data-testid=stMetric]{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);padding:1rem 1.25rem}[data-testid=stMetricLabel] p{--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-slate-500);font-size:.78rem}[data-testid=stMetricValue]{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold);--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight);color:var(--tw-color-slate-900);font-size:1.8rem}[data-testid=stMetricValue]>div{font-size:1.8rem}.st-key-lifecycle_chart{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);padding:1.25rem;overflow-x:auto}.st-key-lifecycle_chart [data-testid=stImage],.st-key-lifecycle_chart [data-testid=stElementContainer]:has([data-testid=stImage]){min-width:740px}.st-key-lifecycle_chart [data-testid=stImage] img{width:100%!important}[data-testid=stExpander] details{border-radius:var(--tw-radius-xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200)}[data-testid=stExpander] summary{color:var(--tw-color-slate-700);padding:.8rem 1rem}[data-testid=stPills] button{border-radius:999px;min-height:32px;padding:.3rem .8rem}[data-testid=stPills] button p{font-size:.78rem;font-weight:500}[data-testid=stPills] button[aria-pressed=true]{color:var(--tw-color-blue-700);background:var(--tw-color-blue-50);border-color:var(--tw-color-blue-300)}[data-testid=stPills] button[aria-pressed=true] p{color:var(--tw-color-blue-700)}[data-testid=stAppViewContainer] button:focus-visible,[data-testid=stAppViewContainer] a:focus-visible{outline:2px solid var(--tw-color-blue-500);outline-offset:3px}div[class*=st-key-paper_card_]{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);height:100%;transition:border-color .16s,box-shadow .16s;overflow:hidden;box-shadow:0 2px 4px #0f172a06;border:1px solid var(--tw-color-slate-200)!important;gap:.6rem!important;padding:0 0 .5rem!important}div[class*=st-key-paper_card_]:hover{box-shadow:0 8px 24px #0f172a0f;border-color:var(--tw-color-blue-200)!important}div[class*=st-key-paper_card_] [data-testid=stImage]{aspect-ratio:2;background:var(--tw-color-slate-100);overflow:hidden}div[class*=st-key-paper_card_] [data-testid=stImage] img{object-fit:cover;width:100%;height:100%}div[class*=st-key-paper_card_] [data-testid=stButton]{padding:0 1.1rem}div[class*=st-key-paper_card_] [data-testid=stButton] button{text-align:left;background:0 0;border:0;justify-content:flex-start;width:100%;min-height:4.3rem;padding:0}div[class*=st-key-paper_card_] [data-testid=stButton] button p{color:var(--tw-color-slate-900);-webkit-line-clamp:3;-webkit-box-orient:vertical;font-size:1rem;line-height:1.45;display:-webkit-box;overflow:hidden}div[class*=st-key-paper_card_] [data-testid=stButton] button:hover p{color:var(--tw-color-blue-600)}.paper-card-meta{padding:0 1.1rem .5rem}.paper-authors{color:var(--tw-color-slate-500);-webkit-line-clamp:2;-webkit-box-orient:vertical;min-height:2.5rem;margin-bottom:.8rem;font-size:.78rem;line-height:1.6;display:-webkit-box;overflow:hidden}.card-topics{gap:calc(var(--tw-spacing) * 1.5);flex-wrap:wrap;align-content:flex-start;min-height:3.25rem;display:flex}.topic-badge{border-radius:var(--tw-radius-md);background-color:var(--tw-color-blue-50);--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-blue-700);align-items:center;padding:.25rem .55rem;font-size:.7rem;line-height:1.35;display:inline-flex}.keyword-badge{border-radius:var(--tw-radius-md);background-color:var(--tw-color-amber-50);color:var(--tw-color-amber-800);border:1px solid var(--tw-color-amber-100);align-items:center;padding:.25rem .55rem;font-size:.7rem;line-height:1.35;display:inline-flex}.trending-panel{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);border-left:3px solid var(--tw-color-blue-500);margin-bottom:.75rem;padding:1.25rem 1.5rem}.trending-summary{color:var(--tw-color-slate-600);font-size:.9rem;line-height:1.8}.paper-links{gap:calc(var(--tw-spacing) * 2);flex-wrap:wrap;margin-bottom:1rem;display:flex}.paper-links a{border-radius:var(--tw-radius-lg);background-color:var(--tw-color-slate-50);--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-slate-600);border:1px solid var(--tw-color-slate-200);align-items:center;padding:.45rem .75rem;font-size:.8rem;text-decoration:none;display:inline-flex}.paper-links a:hover{background-color:var(--tw-color-blue-50);color:var(--tw-color-blue-700);border-color:var(--tw-color-blue-200)}[role=dialog]{border-radius:1.25rem}.pros-box,.cons-box{border-radius:.8rem;padding:1rem}.pros-box{background-color:var(--tw-color-emerald-50)}.cons-box{background-color:var(--tw-color-rose-50)}.section-label{margin-bottom:.5rem;font-size:.72rem;font-weight:600}.pros-box .section-label{color:var(--tw-color-emerald-700)}.cons-box .section-label{color:var(--tw-color-rose-700)}.point{color:var(--tw-color-slate-700);padding:.35rem 0;font-size:.85rem;line-height:1.75}.stats-bar{gap:calc(var(--tw-spacing) * 6);border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);flex-wrap:wrap;padding:1rem 1.25rem;display:flex}.stat-item{color:var(--tw-color-slate-500);font-size:.8rem}.stat-value{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold);color:var(--tw-color-slate-900);margin-right:.4rem;font-size:1.1rem}@media (max-width:960px){.st-key-app_header{padding-bottom:1rem}.st-key-app_header [data-testid=stHorizontalBlock]{flex-wrap:wrap;gap:1rem .75rem}.st-key-app_header [data-testid=stColumn]{width:auto!important;min-width:0!important}.st-key-app_header [data-testid=stColumn]:first-child{flex:100%}.st-key-app_header [data-testid=stColumn]:nth-child(2){flex:35%}.st-key-app_header [data-testid=stColumn]:nth-child(3){flex:50%}.st-key-app_header [data-testid=stColumn]:nth-child(4){flex:100%}}@media (max-width:760px){.stMainBlockContainer{padding:1.5rem 1rem 3rem}.trending-panel{padding:1rem}.st-key-lifecycle_chart{padding:.6rem}}@media (prefers-reduced-motion:reduce){div[class*=st-key-paper_card_]{transition:none}}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
 
1
  /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--tw-font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;--tw-color-amber-50:oklch(98.7% .022 95.277);--tw-color-amber-100:oklch(96.2% .059 95.617);--tw-color-amber-800:oklch(47.3% .137 46.201);--tw-color-emerald-50:oklch(97.9% .021 166.113);--tw-color-emerald-700:oklch(50.8% .118 165.612);--tw-color-blue-50:oklch(97% .014 254.604);--tw-color-blue-100:oklch(93.2% .032 255.585);--tw-color-blue-200:oklch(88.2% .059 254.128);--tw-color-blue-300:oklch(80.9% .105 251.813);--tw-color-blue-500:oklch(62.3% .214 259.815);--tw-color-blue-600:oklch(54.6% .245 262.881);--tw-color-blue-700:oklch(48.8% .243 264.376);--tw-color-rose-50:oklch(96.9% .015 12.422);--tw-color-rose-700:oklch(51.4% .222 16.935);--tw-color-slate-50:oklch(98.4% .003 247.858);--tw-color-slate-100:oklch(96.8% .007 247.896);--tw-color-slate-200:oklch(92.9% .013 255.508);--tw-color-slate-400:oklch(70.4% .04 256.788);--tw-color-slate-500:oklch(55.4% .046 257.417);--tw-color-slate-600:oklch(44.6% .043 257.281);--tw-color-slate-700:oklch(37.2% .044 257.287);--tw-color-slate-800:oklch(27.9% .041 260.031);--tw-color-slate-900:oklch(20.8% .042 265.755);--tw-color-white:#fff;--tw-spacing:.25rem;--tw-text-xs:.75rem;--tw-text-xs--line-height:calc(1 / .75);--tw-text-sm:.875rem;--tw-text-sm--line-height:calc(1.25 / .875);--tw-text-2xl:1.5rem;--tw-text-2xl--line-height:calc(2 / 1.5);--tw-font-weight-normal:400;--tw-font-weight-medium:500;--tw-font-weight-semibold:600;--tw-font-weight-bold:700;--tw-tracking-tight:-.025em;--tw-radius-md:.375rem;--tw-radius-lg:.5rem;--tw-radius-xl:.75rem;--tw-radius-2xl:1rem}}@layer base,components;@layer utilities{.tw\:mt-2{margin-top:calc(var(--tw-spacing) * 2)}.tw\:mt-4{margin-top:calc(var(--tw-spacing) * 4)}.tw\:mb-3{margin-bottom:calc(var(--tw-spacing) * 3)}.tw\:mb-4{margin-bottom:calc(var(--tw-spacing) * 4)}.tw\:flex{display:flex}.tw\:inline-flex{display:inline-flex}.tw\:size-11{width:calc(var(--tw-spacing) * 11);height:calc(var(--tw-spacing) * 11)}.tw\:flex-wrap{flex-wrap:wrap}.tw\:items-center{align-items:center}.tw\:justify-center{justify-content:center}.tw\:gap-2{gap:calc(var(--tw-spacing) * 2)}.tw\:gap-3{gap:calc(var(--tw-spacing) * 3)}.tw\:rounded-xl{border-radius:var(--tw-radius-xl)}.tw\:bg-blue-50{background-color:var(--tw-color-blue-50)}.tw\:text-2xl{font-size:var(--tw-text-2xl);line-height:var(--tw-leading,var(--tw-text-2xl--line-height))}.tw\:text-sm{font-size:var(--tw-text-sm);line-height:var(--tw-leading,var(--tw-text-sm--line-height))}.tw\:text-xs{font-size:var(--tw-text-xs);line-height:var(--tw-leading,var(--tw-text-xs--line-height))}.tw\:font-bold{--tw-font-weight:var(--tw-font-weight-bold);font-weight:var(--tw-font-weight-bold)}.tw\:font-normal{--tw-font-weight:var(--tw-font-weight-normal);font-weight:var(--tw-font-weight-normal)}.tw\:font-semibold{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold)}.tw\:tracking-tight{--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight)}.tw\:text-blue-700{color:var(--tw-color-blue-700)}.tw\:text-slate-400{color:var(--tw-color-slate-400)}.tw\:text-slate-500{color:var(--tw-color-slate-500)}.tw\:text-slate-900{color:var(--tw-color-slate-900)}}[data-testid=stAppViewContainer]{background-color:var(--tw-color-slate-50);color:var(--tw-color-slate-800);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:var(--tw-font-sans)}[data-testid=stHeader],[data-testid=stToolbar]{pointer-events:none}[data-testid=stHeader]{background:0 0}[data-testid=stHeader] :is(button,a){pointer-events:auto}.stMainBlockContainer{max-width:1480px;padding:2.25rem 2.5rem 4rem}[data-testid=stMarkdownContainer] :is(h1,h2,h3,h4){--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight);color:var(--tw-color-slate-900)}[data-testid=stCaptionContainer],[data-testid=stCaptionContainer] p{color:var(--tw-color-slate-500);line-height:1.65}.stDeployButton,[data-testid=stAppDeployButton],footer,#MainMenu,[data-testid=stSidebar],[data-testid=collapsedControl]{display:none}.st-key-app_header{border-bottom:1px solid var(--tw-color-slate-200);margin-bottom:.5rem;padding-bottom:1.4rem}.st-key-app_header [data-testid=stMarkdownContainer]{margin-bottom:0}.paper-brand{min-height:52px}.paper-brand-name{white-space:nowrap;font-size:1.2rem;line-height:1.35}.paper-brand-tagline{letter-spacing:.015em;margin-top:.125rem;font-size:.72rem}.st-key-app_header :is([data-testid=stToggle],[data-testid=stCheckbox]){white-space:nowrap}.st-key-app_header :is([data-testid=stToggle],[data-testid=stCheckbox]) p{font-size:.875rem}[data-testid=stSelectbox] [data-baseweb=select]>div,[data-testid=stMultiSelect] [data-baseweb=select]>div,[data-testid=stDateInput] [data-baseweb=input]{border-radius:var(--tw-radius-xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);min-height:44px;box-shadow:0 1px 2px #0f172a06}[data-testid=stSelectbox] [data-baseweb=select]>div:hover,[data-testid=stDateInput] [data-baseweb=input]:hover{border-color:var(--tw-color-slate-400)}[data-testid=stSelectbox] [data-baseweb=select]>div:focus-within,[data-testid=stMultiSelect] [data-baseweb=select]>div:focus-within,[data-testid=stDateInput] [data-baseweb=input]:focus-within{border-color:var(--tw-color-blue-500);box-shadow:0 0 0 3px var(--tw-color-blue-100)}[data-testid=stWidgetLabel] p{--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-slate-600);font-size:.8rem}.view-heading{margin:.25rem 0 .6rem}[data-testid=stMarkdownContainer] .view-title{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold);--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight);color:var(--tw-color-slate-900);margin:0;padding:0;font-size:clamp(1.5rem,2.3vw,2rem);line-height:1.25}.view-description{color:var(--tw-color-slate-500);margin:.5rem 0 0;font-size:.9rem}[data-testid=stMetric]{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);padding:1rem 1.25rem}[data-testid=stMetricLabel] p{--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-slate-500);font-size:.78rem}[data-testid=stMetricValue]{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold);--tw-tracking:var(--tw-tracking-tight);letter-spacing:var(--tw-tracking-tight);color:var(--tw-color-slate-900);font-size:1.8rem}[data-testid=stMetricValue]>div{font-size:1.8rem}[data-testid=stExpander] details{border-radius:var(--tw-radius-xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200)}[data-testid=stExpander] summary{color:var(--tw-color-slate-700);padding:.8rem 1rem}[data-testid=stPills] button{border-radius:999px;min-height:32px;padding:.3rem .8rem}[data-testid=stPills] button p{font-size:.78rem;font-weight:500}[data-testid=stPills] button[aria-pressed=true]{color:var(--tw-color-blue-700);background:var(--tw-color-blue-50);border-color:var(--tw-color-blue-300)}[data-testid=stPills] button[aria-pressed=true] p{color:var(--tw-color-blue-700)}[data-testid=stAppViewContainer] button:focus-visible,[data-testid=stAppViewContainer] a:focus-visible{outline:2px solid var(--tw-color-blue-500);outline-offset:3px}div[class*=st-key-paper_card_]{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);height:100%;transition:border-color .16s,box-shadow .16s;overflow:hidden;box-shadow:0 2px 4px #0f172a06;border:1px solid var(--tw-color-slate-200)!important;gap:.6rem!important;padding:0 0 .5rem!important}div[class*=st-key-paper_card_]:hover{box-shadow:0 8px 24px #0f172a0f;border-color:var(--tw-color-blue-200)!important}div[class*=st-key-paper_card_] [data-testid=stImage]{aspect-ratio:2;background:var(--tw-color-slate-100);overflow:hidden}div[class*=st-key-paper_card_] [data-testid=stImage] img{object-fit:cover;width:100%;height:100%}div[class*=st-key-paper_card_] [data-testid=stButton]{padding:0 1.1rem}div[class*=st-key-paper_card_] [data-testid=stButton] button{text-align:left;background:0 0;border:0;justify-content:flex-start;width:100%;min-height:4.3rem;padding:0}div[class*=st-key-paper_card_] [data-testid=stButton] button p{color:var(--tw-color-slate-900);-webkit-line-clamp:3;-webkit-box-orient:vertical;font-size:1rem;line-height:1.45;display:-webkit-box;overflow:hidden}div[class*=st-key-paper_card_] [data-testid=stButton] button:hover p{color:var(--tw-color-blue-600)}.paper-card-meta{padding:0 1.1rem .5rem}.paper-authors{color:var(--tw-color-slate-500);-webkit-line-clamp:2;-webkit-box-orient:vertical;min-height:2.5rem;margin-bottom:.8rem;font-size:.78rem;line-height:1.6;display:-webkit-box;overflow:hidden}.card-topics{gap:calc(var(--tw-spacing) * 1.5);flex-wrap:wrap;align-content:flex-start;min-height:3.25rem;display:flex}.topic-badge{border-radius:var(--tw-radius-md);background-color:var(--tw-color-blue-50);--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-blue-700);align-items:center;padding:.25rem .55rem;font-size:.7rem;line-height:1.35;display:inline-flex}.keyword-badge{border-radius:var(--tw-radius-md);background-color:var(--tw-color-amber-50);color:var(--tw-color-amber-800);border:1px solid var(--tw-color-amber-100);align-items:center;padding:.25rem .55rem;font-size:.7rem;line-height:1.35;display:inline-flex}.trending-panel{border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);border-left:3px solid var(--tw-color-blue-500);margin-bottom:.75rem;padding:1.25rem 1.5rem}.trending-summary{color:var(--tw-color-slate-600);font-size:.9rem;line-height:1.8}.paper-links{gap:calc(var(--tw-spacing) * 2);flex-wrap:wrap;margin-bottom:1rem;display:flex}.paper-links a{border-radius:var(--tw-radius-lg);background-color:var(--tw-color-slate-50);--tw-font-weight:var(--tw-font-weight-medium);font-weight:var(--tw-font-weight-medium);color:var(--tw-color-slate-600);border:1px solid var(--tw-color-slate-200);align-items:center;padding:.45rem .75rem;font-size:.8rem;text-decoration:none;display:inline-flex}.paper-links a:hover{background-color:var(--tw-color-blue-50);color:var(--tw-color-blue-700);border-color:var(--tw-color-blue-200)}[role=dialog]{border-radius:1.25rem}.pros-box,.cons-box{border-radius:.8rem;padding:1rem}.pros-box{background-color:var(--tw-color-emerald-50)}.cons-box{background-color:var(--tw-color-rose-50)}.section-label{margin-bottom:.5rem;font-size:.72rem;font-weight:600}.pros-box .section-label{color:var(--tw-color-emerald-700)}.cons-box .section-label{color:var(--tw-color-rose-700)}.point{color:var(--tw-color-slate-700);padding:.35rem 0;font-size:.85rem;line-height:1.75}.stats-bar{gap:calc(var(--tw-spacing) * 6);border-radius:var(--tw-radius-2xl);background-color:var(--tw-color-white);border:1px solid var(--tw-color-slate-200);flex-wrap:wrap;padding:1rem 1.25rem;display:flex}.stat-item{color:var(--tw-color-slate-500);font-size:.8rem}.stat-value{--tw-font-weight:var(--tw-font-weight-semibold);font-weight:var(--tw-font-weight-semibold);color:var(--tw-color-slate-900);margin-right:.4rem;font-size:1.1rem}@media (max-width:960px){.st-key-app_header{padding-bottom:1rem}.st-key-app_header [data-testid=stHorizontalBlock]{flex-wrap:wrap;gap:1rem .75rem}.st-key-app_header [data-testid=stColumn]{width:auto!important;min-width:0!important}.st-key-app_header [data-testid=stColumn]:first-child{flex:100%}.st-key-app_header [data-testid=stColumn]:nth-child(2){flex:35%}.st-key-app_header [data-testid=stColumn]:nth-child(3){flex:50%}.st-key-app_header [data-testid=stColumn]:nth-child(4){flex:100%}}@media (max-width:760px){.stMainBlockContainer{padding:1.5rem 1rem 3rem}.trending-panel{padding:1rem}}@media (prefers-reduced-motion:reduce){div[class*=st-key-paper_card_]{transition:none}}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
src/styles/tailwind.css CHANGED
@@ -85,17 +85,6 @@
85
  [data-testid="stMetricLabel"] p { @apply tw:text-slate-500 tw:font-medium; font-size: .78rem; }
86
  [data-testid="stMetricValue"] { @apply tw:text-slate-900 tw:font-semibold tw:tracking-tight; font-size: 1.8rem; }
87
  [data-testid="stMetricValue"] > div { font-size: 1.8rem; }
88
- .st-key-lifecycle_chart {
89
- @apply tw:bg-white tw:rounded-2xl;
90
- border: 1px solid var(--tw-color-slate-200);
91
- padding: 1.25rem;
92
- overflow-x: auto;
93
- }
94
- .st-key-lifecycle_chart [data-testid="stImage"] { min-width: 740px; }
95
- .st-key-lifecycle_chart [data-testid="stElementContainer"]:has([data-testid="stImage"]) {
96
- min-width: 740px;
97
- }
98
- .st-key-lifecycle_chart [data-testid="stImage"] img { width: 100% !important; }
99
  [data-testid="stExpander"] details {
100
  @apply tw:bg-white tw:rounded-xl;
101
  border: 1px solid var(--tw-color-slate-200);
@@ -233,7 +222,6 @@ div[class*="st-key-paper_card_"] [data-testid="stButton"] button:hover p { color
233
  @media (max-width: 760px) {
234
  .stMainBlockContainer { padding: 1.5rem 1rem 3rem; }
235
  .trending-panel { padding: 1rem; }
236
- .st-key-lifecycle_chart { padding: .6rem; }
237
  }
238
  @media (prefers-reduced-motion: reduce) {
239
  div[class*="st-key-paper_card_"] { transition: none; }
 
85
  [data-testid="stMetricLabel"] p { @apply tw:text-slate-500 tw:font-medium; font-size: .78rem; }
86
  [data-testid="stMetricValue"] { @apply tw:text-slate-900 tw:font-semibold tw:tracking-tight; font-size: 1.8rem; }
87
  [data-testid="stMetricValue"] > div { font-size: 1.8rem; }
 
 
 
 
 
 
 
 
 
 
 
88
  [data-testid="stExpander"] details {
89
  @apply tw:bg-white tw:rounded-xl;
90
  border: 1px solid var(--tw-color-slate-200);
 
222
  @media (max-width: 760px) {
223
  .stMainBlockContainer { padding: 1.5rem 1rem 3rem; }
224
  .trending-panel { padding: 1rem; }
 
225
  }
226
  @media (prefers-reduced-motion: reduce) {
227
  div[class*="st-key-paper_card_"] { transition: none; }
tests/test_lifecycle_figure.py CHANGED
@@ -5,7 +5,7 @@ import matplotlib
5
 
6
  matplotlib.use("Agg")
7
  from matplotlib import pyplot as plt
8
- from matplotlib.text import Annotation, Text
9
 
10
  from src.lifecycle_figure import PHASES_ORDER, _font_settings, render_hype_cycle
11
 
@@ -29,87 +29,35 @@ class LifecycleFigureTests(unittest.TestCase):
29
 
30
  def topic_labels(self, fig):
31
  return [
32
- text.get_text().replace("\n", " ")
33
- for ax in fig.axes[1:] for text in ax.texts
34
- if text.get_gid() == "topic-label"
 
35
  ]
36
 
37
- def test_default_shows_ten_topics_in_every_phase_with_existing_ranking(self):
38
  data = sample_topics()
39
  fig = render_hype_cycle(data, False)
40
  labels = self.topic_labels(fig)
41
 
42
- self.assertEqual(len(labels), 50)
43
- self.assertIn("Research topic 0-23", labels)
44
- self.assertNotIn("Research topic 0-00", labels)
45
- for phase_index in range(1, 5):
46
- self.assertIn(f"Research topic {phase_index}-00", labels)
47
- self.assertNotIn(f"Research topic {phase_index}-10", labels)
48
-
49
- def test_requested_limit_applies_to_each_phase(self):
50
- for limit in (5, 15, 20):
51
- with self.subTest(limit=limit):
52
- fig = render_hype_cycle(sample_topics(), False, topics_per_phase=limit)
53
- self.assertEqual(len(self.topic_labels(fig)), 5 * limit)
54
- plt.close(fig)
55
-
56
- def test_sparse_phases_keep_available_topics_and_show_totals(self):
57
  data = {"one": sample_topics(1)["0-0"]}
58
  fig = render_hype_cycle(data, False)
59
 
60
  self.assertEqual(self.topic_labels(fig), ["Research topic 0-00"])
61
- texts = [text.get_text() for ax in fig.axes for text in ax.texts]
62
- self.assertIn("Showing 1 / 1 topics", texts)
63
- self.assertEqual(texts.count("No topics in this phase"), 4)
64
  self.assertIsNone(render_hype_cycle({}, False))
65
 
66
- def test_twenty_long_labels_fit_inside_their_panels_without_overlap(self):
67
- for lang in (False, True):
68
- with self.subTest(lang=lang):
69
- data = sample_topics(20)
70
- for key, topic in data.items():
71
- topic["topic"] = (
72
- f"多模态大语言模型的长上下文推理与高效训练研究 {key}"
73
- if lang else
74
- f"Long-context multimodal reasoning and efficient language model training {key}"
75
- )
76
- fig = render_hype_cycle(data, lang, topics_per_phase=20)
77
- fig.canvas.draw()
78
- renderer = fig.canvas.get_renderer()
79
- for ax in fig.axes[1:]:
80
- bounds = ax.get_window_extent(renderer)
81
- labels = [t for t in ax.texts if t.get_gid() == "topic-label"]
82
- previous = None
83
- for text in labels:
84
- box = text.get_window_extent(renderer)
85
- self.assertGreaterEqual(box.x0, bounds.x0)
86
- self.assertLessEqual(box.x1, bounds.x1)
87
- self.assertGreaterEqual(box.y0, bounds.y0)
88
- self.assertLessEqual(box.y1, bounds.y1)
89
- if previous is not None:
90
- self.assertLess(box.y1, previous.y0)
91
- previous = box
92
- plt.close(fig)
93
-
94
  def test_language_rendering_does_not_change_global_font_settings(self):
95
  before = list(matplotlib.rcParams["font.sans-serif"])
96
  render_hype_cycle(sample_topics(1), True)
97
  self.assertEqual(matplotlib.rcParams["font.sans-serif"], before)
98
 
99
- def test_curve_numbers_do_not_overlap_at_twenty_topics_per_phase(self):
100
- fig = render_hype_cycle(sample_topics(), False, topics_per_phase=20)
101
- fig.canvas.draw()
102
- renderer = fig.canvas.get_renderer()
103
- numbers = [
104
- text for text in fig.axes[0].texts
105
- if isinstance(text, Annotation) and text.get_text().isdigit()
106
- ]
107
- # Annotation extents include the leader line; compare only the text.
108
- boxes = [Text.get_window_extent(text, renderer).expanded(1.15, 1.15) for text in numbers]
109
- for index, box in enumerate(boxes):
110
- for other in boxes[index + 1:]:
111
- self.assertFalse(box.overlaps(other), "Topic numbers overlap on the curve")
112
-
113
  def test_chinese_font_is_retained_when_figure_is_drawn_after_return(self):
114
  if "font.family" not in _font_settings(True):
115
  self.skipTest("No CJK font installed")
 
5
 
6
  matplotlib.use("Agg")
7
  from matplotlib import pyplot as plt
8
+ from matplotlib.text import Annotation
9
 
10
  from src.lifecycle_figure import PHASES_ORDER, _font_settings, render_hype_cycle
11
 
 
29
 
30
  def topic_labels(self, fig):
31
  return [
32
+ text.get_text()
33
+ for text in fig.axes[0].texts
34
+ if isinstance(text, Annotation)
35
+ and text.get_text() not in ("Maturity", "成熟度")
36
  ]
37
 
38
+ def test_compact_curve_keeps_original_topic_limits_and_ranking(self):
39
  data = sample_topics()
40
  fig = render_hype_cycle(data, False)
41
  labels = self.topic_labels(fig)
42
 
43
+ expected = [f"Research topic 0-{rank:02}" for rank in (23, 22, 21)]
44
+ for phase_index, limit in enumerate((4, 4, 3, 2), start=1):
45
+ expected.extend(f"Research topic {phase_index}-{rank:02}" for rank in range(limit))
46
+ self.assertEqual(labels, expected)
47
+ self.assertEqual(len(fig.axes), 1)
48
+
49
+ def test_sparse_phases_keep_available_topics_and_empty_data_has_no_figure(self):
 
 
 
 
 
 
 
 
50
  data = {"one": sample_topics(1)["0-0"]}
51
  fig = render_hype_cycle(data, False)
52
 
53
  self.assertEqual(self.topic_labels(fig), ["Research topic 0-00"])
 
 
 
54
  self.assertIsNone(render_hype_cycle({}, False))
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def test_language_rendering_does_not_change_global_font_settings(self):
57
  before = list(matplotlib.rcParams["font.sans-serif"])
58
  render_hype_cycle(sample_topics(1), True)
59
  self.assertEqual(matplotlib.rcParams["font.sans-serif"], before)
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def test_chinese_font_is_retained_when_figure_is_drawn_after_return(self):
62
  if "font.family" not in _font_settings(True):
63
  self.skipTest("No CJK font installed")