libs.commands.graph.summary

libs/commands/graph/summary.py

  1"""
  2libs/commands/graph/summary.py
  3"""
  4
  5import logging
  6from typing import TYPE_CHECKING, Literal, Optional, TypedDict, cast
  7
  8import matplotlib.pyplot as plt
  9import pandas as pd
 10import plotly.express as px  # type: ignore
 11
 12import libs.global_value as g
 13from libs.data import loader
 14from libs.datamodels import GameInfo
 15from libs.functions import compose, message
 16from libs.types import StyleOptions
 17from libs.utils import formatter, graphutil, textutil
 18
 19if TYPE_CHECKING:
 20    from pathlib import Path
 21
 22    from integrations.protocols import MessageParserProtocol
 23
 24
 25class GraphParams(TypedDict, total=False):
 26    """グラフ生成パラメータ"""
 27
 28    graph_type: Literal["point", "rank", "point_hbar"]
 29    title_text: str
 30    xlabel_text: Optional[str]
 31    ylabel_text: Optional[str]
 32    total_game_count: int
 33    target_data: pd.DataFrame
 34    pivot: pd.DataFrame
 35    horizontal: bool  # 横棒切替許可フラグ
 36    save_file: str
 37
 38
 39def point_plot(m: "MessageParserProtocol"):
 40    """ポイント推移グラフを生成する
 41
 42    Args:
 43        m (MessageParserProtocol): メッセージデータ
 44    """
 45
 46    # データ収集
 47    game_info = GameInfo()
 48    target_data, df = _data_collection()
 49
 50    if target_data.empty:  # 描写対象が0人の場合は終了
 51        m.post.headline = {"0": message.random_reply(m, "no_hits")}
 52        m.status.result = False
 53        return
 54
 55    # 集計
 56    if g.params.get("search_word"):
 57        pivot_index = "comment"
 58    else:
 59        pivot_index = "playtime"
 60
 61    pivot = pd.pivot_table(
 62        df, index=pivot_index, columns="name", values="point_sum"
 63    ).ffill()
 64    pivot = pivot.reindex(  # 並び替え
 65        target_data["name"].to_list(), axis="columns"
 66    )
 67
 68    # グラフ生成
 69    graphutil.setup()
 70    graph_params = GraphParams(
 71        graph_type="point",
 72        total_game_count=game_info.count,
 73        target_data=target_data,
 74        pivot=pivot,
 75        horizontal=True,
 76    )
 77
 78    match g.adapter.conf.plotting_backend:
 79        case "plotly":
 80            graph_params.update({"save_file": "graph.html"})
 81            save_file = _graph_generation_plotly(graph_params)
 82        case _:
 83            graph_params.update({"save_file": "graph.png"})
 84            save_file = _graph_generation(graph_params)
 85
 86    file_title = graph_params.get("title_text", "").split()[0]
 87    m.post.headline = {f"{file_title}グラフ": message.header(game_info, m)}
 88    m.set_data(
 89        file_title, save_file,
 90        StyleOptions(use_comment=True, header_hidden=True, key_title=False),
 91    )
 92
 93
 94def rank_plot(m: "MessageParserProtocol"):
 95    """順位変動グラフを生成する
 96
 97    Args:
 98        m (MessageParserProtocol): メッセージデータ
 99    """
