flextool 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- flextool-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""PlotCanvas — embeds a matplotlib FigureCanvasTkAgg with navigation toolbar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tkinter import ttk
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from flextool.gui.downsampling import downsample_for_display
|
|
13
|
+
from flextool.gui.plot_cache import PlotCache
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import matplotlib
|
|
17
|
+
if matplotlib.get_backend().lower() != "tkagg":
|
|
18
|
+
matplotlib.use("TkAgg")
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
import matplotlib.image as mpimage
|
|
23
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
|
24
|
+
from matplotlib.figure import Figure
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Background color used to fill unused canvas area
|
|
29
|
+
_BG = "#f0f0f0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PlotCanvas(ttk.Frame):
|
|
33
|
+
"""Embeds a matplotlib FigureCanvasTkAgg with navigation toolbar.
|
|
34
|
+
|
|
35
|
+
Matplotlib's own ``<Configure>`` handler is permanently disconnected.
|
|
36
|
+
Instead, this class manages figure sizing itself via a ``<Configure>``
|
|
37
|
+
binding on the *PlotCanvas frame*, which only fires on real window
|
|
38
|
+
resizes — not on internal button/style changes. This eliminates
|
|
39
|
+
jitter from the resize → draw → resize feedback loop.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, master: tk.Widget, **kwargs: object) -> None:
|
|
43
|
+
super().__init__(master, **kwargs)
|
|
44
|
+
|
|
45
|
+
self.columnconfigure(0, weight=1)
|
|
46
|
+
self.rowconfigure(0, weight=1)
|
|
47
|
+
|
|
48
|
+
self._raw_line_data: dict[tuple[int, int], tuple[np.ndarray, np.ndarray]] = {}
|
|
49
|
+
self._n_out: int = 3000
|
|
50
|
+
self._cache = PlotCache()
|
|
51
|
+
self._last_widget_size: tuple[int, int] = (0, 0)
|
|
52
|
+
self._resize_pending: str | None = None
|
|
53
|
+
self._natural_size_inches: tuple[float, float] = (6.0, 4.0)
|
|
54
|
+
|
|
55
|
+
# Render figures at the same DPI Tk uses for its own widget fonts,
|
|
56
|
+
# so a 10-pt figure label matches a 10-pt UI label (see
|
|
57
|
+
# _detect_system_dpi for the full rationale).
|
|
58
|
+
self._system_dpi: float = self._detect_system_dpi()
|
|
59
|
+
|
|
60
|
+
# ── Scrollable container ──────────────────────────────────
|
|
61
|
+
# A tk.Canvas with scrollbars wraps the matplotlib widget so
|
|
62
|
+
# that figures larger than the window can be scrolled.
|
|
63
|
+
self._scroll_canvas = tk.Canvas(self, background=_BG, highlightthickness=0)
|
|
64
|
+
self._vscroll = ttk.Scrollbar(self, orient="vertical",
|
|
65
|
+
command=self._scroll_canvas.yview)
|
|
66
|
+
self._hscroll = ttk.Scrollbar(self, orient="horizontal",
|
|
67
|
+
command=self._scroll_canvas.xview)
|
|
68
|
+
self._scroll_canvas.configure(
|
|
69
|
+
yscrollcommand=self._vscroll.set,
|
|
70
|
+
xscrollcommand=self._hscroll.set,
|
|
71
|
+
)
|
|
72
|
+
self._scroll_canvas.grid(row=0, column=0, sticky="nsew")
|
|
73
|
+
self._vscroll.grid(row=0, column=1, sticky="ns")
|
|
74
|
+
self._hscroll.grid(row=1, column=0, sticky="ew")
|
|
75
|
+
self.columnconfigure(0, weight=1)
|
|
76
|
+
self.columnconfigure(1, weight=0)
|
|
77
|
+
self.rowconfigure(0, weight=1)
|
|
78
|
+
self.rowconfigure(1, weight=0)
|
|
79
|
+
|
|
80
|
+
# Create a blank figure that fills the canvas with a solid bg
|
|
81
|
+
self._figure = Figure(facecolor=_BG)
|
|
82
|
+
self._canvas = FigureCanvasTkAgg(self._figure, master=self._scroll_canvas)
|
|
83
|
+
self._canvas_widget = self._canvas.get_tk_widget()
|
|
84
|
+
self._canvas_widget.configure(background=_BG)
|
|
85
|
+
# Place the matplotlib widget as a window on the scroll canvas
|
|
86
|
+
self._inner_window = self._scroll_canvas.create_window(
|
|
87
|
+
0, 0, window=self._canvas_widget, anchor="nw",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ── Permanently disconnect matplotlib's resize handling ──
|
|
91
|
+
# This prevents the <Configure> → resize() → draw_idle() →
|
|
92
|
+
# <Configure> feedback loop that causes jitter.
|
|
93
|
+
self._canvas_widget.unbind("<Configure>")
|
|
94
|
+
self._canvas_widget.unbind("<Map>")
|
|
95
|
+
|
|
96
|
+
# Instead, handle resizes ourselves on the PlotCanvas frame.
|
|
97
|
+
# This only fires on real geometry changes (window resize,
|
|
98
|
+
# paned-window sash), not on internal button style changes.
|
|
99
|
+
self.bind("<Configure>", self._on_frame_configure)
|
|
100
|
+
|
|
101
|
+
# Monkey-patch the canvas widget's configure to silently ignore
|
|
102
|
+
# width/height changes from matplotlib internals (draw/blit).
|
|
103
|
+
self._orig_tk_configure = self._canvas_widget.configure
|
|
104
|
+
|
|
105
|
+
def _no_resize_configure(**kw):
|
|
106
|
+
kw.pop("width", None)
|
|
107
|
+
kw.pop("height", None)
|
|
108
|
+
if kw:
|
|
109
|
+
self._orig_tk_configure(**kw)
|
|
110
|
+
|
|
111
|
+
self._canvas_widget.configure = _no_resize_configure # type: ignore[assignment]
|
|
112
|
+
|
|
113
|
+
# Mouse-wheel scrolling
|
|
114
|
+
self._scroll_canvas.bind("<Enter>", self._bind_mousewheel)
|
|
115
|
+
self._scroll_canvas.bind("<Leave>", self._unbind_mousewheel)
|
|
116
|
+
|
|
117
|
+
# NavigationToolbar2Tk calls pack() in its __init__, so it needs
|
|
118
|
+
# a dedicated container frame managed by grid in the outer layout.
|
|
119
|
+
toolbar_frame = ttk.Frame(self)
|
|
120
|
+
toolbar_frame.grid(row=2, column=0, columnspan=2, sticky="ew")
|
|
121
|
+
self._toolbar = NavigationToolbar2Tk(self._canvas, toolbar_frame)
|
|
122
|
+
self._toolbar.update()
|
|
123
|
+
|
|
124
|
+
# ── Hover tooltip for middle-truncated bar tick labels ──────
|
|
125
|
+
# plot_bars.py middle-ellipsises long category labels and stashes the
|
|
126
|
+
# full text on each axes (_flextool_bar_labels_full); reveal it when
|
|
127
|
+
# the cursor is over a truncated tick label.
|
|
128
|
+
self._tooltip_win: tk.Toplevel | None = None
|
|
129
|
+
self._tooltip_label: tk.Label | None = None
|
|
130
|
+
# Cached [(bbox, full_text)] for truncated tick labels, rebuilt lazily
|
|
131
|
+
# after each draw (None = needs rebuild).
|
|
132
|
+
self._label_hitmap: list[tuple[object, str]] | None = None
|
|
133
|
+
self._canvas.mpl_connect("motion_notify_event", self._on_motion_tooltip)
|
|
134
|
+
self._canvas.mpl_connect(
|
|
135
|
+
"figure_leave_event", lambda _e: self._hide_label_tooltip()
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _bind_mousewheel(self, event: tk.Event) -> None:
|
|
139
|
+
self._scroll_canvas.bind_all("<MouseWheel>", self._on_mousewheel)
|
|
140
|
+
self._scroll_canvas.bind_all("<Button-4>", self._on_mousewheel)
|
|
141
|
+
self._scroll_canvas.bind_all("<Button-5>", self._on_mousewheel)
|
|
142
|
+
|
|
143
|
+
def _unbind_mousewheel(self, event: tk.Event) -> None:
|
|
144
|
+
self._scroll_canvas.unbind_all("<MouseWheel>")
|
|
145
|
+
self._scroll_canvas.unbind_all("<Button-4>")
|
|
146
|
+
self._scroll_canvas.unbind_all("<Button-5>")
|
|
147
|
+
|
|
148
|
+
def _detect_system_dpi(self) -> float:
|
|
149
|
+
"""Return the DPI to render figures at, matched to Tk's font DPI.
|
|
150
|
+
|
|
151
|
+
The figure bitmap is blitted 1:1 into the Tk canvas, so for figure
|
|
152
|
+
text to match the surrounding widget text both must use the same
|
|
153
|
+
pixels-per-point. Tk renders fonts at ``tk scaling`` px/pt, i.e.
|
|
154
|
+
``winfo_fpixels('1i')`` px/inch — so we render the figure at that
|
|
155
|
+
same DPI.
|
|
156
|
+
|
|
157
|
+
The previous code pinned the figure to the raw OS system DPI
|
|
158
|
+
(``GetDpiForSystem`` on Windows, e.g. 144 at 150% scaling). But
|
|
159
|
+
``apply_dpi_scaling`` deliberately keeps ``tk scaling`` lower than
|
|
160
|
+
the geometric system value, so the figure ended up rendered at a
|
|
161
|
+
*higher* DPI than the rest of the UI and its text/axes looked
|
|
162
|
+
~1.5x oversized next to the Tk widgets. Tying both to
|
|
163
|
+
``winfo_fpixels`` removes that decoupling at any scaling factor.
|
|
164
|
+
|
|
165
|
+
macOS is left on matplotlib's default: Tk uses a 72-DPI logical
|
|
166
|
+
space there and handles Retina at the backing-store level, so
|
|
167
|
+
``winfo_fpixels`` would under-report.
|
|
168
|
+
"""
|
|
169
|
+
import sys
|
|
170
|
+
if sys.platform == "darwin":
|
|
171
|
+
return matplotlib.rcParams.get("figure.dpi", 100)
|
|
172
|
+
try:
|
|
173
|
+
dpi = float(self.tk.call("winfo", "fpixels", self._w, "1i"))
|
|
174
|
+
if dpi > 0:
|
|
175
|
+
return dpi
|
|
176
|
+
except (tk.TclError, AttributeError):
|
|
177
|
+
pass
|
|
178
|
+
return matplotlib.rcParams.get("figure.dpi", 100)
|
|
179
|
+
|
|
180
|
+
# X11 pixmaps are limited to 32767 px per side (signed 16-bit). At
|
|
181
|
+
# high DPI on tall figures (many subplots × long timeline) we can
|
|
182
|
+
# easily blow past that, causing a BadValue on X_CreatePixmap.
|
|
183
|
+
# Stay safely under the limit.
|
|
184
|
+
_MAX_PIXMAP_PX = 32000
|
|
185
|
+
|
|
186
|
+
def _apply_system_dpi(self, fig: Figure) -> None:
|
|
187
|
+
"""Set the figure DPI to the system DPI, capped to keep the
|
|
188
|
+
rendered pixmap under X11's 32767-px-per-side limit.
|
|
189
|
+
|
|
190
|
+
Rescales rendering so point-sized fonts appear at correct
|
|
191
|
+
physical size on high-DPI screens. For oversized figures
|
|
192
|
+
(typically many-subplot single-scenario plots with a long
|
|
193
|
+
timeline) the DPI is reduced just enough to avoid the X
|
|
194
|
+
server's BadValue on CreatePixmap.
|
|
195
|
+
"""
|
|
196
|
+
if not self._system_dpi:
|
|
197
|
+
return
|
|
198
|
+
target_dpi = float(self._system_dpi)
|
|
199
|
+
try:
|
|
200
|
+
w_in, h_in = fig.get_size_inches()
|
|
201
|
+
except Exception:
|
|
202
|
+
w_in = h_in = 0.0
|
|
203
|
+
# Floor first for legibility, then cap by the pixmap limit — the
|
|
204
|
+
# cap must win, since exceeding it crashes the X server with
|
|
205
|
+
# BadValue on X_CreatePixmap, while sub-50-DPI is merely fuzzy.
|
|
206
|
+
target_dpi = max(50.0, target_dpi)
|
|
207
|
+
if w_in and w_in * target_dpi > self._MAX_PIXMAP_PX:
|
|
208
|
+
target_dpi = self._MAX_PIXMAP_PX / w_in
|
|
209
|
+
if h_in and h_in * target_dpi > self._MAX_PIXMAP_PX:
|
|
210
|
+
target_dpi = self._MAX_PIXMAP_PX / h_in
|
|
211
|
+
if fig.get_dpi() != target_dpi:
|
|
212
|
+
fig.set_dpi(target_dpi)
|
|
213
|
+
|
|
214
|
+
def _on_mousewheel(self, event: tk.Event) -> None:
|
|
215
|
+
# Linux uses Button-4/5, Windows/Mac uses MouseWheel.
|
|
216
|
+
# Skip scrolling when the content already fits the viewport —
|
|
217
|
+
# otherwise the figure visibly drifts under the wheel even
|
|
218
|
+
# though no scrollbar is shown.
|
|
219
|
+
try:
|
|
220
|
+
top, bottom = self._scroll_canvas.yview()
|
|
221
|
+
except tk.TclError:
|
|
222
|
+
return
|
|
223
|
+
if top <= 0.0 and bottom >= 1.0:
|
|
224
|
+
return
|
|
225
|
+
if event.num == 4:
|
|
226
|
+
self._scroll_canvas.yview_scroll(-3, "units")
|
|
227
|
+
elif event.num == 5:
|
|
228
|
+
self._scroll_canvas.yview_scroll(3, "units")
|
|
229
|
+
elif event.delta:
|
|
230
|
+
self._scroll_canvas.yview_scroll(-event.delta // 120, "units")
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
# Resize handling (replaces matplotlib's <Configure> handler)
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _on_frame_configure(self, event: tk.Event) -> None:
|
|
237
|
+
"""Handle real geometry changes with debouncing.
|
|
238
|
+
|
|
239
|
+
Schedules a single redraw after 50ms of no further resize events,
|
|
240
|
+
so dragging a window edge doesn't trigger dozens of redraws.
|
|
241
|
+
"""
|
|
242
|
+
if self._resize_pending is not None:
|
|
243
|
+
self.after_cancel(self._resize_pending)
|
|
244
|
+
self._resize_pending = self.after(50, self._do_resize)
|
|
245
|
+
|
|
246
|
+
def _do_resize(self) -> None:
|
|
247
|
+
"""Re-render figure at natural size and update scrollbars."""
|
|
248
|
+
self._resize_pending = None
|
|
249
|
+
w = self._scroll_canvas.winfo_width()
|
|
250
|
+
h = self._scroll_canvas.winfo_height()
|
|
251
|
+
if w < 2 or h < 2:
|
|
252
|
+
return
|
|
253
|
+
self._apply_system_dpi(self._figure)
|
|
254
|
+
self._size_and_draw()
|
|
255
|
+
# Invalidate PNG cache if the size changed significantly
|
|
256
|
+
old_w, old_h = self._last_widget_size
|
|
257
|
+
if abs(w - old_w) > 4 or abs(h - old_h) > 4:
|
|
258
|
+
self._cache.clear()
|
|
259
|
+
self._last_widget_size = (w, h)
|
|
260
|
+
|
|
261
|
+
def _size_and_draw(self) -> None:
|
|
262
|
+
"""Set the figure to its natural pixel size, update scroll region, draw."""
|
|
263
|
+
self._label_hitmap = None # tick-label geometry changes on redraw
|
|
264
|
+
self._hide_label_tooltip()
|
|
265
|
+
dpi = self._figure.get_dpi() or 100
|
|
266
|
+
nat_w, nat_h = self._natural_size_inches
|
|
267
|
+
self._figure.set_size_inches(nat_w, nat_h, forward=False)
|
|
268
|
+
fig_w = int(nat_w * dpi)
|
|
269
|
+
fig_h = int(nat_h * dpi)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
# Resize the matplotlib widget and its internal PhotoImage
|
|
273
|
+
self._orig_tk_configure(width=fig_w, height=fig_h)
|
|
274
|
+
self._canvas._tkcanvas.delete(self._canvas._tkcanvas_image_region)
|
|
275
|
+
self._canvas._tkphoto.configure(width=fig_w, height=fig_h)
|
|
276
|
+
self._canvas._tkcanvas_image_region = (
|
|
277
|
+
self._canvas._tkcanvas.create_image(
|
|
278
|
+
0, 0, anchor="nw", image=self._canvas._tkphoto,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
self._scroll_canvas.configure(scrollregion=(0, 0, fig_w, fig_h))
|
|
282
|
+
except (AttributeError, tk.TclError):
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
# Reset the toolbar's nav stack for the new figure
|
|
286
|
+
self._toolbar.update()
|
|
287
|
+
self._cancel_pending_draws()
|
|
288
|
+
self._canvas.draw()
|
|
289
|
+
self._cancel_pending_draws()
|
|
290
|
+
|
|
291
|
+
# Show/hide scrollbars based on whether content exceeds viewport
|
|
292
|
+
w = self._scroll_canvas.winfo_width()
|
|
293
|
+
h = self._scroll_canvas.winfo_height()
|
|
294
|
+
self._vscroll.grid() if fig_h > h else self._vscroll.grid_remove()
|
|
295
|
+
self._hscroll.grid() if fig_w > w else self._hscroll.grid_remove()
|
|
296
|
+
|
|
297
|
+
# Scroll to top-left
|
|
298
|
+
self._scroll_canvas.xview_moveto(0)
|
|
299
|
+
self._scroll_canvas.yview_moveto(0)
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# Figure display
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def display_figure(self, fig: Figure) -> None:
|
|
306
|
+
"""Display a matplotlib Figure at its natural size.
|
|
307
|
+
|
|
308
|
+
Scrollbars appear when the figure exceeds the visible area.
|
|
309
|
+
Only one ``draw()`` call is made — no jitter, no redundant renders.
|
|
310
|
+
"""
|
|
311
|
+
if fig is self._figure:
|
|
312
|
+
self._canvas.draw()
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
fig.set_facecolor(_BG)
|
|
316
|
+
# Remember the figure's designed size for resize handling
|
|
317
|
+
self._natural_size_inches = tuple(fig.get_size_inches())
|
|
318
|
+
# Render at system DPI so fonts match the OS scaling
|
|
319
|
+
self._apply_system_dpi(fig)
|
|
320
|
+
|
|
321
|
+
self._figure = fig
|
|
322
|
+
self._canvas.figure = fig
|
|
323
|
+
fig.set_canvas(self._canvas)
|
|
324
|
+
|
|
325
|
+
self._size_and_draw()
|
|
326
|
+
|
|
327
|
+
def _cancel_pending_draws(self) -> None:
|
|
328
|
+
"""Cancel any ``after_idle(draw)`` scheduled by ``draw_idle()``."""
|
|
329
|
+
idle_id = getattr(self._canvas, "_idle_draw_id", None)
|
|
330
|
+
if idle_id is not None:
|
|
331
|
+
self._canvas_widget.after_cancel(idle_id)
|
|
332
|
+
self._canvas._idle_draw_id = None
|
|
333
|
+
|
|
334
|
+
def display_png(self, png_path: Path) -> None:
|
|
335
|
+
"""Load and display a PNG file at its natural resolution.
|
|
336
|
+
|
|
337
|
+
Uses the cache — switching back to a previously viewed PNG is
|
|
338
|
+
instant. If the image is larger than the canvas it is scaled
|
|
339
|
+
down (keeping aspect ratio); otherwise shown at 1:1 pixels.
|
|
340
|
+
"""
|
|
341
|
+
cache_key = ("png", str(png_path))
|
|
342
|
+
|
|
343
|
+
cached = self._cache.get(cache_key)
|
|
344
|
+
if cached is not None:
|
|
345
|
+
self.display_figure(cached)
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
img = mpimage.imread(str(png_path))
|
|
350
|
+
except Exception:
|
|
351
|
+
logger.exception("Failed to load PNG: %s", png_path)
|
|
352
|
+
self.show_message(f"Failed to load image:\n{png_path.name}")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
img_h, img_w = img.shape[:2]
|
|
356
|
+
|
|
357
|
+
w_px = self._scroll_canvas.winfo_width()
|
|
358
|
+
h_px = self._scroll_canvas.winfo_height()
|
|
359
|
+
widget_w = max(w_px, 100)
|
|
360
|
+
widget_h = max(h_px, 100)
|
|
361
|
+
self._last_widget_size = (widget_w, widget_h)
|
|
362
|
+
|
|
363
|
+
# Display at natural size — scrollbars appear if needed
|
|
364
|
+
disp_w = img_w
|
|
365
|
+
disp_h = img_h
|
|
366
|
+
|
|
367
|
+
# Place the image at top-left inside a figure sized to the image.
|
|
368
|
+
# Use system DPI so the image is rendered at the correct scale.
|
|
369
|
+
dpi = self._system_dpi or 100
|
|
370
|
+
fig = Figure(figsize=(disp_w / dpi, disp_h / dpi), dpi=dpi)
|
|
371
|
+
ax = fig.add_axes([0, 0, 1, 1])
|
|
372
|
+
ax.imshow(img, interpolation="nearest")
|
|
373
|
+
ax.set_axis_off()
|
|
374
|
+
|
|
375
|
+
self._cache.put(cache_key, fig)
|
|
376
|
+
self.display_figure(fig)
|
|
377
|
+
|
|
378
|
+
def show_message(self, text: str) -> None:
|
|
379
|
+
"""Display a text message (e.g., 'No data available')."""
|
|
380
|
+
fig = Figure()
|
|
381
|
+
ax = fig.add_subplot(111)
|
|
382
|
+
ax.text(
|
|
383
|
+
0.5, 0.5, text,
|
|
384
|
+
transform=ax.transAxes,
|
|
385
|
+
ha="center", va="center",
|
|
386
|
+
fontsize=14, color="grey",
|
|
387
|
+
)
|
|
388
|
+
ax.set_axis_off()
|
|
389
|
+
self.display_figure(fig)
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
# Downsampling for time series
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def display_timeseries_figure(
|
|
396
|
+
self, fig: Figure, n_out: int = 3000
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Display a time-series Figure with downsampling support."""
|
|
399
|
+
self._raw_line_data = {}
|
|
400
|
+
self._n_out = n_out
|
|
401
|
+
|
|
402
|
+
for ax_idx, ax in enumerate(fig.axes):
|
|
403
|
+
for line_idx, line in enumerate(ax.get_lines()):
|
|
404
|
+
x_full = np.asarray(line.get_xdata(), dtype=np.float64)
|
|
405
|
+
y_full = np.asarray(line.get_ydata(), dtype=np.float64)
|
|
406
|
+
self._raw_line_data[(ax_idx, line_idx)] = (x_full, y_full)
|
|
407
|
+
|
|
408
|
+
x_ds, y_ds = downsample_for_display(x_full, y_full, n_out)
|
|
409
|
+
line.set_xdata(x_ds)
|
|
410
|
+
line.set_ydata(y_ds)
|
|
411
|
+
|
|
412
|
+
ax.callbacks.connect("xlim_changed", self._on_xlim_changed)
|
|
413
|
+
|
|
414
|
+
self.display_figure(fig)
|
|
415
|
+
|
|
416
|
+
def _on_xlim_changed(self, ax) -> None: # type: ignore[override]
|
|
417
|
+
"""Re-downsample visible data when the user zooms or pans."""
|
|
418
|
+
if not self._raw_line_data:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
ax_idx = list(self._figure.axes).index(ax)
|
|
423
|
+
except ValueError:
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
lo, hi = ax.get_xlim()
|
|
427
|
+
|
|
428
|
+
for line_idx, line in enumerate(ax.get_lines()):
|
|
429
|
+
key = (ax_idx, line_idx)
|
|
430
|
+
if key not in self._raw_line_data:
|
|
431
|
+
continue
|
|
432
|
+
x_full, y_full = self._raw_line_data[key]
|
|
433
|
+
|
|
434
|
+
mask = (x_full >= lo) & (x_full <= hi)
|
|
435
|
+
x_vis = x_full[mask]
|
|
436
|
+
y_vis = y_full[mask]
|
|
437
|
+
|
|
438
|
+
if len(x_vis) == 0:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
x_ds, y_ds = downsample_for_display(x_vis, y_vis, self._n_out)
|
|
442
|
+
line.set_xdata(x_ds)
|
|
443
|
+
line.set_ydata(y_ds)
|
|
444
|
+
|
|
445
|
+
self._canvas.draw_idle()
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
# Full-name hover tooltip for middle-truncated bar tick labels
|
|
449
|
+
# ------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _build_label_hitmap(self) -> list[tuple[object, str]]:
|
|
452
|
+
"""Collect display-space bboxes of *truncated* tick labels + full text.
|
|
453
|
+
|
|
454
|
+
Only labels whose drawn text differs from the stashed full text are
|
|
455
|
+
included, so plots without truncation produce an empty hitmap (and
|
|
456
|
+
never show a tooltip).
|
|
457
|
+
"""
|
|
458
|
+
hitmap: list[tuple[object, str]] = []
|
|
459
|
+
try:
|
|
460
|
+
renderer = self._canvas.get_renderer()
|
|
461
|
+
except Exception:
|
|
462
|
+
return hitmap
|
|
463
|
+
for ax in self._figure.axes:
|
|
464
|
+
full = getattr(ax, "_flextool_bar_labels_full", None)
|
|
465
|
+
if not full:
|
|
466
|
+
continue
|
|
467
|
+
orient = getattr(ax, "_flextool_bar_orientation", "horizontal")
|
|
468
|
+
ticklabels = (ax.get_yticklabels() if orient == "horizontal"
|
|
469
|
+
else ax.get_xticklabels())
|
|
470
|
+
for i, artist in enumerate(ticklabels):
|
|
471
|
+
if i >= len(full):
|
|
472
|
+
break
|
|
473
|
+
if artist.get_text() == full[i]:
|
|
474
|
+
continue # not truncated → nothing to reveal
|
|
475
|
+
try:
|
|
476
|
+
bbox = artist.get_window_extent(renderer)
|
|
477
|
+
except Exception:
|
|
478
|
+
continue
|
|
479
|
+
hitmap.append((bbox, full[i]))
|
|
480
|
+
return hitmap
|
|
481
|
+
|
|
482
|
+
def _on_motion_tooltip(self, event) -> None:
|
|
483
|
+
"""Show the full label while hovering a truncated tick label."""
|
|
484
|
+
if event is None or event.x is None or event.y is None:
|
|
485
|
+
self._hide_label_tooltip()
|
|
486
|
+
return
|
|
487
|
+
if self._label_hitmap is None:
|
|
488
|
+
self._label_hitmap = self._build_label_hitmap()
|
|
489
|
+
for bbox, full_text in self._label_hitmap:
|
|
490
|
+
try:
|
|
491
|
+
hit = bool(bbox.contains(event.x, event.y))
|
|
492
|
+
except Exception:
|
|
493
|
+
hit = False
|
|
494
|
+
if hit:
|
|
495
|
+
self._show_label_tooltip(full_text)
|
|
496
|
+
return
|
|
497
|
+
self._hide_label_tooltip()
|
|
498
|
+
|
|
499
|
+
def _show_label_tooltip(self, text: str) -> None:
|
|
500
|
+
if self._tooltip_win is None:
|
|
501
|
+
self._tooltip_win = tk.Toplevel(self)
|
|
502
|
+
self._tooltip_win.wm_overrideredirect(True)
|
|
503
|
+
try:
|
|
504
|
+
self._tooltip_win.attributes("-topmost", True)
|
|
505
|
+
except tk.TclError:
|
|
506
|
+
pass
|
|
507
|
+
self._tooltip_label = tk.Label(
|
|
508
|
+
self._tooltip_win, justify="left", background="#ffffe0",
|
|
509
|
+
relief="solid", borderwidth=1, font="TkTooltipFont",
|
|
510
|
+
)
|
|
511
|
+
self._tooltip_label.pack()
|
|
512
|
+
if self._tooltip_label.cget("text") != text:
|
|
513
|
+
self._tooltip_label.configure(text=text)
|
|
514
|
+
x = self.winfo_pointerx() + 14
|
|
515
|
+
y = self.winfo_pointery() + 12
|
|
516
|
+
self._tooltip_win.wm_geometry(f"+{x}+{y}")
|
|
517
|
+
self._tooltip_win.deiconify()
|
|
518
|
+
|
|
519
|
+
def _hide_label_tooltip(self) -> None:
|
|
520
|
+
if self._tooltip_win is not None:
|
|
521
|
+
try:
|
|
522
|
+
self._tooltip_win.withdraw()
|
|
523
|
+
except tk.TclError:
|
|
524
|
+
pass
|
|
525
|
+
|
|
526
|
+
def cleanup(self) -> None:
|
|
527
|
+
"""Release matplotlib resources held by this canvas."""
|
|
528
|
+
self._raw_line_data.clear()
|
|
529
|
+
self._cache.clear()
|
|
530
|
+
self._label_hitmap = None
|
|
531
|
+
if self._tooltip_win is not None:
|
|
532
|
+
try:
|
|
533
|
+
self._tooltip_win.destroy()
|
|
534
|
+
except tk.TclError:
|
|
535
|
+
pass
|
|
536
|
+
self._tooltip_win = None
|
|
537
|
+
self._tooltip_label = None
|
|
538
|
+
|
|
539
|
+
def clear(self) -> None:
|
|
540
|
+
"""Clear the display."""
|
|
541
|
+
self._raw_line_data = {}
|
|
542
|
+
self._figure.clear()
|
|
543
|
+
self._canvas.draw_idle()
|