diff --git a/.gitignore b/.gitignore
index 8f34001cbd..1c46a16598 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@
#Pyfa file
pyfaFits.html
+#Local EVE static data dump
+eve.db
+
#Temporary files
*.py__jb_tmp__
diff --git a/graphs/data/__init__.py b/graphs/data/__init__.py
index b3a8097a59..650239edee 100644
--- a/graphs/data/__init__.py
+++ b/graphs/data/__init__.py
@@ -19,7 +19,7 @@
from . import fitDamageStats
-from . import fitDamageEnvelope
+from . import fitApplicationProfile
from . import fitEwarStats
from . import fitRemoteReps
from . import fitShieldRegen
diff --git a/graphs/data/fitDamageEnvelope/__init__.py b/graphs/data/fitApplicationProfile/__init__.py
similarity index 91%
rename from graphs/data/fitDamageEnvelope/__init__.py
rename to graphs/data/fitApplicationProfile/__init__.py
index 03378417e7..a5480b0340 100644
--- a/graphs/data/fitDamageEnvelope/__init__.py
+++ b/graphs/data/fitApplicationProfile/__init__.py
@@ -17,7 +17,7 @@
# along with pyfa. If not, see .
# =============================================================================
+from .graph import FitAmmoOptimalDpsGraph
-from .graph import FitDamageEnvelopeGraph
-FitDamageEnvelopeGraph.register()
+FitAmmoOptimalDpsGraph.register()
diff --git a/graphs/data/fitApplicationProfile/calc/__init__.py b/graphs/data/fitApplicationProfile/calc/__init__.py
new file mode 100644
index 0000000000..e4ed3e84d2
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/__init__.py
@@ -0,0 +1,24 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+# Import key functions for convenient access
+from .projected import (
+ buildProjectedCache,
+ getProjectedParamsAtDistance,
+)
diff --git a/graphs/data/fitApplicationProfile/calc/charges.py b/graphs/data/fitApplicationProfile/calc/charges.py
new file mode 100644
index 0000000000..2eda421c6e
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/charges.py
@@ -0,0 +1,255 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Navy faction ammo prefixes (for S/M/L ammo)
+NAVY_PREFIXES = (
+ 'Imperial Navy ',
+ 'Republic Fleet ',
+ 'Caldari Navy ',
+ 'Federation Navy ',
+)
+
+# Capital (XL) "navy-tier" faction ammo prefixes
+# There is no empire Navy XL ammo, so pirate faction serves as the "navy" tier for capitals
+CAPITAL_NAVY_PREFIXES = (
+ 'Sansha ',
+ 'Arch Angel ',
+ 'Shadow ',
+)
+
+
+# =============================================================================
+# Quality Tier Filtering
+# =============================================================================
+
+def filterChargesByQuality(charges, qualityTier):
+ """
+ Filter charges based on quality tier selection.
+
+ Args:
+ charges: List of charge items
+ qualityTier: 't1', 'navy', or 'all'
+
+ Returns:
+ Filtered list of charges
+
+ Tiers are cumulative (each tier includes everything below it):
+ - 't1': Tech I only (metaGroup 1)
+ - 'navy': t1 + Tech II (metaGroup 2) + Navy faction ammo (Imperial Navy,
+ Republic Fleet, Caldari Navy, Federation Navy)
+ For XL (capital) ammo: includes pirate faction (Sansha, Arch Angel, Shadow)
+ - 'all': Everything including high-tier faction (Blood, Dark Blood, True Sansha, etc.)
+
+ Charges with no meta group in the game data (metaGroupID is NULL - e.g. all
+ Baryon Exotic Plasma and every XL Triglavian charge) are treated as Tech I.
+ Otherwise they would be filtered out of every tier despite being basic ammo.
+ """
+ if qualityTier == 'all':
+ return charges
+
+ filtered = []
+ classifiable = False # Did any charge have a meta group we could rank?
+ for charge in charges:
+ mg = charge.metaGroup
+ mgId = mg.ID if mg else None
+ if mgId is not None:
+ classifiable = True
+
+ # Tech I (metaGroup 1), or unclassified ammo (NULL metaGroup) treated as
+ # Tech I - always included in every tier.
+ if mgId == 1 or mgId is None:
+ filtered.append(charge)
+ continue
+
+ # 'navy' tier additionally includes Tech II and Navy faction ammo.
+ if qualityTier == 'navy':
+ # Tech II (metaGroup 2) - distinct ammo type like Conflagration, Void, etc.
+ if mgId == 2:
+ filtered.append(charge)
+ continue
+
+ # Navy faction ammo (metaGroup 4)
+ if mgId == 4:
+ # Check if it's XL (capital) ammo by name suffix
+ isCapital = charge.name.endswith(' XL')
+
+ if isCapital:
+ # For capital ammo, use pirate faction prefixes as "navy" tier
+ if any(charge.name.startswith(prefix) for prefix in CAPITAL_NAVY_PREFIXES):
+ filtered.append(charge)
+ else:
+ # For subcap ammo, use empire Navy prefixes
+ if any(charge.name.startswith(prefix) for prefix in NAVY_PREFIXES):
+ filtered.append(charge)
+
+ # Honor the user's tier selection even when it excludes every charge (the
+ # weapon simply has no ammo in this tier). Only fall back to the full list
+ # when no charge could be classified by meta group at all - in that case
+ # the tier system does not apply and returning nothing would wrongly hide
+ # the weapon.
+ if filtered or classifiable:
+ return filtered
+ return charges
+
+
+# =============================================================================
+# Charge Stats Extraction
+# =============================================================================
+
+def getChargeStats(charge):
+ """
+ Extract charge stats including damage values and multipliers.
+
+ Args:
+ charge: The charge item
+
+ Returns:
+ Dict with damage values and range/falloff/tracking multipliers
+ """
+ em = charge.getAttribute('emDamage') or 0
+ thermal = charge.getAttribute('thermalDamage') or 0
+ kinetic = charge.getAttribute('kineticDamage') or 0
+ explosive = charge.getAttribute('explosiveDamage') or 0
+
+ return {
+ 'emDamage': em,
+ 'thermalDamage': thermal,
+ 'kineticDamage': kinetic,
+ 'explosiveDamage': explosive,
+ 'totalDamage': em + thermal + kinetic + explosive,
+ 'rangeMultiplier': charge.getAttribute('weaponRangeMultiplier') or 1,
+ 'falloffMultiplier': charge.getAttribute('fallofMultiplier') or 1,
+ 'trackingMultiplier': charge.getAttribute('trackingSpeedMultiplier') or 1
+ }
+
+
+# =============================================================================
+# Resist Application
+# =============================================================================
+
+def applyResists(chargeStats, tgtResists):
+ """
+ Apply target resists to charge stats.
+
+ Args:
+ chargeStats: Dict from getChargeStats
+ tgtResists: Tuple of (em, therm, kin, explo) resist values (0-1)
+
+ Returns:
+ New dict with resisted damage values
+ """
+ if not tgtResists:
+ return chargeStats
+
+ emRes, thermRes, kinRes, exploRes = tgtResists
+
+ em = chargeStats['emDamage'] * (1 - emRes)
+ thermal = chargeStats['thermalDamage'] * (1 - thermRes)
+ kinetic = chargeStats['kineticDamage'] * (1 - kinRes)
+ explosive = chargeStats['explosiveDamage'] * (1 - exploRes)
+
+ result = chargeStats.copy()
+ result.update({
+ 'emDamage': em,
+ 'thermalDamage': thermal,
+ 'kineticDamage': kinetic,
+ 'explosiveDamage': explosive,
+ 'totalDamage': em + thermal + kinetic + explosive
+ })
+ return result
+
+
+# =============================================================================
+# Charge Data Precomputation
+# =============================================================================
+
+def precomputeChargeData(turretBase, charges, skillMult=1.0, tgtResists=None):
+ """
+ Pre-compute constant values for each charge.
+
+ This computes effective stats (turret base * charge multipliers) and
+ raw volley for each charge, which can then be used for fast lookups.
+
+ Args:
+ turretBase: Base turret stats dict from getTurretBaseStats
+ charges: List of charge items
+ skillMult: Skill damage multiplier from getSkillMultiplier
+ tgtResists: Target resists tuple or None
+
+ Returns:
+ List of dicts with: name, raw_volley, effective_optimal,
+ effective_falloff, effective_tracking
+
+ Note: We do NOT store raw_dps - it's derived from raw_volley / cycle_time
+ when needed at the mixin level.
+ """
+ chargeData = []
+
+ for charge in charges:
+ stats = getChargeStats(charge)
+
+ # Apply resists early for efficiency
+ if tgtResists:
+ stats = applyResists(stats, tgtResists)
+
+ # Compute effective turret stats with charge modifiers
+ effectiveOptimal = turretBase['optimal'] * stats['rangeMultiplier']
+ effectiveFalloff = turretBase['falloff'] * stats['falloffMultiplier']
+ effectiveTracking = turretBase['tracking'] * stats['trackingMultiplier']
+
+ # Compute raw volley (unmodified by range/tracking)
+ rawVolley = stats['totalDamage'] * skillMult * turretBase['damageMultiplier']
+
+ chargeData.append({
+ 'name': charge.name,
+ 'raw_volley': rawVolley,
+ 'effective_optimal': effectiveOptimal,
+ 'effective_falloff': effectiveFalloff,
+ 'effective_tracking': effectiveTracking
+ })
+
+ return chargeData
+
+
+def getLongestRangeMultiplier(charges):
+ """
+ Get the maximum range multiplier from a list of charges.
+
+ Used to calculate the max effective range of a turret for cache sizing.
+
+ Args:
+ charges: List of charge items
+
+ Returns:
+ The highest rangeMultiplier value among all charges
+ """
+ if not charges:
+ return 1.0
+
+ maxRangeMult = 1.0
+ for charge in charges:
+ rangeMult = charge.getAttribute('weaponRangeMultiplier') or 1.0
+ if rangeMult > maxRangeMult:
+ maxRangeMult = rangeMult
+
+ return maxRangeMult
diff --git a/graphs/data/fitApplicationProfile/calc/launcher.py b/graphs/data/fitApplicationProfile/calc/launcher.py
new file mode 100644
index 0000000000..3d0353d590
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/launcher.py
@@ -0,0 +1,682 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import math
+from bisect import bisect_right
+
+from .projected import getProjectedParamsAtDistance
+
+
+# =============================================================================
+# Missile Application Factor
+# =============================================================================
+
+def calcMissileFactor(atkEr, atkEv, atkDrf, tgtSpeed, tgtSigRadius):
+ """
+ Calculate missile application factor.
+
+ Formula: min(1, tgtSigRadius/eR, ((eV * tgtSigRadius) / (eR * tgtSpeed))^DRF)
+
+ Args:
+ atkEr: Missile explosion radius (aoeCloudSize) in meters
+ atkEv: Missile explosion velocity (aoeVelocity) in m/s
+ atkDrf: Missile damage reduction factor (aoeDamageReductionFactor)
+ tgtSpeed: Target velocity (m/s)
+ tgtSigRadius: Target signature radius (m)
+
+ Returns:
+ Application factor (0-1)
+ """
+ factors = [1]
+ # "Slow" part - signature vs explosion radius
+ if atkEr > 0:
+ factors.append(tgtSigRadius / atkEr)
+ # "Fast" part - explosion velocity vs target speed (raised to DRF power)
+ if tgtSpeed > 0 and atkEr > 0:
+ factors.append(((atkEv * tgtSigRadius) / (atkEr * tgtSpeed)) ** atkDrf)
+ return min(factors)
+
+
+# =============================================================================
+# Multiplier Extraction
+# =============================================================================
+
+def _extractMultiplier(mod, attr):
+ """
+ Extract multiplier for a specific attribute.
+
+ If the base value is 0 (e.g. Mjolnir has 0 thermal damage), we cannot
+ calculate the multiplier by division (x / 0).
+
+ In that case, we temporarily inject a base value of 1.0 into the modifier
+ dictionary, read the modified value (which will be 1.0 * multiplier),
+ and use that as the multiplier.
+ """
+ base = mod.getChargeBaseAttrValue(attr) or 0
+
+ if base > 0:
+ modified = mod.getModifiedChargeAttr(attr) or 0
+ return modified / base
+
+ # Base is 0, we need to trick the eos logic to give us the multiplier
+ # We use preAssign to set the base value to 1.0 for this calculation
+ mod.chargeModifiedAttributes.preAssign(attr, 1.0)
+ try:
+ # Get the modified value, which should now be 1.0 * multiplier
+ multiplier = mod.getModifiedChargeAttr(attr) or 1.0
+ finally:
+ # Cleanup: remove the preAssign
+ # Accessing private members is naughty but eos doesn't give us a clean way to remove preAssigns
+ # and we must clean up to avoid side effects
+ if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__preAssigns:
+ del mod.chargeModifiedAttributes._ModifiedAttributeDict__preAssigns[attr]
+ # Force recalculation by removing from cache
+ if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__modified:
+ del mod.chargeModifiedAttributes._ModifiedAttributeDict__modified[attr]
+ if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__intermediary:
+ del mod.chargeModifiedAttributes._ModifiedAttributeDict__intermediary[attr]
+
+ return multiplier
+
+def getDamageMultipliers(mod):
+ """
+ Extract per-damage-type multipliers by comparing modified to base values.
+
+ This captures all skill bonuses (Warhead Upgrades, etc.) and ship bonuses
+ that affect missile damage. Different damage types may have different bonuses
+ (e.g., Gila has kinetic/thermal bonus).
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Dict with multipliers for emDamage, thermalDamage, kineticDamage, explosiveDamage
+ """
+ if mod.charge is None:
+ return {
+ 'emDamage': 1.0,
+ 'thermalDamage': 1.0,
+ 'kineticDamage': 1.0,
+ 'explosiveDamage': 1.0
+ }
+
+ multipliers = {}
+ for dmgType in ('emDamage', 'thermalDamage', 'kineticDamage', 'explosiveDamage'):
+ multipliers[dmgType] = _extractMultiplier(mod, dmgType)
+
+ return multipliers
+
+
+def getFlightMultipliers(mod):
+ """
+ Extract flight attribute multipliers by comparing modified to base values.
+
+ This captures skill bonuses from Missile Projection, Missile Bombardment,
+ and ship bonuses that affect flight time/velocity.
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Dict with multipliers for maxVelocity and explosionDelay
+ """
+ if mod.charge is None:
+ return {'maxVelocity': 1.0, 'explosionDelay': 1.0}
+
+ multipliers = {}
+ for attr in ('maxVelocity', 'explosionDelay'):
+ multipliers[attr] = _extractMultiplier(mod, attr)
+
+ return multipliers
+
+
+def getApplicationMultipliers(mod):
+ """
+ Extract application attribute multipliers by comparing modified to base values.
+
+ This captures skills like Guided Missile Precision, Target Navigation Prediction,
+ and rigging/implant bonuses that affect explosion radius/velocity.
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Dict with multipliers for aoeCloudSize, aoeVelocity, aoeDamageReductionFactor
+ """
+ if mod.charge is None:
+ return {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0}
+
+ multipliers = {}
+ for attr in ('aoeCloudSize', 'aoeVelocity', 'aoeDamageReductionFactor'):
+ multipliers[attr] = _extractMultiplier(mod, attr)
+
+ return multipliers
+
+
+def getAllMultipliers(mod):
+ """
+ Extract all multipliers (damage, flight, application) from a module.
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Tuple of (damageMults, flightMults, appMults)
+ """
+ return (
+ getDamageMultipliers(mod),
+ getFlightMultipliers(mod),
+ getApplicationMultipliers(mod)
+ )
+
+
+# =============================================================================
+# Range Calculation
+# =============================================================================
+
+def calculateMissileRange(maxVelocity, mass, agility, flightTime):
+ """
+ Calculate missile range for a given flight time.
+
+ Uses EVE formula accounting for acceleration time.
+ Source: http://www.eveonline.com/ingameboard.asp?a=topic&threadID=1307419&page=1#15
+
+ D_m = V_m * (T_m + T_0*[exp(- T_m/T_0)-1])
+
+ Simplified: acceleration time = min(flightTime, mass * agility / 1e6)
+
+ Args:
+ maxVelocity: Missile max velocity (m/s)
+ mass: Missile mass (kg)
+ agility: Missile agility
+ flightTime: Flight time (seconds)
+
+ Returns:
+ Range in meters
+ """
+ accelTime = min(flightTime, mass * agility / 1000000)
+ # Average distance during acceleration (starts at 0, ends at maxVelocity)
+ duringAcceleration = maxVelocity / 2 * accelTime
+ # Distance at full speed
+ fullSpeed = maxVelocity * (flightTime - accelTime)
+ return duringAcceleration + fullSpeed
+
+
+def getMissileRangeData(charge, shipRadius, damageMults=None, flightMults=None, appMults=None):
+ """
+ Calculate missile range data for a charge with applied multipliers.
+
+ EVE missiles have discrete flight times - if flight time is 1.3s, there's
+ a 30% chance of flying 2s and 70% chance of flying 1s.
+
+ Args:
+ charge: Missile charge item
+ shipRadius: Launching ship's radius (affects flight time)
+ damageMults: Damage multipliers dict (or None for base values)
+ flightMults: Flight multipliers dict (or None for base values)
+ appMults: Application multipliers dict (or None for base values)
+
+ Returns:
+ Dict with: lowerRange, higherRange, higherChance, maxEffectiveRange,
+ and all computed stats
+ """
+ if flightMults is None:
+ flightMults = {'maxVelocity': 1.0, 'explosionDelay': 1.0}
+ if appMults is None:
+ appMults = {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0}
+ if damageMults is None:
+ damageMults = {'emDamage': 1.0, 'thermalDamage': 1.0, 'kineticDamage': 1.0, 'explosiveDamage': 1.0}
+
+ # Get base charge attributes
+ baseVelocity = charge.getAttribute('maxVelocity') or 0
+ baseExplosionDelay = charge.getAttribute('explosionDelay') or 0
+ baseMass = charge.getAttribute('mass') or 1
+ baseAgility = charge.getAttribute('agility') or 1
+
+ if baseVelocity <= 0 or baseExplosionDelay <= 0:
+ return None
+
+ # Apply flight multipliers
+ maxVelocity = baseVelocity * flightMults['maxVelocity']
+ explosionDelay = baseExplosionDelay * flightMults['explosionDelay']
+
+ # Calculate flight time (includes ship radius bonus)
+ # Flight time has bonus based on ship radius: https://github.com/pyfa-org/Pyfa/issues/2083
+ flightTime = explosionDelay / 1000 + shipRadius / maxVelocity
+
+ # Discrete flight time: floor and ceil
+ lowerTime = math.floor(flightTime)
+ higherTime = math.ceil(flightTime)
+ higherChance = flightTime - lowerTime # Probability of flying the extra second
+
+ # Calculate ranges
+ lowerRange = calculateMissileRange(maxVelocity, baseMass, baseAgility, lowerTime)
+ higherRange = calculateMissileRange(maxVelocity, baseMass, baseAgility, higherTime)
+
+ # Make range center-to-surface (missiles spawn at ship center)
+ lowerRange = max(0, lowerRange - shipRadius)
+ higherRange = max(0, higherRange - shipRadius)
+
+ # Max effective range uses ceil(flightTime) * velocity for sorting
+ maxEffectiveRange = higherRange
+
+ # Get application stats with multipliers
+ baseEr = charge.getAttribute('aoeCloudSize') or 0
+ baseEv = charge.getAttribute('aoeVelocity') or 0
+ baseDrf = charge.getAttribute('aoeDamageReductionFactor') or 1
+
+ explosionRadius = baseEr * appMults['aoeCloudSize']
+ explosionVelocity = baseEv * appMults['aoeVelocity']
+ damageReductionFactor = baseDrf * appMults['aoeDamageReductionFactor']
+
+ # Get damage with multipliers
+ baseEm = charge.getAttribute('emDamage') or 0
+ baseThermal = charge.getAttribute('thermalDamage') or 0
+ baseKinetic = charge.getAttribute('kineticDamage') or 0
+ baseExplosive = charge.getAttribute('explosiveDamage') or 0
+
+ em = baseEm * damageMults['emDamage']
+ thermal = baseThermal * damageMults['thermalDamage']
+ kinetic = baseKinetic * damageMults['kineticDamage']
+ explosive = baseExplosive * damageMults['explosiveDamage']
+ totalDamage = em + thermal + kinetic + explosive
+
+ return {
+ 'lowerRange': lowerRange,
+ 'higherRange': higherRange,
+ 'higherChance': higherChance,
+ 'maxEffectiveRange': maxEffectiveRange,
+ 'explosionRadius': explosionRadius,
+ 'explosionVelocity': explosionVelocity,
+ 'damageReductionFactor': damageReductionFactor,
+ 'totalDamage': totalDamage,
+ 'emDamage': em,
+ 'thermalDamage': thermal,
+ 'kineticDamage': kinetic,
+ 'explosiveDamage': explosive
+ }
+
+
+# =============================================================================
+# Charge Data Precomputation
+# =============================================================================
+
+# Damage type priority for tie-breaking (EM > Thermal > Kinetic > Explosive)
+DAMAGE_TYPE_PRIORITY = {
+ 'em': 0,
+ 'thermal': 1,
+ 'kinetic': 2,
+ 'explosive': 3
+}
+
+
+def getDominantDamageType(chargeName):
+ """
+ Determine the dominant damage type of a missile based on its name.
+
+ Mjolnir = EM, Inferno = Thermal, Scourge = Kinetic, Nova = Explosive
+
+ Args:
+ chargeName: Missile name
+
+ Returns:
+ 'em', 'thermal', 'kinetic', 'explosive', or 'unknown'
+ """
+ nameLower = chargeName.lower()
+ if 'mjolnir' in nameLower:
+ return 'em'
+ elif 'inferno' in nameLower:
+ return 'thermal'
+ elif 'scourge' in nameLower:
+ return 'kinetic'
+ elif 'nova' in nameLower:
+ return 'explosive'
+ return 'unknown'
+
+
+def precomputeMissileChargeData(mod, charges, cycleTimeMs, shipRadius,
+ damageMults=None, flightMults=None, appMults=None,
+ tgtResists=None):
+ """
+ Pre-compute constant values for each missile charge.
+
+ Args:
+ mod: Launcher module
+ charges: List of valid missile charges
+ cycleTimeMs: Launcher cycle time in milliseconds
+ shipRadius: Ship radius for flight calculations
+ damageMults: Per-damage-type multipliers from skills/ship
+ flightMults: Flight attribute multipliers
+ appMults: Application attribute multipliers
+ tgtResists: Target resist tuple (em, therm, kin, explo) or None
+
+ Returns:
+ List of charge data dicts, sorted by maxEffectiveRange descending
+ """
+ if damageMults is None:
+ damageMults = {'emDamage': 1.0, 'thermalDamage': 1.0, 'kineticDamage': 1.0, 'explosiveDamage': 1.0}
+ if flightMults is None:
+ flightMults = {'maxVelocity': 1.0, 'explosionDelay': 1.0}
+ if appMults is None:
+ appMults = {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0}
+
+ # Get launcher damage multiplier
+ launcherDamageMult = mod.getModifiedItemAttr('damageMultiplier') or 1
+
+ chargeData = []
+ for charge in charges:
+ rangeData = getMissileRangeData(charge, shipRadius, damageMults, flightMults, appMults)
+ if rangeData is None:
+ continue
+
+ # Apply target resists
+ totalDamage = rangeData['totalDamage']
+ if tgtResists:
+ emRes, thermRes, kinRes, exploRes = tgtResists
+ totalDamage = (
+ rangeData['emDamage'] * (1 - emRes) +
+ rangeData['thermalDamage'] * (1 - thermRes) +
+ rangeData['kineticDamage'] * (1 - kinRes) +
+ rangeData['explosiveDamage'] * (1 - exploRes)
+ )
+
+ # Calculate raw volley and DPS
+ rawVolley = totalDamage * launcherDamageMult
+ rawDps = rawVolley / (cycleTimeMs / 1000) if cycleTimeMs > 0 else 0
+
+ # Get damage type priority for tie-breaking
+ damageType = getDominantDamageType(charge.name)
+ damagePriority = DAMAGE_TYPE_PRIORITY.get(damageType, 99)
+
+ chargeData.append({
+ 'name': charge.name,
+ 'raw_volley': rawVolley,
+ 'raw_dps': rawDps,
+ 'lowerRange': rangeData['lowerRange'],
+ 'higherRange': rangeData['higherRange'],
+ 'higherChance': rangeData['higherChance'],
+ 'maxEffectiveRange': rangeData['maxEffectiveRange'],
+ 'explosionRadius': rangeData['explosionRadius'],
+ 'explosionVelocity': rangeData['explosionVelocity'],
+ 'damageReductionFactor': rangeData['damageReductionFactor'],
+ 'damage_priority': damagePriority
+ })
+
+ # Sort by maxEffectiveRange descending (longest range first for max range calculation)
+ # Then by raw_dps descending for tie-breaking
+ chargeData.sort(key=lambda x: (-x['maxEffectiveRange'], -x['raw_dps']))
+
+ return chargeData
+
+
+def getMaxEffectiveRange(chargeData):
+ """
+ Get the maximum effective range from precomputed charge data.
+
+ Args:
+ chargeData: List of precomputed charge data dicts
+
+ Returns:
+ Maximum effective range in meters
+ """
+ if not chargeData:
+ return 0
+ # Charge data is sorted by maxEffectiveRange descending
+ return chargeData[0]['maxEffectiveRange']
+
+
+# =============================================================================
+# Applied Volley Calculation
+# =============================================================================
+
+def calculateRangeFactor(distance, lowerRange, higherRange, higherChance):
+ """
+ Calculate range factor for missile at a distance.
+
+ Args:
+ distance: Distance to target (m)
+ lowerRange: Range at floor(flightTime)
+ higherRange: Range at ceil(flightTime)
+ higherChance: Probability of flying the extra second
+
+ Returns:
+ Range factor (0, higherChance, or 1)
+ """
+ if distance <= lowerRange:
+ return 1.0
+ elif distance <= higherRange:
+ return higherChance
+ else:
+ return 0.0
+
+
+def calculateAppliedVolley(chargeData, distance, tgtSpeed, tgtSigRadius):
+ """
+ Calculate applied volley for a missile charge at a distance.
+
+ Args:
+ chargeData: Single charge data dict
+ distance: Distance to target (m)
+ tgtSpeed: Target velocity (m/s) - can be modified by webs
+ tgtSigRadius: Target signature radius (m) - can be modified by TPs
+
+ Returns:
+ Applied volley (damage accounting for range and application)
+ """
+ # Range factor (discrete: 1, higherChance, or 0)
+ rangeFactor = calculateRangeFactor(
+ distance,
+ chargeData['lowerRange'],
+ chargeData['higherRange'],
+ chargeData['higherChance']
+ )
+
+ if rangeFactor == 0:
+ return 0
+
+ # Application factor
+ appFactor = calcMissileFactor(
+ chargeData['explosionRadius'],
+ chargeData['explosionVelocity'],
+ chargeData['damageReductionFactor'],
+ tgtSpeed,
+ tgtSigRadius
+ )
+
+ return chargeData['raw_volley'] * rangeFactor * appFactor
+
+
+def volleyToDps(volley, cycleTimeMs):
+ """
+ Convert volley to DPS.
+
+ Args:
+ volley: Damage per shot
+ cycleTimeMs: Cycle time in milliseconds
+
+ Returns:
+ DPS (damage per second)
+ """
+ if cycleTimeMs <= 0:
+ return 0
+ return volley / (cycleTimeMs / 1000)
+
+
+# =============================================================================
+# Best Charge Finding
+# =============================================================================
+
+def findBestCharge(chargeData, distance, tgtSpeed, tgtSigRadius):
+ """
+ Find the best missile charge at a distance.
+
+ Uses damage type priority (EM > Thermal > Kinetic > Explosive) as tie-breaker.
+
+ Args:
+ chargeData: List of charge data dicts
+ distance: Distance to target (m)
+ tgtSpeed: Target velocity (m/s)
+ tgtSigRadius: Target signature radius (m)
+
+ Returns:
+ Tuple of (best_volley, best_name, best_index)
+ """
+ bestVolley = 0
+ bestName = None
+ bestIndex = 0
+ bestPriority = 99
+
+ for i, cd in enumerate(chargeData):
+ volley = calculateAppliedVolley(cd, distance, tgtSpeed, tgtSigRadius)
+
+ # Tie-break: higher volley wins; if equal, lower damage_priority wins
+ if volley > bestVolley or (volley == bestVolley and volley > 0 and cd['damage_priority'] < bestPriority):
+ bestVolley = volley
+ bestName = cd['name']
+ bestIndex = i
+ bestPriority = cd['damage_priority']
+
+ return bestVolley, bestName, bestIndex
+
+
+# =============================================================================
+# Transition Point Calculation
+# =============================================================================
+
+def _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance):
+ """
+ Update target params using projected cache for webs/TPs.
+
+ Args:
+ baseTgtSpeed: Base target speed (from graph params)
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built cache from buildProjectedCache()
+ distance: Distance in meters
+
+ Returns:
+ Tuple of (tgtSpeed, tgtSigRadius) with projected effects applied
+ """
+ projected = getProjectedParamsAtDistance(projectedCache, distance)
+ return projected['tgtSpeed'], projected['tgtSigRadius']
+
+
+def calculateTransitions(chargeData, baseTgtSpeed, baseTgtSigRadius,
+ projectedCache, maxDistance=300000, resolution=100):
+ """
+ Calculate distances where optimal missile ammo changes.
+
+ Args:
+ chargeData: List of charge data dicts
+ baseTgtSpeed: Base target speed (from graph params)
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built cache for webs/TPs
+ maxDistance: Maximum distance to scan (m)
+ resolution: Distance interval (m)
+
+ Returns:
+ List of tuples: [(distance, charge_index, charge_name, volley), ...]
+ """
+ if not chargeData:
+ return []
+
+ transitions = []
+ currentCharge = None
+
+ # Start at distance 0
+ tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, 0)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, 0, tgtSpeed, tgtSigRadius)
+ transitions.append((0, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ # Scan for transitions
+ distance = resolution
+ while distance <= maxDistance:
+ tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, distance, tgtSpeed, tgtSigRadius)
+
+ if bestName != currentCharge:
+ # Binary search for exact transition point
+ low, high = distance - resolution, distance
+ while high - low > 10:
+ mid = (low + high) // 2
+ midSpeed, midSig = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, mid)
+ _, midName, _ = findBestCharge(chargeData, mid, midSpeed, midSig)
+ if midName == currentCharge:
+ low = mid
+ else:
+ high = mid
+
+ # Get volley at transition
+ highSpeed, highSig = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, high)
+ bestVolley, _, _ = findBestCharge(chargeData, high, highSpeed, highSig)
+
+ transitions.append((high, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ # Stop if we're past all missile ranges
+ if bestVolley < 0.01:
+ transitions.append((distance, -1, None, 0))
+ break
+
+ distance += resolution
+
+ return transitions
+
+
+# =============================================================================
+# Query Functions
+# =============================================================================
+
+def getVolleyAtDistance(transitions, chargeData, distance,
+ baseTgtSpeed, baseTgtSigRadius, projectedCache):
+ """
+ Get applied volley at a specific distance.
+
+ Args:
+ transitions: List of transition tuples
+ chargeData: List of charge data dicts
+ distance: Distance to query (m)
+ baseTgtSpeed: Base target speed
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built projected cache
+
+ Returns:
+ Tuple of (volley, charge_name)
+ """
+ if not transitions or not chargeData:
+ return 0, None
+
+ # Find which charge is optimal at this distance
+ distances = [t[0] for t in transitions]
+ idx = bisect_right(distances, distance) - 1
+ if idx < 0:
+ idx = 0
+
+ chargeIdx = transitions[idx][1]
+ if chargeIdx < 0 or chargeIdx >= len(chargeData):
+ return 0, None
+
+ cd = chargeData[chargeIdx]
+
+ # Calculate exact volley with projected effects
+ tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance)
+ volley = calculateAppliedVolley(cd, distance, tgtSpeed, tgtSigRadius)
+
+ return volley, cd['name']
+
diff --git a/graphs/data/fitApplicationProfile/calc/optimize_ammo.py b/graphs/data/fitApplicationProfile/calc/optimize_ammo.py
new file mode 100644
index 0000000000..b93093b8c3
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/optimize_ammo.py
@@ -0,0 +1,216 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+from bisect import bisect_right
+
+from logbook import Logger
+
+from .turret import calculateAppliedVolley
+from .projected import getProjectedParamsAtDistance
+
+
+pyfalog = Logger(__name__)
+
+
+# =============================================================================
+# Utility Functions
+# =============================================================================
+
+def volleyToDps(volley, cycleTimeMs):
+ """
+ Convert volley to DPS.
+
+ Args:
+ volley: Damage per shot
+ cycleTimeMs: Cycle time in milliseconds
+
+ Returns:
+ DPS (damage per second)
+ """
+ if cycleTimeMs <= 0:
+ return 0
+ return volley / (cycleTimeMs / 1000)
+
+
+# =============================================================================
+# Best Charge Finding
+# =============================================================================
+
+def findBestCharge(chargeData, distance, turretBase, trackingParams):
+ """
+ Find the best charge at a distance based on applied volley.
+
+ Args:
+ chargeData: List of charge data dicts
+ distance: Surface-to-surface distance (m)
+ turretBase: Base turret stats dict
+ trackingParams: Tracking params dict or None for perfect tracking
+
+ Returns:
+ Tuple of (best_volley, best_name, best_index)
+ """
+ bestVolley = 0
+ bestName = None
+ bestIndex = 0
+
+ for i, cd in enumerate(chargeData):
+ volley = calculateAppliedVolley(cd, distance, turretBase, trackingParams)
+ if volley > bestVolley:
+ bestVolley = volley
+ bestName = cd['name']
+ bestIndex = i
+
+ return bestVolley, bestName, bestIndex
+
+
+# =============================================================================
+# Transition Point Calculation
+# =============================================================================
+
+def _updateTrackingWithCache(baseTrackingParams, projectedCache, distance):
+ """
+ Fast update of tracking params using pre-built projected cache.
+
+ This is the performance-critical inner loop optimization - instead of
+ calling getTackledSpeed/getSigRadiusMult 300+ times, we do a single
+ cache lookup.
+
+ Args:
+ baseTrackingParams: Base tracking params dict (or None for perfect tracking)
+ projectedCache: Cache from buildProjectedCache()
+ distance: Distance (m)
+
+ Returns:
+ Updated tracking params dict with cached tgtSpeed/tgtSigRadius
+ """
+ if baseTrackingParams is None:
+ return None
+
+ params = baseTrackingParams.copy()
+ projected = getProjectedParamsAtDistance(projectedCache, distance)
+ params['tgtSpeed'] = projected['tgtSpeed']
+ params['tgtSigRadius'] = projected['tgtSigRadius']
+ return params
+
+
+def calculateTransitions(chargeData, turretBase, baseTrackingParams,
+ projectedCache,
+ maxDistance=300000, resolution=100):
+ """
+ Calculate distances where optimal ammo changes.
+
+ Uses coarse resolution for scanning, then binary search for exact
+ transition points. This is much faster than fine-grained scanning.
+
+ PERFORMANCE: Uses projectedCache for O(1) lookup of target speed/sig
+ at each distance, avoiding expensive getTackledSpeed/getSigRadiusMult calls.
+
+ Args:
+ chargeData: List of charge data dicts
+ turretBase: Base turret stats dict
+ baseTrackingParams: Base tracking params dict (with base tgtSpeed/tgtSigRadius)
+ projectedCache: Pre-built cache from buildProjectedCache()
+ maxDistance: Maximum distance to scan (m)
+ resolution: Distance interval (m)
+
+ Returns:
+ List of tuples: [(distance, charge_index, charge_name, volley), ...]
+ """
+ if not chargeData:
+ return []
+
+ transitions = []
+ currentCharge = None
+
+ # Start at distance 0
+ params0 = _updateTrackingWithCache(baseTrackingParams, projectedCache, 0)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, 0, turretBase, params0)
+ transitions.append((0, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ # Scan for transitions
+ distance = resolution
+ while distance <= maxDistance:
+ params = _updateTrackingWithCache(baseTrackingParams, projectedCache, distance)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, distance, turretBase, params)
+
+ if bestName != currentCharge:
+ # Binary search for exact transition point
+ low, high = distance - resolution, distance
+ while high - low > 10:
+ mid = (low + high) // 2
+ paramsMid = _updateTrackingWithCache(baseTrackingParams, projectedCache, mid)
+ _, midName, _ = findBestCharge(chargeData, mid, turretBase, paramsMid)
+ if midName == currentCharge:
+ low = mid
+ else:
+ high = mid
+
+ # Get volley at transition
+ paramsHigh = _updateTrackingWithCache(baseTrackingParams, projectedCache, high)
+ bestVolley, _, _ = findBestCharge(chargeData, high, turretBase, paramsHigh)
+
+ transitions.append((high, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ distance += resolution
+
+ return transitions
+
+
+# =============================================================================
+# Query Functions
+# =============================================================================
+
+def getVolleyAtDistance(transitions, chargeData, turretBase, distance,
+ baseTrackingParams, projectedCache):
+ """
+ Get applied volley at a specific distance.
+
+ Uses transitions for O(log n) charge lookup, then calculates exact volley
+ using the pre-built projected cache for target speed/sig.
+
+ Args:
+ transitions: List of transition tuples from calculateTransitions
+ chargeData: List of charge data dicts
+ turretBase: Base turret stats dict
+ distance: Distance to query (m)
+ baseTrackingParams: Base tracking params dict
+ projectedCache: Pre-built cache from buildProjectedCache()
+
+ Returns:
+ Tuple of (volley, charge_name)
+ """
+ if not transitions:
+ return 0, None
+
+ # Find which charge is optimal at this distance
+ distances = [t[0] for t in transitions]
+ idx = bisect_right(distances, distance) - 1
+ if idx < 0:
+ idx = 0
+
+ chargeIdx = transitions[idx][1]
+ cd = chargeData[chargeIdx]
+
+ # Calculate exact volley with projected effects from cache
+ params = _updateTrackingWithCache(baseTrackingParams, projectedCache, distance)
+ volley = calculateAppliedVolley(cd, distance, turretBase, params)
+
+ return volley, cd['name']
diff --git a/graphs/data/fitApplicationProfile/calc/projected.py b/graphs/data/fitApplicationProfile/calc/projected.py
new file mode 100644
index 0000000000..8f19d2ae1f
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/projected.py
@@ -0,0 +1,243 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import math
+from bisect import bisect_right
+
+from eos.calc import calculateRangeFactor
+from eos.utils.float import floatUnerr
+from graphs.calc import checkLockRange, checkDroneControlRange
+from service.const import GraphDpsDroneMode
+from service.settings import GraphSettings
+
+
+# =============================================================================
+# Re-exports from fitDamageStats for convenience
+# =============================================================================
+
+from graphs.data.fitDamageStats.calc.projected import (
+ getScramRange,
+ getScrammables,
+ getTackledSpeed,
+ getSigRadiusMult,
+)
+
+
+# =============================================================================
+# Distance-Keyed Projected Cache
+# =============================================================================
+
+def buildProjectedCache(src, tgt, commonData, baseTgtSpeed, baseTgtSigRadius,
+ maxDistance=300000, resolution=100, existingCache=None):
+ """
+ Build a distance-keyed cache of target speed and signature radius.
+
+ This pre-computes the expensive getTackledSpeed() and getSigRadiusMult()
+ calls at regular intervals, allowing O(1) lookup during ammo optimization.
+
+ If an existingCache is provided and the target hasn't changed (same base
+ speed/sig), we extend it rather than rebuild from scratch.
+
+ Args:
+ src: Source fit wrapper
+ tgt: Target wrapper
+ commonData: Dict with projected effect data (webMods, tpMods, etc.)
+ baseTgtSpeed: Base (untackled) target speed
+ baseTgtSigRadius: Base target signature radius
+ maxDistance: Maximum distance to cache (m)
+ resolution: Distance interval (m)
+ existingCache: Optional existing cache to extend (if target unchanged)
+
+ Returns:
+ Dict with:
+ 'distances': sorted list of distance keys
+ 'cache': {distance: {'tgtSpeed': float, 'tgtSigRadius': float}}
+ 'hasProjected': bool - whether projected effects are applied
+ 'maxCachedDistance': int - highest distance in cache
+ """
+ applyProjected = commonData.get('applyProjected', False)
+
+ # If no projected effects, return a simple cache with base values
+ if not applyProjected:
+ return {
+ 'distances': [],
+ 'cache': {},
+ 'hasProjected': False,
+ 'baseTgtSpeed': baseTgtSpeed,
+ 'baseTgtSigRadius': baseTgtSigRadius,
+ 'maxCachedDistance': 0
+ }
+
+ # Check if we can extend an existing cache
+ # NOTE: Vector angles are now included in the projectedCacheKey (in getter.py)
+ # so this cache is already isolated per vector configuration. We only need to
+ # check if the base target parameters match.
+ canExtend = (
+ existingCache is not None and
+ existingCache.get('hasProjected', False) and
+ existingCache.get('baseTgtSpeed') == baseTgtSpeed and
+ existingCache.get('baseTgtSigRadius') == baseTgtSigRadius
+ )
+
+ if canExtend:
+ existingMax = existingCache.get('maxCachedDistance', 0)
+
+ # If existing cache already covers our needed range, just return it
+ if existingMax >= maxDistance:
+ return existingCache
+
+ # Otherwise, extend the existing cache
+ distances = existingCache['distances'].copy()
+ cache = existingCache['cache'].copy()
+ startDistance = existingMax + resolution
+ else:
+ distances = []
+ cache = {}
+ startDistance = 0
+
+ # Extract projected data from commonData
+ srcScramRange = commonData.get('srcScramRange', 0)
+ tgtScrammables = commonData.get('tgtScrammables', ())
+ webMods = commonData.get('webMods', ())
+ webDrones = commonData.get('webDrones', ())
+ webFighters = commonData.get('webFighters', ())
+ tpMods = commonData.get('tpMods', ())
+ tpDrones = commonData.get('tpDrones', ())
+ tpFighters = commonData.get('tpFighters', ())
+
+ distance = startDistance
+ entriesAdded = 0
+ while distance <= maxDistance:
+ # Calculate tackled speed at this distance
+ tackledSpeed = getTackledSpeed(
+ src=src,
+ tgt=tgt,
+ currentUntackledSpeed=baseTgtSpeed,
+ srcScramRange=srcScramRange,
+ tgtScrammables=tgtScrammables,
+ webMods=webMods,
+ webDrones=webDrones,
+ webFighters=webFighters,
+ distance=distance
+ )
+
+ # Calculate sig radius multiplier at this distance
+ sigMult = getSigRadiusMult(
+ src=src,
+ tgt=tgt,
+ tgtSpeed=tackledSpeed,
+ srcScramRange=srcScramRange,
+ tgtScrammables=tgtScrammables,
+ tpMods=tpMods,
+ tpDrones=tpDrones,
+ tpFighters=tpFighters,
+ distance=distance
+ )
+
+ distances.append(distance)
+ cache[distance] = {
+ 'tgtSpeed': tackledSpeed,
+ 'tgtSigRadius': baseTgtSigRadius * sigMult
+ }
+
+ distance += resolution
+ entriesAdded += 1
+
+ # Ensure distances list is sorted (should already be, but safe to ensure)
+ distances.sort()
+
+ return {
+ 'distances': distances,
+ 'cache': cache,
+ 'hasProjected': True,
+ 'baseTgtSpeed': baseTgtSpeed,
+ 'baseTgtSigRadius': baseTgtSigRadius,
+ 'maxCachedDistance': distances[-1] if distances else 0
+ }
+
+
+def getProjectedParamsAtDistance(projectedCache, distance, interpolate=True):
+ """
+ Get target speed and sig radius at a distance from the pre-built cache.
+
+ Uses linear interpolation between cache entries for smoother curves,
+ especially important for grapples/webs with falloff mechanics.
+
+ Args:
+ projectedCache: Cache dict from buildProjectedCache()
+ distance: Distance to query (m)
+ interpolate: If True, interpolate between cache entries (default)
+
+ Returns:
+ Dict with 'tgtSpeed' and 'tgtSigRadius'
+ """
+ if not projectedCache.get('hasProjected', False):
+ # No projected effects - return base values
+ return {
+ 'tgtSpeed': projectedCache.get('baseTgtSpeed', 0),
+ 'tgtSigRadius': projectedCache.get('baseTgtSigRadius', 0)
+ }
+
+ distances = projectedCache.get('distances', [])
+ cache = projectedCache.get('cache', {})
+
+ if not distances:
+ return {
+ 'tgtSpeed': projectedCache.get('baseTgtSpeed', 0),
+ 'tgtSigRadius': projectedCache.get('baseTgtSigRadius', 0)
+ }
+
+ # Find position in sorted distances
+ idx = bisect_right(distances, distance) - 1
+
+ # Clamp to valid range
+ if idx < 0:
+ idx = 0
+ if idx >= len(distances) - 1:
+ # At or beyond the last cached distance
+ distKey = distances[-1]
+ return cache[distKey]
+
+ # Get bounding distances
+ distLow = distances[idx]
+ distHigh = distances[idx + 1]
+
+ # If not interpolating or exact match, return lower bound
+ if not interpolate or distance <= distLow:
+ return cache[distLow]
+
+ # Linear interpolation
+ cacheLow = cache[distLow]
+ cacheHigh = cache[distHigh]
+
+ # Calculate interpolation factor (0-1)
+ t = (distance - distLow) / (distHigh - distLow) if distHigh > distLow else 0
+
+ # Interpolate both speed and sig radius
+ # Handle infinity properly - if either value is inf, result should be inf
+ tgtSpeed = cacheLow['tgtSpeed'] + t * (cacheHigh['tgtSpeed'] - cacheLow['tgtSpeed'])
+ if cacheLow['tgtSigRadius'] == float('inf') or cacheHigh['tgtSigRadius'] == float('inf'):
+ tgtSigRadius = float('inf')
+ else:
+ tgtSigRadius = cacheLow['tgtSigRadius'] + t * (cacheHigh['tgtSigRadius'] - cacheLow['tgtSigRadius'])
+
+ return {
+ 'tgtSpeed': tgtSpeed,
+ 'tgtSigRadius': tgtSigRadius
+ }
diff --git a/graphs/data/fitApplicationProfile/calc/turret.py b/graphs/data/fitApplicationProfile/calc/turret.py
new file mode 100644
index 0000000000..6f9014667c
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/turret.py
@@ -0,0 +1,187 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import math
+
+from eos.calc import calculateRangeFactor
+
+
+# =============================================================================
+# Angular Speed
+# =============================================================================
+
+def calcAngularSpeed(atkSpeed, atkAngle, atkRadius, distance, tgtSpeed, tgtAngle, tgtRadius):
+ """
+ Calculate angular speed (rad/s) between attacker and target.
+ """
+ if distance is None:
+ return 0
+
+ atkAngleRad = atkAngle * math.pi / 180
+ tgtAngleRad = tgtAngle * math.pi / 180
+
+ ctcDistance = atkRadius + distance + tgtRadius
+
+
+ transSpeed = abs(atkSpeed * math.sin(atkAngleRad) - tgtSpeed * math.sin(tgtAngleRad))
+
+ if ctcDistance == 0:
+ return 0 if transSpeed == 0 else math.inf
+ else:
+ return transSpeed / ctcDistance
+
+
+def calcTrackingFactor(tracking, optimalSigRadius, angularSpeed, tgtSigRadius):
+ """
+ Calculate the tracking factor component of chance to hit.
+ """
+ if tracking <= 0 or tgtSigRadius <= 0:
+ return 0
+ if angularSpeed <= 0:
+ return 1.0
+
+ exponent = (angularSpeed * optimalSigRadius) / (tracking * tgtSigRadius)
+ return 0.5 ** (exponent ** 2)
+
+
+# def calcTrackingFactor(atkTracking, atkOptimalSigRadius, angularSpeed, tgtSigRadius):
+# """Calculate tracking chance to hit component."""
+# return 0.5 ** (((angularSpeed * atkOptimalSigRadius) / (atkTracking * tgtSigRadius)) ** 2)
+
+
+def calcTurretDamageMult(chanceToHit):
+ """
+ Calculate turret damage multiplier from chance to hit.
+ """
+ # https://wiki.eveuniversity.org/Turret_mechanics#Damage
+ wreckingChance = min(chanceToHit, 0.01)
+ wreckingPart = wreckingChance * 3
+ normalChance = chanceToHit - wreckingChance
+ if normalChance > 0:
+ avgDamageMult = (0.01 + chanceToHit) / 2 + 0.49
+ normalPart = normalChance * avgDamageMult
+ else:
+ normalPart = 0
+
+ totalMult = normalPart + wreckingPart
+ return totalMult
+
+
+def getTurretBaseStats(mod):
+ """
+ Get turret stats with ship/skill bonuses but WITHOUT charge modifiers.
+ """
+ # Get the modified values (includes charge effects if charge is loaded)
+ optimal = mod.getModifiedItemAttr('maxRange') or 0
+ falloff = mod.getModifiedItemAttr('falloff') or 0
+ tracking = mod.getModifiedItemAttr('trackingSpeed') or 0
+ optimalSigRadius = mod.getModifiedItemAttr('optimalSigRadius') or 0
+ damageMult = mod.getModifiedItemAttr('damageMultiplier') or 1
+
+ # If a charge is loaded, undo its range/falloff/tracking multiplier effects
+ # Charges multiply these stats, so we divide them out to get base stats
+ if mod.charge:
+ chargeRangeMult = mod.charge.getAttribute('weaponRangeMultiplier') or 1
+ chargeFalloffMult = mod.charge.getAttribute('fallofMultiplier') or 1 # EVE typo
+ chargeTrackingMult = mod.charge.getAttribute('trackingSpeedMultiplier') or 1
+
+ if chargeRangeMult != 0:
+ optimal = optimal / chargeRangeMult
+ if chargeFalloffMult != 0:
+ falloff = falloff / chargeFalloffMult
+ if chargeTrackingMult != 0:
+ tracking = tracking / chargeTrackingMult
+
+ return {
+ 'optimal': optimal,
+ 'falloff': falloff,
+ 'tracking': tracking,
+ 'optimalSigRadius': optimalSigRadius,
+ 'damageMultiplier': damageMult
+ }
+
+
+def getSkillMultiplier(mod):
+ """
+ Get the skill-based damage multiplier for a turret.
+ """
+ charge = mod.charge
+ if not charge:
+ return 1.0
+
+ baseDamage = (
+ (charge.getAttribute('emDamage') or 0) +
+ (charge.getAttribute('thermalDamage') or 0) +
+ (charge.getAttribute('kineticDamage') or 0) +
+ (charge.getAttribute('explosiveDamage') or 0)
+ )
+
+ if baseDamage <= 0:
+ return 1.0
+
+ modifiedDamage = (
+ (mod.getModifiedChargeAttr('emDamage') or 0) +
+ (mod.getModifiedChargeAttr('thermalDamage') or 0) +
+ (mod.getModifiedChargeAttr('kineticDamage') or 0) +
+ (mod.getModifiedChargeAttr('explosiveDamage') or 0)
+ )
+
+ return modifiedDamage / baseDamage if baseDamage > 0 else 1.0
+
+
+def calculateAppliedVolley(chargeData, distance, turretBase, trackingParams):
+ """
+ Calculate applied volley for a charge at a distance.
+ """
+ # Range factor
+ if distance <= chargeData['effective_optimal']:
+ rangeFactor = 1.0
+ else:
+ rangeFactor = calculateRangeFactor(
+ chargeData['effective_optimal'],
+ chargeData['effective_falloff'],
+ distance,
+ restrictedRange=False
+ )
+
+ # Tracking factor
+ if trackingParams is None:
+ trackingFactor = 1.0
+ else:
+ angularSpeed = calcAngularSpeed(
+ trackingParams['atkSpeed'],
+ trackingParams['atkAngle'],
+ trackingParams['atkRadius'],
+ distance,
+ trackingParams['tgtSpeed'],
+ trackingParams['tgtAngle'],
+ trackingParams['tgtRadius']
+ )
+ trackingFactor = calcTrackingFactor(
+ chargeData['effective_tracking'],
+ turretBase['optimalSigRadius'],
+ angularSpeed,
+ trackingParams['tgtSigRadius']
+ )
+
+ # Chance to hit and damage multiplier
+ cth = rangeFactor * trackingFactor
+ damageMult = calcTurretDamageMult(cth)
+
+ return chargeData['raw_volley'] * damageMult
diff --git a/graphs/data/fitApplicationProfile/calc/valid_charges.py b/graphs/data/fitApplicationProfile/calc/valid_charges.py
new file mode 100644
index 0000000000..015666d6f5
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/calc/valid_charges.py
@@ -0,0 +1,79 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import eos.db
+from eos.gamedata import Item
+
+
+# Class-level cache for valid charges: {itemID: set(charges)}
+# This prevents repeated DB queries for the same module type
+_validChargesCache = {}
+
+
+def getValidChargesForModule(module):
+ """
+ Get all valid charges for a module using optimized database query.
+
+ This is a performance-optimized version for graph calculations that:
+ 1. Uses class-level caching to prevent repeated queries for the same module type
+ 2. Uses direct SQLAlchemy queries instead of eager loading full groups
+ 3. Only validates published items that match the charge groups
+
+ Args:
+ module: The Module instance to get valid charges for
+
+ Returns:
+ set: Set of valid Item instances that can be used as charges
+ """
+ # Check class-level cache first
+ if module.item.ID in _validChargesCache:
+ return _validChargesCache[module.item.ID].copy()
+
+ # Collect all charge group IDs for this module
+ chargeGroupIDs = []
+ for i in range(5):
+ itemChargeGroup = module.getModifiedItemAttr('chargeGroup' + str(i), None)
+ if itemChargeGroup:
+ chargeGroupIDs.append(int(itemChargeGroup))
+
+ if not chargeGroupIDs:
+ _validChargesCache[module.item.ID] = set()
+ return set()
+
+ # Query only published items from the relevant charge groups
+ # This is much more efficient than loading entire groups with all attributes
+ session = eos.db.get_gamedata_session()
+
+ # Query published items in the relevant groups
+ # Note: We let attributes lazy-load only when needed by isValidCharge()
+ items = session.query(Item).filter(
+ Item.groupID.in_(chargeGroupIDs),
+ Item.published == True
+ ).all()
+
+ # Validate each item with the module's size/capacity constraints
+ validCharges = set()
+ for item in items:
+ if module.isValidCharge(item):
+ validCharges.add(item)
+
+ # Store in class-level cache
+ _validChargesCache[module.item.ID] = validCharges
+ return validCharges.copy()
+
diff --git a/graphs/data/fitApplicationProfile/getter.py b/graphs/data/fitApplicationProfile/getter.py
new file mode 100644
index 0000000000..54bcb1f80b
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/getter.py
@@ -0,0 +1,969 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+from eos.const import FittingHardpoint
+from logbook import Logger
+
+from graphs.data.base.getter import SmoothPointGetter
+from graphs.data.fitDamageStats.calc.projected import (
+ getScramRange, getScrammables
+)
+from service.settings import GraphSettings
+from .calc.valid_charges import getValidChargesForModule
+
+from .calc.turret import (
+ getTurretBaseStats,
+ getSkillMultiplier
+)
+from .calc.charges import (
+ filterChargesByQuality,
+ precomputeChargeData,
+ getLongestRangeMultiplier
+)
+from .calc.optimize_ammo import (
+ volleyToDps,
+ calculateTransitions,
+ getVolleyAtDistance
+)
+from .calc.projected import (
+ buildProjectedCache
+)
+from .calc.launcher import (
+ getAllMultipliers as getLauncherMultipliers,
+ precomputeMissileChargeData,
+ getMaxEffectiveRange as getMissileMaxEffectiveRange,
+ calculateTransitions as calculateMissileTransitions,
+ getVolleyAtDistance as getMissileVolleyAtDistance,
+ volleyToDps as missileVolleyToDps
+)
+
+
+pyfalog = Logger(__name__)
+
+
+# =============================================================================
+# Max Effective Range Calculation
+# =============================================================================
+
+def getMaxEffectiveRange(turretBase, charges):
+ """
+ Calculate the max effective range for a turret with its available charges.
+
+ Formula: optimal * longestRangeMult + falloff * 3.1
+
+ At falloff * 3.1, the range factor is ~0.5% (negligible damage).
+
+ Args:
+ turretBase: Base turret stats dict from getTurretBaseStats
+ charges: List of charge items
+
+ Returns:
+ Max effective range in meters
+ """
+ longestRangeMult = getLongestRangeMultiplier(charges)
+ effectiveOptimal = turretBase['optimal'] * longestRangeMult
+ effectiveMaxRange = effectiveOptimal + turretBase['falloff'] * 3.1
+ return int(effectiveMaxRange)
+
+
+def getTurretRangeInfo(mod, qualityTier, chargeCache=None):
+ """
+ Get turret base stats and max effective range without computing transitions.
+
+ This is used in the first pass to determine how far the projected cache
+ needs to extend.
+
+ Args:
+ mod: The turret module
+ qualityTier: 't1', 'navy', or 'all'
+ chargeCache: Optional cache dict for getValidCharges results
+
+ Returns:
+ Dict with turret_base, charges, max_effective_range, cycle_time_ms
+ Or None if turret has no valid charges
+ """
+ # Get turret base stats
+ turretBase = getTurretBaseStats(mod)
+
+ # Get cycle time
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges - use cache if available
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ # Calculate max effective range
+ maxEffectiveRange = getMaxEffectiveRange(turretBase, charges)
+
+ return {
+ 'turret_base': turretBase,
+ 'charges': charges,
+ 'max_effective_range': maxEffectiveRange,
+ 'cycle_time_ms': cycleTimeMs
+ }
+
+
+# =============================================================================
+# Launcher Max Range Functions
+# =============================================================================
+
+def getLauncherRangeInfo(mod, qualityTier, shipRadius, chargeCache=None):
+ """
+ Get launcher stats and max effective range without computing transitions.
+
+ This is used in the first pass to determine how far the projected cache
+ needs to extend.
+
+ Args:
+ mod: The launcher module
+ qualityTier: 't1', 'navy', or 'all'
+ shipRadius: Ship radius for flight time bonus
+ chargeCache: Optional cache dict for getValidCharges results
+
+ Returns:
+ Dict with charges, max_effective_range, cycle_time_ms, and multipliers
+ Or None if launcher has no valid charges
+ """
+ # Get cycle time
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges - use cache if available
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ # Get multipliers from the currently loaded charge (or first valid charge)
+ damageMults, flightMults, appMults = getLauncherMultipliers(mod)
+
+ # Get launcher damage multiplier
+ launcherDamageMult = mod.getModifiedItemAttr('damageMultiplier') or 1
+
+ # Precompute charge data to determine max effective range
+ chargeData = precomputeMissileChargeData(
+ mod, charges, cycleTimeMs, shipRadius,
+ damageMults, flightMults, appMults,
+ tgtResists=None # Don't filter by resists for range calculation
+ )
+
+ if not chargeData:
+ return None
+
+ # Max effective range is from the longest-range charge
+ maxEffectiveRange = getMissileMaxEffectiveRange(chargeData)
+
+ return {
+ 'charges': charges,
+ 'charge_data': chargeData, # Cache the precomputed data
+ 'max_effective_range': maxEffectiveRange,
+ 'cycle_time_ms': cycleTimeMs,
+ 'damage_mults': damageMults,
+ 'flight_mults': flightMults,
+ 'app_mults': appMults,
+ 'launcher_damage_mult': launcherDamageMult
+ }
+
+
+# =============================================================================
+# Dominant Group Detection
+# =============================================================================
+
+def countWeaponGroups(src):
+ """
+ Count turrets and launchers on the source fit.
+
+ Args:
+ src: Source fit wrapper
+
+ Returns:
+ Tuple of (turret_count, launcher_count)
+ """
+ turretCount = 0
+ launcherCount = 0
+
+ for mod in src.item.activeModulesIter():
+ # Skip mining lasers
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ if mod.hardpoint == FittingHardpoint.TURRET:
+ turretCount += 1
+ elif mod.hardpoint == FittingHardpoint.MISSILE:
+ launcherCount += 1
+
+ return turretCount, launcherCount
+
+
+def getDominantWeaponType(src):
+ """
+ Determine which weapon type dominates on the fit.
+
+ Args:
+ src: Source fit wrapper
+
+ Returns:
+ 'turret', 'launcher', or None (if no weapons)
+ """
+ turretCount, launcherCount = countWeaponGroups(src)
+
+ if turretCount == 0 and launcherCount == 0:
+ return None
+
+ # Turrets win ties (arbitrary, but consistent)
+ if turretCount >= launcherCount:
+ return 'turret'
+ else:
+ return 'launcher'
+
+
+# =============================================================================
+# Cache Building
+# =============================================================================
+
+def buildTurretCacheEntry(mod, qualityTier, tgtResists, baseTrackingParams,
+ projectedCache, chargeCache=None, rangeInfo=None):
+ """
+ Build a complete cache entry for a single turret type.
+
+ Args:
+ mod: The turret module
+ qualityTier: 't1', 'navy', or 'all'
+ tgtResists: Target resists tuple or None
+ baseTrackingParams: Base tracking params dict
+ projectedCache: Pre-built cache from buildProjectedCache()
+ chargeCache: Optional cache dict for getValidCharges results
+ rangeInfo: Optional pre-computed range info from getTurretRangeInfo
+
+ Returns:
+ Dict with charge_data, transitions, turret_base, cycle_time_ms
+ Or None if turret has no valid charges
+ """
+ # Use pre-computed range info if available, otherwise compute now
+ if rangeInfo is not None:
+ turretBase = rangeInfo['turret_base']
+ charges = rangeInfo['charges']
+ cycleTimeMs = rangeInfo['cycle_time_ms']
+ else:
+ turretBase = getTurretBaseStats(mod)
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ if not charges:
+ return None
+
+ # Get skill multiplier
+ skillMult = getSkillMultiplier(mod)
+
+ # Precompute charge data
+ chargeData = precomputeChargeData(turretBase, charges, skillMult, tgtResists)
+
+ # Calculate max effective range for this turret (after charge filtering)
+ # Use the precomputed chargeData to get the longest range
+ maxEffectiveOptimal = max(cd['effective_optimal'] for cd in chargeData)
+ maxEffectiveFalloff = max(cd['effective_falloff'] for cd in chargeData)
+ maxEffectiveRange = int(maxEffectiveOptimal + maxEffectiveFalloff * 3.1)
+
+ # Calculate transitions using the pre-built projected cache
+ # Only scan up to this turret's max effective range
+ transitions = calculateTransitions(
+ chargeData, turretBase, baseTrackingParams,
+ projectedCache,
+ maxDistance=maxEffectiveRange
+ )
+
+ return {
+ 'charge_data': chargeData,
+ 'transitions': transitions,
+ 'turret_base': turretBase,
+ 'cycle_time_ms': cycleTimeMs,
+ 'count': 1
+ }
+
+
+def buildLauncherCacheEntry(mod, qualityTier, tgtResists, shipRadius,
+ baseTgtSpeed, baseTgtSigRadius,
+ projectedCache, chargeCache=None, rangeInfo=None):
+ """
+ Build a complete cache entry for a single launcher type.
+
+
+ Args:
+ mod: The launcher module
+ qualityTier: 't1', 'navy', or 'all'
+ tgtResists: Target resists tuple or None
+ shipRadius: Ship radius for flight time bonus
+ baseTgtSpeed: Base target speed (from params)
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built cache from buildProjectedCache()
+ chargeCache: Optional cache dict for getValidCharges results
+ rangeInfo: Optional pre-computed range info from getLauncherRangeInfo
+
+ Returns:
+ Dict with charge_data, transitions, cycle_time_ms
+ Or None if launcher has no valid charges
+ """
+ # Use pre-computed range info if available, otherwise compute now
+ if rangeInfo is not None:
+ charges = rangeInfo['charges']
+ # chargeData = rangeInfo['charge_data'] # Don't use cached data (it ignores resists)
+ cycleTimeMs = rangeInfo['cycle_time_ms']
+ damageMults = rangeInfo['damage_mults']
+ flightMults = rangeInfo['flight_mults']
+ appMults = rangeInfo['app_mults']
+ else:
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ # Get multipliers from the currently loaded charge
+ damageMults, flightMults, appMults = getLauncherMultipliers(mod)
+
+ # Precompute charge data with current resists
+ chargeData = precomputeMissileChargeData(
+ mod, charges, cycleTimeMs, shipRadius,
+ damageMults, flightMults, appMults, tgtResists
+ )
+
+ if not chargeData:
+ return None
+
+ # Calculate max effective range from precomputed data
+ maxEffectiveRange = getMissileMaxEffectiveRange(chargeData)
+
+ # Calculate transitions using the pre-built projected cache
+ transitions = calculateMissileTransitions(
+ chargeData, baseTgtSpeed, baseTgtSigRadius,
+ projectedCache,
+ maxDistance=int(maxEffectiveRange)
+ )
+
+ return {
+ 'charge_data': chargeData,
+ 'transitions': transitions,
+ 'cycle_time_ms': cycleTimeMs,
+ 'count': 1
+ }
+
+
+# =============================================================================
+# Y-Axis Mixins
+# =============================================================================
+
+class YOptimalAmmoDpsMixin:
+ """Y-axis mixin: Calculate DPS using optimal ammo selection."""
+
+ def _getOptimalDpsAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total DPS with optimal ammo at a specific distance."""
+ totalDps = 0
+
+ if weaponType == 'turret':
+ for group_id, groupInfo in weaponCache.items():
+ volley, _ = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ dps = volleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+ else: # launcher
+ tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams)
+ for group_id, groupInfo in weaponCache.items():
+ volley, _ = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ tgtSpeed,
+ tgtSigRadius,
+ projectedCache
+ )
+ dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+
+ return totalDps
+
+ def _getOptimalDpsWithAmmoAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total DPS and ammo name at a specific distance."""
+ totalDps = 0
+ ammoName = None
+
+ if weaponType == 'turret':
+ for groupInfo in weaponCache.values():
+ volley, name = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ dps = volleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+ else: # launcher
+ tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams)
+ for groupInfo in weaponCache.values():
+ volley, name = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ tgtSpeed,
+ tgtSigRadius,
+ projectedCache
+ )
+ dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+
+ return totalDps, ammoName
+
+
+class YOptimalAmmoVolleyMixin:
+ """Y-axis mixin: Calculate volley using optimal ammo selection."""
+
+ def _getOptimalVolleyAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total volley with optimal ammo at a specific distance."""
+ totalVolley = 0
+
+ if weaponType == 'turret':
+ for groupInfo in weaponCache.values():
+ volley, _ = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+ else: # launcher
+ tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams)
+ for groupInfo in weaponCache.values():
+ volley, _ = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ tgtSpeed,
+ tgtSigRadius,
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+
+ return totalVolley
+
+ def _getOptimalVolleyWithAmmoAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total volley and ammo name at a specific distance."""
+ totalVolley = 0
+ ammoName = None
+
+ if weaponType == 'turret':
+ for groupInfo in weaponCache.values():
+ volley, name = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+ else: # launcher
+ tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams)
+ for groupInfo in weaponCache.values():
+ volley, name = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ tgtSpeed,
+ tgtSigRadius,
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+
+ return totalVolley, ammoName
+
+
+# =============================================================================
+# X-Axis Mixin
+# =============================================================================
+
+class XDistanceMixin(SmoothPointGetter):
+ """X-axis mixin: Distance in meters. Builds weapon cache and handles lookups."""
+
+ # Coarse resolution for graph display - 100m intervals
+ # Exact calculations are done on-demand via getPoint/getPointExtended
+ _baseResolution = 100 # meters
+
+ def _getCommonData(self, miscParams, src, tgt):
+ """
+ Build common data including projected cache and weapon (turret/launcher) cache.
+
+ The projected cache is keyed by target (tgtSpeed, tgtSigRadius) and can be
+ extended if the attacker's max range increases, without recalculating
+ existing entries.
+ """
+ # Get settings
+ qualityTier = getattr(self.graph, '_ammoQuality', 'all')
+ ignoreResists = GraphSettings.getInstance().get('ammoOptimalIgnoreResists')
+ applyProjected = GraphSettings.getInstance().get('ammoOptimalApplyProjected')
+
+ tgtResists = None if (ignoreResists or tgt is None) else tgt.getResists()
+ tgtSpeed = miscParams.get('tgtSpeed', 0) or 0
+ tgtSigRadius = tgt.getSigRadius() if tgt else 0
+ shipRadius = src.getRadius()
+
+ weaponType = getDominantWeaponType(src)
+
+ fit_id = src.item.ID
+
+ atkSpeed = miscParams.get('atkSpeed', 0) or 0
+ atkAngle = miscParams.get('atkAngle', 0) or 0
+ tgtAngle = miscParams.get('tgtAngle', 0) or 0
+
+ weaponCacheKey = (fit_id, weaponType, qualityTier, tgtResists, applyProjected, tgtSpeed, tgtSigRadius, atkSpeed, atkAngle, tgtAngle)
+
+ projectedCacheKey = (fit_id, tgtSpeed, tgtSigRadius, atkSpeed, atkAngle, tgtAngle)
+
+ # Initialize graph caches if needed
+ if not hasattr(self.graph, '_ammo_weapon_cache'):
+ self.graph._ammo_weapon_cache = {}
+ if not hasattr(self.graph, '_ammo_charge_cache'):
+ self.graph._ammo_charge_cache = {}
+ if not hasattr(self.graph, '_ammo_projected_cache'):
+ self.graph._ammo_projected_cache = {}
+
+ # Build base commonData with projected effect info
+ commonData = {
+ 'applyProjected': applyProjected,
+ 'src_radius': shipRadius,
+ 'weapon_type': weaponType,
+ }
+
+ # Add projected effect data if enabled
+ if applyProjected:
+ commonData['srcScramRange'] = getScramRange(src=src)
+ commonData['tgtScrammables'] = getScrammables(tgt=tgt) if tgt else ()
+ webMods, tpMods = self.graph._projectedCache.getProjModData(src)
+ webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src)
+ webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src)
+ commonData['webMods'] = webMods
+ commonData['tpMods'] = tpMods
+ commonData['webDrones'] = webDrones
+ commonData['tpDrones'] = tpDrones
+ commonData['webFighters'] = webFighters
+ commonData['tpFighters'] = tpFighters
+
+ if weaponCacheKey in self.graph._ammo_weapon_cache:
+ cached_weapon = self.graph._ammo_weapon_cache[weaponCacheKey]
+ commonData['weapon_cache'] = cached_weapon
+ commonData['projected_cache'] = self.graph._ammo_projected_cache.get(projectedCacheKey, {})
+ return commonData
+
+ if weaponType is None:
+ commonData['weapon_cache'] = {}
+ commonData['projected_cache'] = {}
+ return commonData
+
+
+ weaponRangeInfos = {} # {mod.item.ID: rangeInfo}
+ maxEffectiveRange = 0
+
+ if weaponType == 'turret':
+ hardpointType = FittingHardpoint.TURRET
+ else:
+ hardpointType = FittingHardpoint.MISSILE
+
+ for mod in src.item.activeModulesIter():
+ if mod.hardpoint != hardpointType:
+ continue
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ key = mod.item.ID
+ if key not in weaponRangeInfos:
+ if weaponType == 'turret':
+ rangeInfo = getTurretRangeInfo(mod, qualityTier, self.graph._ammo_charge_cache)
+ else:
+ # Special handling for empty launchers (Missiles only):
+ # To apply skill/ship modifiers correctly, eos needs a charge loaded.
+ # If launcher is empty, temporarily load a charge to extract multipliers.
+ if mod.charge is None:
+ # Find a valid charge to simulate load
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ validCharges = None
+ if self.graph._ammo_charge_cache is not None and chargeCacheKey in self.graph._ammo_charge_cache:
+ validCharges = self.graph._ammo_charge_cache[chargeCacheKey]
+
+ if validCharges is None:
+ allCharges = list(getValidChargesForModule(mod))
+ validCharges = filterChargesByQuality(allCharges, qualityTier)
+ if self.graph._ammo_charge_cache is not None:
+ self.graph._ammo_charge_cache[chargeCacheKey] = validCharges
+
+ if validCharges:
+ # Temporarily load the first valid charge
+ tempCharge = validCharges[0]
+ try:
+ mod.charge = tempCharge
+ # Force fit update (important for effects to apply)
+ if mod.owner:
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+
+ ranges = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache)
+ rangeInfo = ranges
+
+ # Unload charge
+ mod.charge = None
+ if mod.owner:
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+
+ except Exception as e:
+ pyfalog.error(f"Error simulating charge for {mod.item.name}: {e}")
+ mod.charge = None # Ensure cleanup
+ if mod.owner:
+ mod.owner.calculated = False
+ try:
+ mod.owner.calculateModifiedAttributes()
+ except:
+ pass
+ rangeInfo = None
+ else:
+ rangeInfo = None
+ else:
+ rangeInfo = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache)
+
+ if rangeInfo:
+ weaponRangeInfos[key] = rangeInfo
+ if rangeInfo['max_effective_range'] > maxEffectiveRange:
+ maxEffectiveRange = rangeInfo['max_effective_range']
+
+ if not weaponRangeInfos:
+ # No weapons found
+ commonData['weapon_cache'] = {}
+ commonData['projected_cache'] = {}
+ return commonData
+
+ # =====================================================================
+ # PHASE 2: Build/extend projected cache to max effective range
+ # =====================================================================
+
+ # Get existing cache for this target (if any)
+ existingCache = self.graph._ammo_projected_cache.get(projectedCacheKey)
+
+ # Build base tracking params (used for turrets, also provides tgtSpeed/tgtSig for missiles)
+ # Vector parameters already extracted above for cache keys
+ baseTrackingParams = {
+ 'atkSpeed': atkSpeed,
+ 'atkAngle': atkAngle,
+ 'atkRadius': shipRadius,
+ 'tgtSpeed': tgtSpeed,
+ 'tgtAngle': tgtAngle,
+ 'tgtRadius': tgt.getRadius() if tgt else 0,
+ 'tgtSigRadius': tgtSigRadius
+ }
+
+ # Build or extend the projected cache
+ projectedCache = buildProjectedCache(
+ src=src,
+ tgt=tgt,
+ commonData=commonData,
+ baseTgtSpeed=tgtSpeed,
+ baseTgtSigRadius=tgtSigRadius,
+ maxDistance=maxEffectiveRange,
+ resolution=100, # 100m intervals
+ existingCache=existingCache
+ )
+
+ # Store projected cache - can be reused if target stays the same
+ self.graph._ammo_projected_cache[projectedCacheKey] = projectedCache
+ commonData['projected_cache'] = projectedCache
+
+ # =====================================================================
+ # PHASE 3: Build weapon cache with transitions
+ # =====================================================================
+
+ weaponCache = {}
+ for mod in src.item.activeModulesIter():
+ if mod.hardpoint != hardpointType:
+ continue
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ key = mod.item.ID
+ if key not in weaponCache:
+ rangeInfo = weaponRangeInfos.get(key)
+ if rangeInfo:
+ if weaponType == 'turret':
+ entry = buildTurretCacheEntry(
+ mod, qualityTier, tgtResists, baseTrackingParams,
+ projectedCache, self.graph._ammo_charge_cache,
+ rangeInfo=rangeInfo
+ )
+ else:
+ entry = buildLauncherCacheEntry(
+ mod, qualityTier, tgtResists, shipRadius,
+ tgtSpeed, tgtSigRadius,
+ projectedCache, self.graph._ammo_charge_cache,
+ rangeInfo=rangeInfo
+ )
+ if entry:
+ weaponCache[key] = entry
+ else:
+ weaponCache[key]['count'] += 1
+
+ # Cache and return
+ self.graph._ammo_weapon_cache[weaponCacheKey] = weaponCache
+ commonData['weapon_cache'] = weaponCache
+
+ return commonData
+
+ def _buildTrackingParams(self, distance, miscParams, src, tgt, commonData):
+ """
+ Build base tracking params for a distance query.
+
+ NOTE: This returns BASE params only. The projected effects (web/TP)
+ are applied via the projected cache in getVolleyAtDistance.
+ """
+ tgtSpeed = miscParams.get('tgtSpeed', 0) or 0
+ tgtSigRadius = tgt.getSigRadius() if tgt else 0
+
+ # Only return None if sig radius is exactly 0 (not infinity - that's valid for Ideal Target)
+ if tgtSigRadius == 0:
+ return None
+
+ params = {
+ 'atkSpeed': miscParams.get('atkSpeed', 0) or 0,
+ 'atkAngle': miscParams.get('atkAngle', 0) or 0,
+ 'atkRadius': commonData.get('src_radius', 0),
+ 'tgtSpeed': tgtSpeed,
+ 'tgtAngle': miscParams.get('tgtAngle', 0) or 0,
+ 'tgtRadius': tgt.getRadius() if tgt else 0,
+ 'tgtSigRadius': tgtSigRadius
+ }
+
+ return params
+
+ @staticmethod
+ def _missileTargetParams(trackingParams):
+ """
+ Extract (tgtSpeed, tgtSigRadius) for the missile volley calc from a
+ possibly-None trackingParams.
+
+ _buildTrackingParams returns None to mean "perfect tracking" (no target
+ or a target with signature radius 0). The turret path mirrors this as a
+ tracking factor of 1.0; for missiles we get the same full-application
+ result by feeding an effectively-infinite signature (and zero speed)
+ into the application formula. Without this guard the launcher branches
+ raise TypeError subscripting None.
+ """
+ if trackingParams is None:
+ return 0, float('inf')
+ return trackingParams['tgtSpeed'], trackingParams['tgtSigRadius']
+
+ def _calculatePoint(self, x, miscParams, src, tgt, commonData):
+ """Calculate value at distance x."""
+ weaponCache = commonData.get('weapon_cache', {})
+ weaponType = commonData.get('weapon_type')
+ if not weaponCache:
+ return 0
+
+ trackingParams = self._buildTrackingParams(x, miscParams, src, tgt, commonData)
+ projectedCache = commonData.get('projected_cache', {})
+
+ if hasattr(self, '_getOptimalDpsAtDistance'):
+ result = self._getOptimalDpsAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ return result
+ elif hasattr(self, '_getOptimalVolleyAtDistance'):
+ result = self._getOptimalVolleyAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ return result
+ return 0
+
+ def _calculatePointExtended(self, x, miscParams, src, tgt, commonData):
+ """Calculate value and ammo name at distance x."""
+ weaponCache = commonData.get('weapon_cache', {})
+ weaponType = commonData.get('weapon_type')
+ if not weaponCache:
+ return 0, None
+
+ trackingParams = self._buildTrackingParams(x, miscParams, src, tgt, commonData)
+ projectedCache = commonData.get('projected_cache', {})
+
+ if hasattr(self, '_getOptimalDpsWithAmmoAtDistance'):
+ return self._getOptimalDpsWithAmmoAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ elif hasattr(self, '_getOptimalVolleyWithAmmoAtDistance'):
+ return self._getOptimalVolleyWithAmmoAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ return 0, None
+
+ def getSegments(self, xRange, miscParams, src, tgt):
+ """Get plot segments with ammo transition information."""
+ # Validate xRange - can contain None from range limiters
+ minX, maxX = xRange
+ if minX is None or maxX is None:
+ return []
+
+ commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt)
+ weaponCache = commonData.get('weapon_cache', {})
+ weaponType = commonData.get('weapon_type')
+
+ if not weaponCache:
+ return []
+
+ # Get transitions from first weapon group
+ transitions = None
+ for groupInfo in weaponCache.values():
+ transitions = groupInfo['transitions']
+ break
+
+ if not transitions:
+ return []
+
+ # Filter valid transitions (with ammo name)
+ validTransitions = [t for t in transitions if t[2] is not None]
+ if not validTransitions:
+ return []
+
+ # Build ammo index mapping
+ ammoToIndex = {}
+ for t in validTransitions:
+ if t[2] not in ammoToIndex:
+ ammoToIndex[t[2]] = len(ammoToIndex)
+
+ # Generate segments
+ segments = []
+
+ for i, transition in enumerate(validTransitions):
+ transDist, _, ammoName, _ = transition
+ segStart = max(transDist, minX)
+
+ # Find segment end
+ if i + 1 < len(validTransitions):
+ segEnd = min(validTransitions[i + 1][0], maxX)
+ else:
+ segEnd = maxX
+
+ if segStart >= segEnd:
+ continue
+
+ # Generate points at fixed 100m resolution for performance
+ step = 100
+ xs, ys = [], []
+ x = segStart
+ while x <= segEnd:
+ y = self._calculatePoint(x, miscParams, src, tgt, commonData)
+ xs.append(x)
+ ys.append(y)
+ x += step
+
+ # Always include the segment end point for smooth transitions
+ if xs[-1] < segEnd:
+ y = self._calculatePoint(segEnd, miscParams, src, tgt, commonData)
+ xs.append(segEnd)
+ ys.append(y)
+
+ segments.append({
+ 'xs': xs,
+ 'ys': ys,
+ 'ammo': ammoName,
+ 'ammoIndex': ammoToIndex[ammoName]
+ })
+
+ return segments
+
+
+# =============================================================================
+# Getter Classes
+# =============================================================================
+
+class Distance2OptimalAmmoDpsGetter(XDistanceMixin, YOptimalAmmoDpsMixin):
+ """Distance vs Optimal Ammo DPS graph getter."""
+
+ def getPointExtended(self, x, miscParams, src, tgt):
+ commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt)
+ value, ammo = self._calculatePointExtended(x, miscParams, src, tgt, commonData)
+ return value, {'ammo': ammo}
+
+
+class Distance2OptimalAmmoVolleyGetter(XDistanceMixin, YOptimalAmmoVolleyMixin):
+ """Distance vs Optimal Ammo Volley graph getter."""
+
+ def getPointExtended(self, x, miscParams, src, tgt):
+ commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt)
+ value, ammo = self._calculatePointExtended(x, miscParams, src, tgt, commonData)
+ return value, {'ammo': ammo}
diff --git a/graphs/data/fitApplicationProfile/graph.py b/graphs/data/fitApplicationProfile/graph.py
new file mode 100644
index 0000000000..20963c2312
--- /dev/null
+++ b/graphs/data/fitApplicationProfile/graph.py
@@ -0,0 +1,575 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import colorsys
+import math
+import re
+
+from eos.const import FittingHardpoint
+from eos.saveddata.fit import Fit
+from graphs.data.base import FitGraph, XDef, YDef, Input, VectorDef
+from graphs.data.fitApplicationProfile.getter import (
+ Distance2OptimalAmmoDpsGetter,
+ Distance2OptimalAmmoVolleyGetter,
+)
+from graphs.data.fitApplicationProfile.calc.turret import getTurretBaseStats
+from graphs.data.fitApplicationProfile.calc.charges import getChargeStats
+from graphs.data.fitApplicationProfile.calc.valid_charges import getValidChargesForModule
+from graphs.data.fitApplicationProfile.calc.launcher import getFlightMultipliers
+from graphs.data.fitDamageStats.cache import ProjectedDataCache
+from service.const import GraphCacheCleanupReason
+from service.settings import GraphSettings
+
+
+# Ammo color definitions (RGB tuples, 0-255 range)
+AMMO_COLORS = {
+ # Hybrid - Short Range
+ "Null": (179, 179, 166),
+ "Void": (128, 26, 51),
+ # Hybrid - Long Range
+ "Spike": (194, 255, 43),
+ "Javelin": (112, 251, 0),
+ # Hybrid - Standard
+ "Antimatter": (15, 0, 0),
+ "Iridium": (26, 179, 179),
+ "Lead": (114, 120, 125),
+ "Plutonium": (0, 150, 68),
+ "Thorium": (148, 127, 115),
+ "Uranium": (94, 230, 73),
+ "Tungsten": (8, 0, 38),
+ "Iron": (153, 77, 77),
+
+ # Energy - Short Range
+ "Scorch": (235, 79, 255),
+ "Conflagration": (0, 184, 64),
+ # Energy - Long Range
+ "Gleam": (181, 145, 94),
+ "Aurora": (166, 18, 55),
+ # Energy - Standard
+ "Multifrequency": (204, 204, 204),
+ "Gamma": (5, 102, 242),
+ "Xray": (0, 189, 134),
+ "Ultraviolet": (107, 0, 189),
+ "Standard": (230, 179, 0),
+ "Infrared": (242, 64, 5),
+ "Microwave": (242, 142, 5),
+ "Radio": (227, 10, 10),
+
+ # Projectile - Short Range
+ "Quake": (199, 154, 82),
+ "Hail": (255, 153, 0),
+ # Projectile - Long Range
+ "Tremor": (74, 64, 47),
+ "Barrage": (196, 83, 2),
+ # Projectile - Standard
+ "Carbonized Lead": (192, 81, 214),
+ "Depleted Uranium": (103, 0, 207),
+ "EMP": (25, 194, 194),
+ "Fusion": (222, 140, 33),
+ "Nuclear": (122, 184, 15),
+ "Phased Plasma": (184, 15, 54),
+ "Proton": (55, 116, 117),
+ "Titanium Sabot": (54, 75, 94),
+
+ # Exotic Plasma - Advanced
+ "Occult": (189,0,38),
+ "Mystic": (252,174,145),
+ # Exotic Plasma - Standard
+ "Tetryon": (240,59,32),
+ "Baryon": (253,141,60),
+ "Meson": (254,204,92),
+
+ # Vorton Charges - Advanced
+ "ElectroPunch Ultra": (37,52,148),
+ "StrikeSnipe Ultra": (103,169,207),
+ # Vorton Charges - Standard
+ "BlastShot Condenser Pack": (49,163,84),
+ "GalvaSurge Condenser Pack": (44,127,184),
+ "MesmerFlux Condenser Pack": (65,182,196),
+ "SlamBolt Condenser Pack": (194,230,153),
+}
+
+# Missile damage type hues (0-360 degrees)
+MISSILE_DAMAGE_HUES = {
+ 'Mjolnir': 210, # Blue (EM)
+ 'Inferno': 0, # Red (Thermal)
+ 'Scourge': 180, # Cyan/Teal (Kinetic)
+ 'Nova': 30, # Orange (Explosive)
+}
+
+# Charge type saturation and value/brightness (0-100 scale)
+MISSILE_CHARGE_SV = {
+ 'Rage': (90, 55),
+ 'Fury': (90, 55),
+ 'Faction': (55, 90),
+ 'Precision': (50, 85),
+ 'Javelin': (50, 45),
+ 'T1': (25, 90),
+}
+
+
+def _hsv_to_rgb_255(h, s, v):
+ """Convert HSV (h: 0-360, s: 0-100, v: 0-100) to RGB (0-255)."""
+ r, g, b = colorsys.hsv_to_rgb(h / 360, s / 100, v / 100)
+ return (int(r * 255), int(g * 255), int(b * 255))
+
+
+def _generate_missile_colors():
+ """Generate missile ammo colors based on damage type hue and charge type sat/brightness."""
+ colors = {}
+
+ for damage_type, hue in MISSILE_DAMAGE_HUES.items():
+ # Rage variant
+ s, v = MISSILE_CHARGE_SV['Rage']
+ colors[f"{damage_type} Rage"] = _hsv_to_rgb_255(hue, s, v)
+
+ # Fury variant
+ s, v = MISSILE_CHARGE_SV['Fury']
+ colors[f"{damage_type} Fury"] = _hsv_to_rgb_255(hue, s, v)
+
+ # Faction variant
+ s, v = MISSILE_CHARGE_SV['Faction']
+ colors[f"Faction {damage_type}"] = _hsv_to_rgb_255(hue, s, v)
+
+ # Precision variant
+ s, v = MISSILE_CHARGE_SV['Precision']
+ colors[f"{damage_type} Precision"] = _hsv_to_rgb_255(hue, s, v)
+
+ # Javelin variant
+ s, v = MISSILE_CHARGE_SV['Javelin']
+ colors[f"{damage_type} Javelin"] = _hsv_to_rgb_255(hue, s, v)
+
+ # T1 Standard (just damage type name)
+ s, v = MISSILE_CHARGE_SV['T1']
+ colors[damage_type] = _hsv_to_rgb_255(hue, s, v)
+
+ return colors
+
+# Add generated missile colors to AMMO_COLORS
+AMMO_COLORS.update(_generate_missile_colors())
+
+
+def get_ammo_base_name(ammo_name):
+ """
+ Extract base ammo name by removing size suffix (S/M/L/XL), missile type suffixes, and other common suffixes.
+ """
+ if not ammo_name:
+ return None
+
+ cleaned = ammo_name
+
+ # Remove missile type suffixes (e.g., "Light Missile", "Heavy Assault Missile", "Torpedo", "Cruise Missile")
+ missile_suffixes = [
+ ' XL Torpedo', ' XL Cruise Missile', # XL variants first (longest match)
+ ' Light Missile', ' Heavy Missile', ' Heavy Assault Missile',
+ ' Cruise Missile', ' Torpedo', ' Auto-Targeting Missile',
+ ' Defender Missile',
+ ]
+ is_missile = False
+ for suffix in missile_suffixes:
+ if cleaned.endswith(suffix):
+ cleaned = cleaned[:-len(suffix)]
+ is_missile = True
+ break
+
+ # For turret ammo, remove faction prefixes (e.g., "Republic Fleet ", "Imperial Navy ", "Caldari Navy ")
+ # For missiles, keep faction prefix as it indicates ammo quality
+ if not is_missile:
+ faction_prefixes = [
+ 'Republic Fleet ', 'Imperial Navy ', 'Caldari Navy ', 'Federation Navy ',
+ 'Dread Guristas ', 'True Sansha ', 'Shadow Serpentis ', 'Domination ',
+ 'Dark Blood ', "Arch Angel ", 'Guristas ', 'Sansha ', 'Serpentis ',
+ 'Blood ', 'Angel '
+ ]
+ for prefix in faction_prefixes:
+ if cleaned.startswith(prefix):
+ cleaned = cleaned[len(prefix):]
+ break
+
+ cleaned = re.sub(r'\s+(S|M|L|XL)$', '', cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r'\s+Charge$', '', cleaned, flags=re.IGNORECASE)
+
+ return cleaned
+
+
+# Missile damage type base names for faction lookup
+MISSILE_DAMAGE_TYPES = {'Mjolnir', 'Inferno', 'Scourge', 'Nova'}
+
+# Faction prefixes to normalize for missile color lookup
+FACTION_PREFIXES = [
+ 'Caldari Navy ', 'Dread Guristas ', 'True Sansha ', 'Shadow Serpentis ',
+ 'Domination ', 'Dark Blood ', "Arch Angel ", 'Guristas ', 'Sansha ',
+ 'Serpentis ', 'Blood ', 'Angel ', 'Republic Fleet ', 'Imperial Navy ',
+ 'Federation Navy '
+]
+
+
+def get_ammo_color(ammo_name):
+ """
+ Get RGB color tuple for an ammo type.
+ Returns color in 0-1 range for matplotlib, or None if no color defined.
+ """
+ base_name = get_ammo_base_name(ammo_name)
+ if not base_name:
+ return None
+
+ color = None
+
+ # Direct lookup first
+ if base_name in AMMO_COLORS:
+ color = AMMO_COLORS[base_name]
+ else:
+ # For faction missiles, normalize to "Faction " lookup
+ # e.g., "Caldari Navy Mjolnir" -> try "Faction Mjolnir"
+ for prefix in FACTION_PREFIXES:
+ if base_name.startswith(prefix):
+ faction_normalized = 'Faction ' + base_name[len(prefix):]
+ if faction_normalized in AMMO_COLORS:
+ color = AMMO_COLORS[faction_normalized]
+ break
+
+ # If still not found, try partial match for turret ammo names
+ if color is None:
+ for key in AMMO_COLORS:
+ if key in base_name or base_name in key:
+ color = AMMO_COLORS[key]
+ break
+
+ # Convert from 0-255 to 0-1 range for matplotlib
+ if color:
+ return (color[0] / 255, color[1] / 255, color[2] / 255)
+ return None
+
+
+class FitAmmoOptimalDpsGraph(FitGraph):
+
+ # Graph definition
+ internalName = 'ammoOptimalDpsGraph'
+ name = 'Application Profile'
+ xDefs = [
+ XDef(handle='distance', unit='km', label='Distance', mainInput=('distance', 'km'))]
+ inputs = [
+ Input(handle='distance', unit='km', label='Distance', iconID=None, defaultValue=None, defaultRange=(0, 100), mainTooltip='Distance to target')]
+
+ # Vector controls for attacker and target velocity/angle (same as DPS graph)
+ srcVectorDef = VectorDef(lengthHandle='atkSpeed', lengthUnit='%', angleHandle='atkAngle', angleUnit='degrees', label='Attacker')
+ tgtVectorDef = VectorDef(lengthHandle='tgtSpeed', lengthUnit='%', angleHandle='tgtAngle', angleUnit='degrees', label='Target')
+
+ sources = {Fit}
+ _limitToOutgoingProjected = True
+ hasTargets = True
+ srcExtraCols = ('Dps', 'Volley', 'Speed', 'SigRadius', 'Radius')
+
+ @property
+ def tgtExtraCols(self):
+ """Define target extra columns similar to Damage Stats graph"""
+ cols = ['Target Resists', 'Speed', 'SigRadius', 'Radius']
+ return cols
+
+ @property
+ def yDefs(self):
+ ignoreResists = GraphSettings.getInstance().get('ammoOptimalIgnoreResists')
+ return [
+ YDef(handle='dps', unit=None, label='DPS' if ignoreResists else 'Effective DPS'),
+ YDef(handle='volley', unit=None, label='Volley' if ignoreResists else 'Effective Volley')]
+
+ # Normalizers convert input values to internal units
+ _normalizers = {
+ ('distance', 'km'): lambda v, src, tgt: None if v is None else v * 1000,
+ ('atkSpeed', '%'): lambda v, src, tgt: v / 100 * src.getMaxVelocity(),
+ ('tgtSpeed', '%'): lambda v, src, tgt: v / 100 * tgt.getMaxVelocity()}
+
+ # Denormalizers convert internal units back to display units
+ _denormalizers = {
+ ('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000,
+ ('tgtSpeed', '%'): lambda v, src, tgt: v * 100 / tgt.getMaxVelocity()}
+
+ # No limiters - allow user to specify any range they want
+ _limiters = {}
+
+ # Getter mapping
+ _getters = {
+ ('distance', 'dps'): Distance2OptimalAmmoDpsGetter,
+ ('distance', 'volley'): Distance2OptimalAmmoVolleyGetter}
+
+ # Enable segmented plotting for this graph
+ hasSegments = True
+
+ # Ammo color mode: True = use ammo-specific colors, False = use line patterns
+ useAmmoColors = True
+
+ def __init__(self):
+ super().__init__()
+ self._projectedCache = ProjectedDataCache()
+ self._rangeCache = {} # Cache for getDefaultInputRange: {frozenset(fitIDs): (min, max)}
+
+ def getAmmoColor(self, ammoName):
+ """Get RGB color tuple for an ammo type."""
+ return get_ammo_color(ammoName)
+
+ def getDefaultInputRange(self, inputDef, sources):
+ """
+ Calculate dynamic default range based on the turrets/missiles max effective range.
+
+ Returns (min, max) tuple in the input's units (km for distance).
+ For turrets: the longest range ammo's optimal+falloff*2 + 10%, capped at 300km.
+ For missiles: the longest range missile's max range + 10%, capped at 300km.
+ """
+ if inputDef.handle != 'distance' or not sources:
+ return inputDef.defaultRange
+
+ # Build cache key from fit IDs
+ fitIDs = frozenset(src.item.ID for src in sources if src.item is not None)
+ if not fitIDs:
+ return inputDef.defaultRange
+
+ # Check cache
+ if fitIDs in self._rangeCache:
+ return self._rangeCache[fitIDs]
+
+ max_range_m = 0
+
+ for src in sources:
+ fit = src.item
+ if fit is None:
+ continue
+
+ # Check all turrets and missiles
+ for mod in fit.activeModulesIter():
+ if mod.hardpoint == FittingHardpoint.TURRET:
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ # Get turret base stats
+ turret_base = getTurretBaseStats(mod)
+
+ # Check all compatible charges for this turret
+ for charge in getValidChargesForModule(mod):
+ charge_stats = getChargeStats(charge)
+
+ # Calculate effective optimal + 2*falloff (where DPS drops to ~6%)
+ effective_optimal = turret_base['optimal'] * charge_stats['rangeMultiplier']
+ effective_falloff = turret_base['falloff'] * charge_stats['falloffMultiplier']
+ effective_max = effective_optimal + effective_falloff * 2.5
+
+ if effective_max > max_range_m:
+ max_range_m = effective_max
+
+ elif mod.hardpoint == FittingHardpoint.MISSILE:
+ # For missiles, check ALL compatible charges to find longest range
+ # We need the max range across all ammo types, not just the loaded one
+
+ valid_charges = list(getValidChargesForModule(mod))
+ if not valid_charges:
+ continue
+
+ # Get flight multipliers from skills/ship (handling empty launcher case)
+ if mod.charge is None:
+ # Temp load first valid charge to extract multipliers
+ temp_charge = valid_charges[0]
+ mod.charge = temp_charge
+ if mod.owner:
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+ try:
+ flight_mults = getFlightMultipliers(mod)
+ finally:
+ # Always restore the empty launcher, even if the
+ # multiplier extraction raises - otherwise the fit
+ # is left with a phantom charge and stale attributes
+ mod.charge = None
+ if mod.owner:
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+ else:
+ flight_mults = getFlightMultipliers(mod)
+
+ for charge in valid_charges:
+ base_velocity = charge.getAttribute('maxVelocity') or 0
+ base_explosion_delay = charge.getAttribute('explosionDelay') or 0
+ if base_velocity > 0 and base_explosion_delay > 0:
+ # Apply skill/ship bonuses to flight attributes
+ maxVelocity = base_velocity * flight_mults['maxVelocity']
+ explosionDelay = base_explosion_delay * flight_mults['explosionDelay']
+ # Estimate range: velocity * flight_time
+ flightTime = explosionDelay / 1000
+ estimated_range = maxVelocity * flightTime * 1.1
+ if estimated_range > max_range_m:
+ max_range_m = estimated_range
+
+ if max_range_m <= 0:
+ return inputDef.defaultRange
+
+ # Add 10% buffer and convert to km
+ max_range_km = (max_range_m * 1.1) / 1000
+
+ # Cap at 300km (EVE's max lock range)
+ max_range_km = min(max_range_km, 300)
+
+ # Round to nice number
+ max_range_km = int(max_range_km + 0.5)
+
+ result = (0, max_range_km)
+ self._rangeCache[fitIDs] = result
+ return result
+
+ def _clearInternalCache(self, reason, extraData):
+ if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved):
+ # extraData is the fit ID (integer), not the fit object
+ fit_id = extraData
+
+ # Clear base projected cache for this fit
+ self._projectedCache.clearForFit(fit_id)
+
+ # Clear weapon cache entries for this specific fit only
+ # Cache key format: (fitID, weaponType, qualityTier, tgtResists, applyProjected, tgtSpeed, tgtSigRadius)
+ if hasattr(self, '_ammo_weapon_cache'):
+ keys_to_remove = [k for k in self._ammo_weapon_cache.keys() if k[0] == fit_id]
+ for key in keys_to_remove:
+ del self._ammo_weapon_cache[key]
+
+ # Clear projected cache entries for this specific fit (all target combinations)
+ # Projected cache key format: (fitID, tgtSpeed, tgtSigRadius)
+ if hasattr(self, '_ammo_projected_cache'):
+ keys_to_remove = [k for k in self._ammo_projected_cache.keys() if k[0] == fit_id]
+ for key in keys_to_remove:
+ del self._ammo_projected_cache[key]
+
+ # Clear range cache entries that include this fit ID
+ if hasattr(self, '_rangeCache'):
+ keys_to_remove = [k for k in self._rangeCache.keys() if fit_id in k]
+ for key in keys_to_remove:
+ del self._rangeCache[key]
+
+ # Clear charge cache - when fits change, weapon types might change
+ if hasattr(self, '_ammo_charge_cache'):
+ self._ammo_charge_cache = {}
+
+ elif reason in (GraphCacheCleanupReason.profileChanged, GraphCacheCleanupReason.profileRemoved):
+ if hasattr(self, '_ammo_weapon_cache'):
+ self._ammo_weapon_cache = {}
+
+ if hasattr(self, '_ammo_projected_cache'):
+ self._ammo_projected_cache = {}
+
+ if hasattr(self, '_rangeCache'):
+ self._rangeCache = {}
+
+ elif reason == GraphCacheCleanupReason.graphSwitched:
+ self._projectedCache.clearAll()
+
+ # Clear all ammo caches globally
+ if hasattr(self, '_ammo_weapon_cache'):
+ self._ammo_weapon_cache = {}
+
+ if hasattr(self, '_ammo_projected_cache'):
+ self._ammo_projected_cache = {}
+
+ if hasattr(self, '_rangeCache'):
+ self._rangeCache = {}
+
+ if hasattr(self, '_ammo_charge_cache'):
+ self._ammo_charge_cache = {}
+
+ elif reason in (GraphCacheCleanupReason.inputChanged, GraphCacheCleanupReason.optionChanged):
+ if hasattr(self, '_ammo_weapon_cache'):
+ self._ammo_weapon_cache = {}
+
+ if hasattr(self, '_ammo_projected_cache'):
+ self._ammo_projected_cache = {}
+
+
+ def getPlotSegments(self, mainInput, miscInputs, xSpec, ySpec, src, tgt=None):
+ """
+ Get segmented plot data with ammo information for color coding.
+
+ Returns list of segments, each with xs, ys, ammo name, and ammo index.
+ Returns None if this graph doesn't support segments or getter doesn't have getSegments.
+ """
+ try:
+ getterClass = self._getters[(xSpec.handle, ySpec.handle)]
+ except KeyError:
+ return None
+
+ # Normalize the input range
+ mainParamRange = self._normalizeMain(mainInput=mainInput, src=src, tgt=tgt)
+ miscParams = self._normalizeMisc(miscInputs=miscInputs, src=src, tgt=tgt)
+ mainParamRange = self._limitMain(mainParamRange=mainParamRange, src=src, tgt=tgt)
+ miscParams = self._limitMisc(miscParams=miscParams, src=src, tgt=tgt)
+
+ getter = getterClass(graph=self)
+
+ # Check if getter has getSegments method
+ if not hasattr(getter, 'getSegments'):
+ return None
+
+ segments = getter.getSegments(
+ xRange=mainParamRange[1],
+ miscParams=miscParams,
+ src=src,
+ tgt=tgt)
+
+ if not segments:
+ return None
+
+ # Denormalize the values back to display units
+ for segment in segments:
+ segment['xs'] = self._denormalizeValues(values=segment['xs'], axisSpec=xSpec, src=src, tgt=tgt)
+ segment['ys'] = self._denormalizeValues(values=segment['ys'], axisSpec=ySpec, src=src, tgt=tgt)
+
+ return segments
+
+ def getPointExtended(self, x, miscInputs, xSpec, ySpec, src, tgt=None):
+ """
+ Get point value with extended info (like ammo name) at x.
+
+ Returns (y_value, extra_info_dict) tuple.
+ extra_info_dict may contain 'ammo' key with the ammo name.
+ """
+ try:
+ getterClass = self._getters[(xSpec.handle, ySpec.handle)]
+ except KeyError:
+ return None, {}
+
+ x = self._normalizeValue(value=x, axisSpec=xSpec, src=src, tgt=tgt)
+ miscParams = self._normalizeMisc(miscInputs=miscInputs, src=src, tgt=tgt)
+ miscParams = self._limitMisc(miscParams=miscParams, src=src, tgt=tgt)
+
+ getter = getterClass(graph=self)
+
+ # Check if getter has getPointExtended method
+ if hasattr(getter, 'getPointExtended'):
+ y, extraInfo = getter.getPointExtended(x=x, miscParams=miscParams, src=src, tgt=tgt)
+ y = self._denormalizeValue(value=y, axisSpec=ySpec, src=src, tgt=tgt)
+ return y, extraInfo
+ else:
+ # Fall back to regular getPoint
+ y = self._getPoint(x=x, miscParams=miscParams, xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt)
+ y = self._denormalizeValue(value=y, axisSpec=ySpec, src=src, tgt=tgt)
+ return y, {}
+
+ def _updateMiscParams(self, **kwargs):
+ miscParams = super()._updateMiscParams(**kwargs)
+ # Set defaults from target profile
+ miscParams['tgtSigRadius'] = miscParams['tgt'].getSigRadius()
+ miscParams['tgtSpeed'] = miscParams['tgt'].getMaxVelocity()
+ miscParams.setdefault('atkSpeed', 0)
+ miscParams.setdefault('atkAngle', 0)
+ miscParams.setdefault('tgtAngle', 0)
+ return miscParams
diff --git a/graphs/data/fitDamageEnvelope/getter.py b/graphs/data/fitDamageEnvelope/getter.py
deleted file mode 100644
index a11a0f4252..0000000000
--- a/graphs/data/fitDamageEnvelope/getter.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# =============================================================================
-# Copyright (C) 2010 Diego Duclos
-#
-# This file is part of pyfa.
-#
-# pyfa is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# pyfa is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with pyfa. If not, see .
-# =============================================================================
-
-
-import eos.config
-from eos.const import FittingHardpoint
-from eos.saveddata.targetProfile import TargetProfile
-from eos.utils.spoolSupport import SpoolOptions, SpoolType
-from graphs.calc import checkLockRange
-from graphs.data.base import SmoothPointGetter
-from graphs.data.fitDamageStats.calc.application import (_calcMissileFactor, _calcTurretChanceToHit, _calcTurretMult,
- getApplicationPerKey, )
-from service.settings import GraphSettings
-
-
-def _buildResistProfile(tgtResists, tgtFullHp):
- if not GraphSettings.getInstance().get('ignoreResists'):
- emRes, thermRes, kinRes, exploRes = tgtResists
- else:
- emRes = thermRes = kinRes = exploRes = 0
- return TargetProfile(emAmount=emRes, thermalAmount=thermRes, kineticAmount=kinRes, explosiveAmount=exploRes,
- hp=tgtFullHp)
-
-
-def _typedDmgScalar(dmgTyped, applicationMult, profile):
- """Apply application multiplier and resist profile, return scalar EHP/s."""
- if applicationMult == 0:
- return 0
- scaled = dmgTyped * applicationMult
- scaled.profile = profile
- return scaled.total
-
-
-def _turretApplication(snapshot, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius):
- cth = _calcTurretChanceToHit(atkSpeed=atkSpeed, atkAngle=atkAngle, atkRadius=src.getRadius(),
- atkOptimalRange=snapshot['maxRange'] or 0, atkFalloffRange=snapshot['falloff'] or 0,
- atkTracking=snapshot['tracking'], atkOptimalSigRadius=snapshot['optimalSigRadius'], distance=distance,
- tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtRadius=tgt.getRadius(), tgtSigRadius=tgtSigRadius)
- return _calcTurretMult(cth)
-
-
-def _missileApplication(snapshot, distance, tgtSpeed, tgtSigRadius):
- rangeData = snapshot['missileMaxRangeData']
- if rangeData is None:
- return 0
- lowerRange, higherRange, higherChance = rangeData
- if distance is None or distance <= lowerRange:
- distanceFactor = 1
- elif lowerRange < distance <= higherRange:
- distanceFactor = higherChance
- else:
- distanceFactor = 0
- if distanceFactor == 0:
- return 0
- applicationFactor = _calcMissileFactor(atkEr=snapshot['aoeCloudSize'], atkEv=snapshot['aoeVelocity'],
- atkDrf=snapshot['aoeDamageReductionFactor'], tgtSpeed=tgtSpeed, tgtSigRadius=tgtSigRadius)
- return distanceFactor * applicationFactor
-
-
-def _snapshotTurret(mod, dmgTyped, charge):
- return {'kind': 'turret', 'charge': charge, 'dmg': dmgTyped, 'maxRange': mod.maxRange, 'falloff': mod.falloff,
- 'tracking': mod.getModifiedItemAttr('trackingSpeed'),
- 'optimalSigRadius': mod.getModifiedItemAttr('optimalSigRadius')}
-
-
-def _snapshotMissile(mod, dmgTyped, charge):
- return {'kind': 'missile', 'charge': charge, 'dmg': dmgTyped, 'missileMaxRangeData': mod.missileMaxRangeData,
- 'aoeCloudSize': mod.getModifiedChargeAttr('aoeCloudSize'),
- 'aoeVelocity': mod.getModifiedChargeAttr('aoeVelocity'),
- 'aoeDamageReductionFactor': mod.getModifiedChargeAttr('aoeDamageReductionFactor'),
- 'isFoF': 'fofMissileLaunching' in (charge.effects if charge else {})}
-
-
-def _isAmmoEnvelopeWeapon(mod):
- """Turret or standard missile launcher with valid charges."""
- if mod.hardpoint not in (FittingHardpoint.TURRET, FittingHardpoint.MISSILE):
- return False
- # Skip exotic weapon groups handled separately by stock app logic
- if mod.item.group.name in ('Missile Launcher Bomb', 'Structure Guided Bomb Launcher'):
- return False
- if 'ChainLightning' in mod.item.effects:
- return False
- if mod.isBreacher:
- return False
- return bool(mod.getValidCharges())
-
-
-def _snapshotForCurrentCharge(mod):
- """Build a snapshot dict for whatever charge is currently loaded on mod."""
- spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
- dmgTyped = mod.getDps(spoolOptions=spoolOptions)
- if mod.hardpoint == FittingHardpoint.TURRET:
- return _snapshotTurret(mod, dmgTyped, mod.charge)
- return _snapshotMissile(mod, dmgTyped, mod.charge)
-
-
-def _collectWeaponCandidates(src):
- """For each ammo-bearing weapon, return list of per-charge snapshots.
-
- Charge-dependent attributes (optimal/falloff/tracking/missile range/AoE) are
- only applied to the module's modified attributes by a full fit recalc.
- Since ammo effects are gun-local in EVE (a crystal in laser-1 does not
- affect laser-2's attributes), we load up to N different ammos onto N
- different weapons of the same group, recalc the fit once, and snapshot
- all N (weapon, ammo) pairs from that single recalc. For a group of size
- K weapons and M ammos this needs ceil(M / K) recalcs instead of M.
- Originals are always restored via try/finally even if a calc raises.
- """
- fit = src.item
- weapon_mods = [mod for mod in fit.activeModulesIter() if _isAmmoEnvelopeWeapon(mod)]
- if not weapon_mods:
- return []
-
- # Group by (item ID, state) — within such a group, snapshots can be shared
- # across mods, and DPS reads need consistent per-mod state.
- groups = {}
- for mod in weapon_mods:
- groups.setdefault((mod.item.ID, mod.state), []).append(mod)
-
- originals = {id(mod): mod.charge for mod in weapon_mods}
- snapshots_by_mod = {id(mod): [] for mod in weapon_mods}
- spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
-
- try:
- for group_mods in groups.values():
- valid_charges = sorted(group_mods[0].getValidCharges(), key=lambda c: c.name)
- if not valid_charges:
- continue
- chunk_size = len(group_mods)
- for chunk_start in range(0, len(valid_charges), chunk_size):
- chunk = valid_charges[chunk_start:chunk_start + chunk_size]
- # Assign one chunk-ammo per group mod (extras stay on their previous charge)
- for i, charge in enumerate(chunk):
- group_mods[i].charge = charge
- fit.clear()
- fit.calculateModifiedAttributes()
- # Snapshot per (assignee mod, charge); copy to all group mods since
- # within an (item ID, state) group attributes for a given ammo match.
- for i, charge in enumerate(chunk):
- assignee = group_mods[i]
- dmgTyped = assignee.getDps(spoolOptions=spoolOptions)
- if dmgTyped.total <= 0:
- continue
- if assignee.hardpoint == FittingHardpoint.TURRET:
- snap = _snapshotTurret(assignee, dmgTyped, charge)
- else:
- snap = _snapshotMissile(assignee, dmgTyped, charge)
- for target_mod in group_mods:
- snapshots_by_mod[id(target_mod)].append(snap)
- finally:
- for mod in weapon_mods:
- mod.charge = originals[id(mod)]
- fit.clear()
- fit.calculateModifiedAttributes()
-
- weapons = [{'mod': mod, 'candidates': snapshots_by_mod[id(mod)]} for mod in weapon_mods if
- snapshots_by_mod[id(mod)]]
- for weapon in weapons:
- weapon['candidates'] = _pruneDominated(weapon['candidates'], src)
- return weapons
-
-
-def _pruneDominated(candidates, src):
- """Drop candidates whose effective-DPS curve is dominated everywhere.
-
- Sample each candidate's application-only multiplier (ignoring resists,
- which are mod-independent and uniformly scale all candidates) over a
- coarse distance grid. A candidate X is dominated if there exists Y such
- that Y's raw_damage * multiplier(distance) >= X's at every sample.
- """
- if len(candidates) <= 1:
- return candidates
- # Sample multipliers under a neutral mid-range scenario; this captures
- # the shape of each ammo's range envelope without depending on misc inputs.
- sampleDistances = [0, 1000, 5000, 10000, 20000, 40000, 80000, 160000, 320000]
- tgtSpeed = 0
- atkSpeed = 0
- tgtSigRadius = 125
- sigRefMod = src.getSigRadius() # not directly used, kept for clarity
- del sigRefMod
- # For each candidate, build a scalar score vector across samples.
- scores = []
- for snap in candidates:
- rawTotal = snap['dmg'].total
- vec = []
- for d in sampleDistances:
- if snap['kind'] == 'turret':
- # Use only the range factor (drop tracking — angular speed is 0 here)
- # by passing 0 atkSpeed/tgtSpeed/tgtAngle.
- mult = _turretApplication(snap, src, src, atkSpeed, 0, d, tgtSpeed, 0, tgtSigRadius)
- else:
- mult = _missileApplication(snap, d, tgtSpeed, tgtSigRadius)
- vec.append(rawTotal * mult)
- scores.append(vec)
- # Mark dominated
- n = len(candidates)
- eps = 1e-9
- keep = [True] * n
- for i in range(n):
- if not keep[i]:
- continue
- for j in range(n):
- if i == j or not keep[j]:
- continue
- # j dominates i if scores[j][k] >= scores[i][k] - eps for all k
- # and scores[j][k] > scores[i][k] + eps for at least one k
- dominates = True
- strict = False
- for k in range(len(sampleDistances)):
- if scores[j][k] + eps < scores[i][k]:
- dominates = False
- break
- if scores[j][k] > scores[i][k] + eps:
- strict = True
- if dominates and strict:
- keep[i] = False
- break
- return [c for c, k in zip(candidates, keep) if k]
-
-
-def _bestWeaponDpsAtDistance(weapon, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius, profile,
- inLockRange):
- if not inLockRange:
- # Special case: FoF missiles ignore lock range
- candidates = [c for c in weapon['candidates'] if c.get('isFoF')]
- if not candidates:
- return 0
- else:
- candidates = weapon['candidates']
- best = 0
- for snap in candidates:
- if snap['kind'] == 'turret':
- mult = _turretApplication(snap, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius)
- else:
- mult = _missileApplication(snap, distance, tgtSpeed, tgtSigRadius)
- scalar = _typedDmgScalar(snap['dmg'], mult, profile)
- if scalar > best:
- best = scalar
- return best
-
-
-class Distance2EnvelopeDpsGetter(SmoothPointGetter):
- _baseResolution = 50
- _extraDepth = 2
-
- def _getCommonData(self, miscParams, src, tgt):
- # Snapshot per-weapon ammo candidates once. _calculatePoint reuses these
- # for every distance step so we avoid repeated charge swaps.
- weapons = _collectWeaponCandidates(src)
- # Track ammo-envelope weapon IDs so we can subtract their stock contribution
- # from the common application map below.
- envelopeMods = {id(w['mod']) for w in weapons}
- # Standard application path covers everything else (drones, fighters,
- # smartbombs, doomsdays, modules without valid charges, etc.).
- defaultSpool = eos.config.settings['globalDefaultSpoolupPercentage']
- spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, defaultSpool, False)
- nonEnvelopeDmg = {}
- for mod in src.item.activeModulesIter():
- if id(mod) in envelopeMods:
- continue
- if not mod.isDealingDamage():
- continue
- nonEnvelopeDmg[mod] = mod.getDps(spoolOptions=spoolOptions)
- for drone in src.item.activeDronesIter():
- if not drone.isDealingDamage():
- continue
- nonEnvelopeDmg[drone] = drone.getDps()
- for fighter in src.item.activeFightersIter():
- if not fighter.isDealingDamage():
- continue
- for effectID, effectDps in fighter.getDpsPerEffect().items():
- nonEnvelopeDmg[(fighter, effectID)] = effectDps
- return {'weapons': weapons, 'nonEnvelopeDmg': nonEnvelopeDmg, 'tgtResists': tgt.getResists(),
- 'tgtFullHp': tgt.getFullHp()}
-
- def _calculatePoint(self, x, miscParams, src, tgt, commonData):
- distance = x
- tgtSpeed = miscParams['tgtSpeed']
- tgtSigRadius = miscParams.get('tgtSigRad', tgt.getSigRadius())
- atkSpeed = miscParams.get('atkSpeed', 0) or 0
- atkAngle = miscParams.get('atkAngle', 0) or 0
- tgtAngle = miscParams.get('tgtAngle', 0) or 0
- profile = _buildResistProfile(commonData['tgtResists'], commonData['tgtFullHp'])
- inLockRange = checkLockRange(src=src, distance=distance)
-
- total = 0
- # Sum optimum-ammo contribution for each ammo-bearing weapon
- for weapon in commonData['weapons']:
- total += _bestWeaponDpsAtDistance(weapon=weapon, src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
- distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius, profile=profile,
- inLockRange=inLockRange)
-
- # Add fixed-ammo contributors (drones, fighters, smartbombs, etc.) using
- # the standard application math from fitDamageStats.
- if commonData['nonEnvelopeDmg']:
- applicationMap = getApplicationPerKey(src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
- distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius)
- for key, dmgTyped in commonData['nonEnvelopeDmg'].items():
- mult = applicationMap.get(key, 0)
- total += _typedDmgScalar(dmgTyped, mult, profile)
- return total
diff --git a/graphs/data/fitDamageEnvelope/graph.py b/graphs/data/fitDamageEnvelope/graph.py
deleted file mode 100644
index a531422fc9..0000000000
--- a/graphs/data/fitDamageEnvelope/graph.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# =============================================================================
-# Copyright (C) 2010 Diego Duclos
-#
-# This file is part of pyfa.
-#
-# pyfa is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# pyfa is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with pyfa. If not, see .
-# =============================================================================
-
-
-import wx
-
-from graphs.data.base import FitGraph, Input, VectorDef, XDef, YDef
-from service.settings import GraphSettings
-from .getter import Distance2EnvelopeDpsGetter
-
-_t = wx.GetTranslation
-
-
-class FitDamageEnvelopeGraph(FitGraph):
- # UI stuff
- internalName = 'dmgEnvelopeGraph'
- name = _t('Damage Projection')
- xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
- inputs = [
- Input(handle='distance', unit='km', label=_t('Distance'), iconID=1391, defaultValue=None, defaultRange=(0, 100),
- mainTooltip=_t('Distance between the attacker and the target, as seen in overview (surface-to-surface)'),
- secondaryTooltip=_t(
- 'Distance between the attacker and the target, as seen in overview (surface-to-surface)')),
- Input(handle='tgtSpeed', unit='%', label=_t('Target speed'), iconID=1389, defaultValue=100,
- defaultRange=(0, 100)),
- Input(handle='tgtSigRad', unit='%', label=_t('Target signature'), iconID=1390, defaultValue=100,
- defaultRange=(100, 200), conditions=[(('tgtSigRad', 'm'), None), (('tgtSigRad', '%'), None)])]
- srcVectorDef = VectorDef(lengthHandle='atkSpeed', lengthUnit='%', angleHandle='atkAngle', angleUnit='degrees',
- label=_t('Attacker'))
- tgtVectorDef = VectorDef(lengthHandle='tgtSpeed', lengthUnit='%', angleHandle='tgtAngle', angleUnit='degrees',
- label=_t('Target'))
- hasTargets = True
- srcExtraCols = ('Dps', 'Speed', 'Radius')
-
- @property
- def yDefs(self):
- ignoreResists = GraphSettings.getInstance().get('ignoreResists')
- return [YDef(handle='dps', unit=None, label=_t('Best DPS') if ignoreResists else _t('Best effective DPS'))]
-
- @property
- def tgtExtraCols(self):
- cols = []
- if not GraphSettings.getInstance().get('ignoreResists'):
- cols.append('Target Resists')
- cols.extend(('Speed', 'SigRadius', 'Radius', 'FullHP'))
- return cols
-
- # Calculation stuff
- _normalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v * 1000,
- ('atkSpeed', '%'): lambda v, src, tgt: v / 100 * src.getMaxVelocity(),
- ('tgtSpeed', '%'): lambda v, src, tgt: v / 100 * tgt.getMaxVelocity(),
- ('tgtSigRad', '%'): lambda v, src, tgt: v / 100 * tgt.getSigRadius()}
- _getters = {('distance', 'dps'): Distance2EnvelopeDpsGetter}
- _denormalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000,
- ('tgtSpeed', '%'): lambda v, src, tgt: v * 100 / tgt.getMaxVelocity(),
- ('tgtSigRad', '%'): lambda v, src, tgt: v * 100 / tgt.getSigRadius()}
diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py
index 4c862f6001..e792e0bf70 100644
--- a/graphs/gui/canvasPanel.py
+++ b/graphs/gui/canvasPanel.py
@@ -51,6 +51,7 @@
from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as Canvas
from matplotlib.figure import Figure
from matplotlib.colors import hsv_to_rgb
+ import matplotlib.patheffects as PathEffects
except ImportError as e:
pyfalog.warning('Matplotlib failed to import. Likely missing or incompatible version.')
graphFrame_enabled = False
@@ -101,7 +102,37 @@ def __init__(self, graphFrame, parent):
self.mplOnDragHandler = None
self.mplOnReleaseHandler = None
+ # Blitting state for fast X marker updates during drag
+ self._blitBackground = None # Saved background (without X marker)
+ self._xMarkerArtists = [] # Artists for X marker (line + labels)
+ self._blitPlotData = {} # Cached plot data for interpolation during drag
+ self._blitView = None # Cached view
+ self._blitIterList = None # Cached source/target pairs
+ self._blitCanvasLimits = None # Cached (canvasMinX, canvasMaxX, canvasMinY, canvasMaxY)
+ self._blitChosenX = None # Cached X axis spec
+ self._blitChosenY = None # Cached Y axis spec
+ self._blitYDiff = None # Cached Y range for rounding
+ self._blitHasSegments = False # Cached segment flag
+
+ # Track if user has manually overridden the input range (to prevent dynamic bounds from re-triggering)
+ self._defaultInputRange = None # Stores the default (minX, maxX) from graph definition
+ self._userModifiedInput = False # Flag: has user manually changed input field?
+
+ def resetDynamicBoundsTracking(self):
+ """
+ Forget any manual input-range override.
+
+ Called when the graph is switched: the panel is a single long-lived
+ instance shared across all graph types, so without this reset a manual
+ edit on one graph would permanently suppress dynamic auto-ranging for
+ every other graph for the rest of the session.
+ """
+ self._defaultInputRange = None
+ self._userModifiedInput = False
+
def draw(self, accurateMarks=True):
+ # Invalidate blit cache at the start of every draw
+ self._blitBackground = None
self.subplot.clear()
self.subplot.grid(True)
allXs = set()
@@ -116,12 +147,24 @@ def draw(self, accurateMarks=True):
mainInput, miscInputs = self.graphFrame.ctrlPanel.getValues()
view = self.graphFrame.getView()
+
+ # Track the effective max X where data ends (where Y drops to minY threshold)
+ # This is used to limit X bounds for missile-like data that doesn't span full range
+ effectiveMaxX = None
+
+ # Set ammo quality on view for segmented graphs
+ if hasattr(view, 'hasSegments') and view.hasSegments:
+ view._ammoQuality = self.graphFrame.ctrlPanel.ammoQuality
+
sources = self.graphFrame.ctrlPanel.sources
if view.hasTargets:
iterList = tuple(itertools.product(sources, self.graphFrame.ctrlPanel.targets))
else:
iterList = tuple((f, None) for f in sources)
+ # Check if this view supports segmented plotting
+ hasSegments = getattr(view, 'hasSegments', False)
+
# Draw plot lines and get data for legend
for source, target in iterList:
# Get line style data
@@ -130,7 +173,7 @@ def draw(self, accurateMarks=True):
except KeyError:
pyfalog.warning('Invalid color "{}" for "{}"'.format(source.colorID, source.name))
continue
- color = colorData.hsl
+ baseColor = colorData.hsl
lineStyle = 'solid'
if target is not None:
try:
@@ -138,53 +181,223 @@ def draw(self, accurateMarks=True):
except KeyError:
pyfalog.warning('Invalid lightness "{}" for "{}"'.format(target.lightnessID, target.name))
continue
- color = lightnessData.func(color)
+ baseColor = lightnessData.func(baseColor)
try:
lineStyleData = STYLES[target.lineStyleID]
except KeyError:
pyfalog.warning('Invalid line style "{}" for "{}"'.format(target.lightnessID, target.name))
continue
lineStyle = lineStyleData.mplSpec
- color = hsv_to_rgb(hsl_to_hsv(color))
- # Get point data
- try:
- xs, ys = view.getPlotPoints(
- mainInput=mainInput,
- miscInputs=miscInputs,
- xSpec=chosenX,
- ySpec=chosenY,
- src=source,
- tgt=target)
- if not self.__checkNumbers(xs, ys):
- pyfalog.warning('Failed to plot "{}" vs "{}" due to inf or NaN in values'.format(source.name, '' if target is None else target.name))
- continue
- plotData[(source, target)] = (xs, ys)
- allXs.update(xs)
- allYs.update(ys)
- # If we have single data point, show marker - otherwise line won't be shown
- if len(xs) == 1 and len(ys) == 1:
- self.subplot.plot(xs, ys, color=color, linestyle=lineStyle, marker='.')
- else:
- self.subplot.plot(xs, ys, color=color, linestyle=lineStyle)
- # Fill data for legend
- if target is None:
- legendData.append((color, lineStyle, source.shortName))
- else:
- legendData.append((color, lineStyle, '{} vs {}'.format(source.shortName, target.shortName)))
- except (KeyboardInterrupt, SystemExit):
- raise
- except Exception:
- pyfalog.warning('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name))
- self.canvas.draw()
- self.Refresh()
- return
+ # Try segmented plotting first if supported
+ segmentsPlotted = False
+ if hasSegments:
+ try:
+ segments = view.getPlotSegments(
+ mainInput=mainInput,
+ miscInputs=miscInputs,
+ xSpec=chosenX,
+ ySpec=chosenY,
+ src=source,
+ tgt=target)
+ if segments:
+ segmentsPlotted = True
+ # Base color from source/target selection
+ baseRgbColor = hsv_to_rgb(hsl_to_hsv(baseColor))
+ styleKeys = list(STYLES.keys())
+
+ # Get ammo style from control panel ('none', 'pattern', 'color')
+ ammoStyle = self.graphFrame.ctrlPanel.ammoStyle
+ getAmmoColorFunc = getattr(view, 'getAmmoColor', None)
+
+ segmentXs = []
+ segmentYs = []
+ legendSegments = [] # Track segments for legend
+ lastSegmentColor = None
+ lastSegmentStyle = None
+ lastSegmentMaxX = None
+
+ for segIdx, segment in enumerate(segments):
+ xs = segment['xs']
+ ys = segment['ys']
+ ammoName = segment.get('ammo', 'Unknown')
+ ammoIndex = segment.get('ammoIndex', 0)
+
+ if not self.__checkNumbers(xs, ys):
+ continue
+
+ # Check if this is the last segment
+ isLastSegment = (segIdx == len(segments) - 1)
+
+ # Track effective max X (where data actually ends)
+ if xs:
+ segMaxX = max(xs)
+ if effectiveMaxX is None or segMaxX > effectiveMaxX:
+ effectiveMaxX = segMaxX
+
+ # Determine color and line style based on ammo style mode
+ if ammoStyle == 'color' and getAmmoColorFunc:
+ # Color mode: use ammo-specific colors, use target's line style
+ ammoColor = getAmmoColorFunc(ammoName)
+ if ammoColor:
+ segColor = ammoColor
+ else:
+ # Fallback to base color if no ammo color defined
+ segColor = baseRgbColor
+ # Use the target's line style selection
+ segLineStyle = lineStyle
+ elif ammoStyle == 'pattern':
+ # Pattern mode: use base color, vary line patterns
+ segColor = baseRgbColor
+ segStyleKey = styleKeys[ammoIndex % len(styleKeys)]
+ segStyleData = STYLES[segStyleKey]
+ segLineStyle = segStyleData.mplSpec
+ else:
+ # None mode: solid single color line
+ segColor = baseRgbColor
+ segLineStyle = 'solid'
+
+ # Track last segment info for potential Y=0 connection
+ lastSegmentColor = segColor
+ lastSegmentStyle = segLineStyle
+ lastSegmentMaxX = max(xs) if xs else None
+
+ # Plot this segment
+ if len(xs) == 1 and len(ys) == 1:
+ self.subplot.plot(xs, ys, color=segColor, linestyle=segLineStyle, marker='.', linewidth=2)
+ else:
+ self.subplot.plot(xs, ys, color=segColor, linestyle=segLineStyle, linewidth=2)
+
+ segmentXs.extend(xs)
+ segmentYs.extend(ys)
+
+ # Track for legend (color mode only) - always use solid lines in legend
+ if ammoStyle == 'color' and ammoName not in [ls[2] for ls in legendSegments]:
+ legendSegments.append((segColor, 'solid', ammoName))
+
+ # Store combined data for X mark lookup
+ if segmentXs and segmentYs:
+ # Store segment boundaries for fast ammo name lookup during drag
+ segmentData = []
+ for seg in segments:
+ if seg['xs']:
+ segmentData.append((min(seg['xs']), max(seg['xs']), seg.get('ammo', 'Unknown')))
+ plotData[(source, target)] = (segmentXs, segmentYs, segmentData)
+ allXs.update(segmentXs)
+ allYs.update(segmentYs)
+
+ # Add legend entries
+ if ammoStyle == 'color':
+ # Add legend entry for each ammo type (avoid duplicates across targets)
+ existingLabels = [ld[2] for ld in legendData]
+ for segColor, segLineStyle, ammoName in legendSegments:
+ if ammoName not in existingLabels:
+ legendData.append((segColor, 'solid', ammoName))
+ existingLabels.append(ammoName)
+ else:
+ # Single legend entry for this source (none or pattern mode)
+ if target is None:
+ legendData.append((baseRgbColor, 'solid', source.shortName))
+ else:
+ legendData.append((baseRgbColor, 'solid', '{} vs {}'.format(source.shortName, target.shortName)))
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except Exception as e:
+ pyfalog.warning('Failed to get segments for "{}" vs "{}": {}'.format(
+ source.name, '' if target is None else target.name, e))
+
+ # Fall back to regular plotting if segments not available or failed
+ if not segmentsPlotted:
+ color = hsv_to_rgb(hsl_to_hsv(baseColor))
+ try:
+ xs, ys = view.getPlotPoints(
+ mainInput=mainInput,
+ miscInputs=miscInputs,
+ xSpec=chosenX,
+ ySpec=chosenY,
+ src=source,
+ tgt=target)
+ if not self.__checkNumbers(xs, ys):
+ pyfalog.warning('Failed to plot "{}" vs "{}" due to inf or NaN in values'.format(source.name, '' if target is None else target.name))
+ continue
+ # Track effective max X (where data actually ends)
+ if xs:
+ dataMaxX = max(xs)
+ if effectiveMaxX is None or dataMaxX > effectiveMaxX:
+ effectiveMaxX = dataMaxX
+
+ plotData[(source, target)] = (xs, ys, None)
+ allXs.update(xs)
+ allYs.update(ys)
+ # If we have single data point, show marker - otherwise line won't be shown
+ if len(xs) == 1 and len(ys) == 1:
+ self.subplot.plot(xs, ys, color=color, linestyle=lineStyle, marker='.')
+ else:
+ self.subplot.plot(xs, ys, color=color, linestyle=lineStyle)
+ # Fill data for legend
+ if target is None:
+ legendData.append((color, lineStyle, source.shortName))
+ else:
+ legendData.append((color, lineStyle, '{} vs {}'.format(source.shortName, target.shortName)))
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except Exception:
+ pyfalog.warning('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name))
+ self.canvas.draw()
+ self.Refresh()
+ return
# Setting Y limits for canvas
if self.graphFrame.ctrlPanel.showY0:
allYs.add(0)
- canvasMinY, canvasMaxY = self._getLimits(allYs, minExtra=0.05, maxExtra=0.1)
- canvasMinX, canvasMaxX = self._getLimits(allXs, minExtra=0.02, maxExtra=0.02)
+ # Include the user's input range in X limits so axis extends to full range
+ if mainInput and mainInput.value:
+ inputMin = min(mainInput.value)
+ inputMax = max(mainInput.value)
+ allXs.add(inputMin)
+
+ # Initialize default input range on first draw (before any dynamic bounds are applied)
+ if self._defaultInputRange is None:
+ # Get the default range directly from the graph's Input definition
+ # This is the "true" default before any dynamic adjustments
+ try:
+ graphView = self.graphFrame.getView()
+ for inputDef in graphView.inputs:
+ if inputDef == mainInput:
+ self._defaultInputRange = (min(inputDef.defaultValue), max(inputDef.defaultValue))
+ break
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except:
+ # Fallback: use current input as default
+ self._defaultInputRange = (inputMin, inputMax)
+
+ # Check if user has manually modified the input field
+ # Compare current input to the original default range from graph definition
+ if not self._userModifiedInput and self._defaultInputRange is not None:
+ defaultMin, defaultMax = self._defaultInputRange
+ # If input range differs from the graph's default, user has manually modified it
+ if inputMin != defaultMin or inputMax != defaultMax:
+ self._userModifiedInput = True
+
+ # Application Profile graph: use dynamic bounds ONLY on initial load
+ # Once user modifies input OR once dynamic bounds have been applied once, lock it
+ # Damage Stats graph: always uses static bounds (full input range)
+ useDynamicBounds = (
+ effectiveMaxX is not None and
+ view.internalName == 'ammoOptimalDpsGraph' and
+ not self._userModifiedInput and
+ self._defaultInputRange is not None and
+ inputMax == self._defaultInputRange[1] # Only if input is still at default
+ )
+
+ if useDynamicBounds:
+ effectiveMaxXWithMargin = effectiveMaxX * 1
+ allXs.add(effectiveMaxXWithMargin)
+ else:
+ allXs.add(inputMax)
+ canvasMinY, canvasMaxY = self._getLimits(allYs, minExtra=0.05, maxExtra=0.03, roundNice=True)
+ canvasMinX, canvasMaxX = self._getLimits(allXs, minExtra=0.02, maxExtra=0.02, roundNice=False)
self.subplot.set_ylim(bottom=canvasMinY, top=canvasMaxY)
self.subplot.set_xlim(left=canvasMinX, right=canvasMaxX)
# Process X marks line
@@ -196,29 +409,23 @@ def draw(self, accurateMarks=True):
maxY = max(allYs, default=None)
yDiff = (maxY or 0) - (minY or 0)
xMark = max(min(self.xMark, maxX), minX)
- # If in top 10% of X coordinates, align labels differently
- if xMark > canvasMinX + 0.9 * (canvasMaxX - canvasMinX):
- labelAlignment = 'right'
- labelPrefix = ''
- labelSuffix = ' '
- else:
- labelAlignment = 'left'
- labelPrefix = ' '
- labelSuffix = ''
- # Draw line
+
+ # Draw line first
self.subplot.axvline(x=xMark, linestyle='dotted', linewidth=1, color=(0, 0, 0))
- # Draw its X position
+
+ # Prepare X label text (without prefix/suffix yet)
if chosenX.unit is None:
- xLabel = '{}{}{}'.format(labelPrefix, roundToPrec(xMark, 4), labelSuffix)
+ xLabelCore = '{}'.format(roundToPrec(xMark, 4))
else:
- xLabel = '{}{} {}{}'.format(labelPrefix, roundToPrec(xMark, 4), chosenX.unit, labelSuffix)
- self.subplot.annotate(
- xLabel, xy=(xMark, canvasMaxY - 0.01 * (canvasMaxY - canvasMinY)), xytext=(0, 0), annotation_clip=False,
- textcoords='offset pixels', ha=labelAlignment, va='top', fontsize='small')
- # Get Y values
- yMarks = set()
+ xLabelCore = '{} {}'.format(roundToPrec(xMark, 4), chosenX.unit)
+
+ # Text outline effect for better visibility
+ textOutline = [PathEffects.withStroke(linewidth=3, foreground='white')]
+
+ # Get Y values with optional extra info (like ammo name)
+ yMarks = {} # {rounded_value: extra_info_str}
- def addYMark(val):
+ def addYMark(val, extraInfo=None):
if val is None:
return
# Round according to shown Y range - the bigger the range,
@@ -230,23 +437,42 @@ def addYMark(val):
# If due to some bug or insufficient plot density we're
# out of bounds, do not add anything
if minY <= val <= maxY or minY <= rounded <= maxY:
- yMarks.add(rounded)
+ yMarks[rounded] = extraInfo
for source, target in iterList:
- xs, ys = plotData[(source, target)]
+ if (source, target) not in plotData:
+ continue
+ plotEntry = plotData[(source, target)]
+ xs, ys = plotEntry[0], plotEntry[1]
+ segmentData = plotEntry[2] if len(plotEntry) > 2 else None
if not xs or xMark < min(xs) or xMark > max(xs):
continue
# Fetch values from graphs when we're asked to provide accurate data
if accurateMarks:
try:
- y = view.getPoint(
- x=xMark,
- miscInputs=miscInputs,
- xSpec=chosenX,
- ySpec=chosenY,
- src=source,
- tgt=target)
- addYMark(y)
+ # Try extended point info first (for ammo name etc.)
+ if hasattr(view, 'getPointExtended'):
+ y, extraInfo = view.getPointExtended(
+ x=xMark,
+ miscInputs=miscInputs,
+ xSpec=chosenX,
+ ySpec=chosenY,
+ src=source,
+ tgt=target)
+ # Build extra info string
+ extraStr = None
+ if extraInfo and extraInfo.get('ammo'):
+ extraStr = extraInfo['ammo']
+ addYMark(y, extraStr)
+ else:
+ y = view.getPoint(
+ x=xMark,
+ miscInputs=miscInputs,
+ xSpec=chosenX,
+ ySpec=chosenY,
+ src=source,
+ tgt=target)
+ addYMark(y)
except (KeyboardInterrupt, SystemExit):
raise
except Exception:
@@ -255,20 +481,116 @@ def addYMark(val):
continue
# Otherwise just do linear interpolation between two points
else:
+ # Get ammo name from segment data (fast and accurate)
+ extraStr = None
+ if segmentData:
+ for min_x, max_x, ammo_name in segmentData:
+ if min_x <= xMark <= max_x:
+ extraStr = ammo_name
+ break
+ # If xMark is beyond all segments, use last segment's ammo
+ if extraStr is None and segmentData:
+ extraStr = segmentData[-1][2]
+
if xMark in xs:
# We might have multiples of the same value in our sequence, pick value for the last one
idx = len(xs) - xs[::-1].index(xMark) - 1
- addYMark(ys[idx])
+ addYMark(ys[idx], extraStr)
continue
idx = bisect(xs, xMark)
yMark = self._interpolateX(x=xMark, x1=xs[idx - 1], y1=ys[idx - 1], x2=xs[idx], y2=ys[idx])
- addYMark(yMark)
+ addYMark(yMark, extraStr)
+
+ # Draw Y values with optional extra info
+ # First, collect all labels to determine the widest one
+ labelData = [] # List of (yMark, labelText)
+
+ # For DPS graphs (Damage Stats and Application Profile), show integers
+ isDpsGraph = view.internalName in ('dmgStatsGraph', 'ammoOptimalDpsGraph')
+
+ for yMark, extraInfo in yMarks.items():
+ # Format yMark as integer for DPS graphs
+ if isDpsGraph:
+ yMarkStr = '{:.0f}'.format(yMark)
+ else:
+ yMarkStr = '{}'.format(yMark)
- # Draw Y values
- for yMark in yMarks:
+ if extraInfo:
+ labelText = '{} ({})'.format(yMarkStr, extraInfo)
+ else:
+ labelText = yMarkStr
+ labelData.append((yMark, labelText))
+
+ # Determine alignment based on position in data range
+ # Use a simple percentage-based approach but factor in text length
+ # by using a smaller threshold for longer text
+ xRange = canvasMaxX - canvasMinX
+ xPosRatio = (xMark - canvasMinX) / xRange if xRange > 0 else 0
+
+ # Find the longest label to estimate how early we need to flip
+ maxLabelLen = len(xLabelCore)
+ for yMark, labelText in labelData:
+ maxLabelLen = max(maxLabelLen, len(labelText))
+
+ # Adjust threshold based on label length
+ # Short labels (< 15 chars): flip at 80%
+ # Medium labels (15-30 chars): flip at 65%
+ # Long labels (> 30 chars): flip at 50%
+ if maxLabelLen < 15:
+ flipThreshold = 0.80
+ elif maxLabelLen < 30:
+ flipThreshold = 0.65
+ else:
+ flipThreshold = 0.50
+
+ if xPosRatio > flipThreshold:
+ labelAlignment = 'right'
+ labelPrefix = ''
+ labelSuffix = ' '
+ else:
+ labelAlignment = 'left'
+ labelPrefix = ' '
+ labelSuffix = ''
+
+ # Unify Y label offsetting logic with blit path
+ textOutline = [PathEffects.withStroke(linewidth=3, foreground='white')]
+
+ # Draw X label
+ xLabel = '{}{}{}'.format(labelPrefix, xLabelCore, labelSuffix)
+ self.subplot.annotate(
+ xLabel, xy=(xMark, canvasMaxY - 0.01 * (canvasMaxY - canvasMinY)), xytext=(0, 0), annotation_clip=False,
+ textcoords='offset pixels', ha=labelAlignment, va='top', fontsize='small',
+ path_effects=textOutline)
+
+ # Draw Y labels with fixed pixel offset for anti-overlap
+ labelData.sort(key=lambda x: x[0])
+ pixel_pad = 8 # 8 pixels padding top/bottom
+ pixel_spacing = 16 # 16 pixels minimum spacing between labels
+ adjusted_y = []
+ # Convert pixel spacing to data units using the axis transform
+ trans = self.subplot.transData.inverted()
+ # Get the pixel height of the graph area
+ bbox = self.subplot.get_window_extent()
+ y0_pix = bbox.y0
+ y1_pix = bbox.y1
+ # Calculate data units per pixel
+ data_per_pix = (canvasMaxY - canvasMinY) / (y1_pix - y0_pix)
+ min_pad = pixel_pad * data_per_pix
+ min_spacing = pixel_spacing * data_per_pix
+ for i, (yMark, labelText) in enumerate(labelData):
+ # Clamp to graph area with padding
+ yMark = max(min(yMark, canvasMaxY - min_pad), canvasMinY + min_pad)
+ if i > 0:
+ prev_y = adjusted_y[-1]
+ if yMark - prev_y < min_spacing:
+ yMark = prev_y + min_spacing
+ yMark = min(yMark, canvasMaxY - min_pad)
+ adjusted_y.append(yMark)
+ label = '{}{}{}'.format(labelPrefix, labelText, labelSuffix)
self.subplot.annotate(
- '{}{}{}'.format(labelPrefix, yMark, labelSuffix), xy=(xMark, yMark), xytext=(0, 0),
- textcoords='offset pixels', ha=labelAlignment, va='center', fontsize='small')
+ label, xy=(xMark, yMark), xytext=(0, 0),
+ textcoords='offset pixels', ha=labelAlignment, va='center', fontsize='small',
+ path_effects=textOutline)
legendLines = []
for i, iData in enumerate(legendData):
@@ -284,18 +606,255 @@ def addYMark(val):
self.canvas.draw()
self.Refresh()
+ # Always save the background for blitting after drawing the graph, before drawing the X marker
+ self._blitBackground = self.canvas.copy_from_bbox(self.subplot.bbox)
+ # Cache data needed for fast X marker interpolation during drag
+ self._blitPlotData = plotData
+ self._blitView = view
+ self._blitIterList = iterList
+ self._blitCanvasLimits = (canvasMinX, canvasMaxX, canvasMinY, canvasMaxY)
+ self._blitChosenX = chosenX
+ self._blitChosenY = chosenY
+ minY = min(allYs, default=0)
+ maxY = max(allYs, default=0)
+ self._blitYDiff = maxY - minY
+ self._blitHasSegments = hasSegments
+
+ def _drawXMarkerBlit(self, xMark):
+ """Fast X marker update using matplotlib blitting.
+
+ Only redraws the X marker line and labels, not the entire plot.
+ Returns True if blit was successful, False if full redraw needed.
+ """
+ # Check if we have cached data for blitting
+ if (self._blitBackground is None or
+ self._blitPlotData is None or
+ self._blitCanvasLimits is None):
+ return False
+
+ canvasMinX, canvasMaxX, canvasMinY, canvasMaxY = self._blitCanvasLimits
+
+ # Clamp xMark to canvas bounds
+ if xMark is None or xMark < canvasMinX or xMark > canvasMaxX:
+ return False
+
+ # Restore the clean background (without X marker)
+ self.canvas.restore_region(self._blitBackground)
+
+ # Remove old X marker artists
+ for artist in self._xMarkerArtists:
+ try:
+ artist.remove()
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except:
+ pass
+ self._xMarkerArtists = []
+
+ # Draw new X marker line
+ line = self.subplot.axvline(x=xMark, linestyle='dotted', linewidth=1, color=(0, 0, 0), animated=True)
+ self._xMarkerArtists.append(line)
+
+ # Prepare X label
+ chosenX = self._blitChosenX
+ if chosenX.unit is None:
+ xLabelCore = '{}'.format(roundToPrec(xMark, 4))
+ else:
+ xLabelCore = '{} {}'.format(roundToPrec(xMark, 4), chosenX.unit)
+
+ # Calculate Y marks via interpolation
+ yMarks = {}
+ yDiff = self._blitYDiff
+ minY = canvasMinY
+ maxY = canvasMaxY
+
+ def addYMark(val, extraInfo=None):
+ if val is None:
+ return
+ if yDiff != 0:
+ rounded = roundToPrec(val, 4, nsValue=yDiff)
+ else:
+ rounded = val
+ if minY <= val <= maxY or minY <= rounded <= maxY:
+ yMarks[rounded] = extraInfo
+
+ view = self._blitView
+ plotData = self._blitPlotData
+ iterList = self._blitIterList
+
+ for source, target in iterList:
+ if (source, target) not in plotData:
+ continue
+ plotEntry = plotData[(source, target)]
+ xs, ys = plotEntry[0], plotEntry[1]
+ segmentData = plotEntry[2] if len(plotEntry) > 2 else None
+ if not xs or xMark < min(xs) or xMark > max(xs):
+ continue
+
+ # Get ammo name from segment data (fast and accurate)
+ extraStr = None
+ if segmentData:
+ for min_x, max_x, ammo_name in segmentData:
+ if min_x <= xMark <= max_x:
+ extraStr = ammo_name
+ break
+ # If xMark is beyond all segments, use last segment's ammo
+ if extraStr is None and segmentData:
+ extraStr = segmentData[-1][2]
+
+ # Interpolate Y value
+ if xMark in xs:
+ idx = len(xs) - xs[::-1].index(xMark) - 1
+ addYMark(ys[idx], extraStr)
+ else:
+ idx = bisect(xs, xMark)
+ if idx > 0 and idx < len(xs):
+ yMark = self._interpolateX(x=xMark, x1=xs[idx - 1], y1=ys[idx - 1], x2=xs[idx], y2=ys[idx])
+ addYMark(yMark, extraStr)
+
+ # Build label data
+ labelData = []
+ isDpsGraph = view.internalName in ('dmgStatsGraph', 'ammoOptimalDpsGraph')
+
+ for yMark, extraInfo in yMarks.items():
+ if isDpsGraph:
+ yMarkStr = '{:.0f}'.format(yMark)
+ else:
+ yMarkStr = '{}'.format(yMark)
+
+ if extraInfo:
+ labelText = '{} ({})'.format(yMarkStr, extraInfo)
+ else:
+ labelText = yMarkStr
+ labelData.append((yMark, labelText))
+
+ # Determine alignment
+ xRange = canvasMaxX - canvasMinX
+ xPosRatio = (xMark - canvasMinX) / xRange if xRange > 0 else 0
+
+ maxLabelLen = len(xLabelCore)
+ for yMark, labelText in labelData:
+ maxLabelLen = max(maxLabelLen, len(labelText))
+
+ if maxLabelLen < 15:
+ flipThreshold = 0.80
+ elif maxLabelLen < 30:
+ flipThreshold = 0.65
+ else:
+ flipThreshold = 0.50
+
+ if xPosRatio > flipThreshold:
+ labelAlignment = 'right'
+ labelPrefix = ''
+ labelSuffix = ' '
+ else:
+ labelAlignment = 'left'
+ labelPrefix = ' '
+ labelSuffix = ''
+
+ textOutline = [PathEffects.withStroke(linewidth=3, foreground='white')]
+
+ # Draw X label
+ xLabel = '{}{}{}'.format(labelPrefix, xLabelCore, labelSuffix)
+ ann = self.subplot.annotate(
+ xLabel, xy=(xMark, canvasMaxY - 0.01 * (canvasMaxY - canvasMinY)), xytext=(0, 0),
+ annotation_clip=False, textcoords='offset pixels', ha=labelAlignment, va='top',
+ fontsize='small', path_effects=textOutline, animated=True)
+ self._xMarkerArtists.append(ann)
+
+ # Draw Y labels with fixed pixel offset for anti-overlap (same as non-drag)
+ labelData.sort(key=lambda x: x[0])
+ pixel_pad = 8 # 8 pixels padding top/bottom
+ pixel_spacing = 16 # 16 pixels minimum spacing between labels
+ adjusted_y = []
+ trans = self.subplot.transData.inverted()
+ bbox = self.subplot.get_window_extent()
+ y0_pix = bbox.y0
+ y1_pix = bbox.y1
+ data_per_pix = (canvasMaxY - canvasMinY) / (y1_pix - y0_pix)
+ min_pad = pixel_pad * data_per_pix
+ min_spacing = pixel_spacing * data_per_pix
+ for i, (yMark, labelText) in enumerate(labelData):
+ # Clamp to graph area with padding
+ yMark = max(min(yMark, canvasMaxY - min_pad), canvasMinY + min_pad)
+ if i > 0:
+ prev_y = adjusted_y[-1]
+ if yMark - prev_y < min_spacing:
+ yMark = prev_y + min_spacing
+ yMark = min(yMark, canvasMaxY - min_pad)
+ adjusted_y.append(yMark)
+ label = '{}{}{}'.format(labelPrefix, labelText, labelSuffix)
+ ann = self.subplot.annotate(
+ label, xy=(xMark, yMark), xytext=(0, 0),
+ textcoords='offset pixels', ha=labelAlignment, va='center',
+ fontsize='small', path_effects=textOutline, animated=True)
+ self._xMarkerArtists.append(ann)
+
+ # Draw the animated artists
+ for artist in self._xMarkerArtists:
+ self.subplot.draw_artist(artist)
+
+ # Blit the updated region
+ self.canvas.blit(self.subplot.bbox)
+
+ return True
def markXApproximate(self, x):
if x is not None:
self.xMark = x
- self.draw(accurateMarks=False)
+ # Try fast blit path first, fall back to full redraw
+ if not self._drawXMarkerBlit(x):
+ self.draw(accurateMarks=False)
def unmarkX(self):
self.xMark = None
+ # Clear blit state so next draw() saves fresh background
+ self._blitBackground = None
+ self._xMarkerArtists = []
self.draw()
@staticmethod
- def _getLimits(vals, minExtra=0, maxExtra=0):
+ def _roundToNice(val, direction='up', maxIncrease=0.15):
+ """
+ Round a value to a 'nice' number (1, 2, 5, or 10 multiplied by power of 10).
+ This helps stabilize Y-axis limits and reduce flickering.
+
+ Args:
+ val: Value to round
+ direction: 'up' to round up (for max), 'down' to round down (for min)
+ maxIncrease: Maximum allowed increase as a fraction (default 15%)
+ """
+ if val == 0:
+ return 0
+
+ sign = 1 if val >= 0 else -1
+ absVal = abs(val)
+
+ # Find the order of magnitude
+ magnitude = 10 ** math.floor(math.log10(absVal))
+ normalized = absVal / magnitude
+
+ # Nice numbers: 1, 2, 5, 10
+ nice_numbers = [1, 2, 5, 10]
+
+ if direction == 'up':
+ # Round up to next nice number, but cap the increase
+ maxAllowed = absVal * (1 + maxIncrease)
+ for nice in nice_numbers:
+ candidate = nice * magnitude
+ if normalized <= nice and candidate <= maxAllowed:
+ return sign * candidate
+ # If all nice numbers exceed maxIncrease, just return with small buffer
+ return sign * absVal * 1.05
+ else:
+ # Round down to previous nice number
+ for nice in reversed(nice_numbers):
+ if normalized >= nice:
+ return sign * nice * magnitude
+ return sign * magnitude
+
+ @staticmethod
+ def _getLimits(vals, minExtra=0, maxExtra=0, roundNice=False):
minVal = min(vals, default=0)
maxVal = max(vals, default=0)
# Extend range a little for some visual space
@@ -310,6 +869,9 @@ def _getLimits(vals, minExtra=0, maxExtra=0):
if minVal == maxVal:
minVal -= 5
maxVal += 5
+ # Round to nice values to reduce Y-axis flickering (only for Y-axis)
+ if roundNice and maxVal > 0:
+ maxVal = GraphCanvasPanel._roundToNice(maxVal, 'up')
return minVal, maxVal
@staticmethod
@@ -332,6 +894,13 @@ def OnMplCanvasClick(self, event):
self.mplOnDragHandler = self.canvas.mpl_connect('motion_notify_event', self.OnMplCanvasDrag)
if not self.mplOnReleaseHandler:
self.mplOnReleaseHandler = self.canvas.mpl_connect('button_release_event', self.OnMplCanvasRelease)
+ # On drag start, always cache background with no X marker
+ prevXMark = self.xMark
+ self.xMark = None
+ self.draw(accurateMarks=False)
+ self._blitBackground = self.canvas.copy_from_bbox(self.subplot.bbox)
+ # Set X marker to drag position and start moving
+ self.xMark = event.xdata
self.markXApproximate(event.xdata)
elif event.button == 3:
self.unmarkX()
diff --git a/graphs/gui/ctrlPanel.py b/graphs/gui/ctrlPanel.py
index 418bbe468d..7dc07594c8 100644
--- a/graphs/gui/ctrlPanel.py
+++ b/graphs/gui/ctrlPanel.py
@@ -25,7 +25,7 @@
from gui.bitmap_loader import BitmapLoader
from gui.contextMenu import ContextMenu
-from gui.utils.inputs import FloatBox, FloatRangeBox
+from gui.utils.inputs import FloatBox, FloatRangeBox, valToStr
from service.const import GraphCacheCleanupReason
from service.fit import Fit
from .lists import SourceWrapperList, TargetWrapperList
@@ -47,40 +47,81 @@ def __init__(self, graphFrame, parent):
self._inputCheckboxes = []
self._storedRanges = {}
self._storedConsts = {}
+ self._lastDynamicRange = None # Track last applied dynamic range
+ self._userModifiedMainInput = False # Flag: has user manually changed main input?
mainSizer = wx.BoxSizer(wx.VERTICAL)
optsSizer = wx.BoxSizer(wx.HORIZONTAL)
commonOptsSizer = wx.BoxSizer(wx.VERTICAL)
+
+ # Row 1: Y axis
ySubSelectionSizer = wx.BoxSizer(wx.HORIZONTAL)
yText = wx.StaticText(self, wx.ID_ANY, _t('Axis Y:'))
ySubSelectionSizer.Add(yText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.ySubSelection = wx.Choice(self, wx.ID_ANY)
self.ySubSelection.Bind(wx.EVT_CHOICE, self.OnYTypeUpdate)
- ySubSelectionSizer.Add(self.ySubSelection, 1, wx.EXPAND | wx.ALL, 0)
- commonOptsSizer.Add(ySubSelectionSizer, 0, wx.EXPAND | wx.ALL, 0)
+ ySubSelectionSizer.Add(self.ySubSelection, 1, wx.EXPAND, 0)
+ commonOptsSizer.Add(ySubSelectionSizer, 0, wx.EXPAND, 0)
- xSubSelectionSizer = wx.BoxSizer(wx.HORIZONTAL)
- xText = wx.StaticText(self, wx.ID_ANY, _t('Axis X:'))
- xSubSelectionSizer.Add(xText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
+ # Row 2: X axis (hidden for segment graphs)
+ self.xSubSelectionSizer = wx.BoxSizer(wx.HORIZONTAL)
+ self.xText = wx.StaticText(self, wx.ID_ANY, _t('Axis X:'))
+ self.xSubSelectionSizer.Add(self.xText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.xSubSelection = wx.Choice(self, wx.ID_ANY)
self.xSubSelection.Bind(wx.EVT_CHOICE, self.OnXTypeUpdate)
- xSubSelectionSizer.Add(self.xSubSelection, 1, wx.EXPAND | wx.ALL, 0)
- commonOptsSizer.Add(xSubSelectionSizer, 0, wx.EXPAND | wx.TOP, 5)
-
+ self.xSubSelectionSizer.Add(self.xSubSelection, 1, wx.EXPAND, 0)
+ commonOptsSizer.Add(self.xSubSelectionSizer, 0, wx.EXPAND | wx.TOP, 5)
+
+ # Row 3: Color dropdown (only shown for graphs with segments) - Quality is in right column
+ self.ammoStyleSizer = wx.BoxSizer(wx.HORIZONTAL)
+ self.ammoStyleText = wx.StaticText(self, wx.ID_ANY, _t('Style:'))
+ self.ammoStyleSizer.Add(self.ammoStyleText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
+ self.ammoStyleSelection = wx.Choice(self, wx.ID_ANY)
+ self.ammoStyleSelection.Append(_t('None'), 'none')
+ self.ammoStyleSelection.Append(_t('Pattern'), 'pattern')
+ self.ammoStyleSelection.Append(_t('Color'), 'color')
+ self.ammoStyleSelection.SetSelection(2) # Default to Color
+ self.ammoStyleSelection.Bind(wx.EVT_CHOICE, self.OnAmmoStyleChange)
+ self.ammoStyleSizer.Add(self.ammoStyleSelection, 1, wx.EXPAND, 0)
+ commonOptsSizer.Add(self.ammoStyleSizer, 0, wx.EXPAND | wx.TOP, 5)
+
+ # Row 4: Ammo Meta dropdown (moved from right column)
+ self.ammoQualitySizer = wx.BoxSizer(wx.HORIZONTAL)
+ self.ammoQualityText = wx.StaticText(self, wx.ID_ANY, _t('Ammo Meta:'))
+ self.ammoQualitySizer.Add(self.ammoQualityText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
+ self.ammoQualitySelection = wx.Choice(self, wx.ID_ANY)
+ self.ammoQualitySelection.Append(_t('T1'), 't1')
+ self.ammoQualitySelection.Append(_t('Navy'), 'navy')
+ self.ammoQualitySelection.Append(_t('All'), 'all')
+ self.ammoQualitySelection.SetSelection(1) # Default to Navy
+ self.ammoQualitySelection.Bind(wx.EVT_CHOICE, self.OnAmmoQualityChange)
+ self.ammoQualitySizer.Add(self.ammoQualitySelection, 1, wx.EXPAND, 0)
+ commonOptsSizer.Add(self.ammoQualitySizer, 0, wx.EXPAND | wx.TOP, 5)
+
+ # Row 5: Show legend checkbox
self.showLegendCb = wx.CheckBox(self, wx.ID_ANY, _t('Show legend'), wx.DefaultPosition, wx.DefaultSize, 0)
self.showLegendCb.SetValue(True)
self.showLegendCb.Bind(wx.EVT_CHECKBOX, self.OnShowLegendChange)
- commonOptsSizer.Add(self.showLegendCb, 0, wx.EXPAND | wx.TOP, 5)
+ commonOptsSizer.Add(self.showLegendCb, 0, wx.TOP, 5)
self.showY0Cb = wx.CheckBox(self, wx.ID_ANY, _t('Always show Y = 0'), wx.DefaultPosition, wx.DefaultSize, 0)
self.showY0Cb.SetValue(True)
self.showY0Cb.Bind(wx.EVT_CHECKBOX, self.OnShowY0Change)
commonOptsSizer.Add(self.showY0Cb, 0, wx.EXPAND | wx.TOP, 5)
+
optsSizer.Add(commonOptsSizer, 0, wx.EXPAND | wx.RIGHT, 10)
+ # Right column: inputs
graphOptsSizer = wx.BoxSizer(wx.HORIZONTAL)
+
+ # Container for inputs (normal graphs)
+ self.rightColumnSizer = wx.BoxSizer(wx.VERTICAL)
+
+ # Input fields sizer (shown for normal graphs) - at the top
self.inputsSizer = wx.BoxSizer(wx.VERTICAL)
- graphOptsSizer.Add(self.inputsSizer, 1, wx.EXPAND | wx.ALL, 0)
+ self.rightColumnSizer.Add(self.inputsSizer, 0, wx.EXPAND, 0)
+
+ graphOptsSizer.Add(self.rightColumnSizer, 1, wx.EXPAND | wx.ALL, 0)
vectorSize = 90 if 'wxGTK' in wx.PlatformInfo else 75
self.srcVectorSizer = wx.BoxSizer(wx.VERTICAL)
@@ -134,6 +175,14 @@ def updateControls(self, layout=True):
if layout:
self.Freeze()
self._clearStoredValues()
+ # Forget any manual input-range override from the previous graph. The
+ # control and canvas panels are single instances shared across every
+ # graph, so these flags must be reset on switch or dynamic auto-ranging
+ # stays suppressed for the rest of the session once the user edits the
+ # distance field on any one graph.
+ self._lastDynamicRange = None
+ self._userModifiedMainInput = False
+ self.graphFrame.canvasPanel.resetDynamicBoundsTracking()
view = self.graphFrame.getView()
self.refreshAxeLabels()
@@ -159,6 +208,28 @@ def updateControls(self, layout=True):
self.refreshColumns(layout=False)
self.targetList.Show(view.hasTargets)
+ # Ammo options and X axis visibility (only for graphs with segments)
+ hasSegments = getattr(view, 'hasSegments', False)
+ # Hide X axis dropdown for segment graphs (Application Profile)
+ self.xText.Show(not hasSegments)
+ self.xSubSelection.Show(not hasSegments)
+ self.xSubSelectionSizer.ShowItems(not hasSegments)
+ # Show ammo style (Color) dropdown for segment graphs (left column)
+ self.ammoStyleText.Show(hasSegments)
+ self.ammoStyleSelection.Show(hasSegments)
+ self.ammoStyleSizer.ShowItems(hasSegments)
+ # Show ammo quality dropdown for segment graphs (right column)
+ self.ammoQualityText.Show(hasSegments)
+ self.ammoQualitySelection.Show(hasSegments)
+ self.ammoQualitySizer.ShowItems(hasSegments)
+
+ # Check if we need to auto-switch ammo style when switching to/from segmented graphs
+ if hasSegments:
+ # First check if we should switch back to color (no conflicts)
+ self.sourceList._checkAutoSwitchBackToColor()
+ # Then check if we need to switch to pattern (conflicts exist)
+ self.sourceList._checkAutoSwitchAmmoStyle()
+
# Inputs
self._updateInputs(storeInputs=False)
@@ -229,7 +300,14 @@ def __addInputField(self, inputDef, handledHandles, mainInput=False):
fieldSizer = wx.BoxSizer(wx.HORIZONTAL)
tooltipText = (inputDef.mainTooltip if mainInput else inputDef.secondaryTooltip) or ''
if mainInput:
- fieldTextBox = FloatRangeBox(self, self._storedRanges.get((inputDef.handle, inputDef.unit), inputDef.defaultRange))
+ # Check if view has a dynamic default range method
+ view = self.graphFrame.getView()
+ defaultRange = inputDef.defaultRange
+ if hasattr(view, 'getDefaultInputRange'):
+ dynamicRange = view.getDefaultInputRange(inputDef, self.sources)
+ if dynamicRange is not None:
+ defaultRange = dynamicRange
+ fieldTextBox = FloatRangeBox(self, self._storedRanges.get((inputDef.handle, inputDef.unit), defaultRange))
fieldTextBox.Bind(wx.EVT_TEXT, self.OnMainInputChanged)
else:
fieldTextBox = FloatBox(self, self._storedConsts.get((inputDef.handle, inputDef.unit), inputDef.defaultValue))
@@ -313,6 +391,8 @@ def refreshColumns(self, layout=True):
view = self.graphFrame.getView()
self.sourceList.refreshExtraColumns(view.srcExtraCols)
self.targetList.refreshExtraColumns(view.tgtExtraCols)
+ # Also refresh default columns for target list based on ammo style
+ self.targetList.refreshDefaultColumns()
self.srcTgtSizer.Detach(self.sourceList)
self.srcTgtSizer.Detach(self.targetList)
self.srcTgtSizer.Add(self.sourceList, self.sourceList.getWidthProportion(), wx.EXPAND | wx.ALL, 0)
@@ -327,6 +407,18 @@ def OnShowY0Change(self, event):
event.Skip()
self.graphFrame.draw()
+ def OnAmmoStyleChange(self, event):
+ event.Skip()
+ # Refresh target list columns to show/hide lightness/line style based on ammo style
+ self.targetList.refreshDefaultColumns()
+ self.graphFrame.draw()
+
+ def OnAmmoQualityChange(self, event):
+ event.Skip()
+ # Clear cache when quality changes since we need to recalculate with different ammo
+ self.graphFrame.clearCache(reason=GraphCacheCleanupReason.inputChanged)
+ self.graphFrame.draw()
+
def OnYTypeUpdate(self, event):
event.Skip()
self._updateInputs()
@@ -359,6 +451,64 @@ def OnInputTimer(self, event):
self.graphFrame.clearCache(reason=GraphCacheCleanupReason.inputChanged)
self.graphFrame.draw()
+ def _refreshMainInputRange(self):
+ """
+ Refresh the main input field's range based on current fit data.
+
+ Called when fits change to update the distance range dynamically
+ for graphs that support getDefaultInputRange (like Application Profile).
+ """
+ # If user has manually modified the main input, never override it
+ if self._userModifiedMainInput:
+ return
+
+ if self._mainInputBox is None:
+ return
+
+ view = self.graphFrame.getView()
+ if not hasattr(view, 'getDefaultInputRange'):
+ return
+
+ # Get the input definition for the main input
+ mainInputKey = self.xType.mainInput
+ if mainInputKey not in view.inputMap:
+ return
+
+ inputDef = view.inputMap[mainInputKey]
+
+ # Check if user has manually modified the input field since last dynamic update
+ currentRange = self._mainInputBox.textBox.GetValueRange()
+ if currentRange:
+ currentMin, currentMax = currentRange
+ # Get the baseline to compare against
+ if self._lastDynamicRange is not None:
+ baselineMin, baselineMax = self._lastDynamicRange
+ else:
+ baselineMin, baselineMax = inputDef.defaultRange
+
+ # If current range differs from the baseline, user has manually changed it
+ # Set the flag permanently to prevent future overrides
+ if currentMin != baselineMin or currentMax != baselineMax:
+ self._userModifiedMainInput = True
+ return
+
+ # Calculate the new dynamic range
+ dynamicRange = view.getDefaultInputRange(inputDef, self.sources)
+ if dynamicRange is None:
+ dynamicRange = inputDef.defaultRange
+
+ # Store this as the last dynamic range we applied
+ self._lastDynamicRange = dynamicRange
+
+ # Clear the stored range so the new default is used
+ storedKey = (inputDef.handle, inputDef.unit)
+ if storedKey in self._storedRanges:
+ del self._storedRanges[storedKey]
+
+ # Update the text box with the new range
+ self._mainInputBox.textBox.ChangeValue('{}-{}'.format(
+ valToStr(dynamicRange[0]), valToStr(dynamicRange[1])))
+
def getValues(self):
view = self.graphFrame.getView()
misc = []
@@ -401,6 +551,26 @@ def showLegend(self):
def showY0(self):
return self.showY0Cb.GetValue()
+ @property
+ def ammoStyle(self):
+ """Returns ammo style: 'none', 'pattern', or 'color'"""
+ return self.ammoStyleSelection.GetClientData(self.ammoStyleSelection.GetSelection())
+
+ def setAmmoStyle(self, style):
+ """Set ammo style programmatically: 'none', 'pattern', or 'color'"""
+ for i in range(self.ammoStyleSelection.GetCount()):
+ if self.ammoStyleSelection.GetClientData(i) == style:
+ self.ammoStyleSelection.SetSelection(i)
+ # Trigger the same updates as OnAmmoStyleChange
+ self.targetList.refreshDefaultColumns()
+ self.graphFrame.draw()
+ return
+
+ @property
+ def ammoQuality(self):
+ """Returns ammo quality tier: 't1', 'navy', or 'all'"""
+ return self.ammoQualitySelection.GetClientData(self.ammoQualitySelection.GetSelection())
+
@property
def yType(self):
return self.ySubSelection.GetClientData(self.ySubSelection.GetSelection())
@@ -425,6 +595,8 @@ def OnFitRenamed(self, event):
def OnFitChanged(self, event):
self.sourceList.OnFitChanged(event)
self.targetList.OnFitChanged(event)
+ # Refresh the main input's default range when fit changes
+ self._refreshMainInputRange()
def OnFitRemoved(self, event):
self.sourceList.OnFitRemoved(event)
diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py
index 4313b81d70..c8c6c0b7b6 100644
--- a/graphs/gui/frame.py
+++ b/graphs/gui/frame.py
@@ -38,7 +38,7 @@
_t = wx.GetTranslation
-REDRAW_DELAY = 500
+REDRAW_DELAY = 200
class GraphFrame(AuxiliaryFrame):
diff --git a/graphs/gui/lists.py b/graphs/gui/lists.py
index a63efebcd8..de4b103da9 100644
--- a/graphs/gui/lists.py
+++ b/graphs/gui/lists.py
@@ -22,6 +22,8 @@
import wx
import gui.display
+import gui.globalEvents as GE
+from eos.const import FittingHardpoint
from eos.saveddata.targetProfile import TargetProfile
from graphs.style import BASE_COLORS, LIGHTNESSES, STYLES
from graphs.wrapper import SourceWrapper, TargetWrapper
@@ -29,12 +31,61 @@
from gui.builtinViewColumns.graphLightness import GraphLightness
from gui.builtinViewColumns.graphLineStyle import GraphLineStyle
from gui.contextMenu import ContextMenu
-from service.const import GraphCacheCleanupReason
+from service.const import GraphCacheCleanupReason, GraphLightness as GraphLightnessEnum, GraphLineStyle as GraphLineStyleEnum
from service.fit import Fit
from .stylePickers import ColorPickerPopup, LightnessPickerPopup, LineStylePickerPopup
_t = wx.GetTranslation
+
+def getFitWeaponClass(fit):
+ """
+ Determine the weapon class of a fit based on its turret or missile type.
+
+ Returns: 'energy', 'projectile', 'hybrid', 'exotic', 'vorton', 'missile', or None if no weapons.
+
+ Uses module group names instead of loading charges for performance.
+ """
+ if fit is None:
+ return None
+
+ # Try activeModulesIter first (more reliable), fall back to modules
+ modules = list(fit.activeModulesIter()) if hasattr(fit, 'activeModulesIter') else fit.modules
+
+ for mod in modules:
+ if mod.isEmpty or mod.item is None:
+ continue
+
+ # Check turret hardpoints - use module group name to determine type
+ if mod.hardpoint == FittingHardpoint.TURRET:
+ # Skip mining turrets
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ # Get module group name to determine weapon class
+ if mod.item.group is None:
+ continue
+
+ groupName = mod.item.group.name
+
+ # Determine weapon class from module group
+ if 'Energy' in groupName or 'Laser' in groupName or 'Beam' in groupName or 'Pulse' in groupName:
+ return 'energy'
+ elif 'Projectile' in groupName or 'Autocannon' in groupName or 'Artillery' in groupName:
+ return 'projectile'
+ elif 'Hybrid' in groupName or 'Blaster' in groupName or 'Railgun' in groupName:
+ return 'hybrid'
+ elif 'Entropic' in groupName or 'Disintegrator' in groupName:
+ return 'exotic'
+ elif 'Vorton' in groupName or 'Arcing' in groupName:
+ return 'vorton'
+
+ # Check missile hardpoints
+ elif mod.hardpoint == FittingHardpoint.MISSILE:
+ return 'missile'
+
+ return None
+
class BaseWrapperList(gui.display.Display):
def __init__(self, graphFrame, parent):
@@ -242,23 +293,33 @@ def addFit(self, fit):
return
if self.containsFitID(fit.ID):
return
+ # Ensure fit is fully recalculated before adding to graph
+ sFit = Fit.getInstance()
+ sFit.recalc(fit)
self.appendItem(fit)
self.updateView()
- self.graphFrame.draw()
+ # Trigger FIT_CHANGED event to refresh all caches and views
+ wx.PostEvent(self.graphFrame.mainFrame, GE.FitChanged(fitIDs=(fit.ID,)))
def getExistingFitIDs(self):
return [w.item.ID for w in self._wrappers if w.isFit]
def addFitsByIDs(self, fitIDs):
sFit = Fit.getInstance()
+ addedFitIDs = []
for fitID in fitIDs:
if self.containsFitID(fitID):
continue
fit = sFit.getFit(fitID)
if fit is not None:
+ # Ensure fit is fully recalculated before adding to graph
+ sFit.recalc(fit)
self.appendItem(fit)
+ addedFitIDs.append(fitID)
self.updateView()
- self.graphFrame.draw()
+ # Trigger FIT_CHANGED event to refresh all caches and views
+ if addedFitIDs:
+ wx.PostEvent(self.graphFrame.mainFrame, GE.FitChanged(fitIDs=tuple(addedFitIDs)))
class SourceWrapperList(BaseWrapperList):
@@ -296,6 +357,101 @@ def getDefaultParams():
colorID = getDefaultParams()
self._wrappers.append(SourceWrapper(item=item, colorID=colorID))
+ # Check if we should switch to Pattern mode (for Application Profile graph)
+ self._checkAutoSwitchAmmoStyle()
+
+ def _checkAutoSwitchAmmoStyle(self):
+ """
+ Auto-switch ammo style to Pattern when multiple fits with same weapon class are added.
+
+ This helps differentiate between attackers when they use the same ammo types.
+ """
+ # Check if ctrlPanel is fully initialized (has ammoStyleSelection)
+ ctrlPanel = getattr(self.graphFrame, 'ctrlPanel', None)
+ if ctrlPanel is None:
+ return
+ if not hasattr(ctrlPanel, 'ammoStyleSelection'):
+ return
+
+ # Check if this graph supports segments (Application Profile)
+ try:
+ view = self.graphFrame.getView()
+ except Exception:
+ return
+
+ if not getattr(view, 'hasSegments', False):
+ return
+
+ # Get current ammo style
+ currentStyle = ctrlPanel.ammoStyle
+
+ # Only auto-switch if currently on 'color' mode
+ if currentStyle != 'color':
+ return
+
+ # Check if we have 2+ fits with the same weapon class
+ weaponClasses = {}
+ for wrapper in self._wrappers:
+ if not wrapper.isFit:
+ continue
+ wc = getFitWeaponClass(wrapper.item)
+ if wc:
+ weaponClasses[wc] = weaponClasses.get(wc, 0) + 1
+
+ # If any weapon class has 2+ fits, switch to pattern mode
+ for wc, count in weaponClasses.items():
+ if count >= 2:
+ ctrlPanel.setAmmoStyle('pattern')
+ return
+
+ def _checkAutoSwitchBackToColor(self):
+ """
+ Auto-switch ammo style back to Color when no more weapon class conflicts exist.
+
+ Called after removing a fit to see if we can switch back to color mode.
+ """
+ # Check if ctrlPanel is fully initialized (has ammoStyleSelection)
+ ctrlPanel = getattr(self.graphFrame, 'ctrlPanel', None)
+ if ctrlPanel is None:
+ return
+ if not hasattr(ctrlPanel, 'ammoStyleSelection'):
+ return
+
+ # Check if this graph supports segments (Application Profile)
+ try:
+ view = self.graphFrame.getView()
+ except Exception:
+ return
+
+ if not getattr(view, 'hasSegments', False):
+ return
+
+ # Get current ammo style
+ currentStyle = ctrlPanel.ammoStyle
+
+ # Only auto-switch if currently on 'pattern' mode
+ if currentStyle != 'pattern':
+ return
+
+ # Check if we still have 2+ fits with the same weapon class
+ weaponClasses = {}
+ for wrapper in self._wrappers:
+ if not wrapper.isFit:
+ continue
+ wc = getFitWeaponClass(wrapper.item)
+ if wc:
+ weaponClasses[wc] = weaponClasses.get(wc, 0) + 1
+
+ # If no weapon class has 2+ fits anymore, switch back to color mode
+ hasConflict = any(count >= 2 for count in weaponClasses.values())
+ if not hasConflict:
+ ctrlPanel.setAmmoStyle('color')
+
+ def removeWrappers(self, wrappers):
+ """Override to check if we should switch back to color mode after removal."""
+ super().removeWrappers(wrappers)
+ self._checkAutoSwitchBackToColor()
+
def spawnMenu(self, event):
clickedPos = self.getRowByAbs(event.Position)
self.ensureSelection(clickedPos)
@@ -329,26 +485,132 @@ def __init__(self, graphFrame, parent):
self.appendItem(TargetProfile.getIdeal())
self.updateView()
+ def getFilteredDefaultCols(self):
+ """Return default columns filtered based on current ammo style.
+
+ For the Application Profile graph (hasSegments=True):
+ - 'color' mode: Ammo determines line color, so hide Lightness (show Line Style only)
+ - 'pattern' mode: Ammo determines line pattern, so hide Line Style (show Lightness only)
+ - 'none' mode: Show both columns
+
+ For other graphs, always show both columns.
+ """
+ view = self.graphFrame.getView()
+ hasSegments = getattr(view, 'hasSegments', False)
+
+ if not hasSegments:
+ return self.DEFAULT_COLS
+
+ ammoStyle = self.graphFrame.ctrlPanel.ammoStyle
+
+ if ammoStyle == 'color':
+ # Color mode: ammo color differentiates, use line style for targets
+ return tuple(c for c in self.DEFAULT_COLS if c != 'Graph Lightness')
+ elif ammoStyle == 'pattern':
+ # Pattern mode: ammo pattern differentiates, use lightness for targets
+ return tuple(c for c in self.DEFAULT_COLS if c != 'Graph Line Style')
+ else:
+ # None mode: show both
+ return self.DEFAULT_COLS
+
+ def refreshDefaultColumns(self):
+ """Refresh the default columns based on current ammo style.
+
+ Rebuilds all columns in correct order to maintain proper column positions.
+ """
+ filteredCols = self.getFilteredDefaultCols()
+
+ # Get base names of columns that should be shown
+ colNamesToShow = set()
+ for colName in filteredCols:
+ if ":" in colName:
+ colName = colName.split(":", 1)[0]
+ colNamesToShow.add(colName)
+
+ # Check if we need to make any changes
+ currentStyleCols = [col.name for col in self.activeColumns
+ if col.name in ('Graph Lightness', 'Graph Line Style')]
+ targetStyleCols = [c for c in ('Graph Lightness', 'Graph Line Style') if c in colNamesToShow]
+
+ if currentStyleCols == targetStyleCols:
+ # No changes needed
+ return
+
+ # Save any extra columns (non-default columns added by the view)
+ extraCols = [col.name for col in self.activeColumns
+ if col.name not in ('Graph Lightness', 'Graph Line Style', 'Base Icon', 'Base Name')]
+
+ # Remove ALL columns
+ while self.activeColumns:
+ self.removeColumn(self.activeColumns[0])
+
+ # Re-add columns in correct order using filtered defaults
+ for colName in filteredCols:
+ self.appendColumnBySpec(colName)
+
+ # Re-add any extra columns
+ for colName in extraCols:
+ self.appendColumnBySpec(colName)
+
+ self.refreshView()
+
def appendItem(self, item):
- # Find out least used lightness
- lightnessUseMap = {(l, s): 0 for l in LIGHTNESSES for s in STYLES}
+ # Find least used line style and least used lightness independently
+ # This ensures both properties iterate even when only one is visible
+
+ # Count line style usage
+ lineStyleUseMap = {s: 0 for s in STYLES}
for wrapper in self._wrappers:
- key = (wrapper.lightnessID, wrapper.lineStyleID)
- if key not in lightnessUseMap:
- continue
- lightnessUseMap[key] += 1
+ if wrapper.lineStyleID in lineStyleUseMap:
+ lineStyleUseMap[wrapper.lineStyleID] += 1
+
+ # Count lightness usage
+ lightnessUseMap = {l: 0 for l in LIGHTNESSES}
+ for wrapper in self._wrappers:
+ if wrapper.lightnessID in lightnessUseMap:
+ lightnessUseMap[wrapper.lightnessID] += 1
+
+ # Find least used line style
+ leastLineStyleUses = min(lineStyleUseMap.values(), default=0)
+ lineStyleID = None
+ for sid in STYLES:
+ if lineStyleUseMap.get(sid, 0) == leastLineStyleUses:
+ lineStyleID = sid
+ break
+
+ # Find least used lightness
+ leastLightnessUses = min(lightnessUseMap.values(), default=0)
+ lightnessID = None
+ for lid in LIGHTNESSES:
+ if lightnessUseMap.get(lid, 0) == leastLightnessUses:
+ lightnessID = lid
+ break
- def getDefaultParams():
- leastUses = min(lightnessUseMap.values(), default=0)
- for lineStyleID in STYLES:
- for lightnessID in LIGHTNESSES:
- if leastUses == lightnessUseMap.get((lightnessID, lineStyleID), 0):
- return lightnessID, lineStyleID
- return None, None
-
- lightnessID, lineStyleID = getDefaultParams()
self._wrappers.append(TargetWrapper(item=item, lightnessID=lightnessID, lineStyleID=lineStyleID))
+ def removeWrappers(self, wrappers):
+ """Override to reset remaining target to default style when only one remains."""
+ # Call parent implementation
+ wrappers = set(wrappers).intersection(self._wrappers)
+ if not wrappers:
+ return
+ for wrapper in wrappers:
+ self._wrappers.remove(wrapper)
+
+ # If only one target remains, reset it to default styles
+ if len(self._wrappers) == 1:
+ remaining = self._wrappers[0]
+ remaining.lightnessID = GraphLightnessEnum.normal
+ remaining.lineStyleID = GraphLineStyleEnum.solid
+
+ self.updateView()
+ for wrapper in wrappers:
+ if wrapper.isFit:
+ self.graphFrame.clearCache(reason=GraphCacheCleanupReason.fitRemoved, extraData=wrapper.item.ID)
+ elif wrapper.isProfile:
+ self.graphFrame.clearCache(reason=GraphCacheCleanupReason.profileRemoved, extraData=wrapper.item.ID)
+ self.graphFrame.draw()
+
def spawnMenu(self, event):
clickedPos = self.getRowByAbs(event.Position)
self.ensureSelection(clickedPos)
diff --git a/gui/builtinContextMenus/__init__.py b/gui/builtinContextMenus/__init__.py
index a1a26e591c..5740565f35 100644
--- a/gui/builtinContextMenus/__init__.py
+++ b/gui/builtinContextMenus/__init__.py
@@ -52,6 +52,8 @@
# Graph extra options
from gui.builtinContextMenus import graphDmgApplyProjected
from gui.builtinContextMenus import graphDmgIgnoreResists
+from gui.builtinContextMenus import graphAmmoOptimalIgnoreResists
+from gui.builtinContextMenus import graphAmmoOptimalApplyProjected
from gui.builtinContextMenus import graphLockRange
from gui.builtinContextMenus import graphDroneControlRange
from gui.builtinContextMenus import graphDmgDroneMode
diff --git a/gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py b/gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py
new file mode 100644
index 0000000000..32c0859d40
--- /dev/null
+++ b/gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py
@@ -0,0 +1,33 @@
+# noinspection PyPackageRequirements
+
+import wx
+
+import gui.globalEvents as GE
+import gui.mainFrame
+from gui.contextMenu import ContextMenuUnconditional
+from service.settings import GraphSettings
+
+_t = wx.GetTranslation
+
+
+class GraphAmmoOptimalApplyProjectedMenu(ContextMenuUnconditional):
+
+ def __init__(self):
+ self.mainFrame = gui.mainFrame.MainFrame.getInstance()
+ self.settings = GraphSettings.getInstance()
+
+ def display(self, callingWindow, srcContext):
+ return srcContext == 'ammoOptimalDpsGraph'
+
+ def getText(self, callingWindow, itmContext):
+ return _t('Apply Projected Effects')
+
+ def activate(self, callingWindow, fullContext, i):
+ self.settings.set('ammoOptimalApplyProjected', not self.settings.get('ammoOptimalApplyProjected'))
+ wx.PostEvent(self.mainFrame, GE.GraphOptionChanged())
+
+ def isChecked(self, i):
+ return self.settings.get('ammoOptimalApplyProjected')
+
+
+GraphAmmoOptimalApplyProjectedMenu.register()
diff --git a/gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py b/gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py
new file mode 100644
index 0000000000..b721452985
--- /dev/null
+++ b/gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py
@@ -0,0 +1,33 @@
+# noinspection PyPackageRequirements
+
+import wx
+
+import gui.globalEvents as GE
+import gui.mainFrame
+from gui.contextMenu import ContextMenuUnconditional
+from service.settings import GraphSettings
+
+_t = wx.GetTranslation
+
+
+class GraphAmmoOptimalIgnoreResistsMenu(ContextMenuUnconditional):
+
+ def __init__(self):
+ self.mainFrame = gui.mainFrame.MainFrame.getInstance()
+ self.settings = GraphSettings.getInstance()
+
+ def display(self, callingWindow, srcContext):
+ return srcContext == 'ammoOptimalDpsGraph'
+
+ def getText(self, callingWindow, itmContext):
+ return _t('Ignore Target Resists')
+
+ def activate(self, callingWindow, fullContext, i):
+ self.settings.set('ammoOptimalIgnoreResists', not self.settings.get('ammoOptimalIgnoreResists'))
+ wx.PostEvent(self.mainFrame, GE.GraphOptionChanged(refreshAxeLabels=True, refreshColumns=True))
+
+ def isChecked(self, i):
+ return self.settings.get('ammoOptimalIgnoreResists')
+
+
+GraphAmmoOptimalIgnoreResistsMenu.register()
diff --git a/service/settings.py b/service/settings.py
index e80b2f6ff6..9d24469033 100644
--- a/service/settings.py
+++ b/service/settings.py
@@ -537,6 +537,8 @@ def __init__(self):
'mobileDroneMode': GraphDpsDroneMode.auto,
'ignoreDCR': False,
'ignoreResists': True,
+ 'ammoOptimalIgnoreResists': True,
+ 'ammoOptimalApplyProjected': True,
'ignoreLockRange': True,
'applyProjected': True}
self.settings = SettingsProvider.getInstance().getSettings('graphSettings', defaults)