diff --git a/.codex/config.toml b/.codex/config.toml index 42252341..e69de29b 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,34 +0,0 @@ -sandbox_mode = "workspace-write" -default_permissions = "workspace_no_env" - -[permissions.workspace_no_env.filesystem] -":minimal" = "read" -glob_scan_max_depth = 5 - -[permissions.workspace_no_env.filesystem.":workspace_roots"] -"." = "write" - -# repo直下 -".env" = "deny" -".env.*" = "deny" - -# サブディレクトリ配下 -"**/.env" = "deny" -"**/.env.*" = "deny" -"**/*.env" = "deny" - -[permissions.workspace_no_env.network] -enabled = true - -[permissions.workspace_no_env.network.domains] -"kurusugawa.jp" = "allow" -"api.openai.com" = "allow" -"registry.npmjs.org" = "allow" -"pypi.org" = "allow" -"files.pythonhosted.org" = "allow" -"*.github.com" = "allow" -"objects.githubusercontent.com" = "allow" - -[shell_environment_policy] -inherit = "core" -set = { UV_CACHE_DIR = "/tmp/uv-cache" } diff --git a/annofabcli/statistics/scatter.py b/annofabcli/statistics/scatter.py index 48f0dcdc..4769b476 100644 --- a/annofabcli/statistics/scatter.py +++ b/annofabcli/statistics/scatter.py @@ -88,6 +88,8 @@ def __init__( self.text_glyphs: dict[str, GlyphRenderer] = {} """key:user_id, value: 散布図に表示している名前のGlyph""" + self._plotted_users: dict[str, str] = {} + """key:user_id, value: 散布図に表示しているユーザー名""" self._scatter_glyphs: dict[str, GlyphRenderer] = {} """key:biography, value: 散布図に表示している円形のGlyph""" @@ -148,6 +150,7 @@ def plot_scatter( # 1点ごとに`text`で名前を表示している理由: # `add_multi_choice_widget_for_searching_user`関数で追加したMultiChoice Widgetで、名前の表示スタイルを変更するため for x, y, username, user_id in zip(source.data[x_column_name], source.data[y_column_name], source.data[username_column_name], source.data[user_id_column_name], strict=False): + self._plotted_users[user_id] = username self.text_glyphs[user_id] = self.figure.text( x=x, y=y, @@ -202,6 +205,7 @@ def plot_bubble( # 1点ごとに`text`で名前を表示している理由: # `add_multi_choice_widget_for_searching_user`関数で追加したMultiChoice Widgetで、名前の表示スタイルを変更するため for x, y, username, user_id in zip(source.data[x_column_name], source.data[y_column_name], source.data[username_column_name], source.data[user_id_column_name], strict=False): + self._plotted_users[user_id] = username self.text_glyphs[user_id] = self.figure.text( x=x, y=y, @@ -231,6 +235,10 @@ def process_after_adding_glyphs(self) -> None: if self._hover_tool is not None and self._scatter_glyphs is not None: self._hover_tool.renderers = list(self._scatter_glyphs.values()) + def get_plotted_users(self) -> list[tuple[str, str]]: + """散布図に表示しているユーザーのリストを返します。""" + return list(self._plotted_users.items()) + def configure_legend(self) -> None: """ 凡例を設定します。 diff --git a/annofabcli/statistics/visualization/dataframe/user_performance.py b/annofabcli/statistics/visualization/dataframe/user_performance.py index 63f8e3a4..78e99989 100644 --- a/annofabcli/statistics/visualization/dataframe/user_performance.py +++ b/annofabcli/statistics/visualization/dataframe/user_performance.py @@ -967,7 +967,7 @@ def _create_productivity_element_list( quartile = self._get_quartile_value(df[(f"{worktime_type.value}_worktime_minute/{production_volume_column}", phase)]) scatter_obj.plot_quartile_line(quartile, dimension="width") - scatter_obj.add_multi_choice_widget_for_searching_user(list(zip(df[("user_id", "")], df[("username", "")], strict=False))) + scatter_obj.add_multi_choice_widget_for_searching_user(scatter_obj.get_plotted_users()) scatter_obj.process_after_adding_glyphs() div_element = self._create_div_element() @@ -1144,7 +1144,7 @@ def create_scatter_obj(title: str, x_axis_label: str, y_axis_label: str) -> Scat scatter_obj.plot_quartile_line(quartile, dimension="width") for scatter_obj in scatter_obj_list: - scatter_obj.add_multi_choice_widget_for_searching_user(list(zip(df[("user_id", "")], df[("username", "")], strict=False))) + scatter_obj.add_multi_choice_widget_for_searching_user(scatter_obj.get_plotted_users()) scatter_obj.process_after_adding_glyphs() div_element = self._create_div_element() @@ -1280,7 +1280,7 @@ def plot_average_and_quartile_line() -> None: plot_average_and_quartile_line() for scatter_obj in scatter_obj_list: - scatter_obj.add_multi_choice_widget_for_searching_user(list(zip(df[("user_id", "")], df[("username", "")], strict=False))) + scatter_obj.add_multi_choice_widget_for_searching_user(scatter_obj.get_plotted_users()) scatter_obj.process_after_adding_glyphs() div_element = self._create_div_element() diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 85a61255..35daa3d4 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -6,15 +6,15 @@ from __future__ import annotations import logging -from dataclasses import dataclass from pathlib import Path from typing import Any, assert_never import bokeh.layouts import pandas from annofabapi.models import TaskPhase, TaskStatus -from bokeh.models import DataRange1d +from bokeh.models import CustomJS from bokeh.models.ui import UIElement +from bokeh.models.widgets.inputs import Select from bokeh.models.widgets.markups import Div from bokeh.plotting import ColumnDataSource from dateutil.parser import parse @@ -292,7 +292,7 @@ def _create_div_element(self) -> Div: elm_list.append("

タスクが検査フェーズに到達したら作業が完了したとみなしているため、検査作業時間と受入作業時間は0にしています。