100
101    # データ収集
102    game_info = GameInfo()
103    target_data, df = _data_collection()
104
105    if target_data.empty:  # 描写対象が0人の場合は終了
106        m.post.headline = {"0": message.random_reply(m, "no_hits")}
107        m.status.result = False
108        return
109
110    if g.params.get("search_word"):
111        pivot_index = "comment"
112    else:
113        pivot_index = "playtime"
114
115    # 集計
116    pivot = pd.pivot_table(
117        df, index=pivot_index, columns="name", values="point_sum"
118    ).ffill()
119    pivot = pivot.reindex(  # 並び替え
120        target_data["name"].to_list(), axis="columns"
121    )
122    pivot = pivot.rank(method="dense", ascending=False, axis=1)
123
124    # グラフ生成
125    graphutil.setup()
126    graph_params = GraphParams(
127        graph_type="rank",
128        total_game_count=game_info.count,
129        target_data=target_data,
130        pivot=pivot,
131        horizontal=False,
132    )
133
134    match g.adapter.conf.plotting_backend:
135        case "plotly":
136            graph_params.update({"save_file": "graph.html"})
137            save_file = _graph_generation_plotly(graph_params)
138        case _:
139            graph_params.update({"save_file": "graph.png"})
140            save_file = _graph_generation(graph_params)
141
142    file_title = graph_params.get("title_text", "").split()[0]
143    m.post.headline = {f"{file_title}グラフ": message.header(game_info, m)}
144    m.set_data(
145        file_title, save_file,
146        StyleOptions(use_comment=True, header_hidden=True, key_title=False),
147    )
148
149
150def _data_collection() -> tuple[pd.DataFrame, pd.DataFrame]:
151    """データ収集
152
153    Returns:
154        tuple[pd.DataFrame, pd.DataFrame]:
155        - pd.DataFrame: 収集したデータのサマリ
156        - pd.DataFrame: 集計範囲のデータ
157    """
158
159    # データ収集
160    g.params.update(fourfold=True)  # 直近Nは4倍する(縦持ちなので4人分)
161
162    target_data = pd.DataFrame()
163    if g.params.get("individual"):  # 個人集計
164        df = loader.read_data("SUMMARY_GAMEDATA")
165        if df.empty:
166            return (target_data, df)
167
168        target_data["name"] = df.groupby("name", as_index=False).last()["name"]
169        target_data["last_point"] = df.groupby("name", as_index=False).last()["point_sum"]
170        target_data["game_count"] = df.groupby("name", as_index=False).max(numeric_only=True)["count"]
171
172        # 足切り
173        target_list = list(
174            target_data.query("game_count >= @g.params['stipulated']")["name"]
175        )
176        _ = target_list  # ignore PEP8 F841
177        target_data = target_data.query("name == @target_list").copy()
178        df = df.query("name == @target_list").copy()
179    else:  # チーム集計
180        df = loader.read_data("SUMMARY_GAMEDATA")
181        if df.empty:
182            return (target_data, df)
183
184        target_data["last_point"] = df.groupby("name").last()["point_sum"]
185        target_data["game_count"] = (
186            df.groupby("name").max(numeric_only=True)["count"]
187        )
188        target_data["name"] = target_data.index
189        target_data = target_data.sort_values("last_point", ascending=False)
190
191    # 順位付け
192    target_data["position"] = target_data["last_point"].rank(ascending=False).astype(int)
193
194    if g.params.get("anonymous"):
195        mapping_dict = formatter.anonymous_mapping(df["name"].unique().tolist())
196        df["name"] = df["name"].replace(mapping_dict)
197        target_data["name"] = target_data["name"].replace(mapping_dict)
198
199    # 凡例用文字列生成
200    target_data["legend"] = target_data.apply(
201        lambda x: f"{x["position"]}位: {x["name"]} ({x["last_point"]:+.1f}pt / {x["game_count"]:.0f}G)".replace("-", "▲"), axis=1
202    )
203
204    return (target_data.sort_values("position"), df)
205
206
207def _graph_generation(graph_params: GraphParams) -> "Path":
208    """グラフ生成共通処理(matplotlib用)
209
210    Args:
211        args (GraphParams): グラフ生成パラメータ
212
213    Returns:
214        Path: 保存先ファイル名
215    """
216
217    save_file = textutil.save_file_path(graph_params["save_file"])
218    target_data = graph_params["target_data"]
219    df = graph_params["pivot"]
220
221    if (all(df.count() == 1) or g.params["collection"] == "all") and graph_params["horizontal"]:
222        graph_params["graph_type"] = "point_hbar"
223        color: list = []
224        for _, v in target_data.iterrows():
225            if v["last_point"] > 0:
226                color.append("deepskyblue")
227            else:
228                color.append("orangered")
229
230        _graph_title(graph_params)
231        tmpdf = pd.DataFrame(
232            {"point": target_data["last_point"].to_list()[::-1]},
233            index=target_data["legend"].to_list()[::-1],
234        )
235
236        tmpdf.plot.barh(
237            figsize=(8, 2 + tmpdf.count().iloc[0] / 5),
238            y="point",
239            xlabel=graph_params["xlabel_text"],
240            color=color[::-1],
241        ).get_figure()
242
243        plt.legend().remove()
244        plt.gca().yaxis.tick_right()
245
246        # X軸修正
247        xlocs, xlabs = plt.xticks()
248        new_xlabs = [xlab.get_text().replace("−", "▲") for xlab in xlabs]
249        plt.xticks(list(xlocs[1:-1]), new_xlabs[1:-1])
250
251        logging.debug("plot data:\n%s", tmpdf)
252    else:
253        _graph_title(graph_params)
254        df.plot(
255            figsize=(8, 6),
256            xlabel=str(graph_params["xlabel_text"]),
257            ylabel=str(graph_params["ylabel_text"]),
258            marker="." if len(df) < 20 else None,
259            linewidth=2 if len(df) < 40 else 1,
260        ).get_figure()
261
262        # 凡例
263        plt.legend(
264            target_data["legend"].to_list(),
265            bbox_to_anchor=(1, 1),
266            loc="upper left",
267            borderaxespad=0.5,
268            ncol=int(len(target_data) / 25 + 1),
269        )
270
271        # X軸修正
272        plt.xticks(**graphutil.xticks_parameter(df.index.to_list()))
273
274        # Y軸修正
275        ylocs, ylabs = plt.yticks()
276        new_ylabs = [ylab.get_text().replace("−", "▲") for ylab in ylabs]
277        plt.yticks(list(ylocs[1:-1]), new_ylabs[1:-1])
278        logging.debug("plot data:\n%s", df)
279
280    # メモリ調整
281    match graph_params["graph_type"]:
282        case "point_hbar":
283            plt.axvline(x=0, linewidth=0.5, ls="dashed", color="grey")
284        case "point":
285            plt.axhline(y=0, linewidth=0.5, ls="dashed", color="grey")
286        case "rank":
287            lab = range(len(target_data) + 1)
288            if len(lab) > 10:
289                plt.yticks(list(map(int, lab))[1::2], list(map(str, lab))[1::2])
290            else:
291                plt.yticks(list(map(int, lab))[1:], list(map(str, lab))[1:])
292            plt.gca().invert_yaxis()
293
294    plt.title(
295        graph_params["title_text"],
296        fontsize=16,
297    )
298
299    plt.savefig(save_file, bbox_inches="tight")
300    return save_file
301
302
303def _graph_generation_plotly(graph_params: GraphParams) -> "Path":
304    """グラフ生成共通処理(plotly用)
305
306    Args:
307        args (GraphParams): グラフ生成パラメータ
308
309    Returns:
310        Path: 保存先ファイル名
311    """
312
313    save_file = textutil.save_file_path(graph_params["save_file"])
314    target_data = cast(pd.DataFrame, graph_params["target_data"])
315    df = graph_params["pivot"]
316
317    if (all(df.count() == 1) or g.params["collection"] == "all") and graph_params["horizontal"]:
318        graph_params["graph_type"] = "point_hbar"
319        df_t = df.T
320        df_t.columns = ["point"]
321        df_t["rank"] = df_t["point"].rank(ascending=False, method="dense").astype("int")
322        df_t["positive"] = df_t["point"] > 0
323        fig = px.bar(
324            df_t,
325            orientation="h",
326            color="positive",
327            color_discrete_map={True: "blue", False: "red"},
328            x=df_t["point"],
329            y=target_data["legend"],
330        )
331    else:
332        df.columns = target_data["legend"].to_list()  # 凡例用ラベル生成
333        fig = px.line(df, markers=True)
334
335    # グラフレイアウト調整
336    _graph_title(graph_params)
337    fig.update_layout(
338        width=1280,
339        height=800,
340        title={
341            "text": graph_params["title_text"],
342            "font": {"size": 30},
343            "xref": "paper",
344            "xanchor": "center",
345            "x": 0.5,
346        },
347        xaxis_title={
348            "text": graph_params["xlabel_text"],
349            "font": {"size": 18},
350        },
351        yaxis_title={
352            "text": graph_params["ylabel_text"],
353            "font": {"size": 18},
354        },
355        legend_title=None,
356    )
357
358    # 軸/目盛調整
359    match graph_params["graph_type"]:
360        case "point_hbar":
361            fig.update_traces(hovertemplate="%{y}<extra></extra>")
362            fig.update_layout(showlegend=False)
363            fig.update_yaxes(
364                autorange="reversed",
365                side="right",
366                title=None,
367            )
368        case "point":
369            # マーカー
370            if all(df.count() > 20):
371                fig.update_traces(mode="lines")
372            # ライン
373            if len(fig.data) > 40:
374                fig.update_traces(mode="lines", line={"width": 1})
375        case "rank":
376            # Y軸目盛
377            lab = list(range(len(target_data) + 1))
378            fig.update_yaxes(
379                autorange="reversed",
380                zeroline=False,
381                tickvals=lab[1:] if len(lab) < 10 else lab[1::2],
382            )
383            # マーカー
384            if all(df.count() == 1):
385                fig.update_traces(marker={"size": 10})
386            elif all(df.count() > 20):
387                fig.update_traces(mode="lines")
388            # ライン
389            if len(fig.data) > 40:
390                fig.update_traces(mode="lines", line={"width": 1})
391
392    fig.write_html(save_file, full_html=False)
393    return save_file
394
395
396def _graph_title(graph_params: GraphParams):
397    """グラフタイトル生成
398
399    Args:
400        args (GraphParams): グラフ生成パラメータ
401    """
402
403    if g.params.get("target_count"):
404        kind = "ymd_o"
405        graph_params.update({"xlabel_text": f"集計日({graph_params["total_game_count"]} ゲーム)"})
406        match graph_params.get("graph_type"):
407            case "point":
408                graph_params.update({"title_text": f"ポイント推移 (直近 {g.params["target_count"]} ゲーム)"})
409            case "rank":
410                graph_params.update({"title_text": f"順位変動 (直近 {g.params["target_count"]} ゲーム)"})
411            case "point_hbar":
412                graph_params.update({"title_text": f"通算ポイント (直近 {g.params["target_count"]} ゲーム)"})
413    else:
414        match g.params.get("collection"):
415            case "daily":
416                kind = "ymd_o"
417                graph_params.update({"xlabel_text": f"集計日({graph_params["total_game_count"]} ゲーム)"})
418            case "monthly":
419                kind = "jym_o"
420                graph_params.update({"xlabel_text": f"集計月({graph_params["total_game_count"]} ゲーム)"})
421            case "yearly":
422                kind = "jy_o"
423                graph_params.update({"xlabel_text": f"集計年({graph_params["total_game_count"]} ゲーム)"})
424            case "all":
425                kind = "ymdhm"
426                graph_params.update({"xlabel_text": f"ゲーム数:{graph_params["total_game_count"]} ゲーム"})
427            case _:
428                kind = "ymdhm"
429                if g.params.get("search_word"):
430                    graph_params.update({"xlabel_text": f"ゲーム数:{graph_params["total_game_count"]} ゲーム"})
431                else:
432                    graph_params.update({"xlabel_text": f"ゲーム終了日時({graph_params["total_game_count"]} ゲーム)"})
433
434    match graph_params.get("graph_type", "point"):
435        case "point":
436            graph_params.update({
437                "ylabel_text": "通算ポイント",
438                "title_text": compose.text_item.date_range(kind, "通算ポイント", "ポイント推移"),
439            })
440        case "rank":
441            graph_params.update({
442                "ylabel_text": "順位 (通算ポイント順)",
443                "title_text": compose.text_item.date_range(kind, "順位", "順位変動"),
444            })
445        case "point_hbar":
446            graph_params.update({
447                "ylabel_text": None,
448                "title_text": compose.text_item.date_range(kind, "通算ポイント", "通算ポイント"),
449            })
450            if graph_params["total_game_count"] == 1:
451                graph_params.update({"xlabel_text": "ポイント"})
452            else:
453                graph_params.update({"xlabel_text": f"ポイント(ゲーム数:{graph_params["total_game_count"]} ゲーム)"})
class GraphParams(typing.TypedDict):
26class GraphParams(TypedDict, total=False):
27    """グラフ生成パラメータ"""
28
29    graph_type: Literal["point", "rank", "point_hbar"]
30    title_text: str
31    xlabel_text: Optional[str]
32    ylabel_text: Optional[str]
33    total_game_count: int
34    target_data: pd.DataFrame
35    pivot: pd.DataFrame
36    horizontal: bool  # 横棒切替許可フラグ
37    save_file: str

