diff --git a/assets/locales/en/translation.json b/assets/locales/en/translation.json index a6a250ec95..9f35ed6e80 100644 --- a/assets/locales/en/translation.json +++ b/assets/locales/en/translation.json @@ -26,6 +26,7 @@ "name": "Name", "search": "Search", "filter": "Filter", + "elapsed_time": "Elapsed Time", "phases": { "1": "Phase 1 (5.0 - T14)", "2": "Phase 2 (5.1)", @@ -2331,6 +2332,7 @@ "breakpoints_implemented": "The following breakpoints have been implemented for this spec:", "post_cap_ep": "Post cap EP", "reforge_optimization_failed": "Reforge optimization failed. Please try again, or report the issue if it persists.", + "reforge_optimization_cancelled": "Reforge optimization cancelled.", "use_custom": "Use custom EP Weights", "enable_modification": "This will enable modification of the default EP weights and setting custom stat caps.", "modify_in_editor": "Ep weights can be modified in the Stat Weights editor.", diff --git a/assets/locales/fr/translation.json b/assets/locales/fr/translation.json index 7e637188f8..626fe581bc 100644 --- a/assets/locales/fr/translation.json +++ b/assets/locales/fr/translation.json @@ -26,6 +26,7 @@ "name": "Nom", "search": "Rechercher", "filter": "Filtrer", + "elapsed_time": "Temps écoulé", "phases": { "1": "Phase 1 (5.0 - T14)", "2": "Phase 2 (5.1)", @@ -2331,6 +2332,7 @@ "breakpoints_implemented": "Les breakpoints suivants ont été implémentés pour cette spé :", "post_cap_ep": "PE post-cap", "reforge_optimization_failed": "L'optimisation de la retouche a échoué. Veuillez réessayer ou signaler le problème s'il persiste.", + "reforge_optimization_cancelled": "Optimisation de la retouche annulée.", "use_custom": "Utiliser des poids PE personnalisés", "enable_modification": "Ceci permettra la modification des poids PE par défaut et la définition de caps de statistiques personnalisés.", "modify_in_editor": "Les poids PE peuvent être modifiés dans l'éditeur de Poids des Statistiques.", diff --git a/schemas/translation.schema.json b/schemas/translation.schema.json index 04e08421dd..9425797966 100644 --- a/schemas/translation.schema.json +++ b/schemas/translation.schema.json @@ -107,6 +107,9 @@ "filter": { "type": "string" }, + "elapsed_time": { + "type": "string" + }, "phases": { "type": "object", "properties": { @@ -9748,6 +9751,9 @@ "reforge_optimization_failed": { "type": "string" }, + "reforge_optimization_cancelled": { + "type": "string" + }, "use_custom": { "type": "string" }, diff --git a/ui/core/components/base_modal.tsx b/ui/core/components/base_modal.tsx index 4fa66ae21c..6b82028c1b 100644 --- a/ui/core/components/base_modal.tsx +++ b/ui/core/components/base_modal.tsx @@ -24,6 +24,8 @@ type BaseModalConfig = { title?: string | null; // Should the modal be disposed on close? disposeOnClose?: boolean; + // User should not be able to close the modal + preventClose?: boolean; }; const DEFAULT_CONFIG = { @@ -46,7 +48,7 @@ export class BaseModal extends Component { readonly body: HTMLElement; readonly footer: HTMLElement | undefined; - constructor(parent: HTMLElement, cssClass: string, config: BaseModalConfig = { disposeOnClose: true }) { + constructor(parent: HTMLElement, cssClass: string, config: BaseModalConfig = { disposeOnClose: true, preventClose: false }) { super(parent, 'modal'); this.modalConfig = { ...DEFAULT_CONFIG, ...config }; @@ -58,18 +60,22 @@ export class BaseModal extends Component { const modalSizeKlass = this.modalConfig.size && this.modalConfig.size != 'md' ? `modal-${this.modalConfig.size}` : ''; this.rootElem.classList.add('fade'); + if (this.modalConfig.preventClose) this.rootElem.classList.add('modal-static'); + this.rootElem.appendChild(
{this.modalConfig.title &&
{this.modalConfig.title}
} - + {!this.modalConfig.preventClose && ( + + )}
{this.modalConfig.footer &&
} @@ -82,7 +88,10 @@ export class BaseModal extends Component { this.body = bodyRef.value!; this.footer = footerRef.value!; - this.modal = new Modal(this.rootElem); + this.modal = new Modal(this.rootElem, { + backdrop: this.modalConfig.preventClose ? 'static' : true, + keyboard: !this.modalConfig.preventClose, + }); if (this.modalConfig.disposeOnClose) { this.rootElem.addEventListener( @@ -169,7 +178,7 @@ export class BaseModal extends Component { } private closeModalOnEscKey(event: KeyboardEvent) { - if (event.key == 'Escape') { + if (!this.modalConfig.preventClose && event.key == 'Escape') { this.close(); } } diff --git a/ui/core/components/progress_tracker_modal.tsx b/ui/core/components/progress_tracker_modal.tsx new file mode 100644 index 0000000000..5f707dbb60 --- /dev/null +++ b/ui/core/components/progress_tracker_modal.tsx @@ -0,0 +1,139 @@ +import clsx from 'clsx'; +import { BaseModal } from './base_modal.js'; +import { Component } from './component.js'; +import { ref } from 'tsx-vanilla'; +import i18n from '../../i18n/config.js'; + +export interface ProgressTrackerModalState { + stage: 'initializing' | 'complete' | 'error' | string; + message?: string; +} + +interface ProgressTrackerModalOptions { + id: string; + onCancel?: () => void; + onComplete?: () => void; + title: string; + warning?: string | Element; + initializingMessage?: string; +} + +export class ProgressTrackerModal extends Component { + private progressState: ProgressTrackerModalState; + + readonly id: string; + private modal: BaseModal; + private startTime: number = 0; + private updateInterval: number | null = null; + + private messageElement: HTMLElement | null = null; + private elapsedTimeElement: HTMLElement | null = null; + private contentElement: HTMLElement | null = null; + + constructor(parent: HTMLElement, options: ProgressTrackerModalOptions) { + super(null, undefined, parent); + this.id = options.id; + this.progressState = { + stage: 'initializing', + message: options.initializingMessage, + }; + + this.modal = new BaseModal(this.rootElem, clsx('progress-tracker-modal', options.id), { + title: options.title, + disposeOnClose: false, + preventClose: true, + size: 'md', + }); + this.modal.rootElem.id = this.id; + + const messageRef = ref(); + const contentRef = ref(); + const elapsedRef = ref(); + + this.modal.body.replaceChildren( +
+
+
+ {options.warning &&
{options.warning}
} +
+ {i18n.t('common.elapsed_time')}:{' '} + + 0s + +
+
+ {this.progressState.message} +
+ {options.onCancel && ( + + )} +
+
, + ); + + this.elapsedTimeElement = elapsedRef.value!; + this.messageElement = messageRef.value!; + this.contentElement = contentRef.value!; + } + + show(): void { + this.modal.open(); + this.startTime = Date.now(); + this.updateInterval = window.setInterval(() => this.updateTimeDisplay(), 100); + } + + hide(): void { + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + + // Ensure we give the modal enough time to finish opening + // To solve a Bootstrap Modal bug where it will not close properly + setTimeout(() => this.modal.close(), Math.max(0, 650 - (Date.now() - this.startTime))); + } + + updateProgress(state: Partial): void { + this.progressState = { ...this.progressState, ...state }; + this.render(); + } + + private render(): void { + const { stage, message } = this.progressState; + + // Update data-stage attribute for CSS styling + if (this.contentElement) this.contentElement.dataset.stage = stage; + + if (!this.messageElement) return; + + this.messageElement.classList[message ? 'remove' : 'add']('d-none'); + this.messageElement.textContent = message || ''; + } + + private updateTimeDisplay(): void { + if (!this.startTime || !this.elapsedTimeElement) return; + + const elapsed = (Date.now() - this.startTime) / 1000; + + // Format time nicely + if (elapsed < 60) { + this.elapsedTimeElement.textContent = `${elapsed.toFixed(1)}s`; + } else { + const minutes = Math.floor(elapsed / 60); + const seconds = Math.floor(elapsed % 60); + this.elapsedTimeElement.textContent = `${minutes}m ${seconds}s`; + } + } +} diff --git a/ui/core/components/suggest_reforges_action.tsx b/ui/core/components/suggest_reforges_action.tsx index 85ceeb1c47..eeb7fc25bf 100644 --- a/ui/core/components/suggest_reforges_action.tsx +++ b/ui/core/components/suggest_reforges_action.tsx @@ -26,8 +26,9 @@ import { NumberPicker, NumberPickerConfig } from './pickers/number_picker'; import { renderSavedEPWeights } from './saved_data_managers/ep_weights'; import Toast from './toast'; import { trackEvent, trackPageView } from '../../tracking/utils'; -import { getReforgeWorkerPool } from '../reforge_worker_pool.js'; +import { ReforgeWorkerPool, getReforgeWorkerPool } from '../reforge_worker_pool'; import type { LPModel, LPSolution, SerializedConstraints, SerializedVariables } from '../../worker/reforge_types'; +import { ProgressTrackerModal } from './progress_tracker_modal'; type YalpsCoefficients = Map; type YalpsVariables = Map; @@ -231,6 +232,7 @@ export class ReforgeOptimizer { protected _softCapsConfig: StatCap[]; private useCustomEPValues = false; private useSoftCapBreakpoints = true; + protected progressTrackerModal: ProgressTrackerModal; protected softCapBreakpoints: StatCap[] = []; protected updateSoftCaps: ReforgeOptimizerOptions['updateSoftCaps']; protected enableBreakpointLimits: ReforgeOptimizerOptions['enableBreakpointLimits']; @@ -243,6 +245,9 @@ export class ReforgeOptimizer { protected frozenItemSlots = new Set(); protected includeTimeout = true; protected undershootCaps = new Stats(); + protected wasCM: boolean = false; + protected isCancelling: boolean = false; + protected pendingWorker: ReforgeWorkerPool | null = null; protected previousGear: Gear | null = null; protected previousReforges = new Map(); protected currentReforges = new Map(); @@ -283,6 +288,41 @@ export class ReforgeOptimizer { this._statCaps = this.defaults.statCaps || new Stats(); this.enableBreakpointLimits = !!options?.enableBreakpointLimits; this.relativeStatCapStat = options?.defaultRelativeStatCap ?? -1; + this.progressTrackerModal = new ProgressTrackerModal(simUI.rootElem, { + id: 'reforge-optimizer-progress-tracker', + title: 'Optimizing Reforges', + warning: ( + <> +

+ Reforging can be a lengthy process, especially as specific stat caps and breakpoints come into play for classes. This may take a while, + but be assured that the calculation will eventually complete. +

+

You may cancel this operation at any time using the button below.

+ + ), + onCancel: () => { + this.isCancelling = true; + if (isDevMode()) { + console.log('User cancelled reforge optimization'); + } + try { + this.pendingWorker?.terminate(); + } catch {} + if (this.previousGear) this.player.setGear(TypedEvent.nextEventID(), this.previousGear); + this.progressTrackerModal.hide(); + trackEvent({ + action: 'settings', + category: 'reforging', + label: 'suggest_cancel', + }); + + new Toast({ + variant: 'warning', + body: i18n.t('sidebar.buttons.suggest_reforges.reforge_optimization_cancelled'), + delay: 3000, + }); + }, + }); // Pre-warm the worker pool getReforgeWorkerPool().warmUp(); @@ -290,50 +330,27 @@ export class ReforgeOptimizer { const startReforgeOptimizationEntry: ActionGroupItem = { label: i18n.t('sidebar.buttons.suggest_reforges.title'), cssClass: 'suggest-reforges-action-button flex-grow-1', - onClick: async ({ currentTarget }) => { + onClick: async () => { + this.progressTrackerModal.show(); trackEvent({ action: 'settings', category: 'reforging', label: 'suggest_start', }); - const button = currentTarget as HTMLButtonElement; - if (button) { - button.classList.add('loading'); - button.disabled = true; - } - const wasCM = simUI.player.getChallengeModeEnabled(); + this.wasCM = simUI.player.getChallengeModeEnabled(); try { performance.mark('reforge-optimization-start'); - if (wasCM) { + if (this.wasCM) { simUI.player.setChallengeModeEnabled(TypedEvent.nextEventID(), false); } await this.optimizeReforges(); this.onReforgeDone(); } catch (error) { + if (this.isCancelling) return; this.onReforgeError(error); } finally { - if (wasCM) { - simUI.player.setChallengeModeEnabled(TypedEvent.nextEventID(), true); - } - performance.mark('reforge-optimization-end'); - const completionTimeInMs = performance.measure( - 'reforge-optimization-measure', - 'reforge-optimization-start', - 'reforge-optimization-end', - ).duration; - if (isDevMode()) console.log('Reforge optimization took:', `${completionTimeInMs.toFixed(2)}ms`); - - trackEvent({ - action: 'settings', - category: 'reforging', - label: 'suggest_duration', - value: Math.ceil(completionTimeInMs / 1000), - }); - if (button) { - button.classList.remove('loading'); - button.disabled = false; - } + this.onReforgeFinally(); } }, }; @@ -1720,8 +1737,8 @@ export class ReforgeOptimizer { const startTimeMs: number = Date.now(); - const workerPool = getReforgeWorkerPool(); - const solution: LPSolution = await workerPool.solve(model, { + this.pendingWorker = getReforgeWorkerPool(); + const solution: LPSolution = await this.pendingWorker.solve(model, { timeout: maxSeconds * 1000, tolerance: 0.005, // unused currently }); @@ -2101,6 +2118,7 @@ export class ReforgeOptimizer { label: 'suggest_error', value: error, }); + new Toast({ variant: 'error', body: ( @@ -2116,6 +2134,24 @@ export class ReforgeOptimizer { }); } + onReforgeFinally() { + this.progressTrackerModal.hide(); + + if (this.wasCM) { + this.simUI.player.setChallengeModeEnabled(TypedEvent.nextEventID(), true); + } + performance.mark('reforge-optimization-end'); + const completionTimeInMs = performance.measure('reforge-optimization-measure', 'reforge-optimization-start', 'reforge-optimization-end').duration; + if (isDevMode()) console.log('Reforge optimization took:', `${completionTimeInMs.toFixed(2)}ms`); + + trackEvent({ + action: 'settings', + category: 'reforging', + label: 'suggest_duration', + value: Math.ceil(completionTimeInMs / 1000), + }); + } + fromProto(eventID: EventID, proto: ReforgeSettings) { TypedEvent.freezeAllAndDo(() => { this.setUseCustomEPValues(eventID, proto.useCustomEpValues); diff --git a/ui/core/reforge_worker_pool.ts b/ui/core/reforge_worker_pool.ts index a7fb58fd8f..6ea4680351 100644 --- a/ui/core/reforge_worker_pool.ts +++ b/ui/core/reforge_worker_pool.ts @@ -242,6 +242,7 @@ export class ReforgeWorkerPool { terminate() { this.worker?.terminate(); this.worker = null; + ReforgeWorkerPool.instance = null; } } diff --git a/ui/scss/core/components/_progress_tracker_modal.scss b/ui/scss/core/components/_progress_tracker_modal.scss new file mode 100644 index 0000000000..0866fee9e9 --- /dev/null +++ b/ui/scss/core/components/_progress_tracker_modal.scss @@ -0,0 +1,26 @@ +.progress-tracker-modal { + margin: 0; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) !important; + + .modal-title { + margin-left: auto; + margin-right: auto; + text-align: center; + } +} + +.progress-tracker-modal-warning { + border: 1px solid var(--bs-warning); + padding: var(--spacer-3); + font-size: var(--btn-font-size); +} + +.progress-tracker-modal-content { + display: flex; + flex-direction: column; + gap: var(--spacer-3); + align-items: center; + text-align: center; +} diff --git a/ui/scss/core/individual_sim_ui/index.scss b/ui/scss/core/individual_sim_ui/index.scss index 22935d3164..525502e42c 100644 --- a/ui/scss/core/individual_sim_ui/index.scss +++ b/ui/scss/core/individual_sim_ui/index.scss @@ -33,6 +33,7 @@ @import '../components/tooltip_button'; @import '../components/unit_picker'; @import '../components/suggest_reforges_action'; +@import '../components/progress_tracker_modal'; @import '../components/individual_sim_ui';