") return Div(text=" ".join(elm_list)) - def plot( + def plot( # noqa: PLR0915 self, output_file: Path, *, @@ -319,8 +319,7 @@ def add_velocity_columns(df: pandas.DataFrame) -> None: df[f"monitored_worktime_hour/task_count{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}"] = get_weekly_sum(df["monitored_worktime_hour"]) / get_weekly_sum(df["task_count"]) for column in [ - "task_count", - "input_data_count", + *[e.value for e in production_volume_list], "actual_worktime_hour", "monitored_worktime_hour", "monitored_annotation_worktime_hour", @@ -341,96 +340,193 @@ def create_line_graph(title: str, y_axis_label: str, tooltip_columns: list[str]) tooltip_columns=tooltip_columns, ) - def create_task_line_graph() -> LineGraph: + def get_production_volume_name(production_volume: ProductionVolumeColumn) -> str: + return f"{production_volume.name}数" if production_volume.value in ["task_count", "input_data_count", "annotation_count"] else production_volume.name + + def create_production_volume_line_graph(production_volume_list: list[ProductionVolumeColumn]) -> tuple[LineGraph, Select]: + default_production_volume = production_volume_list[0] + default_production_volume_name = get_production_volume_name(default_production_volume) + tooltip_columns = [ + "date", + "actual_worktime_hour", + "monitored_worktime_hour", + *[production_volume.value for production_volume in production_volume_list], + ] line_graph = create_line_graph( - title="日ごとのタスク数と作業時間", - y_axis_label="タスク数", - tooltip_columns=["date", "task_count", "actual_worktime_hour", "monitored_worktime_hour", "working_user_count"], - ) - line_graph.add_secondary_y_axis( - "作業時間[時間]", - secondary_y_axis_range=DataRange1d(end=max(df["actual_worktime_hour"].max(), df["monitored_worktime_hour"].max()) * SECONDARY_Y_RANGE_RATIO), - primary_y_axis_range=DataRange1d(end=df["task_count"].max() * SECONDARY_Y_RANGE_RATIO), + title=f"日ごとの{default_production_volume_name}", + y_axis_label=default_production_volume_name, + tooltip_columns=tooltip_columns, ) - plot_index = 0 - _plot_and_moving_average( - line_graph, - source=source, + line_renderer, marker_renderer = line_graph.add_line( x_column="dt_date", - y_column="task_count", - legend_name="タスク数", - color=get_color_from_small_palette(plot_index), - ) - - plot_index += 1 - _plot_and_moving_average( - line_graph, + y_column=default_production_volume.value, source=source, - x_column="dt_date", - y_column="actual_worktime_hour", - legend_name="実績作業時間", - color=get_color_from_small_palette(plot_index), - is_secondary_y_axis=True, + color=get_color_from_small_palette(0), + legend_label=default_production_volume_name, ) - - plot_index += 1 - _plot_and_moving_average( - line_graph, + moving_average_renderer = line_graph.add_moving_average_line( source=source, x_column="dt_date", - y_column="monitored_worktime_hour", - legend_name="計測作業時間", - color=get_color_from_small_palette(plot_index), - is_secondary_y_axis=True, + y_column=f"{default_production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}", + color=get_color_from_small_palette(0), + legend_label=f"{default_production_volume_name}の1週間移動平均", ) - return line_graph - def create_input_data_line_graph() -> LineGraph: - line_graph = create_line_graph( - title="日ごとの入力データ数と作業時間", - y_axis_label="入力データ数", - tooltip_columns=["date", "input_data_count", "actual_worktime_hour", "monitored_worktime_hour", "working_user_count"], + production_volume_by_value = { + production_volume.value: { + "movingAverageColumn": f"{production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}", + "name": get_production_volume_name(production_volume), + "title": f"日ごとの{get_production_volume_name(production_volume)}", + "valueColumn": production_volume.value, + } + for production_volume in production_volume_list + } + select_options: list[str | tuple[Any, str]] = [(production_volume.value, get_production_volume_name(production_volume)) for production_volume in production_volume_list] + select = Select( + title="生産量種別:", + value=default_production_volume.value, + options=select_options, + width=300, ) - line_graph.add_secondary_y_axis( - "作業時間[時間]", - secondary_y_axis_range=DataRange1d(end=max(df["actual_worktime_hour"].max(), df["monitored_worktime_hour"].max()) * SECONDARY_Y_RANGE_RATIO), - primary_y_axis_range=DataRange1d(end=df["input_data_count"].max() * SECONDARY_Y_RANGE_RATIO), + select.js_on_change( + "value", + CustomJS( + args={ + "figure": line_graph.figure, + "figureTitle": line_graph.figure.title, + "legendItems": line_graph.figure.legend[0].items, + "lineRenderer": line_renderer, + "markerRenderer": marker_renderer, + "movingAverageRenderer": moving_average_renderer, + "productionVolumeByValue": production_volume_by_value, + "yAxis": line_graph.figure.yaxis[0], + "yRange": line_graph.figure.y_range, + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (const renderer of [lineRenderer, markerRenderer]) { + renderer.glyph.y.field = selected.valueColumn; + renderer.glyph.change.emit(); + renderer.change.emit(); + } + movingAverageRenderer.glyph.y.field = selected.movingAverageColumn; + movingAverageRenderer.glyph.change.emit(); + movingAverageRenderer.change.emit(); + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = selected.name; + yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); + legendItems[0].label.value = selected.name; + legendItems[0].change.emit(); + legendItems[1].label.value = `${selected.name}の1週間移動平均`; + legendItems[1].change.emit(); + """, + ), ) + return line_graph, select - plot_index = 0 - _plot_and_moving_average( - line_graph, - source=source, - x_column="dt_date", - y_column="input_data_count", - legend_name="入力データ数", - color=get_color_from_small_palette(plot_index), + def create_productivity_line_graph(production_volume_list: list[ProductionVolumeColumn], phase_prefix: list[tuple[str, str]]) -> tuple[LineGraph, Select]: + default_production_volume = production_volume_list[0] + default_production_volume_name = get_production_volume_name(default_production_volume) + tooltip_columns = [ + "date", + "actual_worktime_hour", + "monitored_worktime_hour", + *[production_volume.value for production_volume in production_volume_list], + *[f"{prefix}_minute/{production_volume.value}" for production_volume in production_volume_list for prefix, _ in phase_prefix], + ] + line_graph = create_line_graph( + title=f"日ごとの{default_production_volume_name}あたり作業時間", + y_axis_label=f"{default_production_volume_name}あたり作業時間[分/{default_production_volume_name}]", + tooltip_columns=tooltip_columns, ) - plot_index += 1 - _plot_and_moving_average( - line_graph, - source=source, - x_column="dt_date", - y_column="actual_worktime_hour", - legend_name="実績作業時間", - color=get_color_from_small_palette(plot_index), - is_secondary_y_axis=True, + line_renderers = [] + marker_renderers = [] + moving_average_renderers = [] + for plot_index, (prefix, phase_name) in enumerate(phase_prefix): + color = get_color_from_small_palette(plot_index) + line_renderer, marker_renderer = line_graph.add_line( + source=source, + x_column="dt_date", + y_column=f"{prefix}_minute/{default_production_volume.value}", + color=color, + legend_label=f"{default_production_volume_name}あたり{phase_name}", + ) + moving_average_renderer = line_graph.add_moving_average_line( + source=source, + x_column="dt_date", + y_column=f"{prefix}_minute/{default_production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}", + color=color, + legend_label=f"{default_production_volume_name}あたり{phase_name}の1週間移動平均", + ) + line_renderers.append(line_renderer) + marker_renderers.append(marker_renderer) + moving_average_renderers.append(moving_average_renderer) + + production_volume_by_value = { + production_volume.value: { + "columns": [f"{prefix}_minute/{production_volume.value}" for prefix, _ in phase_prefix], + "movingAverageColumns": [f"{prefix}_minute/{production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}" for prefix, _ in phase_prefix], + "name": get_production_volume_name(production_volume), + "title": f"日ごとの{get_production_volume_name(production_volume)}あたり作業時間", + } + for production_volume in production_volume_list + } + phase_names = [phase_name for _, phase_name in phase_prefix] + select_options: list[str | tuple[Any, str]] = [(production_volume.value, get_production_volume_name(production_volume)) for production_volume in production_volume_list] + select = Select( + title="生産量種別:", + value=default_production_volume.value, + options=select_options, + width=300, ) - - plot_index += 1 - _plot_and_moving_average( - line_graph, - source=source, - x_column="dt_date", - y_column="monitored_worktime_hour", - legend_name="計測作業時間", - color=get_color_from_small_palette(plot_index), - is_secondary_y_axis=True, + select.js_on_change( + "value", + CustomJS( + args={ + "figure": line_graph.figure, + "figureTitle": line_graph.figure.title, + "legendItems": line_graph.figure.legend[0].items, + "lineRenderers": line_renderers, + "markerRenderers": marker_renderers, + "movingAverageRenderers": moving_average_renderers, + "phaseNames": phase_names, + "productionVolumeByValue": production_volume_by_value, + "yAxis": line_graph.figure.yaxis[0], + "yRange": line_graph.figure.y_range, + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (let i = 0; i < lineRenderers.length; i++) { + for (const renderer of [lineRenderers[i], markerRenderers[i]]) { + renderer.glyph.y.field = selected.columns[i]; + renderer.glyph.change.emit(); + renderer.change.emit(); + } + movingAverageRenderers[i].glyph.y.field = selected.movingAverageColumns[i]; + movingAverageRenderers[i].glyph.change.emit(); + movingAverageRenderers[i].change.emit(); + + const legendName = `${selected.name}あたり${phaseNames[i]}`; + legendItems[i * 2].label.value = legendName; + legendItems[i * 2].change.emit(); + legendItems[i * 2 + 1].label.value = `${legendName}の1週間移動平均`; + legendItems[i * 2 + 1].change.emit(); + } + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = `${selected.name}あたり作業時間[分/${selected.name}]`; + yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); + """, + ), ) - - return line_graph + return line_graph, select if not self._validate_df_for_output(output_file): return @@ -439,6 +535,7 @@ def create_input_data_line_graph() -> LineGraph: df["dt_date"] = df["date"].map(lambda e: parse(e).date()) production_volume_list = [ + ProductionVolumeColumn("task_count", "タスク"), ProductionVolumeColumn("input_data_count", "入力データ"), ProductionVolumeColumn("annotation_count", "アノテーション"), *self.custom_production_volume_list, @@ -459,77 +556,49 @@ def create_input_data_line_graph() -> LineGraph: # 条件分岐の理由:実績作業時間がないときは、非計測作業時間がマイナス値になり、分かりづらいグラフになるため。必要なときのみ非計測作業時間をプロットする phase_prefix.append(("unmonitored_worktime", "非計測作業時間")) - fig_info_list = [ - { - "line_graph": create_line_graph( - title="日ごとの作業時間", - y_axis_label="作業時間[時間]", - tooltip_columns=[ - "date", - "actual_worktime_hour", - "monitored_worktime_hour", - "monitored_annotation_worktime_hour", - "monitored_inspection_worktime_hour", - "monitored_acceptance_worktime_hour", - "working_user_count", - ], - ), - "y_info_list": [{"column": f"{e[0]}_hour", "legend": f"{e[1]}"} for e in phase_prefix], - }, - ] - - for info in production_volume_list: - fig_info_list.append( # noqa: PERF401 - { - "line_graph": create_line_graph( - title=f"日ごとの{info.name}あたり作業時間", - y_axis_label=f"{info.name}あたり作業時間[分/{info.name}]", - tooltip_columns=[ - "date", - info.value, - "actual_worktime_hour", - "monitored_worktime_hour", - "monitored_annotation_worktime_hour", - "monitored_inspection_worktime_hour", - "monitored_acceptance_worktime_hour", - f"actual_worktime_minute/{info.value}", - f"monitored_worktime_minute/{info.value}", - f"monitored_annotation_worktime_minute/{info.value}", - f"monitored_inspection_worktime_minute/{info.value}", - f"monitored_acceptance_worktime_minute/{info.value}", - ], - ), - "y_info_list": [{"column": f"{e[0]}_minute/{info.value}", "legend": f"{info.name}あたり{e[1]}"} for e in phase_prefix], - } - ) - source = ColumnDataSource(data=df) - for fig_info in fig_info_list: - y_info_list: list[dict[str, str]] = fig_info["y_info_list"] # type: ignore[assignment] - for index, y_info in enumerate(y_info_list): - color = get_color_from_small_palette(index) - line_graph: LineGraph = fig_info["line_graph"] # type: ignore[assignment] - _plot_and_moving_average( - line_graph=line_graph, - x_column="dt_date", - y_column=y_info["column"], - legend_name=y_info["legend"], - source=source, - color=color, - ) + worktime_line_graph = create_line_graph( + title="日ごとの作業時間", + y_axis_label="作業時間[時間]", + tooltip_columns=[ + "date", + "actual_worktime_hour", + "monitored_worktime_hour", + "monitored_annotation_worktime_hour", + "monitored_inspection_worktime_hour", + "monitored_acceptance_worktime_hour", + "working_user_count", + ], + ) + for index, (prefix, phase_name) in enumerate(phase_prefix): + _plot_and_moving_average( + line_graph=worktime_line_graph, + x_column="dt_date", + y_column=f"{prefix}_hour", + legend_name=phase_name, + source=source, + color=get_color_from_small_palette(index), + ) + production_volume_line_graph, production_volume_select = create_production_volume_line_graph(production_volume_list) + productivity_line_graph, productivity_select = create_productivity_line_graph(production_volume_list, phase_prefix) line_graph_list = [ - create_task_line_graph(), - create_input_data_line_graph(), + worktime_line_graph, + production_volume_line_graph, + productivity_line_graph, ] - line_graph_list.extend([info["line_graph"] for info in fig_info_list]) # type: ignore[misc] for line_graph in line_graph_list: line_graph.process_after_adding_glyphs() div_element = self._create_div_element() - element_list: list[UIElement] = [div_element] + [e.figure for e in line_graph_list] + element_list: list[UIElement] = [ + div_element, + line_graph_list[0].figure, + bokeh.layouts.row([line_graph_list[1].figure, production_volume_select]), + bokeh.layouts.row([line_graph_list[2].figure, productivity_select]), + ] if metadata is not None: element_list.insert(0, create_pretext_from_metadata(metadata)) @@ -551,52 +620,82 @@ def create_line_graph(title: str, y_axis_label: str, tooltip_columns: list[str]) tooltip_columns=tooltip_columns, ) - def create_task_line_graph() -> LineGraph: + def create_production_volume_line_graph(production_volume_list: list[ProductionVolumeColumn]) -> tuple[LineGraph, Select]: + def get_production_volume_name(production_volume: ProductionVolumeColumn) -> str: + return f"{production_volume.name}数" if production_volume.value in ["task_count", "input_data_count", "annotation_count"] else production_volume.name + + default_production_volume = production_volume_list[0] + default_production_volume_name = get_production_volume_name(default_production_volume) + tooltip_columns = [ + "date", + "actual_worktime_hour", + "monitored_worktime_hour", + *[production_volume.value for production_volume in production_volume_list], + *[f"cumsum_{production_volume.value}" for production_volume in production_volume_list], + ] + line_graph = create_line_graph( - title="日ごとの累積タスク数", - y_axis_label="タスク数", - tooltip_columns=[ - "date", - "task_count", - "working_user_count", - "cumsum_task_count", - ], + title=f"日ごとの累積{default_production_volume_name}", + y_axis_label=default_production_volume_name, + tooltip_columns=tooltip_columns, ) - # 値をプロット x_column = "dt_date" - line_graph.add_line( + line_renderer, marker_renderer = line_graph.add_line( x_column=x_column, - y_column="cumsum_task_count", + y_column=f"cumsum_{default_production_volume.value}", source=source, color=get_color_from_small_palette(0), - legend_label="タスク数", + legend_label=default_production_volume_name, ) - return line_graph - - def create_production_volume_line_graph(production_volume: ProductionVolumeColumn) -> LineGraph: - production_volume_name = f"{production_volume.name}数" if production_volume.value in ["input_data_count", "annotation_count"] else production_volume.name - line_graph = create_line_graph( - title=f"日ごとの累積{production_volume_name}", - y_axis_label=production_volume_name, - tooltip_columns=[ - "date", - production_volume.value, - "working_user_count", - f"cumsum_{production_volume.value}", - ], + production_volume_by_value = { + production_volume.value: { + "cumsumColumn": f"cumsum_{production_volume.value}", + "name": get_production_volume_name(production_volume), + "title": f"日ごとの累積{get_production_volume_name(production_volume)}", + } + for production_volume in production_volume_list + } + select_options: list[str | tuple[Any, str]] = [(production_volume.value, get_production_volume_name(production_volume)) for production_volume in production_volume_list] + select = Select( + title="生産量種別:", + value=default_production_volume.value, + options=select_options, + width=300, ) - - x_column = "dt_date" - line_graph.add_line( - x_column=x_column, - y_column=f"cumsum_{production_volume.value}", - source=source, - color=get_color_from_small_palette(0), - legend_label=production_volume_name, + select.js_on_change( + "value", + CustomJS( + args={ + "figure": line_graph.figure, + "figureTitle": line_graph.figure.title, + "legendItem": line_graph.figure.legend[0].items[0], + "lineRenderer": line_renderer, + "markerRenderer": marker_renderer, + "productionVolumeByValue": production_volume_by_value, + "yAxis": line_graph.figure.yaxis[0], + "yRange": line_graph.figure.y_range, + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (const renderer of [lineRenderer, markerRenderer]) { + renderer.glyph.y.field = selected.cumsumColumn; + renderer.glyph.change.emit(); + renderer.change.emit(); + } + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = selected.name; + yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); + legendItem.label.value = selected.name; + legendItem.change.emit(); + """, + ), ) - return line_graph + return line_graph, select def create_worktime_line_graph() -> LineGraph: line_graph = create_line_graph( @@ -678,6 +777,7 @@ def create_worktime_line_graph() -> LineGraph: df["cumsum_monitored_acceptance_worktime_hour"] = df["monitored_acceptance_worktime_hour"].cumsum() production_volume_list = [ + ProductionVolumeColumn("task_count", "タスク"), ProductionVolumeColumn("input_data_count", "入力データ"), ProductionVolumeColumn("annotation_count", "アノテーション"), *self.custom_production_volume_list, @@ -686,11 +786,11 @@ def create_worktime_line_graph() -> LineGraph: logger.debug(f"{output_file} を出力します。") source = ColumnDataSource(data=df) + production_volume_line_graph, production_volume_select = create_production_volume_line_graph(production_volume_list) line_graph_list = [ - create_task_line_graph(), - *[create_production_volume_line_graph(production_volume) for production_volume in production_volume_list], create_worktime_line_graph(), + production_volume_line_graph, ] for line_graph in line_graph_list: @@ -698,7 +798,11 @@ def create_worktime_line_graph() -> LineGraph: div_element = self._create_div_element() - element_list: list[UIElement] = [div_element] + [e.figure for e in line_graph_list] + element_list: list[UIElement] = [ + div_element, + line_graph_list[0].figure, + bokeh.layouts.row([line_graph_list[1].figure, production_volume_select]), + ] if metadata is not None: element_list.insert(0, create_pretext_from_metadata(metadata)) @@ -945,8 +1049,7 @@ def plot(self, output_file: Path, *, metadata: dict[str, Any] | None = None) -> def add_velocity_and_weekly_moving_average_columns(df: pandas.DataFrame) -> None: for column in [ - "task_count", - "input_data_count", + *[e.value for e in production_volume_list], "worktime_hour", "annotation_worktime_hour", "inspection_worktime_hour", @@ -993,84 +1096,218 @@ def create_line_graph(title: str, y_axis_label: str, tooltip_columns: list[str]) tooltip_columns=tooltip_columns, ) - def create_task_graph() -> LineGraph: + def get_production_volume_name(production_volume: ProductionVolumeColumn) -> str: + return f"{production_volume.name}数" if production_volume.value in ["task_count", "input_data_count", "annotation_count"] else production_volume.name + + def create_production_volume_line_graph(production_volume_list: list[ProductionVolumeColumn]) -> tuple[LineGraph, Select]: + default_production_volume = production_volume_list[0] + default_production_volume_name = get_production_volume_name(default_production_volume) line_graph = create_line_graph( - title="教師付開始日ごとのタスク数と計測作業時間", - y_axis_label="タスク数", + title=f"教師付開始日ごとの{default_production_volume_name}", + y_axis_label=default_production_volume_name, tooltip_columns=[ "first_annotation_started_date", - "task_count", "worktime_hour", + *[production_volume.value for production_volume in production_volume_list], ], ) - line_graph.add_secondary_y_axis( - "作業時間[時間]", - secondary_y_axis_range=DataRange1d(end=df["worktime_hour"].max() * SECONDARY_Y_RANGE_RATIO), - primary_y_axis_range=DataRange1d(end=df["task_count"].max() * SECONDARY_Y_RANGE_RATIO), - ) - - plot_index = 0 - _plot_and_moving_average( - line_graph, + line_renderer, marker_renderer = line_graph.add_line( x_column="dt_first_annotation_started_date", - y_column="task_count", - legend_name="タスク数", + y_column=default_production_volume.value, source=source, - color=get_color_from_small_palette(plot_index), + color=get_color_from_small_palette(0), + legend_label=default_production_volume_name, ) - - plot_index += 1 - _plot_and_moving_average( - line_graph, + moving_average_renderer = line_graph.add_moving_average_line( x_column="dt_first_annotation_started_date", - y_column="worktime_hour", - legend_name="計測作業時間", + y_column=f"{default_production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}", source=source, - color=get_color_from_small_palette(plot_index), - is_secondary_y_axis=True, + color=get_color_from_small_palette(0), + legend_label=f"{default_production_volume_name}の1週間移動平均", ) - return line_graph - def create_input_data_graph() -> LineGraph: + production_volume_by_value = { + production_volume.value: { + "movingAverageColumn": f"{production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}", + "name": get_production_volume_name(production_volume), + "title": f"教師付開始日ごとの{get_production_volume_name(production_volume)}", + "valueColumn": production_volume.value, + } + for production_volume in production_volume_list + } + select_options: list[str | tuple[Any, str]] = [(production_volume.value, get_production_volume_name(production_volume)) for production_volume in production_volume_list] + select = Select( + title="生産量種別:", + value=default_production_volume.value, + options=select_options, + width=300, + ) + select.js_on_change( + "value", + CustomJS( + args={ + "figure": line_graph.figure, + "figureTitle": line_graph.figure.title, + "legendItems": line_graph.figure.legend[0].items, + "lineRenderer": line_renderer, + "markerRenderer": marker_renderer, + "movingAverageRenderer": moving_average_renderer, + "productionVolumeByValue": production_volume_by_value, + "yAxis": line_graph.figure.yaxis[0], + "yRange": line_graph.figure.y_range, + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (const renderer of [lineRenderer, markerRenderer]) { + renderer.glyph.y.field = selected.valueColumn; + renderer.glyph.change.emit(); + renderer.change.emit(); + } + movingAverageRenderer.glyph.y.field = selected.movingAverageColumn; + movingAverageRenderer.glyph.change.emit(); + movingAverageRenderer.change.emit(); + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = selected.name; + yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); + legendItems[0].label.value = selected.name; + legendItems[0].change.emit(); + legendItems[1].label.value = `${selected.name}の1週間移動平均`; + legendItems[1].change.emit(); + """, + ), + ) + return line_graph, select + + def create_productivity_line_graph(production_volume_list: list[ProductionVolumeColumn], phase_prefix: list[tuple[str, str]]) -> tuple[LineGraph, Select]: + default_production_volume = production_volume_list[0] + default_production_volume_name = get_production_volume_name(default_production_volume) line_graph = create_line_graph( - title="教師付開始日ごとの入力データ数と計測作業時間", - y_axis_label="入力データ数", + title=f"教師付開始日ごとの{default_production_volume_name}あたり計測作業時間", + y_axis_label=f"{default_production_volume_name}あたり作業時間[分/{default_production_volume_name}]", tooltip_columns=[ "first_annotation_started_date", - "input_data_count", "worktime_hour", + *[production_volume.value for production_volume in production_volume_list], + *[f"{prefix}_minute/{production_volume.value}" for production_volume in production_volume_list for prefix, _ in phase_prefix], ], ) - line_graph.add_secondary_y_axis("作業時間[時間]") - - plot_index = 0 - _plot_and_moving_average( - line_graph, - x_column="dt_first_annotation_started_date", - y_column="input_data_count", - legend_name="入力データ数", - source=source, - color=get_color_from_small_palette(plot_index), + line_renderers = [] + marker_renderers = [] + moving_average_renderers = [] + for plot_index, (prefix, phase_name) in enumerate(phase_prefix): + color = get_color_from_small_palette(plot_index) + line_renderer, marker_renderer = line_graph.add_line( + source=source, + x_column="dt_first_annotation_started_date", + y_column=f"{prefix}_minute/{default_production_volume.value}", + color=color, + legend_label=f"{default_production_volume_name}あたり{phase_name}", + ) + moving_average_renderer = line_graph.add_moving_average_line( + source=source, + x_column="dt_first_annotation_started_date", + y_column=f"{prefix}_minute/{default_production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}", + color=color, + legend_label=f"{default_production_volume_name}あたり{phase_name}の1週間移動平均", + ) + line_renderers.append(line_renderer) + marker_renderers.append(marker_renderer) + moving_average_renderers.append(moving_average_renderer) + + production_volume_by_value = { + production_volume.value: { + "columns": [f"{prefix}_minute/{production_volume.value}" for prefix, _ in phase_prefix], + "movingAverageColumns": [f"{prefix}_minute/{production_volume.value}{WEEKLY_MOVING_AVERAGE_COLUMN_SUFFIX}" for prefix, _ in phase_prefix], + "name": get_production_volume_name(production_volume), + "title": f"教師付開始日ごとの{get_production_volume_name(production_volume)}あたり計測作業時間", + } + for production_volume in production_volume_list + } + phase_names = [phase_name for _, phase_name in phase_prefix] + select_options: list[str | tuple[Any, str]] = [(production_volume.value, get_production_volume_name(production_volume)) for production_volume in production_volume_list] + select = Select( + title="生産量種別:", + value=default_production_volume.value, + options=select_options, + width=300, + ) + select.js_on_change( + "value", + CustomJS( + args={ + "figure": line_graph.figure, + "figureTitle": line_graph.figure.title, + "legendItems": line_graph.figure.legend[0].items, + "lineRenderers": line_renderers, + "markerRenderers": marker_renderers, + "movingAverageRenderers": moving_average_renderers, + "phaseNames": phase_names, + "productionVolumeByValue": production_volume_by_value, + "yAxis": line_graph.figure.yaxis[0], + "yRange": line_graph.figure.y_range, + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (let i = 0; i < lineRenderers.length; i++) { + for (const renderer of [lineRenderers[i], markerRenderers[i]]) { + renderer.glyph.y.field = selected.columns[i]; + renderer.glyph.change.emit(); + renderer.change.emit(); + } + movingAverageRenderers[i].glyph.y.field = selected.movingAverageColumns[i]; + movingAverageRenderers[i].glyph.change.emit(); + movingAverageRenderers[i].change.emit(); + + const legendName = `${selected.name}あたり${phaseNames[i]}`; + legendItems[i * 2].label.value = legendName; + legendItems[i * 2].change.emit(); + legendItems[i * 2 + 1].label.value = `${legendName}の1週間移動平均`; + legendItems[i * 2 + 1].change.emit(); + } + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = `${selected.name}あたり作業時間[分/${selected.name}]`; + yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); + """, + ), ) + return line_graph, select - plot_index += 1 - _plot_and_moving_average( - line_graph, - x_column="dt_first_annotation_started_date", - y_column="worktime_hour", - legend_name="計測作業時間", - source=source, - color=get_color_from_small_palette(plot_index), - is_secondary_y_axis=True, + def create_worktime_line_graph(phase_prefix: list[tuple[str, str]]) -> LineGraph: + line_graph = create_line_graph( + title="教師付開始日ごとの計測作業時間", + y_axis_label="作業時間[時間]", + tooltip_columns=[ + "first_annotation_started_date", + "worktime_hour", + "annotation_worktime_hour", + "inspection_worktime_hour", + "acceptance_worktime_hour", + ], ) + for index, (prefix, phase_name) in enumerate(phase_prefix): + _plot_and_moving_average( + line_graph, + x_column="dt_first_annotation_started_date", + y_column=f"{prefix}_hour", + legend_name=phase_name, + source=source, + color=get_color_from_small_palette(index), + ) return line_graph if not self._validate_df_for_output(output_file): return production_volume_list = [ + ProductionVolumeColumn("task_count", "タスク"), ProductionVolumeColumn("input_data_count", "入力データ"), ProductionVolumeColumn("annotation_count", "アノテーション"), *self.custom_production_volume_list, @@ -1082,87 +1319,29 @@ def create_input_data_graph() -> LineGraph: logger.debug(f"{output_file} を出力します。") - @dataclass - class LegendInfo: - column: str - legend: str - - @dataclass - class GraphInfo: - line_graph: LineGraph - y_info_list: list[LegendInfo] - - graph_info_list = [ - GraphInfo( - line_graph=create_line_graph( - title="教師付開始日ごとの計測作業時間", - y_axis_label="作業時間[時間]", - tooltip_columns=[ - "first_annotation_started_date", - "worktime_hour", - "annotation_worktime_hour", - "inspection_worktime_hour", - "acceptance_worktime_hour", - ], - ), - y_info_list=[ - LegendInfo("worktime_hour", "計測作業時間"), - LegendInfo("annotation_worktime_hour", "計測作業時間(教師付)"), - LegendInfo("inspection_worktime_hour", "計測作業時間(検査)"), - LegendInfo("acceptance_worktime_hour", "計測作業時間(受入)"), - ], - ) - ] - for info in production_volume_list: - graph_info_list.append( # noqa: PERF401 - GraphInfo( - line_graph=create_line_graph( - title=f"教師付開始日ごとの{info.name}あたり計測作業時間", - y_axis_label=f"{info.name}あたり作業時間[分/{info.name}]", - tooltip_columns=[ - "first_annotation_started_date", - info.value, - "worktime_hour", - "annotation_worktime_hour", - "inspection_worktime_hour", - "acceptance_worktime_hour", - f"worktime_minute/{info.value}", - f"annotation_worktime_minute/{info.value}", - f"inspection_worktime_minute/{info.value}", - f"acceptance_worktime_minute/{info.value}", - ], - ), - y_info_list=[ - LegendInfo(f"worktime_minute/{info.value}", f"{info.name}あたり計測作業時間"), - LegendInfo(f"annotation_worktime_minute/{info.value}", f"{info.name}あたり計測作業時間(教師付)"), - LegendInfo(f"inspection_worktime_minute/{info.value}", f"{info.name}あたり計測作業時間(検査)"), - LegendInfo(f"acceptance_worktime_minute/{info.value}", f"{info.name}あたり計測作業時間(受入)"), - ], - ) - ) - source = ColumnDataSource(data=df) - for graph_info in graph_info_list: - y_info_list = graph_info.y_info_list - for index, y_info in enumerate(y_info_list): - color = get_color_from_small_palette(index) - - _plot_and_moving_average( - graph_info.line_graph, - x_column="dt_first_annotation_started_date", - y_column=y_info.column, - legend_name=y_info.legend, - source=source, - color=color, - ) + phase_prefix = [ + ("worktime", "計測作業時間"), + ("annotation_worktime", "計測作業時間(教師付)"), + ("inspection_worktime", "計測作業時間(検査)"), + ("acceptance_worktime", "計測作業時間(受入)"), + ] + worktime_line_graph = create_worktime_line_graph(phase_prefix) + production_volume_line_graph, production_volume_select = create_production_volume_line_graph(production_volume_list) + productivity_line_graph, productivity_select = create_productivity_line_graph(production_volume_list, phase_prefix) - line_graph_list = [create_task_graph(), create_input_data_graph(), *[e.line_graph for e in graph_info_list]] + line_graph_list = [worktime_line_graph, production_volume_line_graph, productivity_line_graph] for line_graph in line_graph_list: line_graph.process_after_adding_glyphs() - element_list: list[UIElement] = [create_div_element()] + [e.figure for e in line_graph_list] + element_list: list[UIElement] = [ + create_div_element(), + line_graph_list[0].figure, + bokeh.layouts.row([line_graph_list[1].figure, production_volume_select]), + bokeh.layouts.row([line_graph_list[2].figure, productivity_select]), + ] if metadata is not None: element_list.insert(0, create_pretext_from_metadata(metadata)) diff --git a/tests/statistics/visualization/dataframe/test_user_performance.py b/tests/statistics/visualization/dataframe/test_user_performance.py index 33ef52cb..1c861293 100644 --- a/tests/statistics/visualization/dataframe/test_user_performance.py +++ b/tests/statistics/visualization/dataframe/test_user_performance.py @@ -1,5 +1,7 @@ from pathlib import Path +import pandas + from annofabcli.statistics.visualization.dataframe.task_worktime_by_phase_user import TaskWorktimeByPhaseUser from annofabcli.statistics.visualization.dataframe.user_performance import ( UserPerformance, @@ -105,6 +107,28 @@ def test_plot_productivity__入力データあたり計測時間(self) -> None: production_volume_column="input_data_count", ) + def test_plot_productivity__find_userにはプロットされたユーザーだけを表示する(self, tmp_path: Path) -> None: + df = self.obj.df.copy() + not_plotted_user_df = df.iloc[[0]].copy() + not_plotted_user_df[("user_id", "")] = "not_plotted" + not_plotted_user_df[("username", "")] = "not_plotted" + for phase in self.obj.phase_list: + not_plotted_user_df[("actual_worktime_hour", phase)] = pandas.NA + not_plotted_user_df[("actual_worktime_hour/annotation_count", phase)] = pandas.NA + + obj = UserPerformance( + pandas.concat([df, not_plotted_user_df], ignore_index=True), + self.obj.task_completion_criteria, + custom_production_volume_list=self.obj.custom_production_volume_list, + ) + + output_file = tmp_path / "散布図-アノテーションあたり作業時間と累計作業時間の関係-実績時間.html" + obj.plot_productivity(output_file, worktime_type=WorktimeType.ACTUAL, production_volume_column="annotation_count") + + html = output_file.read_text(encoding="utf-8") + assert "AC:AC" in html + assert "not_plotted:not_plotted" not in html + def test_plot_productivity_with_worktime_type_selector(self, tmp_path: Path) -> None: output_file = tmp_path / "散布図-アノテーションあたり作業時間と累計作業時間の関係.html" self.obj.plot_productivity_with_worktime_type_selector( diff --git a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py index 8702b609..86571b9b 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -1,5 +1,9 @@ from pathlib import Path +from typing import Any +import pytest + +from annofabcli.statistics.visualization.dataframe import whole_productivity_per_date from annofabcli.statistics.visualization.dataframe.task import Task from annofabcli.statistics.visualization.dataframe.whole_productivity_per_date import ( WholeProductivityPerCompletedDate, @@ -79,6 +83,64 @@ def test__to_csv(self): def test__plot(self): self.main_obj.plot(self.output_dir / "test__plot.html") + def test__plot__累積折れ線と対応したグラフ構成にする(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: ANN401 + captured["bokeh_obj"] = bokeh_obj + + monkeypatch.setattr(whole_productivity_per_date, "write_bokeh_graph", fake_write_bokeh_graph) + + self.main_obj.plot(tmp_path / "test__plot.html") + + layout = captured["bokeh_obj"] + assert layout.children[1].title.text == "日ごとの作業時間" + + production_volume_graph = layout.children[2].children[0] + production_volume_select = layout.children[2].children[1] + assert production_volume_graph.title.text == "日ごとのタスク数" + assert production_volume_select.title == "生産量種別:" + assert production_volume_select.options == [ + ("task_count", "タスク数"), + ("input_data_count", "入力データ数"), + ("annotation_count", "アノテーション数"), + ("custom_production_volume1", "custom_生産量1"), + ("custom_production_volume2", "custom_生産量2"), + ] + data_source = production_volume_graph.renderers[0].data_source + assert "custom_production_volume1__lastweek" in data_source.data + assert "custom_production_volume2__lastweek" in data_source.data + callback = production_volume_select.js_property_callbacks["change:value"][0] + assert "renderer.change.emit();" in callback.code + assert "movingAverageRenderer.change.emit();" in callback.code + assert "yRange.change.emit();" in callback.code + assert "figure.change.emit();" in callback.code + hover_tool = next(tool for tool in production_volume_graph.toolbar.tools if hasattr(tool, "tooltips")) + assert hover_tool.tooltips == [ + ("(x,y)", "($x, $y)"), + ("date", "@{date}"), + ("actual_worktime_hour", "@{actual_worktime_hour}"), + ("monitored_worktime_hour", "@{monitored_worktime_hour}"), + ("task_count", "@{task_count}"), + ("input_data_count", "@{input_data_count}"), + ("annotation_count", "@{annotation_count}"), + ("custom_production_volume1", "@{custom_production_volume1}"), + ("custom_production_volume2", "@{custom_production_volume2}"), + ] + + productivity_graph = layout.children[3].children[0] + productivity_select = layout.children[3].children[1] + assert productivity_graph.title.text == "日ごとのタスク数あたり作業時間" + assert productivity_select.title == "生産量種別:" + assert productivity_select.options == production_volume_select.options + callback = productivity_select.js_property_callbacks["change:value"][0] + assert "yAxis.axis_label = `${selected.name}あたり作業時間[分/${selected.name}]`;" in callback.code + assert "legendItems[i * 2].label.value = legendName;" in callback.code + assert "renderer.change.emit();" in callback.code + assert "movingAverageRenderers[i].change.emit();" in callback.code + assert "yRange.change.emit();" in callback.code + assert "figure.change.emit();" in callback.code + def test__plot_cumulatively(self): output_file = self.output_dir / "test__plot_cumulatively.html" self.main_obj.plot_cumulatively(output_file) @@ -90,6 +152,55 @@ def test__plot_cumulatively(self): assert "cumsum_custom_production_volume1" in html assert "cumsum_custom_production_volume2" in html + def test__plot_cumulatively__累積作業時間グラフを先頭に表示する(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: ANN401 + captured["bokeh_obj"] = bokeh_obj + + monkeypatch.setattr(whole_productivity_per_date, "write_bokeh_graph", fake_write_bokeh_graph) + + self.main_obj.plot_cumulatively(tmp_path / "test__plot_cumulatively.html") + + layout = captured["bokeh_obj"] + assert layout.children[1].title.text == "日ごとの累積作業時間" + production_volume_graph = layout.children[2].children[0] + production_volume_select = layout.children[2].children[1] + assert production_volume_graph.title.text == "日ごとの累積タスク数" + assert production_volume_select.title == "生産量種別:" + assert production_volume_select.options == [ + ("task_count", "タスク数"), + ("input_data_count", "入力データ数"), + ("annotation_count", "アノテーション数"), + ("custom_production_volume1", "custom_生産量1"), + ("custom_production_volume2", "custom_生産量2"), + ] + callback = production_volume_select.js_property_callbacks["change:value"][0] + assert "yAxis.axis_label = selected.name;" in callback.code + assert "yAxis.change.emit();" in callback.code + assert "legendItem.label.value = selected.name;" in callback.code + assert "legendItem.change.emit();" in callback.code + assert "renderer.change.emit();" in callback.code + assert "yRange.change.emit();" in callback.code + assert "figure.change.emit();" in callback.code + hover_tool = next(tool for tool in production_volume_graph.toolbar.tools if hasattr(tool, "tooltips")) + assert hover_tool.tooltips == [ + ("(x,y)", "($x, $y)"), + ("date", "@{date}"), + ("actual_worktime_hour", "@{actual_worktime_hour}"), + ("monitored_worktime_hour", "@{monitored_worktime_hour}"), + ("task_count", "@{task_count}"), + ("input_data_count", "@{input_data_count}"), + ("annotation_count", "@{annotation_count}"), + ("custom_production_volume1", "@{custom_production_volume1}"), + ("custom_production_volume2", "@{custom_production_volume2}"), + ("cumsum_task_count", "@{cumsum_task_count}"), + ("cumsum_input_data_count", "@{cumsum_input_data_count}"), + ("cumsum_annotation_count", "@{cumsum_annotation_count}"), + ("cumsum_custom_production_volume1", "@{cumsum_custom_production_volume1}"), + ("cumsum_custom_production_volume2", "@{cumsum_custom_production_volume2}"), + ] + class TestWholeProductivityPerFirstAnnotationStartedDate: output_dir: Path @@ -136,3 +247,57 @@ def test__from_task__and__plot(self): ) obj = WholeProductivityPerFirstAnnotationStartedDate.from_task(task, TaskCompletionCriteria.ACCEPTANCE_COMPLETED) obj.plot(self.output_dir / "test__from_task__and__plot.html") + + def test__plot__日ごとの折れ線と対応したグラフ構成にする(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: ANN401 + captured["bokeh_obj"] = bokeh_obj + + monkeypatch.setattr(whole_productivity_per_date, "write_bokeh_graph", fake_write_bokeh_graph) + + task = Task.from_csv( + data_dir / "task.csv", + custom_production_volume_list=[ + ProductionVolumeColumn("custom_production_volume1", "custom_生産量1"), + ProductionVolumeColumn("custom_production_volume2", "custom_生産量2"), + ], + ) + obj = WholeProductivityPerFirstAnnotationStartedDate.from_task(task, TaskCompletionCriteria.ACCEPTANCE_COMPLETED) + obj.plot(tmp_path / "test__from_task__and__plot.html") + + layout = captured["bokeh_obj"] + assert layout.children[1].title.text == "教師付開始日ごとの計測作業時間" + + production_volume_graph = layout.children[2].children[0] + production_volume_select = layout.children[2].children[1] + assert production_volume_graph.title.text == "教師付開始日ごとのタスク数" + assert production_volume_select.title == "生産量種別:" + assert production_volume_select.options == [ + ("task_count", "タスク数"), + ("input_data_count", "入力データ数"), + ("annotation_count", "アノテーション数"), + ("custom_production_volume1", "custom_生産量1"), + ("custom_production_volume2", "custom_生産量2"), + ] + data_source = production_volume_graph.renderers[0].data_source + assert "custom_production_volume1__lastweek" in data_source.data + assert "custom_production_volume2__lastweek" in data_source.data + callback = production_volume_select.js_property_callbacks["change:value"][0] + assert "renderer.change.emit();" in callback.code + assert "movingAverageRenderer.change.emit();" in callback.code + assert "yRange.change.emit();" in callback.code + assert "figure.change.emit();" in callback.code + + productivity_graph = layout.children[3].children[0] + productivity_select = layout.children[3].children[1] + assert productivity_graph.title.text == "教師付開始日ごとのタスク数あたり計測作業時間" + assert productivity_select.title == "生産量種別:" + assert productivity_select.options == production_volume_select.options + callback = productivity_select.js_property_callbacks["change:value"][0] + assert "yAxis.axis_label = `${selected.name}あたり作業時間[分/${selected.name}]`;" in callback.code + assert "legendItems[i * 2].label.value = legendName;" in callback.code + assert "renderer.change.emit();" in callback.code + assert "movingAverageRenderers[i].change.emit();" in callback.code + assert "yRange.change.emit();" in callback.code + assert "figure.change.emit();" in callback.code