グラフ生成パラメータ

graph_type: Literal['point', 'rank', 'point_hbar']
title_text: str
xlabel_text: Optional[str]
ylabel_text: Optional[str]
total_game_count: int
target_data: pandas.core.frame.DataFrame
pivot: pandas.core.frame.DataFrame
horizontal: bool
save_file: str
def point_plot(m: integrations.protocols.MessageParserProtocol):
40def point_plot(m: "MessageParserProtocol"):
41    """ポイント推移グラフを生成する
42
43    Args:
44        m (MessageParserProtocol): メッセージデータ
45    """
46
47    # データ収集
48    game_info = GameInfo()
49    target_data, df = _data_collection()
50
51    if target_data.empty:  # 描写対象が0人の場合は終了
52        m.post.headline = {"0": message.random_reply(m, "no_hits")}
53        m.status.result = False
54        return
55
56    # 集計
57    if g.params.get("search_word"):
58        pivot_index = "comment"
59    else:
60        pivot_index = "playtime"
61
62    pivot = pd.pivot_table(
63        df, index=pivot_index, columns="name", values="point_sum"
64    ).ffill()
65    pivot = pivot.reindex(  # 並び替え
66        target_data["name"].to_list(), axis="columns"
67    )
68
69    # グラフ生成
70    graphutil.setup()
71    graph_params = GraphParams(
72        graph_type="point",
73        total_game_count=game_info.count,
74        target_data=target_data,
75        pivot=pivot,
76        horizontal=True,
77    )
78
79    match g.adapter.conf.plotting_backend:
80        case "plotly":
81            graph_params.update({"save_file": "graph.html"})
82            save_file = _graph_generation_plotly(graph_params)
83        case _:
84            graph_params.update({"save_file": "graph.png"})
85            save_file = _graph_generation(graph_params)
86
87    file_title = graph_params.get("title_text", "").split()[0]
88    m.post.headline = {f"{file_title}グラフ": message.header(game_info, m)}
89    m.set_data(
90        file_title, save_file,
91        StyleOptions(use_comment=True, header_hidden=True, key_title=False),
92    )

