mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
[v1.9.0] (活跃度可视化) 生成 24h 时间段活跃情况分析
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# QQ群日常分析插件
|
||||
|
||||
|
||||
[](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
|
||||
[](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
|
||||
[](https://github.com/AstrBotDevs/AstrBot)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -144,6 +144,9 @@ _✨ 一个基于AstrBot的智能群聊分析插件,能够生成精美的群
|
||||
### v1.8.0
|
||||
- 修复表情统计情况
|
||||
|
||||
### v1.9.0
|
||||
- 生成 24h 时间段活跃情况分析
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -216,6 +216,8 @@ class QQGroupDailyAnalysis(Star):
|
||||
logger.error(f"群分析失败: {e}", exc_info=True)
|
||||
yield event.plain_result(f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员")
|
||||
|
||||
|
||||
|
||||
@filter.command("设置格式")
|
||||
@filter.permission_type(PermissionType.ADMIN)
|
||||
async def set_output_format(self, event: AiocqhttpMessageEvent, format_type: str = ""):
|
||||
@@ -366,7 +368,7 @@ class QQGroupDailyAnalysis(Star):
|
||||
• 最大查询轮数: {max_rounds}
|
||||
|
||||
💡 可用命令: enable, disable, status, reload, test
|
||||
💡 支持的输出格式: image, text, pdf
|
||||
💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化)
|
||||
💡 其他命令: /设置格式, /安装PDF""")
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -13,6 +13,6 @@ help: | # 插件的帮助信息
|
||||
命令:
|
||||
/群分析 [天数] - 分析群聊活动
|
||||
/分析设置 [操作] - 管理设置(enable/disable/status/test)
|
||||
version: v1.8.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
version: v1.9.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
author: SXP-Simon # 作者
|
||||
repo: https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis # 插件的仓库地址
|
||||
|
||||
@@ -8,7 +8,8 @@ from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from collections import defaultdict
|
||||
from astrbot.api import logger
|
||||
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics
|
||||
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics, ActivityVisualization
|
||||
from ...src.visualization.activity_charts import ActivityVisualizer
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
@@ -16,6 +17,7 @@ class MessageHandler:
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
self.activity_visualizer = ActivityVisualizer()
|
||||
self.bot_qq_id = None
|
||||
|
||||
async def set_bot_qq_id(self, bot_instance):
|
||||
@@ -189,6 +191,9 @@ class MessageHandler:
|
||||
most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0
|
||||
most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00"
|
||||
|
||||
# 生成活跃度可视化数据
|
||||
activity_visualization = self.activity_visualizer.generate_activity_visualization(messages)
|
||||
|
||||
return GroupStatistics(
|
||||
message_count=len(messages),
|
||||
total_characters=total_chars,
|
||||
@@ -197,5 +202,6 @@ class MessageHandler:
|
||||
golden_quotes=[],
|
||||
emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容
|
||||
emoji_statistics=emoji_statistics,
|
||||
activity_visualization=activity_visualization,
|
||||
token_usage=TokenUsage()
|
||||
)
|
||||
@@ -57,6 +57,16 @@ class EmojiStatistics:
|
||||
return self.face_count + self.mface_count + self.bface_count + self.sface_count + self.other_emoji_count
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityVisualization:
|
||||
"""活跃度可视化数据结构"""
|
||||
hourly_activity: dict = field(default_factory=dict) # {hour: count}
|
||||
daily_activity: dict = field(default_factory=dict) # {date: count}
|
||||
user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行
|
||||
peak_hours: list = field(default_factory=list) # 高峰时段
|
||||
activity_heatmap_data: dict = field(default_factory=dict) # 热力图数据
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupStatistics:
|
||||
"""群聊统计数据结构"""
|
||||
@@ -67,4 +77,5 @@ class GroupStatistics:
|
||||
golden_quotes: List[GoldenQuote]
|
||||
emoji_count: int # 保持向后兼容
|
||||
emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics)
|
||||
activity_visualization: ActivityVisualization = field(default_factory=ActivityVisualization)
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
@@ -10,6 +10,7 @@ from typing import Dict, Optional
|
||||
from pathlib import Path
|
||||
from astrbot.api import logger
|
||||
from .templates import HTMLTemplates
|
||||
from ..visualization.activity_charts import ActivityVisualizer
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
@@ -17,6 +18,7 @@ class ReportGenerator:
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
self.activity_visualizer = ActivityVisualizer()
|
||||
|
||||
async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]:
|
||||
"""生成图片格式的分析报告"""
|
||||
@@ -32,6 +34,8 @@ class ReportGenerator:
|
||||
logger.error(f"生成图片报告失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]:
|
||||
"""生成PDF格式的分析报告"""
|
||||
try:
|
||||
@@ -113,6 +117,7 @@ class ReportGenerator:
|
||||
stats = analysis_result["statistics"]
|
||||
topics = analysis_result["topics"]
|
||||
user_titles = analysis_result["user_titles"]
|
||||
activity_viz = stats.activity_visualization
|
||||
|
||||
# 构建话题HTML
|
||||
topics_html = ""
|
||||
@@ -166,6 +171,11 @@ class ReportGenerator:
|
||||
</div>
|
||||
"""
|
||||
|
||||
# 生成活跃度可视化HTML
|
||||
hourly_chart_html = self.activity_visualizer.generate_hourly_chart_html(
|
||||
activity_viz.hourly_activity
|
||||
)
|
||||
|
||||
# 返回扁平化的渲染数据
|
||||
return {
|
||||
"current_date": datetime.now().strftime('%Y年%m月%d日'),
|
||||
@@ -178,11 +188,15 @@ class ReportGenerator:
|
||||
"topics_html": topics_html,
|
||||
"titles_html": titles_html,
|
||||
"quotes_html": quotes_html,
|
||||
"hourly_chart_html": hourly_chart_html,
|
||||
"total_tokens": stats.token_usage.total_tokens,
|
||||
"prompt_tokens": stats.token_usage.prompt_tokens,
|
||||
"completion_tokens": stats.token_usage.completion_tokens
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_html_template(self, template: str, data: Dict, use_jinja_style: bool = False) -> str:
|
||||
"""HTML模板渲染,支持两种占位符格式
|
||||
|
||||
|
||||
@@ -66,6 +66,103 @@ class HTMLTemplates:
|
||||
.quote-author { font-size: 0.9em; color: #4299e1; font-weight: 600; margin-bottom: 8px; text-align: right; }
|
||||
.quote-reason { font-size: 0.8em; color: #666666; font-style: normal; background: rgba(66, 153, 225, 0.1); padding: 8px 12px; border-radius: 12px; border-left: 3px solid #4299e1; }
|
||||
.footer { background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #ffffff; text-align: center; padding: 32px; font-size: 0.8em; font-weight: 300; letter-spacing: 0.5px; opacity: 0.9; }
|
||||
|
||||
/* 活跃度可视化样式 - 重新设计 */
|
||||
.activity-section {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
margin: 40px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.activity-chart-container {
|
||||
background: #ffffff;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #f0f2f5;
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.chart-subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
font-weight: 400;
|
||||
}
|
||||
.hour-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
height: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.hour-bar-container:hover {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.hour-label {
|
||||
width: 55px;
|
||||
text-align: left;
|
||||
color: #4a5568;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bar {
|
||||
height: 10px;
|
||||
background: linear-gradient(90deg, #4299e1 0%, #667eea 100%);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease-out;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(66, 153, 225, 0.2);
|
||||
}
|
||||
.bar:hover {
|
||||
transform: scaleY(1.2);
|
||||
box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);
|
||||
}
|
||||
.hourly-value-outside {
|
||||
color: #4a5568;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
.hourly-value-inside {
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) { .container { max-width: 1400px; } .topics-grid { grid-template-columns: repeat(3, 1fr); } .users-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 768px) { body { padding: 10px; } .container { margin: 0; max-width: 100%; } .header { padding: 24px 20px; } .header h1 { font-size: 1.8em; } .content { padding: 20px; } .topics-grid { grid-template-columns: 1fr; } .users-grid { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr 1fr; gap: 12px; } .stat-card { padding: 20px 16px; } .topic-item { padding: 20px; } .user-title { flex-direction: column; align-items: flex-start; gap: 12px; padding: 16px; min-height: auto; } .user-info { width: 100%; } .user-reason { text-align: left; max-width: none; margin-left: 0; margin-top: 8px; } }
|
||||
</style>
|
||||
@@ -90,6 +187,16 @@ class HTMLTemplates:
|
||||
<div class="label">最活跃时段</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活跃度可视化部分 - 重新设计 -->
|
||||
<div class="activity-chart-container">
|
||||
<div class="chart-header">
|
||||
<div>
|
||||
<div class="chart-title">⏱️ 24小时活跃度分布</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ hourly_chart_html | safe }}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">💬 热门话题</h2>
|
||||
<div class="topics-grid">{{ topics_html | safe }}</div>
|
||||
@@ -157,6 +264,72 @@ class HTMLTemplates:
|
||||
.quote-author { font-size: 14px; color: #4299e1; font-weight: 600; margin-bottom: 8px; text-align: right; }
|
||||
.quote-reason { font-size: 12px; color: #666666; background: rgba(66, 153, 225, 0.1); padding: 8px 12px; border-radius: 6px; border-left: 3px solid #4299e1; }
|
||||
.footer { background: #f8f9ff; color: #666666; text-align: center; padding: 20px; font-size: 12px; border-radius: 8px; margin-top: 40px; }
|
||||
|
||||
/* PDF活跃度可视化样式 - 集成版本 */
|
||||
.activity-chart-container {
|
||||
background: #f8f9ff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-top: 20px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.chart-header {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
.hour-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
height: 16px;
|
||||
}
|
||||
.hour-label {
|
||||
width: 45px;
|
||||
text-align: left;
|
||||
color: #4a5568;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bar {
|
||||
height: 6px;
|
||||
background-color: #4299e1;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hourly-value-outside {
|
||||
color: #4a5568;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 25px;
|
||||
text-align: right;
|
||||
}
|
||||
.hourly-value-inside {
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
padding: 0 4px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media print { body { font-size: 12px; } .container { padding: 10px; } .header { padding: 20px; } .section { margin-bottom: 30px; } .stats-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -178,7 +351,15 @@ class HTMLTemplates:
|
||||
<div class="time">{most_active_period}</div>
|
||||
<div class="label">最活跃时段</div>
|
||||
</div>
|
||||
<!-- 活跃度可视化部分 - 集成到基础统计 -->
|
||||
<div class="activity-chart-container">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">⏱️ 活跃度分布</div>
|
||||
</div>
|
||||
{hourly_chart_html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">💬 热门话题</h2>
|
||||
{topics_html}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
可视化模块
|
||||
"""
|
||||
|
||||
from .activity_charts import ActivityVisualizer
|
||||
|
||||
__all__ = ['ActivityVisualizer']
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
群聊活跃度可视化模块
|
||||
参考 astrbot_plugin_github_analyzer 的实现方式
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Any
|
||||
from ..models.data_models import ActivityVisualization
|
||||
|
||||
|
||||
class ActivityVisualizer:
|
||||
"""活跃度可视化器"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def generate_activity_visualization(self, messages: List[Dict]) -> ActivityVisualization:
|
||||
"""生成活跃度可视化数据 - 专注于小时级别分析"""
|
||||
hourly_activity = defaultdict(int)
|
||||
user_activity = defaultdict(int)
|
||||
emoji_activity = defaultdict(int) # 每小时表情统计
|
||||
|
||||
# 分析消息数据
|
||||
for msg in messages:
|
||||
# 时间分析 - 只关注小时
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
||||
hour = msg_time.hour
|
||||
|
||||
# # 用户分析
|
||||
# sender = msg.get("sender", {})
|
||||
# user_id = str(sender.get("user_id", ""))
|
||||
# nickname = sender.get("nickname", "") or sender.get("card", "")
|
||||
|
||||
# 统计每小时消息数
|
||||
hourly_activity[hour] += 1
|
||||
|
||||
# # 统计用户活跃度
|
||||
# user_activity[user_id] = {
|
||||
# "nickname": nickname,
|
||||
# "count": user_activity.get(user_id, {}).get("count", 0) + 1
|
||||
# }
|
||||
|
||||
# 统计每小时表情数
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") in ["face", "mface", "bface", "sface"]:
|
||||
emoji_activity[hour] += 1
|
||||
elif content.get("type") == "image":
|
||||
data = content.get("data", {})
|
||||
summary = data.get("summary", "")
|
||||
if "动画表情" in summary or "表情" in summary:
|
||||
emoji_activity[hour] += 1
|
||||
|
||||
# 生成用户活跃度排行
|
||||
user_ranking = []
|
||||
for user_id, data in user_activity.items():
|
||||
user_ranking.append({
|
||||
"user_id": user_id,
|
||||
"nickname": data["nickname"],
|
||||
"message_count": data["count"]
|
||||
})
|
||||
user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
|
||||
# 找出高峰时段(活跃度最高的3个小时)
|
||||
peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
peak_hours = [{"hour": hour, "count": count} for hour, count in peak_hours]
|
||||
|
||||
return ActivityVisualization(
|
||||
hourly_activity=dict(hourly_activity),
|
||||
daily_activity={}, # 不使用日期分析
|
||||
user_activity_ranking=user_ranking[:10], # 前10名
|
||||
peak_hours=peak_hours,
|
||||
activity_heatmap_data=self._generate_hourly_heatmap_data(hourly_activity, emoji_activity)
|
||||
)
|
||||
|
||||
def _generate_hourly_heatmap_data(self, hourly_activity: dict, emoji_activity: dict) -> dict:
|
||||
"""生成小时级热力图数据"""
|
||||
# 计算活跃度等级
|
||||
max_hourly = max(hourly_activity.values()) if hourly_activity else 1
|
||||
max_emoji = max(emoji_activity.values()) if emoji_activity else 1
|
||||
|
||||
return {
|
||||
"hourly_max": max_hourly,
|
||||
"emoji_max": max_emoji,
|
||||
"hourly_normalized": {
|
||||
hour: (count / max_hourly) * 100
|
||||
for hour, count in hourly_activity.items()
|
||||
},
|
||||
"emoji_normalized": {
|
||||
hour: (emoji_activity.get(hour, 0) / max_emoji) * 100
|
||||
for hour in range(24)
|
||||
},
|
||||
"activity_levels": self._calculate_activity_levels(hourly_activity)
|
||||
}
|
||||
|
||||
def _calculate_activity_levels(self, hourly_activity: dict) -> dict:
|
||||
"""计算活跃度等级"""
|
||||
if not hourly_activity:
|
||||
return {}
|
||||
|
||||
max_count = max(hourly_activity.values())
|
||||
levels = {}
|
||||
|
||||
for hour in range(24):
|
||||
count = hourly_activity.get(hour, 0)
|
||||
if count == 0:
|
||||
level = "inactive"
|
||||
elif count <= max_count * 0.3:
|
||||
level = "low"
|
||||
elif count <= max_count * 0.7:
|
||||
level = "medium"
|
||||
else:
|
||||
level = "high"
|
||||
levels[hour] = level
|
||||
|
||||
return levels
|
||||
|
||||
def generate_hourly_chart_html(self, hourly_activity: dict) -> str:
|
||||
"""生成每小时活动分布的HTML图表"""
|
||||
html_parts = []
|
||||
max_activity = max(hourly_activity.values()) if hourly_activity else 1
|
||||
threshold_percentage = 20 # 数值显示阈值
|
||||
|
||||
for hour in range(24):
|
||||
count = hourly_activity.get(hour, 0)
|
||||
percentage = (count / max_activity) * 100 if max_activity > 0 else 0
|
||||
|
||||
if count > 0 and percentage >= threshold_percentage:
|
||||
# 活动数较多,数值显示在条形图内部
|
||||
html_segment = f"""
|
||||
<div class="hour-bar-container">
|
||||
<span class="hour-label">{hour:02d}:00</span>
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar" style="width: {percentage}%;">
|
||||
<span class="hourly-value-inside">({count})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
elif count > 0:
|
||||
# 活动数较少,数值显示在条形图外部
|
||||
html_segment = f"""
|
||||
<div class="hour-bar-container">
|
||||
<span class="hour-label">{hour:02d}:00</span>
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar" style="width: {percentage}%;"></div>
|
||||
<span class="hourly-value-outside">({count})</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
# 无活动
|
||||
html_segment = f"""
|
||||
<div class="hour-bar-container">
|
||||
<span class="hour-label">{hour:02d}:00</span>
|
||||
<div class="bar-wrapper">
|
||||
<span class="hourly-value-outside">({count})</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
html_parts.append(html_segment)
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user