From aab0d6b279e6f839c208115ebd61a6143501ead4 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 22:23:24 +0900 Subject: [PATCH 01/10] Fix Find User options in scatter plots --- annofabcli/statistics/scatter.py | 8 +++++++ .../dataframe/user_performance.py | 6 ++--- .../dataframe/test_user_performance.py | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/annofabcli/statistics/scatter.py b/annofabcli/statistics/scatter.py index 48f0dcdc0..4769b476e 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 63f8e3a4e..78e999892 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/tests/statistics/visualization/dataframe/test_user_performance.py b/tests/statistics/visualization/dataframe/test_user_performance.py index 33ef52cba..1c8612935 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( From ab6382bb5211d32d572f670d282b6242f95f45a6 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 22:32:05 +0900 Subject: [PATCH 02/10] =?UTF-8?q?`config.toml`:=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=E5=89=8A=E9=99=A4?= =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/.codex/config.toml b/.codex/config.toml index 42252341d..e69de29bb 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" } From 2a6dc005471d1bb35a8ae854df3f2ce818dec50d Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 22:37:44 +0900 Subject: [PATCH 03/10] Move cumulative worktime graph first --- .../dataframe/whole_productivity_per_date.py | 2 +- .../test_whole_productivity_per_date.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 85a612559..08bd11758 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -688,9 +688,9 @@ def create_worktime_line_graph() -> LineGraph: source = ColumnDataSource(data=df) line_graph_list = [ + create_worktime_line_graph(), create_task_line_graph(), *[create_production_volume_line_graph(production_volume) for production_volume in production_volume_list], - create_worktime_line_graph(), ] for line_graph in line_graph_list: 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 8702b6095..e4892936d 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, @@ -90,6 +94,20 @@ 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"] + graph_titles = [child.title.text for child in layout.children[1:]] + assert graph_titles[0] == "日ごとの累積作業時間" + class TestWholeProductivityPerFirstAnnotationStartedDate: output_dir: Path From 312c9b1b7e2076d54f3d82eb493fa5134e5386f9 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 22:45:02 +0900 Subject: [PATCH 04/10] Use selector for cumulative production graph --- .../dataframe/whole_productivity_per_date.py | 100 +++++++++++------- .../test_whole_productivity_per_date.py | 30 +++++- 2 files changed, 89 insertions(+), 41 deletions(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 08bd11758..11e572757 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -13,8 +13,9 @@ import bokeh.layouts import pandas from annofabapi.models import TaskPhase, TaskStatus -from bokeh.models import DataRange1d +from bokeh.models import CustomJS, DataRange1d 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 @@ -551,52 +552,68 @@ 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", "working_user_count"] + for production_volume in production_volume_list: + tooltip_columns.extend([production_volume.value, f"cumsum_{production_volume.value}"]) + 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, + "lineRenderer": line_renderer, + "markerRenderer": marker_renderer, + "productionVolumeByValue": production_volume_by_value, + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (const renderer of [lineRenderer, markerRenderer]) { + renderer.glyph.y = {field: selected.cumsumColumn}; + renderer.glyph.change.emit(); + } + figure.title.text = selected.title; + figure.yaxis[0].axis_label = selected.name; + figure.legend[0].items[0].label = {value: selected.name}; + """, + ), ) - return line_graph + return line_graph, select def create_worktime_line_graph() -> LineGraph: line_graph = create_line_graph( @@ -678,6 +695,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 +704,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_worktime_line_graph(), - create_task_line_graph(), - *[create_production_volume_line_graph(production_volume) for production_volume in production_volume_list], + production_volume_line_graph, ] for line_graph in line_graph_list: @@ -698,7 +716,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)) 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 e4892936d..218b901d4 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -105,8 +105,34 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: self.main_obj.plot_cumulatively(tmp_path / "test__plot_cumulatively.html") layout = captured["bokeh_obj"] - graph_titles = [child.title.text for child in layout.children[1:]] - assert graph_titles[0] == "日ごとの累積作業時間" + 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"), + ] + 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}"), + ("working_user_count", "@{working_user_count}"), + ("task_count", "@{task_count}"), + ("cumsum_task_count", "@{cumsum_task_count}"), + ("input_data_count", "@{input_data_count}"), + ("cumsum_input_data_count", "@{cumsum_input_data_count}"), + ("annotation_count", "@{annotation_count}"), + ("cumsum_annotation_count", "@{cumsum_annotation_count}"), + ("custom_production_volume1", "@{custom_production_volume1}"), + ("cumsum_custom_production_volume1", "@{cumsum_custom_production_volume1}"), + ("custom_production_volume2", "@{custom_production_volume2}"), + ("cumsum_custom_production_volume2", "@{cumsum_custom_production_volume2}"), + ] class TestWholeProductivityPerFirstAnnotationStartedDate: From db32a76fb6c21318f5e54aab7610087d4b3fab31 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 22:47:42 +0900 Subject: [PATCH 05/10] Fix cumulative production selector labels --- .../dataframe/whole_productivity_per_date.py | 15 ++++++++++----- .../dataframe/test_whole_productivity_per_date.py | 5 +++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 11e572757..dd256d9bf 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -596,20 +596,25 @@ def get_production_volume_name(production_volume: ProductionVolumeColumn) -> str "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], }, code=""" const selected = productionVolumeByValue[this.value]; for (const renderer of [lineRenderer, markerRenderer]) { - renderer.glyph.y = {field: selected.cumsumColumn}; + renderer.glyph.y.field = selected.cumsumColumn; renderer.glyph.change.emit(); } - figure.title.text = selected.title; - figure.yaxis[0].axis_label = selected.name; - figure.legend[0].items[0].label = {value: selected.name}; + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = selected.name; + yAxis.change.emit(); + legendItem.label.value = selected.name; + legendItem.change.emit(); """, ), ) 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 218b901d4..bf6184abe 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -117,6 +117,11 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: ("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 hover_tool = next(tool for tool in production_volume_graph.toolbar.tools if hasattr(tool, "tooltips")) assert hover_tool.tooltips == [ ("(x,y)", "($x, $y)"), From 6b820a9774e835a33a35d239aa1a270c56369e8a Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 22:52:32 +0900 Subject: [PATCH 06/10] Reorder cumulative production tooltips --- .../dataframe/whole_productivity_per_date.py | 10 +++++++--- .../dataframe/test_whole_productivity_per_date.py | 11 ++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index dd256d9bf..3f6252a18 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -558,9 +558,13 @@ def get_production_volume_name(production_volume: ProductionVolumeColumn) -> str default_production_volume = production_volume_list[0] default_production_volume_name = get_production_volume_name(default_production_volume) - tooltip_columns = ["date", "working_user_count"] - for production_volume in production_volume_list: - tooltip_columns.extend([production_volume.value, f"cumsum_{production_volume.value}"]) + 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=f"日ごとの累積{default_production_volume_name}", 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 bf6184abe..cb1adc40d 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -126,16 +126,17 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: assert hover_tool.tooltips == [ ("(x,y)", "($x, $y)"), ("date", "@{date}"), - ("working_user_count", "@{working_user_count}"), + ("actual_worktime_hour", "@{actual_worktime_hour}"), + ("monitored_worktime_hour", "@{monitored_worktime_hour}"), ("task_count", "@{task_count}"), - ("cumsum_task_count", "@{cumsum_task_count}"), ("input_data_count", "@{input_data_count}"), - ("cumsum_input_data_count", "@{cumsum_input_data_count}"), ("annotation_count", "@{annotation_count}"), - ("cumsum_annotation_count", "@{cumsum_annotation_count}"), ("custom_production_volume1", "@{custom_production_volume1}"), - ("cumsum_custom_production_volume1", "@{cumsum_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}"), ] From c2bd250cae2dc1be30c9e3e7afc0b3cdcef89533 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 23:01:33 +0900 Subject: [PATCH 07/10] Align daily line graph with cumulative UI --- .../dataframe/whole_productivity_per_date.py | 328 +++++++++++------- .../test_whole_productivity_per_date.py | 46 +++ 2 files changed, 239 insertions(+), 135 deletions(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 3f6252a18..6f75a779f 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -293,7 +293,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, *, @@ -342,96 +342,181 @@ 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={ + "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], + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (const renderer of [lineRenderer, markerRenderer]) { + renderer.glyph.y.field = selected.valueColumn; + renderer.glyph.change.emit(); + } + movingAverageRenderer.glyph.y.field = selected.movingAverageColumn; + movingAverageRenderer.glyph.change.emit(); + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = selected.name; + yAxis.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) - 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, + 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, ) - - return line_graph + select.js_on_change( + "value", + CustomJS( + args={ + "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], + }, + 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(); + } + movingAverageRenderers[i].glyph.y.field = selected.movingAverageColumns[i]; + movingAverageRenderers[i].glyph.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(); + """, + ), + ) + return line_graph, select if not self._validate_df_for_output(output_file): return @@ -440,6 +525,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, @@ -460,77 +546,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)) 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 cb1adc40d..c6e109ba9 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -83,6 +83,52 @@ 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"), + ] + 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 + def test__plot_cumulatively(self): output_file = self.output_dir / "test__plot_cumulatively.html" self.main_obj.plot_cumulatively(output_file) From 48b28e9aa3e827cd44adda165675b70540f348c9 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 23:06:13 +0900 Subject: [PATCH 08/10] Add moving averages for custom production volumes --- .../visualization/dataframe/whole_productivity_per_date.py | 3 +-- .../dataframe/test_whole_productivity_per_date.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 6f75a779f..33f0be7d9 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -320,8 +320,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", 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 c6e109ba9..57c69e000 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -107,6 +107,9 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: ("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 hover_tool = next(tool for tool in production_volume_graph.toolbar.tools if hasattr(tool, "tooltips")) assert hover_tool.tooltips == [ ("(x,y)", "($x, $y)"), From d5cd47857a5bd6aed6f7c03141ef7c93fd59f330 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Sun, 21 Jun 2026 23:10:51 +0900 Subject: [PATCH 09/10] Align annotation-start line graph UI --- .../dataframe/whole_productivity_per_date.py | 312 +++++++++++------- .../test_whole_productivity_per_date.py | 45 +++ 2 files changed, 232 insertions(+), 125 deletions(-) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 33f0be7d9..4dbf4ccea 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -6,14 +6,13 @@ 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 CustomJS, 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 @@ -1033,8 +1032,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", @@ -1081,84 +1079,206 @@ 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={ + "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], + }, + code=""" + const selected = productionVolumeByValue[this.value]; + for (const renderer of [lineRenderer, markerRenderer]) { + renderer.glyph.y.field = selected.valueColumn; + renderer.glyph.change.emit(); + } + movingAverageRenderer.glyph.y.field = selected.movingAverageColumn; + movingAverageRenderer.glyph.change.emit(); + figureTitle.text = selected.title; + figureTitle.change.emit(); + yAxis.axis_label = selected.name; + yAxis.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("作業時間[時間]") + 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) - 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), + 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={ + "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], + }, + 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(); + } + movingAverageRenderers[i].glyph.y.field = selected.movingAverageColumns[i]; + movingAverageRenderers[i].glyph.change.emit(); - 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, + 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(); + """, + ), ) + return line_graph, select + + 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, @@ -1170,87 +1290,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_whole_productivity_per_date.py b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py index 57c69e000..6e5a4fc8e 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -235,3 +235,48 @@ 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 + + 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 From 96a015c573cd58881cf919952ec37f3e1a6259d4 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Mon, 22 Jun 2026 19:18:48 +0900 Subject: [PATCH 10/10] Emit renderer changes for line graph selectors --- .../dataframe/whole_productivity_per_date.py | 29 +++++++++++++++++++ .../test_whole_productivity_per_date.py | 21 ++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py index 4dbf4ccea..35daa3d4a 100644 --- a/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +++ b/annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py @@ -393,6 +393,7 @@ def create_production_volume_line_graph(production_volume_list: list[ProductionV "value", CustomJS( args={ + "figure": line_graph.figure, "figureTitle": line_graph.figure.title, "legendItems": line_graph.figure.legend[0].items, "lineRenderer": line_renderer, @@ -400,19 +401,24 @@ def create_production_volume_line_graph(production_volume_list: list[ProductionV "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週間移動平均`; @@ -482,6 +488,7 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume "value", CustomJS( args={ + "figure": line_graph.figure, "figureTitle": line_graph.figure.title, "legendItems": line_graph.figure.legend[0].items, "lineRenderers": line_renderers, @@ -490,6 +497,7 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume "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]; @@ -497,9 +505,11 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume 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; @@ -511,6 +521,8 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume figureTitle.change.emit(); yAxis.axis_label = `${selected.name}あたり作業時間[分/${selected.name}]`; yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); """, ), ) @@ -656,23 +668,28 @@ def get_production_volume_name(production_volume: ProductionVolumeColumn) -> str "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(); """, @@ -1130,6 +1147,7 @@ def create_production_volume_line_graph(production_volume_list: list[ProductionV "value", CustomJS( args={ + "figure": line_graph.figure, "figureTitle": line_graph.figure.title, "legendItems": line_graph.figure.legend[0].items, "lineRenderer": line_renderer, @@ -1137,19 +1155,24 @@ def create_production_volume_line_graph(production_volume_list: list[ProductionV "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週間移動平均`; @@ -1217,6 +1240,7 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume "value", CustomJS( args={ + "figure": line_graph.figure, "figureTitle": line_graph.figure.title, "legendItems": line_graph.figure.legend[0].items, "lineRenderers": line_renderers, @@ -1225,6 +1249,7 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume "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]; @@ -1232,9 +1257,11 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume 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; @@ -1246,6 +1273,8 @@ def create_productivity_line_graph(production_volume_list: list[ProductionVolume figureTitle.change.emit(); yAxis.axis_label = `${selected.name}あたり作業時間[分/${selected.name}]`; yAxis.change.emit(); + yRange.change.emit(); + figure.change.emit(); """, ), ) 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 6e5a4fc8e..86571b9ba 100644 --- a/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py +++ b/tests/statistics/visualization/dataframe/test_whole_productivity_per_date.py @@ -110,6 +110,11 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: 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)"), @@ -131,6 +136,10 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: 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" @@ -171,6 +180,9 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: 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)"), @@ -271,6 +283,11 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: 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] @@ -280,3 +297,7 @@ def fake_write_bokeh_graph(bokeh_obj: Any, _output_file: Path) -> None: # noqa: 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