ポイント推移グラフを生成する

Arguments:
  • m (MessageParserProtocol): メッセージデータ
def rank_plot(m: integrations.protocols.MessageParserProtocol):
 95def rank_plot(m: "MessageParserProtocol"):
 96    """順位変動グラフを生成する
 97
 98    Args:
 99        m (MessageParserProtocol): メッセージデータ
100    """
101
102    # データ収集
103    game_info = GameInfo()
104    target_data, df = _data_collection()
105
106    if target_data.empty:  # 描写対象が0人の場合は終了
107        m.post.headline = {"0": message.random_reply(m, "no_hits")}
108        m.status.result = False
109        return
110
111    if g.params.get("search_word"):
112        pivot_index = "comment"
113    else:
114        pivot_index = "playtime"
115
116    # 集計
117    pivot = pd.pivot_table(
118        df, index=pivot_index, columns="name", values="point_sum"
119    ).ffill()
120    pivot = pivot.reindex(  # 並び替え
121        target_data["name"].to_list(), axis="columns"
122    )
123    pivot = pivot.rank(method="dense", ascending=False, axis=1)
124
125    # グラフ生成
126    graphutil.setup()
127    graph_params = GraphParams(
128        graph_type="rank",
129        total_game_count=game_info.count,
130        target_data=target_data,
131        pivot=pivot,
132        horizontal=False,
133    )
134
135    match g.adapter.conf.plotting_backend:
136        case "plotly":
137            graph_params.update({"save_file": "graph.html"})
138            save_file = _graph_generation_plotly(graph_params)
139        case _:
140            graph_params.update({"save_file": "graph.png"})
141            save_file = _graph_generation(graph_params)
142
143    file_title = graph_params.get("title_text", "").split()[0]
144    m.post.headline = {f"{file_title}グラフ": message.header(game_info, m)}
145    m.set_data(
146        file_title, save_file,
147        StyleOptions(use_comment=True, header_hidden=True, key_title=False),
148    )

順位変動グラフを生成する

Arguments:
  • m (MessageParserProtocol): メッセージデータ