From 55e7279586397abd7d24ae3574e12f0494a16cf1 Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Fri, 3 Apr 2026 22:48:53 +0300 Subject: [PATCH 1/8] filter graphs on selection --- graphs/gui/canvasPanel.py | 52 +++++++++++++++++++++++++++++++++++++-- graphs/gui/ctrlPanel.py | 40 ++++++++++++++++++++++++++++++ graphs/gui/lists.py | 52 +++++++++++++++++++++++++-------------- 3 files changed, 123 insertions(+), 21 deletions(-) diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py index 4c862f6001..3fb57f1946 100644 --- a/graphs/gui/canvasPanel.py +++ b/graphs/gui/canvasPanel.py @@ -30,6 +30,8 @@ from logbook import Logger +from eos.saveddata.fit import Fit +from eos.saveddata.targetProfile import TargetProfile from graphs.style import BASE_COLORS, LIGHTNESSES, STYLES, hsl_to_hsv from gui.utils.numberFormatter import roundToPrec @@ -37,6 +39,50 @@ pyfalog = Logger(__name__) +def _graph_item_key(item): + if isinstance(item, Fit): + return ('fit', item.ID) + if isinstance(item, TargetProfile): + return ('profile', item.ID) + return None + + +def expand_reverse_filtered_matchups(ctrl, base_pairs): + """ + For each (attacker, target) in base_pairs, optionally add (targetShip as attacker, attackerShip as target) + using the SourceWrapper / TargetWrapper instances from the full lists when present. + """ + if not ctrl.showReverseFilteredMatchups: + return base_pairs + src_by_key = {} + for w in ctrl.sources: + k = _graph_item_key(w.item) + if k: + src_by_key[k] = w + tgt_by_key = {} + for w in ctrl.targets: + k = _graph_item_key(w.item) + if k: + tgt_by_key[k] = w + seen = set((id(s), id(t)) for s, t in base_pairs) + out = list(base_pairs) + for s, t in base_pairs: + ks = _graph_item_key(t.item) + kt = _graph_item_key(s.item) + if ks is None or kt is None: + continue + rev_s = src_by_key.get(ks) + rev_t = tgt_by_key.get(kt) + if rev_s is None or rev_t is None: + continue + key = (id(rev_s), id(rev_t)) + if key in seen: + continue + seen.add(key) + out.append((rev_s, rev_t)) + return out + + try: import matplotlib as mpl @@ -116,9 +162,11 @@ def draw(self, accurateMarks=True): mainInput, miscInputs = self.graphFrame.ctrlPanel.getValues() view = self.graphFrame.getView() - sources = self.graphFrame.ctrlPanel.sources + ctrl = self.graphFrame.ctrlPanel + sources = ctrl.filteredSources if view.hasTargets: - iterList = tuple(itertools.product(sources, self.graphFrame.ctrlPanel.targets)) + base_pairs = list(itertools.product(sources, ctrl.filteredTargets)) + iterList = tuple(expand_reverse_filtered_matchups(ctrl, base_pairs)) else: iterList = tuple((f, None) for f in sources) diff --git a/graphs/gui/ctrlPanel.py b/graphs/gui/ctrlPanel.py index 418bbe468d..819eb089d8 100644 --- a/graphs/gui/ctrlPanel.py +++ b/graphs/gui/ctrlPanel.py @@ -76,6 +76,13 @@ def __init__(self, graphFrame, parent): self.showY0Cb.SetValue(True) self.showY0Cb.Bind(wx.EVT_CHECKBOX, self.OnShowY0Change) commonOptsSizer.Add(self.showY0Cb, 0, wx.EXPAND | wx.TOP, 5) + self.reverseFilteredMatchupsCb = wx.CheckBox( + self, wx.ID_ANY, _t('Include reverse matchups when filtered'), wx.DefaultPosition, wx.DefaultSize, 0) + self.reverseFilteredMatchupsCb.SetValue(False) + self.reverseFilteredMatchupsCb.Bind(wx.EVT_CHECKBOX, self.OnReverseFilteredMatchupsChange) + self.reverseFilteredMatchupsCb.SetToolTip(wx.ToolTip(_t( + 'Also plot target→attacker for the same ships. Add each ship to both attacker and target lists.'))) + commonOptsSizer.Add(self.reverseFilteredMatchupsCb, 0, wx.EXPAND | wx.TOP, 5) optsSizer.Add(commonOptsSizer, 0, wx.EXPAND | wx.RIGHT, 10) graphOptsSizer = wx.BoxSizer(wx.HORIZONTAL) @@ -158,6 +165,7 @@ def updateControls(self, layout=True): # Source and target list self.refreshColumns(layout=False) self.targetList.Show(view.hasTargets) + self.reverseFilteredMatchupsCb.Show(view.hasTargets) # Inputs self._updateInputs(storeInputs=False) @@ -327,6 +335,10 @@ def OnShowY0Change(self, event): event.Skip() self.graphFrame.draw() + def OnReverseFilteredMatchupsChange(self, event): + event.Skip() + self.graphFrame.draw() + def OnYTypeUpdate(self, event): event.Skip() self._updateInputs() @@ -417,6 +429,34 @@ def sources(self): def targets(self): return self.targetList.wrappers + @property + def filteredSources(self): + srcs = self.sources + selected = self.sourceList.getSelectedWrappers() + if not selected: + return srcs + sel = set(selected) + return [w for w in srcs if w in sel] + + @property + def filteredTargets(self): + tgts = self.targets + selected = self.targetList.getSelectedWrappers() + if not selected: + return tgts + sel = set(selected) + return [w for w in tgts if w in sel] + + @property + def isGraphFiltered(self): + return bool(self.sourceList.getSelectedWrappers()) or bool(self.targetList.getSelectedWrappers()) + + @property + def showReverseFilteredMatchups(self): + if not self.graphFrame.getView().hasTargets or not self.isGraphFiltered: + return False + return self.reverseFilteredMatchupsCb.GetValue() + # Fit events def OnFitRenamed(self, event): self.sourceList.OnFitRenamed(event) diff --git a/graphs/gui/lists.py b/graphs/gui/lists.py index a63efebcd8..2b682074c8 100644 --- a/graphs/gui/lists.py +++ b/graphs/gui/lists.py @@ -44,8 +44,11 @@ def __init__(self, graphFrame, parent): self.hoveredRow = None self.hoveredColumn = None + self._graphSelectionRedrawPending = False self.Bind(wx.EVT_CHAR_HOOK, self.kbEvent) + self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnGraphListSelectionChanged) + self.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.OnGraphListSelectionChanged) self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown) self.Bind(wx.EVT_LEFT_DCLICK, self.OnLeftDClick) self.Bind(wx.EVT_MOTION, self.OnMouseMove) @@ -56,6 +59,16 @@ def wrappers(self): # Sort fits first, then target profiles return sorted(self._wrappers, key=lambda w: not w.isFit) + def OnGraphListSelectionChanged(self, event): + event.Skip() + if not self._graphSelectionRedrawPending: + self._graphSelectionRedrawPending = True + wx.CallAfter(self._flushGraphSelectionRedraw) + + def _flushGraphSelectionRedraw(self): + self._graphSelectionRedrawPending = False + self.graphFrame.draw() + # UI-related stuff @property def defaultTTText(self): @@ -121,23 +134,24 @@ def handleDrag(self, type, fitID): def OnLeftDown(self, event): row, _ = self.HitTest(event.Position) - if row != -1: - pickers = { - self.getColIndex(GraphColor): ColorPickerPopup, - self.getColIndex(GraphLightness): LightnessPickerPopup, - self.getColIndex(GraphLineStyle): LineStylePickerPopup} - # In case we had no index for some column, remove None - pickers.pop(None, None) - col = self.getColumn(event.Position) - if col in pickers: - picker = pickers[col] - wrapper = self.getWrapper(row) - if wrapper is not None: - win = picker(parent=self, wrapper=wrapper) - pos = wx.GetMousePosition() - win.Position(pos, (0, 0)) - win.Popup() - return + if row == -1: + self.unselectAll() + event.Skip() + return + pickers = { + self.getColIndex(GraphColor): ColorPickerPopup, + self.getColIndex(GraphLightness): LightnessPickerPopup, + self.getColIndex(GraphLineStyle): LineStylePickerPopup} + pickers.pop(None, None) + col = self.getColumn(event.Position) + if col in pickers: + wrapper = self.getWrapper(row) + if wrapper is not None: + win = pickers[col](parent=self, wrapper=wrapper) + pos = wx.GetMousePosition() + win.Position(pos, (0, 0)) + win.Popup() + return event.Skip() def OnLineStyleChange(self): @@ -310,7 +324,7 @@ def spawnMenu(self, event): @property def defaultTTText(self): - return _t('Drag a fit into this list to graph it') + return _t('Drag a fit into this list to graph it. Select rows to filter attackers (Shift/Ctrl); click empty space to show all attackers.') class TargetWrapperList(BaseWrapperList): @@ -367,7 +381,7 @@ def OnResistModeChanged(self, event): @property def defaultTTText(self): - return _t('Drag a fit into this list to have your fits graphed against it') + return _t('Drag a fit into this list to have your fits graphed against it. Select rows to filter targets (Shift/Ctrl); click empty space to show all targets.') # Context menu handlers def addProfile(self, profile): From e4d214626637e96fdd105bf4a55931d581a7522f Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Sun, 12 Apr 2026 20:28:07 +0300 Subject: [PATCH 2/8] Recalculate all graph fits so mutual projections apply to every ship When multiple fits are listed, only the active tab normally receives a full local recalc; other fits keep stale attributes for incoming remote effects. Run recalc for each unique fit in the graph before drawing so speeds and DPS match any tab. Made-with: Cursor --- graphs/gui/frame.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index 4313b81d70..6028391550 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -30,6 +30,7 @@ from gui.auxWindow import AuxiliaryFrame from gui.bitmap_loader import BitmapLoader from service.const import GraphCacheCleanupReason +from service.fit import Fit from service.settings import GraphSettings from . import canvasPanel from .ctrlPanel import GraphControlPanel @@ -239,7 +240,30 @@ def getView(self, idx=None): def clearCache(self, reason, extraData=None): self.getView().clearCache(reason, extraData) + def _ensureGraphFitsRecalculated(self): + """ + Recalculate every fit shown in the graph when multiple ships are listed. + + The main window only runs a full local calculation for the active tab. Other + loaded fits can keep stale ship attributes for incoming projections (mutual + projected effects) until they become active, which breaks multi-fit graphs. + """ + ctrl = self.ctrlPanel + sFit = Fit.getInstance() + seen = set() + fits = [] + for wrapper in ctrl.sources + ctrl.targets: + if not wrapper.isFit or wrapper.item.ID in seen: + continue + seen.add(wrapper.item.ID) + fits.append(wrapper.item) + if len(fits) < 2: + return + for fit in fits: + sFit.recalc(fit) + def draw(self): + self._ensureGraphFitsRecalculated() self.canvasPanel.draw() def resetXMark(self): From b64734a2856ba4c13e0d602a633c06c99fde4b36 Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Sun, 12 Apr 2026 20:39:28 +0300 Subject: [PATCH 3/8] more fixes --- eos/saveddata/fit.py | 19 +++++++++++++++++++ graphs/gui/frame.py | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/eos/saveddata/fit.py b/eos/saveddata/fit.py index eb4587b616..a8d6ea7bef 100644 --- a/eos/saveddata/fit.py +++ b/eos/saveddata/fit.py @@ -19,6 +19,7 @@ import datetime import time +from contextlib import contextmanager from copy import deepcopy from itertools import chain from math import ceil, log, sqrt @@ -67,6 +68,22 @@ class Fit: PEAK_RECHARGE = 0.25 + # When > 0, __resetDependentCalcs does not mark projection victims stale. + # Sequential full recalcs for mutually-projected fits (e.g. graph) would + # otherwise set victim.calculated = False without clearing them; the next + # PROJECTED pass then runs clear() and wipes that fit after it was just + # calculated correctly. + _suspendVictimCalcResetDepth = 0 + + @classmethod + @contextmanager + def suspendVictimCalcReset(cls): + cls._suspendVictimCalcResetDepth += 1 + try: + yield + finally: + cls._suspendVictimCalcResetDepth -= 1 + def __init__(self, ship=None, name=""): """Initialize a fit from the program""" self.__ship = None @@ -973,6 +990,8 @@ def __runCommandBoosts(self, runTime="normal"): def __resetDependentCalcs(self): self.calculated = False + if Fit._suspendVictimCalcResetDepth > 0: + return for value in list(self.projectedOnto.values()): if value.victim_fit: # removing a self-projected fit causes victim fit to be None. @todo: look into why. :3 value.victim_fit.calculated = False diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index 6028391550..f382a49331 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -25,6 +25,7 @@ import gui.display import gui.globalEvents as GE import gui.mainFrame +from eos.saveddata.fit import Fit as EosFit from graphs.data.base import FitGraph from graphs.events import RESIST_MODE_CHANGED from gui.auxWindow import AuxiliaryFrame @@ -259,8 +260,13 @@ def _ensureGraphFitsRecalculated(self): fits.append(wrapper.item) if len(fits) < 2: return - for fit in fits: - sFit.recalc(fit) + # Without this, each recalc's __resetDependentCalcs marks projection victims + # as not calculated; the next fit's calculation then invokes those victims in + # PROJECTED mode with calculated=False, which runs clear() and wipes ship state + # built by the previous recalc in the batch. + with EosFit.suspendVictimCalcReset(): + for fit in fits: + sFit.recalc(fit) def draw(self): self._ensureGraphFitsRecalculated() From cd09b4b3ea52c7e7df0f688e0b88f8dfed433f03 Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Mon, 13 Apr 2026 10:08:30 +0300 Subject: [PATCH 4/8] Keep focus-graph scoped to graph UI logic Remove projection engine changes from this branch so it only carries graph behavior updates and remains cleanly composable with fix-projected-on-tab-switch. Made-with: Cursor --- eos/saveddata/fit.py | 19 ------------------- graphs/gui/frame.py | 10 ++-------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/eos/saveddata/fit.py b/eos/saveddata/fit.py index a8d6ea7bef..eb4587b616 100644 --- a/eos/saveddata/fit.py +++ b/eos/saveddata/fit.py @@ -19,7 +19,6 @@ import datetime import time -from contextlib import contextmanager from copy import deepcopy from itertools import chain from math import ceil, log, sqrt @@ -68,22 +67,6 @@ class Fit: PEAK_RECHARGE = 0.25 - # When > 0, __resetDependentCalcs does not mark projection victims stale. - # Sequential full recalcs for mutually-projected fits (e.g. graph) would - # otherwise set victim.calculated = False without clearing them; the next - # PROJECTED pass then runs clear() and wipes that fit after it was just - # calculated correctly. - _suspendVictimCalcResetDepth = 0 - - @classmethod - @contextmanager - def suspendVictimCalcReset(cls): - cls._suspendVictimCalcResetDepth += 1 - try: - yield - finally: - cls._suspendVictimCalcResetDepth -= 1 - def __init__(self, ship=None, name=""): """Initialize a fit from the program""" self.__ship = None @@ -990,8 +973,6 @@ def __runCommandBoosts(self, runTime="normal"): def __resetDependentCalcs(self): self.calculated = False - if Fit._suspendVictimCalcResetDepth > 0: - return for value in list(self.projectedOnto.values()): if value.victim_fit: # removing a self-projected fit causes victim fit to be None. @todo: look into why. :3 value.victim_fit.calculated = False diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index f382a49331..6028391550 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -25,7 +25,6 @@ import gui.display import gui.globalEvents as GE import gui.mainFrame -from eos.saveddata.fit import Fit as EosFit from graphs.data.base import FitGraph from graphs.events import RESIST_MODE_CHANGED from gui.auxWindow import AuxiliaryFrame @@ -260,13 +259,8 @@ def _ensureGraphFitsRecalculated(self): fits.append(wrapper.item) if len(fits) < 2: return - # Without this, each recalc's __resetDependentCalcs marks projection victims - # as not calculated; the next fit's calculation then invokes those victims in - # PROJECTED mode with calculated=False, which runs clear() and wipes ship state - # built by the previous recalc in the batch. - with EosFit.suspendVictimCalcReset(): - for fit in fits: - sFit.recalc(fit) + for fit in fits: + sFit.recalc(fit) def draw(self): self._ensureGraphFitsRecalculated() From 802e95485f34ef3e8a59b51ce97eb07bfcd5cf0e Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Mon, 13 Apr 2026 10:13:36 +0300 Subject: [PATCH 5/8] Use suspendVictimCalcReset during graph batch recalculation Wrap sequential graph recalc loop so projection victims are not marked stale mid-batch, preventing order-dependent mutual projection errors. Made-with: Cursor --- graphs/gui/frame.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index 6028391550..6b69dc3164 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -25,6 +25,7 @@ import gui.display import gui.globalEvents as GE import gui.mainFrame +from eos.saveddata.fit import Fit as EosFit from graphs.data.base import FitGraph from graphs.events import RESIST_MODE_CHANGED from gui.auxWindow import AuxiliaryFrame @@ -259,8 +260,9 @@ def _ensureGraphFitsRecalculated(self): fits.append(wrapper.item) if len(fits) < 2: return - for fit in fits: - sFit.recalc(fit) + with EosFit.suspendVictimCalcReset(): + for fit in fits: + sFit.recalc(fit) def draw(self): self._ensureGraphFitsRecalculated() From 0f6473b98632afe61268939671f132552e09407a Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Mon, 13 Apr 2026 10:21:01 +0300 Subject: [PATCH 6/8] Clarify reverse matchup key mapping names Rename temporary variables in expand_reverse_filtered_matchups to make source/target reversal intent explicit and reduce future confusion. Made-with: Cursor --- graphs/gui/canvasPanel.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py index 3fb57f1946..1b08f1baf8 100644 --- a/graphs/gui/canvasPanel.py +++ b/graphs/gui/canvasPanel.py @@ -67,12 +67,13 @@ def expand_reverse_filtered_matchups(ctrl, base_pairs): seen = set((id(s), id(t)) for s, t in base_pairs) out = list(base_pairs) for s, t in base_pairs: - ks = _graph_item_key(t.item) - kt = _graph_item_key(s.item) - if ks is None or kt is None: + # Reverse matchup means target ship becomes attacker and vice versa. + reverse_src_key = _graph_item_key(t.item) + reverse_tgt_key = _graph_item_key(s.item) + if reverse_src_key is None or reverse_tgt_key is None: continue - rev_s = src_by_key.get(ks) - rev_t = tgt_by_key.get(kt) + rev_s = src_by_key.get(reverse_src_key) + rev_t = tgt_by_key.get(reverse_tgt_key) if rev_s is None or rev_t is None: continue key = (id(rev_s), id(rev_t)) From f0116ffd95e507c43b5c46dc803ea1ddc2aa8c4c Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Mon, 13 Apr 2026 11:05:01 +0300 Subject: [PATCH 7/8] avoid direct dependency on another branch --- graphs/gui/frame.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index 6b69dc3164..9137aa2b26 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -19,6 +19,8 @@ # noinspection PyPackageRequirements +from contextlib import nullcontext + import wx from logbook import Logger @@ -260,7 +262,8 @@ def _ensureGraphFitsRecalculated(self): fits.append(wrapper.item) if len(fits) < 2: return - with EosFit.suspendVictimCalcReset(): + suspend_ctx = getattr(EosFit, 'suspendVictimCalcReset', None) + with (suspend_ctx() if callable(suspend_ctx) else nullcontext()): for fit in fits: sFit.recalc(fit) From bc10f770ef299f4c7b916f254b6114f798428b24 Mon Sep 17 00:00:00 2001 From: Skybladev2 Date: Tue, 14 Apr 2026 11:42:50 +0300 Subject: [PATCH 8/8] Use wrapper keys for reverse filtered matchups. Make reverse matchup expansion rely on graph wrapper type checks instead of direct item class checks so the checkbox consistently adds reverse pairs. Made-with: Cursor --- graphs/gui/canvasPanel.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py index 1b08f1baf8..74ee34e9fd 100644 --- a/graphs/gui/canvasPanel.py +++ b/graphs/gui/canvasPanel.py @@ -30,8 +30,6 @@ from logbook import Logger -from eos.saveddata.fit import Fit -from eos.saveddata.targetProfile import TargetProfile from graphs.style import BASE_COLORS, LIGHTNESSES, STYLES, hsl_to_hsv from gui.utils.numberFormatter import roundToPrec @@ -39,11 +37,13 @@ pyfalog = Logger(__name__) -def _graph_item_key(item): - if isinstance(item, Fit): - return ('fit', item.ID) - if isinstance(item, TargetProfile): - return ('profile', item.ID) +def _graph_wrapper_key(wrapper): + if wrapper is None or wrapper.item is None: + return None + if wrapper.isFit: + return 'fit', wrapper.item.ID + if wrapper.isProfile: + return 'profile', wrapper.item.ID return None @@ -56,20 +56,20 @@ def expand_reverse_filtered_matchups(ctrl, base_pairs): return base_pairs src_by_key = {} for w in ctrl.sources: - k = _graph_item_key(w.item) + k = _graph_wrapper_key(w) if k: src_by_key[k] = w tgt_by_key = {} for w in ctrl.targets: - k = _graph_item_key(w.item) + k = _graph_wrapper_key(w) if k: tgt_by_key[k] = w seen = set((id(s), id(t)) for s, t in base_pairs) out = list(base_pairs) for s, t in base_pairs: # Reverse matchup means target ship becomes attacker and vice versa. - reverse_src_key = _graph_item_key(t.item) - reverse_tgt_key = _graph_item_key(s.item) + reverse_src_key = _graph_wrapper_key(t) + reverse_tgt_key = _graph_wrapper_key(s) if reverse_src_key is None or reverse_tgt_key is None: continue rev_s = src_by_key.get(reverse_src_key)