From f55e17f8e066fa4681772b53e2e68bde3b6901db Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 16 Jul 2026 14:09:16 +0200 Subject: [PATCH 01/64] feat(a12): implement two column layout --- .../preprocessing-wizard.component.html | 112 +++++++++------ .../preprocessing-wizard.component.scss | 132 +++++++++--------- .../preprocessing-wizard.component.ts | 37 +---- .../shared/_wizard-shared.scss | 10 +- .../progress-stepper.component.scss | 35 +++-- .../shared/wizard-step.ts | 26 ++++ .../step1-upload/step1-upload.component.html | 12 -- .../step1-upload/step1-upload.component.ts | 18 ++- .../step2-column-selection.component.html | 21 --- .../step2-column-selection.component.ts | 21 +-- ...ep3-configure-data-features.component.html | 24 ---- ...step3-configure-data-features.component.ts | 19 +-- ...tep4-visualization-settings.component.html | 22 --- .../step4-visualization-settings.component.ts | 15 +- 14 files changed, 240 insertions(+), 264 deletions(-) create mode 100644 src/app/preprocessing-wizard/shared/wizard-step.ts diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.html b/src/app/preprocessing-wizard/preprocessing-wizard.component.html index eef2e22..433aa3d 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.html +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.html @@ -1,49 +1,81 @@
-
-
-

Data Preprocessing Wizard

-
- - -
+
+

Data Preprocessing Wizard

+
+ +
- - -
-
- @if (error) { -
- error - {{ error }} - -
- } +
+ -
- @switch (currentStep) { - @case (0) { - - } - @case (1) { - - } - @case (2) { - - } - @case (3) { - - } - @case (4) { - +
+
+ @if (error) { +
+ error + {{ error }} + +
} + +
+ @switch (currentStep) { + @case (0) { + + } + @case (1) { + + } + @case (2) { + + } + @case (3) { + + } + @case (4) { + + } + } +
+
+ + @if (activeStep(); as step) { +
+ + +
+ @if (!step.canProceed() && step.disabledHint) { +
+ warning + {{ step.disabledHint }} +
+ } + + +
+
}
diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss index 078b3ea..cffffd5 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss @@ -8,22 +8,19 @@ background: v.$wizard-content-bg; } -.wizard-header { - background: v.$wizard-header-bg; - z-index: 100; - flex-shrink: 0; -} - -.header-content { +// Full-width title bar (title + global actions), stepper no longer lives here +.wizard-titlebar { display: flex; justify-content: space-between; align-items: center; - padding: 12px 16px; + padding: 0.75rem 1rem; background: v.$gray-100; border-bottom: 1px solid v.$gray-300; + flex-shrink: 0; + z-index: 100; h1 { - font-size: 16px; + font-size: 1rem; font-weight: bold; color: v.$gray-800; margin: 0; @@ -32,23 +29,23 @@ .header-actions { display: flex; - gap: 8px; + gap: 0.5rem; } .btn-icon { - width: 32px; - height: 32px; + width: 2rem; + height: 2rem; display: flex; align-items: center; justify-content: center; background: rgba(200, 200, 200, 0.5); border: none; - border-radius: 4px; + border-radius: 0.25rem; cursor: pointer; transition: background-color 0.2s ease; .material-icons { - font-size: 20px; + font-size: 1.25rem; color: v.$gray-600; } @@ -57,9 +54,34 @@ } } +// Two-column body: navigation left, work area right +.wizard-body { + flex: 1; + display: flex; + min-height: 0; +} + +.wizard-sidebar { + width: 15rem; + flex-shrink: 0; + box-sizing: border-box; + background: v.$wizard-header-bg; + border-right: 1px solid v.$gray-300; + overflow-x: hidden; + overflow-y: auto; +} + +.wizard-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + .wizard-content { flex: 1; - overflow-y: auto; // Allow scrolling for steps that need it (Step 1) + overflow-y: auto; display: flex; flex-direction: column; min-height: 0; @@ -69,16 +91,16 @@ .error-banner { display: flex; align-items: center; - gap: 12px; - padding: 12px 16px; + gap: 0.75rem; + padding: 0.75rem 1rem; background: rgba(v.$status-error, 0.1); border-left: 3px solid v.$status-error; color: v.$status-error-dark; - margin: 0 16px 16px; - border-radius: 2px; + margin: 1rem 1rem 0; + border-radius: 0.125rem; .material-icons { - font-size: 20px; + font-size: 1.25rem; } .btn-close { @@ -89,7 +111,7 @@ padding: 0; .material-icons { - font-size: 18px; + font-size: 1.125rem; color: v.$status-error-dark; } } @@ -109,66 +131,38 @@ } } -.step-placeholder { - max-width: 1000px; - margin: 0 auto; - padding: 60px 20px; - text-align: center; +// Centralized navigation bar for steps 1–4, pinned to the bottom of the work area +.wizard-nav { + @include w.action-buttons-footer; - h2 { - font-size: 18px; - font-weight: bold; - color: v.$gray-700; - margin-bottom: 8px; + .btn-secondary { + @include w.btn-secondary; } - p { - font-size: 13px; - color: v.$gray-500; + .buttons-right { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; } -} -.wizard-footer { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - background: v.$wizard-header-bg; - border-top: 1px solid v.$gray-300; - flex-shrink: 0; -} - -.footer-info { - font-size: 13px; - color: v.$gray-600; -} - -.btn-secondary { - @include w.btn-secondary; - padding: 8px 16px; -} + .warning-message { + @include w.message-warning; + } -.btn-primary { - @include w.btn-primary; - padding: 8px 16px; + .btn-primary { + @include w.btn-primary; + } } @media (max-width: 768px) { - .header-content { + .wizard-titlebar { h1 { - font-size: 14px; + font-size: 0.875rem; } } - .wizard-content { - padding: 12px 0; - } - - .wizard-footer { - padding: 10px; - } - - .footer-info { - display: none; + .wizard-sidebar { + width: 12.5rem; } } diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts index 604cc1f..b94e78f 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts @@ -1,13 +1,13 @@ -import { Component, OnInit, OnDestroy, Output, EventEmitter, ViewChild, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, Output, EventEmitter, ViewChild, ElementRef, viewChild } from '@angular/core'; import { Subscription, distinctUntilChanged, map } from 'rxjs'; import { PreprocessingService } from './services/preprocessing.service'; import { ProgressStepperComponent, Step } from './shared/progress-stepper/progress-stepper.component'; +import { WIZARD_STEP } from './shared/wizard-step'; import { Step1UploadComponent } from './steps/step1-upload/step1-upload.component'; import { Step2ColumnSelectionComponent } from './steps/step2-column-selection/step2-column-selection.component'; import { Step3ConfigureDataFeaturesComponent } from './steps/step3-configure-data-features/step3-configure-data-features.component'; import { Step4VisualizationSettingsComponent } from './steps/step4-visualization-settings/step4-visualization-settings.component'; import { Step5ReviewProcessingComponent } from './steps/step5-review-processing/step5-review-processing.component'; -import { DataProfile } from './models/column-statistics'; import { PreprocessingState } from './models/preprocessing-state'; @Component({ @@ -28,6 +28,11 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { @Output() wizardClose = new EventEmitter(); @ViewChild('wizardContent') wizardContent!: ElementRef; + // Currently rendered step (1–4) exposed through the WIZARD_STEP token so the + // shell can drive the centralized navigation bar. Undefined on step 5, which + // keeps its own context-dependent footer. + readonly activeStep = viewChild(WIZARD_STEP); + private subscription = new Subscription(); currentStep = 0; @@ -107,38 +112,10 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { this.preprocessingService.goToStep(step); } - onDataLoaded(_profile: DataProfile): void { - // User clicked "Continue to Column Selection" button - proceed to next step - this.preprocessingService.nextStep(); - } - - nextStep(): void { - this.preprocessingService.nextStep(); - } - previousStep(): void { this.preprocessingService.previousStep(); } - canGoNext(): boolean { - const state = this.preprocessingService.currentState; - - switch (this.currentStep) { - case 0: // Upload - return state.dataProfile !== null; - case 1: // Column Selection - return state.columnConfigs.size > 0; - case 2: // Configure Data & Features - return true; - case 3: // Visualization Settings - return true; - case 4: // Review & Process - return false; // Final step - processing happens here - default: - return false; - } - } - reset(): void { if (confirm('Are you sure you want to start over? All progress will be lost.')) { this.highestStepVisited = 0; // Reset navigation tracking diff --git a/src/app/preprocessing-wizard/shared/_wizard-shared.scss b/src/app/preprocessing-wizard/shared/_wizard-shared.scss index 8900370..dfdf629 100644 --- a/src/app/preprocessing-wizard/shared/_wizard-shared.scss +++ b/src/app/preprocessing-wizard/shared/_wizard-shared.scss @@ -3,7 +3,7 @@ // Shared wizard styles following GlyphSpace design system @mixin step-header { - text-align: center; + text-align: left; margin-bottom: 16px; h2 { @@ -402,13 +402,11 @@ } } -// Step content wrapper - constrains content width while allowing footer to be full-width +// Step content wrapper - fills the available work-area width with consistent padding @mixin step-content { flex: 1; - padding: 16px 32px; - max-width: 1150px; + padding: 24px 32px; width: 100%; - margin: 0 auto; box-sizing: border-box; } @@ -451,7 +449,7 @@ // Step purpose statement @mixin step-purpose { - text-align: center; + text-align: left; font-size: 13px; color: v.$gray-600; margin-bottom: 16px; diff --git a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss index 4001bf8..d7155e0 100644 --- a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss +++ b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss @@ -8,18 +8,23 @@ &.vertical { height: 100%; - padding: 24px 16px; + padding: 1.5rem 1rem; border-bottom: none; + box-sizing: border-box; .stepper-container { flex-direction: column; max-width: none; + width: 100%; + padding: 0; gap: 0; + box-sizing: border-box; } .step-wrapper { flex-direction: column; flex: none; + width: 100%; &:last-child { flex: none; @@ -27,11 +32,13 @@ } .step { + width: 100%; + box-sizing: border-box; flex-direction: row; align-items: center; - gap: 12px; - padding: 12px; - border-radius: 8px; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: 0.5rem; &.clickable:hover { background: rgba(0, 0, 0, 0.05); @@ -43,30 +50,34 @@ } .step-circle { - width: 36px; - height: 36px; + width: 2.25rem; + height: 2.25rem; flex-shrink: 0; .material-icons { - font-size: 20px; + font-size: 1.25rem; } } .step-number { - font-size: 15px; + font-size: 0.9375rem; } .step-label { - font-size: 14px; + font-size: 0.875rem; text-align: left; flex: 1; white-space: normal; + line-height: 1.3; } + // Compact connector aligned under the circle centre; no flex-grow so it + // never stretches the column into a scroll .step-connector { + flex: none; width: 2px; - height: 20px; - margin: 0 0 0 29px; + height: 1.5rem; + margin: 0.25rem 0 0.25rem 1.8125rem; } } } @@ -226,7 +237,7 @@ } .step-connector { - height: 16px; + min-height: 16px; margin-left: 27px; } } diff --git a/src/app/preprocessing-wizard/shared/wizard-step.ts b/src/app/preprocessing-wizard/shared/wizard-step.ts new file mode 100644 index 0000000..0981599 --- /dev/null +++ b/src/app/preprocessing-wizard/shared/wizard-step.ts @@ -0,0 +1,26 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Contract that each navigable wizard step (steps 1–4) implements so the + * shell can render a single, centralized navigation bar instead of every + * step carrying its own footer buttons. + * + * The shell resolves the currently rendered step via the WIZARD_STEP token + * (see PreprocessingWizardComponent). Step 5 (Review & Process) has its own + * context-dependent footer and deliberately does not provide the token. + */ +export interface WizardStep { + /** Whether the primary "continue" action is currently allowed. */ + canProceed(): boolean; + + /** Advances the wizard to the next step (guarded by canProceed). */ + proceed(): void; + + /** Label of the primary button, e.g. "Continue to Column Selection". */ + readonly primaryLabel: string; + + /** Optional hint shown next to the disabled primary button. */ + readonly disabledHint?: string; +} + +export const WIZARD_STEP = new InjectionToken('WIZARD_STEP'); diff --git a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html index ca7d2ac..07bdc0d 100644 --- a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html +++ b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html @@ -154,16 +154,4 @@

Data Preview (First 10 rows)

}
- - @if (profile) { -
-
-
- -
-
- }
diff --git a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts index 9fcc396..520f4d7 100644 --- a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts +++ b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts @@ -1,4 +1,4 @@ -import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; @@ -7,6 +7,7 @@ import { DataPreviewTableComponent } from '../../shared/data-preview-table/data- import { DataProfile } from '../../models/column-statistics'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { STEP_INFO } from '../../shared/constants/step-info'; +import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; @Component({ selector: 'app-step1-upload', @@ -14,9 +15,10 @@ import { STEP_INFO } from '../../shared/constants/step-info'; imports: [CommonModule, FormsModule, DataPreviewTableComponent, HelpTooltipComponent], templateUrl: './step1-upload.component.html', styleUrl: './step1-upload.component.scss', + providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step1UploadComponent) }], }) -export class Step1UploadComponent implements OnInit, OnDestroy { - @Output() dataLoaded = new EventEmitter(); +export class Step1UploadComponent implements OnInit, OnDestroy, WizardStep { + readonly primaryLabel = 'Continue to Column Selection'; private subscription = new Subscription(); @@ -43,6 +45,16 @@ export class Step1UploadComponent implements OnInit, OnDestroy { this.subscription.unsubscribe(); } + canProceed(): boolean { + return this.profile !== null; + } + + proceed(): void { + if (this.canProceed()) { + this.preprocessingService.nextStep(); + } + } + onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index 2b9684a..fa283fd 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -178,25 +178,4 @@

{{ stepInfo.title }}

}
- -
- - -
- @if (enabledCount === 0) { -
- warning - Please select at least one column to continue -
- } - - -
-
diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts index 657c22d..9add036 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Output, EventEmitter } from '@angular/core'; +import { Component, OnInit, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { PreprocessingService } from '../../services/preprocessing.service'; @@ -10,6 +10,7 @@ import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.com import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; import { DataTypeBadgeComponent } from '../../shared/data-type-badge/data-type-badge.component'; +import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; @Component({ selector: 'app-step2-column-selection', @@ -17,9 +18,11 @@ import { DataTypeBadgeComponent } from '../../shared/data-type-badge/data-type-b imports: [CommonModule, FormsModule, MiniHistogramComponent, HelpTooltipComponent, DataTypeBadgeComponent], templateUrl: './step2-column-selection.component.html', styleUrl: './step2-column-selection.component.scss', + providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step2ColumnSelectionComponent) }], }) -export class Step2ColumnSelectionComponent implements OnInit { - @Output() continue = new EventEmitter(); +export class Step2ColumnSelectionComponent implements OnInit, WizardStep { + readonly primaryLabel = 'Continue to Configure Data & Features'; + readonly disabledHint = 'Please select at least one column to continue'; columns: ColumnStatistics[] = []; columnConfigs = new Map(); @@ -91,14 +94,14 @@ export class Step2ColumnSelectionComponent implements OnInit { this.columnConfigs = this.preprocessingService.currentState.columnConfigs; } - onContinue(): void { - if (this.enabledCount > 0) { - this.continue.emit(); - } + canProceed(): boolean { + return this.enabledCount > 0; } - goBack(): void { - this.preprocessingService.previousStep(); + proceed(): void { + if (this.canProceed()) { + this.preprocessingService.nextStep(); + } } getColumnConfig(columnName: string): ColumnConfig | undefined { diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html index cb98c31..98120fd 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html @@ -420,28 +420,4 @@

} } - - @if (!error) { - -
- - -
- @if (!canContinue()) { -
- warning - Please include at least one column in projection to continue. -
- } - - -
-
- } diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts index 796f932..afcd6d2 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts @@ -1,6 +1,7 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, forwardRef } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { PreprocessingService } from '../../services/preprocessing.service'; +import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; import { ColumnConfig, CleaningConfig } from '../../models/column-config'; import { ColumnStatistics } from '../../models/column-statistics'; import { @@ -31,8 +32,12 @@ interface ColumnConfigState { imports: [FormsModule, HelpTooltipComponent, DataTypeBadgeComponent], templateUrl: './step3-configure-data-features.component.html', styleUrl: './step3-configure-data-features.component.scss', + providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step3ConfigureDataFeaturesComponent) }], }) -export class Step3ConfigureDataFeaturesComponent implements OnInit { +export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { + readonly primaryLabel = 'Continue to Visualization Settings'; + readonly disabledHint = 'Please include at least one column in projection to continue.'; + columns: ColumnConfigState[] = []; filteredColumns: ColumnConfigState[] = []; @@ -414,18 +419,14 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit { return this.columns.filter(c => c.config.includeInProjection).length; } - canContinue(): boolean { + canProceed(): boolean { // At least one column must be included in projection return this.getProjectionCount() > 0; } - onContinue(): void { - if (this.canContinue()) { + proceed(): void { + if (this.canProceed()) { this.preprocessingService.nextStep(); } } - - goBack(): void { - this.preprocessingService.previousStep(); - } } diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 583e0a9..16c52a6 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -358,26 +358,4 @@

} - - -
- - -
- @if (!canProceed()) { -
- warning - Please select 3-12 glyph features to continue. -
- } - - -
-
diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index 75a14dc..0a59163 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -1,7 +1,8 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { PreprocessingService } from '../../services/preprocessing.service'; +import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; import { ProjectionConfig } from '../../models/column-config'; import { ColumnStatistics } from '../../models/column-statistics'; import { DataType } from '../../models/data-type.enum'; @@ -56,8 +57,12 @@ interface ProjectionMethodUI { imports: [CommonModule, FormsModule, HelpTooltipComponent, ColorScaleSelectorComponent], templateUrl: './step4-visualization-settings.component.html', styleUrl: './step4-visualization-settings.component.scss', + providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step4VisualizationSettingsComponent) }], }) -export class Step4VisualizationSettingsComponent implements OnInit { +export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { + readonly primaryLabel = 'Continue to Review'; + readonly disabledHint = 'Please select 3-12 glyph features to continue.'; + // Color feature selection columns: ColumnStatistics[] = []; colorFeature: string | null = null; @@ -630,13 +635,9 @@ export class Step4VisualizationSettingsComponent implements OnInit { return glyphValid && projectionValid; } - continue(): void { + proceed(): void { if (this.canProceed()) { this.preprocessingService.nextStep(); } } - - goBack(): void { - this.preprocessingService.previousStep(); - } } From 90c554834e08cde2f007b37d9e73a84ad0e2c3b4 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 16 Jul 2026 14:10:21 +0200 Subject: [PATCH 02/64] chore(wizard-redesign): add roadmap --- ROADMAP.md | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..b01105d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,285 @@ +# Implementierungs-Tickets – Wizard-Überarbeitung + +Ticketsystem für die Umsetzungsphase. Als Backbone dient der **Anforderungskatalog (A1–A15)** +Jede Anforderung ist ein Ticket. Feingranulare Einzelbefunde werden nicht als eigene Tickets geführt, sondern innerhalb des jeweiligen Anforderungstickets als abzuhakende Punkte gelistet. + +Pfadangaben sind relativ zu `Code/src/app/`. Der Wizard liegt vollständig unter `preprocessing-wizard/`. + +Miro-Board (Lösungskonzepte): https://miro.com/app/board/uXjVGrxvTQs=/ + +**Status-Legende:** `TODO` · `In Progress` · `In Feature-Branch` (fertig entwickelt, in `feature/wizard-redesign` gemergt, noch nicht in `main`) · `Finished` (in `main`) +**Typ:** `Konzept` (durchgeplant in Miro) · `trivial` (kleiner, klar umrissener Fix) + +### Branch-Strategie + +Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket ein kurzer Branch davon, dort das Feature bauen, dann per Merge/PR zurück in `feature/wizard-redesign`. Am Ende der ganze Feature-Branch nach `main`. + +- Ticket-Branch-Namensschema: `feat/aN-kurzbezeichnung` (z. B. `feat/a4-undo-historie`). +- Ticket-Branches klein halten und zügig zurückmergen, sonst driftet der Feature-Branch weg. +- Regelmäßig `main` in `feature/wizard-redesign` mergen, damit der finale Merge klein bleibt. +- Statusübergang: `TODO` → (Branch anlegen) `In Progress` → (in Feature-Branch gemergt) `In Feature-Branch` → (Feature-Branch in `main`) `Finished`. + + +--- + +## Übersicht + +| Ticket | Kurztitel | Typ | Status | Branch | Hauptort im Code | +|--------|-----------|-----|--------|--------|------------------| +| A1 | Fachbegriffe/Optionen erklären | Konzept | TODO | `feat/a1-erklaertexte` | `shared/constants/help-text.ts`, `shared/help-tooltip/`, Step 3+4 | +| A2 | Voreinstellungen sichtbar/umkehrbar | Konzept | TODO | `feat/a2-smart-defaults` | Step 2+3, `services/preprocessing.service.ts` | +| A3 | Fehlermeldungen inline/verständlich | Konzept | TODO | `feat/a3-fehleranzeige` | Step 4+5 | +| A4 | Undo/Zurücksetzen | Konzept | TODO | `feat/a4-undo-historie` | `services/preprocessing.service.ts`, Step 1+4 | +| A5 | Zusammengehöriges gruppieren | Konzept | TODO | `feat/a5-gruppierung` | Step 3+4 | +| A6 | Review-Navigation (Direktsprünge) | Konzept | TODO | `feat/a6-direct-links` | Step 5, `shared/progress-stepper/` | +| A7 | Ehrliche/konsistente Signifier | trivial | TODO | `feat/a7-signifier` | Step 1/2/4, `shared/progress-stepper/` | +| A8 | Systemstatus/Datenanzeige korrekt | trivial | TODO | `feat/a8-datenanzeige` | Step 2+4 | +| A9 | Beschleuniger für Power-Nutzer | Konzept | TODO | `feat/a9-power-shortcuts` | Step 2/3/4 | +| A10 | Layout gegen lange/viele Inhalte | Konzept | TODO | `feat/a10-layout-robust` | Step 1+4, `shared/_wizard-shared.scss` | +| A11 | Technische Defekte beheben | trivial | TODO | `feat/a11-bugfixes` | siehe Ticket | +| A12 | Zweispaltiges Layout | Konzept | TODO | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | +| A13 | Schrittlogik klären/neu ordnen | Konzept | TODO | `feat/a13-schrittlogik` | Step 2/3/4, `shared/constants/step-info.ts` | +| A14 | Übergänge sichtbar (Animation) | Konzept | TODO | `feat/a14-animation` | Step 2↔3, `shared/data-preview-table/` | +| A15 | Einheitliches Tabellen/Listen-System | Konzept | TODO | `feat/a15-table-system` | `shared/data-preview-table/`, Step 2/3/4 | + +--- + +## Umsetzungsreihenfolge und Abhängigkeiten + +Die Reihenfolge ist von den Abhängigkeiten getrieben, nicht von der Katalognummer. Grundgedanke: erst das strukturelle Fundament (Layout-Shell, Schrittlogik, gemeinsame Tabelle), dann die Features, die darauf aufsetzen, zuletzt der Feinschliff. Undo (A4) kommt spät, weil es zum einen den Platz im großen Layout braucht und zum anderen den Zustand sichern muss, den erst die Smart Defaults (A2) festlegen. + +| Phase | Reihenfolge | Ticket | Blockiert von | Begründung | +|-------|-------------|--------|---------------|------------| +| 1 – Fundament | 1 | A12 Zweispaltiges Layout | — | Die Shell, in der alle Schritte rendern. Muss zuerst stehen. | +| 1 – Fundament | 2 | A13 Schrittlogik/Neuzuschnitt Schritt 4 | A12 | Legt fest, welcher Inhalt in welchem Schritt lebt; spätere Tickets bauen darauf. | +| 1 – Fundament | 3 | A15 Tabellen/Listen-System | A12 (inhaltlich A13) | Gemeinsame Tabellenkomponente, die A2, A8, A9, A14 wiederverwenden. | +| 2 – Features | 4 | A5 Gruppierung/Parameter in Box | A13 | Setzt den Neuzuschnitt von Schritt 4 voraus. | +| 2 – Features | 5 | A2 Smart Defaults sichtbar/umkehrbar | A15 | Braucht die Tabelle, um Auswahl-/Default-Zustand anzuzeigen. | +| 2 – Features | 6 | A14 Übergangsanimation 2↔3 | A15, A13 | Tabelle ist Träger des Morphings; Schrittstruktur muss stehen. | +| 2 – Features | 7 | A9 Power-Beschleuniger | A15 | Shift-Klick, Select-all-gefiltert, Drag-and-drop bauen auf der Tabelle/Liste auf. | +| 2 – Features | 8 | A10 Layout-Robustheit/Feature-Suche | A15, A12 | Durchsuchbare Feature-Liste und Umbruch im neuen Layout. | +| 2 – Features | 9 | A6 Review-Direktlinks | A13 | Sprünge zielen auf die final zugeschnittenen Schritte. | +| 2 – Features | 10 | A8 Datenanzeige („-"→„0", Zeilenkontext) | A15 | Anzeige in der gemeinsamen Tabelle. | +| 3 – Zustand/Inhalt | 11 | A3 Fehleranzeige/Persistent Result | A13 | Step 4/5 müssen strukturell stehen. | +| 3 – Zustand/Inhalt | 12 | A4 Undo/Historie | A12, A2 | Braucht Platz im Layout und ein gesetztes State-/Reset-Modell. | +| 4 – Feinschliff | 13 | A1 Erklärtexte/Tooltips | — (locker) | Inhaltsschicht; sinnvoll, wenn die Komponenten existieren. | +| 4 – Feinschliff | 14 | A7 Signifier | — | Kosmetisch; final nach dem Restyle durch A12/A15. | +| 4 – Feinschliff | 15 | A11 Technische Defekte | teils — | Flaky Tooltip und doppelte „Moderate"-Option sind unabhängige Quick Wins (jederzeit, gern zuerst für Momentum); Persistent-Result-Teil zusammen mit A3. | + +```mermaid +graph TD + A12[A12 Zweispalten-Layout] --> A13[A13 Schrittlogik] + A12 --> A15[A15 Tabellen-System] + A13 --> A15 + A13 --> A5[A5 Gruppierung/Parameter] + A15 --> A2[A2 Smart Defaults] + A15 --> A14[A14 Animation 2 zu 3] + A13 --> A14 + A15 --> A9[A9 Power-Beschleuniger] + A15 --> A10[A10 Layout-Robustheit] + A12 --> A10 + A13 --> A6[A6 Review-Direktlinks] + A15 --> A8[A8 Datenanzeige] + A13 --> A3[A3 Fehleranzeige] + A12 --> A4[A4 Undo/Historie] + A2 --> A4 + A1[A1 Erklaertexte] + A7[A7 Signifier] + A11[A11 Technische Defekte / Quick Wins] +``` + +Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen: A11 (flaky Tooltip, doppelte Option), A7 (Signifier), teils A1 (Erklärtexte). + +--- + +## A1 – Fachbegriffe und Optionen verständlich erklären +**Typ:** Konzept · **Miro:** Visual Tooltips Methoden · **Status:** TODO · **Branch:** `feat/a1-erklaertexte` +**Befunde:** C-S4-01, C-S4-03, C-S4-08, C-S4-10, S-S3-02, S-S3-04, P-S3-04 + +**Problem:** Fachbegriffe werden ohne Erklärung angeboten (Farbattribut, Projektionsmethoden, FastMap, Ausreißermethoden, Z-Wert-Standardisierung, Smart Defaults). + +**Wo umsetzen:** +- Erklärtexte zentral pflegen in `shared/constants/help-text.ts`. +- Abrufbare Erklärung (Progressive Disclosure) über die bestehende `shared/help-tooltip/`-Komponente; für Projektionsmethoden auf visuelle Tooltips erweitern (Miro „Visual Tooltips Methoden", vgl. `tmp/Projektion_Methoden_Visualisierung.html`). +- Einbindung an den Optionen selbst: Farbattribut und Projektionen in `steps/step4-visualization-settings/`, Ausreißer-/Standardisierungsoptionen in `steps/step3-configure-data-features/`. + +--- + +## A2 – Voreinstellungen sichtbar und umkehrbar machen +**Typ:** Konzept · **Miro:** Smart Defaults Button · **Status:** TODO · **Branch:** `feat/a2-smart-defaults` +**Befunde:** C-S2-01, C-S3-02, S-S3-01, P-S3-04 + +**Problem:** Smart Defaults sind unsichtbar; unklar was automatisch gesetzt wurde; geänderte Defaults sind nicht reaktivierbar; in der Spaltenauswahl fehlt eine sinnvolle Vorauswahl. + +**Wo umsetzen:** +- Smart-Default-Logik/State in `services/preprocessing.service.ts` (Kennzeichnung „vom Default abweichend", Reset-Funktion). +- Vorauswahl beim Spalten-Import in `steps/step2-column-selection/` (offensichtlich ungeeignete Spalten vorab abwählen; vgl. `missingPercentage > 50 || uniqueCount === 1`). +- Sichtbare Kennzeichnung + „Smart Defaults zurücksetzen/erneut anwenden"-Button in `steps/step3-configure-data-features/` und `steps/step4-visualization-settings/`. + +--- + +## A3 – Fehlermeldungen inline, verständlich und lösungsorientiert +**Typ:** Konzept · **Miro:** Fehleranzeige, NEU: Persistent Result Screen + Errorhandling · **Status:** TODO · **Branch:** `feat/a3-fehleranzeige` +**Befunde:** C-S4-05, C-S5-02, P-S5-01 + +**Problem:** Fehler erscheinen spät, entfernt vom Ort, inhaltlich nichtssagend, teils nur als flüchtiges Popup. + +**Wo umsetzen:** +- Feldnahe Inline-Validierung in `steps/step4-visualization-settings/` (Fehler am jeweiligen Parameter). +- Verständliche, dauerhaft sichtbare Fehlermeldung im Review/Processing: `steps/step5-review-processing/` (Ursache + Lösung, kein verschwindendes Popup). +- Analysegrundlage: `fehlerbehandlung-wizard-analyse.html`, `fehlerklassen-ableitbarkeit.html`. + +--- + +## A4 – Rückgängigmachen und Zurücksetzen anbieten +**Typ:** Konzept · **Miro:** Historie einführen · **Status:** TODO · **Branch:** `feat/a4-undo-historie` +**Befunde:** C-S1-02, C-S4-09, P-S3-01 + +**Problem:** Folgenschwere Aktionen (erneuter Upload, Smart-Defaults-Klick, gesetzte Filter) sind nicht umkehrbar. Voraussetzung ist eine Aktionshistorie — im Code bisher **nicht vorhanden** (kein `undo`/`history` gefunden). + +**Wo umsetzen:** +- Historie/Undo-Stack neu im State: `services/preprocessing.service.ts` (Snapshot der `preprocessing-state`). +- Undo nach „Upload Different File": `steps/step1-upload/`. +- Undo für Smart-Defaults-Reset: `steps/step4-visualization-settings/`. +- Filter leeren: `steps/step3-configure-data-features/`. + +--- + +## A5 – Zusammengehöriges räumlich gruppieren +**Typ:** Konzept · **Miro:** Parameter Location, Aufbereitung Rework · **Status:** TODO · **Branch:** `feat/a5-gruppierung` +**Befunde:** C-S3-01, C-S4-07, S-S4-03 + +**Problem:** Projektionsparameter liegen außerhalb ihres Methodenkastens (willkürlich platziert); Datenkonfiguration überladen, Spaltenzuordnung nicht erkennbar. *(Vom Nutzer als teils trivial markiert: Parameter in die zugehörige Parameterbox verschieben.)* + +**Wo umsetzen:** +- Parameter in den Kasten der jeweiligen Methode ziehen + Reset auf Defaultwerte: `steps/step4-visualization-settings/step4-visualization-settings.component.html` und `.ts` (Miro „Parameter Location"). +- Datenkonfiguration klarer strukturieren (Angabe↔Spalte erkennbar): `steps/step3-configure-data-features/`. + +--- + +## A6 – Navigation und Bearbeitung im Review-Schritt +**Typ:** Konzept · **Miro:** Direct Links · **Status:** TODO · **Branch:** `feat/a6-direct-links` +**Befunde:** C-S5-01, S-S5-01 + +**Problem:** Im Review lässt sich nichts direkt bearbeiten; Nutzer müssen sich merken, in welchem Schritt eine Einstellung lag. + +**Wo umsetzen:** +- Direktsprünge je Einstellungsblock in `steps/step5-review-processing/step5-review-processing.component.html`. +- Sprung-/Aktivierungslogik in `preprocessing-wizard.component.ts` bzw. `shared/progress-stepper/` (gezielt zu Schritt X wechseln). + +--- + +## A7 – Ehrliche und konsistente Signifier +**Typ:** trivial · **Miro:** Änderungen · **Status:** TODO · **Branch:** `feat/a7-signifier` +**Befunde:** C-S1-01, C-S4-02, C-S3-03, S-S2-01 + +**Problem:** Aktive Navigationsleiste wirkt deaktiviert; Auswahlfeld sieht aus wie Eingabefeld; farbliche Abweichungen unerklärt; „Select All" suggeriert falschen Zustand. + +**Wo umsetzen:** +- Navigationsleisten-Farbschema: `shared/progress-stepper/progress-stepper.component.scss`. +- Auswahlfeld-Optik (Select statt Eingabe): `steps/step4-visualization-settings/`. +- „Select All"-Zustand: `steps/step2-column-selection/`. + +--- + +## A8 – Systemstatus und Datenanzeige korrekt sichtbar machen +**Typ:** trivial · **Miro:** — · **Status:** TODO · **Branch:** `feat/a8-datenanzeige` +**Befunde:** C-S4-06, C-S2-03 + +**Problem:** Zeilenanzahl ohne Gesamtkontext; fehlende Werte als „-" statt „0". + +**Wo umsetzen:** +- „-"→„0" bei fehlenden Werten: `steps/step2-column-selection/` bzw. `shared/data-preview-table/` (Missing-Spalte). +- Zeilenanzahl im Gesamtkontext anzeigen: `steps/step4-visualization-settings/`. + +--- + +## A9 – Beschleuniger für Power-Nutzer +**Typ:** Konzept · **Miro:** Glyph Feature Search Filter · **Status:** TODO · **Branch:** `feat/a9-power-shortcuts` +**Befunde:** P-S2-02, P-S3-02, P-S3-03, P-S4-01 + +**Problem:** Fehlende Tastenkürzel (Shift-Klick, Strg+F), keine gemeinsame Bearbeitung gleichartiger Spalten, kein „alle auswählen" bei aktivem Filter, keine Drag-and-drop-Reihenfolge der Features. + +**Wo umsetzen:** +- Shift-Klick/Mehrfachauswahl + „alle gefilterten auswählen": `steps/step2-column-selection/`, `steps/step3-configure-data-features/`. +- Bulk-Umstellung gleichartiger Spalten: `steps/step3-configure-data-features/`. +- Drag-and-drop-Reihenfolge der Features: `steps/step4-visualization-settings/`. +- Such-/Filterfeld als Navigationshilfe (Miro „Glyph Feature Search Filter"). + +--- + +## A10 – Layout gegen lange Inhalte und viele Elemente absichern +**Typ:** Konzept · **Miro:** Glyph Feature Search Filter · **Status:** TODO · **Branch:** `feat/a10-layout-robust` +**Befunde:** P-S1-01, C-S4-11 + +**Problem:** Lange Namen brechen aus dem Layout; bei vielen Eigenschaften wird die Feature-Navigation unübersichtlich. + +**Wo umsetzen:** +- Umbruch/Kürzung mit Tooltip für langen Text: `shared/_wizard-shared.scss`, `steps/step1-upload/`. +- Platzsparende, durchsuchbare Feature-Liste: `steps/step4-visualization-settings/` (gemeinsam mit A9-Suchfeld). + +--- + +## A11 – Technische Defekte beheben +**Typ:** trivial · **Miro:** Visual Tooltips Methoden, NEU: Persistent Result Screen + Errorhandling · **Status:** TODO · **Branch:** `feat/a11-bugfixes` +**Befunde:** C-S3-04, C-S4-04, S-S4-02, P-S5-02, P-S5-03 + +**Problem/Wo je Defekt:** +- **C-S4-04** Tooltip verschwindet beim Inspizieren (Flaky) → `shared/help-tooltip/help-tooltip.component.ts` (Hover-/Z-Index-Verhalten). *Vom Nutzer als trivial markiert.* +- **S-S3-03** verwandt (Tooltip auf falscher Z-Ebene) → gleiche Komponente / `steps/step3-configure-data-features/`. +- **C-S3-04** Option „Moderate" doppelt → `steps/step3-configure-data-features/` (Optionsliste). +- **S-S4-02** Schritt-Navigation springt nicht zurück zu Schritt 4 → `shared/progress-stepper/` bzw. `preprocessing-wizard.component.ts`. +- **P-S5-02 / P-S5-03** Zusammenfassung verschwindet beim Schließen während der Projektion / nichtssagender Screen nach Wiedereinstieg → `steps/step5-review-processing/` (Miro „Persistent Result Screen + Errorhandling"). + +--- + +## A12 – Anzeigeraum effizient und zweispaltig nutzen +**Typ:** Konzept · **Miro:** Neue two column UI, Neuer Schritt 4 Anpassung · **Status:** TODO · **Branch:** `feat/a12-two-column` +**Befund:** K-01 + +**Problem:** Vertikaler Bildschirmraum schlecht genutzt; Navigation über volle Breite erzeugt langes Scrollen. + +**Wo umsetzen:** +- Grundlayout (Navigation links, Arbeitsbereich rechts): `preprocessing-wizard.component.html` + `.scss`. +- Schritt-Navigation an den linken Rand: `shared/progress-stepper/`. + +--- + +## A13 – Schrittlogik klären und neu ordnen +**Typ:** Konzept · **Miro:** Aufbereitung Rework, Neuer Schritt 4 Anpassung · **Status:** TODO · **Branch:** `feat/a13-schrittlogik` +**Befund:** K-02 + +**Problem:** Aufgabe der Schritte 2/3/4 und Bedeutung der jeweiligen Spaltenauswahl konzeptionell nicht getrennt. + +**Wo umsetzen:** +- Schrittbeschreibungen/mentales Modell: `shared/constants/step-info.ts`. +- Neuzuschnitt Schritt 4 (Spaltenauswahl neben Projektionsmethode): `steps/step4-visualization-settings/`. +- Abgrenzung Schritt 2 (betrachtete Spalten) vs. Schritt 3 (in Dimensionsreduktion): `steps/step2-column-selection/`, `steps/step3-configure-data-features/`. + +--- + +## A14 – Übergänge zwischen den Schritten sichtbar machen +**Typ:** Konzept · **Miro:** Animation · **Status:** TODO · **Branch:** `feat/a14-animation` +**Befund:** K-03 + +**Problem:** Zusammenhang der Tabellen von Schritt 2 und 3 nicht sichtbar; unklar, wie die Auswahl übernommen wird. + +**Wo umsetzen:** +- Morphing-/Übergangsanimation Schritt 2↔3 (beidseitig): Übergang in `preprocessing-wizard.component.ts` orchestrieren, gemeinsame Tabellenkomponente `shared/data-preview-table/` als Träger der Animation. + +--- + +## A15 – Einheitliches Tabellen- und Listen-Designsystem +**Typ:** Konzept · **Miro:** Tabellen/Listen · **Status:** TODO · **Branch:** `feat/a15-table-system` +**Befund:** K-04 + +**Problem:** Wiederkehrende Tabellen/Listen (Spaltenauswahl, Datenkonfiguration, Feature-Auswahl) sind funktional gleich, aber uneinheitlich gestaltet. + +**Wo umsetzen:** +- Gemeinsames Muster in `shared/data-preview-table/` konsolidieren (Auswahl links, Umschalter „alle", Suchfeld, keine Typ-Farbcodierung, einheitliche blaue Akzentfarbe). +- Anwendung in `steps/step2-column-selection/`, `steps/step3-configure-data-features/`, `steps/step4-visualization-settings/`. +- Gemeinsame Styles: `shared/_wizard-shared.scss`. + +--- + +*Ausblick (nicht im Umsetzungsrahmen): methodenspezifische Standardwerte je Projektionsmethode — laut Anforderungskatalog bewusst außerhalb des Rahmens.* From c1ee3c04266036f4803a8993acbf4d8c3f1cb2f7 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 16 Jul 2026 14:25:57 +0200 Subject: [PATCH 03/64] feat(a12): move the stepheader and description into the navbar --- .../preprocessing-wizard.component.ts | 18 +++++++----- .../progress-stepper.component.html | 7 ++++- .../progress-stepper.component.scss | 29 ++++++++++++++++++- .../progress-stepper.component.ts | 1 + .../step1-upload/step1-upload.component.html | 5 ---- .../step2-column-selection.component.html | 5 ---- ...ep3-configure-data-features.component.html | 5 ---- ...tep4-visualization-settings.component.html | 5 ---- .../step5-review-processing.component.html | 5 ---- 9 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts index b94e78f..d448bb7 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts @@ -3,6 +3,7 @@ import { Subscription, distinctUntilChanged, map } from 'rxjs'; import { PreprocessingService } from './services/preprocessing.service'; import { ProgressStepperComponent, Step } from './shared/progress-stepper/progress-stepper.component'; import { WIZARD_STEP } from './shared/wizard-step'; +import { STEP_INFO } from './shared/constants/step-info'; import { Step1UploadComponent } from './steps/step1-upload/step1-upload.component'; import { Step2ColumnSelectionComponent } from './steps/step2-column-selection/step2-column-selection.component'; import { Step3ConfigureDataFeaturesComponent } from './steps/step3-configure-data-features/step3-configure-data-features.component'; @@ -40,13 +41,16 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { isProcessing = false; error: string | null = null; - steps: Step[] = [ - { label: 'Upload Data', completed: false }, - { label: 'Select Columns', completed: false }, - { label: 'Configure Data & Features', completed: false }, - { label: 'Visualization Settings', completed: false }, - { label: 'Review & Process', completed: false }, - ]; + // Labels and descriptions come from STEP_INFO so the sidebar stays the single + // source of truth for step titles/purposes (no duplication in the work area). + steps: Step[] = Object.keys(STEP_INFO) + .map(Number) + .sort((a, b) => a - b) + .map(i => ({ + label: STEP_INFO[i].title, + description: STEP_INFO[i].purpose, + completed: false, + })); constructor(private preprocessingService: PreprocessingService) {} diff --git a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.html b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.html index a4b1bca..60540ae 100644 --- a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.html +++ b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.html @@ -19,7 +19,12 @@ } -
{{ step.label }}
+
+
{{ step.label }}
+ @if (step.description) { +
{{ step.description }}
+ } +
@if ($index < steps.length - 1) { diff --git a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss index d7155e0..baa504d 100644 --- a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss +++ b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss @@ -63,14 +63,31 @@ font-size: 0.9375rem; } + .step-text { + flex: 1; + min-width: 0; + gap: 0.125rem; + } + .step-label { font-size: 0.875rem; text-align: left; - flex: 1; white-space: normal; line-height: 1.3; } + // Description only shown for the active step + .step-description { + font-size: 0.75rem; + color: v.$gray-500; + line-height: 1.3; + white-space: normal; + } + + .step.active .step-description { + display: block; + } + // Compact connector aligned under the circle centre; no flex-grow so it // never stretches the column into a scroll .step-connector { @@ -172,6 +189,16 @@ } } +.step-text { + display: flex; + flex-direction: column; +} + +// Step description is a sidebar-only subtitle (vertical mode) +.step-description { + display: none; +} + .step-connector { flex: 1; height: 2px; diff --git a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.ts b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.ts index b3d7f7c..545f5e7 100644 --- a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.ts +++ b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.ts @@ -1,6 +1,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; export interface Step { label: string; + description?: string; icon?: string; completed: boolean; } diff --git a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html index 07bdc0d..17b6c5c 100644 --- a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html +++ b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html @@ -1,10 +1,5 @@
-
-

{{ stepInfo.title }}

-

{{ stepInfo.purpose }}

-
- @if (!profile) {
diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index fa283fd..4d3f4a2 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -1,10 +1,5 @@
-
-

{{ stepInfo.title }}

-

{{ stepInfo.purpose }}

-
-
info diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html index 98120fd..78cba54 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html @@ -1,10 +1,5 @@
-
-

{{ stepInfo.title }}

-

{{ stepInfo.purpose }}

-
- @if (error) {
error diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 16c52a6..f83d3b4 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -1,10 +1,5 @@
-
-

{{ stepInfo.title }}

-

{{ stepInfo.purpose }}

-
-
diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html index 93c3988..a139d1a 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html @@ -1,10 +1,5 @@
-
-

{{ stepInfo.title }}

-

{{ stepInfo.purpose }}

-
- From bf9794f49b8180b128dcd00544fe3c44a3d51fdd Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 14:04:36 +0200 Subject: [PATCH 04/64] feat(a12): align Header with Stepindicator --- .../progress-stepper/progress-stepper.component.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss index baa504d..7afdaf6 100644 --- a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss +++ b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss @@ -35,7 +35,7 @@ width: 100%; box-sizing: border-box; flex-direction: row; - align-items: center; + align-items: flex-start; gap: 0.75rem; padding: 0.625rem 0.75rem; border-radius: 0.5rem; @@ -69,7 +69,12 @@ gap: 0.125rem; } + // Label matches the circle height and centres its text, so the circle and + // the heading sit on the same line (description flows underneath) .step-label { + display: flex; + align-items: center; + min-height: 2.25rem; font-size: 0.875rem; text-align: left; white-space: normal; From deb3dcca6bd6768ebea0e044f56faa88d414b679 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 14:05:58 +0200 Subject: [PATCH 05/64] chore(a12): update ROADMAP --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b01105d..43db189 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket e | A9 | Beschleuniger für Power-Nutzer | Konzept | TODO | `feat/a9-power-shortcuts` | Step 2/3/4 | | A10 | Layout gegen lange/viele Inhalte | Konzept | TODO | `feat/a10-layout-robust` | Step 1+4, `shared/_wizard-shared.scss` | | A11 | Technische Defekte beheben | trivial | TODO | `feat/a11-bugfixes` | siehe Ticket | -| A12 | Zweispaltiges Layout | Konzept | TODO | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | +| A12 | Zweispaltiges Layout | Konzept | In Progress | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | | A13 | Schrittlogik klären/neu ordnen | Konzept | TODO | `feat/a13-schrittlogik` | Step 2/3/4, `shared/constants/step-info.ts` | | A14 | Übergänge sichtbar (Animation) | Konzept | TODO | `feat/a14-animation` | Step 2↔3, `shared/data-preview-table/` | | A15 | Einheitliches Tabellen/Listen-System | Konzept | TODO | `feat/a15-table-system` | `shared/data-preview-table/`, Step 2/3/4 | @@ -234,7 +234,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A12 – Anzeigeraum effizient und zweispaltig nutzen -**Typ:** Konzept · **Miro:** Neue two column UI, Neuer Schritt 4 Anpassung · **Status:** TODO · **Branch:** `feat/a12-two-column` +**Typ:** Konzept · **Miro:** Neue two column UI, Neuer Schritt 4 Anpassung · **Status:** In Progress · **Branch:** `feat/a12-two-column` **Befund:** K-01 **Problem:** Vertikaler Bildschirmraum schlecht genutzt; Navigation über volle Breite erzeugt langes Scrollen. From 46c840c35b4f7457992fb668eb9219dc7f203faa Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 14:48:13 +0200 Subject: [PATCH 06/64] feat(a5): Projektionsparameter in die jeweilige Methodenbox verschieben Parameter jeder Projektionsmethode erscheinen nun innerhalb der Methoden-Card statt in einem separaten Block darunter; pro Box ein Reset auf die Defaultwerte. ROADMAP-Hinweis ergaenzt: unklare Farbdefinition gehoert zu A1. Co-Authored-By: Claude Opus 4.8 --- ROADMAP.md | 4 + ...tep4-visualization-settings.component.html | 103 ++++++++++-------- ...tep4-visualization-settings.component.scss | 44 +++++++- .../step4-visualization-settings.component.ts | 70 ++++++++++-- 4 files changed, 166 insertions(+), 55 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 43db189..167813d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,6 +98,8 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen **Problem:** Fachbegriffe werden ohne Erklärung angeboten (Farbattribut, Projektionsmethoden, FastMap, Ausreißermethoden, Z-Wert-Standardisierung, Smart Defaults). +**Hinweis:** Aus A5 übernommen: Die unerklärten Farbabweichungen bzw. die „Farbauswahl" sind hier über einen erklärenden Text zu lösen, nicht über ein zusätzliches Farb-Steuerelement. + **Wo umsetzen:** - Erklärtexte zentral pflegen in `shared/constants/help-text.ts`. - Abrufbare Erklärung (Progressive Disclosure) über die bestehende `shared/help-tooltip/`-Komponente; für Projektionsmethoden auf visuelle Tooltips erweitern (Miro „Visual Tooltips Methoden", vgl. `tmp/Projektion_Methoden_Visualisierung.html`). @@ -155,6 +157,8 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen - Parameter in den Kasten der jeweiligen Methode ziehen + Reset auf Defaultwerte: `steps/step4-visualization-settings/step4-visualization-settings.component.html` und `.ts` (Miro „Parameter Location"). - Datenkonfiguration klarer strukturieren (Angabe↔Spalte erkennbar): `steps/step3-configure-data-features/`. +**Hinweis:** Der Befund zu unerklärten Farbabweichungen bzw. zur „Farbauswahl" passt inhaltlich nicht zu A5 und wird nach A1 verschoben; dort ist er über einen erklärenden Text statt über ein Farb-Steuerelement zu lösen. + --- ## A6 – Navigation und Bearbeitung im Review-Schritt diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index f83d3b4..7b3d432 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -262,6 +262,7 @@

class="projection-card" [class.enabled]="projectionConfig[method.key]" [class.has-warning]="shouldShowLargeDatasetWarning(method)" + [class.expanded]="methodHasParams(method) && projectionConfig[method.key] && isMethodParamsExpanded(method.key)" (click)="isMethodDisabled(method) ? null : toggleProjectionMethod(method)" (keydown.enter)="isMethodDisabled(method) ? null : toggleProjectionMethod(method)" tabindex="0" @@ -299,58 +300,68 @@

}

{{ method.description }}
-
- } -
- - @for (method of projectionMethods; track method.key) { - @if (projectionConfig[method.key] && isMethodParamsExpanded(method.key)) { -
- {{ method.name }} Parameters - @for (param of method.params; track param.configKey) { -
- -
- - -
-
- } - @if (method.key === 'enableTSNE' && shouldShowTSNEWarning()) { -
- warning -
-

- {{ getTSNETimeEstimate() }} -

-

t-SNE will run in the background. Explore with FastMap while waiting.

+ + @if (methodHasParams(method) && projectionConfig[method.key] && isMethodParamsExpanded(method.key)) { +
+
+ {{ method.name }} Parameters +
+ @for (param of method.params; track param.configKey) { +
+ +
+ + +
+
+ } + @if (method.key === 'enableTSNE' && shouldShowTSNEWarning()) { +
+ warning +
+

+ {{ getTSNETimeEstimate() }} +

+

t-SNE will run in the background. Explore with FastMap while waiting.

+
+
+ }
}
} - } +
diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 8971086..515bbe9 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -475,6 +475,12 @@ border-color: rgba(v.$status-warning, 0.3); } + // Give the parameter box full width when a method is expanded + &.expanded { + grid-column: 1 / -1; + cursor: default; + } + .card-row { display: flex; align-items: center; @@ -585,17 +591,51 @@ background: v.$wizard-header-bg; border: 1px solid v.$gray-200; border-radius: 8px; - margin-top: 4px; + margin-top: 6px; + cursor: default; display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; + .params-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + .params-label { font-size: 11px; font-weight: 600; color: v.$gray-600; - width: 100%; + } + + .btn-reset-params { + @include w.btn-base; + background: white; + border: 1px solid v.$gray-300; + color: v.$gray-600; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + gap: 3px; + flex-shrink: 0; + + .material-icons { + font-size: 13px; + } + + &:hover:not(:disabled) { + background: v.$gray-100; + border-color: v.$active-color; + color: v.$active-color; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } } .param-field { diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index 0a59163..c0e4f61 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -20,6 +20,8 @@ interface ProjectionParam { min: number; max: number; step?: number; + /** Default value used by the per-method "reset parameters" action. */ + default: number; } /** @@ -138,6 +140,7 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { min: 100, max: 2000, step: 50, + default: 500, }, ], }, @@ -159,7 +162,14 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { sizeHint: 'up to 5K rows', largeDatasetWarning: true, params: [ - { label: 'Neighbors (0 = auto)', helpKey: 'isomapNeighbors', configKey: 'isomapNeighbors', min: 0, max: 200 }, + { + label: 'Neighbors (0 = auto)', + helpKey: 'isomapNeighbors', + configKey: 'isomapNeighbors', + min: 0, + max: 200, + default: 0, + }, ], }, { @@ -170,7 +180,16 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { badge: 'Medium', sizeHint: 'up to 30K rows', largeDatasetWarning: true, - params: [{ label: 'Neighbors (0 = auto)', helpKey: 'lleNeighbors', configKey: 'lleNeighbors', min: 0, max: 200 }], + params: [ + { + label: 'Neighbors (0 = auto)', + helpKey: 'lleNeighbors', + configKey: 'lleNeighbors', + min: 0, + max: 200, + default: 0, + }, + ], }, { key: 'enableLTSA', @@ -181,7 +200,14 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { sizeHint: 'up to 20K rows', largeDatasetWarning: true, params: [ - { label: 'Neighbors (0 = auto)', helpKey: 'ltsaNeighbors', configKey: 'ltsaNeighbors', min: 0, max: 200 }, + { + label: 'Neighbors (0 = auto)', + helpKey: 'ltsaNeighbors', + configKey: 'ltsaNeighbors', + min: 0, + max: 200, + default: 0, + }, ], }, { @@ -202,8 +228,16 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { sizeHint: 'up to 100K rows', largeDatasetWarning: true, params: [ - { label: 'Number of Neighbors', helpKey: 'umapNeighbors', configKey: 'umapNeighbors', min: 2, max: 200 }, - { label: 'Minimum Distance', helpKey: 'umapMinDist', configKey: 'umapMinDist', min: 0, max: 0.99, step: 0.01 }, + { label: 'Number of Neighbors', helpKey: 'umapNeighbors', configKey: 'umapNeighbors', min: 2, max: 200, default: 15 }, + { + label: 'Minimum Distance', + helpKey: 'umapMinDist', + configKey: 'umapMinDist', + min: 0, + max: 0.99, + step: 0.01, + default: 0.1, + }, ], }, { @@ -224,8 +258,16 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { sizeHint: 'up to 15K rows', largeDatasetWarning: true, params: [ - { label: 'Perplexity', helpKey: 'tsnePerplexity', configKey: 'tsnePerplexity', min: 5, max: 50 }, - { label: 'Iterations', helpKey: 'tsneIterations', configKey: 'tsneIterations', min: 250, max: 5000, step: 250 }, + { label: 'Perplexity', helpKey: 'tsnePerplexity', configKey: 'tsnePerplexity', min: 5, max: 50, default: 30 }, + { + label: 'Iterations', + helpKey: 'tsneIterations', + configKey: 'tsneIterations', + min: 250, + max: 5000, + step: 250, + default: 1000, + }, ], }, ]; @@ -623,6 +665,20 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { return (method.params?.length ?? 0) > 0; } + /** Resets all parameters of a method back to their default values. */ + resetMethodParams(method: ProjectionMethodUI): void { + if (!method.params) return; + for (const param of method.params) { + this.onParamChange(param.configKey, param.default, param.min, param.max); + } + } + + /** True when at least one parameter of the method differs from its default. */ + methodParamsChanged(method: ProjectionMethodUI): boolean { + if (!method.params) return false; + return method.params.some(param => this.projectionConfig[param.configKey] !== param.default); + } + // ============================================================================ // Navigation // ============================================================================ From 6e86df9b415b0da44be183d66d3691f5b7946c2e Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 14:50:14 +0200 Subject: [PATCH 07/64] feat(a13): Spaltenauswahl fuer Projektion von Schritt 3 nach Schritt 4 Auswahl der in die Projektion einfliessenden Spalten aus Schritt 3 entfernt und in Schritt 4 als zweispaltige Section 'Projection Options' platziert (links Spalten, rechts Methode). Schrittbeschreibungen in step-info.ts geschaerft. Co-Authored-By: Claude Opus 4.8 --- .../shared/constants/step-info.ts | 8 +- ...ep3-configure-data-features.component.html | 25 ---- ...step3-configure-data-features.component.ts | 24 +--- ...tep4-visualization-settings.component.html | 46 ++++++- ...tep4-visualization-settings.component.scss | 124 ++++++++++++++++++ .../step4-visualization-settings.component.ts | 58 +++++++- 6 files changed, 231 insertions(+), 54 deletions(-) diff --git a/src/app/preprocessing-wizard/shared/constants/step-info.ts b/src/app/preprocessing-wizard/shared/constants/step-info.ts index 71bbda0..1ec5923 100644 --- a/src/app/preprocessing-wizard/shared/constants/step-info.ts +++ b/src/app/preprocessing-wizard/shared/constants/step-info.ts @@ -15,16 +15,18 @@ export const STEP_INFO: Record = { }, 1: { title: 'Select Columns', - purpose: 'Choose which columns to include in your visualization. Deselect ID fields and irrelevant columns.', + purpose: + 'Choose which columns to keep under consideration for the analysis. Deselect ID fields and irrelevant columns.', }, 2: { title: 'Configure Data & Features', purpose: - 'Configure encoding, scaling, and data cleaning settings for each column. Select a column to view and edit its options.', + 'Configure data types, encoding, scaling, outliers, and cleaning for each column. Select a column to view and edit its options.', }, 3: { title: 'Visualization Settings', - purpose: 'Configure glyph features with live preview, set color mapping, and select projection methods.', + purpose: + 'Choose which columns feed the projection and pick the projection methods, then configure glyph features and color mapping.', }, 4: { title: 'Review & Process', diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html index 78cba54..4acf2a3 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html @@ -58,7 +58,6 @@
@@ -87,19 +86,6 @@ }
-
- -
} @@ -325,17 +311,6 @@

{{ selectedColumn.column.name }}

} - -
- -
} @else {
touch_app diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts index afcd6d2..9d4917d 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts @@ -36,7 +36,7 @@ interface ColumnConfigState { }) export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { readonly primaryLabel = 'Continue to Visualization Settings'; - readonly disabledHint = 'Please include at least one column in projection to continue.'; + readonly disabledHint = ''; columns: ColumnConfigState[] = []; filteredColumns: ColumnConfigState[] = []; @@ -308,19 +308,6 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { }); } - toggleProjection(columnName: string): void { - const colState = this.columns.find(c => c.column.name === columnName); - if (colState) { - const newValue = !colState.config.includeInProjection; - // Update service - this.preprocessingService.updateColumnConfig(columnName, { - includeInProjection: newValue, - }); - // Update local state to trigger template re-render - colState.config.includeInProjection = newValue; - } - } - toggleRemoveDuplicates(): void { this.cleaningConfig.removeDuplicates = !this.cleaningConfig.removeDuplicates; this.preprocessingService.updateCleaningConfig({ @@ -415,13 +402,10 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { return state.dataProfile.columns.map(c => c.name); } - getProjectionCount(): number { - return this.columns.filter(c => c.config.includeInProjection).length; - } - canProceed(): boolean { - // At least one column must be included in projection - return this.getProjectionCount() > 0; + // Data configuration always has valid defaults; column selection for the + // projection now happens in Step 4 (Visualization Settings). + return true; } proceed(): void { diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index f83d3b4..f2f6d51 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -241,8 +241,49 @@

- -
+ +
+ +
+
+

+ view_column + Projection Columns + +

+ {{ getProjectionCount() }}/{{ projectionColumns.length }} +
+

Choose which columns feed the dimensionality reduction.

+ +
+ + +
+ +
+ @for (entry of projectionColumns; track entry.column.name) { + + } +
+ + @if (getProjectionCount() === 0) { +
+ warning + Select at least one column for the projection +
+ } +
+ + +

settings @@ -351,6 +392,7 @@

} } +
diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 8971086..36ebf5e 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -648,6 +648,130 @@ } } + // ============================================================================ + // Projection Options (two-column: columns + method) + // ============================================================================ + + .projection-options-two-col { + display: grid; + grid-template-columns: 2fr 3fr; + gap: 16px; + align-items: start; + + // Panels inside the grid should not add their own bottom margin + .config-panel { + margin-bottom: 0; + } + } + + .projection-columns-panel { + display: flex; + flex-direction: column; + max-height: 420px; + + .projection-columns-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + h3 { + margin-bottom: 0; + } + + .projection-count { + font-size: 11px; + font-weight: 600; + color: v.$active-color; + background: rgba(v.$active-color, 0.08); + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; + } + } + + .projection-columns-actions { + display: flex; + gap: 12px; + margin-bottom: 6px; + + .btn-link { + background: none; + border: none; + padding: 0; + font-size: 11px; + font-weight: 600; + color: v.$active-color; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + + .projection-columns-list { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1; + min-height: 0; + } + + .projection-column-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border: 1px solid v.$gray-200; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + transition: all 0.15s ease; + + &:hover { + border-color: v.$gray-400; + } + + &.selected { + background: rgba(v.$active-color, 0.04); + border-color: rgba(v.$active-color, 0.3); + } + + input[type='checkbox'] { + @include w.checkbox; + width: 14px; + height: 14px; + flex-shrink: 0; + } + + .col-name { + flex: 1; + font-weight: 500; + color: v.$gray-800; + @include m.text-truncate; + } + } + + .validation-warning { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: rgba(v.$status-warning, 0.1); + border-left: 3px solid v.$status-warning; + border-radius: 8px; + font-size: 12px; + color: v.$status-warning-dark; + margin-top: 8px; + + .material-icons { + font-size: 14px; + } + } + } + // ============================================================================ // Action Buttons // ============================================================================ diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index 0a59163..9193754 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -3,15 +3,22 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { PreprocessingService } from '../../services/preprocessing.service'; import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; -import { ProjectionConfig } from '../../models/column-config'; +import { ColumnConfig, ProjectionConfig } from '../../models/column-config'; import { ColumnStatistics } from '../../models/column-statistics'; import { DataType } from '../../models/data-type.enum'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; +import { DataTypeBadgeComponent } from '../../shared/data-type-badge/data-type-badge.component'; import { COLOR_SCALES, ColorScale, buildGroupedColorScales } from '../../../shared/interfaces/color-scale'; import { ColorScaleSelectorComponent } from '../../../shared/components/color-scale-selector/color-scale-selector.component'; +/** A dataset column paired with its live configuration, used for projection column selection. */ +interface ProjectionColumnState { + column: ColumnStatistics; + config: ColumnConfig; +} + /** Describes a tunable parameter for a projection method. */ interface ProjectionParam { label: string; @@ -54,14 +61,17 @@ interface ProjectionMethodUI { @Component({ selector: 'app-step4-visualization-settings', standalone: true, - imports: [CommonModule, FormsModule, HelpTooltipComponent, ColorScaleSelectorComponent], + imports: [CommonModule, FormsModule, HelpTooltipComponent, DataTypeBadgeComponent, ColorScaleSelectorComponent], templateUrl: './step4-visualization-settings.component.html', styleUrl: './step4-visualization-settings.component.scss', providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step4VisualizationSettingsComponent) }], }) export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { readonly primaryLabel = 'Continue to Review'; - readonly disabledHint = 'Please select 3-12 glyph features to continue.'; + readonly disabledHint = 'Select at least one projection column and 3-12 glyph features to continue.'; + + // Projection column selection (which features feed the dimensionality reduction) + projectionColumns: ProjectionColumnState[] = []; // Color feature selection columns: ColumnStatistics[] = []; @@ -244,6 +254,14 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { const config = state.columnConfigs.get(col.name); return config && config.enabled; }); + + // Build projection column selection from the enabled columns + this.projectionColumns = this.columns + .map(col => { + const config = state.columnConfigs.get(col.name); + return config ? { column: col, config } : null; + }) + .filter((entry): entry is ProjectionColumnState => entry !== null); } // Load color feature and scale @@ -441,6 +459,37 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { this.onDragEnd(event); } + // ============================================================================ + // Projection Column Selection + // ============================================================================ + + toggleColumnProjection(columnName: string): void { + const entry = this.projectionColumns.find(c => c.column.name === columnName); + if (entry) { + const newValue = !entry.config.includeInProjection; + this.preprocessingService.updateColumnConfig(columnName, { includeInProjection: newValue }); + // Update local reference to trigger template re-render + entry.config.includeInProjection = newValue; + } + } + + isColumnInProjection(columnName: string): boolean { + return this.projectionColumns.find(c => c.column.name === columnName)?.config.includeInProjection ?? false; + } + + getProjectionCount(): number { + return this.projectionColumns.filter(c => c.config.includeInProjection).length; + } + + setAllProjectionColumns(included: boolean): void { + for (const entry of this.projectionColumns) { + if (entry.config.includeInProjection !== included) { + this.preprocessingService.updateColumnConfig(entry.column.name, { includeInProjection: included }); + entry.config.includeInProjection = included; + } + } + } + // ============================================================================ // Projection Configuration // ============================================================================ @@ -632,7 +681,8 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { this.selectedGlyphFeatures.length >= this.MIN_GLYPH_FEATURES && this.selectedGlyphFeatures.length <= this.MAX_GLYPH_FEATURES; const projectionValid = this.hasEnabledMethod(); - return glyphValid && projectionValid; + const projectionColumnsValid = this.getProjectionCount() > 0; + return glyphValid && projectionValid && projectionColumnsValid; } proceed(): void { From 23c4c8724c267c30d3415457ebf62d0458f1efb5 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 14:59:10 +0200 Subject: [PATCH 08/64] feat(a13): Projection-Columns-Panel an Design-Vorlage angleichen Akzentfarben aus _variables.scss ($active-color / $status-info) statt hart codiert; Tri-State-Master-Checkbox im Listenkopf statt Select-all/Clear-Links; ausgeschlossene Spalten ausgegraut + durchgestrichen. Co-Authored-By: Claude Opus 4.8 --- ...tep4-visualization-settings.component.html | 21 +++++++-- ...tep4-visualization-settings.component.scss | 46 +++++++++++++------ .../step4-visualization-settings.component.ts | 16 +++++++ 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index f2f6d51..bdc1bd6 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -251,18 +251,29 @@

Projection Columns

- {{ getProjectionCount() }}/{{ projectionColumns.length }} + {{ getProjectionCount() }} / {{ projectionColumns.length }} included

Choose which columns feed the dimensionality reduction.

-
- - +
+ + All columns
@for (entry of projectionColumns; track entry.column.name) { -
- - -
- {{ enabledCount }} selected - {{ disabledCount }} excluded -
+ {{ enabledCount }} of {{ columns.length }} selected
diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss index 87f0829..2eb7e8d 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss @@ -47,26 +47,8 @@ font-size: 13px; } -.selection-summary { - display: flex; - gap: 8px; -} - -.count-badge { - padding: 4px 10px; - border-radius: 12px; - font-size: 12px; - font-weight: 600; - - &.enabled { - background: rgba(v.$active-color, 0.1); - color: v.$active-color; - } - - &.disabled { - background: v.$gray-200; - color: v.$gray-600; - } +.selection-count { + @include wizard.selection-count-text; } // Table Layout @@ -88,8 +70,11 @@ } &.col-checkbox { - width: 44px; - padding-left: 12px; + @include wizard.selection-cell; + + input[type='checkbox'] { + @include wizard.selection-checkbox; + } } &.col-name { @@ -134,12 +119,10 @@ td { &.col-checkbox { - padding-left: 12px; + @include wizard.selection-cell; input[type='checkbox'] { - width: 16px; - height: 16px; - cursor: pointer; + @include wizard.selection-checkbox; } } diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html index 78cba54..7d7dc4e 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html @@ -62,6 +62,20 @@
+ +
+ + + + Include all in projection +
+
@for (colState of filteredColumns; track colState.column.name) {
+ + +
{{ colState.column.name }} @@ -87,19 +114,6 @@ }
-
- -
} @@ -389,24 +403,11 @@

@if (showDuplicateSamples) {
- - - - @for (colName of columnNames; track colName) { - - } - - - - @for (row of sampleDuplicates; track $index) { - - @for (colName of columnNames; track colName) { - - } - - } - -
{{ colName }}
{{ row[colName] || '' }}
+
} } diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss index ffeac3f..34de20c 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss @@ -200,14 +200,32 @@ } } - .item-projection { + // Unified left-hand selection cell — shared by the select-all header bar and + // every list item so the header checkbox aligns vertically with row checkboxes. + .select-cell { + display: inline-flex; + align-items: center; + justify-content: flex-start; + width: 24px; flex-shrink: 0; input[type='checkbox'] { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: v.$active-color; + @include w.selection-checkbox; + } + } + + .list-select-all { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid v.$gray-300; + background: white; + + .select-all-label { + font-size: 12px; + font-weight: 600; + color: v.$gray-700; } } @@ -397,7 +415,7 @@ color: v.$gray-800; input[type='checkbox'] { - @include w.checkbox; + @include w.selection-checkbox; } } } @@ -525,16 +543,8 @@ .duplicate-samples { margin-top: 8px; - border: 1px solid v.$gray-300; - border-radius: 4px; - overflow-x: auto; overflow-y: auto; max-height: 250px; - - .sample-table { - @include w.table; - font-size: 12px; - } } } } diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts index afcd6d2..1dc4294 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts @@ -17,6 +17,7 @@ import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.com import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; import { DataTypeBadgeComponent } from '../../shared/data-type-badge/data-type-badge.component'; +import { DataPreviewTableComponent } from '../../shared/data-preview-table/data-preview-table.component'; interface ColumnConfigState { column: ColumnStatistics; @@ -29,7 +30,7 @@ interface ColumnConfigState { @Component({ selector: 'app-step3-configure-data-features', standalone: true, - imports: [FormsModule, HelpTooltipComponent, DataTypeBadgeComponent], + imports: [FormsModule, HelpTooltipComponent, DataTypeBadgeComponent, DataPreviewTableComponent], templateUrl: './step3-configure-data-features.component.html', styleUrl: './step3-configure-data-features.component.scss', providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step3ConfigureDataFeaturesComponent) }], @@ -321,6 +322,28 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { } } + // Select-all state for the unified list header toggle. Operates on the + // currently listed (filtered) columns so the toggle matches what the user sees. + get allFilteredInProjection(): boolean { + return this.filteredColumns.length > 0 && this.filteredColumns.every(c => c.config.includeInProjection); + } + + get someFilteredInProjection(): boolean { + return this.filteredColumns.some(c => c.config.includeInProjection); + } + + toggleAllProjection(): void { + const target = !this.allFilteredInProjection; + for (const colState of this.filteredColumns) { + if (colState.config.includeInProjection !== target) { + this.preprocessingService.updateColumnConfig(colState.column.name, { + includeInProjection: target, + }); + colState.config.includeInProjection = target; + } + } + } + toggleRemoveDuplicates(): void { this.cleaningConfig.removeDuplicates = !this.cleaningConfig.removeDuplicates; this.preprocessingService.updateCleaningConfig({ diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 8971086..dea0af1 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -484,15 +484,12 @@ .card-desc { font-size: 10px; color: v.$gray-500; - padding-left: 19px; + padding-left: 20px; line-height: 1.3; } input[type='checkbox'] { - @include w.checkbox; - width: 14px; - height: 14px; - flex-shrink: 0; + @include w.selection-checkbox; } .method-name { From 3e6b2a75cb10d05f4fc3da9402df88cc5351888e Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 15:30:09 +0200 Subject: [PATCH 10/64] feat(a5): Parameterbox in-place aufklappen statt volle Zeile Expanded-Card bleibt in ihrer Grid-Zelle (Breite konstant); Hoehe animiert per CSS grid-template-rows 0fr->1fr; Parameter fuellen die Spaltenbreite. Co-Authored-By: Claude Opus 4.8 --- ...tep4-visualization-settings.component.html | 10 ++++--- ...tep4-visualization-settings.component.scss | 27 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 7b3d432..bcf63b6 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -301,10 +301,11 @@

{{ method.description }}
- - @if (methodHasParams(method) && projectionConfig[method.key] && isMethodParamsExpanded(method.key)) { -
-
+ + @if (methodHasParams(method) && projectionConfig[method.key]) { +
+
+
{{ method.name }} Parameters
} +
}
diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 515bbe9..7e3dda6 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -475,9 +475,8 @@ border-color: rgba(v.$status-warning, 0.3); } - // Give the parameter box full width when a method is expanded + // Keep the card in its own cell; only its height grows when expanded &.expanded { - grid-column: 1 / -1; cursor: default; } @@ -586,12 +585,29 @@ border: 1px solid v.$gray-300; } + // Animated height wrapper: collapses to 0fr, expands to content height in place + .params-wrapper { + display: grid; + grid-template-rows: 0fr; + margin-top: 0; + transition: + grid-template-rows 0.25s ease, + margin-top 0.25s ease; + + &.open { + grid-template-rows: 1fr; + margin-top: 6px; + } + } + .inline-params { + // Clipped by the 0fr wrapper while collapsed + min-height: 0; + overflow: hidden; padding: 6px 10px; background: v.$wizard-header-bg; border: 1px solid v.$gray-200; border-radius: 8px; - margin-top: 6px; cursor: default; display: flex; flex-wrap: wrap; @@ -639,8 +655,9 @@ } .param-field { - flex: 1; - min-width: 180px; + // Fit the (narrower) card column: each parameter takes the full width + flex: 1 1 100%; + min-width: 0; label { display: flex; From ab61d58ca990c09249409ce400fe48f8bf2d0a28 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 15:35:14 +0200 Subject: [PATCH 11/64] feat(a15): Schritt-3-Redesign (Option 1d) + Review-Feedback Typ als farbloses Label (data-type-badge entfaerbt); Distribution durchgehend Cyan; funktionale Suche + Typ-Filter in Schritt 3. Schritt 3 als Master-Rail + eingelassenes Imprint-Well mit Breadcrumb, Warning-Counts und Settings-Card. Keine Projektions-Auswahl mehr in Schritt 3 (liegt in Schritt 4 / A13). Co-Authored-By: Claude Opus 4.8 --- .../shared/_wizard-shared.scss | 52 +- .../step2-column-selection.component.html | 4 +- .../step2-column-selection.component.ts | 6 + ...ep3-configure-data-features.component.html | 467 +++++++++--------- ...ep3-configure-data-features.component.scss | 323 ++++++------ ...step3-configure-data-features.component.ts | 34 +- 6 files changed, 417 insertions(+), 469 deletions(-) diff --git a/src/app/preprocessing-wizard/shared/_wizard-shared.scss b/src/app/preprocessing-wizard/shared/_wizard-shared.scss index 732cf33..05909df 100644 --- a/src/app/preprocessing-wizard/shared/_wizard-shared.scss +++ b/src/app/preprocessing-wizard/shared/_wizard-shared.scss @@ -133,50 +133,18 @@ } } -// Data type badges - using centralized colors (supports both class and attribute selectors) +// Data type label — colorless (A15 feedback): the data type is shown as a plain +// uppercase grey label, not a colored badge, so tables/lists stay low-color and +// only the cyan accent + informational missing/warning badges carry color. @mixin data-type-badge { - @include badge; flex-shrink: 0; - - &.badge-numeric { - background: v.$data-type-numeric-bg; - color: v.$data-type-numeric; - } - - &.badge-categorical { - background: v.$data-type-categorical-bg; - color: v.$data-type-categorical; - } - - &.badge-text { - background: v.$data-type-text-bg; - color: v.$data-type-text; - } - - &.badge-date { - background: v.$data-type-date-bg; - color: v.$data-type-date; - } - - &.badge-boolean { - background: v.$data-type-boolean-bg; - color: v.$data-type-boolean; - } - - &.badge-id { - background: v.$data-type-id-bg; - color: v.$data-type-id; - } - - &.badge-coordinate { - background: v.$data-type-coordinate-bg; - color: v.$data-type-coordinate; - } - - &.badge-unknown { - background: v.$data-type-unknown-bg; - color: v.$data-type-unknown; - } + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: v.$gray-500; + background: none; + padding: 0; } // Messages diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index a4db310..c8ed41c 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -144,8 +144,8 @@ (); + // Distribution bars use the single cyan accent (A15) instead of per-type colors. + // These mirror $active-color (#00bcd4) / $status-info (#00838f); the mini-histogram + // renders to a d3 canvas so the color must be passed as a string here. + readonly distributionColor = '#00bcd4'; + readonly distributionHoverColor = '#00838f'; + // Expose help text and step info to template readonly HELP_TEXT = HELP_TEXT; readonly stepInfo = STEP_INFO[1]; // Step 2 (index 1) diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html index 7d7dc4e..c349a15 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html @@ -20,18 +20,20 @@ } - -
- -
-
+ +
+ +
+
+ DATASET COLUMNS + -
+ +
-
- -
- -
- - - - Include all in projection +
{{ filteredColumns.length }} of {{ columns.length }} columns
@@ -81,39 +68,32 @@
- - - -
-
- {{ colState.column.name }} - +
+
+ {{ colState.column.name }} + {{ getTypeLabel(colState.column.dataType) }}
-
+
{{ getConfigSummary(colState) }} - @if (colState.column.missingCount > 0) { - {{ colState.column.missingCount }} missing - } - @if ((colState.outlierCount || 0) > 0) { - {{ colState.outlierCount }} outliers - }
+ + @if (colState.column.missingCount > 0) { + + warning + {{ colState.column.missingCount }} + + }
} @@ -127,228 +107,223 @@
- -
+ +
@if (selectedColumn) { -
-

{{ selectedColumn.column.name }}

- + - -
-
- Rows - {{ selectedColumn.column.count }} -
-
- Unique - {{ selectedColumn.column.uniqueCount }} -
- @if (selectedColumn.column.missingCount > 0) { -
- Missing - {{ selectedColumn.column.missingCount }} ({{ - selectedColumn.column.missingPercentage.toFixed(1) - }}%) -
- } - @if ( - selectedColumn.column.dataType === DataType.Numeric || - selectedColumn.column.dataType === DataType.Coordinate - ) { -
- Range - {{ selectedColumn.column.min?.toFixed(2) }} — {{ selectedColumn.column.max?.toFixed(2) }} -
-
- Mean - {{ selectedColumn.column.mean?.toFixed(2) }} -
-
- Std - {{ selectedColumn.column.stdDev?.toFixed(2) }} +
+
+ CONFIGURING SELECTED COLUMN +
+

{{ selectedColumn.column.name }}

+ {{ getTypeLabel(selectedColumn.column.dataType) }}
- } - @if ( - selectedColumn.column.dataType === DataType.Date && - selectedColumn.column.min != null && - selectedColumn.column.max != null - ) { -
- Range - {{ formatDate(selectedColumn.column.min!) }} — {{ formatDate(selectedColumn.column.max!) }} -
- } - @if ( - selectedColumn.column.dataType === DataType.Categorical && - selectedColumn.column.topValues && - selectedColumn.column.topValues.length > 0 - ) { -
- Top - {{ selectedColumn.column.topValues![0].value }} ({{ - selectedColumn.column.topValues![0].count - }}) -
- } - @if (selectedColumn.column.dataType === DataType.Boolean && selectedColumn.column.topValues) { -
- Distribution - - @for (tv of selectedColumn.column.topValues!; track tv.value) { - {{ tv.value }}: {{ tv.count }}{{ $last ? '' : ', ' }} - } - -
- } - @if (selectedColumn.column.dataType === DataType.Text) { -
- Avg length - {{ selectedColumn.column.mean?.toFixed(0) || '—' }} -
- } -
- - - @if (shouldShowEncoding(selectedColumn)) { -
-
- - -
-
- } - - @if (shouldShowScaling(selectedColumn)) { -
-
- - + +
+
+ Rows + {{ selectedColumn.column.count }}
- -
- } - - - @if (hasMissingValues(selectedColumn)) { -
-
- - {{ selectedColumn.column.missingCount }} ({{ - selectedColumn.column.missingPercentage.toFixed(1) - }}%) - +
+ Unique + {{ selectedColumn.column.uniqueCount }}
- - @if (selectedColumn.config.missingValueStrategy === MissingValueStrategy.FillValue) { - + @if (selectedColumn.column.missingCount > 0) { +
+ warning + Missing + {{ selectedColumn.column.missingCount }} ({{ + selectedColumn.column.missingPercentage.toFixed(1) + }}%) +
+ } + @if ( + selectedColumn.column.dataType === DataType.Numeric || + selectedColumn.column.dataType === DataType.Coordinate + ) { +
+ Range + {{ selectedColumn.column.min?.toFixed(2) }} — {{ selectedColumn.column.max?.toFixed(2) }} +
+
+ Mean + {{ selectedColumn.column.mean?.toFixed(2) }} +
+
+ Std + {{ selectedColumn.column.stdDev?.toFixed(2) }} +
+ } + @if ( + selectedColumn.column.dataType === DataType.Date && + selectedColumn.column.min != null && + selectedColumn.column.max != null + ) { +
+ Range + {{ formatDate(selectedColumn.column.min!) }} — {{ formatDate(selectedColumn.column.max!) }} +
+ } + @if ( + selectedColumn.column.dataType === DataType.Categorical && + selectedColumn.column.topValues && + selectedColumn.column.topValues.length > 0 + ) { +
+ Top + {{ selectedColumn.column.topValues![0].value }} ({{ + selectedColumn.column.topValues![0].count + }}) +
+ } + @if (selectedColumn.column.dataType === DataType.Text) { +
+ Avg length + {{ selectedColumn.column.mean?.toFixed(0) || '—' }} +
}
- } - - @if (shouldShowOutliers(selectedColumn)) { -
-
- - @if (selectedColumn.isLoadingOutliers) { - - sync - - } @else if (hasOutliers(selectedColumn)) { - {{ selectedColumn.outlierCount }} detected - } - -
-
-
- + +
+ SETTINGS + + + @if (shouldShowEncoding(selectedColumn)) { +
+
+ + +
+ +
+ } + + + @if (shouldShowScaling(selectedColumn)) { +
+
+ + +
-
- + } + + + @if (hasMissingValues(selectedColumn)) { +
+
+ + {{ selectedColumn.column.missingCount }} ({{ + selectedColumn.column.missingPercentage.toFixed(1) + }}%) + +
+ @if (selectedColumn.config.missingValueStrategy === MissingValueStrategy.FillValue) { + + }
-
-
- } + } - -
- + + @if (shouldShowOutliers(selectedColumn)) { +
+
+ + @if (selectedColumn.isLoadingOutliers) { + + sync + + } @else if (hasOutliers(selectedColumn)) { + {{ selectedColumn.outlierCount }} detected + } + +
+
+
+ + +
+
+ + +
+
+
+ } +
} @else {
diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss index 34de20c..b7a76b2 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss @@ -44,53 +44,62 @@ } // ============================================================================ - // List + Detail Layout + // Option 1d layout: master rail (left) + recessed "imprint" well (right) // ============================================================================ - .config-layout { + .config-shell { display: flex; - gap: 16px; + border: 1px solid v.$gray-300; + border-radius: 4px; + overflow: hidden; margin-bottom: 16px; + background: white; } - // ── Left Panel: Column List ────────────────────────────────────────────────── - - .column-list-panel { + // ── Left rail ──────────────────────────────────────────────────────────── + .master-rail { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; - border: 1px solid v.$gray-300; - border-radius: 4px; background: white; + border-right: 1px solid v.$gray-300; } - .list-header { - padding: 10px 12px; - border-bottom: 1px solid v.$gray-300; - background: v.$wizard-header-bg; + .rail-header { display: flex; flex-direction: column; gap: 8px; + padding: 10px 12px; + background: v.$wizard-header-bg; + border-bottom: 1px solid v.$gray-200; + } + + .rail-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.7px; + color: v.$gray-500; + text-transform: uppercase; } .search-box { @include w.search-box; } - .list-filters { + .rail-filters { display: flex; align-items: center; gap: 8px; .filter-select { @include w.select; + flex: 1; font-size: 12px; padding: 4px 24px 4px 8px; - flex: 1; } - .checkbox-label { + .issues-check { display: flex; align-items: center; gap: 4px; @@ -108,28 +117,21 @@ } } - .list-footer { - display: flex; - justify-content: space-between; - align-items: center; + .rail-count { font-size: 11px; color: v.$gray-500; - - .projection-count { - color: v.$active-color; - font-weight: 600; - } } .column-list { - flex: none; + flex: 1; + overflow-y: auto; } .column-item { display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; + align-items: flex-start; + gap: 10px; + padding: 10px 14px 10px 12px; cursor: pointer; border-bottom: 1px solid v.$gray-200; border-left: 3px solid transparent; @@ -140,97 +142,69 @@ } &.selected { - background: rgba(v.$active-color, 0.06); + background: rgba(v.$active-color, 0.08); border-left-color: v.$active-color; - } - &.has-issues { .column-name { - color: v.$status-warning-dark; + color: v.$status-info; + font-weight: 700; } } } - .item-content { + .item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: 3px; } - .item-top { + .item-line1 { display: flex; align-items: center; gap: 8px; .column-name { - @include w.column-name-text; font-size: 13px; + font-weight: 600; + color: v.$gray-800; @include m.text-truncate; } - } - .item-bottom { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; + .type-label { + @include w.data-type-badge; + } + } + .item-line2 { .config-summary { font-size: 11px; color: v.$gray-500; } - - .issue-badge { - font-size: 10px; - font-weight: 600; - padding: 1px 6px; - border-radius: 8px; - - &.missing { - background: rgba(v.$status-warning, 0.1); - color: v.$status-warning-dark; - } - - &.outliers { - background: rgba(v.$status-error, 0.1); - color: v.$status-error-dark; - } - } } - // Unified left-hand selection cell — shared by the select-all header bar and - // every list item so the header checkbox aligns vertically with row checkboxes. - .select-cell { + // Missing-value warning badge (informational — stays amber, red at >= 20%) + .missing-badge { display: inline-flex; align-items: center; - justify-content: flex-start; - width: 24px; + gap: 3px; flex-shrink: 0; + padding: 2px 6px; + border-radius: 8px; + font-size: 11px; + font-weight: 700; + background: #fff3e0; + color: #c85f00; - input[type='checkbox'] { - @include w.selection-checkbox; - } - } - - .list-select-all { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid v.$gray-300; - background: white; - - .select-all-label { + .material-icons { font-size: 12px; - font-weight: 600; - color: v.$gray-700; } - } - .data-type-badge { - @include w.data-type-badge; + &.high { + background: #fbe4e4; + color: #c0341d; + } } .no-columns { @@ -243,85 +217,154 @@ } } - // ── Right Panel: Detail Configuration ──────────────────────────────────────── - - .detail-panel { + // ── Right recessed well ──────────────────────────────────────────────────── + .detail-well { flex: 1; - border: 1px solid v.$gray-300; - border-radius: 4px; - background: white; + min-width: 0; display: flex; flex-direction: column; + gap: 12px; + padding: 18px; + background: #eceff2; // recessed "imprint" tone (no matching SCSS var) } - .detail-header { + .breadcrumb { display: flex; align-items: center; - gap: 10px; - padding: 12px 16px; - border-bottom: 1px solid v.$gray-300; - background: v.$wizard-header-bg; + gap: 4px; + font-size: 11px; + color: v.$gray-500; - h3 { - font-size: 15px; - font-weight: 600; - color: v.$gray-800; - margin: 0; + .material-icons { + font-size: 14px; + } + + .crumb-current { + color: v.$status-info; + font-weight: 700; + @include m.text-truncate; } } - .stats-bar { + .detail-card { + flex: 1; + display: flex; + flex-direction: column; + background: white; + border: 1px solid v.$gray-300; + border-radius: 6px; + overflow: hidden; + } + + .card-header { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 18px; + border-bottom: 1px solid v.$gray-200; + + .card-eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.8px; + color: v.$status-info; + } + + .card-title-row { + display: flex; + align-items: center; + gap: 10px; + + h3 { + font-size: 19px; + font-weight: 700; + color: v.$gray-800; + margin: 0; + @include m.text-truncate; + } + + .type-pill { + flex-shrink: 0; + padding: 2px 6px; + border: 1px solid v.$gray-200; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: v.$gray-500; + } + } + } + + .stat-chips { display: flex; flex-wrap: wrap; gap: 6px; - padding: 10px 16px; + padding: 12px 18px; border-bottom: 1px solid v.$gray-200; - background: v.$wizard-header-bg; - .stat { + .chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; - background: white; + background: v.$wizard-header-bg; border: 1px solid v.$gray-200; border-radius: 4px; font-size: 12px; line-height: 1; - .stat-label { + .chip-label { color: v.$gray-500; - font-weight: 400; } - .stat-value { + .chip-value { font-weight: 600; color: v.$gray-800; font-variant-numeric: tabular-nums; + } + + &.chip-warning { + background: #fffaf2; + border-color: #ffdca8; - &.warn { + .material-icons { + font-size: 14px; color: v.$status-warning-dark; } - } - &.top-values .stat-value { - font-weight: 500; - max-width: 100px; - @include m.text-truncate; + .chip-value { + color: v.$status-warning-dark; + } } + } + } - &.stat-date .stat-value { - font-weight: 500; - font-size: 11px; - } + .settings-section { + display: flex; + flex-direction: column; + padding: 16px 18px; + + .settings-label { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + color: v.$gray-500; + text-transform: uppercase; + margin-bottom: 10px; } } .config-section { - padding: 10px 16px; + padding-bottom: 12px; + margin-bottom: 12px; border-bottom: 1px solid v.$gray-200; &:last-child { + padding-bottom: 0; + margin-bottom: 0; border-bottom: none; } } @@ -364,7 +407,7 @@ .config-select { @include w.select; width: 100%; - max-width: 320px; + max-width: 340px; font-size: 13px; padding: 6px 28px 6px 10px; @@ -377,7 +420,7 @@ .fill-value-input { @include w.input-base; width: 100%; - max-width: 320px; + max-width: 340px; font-size: 13px; padding: 6px 10px; margin-top: 6px; @@ -404,30 +447,17 @@ } } - .projection-section { - .projection-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - font-weight: 500; - color: v.$gray-800; - - input[type='checkbox'] { - @include w.selection-checkbox; - } - } - } - .no-selection { @include w.no-content($icon-size: 40px, $text-size: 14px); flex: 1; gap: 8px; + background: white; + border: 1px solid v.$gray-300; + border-radius: 6px; } // ============================================================================ - // Duplicates Section (unchanged) + // Duplicates Section // ============================================================================ .duplicates-section { @@ -452,23 +482,6 @@ } } - .no-duplicates { - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - background: rgba(v.$status-success, 0.05); - border-left: 3px solid v.$status-success; - border-radius: 2px; - color: v.$status-success-dark; - font-size: 13px; - - .material-icons { - font-size: 18px; - color: v.$status-success; - } - } - .duplicate-info { display: flex; flex-direction: column; @@ -514,7 +527,7 @@ font-weight: 500; input[type='checkbox'] { - @include w.checkbox; + @include w.selection-checkbox; } } diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts index 1dc4294..3dc2fc9 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts @@ -12,11 +12,11 @@ import { OutlierStrategy, OutlierMethod, DATA_TYPE_CONFIG, + getDataTypeLabel, } from '../../models/data-type.enum'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; -import { DataTypeBadgeComponent } from '../../shared/data-type-badge/data-type-badge.component'; import { DataPreviewTableComponent } from '../../shared/data-preview-table/data-preview-table.component'; interface ColumnConfigState { @@ -30,7 +30,7 @@ interface ColumnConfigState { @Component({ selector: 'app-step3-configure-data-features', standalone: true, - imports: [FormsModule, HelpTooltipComponent, DataTypeBadgeComponent, DataPreviewTableComponent], + imports: [FormsModule, HelpTooltipComponent, DataPreviewTableComponent], templateUrl: './step3-configure-data-features.component.html', styleUrl: './step3-configure-data-features.component.scss', providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step3ConfigureDataFeaturesComponent) }], @@ -322,28 +322,6 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { } } - // Select-all state for the unified list header toggle. Operates on the - // currently listed (filtered) columns so the toggle matches what the user sees. - get allFilteredInProjection(): boolean { - return this.filteredColumns.length > 0 && this.filteredColumns.every(c => c.config.includeInProjection); - } - - get someFilteredInProjection(): boolean { - return this.filteredColumns.some(c => c.config.includeInProjection); - } - - toggleAllProjection(): void { - const target = !this.allFilteredInProjection; - for (const colState of this.filteredColumns) { - if (colState.config.includeInProjection !== target) { - this.preprocessingService.updateColumnConfig(colState.column.name, { - includeInProjection: target, - }); - colState.config.includeInProjection = target; - } - } - } - toggleRemoveDuplicates(): void { this.cleaningConfig.removeDuplicates = !this.cleaningConfig.removeDuplicates; this.preprocessingService.updateCleaningConfig({ @@ -416,6 +394,14 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { return this.hasMissingValues(colState) || this.hasOutliers(colState); } + // Colorless uppercase type label shown in the master rail and detail card. + getTypeLabel = getDataTypeLabel; + + // Missing-value warning badge turns red when the missing share reaches 20%. + isHighMissing(colState: ColumnConfigState): boolean { + return colState.column.missingPercentage >= 20; + } + formatDate(timestamp: number): string { const date = new Date(timestamp); return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); From 0b444713a3ca650938f00067692f717585d1588f Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 16:48:09 +0200 Subject: [PATCH 12/64] feat(a5): Parametersliver im eingeklappten Zustand vollstaendig verstecken overflow:hidden auf .params-wrapper, damit Border/Padding der inneren .inline-params bei 0fr nicht als Rest sichtbar bleiben. Co-Authored-By: Claude Opus 4.8 --- .../step4-visualization-settings.component.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 7e3dda6..b95a587 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -585,11 +585,14 @@ border: 1px solid v.$gray-300; } - // Animated height wrapper: collapses to 0fr, expands to content height in place + // Animated height wrapper: collapses to 0fr, expands to content height in place. + // overflow:hidden clips the inner box (incl. its border/padding) so nothing + // shows while collapsed. .params-wrapper { display: grid; grid-template-rows: 0fr; margin-top: 0; + overflow: hidden; transition: grid-template-rows 0.25s ease, margin-top 0.25s ease; From da2f22496848f01fbdefa565c6d9e1abcb324a9c Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 16:48:39 +0200 Subject: [PATCH 13/64] feat(a15): Schritt-4-Feature-Liste - Suche/Filter, Drag-Reihenfolge, Drop-in, Scroll Suche + Typ-Filter ueber der Available-Liste; Drag-and-drop-Reihenfolge der gewaehlten Features (Reihenfolge persistiert); Drag-in aus Available an Position und Drag-out zum Entfernen (Klick-Add/Remove bleibt); max-height + Scroll auf beiden Listen, damit das Layout bei vielen Spalten haelt. Natives HTML5-DnD. Co-Authored-By: Claude Opus 4.8 --- ...tep4-visualization-settings.component.html | 79 ++++++++++++---- ...tep4-visualization-settings.component.scss | 53 +++++++++++ .../step4-visualization-settings.component.ts | 94 +++++++++++++++++++ 3 files changed, 206 insertions(+), 20 deletions(-) diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index f83d3b4..8a7df7a 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -69,10 +69,14 @@

@for (feature of selectedGlyphFeatures; track feature; let i = $index) {
+ drag_indicator {{ i + 1 }} {{ feature }} @if (getFeatureVariance(feature) !== null) { @@ -102,27 +106,62 @@

Available features Variance

-
- @for (feature of availableFeatures; track feature) { - @if (!isFeatureSelected(feature)) { -
- add - {{ feature }} - @if (getFeatureVariance(feature) !== null) { - - - - } -
+ + +
+ + +
+ +
+ @for (feature of filteredAvailableFeatures; track feature) { +
+ add + {{ feature }} + @if (getFeatureVariance(feature) !== null) { + + + + } +
+ } + @if (filteredAvailableFeatures.length === 0) { +
+ No features match + @if (featureSearch || featureTypeFilter !== 'all') { + + } +
}
diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index dea0af1..2ae860c 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -153,12 +153,52 @@ flex-direction: column; gap: 3px; min-height: 50px; + max-height: 200px; + overflow-y: auto; padding: 6px; border: 2px dashed v.$gray-300; border-radius: 8px; background: v.$wizard-header-bg; } + // Search + type filter above the available list (same anatomy as steps 2/3) + .feature-filter-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + + .search-box { + @include w.search-box($padding: 4px 8px, $icon-size: 16px); + flex: 1; + min-width: 0; + } + + .filter-select { + @include w.select; + font-size: 12px; + padding: 4px 24px 4px 8px; + flex-shrink: 0; + max-width: 130px; + } + } + + .available-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 16px 8px; + color: v.$gray-500; + font-size: 12px; + + .btn-clear-inline { + @include w.btn-text; + font-size: 11px; + padding: 3px 8px; + } + } + .empty-hint { display: flex; align-items: center; @@ -203,6 +243,7 @@ overflow-y: auto; flex: 1; min-height: 0; + max-height: 240px; } .feature-row { @@ -227,6 +268,18 @@ border-color: v.$active-color; } + &.drop-target { + border-color: v.$active-color; + box-shadow: inset 0 2px 0 v.$active-color; + } + + .drag-handle { + font-size: 16px; + color: v.$gray-400; + flex-shrink: 0; + cursor: grab; + } + .feature-order { display: flex; align-items: center; diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index 0a59163..f20dd82 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -78,6 +78,13 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { draggedFeature: string | null = null; draggedFromList: 'selected' | 'available' = 'available'; draggedIndex = -1; + // Index of the selected row currently under the drag cursor (insertion highlight). + dragOverIndex = -1; + + // Search + type filter for the AVAILABLE feature list. + featureSearch = ''; + featureTypeFilter: DataType | 'all' = 'all'; + readonly DataType = DataType; // Glyph preview selectedGlyphType: 'star' | 'flower' | 'whisker' = 'star'; @@ -382,6 +389,31 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { return this.selectedGlyphFeatures.includes(feature); } + /** Data type of a feature, resolving one-hot columns (e.g. city_NYC → city). */ + getFeatureType(feature: string): DataType | null { + const profile = this.preprocessingService.currentState.dataProfile; + if (!profile) return null; + const col = + profile.columns.find(c => c.name === feature) || profile.columns.find(c => feature.startsWith(c.name + '_')); + return col ? col.dataType : null; + } + + /** Available features minus the already-selected ones, filtered by search + type. */ + get filteredAvailableFeatures(): string[] { + const term = this.featureSearch.trim().toLowerCase(); + return this.availableFeatures.filter(feature => { + if (this.isFeatureSelected(feature)) return false; + if (term && !feature.toLowerCase().includes(term)) return false; + if (this.featureTypeFilter !== 'all' && this.getFeatureType(feature) !== this.featureTypeFilter) return false; + return true; + }); + } + + clearFeatureFilters(): void { + this.featureSearch = ''; + this.featureTypeFilter = 'all'; + } + getFeatureVariance(feature: string): number | null { return this.featureVariances.get(feature) ?? null; } @@ -417,6 +449,7 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { this.draggedFeature = null; this.draggedFromList = 'available'; this.draggedIndex = -1; + this.dragOverIndex = -1; } onDragOver(event: DragEvent): void { @@ -426,6 +459,53 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { } } + /** Hovering a specific selected row while dragging — shows the insertion point. */ + onItemDragOver(event: DragEvent, index: number): void { + event.preventDefault(); + event.stopPropagation(); + this.dragOverIndex = index; + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + } + + /** Insert a feature into the selected list at a specific position. */ + private insertSelectedAt(feature: string, index: number): void { + if (this.selectedGlyphFeatures.length >= this.MAX_GLYPH_FEATURES || this.isFeatureSelected(feature)) return; + const clamped = Math.max(0, Math.min(index, this.selectedGlyphFeatures.length)); + this.selectedGlyphFeatures.splice(clamped, 0, feature); + this.saveGlyphFeatures(); + this.regeneratePreviewData(); + } + + /** Move a selected feature to a new position (reordering — order drives the glyph). */ + private moveSelected(from: number, to: number): void { + if (from < 0 || from >= this.selectedGlyphFeatures.length) return; + const clampedTo = Math.max(0, Math.min(to, this.selectedGlyphFeatures.length - 1)); + if (from === clampedTo) return; + const [moved] = this.selectedGlyphFeatures.splice(from, 1); + this.selectedGlyphFeatures.splice(clampedTo, 0, moved); + this.saveGlyphFeatures(); + this.regeneratePreviewData(); + } + + /** Drop onto a specific selected row: reorder (from selected) or insert (from available). */ + onDropOnSelectedItem(event: DragEvent, targetIndex: number): void { + event.preventDefault(); + event.stopPropagation(); + if (!this.draggedFeature) { + this.onDragEnd(event); + return; + } + if (this.draggedFromList === 'selected') { + this.moveSelected(this.draggedIndex, targetIndex); + } else { + this.insertSelectedAt(this.draggedFeature, targetIndex); + } + this.onDragEnd(event); + } + + /** Drop on the selected list background: append (available) or move to end (selected). */ onDropInSelected(event: DragEvent): void { event.preventDefault(); if (!this.draggedFeature) return; @@ -436,11 +516,25 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { this.saveGlyphFeatures(); this.regeneratePreviewData(); } + } else { + this.moveSelected(this.draggedIndex, this.selectedGlyphFeatures.length - 1); } this.onDragEnd(event); } + /** Drop a selected feature onto the available area to remove it from the glyph. */ + onDropInAvailable(event: DragEvent): void { + event.preventDefault(); + if (this.draggedFeature && this.draggedFromList === 'selected') { + const idx = this.selectedGlyphFeatures.indexOf(this.draggedFeature); + if (idx !== -1) { + this.removeGlyphFeature(idx); + } + } + this.onDragEnd(event); + } + // ============================================================================ // Projection Configuration // ============================================================================ From fdb48aaa9a5140fee87059dc0a8028b5b2d3c802 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 16:56:01 +0200 Subject: [PATCH 14/64] fix(a5): Parametersliver durch separaten Clip-Container beseitigen Bare .params-clip (min-height:0; overflow:hidden) als Grid-Kind; Padding/Rand bleiben auf .inline-params darin. So kollabiert der 0fr-Track wirklich auf 0, kein sichtbarer Rest mehr im eingeklappten Zustand. Co-Authored-By: Claude Opus 4.8 --- .../step4-visualization-settings.component.html | 6 ++++-- .../step4-visualization-settings.component.scss | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index bcf63b6..4912de6 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -304,8 +304,9 @@

@if (methodHasParams(method) && projectionConfig[method.key]) {
-
-
+
+
+
{{ method.name }} Parameters
} +
} diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index b95a587..d353ff3 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -586,13 +586,10 @@ } // Animated height wrapper: collapses to 0fr, expands to content height in place. - // overflow:hidden clips the inner box (incl. its border/padding) so nothing - // shows while collapsed. .params-wrapper { display: grid; grid-template-rows: 0fr; margin-top: 0; - overflow: hidden; transition: grid-template-rows 0.25s ease, margin-top 0.25s ease; @@ -603,10 +600,14 @@ } } - .inline-params { - // Clipped by the 0fr wrapper while collapsed + // Bare grid item: no padding/border/background so the 0fr track truly + // collapses to zero and fully clips the padded box below (no sliver). + .params-clip { min-height: 0; overflow: hidden; + } + + .inline-params { padding: 6px 10px; background: v.$wizard-header-bg; border: 1px solid v.$gray-200; From 8f507ea319df4220ccead1e2f7602b04c0d2cee2 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 17:12:26 +0200 Subject: [PATCH 15/64] feat(a15): Schritt-4-Feinschliff - Preview-Labels, Suggestions-Button, Drag-Cursor Lange Feature-Namen in der Glyph-Preview nicht mehr abgeschnitten (overflow visible, keine 14-Zeichen-Kuerzung); Smart-Suggestions-Button auf den Continue-Button-Stil (btn-primary, cyan); grab/grabbing-Cursor auf Available-Rows. Co-Authored-By: Claude Opus 4.8 --- ...tep4-visualization-settings.component.html | 1 + ...tep4-visualization-settings.component.scss | 25 ++++++++----------- .../step4-visualization-settings.component.ts | 5 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 8a7df7a..f313ca5 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -139,6 +139,7 @@

@for (feature of filteredAvailableFeatures; track feature) {
0.1 ? 'start' : cos < -0.1 ? 'end' : 'middle'; - const name = feature.length > 14 ? feature.substring(0, 13) + '\u2026' : feature; - return { x, y, name, anchor }; + // Show the full feature name; the SVG is allowed to overflow so long + // one-hot names (e.g. neighbourhood_group_Manhattan) are not clipped. + return { x, y, name: feature, anchor }; }); } From d3e4077361079671558ea59865e4d2934db5b41e Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 18:33:00 +0200 Subject: [PATCH 16/64] feat(a15): Tabellen-/Listen-Header auf einheitliche Hoehe (40px) $wizard-header-row-height + list-header-row Mixin in _wizard-shared.scss; angewandt auf Schritt-2-Tabellenkopf und alle Schritt-4-Listenkoepfe inkl. A13 Projection-Columns-Header/Select-all-Zeile. Co-Authored-By: Claude Opus 4.8 --- .../shared/_wizard-shared.scss | 22 ++++++++++++++++++- ...tep4-visualization-settings.component.scss | 18 ++++++--------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/app/preprocessing-wizard/shared/_wizard-shared.scss b/src/app/preprocessing-wizard/shared/_wizard-shared.scss index 05909df..76ce004 100644 --- a/src/app/preprocessing-wizard/shared/_wizard-shared.scss +++ b/src/app/preprocessing-wizard/shared/_wizard-shared.scss @@ -315,6 +315,24 @@ $selection-col-pad-left: 12px; } } +// ============================================================================ +// Unified header-row height (A15) +// Every single-row table/list header bar in the wizard uses this height so the +// headers line up visually across steps. Height is governed by min-height + +// box-sizing (not vertical padding), so different content stays vertically +// centered at the same height. +// ============================================================================ +$wizard-header-row-height: 40px; + +@mixin list-header-row { + display: flex; + align-items: center; + min-height: $wizard-header-row-height; + box-sizing: border-box; + padding-top: 0; + padding-bottom: 0; +} + // Tables @mixin table-container { background: white; @@ -340,8 +358,10 @@ $selection-col-pad-left: 12px; } th { - padding: 10px 12px; + height: $wizard-header-row-height; + padding: 0 12px; text-align: left; + vertical-align: middle; font-weight: 600; color: v.$gray-700; font-size: 12px; diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index d3a88d6..3330daf 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -104,8 +104,7 @@ overflow: hidden; .glyph-panel-header { - display: flex; - align-items: center; + @include w.list-header-row; justify-content: space-between; gap: 8px; margin-bottom: 8px; @@ -208,8 +207,7 @@ } .available-label { - display: flex; - align-items: center; + @include w.list-header-row; justify-content: space-between; font-size: 11px; font-weight: 600; @@ -467,8 +465,7 @@ padding-bottom: 8px; .projection-panel-header { - display: flex; - align-items: center; + @include w.list-header-row; gap: 12px; margin-bottom: 8px; @@ -717,8 +714,7 @@ max-height: 420px; .projection-columns-header { - display: flex; - align-items: center; + @include w.list-header-row; justify-content: space-between; gap: 8px; @@ -743,10 +739,10 @@ } .projection-columns-list-header { - display: flex; - align-items: center; + @include w.list-header-row; gap: 8px; - padding: 4px 8px; + padding-left: 8px; + padding-right: 8px; margin-bottom: 4px; border-bottom: 1px solid v.$gray-200; From bd685bc3d52de682edabfab0f71e6eab6bd56d47 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 18:49:36 +0200 Subject: [PATCH 17/64] docs(roadmap): A13/A15/A5 auf In Feature-Branch, erweiterten Umfang notiert Co-Authored-By: Claude Opus 4.8 --- ROADMAP.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 167813d..848310f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,7 @@ Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket e | A2 | Voreinstellungen sichtbar/umkehrbar | Konzept | TODO | `feat/a2-smart-defaults` | Step 2+3, `services/preprocessing.service.ts` | | A3 | Fehlermeldungen inline/verständlich | Konzept | TODO | `feat/a3-fehleranzeige` | Step 4+5 | | A4 | Undo/Zurücksetzen | Konzept | TODO | `feat/a4-undo-historie` | `services/preprocessing.service.ts`, Step 1+4 | -| A5 | Zusammengehöriges gruppieren | Konzept | TODO | `feat/a5-gruppierung` | Step 3+4 | +| A5 | Zusammengehöriges gruppieren | Konzept | In Feature-Branch | `feat/a5-gruppierung` | Step 3+4 | | A6 | Review-Navigation (Direktsprünge) | Konzept | TODO | `feat/a6-direct-links` | Step 5, `shared/progress-stepper/` | | A7 | Ehrliche/konsistente Signifier | trivial | TODO | `feat/a7-signifier` | Step 1/2/4, `shared/progress-stepper/` | | A8 | Systemstatus/Datenanzeige korrekt | trivial | TODO | `feat/a8-datenanzeige` | Step 2+4 | @@ -38,9 +38,9 @@ Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket e | A10 | Layout gegen lange/viele Inhalte | Konzept | TODO | `feat/a10-layout-robust` | Step 1+4, `shared/_wizard-shared.scss` | | A11 | Technische Defekte beheben | trivial | TODO | `feat/a11-bugfixes` | siehe Ticket | | A12 | Zweispaltiges Layout | Konzept | In Progress | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | -| A13 | Schrittlogik klären/neu ordnen | Konzept | TODO | `feat/a13-schrittlogik` | Step 2/3/4, `shared/constants/step-info.ts` | +| A13 | Schrittlogik klären/neu ordnen | Konzept | In Feature-Branch | `feat/a13-schrittlogik` | Step 2/3/4, `shared/constants/step-info.ts` | | A14 | Übergänge sichtbar (Animation) | Konzept | TODO | `feat/a14-animation` | Step 2↔3, `shared/data-preview-table/` | -| A15 | Einheitliches Tabellen/Listen-System | Konzept | TODO | `feat/a15-table-system` | `shared/data-preview-table/`, Step 2/3/4 | +| A15 | Einheitliches Tabellen/Listen-System | Konzept | In Feature-Branch | `feat/a15-table-system` | `shared/data-preview-table/`, Step 2/3/4 | --- @@ -148,7 +148,8 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A5 – Zusammengehöriges räumlich gruppieren -**Typ:** Konzept · **Miro:** Parameter Location, Aufbereitung Rework · **Status:** TODO · **Branch:** `feat/a5-gruppierung` +**Typ:** Konzept · **Miro:** Parameter Location, Aufbereitung Rework · **Status:** In Feature-Branch · **Branch:** `feat/a5-gruppierung` +**Erweiterter Umfang:** die Methodenbox klappt in-place auf (Höhe animiert, Breite konstant) und ist im nicht-aufgeklappten Zustand vollständig geschlossen; Reset je Box. Der Schritt-3-Tabellenteil von A5 wurde bewusst nicht hier, sondern im Zuge von A15 (Schritt-3-Redesign) behandelt. **Befunde:** C-S3-01, C-S4-07, S-S4-03 **Problem:** Projektionsparameter liegen außerhalb ihres Methodenkastens (willkürlich platziert); Datenkonfiguration überladen, Spaltenzuordnung nicht erkennbar. *(Vom Nutzer als teils trivial markiert: Parameter in die zugehörige Parameterbox verschieben.)* @@ -250,7 +251,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A13 – Schrittlogik klären und neu ordnen -**Typ:** Konzept · **Miro:** Aufbereitung Rework, Neuer Schritt 4 Anpassung · **Status:** TODO · **Branch:** `feat/a13-schrittlogik` +**Typ:** Konzept · **Miro:** Aufbereitung Rework, Neuer Schritt 4 Anpassung · **Status:** In Feature-Branch · **Branch:** `feat/a13-schrittlogik` **Befund:** K-02 **Problem:** Aufgabe der Schritte 2/3/4 und Bedeutung der jeweiligen Spaltenauswahl konzeptionell nicht getrennt. @@ -274,7 +275,8 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A15 – Einheitliches Tabellen- und Listen-Designsystem -**Typ:** Konzept · **Miro:** Tabellen/Listen · **Status:** TODO · **Branch:** `feat/a15-table-system` +**Typ:** Konzept · **Miro:** Tabellen/Listen · **Status:** In Feature-Branch · **Branch:** `feat/a15-table-system` +**Erweiterter Umfang:** zusätzlich Schritt-3-Redesign (Option 1d: Master-Rail + eingelassenes Konfigurations-Well mit Breadcrumb und Fehlwert-Badges) sowie Überarbeitung der Schritt-4-Feature-Liste (funktionale Suche/Typ-Filter, Drag-and-drop-Reihenfolge, Drop-in aus der Available-Liste, Höhenlimit mit Scroll) — Letzteres überschneidet sich mit A9. **Befund:** K-04 **Problem:** Wiederkehrende Tabellen/Listen (Spaltenauswahl, Datenkonfiguration, Feature-Auswahl) sind funktional gleich, aber uneinheitlich gestaltet. From 5284f1c81eba7471d5b038b7dbee5fdd8867a187 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 19:05:28 +0200 Subject: [PATCH 18/64] docs(roadmap): A12 auf In Feature-Branch --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 848310f..e1e15a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket e | A9 | Beschleuniger für Power-Nutzer | Konzept | TODO | `feat/a9-power-shortcuts` | Step 2/3/4 | | A10 | Layout gegen lange/viele Inhalte | Konzept | TODO | `feat/a10-layout-robust` | Step 1+4, `shared/_wizard-shared.scss` | | A11 | Technische Defekte beheben | trivial | TODO | `feat/a11-bugfixes` | siehe Ticket | -| A12 | Zweispaltiges Layout | Konzept | In Progress | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | +| A12 | Zweispaltiges Layout | Konzept | In Feature-Branch | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | | A13 | Schrittlogik klären/neu ordnen | Konzept | In Feature-Branch | `feat/a13-schrittlogik` | Step 2/3/4, `shared/constants/step-info.ts` | | A14 | Übergänge sichtbar (Animation) | Konzept | TODO | `feat/a14-animation` | Step 2↔3, `shared/data-preview-table/` | | A15 | Einheitliches Tabellen/Listen-System | Konzept | In Feature-Branch | `feat/a15-table-system` | `shared/data-preview-table/`, Step 2/3/4 | @@ -239,7 +239,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A12 – Anzeigeraum effizient und zweispaltig nutzen -**Typ:** Konzept · **Miro:** Neue two column UI, Neuer Schritt 4 Anpassung · **Status:** In Progress · **Branch:** `feat/a12-two-column` +**Typ:** Konzept · **Miro:** Neue two column UI, Neuer Schritt 4 Anpassung · **Status:** In Feature-Branch · **Branch:** `feat/a12-two-column` **Befund:** K-01 **Problem:** Vertikaler Bildschirmraum schlecht genutzt; Navigation über volle Breite erzeugt langes Scrollen. From c70eef52dcc156b4d4937265175f6c86fa73f8fb Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 19 Jul 2026 09:07:13 +0200 Subject: [PATCH 19/64] feat(a2/a9): Smart-Default-Reset ohne Banner und Power-Shortcuts --- .../services/preprocessing.service.ts | 45 +++++- .../step2-column-selection.component.html | 13 +- .../step2-column-selection.component.ts | 62 ++++++-- ...ep3-configure-data-features.component.html | 89 +++++++++-- ...ep3-configure-data-features.component.scss | 110 ++++++++++---- ...step3-configure-data-features.component.ts | 141 +++++++++++++++++- 6 files changed, 398 insertions(+), 62 deletions(-) diff --git a/src/app/preprocessing-wizard/services/preprocessing.service.ts b/src/app/preprocessing-wizard/services/preprocessing.service.ts index 5e0992e..238cf90 100644 --- a/src/app/preprocessing-wizard/services/preprocessing.service.ts +++ b/src/app/preprocessing-wizard/services/preprocessing.service.ts @@ -157,6 +157,10 @@ export class PreprocessingService { private createDefaultColumnConfig(col: ColumnStatistics): ColumnConfig { const capabilities = DATA_TYPE_CONFIG[col.dataType] ?? DATA_TYPE_CONFIG[DataType.Unknown]; + // Smart default: obviously unsuitable columns (mostly missing or constant) + // start deselected. Users can re-enable them in Step 2 at any time. + const hasIssues = col.missingPercentage > 50 || col.uniqueCount === 1; + return { name: col.name, originalType: col.dataType, @@ -168,8 +172,29 @@ export class PreprocessingService { missingValueStrategy: MissingValueStrategy.Keep, outlierMethod: OutlierMethod.IQR_1_5, outlierStrategy: OutlierStrategy.Keep, - enabled: true, - hasIssues: col.missingPercentage > 50 || col.uniqueCount === 1, + enabled: !hasIssues, + hasIssues, + }; + } + + /** + * Returns the smart-default configuration values for a column, derived from its + * original data type (see DATA_TYPE_CONFIG). Used by Step 3 to detect deviations + * and offer a reversible "reset to default" control. + */ + public getColumnDefaults(columnName: string): Partial | null { + const config = this.currentState.columnConfigs.get(columnName); + if (!config) return null; + + const capabilities = DATA_TYPE_CONFIG[config.originalType] ?? DATA_TYPE_CONFIG[DataType.Unknown]; + return { + encodingMethod: capabilities.defaultEncoding, + scalingMethod: capabilities.defaultScaling, + includeInProjection: capabilities.defaultIncludeInProjection, + missingValueStrategy: MissingValueStrategy.Keep, + missingValueFillValue: undefined, + outlierMethod: OutlierMethod.IQR_1_5, + outlierStrategy: OutlierStrategy.Keep, }; } @@ -285,6 +310,22 @@ export class PreprocessingService { } } + /** + * Set the enabled state for a specific set of columns in one update. Used by + * Step 2 range-select (shift-click) and "select all filtered". + */ + public setColumnsEnabled(columnNames: string[], enabled: boolean): void { + const configs = this.currentState.columnConfigs; + columnNames.forEach(name => { + const config = configs.get(name); + if (config) { + config.enabled = enabled; + } + }); + this.updateState({ columnConfigs: new Map(configs) }); + this.saveStateToStorage(); + } + public selectAllColumns(): void { const configs = this.currentState.columnConfigs; configs.forEach(config => (config.enabled = true)); diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index c8ed41c..eebf04e 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -13,9 +13,10 @@ } @else { - - @if (showInfoBox) { -
- auto_fix_high - Smart defaults applied — review and adjust settings as needed. - - - -
- } -
@@ -30,10 +16,11 @@ @@ -209,6 +207,17 @@

{{ selectedColumn.column.name }}

+ @if (isEncodingModified(selectedColumn)) { + + }
{{ selectedColumn.column.name }}

[helpText]="HELP_TEXT.tableHeaders.missingValues" position="bottom" > + @if (isMissingModified(selectedColumn)) { + + }

+

diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 22c716e..7d47755 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -3,6 +3,13 @@ @use '../../../../styles/variables' as v; @use '../../../../styles/mixins' as m; +// A6: invisible scroll anchor used by the review step's deep-links +.scroll-anchor { + display: block; + height: 0; + scroll-margin-top: 12px; +} + .step4-visualization-settings { display: flex; flex-direction: column; @@ -774,6 +781,21 @@ flex-direction: column; max-height: 420px; + // A8: total dataset row count for context + .dataset-row-count { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: v.$gray-600; + margin: -4px 0 10px; + + .material-icons { + font-size: 14px; + color: v.$gray-500; + } + } + .projection-columns-header { @include w.list-header-row; justify-content: space-between; diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index d79d19d..ee040fc 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, forwardRef } from '@angular/core'; +import { Component, OnInit, AfterViewInit, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { PreprocessingService } from '../../services/preprocessing.service'; @@ -68,7 +68,7 @@ interface ProjectionMethodUI { styleUrl: './step4-visualization-settings.component.scss', providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step4VisualizationSettingsComponent) }], }) -export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { +export class Step4VisualizationSettingsComponent implements OnInit, AfterViewInit, WizardStep { readonly primaryLabel = 'Continue to Review'; readonly disabledHint = 'Select at least one projection column and 3-12 glyph features to continue.'; @@ -294,6 +294,18 @@ export class Step4VisualizationSettingsComponent implements OnInit, WizardStep { constructor(public preprocessingService: PreprocessingService) {} + // A6: if the review step requested a jump to a specific setting, scroll its + // anchor into view once this step has rendered. The delay lets the shell's + // scroll-to-top run first so it does not override this. + ngAfterViewInit(): void { + const target = this.preprocessingService.consumeScrollTarget(); + if (target) { + setTimeout(() => { + document.getElementById(target)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 120); + } + } + ngOnInit(): void { const state = this.preprocessingService.currentState; diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html index a139d1a..ecf5a2b 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html @@ -13,6 +13,14 @@ Columns Selected {{ enabledColumns }} / {{ totalColumns }}

+
@@ -21,6 +29,14 @@ Projection Features {{ projectionColumns }}
+
@@ -29,6 +45,14 @@ Color Feature {{ colorFeature || 'None' }}
+
@@ -37,6 +61,14 @@ Glyph Features {{ selectedGlyphFeatures.length }}
+
@@ -45,6 +77,14 @@ Projection Methods {{ enabledMethods.join(', ') }}
+
diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss index 059319f..6a18f19 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss @@ -32,6 +32,42 @@ border: 1px solid v.$gray-300; border-radius: 4px; + // A6: edit/jump affordance per summary card + .card-edit-btn { + margin-left: auto; + align-self: flex-start; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid v.$gray-300; + border-radius: 4px; + color: v.$gray-600; + cursor: pointer; + transition: + color 0.15s ease, + border-color 0.15s ease, + background 0.15s ease; + + .material-icons { + font-size: 16px; + } + + &:hover { + color: v.$status-info; + border-color: v.$active-color; + background: rgba(v.$active-color, 0.1); + } + + &:focus-visible { + outline: 2px solid v.$active-color; + outline-offset: 1px; + } + } + .card-icon { display: flex; align-items: center; diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts index b6edf37..0fa2132 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts @@ -357,6 +357,12 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { this.preprocessingService.previousStep(); } + // A6: jump from a review summary card back to the step that owns that setting + // and scroll the relevant section into view. + editSetting(step: number, targetId: string): void { + this.preprocessingService.goToStepWithScroll(step, targetId); + } + startOver(): void { if (confirm('Are you sure you want to start over? All current configuration will be lost.')) { // Terminate any running background projection workers From 54013838df7e79583242d4f25557f6321b4ffbcf Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 19 Jul 2026 14:17:41 +0200 Subject: [PATCH 22/64] =?UTF-8?q?refactor(a14):=20=C3=9Cbergang=20als=20ko?= =?UTF-8?q?ntinuierlicher=20Morph=20statt=20Fade/Crossfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../animations/step-transition.animations.ts | 100 +++++++++--------- .../preprocessing-wizard.component.scss | 8 ++ 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/src/app/preprocessing-wizard/animations/step-transition.animations.ts b/src/app/preprocessing-wizard/animations/step-transition.animations.ts index 8d8adb3..8e3304f 100644 --- a/src/app/preprocessing-wizard/animations/step-transition.animations.ts +++ b/src/app/preprocessing-wizard/animations/step-transition.animations.ts @@ -1,68 +1,70 @@ -import { trigger, transition, query, style, animate, group, sequence } from '@angular/animations'; +import { trigger, transition, query, style, animate, group } from '@angular/animations'; /** - * A14 – Übergangsanimation Schritt 2 → Schritt 3. + * A14 – Übergangsanimation Schritt 2 → Schritt 3 (kontinuierlicher Morph). * - * Diese Animation liegt bewusst isoliert in der Wizard-Shell und greift die - * DOM-Struktur von Schritt 2/Schritt 3 nur lesend (per CSS-Selektor) ab, ohne - * deren Markup zu verändern. Alle Query-Selektoren sind `optional`, damit die - * Animation degradiert (statt zu brechen), falls parallele Tickets Klassen - * umbenennen. + * Anforderung (Storyboard): Es ist ein durchgehender Morph bestehender Elemente. + * Nichts fadet weg oder erscheint per Fade/Pop-in — alle Bewegungen sind + * Geometrie (clip/translate) und laufen zeitlich überlappend. * - * Sequenz beim Wechsel 1 => 2 (currentStep): - * Phase 1 (Kollaps): Die Detailspalten der Step-2-Tabelle - * (Typ/Count/Unique/Missing/Distribution) blenden aus, - * übrig bleibt die Spaltennamen-Liste. Diese blendet in - * die Master-Rail von Step 3 über (Crossfade). - * Phase 2 (Slide-in): Das Detail-/Konfig-Well von Step 3 fährt von rechts - * herein (translateX 100% -> 0) und blendet dabei ein. + * Umsetzung als „Curtain/Overlay-Morph" komplett in der Wizard-Shell, ohne das + * Markup von Step 2/Step 3 zu verändern (Selektoren nur lesend, alle `optional`): * - * `prefers-reduced-motion` wird über das `@stepTransition.disabled`-Binding in - * der Komponente respektiert; ist es gesetzt, überspringt Angular die Animation - * vollständig (sofortiger Wechsel wie zuvor). + * Layer/Anordnung: Während des Übergangs werden aus- und eingehender Schritt + * überlagert (absolut). Step 2 (`:leave`) liegt OBEN und ist deckend (Hintergrund + * via SCSS), Step 3 (`:enter`) liegt darunter im finalen Layout + * (Master-Rail links + Detail-Well rechts). + * + * Kollaps (Anker): Step 2 wird per `clip-path` von der rechten Kante nach links + * eingezogen (`inset(0 0 0 0)` → `inset(0 100% 0 0)`). Dadurch retrahieren die + * Detailspalten (Typ/Count/Unique/Missing/Distribution) zuerst nach links; die + * „Columnname"-Spalte am linken Rand bleibt am längsten stehen. In genau dieser + * Position liegt darunter bereits die Master-Rail von Step 3 — die Spaltennamen + * bleiben also über den gesamten Übergang als durchgehende Liste sichtbar + * (Handoff ohne Fade, Ankerelement). + * + * Einfahren (gleichzeitig): Das Detail-/Konfig-Well von Step 3 fährt aus dem + * Offscreen rechts (`translateX(100%)` → `0`) an seinen Zielplatz neben der + * Columnname-Spalte. Kollaps und Einfahren laufen im selben `group()` und enden + * gemeinsam exakt im Step-3-Layout. + * + * Es wird bewusst KEINE `opacity` animiert (kein Verschwinden/Erscheinen). + * + * `prefers-reduced-motion` wird über das `@stepTransition.disabled`-Binding in der + * Komponente respektiert; ist es gesetzt, überspringt Angular die Animation + * vollständig (sofortiger Wechsel). */ -// Material-Standard-Easing; keine harten Farb-/Layoutwerte, nur Bewegung. +// Material-Standard-Easing; nur Bewegung, keine harten Farb-/Layoutwerte. const EASE = 'cubic-bezier(0.4, 0, 0.2, 1)'; - -// Detailspalten der Step-2-Tabelle, die in Phase 1 kollabieren sollen. -const LEAVE_DETAIL_COLUMNS = [ - ':leave .col-type', - ':leave .col-count', - ':leave .col-unique', - ':leave .col-missing', - ':leave .col-distribution', -].join(', '); +// Überlappende Gesamtdauer des Morphs. +const DURATION = '450ms'; export const stepTransition = trigger('stepTransition', [ transition('1 => 2', [ - // Ein- und ausgehenden Schritt überlagern, damit die Sequenz staffelbar ist. + // Aus- und eingehenden Schritt deckungsgleich überlagern. query(':enter, :leave', style({ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }), { optional: true, }), - // Step 3 zunächst verstecken; das Detail-Well startet rechts außerhalb. - query(':enter', style({ opacity: 0 }), { optional: true }), - query(':enter .detail-well', style({ transform: 'translateX(100%)', opacity: 0 }), { optional: true }), - // Nur während der Animation clippen, damit das einfahrende Well keinen - // horizontalen Scrollbalken erzeugt. Wird danach automatisch entfernt. + // Step 2 als deckende „Curtain" oben, Step 3 (Ziellayout) darunter. + query(':leave', style({ zIndex: 2, clipPath: 'inset(0 0 0 0)' }), { optional: true }), + query(':enter', style({ zIndex: 1 }), { optional: true }), + // Detail-Well startet offscreen rechts (fährt gleich ein). + query(':enter .detail-well', style({ transform: 'translateX(100%)' }), { optional: true }), + // Nur während der Animation clippen (kein horizontaler Überlauf durch das Well). style({ overflow: 'hidden' }), - sequence([ - // Phase 1a: Detailspalten kollabieren (ausblenden), Spaltennamen bleiben. - query(LEAVE_DETAIL_COLUMNS, [animate(`200ms ${EASE}`, style({ opacity: 0 }))], { optional: true }), - - // Phase 1b: Crossfade der verschlankten Tabelle in die Master-Rail. - group([ - query(':leave', [animate(`180ms ${EASE}`, style({ opacity: 0 }))], { optional: true }), - query(':enter', [animate(`180ms ${EASE}`, style({ opacity: 1 }))], { optional: true }), - ]), - - // Phase 2: Detail-/Konfig-Well fährt von rechts herein. - query( - ':enter .detail-well', - [animate(`300ms ${EASE}`, style({ transform: 'translateX(0)', opacity: 1 }))], - { optional: true } - ), + // Kollaps und Einfahren laufen gleichzeitig / überlappend. + group([ + // Step 2 wird von rechts nach links eingezogen (Detailspalten retrahieren, + // Columnname-Spalte bleibt am längsten und geht in die Master-Rail über). + query(':leave', [animate(`${DURATION} ${EASE}`, style({ clipPath: 'inset(0 100% 0 0)' }))], { + optional: true, + }), + // Detail-Well fährt gleichzeitig von rechts an seinen Zielplatz. + query(':enter .detail-well', [animate(`${DURATION} ${EASE}`, style({ transform: 'translateX(0)' }))], { + optional: true, + }), ]), ]), ]); diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss index d976042..72f4d54 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss @@ -132,6 +132,14 @@ flex-direction: column; min-height: 0; } + + // A14: Deckender Hintergrund, damit der austretende Schritt 2 während des + // Morphs (Curtain) den darunterliegenden Schritt 3 sauber verdeckt, bis er + // per clip-path nach links eingezogen ist. Im Ruhezustand ohne Effekt. + > app-step2-column-selection, + > app-step3-configure-data-features { + background: v.$wizard-content-bg; + } } // Centralized navigation bar for steps 1–4, pinned to the bottom of the work area From 61950deea6a50834126dd83a5404c9aac47fd197 Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 19 Jul 2026 15:19:30 +0200 Subject: [PATCH 23/64] refactor(a14): Step-2/3-Morph via GSAP Flip statt Angular-Animations --- package-lock.json | 24 +--- package.json | 2 +- src/app/app.config.ts | 3 +- .../animations/step-transition.animations.ts | 70 ---------- .../preprocessing-wizard.component.html | 2 +- .../preprocessing-wizard.component.scss | 13 +- .../preprocessing-wizard.component.ts | 127 +++++++++++++++++- .../step2-column-selection.component.html | 3 +- ...ep3-configure-data-features.component.html | 18 ++- 9 files changed, 152 insertions(+), 110 deletions(-) delete mode 100644 src/app/preprocessing-wizard/animations/step-transition.animations.ts diff --git a/package-lock.json b/package-lock.json index a1c6de6..b762b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "glyphspace", "version": "0.0.0", "dependencies": { - "@angular/animations": "^21.2.13", "@angular/common": "^21.1.4", "@angular/compiler": "^21.1.4", "@angular/core": "^21.1.4", @@ -18,6 +17,7 @@ "@saehrimnir/druidjs": "^0.7.3", "d3": "^7.9.0", "d3-force": "^3.0.0", + "gsap": "^3.15.0", "material-icons": "^1.13.14", "pyodide": "^0.28.0", "rxjs": "~7.8.0", @@ -458,22 +458,6 @@ "typescript": "*" } }, - "node_modules/@angular/animations": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.13.tgz", - "integrity": "sha512-bOztfduqo6PPgWTJcmZ402mPZEXCaeODZcNUqkSz76LibS7uyiT2kuvk2duw7EOFi3LIptxCLQe0ofnB+njiOw==", - "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.13" - } - }, "node_modules/@angular/build": { "version": "21.2.11", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.11.tgz", @@ -8134,6 +8118,12 @@ "dev": true, "license": "MIT" }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 9db32cd..174873d 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ }, "private": true, "dependencies": { - "@angular/animations": "^21.2.13", "@angular/common": "^21.1.4", "@angular/compiler": "^21.1.4", "@angular/core": "^21.1.4", @@ -25,6 +24,7 @@ "@saehrimnir/druidjs": "^0.7.3", "d3": "^7.9.0", "d3-force": "^3.0.0", + "gsap": "^3.15.0", "material-icons": "^1.13.14", "pyodide": "^0.28.0", "rxjs": "~7.8.0", diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 117fb6a..1c0c942 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,7 +1,6 @@ import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; -import { provideAnimations } from '@angular/platform-browser/animations'; export const appConfig: ApplicationConfig = { - providers: [provideHttpClient(), provideAnimations()], + providers: [provideHttpClient()], }; diff --git a/src/app/preprocessing-wizard/animations/step-transition.animations.ts b/src/app/preprocessing-wizard/animations/step-transition.animations.ts deleted file mode 100644 index 8e3304f..0000000 --- a/src/app/preprocessing-wizard/animations/step-transition.animations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { trigger, transition, query, style, animate, group } from '@angular/animations'; - -/** - * A14 – Übergangsanimation Schritt 2 → Schritt 3 (kontinuierlicher Morph). - * - * Anforderung (Storyboard): Es ist ein durchgehender Morph bestehender Elemente. - * Nichts fadet weg oder erscheint per Fade/Pop-in — alle Bewegungen sind - * Geometrie (clip/translate) und laufen zeitlich überlappend. - * - * Umsetzung als „Curtain/Overlay-Morph" komplett in der Wizard-Shell, ohne das - * Markup von Step 2/Step 3 zu verändern (Selektoren nur lesend, alle `optional`): - * - * Layer/Anordnung: Während des Übergangs werden aus- und eingehender Schritt - * überlagert (absolut). Step 2 (`:leave`) liegt OBEN und ist deckend (Hintergrund - * via SCSS), Step 3 (`:enter`) liegt darunter im finalen Layout - * (Master-Rail links + Detail-Well rechts). - * - * Kollaps (Anker): Step 2 wird per `clip-path` von der rechten Kante nach links - * eingezogen (`inset(0 0 0 0)` → `inset(0 100% 0 0)`). Dadurch retrahieren die - * Detailspalten (Typ/Count/Unique/Missing/Distribution) zuerst nach links; die - * „Columnname"-Spalte am linken Rand bleibt am längsten stehen. In genau dieser - * Position liegt darunter bereits die Master-Rail von Step 3 — die Spaltennamen - * bleiben also über den gesamten Übergang als durchgehende Liste sichtbar - * (Handoff ohne Fade, Ankerelement). - * - * Einfahren (gleichzeitig): Das Detail-/Konfig-Well von Step 3 fährt aus dem - * Offscreen rechts (`translateX(100%)` → `0`) an seinen Zielplatz neben der - * Columnname-Spalte. Kollaps und Einfahren laufen im selben `group()` und enden - * gemeinsam exakt im Step-3-Layout. - * - * Es wird bewusst KEINE `opacity` animiert (kein Verschwinden/Erscheinen). - * - * `prefers-reduced-motion` wird über das `@stepTransition.disabled`-Binding in der - * Komponente respektiert; ist es gesetzt, überspringt Angular die Animation - * vollständig (sofortiger Wechsel). - */ - -// Material-Standard-Easing; nur Bewegung, keine harten Farb-/Layoutwerte. -const EASE = 'cubic-bezier(0.4, 0, 0.2, 1)'; -// Überlappende Gesamtdauer des Morphs. -const DURATION = '450ms'; - -export const stepTransition = trigger('stepTransition', [ - transition('1 => 2', [ - // Aus- und eingehenden Schritt deckungsgleich überlagern. - query(':enter, :leave', style({ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }), { - optional: true, - }), - // Step 2 als deckende „Curtain" oben, Step 3 (Ziellayout) darunter. - query(':leave', style({ zIndex: 2, clipPath: 'inset(0 0 0 0)' }), { optional: true }), - query(':enter', style({ zIndex: 1 }), { optional: true }), - // Detail-Well startet offscreen rechts (fährt gleich ein). - query(':enter .detail-well', style({ transform: 'translateX(100%)' }), { optional: true }), - // Nur während der Animation clippen (kein horizontaler Überlauf durch das Well). - style({ overflow: 'hidden' }), - - // Kollaps und Einfahren laufen gleichzeitig / überlappend. - group([ - // Step 2 wird von rechts nach links eingezogen (Detailspalten retrahieren, - // Columnname-Spalte bleibt am längsten und geht in die Master-Rail über). - query(':leave', [animate(`${DURATION} ${EASE}`, style({ clipPath: 'inset(0 100% 0 0)' }))], { - optional: true, - }), - // Detail-Well fährt gleichzeitig von rechts an seinen Zielplatz. - query(':enter .detail-well', [animate(`${DURATION} ${EASE}`, style({ transform: 'translateX(0)' }))], { - optional: true, - }), - ]), - ]), -]); diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.html b/src/app/preprocessing-wizard/preprocessing-wizard.component.html index 27198a6..433aa3d 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.html +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.html @@ -34,7 +34,7 @@

Data Preprocessing Wizard

} -
+
@switch (currentStep) { @case (0) { diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss index 72f4d54..c24e968 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss @@ -122,8 +122,9 @@ display: flex; flex-direction: column; min-height: 0; - // A14: Anker für die absolut positionierten Ein-/Ausgangs-Schritte während - // der Übergangsanimation. Ohne Animation bleibt das Layout unverändert. + // A14: Bezugselement (offset parent) für die absolut positionierten Elemente, + // die GSAP Flip während des Step-2->Step-3-Morphs erzeugt. Im Ruhezustand + // bleibt das Layout unverändert. position: relative; > * { @@ -132,14 +133,6 @@ flex-direction: column; min-height: 0; } - - // A14: Deckender Hintergrund, damit der austretende Schritt 2 während des - // Morphs (Curtain) den darunterliegenden Schritt 3 sauber verdeckt, bis er - // per clip-path nach links eingezogen ist. Im Ruhezustand ohne Effekt. - > app-step2-column-selection, - > app-step3-configure-data-features { - background: v.$wizard-content-bg; - } } // Centralized navigation bar for steps 1–4, pinned to the bottom of the work area diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts index 6917f5b..35de5eb 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts @@ -10,7 +10,12 @@ import { Step3ConfigureDataFeaturesComponent } from './steps/step3-configure-dat import { Step4VisualizationSettingsComponent } from './steps/step4-visualization-settings/step4-visualization-settings.component'; import { Step5ReviewProcessingComponent } from './steps/step5-review-processing/step5-review-processing.component'; import { PreprocessingState } from './models/preprocessing-state'; -import { stepTransition } from './animations/step-transition.animations'; +import { gsap } from 'gsap'; +import { Flip } from 'gsap/Flip'; + +// A14: Register the Flip plugin once at module load. Flip morphs elements across +// the step2 -> step3 @switch component swap by matching `data-flip-id`. +gsap.registerPlugin(Flip); @Component({ selector: 'app-preprocessing-wizard', @@ -25,7 +30,6 @@ import { stepTransition } from './animations/step-transition.animations'; ], templateUrl: './preprocessing-wizard.component.html', styleUrl: './preprocessing-wizard.component.scss', - animations: [stepTransition], }) export class PreprocessingWizardComponent implements OnInit, OnDestroy { @Output() wizardClose = new EventEmitter(); @@ -43,13 +47,18 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { isProcessing = false; error: string | null = null; - // A14: When the user prefers reduced motion, the step 2 -> 3 transition is + // A14: When the user prefers reduced motion, the step 2 -> 3 morph is // skipped (Angular renders the switch instantly). Read once at init. reduceMotion = typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // A14: Cached natural height of `.step-container` captured just before the + // step2 -> step3 swap, used to animate the container height during the morph + // so there is no vertical jump. + private morphOldHeight = 0; + // Labels and descriptions come from STEP_INFO so the sidebar stays the single // source of truth for step titles/purposes (no duplication in the work area). steps: Step[] = Object.keys(STEP_INFO) @@ -67,7 +76,17 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { // Subscribe to state changes this.subscription.add( this.preprocessingService.state$.subscribe(state => { - this.currentStep = state.currentStep; + const previousStep = this.currentStep; + const nextStep = state.currentStep; + + // A14: Only the forward Step 2 -> Step 3 transition morphs. Capture the + // Flip state while step2 is still rendered (this subscriber runs + // synchronously before Angular re-renders the @switch), then trigger the + // morph after the new DOM is in place. + const shouldMorph = previousStep === 1 && nextStep === 2 && !this.reduceMotion; + const flipState = shouldMorph ? this.captureMorphState() : null; + + this.currentStep = nextStep; this.isProcessing = state.isProcessing; this.error = state.error; @@ -78,6 +97,10 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { // Update step completion based on state this.updateStepCompletion(state); + + if (flipState) { + this.runStepMorph(flipState); + } }) ); @@ -106,6 +129,102 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { }, 0); } + private get stepContainer(): HTMLElement | null { + return this.wizardContent?.nativeElement?.querySelector('.step-container') ?? null; + } + + /** + * A14: Capture the GSAP Flip state of the step-2 column list + search box + * (matched across the swap via `data-flip-id`) plus the stat columns that are + * about to leave. Must run while step 2 is still in the DOM. + */ + private captureMorphState(): Flip.FlipState | null { + const container = this.stepContainer; + if (!container) { + return null; + } + + // Matched, morphing elements (column rows + search box) share a data-flip-id + // with their step-3 counterparts. The stat cells + select-all header cell are + // captured too so Flip can animate them out via onLeave (they only exist in + // step 2, so their DOM nodes get disconnected by the swap). + const morphTargets = Array.from(container.querySelectorAll('[data-flip-id]')); + const leaveTargets = Array.from( + container.querySelectorAll( + '.col-type, .col-count, .col-unique, .col-missing, .col-distribution, thead .col-checkbox' + ) + ); + + this.morphOldHeight = container.offsetHeight; + return Flip.getState([...morphTargets, ...leaveTargets]); + } + + /** + * A14: After Angular has rendered step 3, morph from the captured state: + * matched column rows/search box glide from table to master-rail geometry, + * stat columns collapse to the left (onLeave), and the detail well + type + * filter enter from the right (onEnter). The container height is tweened in + * the same timeline to avoid a vertical jump. + */ + private runStepMorph(state: Flip.FlipState): void { + // setTimeout(0) fires after Angular's change detection has swapped the DOM, + // mirroring the existing scrollToTop pattern. + setTimeout(() => { + const container = this.stepContainer; + if (!container) { + return; + } + + const timeline = Flip.from(state, { + targets: container.querySelectorAll('[data-flip-id]'), + duration: 0.5, + ease: 'power2.inOut', + absolute: true, + // Stat columns / select-all: retract to the left and fade out. + onLeave: leaving => + gsap.to(leaving, { + xPercent: -30, + scaleX: 0, + transformOrigin: 'left center', + autoAlpha: 0, + duration: 0.4, + ease: 'power2.in', + }), + }); + + // onEnter (step-3 only): detail/config well slides in from the right, the + // type filter fades in. These have no data-flip-id, so they are driven as + // explicit tweens layered onto the Flip timeline at the same start time. + const well = container.querySelector('.detail-well'); + if (well) { + timeline.from(well, { xPercent: 40, autoAlpha: 0, duration: 0.5, ease: 'power2.out' }, 0); + } + const railFilters = container.querySelector('.rail-filters'); + if (railFilters) { + timeline.from(railFilters, { autoAlpha: 0, y: -6, duration: 0.3, ease: 'power2.out' }, 0.15); + } + + // Animate the container height old -> new to prevent a vertical jump, then + // release the inline height so flex layout resumes. + const newHeight = container.offsetHeight; + if (this.morphOldHeight > 0 && Math.abs(this.morphOldHeight - newHeight) > 1) { + timeline.fromTo( + container, + { height: this.morphOldHeight }, + { + height: newHeight, + duration: 0.5, + ease: 'power2.inOut', + onComplete: () => { + container.style.height = ''; + }, + }, + 0 + ); + } + }, 0); + } + ngOnDestroy(): void { this.subscription.unsubscribe(); } diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index c8ed41c..0d20e0e 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -10,7 +10,7 @@
-

- +
{{ selectedColumn.column.name }} } @else if (hasOutliers(selectedColumn)) { {{ selectedColumn.outlierCount }} detected } - +
From f86aeca5ce4a3b3406da43f6e9330b48a45f69f8 Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 19 Jul 2026 15:50:21 +0200 Subject: [PATCH 24/64] =?UTF-8?q?refactor(a14):=20Step-2/3-Zust=C3=A4nde?= =?UTF-8?q?=20angleichen=20f=C3=BCr=20glatteren=20Flip-Morph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../preprocessing-wizard.component.ts | 41 ++++++-------- .../shared/_wizard-shared.scss | 11 ++++ .../step2-column-selection.component.html | 55 +++++++++++++------ .../step2-column-selection.component.scss | 42 ++++++++++---- .../step2-column-selection.component.ts | 27 ++++++--- ...ep3-configure-data-features.component.html | 7 ++- ...ep3-configure-data-features.component.scss | 7 ++- 7 files changed, 128 insertions(+), 62 deletions(-) diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts index 35de5eb..5178912 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts @@ -144,15 +144,15 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { return null; } - // Matched, morphing elements (column rows + search box) share a data-flip-id - // with their step-3 counterparts. The stat cells + select-all header cell are - // captured too so Flip can animate them out via onLeave (they only exist in - // step 2, so their DOM nodes get disconnected by the swap). + // Matched, morphing elements (column rows + search box + type filter) share a + // data-flip-id with their step-3 counterparts. The stat cells + select-all + // header cell are captured too so Flip can animate them out via onLeave (they + // only exist in step 2, so their DOM nodes get disconnected by the swap). + // NOTE: `.col-missing` is part of the left band now (it morphs with the row), + // so it is NOT in the leave set. const morphTargets = Array.from(container.querySelectorAll('[data-flip-id]')); const leaveTargets = Array.from( - container.querySelectorAll( - '.col-type, .col-count, .col-unique, .col-missing, .col-distribution, thead .col-checkbox' - ) + container.querySelectorAll('.col-type, .col-count, .col-unique, .col-distribution, thead .col-checkbox') ); this.morphOldHeight = container.offsetHeight; @@ -161,10 +161,11 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { /** * A14: After Angular has rendered step 3, morph from the captured state: - * matched column rows/search box glide from table to master-rail geometry, - * stat columns collapse to the left (onLeave), and the detail well + type - * filter enter from the right (onEnter). The container height is tweened in - * the same timeline to avoid a vertical jump. + * matched left-band rows / search box / type filter glide from the table into + * the master-rail geometry (small delta since both steps now share the rail + * width, header controls and list height), the stat columns collapse away + * (onLeave), and the detail well enters from the right (onEnter). The container + * height is tweened in the same timeline only if a residual mismatch remains. */ private runStepMorph(state: Flip.FlipState): void { // setTimeout(0) fires after Angular's change detection has swapped the DOM, @@ -180,29 +181,23 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { duration: 0.5, ease: 'power2.inOut', absolute: true, - // Stat columns / select-all: retract to the left and fade out. + // Stat columns / select-all: fade out with a small rightward drift as the + // detail well slides in over that area. onLeave: leaving => gsap.to(leaving, { - xPercent: -30, - scaleX: 0, - transformOrigin: 'left center', + x: 24, autoAlpha: 0, - duration: 0.4, + duration: 0.3, ease: 'power2.in', }), }); - // onEnter (step-3 only): detail/config well slides in from the right, the - // type filter fades in. These have no data-flip-id, so they are driven as - // explicit tweens layered onto the Flip timeline at the same start time. + // onEnter (step-3 only): the detail/config well slides in from the right. + // The type filter now morphs (shared data-flip-id) instead of appearing. const well = container.querySelector('.detail-well'); if (well) { timeline.from(well, { xPercent: 40, autoAlpha: 0, duration: 0.5, ease: 'power2.out' }, 0); } - const railFilters = container.querySelector('.rail-filters'); - if (railFilters) { - timeline.from(railFilters, { autoAlpha: 0, y: -6, duration: 0.3, ease: 'power2.out' }, 0.15); - } // Animate the container height old -> new to prevent a vertical jump, then // release the inline height so flex layout resumes. diff --git a/src/app/preprocessing-wizard/shared/_wizard-shared.scss b/src/app/preprocessing-wizard/shared/_wizard-shared.scss index 76ce004..ba2eb12 100644 --- a/src/app/preprocessing-wizard/shared/_wizard-shared.scss +++ b/src/app/preprocessing-wizard/shared/_wizard-shared.scss @@ -324,6 +324,17 @@ $selection-col-pad-left: 12px; // ============================================================================ $wizard-header-row-height: 40px; +// ============================================================================ +// A14: Shared master-list geometry +// Step 2's left "column band" and Step 3's master rail share the same width, +// list-viewport height and row padding so the GSAP Flip morph between the two +// steps is minimal (matched elements barely move). Keep these values in ONE +// place so both steps stay in lockstep. +// ============================================================================ +$wizard-rail-width: 320px; +$wizard-list-viewport-height: 420px; +$wizard-list-row-padding: 10px 12px; + @mixin list-header-row { display: flex; align-items: center; diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index 0d20e0e..030f554 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -25,6 +25,23 @@ }
+ + +
{{ enabledCount }} of {{ columns.length }} selected
@@ -41,6 +58,7 @@ + + + - + - - - - - - `; - }) - .join(''); - - return ` -
- Item: ${glyph.id} -
Column Name + + Missing + + + Data Type @@ -64,12 +89,6 @@ - - Missing - - - Distribution @@ -109,6 +128,18 @@ + + + @if (column.missingCount > 0) { +
+ {{ column.missingCount | number }} + ({{ column.missingPercentage.toFixed(1) }}%) +
+ } @else { + — + } +
@@ -127,18 +158,6 @@ - @if (column.missingCount > 0) { -
- {{ column.missingCount | number }} - ({{ column.missingPercentage.toFixed(1) }}%) -
- } @else { - — - } -
@if (getColumnHistogramData(column); as histogramData) { diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss index 2eb7e8d..106acd2 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss @@ -36,6 +36,16 @@ position: relative; } +// A14: Type filter mirroring the Step 3 master-rail filter so both steps share +// the same header controls (matched via data-flip-id for the morph). +.filter-select { + @include wizard.select; + flex-shrink: 0; + min-width: 150px; + font-size: 13px; + padding: 6px 28px 6px 10px; +} + .selection-controls { display: flex; gap: 12px; @@ -52,16 +62,28 @@ } // Table Layout +// A14: bounded, internally scrolling viewport with a sticky header (from the +// shared `table` mixin) so the row list matches Step 3's master-rail list. The +// viewport height is derived from the shared value + the sticky header height so +// the visible ROW area equals Step 3's `.column-list` height. .columns-table-container { @include wizard.table-container; - overflow-x: hidden; + overflow-x: auto; + overflow-y: auto; + max-height: calc(#{wizard.$wizard-list-viewport-height} + #{wizard.$wizard-header-row-height}); margin-bottom: 16px; } .columns-table { @include wizard.table; table-layout: fixed; + // Keep the stat columns readable; the container scrolls horizontally if the + // work area is narrower than this. + min-width: 860px; + // A14 column grouping: the left band (select-all + name + missing) sums to the + // shared rail width (44 + 210 + 66 = 320px); the stat columns sit to its right + // and collapse away during the Step 2 -> Step 3 morph. thead th { .header-with-tooltip { display: inline-flex; @@ -78,25 +100,25 @@ } &.col-name { - width: 20%; + width: 210px; + } + + &.col-missing { + width: 66px; + text-align: right; } &.col-type { - width: 10%; + width: 110px; } &.col-count { - width: 8%; + width: 90px; text-align: right; } &.col-unique { - width: 10%; - text-align: right; - } - - &.col-missing { - width: 10%; + width: 110px; text-align: right; } diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts index 79de434..d2f265c 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts @@ -5,7 +5,7 @@ import { PreprocessingService } from '../../services/preprocessing.service'; import { MiniHistogramComponent } from '../../../shared/components/mini-histogram/mini-histogram.component'; import { ColumnStatistics, HistogramData } from '../../models/column-statistics'; import { ColumnConfig } from '../../models/column-config'; -import { getDataTypeColor, getDataTypeBgColor } from '../../models/data-type.enum'; +import { DataType, getDataTypeColor, getDataTypeBgColor } from '../../models/data-type.enum'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; @@ -27,8 +27,14 @@ export class Step2ColumnSelectionComponent implements OnInit, WizardStep { columns: ColumnStatistics[] = []; columnConfigs = new Map(); searchTerm = ''; + // A14: Type filter, mirroring Step 3's master-rail type filter so both steps + // share the same header controls (enables the minimal Flip morph). + filterType: DataType | 'all' = 'all'; columnHistogramCache = new Map(); + // Enum reference for the template type-filter + + @if (projectionColumnSearch) { + + } + + + +
title="Select all columns" /> All columns + @if (hasProjectionColumnFilter()) { + Showing {{ filteredProjectionColumns.length }} of {{ projectionColumns.length }} + }
- @for (entry of projectionColumns; track entry.column.name) { - + + + @if (isProjectionDetailsExpanded(entry.column.name)) { +
+
+ Encoding + {{ getEncodingLabel(entry.config.encodingMethod) }} +
+
+ Scaling + + @if (entry.column.dataType === DataType.Numeric || entry.column.dataType === DataType.Date) { + {{ getScalingLabel(entry.config.scalingMethod) }} + } @else { + N/A + } + +
+
+ Missing + {{ getMissingLabel(entry.config.missingValueStrategy) }} +
+
+ edit + Adjust these in step “Configure Data & Features”. +
+
+ } +
+ } + @if (filteredProjectionColumns.length === 0) { +
+ No columns match + @if (hasProjectionColumnFilter()) { + + } +
} @@ -341,8 +420,8 @@

-
+

settings diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 7d47755..29c2894 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -766,13 +766,16 @@ .projection-options-two-col { display: grid; + // Always side-by-side (columns left, methods right), even at narrower widths. grid-template-columns: 2fr 3fr; gap: 16px; align-items: start; - // Panels inside the grid should not add their own bottom margin - .config-panel { + // Panels inside the grid should not add their own bottom margin and must be + // allowed to shrink so long column names never force the grid to wrap. + > .config-panel { margin-bottom: 0; + min-width: 0; } } @@ -821,6 +824,28 @@ } } + // Search + type filter (same visual language as the glyph feature filter) + .feature-filter-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + + .search-box { + @include w.search-box($padding: 4px 8px, $icon-size: 16px); + flex: 1; + min-width: 0; + } + + .filter-select { + @include w.select; + font-size: 12px; + padding: 4px 24px 4px 8px; + flex-shrink: 0; + max-width: 120px; + } + } + .projection-columns-list-header { @include w.list-header-row; gap: 8px; @@ -844,6 +869,14 @@ letter-spacing: 0.3px; text-transform: uppercase; } + + .showing-count { + margin-left: auto; + font-size: 11px; + font-weight: 600; + color: v.$active-color; + white-space: nowrap; + } } .projection-columns-list { @@ -855,16 +888,14 @@ min-height: 0; } + // Compact row wrapper: holds the selectable row plus an optional details panel. .projection-column-item { - display: flex; - align-items: center; - gap: 8px; - padding: 5px 8px; border: 1px solid v.$gray-200; border-radius: 6px; - cursor: pointer; font-size: 12px; - transition: all 0.15s ease; + transition: + border-color 0.15s ease, + background 0.15s ease; &:hover { border-color: v.$gray-400; @@ -875,11 +906,25 @@ border-color: rgba(v.$active-color, 0.3); } + &.details-open { + border-color: rgba(v.$active-color, 0.3); + } + &.excluded .col-name { color: v.$gray-500; text-decoration: line-through; } + // The clickable row itself is dense (compact padding to save width). + .projection-column-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + cursor: pointer; + margin: 0; + } + input[type='checkbox'] { @include w.checkbox; width: 14px; @@ -894,6 +939,88 @@ color: v.$gray-800; @include m.text-truncate; } + + .btn-details-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: v.$gray-500; + cursor: pointer; + flex-shrink: 0; + + .material-icons { + font-size: 18px; + } + + &:hover { + color: v.$status-info; + background: rgba(v.$active-color, 0.1); + } + } + + // Progressive-disclosure details: encoding / scaling / missing per column. + .projection-column-details { + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 8px 8px; + border-top: 1px dashed v.$gray-200; + margin-top: 2px; + + .detail-row { + display: flex; + justify-content: space-between; + gap: 8px; + + .detail-key { + color: v.$gray-500; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.3px; + } + + .detail-val { + color: v.$gray-800; + font-weight: 500; + } + } + + .detail-hint { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + color: v.$gray-500; + font-size: 10px; + + .material-icons { + font-size: 12px; + } + } + } + } + + // Empty state when search/filter matches nothing + .available-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 16px 8px; + color: v.$gray-500; + font-size: 12px; + + .btn-clear-inline { + @include w.btn-text; + font-size: 11px; + padding: 3px 8px; + } } .validation-warning { diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index ee040fc..be7af1c 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -5,7 +5,12 @@ import { PreprocessingService } from '../../services/preprocessing.service'; import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; import { ColumnConfig, ProjectionConfig } from '../../models/column-config'; import { ColumnStatistics } from '../../models/column-statistics'; -import { DataType } from '../../models/data-type.enum'; +import { + DataType, + MissingValueStrategy, + getEncodingLabel as encodingLabelFn, + getScalingLabel as scalingLabelFn, +} from '../../models/data-type.enum'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; @@ -75,6 +80,16 @@ export class Step4VisualizationSettingsComponent implements OnInit, AfterViewIni // Projection column selection (which features feed the dimensionality reduction) projectionColumns: ProjectionColumnState[] = []; + // Search + type filter for the projection column list (mirrors the glyph feature filter). + projectionColumnSearch = ''; + projectionColumnTypeFilter: DataType | 'all' = 'all'; + // Names of columns whose per-column details (encoding/scaling/missing) are expanded. + private expandedProjectionDetails = new Set(); + + // Label helpers for the per-column details toggle. + readonly getEncodingLabel = encodingLabelFn; + readonly getScalingLabel = scalingLabelFn; + // Color feature selection columns: ColumnStatistics[] = []; colorFeature: string | null = null; @@ -625,6 +640,55 @@ export class Step4VisualizationSettingsComponent implements OnInit, AfterViewIni return this.projectionColumns.find(c => c.column.name === columnName)?.config.includeInProjection ?? false; } + /** Projection columns filtered by the search box and the type filter. */ + get filteredProjectionColumns(): ProjectionColumnState[] { + const term = this.projectionColumnSearch.trim().toLowerCase(); + return this.projectionColumns.filter(entry => { + if (term && !entry.column.name.toLowerCase().includes(term)) return false; + if (this.projectionColumnTypeFilter !== 'all' && entry.column.dataType !== this.projectionColumnTypeFilter) { + return false; + } + return true; + }); + } + + hasProjectionColumnFilter(): boolean { + return this.projectionColumnSearch.trim() !== '' || this.projectionColumnTypeFilter !== 'all'; + } + + clearProjectionColumnFilters(): void { + this.projectionColumnSearch = ''; + this.projectionColumnTypeFilter = 'all'; + } + + /** Toggle the per-column details panel (encoding/scaling/missing) for progressive disclosure. */ + toggleProjectionDetails(columnName: string, event?: Event): void { + event?.stopPropagation(); + event?.preventDefault(); + if (this.expandedProjectionDetails.has(columnName)) { + this.expandedProjectionDetails.delete(columnName); + } else { + this.expandedProjectionDetails.add(columnName); + } + } + + isProjectionDetailsExpanded(columnName: string): boolean { + return this.expandedProjectionDetails.has(columnName); + } + + /** Human-readable label for the configured missing-value strategy. */ + getMissingLabel(strategy: MissingValueStrategy): string { + const labels: Record = { + [MissingValueStrategy.Keep]: 'Keep', + [MissingValueStrategy.RemoveRows]: 'Remove rows', + [MissingValueStrategy.FillMean]: 'Fill mean', + [MissingValueStrategy.FillMedian]: 'Fill median', + [MissingValueStrategy.FillMode]: 'Fill mode', + [MissingValueStrategy.FillValue]: 'Fill value', + }; + return labels[strategy] ?? 'Keep'; + } + getProjectionCount(): number { return this.projectionColumns.filter(c => c.config.includeInProjection).length; } diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss index 6a18f19..b116db1 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss @@ -32,7 +32,7 @@ border: 1px solid v.$gray-300; border-radius: 4px; - // A6: edit/jump affordance per summary card + // A6: edit/jump affordance per summary card — uniform, borderless icon button .card-edit-btn { margin-left: auto; align-self: flex-start; @@ -43,22 +43,20 @@ height: 28px; padding: 0; background: transparent; - border: 1px solid v.$gray-300; + border: none; border-radius: 4px; - color: v.$gray-600; + color: v.$gray-500; cursor: pointer; transition: color 0.15s ease, - border-color 0.15s ease, background 0.15s ease; .material-icons { - font-size: 16px; + font-size: 18px; } &:hover { color: v.$status-info; - border-color: v.$active-color; background: rgba(v.$active-color, 0.1); } From 871c274eee7a7f114db2335236bda6fe6b93521a Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 19 Jul 2026 17:02:03 +0200 Subject: [PATCH 27/64] =?UTF-8?q?fix(a2/a9):=20Review=20=E2=80=93=20inline?= =?UTF-8?q?=20Resets=20ohne=20Verschub,=20Apply-to-all=20explizit=20+=20Ov?= =?UTF-8?q?erlay,=20Shift-Select-Fix,=20Typ-Filter=20Schritt=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step2-column-selection.component.html | 14 +- .../step2-column-selection.component.scss | 7 + .../step2-column-selection.component.ts | 46 +++-- ...ep3-configure-data-features.component.html | 175 ++++++++++------ ...ep3-configure-data-features.component.scss | 192 +++++++++++++++++- ...step3-configure-data-features.component.ts | 119 +++++++++-- 6 files changed, 462 insertions(+), 91 deletions(-) diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index eebf04e..5550e25 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -26,6 +26,17 @@ }

+ +
{{ enabledCount }} of {{ columns.length }} selected
@@ -36,7 +47,7 @@ @if (filteredColumns.length === 0) {
search_off -

No columns match your search

+

No columns match your search or filter

} @else { @@ -95,7 +106,6 @@ type="checkbox" [checked]="isColumnEnabled(column.name)" (click)="onCheckboxClick(i, column.name, $event)" - (change)="toggleColumn(column.name)" [id]="'col-' + column.name" title="Shift-click to select a range" /> diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss index 2eb7e8d..6c2e689 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss @@ -36,6 +36,13 @@ position: relative; } +.type-filter { + @include wizard.select; + flex-shrink: 0; + font-size: 13px; + padding: 8px 28px 8px 10px; +} + .selection-controls { display: flex; gap: 12px; diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts index 17bcbd9..bca4f0a 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts @@ -5,7 +5,7 @@ import { PreprocessingService } from '../../services/preprocessing.service'; import { MiniHistogramComponent } from '../../../shared/components/mini-histogram/mini-histogram.component'; import { ColumnStatistics, HistogramData } from '../../models/column-statistics'; import { ColumnConfig } from '../../models/column-config'; -import { getDataTypeColor, getDataTypeBgColor } from '../../models/data-type.enum'; +import { DataType, getDataTypeColor, getDataTypeBgColor } from '../../models/data-type.enum'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; @@ -29,8 +29,12 @@ export class Step2ColumnSelectionComponent implements OnInit, WizardStep { columns: ColumnStatistics[] = []; columnConfigs = new Map(); searchTerm = ''; + filterType: DataType | 'all' = 'all'; columnHistogramCache = new Map(); + // Enum reference for the template's type-filter {{ selectedColumn.column.name }}
- @if (isScalingModified(selectedColumn)) { - - } +
{{ selectedColumn.column.name }} {{ selectedColumn.outlierCount }} detected } - @if (isOutlierModified(selectedColumn)) { - - }
- +
+ + +
- +
+ + +
diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts index bca4f0a..17213d6 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts @@ -37,6 +37,8 @@ export class Step2ColumnSelectionComponent implements OnInit, WizardStep { // Anchor index (into the currently filtered list) for shift-click range select. private lastCheckedIndex: number | null = null; + // Whether Shift was held during the click that precedes the next (change). + private pendingShift = false; // Distribution bars use the single cyan accent (A15) instead of per-type colors. // These mirror $active-color (#00bcd4) / $status-info (#00838f); the mini-histogram @@ -100,30 +102,36 @@ export class Step2ColumnSelectionComponent implements OnInit, WizardStep { } /** - * Single click handler for a row checkbox. It fully owns the checkbox state: the - * native toggle is prevented so the one-way [checked] binding stays authoritative - * and no second (change) handler can double-toggle the clicked row. - * - * Plain click toggles that one row. Shift-click sets every row between the - * previous checkbox click and this one (inclusive of both ends, on the currently - * filtered list) to the state the clicked row is toggling to. + * Records whether Shift was held for the click that is about to trigger a + * (change). It does NOT toggle anything and does NOT preventDefault — the native + * checkbox performs the real toggle, so single selection works and is rendered + * correctly via [checked]. The flag is consumed by onCheckboxChange(). */ - onCheckboxClick(index: number, columnName: string, event: MouseEvent): void { - event.preventDefault(); + onCheckboxClick(event: MouseEvent): void { + this.pendingShift = event.shiftKey; + } + + /** + * Native change handler. The clicked row has already been toggled by the browser; + * `newState` is its resulting checked value. We commit that single toggle, and if + * Shift was held with a valid anchor, we extend the SAME state across the whole + * inclusive range [min(anchor,current) .. max(anchor,current)] on the filtered list. + */ + onCheckboxChange(index: number, columnName: string, event: Event): void { + const newState = (event.target as HTMLInputElement).checked; - if (event.shiftKey && this.lastCheckedIndex !== null && this.lastCheckedIndex !== index) { - const targetState = !this.isColumnEnabled(columnName); + if (this.pendingShift && this.lastCheckedIndex !== null && this.lastCheckedIndex !== index) { const lo = Math.min(this.lastCheckedIndex, index); const hi = Math.max(this.lastCheckedIndex, index); const names = this.filteredColumns.slice(lo, hi + 1).map(col => col.name); - this.preprocessingService.setColumnsEnabled(names, targetState); + this.preprocessingService.setColumnsEnabled(names, newState); } else { - this.preprocessingService.toggleColumnEnabled(columnName); + this.preprocessingService.setColumnsEnabled([columnName], newState); } this.columnConfigs = this.preprocessingService.currentState.columnConfigs; - // Update anchor for the next shift-click. this.lastCheckedIndex = index; + this.pendingShift = false; } // ── Select-all operates on the currently filtered/visible set (A9) ────────── From 469528637c7649d219dc615959901307357aab43 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 20 Jul 2026 17:11:34 +0200 Subject: [PATCH 29/64] docs(roadmap): A2/A6/A8/A9 auf In Feature-Branch, A14 In Progress --- ROADMAP.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e1e15a3..c9a2602 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,19 +27,19 @@ Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket e | Ticket | Kurztitel | Typ | Status | Branch | Hauptort im Code | |--------|-----------|-----|--------|--------|------------------| | A1 | Fachbegriffe/Optionen erklären | Konzept | TODO | `feat/a1-erklaertexte` | `shared/constants/help-text.ts`, `shared/help-tooltip/`, Step 3+4 | -| A2 | Voreinstellungen sichtbar/umkehrbar | Konzept | TODO | `feat/a2-smart-defaults` | Step 2+3, `services/preprocessing.service.ts` | +| A2 | Voreinstellungen sichtbar/umkehrbar | Konzept | In Feature-Branch | `feat/a2-smart-defaults` | Step 2+3, `services/preprocessing.service.ts` | | A3 | Fehlermeldungen inline/verständlich | Konzept | TODO | `feat/a3-fehleranzeige` | Step 4+5 | | A4 | Undo/Zurücksetzen | Konzept | TODO | `feat/a4-undo-historie` | `services/preprocessing.service.ts`, Step 1+4 | | A5 | Zusammengehöriges gruppieren | Konzept | In Feature-Branch | `feat/a5-gruppierung` | Step 3+4 | -| A6 | Review-Navigation (Direktsprünge) | Konzept | TODO | `feat/a6-direct-links` | Step 5, `shared/progress-stepper/` | +| A6 | Review-Navigation (Direktsprünge) | Konzept | In Feature-Branch | `feat/a6-direct-links` | Step 5, `shared/progress-stepper/` | | A7 | Ehrliche/konsistente Signifier | trivial | TODO | `feat/a7-signifier` | Step 1/2/4, `shared/progress-stepper/` | -| A8 | Systemstatus/Datenanzeige korrekt | trivial | TODO | `feat/a8-datenanzeige` | Step 2+4 | -| A9 | Beschleuniger für Power-Nutzer | Konzept | TODO | `feat/a9-power-shortcuts` | Step 2/3/4 | +| A8 | Systemstatus/Datenanzeige korrekt | trivial | In Feature-Branch | `feat/a8-datenanzeige` | Step 2+4 | +| A9 | Beschleuniger für Power-Nutzer | Konzept | In Feature-Branch | `feat/a9-power-shortcuts` | Step 2/3/4 | | A10 | Layout gegen lange/viele Inhalte | Konzept | TODO | `feat/a10-layout-robust` | Step 1+4, `shared/_wizard-shared.scss` | | A11 | Technische Defekte beheben | trivial | TODO | `feat/a11-bugfixes` | siehe Ticket | | A12 | Zweispaltiges Layout | Konzept | In Feature-Branch | `feat/a12-two-column` | `preprocessing-wizard.component.*`, `shared/progress-stepper/` | | A13 | Schrittlogik klären/neu ordnen | Konzept | In Feature-Branch | `feat/a13-schrittlogik` | Step 2/3/4, `shared/constants/step-info.ts` | -| A14 | Übergänge sichtbar (Animation) | Konzept | TODO | `feat/a14-animation` | Step 2↔3, `shared/data-preview-table/` | +| A14 | Übergänge sichtbar (Animation) | Konzept | In Progress | `feat/a14-animation` | Step 2↔3, `shared/data-preview-table/` | | A15 | Einheitliches Tabellen/Listen-System | Konzept | In Feature-Branch | `feat/a15-table-system` | `shared/data-preview-table/`, Step 2/3/4 | --- @@ -108,7 +108,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A2 – Voreinstellungen sichtbar und umkehrbar machen -**Typ:** Konzept · **Miro:** Smart Defaults Button · **Status:** TODO · **Branch:** `feat/a2-smart-defaults` +**Typ:** Konzept · **Miro:** Smart Defaults Button · **Status:** In Feature-Branch · **Branch:** `feat/a2-a9-defaults-power` **Befunde:** C-S2-01, C-S3-02, S-S3-01, P-S3-04 **Problem:** Smart Defaults sind unsichtbar; unklar was automatisch gesetzt wurde; geänderte Defaults sind nicht reaktivierbar; in der Spaltenauswahl fehlt eine sinnvolle Vorauswahl. @@ -163,7 +163,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A6 – Navigation und Bearbeitung im Review-Schritt -**Typ:** Konzept · **Miro:** Direct Links · **Status:** TODO · **Branch:** `feat/a6-direct-links` +**Typ:** Konzept · **Miro:** Direct Links · **Status:** In Feature-Branch · **Branch:** `feat/a6-a8-review-datenanzeige` **Befunde:** C-S5-01, S-S5-01 **Problem:** Im Review lässt sich nichts direkt bearbeiten; Nutzer müssen sich merken, in welchem Schritt eine Einstellung lag. @@ -188,7 +188,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A8 – Systemstatus und Datenanzeige korrekt sichtbar machen -**Typ:** trivial · **Miro:** — · **Status:** TODO · **Branch:** `feat/a8-datenanzeige` +**Typ:** trivial · **Miro:** — · **Status:** In Feature-Branch · **Branch:** `feat/a6-a8-review-datenanzeige` **Befunde:** C-S4-06, C-S2-03 **Problem:** Zeilenanzahl ohne Gesamtkontext; fehlende Werte als „-" statt „0". @@ -200,7 +200,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A9 – Beschleuniger für Power-Nutzer -**Typ:** Konzept · **Miro:** Glyph Feature Search Filter · **Status:** TODO · **Branch:** `feat/a9-power-shortcuts` +**Typ:** Konzept · **Miro:** Glyph Feature Search Filter · **Status:** In Feature-Branch · **Branch:** `feat/a2-a9-defaults-power` **Befunde:** P-S2-02, P-S3-02, P-S3-03, P-S4-01 **Problem:** Fehlende Tastenkürzel (Shift-Klick, Strg+F), keine gemeinsame Bearbeitung gleichartiger Spalten, kein „alle auswählen" bei aktivem Filter, keine Drag-and-drop-Reihenfolge der Features. @@ -264,7 +264,7 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen --- ## A14 – Übergänge zwischen den Schritten sichtbar machen -**Typ:** Konzept · **Miro:** Animation · **Status:** TODO · **Branch:** `feat/a14-animation` +**Typ:** Konzept · **Miro:** Animation · **Status:** In Progress · **Branch:** `feat/a14-animation` **Befund:** K-03 **Problem:** Zusammenhang der Tabellen von Schritt 2 und 3 nicht sichtbar; unklar, wie die Auswahl übernommen wird. From 61554c60113b2889a4a0ba2ea4ab0bac67971f81 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 20 Jul 2026 18:33:09 +0200 Subject: [PATCH 30/64] feat(a3): persistente Fehler- und Statusscreens, Inline-Validierung, Fehlerklassen-Modell --- .../services/preprocessing.service.ts | 22 +- .../shared/constants/wizard-error-classes.ts | 239 +++++++++++++ .../wizard-issue-card.component.html | 43 +++ .../wizard-issue-card.component.scss | 192 +++++++++++ .../wizard-issue-card.component.ts | 43 +++ ...tep4-visualization-settings.component.html | 14 +- ...tep4-visualization-settings.component.scss | 17 + .../step4-visualization-settings.component.ts | 24 ++ .../step5-review-processing.component.html | 242 ++++++++----- .../step5-review-processing.component.scss | 321 ++++++++++++++++++ .../step5-review-processing.component.ts | 205 ++++++++++- src/app/services/data-processor.ts | 5 +- 12 files changed, 1264 insertions(+), 103 deletions(-) create mode 100644 src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts create mode 100644 src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.html create mode 100644 src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.scss create mode 100644 src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.ts diff --git a/src/app/preprocessing-wizard/services/preprocessing.service.ts b/src/app/preprocessing-wizard/services/preprocessing.service.ts index 7d5bfbf..d82c5f5 100644 --- a/src/app/preprocessing-wizard/services/preprocessing.service.ts +++ b/src/app/preprocessing-wizard/services/preprocessing.service.ts @@ -168,7 +168,7 @@ export class PreprocessingService { } catch (error: unknown) { this.updateState({ isProcessing: false, - error: error instanceof Error ? error.message : 'Failed to load data file', + error: this.toErrorMessage(error, 'Failed to load data file'), }); throw error; } @@ -449,12 +449,30 @@ export class PreprocessingService { } catch (error: unknown) { this.updateState({ isProcessing: false, - error: error instanceof Error ? error.message : 'Processing failed', + error: this.toErrorMessage(error, 'Processing failed'), }); throw error; } } + /** Extract a human-readable message from an unknown thrown value without + * discarding string rejections (the old `instanceof Error` check dropped + * the real worker cause and fell back to a generic string). */ + private toErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string' && error.trim().length > 0) return error; + return fallback; + } + + /** Clear a lingering processing error. The error flag lives in the root + * singleton state and previously survived closing/reopening the wizard, + * so a stale "Processing failed" reappeared on re-entry (P-S5-03). */ + public clearError(): void { + if (this.currentState.error !== null) { + this.updateState({ error: null }); + } + } + /** * Get processed features CSV exported by Python for JavaScript projections */ diff --git a/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts b/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts new file mode 100644 index 0000000..677b98e --- /dev/null +++ b/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts @@ -0,0 +1,239 @@ +/** + * Wizard error classes (A3). + * + * The six user-relevant failure classes are derivable from the code, but today + * the raw message that reaches the UI rarely makes the class obvious (see the + * analysis docs "fehlerklassen-ableitbarkeit" / "fehlerbehandlung-wizard-analyse"). + * This module turns raw error strings — and pre-flight state signals — into + * structured issues that name the likely cause (WHY), the concrete action + * (FIX) and the exact wizard step that resolves it. + * + * The `step` index is 0-based (0=Upload … 4=Review); the displayed "Schritt N" + * is `step + 1`. `anchorId` reuses the A6 deep-link anchors so the "Fix in + * Schritt N" button jumps straight to the responsible control. + */ + +import { PreprocessingState } from '../../models/preprocessing-state'; +import { ProjectionConfig } from '../../models/column-config'; + +export type IssueSeverity = 'blocking' | 'warning'; + +export interface WizardIssue { + /** Error-class code, e.g. "K1". Empty for unclassified/generic issues. */ + code: string; + severity: IssueSeverity; + /** Everyday-language title (what went wrong). */ + title: string; + /** WHY — the likely cause in plain language. */ + why: string; + /** FIX — the concrete action, naming the step. */ + fix: string; + /** 0-based target step index (goToStep / goToStepWithScroll). */ + step: number; + /** A6 deep-link anchor id inside the target step (optional). */ + anchorId?: string; + /** Raw technical detail, shown collapsed under "Technical details". */ + raw?: string; + /** Whether the user may dismiss this issue (partial/optional failures). */ + dismissable?: boolean; +} + +/** English step titles, aligned with STEP_INFO, for the "Fix in Schritt N · " button. */ +export const STEP_NAMES: Record = { + 0: 'Upload Data', + 1: 'Select Columns', + 2: 'Configure Data & Features', + 3: 'Visualization Settings', + 4: 'Review & Process', +}; + +/** Static class templates. Cause/fix/step are fixed per class; `raw`/`title` may be enriched. */ +const CLASS_TEMPLATES: Record> = { + K1: { + code: 'K1', + severity: 'blocking', + title: 'A projection column still contains text values', + why: 'Text or categorical values without a matching encoding ended up in the feature matrix, so the numeric projection cannot use them.', + fix: 'Set an encoding for that column, or remove it from the projection, in Step 3 (Configure Data & Features).', + step: 2, + anchorId: 'wizard-anchor-columns', + }, + K2: { + code: 'K2', + severity: 'blocking', + title: 'No rows were left after cleaning', + why: 'The missing-value, outlier or duplicate rules were strict enough to remove every row.', + fix: 'Loosen the cleaning rules in Step 3 (Configure Data & Features) so some rows survive.', + step: 2, + anchorId: 'wizard-anchor-columns', + }, + K3: { + code: 'K3', + severity: 'blocking', + title: 'A column has no usable values', + why: 'A selected column is constant or almost entirely empty, so scaling and projection have nothing to work with.', + fix: 'Deselect the affected column in Step 2 (Select Columns).', + step: 1, + anchorId: 'wizard-anchor-columns', + }, + K4: { + code: 'K4', + severity: 'blocking', + title: 'A projection parameter is out of range', + why: 'The neighbour count is larger than the number of samples (IsoMap, LLE, LTSA or UMAP), which the method cannot compute.', + fix: 'Lower the parameter or disable the method in Step 4 (Visualization Settings).', + step: 3, + anchorId: 'wizard-anchor-methods', + }, + K5: { + code: 'K5', + severity: 'blocking', + title: 'The number of glyph features is out of range', + why: 'A glyph needs between 3 and 12 features; the current selection is outside that range.', + fix: 'Adjust the selection to 3–12 features in Step 4 (Visualization Settings).', + step: 3, + anchorId: 'wizard-anchor-glyph', + }, + K6: { + code: 'K6', + severity: 'blocking', + title: 'Processing hit a time or resource limit', + why: 'The dataset is large enough to exceed the worker time limit or available memory.', + fix: 'Reduce the dataset in Step 2, or reduce the number of methods in Step 4.', + step: 3, + anchorId: 'wizard-anchor-methods', + }, +}; + +function template(code: string): WizardIssue { + return { ...CLASS_TEMPLATES[code] }; +} + +/** + * Classify a raw processing error message into one of the six classes. + * Falls back to a generic (still persistent, still actionable) blocking issue + * when no pattern matches, so the user never sees a bare "Processing failed". + */ +export function classifyProcessingError(raw: string): WizardIssue { + const msg = (raw || '').toLowerCase(); + let issue: WizardIssue; + + if (/timed out|timeout|out of memory|memory|aborted/.test(msg)) { + issue = template('K6'); + } else if (/invalid numeric value|could not convert|non-?numeric|not a number|isnan|convert string/.test(msg)) { + issue = template('K1'); + } else if (/empty|no valid feature data|no data rows|0 rows|no rows|nothing left/.test(msg)) { + issue = template('K2'); + } else if (/neighbor|n_neighbors|perplexity|k must|greater than|exceeds|out of range|sample/.test(msg)) { + issue = template('K4'); + } else if (/glyph feature|3-12|3 to 12/.test(msg)) { + issue = template('K5'); + } else if (/constant|zero variance|no variance|all missing|singular|division by zero/.test(msg)) { + issue = template('K3'); + } else { + issue = { + code: '', + severity: 'blocking', + title: "We couldn't finish processing your data", + why: 'The exact cause was reported by the processing engine (see technical details below).', + fix: 'Review your column and method settings, then start processing again. If it keeps failing, remove the most complex methods in Step 4.', + step: 3, + anchorId: 'wizard-anchor-methods', + }; + } + + issue.raw = raw; + return issue; +} + +/** + * Classify a background (non-primary) projection failure into a dismissable + * warning. FastMap already succeeded, so the visualization is usable — this is + * a degraded/partial state, not a hard failure. + */ +export function classifyBackgroundFailure(method: string, raw?: string): WizardIssue { + const base = classifyProcessingError(raw || ''); + return { + ...base, + severity: 'warning', + dismissable: true, + title: `${method.toUpperCase()} could not be computed`, + raw: raw || `${method} failed`, + }; +} + +/** + * Pre-flight detection: read signals already present in the state to warn about + * likely failures BEFORE processing starts. These use the same signals the + * profiler already computes (hasIssues = constant/mostly-missing column) plus + * derivable parameter/size checks. Returned as "possible issue" warnings. + */ +export function detectPreflightIssues(state: PreprocessingState): WizardIssue[] { + const issues: WizardIssue[] = []; + const profile = state.dataProfile; + if (!profile) return issues; + + const rowCount = profile.totalRows ?? 0; + + // K3 — constant / mostly-empty columns that are still selected for the projection. + const badColumns: string[] = []; + state.columnConfigs.forEach(config => { + if (!config.enabled || !config.includeInProjection) return; + const col = profile.columns.find(c => c.name === config.name); + if (!col) return; + if (col.uniqueCount === 1 || col.missingPercentage > 50) { + badColumns.push(config.name); + } + }); + if (badColumns.length > 0) { + const t = template('K3'); + issues.push({ + ...t, + severity: 'warning', + title: + badColumns.length === 1 + ? `Column "${badColumns[0]}" may have no usable values` + : `${badColumns.length} projection columns may have no usable values`, + why: `These columns are constant or mostly empty: ${badColumns.join(', ')}. They add no information and can make the projection fail.`, + raw: `Flagged by profiling (uniqueCount === 1 || missingPercentage > 50): ${badColumns.join(', ')}`, + }); + } + + // K4 — neighbour parameters larger than the sample size. + const pc: ProjectionConfig = state.projectionConfig; + const neighborChecks: { enabled: boolean; label: string; value: number }[] = [ + { enabled: pc.enableIsoMap, label: 'IsoMap', value: pc.isomapNeighbors }, + { enabled: pc.enableLLE, label: 'LLE', value: pc.lleNeighbors }, + { enabled: pc.enableLTSA, label: 'LTSA', value: pc.ltsaNeighbors }, + { enabled: pc.enableUMAP, label: 'UMAP', value: pc.umapNeighbors }, + ]; + const offending = neighborChecks.filter(c => c.enabled && c.value > 0 && rowCount > 0 && c.value >= rowCount); + if (offending.length > 0) { + const t = template('K4'); + issues.push({ + ...t, + severity: 'warning', + title: 'A method needs more neighbours than you have rows', + why: `${offending + .map(o => `${o.label} (neighbours ${o.value})`) + .join(', ')} — the neighbour count must stay below the row count (${rowCount}).`, + raw: `rowCount=${rowCount}; ${offending.map(o => `${o.label}.neighbors=${o.value}`).join(', ')}`, + }); + } + + // K6 — large dataset combined with a slow method risks the worker time limit. + const SLOW_LIMIT = 15000; + const slowEnabled = pc.enableTSNE || pc.enableSammon || pc.enableUMAP; + if (rowCount > SLOW_LIMIT && slowEnabled) { + const t = template('K6'); + issues.push({ + ...t, + severity: 'warning', + title: 'A slow method on a large dataset may time out', + why: `The dataset has ${rowCount.toLocaleString()} rows and a slow method (t-SNE, Sammon or UMAP) is enabled, which can exceed the processing time limit.`, + raw: `rowCount=${rowCount}; slow methods enabled.`, + }); + } + + return issues; +} diff --git a/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.html b/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.html new file mode 100644 index 0000000..c5a4c5c --- /dev/null +++ b/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.html @@ -0,0 +1,43 @@ +
+
+ + {{ issue.severity === 'blocking' ? 'error' : 'warning' }} + + @if (issue.code) { + {{ issue.code }} + } + {{ issue.title }} + {{ pillLabel }} +
+ +
+
+ WHY + {{ issue.why }} +
+
+ FIX + {{ issue.fix }} +
+
+ + @if (issue.raw) { + + @if (technicalOpen) { +
{{ issue.raw }}
+ } + } + +
+ + @if (issue.dismissable) { + + } +
+
diff --git a/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.scss b/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.scss new file mode 100644 index 0000000..d763eba --- /dev/null +++ b/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.scss @@ -0,0 +1,192 @@ +@use '../../../../styles/variables' as v; + +.issue-card { + border: 1px solid v.$gray-300; + border-radius: 6px; + padding: 14px 16px; + background: white; + + // Blocking / hard failure (design palette, aligned to $status-error family). + &.blocking { + background: #fffafa; + border-color: #f0d3cf; + + .severity-icon { + color: #d64541; + } + + .class-badge { + background: #fbe0de; + color: #b3261e; + } + } + + // Warning / degraded or possible issue (aligned to $status-warning family). + &.warning { + background: #fffdf9; + border-color: #f2dcb6; + + .severity-icon { + color: #d08a1e; + } + + .class-badge { + background: #ffe8c9; + color: #a4670f; + } + } + + .issue-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + + .severity-icon { + font-size: 20px; + flex-shrink: 0; + } + + .class-badge { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 11px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; + } + + .issue-title { + font-size: 14px; + font-weight: 600; + color: v.$gray-800; + flex: 1; + } + + .severity-pill { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: v.$gray-600; + background: v.$gray-100; + border: 1px solid v.$gray-300; + border-radius: 10px; + padding: 2px 8px; + flex-shrink: 0; + } + } + + .issue-grid { + display: flex; + flex-direction: column; + gap: 6px; + + .grid-row { + display: grid; + grid-template-columns: 44px 1fr; + gap: 10px; + align-items: start; + + .grid-key { + font-size: 10px; + font-weight: 700; + color: v.$gray-500; + letter-spacing: 0.05em; + padding-top: 2px; + } + + .grid-val { + font-size: 13px; + color: v.$gray-700; + line-height: 1.45; + } + } + } + + .technical-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 10px; + padding: 0; + background: none; + border: none; + color: v.$status-info; + font-size: 12px; + font-weight: 600; + cursor: pointer; + + .material-icons { + font-size: 16px; + } + + &:hover { + text-decoration: underline; + } + } + + .technical-detail { + margin: 8px 0 0; + padding: 10px 12px; + background: v.$gray-100; + border: 1px solid v.$gray-200; + border-radius: 4px; + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 12px; + color: v.$gray-700; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + } + + .issue-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; + + // Cyan-outline primary action (design spec: border #00bcd4, bg #e8fafd, text #00838f). + .btn-fix { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + background: #e8fafd; + border: 1px solid v.$active-color; + border-radius: 4px; + color: v.$status-info; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + + .material-icons { + font-size: 16px; + } + + &:hover { + background: color-mix(in srgb, #e8fafd 80%, v.$active-color 20%); + } + + &:focus-visible { + outline: 2px solid v.$active-color; + outline-offset: 1px; + } + } + + .btn-dismiss { + padding: 7px 12px; + background: transparent; + border: 1px solid v.$gray-300; + border-radius: 4px; + color: v.$gray-600; + font-size: 13px; + cursor: pointer; + + &:hover { + background: v.$gray-100; + } + } + } +} diff --git a/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.ts b/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.ts new file mode 100644 index 0000000..b1fab23 --- /dev/null +++ b/src/app/preprocessing-wizard/shared/wizard-issue-card/wizard-issue-card.component.ts @@ -0,0 +1,43 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { WizardIssue, STEP_NAMES } from '../constants/wizard-error-classes'; + +/** + * A3: presentational card for a single wizard issue. Shows severity, an + * everyday-language title, the WHY (cause) and FIX (action naming the step), + * collapsible technical details, and a "Fix in Schritt N" jump button that the + * host wires to the A6 step-jump logic. Optionally dismissable (partial issues). + */ +@Component({ + selector: 'app-wizard-issue-card', + standalone: true, + imports: [], + templateUrl: './wizard-issue-card.component.html', + styleUrl: './wizard-issue-card.component.scss', +}) +export class WizardIssueCardComponent { + @Input({ required: true }) issue!: WizardIssue; + /** Label shown on the severity pill: Blocking / Warning / Possible issue. */ + @Input() severityLabel = ''; + @Output() fix = new EventEmitter(); + @Output() dismiss = new EventEmitter(); + + readonly STEP_NAMES = STEP_NAMES; + technicalOpen = false; + + get stepNumber(): number { + return this.issue.step + 1; + } + + get stepName(): string { + return STEP_NAMES[this.issue.step] ?? ''; + } + + get pillLabel(): string { + if (this.severityLabel) return this.severityLabel; + return this.issue.severity === 'blocking' ? 'Blocking' : 'Warning'; + } + + toggleTechnical(): void { + this.technicalOpen = !this.technicalOpen; + } +} diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 501aa2a..3f131ea 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -96,7 +96,13 @@

@if (selectedGlyphFeatures.length > 0 && selectedGlyphFeatures.length < MIN_GLYPH_FEATURES) {
warning - {{ MIN_GLYPH_FEATURES - selectedGlyphFeatures.length }} more needed + {{ MIN_GLYPH_FEATURES - selectedGlyphFeatures.length }} more needed (a glyph needs 3–12 features) +
+ } + @if (selectedGlyphFeatures.length > MAX_GLYPH_FEATURES) { +
+ error_outline + Remove {{ selectedGlyphFeatures.length - MAX_GLYPH_FEATURES }} — a glyph takes at most {{ MAX_GLYPH_FEATURES }} features
} @@ -525,6 +531,12 @@

class="number-input" />

+ @if (neighborParamError(param)) { +
+ error_outline + {{ neighborParamError(param) }} +
+ }
} @if (method.key === 'enableTSNE' && shouldShowTSNEWarning()) { diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss index 29c2894..2991ad5 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss @@ -714,6 +714,23 @@ flex: 1 1 100%; min-width: 0; + // A3 / C-S4-05: field-near validation message (error class K4). + .param-inline-error { + display: flex; + align-items: flex-start; + gap: 5px; + margin-top: 5px; + font-size: 11px; + line-height: 1.4; + color: v.$status-error-dark; + + .material-icons { + font-size: 14px; + color: #d64541; + flex-shrink: 0; + } + } + label { display: flex; align-items: center; diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index be7af1c..87da49e 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -736,6 +736,30 @@ export class Step4VisualizationSettingsComponent implements OnInit, AfterViewIni this.updateProjectionConfig(); } + /** + * A3 / C-S4-05: field-near validation for neighbour parameters (error class K4). + * A neighbour count at or above the row count makes IsoMap / LLE / LTSA / UMAP + * fail during processing; we flag it right at the parameter instead of only + * surfacing a cryptic error later in Step 5. Returns null when valid. + * (0 means "auto" for IsoMap/LLE/LTSA and is always valid.) + */ + neighborParamError(param: ProjectionParam): string | null { + const neighborKeys: (keyof ProjectionConfig)[] = [ + 'isomapNeighbors', + 'lleNeighbors', + 'ltsaNeighbors', + 'umapNeighbors', + ]; + if (!neighborKeys.includes(param.configKey)) return null; + + const value = this.projectionConfig[param.configKey] as number; + const rows = this.getDatasetRowCount(); + if (value > 0 && rows > 0 && value >= rows) { + return `Only ${rows} rows available — set neighbours below ${rows}, or this method will fail during processing.`; + } + return null; + } + onParamChange(configKey: keyof ProjectionConfig, value: number, min: number, max: number): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.projectionConfig as any)[configKey] = Math.max(min, Math.min(max, value)); diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html index ecf5a2b..5e48dcc 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html @@ -1,11 +1,19 @@
+ + @if (showProcessing) { +
+ save + Your work is saved +
+ } + - + @if (!showProcessing) {
- +
view_column
@@ -88,6 +96,27 @@
+ + @if (preflightIssues.length > 0) { +
+ info +
+ Heads up — {{ preflightIssues.length }} + {{ preflightIssues.length === 1 ? 'thing' : 'things' }} could cause processing to fail. You can start + anyway, or fix them first. +
+
+
+ @for (issue of preflightIssues; track issue.code + issue.title) { + + } +
+ } +
@@ -149,120 +178,154 @@
Processing Info: Processing time depends on your dataset size and enabled projection methods. FastMap loads immediately, allowing you to explore your data right away. Additional projections - compute in the background. + compute in the background. If anything fails, it appears here with a fix — never a blank screen.
} - + @if (showProcessing) { + @if (isProcessing) { - -
-
-
- sync +
+
+ sync +
+

{{ processingStep || 'FastMap is computing your primary layout…' }}

+

+ FastMap loads first so you can explore right away. Other projections keep computing in the background — + if a problem comes up, it shows here, not on a blank page. +

+
-

Processing Your Data

-

{{ processingStep || 'Applying your preprocessing configuration...' }}

+
+
+
+
{{ processingProgress }}%
-
-
+ +
+ } + + + @if (isErrorScreen && blockingIssue) { +
+
+ error_outline +
+

We couldn't finish your visualization — but this is fixable.

+

Deine hochgeladenen Daten und alle Einstellungen sind noch da — nichts ging verloren.

+
+ + -
{{ processingProgress }}%
+
+
} + @if (processingComplete && !isProcessing) { - -
-
-
- check_circle +
+
+ check_circle +
+

Your visualization is ready

+

FastMap is loaded — open Glyphspace to start exploring. Additional projections finish in the background.

+
-

Processing Complete!

-

Your dataset has been successfully processed and is ready for visualization.

- -
-
- check - {{ enabledColumns }} columns processed -
-
- check - FastMap projection ready -
-
- check - Data cleaning applied + @if (isPartialResult) { +
+ warning +
+ {{ partialIssues.length }} of {{ enabledMethods.length }} projections could not be computed. Your + visualization still works with the ones that succeeded.
+
+ @for (issue of partialIssues; track issue.title) { + + } +
+ } @else { +
+
check{{ enabledColumns }} columns processed
+
checkFastMap projection ready
+
checkData cleaning applied
+
+ } - - @if (getBackgroundProjectionsArray().length > 0) { -
-

- schedule - Background Projections -

-
- @for (proj of getBackgroundProjectionsArray(); track proj.method) { -
-
- {{ proj.method.toUpperCase() }} - @if (proj.status === 'running') { - - sync - Running - - } - @if (proj.status === 'complete') { - - check_circle - Complete - - } - @if (proj.status === 'error') { - - error - Error - - } -
+ + + + @if (getBackgroundProjectionsArray().length > 0) { +
+

+ schedule + Background Projections +

+
+ @for (proj of getBackgroundProjectionsArray(); track proj.method) { +
+
+ {{ proj.method.toUpperCase() }} @if (proj.status === 'running') { -
-
-
-

{{ proj.message }}

+ syncRunning } @if (proj.status === 'complete') { -

Available in Glyphspace dropdown

+ check_circleComplete + } + @if (proj.status === 'error') { + errorFailed }
- } -
+ @if (proj.status === 'running') { +
+

{{ proj.message }}

+ } + @if (proj.status === 'complete') { +

Available in Glyphspace dropdown

+ } +
+ }
- } -
+
+ }
} + } +
- @if (error) { - -
- error -
Error: {{ error }}
+ + +
+ @for (m of methodStatuses; track m.name) { +
+
+ {{ m.name }} + {{ m.role }} +
+ {{ m.statusLabel }}
} - } -
+
+ @if (!showProcessing) { @@ -271,7 +334,6 @@

arrow_back Back -
} + @if (isErrorScreen) { +
+ +
+ +
+
+ } + @if (processingComplete && !isProcessing) {
-

@for (column of displayColumns; track column) { - } @@ -17,7 +17,7 @@ @for (row of displayData; track $index) { @for (column of displayColumns; track column) { - } diff --git a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss index 7afdaf6..0af538a 100644 --- a/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss +++ b/src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.scss @@ -45,7 +45,7 @@ } &.active { - background: rgba(68, 138, 255, 0.1); + background: rgba(v.$active-color, 0.12); } } @@ -135,9 +135,16 @@ width: 110px; flex-shrink: 0; + // Reachable steps read as enabled/interactive (accent outline), not greyed + // out like the disabled future steps. &.clickable { cursor: pointer; + .step-circle { + border-color: v.$active-color; + color: v.$active-color; + } + &:hover .step-circle { opacity: 0.8; } @@ -184,6 +191,10 @@ text-align: center; line-height: 1.3; + .clickable & { + color: v.$gray-700; + } + .active & { color: v.$active-color; font-weight: 600; diff --git a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html index 17b6c5c..32568f5 100644 --- a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html +++ b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.html @@ -89,7 +89,7 @@

Data Loaded Successfully

description
File Name
-
{{ profile.fileName }}
+
{{ profile.fileName }}
diff --git a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.scss b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.scss index 90455f6..d03f3f0 100644 --- a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.scss +++ b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.scss @@ -1,4 +1,5 @@ @use '../../../../styles/variables' as v; +@use '../../../../styles/mixins' as m; @use '../../shared/wizard-shared' as wizard; .step1-upload { @@ -235,6 +236,7 @@ .stat-content { flex: 1; + min-width: 0; } .stat-label { @@ -247,6 +249,7 @@ font-size: 14px; font-weight: 600; color: v.$gray-800; + @include m.text-truncate; } .profile-section { diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 501aa2a..f547224 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -80,7 +80,7 @@

> drag_indicator {{ i + 1 }} - {{ feature }} + {{ feature }} @if (getFeatureVariance(feature) !== null) { @@ -150,7 +150,7 @@

(dragend)="onDragEnd($event)" > add - {{ feature }} + {{ feature }} @if (getFeatureVariance(feature) !== null) { @@ -359,7 +359,7 @@

[checked]="entry.config.includeInProjection" (change)="toggleColumnProjection(entry.column.name)" /> - {{ entry.column.name }} + {{ entry.column.name }} + + +
@if (error) {
@@ -34,25 +60,27 @@

Data Preprocessing Wizard

} -
- @switch (currentStep) { - @case (0) { - - } - @case (1) { - - } - @case (2) { - - } - @case (3) { - + @if (stepVisible) { +
+ @switch (currentStep) { + @case (0) { + + } + @case (1) { + + } + @case (2) { + + } + @case (3) { + + } + @case (4) { + + } } - @case (4) { - - } - } -
+
+ }
@if (activeStep(); as step) { diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss index cffffd5..5c39fe9 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.scss +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.scss @@ -79,6 +79,50 @@ min-height: 0; } +// A4: undo/redo toolbar — a thin strip at the top of the work area, right-aligned. +// Deliberately separated from the bottom step navigation (position + curved icons) +// so it never reads as "Back/Next". +.wizard-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 1rem; + background: v.$wizard-content-bg; + border-bottom: 1px solid v.$gray-200; + flex-shrink: 0; +} + +.btn-history { + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: 0.25rem; + cursor: pointer; + color: v.$status-info; + transition: + background-color 0.2s ease, + color 0.2s ease; + + .material-icons { + font-size: 1.25rem; + } + + &:hover:not(:disabled) { + background: rgba(v.$active-color, 0.12); + border-color: rgba(v.$active-color, 0.4); + } + + &:disabled { + color: v.$gray-400; + cursor: default; + } +} + .wizard-content { flex: 1; overflow-y: auto; diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts index d448bb7..413192b 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts @@ -1,6 +1,17 @@ -import { Component, OnInit, OnDestroy, Output, EventEmitter, ViewChild, ElementRef, viewChild } from '@angular/core'; +import { + Component, + OnInit, + OnDestroy, + Output, + EventEmitter, + ViewChild, + ElementRef, + viewChild, + HostListener, +} from '@angular/core'; import { Subscription, distinctUntilChanged, map } from 'rxjs'; import { PreprocessingService } from './services/preprocessing.service'; +import { HistoryStatus } from './models/preprocessing-state'; import { ProgressStepperComponent, Step } from './shared/progress-stepper/progress-stepper.component'; import { WIZARD_STEP } from './shared/wizard-step'; import { STEP_INFO } from './shared/constants/step-info'; @@ -41,6 +52,12 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { isProcessing = false; error: string | null = null; + // A4: undo/redo history state, driven by the service's history$ stream. + history: HistoryStatus = { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null }; + // Toggled off/on to force the current step to re-instantiate after an undo/redo, + // so steps that cache state in ngOnInit re-read the restored snapshot. + stepVisible = true; + // Labels and descriptions come from STEP_INFO so the sidebar stays the single // source of truth for step titles/purposes (no duplication in the work area). steps: Step[] = Object.keys(STEP_INFO) @@ -83,6 +100,82 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { this.scrollToTop(); }) ); + + // A4: keep the undo/redo toolbar in sync with the history stack. + this.subscription.add( + this.preprocessingService.history$.subscribe(status => { + this.history = status; + }) + ); + + // A4: after an undo/redo reinstalls a snapshot, force the visible step to + // rebuild so it reflects the restored state (steps read state once on init). + this.subscription.add( + this.preprocessingService.stateRestored$.subscribe(() => { + this.reloadCurrentStep(); + }) + ); + } + + // ── A4: Undo/Redo ───────────────────────────────────────────────────────── + + undo(): void { + this.preprocessingService.undo(); + } + + redo(): void { + this.preprocessingService.redo(); + } + + get undoTooltip(): string { + return this.history.canUndo && this.history.undoLabel + ? `Rückgängig: ${this.history.undoLabel}` + : 'Nichts rückgängig zu machen'; + } + + get redoTooltip(): string { + return this.history.canRedo && this.history.redoLabel + ? `Wiederherstellen: ${this.history.redoLabel}` + : 'Nichts wiederherzustellen'; + } + + /** + * Global wizard shortcuts: Strg+Z = undo, Strg+Umschalt+Z (or Strg+Y) = redo. + * When focus is inside a text input/textarea/contenteditable we do nothing and + * let the browser's native field-level undo run instead. + */ + @HostListener('document:keydown', ['$event']) + onKeyDown(event: KeyboardEvent): void { + const ctrl = event.ctrlKey || event.metaKey; + if (!ctrl) return; + + const key = event.key.toLowerCase(); + const isUndo = key === 'z' && !event.shiftKey; + const isRedo = (key === 'z' && event.shiftKey) || key === 'y'; + if (!isUndo && !isRedo) return; + + if (this.isEditableTarget(event.target)) return; + + event.preventDefault(); + if (isRedo) { + this.redo(); + } else { + this.undo(); + } + } + + private isEditableTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; + } + + private reloadCurrentStep(): void { + this.stepVisible = false; + setTimeout(() => { + this.stepVisible = true; + }, 0); } private scrollToTop(): void { diff --git a/src/app/preprocessing-wizard/services/preprocessing.service.ts b/src/app/preprocessing-wizard/services/preprocessing.service.ts index 7d5bfbf..f3e937c 100644 --- a/src/app/preprocessing-wizard/services/preprocessing.service.ts +++ b/src/app/preprocessing-wizard/services/preprocessing.service.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { PreprocessingState, ProcessingProgress, + HistoryStatus, DEFAULT_CLEANING_CONFIG, DEFAULT_PROJECTION_CONFIG, } from '../models/preprocessing-state'; @@ -19,6 +20,31 @@ import { import { DataProcessorService } from '../../services/data-processor'; import { DataLoaderService } from '../../services/data-loader.service'; +/** + * A4 – Undo/Redo. The subset of PreprocessingState that undo restores. Transient + * UI fields (currentStep, isProcessing, error, processedDataset, …) are left out + * so undoing a data-config change never yanks the user to another step or discards + * an in-flight processing run. + */ +interface HistorySnapshot { + rawFileName: string | null; + dataProfile: DataProfile | null; + columnConfigs: Map; + cleaningConfig: CleaningConfig; + projectionConfig: ProjectionConfig; + datasetName: string; + timestamp: string; + glyphFeatures: string[]; + tooltipFeatures: string[]; + colorScaleMode: 'continuous' | 'categorical'; + colorScaleId: number; +} + +interface HistoryEntry { + label: string; // human-readable description of the action this snapshot precedes + snapshot: HistorySnapshot; +} + @Injectable({ providedIn: 'root', }) @@ -33,6 +59,27 @@ export class PreprocessingService { // step component consumes it once after it renders and scrolls into view. private scrollTargetSubject = new BehaviorSubject(null); + // ── A4: Undo/Redo history ──────────────────────────────────────────────── + // Full state-snapshot stack over the undoable portion of the preprocessing + // state. Each completed, state-changing action pushes a snapshot before it + // runs; undo()/redo() move snapshots between the two stacks and reinstall them. + private undoStack: HistoryEntry[] = []; + private redoStack: HistoryEntry[] = []; + private readonly MAX_HISTORY = 50; + + private historySubject = new BehaviorSubject({ + canUndo: false, + canRedo: false, + undoLabel: null, + redoLabel: null, + }); + public history$ = this.historySubject.asObservable(); + + // Emitted after undo()/redo() reinstall a snapshot so the shell can refresh the + // currently rendered step (steps read state once in ngOnInit and cache locally). + private stateRestoredSubject = new Subject(); + public stateRestored$ = this.stateRestoredSubject.asObservable(); + constructor( private dataProcessor: DataProcessorService, private dataLoader: DataLoaderService @@ -154,6 +201,9 @@ export class PreprocessingService { const datasetName = this.getUniqueDatasetName(baseName); const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, ''); + // A4: snapshot the prior data before it is replaced, so a re-upload is undoable. + this.pushHistory(this.currentState.dataProfile !== null ? 'Datei ersetzt' : 'Datei geladen'); + this.updateState({ dataProfile: profile, rawFileName: fileName, @@ -220,6 +270,14 @@ export class PreprocessingService { // Column configuration public updateColumnConfig(columnName: string, updates: Partial): void { + this.pushHistory('Spalteneinstellung geändert'); + this.applyColumnConfig(columnName, updates); + } + + // Applies a column-config change without touching the undo history. Used both by + // the public updateColumnConfig (which pushes first) and by other actions that + // manage their own history entry, so a single action never records twice. + private applyColumnConfig(columnName: string, updates: Partial): void { const configs = this.currentState.columnConfigs; const existing = configs.get(columnName); @@ -326,7 +384,8 @@ export class PreprocessingService { public toggleColumnEnabled(columnName: string): void { const config = this.currentState.columnConfigs.get(columnName); if (config) { - this.updateColumnConfig(columnName, { enabled: !config.enabled }); + this.pushHistory(config.enabled ? 'Spalte abgewählt' : 'Spalte ausgewählt'); + this.applyColumnConfig(columnName, { enabled: !config.enabled }); } } @@ -335,6 +394,7 @@ export class PreprocessingService { * Step 2 range-select (shift-click) and "select all filtered". */ public setColumnsEnabled(columnNames: string[], enabled: boolean): void { + this.pushHistory('Spaltenauswahl geändert'); const configs = this.currentState.columnConfigs; columnNames.forEach(name => { const config = configs.get(name); @@ -347,6 +407,7 @@ export class PreprocessingService { } public selectAllColumns(): void { + this.pushHistory('Alle Spalten ausgewählt'); const configs = this.currentState.columnConfigs; configs.forEach(config => (config.enabled = true)); this.updateState({ columnConfigs: new Map(configs) }); @@ -354,6 +415,7 @@ export class PreprocessingService { } public deselectAllColumns(): void { + this.pushHistory('Alle Spalten abgewählt'); const configs = this.currentState.columnConfigs; configs.forEach(config => { if (config.originalType !== DataType.ID) { @@ -366,6 +428,7 @@ export class PreprocessingService { // Cleaning configuration public updateCleaningConfig(updates: Partial): void { + this.pushHistory('Bereinigung geändert'); this.updateState({ cleaningConfig: { ...this.currentState.cleaningConfig, ...updates }, }); @@ -374,6 +437,7 @@ export class PreprocessingService { // Projection configuration public updateProjectionConfig(updates: Partial): void { + this.pushHistory('Projektionsparameter geändert'); this.updateState({ projectionConfig: { ...this.currentState.projectionConfig, ...updates }, }); @@ -547,9 +611,113 @@ export class PreprocessingService { public resetState(): void { this.stateSubject.next(this.getInitialState()); + this.undoStack = []; + this.redoStack = []; + this.emitHistoryStatus(); this.clearStateFromStorage(); } + // ── A4: Undo/Redo history ──────────────────────────────────────────────── + + /** + * Record a snapshot of the current state before a state-changing action runs. + * `label` describes the action (e.g. "Smart Defaults angewendet") and drives the + * toolbar tooltip. Pushing a new action clears the redo stack, as usual. + * Granularity is per completed action (one call per action), never per keystroke. + */ + public pushHistory(label: string): void { + this.undoStack.push({ label, snapshot: this.captureSnapshot() }); + if (this.undoStack.length > this.MAX_HISTORY) { + this.undoStack.shift(); + } + this.redoStack = []; + this.emitHistoryStatus(); + } + + public get canUndo(): boolean { + return this.undoStack.length > 0; + } + + public get canRedo(): boolean { + return this.redoStack.length > 0; + } + + /** Revert the most recent action; returns its label, or null if nothing to undo. */ + public undo(): string | null { + const entry = this.undoStack.pop(); + if (!entry) return null; + + // Preserve the current state on the redo stack under the same label so redo + // can re-apply the reverted action. + this.redoStack.push({ label: entry.label, snapshot: this.captureSnapshot() }); + this.restoreSnapshot(entry.snapshot); + this.emitHistoryStatus(); + this.stateRestoredSubject.next(); + this.saveStateToStorage(); + return entry.label; + } + + /** Re-apply the most recently undone action; returns its label, or null. */ + public redo(): string | null { + const entry = this.redoStack.pop(); + if (!entry) return null; + + this.undoStack.push({ label: entry.label, snapshot: this.captureSnapshot() }); + this.restoreSnapshot(entry.snapshot); + this.emitHistoryStatus(); + this.stateRestoredSubject.next(); + this.saveStateToStorage(); + return entry.label; + } + + private emitHistoryStatus(): void { + this.historySubject.next({ + canUndo: this.undoStack.length > 0, + canRedo: this.redoStack.length > 0, + undoLabel: this.undoStack.length > 0 ? this.undoStack[this.undoStack.length - 1].label : null, + redoLabel: this.redoStack.length > 0 ? this.redoStack[this.redoStack.length - 1].label : null, + }); + } + + /** Deep-copy the undoable slice of state so later in-place edits cannot mutate it. */ + private captureSnapshot(): HistorySnapshot { + const s = this.currentState; + return { + rawFileName: s.rawFileName, + dataProfile: s.dataProfile, // replaced wholesale on re-upload; reference is safe + columnConfigs: this.cloneColumnConfigs(s.columnConfigs), + cleaningConfig: { ...s.cleaningConfig }, + projectionConfig: { ...s.projectionConfig }, + datasetName: s.datasetName, + timestamp: s.timestamp, + glyphFeatures: [...s.glyphFeatures], + tooltipFeatures: [...s.tooltipFeatures], + colorScaleMode: s.colorScaleMode, + colorScaleId: s.colorScaleId, + }; + } + + /** Install a snapshot, deep-copying again so the stored entry stays pristine. */ + private restoreSnapshot(snap: HistorySnapshot): void { + this.updateState({ + rawFileName: snap.rawFileName, + dataProfile: snap.dataProfile, + columnConfigs: this.cloneColumnConfigs(snap.columnConfigs), + cleaningConfig: { ...snap.cleaningConfig }, + projectionConfig: { ...snap.projectionConfig }, + datasetName: snap.datasetName, + timestamp: snap.timestamp, + glyphFeatures: [...snap.glyphFeatures], + tooltipFeatures: [...snap.tooltipFeatures], + colorScaleMode: snap.colorScaleMode, + colorScaleId: snap.colorScaleId, + }); + } + + private cloneColumnConfigs(configs: Map): Map { + return new Map(Array.from(configs.entries()).map(([name, config]) => [name, { ...config }])); + } + // Persistence private saveStateToStorage(): void { try { diff --git a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts index 520f4d7..7be27f9 100644 --- a/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts +++ b/src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.ts @@ -8,6 +8,7 @@ import { DataProfile } from '../../models/column-statistics'; import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; import { STEP_INFO } from '../../shared/constants/step-info'; import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; +import { ToastService } from '../../../services/toast.service'; @Component({ selector: 'app-step1-upload', @@ -30,7 +31,10 @@ export class Step1UploadComponent implements OnInit, OnDestroy, WizardStep { // Expose step info to template readonly stepInfo = STEP_INFO[0]; // Step 1 (index 0) - constructor(private preprocessingService: PreprocessingService) {} + constructor( + private preprocessingService: PreprocessingService, + private toastService: ToastService + ) {} ngOnInit(): void { // Subscribe to state changes to react to reset @@ -103,10 +107,17 @@ export class Step1UploadComponent implements OnInit, OnDestroy, WizardStep { this.isLoading = true; this.error = null; + // A4: if a dataset was already loaded, this upload replaces it (and its column + // config). loadCSV snapshots the prior state, so we offer a one-click undo. + const wasReplacement = this.preprocessingService.currentState.dataProfile !== null; + try { this.profile = await this.preprocessingService.loadCSV(file); // Don't emit here - let user review the data first // User will click "Continue to Column Selection" button to emit and proceed + if (wasReplacement) { + this.toastService.showUndo('Datei ersetzt', () => this.preprocessingService.undo()); + } } catch (err: unknown) { this.error = err instanceof Error ? err.message : 'Failed to load data file'; this.profile = null; diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts index 365e02d..32c958d 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.ts @@ -18,6 +18,7 @@ import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.com import { HELP_TEXT } from '../../shared/constants/help-text'; import { STEP_INFO } from '../../shared/constants/step-info'; import { DataPreviewTableComponent } from '../../shared/data-preview-table/data-preview-table.component'; +import { ToastService } from '../../../services/toast.service'; interface ColumnConfigState { column: ColumnStatistics; @@ -127,7 +128,10 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { readonly HELP_TEXT = HELP_TEXT; readonly stepInfo = STEP_INFO[2]; // Step 3 (index 2) - constructor(public preprocessingService: PreprocessingService) { + constructor( + public preprocessingService: PreprocessingService, + private toastService: ToastService + ) { this.cleaningConfig = this.preprocessingService.currentState.cleaningConfig; } @@ -268,10 +272,29 @@ export class Step3ConfigureDataFeaturesComponent implements OnInit, WizardStep { } clearFilters(): void { + // A4: filters are view-only state (not part of preprocessing-state), so this + // action is made reversible via a toast whose undo restores the local values, + // rather than through the global snapshot stack. + const previous = { + filterText: this.filterText, + filterType: this.filterType, + showIssuesOnly: this.showIssuesOnly, + }; + const hadActiveFilter = !!this.filterText || this.filterType !== 'all' || this.showIssuesOnly; + this.filterText = ''; this.filterType = 'all'; this.showIssuesOnly = false; this.applyFilters(); + + if (hadActiveFilter) { + this.toastService.showUndo('Filter zurückgesetzt', () => { + this.filterText = previous.filterText; + this.filterType = previous.filterType; + this.showIssuesOnly = previous.showIssuesOnly; + this.applyFilters(); + }); + } } // ============================================================================ diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html index 501aa2a..9244920 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html @@ -39,7 +39,7 @@

+ } } @@ -103,6 +108,25 @@ import { ToastService } from '../../../services/toast.service'; line-height: 1.4; } + .toast-action { + background: rgba(255, 255, 255, 0.22); + border: 1px solid rgba(255, 255, 255, 0.6); + color: white; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + cursor: pointer; + padding: 5px 12px; + border-radius: 5px; + flex-shrink: 0; + transition: background-color 0.15s ease; + } + + .toast-action:hover { + background: rgba(255, 255, 255, 0.35); + } + .toast-close { background: none; border: none; @@ -136,4 +160,11 @@ export class ToastComponent { remove(id: number): void { this.toastService.remove(id); } + + runAction(toast: Toast, event: Event): void { + // Prevent the container's click-to-dismiss from firing before the handler. + event.stopPropagation(); + toast.action?.handler(); + this.remove(toast.id); + } } From b42ccd65795123c6e604c5c5a4f5b18b5b21852e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 20 Jul 2026 19:01:26 +0200 Subject: [PATCH 33/64] docs(a14): Design-Spec ruhiger Morph Schritt 2 zu 3 --- .../2026-07-20-morph-step2-step3-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md diff --git a/docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md b/docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md new file mode 100644 index 0000000..1b3a4f1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md @@ -0,0 +1,119 @@ +# Design: Ruhiger Morph Schritt 2 → Schritt 3 (Preprocessing-Wizard) + +Datum: 2026-07-20 +Branch: feat/a14-animation + +## Problem + +Beim Übergang von Wizard-Schritt 2 (Spaltenauswahl, Tabelle) zu Schritt 3 +(Datenkonfiguration, Rail + Detailbereich) bewegen sich zu viele Elemente in zu +vielen verschiedenen Richtungen gleichzeitig. Optisch wirkt es, als würden alle +Teile aus allen Richtungen zur Mitte fließen und sich dort zusammensetzen +(„Schwarm-Effekt"). Ursachen (aus Codeanalyse): + +1. **Container-Versatz:** Schritt 2 `.step-content` ist auf `max-width:1150px` + zentriert, Schritt 3 nutzt volle Breite. Dadurch liegt die linke Kante der + Liste in Schritt 3 nicht dort, wo die Tabelle in Schritt 2 beginnt → jede + Zeile bekommt einen horizontalen Grund-Offset. +2. **`data-flip-id` auf `

` (Schritt 2) → `
` (Schritt 3) mit + `absolute:true`:** Tabellenzeilen verhalten sich unter `position:absolute` + unzuverlässig; Zeilen springen und „fließen" dann zur Rail. +3. **Kein `stagger`:** Alle Zeilen + Suchbox + Filter animieren gleichzeitig. +4. **Header-Controls aus verschiedenen Ecken:** `col-search` breit oben (S2) vs. + schmal in Rail (S3); `col-typefilter` rechts-mittig (S2) vs. links unter Suche + (S3). +5. **Gegenläufige Bewegung rechts:** Stat-Zellen driften nach rechts (`x:24`), + während die `.detail-well` gleichzeitig von rechts (`xPercent:40`) hereinkommt. + +## Zielbild + +Möglichst wenige, gleichgerichtete Bewegungen (statt ~10 Vektoren). Erst +kollabieren die Stat-Spalten von rechts nach links weg, dann folgt der +Detailbereich als ein Block von rechts. Insgesamt langsamer und lesbar. + +## Getroffene Entscheidungen + +- **Architektur:** Schritt 2 und 3 bleiben getrennte Komponenten; ihre Geometrie + wird hart angeglichen (kein Merge zu einer Komponente). +- **Schritt 2 bleibt eine echte HTML-Tabelle** mit allen Spalten. Keine + Zerlegung in „Zonen". +- **Choreografie:** Kollaps links zuerst, dann Detail von rechts. Name-Spalte + bleibt die ganze Zeit stehen. +- **Timing:** ruhig, ~1,2s gesamt (Phase 1 ~0,6s, Überlappung ~0,1s, Phase 2 + ~0,6s), `power2.inOut`. +- **Suche/Filter:** Schritt-2-Layout bleibt unverändert (Suche breit oben, Filter + rechts daneben). In Phase 1 schrumpft die Suchleiste synchron mit der Tabelle + auf Rail-Breite, der Typ-Filter rutscht darunter. + +## Ansatz + +Kernidee: Der Morph reduziert die Tabelle per **Spaltenkollaps** auf die +Name-Spalte, die deckungsgleich mit der `master-rail` aus Schritt 3 liegt. +Danach slidet der Detailbereich von rechts herein. Zeilen werden nicht mehr per +GSAP Flip gematcht (sie bewegen sich nicht), wodurch das `tr → div`-Problem +entfällt. + +### Geometrie-Angleichung (vorab, ohne Animation sichtbar) + +- Gemeinsamer Container für beide Schritte; der `max-width:1150px`-Versatz in + Schritt 2 wird entfernt bzw. so gesetzt, dass die linke Kante identisch liegt. +- Die Name-Spalte (inkl. vorangestellter Checkbox-Spalte) in Schritt 2 liegt an + der linken Kante und Breite der `master-rail` (Bezug: `$wizard-rail-width:320px`, + `$wizard-list-row-height:56px` aus `_wizard-shared.scss`). +- Zeilenhöhe bleibt 56px (bereits angeglichen). + +### Phase 1 (0,0–0,6s): Kollaps nach links, gestaffelt rechts→links + +- Die vier Stat-Spalten (Data Type, Count, Unique, Distribution) tweenen ihre + Breite auf 0; Inhalt fadet aus. Stagger von der rechtesten Spalte zuerst nach + links. +- Synchron: Suchleiste (`col-search`) schrumpft von voller Breite auf Rail-Breite + (rechte Kante wandert mit den Spalten nach links). Der Typ-Filter + (`col-typefilter`) rutscht per Flip unter die Suche – ein Element, ein Vektor. +- Checkbox- und Name-Spalte bleiben stehen (kein Vektor). + +### Wechsel + +Bei Deckungsgleichheit (Name-Spalte an Rail-Position) erfolgt der +Komponententausch Schritt 2 → 3 unsichtbar. Zeilen fliegen nicht. + +### Phase 2 (0,5–1,2s, ~0,1s Überlappung): Detail von rechts + +- Die `.detail-well` slidet als ein Block von rechts herein (`x` von positiv nach + 0) und fadet ein. + +### Resultat + +Zwei gerichtete Vektoren (Spalten-/Suche-Kollaps nach links, Detail rein von +rechts) plus ein kleiner Filter-Reflow; sonst statisch. + +## Betroffene Dateien + +- `src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html` + – kollabierbare Stat-Spalten (colgroup bzw. Breiten-Hooks), Name-Spalte auf + Rail-Geometrie; `data-flip-id` auf `
` entfernen. +- `src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss` + – Spaltenbreiten/Kollaps-Vorbereitung, Name-Spalte an Rail-Breite. +- `src/app/preprocessing-wizard/preprocessing-wizard.component.ts` + – neue Zwei-Phasen-Timeline; Breiten-Tweens + Stagger; `col-search` / + `col-typefilter` als einzige Flip-Targets; Stat-`x:24`-Drift und + `.detail-well`-`xPercent:40`-Konflikt entfernen; Dauer/Ease anpassen. +- `src/app/preprocessing-wizard/preprocessing-wizard.component.scss` + – gemeinsamer Container, kein Zentrier-Versatz. +- `src/app/preprocessing-wizard/shared/_wizard-shared.scss` + – ggf. geteilte Rail-/Name-Geometrie-Tokens ergänzen/wiederverwenden. +- Schritt 3 (`step3-configure-data-features`) bleibt Referenz und weitgehend + unverändert. + +## Verifikation + +Playwright-Flow (siehe Memory „Wizard visual verification"): Wizard bis Schritt 2 +durchklicken, Übergang 2→3 auslösen, prüfen, dass nur die zwei gerichteten +Bewegungen sichtbar sind (Kollaps nach links, Detail von rechts) und keine Zeilen +quer springen. Sichtprüfung der Deckungsgleichheit beim Komponentenwechsel. + +## Nicht im Scope + +- Kein Merge von Schritt 2 und 3 zu einer Komponente. +- Keine inhaltliche Änderung an Schritt 3. +- Keine Änderung an anderen Wizard-Schritten (1, 4). From e4f12a841c38c354ce7db72e684924ad69364600 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 20 Jul 2026 20:02:30 +0200 Subject: [PATCH 34/64] docs(a14): Implementierungsplan ruhiger Morph Schritt 2 zu 3 --- .../plans/2026-07-20-morph-step2-step3.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-morph-step2-step3.md diff --git a/docs/superpowers/plans/2026-07-20-morph-step2-step3.md b/docs/superpowers/plans/2026-07-20-morph-step2-step3.md new file mode 100644 index 0000000..6d0ba2c --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-morph-step2-step3.md @@ -0,0 +1,259 @@ +# Ruhiger Morph Schritt 2 → 3 – Implementierungsplan + +> **Für agentische Worker:** Umsetzung task-by-task. Da es sich um eine +> GSAP-/CSS-Animation handelt, ist die Verifikation pro Task **visuell** +> (Angular-Build ohne Fehler + Playwright-Sichtprüfung des Übergangs 2→3), nicht +> per Unit-Test. Schritte nutzen Checkbox-Syntax (`- [ ]`). + +**Goal:** Der Übergang von Wizard-Schritt 2 (Tabelle) zu Schritt 3 (Rail + +Detail) soll aus wenigen, gleichgerichteten Bewegungen bestehen statt aus einem +Schwarm: Name-Spalte bleibt stehen, Stat-Spalten kollabieren nach links, dann +slidet der Detailbereich von rechts herein. + +**Architecture:** Zwei getrennte Angular-Komponenten (Step2/Step3), deren +Geometrie hart angeglichen wird. Der Morph läuft weiter über GSAP Flip im +Wizard-Shell (`preprocessing-wizard.component.ts`), aber neu choreografiert: +Zeilen werden nicht mehr per Flip gematcht (kein `tr→div`-Flip), nur noch +Suchbox + Typ-Filter; die Stat-Zellen kollabieren über `onLeave`, die +`.detail-well` kommt über `onEnter`/Timeline von rechts. + +**Tech Stack:** Angular (standalone components), SCSS, GSAP + Flip-Plugin. + +## Global Constraints + +- Schritt 2 bleibt eine echte HTML-`
+ {{ column }}
+ {{ getCellValue(row, column) }}
`. Kein Umbau auf Div-Layout. +- Geteilte Geometrie kommt aus `_wizard-shared.scss` + (`$wizard-rail-width: 320px`, `$wizard-list-row-height: 56px`). Werte dort + zentral halten, nicht in Komponenten duplizieren. +- `prefers-reduced-motion`: Der Morph wird bei reduzierter Bewegung übersprungen + (bestehende `reduceMotion`-Logik in `preprocessing-wizard.component.ts` bleibt). +- Gesamtdauer ~1,2s, `power2.inOut`, Phase 1 ~0,6s, Überlappung ~0,1s, Phase 2 + ~0,6s. + +--- + +### Task 1: Container-Geometrie angleichen (linke Kante + Start-Y) + +Ziel: linke Kante der Name-Spalte (Schritt 2) und der `master-rail` (Schritt 3) +liegen deckungsgleich; die erste Zeile beginnt in beiden Schritten auf gleicher +Höhe. Schritt 2 bleibt optisch unverändert; Schritt 3 wird auf denselben +zentrierten Container gebracht. + +**Files:** +- Modify: `src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss:9-11` + +**Interfaces:** +- Consumes: `w.step-content` Mixin (unverändert), `$wizard-rail-width`. +- Produces: Schritt-3-`.step-content` mit `max-width: 1150px; margin: 0 auto` + (gleich wie Schritt 2, `step2...scss:11-18`). + +- [ ] **Step 1:** In `step3...scss` den `.step-content`-Block erweitern: + +```scss + .step-content { + @include w.step-content; + max-width: 1150px; + margin: 0 auto; + } +``` + +- [ ] **Step 2:** Build prüfen. + +Run: `npx ng build --configuration development` +Expected: Build erfolgreich, keine SCSS-Fehler. + +- [ ] **Step 3:** Sichtprüfung (Playwright, Memory „Wizard visual verification"): +Schritt 2 und Schritt 3 je einen Screenshot. Erwartung: linke Kante der +Name-Spalte (S2) und der Rail (S3) liegen an derselben x-Position; Schritt 2 +sieht unverändert aus. + +- [ ] **Step 4:** Commit. + +```bash +git add src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss +git commit -m "refactor(a14): gemeinsamer zentrierter Container fuer Schritt 2 und 3" +``` + +--- + +### Task 2: Zeilen aus dem Flip-Match nehmen (kein tr→div-Flip) + +Ziel: Tabellenzeilen fliegen nicht mehr. Sie bleiben durch die Geometrie an +ihrem Platz; nur Suchbox und Typ-Filter bleiben Flip-Targets. + +**Files:** +- Modify: `src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html:105-110` +- Modify: `src/app/preprocessing-wizard/preprocessing-wizard.component.ts:153` + +**Interfaces:** +- Consumes: `captureMorphState()` sammelt weiterhin `[data-flip-id]` (jetzt nur + Suchbox + Typ-Filter) und das Leave-Set (Stat-Zellen). +- Produces: `` ohne `data-flip-id`. + +- [ ] **Step 1:** In `step2...html` das `data-flip-id` von der `` entfernen: + +```html + +``` + +- [ ] **Step 2:** In `preprocessing-wizard.component.ts` den Kommentar bei +`captureMorphState` an die neue Realität anpassen (nur Suchbox/Filter gematcht; +Zeilen nicht mehr). Funktional bleibt `querySelectorAll('[data-flip-id]')` +korrekt, da die Zeilen das Attribut nicht mehr tragen. Kommentar in Zeile +147-152 entsprechend kürzen. + +- [ ] **Step 3:** Build prüfen. + +Run: `npx ng build --configuration development` +Expected: Build erfolgreich. + +- [ ] **Step 4:** Commit. + +```bash +git add src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html src/app/preprocessing-wizard/preprocessing-wizard.component.ts +git commit -m "refactor(a14): Tabellenzeilen nicht mehr per Flip morphen" +``` + +--- + +### Task 3: Zwei-Phasen-Choreografie in `runStepMorph` + +Ziel: Phase 1 – Stat-Zellen kollabieren nach links (gestaffelt rechts→links) und +die Rail-Liste in Schritt 3 blendet sanft ein; Phase 2 – `.detail-well` slidet +von rechts herein. Suchbox/Filter gleiten via Flip (nahezu 0, da Geometrie +angeglichen). Gesamt ~1,2s. + +**Files:** +- Modify: `src/app/preprocessing-wizard/preprocessing-wizard.component.ts:170-221` + +**Interfaces:** +- Consumes: `state: Flip.FlipState`, `this.stepContainer`, `this.morphOldHeight`. +- Produces: neue Timeline-Logik in `runStepMorph`. + +- [ ] **Step 1:** `runStepMorph` ersetzen. Kernpunkte: `duration: 0.6`; `onLeave` +kollabiert Stat-Zellen mit `xPercent: -30, autoAlpha: 0`, Stagger +`{ each: 0.015, from: 'end' }` (rechte/untere zuerst → Wirkung rechts→links); +Rail-Liste (`.column-list`) blendet ein (`autoAlpha` 0→1, 0.3s); `.detail-well` +kommt bei `t=0.5` von rechts (`xPercent: 40 → 0`, 0.6s, `power2.out`). +Stat-Drift `x:24` entfällt. + +```typescript + const timeline = Flip.from(state, { + targets: container.querySelectorAll('[data-flip-id]'), + duration: 0.6, + ease: 'power2.inOut', + absolute: true, + // Phase 1: Stat-Spalten kollabieren nach links, gestaffelt rechts->links. + onLeave: leaving => + gsap.to(leaving, { + xPercent: -30, + autoAlpha: 0, + duration: 0.5, + ease: 'power2.in', + stagger: { each: 0.015, from: 'end' }, + }), + }); + + // Rail-Liste sanft einblenden, damit der Zeileninhalt-Wechsel + // (Checkbox weg, Config-Zeile dazu) nicht hart "poppt". + const list = container.querySelector('.column-list'); + if (list) { + timeline.from(list, { autoAlpha: 0, duration: 0.3, ease: 'power1.out' }, 0); + } + + // Phase 2: Detailbereich slidet als ein Block von rechts herein. + const well = container.querySelector('.detail-well'); + if (well) { + timeline.from( + well, + { xPercent: 40, autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, + 0.5 + ); + } + + // Container-Höhe old->new tweenen (kein vertikaler Sprung). + const newHeight = container.offsetHeight; + if (this.morphOldHeight > 0 && Math.abs(this.morphOldHeight - newHeight) > 1) { + timeline.fromTo( + container, + { height: this.morphOldHeight }, + { + height: newHeight, + duration: 1.1, + ease: 'power2.inOut', + onComplete: () => { + container.style.height = ''; + }, + }, + 0 + ); + } +``` + +- [ ] **Step 2:** Build prüfen. + +Run: `npx ng build --configuration development` +Expected: Build erfolgreich. + +- [ ] **Step 3:** Sichtprüfung (Playwright): Übergang 2→3 auslösen und als +Screenshot-Serie / Video beobachten. Erwartung: (a) linke Rail/Name steht; +(b) Stat-Bereich kollabiert nach links und verschwindet; (c) Detailbereich kommt +von rechts; (d) keine Zeilen springen quer. Timing spürbar langsamer (~1,2s). + +- [ ] **Step 4:** Commit. + +```bash +git add src/app/preprocessing-wizard/preprocessing-wizard.component.ts +git commit -m "refactor(a14): Zwei-Phasen-Morph (Kollaps links, Detail von rechts)" +``` + +--- + +### Task 4: Feinschliff Timing/Richtung + Reduced-Motion-Check + +Ziel: Bewegung final abstimmen (Stagger-Richtung wirklich rechts→links, Ease, +Überlappung), Regressionen ausschließen. + +**Files:** +- Modify: `src/app/preprocessing-wizard/preprocessing-wizard.component.ts` (nur + Feintuning der in Task 3 gesetzten Werte, falls die Sichtprüfung es verlangt). + +- [ ] **Step 1:** Falls der Kollaps nicht klar rechts→links wirkt: `from: 'end'` +bzw. `each` anpassen; falls Detail zu früh/spät kommt: Startzeit 0.5 justieren. +Werte nur nach visueller Kontrolle ändern. + +- [ ] **Step 2:** Reduced-Motion prüfen: Mit +`prefers-reduced-motion: reduce` (Playwright `emulateMedia`) Übergang 2→3 +auslösen. Erwartung: sofortiger Wechsel ohne Animation, kein Fehler. + +- [ ] **Step 3:** Finaler Build. + +Run: `npx ng build --configuration development` +Expected: Build erfolgreich. + +- [ ] **Step 4:** Commit (falls Feintuning-Änderungen). + +```bash +git add src/app/preprocessing-wizard/preprocessing-wizard.component.ts +git commit -m "polish(a14): Timing und Richtung des Morphs final abgestimmt" +``` + +--- + +## Self-Review + +- **Spec-Abdeckung:** Container-Angleichung (Task 1) → Ursache 1. Kein tr→div-Flip + (Task 2) → Ursache 2. Stagger + Zwei-Phasen (Task 3) → Ursachen 3+5. Suche/ + Filter via Flip statt querfliegen (bestehend + Task 3) → Ursache 4. Timing + ~1,2s (Task 3/4). Alle fünf Ursachen adressiert. +- **Platzhalter:** keine. +- **Typ-Konsistenz:** `runStepMorph`/`captureMorphState`-Signaturen unverändert; + nur Body angepasst. +- **Offener Punkt (bewusst iterativ):** Der vertikale Start (Info-Box-Höhen, + Padding) kann in Task 1/3 eine kleine y-Differenz lassen; wird per Sichtprüfung + in Task 3 kontrolliert und bei Bedarf über die Container-Höhen-/Padding-Werte + nachgezogen. From 0cb7748b63fdfda50df895b39488879a77d03ce1 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 20 Jul 2026 20:27:39 +0200 Subject: [PATCH 35/64] feat(a14): ruhiger Zwei-Phasen-Morph Schritt 2 zu 3 (Kollaps links, Detail von rechts) Ersetzt den Flip-basierten Morph durch eine gerichtete Zwei-Phasen-Animation: - Phase 1 (vor dem Wechsel): die noch lebende Step-2-Tabelle kollabiert von rechts nach links auf die Rail-Breite (320px), die Stat-Spalten werden am rechten Rand weggeclippt, die Suchleiste schrumpft/fadet mit. - Phase 2 (nach dem Wechsel): der Detailbereich slidet als ein Block von rechts herein, die Rail-Liste blendet sanft ein. Damit entfallen die vielen Streubewegungen des alten Flip-Ansatzes (tr->div unter position:absolute, nicht renderbare Leave-Zellen). Gemeinsamer zentrierter Container fuer Schritt 2 und 3 haelt die linke Kante deckungsgleich. Navigation laeuft ueber onProceed(); reduced-motion wechselt weiterhin sofort ohne Animation. Co-Authored-By: Claude Opus 4.8 --- .../preprocessing-wizard.component.html | 2 +- .../preprocessing-wizard.component.ts | 140 ++++++++++-------- .../step2-column-selection.component.html | 10 +- ...ep3-configure-data-features.component.html | 9 +- ...ep3-configure-data-features.component.scss | 5 + 5 files changed, 92 insertions(+), 74 deletions(-) diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.html b/src/app/preprocessing-wizard/preprocessing-wizard.component.html index 433aa3d..4bb638a 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.html +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.html @@ -70,7 +70,7 @@

Data Preprocessing Wizard

} - diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts index 5178912..9f3249d 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.ts +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy, Output, EventEmitter, ViewChild, ElementR import { Subscription, distinctUntilChanged, map } from 'rxjs'; import { PreprocessingService } from './services/preprocessing.service'; import { ProgressStepperComponent, Step } from './shared/progress-stepper/progress-stepper.component'; -import { WIZARD_STEP } from './shared/wizard-step'; +import { WIZARD_STEP, WizardStep } from './shared/wizard-step'; import { STEP_INFO } from './shared/constants/step-info'; import { Step1UploadComponent } from './steps/step1-upload/step1-upload.component'; import { Step2ColumnSelectionComponent } from './steps/step2-column-selection/step2-column-selection.component'; @@ -11,11 +11,15 @@ import { Step4VisualizationSettingsComponent } from './steps/step4-visualization import { Step5ReviewProcessingComponent } from './steps/step5-review-processing/step5-review-processing.component'; import { PreprocessingState } from './models/preprocessing-state'; import { gsap } from 'gsap'; -import { Flip } from 'gsap/Flip'; -// A14: Register the Flip plugin once at module load. Flip morphs elements across -// the step2 -> step3 @switch component swap by matching `data-flip-id`. -gsap.registerPlugin(Flip); +// A14: Shared rail width (mirrors `$wizard-rail-width` in _wizard-shared.scss). +// Step 2's table collapses to exactly this width before the swap so the leftover +// name band lines up 1:1 with Step 3's master rail. +const WIZARD_RAIL_WIDTH = 320; + +// Global speed factor for the Step 2 -> 3 morph (1 = production; lower = slower, +// useful for frame-by-frame inspection). +const MORPH_TIMESCALE = 1; @Component({ selector: 'app-preprocessing-wizard', @@ -79,12 +83,10 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { const previousStep = this.currentStep; const nextStep = state.currentStep; - // A14: Only the forward Step 2 -> Step 3 transition morphs. Capture the - // Flip state while step2 is still rendered (this subscriber runs - // synchronously before Angular re-renders the @switch), then trigger the - // morph after the new DOM is in place. + // A14: Only the forward Step 2 -> Step 3 transition is animated. Phase 1 + // (collapsing Step 2's table to the rail width) already ran in onProceed + // before this state change fired; here we run Phase 2 after the swap. const shouldMorph = previousStep === 1 && nextStep === 2 && !this.reduceMotion; - const flipState = shouldMorph ? this.captureMorphState() : null; this.currentStep = nextStep; this.isProcessing = state.isProcessing; @@ -98,8 +100,8 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { // Update step completion based on state this.updateStepCompletion(state); - if (flipState) { - this.runStepMorph(flipState); + if (shouldMorph) { + this.runDetailEntrance(); } }) ); @@ -134,40 +136,74 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { } /** - * A14: Capture the GSAP Flip state of the step-2 column list + search box - * (matched across the swap via `data-flip-id`) plus the stat columns that are - * about to leave. Must run while step 2 is still in the DOM. + * A14: Central handler for the primary "continue" button. For the Step 2 -> 3 + * transition (with motion enabled) it first collapses Step 2's table down to + * the rail width (Phase 1) and only then advances the wizard, so the swap + * happens while both steps share the same narrow geometry. Every other step + * proceeds immediately. + */ + onProceed(step: WizardStep): void { + if (this.currentStep === 1 && !this.reduceMotion) { + this.collapseStep2Then(() => step.proceed()); + return; + } + step.proceed(); + } + + /** + * A14 Phase 1: collapse Step 2 on the *live* table (before the component swap), + * so the movement reads as one directed wave from right to left. The table and + * the search/filter bar shrink from full width down to the rail width; the stat + * columns are clipped away at the right edge (overflow hidden). When the wave + * finishes we snapshot the height and run `proceed()`, which triggers Phase 2. */ - private captureMorphState(): Flip.FlipState | null { + private collapseStep2Then(proceed: () => void): void { const container = this.stepContainer; - if (!container) { - return null; + const table = container?.querySelector('.columns-table-container'); + const actions = container?.querySelector('.column-actions'); + if (!container || !table) { + proceed(); + return; } - // Matched, morphing elements (column rows + search box + type filter) share a - // data-flip-id with their step-3 counterparts. The stat cells + select-all - // header cell are captured too so Flip can animate them out via onLeave (they - // only exist in step 2, so their DOM nodes get disconnected by the swap). - // NOTE: `.col-missing` is part of the left band now (it morphs with the row), - // so it is NOT in the leave set. - const morphTargets = Array.from(container.querySelectorAll('[data-flip-id]')); - const leaveTargets = Array.from( - container.querySelectorAll('.col-type, .col-count, .col-unique, .col-distribution, thead .col-checkbox') + const startWidth = table.getBoundingClientRect().width; + const timeline = gsap.timeline({ + onComplete: () => { + // Height the collapsed table occupies right before Step 3 replaces it; + // Phase 2 tweens from here to Step 3's natural height (no vertical jump). + this.morphOldHeight = container.offsetHeight; + proceed(); + }, + }); + timeline.timeScale(MORPH_TIMESCALE); + + // The table narrows to the rail width; stat columns are wiped off the right. + timeline.fromTo( + table, + { width: startWidth, overflow: 'hidden' }, + { width: WIZARD_RAIL_WIDTH, duration: 0.6, ease: 'power2.inOut' }, + 0 ); - this.morphOldHeight = container.offsetHeight; - return Flip.getState([...morphTargets, ...leaveTargets]); + // The search/filter bar shrinks in lockstep and fades so its reflow (filter + // wrapping under the search) is not visible. + if (actions) { + timeline.fromTo( + actions, + { maxWidth: startWidth, overflow: 'hidden' }, + { maxWidth: WIZARD_RAIL_WIDTH, autoAlpha: 0, duration: 0.5, ease: 'power2.in' }, + 0 + ); + } } /** - * A14: After Angular has rendered step 3, morph from the captured state: - * matched left-band rows / search box / type filter glide from the table into - * the master-rail geometry (small delta since both steps now share the rail - * width, header controls and list height), the stat columns collapse away - * (onLeave), and the detail well enters from the right (onEnter). The container - * height is tweened in the same timeline only if a residual mismatch remains. + * A14 Phase 2: after Angular has rendered Step 3, the detail/config well slides + * in from the right as one block while the rail list fades in over the (already + * collapsed) name band. The container height is tweened old -> new to avoid a + * vertical jump, then the inline height is released so flex layout resumes. */ - private runStepMorph(state: Flip.FlipState): void { + private runDetailEntrance(): void { // setTimeout(0) fires after Angular's change detection has swapped the DOM, // mirroring the existing scrollToTop pattern. setTimeout(() => { @@ -176,31 +212,19 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { return; } - const timeline = Flip.from(state, { - targets: container.querySelectorAll('[data-flip-id]'), - duration: 0.5, - ease: 'power2.inOut', - absolute: true, - // Stat columns / select-all: fade out with a small rightward drift as the - // detail well slides in over that area. - onLeave: leaving => - gsap.to(leaving, { - x: 24, - autoAlpha: 0, - duration: 0.3, - ease: 'power2.in', - }), - }); - - // onEnter (step-3 only): the detail/config well slides in from the right. - // The type filter now morphs (shared data-flip-id) instead of appearing. + const timeline = gsap.timeline(); + timeline.timeScale(MORPH_TIMESCALE); + + const list = container.querySelector('.column-list'); + if (list) { + timeline.from(list, { autoAlpha: 0, duration: 0.3, ease: 'power1.out' }, 0); + } + const well = container.querySelector('.detail-well'); if (well) { - timeline.from(well, { xPercent: 40, autoAlpha: 0, duration: 0.5, ease: 'power2.out' }, 0); + timeline.from(well, { xPercent: 40, autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, 0.1); } - // Animate the container height old -> new to prevent a vertical jump, then - // release the inline height so flex layout resumes. const newHeight = container.offsetHeight; if (this.morphOldHeight > 0 && Math.abs(this.morphOldHeight - newHeight) > 1) { timeline.fromTo( @@ -208,7 +232,7 @@ export class PreprocessingWizardComponent implements OnInit, OnDestroy { { height: this.morphOldHeight }, { height: newHeight, - duration: 0.5, + duration: 0.6, ease: 'power2.inOut', onComplete: () => { container.style.height = ''; diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index 030f554..21ef62b 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -10,7 +10,7 @@
-
diff --git a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html index 9daddde..314771f 100644 --- a/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html +++ b/src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.html @@ -27,7 +27,7 @@
DATASET COLUMNS -
`. Kein Umbau auf Div-Layout. -- Geteilte Geometrie kommt aus `_wizard-shared.scss` - (`$wizard-rail-width: 320px`, `$wizard-list-row-height: 56px`). Werte dort - zentral halten, nicht in Komponenten duplizieren. -- `prefers-reduced-motion`: Der Morph wird bei reduzierter Bewegung übersprungen - (bestehende `reduceMotion`-Logik in `preprocessing-wizard.component.ts` bleibt). -- Gesamtdauer ~1,2s, `power2.inOut`, Phase 1 ~0,6s, Überlappung ~0,1s, Phase 2 - ~0,6s. - ---- - -### Task 1: Container-Geometrie angleichen (linke Kante + Start-Y) - -Ziel: linke Kante der Name-Spalte (Schritt 2) und der `master-rail` (Schritt 3) -liegen deckungsgleich; die erste Zeile beginnt in beiden Schritten auf gleicher -Höhe. Schritt 2 bleibt optisch unverändert; Schritt 3 wird auf denselben -zentrierten Container gebracht. - -**Files:** -- Modify: `src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss:9-11` - -**Interfaces:** -- Consumes: `w.step-content` Mixin (unverändert), `$wizard-rail-width`. -- Produces: Schritt-3-`.step-content` mit `max-width: 1150px; margin: 0 auto` - (gleich wie Schritt 2, `step2...scss:11-18`). - -- [ ] **Step 1:** In `step3...scss` den `.step-content`-Block erweitern: - -```scss - .step-content { - @include w.step-content; - max-width: 1150px; - margin: 0 auto; - } -``` - -- [ ] **Step 2:** Build prüfen. - -Run: `npx ng build --configuration development` -Expected: Build erfolgreich, keine SCSS-Fehler. - -- [ ] **Step 3:** Sichtprüfung (Playwright, Memory „Wizard visual verification"): -Schritt 2 und Schritt 3 je einen Screenshot. Erwartung: linke Kante der -Name-Spalte (S2) und der Rail (S3) liegen an derselben x-Position; Schritt 2 -sieht unverändert aus. - -- [ ] **Step 4:** Commit. - -```bash -git add src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.scss -git commit -m "refactor(a14): gemeinsamer zentrierter Container fuer Schritt 2 und 3" -``` - ---- - -### Task 2: Zeilen aus dem Flip-Match nehmen (kein tr→div-Flip) - -Ziel: Tabellenzeilen fliegen nicht mehr. Sie bleiben durch die Geometrie an -ihrem Platz; nur Suchbox und Typ-Filter bleiben Flip-Targets. - -**Files:** -- Modify: `src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html:105-110` -- Modify: `src/app/preprocessing-wizard/preprocessing-wizard.component.ts:153` - -**Interfaces:** -- Consumes: `captureMorphState()` sammelt weiterhin `[data-flip-id]` (jetzt nur - Suchbox + Typ-Filter) und das Leave-Set (Stat-Zellen). -- Produces: `` ohne `data-flip-id`. - -- [ ] **Step 1:** In `step2...html` das `data-flip-id` von der `` entfernen: - -```html - -``` - -- [ ] **Step 2:** In `preprocessing-wizard.component.ts` den Kommentar bei -`captureMorphState` an die neue Realität anpassen (nur Suchbox/Filter gematcht; -Zeilen nicht mehr). Funktional bleibt `querySelectorAll('[data-flip-id]')` -korrekt, da die Zeilen das Attribut nicht mehr tragen. Kommentar in Zeile -147-152 entsprechend kürzen. - -- [ ] **Step 3:** Build prüfen. - -Run: `npx ng build --configuration development` -Expected: Build erfolgreich. - -- [ ] **Step 4:** Commit. - -```bash -git add src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html src/app/preprocessing-wizard/preprocessing-wizard.component.ts -git commit -m "refactor(a14): Tabellenzeilen nicht mehr per Flip morphen" -``` - ---- - -### Task 3: Zwei-Phasen-Choreografie in `runStepMorph` - -Ziel: Phase 1 – Stat-Zellen kollabieren nach links (gestaffelt rechts→links) und -die Rail-Liste in Schritt 3 blendet sanft ein; Phase 2 – `.detail-well` slidet -von rechts herein. Suchbox/Filter gleiten via Flip (nahezu 0, da Geometrie -angeglichen). Gesamt ~1,2s. - -**Files:** -- Modify: `src/app/preprocessing-wizard/preprocessing-wizard.component.ts:170-221` - -**Interfaces:** -- Consumes: `state: Flip.FlipState`, `this.stepContainer`, `this.morphOldHeight`. -- Produces: neue Timeline-Logik in `runStepMorph`. - -- [ ] **Step 1:** `runStepMorph` ersetzen. Kernpunkte: `duration: 0.6`; `onLeave` -kollabiert Stat-Zellen mit `xPercent: -30, autoAlpha: 0`, Stagger -`{ each: 0.015, from: 'end' }` (rechte/untere zuerst → Wirkung rechts→links); -Rail-Liste (`.column-list`) blendet ein (`autoAlpha` 0→1, 0.3s); `.detail-well` -kommt bei `t=0.5` von rechts (`xPercent: 40 → 0`, 0.6s, `power2.out`). -Stat-Drift `x:24` entfällt. - -```typescript - const timeline = Flip.from(state, { - targets: container.querySelectorAll('[data-flip-id]'), - duration: 0.6, - ease: 'power2.inOut', - absolute: true, - // Phase 1: Stat-Spalten kollabieren nach links, gestaffelt rechts->links. - onLeave: leaving => - gsap.to(leaving, { - xPercent: -30, - autoAlpha: 0, - duration: 0.5, - ease: 'power2.in', - stagger: { each: 0.015, from: 'end' }, - }), - }); - - // Rail-Liste sanft einblenden, damit der Zeileninhalt-Wechsel - // (Checkbox weg, Config-Zeile dazu) nicht hart "poppt". - const list = container.querySelector('.column-list'); - if (list) { - timeline.from(list, { autoAlpha: 0, duration: 0.3, ease: 'power1.out' }, 0); - } - - // Phase 2: Detailbereich slidet als ein Block von rechts herein. - const well = container.querySelector('.detail-well'); - if (well) { - timeline.from( - well, - { xPercent: 40, autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, - 0.5 - ); - } - - // Container-Höhe old->new tweenen (kein vertikaler Sprung). - const newHeight = container.offsetHeight; - if (this.morphOldHeight > 0 && Math.abs(this.morphOldHeight - newHeight) > 1) { - timeline.fromTo( - container, - { height: this.morphOldHeight }, - { - height: newHeight, - duration: 1.1, - ease: 'power2.inOut', - onComplete: () => { - container.style.height = ''; - }, - }, - 0 - ); - } -``` - -- [ ] **Step 2:** Build prüfen. - -Run: `npx ng build --configuration development` -Expected: Build erfolgreich. - -- [ ] **Step 3:** Sichtprüfung (Playwright): Übergang 2→3 auslösen und als -Screenshot-Serie / Video beobachten. Erwartung: (a) linke Rail/Name steht; -(b) Stat-Bereich kollabiert nach links und verschwindet; (c) Detailbereich kommt -von rechts; (d) keine Zeilen springen quer. Timing spürbar langsamer (~1,2s). - -- [ ] **Step 4:** Commit. - -```bash -git add src/app/preprocessing-wizard/preprocessing-wizard.component.ts -git commit -m "refactor(a14): Zwei-Phasen-Morph (Kollaps links, Detail von rechts)" -``` - ---- - -### Task 4: Feinschliff Timing/Richtung + Reduced-Motion-Check - -Ziel: Bewegung final abstimmen (Stagger-Richtung wirklich rechts→links, Ease, -Überlappung), Regressionen ausschließen. - -**Files:** -- Modify: `src/app/preprocessing-wizard/preprocessing-wizard.component.ts` (nur - Feintuning der in Task 3 gesetzten Werte, falls die Sichtprüfung es verlangt). - -- [ ] **Step 1:** Falls der Kollaps nicht klar rechts→links wirkt: `from: 'end'` -bzw. `each` anpassen; falls Detail zu früh/spät kommt: Startzeit 0.5 justieren. -Werte nur nach visueller Kontrolle ändern. - -- [ ] **Step 2:** Reduced-Motion prüfen: Mit -`prefers-reduced-motion: reduce` (Playwright `emulateMedia`) Übergang 2→3 -auslösen. Erwartung: sofortiger Wechsel ohne Animation, kein Fehler. - -- [ ] **Step 3:** Finaler Build. - -Run: `npx ng build --configuration development` -Expected: Build erfolgreich. - -- [ ] **Step 4:** Commit (falls Feintuning-Änderungen). - -```bash -git add src/app/preprocessing-wizard/preprocessing-wizard.component.ts -git commit -m "polish(a14): Timing und Richtung des Morphs final abgestimmt" -``` - ---- - -## Self-Review - -- **Spec-Abdeckung:** Container-Angleichung (Task 1) → Ursache 1. Kein tr→div-Flip - (Task 2) → Ursache 2. Stagger + Zwei-Phasen (Task 3) → Ursachen 3+5. Suche/ - Filter via Flip statt querfliegen (bestehend + Task 3) → Ursache 4. Timing - ~1,2s (Task 3/4). Alle fünf Ursachen adressiert. -- **Platzhalter:** keine. -- **Typ-Konsistenz:** `runStepMorph`/`captureMorphState`-Signaturen unverändert; - nur Body angepasst. -- **Offener Punkt (bewusst iterativ):** Der vertikale Start (Info-Box-Höhen, - Padding) kann in Task 1/3 eine kleine y-Differenz lassen; wird per Sichtprüfung - in Task 3 kontrolliert und bei Bedarf über die Container-Höhen-/Padding-Werte - nachgezogen. diff --git a/docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md b/docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md deleted file mode 100644 index 1b3a4f1..0000000 --- a/docs/superpowers/specs/2026-07-20-morph-step2-step3-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# Design: Ruhiger Morph Schritt 2 → Schritt 3 (Preprocessing-Wizard) - -Datum: 2026-07-20 -Branch: feat/a14-animation - -## Problem - -Beim Übergang von Wizard-Schritt 2 (Spaltenauswahl, Tabelle) zu Schritt 3 -(Datenkonfiguration, Rail + Detailbereich) bewegen sich zu viele Elemente in zu -vielen verschiedenen Richtungen gleichzeitig. Optisch wirkt es, als würden alle -Teile aus allen Richtungen zur Mitte fließen und sich dort zusammensetzen -(„Schwarm-Effekt"). Ursachen (aus Codeanalyse): - -1. **Container-Versatz:** Schritt 2 `.step-content` ist auf `max-width:1150px` - zentriert, Schritt 3 nutzt volle Breite. Dadurch liegt die linke Kante der - Liste in Schritt 3 nicht dort, wo die Tabelle in Schritt 2 beginnt → jede - Zeile bekommt einen horizontalen Grund-Offset. -2. **`data-flip-id` auf `` (Schritt 2) → `
` (Schritt 3) mit - `absolute:true`:** Tabellenzeilen verhalten sich unter `position:absolute` - unzuverlässig; Zeilen springen und „fließen" dann zur Rail. -3. **Kein `stagger`:** Alle Zeilen + Suchbox + Filter animieren gleichzeitig. -4. **Header-Controls aus verschiedenen Ecken:** `col-search` breit oben (S2) vs. - schmal in Rail (S3); `col-typefilter` rechts-mittig (S2) vs. links unter Suche - (S3). -5. **Gegenläufige Bewegung rechts:** Stat-Zellen driften nach rechts (`x:24`), - während die `.detail-well` gleichzeitig von rechts (`xPercent:40`) hereinkommt. - -## Zielbild - -Möglichst wenige, gleichgerichtete Bewegungen (statt ~10 Vektoren). Erst -kollabieren die Stat-Spalten von rechts nach links weg, dann folgt der -Detailbereich als ein Block von rechts. Insgesamt langsamer und lesbar. - -## Getroffene Entscheidungen - -- **Architektur:** Schritt 2 und 3 bleiben getrennte Komponenten; ihre Geometrie - wird hart angeglichen (kein Merge zu einer Komponente). -- **Schritt 2 bleibt eine echte HTML-Tabelle** mit allen Spalten. Keine - Zerlegung in „Zonen". -- **Choreografie:** Kollaps links zuerst, dann Detail von rechts. Name-Spalte - bleibt die ganze Zeit stehen. -- **Timing:** ruhig, ~1,2s gesamt (Phase 1 ~0,6s, Überlappung ~0,1s, Phase 2 - ~0,6s), `power2.inOut`. -- **Suche/Filter:** Schritt-2-Layout bleibt unverändert (Suche breit oben, Filter - rechts daneben). In Phase 1 schrumpft die Suchleiste synchron mit der Tabelle - auf Rail-Breite, der Typ-Filter rutscht darunter. - -## Ansatz - -Kernidee: Der Morph reduziert die Tabelle per **Spaltenkollaps** auf die -Name-Spalte, die deckungsgleich mit der `master-rail` aus Schritt 3 liegt. -Danach slidet der Detailbereich von rechts herein. Zeilen werden nicht mehr per -GSAP Flip gematcht (sie bewegen sich nicht), wodurch das `tr → div`-Problem -entfällt. - -### Geometrie-Angleichung (vorab, ohne Animation sichtbar) - -- Gemeinsamer Container für beide Schritte; der `max-width:1150px`-Versatz in - Schritt 2 wird entfernt bzw. so gesetzt, dass die linke Kante identisch liegt. -- Die Name-Spalte (inkl. vorangestellter Checkbox-Spalte) in Schritt 2 liegt an - der linken Kante und Breite der `master-rail` (Bezug: `$wizard-rail-width:320px`, - `$wizard-list-row-height:56px` aus `_wizard-shared.scss`). -- Zeilenhöhe bleibt 56px (bereits angeglichen). - -### Phase 1 (0,0–0,6s): Kollaps nach links, gestaffelt rechts→links - -- Die vier Stat-Spalten (Data Type, Count, Unique, Distribution) tweenen ihre - Breite auf 0; Inhalt fadet aus. Stagger von der rechtesten Spalte zuerst nach - links. -- Synchron: Suchleiste (`col-search`) schrumpft von voller Breite auf Rail-Breite - (rechte Kante wandert mit den Spalten nach links). Der Typ-Filter - (`col-typefilter`) rutscht per Flip unter die Suche – ein Element, ein Vektor. -- Checkbox- und Name-Spalte bleiben stehen (kein Vektor). - -### Wechsel - -Bei Deckungsgleichheit (Name-Spalte an Rail-Position) erfolgt der -Komponententausch Schritt 2 → 3 unsichtbar. Zeilen fliegen nicht. - -### Phase 2 (0,5–1,2s, ~0,1s Überlappung): Detail von rechts - -- Die `.detail-well` slidet als ein Block von rechts herein (`x` von positiv nach - 0) und fadet ein. - -### Resultat - -Zwei gerichtete Vektoren (Spalten-/Suche-Kollaps nach links, Detail rein von -rechts) plus ein kleiner Filter-Reflow; sonst statisch. - -## Betroffene Dateien - -- `src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html` - – kollabierbare Stat-Spalten (colgroup bzw. Breiten-Hooks), Name-Spalte auf - Rail-Geometrie; `data-flip-id` auf `
` entfernen. -- `src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss` - – Spaltenbreiten/Kollaps-Vorbereitung, Name-Spalte an Rail-Breite. -- `src/app/preprocessing-wizard/preprocessing-wizard.component.ts` - – neue Zwei-Phasen-Timeline; Breiten-Tweens + Stagger; `col-search` / - `col-typefilter` als einzige Flip-Targets; Stat-`x:24`-Drift und - `.detail-well`-`xPercent:40`-Konflikt entfernen; Dauer/Ease anpassen. -- `src/app/preprocessing-wizard/preprocessing-wizard.component.scss` - – gemeinsamer Container, kein Zentrier-Versatz. -- `src/app/preprocessing-wizard/shared/_wizard-shared.scss` - – ggf. geteilte Rail-/Name-Geometrie-Tokens ergänzen/wiederverwenden. -- Schritt 3 (`step3-configure-data-features`) bleibt Referenz und weitgehend - unverändert. - -## Verifikation - -Playwright-Flow (siehe Memory „Wizard visual verification"): Wizard bis Schritt 2 -durchklicken, Übergang 2→3 auslösen, prüfen, dass nur die zwei gerichteten -Bewegungen sichtbar sind (Kollaps nach links, Detail von rechts) und keine Zeilen -quer springen. Sichtprüfung der Deckungsgleichheit beim Komponentenwechsel. - -## Nicht im Scope - -- Kein Merge von Schritt 2 und 3 zu einer Komponente. -- Keine inhaltliche Änderung an Schritt 3. -- Keine Änderung an anderen Wizard-Schritten (1, 4). From f17e59e946069dfd6aef544508d78870197115bc Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 22 Jul 2026 14:02:07 +0200 Subject: [PATCH 60/64] feat(a2): Smart Defaults erweitern - Hochkardinalitaet Schritt 2 + kluegerer colorFeature-Default A2-Nachtrag (Hoch + Mittel): - Hochkardinalitaets-Heuristik: Text/Categorical/ID-Spalten mit >=90% einzigartigen Werten (und >20 Zeilen) starten abgewaehlt; Numeric/Date ausgenommen. isHighCardinality + issueDescription in preprocessing.service.ts - Schritt 2: expliziter 'Auto-deselected'-Marker mit Begruendung (issueDescription) getrennt vom Qualitaets-Warnicon; Tipp-Text erweitert um 100%-Unique/Reaktivierung - Schritt 4: colorFeature-Default waehlt niedrig-kardinales Kategoriemerkmal, sonst varianzstaerkste Numeric, statt columns[0]; Hinweis im Erklaertext - 7 Unit-Tests fuer die Auto-Deselect-Heuristik --- .../services/preprocessing.service.spec.ts | 221 ++++++++++++++++-- .../services/preprocessing.service.ts | 51 +++- .../shared/constants/help-text.ts | 2 +- .../step2-column-selection.component.html | 10 +- .../step2-column-selection.component.scss | 16 ++ .../step2-column-selection.component.ts | 14 ++ .../step4-visualization-settings.component.ts | 40 +++- 7 files changed, 323 insertions(+), 31 deletions(-) diff --git a/src/app/preprocessing-wizard/services/preprocessing.service.spec.ts b/src/app/preprocessing-wizard/services/preprocessing.service.spec.ts index 076036a..9c9a489 100644 --- a/src/app/preprocessing-wizard/services/preprocessing.service.spec.ts +++ b/src/app/preprocessing-wizard/services/preprocessing.service.spec.ts @@ -322,7 +322,9 @@ describe('PreprocessingService – Undo/Redo history', () => { function mutateColumn(field: K, value: ColumnConfig[K]): void { const configs = new Map(service.currentState.columnConfigs); configs.set('alpha', { ...configs.get('alpha')!, [field]: value }); - (service as unknown as { updateState(u: Partial): void }).updateState({ columnConfigs: configs }); + (service as unknown as { updateState(u: Partial): void }).updateState({ + columnConfigs: configs, + }); } function setTop(updates: Partial): void { @@ -330,22 +332,118 @@ describe('PreprocessingService – Undo/Redo history', () => { } const cases: { name: string; mutate: () => void; label: string; step: number | null; anchor: string | null }[] = [ - { name: 'rawFileName', mutate: () => setTop({ rawFileName: 'anders.csv' }), label: 'Datei', step: 0, anchor: null }, - { name: 'datasetName', mutate: () => setTop({ datasetName: 'umbenannt' }), label: 'Datensatzname', step: 0, anchor: null }, - { name: 'columnConfigs.enabled', mutate: () => mutateColumn('enabled', false), label: 'Spaltenauswahl', step: 1, anchor: 'wizard-anchor-columns' }, - { name: 'columnConfigs.isColorFeature', mutate: () => mutateColumn('isColorFeature', true), label: 'Farbattribut', step: 3, anchor: 'wizard-anchor-color' }, - { name: 'columnConfigs.targetType', mutate: () => mutateColumn('targetType', DataType.Categorical), label: 'Datentyp', step: 2, anchor: 'wizard-anchor-features' }, - { name: 'columnConfigs.encodingMethod', mutate: () => mutateColumn('encodingMethod', EncodingMethod.Standardize), label: 'Encoding', step: 2, anchor: 'wizard-anchor-features' }, - { name: 'columnConfigs.scalingMethod', mutate: () => mutateColumn('scalingMethod', ScalingMethod.Standard), label: 'Skalierung', step: 2, anchor: 'wizard-anchor-features' }, - { name: 'columnConfigs.includeInProjection', mutate: () => mutateColumn('includeInProjection', false), label: 'Projektionsspalten', step: 3, anchor: 'wizard-anchor-projection-columns' }, - { name: 'columnConfigs.missingValueStrategy', mutate: () => mutateColumn('missingValueStrategy', MissingValueStrategy.FillMean), label: 'Fehlende Werte', step: 2, anchor: 'wizard-anchor-features' }, - { name: 'columnConfigs.outlierStrategy', mutate: () => mutateColumn('outlierStrategy', OutlierStrategy.Remove), label: 'Ausreißerbehandlung', step: 2, anchor: 'wizard-anchor-features' }, - { name: 'cleaningConfig', mutate: () => setTop({ cleaningConfig: { removeDuplicates: true } }), label: 'Datenbereinigung', step: 2, anchor: 'wizard-anchor-cleaning' }, - { name: 'projectionConfig', mutate: () => setTop({ projectionConfig: { ...service.currentState.projectionConfig, enablePCA: false } }), label: 'Projektionsparameter', step: 3, anchor: 'wizard-anchor-methods' }, - { name: 'glyphFeatures', mutate: () => setTop({ glyphFeatures: ['alpha'] }), label: 'Glyph-Merkmale', step: 3, anchor: 'wizard-anchor-glyph' }, - { name: 'tooltipFeatures', mutate: () => setTop({ tooltipFeatures: ['alpha'] }), label: 'Tooltip-Merkmale', step: 3, anchor: 'wizard-anchor-glyph' }, - { name: 'colorScaleMode', mutate: () => setTop({ colorScaleMode: 'categorical' }), label: 'Farbmodus', step: 3, anchor: 'wizard-anchor-color' }, - { name: 'colorScaleId', mutate: () => setTop({ colorScaleId: 5 }), label: 'Farbskala', step: 3, anchor: 'wizard-anchor-color' }, + { + name: 'rawFileName', + mutate: () => setTop({ rawFileName: 'anders.csv' }), + label: 'Datei', + step: 0, + anchor: null, + }, + { + name: 'datasetName', + mutate: () => setTop({ datasetName: 'umbenannt' }), + label: 'Datensatzname', + step: 0, + anchor: null, + }, + { + name: 'columnConfigs.enabled', + mutate: () => mutateColumn('enabled', false), + label: 'Spaltenauswahl', + step: 1, + anchor: 'wizard-anchor-columns', + }, + { + name: 'columnConfigs.isColorFeature', + mutate: () => mutateColumn('isColorFeature', true), + label: 'Farbattribut', + step: 3, + anchor: 'wizard-anchor-color', + }, + { + name: 'columnConfigs.targetType', + mutate: () => mutateColumn('targetType', DataType.Categorical), + label: 'Datentyp', + step: 2, + anchor: 'wizard-anchor-features', + }, + { + name: 'columnConfigs.encodingMethod', + mutate: () => mutateColumn('encodingMethod', EncodingMethod.Standardize), + label: 'Encoding', + step: 2, + anchor: 'wizard-anchor-features', + }, + { + name: 'columnConfigs.scalingMethod', + mutate: () => mutateColumn('scalingMethod', ScalingMethod.Standard), + label: 'Skalierung', + step: 2, + anchor: 'wizard-anchor-features', + }, + { + name: 'columnConfigs.includeInProjection', + mutate: () => mutateColumn('includeInProjection', false), + label: 'Projektionsspalten', + step: 3, + anchor: 'wizard-anchor-projection-columns', + }, + { + name: 'columnConfigs.missingValueStrategy', + mutate: () => mutateColumn('missingValueStrategy', MissingValueStrategy.FillMean), + label: 'Fehlende Werte', + step: 2, + anchor: 'wizard-anchor-features', + }, + { + name: 'columnConfigs.outlierStrategy', + mutate: () => mutateColumn('outlierStrategy', OutlierStrategy.Remove), + label: 'Ausreißerbehandlung', + step: 2, + anchor: 'wizard-anchor-features', + }, + { + name: 'cleaningConfig', + mutate: () => setTop({ cleaningConfig: { removeDuplicates: true } }), + label: 'Datenbereinigung', + step: 2, + anchor: 'wizard-anchor-cleaning', + }, + { + name: 'projectionConfig', + mutate: () => setTop({ projectionConfig: { ...service.currentState.projectionConfig, enablePCA: false } }), + label: 'Projektionsparameter', + step: 3, + anchor: 'wizard-anchor-methods', + }, + { + name: 'glyphFeatures', + mutate: () => setTop({ glyphFeatures: ['alpha'] }), + label: 'Glyph-Merkmale', + step: 3, + anchor: 'wizard-anchor-glyph', + }, + { + name: 'tooltipFeatures', + mutate: () => setTop({ tooltipFeatures: ['alpha'] }), + label: 'Tooltip-Merkmale', + step: 3, + anchor: 'wizard-anchor-glyph', + }, + { + name: 'colorScaleMode', + mutate: () => setTop({ colorScaleMode: 'categorical' }), + label: 'Farbmodus', + step: 3, + anchor: 'wizard-anchor-color', + }, + { + name: 'colorScaleId', + mutate: () => setTop({ colorScaleId: 5 }), + label: 'Farbskala', + step: 3, + anchor: 'wizard-anchor-color', + }, ]; cases.forEach(c => { @@ -361,7 +459,9 @@ describe('PreprocessingService – Undo/Redo history', () => { service.pushHistory('irgendeine Aktion'); const configs = new Map(service.currentState.columnConfigs); configs.delete('gamma'); - (service as unknown as { updateState(u: Partial): void }).updateState({ columnConfigs: configs }); + (service as unknown as { updateState(u: Partial): void }).updateState({ + columnConfigs: configs, + }); const info = service.undo()!; expect(info.settingLabel).toBe('Spaltenauswahl'); }); @@ -429,7 +529,9 @@ describe('PreprocessingService – Undo/Redo history', () => { }; function serviceWithLoader() { - const proc = { profileData: async () => profile } as unknown as ConstructorParameters[0]; + const proc = { profileData: async () => profile } as unknown as ConstructorParameters< + typeof PreprocessingService + >[0]; return new PreprocessingService(proc, dataLoaderStub); } @@ -533,3 +635,84 @@ describe('PreprocessingService — error handling (A3)', () => { }); }); }); + +/** + * A2-Nachtrag – smart defaults for the initial column selection. + * + * createDefaultColumnConfig is private and only reached via loadCSV (worker), so + * these tests call it directly through a cast to pin the auto-deselect heuristic: + * mostly-missing / constant columns, and the new high-cardinality rule for + * text/categorical/ID columns whose values are nearly all unique. + */ +describe('PreprocessingService – smart-default column selection', () => { + let service: PreprocessingService; + + const dataProcessorStub = {} as unknown as ConstructorParameters[0]; + const dataLoaderStub = { + getDataSetNames: () => [] as string[], + } as unknown as ConstructorParameters[1]; + + function col(overrides: Partial): ColumnStatistics { + return { + name: 'c', + dataType: DataType.Categorical, + count: 100, + missingCount: 0, + missingPercentage: 0, + uniqueCount: 5, + ...overrides, + }; + } + + function defaultConfig(c: ColumnStatistics): ColumnConfig { + return ( + service as unknown as { createDefaultColumnConfig(c: ColumnStatistics): ColumnConfig } + ).createDefaultColumnConfig(c); + } + + beforeEach(() => { + service = new PreprocessingService(dataProcessorStub, dataLoaderStub); + }); + + it('deselects a nearly all-unique text column (likely ID/free text)', () => { + const config = defaultConfig(col({ dataType: DataType.Text, count: 100, uniqueCount: 100 })); + expect(config.enabled).toBeFalse(); + expect(config.hasIssues).toBeFalse(); // high cardinality is not a data-quality issue + expect(config.issueDescription).toContain('unique'); + }); + + it('deselects a high-cardinality categorical column at the 90% threshold', () => { + const config = defaultConfig(col({ dataType: DataType.Categorical, count: 100, uniqueCount: 95 })); + expect(config.enabled).toBeFalse(); + }); + + it('keeps a low-cardinality categorical column', () => { + const config = defaultConfig(col({ dataType: DataType.Categorical, count: 100, uniqueCount: 5 })); + expect(config.enabled).toBeTrue(); + expect(config.issueDescription).toBeUndefined(); + }); + + it('does not treat a near-unique numeric column as high cardinality', () => { + const config = defaultConfig(col({ dataType: DataType.Numeric, count: 100, uniqueCount: 100 })); + expect(config.enabled).toBeTrue(); + expect(config.issueDescription).toBeUndefined(); + }); + + it('ignores high uniqueness on tiny datasets (below the min-row guard)', () => { + const config = defaultConfig(col({ dataType: DataType.Text, count: 15, uniqueCount: 15 })); + expect(config.enabled).toBeTrue(); + }); + + it('deselects a mostly-empty column and flags it as a quality issue', () => { + const config = defaultConfig(col({ missingPercentage: 80, uniqueCount: 5 })); + expect(config.enabled).toBeFalse(); + expect(config.hasIssues).toBeTrue(); + expect(config.issueDescription).toContain('missing'); + }); + + it('deselects a constant column', () => { + const config = defaultConfig(col({ uniqueCount: 1 })); + expect(config.enabled).toBeFalse(); + expect(config.hasIssues).toBeTrue(); + }); +}); diff --git a/src/app/preprocessing-wizard/services/preprocessing.service.ts b/src/app/preprocessing-wizard/services/preprocessing.service.ts index 0021f47..1c76033 100644 --- a/src/app/preprocessing-wizard/services/preprocessing.service.ts +++ b/src/app/preprocessing-wizard/services/preprocessing.service.ts @@ -83,7 +83,11 @@ const SETTING_META: Record = { 'columnConfigs.outlierStrategy': { label: 'Ausreißerbehandlung', step: 2, anchorId: 'wizard-anchor-features' }, cleaningConfig: { label: 'Datenbereinigung', step: 2, anchorId: 'wizard-anchor-cleaning' }, // Step 4 – Visualisierung (color / glyph / projection live here) - 'columnConfigs.includeInProjection': { label: 'Projektionsspalten', step: 3, anchorId: 'wizard-anchor-projection-columns' }, + 'columnConfigs.includeInProjection': { + label: 'Projektionsspalten', + step: 3, + anchorId: 'wizard-anchor-projection-columns', + }, 'columnConfigs.isColorFeature': { label: 'Farbattribut', step: 3, anchorId: 'wizard-anchor-color' }, isColorFeature: { label: 'Farbattribut', step: 3, anchorId: 'wizard-anchor-color' }, colorScaleId: { label: 'Farbskala', step: 3, anchorId: 'wizard-anchor-color' }, @@ -275,12 +279,48 @@ export class PreprocessingService { } } + private static readonly HIGH_CARDINALITY_RATIO = 0.9; + private static readonly HIGH_CARDINALITY_MIN_ROWS = 20; + + /** + * A text/categorical/ID column counts as high-cardinality when nearly all of + * its values are unique (typical of IDs, titles or free text). Such columns + * carry little structure for a projection and would explode one-hot encoding, + * so they start deselected as a smart default. Numeric/date columns are exempt — + * near-unique values there are legitimate measurements. + */ + private isHighCardinality(col: ColumnStatistics): boolean { + const eligible = + col.dataType === DataType.Text || col.dataType === DataType.Categorical || col.dataType === DataType.ID; + if (!eligible || col.count <= PreprocessingService.HIGH_CARDINALITY_MIN_ROWS) { + return false; + } + return col.uniqueCount / col.count >= PreprocessingService.HIGH_CARDINALITY_RATIO; + } + private createDefaultColumnConfig(col: ColumnStatistics): ColumnConfig { const capabilities = DATA_TYPE_CONFIG[col.dataType] ?? DATA_TYPE_CONFIG[DataType.Unknown]; - // Smart default: obviously unsuitable columns (mostly missing or constant) - // start deselected. Users can re-enable them in Step 2 at any time. - const hasIssues = col.missingPercentage > 50 || col.uniqueCount === 1; + // Smart defaults: obviously unsuitable columns start deselected; users can + // re-enable them in Step 2 at any time. issueDescription records WHY a column + // was auto-deselected so Step 2 can show a reason next to it. + const isMostlyMissing = col.missingPercentage > 50; + const isConstant = col.uniqueCount === 1; + const isHighCardinality = this.isHighCardinality(col); + + // hasIssues drives the data-quality warning icon (mostly-missing / constant). + const hasIssues = isMostlyMissing || isConstant; + const autoDeselected = hasIssues || isHighCardinality; + + let issueDescription: string | undefined; + if (isMostlyMissing) { + issueDescription = `Mostly empty (${Math.round(col.missingPercentage)}% missing)`; + } else if (isConstant) { + issueDescription = 'Only one unique value — nothing to distinguish rows'; + } else if (isHighCardinality) { + const pct = Math.round((col.uniqueCount / col.count) * 100); + issueDescription = `Nearly all values are unique (${pct}%) — likely an ID, title or free-text column`; + } return { name: col.name, @@ -293,8 +333,9 @@ export class PreprocessingService { missingValueStrategy: MissingValueStrategy.Keep, outlierMethod: OutlierMethod.IQR_1_5, outlierStrategy: OutlierStrategy.Keep, - enabled: !hasIssues, + enabled: !autoDeselected, hasIssues, + issueDescription, }; } diff --git a/src/app/preprocessing-wizard/shared/constants/help-text.ts b/src/app/preprocessing-wizard/shared/constants/help-text.ts index e6ffd61..52ff94f 100644 --- a/src/app/preprocessing-wizard/shared/constants/help-text.ts +++ b/src/app/preprocessing-wizard/shared/constants/help-text.ts @@ -417,7 +417,7 @@ export const HELP_TEXT = { colorFeature: `Color Feature determines the color of each glyph in the visualization.

• Numeric: Creates a continuous color gradient (e.g., blue to red)
• Categorical: Assigns distinct colors to each category

- Optional - leave unselected for uniform color across all glyphs`, + A sensible attribute is selected automatically (a small categorical group, or the most varied numeric column) — change it anytime.`, colorScale: `Color Scale is the palette used to map the color feature onto the glyphs.

It is purely cosmetic — it only changes how the visualization looks, not the underlying data or the projection.

diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html index 54f5896..50261ce 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.html @@ -4,8 +4,9 @@
info
- Tip: Start by deselecting ID columns and text fields unless needed for tooltips. You can search - by column name or type (e.g., "numeric"). + Tip: Columns that are nearly all unique (IDs, titles, free text) or mostly empty are + deselected automatically — re-enable any of them if you need it. You can search by column name or type + (e.g., "numeric").
@@ -128,6 +129,11 @@ @if (hasIssues(column)) { warning } + @if (isAutoDeselected(column.name)) { + Auto-deselected + } diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss index 4068da5..9726b45 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.scss @@ -183,6 +183,22 @@ font-size: 16px; color: v.$status-warning; } + + // Smart-default marker: explains that a column was deselected automatically + // (reason in the title tooltip). Distinct from the quality warning icon. + .auto-deselected-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 600; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; + color: v.$gray-600; + background: v.$gray-100; + border: 1px solid v.$gray-300; + cursor: help; + white-space: nowrap; + } } .data-type-badge { diff --git a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts index 251fd80..0fc375d 100644 --- a/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts +++ b/src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.ts @@ -201,6 +201,20 @@ export class Step2ColumnSelectionComponent implements OnInit, AfterViewInit, Wiz return column.missingPercentage > 50 || column.uniqueCount === 1; } + /** + * True when a column was auto-deselected by a smart default (mostly missing, + * constant, or nearly all-unique) and has not been re-enabled by the user. + */ + isAutoDeselected(columnName: string): boolean { + const config = this.columnConfigs.get(columnName); + return !!config && !config.enabled && !!config.issueDescription; + } + + /** Human-readable reason a column was auto-deselected (for the marker tooltip). */ + getDeselectReason(columnName: string): string { + return this.columnConfigs.get(columnName)?.issueDescription ?? ''; + } + /** * Get unique value percentage */ diff --git a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts index ff0a31d..2fa2fe1 100644 --- a/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts +++ b/src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts @@ -387,11 +387,14 @@ export class Step4VisualizationSettingsComponent implements OnInit, AfterViewIni const colorCol = Array.from(state.columnConfigs.values()).find(c => c.isColorFeature); if (colorCol) { this.colorFeature = colorCol.name; - } else if (this.columns.length > 0) { - this.colorFeature = this.columns[0].name; - this.preprocessingService.setColorFeature(this.columns[0].name); } else { - this.colorFeature = null; + // Smart default: pick a meaningful colour attribute instead of just the + // first column (see pickDefaultColorFeature). + const pick = this.pickDefaultColorFeature(); + this.colorFeature = pick; + if (pick) { + this.preprocessingService.setColorFeature(pick); + } } this.selectedColorScaleId = state.colorScaleId; this.groupedColorScales = buildGroupedColorScales(); @@ -434,6 +437,35 @@ export class Step4VisualizationSettingsComponent implements OnInit, AfterViewIni this.selectedColorScaleId = this.preprocessingService.currentState.colorScaleId; } + /** + * Smart default for the colour attribute. Prefers a low-cardinality + * categorical/boolean column (distinct, legible colour groups), then falls + * back to the numeric column with the most spread (a meaningful gradient), + * and only as a last resort to the first column. Returns null if there are + * no columns. + */ + private pickDefaultColorFeature(): string | null { + if (this.columns.length === 0) return null; + + const lowCardCategorical = this.columns + .filter( + c => + (c.dataType === DataType.Categorical || c.dataType === DataType.Boolean) && + c.uniqueCount >= 2 && + c.uniqueCount <= 12 + ) + .sort((a, b) => a.uniqueCount - b.uniqueCount)[0]; + if (lowCardCategorical) return lowCardCategorical.name; + + const variance = (c: ColumnStatistics): number => c.variance ?? (c.stdDev ?? 0) ** 2; + const numericBySpread = this.columns + .filter(c => c.dataType === DataType.Numeric && variance(c) > 0) + .sort((a, b) => variance(b) - variance(a))[0]; + if (numericBySpread) return numericBySpread.name; + + return this.columns[0].name; + } + getSelectedColorScale(): ColorScale { return COLOR_SCALES.find(s => s.id === this.selectedColorScaleId) ?? COLOR_SCALES[0]; } From 9b385fa9c248ac967e7734434a5d32378a0ad8ae Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 22 Jul 2026 14:56:51 +0200 Subject: [PATCH 61/64] docs(roadmap): A2-Nachtrag In Feature-Branch (PR #85), Planungsartefakte entfernt (PR #84) --- ROADMAP.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 67c6f30..562f0f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,8 +45,9 @@ Integrations-Branch `feature/wizard-redesign` von `main` abzweigen. Pro Ticket e **Aktueller Stand (Integrations-Branch `feature/wizard-redesign`):** - Gemergt (In Feature-Branch): A1–A15 vollständig — alle Tickets sind im Integrations-Branch. - In Arbeit: keine. -- Offen (TODO): A2-Nachtrag (Smart Defaults erweitern — Hochkardinalität in Schritt 2 + klügerer `colorFeature`-Default; siehe Ticket A2). -- Nächster Schritt: A2-Nachtrag umsetzen; danach `feature/wizard-redesign` nach `main` mergen und alle Tickets auf `Finished` setzen. +- Offen (TODO): keine (A2-Nachtrag umgesetzt und gemergt, PR #85). +- Nächster Schritt: `feature/wizard-redesign` nach `main` mergen; danach alle Tickets auf `Finished` setzen. +- Aufräumung: Planungsartefakte (`docs/superpowers/`) entfernt und via `.gitignore` künftig ausgeschlossen (PR #84). - Gebündelt umgesetzt: A7+A10 in `feat/a10-a7-polish`; A1+A11 in `feat/a1-a11-erklaerungen-defekte`. - Doku: Jedes Ticket hat einen „Umgesetzt"-Abschnitt mit PR-Verweis. Die Abschnitte für A2–A10 und A12–A15 wurden nach dem Merge aus Code und PR-Beschreibungen abgeleitet (A1/A11 direkt aus der Umsetzung). @@ -144,8 +145,8 @@ Quick Wins ohne Abhängigkeit, die sich parallel zum Fundament wegräumen lassen - Proaktive Vorauswahl beim Import: `createDefaultColumnConfig` setzt `enabled: !hasIssues` mit `hasIssues = missingPercentage > 50 || uniqueCount === 1`; solche Spalten starten abgewählt und sind in Schritt 2 reaktivierbar (entschärft One-Hot-Explosion proaktiv, Bezug K7). - Nicht umgesetzt: die zusätzlich geplante Hochkardinalitäts-Heuristik (`uniqueCount ≈ totalRows`, typische IDs/Freitext) fehlt in `hasIssues` — vorab abgewählt werden nur konstante bzw. überwiegend leere Spalten. -**Nachtrag (TODO) — Smart Defaults erweitern · Branch (Vorschlag):** `feat/a2-nachtrag-smart-defaults` -Eigenständiges Folge-Arbeitspaket zu A2 (der Rest von A2 ist gemergt). Umfang „Hoch + Mittel": +**Nachtrag (In Feature-Branch, PR #85 gemergt) — Smart Defaults erweitern · Branch:** `feat/a2-nachtrag-smart-defaults` +Eigenständiges Folge-Arbeitspaket zu A2, umgesetzt und gemergt (7 neue Unit-Tests für die Auto-Deselect-Heuristik). Umfang „Hoch + Mittel": - *Hoch — Hochkardinalität in Schritt 2:* Spalten mit nahezu 100 % einzigartigen Werten proaktiv abwählen. Heuristik: `uniqueCount / count >= 0.9` (ggf. 0.95) **und** `count > ~20`, beschränkt auf Text/Categorical/ID (numerische Messwerte ausnehmen). Nutzt vorhandene Felder (`column-statistics.ts`, `getUniquePercent`), kein neues Datenmodell. Umsetzung in `createDefaultColumnConfig` (`services/preprocessing.service.ts:283`) + gespiegelt in `steps/step2-column-selection/` (ts:200) — mit eigenem Grund (`issueDescription`) statt Vermischung in `hasIssues`. - *Hoch — sichtbare Kennzeichnung:* expliziter „automatisch abgewählt"-Marker in Schritt 2 (getrennt vom Qualitäts-Warnicon) mit Kurzbegründung; Tipp erweitern um den Fall 100 % einzigartiger Werte (IDs/Titel/Freitext) und den Hinweis auf Reaktivierbarkeit. - *Mittel — klügerer `colorFeature`-Default:* statt willkürlich `columns[0]` (`step4…ts:387-395`) ein niedrig-kardinales Kategoriemerkmal bzw. eine varianzstarke Numeric wählen und als Default sichtbar kennzeichnen. From cc1001e14ff2f629363cab990314171cf3f704eb Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 23 Jul 2026 08:41:44 +0200 Subject: [PATCH 62/64] docs(roadmap): Ausblick - manuelles Beenden laufender Reduktionsmethoden als offenen Punkt ausserhalb des Umfangs aufgenommen --- ROADMAP.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 562f0f8..a2a7510 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -396,4 +396,9 @@ Eigenständiges Folge-Arbeitspaket zu A2, umgesetzt und gemergt (7 neue Unit-Tes --- -*Ausblick (nicht im Umsetzungsrahmen): methodenspezifische Standardwerte je Projektionsmethode — laut Anforderungskatalog bewusst außerhalb des Rahmens.* +## Ausblick — offen, aber nicht im Umsetzungsrahmen + +Identifizierte, aber bewusst nicht umgesetzte Punkte: + +- **Methodenspezifische Standardwerte je Projektionsmethode** — laut Anforderungskatalog bewusst außerhalb des Rahmens. +- **Manuelles Beenden laufender Reduktionsmethoden** — aktuell lässt sich eine hängende/langlaufende Dimensionsreduktion nur über „Start over" stoppen, was den gesamten Wizard zurücksetzt (Konfiguration geht verloren). Ein gezielter Abbruch je Methode (bzw. globales „Verarbeitung abbrechen" ohne Reset) sowie ein Timeout pro Methode fehlen. Technische Grundlage vorhanden (isolierte Web-Worker, `ProjectionService.terminateAllWorkers()`), aber ohne nutzerseitige Steuerung. Offen für eine spätere Erweiterung. From 32c6fd237dc3bb867e7fdea4a572df6edc655898 Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 26 Jul 2026 14:35:51 +0200 Subject: [PATCH 63/64] fix(wizard): translate step5 status text to English, dedupe K7 column list Step 5's blocking-error screen and retry toast still had German text. Also fixes the K7 "distinct values" message listing the same offending column twice: the Python wrapper re-raised without `from None`, so the chained traceback repeated the offender text; the JS classifier now also dedupes matches defensively. --- .../shared/constants/wizard-error-classes.spec.ts | 14 ++++++++++++++ .../shared/constants/wizard-error-classes.ts | 5 ++++- .../step5-review-processing.component.html | 2 +- .../step5-review-processing.component.ts | 2 +- src/assets/preprocessing_processor_config.py | 6 +++++- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.spec.ts b/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.spec.ts index 778b24b..8b2d8b7 100644 --- a/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.spec.ts +++ b/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.spec.ts @@ -134,6 +134,20 @@ describe('classifyProcessingError', () => { expectActionable(issue, raw); }); + it('K7 collapses a repeated column mention instead of double-counting it', () => { + // Simulates a chained Python exception whose formatted traceback repeats the + // same "'col' (N distinct values)" fragment (original exception + wrapper). + const raw = + "These columns have too many distinct values to one-hot encode: 'show_id' (9000 distinct values). " + + 'During handling of the above exception, another exception occurred: Failed to process data: These ' + + "columns have too many distinct values to one-hot encode: 'show_id' (9000 distinct values)."; + const issue = classifyProcessingError(raw); + expect(issue.code).toBe('K7'); + expect(issue.title).toBe('The column show_id (9000) has too many distinct values'); + expect(issue.why).not.toMatch(/show_id.*show_id/s); + expectActionable(issue, raw); + }); + it('falls back to a generic, still-actionable blocking issue for unmatched errors', () => { const raw = 'Segfault 0xDEADBEEF in libfoo.so at frame #7'; const issue = classifyProcessingError(raw); diff --git a/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts b/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts index d3f435f..113d443 100644 --- a/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts +++ b/src/app/preprocessing-wizard/shared/constants/wizard-error-classes.ts @@ -132,7 +132,10 @@ export function classifyProcessingError(raw: string): WizardIssue { // but the specific cause (a near-unique column blown up by one-hot) has a // much more actionable fix than the generic "resource limit" advice. issue = template('K7'); - const pairs = [...raw.matchAll(/'([^']+)' \((\d+) distinct values\)/g)].map(m => `${m[1]} (${m[2]})`); + // Deduplicated: a chained/re-raised Python exception can repeat the same + // "'col' (N distinct values)" fragment in its formatted traceback, which + // would otherwise double-count and re-list the same column. + const pairs = [...new Set([...raw.matchAll(/'([^']+)' \((\d+) distinct values\)/g)].map(m => `${m[1]} (${m[2]})`))]; if (pairs.length === 1) { issue.title = `The column ${pairs[0]} has too many distinct values`; issue.why = `This column has almost as many different values as there are rows (typical of an ID, title, name or free-text field): ${pairs[0]}. Encoding it would expand the data into that many columns — more than the in-browser engine can hold in memory.`; diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html index f64498d..c51425a 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html @@ -218,7 +218,7 @@

{{ processingStep || 'FastMap is computing your primary layout…' }}

error_outline

We couldn't finish your visualization — but this is fixable.

-

Deine hochgeladenen Daten und alle Einstellungen sind noch da — nichts ging verloren.

+

Your uploaded data and all settings are still there — nothing was lost.

diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts index 411509a..58f8849 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts +++ b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts @@ -598,7 +598,7 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { * clearly sees the spinner + progress + method status while it re-runs. */ retryProcessing(): void { - this.toastService.info('Verarbeitung wird erneut gestartet…', 3000); + this.toastService.info('Restarting processing…', 3000); // Make the processing state visible immediately, even before startProcessing's // own reset runs, so the transition is never just a flicker. this.showProcessing = true; diff --git a/src/assets/preprocessing_processor_config.py b/src/assets/preprocessing_processor_config.py index de3732f..6bbf308 100644 --- a/src/assets/preprocessing_processor_config.py +++ b/src/assets/preprocessing_processor_config.py @@ -81,7 +81,11 @@ def process_with_config(file_name, config_json, output_file=None): return json.dumps(dataset) except Exception as e: - raise Exception(f"Failed to process data: {str(e)}") + # `from None` suppresses the implicit exception chain: without it, the + # formatted traceback that reaches the JS side (via Pyodide's PythonError) + # includes both the original exception's text and this wrapped message, + # so any offending-column names inside it (K7) get counted/listed twice. + raise Exception(f"Failed to process data: {str(e)}") from None def apply_cleaning(df, config): From a4a87c374fab7d5d638bee185e10016f7131cad1 Mon Sep 17 00:00:00 2001 From: Thomas Gruender Date: Thu, 24 Sep 2026 23:10:26 +0200 Subject: [PATCH 64/64] feat(wizard): review fixes, 6-step flow, a11y, security and deps - fix undo/redo, Python injection and tooltip XSS - make pipeline options work; handle missing values - split step 4 into Projection + Glyphs & Color - add a11y and design tokens - upgrade Angular 21.2.24 and druidjs 0.9 (LLE hidden) - switch tests to Vitest; update CI to Node 24 - ship licence notices; update docs --- .github/workflows/deploy.yaml | 77 +- .gitignore | 6 +- .nvmrc | 1 + README.md | 86 +- ROADMAP.md | 22 +- angular.json | 35 +- docs/wizard.md | 80 + package-lock.json | 7040 +++++++---------- package.json | 53 +- src/app/canvas/glyph-canvas.component.ts | 2 +- .../canvas/magiclens/magiclens.component.ts | 2 +- .../services/canvas-renderer.service.ts | 7 +- .../settingscontrols.component.ts | 4 +- src/app/canvas/tooltip/tooltip.component.html | 19 +- src/app/canvas/tooltip/tooltip.component.scss | 33 + .../canvas/tooltip/tooltip.component.spec.ts | 58 + src/app/canvas/tooltip/tooltip.component.ts | 77 +- src/app/glyph/glyph-cache-object.ts | 2 +- src/app/menubar/menubar.component.html | 51 +- src/app/menubar/menubar.component.scss | 91 +- src/app/menubar/menubar.component.spec.ts | 77 + src/app/menubar/menubar.component.ts | 17 +- .../models/column-config.ts | 3 +- .../models/preprocessing-state.ts | 32 +- .../preprocessing-wizard.component.html | 170 +- .../preprocessing-wizard.component.scss | 300 +- .../preprocessing-wizard.component.spec.ts | 477 +- .../preprocessing-wizard.component.ts | 503 +- .../preprocessing-wizard.dialog.spec.ts | 277 + .../services/column-defaults.spec.ts | 122 + .../services/column-defaults.ts | 113 + .../services/history-diff.ts | 175 + .../services/preprocessing.service.spec.ts | 659 +- .../services/preprocessing.service.ts | 884 +-- ...preprocessing.service.worker-files.spec.ts | 133 + .../services/processing-config.ts | 121 + .../services/wizard-storage.spec.ts | 172 + .../services/wizard-storage.ts | 117 + .../shared/_wizard-shared.scss | 416 +- .../shared/constants/help-text.ts | 121 +- .../shared/constants/step-info.spec.ts | 66 + .../shared/constants/step-info.ts | 72 +- .../constants/wizard-error-classes.spec.ts | 70 +- .../shared/constants/wizard-error-classes.ts | 98 +- .../data-preview-table.component.html | 7 +- .../data-preview-table.component.scss | 14 +- .../data-preview-table.component.spec.ts | 36 + .../data-type-badge.component.scss | 1 - .../shared/dialog-focus.spec.ts | 121 + .../shared/dialog-focus.ts | 87 + .../help-tooltip/help-tooltip.component.html | 30 +- .../help-tooltip/help-tooltip.component.scss | 28 +- .../help-tooltip.component.spec.ts | 114 + .../help-tooltip/help-tooltip.component.ts | 165 +- .../shared/popup-coordinator.spec.ts | 186 + .../shared/popup-coordinator.ts | 257 + .../progress-stepper.component.html | 54 +- .../progress-stepper.component.scss | 162 +- .../progress-stepper.component.spec.ts | 97 + .../progress-stepper.component.ts | 27 + .../shared/projection-preview/engine/index.ts | 10 + .../engine/preview-draw-cluster.ts | 159 + .../engine/preview-draw-linear.ts | 189 + .../engine/preview-draw-manifold.ts | 109 + .../engine/preview-engine.spec.ts | 163 + .../engine/preview-geometry.ts | 329 + .../projection-preview/engine/preview-math.ts | 94 + .../engine/preview-primitives.ts | 188 + .../engine/preview-renderer.ts | 60 + .../engine/preview-types.ts | 153 + .../projection-preview.component.html | 66 +- .../projection-preview.component.scss | 77 +- .../projection-preview.component.spec.ts | 217 + .../projection-preview.component.ts | 1457 +--- .../projection-preview.data.ts | 189 + .../shared/step-summaries.spec.ts | 146 + .../shared/step-summaries.ts | 72 + .../shared/wizard-announcer.service.spec.ts | 26 + .../shared/wizard-announcer.service.ts | 21 + .../wizard-issue-card.component.html | 21 +- .../wizard-issue-card.component.scss | 95 +- .../wizard-issue-card.component.spec.ts | 51 +- .../wizard-issue-card.component.ts | 15 +- .../shared/wizard-step.ts | 8 +- .../step1-upload/step1-upload.component.html | 61 +- .../step1-upload/step1-upload.component.scss | 177 +- .../step1-upload.component.spec.ts | 168 + .../step1-upload/step1-upload.component.ts | 131 +- .../step2-column-selection.component.html | 55 +- .../step2-column-selection.component.scss | 104 +- .../step2-column-selection.component.spec.ts | 197 + .../step2-column-selection.component.ts | 78 +- .../consequence-hint.component.scss | 16 + .../consequence-hint.component.ts | 25 + .../consequence-preview.spec.ts | 339 + .../consequence-preview.ts | 265 + .../step3-accessibility.scss | 24 + .../step3-accessibility.spec.ts | 275 + ...ep3-configure-data-features.component.html | 270 +- ...ep3-configure-data-features.component.scss | 414 +- ...-configure-data-features.component.spec.ts | 301 + ...step3-configure-data-features.component.ts | 241 +- .../projection-methods.ts | 237 + .../step4-accessibility.spec.ts | 211 + .../step4-projection-settings.component.html | 336 + .../step4-projection-settings.component.scss | 630 ++ ...tep4-projection-settings.component.spec.ts | 468 ++ .../step4-projection-settings.component.ts | 392 + ...tep4-visualization-settings.component.html | 581 -- ...tep4-visualization-settings.component.scss | 1112 --- ...4-visualization-settings.component.spec.ts | 354 - .../step4-visualization-settings.component.ts | 1073 --- .../glyph-preview-geometry.spec.ts | 16 + .../glyph-preview-geometry.ts | 132 + .../step5-glyph-color-settings.component.html | 367 + .../step5-glyph-color-settings.component.scss | 490 ++ ...ep5-glyph-color-settings.component.spec.ts | 677 ++ .../step5-glyph-color-settings.component.ts | 519 ++ .../step5-review-processing.component.html | 395 - .../step5-review-processing.component.scss | 660 -- .../background-projections.spec.ts | 78 + .../background-projections.ts | 96 + .../step6-review-processing.component.html | 482 ++ .../step6-review-processing.component.scss | 555 ++ .../step6-review-processing.component.spec.ts | 542 ++ .../step6-review-processing.component.ts} | 517 +- .../testing/preprocessing-test-helpers.ts | 32 + src/app/services/data-processor.spec.ts | 138 + src/app/services/data-processor.ts | 44 +- .../dataset-collection.service.spec.ts | 19 +- src/app/services/filter.service.spec.ts | 26 +- src/app/services/projection.service.spec.ts | 303 + src/app/services/projection.service.ts | 336 +- src/app/services/toast.service.spec.ts | 172 +- src/app/services/toast.service.ts | 83 +- .../color-scale-selector.component.html | 79 +- .../color-scale-selector.component.scss | 22 + .../color-scale-selector.component.spec.ts | 142 + .../color-scale-selector.component.ts | 138 +- .../components/toast/toast.component.spec.ts | 121 + .../components/toast/toast.component.ts | 199 +- src/app/shared/helpers/color-helper.spec.ts | 44 +- src/app/shared/helpers/d3-helper.spec.ts | 87 + src/app/shared/helpers/d3-helper.ts | 47 +- src/app/shared/helpers/glyph-helper.ts | 2 +- src/app/shared/helpers/spatial-grid.spec.ts | 16 +- src/app/shared/helpers/three-helper.spec.ts | 119 + src/app/shared/helpers/three-helper.ts | 84 +- src/app/shared/interfaces/color-scale.ts | 5 +- src/app/shared/interfaces/pyodide-messages.ts | 12 +- src/app/shared/types/projection.types.ts | 11 - src/app/shared/utils/histogram.utils.spec.ts | 26 + src/app/shared/utils/histogram.utils.ts | 32 +- .../sidebar/histogram/histogram.component.ts | 61 +- src/app/sidebar/sidebar.component.html | 4 +- src/app/types/druidjs.d.ts | 83 - src/app/workers/druid-runner.spec.ts | 233 + src/app/workers/druid-runner.ts | 176 + src/app/workers/projection.worker.spec.ts | 116 + src/app/workers/projection.worker.ts | 399 +- src/app/workers/pyodide-calls.spec.ts | 132 + src/app/workers/pyodide-calls.ts | 110 + src/app/workers/pyodide-files.spec.ts | 91 + src/app/workers/pyodide-files.ts | 72 + src/app/workers/pyodide.worker.ts | 439 +- src/assets/preprocessing_processor.py | 120 +- src/assets/preprocessing_processor_config.py | 364 +- src/assets/processor.py | 477 -- src/assets/samples/wine-quality-red.csv | 1600 ++++ src/licenses/GPL-3.0.txt | 674 ++ src/licenses/LGPL-3.0.txt | 165 + src/licenses/README.txt | 19 + src/styles.scss | 23 + src/styles/_mixins.scss | 43 +- src/styles/_variables.scss | 144 +- src/test-setup.ts | 9 + src/testing/create-spy-obj.ts | 19 + tools/dataset_common.py | 454 ++ tools/make-sample-csv.py | 62 + tools/process.py | 561 +- tools/tests/test_preprocessing_config.py | 521 ++ tools/tests/test_process_cli.py | 265 + tsconfig.app.json | 4 +- tsconfig.spec.json | 3 +- 184 files changed, 27199 insertions(+), 14886 deletions(-) create mode 100644 .nvmrc create mode 100644 docs/wizard.md create mode 100644 src/app/canvas/tooltip/tooltip.component.spec.ts create mode 100644 src/app/menubar/menubar.component.spec.ts create mode 100644 src/app/preprocessing-wizard/preprocessing-wizard.dialog.spec.ts create mode 100644 src/app/preprocessing-wizard/services/column-defaults.spec.ts create mode 100644 src/app/preprocessing-wizard/services/column-defaults.ts create mode 100644 src/app/preprocessing-wizard/services/history-diff.ts create mode 100644 src/app/preprocessing-wizard/services/preprocessing.service.worker-files.spec.ts create mode 100644 src/app/preprocessing-wizard/services/processing-config.ts create mode 100644 src/app/preprocessing-wizard/services/wizard-storage.spec.ts create mode 100644 src/app/preprocessing-wizard/services/wizard-storage.ts create mode 100644 src/app/preprocessing-wizard/shared/constants/step-info.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/data-preview-table/data-preview-table.component.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/dialog-focus.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/dialog-focus.ts create mode 100644 src/app/preprocessing-wizard/shared/help-tooltip/help-tooltip.component.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/popup-coordinator.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/popup-coordinator.ts create mode 100644 src/app/preprocessing-wizard/shared/progress-stepper/progress-stepper.component.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/index.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-draw-cluster.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-draw-linear.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-draw-manifold.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-engine.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-geometry.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-math.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-primitives.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-renderer.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/engine/preview-types.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/projection-preview.component.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/projection-preview/projection-preview.data.ts create mode 100644 src/app/preprocessing-wizard/shared/step-summaries.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/step-summaries.ts create mode 100644 src/app/preprocessing-wizard/shared/wizard-announcer.service.spec.ts create mode 100644 src/app/preprocessing-wizard/shared/wizard-announcer.service.ts create mode 100644 src/app/preprocessing-wizard/steps/step1-upload/step1-upload.component.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step2-column-selection/step2-column-selection.component.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/consequence-hint.component.scss create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/consequence-hint.component.ts create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/consequence-preview.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/consequence-preview.ts create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-accessibility.scss create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-accessibility.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step3-configure-data-features/step3-configure-data-features.component.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step4-projection-settings/projection-methods.ts create mode 100644 src/app/preprocessing-wizard/steps/step4-projection-settings/step4-accessibility.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step4-projection-settings/step4-projection-settings.component.html create mode 100644 src/app/preprocessing-wizard/steps/step4-projection-settings/step4-projection-settings.component.scss create mode 100644 src/app/preprocessing-wizard/steps/step4-projection-settings/step4-projection-settings.component.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step4-projection-settings/step4-projection-settings.component.ts delete mode 100644 src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.html delete mode 100644 src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.scss delete mode 100644 src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.spec.ts delete mode 100644 src/app/preprocessing-wizard/steps/step4-visualization-settings/step4-visualization-settings.component.ts create mode 100644 src/app/preprocessing-wizard/steps/step5-glyph-color-settings/glyph-preview-geometry.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step5-glyph-color-settings/glyph-preview-geometry.ts create mode 100644 src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.html create mode 100644 src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.scss create mode 100644 src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.ts delete mode 100644 src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html delete mode 100644 src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss create mode 100644 src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.spec.ts create mode 100644 src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.ts create mode 100644 src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.html create mode 100644 src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.scss create mode 100644 src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.spec.ts rename src/app/preprocessing-wizard/steps/{step5-review-processing/step5-review-processing.component.ts => step6-review-processing/step6-review-processing.component.ts} (54%) create mode 100644 src/app/preprocessing-wizard/testing/preprocessing-test-helpers.ts create mode 100644 src/app/services/data-processor.spec.ts create mode 100644 src/app/services/projection.service.spec.ts create mode 100644 src/app/shared/components/color-scale-selector/color-scale-selector.component.spec.ts create mode 100644 src/app/shared/components/toast/toast.component.spec.ts create mode 100644 src/app/shared/helpers/d3-helper.spec.ts create mode 100644 src/app/shared/helpers/three-helper.spec.ts create mode 100644 src/app/shared/utils/histogram.utils.spec.ts delete mode 100644 src/app/types/druidjs.d.ts create mode 100644 src/app/workers/druid-runner.spec.ts create mode 100644 src/app/workers/druid-runner.ts create mode 100644 src/app/workers/projection.worker.spec.ts create mode 100644 src/app/workers/pyodide-calls.spec.ts create mode 100644 src/app/workers/pyodide-calls.ts create mode 100644 src/app/workers/pyodide-files.spec.ts create mode 100644 src/app/workers/pyodide-files.ts delete mode 100644 src/assets/processor.py create mode 100644 src/assets/samples/wine-quality-red.csv create mode 100644 src/licenses/GPL-3.0.txt create mode 100644 src/licenses/LGPL-3.0.txt create mode 100644 src/licenses/README.txt create mode 100644 src/test-setup.ts create mode 100644 src/testing/create-spy-obj.ts create mode 100644 tools/dataset_common.py create mode 100644 tools/make-sample-csv.py create mode 100644 tools/tests/test_preprocessing_config.py create mode 100644 tools/tests/test_process_cli.py diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index fd233ce..da79ca3 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,25 +1,51 @@ -name: Deploy Angular to GitHub Pages +name: CI and deploy to GitHub Pages on: push: branches: - - main # or master, depending on your default branch + - main + pull_request: + +# Least privilege by default; only the deploy job may push to gh-pages. +permissions: + contents: read jobs: - build-and-deploy: + verify: runs-on: ubuntu-latest + concurrency: + group: verify-${{ github.ref }} + cancel-in-progress: true steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: 🟢 Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v7 with: - node-version: 25 + node-version-file: .nvmrc + cache: npm - name: 📦 Install dependencies - run: npm install + run: npm ci + + - name: 🔍 Lint + run: npm run lint + + # Vitest runs the unit tests in Playwright's headless Chromium (version pinned by package-lock). + # Not cached on purpose: Playwright advises against it, restoring takes about as long as the + # download, and --with-deps has to install the Linux system packages either way. + - name: 🎭 Install Playwright Chromium + run: npx playwright install --with-deps --only-shell chromium + + # The test command lives in package.json ("test:ci") so the runner can be swapped in one place. + - name: 🧪 Unit tests + run: npm run test:ci + env: + # The runner image points CHROME_BIN at its own Google Chrome, which the Angular test + # builder would launch instead of the pinned Playwright Chromium. + CHROME_BIN: '' - name: 🛠️ Build Angular app run: npm run build -- --base-href=/glyphspace/ --configuration production @@ -27,10 +53,41 @@ jobs: - name: 📄 Create .nojekyll file run: echo > dist/glyphspace/browser/.nojekyll + # The builder writes the licence list next to browser/, which is the only folder published. + - name: 📜 Publish third-party licences + run: cp dist/glyphspace/3rdpartylicenses.txt dist/glyphspace/browser/ + + - name: 📤 Upload site artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v7 + with: + name: site + path: dist/glyphspace/browser/ + include-hidden-files: true # keep .nojekyll + if-no-files-found: error + retention-days: 1 + + deploy: + needs: verify + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: gh-pages-deploy + cancel-in-progress: false + + steps: + - name: 📥 Download site artifact + uses: actions/download-artifact@v8 + with: + name: site + path: site + - name: 🚀 Deploy to gh-pages - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dist/glyphspace/browser/ + publish_dir: ./site publish_branch: gh-pages - enable_jekyll: false # this adds the .nojekyll file automatically + enable_jekyll: false # this adds the .nojekyll file automatically diff --git a/.gitignore b/.gitignore index 7ffe0d3..2a50513 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ /.sass-cache /connect.lock /coverage +# Vitest browser mode: screenshots of failed tests (next to the spec) and their attachment copies +__screenshots__/ +/.vitest-attachments /libpeerconnection.log npm-debug.log testem.log @@ -43,7 +46,8 @@ Thumbs.db # Miscellaneous /.angular/cache -/src/assets/__pycache__ +__pycache__/ +.pytest_cache/ /src/assets/tests /.claude /.angular diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 9006338..2863379 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,28 @@ Glyphspace is a complete rewrite of the [Glyphboard application](https://github. See the running demo on Github pages: [https://visual-engineers.org/glyphspace/](https://visual-engineers.org/glyphspace/). -## Usage +## Using the Wizard -... coming soon. +The preprocessing wizard guides you through six steps to prepare your data for visualization: + +1. **Upload Data** — Load your CSV file. +2. **Select Columns** — Choose columns to keep; ID and low-value fields can be deselected. +3. **Configure Data & Features** — Set encoding, scaling, missing-value and outlier handling per column. +4. **Projection** — Select columns for dimensionality reduction and pick projection methods (PCA, UMAP, t-SNE). +5. **Glyphs & Color** — Choose glyph features and color encoding for the dashboard's initial view (both changeable later). +6. **Review & Process** — Review your configuration and process the data. + +**Undo/Redo:** Use the buttons in the wizard's title bar or Ctrl+Z / Ctrl+Shift+Z (Cmd on Mac) to step back and forth through your changes. + +**Try with sample data:** Step 1 offers a Wine Quality (red) dataset as a starter (P. Cortez et al., UCI Machine Learning Repository, CC BY 4.0). + +Developer notes on the wizard's architecture are in [docs/wizard.md](docs/wizard.md). ## Data Import ### Dynamic Import -Inside of the running application, you can upload suitable CSV files into the filesystem of Micropython running as a web assembly behind the scenes (see below for CSV file structure). You can also upload ZIP folders with thumbnails for your data. The filename must be the same as your CSV file and the contained files should be named according to the IDs of the data rows. +Inside of the running application, you can upload suitable CSV files into the filesystem of Pyodide (Python compiled to WebAssembly, loaded from the jsDelivr CDN; the version is set in `src/app/workers/pyodide.worker.ts`; see below for CSV file structure). You can also upload ZIP folders with thumbnails for your data. The filename must be the same as your CSV file and the contained files should be named according to the IDs of the data rows. ### Preprocessed Data @@ -26,7 +39,7 @@ You can either process CSV files via the command line using: python tools/process.py ``` -Which creates the necessary data files inside the `src/assets/data` folder of the project and recreates the file `src/defauult-dataset.ts` which the application picks up after starting. You can also store thumbnails in the folder `src/assets/data/thumbnails/`, which are in JPG format and have the ID of your data items as filename. +Which creates the necessary data files inside the `src/assets/data` folder of the project and recreates the file `src/default-dataset.ts` which the application picks up after starting. You can also store thumbnails in the folder `src/assets/data/thumbnails/`, which are in JPG format and have the ID of your data items as filename. All columns except IDs and positional information are converted with a label encoder if they are strings and then normalized for processing of the meta data (including a histogram, min, max, etc.) as well as a PCA, UMAP, and tSNE multidimensional reduction. @@ -55,65 +68,42 @@ Notable changes: - in feature.json `default-context` is renamed to `defaultcontext` for the same reasons - IDs are treated as strings -## Angular Development Instructions - -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.13. - -### Development server - -To start a local development server, run: - -```bash -ng serve -``` - -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. - -### Code scaffolding +## Development -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: +**Node version:** v24 (see `.nvmrc`) -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -### Building - -To build the project run: +### Setup ```bash -ng build +npm ci # Install dependencies (prefer ci over install for reproducible builds) +npx playwright install chromium # Once per machine: browser for the unit tests +npm start # Start local dev server at http://localhost:4200/ +npm test # Run unit tests (Vitest in headless Chromium, watch mode) +npm run test:ci # Run unit tests once (CI mode) +npm run lint # Run ESLint +npm run build # Build for production (output in dist/) ``` -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -### Running unit tests +### Python tools -To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: +The project includes Python preprocessing tools that run locally via `tools/process.py` (CLI; the dataset file generation lives in `tools/dataset_common.py`, the in-browser import is the wizard's pipeline in `src/assets/preprocessing_processor*.py`): ```bash -ng test +python tools/process.py # Process a CSV and generate src/default-dataset.ts +python tools/process.py # Rebuild default dataset list +python -B -m pytest -p no:cacheprovider tools/tests # Run Python tests ``` -### Running end-to-end tests +Other tools: +- `tools/make-sample-csv.py` — Generate sample data -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` +### Deployment -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. +The app is deployed to GitHub Pages on every push to `main`. See `.github/workflows/deploy.yaml` for the workflow (runs tests, lints, builds on PRs; publishes on merge to main). -### Additional Resources +## Licence -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. +GlyphSpace is licensed under the MIT License (see [LICENSE](LICENSE)). It bundles [DruidJS](https://github.com/saehm/DruidJS) for the dimensionality reductions, which is licensed under the LGPL-3.0-or-later. The deployed site ships the LGPL and GPL texts and a notice in `licenses/` (source: `src/licenses/`), plus `3rdpartylicenses.txt` listing all bundled packages. diff --git a/ROADMAP.md b/ROADMAP.md index a2a7510..fd93f60 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -396,9 +396,29 @@ Eigenständiges Folge-Arbeitspaket zu A2, umgesetzt und gemergt (7 neue Unit-Tes --- +## Review-Runde (September 2026) + +Ergebnis eines Code-, UI- und Barrierefreiheits-Reviews des Integrations-Branches. Details für Entwickler: `docs/wizard.md`. + +- **Korrektheit / Undo** — Jede Nutzeraktion ist genau ein Undo-Schritt: Schritt 3 füllte die Historie beim Öffnen (Ausreißerzählung), „Reset to Default“/„Apply to same type“ und die Master-Checkbox erzeugten mehrere Einträge, Projektionsänderungen umgingen Historie und Speicherung. Toast-„Undo“ macht nur noch die eigene Aktion rückgängig (`undoIfLatest`). Kein Undo während der Verarbeitung (inkl. FastMap). Schließen während der Schritt-2→3-Animation, doppelter Start in Schritt 6 und der Shift-Klick-Bereich nach Filterwechsel korrigiert. +- **Sicherheit** — Python-Code-Injection über Spaltennamen im CSV-Header bzw. Dateinamen behoben: Werte gehen nur noch als Daten an Pyodide (`workers/pyodide-calls.ts`). Dateinamen wie `pandas.py` oder `../` werden abgelehnt; ein Zip namens `lib.zip` kann `/lib` nicht mehr löschen. Worker-Anfragen haben Request-IDs. Stored XSS im Dashboard-Tooltip behoben: CSV-Werte, Spaltennamen und IDs wurden per `bypassSecurityTrustHtml` als HTML eingefügt; der Tooltip rendert sie jetzt als Text. +- **Pipeline-Optionen jetzt wirksam** — Wirkungslos waren bisher: Fehlende Werte „Remove rows“ je Spalte, Ausreißer „Remove“/„Cap“, Encodings „Normalize“/„Standardize“, „Remove duplicates“ bei CSVs ohne ID-Spalte, UMAP-Mindestabstand 0. Außerdem behoben: Absturz bei Label-Encoding nach Zeilenlöschung, verrutschte Tooltip-Werte, String-Füllwert in Zahlenspalten, Ausreißer bei Boolean-Spalten, vertauschte „Breitengrad“/„Längengrad“. Neue Fehlerklasse K8 (Füllwert passt nicht). 140 Python-Tests (`tools/tests/`). +- **Fehlende Werte / Datenqualität** — Tooltips zeigen „missing“ statt „0“ (bzw. „nan“ aus der CLI). Fehlende Zahlen und Datumswerte werden erst nach der Skalierung auf 0 gesetzt und verzerren die echten Werte nicht mehr (vorher z. B. alle Datumswerte in ~4 % des Bereichs, fehlende Daten als 1970-01-01). Boolean-Beschriftungen waren vertauscht, wenn die erste Zeile „True“ war. Die Auswahlfelder in Schritt 3 zeigten „None“/„Keep“, obwohl eine andere Einstellung aktiv war. CLI (`tools/process.py`): Beschriftungen nach One-Hot-Spalten verschoben, Tooltips für Kategorien mit Unterstrich, Kategorien bei Label-Encoding mit fehlenden Werten — behoben. +- **Schritt-Aufteilung** — Schritt 4 „Projection“ und Schritt 5 „Glyphs & Color“ statt eines gemeinsamen Visualisierungsschritts; Review ist Schritt 6. Schritte über Namen statt Nummern (`shared/constants/step-info.ts`), gespeicherte Zustände werden migriert. +- **Wizard-UI** — Undo/Redo in der Titelleiste neben „Start over“ (Icon `restart_alt`, Bestätigung als nativer Dialog). Schritt 3 zeigt die Folgen einer Einstellung mit echten Zahlen der Daten; Schritt 1 bietet Beispieldaten (Wine Quality, CC BY 4.0); die Schrittleiste fasst getroffene Entscheidungen zusammen. Laufende Hintergrund-Projektionen lassen sich einzeln abbrechen und neu starten. +- **Barrierefreiheit** — Wizard als modaler Dialog (Fokusfalle, Escape, Fokus-Rückgabe), Fokus und Ansage bei Schrittwechsel, Datei-Upload per Tastatur, Beschriftungen für alle Bedienelemente, Tastatur-Alternative zum Drag-and-drop der Glyph-Merkmale, Live-Regions für Status und Toasts, Tooltips per Fokus öffnen und per Escape schließen, Fortschrittsbalken und Fehlermeldungen für Screenreader. +- **Design / Größen** — Eine Typo-Skala (11–24 px, keine Schrift unter 11 px), Abstands-, Icon- und z-index-Tokens, einheitliche Inhaltsbreite in allen Schritten, gemeinsame Farbtokens für Fehler/Warnung/Erfolg statt ~40 einzelner Hex-Werte. Alle Text- und Fokusfarben erfüllen WCAG-Kontrast (Akzent `#00737d` statt `#00bcd4` für Text, Fokusring und Primär-Buttons). Schritte 3–5 stapeln sich unter 1024 px, nichts wird bei 200 % Zoom abgeschnitten; `prefers-reduced-motion` wird im ganzen Wizard beachtet. +- **Abhängigkeiten / CI / Bundle** — Angular 21.2.24 (Sicherheits-Patches), three 0.186, zone.js 0.16, Pyodide 0.29.5; `gsap` durch die Web Animations API ersetzt; ungenutzte Pakete entfernt; `npm audit` ohne Befund. Wizard wird per `@defer` nachgeladen (Initial-Bundle 1,47 → 1,12 MB), nur noch die genutzte Icon-Schriftart. druidjs 0.9 (Projektionen insgesamt ~2,7× schneller, korrigiertes UMAP/SAMMON; Lizenz jetzt LGPL-3.0-or-later — Lizenztexte werden unter `licenses/` mit ausgeliefert, `3rdpartylicenses.txt` wird veröffentlicht). Tests: Karma + Jasmine durch Vitest ersetzt (Browser-Modus, headless Chromium über Playwright). CI: Node 24 (`.nvmrc`), `npm ci`, Lint + Tests + Build vor dem Deploy. +- **Aufräumen** — `projection-preview` von 1.397 auf 186 Zeilen (Engine ausgelagert), Projektions-Worker von ~420 auf 62 Zeilen, toter In-Browser-CSV-Pfad (`processor.py`, Worker-Nachricht `process`) entfernt, `tools/dataset_common.py` nur noch für die lokale CLI, toter Code entfernt, Texte durchgehend Englisch. + +--- + ## Ausblick — offen, aber nicht im Umsetzungsrahmen Identifizierte, aber bewusst nicht umgesetzte Punkte: - **Methodenspezifische Standardwerte je Projektionsmethode** — laut Anforderungskatalog bewusst außerhalb des Rahmens. -- **Manuelles Beenden laufender Reduktionsmethoden** — aktuell lässt sich eine hängende/langlaufende Dimensionsreduktion nur über „Start over" stoppen, was den gesamten Wizard zurücksetzt (Konfiguration geht verloren). Ein gezielter Abbruch je Methode (bzw. globales „Verarbeitung abbrechen" ohne Reset) sowie ein Timeout pro Methode fehlen. Technische Grundlage vorhanden (isolierte Web-Worker, `ProjectionService.terminateAllWorkers()`), aber ohne nutzerseitige Steuerung. Offen für eine spätere Erweiterung. +- **Manuelles Beenden laufender Reduktionsmethoden** — umgesetzt für Hintergrund-Projektionen (Abbrechen/Neu starten je Methode, „Stop all“; `ProjectionService.cancel/retry/terminateAllWorkers`). Der primäre Pyodide-Lauf bleibt nicht abbrechbar: den Worker zu beenden würde die geladene Python-Laufzeit und die hochgeladene Datei verwerfen. Ein Timeout je Methode fehlt weiterhin. +- **Fehlende Werte bei Skalierung „None“** — landen bei 0 in Originaleinheiten (z. B. Breitengrad 0°, Datum 1970); betrifft nur Spalten ohne Skalierung (Koordinaten standardmäßig). +- **Upgrades** — Angular 22 (Komponenten standardmäßig OnPush; eigener PR, Angular 21 erhält Sicherheitsupdates bis ca. Mai 2027), Pyodide 314 (pandas 3; kein sichtbarer Nutzen, größerer Download; die Pipeline ist dafür vorbereitet und getestet). Erledigt: druidjs 0.9 (ändert die Einbettungen; TriMaps Parameter `weight_adj` gibt es dort nicht mehr, der Regler „Weight Adjustment“ wurde daher entfernt — TriMap läuft mit den Standardwerten der Bibliothek). +- **LLE vorübergehend ausgeblendet** — druidjs 0.9 liefert falsche LLE-Einbettungen (Eigenvektor-Shift ≈ 0, Power-Iteration findet die größten statt der kleinsten Eigenvektoren); wieder einblenden, sobald upstream behoben (`enableLLE` aus `HIDDEN_PROJECTION_METHODS` in `projection-methods.ts` entfernen). diff --git a/angular.json b/angular.json index e758743..1665789 100644 --- a/angular.json +++ b/angular.json @@ -27,10 +27,11 @@ "inlineStyleLanguage": "scss", "assets": [ "src/assets", - "src/favicon.ico" + "src/favicon.ico", + { "glob": "*.txt", "input": "src/licenses", "output": "licenses" } ], "styles": [ - "node_modules/material-icons/iconfont/material-icons.css", + "node_modules/material-icons/iconfont/filled.css", "src/styles.scss" ], "scripts": [], @@ -41,8 +42,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1MB", - "maximumError": "2MB" + "maximumWarning": "1.25MB", + "maximumError": "1.7MB" }, { "type": "anyComponentStyle", @@ -80,25 +81,21 @@ "builder": "@angular/build:extract-i18n" }, "test": { - "builder": "@angular/build:karma", + "builder": "@angular/build:unit-test", "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], + "runner": "vitest", "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "public" - } + "browsers": [ + "ChromiumHeadless" ], - "styles": [ - "src/styles.scss" + "browserViewport": "1280x720", + "setupFiles": [ + "src/test-setup.ts" ], - "scripts": [], - "webWorkerTsConfig": "tsconfig.worker.json" + "coverageReporters": [ + "html", + "text-summary" + ] } }, "lint": { diff --git a/docs/wizard.md b/docs/wizard.md new file mode 100644 index 0000000..2f88e0d --- /dev/null +++ b/docs/wizard.md @@ -0,0 +1,80 @@ +# Preprocessing wizard — developer notes + +The wizard turns an uploaded CSV into a dataset for the dashboard. It runs entirely in the browser: Python (pandas, scikit-learn) in a Pyodide web worker, dimensionality reductions (DruidJS) in further workers. All paths below are relative to `src/app/` unless they start with `src/` or `tools/`. + +## Steps + +The ordered step list is `WIZARD_STEPS` in `preprocessing-wizard/shared/constants/step-info.ts`. Code refers to steps by id, never by number; helpers `stepIndex`, `stepIdAt`, `stepNumber`, `stepLabel` convert. + +| # | id | Title | +|---|----|-------| +| 1 | `upload` | Upload Data | +| 2 | `columns` | Select Columns | +| 3 | `features` | Configure Data & Features | +| 4 | `projection` | Projection | +| 5 | `appearance` | Glyphs & Color | +| 6 | `review` | Review & Process | + +Steps 1–5 implement `WizardStep` (`shared/wizard-step.ts`) so the shell renders one navigation bar. Glyph and colour settings (step 5) only set the dashboard's initial view; the dashboard's Encoding panel can change them later. + +## Shell (`preprocessing-wizard/preprocessing-wizard.component.ts`) + +- **Dialog:** the root is `role="dialog"` / `aria-modal="true"`, labelled by the h1 `#wizard-dialog-title`. Focus moves to it on open, Tab is wrapped inside (`shared/dialog-focus.ts`), Escape closes the wizard unless a popup or the Start-over `` is open, and focus returns to the opener in the menubar. The wizard chunk is lazy-loaded with `@defer` from `menubar/menubar.component.html`. +- **Step changes:** focus moves to the "Step N of 6" heading and the change is announced. +- **Announcements:** `shared/wizard-announcer.service.ts` (`WizardAnnouncer.announce(message, 'polite' | 'assertive')`). The shell renders the two always-present live regions; steps never create their own. +- **Deep links:** `PreprocessingService.goToStepWithScroll(id, anchorId)` (history hint "Show change", Review edit buttons, issue-card "Fix in step N"). The shell takes the anchor with `consumeScrollTarget()`, then scrolls to and focuses it after render. Anchors are `wizard-anchor-*` ids in the step templates. +- **Stepper:** `shared/progress-stepper/` — `
    ` of buttons with `aria-current="step"`; completed steps show a summary from `shared/step-summaries.ts`. +- **Title bar:** Undo, Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y; ignored in text fields and while processing), Start over (native `` confirmation), Close. +- **Step 2 → 3 transition:** Web Animations API, skipped under `prefers-reduced-motion`, cancelled on destroy. + +## State and persistence + +`preprocessing-wizard/services/preprocessing.service.ts` owns `PreprocessingState` (`models/preprocessing-state.ts`). State is saved to localStorage by `services/wizard-storage.ts` with a format version (currently 2); older saves are migrated by step id (the old 5-step layout's review step 4 becomes 5), and projection settings that no longer exist (e.g. `trimapWeightAdj`) are dropped. Column defaults and the "smart defaults" live in `services/column-defaults.ts`; the config sent to Python is built in `services/processing-config.ts`. + +## Undo / redo + +- Every user action pushes exactly one snapshot (`pushHistory(label)`, capped at 50). Bulk actions use `updateColumnConfigs(changes, label)`; derived metadata such as outlier counts uses `updateColumnMetadata` and never creates an entry. +- `services/history-diff.ts` diffs snapshots and maps the changed field to a label, step id and anchor (`SETTING_META`) for the undo hint. +- Toast "Undo" buttons call `undoIfLatest(id)`, so they only revert their own action. +- While processing, undo/redo are no-ops (`holdProcessing()` / `whileProcessing(task)` keep `isProcessing` up until the whole primary run, FastMap included, finishes). +- The worker keeps uploaded CSVs for reprocessing; the service calls `DataProcessorService.pruneFiles(keep)` with every file name still referenced by the state or the undo/redo stacks. + +## Errors + +`preprocessing-wizard/shared/constants/wizard-error-classes.ts` maps raw worker errors to classes with a cause, a fix and a target step: + +| Class | Meaning | Fix in | +|---|---|---| +| K1 | Text values reached the numeric feature matrix | features | +| K2 | Cleaning removed every row | features | +| K3 | A column is constant or almost empty | columns | +| K4 | A neighbour parameter is ≥ the number of rows | projection | +| K5 | Glyph feature count outside 3–12 | appearance | +| K6 | Time or memory limit | projection | +| K7 | A text column has too many distinct values to one-hot encode | columns | +| K8 | A fill value doesn't fit its numeric column | features | + +Unmatched errors fall back to a generic blocking issue. `detectPreflightIssues` warns before processing (K3, K4, K6). Failed background projections become dismissable warnings (`classifyBackgroundFailure`). + +## Processing pipeline + +- **Worker:** `workers/pyodide.worker.ts` loads Pyodide from the jsDelivr CDN (`PYODIDE_VERSION`), installs pandas and scikit-learn, and writes the Python modules from `src/assets/` into its file system on demand. Requests carry a `requestId` and are handled one at a time. +- **No Python built from data:** `workers/pyodide-calls.ts` only turns fixed module/function names into code; all arguments (file names, column names from the CSV header, the config) are passed as data in a throwaway namespace. +- **File names:** `workers/pyodide-files.ts` rejects path separators, `.`/`..`, and importable suffixes (`.py`, `.pyc`, `.so`), and tracks the files the worker wrote for `pruneFiles`. +- **Order of operations** (header of `src/assets/preprocessing_processor_config.py`): fill missing values → cap outliers (detected on the original values) → drop rows (`remove_rows` columns, outlier `remove`) → drop duplicates → encode (one-hot, label, normalize, standardize, dates, booleans) → scale. Outlier detection is shared with Step 3's count (`find_outliers` in `src/assets/preprocessing_processor.py`). +- **Projections:** DruidJS (0.9: WASM kernels with a JS fallback; its worker pool is Node-only, so browsers get no nested workers) runs in `workers/projection.worker.ts` via `workers/druid-runner.ts`; only the primary FastMap runs on the main thread, loading DruidJS on first use. `services/projection.service.ts` runs each background method in its own worker and offers `cancel(method)`, `retry(method)`, `terminateAllWorkers()` and `detachAll()`. The primary Pyodide run can't be cancelled: terminating that worker would drop the loaded runtime and the uploaded file. +- **Hidden methods:** methods in `HIDDEN_PROJECTION_METHODS` (`preprocessing-wizard/steps/step4-projection-settings/projection-methods.ts`) are not offered in Step 4 and never run, summarised or warned about, even if a saved config enables them. LLE is hidden until druidjs fixes it (see ROADMAP); TriMap has no parameters and runs with DruidJS's defaults. +- **Local CSV import:** the CLI `tools/process.py` (with `tools/dataset_common.py`) writes the bundled datasets in `src/assets/data/`. It is not part of the app build; in the browser, CSV files are only imported through this wizard pipeline. + +## UI helpers + +- **Popups:** help tooltips and method previews go through `shared/popup-coordinator.ts` — one popup at a time, opens on hover/focus/click, closes on Escape or outside click, 180 ms hover grace. Method previews (`shared/projection-preview/`) draw synthetic data with a framework-free engine in `engine/`. +- **Consequence hints (Step 3):** `steps/step3-configure-data-features/consequence-preview.ts` turns the column profile into text such as "12.4 – 98.1 → 0.00 – 1.00" or "Drops 132 of 5,000 rows". +- **Styles:** tokens in `src/styles/_variables.scss`, wizard mixins in `preprocessing-wizard/shared/_wizard-shared.scss`. + +## Tests + +- Specs sit next to the code (`*.spec.ts`) and run with Vitest in headless Chromium (Angular `@angular/build:unit-test`, Playwright provider): `npm test` watches, `npm run test:ci` runs once. One file: `npx ng test --include=`. +- Shared test helpers: `preprocessing-wizard/testing/preprocessing-test-helpers.ts` (`createTestPreprocessingService()`, `createToastServiceSpy()`) and `src/testing/create-spy-obj.ts`. `src/test-setup.ts` restores `vi.spyOn` spies after every test. +- Python pipeline: `python -B -m pytest -p no:cacheprovider tools/tests/test_preprocessing_config.py` (each test runs with default pandas and with pandas-3 behaviour enabled; the wizard's option values are read from the TypeScript enums). +- Sample data for manual testing: "Try with sample data" in Step 1 (`src/assets/samples/wine-quality-red.csv`, regenerated by `tools/make-sample-csv.py`). diff --git a/package-lock.json b/package-lock.json index b762b2d..6824db6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,47 +8,40 @@ "name": "glyphspace", "version": "0.0.0", "dependencies": { - "@angular/common": "^21.1.4", - "@angular/compiler": "^21.1.4", - "@angular/core": "^21.1.4", - "@angular/forms": "^21.1.4", - "@angular/platform-browser": "^21.1.4", - "@angular/platform-browser-dynamic": "^21.1.4", - "@saehrimnir/druidjs": "^0.7.3", + "@angular/common": "^21.2.24", + "@angular/compiler": "^21.2.24", + "@angular/core": "^21.2.24", + "@angular/forms": "^21.2.24", + "@angular/platform-browser": "^21.2.24", + "@saehrimnir/druidjs": "^0.9.0", "d3": "^7.9.0", - "d3-force": "^3.0.0", - "gsap": "^3.15.0", "material-icons": "^1.13.14", - "pyodide": "^0.28.0", "rxjs": "~7.8.0", - "three": "^0.176.0", + "three": "^0.186.0", "tslib": "^2.3.0", - "zone.js": "~0.15.0" + "zone.js": "~0.16.3" }, "devDependencies": { - "@angular/build": "^21.1.4", - "@angular/cli": "^21.1.4", - "@angular/compiler-cli": "^21.1.4", + "@angular/build": "^21.2.24", + "@angular/cli": "^21.2.24", + "@angular/compiler-cli": "^21.2.24", "@types/d3": "^7.4.3", - "@types/d3-force": "^3.0.10", - "@types/d3-quadtree": "^3.0.6", - "@types/jasmine": "~5.1.0", - "@types/node": "^18.18.0", - "@types/three": "^0.176.0", - "angular-eslint": "21.0.1", - "eslint": "^9.39.1", + "@types/three": "^0.186.0", + "@vitest/browser-playwright": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", + "angular-eslint": "~21.4.0", + "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", - "jasmine-core": "~5.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", "lint-staged": "^16.2.7", + "playwright": "^1.62.1", "prettier": "^3.8.1", "typescript": "~5.9.3", - "typescript-eslint": "8.46.4" + "typescript-eslint": "^8.70.1", + "vitest": "^4.1.11" + }, + "engines": { + "node": "^24.15.0 || >=26.0.0" } }, "node_modules/@algolia/abtesting": { @@ -275,13 +268,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2102.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.11.tgz", - "integrity": "sha512-t7J8aaUho1mXjiIecPNX5/rjXeV8j8ZCGY5tD3ic5kzKxPkbuYYcQpJLdzlmBcN+wDgCmNdo8ySvItvU0m58lg==", + "version": "0.2102.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.24.tgz", + "integrity": "sha512-U/d5l/1rfrNSlbGjMTunyzwxh+2vJce3y0r0tIx/NOFZbnYhq+gx+e33mR6oxI4yk/1Jks67ltvstrJuBbVbYg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.11", + "@angular-devkit/core": "21.2.24", "rxjs": "7.8.2" }, "bin": { @@ -294,9 +287,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.11.tgz", - "integrity": "sha512-kfMNh5X2hOdyr0uNFaaHUJR3OVr4oH2+UhI+FsTu7gqogdgYlHAVHhHAFulfDgtAEOiqpeSQF9RhQnCJl+/LXA==", + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.24.tgz", + "integrity": "sha512-QLYLd6OcfQS5qWTlzG1/d8lFTcJDFcsY6sOaGVtrruYiDxrbeM2iJttoIoKzvbNPROFyo3fANVQJOVhytpXWPg==", "dev": true, "license": "MIT", "dependencies": { @@ -322,13 +315,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.2.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.11.tgz", - "integrity": "sha512-69CWZ5/ftLdpUPAwwdAxTNosiGXUyvwdnOfmHsd9NvCT0OSTeq0eQ0UfnGcHASrXIVmnyWiNfBWM1DLqsgBXmw==", + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.24.tgz", + "integrity": "sha512-mLOme2vfbLrHFmMyM6ybPuGCnCvPiemXWdHnptY2Q7Hzum72DabfgtTNsrMcI2F5zrQV2DDQunNjC5cXqtq4/w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.11", + "@angular-devkit/core": "21.2.24", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -341,9 +334,9 @@ } }, "node_modules/@angular-eslint/builder": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.0.1.tgz", - "integrity": "sha512-6BqpmW0XvjTOs2YOHwzeZcQ32eL8vs8SCHjt1cQnq1+libOVDXky1eb/jRs7ouyA49UagLDoM34K1kjrYo8P3Q==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.4.0.tgz", + "integrity": "sha512-3kgGmrVaCYbLtDjC8g4BmMBbdz4thsOB8/NYly8JtXM8EuDZEk5Pz6VTRpJR02ARprwayraTTmhyvq6OGBlQ9w==", "dev": true, "license": "MIT", "dependencies": { @@ -351,130 +344,121 @@ "@angular-devkit/core": ">= 21.0.0 < 22.0.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "*" } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.0.1.tgz", - "integrity": "sha512-Kb59SopkJ2sDgZSysL+ZqcfqM2cbK+gciAyHljkrCUsqo66eEq5KCZUU//RVoo4MHi+qL/dFy54JG/+A/35xcQ==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.4.0.tgz", + "integrity": "sha512-/3H4BPbQ1BHJkkrUsfusZtmHc+qiFWBBZ9UDPWah4xZMjflexOK9U4GYeH7nMjcuyqFnIlMMeJJNwNLGt/hmdg==", "dev": true, "license": "MIT" }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.0.1.tgz", - "integrity": "sha512-tSb5qgIwoMrX3Z17dSsHrNFWrgBWafxK7IQudU0RXxdzq6joq1qDrzHwLT3Jn+Y6ocn0jdavAefEGHAhomCjcQ==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.4.0.tgz", + "integrity": "sha512-mow2DMj+xBvGl5t7jzC34R8YfbHbaGNyCNFzpovtl9qc0JbuqLyg6htmt8xb05f8ZjATOr4nz0ESt6HV4c51hw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.0.1", - "@angular-eslint/utils": "21.0.1", + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "*" } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.0.1.tgz", - "integrity": "sha512-DF1WEMalbV1hNKxbu3nwK1yUa+E2FQpNz0KDORU65/vdCffeuftCetobrsAS7zDgJ6FO+Fsb+ZeCzNKEhhh1vA==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.4.0.tgz", + "integrity": "sha512-sJEHx2WYnvOgPpzP1eHnUdRS06zgKmRxbiIR0JiCcaSen5iv1HlsMieXy//FS9TtNW+abHOy4UtDuGuSPflPFA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.0.1", - "@angular-eslint/utils": "21.0.1", + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, "peerDependencies": { - "@angular-eslint/template-parser": "21.0.1", + "@angular-eslint/template-parser": "21.4.0", "@typescript-eslint/types": "^7.11.0 || ^8.0.0", "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "*" } }, "node_modules/@angular-eslint/schematics": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.0.1.tgz", - "integrity": "sha512-IdtGdRPuJctHuiZ8v8SN3MqWiUa3cD9Q5jFvIRkAkjpHXXmTk5PYelSjUP8UX/zfUfFkxHXghasTmJd2+252OQ==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.4.0.tgz", + "integrity": "sha512-crD6Hfxs7x5bN9FCqTZI7uVSiGvprfCS3MCPOpyIQl87bRr/9aNhnicJ3ROUHv+2A713BgPHIgiCII/bxzrfPw==", "dev": true, "license": "MIT", "dependencies": { "@angular-devkit/core": ">= 21.0.0 < 22.0.0", "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", - "@angular-eslint/eslint-plugin": "21.0.1", - "@angular-eslint/eslint-plugin-template": "21.0.1", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", "ignore": "7.0.5", - "semver": "7.7.3", + "semver": "7.7.4", "strip-json-comments": "3.1.1" - } - }, - "node_modules/@angular-eslint/schematics/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0" } }, "node_modules/@angular-eslint/template-parser": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.0.1.tgz", - "integrity": "sha512-1KocmjmBP0qlKQGRhRGN0MGvLxf1q2KDWbvzn7ZGdQrIDLC/hFJ8YmnOWsPrM9RxiZi0o5BxCCu9D7KlbthxIg==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.4.0.tgz", + "integrity": "sha512-BaUSLSyS+43fzDoJkTMkGqNdCXq3fGnUZsfXTmrlZPJf5AYFbgAlAPGZXDJyoNWw43fux+DafdlrlKcYUSgSIw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.0.1", - "eslint-scope": "^9.0.0" + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "eslint-scope": "9.1.2" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "*" } }, "node_modules/@angular-eslint/utils": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.0.1.tgz", - "integrity": "sha512-tovWIDiEsfSAsPWH+/wL9Hfl/Hc+2j2IP+Z85I6uWTbynLVdyURx8gmJjKBUTSCmcyrgBnTbnnlr4DTM6/aFOg==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.4.0.tgz", + "integrity": "sha512-7pi+Ga7QmdH5Ig/diau6fR5L4yubgKr9TOjdCg7OeuE/zo0O3osTCNT6JOodzS/iQM1kSCJFDoIBKFeUOttiNw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.0.1" + "@angular-eslint/bundled-angular-compiler": "21.4.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "*" } }, "node_modules/@angular/build": { - "version": "21.2.11", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.11.tgz", - "integrity": "sha512-2afR6VKkP0HH2u6OuijSMgSHsL5tU4CBCixgQtY677mlvS8TOZg/kOksJIUlz0EvDVCJZBK8WLH9cPJ6mC/Qdg==", + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.24.tgz", + "integrity": "sha512-NurM2W7Q/lCGonkYgSNNxGrh9AYH3LksekYRVxl0KxVKa8Z5x6oMYlLfyI4Lv9Q4C1/x1SXv3xNxgYu/nIW4vA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.11", - "@babel/core": "7.29.0", + "@angular-devkit/architect": "0.2102.24", + "@babel/core": "7.29.7", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "5.1.21", "@vitejs/plugin-basic-ssl": "2.1.4", "beasties": "0.4.1", "browserslist": "^4.26.0", - "esbuild": "0.27.3", + "esbuild": "0.28.1", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", @@ -483,14 +467,14 @@ "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.4", - "piscina": "5.1.4", + "piscina": "5.2.0", "rolldown": "1.0.0-rc.4", "sass": "1.97.3", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.15", - "undici": "7.24.4", - "vite": "7.3.2", + "undici": "7.29.1", + "vite": "7.3.6", "watchpack": "2.5.1" }, "engines": { @@ -509,7 +493,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.11", + "@angular/ssr": "^21.2.24", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -558,761 +542,1040 @@ } } }, - "node_modules/@angular/build/node_modules/@vitejs/plugin-basic-ssl": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", - "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", + "node_modules/@angular/build/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0" + "node": ">=18" } }, - "node_modules/@angular/build/node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "node_modules/@angular/build/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/cli": { - "version": "21.2.11", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.11.tgz", - "integrity": "sha512-vpF/oa+HzLl4lF78ePCgkhBdQj29IlFvZtBsbAXXpb16FLZSua2m7+yHd/PICTlchh1+LfIxFY9snMY1BllBsQ==", + "node_modules/@angular/build/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@angular-devkit/architect": "0.2102.11", - "@angular-devkit/core": "21.2.11", - "@angular-devkit/schematics": "21.2.11", - "@inquirer/prompts": "7.10.1", - "@listr2/prompt-adapter-inquirer": "3.0.5", - "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.11", - "@yarnpkg/lockfile": "1.1.0", - "algoliasearch": "5.48.1", - "ini": "6.0.0", - "jsonc-parser": "3.3.1", - "listr2": "9.0.5", - "npm-package-arg": "13.0.2", - "pacote": "21.3.1", - "parse5-html-rewriting-stream": "8.0.0", - "semver": "7.7.4", - "yargs": "18.0.0", - "zod": "4.3.6" - }, - "bin": { - "ng": "bin/ng.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": ">=18" } }, - "node_modules/@angular/common": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.13.tgz", - "integrity": "sha512-fNvRmGAX0zbsLX/kJjgb6l8HAuGTpfYRNc06taTCIvED2RsRpfwrh79IxYlPBspr+hpFbHa0/kxU6Q5I8V0jKQ==", + "node_modules/@angular/build/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.13", - "rxjs": "^6.5.3 || ^7.4.0" + "node": ">=18" } }, - "node_modules/@angular/compiler": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.13.tgz", - "integrity": "sha512-0OZk5ujHgowRme3iXJ1Ce1OI3eTDcGovBARBiyJT0E8kt9Y0TdQdGaYMRrNN1UzDv4hk8f1d/xVeF0BpMTvqPQ==", + "node_modules/@angular/build/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@angular/compiler-cli": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.13.tgz", - "integrity": "sha512-ueETJy2ZcXZ4a0aLEr+oPMw26f8Hn903WC4QN0MCH+sLB9Zustpzydqtmzo5mdSzwuoLoxcesYJTZFmpwD1xIQ==", + "node_modules/@angular/build/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "7.29.0", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^5.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^18.0.0" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.13", - "typescript": ">=5.9 <6.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/core": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.13.tgz", - "integrity": "sha512-23tS4oNL8nvkHcI4l9rbruQs2WS4yqQmBVQxWakqS9cmRpArLGgveR+hKNU5tPXm5EAi8oLO34/Zy7z70jUpCg==", + "node_modules/@angular/build/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.13", - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0 || ~0.16.0" - }, - "peerDependenciesMeta": { - "@angular/compiler": { - "optional": true - }, - "zone.js": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/forms": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.13.tgz", - "integrity": "sha512-efAKdL8eVRlGvcJWrUFcYyRE/togWfopUTw2D5TIkDAndnmmRaWA70wD4n/E1FFV5UdxSBxoyEYE0qVlPiewtQ==", + "node_modules/@angular/build/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.13", - "@angular/core": "21.2.13", - "@angular/platform-browser": "21.2.13", - "rxjs": "^6.5.3 || ^7.4.0" + "node": ">=18" } }, - "node_modules/@angular/platform-browser": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.13.tgz", - "integrity": "sha512-96rcwLHsklqAYRuS2SEBOUdQS5PLkuUIEEIjpYu4rxU2PVvOMapJEImM/QBxrbwjnCgRbj/CivkgfjiR0R0wSA==", + "node_modules/@angular/build/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/animations": "21.2.13", - "@angular/common": "21.2.13", - "@angular/core": "21.2.13" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.13.tgz", - "integrity": "sha512-VltLjoKi7lOIGtwkBy9jauV+JLU9PAaLU7/iIVxZBgucvV85xj7CA+//KOBzneQ5V+XtnpgVrdE9bHMFIhiH5Q==", + "node_modules/@angular/build/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.13", - "@angular/compiler": "21.2.13", - "@angular/core": "21.2.13", - "@angular/platform-browser": "21.2.13" + "node": ">=18" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@angular/build/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "node_modules/@angular/build/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "node_modules/@angular/build/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "node": ">=18" } }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@angular/build/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "node_modules/@angular/build/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "node_modules/@angular/build/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "node_modules/@angular/build/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@angular/build/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "node_modules/@angular/build/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "node_modules/@angular/build/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "node_modules/@angular/build/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "node_modules/@angular/build/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "node_modules/@angular/build/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@angular/build/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "node_modules/@angular/build/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "node_modules/@angular/build/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "node_modules/@angular/build/node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", + "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@angular/build/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", "bin": { - "parser": "bin/babel-parser.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=6.0.0" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "node_modules/@angular/cli": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.24.tgz", + "integrity": "sha512-lx8baI3gssBv+xuwEf5wvAoBi7XSIB1RRuAmSnuAO2E4CAlJmU4eiVhKxd/haXvONH6peYxFGH2N6mSrkkHfIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@angular-devkit/architect": "0.2102.24", + "@angular-devkit/core": "21.2.24", + "@angular-devkit/schematics": "21.2.24", + "@inquirer/prompts": "7.10.1", + "@listr2/prompt-adapter-inquirer": "3.0.5", + "@modelcontextprotocol/sdk": "1.30.0", + "@schematics/angular": "21.2.24", + "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.48.1", + "ini": "6.0.0", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "npm-package-arg": "13.0.2", + "pacote": "21.5.1", + "parse5-html-rewriting-stream": "8.0.0", + "semver": "7.7.4", + "yargs": "18.0.0", + "zod": "4.3.6" + }, + "bin": { + "ng": "bin/ng.js" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, + "node_modules/@angular/common": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.24.tgz", + "integrity": "sha512-+olMzBBJHIGrFTUxKRksqtQ+KzBTifLOF+Er0ppRfjbTyYwdq4jfgZ9YjDQa00F/8Jcy2xzM1Nrlq5sIbqxAhg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" + "tslib": "^2.3.0" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.24", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "node_modules/@angular/compiler": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.24.tgz", + "integrity": "sha512-kvhstROpI9gZZ4QUcNkJaqA2LNis8XVTBz3X5SaKZAveUMB6r5V2akYqEBZcXNS4xUfST/UttrUsVdRvpcIItw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "tslib": "^2.3.0" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@angular/compiler-cli": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.24.tgz", + "integrity": "sha512-oUk8IgWG5NXH3ZyqzRPxs9ylfe6K0p+mP89y023+/AF4A1Fse3V+tfjRxvq5gnlSyJm3yRl74W5SwsCi1kxxxA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "7.29.7", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, "engines": { - "node": ">=0.1.90" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.24", + "typescript": ">=5.9 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, + "node_modules/@angular/core": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.24.tgz", + "integrity": "sha512-wCSCNrcBolr2iws6eX8yMnePXvXMHzi3kFla7EoHiKGJBhKHz4g2+p5f4z/Q5mHVCglTsXEOldDhgFTv5pDDoQ==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.24", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } } }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, + "node_modules/@angular/forms": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.24.tgz", + "integrity": "sha512-1hOdu2dOjcVvYtcgdheEfin0nGj74YETHKhhG1iB1foqb70J6P/TfyXzTaiWr56cF0Dek1T3f9BjKE/KEFDUbw==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.24", + "@angular/core": "21.2.24", + "@angular/platform-browser": "21.2.24", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, + "node_modules/@angular/platform-browser": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.24.tgz", + "integrity": "sha512-ffxWfxhMeedWXNASBtEg8AShG8ccWA/48Vp/rQVEhlj/CeydXTNdGmiu6oERQ5my4nOk+xJJ4/BnkQ3fSozrfQ==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/animations": "21.2.24", + "@angular/common": "21.2.24", + "@angular/core": "21.2.24" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/types": "^7.27.3" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -1320,16 +1583,16 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -1337,103 +1600,103 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ - "mips64el" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ - "riscv64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ - "s390x" + "arm" ], "dev": true, "license": "MIT", @@ -1445,12 +1708,12 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -1462,61 +1725,180 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ - "x64" + "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ - "arm64" + "mips64el" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1531,9 +1913,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1548,9 +1930,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1565,9 +1947,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1582,9 +1964,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1599,9 +1981,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1667,9 +2049,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1717,9 +2099,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { @@ -1729,7 +2111,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1765,9 +2147,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1806,9 +2188,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1861,13 +2243,13 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -2478,13 +2860,13 @@ ] }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2746,6 +3128,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2763,6 +3148,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2780,6 +3168,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2797,6 +3188,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2814,6 +3208,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2831,6 +3228,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2848,6 +3248,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2944,48 +3347,10 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@npmcli/agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", "dev": true, "license": "ISC", "dependencies": { @@ -3000,9 +3365,9 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3053,9 +3418,9 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3536,6 +3901,13 @@ "license": "MIT", "optional": true }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", @@ -4115,23 +4487,23 @@ ] }, "node_modules/@saehrimnir/druidjs": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@saehrimnir/druidjs/-/druidjs-0.7.3.tgz", - "integrity": "sha512-ROrGB3VqU2ombyJz9XlvDvMCHstuJ25+lPzV1HXYaP0Pp4uOkcaHZUMJlA0YNB/SM0s/Toc9n4gJFCs7LPclDQ==", - "license": "BSD-3-Clause", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@saehrimnir/druidjs/-/druidjs-0.9.0.tgz", + "integrity": "sha512-Cv0xL8A8LbKqPbEyWu/bBnUd7Esjd/m92Dqyl2kpj6x30yCA6rRAtPl5Iw0Ks8u3cE9/acAeYxC/ezaxewI2nA==", + "license": "LGPL-3.0-or-later", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/@schematics/angular": { - "version": "21.2.11", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.11.tgz", - "integrity": "sha512-EqH12Fr3vaWFpsilFDFXkxwMIidEDZr5cGl0w2hDRG7DjXE2oRB/VXix8xmpuHkzJ40Jgew6hIc+bfbwQhFK1A==", + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.24.tgz", + "integrity": "sha512-eGBr4Yhg/A8Lt8QGtWxmXAyKboX8QBwbDG1hSyApQfHAh9iNFbV7mgWSmk0qPWvRJM38s0t3EEkEg79QZ98dWQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.11", - "@angular-devkit/schematics": "21.2.11", + "@angular-devkit/core": "21.2.24", + "@angular-devkit/schematics": "21.2.24", "jsonc-parser": "3.3.1" }, "engines": { @@ -4154,9 +4526,9 @@ } }, "node_modules/@sigstore/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz", - "integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4164,9 +4536,9 @@ } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", - "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.2.tgz", + "integrity": "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4206,27 +4578,20 @@ } }, "node_modules/@sigstore/verify": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", - "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4275,14 +4640,15 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/d3": { @@ -4569,6 +4935,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4590,13 +4963,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jasmine": { - "version": "5.1.15", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", - "integrity": "sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4604,16 +4970,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -4622,19 +4978,18 @@ "license": "MIT" }, "node_modules/@types/three": { - "version": "0.176.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.176.0.tgz", - "integrity": "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==", + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", "dev": true, "license": "MIT", "dependencies": { - "@dimforge/rapier3d-compat": "^0.12.0", + "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", - "@types/webxr": "*", - "@webgpu/types": "*", - "fflate": "~0.8.2", - "meshoptimizer": "~0.18.1" + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" } }, "node_modules/@types/webxr": { @@ -4644,32 +4999,21 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4679,21 +5023,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4703,33 +5049,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", - "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4738,15 +5072,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4755,24 +5093,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4781,20 +5107,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4804,20 +5131,16 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.4", - "eslint-visitor-keys": "^4.2.1" - }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4826,64 +5149,22 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "debug": "^4.3.4" + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4893,20 +5174,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", - "debug": "^4.3.4" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4916,18 +5197,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4937,583 +5219,296 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "node_modules/@vitest/browser": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.4", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "vitest": "4.1.11" } }, - "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "node_modules/@vitest/browser-playwright": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" + "url": "https://opencollective.com/vitest" }, - "engines": { - "node": ">=16 || 14 >=14.17" + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.11" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependenciesMeta": { + "playwright": { + "optional": false + } } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, + "license": "MIT" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">= 0.6" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.4", - "eslint-visitor-keys": "^4.2.1" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=0.4.0" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 14" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.4", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@webgpu/types": { - "version": "0.1.70", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz", - "integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/ajv-formats": { @@ -5561,24 +5556,25 @@ } }, "node_modules/angular-eslint": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-21.0.1.tgz", - "integrity": "sha512-YZT5SAYXv3zxTcCwjuAJb7D8tOnRyF+a7Hrn4niLAihH+DowBocOAvhWyjCDKEULNGDszcKZukaDMzp2S18I5A==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-21.4.0.tgz", + "integrity": "sha512-LH7bWmtJvsubzwPoztnl1pWgI5X0VrfGTUITGSYcwn2J+SXuN/avzrKrxJmhUiIrNvLtfV+18GG6xZS1IGZdKg==", "dev": true, "license": "MIT", "dependencies": { "@angular-devkit/core": ">= 21.0.0 < 22.0.0", "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", - "@angular-eslint/builder": "21.0.1", - "@angular-eslint/eslint-plugin": "21.0.1", - "@angular-eslint/eslint-plugin-template": "21.0.1", - "@angular-eslint/schematics": "21.0.1", - "@angular-eslint/template-parser": "21.0.1", + "@angular-eslint/builder": "21.4.0", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", + "@angular-eslint/schematics": "21.4.0", + "@angular-eslint/template-parser": "21.4.0", "@typescript-eslint/types": "^8.0.0", "@typescript-eslint/utils": "^8.0.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "*", "typescript-eslint": "^8.0.0" } @@ -5628,33 +5624,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5672,6 +5641,35 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5692,20 +5690,10 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/baseline-browser-mapping": { - "version": "2.10.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", - "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5736,36 +5724,37 @@ "node": ">=18.0.0" } }, - "node_modules/binary-extensions": { + "node_modules/body-parser": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "dev": true, "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, "engines": { "node": ">=18" }, @@ -5782,35 +5771,22 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.1.tgz", + "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", "dev": true, "funding": [ { @@ -5828,11 +5804,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.25", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.438", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -5899,9 +5875,9 @@ } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5950,9 +5926,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001812", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", "dev": true, "funding": [ { @@ -5970,6 +5946,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6183,113 +6169,28 @@ "dev": true, "license": "MIT" }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, "engines": { - "node": ">= 0.10.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" + "engines": { + "node": ">= 0.6" } }, "node_modules/convert-source-map": { @@ -6382,13 +6283,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true, - "license": "MIT" - }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", @@ -6802,16 +6696,6 @@ "node": ">=12" } }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6856,17 +6740,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6878,26 +6751,6 @@ "node": ">=8" } }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -6980,9 +6833,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.360", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", - "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", "dev": true, "license": "ISC" }, @@ -7003,101 +6856,6 @@ "node": ">= 0.8" } }, - "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7134,13 +6892,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -7161,6 +6912,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7175,9 +6933,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7188,32 +6946,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -7247,9 +7005,10 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -7258,8 +7017,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -7379,9 +7138,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7487,1840 +7246,1045 @@ "dev": true, "license": "BSD-3-Clause", "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", - "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/gsap": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", - "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.21", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", - "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/ignore-walk": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", - "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", + "estraverse": "^5.1.0" + }, "engines": { - "node": ">=0.8.19" + "node": ">=0.10" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "ISC" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/internmap": { + "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 0.6" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, "engines": { - "node": ">= 0.10" + "node": ">=18.0.0" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">=18" + "node": ">= 18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">=16.0.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 18.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8.0.0" + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" + "engines": { + "node": ">=16" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.8" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "dev": true, - "license": "BSD-3-Clause", + "license": "ISC", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "minipass": "^7.0.3" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6.9.0" } }, - "node_modules/jasmine-core": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz", - "integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", - "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=10.13.0" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, "license": "BSD-2-Clause" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/jsonfile": { + "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": ">=8" } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" + "function-bind": "^1.1.2" }, "engines": { - "node": ">= 10" + "node": ">= 0.4" } }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", "dev": true, "license": "MIT", - "dependencies": { - "which": "^1.2.1" + "engines": { + "node": ">=16.9.0" } }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "lru-cache": "^11.1.0" }, - "bin": { - "which": "bin/which" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, - "license": "MIT", - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10.0.0" + "node": "20 || >=22" } }, - "node_modules/karma-coverage/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, - "node_modules/karma-coverage/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">=8" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/karma-coverage/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": "*" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "jasmine-core": "^4.1.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" + "node": ">= 14" } }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" } }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=8" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/karma/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 4" + } }, - "node_modules/karma/node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "minimatch": "^10.0.3" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } + "license": "MIT" }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">= 8.10.0" + "node": ">=6" }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=0.8.19" } }, - "node_modules/karma/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, "engines": { - "node": ">= 6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/karma/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/karma/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 12" } }, - "node_modules/karma/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.10" } }, - "node_modules/karma/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/karma/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "is-extglob": "^2.1.1" }, "engines": { - "node": "*" + "node": ">=0.10.0" } }, - "node_modules/karma/node_modules/ms": { + "node_modules/is-interactive": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true, "license": "MIT" }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "picomatch": "^2.2.1" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" } }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" } }, - "node_modules/karma/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/karma/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/panva" } }, - "node_modules/karma/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "argparse": "^2.0.1" }, - "engines": { - "node": ">= 0.6" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=6" } }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", "dev": true, "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, "engines": { - "node": ">=10" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, "engines": { - "node": ">=10" + "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9498,13 +8462,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9615,23 +8572,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9652,6 +8592,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -9669,9 +8621,9 @@ } }, "node_modules/make-fetch-happen": { - "version": "15.0.5", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", - "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", "dev": true, "license": "ISC", "dependencies": { @@ -9709,13 +8661,17 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -9731,63 +8687,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/meshoptimizer": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", - "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", "dev": true, "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -9829,13 +8735,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -9844,16 +8750,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -9987,19 +8883,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -10062,9 +8945,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -10088,13 +8971,34 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "dev": true, "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/node-addon-api": { @@ -10106,9 +9010,9 @@ "optional": true }, "node_modules/node-gyp": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", - "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { @@ -10157,9 +9061,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "dev": true, "license": "MIT", "engines": { @@ -10183,11 +9087,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -10205,16 +9112,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-bundled": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", @@ -10353,6 +9250,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -10487,9 +9398,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", "dev": true, "license": "MIT", "engines": { @@ -10500,12 +9411,13 @@ } }, "node_modules/pacote": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", - "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", "dev": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", @@ -10519,7 +9431,6 @@ "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" @@ -10631,16 +9542,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -10669,9 +9570,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -10689,6 +9590,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10710,9 +9618,9 @@ } }, "node_modules/piscina": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", - "integrity": "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", "dev": true, "license": "MIT", "engines": { @@ -10732,10 +9640,67 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -10753,7 +9718,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10831,24 +9796,10 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10857,45 +9808,21 @@ }, "engines": { "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pyodide": { - "version": "0.28.3", - "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.3.tgz", - "integrity": "sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A==", - "license": "MPL-2.0", - "dependencies": { - "ws": "^8.5.0" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.9" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -10904,27 +9831,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -10972,16 +9878,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -10992,13 +9888,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -11026,27 +9915,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -11054,23 +9922,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -11174,32 +10025,8 @@ "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" + "engines": { + "node": ">= 18" } }, "node_modules/rw": { @@ -11217,24 +10044,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -11383,15 +10192,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -11458,6 +10267,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11472,23 +10288,38 @@ } }, "node_modules/sigstore": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", - "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -11530,101 +10361,10 @@ "npm": ">= 3.0.0" } }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.20.1" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11711,9 +10451,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", "dev": true, "license": "CC0-1.0" }, @@ -11730,6 +10470,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11740,6 +10487,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -11753,21 +10507,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -11838,9 +10577,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -11865,9 +10604,16 @@ } }, "node_modules/three": { - "version": "0.176.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz", - "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, "license": "MIT" }, "node_modules/tinyexec": { @@ -11897,27 +10643,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, "engines": { - "node": ">=8.0" + "node": ">=14.0.0" } }, "node_modules/toidentifier": { @@ -11930,366 +10663,144 @@ "node": ">=0.6" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tuf-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", - "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", - "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": ">=6" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.12" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "prelude-ls": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">= 0.8.0" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "eslint-visitor-keys": "^4.2.1" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/express" } }, - "node_modules/typescript-eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { + "node_modules/type-is/node_modules/content-type": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=14.17" } }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "node_modules/typescript-eslint": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -12301,9 +10812,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -12351,16 +10862,6 @@ "node": ">=6" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -12381,14 +10882,169 @@ "node": ">= 0.8" } }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=0.10.0" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, "node_modules/watchpack": { @@ -12429,6 +11085,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12517,9 +11190,10 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -12630,9 +11304,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", "engines": { @@ -12676,9 +11350,9 @@ } }, "node_modules/zone.js": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", - "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.3.tgz", + "integrity": "sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==", "license": "MIT" } } diff --git a/package.json b/package.json index 174873d..f85a505 100644 --- a/package.json +++ b/package.json @@ -7,55 +7,48 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", - "deploy": "ng build --base-ref /glyphspace/", + "test:ci": "ng test --watch=false", "lint": "ng lint", "format": "prettier --write \"src/**/*.{ts,html,scss,json}\"", "format:check": "prettier --check \"src/**/*.{ts,html,scss,json}\"", "prepare": "husky" }, "private": true, + "engines": { + "node": "^24.15.0 || >=26.0.0" + }, "dependencies": { - "@angular/common": "^21.1.4", - "@angular/compiler": "^21.1.4", - "@angular/core": "^21.1.4", - "@angular/forms": "^21.1.4", - "@angular/platform-browser": "^21.1.4", - "@angular/platform-browser-dynamic": "^21.1.4", - "@saehrimnir/druidjs": "^0.7.3", + "@angular/common": "^21.2.24", + "@angular/compiler": "^21.2.24", + "@angular/core": "^21.2.24", + "@angular/forms": "^21.2.24", + "@angular/platform-browser": "^21.2.24", + "@saehrimnir/druidjs": "^0.9.0", "d3": "^7.9.0", - "d3-force": "^3.0.0", - "gsap": "^3.15.0", "material-icons": "^1.13.14", - "pyodide": "^0.28.0", "rxjs": "~7.8.0", - "three": "^0.176.0", + "three": "^0.186.0", "tslib": "^2.3.0", - "zone.js": "~0.15.0" + "zone.js": "~0.16.3" }, "devDependencies": { - "@angular/build": "^21.1.4", - "@angular/cli": "^21.1.4", - "@angular/compiler-cli": "^21.1.4", + "@angular/build": "^21.2.24", + "@angular/cli": "^21.2.24", + "@angular/compiler-cli": "^21.2.24", "@types/d3": "^7.4.3", - "@types/d3-force": "^3.0.10", - "@types/d3-quadtree": "^3.0.6", - "@types/jasmine": "~5.1.0", - "@types/node": "^18.18.0", - "@types/three": "^0.176.0", - "angular-eslint": "21.0.1", - "eslint": "^9.39.1", + "@types/three": "^0.186.0", + "@vitest/browser-playwright": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", + "angular-eslint": "~21.4.0", + "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", - "jasmine-core": "~5.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", "lint-staged": "^16.2.7", + "playwright": "^1.62.1", "prettier": "^3.8.1", "typescript": "~5.9.3", - "typescript-eslint": "8.46.4" + "typescript-eslint": "^8.70.1", + "vitest": "^4.1.11" }, "lint-staged": { "src/**/*.{ts,html}": [ diff --git a/src/app/canvas/glyph-canvas.component.ts b/src/app/canvas/glyph-canvas.component.ts index 0b0764a..d7f0618 100644 --- a/src/app/canvas/glyph-canvas.component.ts +++ b/src/app/canvas/glyph-canvas.component.ts @@ -20,7 +20,7 @@ import { DataLoaderService } from '../services/data-loader.service'; import { FilterService } from '../services/filter.service'; import { GlyphObject } from '../glyph/glyph-object'; import { ZoomLevel } from '../shared/enum/zoom-level'; -import { forceCollide, forceSimulation, Simulation } from 'd3-force'; +import { forceCollide, forceSimulation, Simulation } from 'd3'; import { clusterGlyphs, getGlyphFromObject } from '../shared/helpers/glyph-helper'; import { InteractionCommand } from '../shared/enum/interaction-command'; import { GlyphCacheObject } from '../glyph/glyph-cache-object'; diff --git a/src/app/canvas/magiclens/magiclens.component.ts b/src/app/canvas/magiclens/magiclens.component.ts index 2938d6e..5477dc8 100644 --- a/src/app/canvas/magiclens/magiclens.component.ts +++ b/src/app/canvas/magiclens/magiclens.component.ts @@ -1,5 +1,5 @@ import { Component, ElementRef, Input, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; -import { forceSimulation, forceCollide } from 'd3-force'; +import { forceSimulation, forceCollide } from 'd3'; import * as THREE from 'three'; import { GlyphObject } from '../../glyph/glyph-object'; import { ZoomLevel } from '../../shared/enum/zoom-level'; diff --git a/src/app/canvas/services/canvas-renderer.service.ts b/src/app/canvas/services/canvas-renderer.service.ts index 608052d..bc657c8 100644 --- a/src/app/canvas/services/canvas-renderer.service.ts +++ b/src/app/canvas/services/canvas-renderer.service.ts @@ -12,7 +12,7 @@ import { scalePosition, screenToWorld, } from '../../shared/helpers/three-helper'; -import { LENS_ENLARGEMENT_FACTOR } from '../../shared/constants/canvas-constants'; +import { LENS_ENLARGEMENT_FACTOR, MIN_RENDER_INTERVAL_MS } from '../../shared/constants/canvas-constants'; import { GlyphRenderConfig } from '../../glyph/glyph-render-config'; // Reusable scratch vector to avoid per-frame allocations @@ -45,7 +45,6 @@ export class CanvasRendererService { // Render throttling to prevent cascading re-renders private lastRenderTime = 0; private pendingRender: ReturnType | null = null; - private readonly MIN_RENDER_INTERVAL_MS = 50; setRenderRequestCallback(fn: () => void): void { this.onRenderRequested = fn; @@ -99,7 +98,7 @@ export class CanvasRendererService { const elapsed = now - this.lastRenderTime; // Throttle: if called too rapidly, schedule a single trailing render - if (elapsed < this.MIN_RENDER_INTERVAL_MS && !force) { + if (elapsed < MIN_RENDER_INTERVAL_MS && !force) { if (!this.pendingRender) { this.pendingRender = setTimeout(() => { this.pendingRender = null; @@ -112,7 +111,7 @@ export class CanvasRendererService { renderConfig, force ); - }, this.MIN_RENDER_INTERVAL_MS - elapsed); + }, MIN_RENDER_INTERVAL_MS - elapsed); } return; } diff --git a/src/app/canvas/settingscontrols/settingscontrols.component.ts b/src/app/canvas/settingscontrols/settingscontrols.component.ts index 0e31234..abf0093 100644 --- a/src/app/canvas/settingscontrols/settingscontrols.component.ts +++ b/src/app/canvas/settingscontrols/settingscontrols.component.ts @@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms'; import { FilterService } from '../../services/filter.service'; import { ProjectionService } from '../../services/projection.service'; import { Subscription } from 'rxjs'; -import { COLOR_SCALES, getContinuousGradient, getCategoricalColors } from '../../shared/interfaces/color-scale'; +import { getColorScaleById, getContinuousGradient, getCategoricalColors } from '../../shared/interfaces/color-scale'; import { getGlyphTypeName } from '../../shared/enum/glyph-type'; export type SettingMode = 'position' | null; @@ -199,7 +199,7 @@ export class SettingsControlPanelComponent implements OnDestroy, OnInit { return; } - const colorScale = COLOR_SCALES.find(cs => cs.id === colorRange) || COLOR_SCALES[0]; + const colorScale = getColorScaleById(colorRange); let gradient: string; if (colorScale.type === 'categorical') { diff --git a/src/app/canvas/tooltip/tooltip.component.html b/src/app/canvas/tooltip/tooltip.component.html index 9a73217..2a2be0e 100644 --- a/src/app/canvas/tooltip/tooltip.component.html +++ b/src/app/canvas/tooltip/tooltip.component.html @@ -4,5 +4,20 @@ [style.left.px]="tooltipX" [style.top.px]="tooltipY" [style.display]="tooltipVisible || tooltipFixed ? 'block' : 'none'" - [innerHTML]="tooltipText" -> +> + @if (tooltipInfo; as info) { +
    + Item: {{ info.id }} +
+ @for (row of info.rows; track row.key) { + + + + + } +
{{ row.label }}{{ row.value ?? 'missing' }}
+
+ } @else { + Unnamed object + } + diff --git a/src/app/canvas/tooltip/tooltip.component.scss b/src/app/canvas/tooltip/tooltip.component.scss index 0f864b4..ee5a4c9 100644 --- a/src/app/canvas/tooltip/tooltip.component.scss +++ b/src/app/canvas/tooltip/tooltip.component.scss @@ -22,3 +22,36 @@ background: rgba(0, 0, 0, 1); } } + +.tooltip-body { + max-width: 300px; + font-family: sans-serif; + font-size: 13px; + + table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + margin-top: 0.4em; + } + + th, + td { + padding: 0; + vertical-align: top; + text-align: left; + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + } + + th { + width: 40%; + padding-right: 0.75em; + } + + td.missing { + font-style: italic; + opacity: 0.7; + } +} diff --git a/src/app/canvas/tooltip/tooltip.component.spec.ts b/src/app/canvas/tooltip/tooltip.component.spec.ts new file mode 100644 index 0000000..fb4e866 --- /dev/null +++ b/src/app/canvas/tooltip/tooltip.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { TooltipComponent, buildTooltipRows } from './tooltip.component'; +import { ConfigService } from '../../services/config.service'; + +describe('buildTooltipRows', () => { + it('labels each value and marks missing ones', () => { + const rows = buildTooltipRows({ '1': '7.4', '2': '', '3': 'nan', '4': '0' }, { '1': 'acidity', '2': 'sugar' }); + + expect(rows).toEqual([ + { key: '1', label: 'acidity', value: '7.4' }, + { key: '2', label: 'sugar', value: null }, + { key: '3', label: '3', value: null }, + { key: '4', label: '4', value: '0' }, + ]); + }); +}); + +describe('TooltipComponent', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TooltipComponent], + providers: [ + { provide: ConfigService, useValue: { modalOpen: false, featureLabels: {}, modalOpenSubject$: of(false) } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(TooltipComponent); + host = fixture.nativeElement as HTMLElement; + }); + + // Regression: values, labels and ids come from uploaded CSV files and were + // interpolated into HTML trusted via bypassSecurityTrustHtml (stored XSS). + it('renders CSV content as text, never as markup', () => { + const payload = ''; + fixture.componentInstance.tooltipInfo = { + id: payload, + rows: [{ key: '1', label: payload, value: payload }], + }; + fixture.componentInstance.tooltipVisible = true; + fixture.detectChanges(); + + expect(host.querySelector('img')).toBeNull(); + expect(host.textContent).toContain(payload); + }); + + it('shows "missing" instead of a value for missing entries', () => { + fixture.componentInstance.tooltipInfo = { id: 'a', rows: [{ key: '1', label: 'sugar', value: null }] }; + fixture.componentInstance.tooltipVisible = true; + fixture.detectChanges(); + + const cell = host.querySelector('td'); + expect(cell?.textContent?.trim()).toBe('missing'); + expect(cell?.classList).toContain('missing'); + }); +}); diff --git a/src/app/canvas/tooltip/tooltip.component.ts b/src/app/canvas/tooltip/tooltip.component.ts index 234e153..fb3771d 100644 --- a/src/app/canvas/tooltip/tooltip.component.ts +++ b/src/app/canvas/tooltip/tooltip.component.ts @@ -2,9 +2,34 @@ import * as THREE from 'three'; import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { getGlyphFromObject } from '../../shared/helpers/glyph-helper'; import { ConfigService } from '../../services/config.service'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { Subscription } from 'rxjs'; +/** One tooltip line; `value` is null when the original data had no value. */ +export interface TooltipRow { + key: string; + label: string; + value: string | null; +} + +export interface TooltipInfo { + id: string; + rows: TooltipRow[]; +} + +// '' is what the processors write for a missing value; 'nan' is what older CLI output contains. +const MISSING_VALUES = new Set(['', 'nan']); + +export function buildTooltipRows( + values: Record, + labels: Record | undefined +): TooltipRow[] { + return Object.entries(values).map(([key, value]) => ({ + key, + label: labels?.[key] ?? key, + value: MISSING_VALUES.has(value.trim().toLowerCase()) ? null : value, + })); +} + @Component({ selector: 'app-tooltip', imports: [], @@ -19,14 +44,12 @@ export class TooltipComponent implements OnInit, OnDestroy { private subscription = new Subscription(); tooltipVisible = false; tooltipFixed = false; - tooltipText: SafeHtml = ''; + // Rendered by the template, so CSV content (ids, labels, values) is always escaped. + tooltipInfo: TooltipInfo | null = null; tooltipX = 0; tooltipY = 0; - constructor( - private config: ConfigService, - private sanitizer: DomSanitizer - ) {} + constructor(private config: ConfigService) {} ngOnInit(): void { // Subscribe to modal state changes to hide fixed tooltips when modal opens @@ -43,12 +66,12 @@ export class TooltipComponent implements OnInit, OnDestroy { this.subscription.unsubscribe(); } - showTooltip(x: number, y: number, text: string): void { + showTooltip(x: number, y: number, info: TooltipInfo | null): void { // Don't show tooltip if a modal (like preprocessing wizard) is open if (this.config.modalOpen) { return; } - this.tooltipText = this.sanitizer.bypassSecurityTrustHtml(text); + this.tooltipInfo = info; this.tooltipX = x + 10; // slight offset from cursor this.tooltipY = y + 10; this.tooltipVisible = true; @@ -137,41 +160,11 @@ export class TooltipComponent implements OnInit, OnDestroy { return this.tooltipFixed; } - getObjectInfo(object: THREE.Object3D): string { + getObjectInfo(object: THREE.Object3D): TooltipInfo | null { const glyph = getGlyphFromObject(object); - const labels = this.config.featureLabels; - - if (glyph !== null) { - const rows = Object.entries(glyph.values || {}) - .map(([key, value]) => { - const label = labels?.[key] ?? key; - return ` -

- ${label} - - ${value} -
- ${rows} -
-
- `; + if (glyph === null) { + return null; } - - return 'Unnamed object'; + return { id: glyph.id, rows: buildTooltipRows(glyph.values ?? {}, this.config.featureLabels) }; } } diff --git a/src/app/glyph/glyph-cache-object.ts b/src/app/glyph/glyph-cache-object.ts index a8ca252..eecddff 100644 --- a/src/app/glyph/glyph-cache-object.ts +++ b/src/app/glyph/glyph-cache-object.ts @@ -1,4 +1,4 @@ -import { SimulationNodeDatum } from 'd3-force'; +import { SimulationNodeDatum } from 'd3'; import { Coordinates } from '../shared/interfaces/coordinates'; import { Object3D } from 'three'; diff --git a/src/app/menubar/menubar.component.html b/src/app/menubar/menubar.component.html index 49b5094..a96f86c 100644 --- a/src/app/menubar/menubar.component.html +++ b/src/app/menubar/menubar.component.html @@ -8,8 +8,15 @@ - @@ -23,14 +30,20 @@
Data
- @for (entry of datasetEntries; track entry.name) { } @if (isUserDataset(selectedDataset)) { - }
@@ -38,18 +51,18 @@
- - -
@@ -57,12 +70,12 @@
Canvas - -
@@ -72,6 +85,12 @@ @if (showWizard) {
- + + @defer (on immediate) { + + } @loading (after 150ms) { +

Loading wizard…

+ }
} diff --git a/src/app/menubar/menubar.component.scss b/src/app/menubar/menubar.component.scss index bb63189..75d2f68 100644 --- a/src/app/menubar/menubar.component.scss +++ b/src/app/menubar/menubar.component.scss @@ -1,4 +1,5 @@ @use '../../styles/variables' as v; +@use '../../styles/mixins' as m; .menu-bar { display: flex; @@ -123,6 +124,10 @@ &:focus { outline: none; } + + &:focus-visible { + @include m.focus-ring; + } } /* Optional: add a custom arrow */ @@ -138,6 +143,24 @@ } } +// Shared look of the toolbar buttons (icon panel and canvas controls). +// Include it after the caller's own declarations so they stay in one rule. +@mixin toolbar-button { + align-items: center; + border: none; + cursor: pointer; + font-size: 14px; + color: v.$gray-800; + + &:hover { + background: v.$gray-200; + } + + .material-icons { + font-size: 18px; + } +} + .icon-panel { display: flex; gap: 6px; @@ -147,25 +170,11 @@ button { display: flex; - align-items: center; gap: 6px; - - border: none; background: transparent; padding: 6px 10px; border-radius: 8px; - cursor: pointer; - - font-size: 14px; - color: v.$gray-800; - - &:hover { - background: v.$gray-200; - } - - .material-icons { - font-size: 18px; - } + @include toolbar-button; } } @@ -180,25 +189,12 @@ .add-btn { display: inline-flex; - align-items: center; gap: 4px; white-space: nowrap; - - border: none; padding: 6px 12px; border-radius: 10px; background: v.$gray-100; - cursor: pointer; - font-size: 14px; - color: v.$gray-800; - - &:hover { - background: v.$gray-200; - } - - .material-icons { - font-size: 18px; - } + @include toolbar-button; } } @@ -242,11 +238,10 @@ top: 64px; left: 0; right: 0; - z-index: 920; + z-index: v.$z-menu; background: white; max-height: 0; overflow: hidden; - transition: max-height 400ms ease; transition: height 400ms ease; } @@ -297,7 +292,7 @@ right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); - z-index: 9999; + z-index: v.$z-overlay; display: flex; align-items: center; justify-content: center; @@ -316,6 +311,38 @@ animation: slideUp 0.3s ease; overflow: hidden; } + + // Shown by @defer while the lazy wizard chunk loads (after 150ms). + .wizard-loading { + margin: 0; + padding: v.$space-2 v.$space-4; + border-radius: 8px; + background: white; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + color: v.$gray-700; + font-size: v.$font-size-md; + } + + @media (prefers-reduced-motion: reduce) { + &, + app-preprocessing-wizard { + animation: none; + } + } + + // Small viewports / 200% zoom (1280x800 -> 640x400 CSS px): the wizard takes + // the whole viewport instead of 90% minus the 32px backdrop padding. + @media (max-width: 767.98px), (max-height: 600px) { + padding: 0; + + app-preprocessing-wizard { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + border-radius: 0; + } + } } @keyframes fadeIn { diff --git a/src/app/menubar/menubar.component.spec.ts b/src/app/menubar/menubar.component.spec.ts new file mode 100644 index 0000000..e951ad5 --- /dev/null +++ b/src/app/menubar/menubar.component.spec.ts @@ -0,0 +1,77 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { BehaviorSubject } from 'rxjs'; +import { MenuBarComponent } from './menubar.component'; +import { DataLoaderService } from '../services/data-loader.service'; +import { FilterService } from '../services/filter.service'; +import { DataExportService } from '../services/data-export.service'; +import { ConfigService } from '../services/config.service'; + +/** The menubar hosts the wizard dialog: opening, closing and returning focus (WCAG 2.4.3). */ +describe('MenuBarComponent – wizard dialog host', () => { + let fixture: ComponentFixture; + let el: HTMLElement; + const config = { modalOpen: false, loadedDataSubject$: new BehaviorSubject('') }; + + function uploadButton(): HTMLButtonElement { + const button = Array.from(el.querySelectorAll('.icon-panel button')).find(b => + b.textContent?.includes('Upload') + ); + if (!button) throw new Error('Upload button not rendered'); + return button; + } + + beforeEach(async () => { + config.modalOpen = false; + await TestBed.configureTestingModule({ + imports: [MenuBarComponent], + providers: [ + { provide: DataLoaderService, useValue: { dataSetCollectionSubject$: new BehaviorSubject([]) } }, + { provide: FilterService, useValue: {} }, + { provide: DataExportService, useValue: {} }, + { provide: ConfigService, useValue: config }, + ], + }) + .overrideComponent(MenuBarComponent, { set: { imports: [FormsModule], schemas: [NO_ERRORS_SCHEMA] } }) + .compileComponents(); + fixture = TestBed.createComponent(MenuBarComponent); + el = fixture.nativeElement as HTMLElement; + fixture.detectChanges(); + }); + + it('announces that the Upload button opens a dialog', () => { + expect(uploadButton().getAttribute('aria-haspopup')).toBe('dialog'); + }); + + it('opens the wizard overlay from the Upload button', () => { + uploadButton().click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.showWizard).toBe(true); + expect(config.modalOpen).toBe(true); + expect(el.querySelector('.wizard-overlay')).not.toBeNull(); + }); + + it('returns focus to the Upload button when the wizard closes', () => { + // On narrow layouts Upload sits in the collapsible menu, which the user opened to reach it. + fixture.componentInstance.menuOpen = true; + fixture.detectChanges(); + uploadButton().click(); + fixture.detectChanges(); + (document.activeElement as HTMLElement | null)?.blur(); + + fixture.componentInstance.closePreprocessingWizard(); + fixture.detectChanges(); + + expect(fixture.componentInstance.showWizard).toBe(false); + expect(config.modalOpen).toBe(false); + expect(document.activeElement).toBe(uploadButton()); + }); + + it('hides the decorative icons from assistive technology', () => { + const icons = Array.from(el.querySelectorAll('.material-icons')); + expect(icons.length).toBeGreaterThan(0); + icons.forEach(icon => expect(icon.getAttribute('aria-hidden'), icon.textContent ?? '').toBe('true')); + }); +}); diff --git a/src/app/menubar/menubar.component.ts b/src/app/menubar/menubar.component.ts index 9360b33..bd6a86d 100644 --- a/src/app/menubar/menubar.component.ts +++ b/src/app/menubar/menubar.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; import { PreprocessingWizardComponent } from '../preprocessing-wizard/preprocessing-wizard.component'; @@ -18,6 +18,9 @@ export class MenuBarComponent implements OnInit, OnDestroy { @Output() addCanvas = new EventEmitter(); @Input() totalCells = 0; + @ViewChild('uploadButton') uploadButton?: ElementRef; + @ViewChild('menuToggle') menuToggle?: ElementRef; + menuOpen = false; showWizard = false; hasData = false; @@ -69,6 +72,18 @@ export class MenuBarComponent implements OnInit, OnDestroy { closePreprocessingWizard() { this.showWizard = false; this.configService.modalOpen = false; + this.restoreFocusAfterWizard(); + } + + /** + * WCAG 2.4.3: closing the wizard dialog returns focus to the Upload button that + * opened it — or to the menu toggle when the (mobile) menu is collapsed. + */ + private restoreFocusAfterWizard(): void { + const toggle = this.menuToggle?.nativeElement; + const menuCollapsed = !this.menuOpen && !!toggle && toggle.getClientRects().length > 0; + const target = menuCollapsed ? toggle : this.uploadButton?.nativeElement; + target?.focus(); } download() { diff --git a/src/app/preprocessing-wizard/models/column-config.ts b/src/app/preprocessing-wizard/models/column-config.ts index bc3cb33..15dcc03 100644 --- a/src/app/preprocessing-wizard/models/column-config.ts +++ b/src/app/preprocessing-wizard/models/column-config.ts @@ -67,8 +67,7 @@ export interface ProjectionConfig { umapNeighbors: number; umapMinDist: number; - // TriMap parameters - trimapWeightAdj: number; // Weight adjustment factor (default: 500) + // TriMap has no parameters: it runs with DruidJS's defaults. } export interface CleaningResult { diff --git a/src/app/preprocessing-wizard/models/preprocessing-state.ts b/src/app/preprocessing-wizard/models/preprocessing-state.ts index b08433f..0387c7c 100644 --- a/src/app/preprocessing-wizard/models/preprocessing-state.ts +++ b/src/app/preprocessing-wizard/models/preprocessing-state.ts @@ -3,7 +3,8 @@ import { ColumnConfig, CleaningConfig, ProjectionConfig, CleaningResult } from ' import { DatasetCollection } from '../../shared/interfaces/dataset-collection'; export interface PreprocessingState { - // Current step (0-5) + // Index into WIZARD_STEPS (0 = upload … 5 = review). Resolve it through the + // step-id helpers in shared/constants/step-info.ts instead of comparing numbers. currentStep: number; // Data @@ -45,7 +46,7 @@ export interface ProcessingProgress { /** * A4 – Undo/Redo. Status of the history stack, published so the wizard shell can * enable/disable the toolbar buttons and show the concrete next action in the - * tooltip (e.g. "Rückgängig: Smart Defaults angewendet"). + * tooltip (e.g. "Undo: Recommended features applied (Ctrl+Z)"). */ export interface HistoryStatus { canUndo: boolean; @@ -88,7 +89,28 @@ export const DEFAULT_PROJECTION_CONFIG: ProjectionConfig = { // UMAP parameters umapNeighbors: 15, umapMinDist: 0.1, - - // TriMap parameters - trimapWeightAdj: 500, }; + +/** State of a wizard with nothing loaded yet. */ +export function createInitialState(): PreprocessingState { + return { + currentStep: 0, + rawFileName: null, + dataProfile: null, + columnConfigs: new Map(), + cleaningConfig: { ...DEFAULT_CLEANING_CONFIG }, + projectionConfig: { ...DEFAULT_PROJECTION_CONFIG }, + cleaningResult: null, + processedDataset: null, + datasetName: '', + timestamp: '', + glyphFeatures: [], + tooltipFeatures: [], + colorScaleMode: 'continuous', + colorScaleId: 0, + isProcessing: false, + processingProgress: 0, + processingStep: '', + error: null, + }; +} diff --git a/src/app/preprocessing-wizard/preprocessing-wizard.component.html b/src/app/preprocessing-wizard/preprocessing-wizard.component.html index b727d6d..e10cdbc 100644 --- a/src/app/preprocessing-wizard/preprocessing-wizard.component.html +++ b/src/app/preprocessing-wizard/preprocessing-wizard.component.html @@ -1,12 +1,57 @@ -
+ + +
+ + + +
+ +
+
+ diff --git a/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.scss b/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.scss new file mode 100644 index 0000000..3491dc3 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.scss @@ -0,0 +1,490 @@ +@use '../../shared/wizard-shared' as w; +@use '../../../../styles/variables' as v; +@use '../../../../styles/mixins' as m; + +// A6: invisible scroll anchor used by the review step's deep-links +.scroll-anchor { + @include w.scroll-anchor; +} + +.step5-glyph-color-settings { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + + .step-content { + @include w.step-content; + } + + // Reminds that this step only seeds the dashboard's initial view. + .initial-view-note { + @include w.info-box; + margin-top: 0; + } + + // ============================================================================ + // Config Panel (shared base) + // ============================================================================ + + .config-panel { + @include w.config-panel; + } + + // ============================================================================ + // Color Feature Panel (compact dropdown) + // ============================================================================ + + .menu-label { + @include m.menu-label($size: v.$font-size-xs); + } + + // Label + help button side by side (the button must not sit inside the label). + .label-row { + display: flex; + align-items: center; + gap: v.$space-1; + } + + .color-feature-panel { + .panel-header-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: v.$space-4; + } + + .form-group { + @include m.form-group; + + // Native select must read as a dropdown (chevron affordance), not a + // plain text input (C-S4-02). + select { + @include w.select; + width: 200px; + max-width: 100%; + display: block; + } + + app-color-scale-selector { + width: 200px; + max-width: 100%; + display: block; + } + } + } + + // ============================================================================ + // Two-Column: Glyph Features + Preview + // ============================================================================ + + .glyph-section-two-col { + display: grid; + grid-template-columns: 3fr 2fr; + gap: v.$space-4; + margin-bottom: v.$space-4; + + @include w.below-stack { + grid-template-columns: minmax(0, 1fr); + } + } + + // ── Left: Glyph Features Panel ───────────────────────────────────────────── + + .glyph-features-panel { + display: flex; + flex-direction: column; + min-width: 0; + max-height: 480px; + overflow: hidden; + + // Stacked: grow with the content instead of clipping it; the two lists + // below keep their own bounded, scrolling heights. + @include w.below-stack { + max-height: none; + overflow: visible; + } + + .glyph-panel-header { + @include w.list-header-row; + flex-wrap: wrap; + justify-content: space-between; + gap: v.$space-2; + margin-bottom: v.$space-2; + + .heading-with-help { + display: flex; + align-items: center; + gap: v.$space-1; + } + + h3 { + margin-bottom: 0; + } + } + + .btn-suggestion-compact { + // Match the wizard's primary "Continue" nav button (accent, white text). + @include w.btn-primary; + font-size: v.$font-size-xs; + padding: v.$space-1 10px; + white-space: nowrap; + flex-shrink: 0; + + .material-icons { + font-size: v.$icon-size-xs; + } + } + + .feature-progress { + @include w.feature-progress; + margin-bottom: v.$space-2; + + .progress-fill.complete { + background: v.$severity-success-icon; + } + } + + .feature-list-unified { + display: flex; + flex-direction: column; + gap: 3px; + min-height: 50px; + max-height: 200px; + overflow-y: auto; + padding: 6px; + border: 2px dashed v.$gray-300; + border-radius: 8px; + background: v.$wizard-header-bg; + } + + // Search + type filter above the available list (same anatomy as steps 2/3) + .feature-filter-bar { + display: flex; + align-items: center; + gap: v.$space-2; + margin-bottom: 6px; + + .search-box { + @include w.search-box($padding: v.$space-1 v.$space-2, $icon-size: v.$icon-size-sm); + flex: 1; + min-width: 0; + } + + .filter-select { + @include w.select; + font-size: v.$font-size-sm; + padding: v.$space-1 v.$space-6 v.$space-1 v.$space-2; + flex-shrink: 0; + max-width: 130px; + } + } + + .available-empty { + @include w.list-empty-state; + } + + .empty-hint { + display: flex; + align-items: center; + gap: 6px; + padding: 10px v.$space-2; + color: v.$text-muted; + font-size: v.$font-size-sm; + + .material-icons { + font-size: v.$icon-size-sm; + } + } + + .feature-separator { + height: 1px; + background: v.$gray-300; + margin: v.$space-2 0; + } + + .available-label { + @include w.list-header-row; + justify-content: space-between; + font-size: v.$font-size-xs; + font-weight: 600; + color: v.$text-muted; + letter-spacing: 0.3px; + margin-bottom: v.$space-1; + padding: 0 v.$space-2; + + .variance-label { + font-size: v.$font-size-xs; + font-weight: 500; + color: v.$text-muted; + } + } + + .available-features-list { + display: flex; + flex-direction: column; + gap: 1px; + overflow-y: auto; + flex: 1; + min-height: 0; + max-height: 240px; + } + + .feature-row { + display: flex; + align-items: center; + gap: 6px; + padding: 5px v.$space-2; + border-radius: 6px; + font-size: v.$font-size-sm; + transition: background-color 0.15s ease; + + &.selected { + background: white; + border: 1px solid v.$gray-300; + cursor: grab; + + &:active { + cursor: grabbing; + } + + &:hover { + border-color: v.$color-accent; + } + + &.drop-target { + border-color: v.$color-accent; + box-shadow: inset 0 2px 0 v.$color-accent; + } + + .drag-handle { + font-size: v.$icon-size-sm; + color: v.$gray-500; + flex-shrink: 0; + cursor: grab; + } + + // White digit on the accent (5.60:1; was 2.30:1 on cyan). + .feature-order { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: v.$color-accent; + color: white; + font-size: v.$font-size-xs; + font-weight: 700; + border-radius: 50%; + flex-shrink: 0; + } + } + + &.available { + cursor: grab; + color: v.$gray-700; + + &:active, + &.dragging { + cursor: grabbing; + } + + &:hover { + background: v.$gray-100; + } + + .add-icon { + font-size: v.$icon-size-sm; + color: v.$gray-500; + flex-shrink: 0; + } + } + + .feature-name { + flex: 1; + font-weight: 500; + color: v.$gray-800; + @include m.text-truncate; + } + + // Data visualisation (variance share): decorative bright fill. + .variance-bar { + width: 40px; + height: 4px; + background: v.$gray-200; + border-radius: 2px; + flex-shrink: 0; + overflow: hidden; + + .variance-fill { + display: block; + height: 100%; + background: v.$active-color; + border-radius: 2px; + min-width: 2px; + } + } + + // Move up / move down / remove + .btn-row-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + color: v.$text-muted; + flex-shrink: 0; + + .material-icons { + font-size: v.$icon-size-xs; + } + + &:focus-visible { + @include m.focus-ring(1px); + } + + &:disabled { + opacity: 0.35; + cursor: default; + } + } + + .btn-move:hover:not(:disabled) { + background: rgba(v.$active-color, 0.12); + color: v.$color-accent; + } + + .btn-remove:hover { + background: v.$severity-error-text; + color: white; + } + } + + // Available features are real buttons (Enter and Space add them). + button.feature-row.available { + width: 100%; + border: none; + background: transparent; + font: inherit; + font-size: v.$font-size-sm; + text-align: left; + + &:hover { + background: v.$gray-100; + } + + &:focus-visible { + @include m.focus-ring(-2px); + } + } + + .validation-warning { + @include w.inline-warning; + margin-top: 6px; + } + } + + // ── Right: Glyph Preview Panel ───────────────────────────────────────────── + + .glyph-preview-panel { + display: flex; + flex-direction: column; + align-items: center; + min-width: 0; + + h3 { + align-self: flex-start; + } + + .preview-svg-container { + width: 100%; + max-width: 260px; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + margin: v.$space-2 0; + // Let long edge labels render past the drawing box instead of being clipped. + overflow: visible; + + .glyph-preview-svg { + width: 100%; + height: 100%; + overflow: visible; + } + + // SVG user units (viewBox 240): >= 11px on screen once the preview is + // rendered at >= 240px. Names are shortened to MAX_AXIS_LABEL_CHARS; the + // white halo keeps them legible over lines and borders. + .axis-label { + font-size: v.$font-size-xs; + fill: v.$gray-700; + font-family: 'Lucida Sans', 'Lucida Sans Regular', Geneva, Verdana, sans-serif; + paint-order: stroke; + stroke: white; + stroke-width: 3px; + stroke-linejoin: round; + } + } + + .preview-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-width: 260px; + aspect-ratio: 1; + color: v.$text-muted; + text-align: center; + + .material-icons { + font-size: v.$icon-size-2xl; + color: v.$gray-400; + margin-bottom: v.$space-2; + } + + p { + font-size: v.$font-size-sm; + margin: 0; + } + } + + .glyph-type-tabs { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: v.$space-1; + + button { + @include w.btn-base; + background: white; + border: 1px solid v.$gray-300; + color: v.$text-muted; + padding: 5px v.$space-3; + font-size: v.$font-size-sm; + gap: v.$space-1; + + &:hover { + background: v.$gray-100; + } + + &.active { + border-color: v.$color-accent; + background: rgba(v.$active-color, 0.08); + color: v.$color-accent; + font-weight: 600; + } + + .material-icons { + font-size: v.$icon-size-sm; + } + } + } + } +} diff --git a/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.spec.ts b/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.spec.ts new file mode 100644 index 0000000..8532c24 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.spec.ts @@ -0,0 +1,677 @@ +import type { Mocked } from 'vitest'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TestBed } from '@angular/core/testing'; +import { Step5GlyphColorSettingsComponent } from './step5-glyph-color-settings.component'; +import { WizardAnnouncer } from '../../shared/wizard-announcer.service'; +import { ColorScaleSelectorComponent } from '../../../shared/components/color-scale-selector/color-scale-selector.component'; +import { PreprocessingService } from '../../services/preprocessing.service'; +import { PreprocessingState } from '../../models/preprocessing-state'; +import { ColumnConfig } from '../../models/column-config'; +import { ColumnStatistics, DataProfile } from '../../models/column-statistics'; +import { + DataType, + EncodingMethod, + ScalingMethod, + MissingValueStrategy, + OutlierMethod, + OutlierStrategy, +} from '../../models/data-type.enum'; +import { ToastService } from '../../../services/toast.service'; +import { createTestPreprocessingService, createToastServiceSpy } from '../../testing/preprocessing-test-helpers'; + +function makeConfig(name: string, isColorFeature = false): ColumnConfig { + return { + name, + originalType: DataType.Numeric, + targetType: DataType.Numeric, + encodingMethod: EncodingMethod.Normalize, + scalingMethod: ScalingMethod.MinMax, + includeInProjection: true, + isColorFeature, + missingValueStrategy: MissingValueStrategy.Keep, + outlierMethod: OutlierMethod.IQR_1_5, + outlierStrategy: OutlierStrategy.Keep, + enabled: true, + hasIssues: false, + }; +} + +function setState(service: PreprocessingService, updates: Partial): void { + (service as unknown as { updateState(u: Partial): void }).updateState(updates); +} + +/** The component outside a view: change detection and focus moves are no-ops. */ +function newComponent( + service: PreprocessingService, + toast: ToastService = createToastServiceSpy(), + announcer: WizardAnnouncer = new WizardAnnouncer() +): Step5GlyphColorSettingsComponent { + const cdr = { detectChanges: () => undefined } as unknown as ChangeDetectorRef; + return new Step5GlyphColorSettingsComponent(service, toast, announcer, cdr); +} + +/** Mark loading as finished so glyph/color writes count as genuine user actions. */ +function ready(component: Step5GlyphColorSettingsComponent): void { + (component as unknown as { historyReady: boolean }).historyReady = true; +} + +/** + * Seven numeric columns a…g whose spread grows alphabetically (stdDev 1…7 at a + * common mean), so the smart suggestion is deterministic: g, f, e, d, c. + */ +function seedProfile(service: PreprocessingService, glyphFeatures: string[] = []): void { + const names = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + const columns: ColumnStatistics[] = names.map((name, i) => ({ + name, + dataType: DataType.Numeric, + count: 100, + missingCount: 0, + missingPercentage: 0, + uniqueCount: 50, + mean: 10, + stdDev: i + 1, + })); + const profile: DataProfile = { + totalRows: 100, + totalColumns: names.length, + fileSize: 1000, + fileName: 'test.csv', + columns, + qualityScore: 90, + duplicateCount: 0, + previewRows: [], + }; + setState(service, { + rawFileName: 'test.csv', + dataProfile: profile, + columnConfigs: new Map(names.map(name => [name, makeConfig(name)])), + glyphFeatures, + }); +} + +/** + * A4 – glyph-feature editing vs. the undo history. + * + * Regression coverage for the reported flaky behaviour: deselecting every glyph + * feature one by one and then pressing undo once brought several features back + * instead of exactly one. These tests pin the invariants a correct + * implementation must keep: + * - the local selection never drops below MIN_GLYPH_FEATURES, + * - the local selection and the service state stay in sync after every removal, + * - each persisted removal is exactly one undo entry. + */ +describe('Step5GlyphColorSettingsComponent – glyph feature history', () => { + let component: Step5GlyphColorSettingsComponent; + let service: PreprocessingService; + + const FIVE = ['a', 'b', 'c', 'd', 'e']; + + function startWith(features: string[]): void { + service.setGlyphFeatures([...features]); + component.selectedGlyphFeatures = [...features]; + ready(component); + } + + beforeEach(() => { + localStorage.clear(); + service = createTestPreprocessingService(); + component = newComponent(service); + }); + + afterEach(() => localStorage.clear()); + + it('never lets the selection drop below the minimum when removing repeatedly', () => { + startWith(FIVE); + for (let i = 0; i < 5; i++) { + component.removeGlyphFeature(0); + } + expect(component.selectedGlyphFeatures.length).toBe(component.MIN_GLYPH_FEATURES); + }); + + it('keeps the local selection and the service state in sync after each removal', () => { + startWith(FIVE); + for (let i = 0; i < 5; i++) { + component.removeGlyphFeature(0); + expect(service.currentState.glyphFeatures, `after ${i + 1} removal(s)`).toEqual(component.selectedGlyphFeatures); + } + }); + + it('records exactly one undo entry per persisted removal, so a single undo reverts one step', () => { + startWith(FIVE); + component.removeGlyphFeature(0); + component.removeGlyphFeature(0); + expect(service.currentState.glyphFeatures.length).toBe(3); + + service.undo(); + expect(service.currentState.glyphFeatures.length).toBe(4); + }); + + it('does not push a history entry or change state when removal is blocked at the minimum', () => { + startWith(['a', 'b', 'c']); + expect(service.canUndo).toBe(false); + + component.removeGlyphFeature(0); + + expect(component.selectedGlyphFeatures).toEqual(['a', 'b', 'c']); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c']); + expect(service.canUndo).toBe(false); + }); + + it('adds a glyph feature back as one undoable action', () => { + startWith(['a', 'b', 'c']); + + component.addGlyphFeature('d'); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c', 'd']); + + service.undo(); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c']); + }); +}); + +/** + * A4 – color selection vs. the undo history: a color change is exactly one + * undoable action, while the default color assigned during ngOnInit is not. + */ +describe('Step5GlyphColorSettingsComponent – color feature history', () => { + let component: Step5GlyphColorSettingsComponent; + let service: PreprocessingService; + + /** Two enabled numeric columns; "red" is the initial color feature, scale 0. */ + function seedColorState(): void { + setState(service, { + columnConfigs: new Map([ + ['red', makeConfig('red', true)], + ['blue', makeConfig('blue')], + ]), + colorScaleId: 0, + colorScaleMode: 'continuous', + }); + component.colorFeature = 'red'; + component.selectedColorScaleId = 0; + } + + beforeEach(() => { + localStorage.clear(); + service = createTestPreprocessingService(); + component = newComponent(service); + }); + + afterEach(() => localStorage.clear()); + + it('records a color-feature change as one undoable action', () => { + seedColorState(); + ready(component); + + component.setColorFeature('blue'); + expect(service.currentState.columnConfigs.get('blue')?.isColorFeature).toBe(true); + expect(service.currentState.columnConfigs.get('red')?.isColorFeature).toBe(false); + expect(service.canUndo).toBe(true); + + service.undo(); + expect(service.currentState.columnConfigs.get('red')?.isColorFeature).toBe(true); + expect(service.currentState.columnConfigs.get('blue')?.isColorFeature).toBe(false); + }); + + it('labels a color-feature undo as Color attribute on the Glyphs & Color step', () => { + seedColorState(); + ready(component); + + component.setColorFeature('blue'); + const info = service.undo(); + expect(info?.settingLabel).toBe('Color attribute'); + expect(info?.step).toBe('appearance'); + expect(info?.anchorId).toBe('wizard-anchor-color'); + }); + + it('records a color-scale change as one undoable action', () => { + seedColorState(); + ready(component); + + component.selectColorScale(2); + expect(service.currentState.colorScaleId).toBe(2); + + service.undo(); + expect(service.currentState.colorScaleId).toBe(0); + }); + + it('does not record color history before the step has finished loading', () => { + seedColorState(); + // historyReady stays false, mirroring the ngOnInit default assignment. + component.setColorFeature('blue'); + expect(service.canUndo).toBe(false); + }); +}); + +/** + * A4 – drag & drop and smart suggestions vs. undo. These paths mutate the glyph + * selection; each must be a single undoable action in sync with the service. + */ +describe('Step5GlyphColorSettingsComponent – drag/drop and suggestions', () => { + let component: Step5GlyphColorSettingsComponent; + let service: PreprocessingService; + let toast: Mocked; + + // Minimal DragEvent stand-in: only the members the handlers actually touch. + function dragEvent(): DragEvent { + return { + preventDefault: () => undefined, + stopPropagation: () => undefined, + dataTransfer: { effectAllowed: '', dropEffect: '', setData: () => undefined, getData: () => '' }, + } as unknown as DragEvent; + } + + beforeEach(() => { + localStorage.clear(); + service = createTestPreprocessingService(); + toast = createToastServiceSpy(); + component = newComponent(service, toast); + }); + + afterEach(() => localStorage.clear()); + + it('reorders a glyph feature via drag as one undoable action', () => { + service.setGlyphFeatures(['a', 'b', 'c', 'd', 'e']); + component.selectedGlyphFeatures = ['a', 'b', 'c', 'd', 'e']; + ready(component); + + component.onDragStart(dragEvent(), 'a', 'selected', 0); + component.onDropOnSelectedItem(dragEvent(), 2); + expect(component.selectedGlyphFeatures).toEqual(['b', 'c', 'a', 'd', 'e']); + expect(service.currentState.glyphFeatures).toEqual(['b', 'c', 'a', 'd', 'e']); + + service.undo(); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('adds a feature by dropping it into the selected list, undoable', () => { + service.setGlyphFeatures(['a', 'b', 'c']); + component.selectedGlyphFeatures = ['a', 'b', 'c']; + component.availableFeatures = ['a', 'b', 'c', 'd']; + ready(component); + + component.onDragStart(dragEvent(), 'd', 'available', 0); + component.onDropInSelected(dragEvent()); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c', 'd']); + + service.undo(); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c']); + }); + + it('removes a feature by dropping it onto the available area, undoable', () => { + service.setGlyphFeatures(['a', 'b', 'c', 'd']); + component.selectedGlyphFeatures = ['a', 'b', 'c', 'd']; + ready(component); + + component.onDragStart(dragEvent(), 'd', 'selected', 3); + component.onDropInAvailable(dragEvent()); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c']); + + service.undo(); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c', 'd']); + }); + + it('applies recommended features as one undoable action and offers a toast undo', () => { + component.suggestedFeatures = ['a', 'b', 'c', 'd', 'e']; + component.availableFeatures = ['a', 'b', 'c', 'd', 'e', 'f']; + ready(component); + + component.applySuggestedFeaturesByUser(); + expect(component.selectedGlyphFeatures).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(toast.showUndo).toHaveBeenCalled(); + + service.undo(); + expect(service.currentState.glyphFeatures).toEqual([]); + }); + + it('tops short suggestions up to the minimum without duplicates', () => { + component.suggestedFeatures = ['c', 'd']; + component.availableFeatures = ['a', 'b', 'c', 'd']; + + component.applySuggestedFeatures(); + + expect(component.selectedGlyphFeatures).toEqual(['c', 'd', 'a']); + }); + + describe('"Recommended features applied" toast undo is target-specific', () => { + function applyAndGetToastUndo(): () => boolean { + component.suggestedFeatures = ['a', 'b', 'c', 'd', 'e']; + component.availableFeatures = ['a', 'b', 'c', 'd', 'e', 'f']; + ready(component); + component.applySuggestedFeaturesByUser(); + const lastCall = toast.showUndo.mock.lastCall; + if (!lastCall) throw new Error('no undo toast was shown'); + return lastCall[1]; + } + + it('uses English toast text', () => { + applyAndGetToastUndo(); + expect(toast.showUndo.mock.lastCall?.[0]).toBe('Recommended features applied'); + }); + + it('reverts the applied suggestions when nothing happened since', () => { + const toastUndo = applyAndGetToastUndo(); + + expect(toastUndo()).toBe(true); + expect(service.currentState.glyphFeatures).toEqual([]); + }); + + it('does not revert a later, unrelated change', () => { + const toastUndo = applyAndGetToastUndo(); + service.updateCleaningConfig({ removeDuplicates: true }); + + expect(toastUndo()).toBe(false); + expect(service.currentState.cleaningConfig.removeDuplicates).toBe(true); + expect(service.currentState.glyphFeatures).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('offers no toast when nothing was recorded', () => { + component.suggestedFeatures = ['a', 'b', 'c', 'd', 'e']; + component.availableFeatures = ['a', 'b', 'c', 'd', 'e', 'f']; + // historyReady stays false: the write is not a recorded user action. + component.applySuggestedFeaturesByUser(); + + expect(toast.showUndo).not.toHaveBeenCalled(); + }); + }); + + it('warns in English when removing below the minimum', () => { + service.setGlyphFeatures(['a', 'b', 'c']); + component.selectedGlyphFeatures = ['a', 'b', 'c']; + ready(component); + + component.removeGlyphFeature(0); + + expect(toast.warning).toHaveBeenCalledWith('At least 3 glyph features are required.'); + }); +}); + +/** + * Entering the step prefills an empty glyph selection with the recommended + * features without recording an undo entry, and Continue follows the 3–12 rule. + */ +describe('Step5GlyphColorSettingsComponent – prefill and canProceed', () => { + let component: Step5GlyphColorSettingsComponent; + let service: PreprocessingService; + + beforeEach(() => { + localStorage.clear(); + service = createTestPreprocessingService(); + component = newComponent(service); + }); + + afterEach(() => localStorage.clear()); + + it('prefills an empty selection with the recommended features', () => { + seedProfile(service); + + component.ngOnInit(); + + expect(component.selectedGlyphFeatures).toEqual(['g', 'f', 'e', 'd', 'c']); + expect(service.currentState.glyphFeatures).toEqual(['g', 'f', 'e', 'd', 'c']); + }); + + it('creates no undo entry for the prefill or the default color', () => { + seedProfile(service); + + component.ngOnInit(); + + expect(service.canUndo).toBe(false); + expect(component.colorFeature).toBe('g'); + }); + + it('keeps an existing valid selection', () => { + seedProfile(service, ['a', 'b', 'c']); + + component.ngOnInit(); + + expect(component.selectedGlyphFeatures).toEqual(['a', 'b', 'c']); + expect(component.canProceed()).toBe(true); + }); + + it('records later edits after the prefill as undoable actions', () => { + seedProfile(service); + component.ngOnInit(); + + component.addGlyphFeature('a'); + + expect(service.canUndo).toBe(true); + service.undo(); + expect(service.currentState.glyphFeatures).toEqual(['g', 'f', 'e', 'd', 'c']); + }); + + it('is ready to continue right after the prefill', () => { + seedProfile(service); + component.ngOnInit(); + expect(component.canProceed()).toBe(true); + }); + + [ + { count: 2, allowed: false }, + { count: 3, allowed: true }, + { count: 12, allowed: true }, + { count: 13, allowed: false }, + ].forEach(({ count, allowed }) => { + it(`${allowed ? 'allows' : 'blocks'} continuing with ${count} glyph features`, () => { + component.selectedGlyphFeatures = Array.from({ length: count }, (_, i) => `f${i}`); + expect(component.canProceed()).toBe(allowed); + }); + }); + + it('explains the glyph rule when Continue is disabled', () => { + expect(component.disabledHint).toBe('Select 3–12 glyph features to continue.'); + }); + + it('proceeds to the review step', () => { + seedProfile(service); + component.ngOnInit(); + service.goToStep('appearance'); + + component.proceed(); + + expect(service.currentStepId).toBe('review'); + }); +}); + +/** + * Glyph feature order defines the glyph axes. Besides dragging, it can be + * changed with Move up / Move down (WCAG 2.1.1, 2.5.7); each move is one undo + * entry, like a drag, and is announced to screen readers. + */ +describe('Step5GlyphColorSettingsComponent – moving features without dragging', () => { + let component: Step5GlyphColorSettingsComponent; + let service: PreprocessingService; + let announcer: WizardAnnouncer; + let toast: Mocked; + + const FIVE = ['a', 'b', 'c', 'd', 'e']; + + beforeEach(() => { + localStorage.clear(); + service = createTestPreprocessingService(); + announcer = new WizardAnnouncer(); + vi.spyOn(announcer, 'announce').mockImplementation(() => undefined); + toast = createToastServiceSpy(); + component = newComponent(service, toast, announcer); + service.setGlyphFeatures([...FIVE]); + component.selectedGlyphFeatures = [...FIVE]; + ready(component); + }); + + afterEach(() => localStorage.clear()); + + it('moves a feature up as one undoable action and announces its new position', () => { + component.moveGlyphFeature(2, 'up'); + + expect(service.currentState.glyphFeatures).toEqual(['a', 'c', 'b', 'd', 'e']); + expect(announcer.announce).toHaveBeenCalledWith('c moved to position 2 of 5.'); + + service.undo(); + expect(service.currentState.glyphFeatures).toEqual(FIVE); + expect(service.canUndo).toBe(false); + }); + + it('moves a feature down', () => { + component.moveGlyphFeature(0, 'down'); + + expect(component.selectedGlyphFeatures).toEqual(['b', 'a', 'c', 'd', 'e']); + expect(announcer.announce).toHaveBeenCalledWith('a moved to position 2 of 5.'); + }); + + it('does nothing past either end', () => { + component.moveGlyphFeature(0, 'up'); + component.moveGlyphFeature(4, 'down'); + component.moveGlyphFeature(9, 'up'); + + expect(service.currentState.glyphFeatures).toEqual(FIVE); + expect(service.canUndo).toBe(false); + expect(announcer.announce).not.toHaveBeenCalled(); + }); + + it('announces an added feature', () => { + component.availableFeatures = [...FIVE, 'f']; + + component.addAvailableFeature('f', 0); + + expect(service.currentState.glyphFeatures).toEqual([...FIVE, 'f']); + expect(announcer.announce).toHaveBeenCalledWith('f added. 6 glyph features selected.'); + }); + + it('explains instead of silently ignoring an add beyond the maximum', () => { + const twelve = Array.from({ length: 12 }, (_, i) => `f${i}`); + service.setGlyphFeatures(twelve); + component.selectedGlyphFeatures = [...twelve]; + + component.addAvailableFeature('extra', 0); + + expect(component.selectedGlyphFeatures).toEqual(twelve); + expect(toast.warning).toHaveBeenCalledWith('A glyph takes at most 12 features. Remove one first.'); + }); + + it('announces a removed feature, but not a removal blocked at the minimum', () => { + component.removeSelectedFeature(1); + expect(announcer.announce).toHaveBeenCalledWith('b removed. 4 glyph features selected.'); + + component.removeSelectedFeature(0); + vi.mocked(announcer.announce).mockClear(); + component.removeSelectedFeature(0); + + expect(component.selectedGlyphFeatures.length).toBe(3); + expect(announcer.announce).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith('At least 3 glyph features are required.'); + }); +}); + +describe('Step5GlyphColorSettingsComponent – template', () => { + let service: PreprocessingService; + + beforeEach(async () => { + localStorage.clear(); + service = createTestPreprocessingService(); + seedProfile(service); + await TestBed.configureTestingModule({ + imports: [Step5GlyphColorSettingsComponent], + providers: [ + { provide: PreprocessingService, useValue: service }, + { provide: ToastService, useValue: createToastServiceSpy() }, + ], + }) + .overrideComponent(Step5GlyphColorSettingsComponent, { + set: { imports: [FormsModule, ColorScaleSelectorComponent], schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + }); + + afterEach(() => localStorage.clear()); + + function render(): HTMLElement { + const fixture = TestBed.createComponent(Step5GlyphColorSettingsComponent); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + function selectedNames(el: HTMLElement): (string | undefined)[] { + return Array.from(el.querySelectorAll('.feature-row.selected .feature-name')).map(n => n.textContent?.trim()); + } + + function byLabel(el: HTMLElement, label: string): HTMLButtonElement | null { + return el.querySelector(`button[aria-label="${label}"]`); + } + + it('offers Move up / Move down per selected feature, disabled at the ends', () => { + const el = render(); // prefilled: g, f, e, d, c + + expect(byLabel(el, 'Move g up')?.disabled).toBe(true); + expect(byLabel(el, 'Move g down')?.disabled).toBe(false); + expect(byLabel(el, 'Move c up')?.disabled).toBe(false); + expect(byLabel(el, 'Move c down')?.disabled).toBe(true); + expect(byLabel(el, 'Remove e')).not.toBeNull(); + }); + + it('moves a feature with its button and keeps focus on the moved feature', () => { + const el = render(); + + byLabel(el, 'Move e up')?.click(); + expect(selectedNames(el)).toEqual(['g', 'e', 'f', 'd', 'c']); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Move e up'); + + byLabel(el, 'Move e up')?.click(); + expect(selectedNames(el)).toEqual(['e', 'g', 'f', 'd', 'c']); + // "Move up" is disabled at the top, so focus switches to "Move down". + expect(document.activeElement?.getAttribute('aria-label')).toBe('Move e down'); + }); + + it('renders available features as buttons named "Add "', () => { + const el = render(); + const buttons = Array.from(el.querySelectorAll('button.feature-row.available')); + + expect(buttons.map(b => b.getAttribute('aria-label'))).toEqual(['Add a', 'Add b']); + + buttons[0].click(); + expect(selectedNames(el)).toEqual(['g', 'f', 'e', 'd', 'c', 'a']); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Add b'); + }); + + it('keeps help buttons out of the label and the heading', () => { + const el = render(); + + const label = el.querySelector('label[for="step5-color-attribute"]'); + expect(label?.textContent?.trim()).toBe('Color attribute'); + expect(label?.querySelector('app-help-tooltip, button')).toBeNull(); + expect(el.querySelector('#step5-glyph-heading app-help-tooltip')).toBeNull(); + }); + + it('names the color scale picker by its visible label', () => { + const el = render(); + const trigger = el.querySelector('app-color-scale-selector button.selected-scale'); + + expect(trigger?.getAttribute('aria-labelledby')?.split(' ')[0]).toBe('step5-color-scale-label'); + expect(el.querySelector('#step5-color-scale-label')?.textContent?.trim()).toBe('Color scale'); + }); + + it('marks the active glyph type with aria-pressed', () => { + const pressed = Array.from(render().querySelectorAll('.glyph-type-tabs button')).map(b => + b.getAttribute('aria-pressed') + ); + expect(pressed).toEqual(['true', 'false', 'false']); + }); + + it('tells the user these settings can be changed later in the dashboard', () => { + const text = (render().textContent ?? '').replace(/\s+/g, ' '); + expect(text).toContain("You can change these later in the dashboard's Encoding panel."); + }); + + it('keeps the deep-link anchors for color and glyph features', () => { + const el = render(); + expect(el.querySelector('#wizard-anchor-color')).not.toBeNull(); + expect(el.querySelector('#wizard-anchor-glyph')).not.toBeNull(); + }); + + it('renders the prefilled features in the selected list', () => { + const names = Array.from(render().querySelectorAll('.feature-row.selected .feature-name')).map(n => + n.textContent?.trim() + ); + expect(names).toEqual(['g', 'f', 'e', 'd', 'c']); + }); +}); diff --git a/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.ts b/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.ts new file mode 100644 index 0000000..9ed1c47 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step5-glyph-color-settings/step5-glyph-color-settings.component.ts @@ -0,0 +1,519 @@ +import { ChangeDetectorRef, Component, OnInit, forwardRef } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PreprocessingService } from '../../services/preprocessing.service'; +import { WizardStep, WIZARD_STEP } from '../../shared/wizard-step'; +import { WizardAnnouncer } from '../../shared/wizard-announcer.service'; +import { ColumnStatistics } from '../../models/column-statistics'; +import { DataType } from '../../models/data-type.enum'; +import { HelpTooltipComponent } from '../../shared/help-tooltip/help-tooltip.component'; +import { HELP_TEXT } from '../../shared/constants/help-text'; +import { ColorScale, buildGroupedColorScales, getColorScaleById } from '../../../shared/interfaces/color-scale'; +import { ColorScaleSelectorComponent } from '../../../shared/components/color-scale-selector/color-scale-selector.component'; +import { ToastService } from '../../../services/toast.service'; +import { GlyphPreview, PREVIEW_CENTER, PREVIEW_RADIUS, buildGlyphPreview } from './glyph-preview-geometry'; + +export const MIN_GLYPH_FEATURES = 3; +export const MAX_GLYPH_FEATURES = 12; +/** How many top-scored features the smart suggestion picks. */ +const SUGGESTED_FEATURE_COUNT = 5; +/** Upper bound of distinct values for a categorical column to make a legible default color. */ +const MAX_DEFAULT_COLOR_CATEGORIES = 12; + +type FeatureList = 'selected' | 'available'; +export type MoveDirection = 'up' | 'down'; + +/** + * Step 5 – Glyphs & Color: color attribute + scale and the ordered glyph + * features. This only configures the dashboard's initial view (it is written + * into the schema's default context), so the step prefills recommended features + * and lets the user continue as soon as the glyph selection is valid. + */ +@Component({ + selector: 'app-step5-glyph-color-settings', + standalone: true, + imports: [FormsModule, HelpTooltipComponent, ColorScaleSelectorComponent], + templateUrl: './step5-glyph-color-settings.component.html', + styleUrl: './step5-glyph-color-settings.component.scss', + providers: [{ provide: WIZARD_STEP, useExisting: forwardRef(() => Step5GlyphColorSettingsComponent) }], +}) +export class Step5GlyphColorSettingsComponent implements OnInit, WizardStep { + readonly primaryLabel = 'Continue to Review'; + readonly disabledHint = `Select ${MIN_GLYPH_FEATURES}–${MAX_GLYPH_FEATURES} glyph features to continue.`; + + // Color feature selection + columns: ColumnStatistics[] = []; + colorFeature: string | null = null; + selectedColorScaleId = 0; + groupedColorScales: { group: string; scales: ColorScale[] }[] = []; + + // Glyph feature mapping + availableFeatures: string[] = []; + selectedGlyphFeatures: string[] = []; + suggestedFeatures: string[] = []; + featureVariances = new Map(); + readonly MIN_GLYPH_FEATURES = MIN_GLYPH_FEATURES; + readonly MAX_GLYPH_FEATURES = MAX_GLYPH_FEATURES; + draggedFeature: string | null = null; + draggedFromList: FeatureList = 'available'; + draggedIndex = -1; + // Index of the selected row currently under the drag cursor (insertion highlight). + dragOverIndex = -1; + + // Search + type filter for the AVAILABLE feature list. + featureSearch = ''; + featureTypeFilter: DataType | 'all' = 'all'; + readonly DataType = DataType; + + // Glyph preview + selectedGlyphType: 'star' | 'flower' | 'whisker' = 'star'; + preview: GlyphPreview = buildGlyphPreview([]); + readonly PREVIEW_RADIUS = PREVIEW_RADIUS; + readonly PREVIEW_CENTER = PREVIEW_CENTER; + + readonly HELP_TEXT = HELP_TEXT; + + // A4: guards the history pushes so writes during the initial load (the + // prefill and the default color) do not create undo entries. Flipped true once + // the step has finished loading. + private historyReady = false; + + constructor( + public preprocessingService: PreprocessingService, + private toastService: ToastService, + private announcer: WizardAnnouncer, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + const state = this.preprocessingService.currentState; + this.columns = (state.dataProfile?.columns ?? []).filter(col => state.columnConfigs.get(col.name)?.enabled); + + this.initColorFeature(); + this.groupedColorScales = buildGroupedColorScales(); + + this.updateAvailableFeatures(); + this.calculateSmartSuggestions(); + // Prefill: an empty (or too small) selection gets the recommended features. + // historyReady is still false, so this records no undo entry. + if (state.glyphFeatures.length >= MIN_GLYPH_FEATURES) { + this.selectedGlyphFeatures = [...state.glyphFeatures]; + this.regeneratePreviewData(); + } else { + this.applySuggestedFeatures(); + } + + this.historyReady = true; + } + + // ============================================================================ + // Color Feature Selection + // ============================================================================ + + /** Adopt the stored color attribute, or assign a smart default (no undo entry). */ + private initColorFeature(): void { + const stored = Array.from(this.preprocessingService.currentState.columnConfigs.values()).find( + c => c.isColorFeature + ); + this.colorFeature = stored?.name ?? this.pickDefaultColorFeature(); + if (!stored && this.colorFeature) { + this.preprocessingService.setColorFeature(this.colorFeature); + } + // Read after setColorFeature, which may auto-switch the scale to match the type. + this.selectedColorScaleId = this.preprocessingService.currentState.colorScaleId; + } + + setColorFeature(columnName: string): void { + // A4: record the color change so undo reverts the color feature (and any + // auto-switched scale) as one action. + if (this.historyReady) { + this.preprocessingService.pushHistory('Color attribute changed'); + } + this.colorFeature = columnName; + this.preprocessingService.setColorFeature(columnName); + // Sync selected scale ID after service auto-switches on type mismatch + this.selectedColorScaleId = this.preprocessingService.currentState.colorScaleId; + } + + /** + * Smart default for the color attribute. Prefers a low-cardinality + * categorical/boolean column (distinct, legible color groups), then falls + * back to the numeric column with the most spread (a meaningful gradient), + * and only as a last resort to the first column. Returns null if there are + * no columns. + */ + private pickDefaultColorFeature(): string | null { + if (this.columns.length === 0) return null; + + const lowCardCategorical = this.columns + .filter( + c => + (c.dataType === DataType.Categorical || c.dataType === DataType.Boolean) && + c.uniqueCount >= 2 && + c.uniqueCount <= MAX_DEFAULT_COLOR_CATEGORIES + ) + .sort((a, b) => a.uniqueCount - b.uniqueCount)[0]; + if (lowCardCategorical) return lowCardCategorical.name; + + const variance = (c: ColumnStatistics): number => c.variance ?? (c.stdDev ?? 0) ** 2; + const numericBySpread = this.columns + .filter(c => c.dataType === DataType.Numeric && variance(c) > 0) + .sort((a, b) => variance(b) - variance(a))[0]; + if (numericBySpread) return numericBySpread.name; + + return this.columns[0].name; + } + + getSelectedColorScale(): ColorScale { + return getColorScaleById(this.selectedColorScaleId); + } + + selectColorScale(id: number): void { + // A4: same as the color feature — record so a scale change is undoable. + if (this.historyReady) { + this.preprocessingService.pushHistory('Color scale changed'); + } + this.selectedColorScaleId = id; + this.preprocessingService.setColorScaleId(id); + } + + // ============================================================================ + // Glyph Feature Mapping + // ============================================================================ + + updateAvailableFeatures(): void { + this.availableFeatures = this.preprocessingService.getPreviewFeatureNames(); + } + + /** Score features by relative spread and keep the top ones as suggestions. */ + calculateSmartSuggestions(): void { + const profile = this.preprocessingService.currentState.dataProfile; + if (!profile) return; + + const scores = this.availableFeatures.flatMap(feature => { + const colStats = this.findColumnStats(feature); + return colStats ? [{ name: feature, score: variationScore(colStats) }] : []; + }); + + this.featureVariances = new Map(scores.map(({ name, score }) => [name, score])); + this.suggestedFeatures = [...scores] + .sort((a, b) => b.score - a.score) + .slice(0, SUGGESTED_FEATURE_COUNT) + .map(f => f.name); + } + + suggestionsWouldChange(): boolean { + if (this.suggestedFeatures.length === 0) return false; + const target = this.suggestedFeatures.slice(0, SUGGESTED_FEATURE_COUNT); + if (target.length !== this.selectedGlyphFeatures.length) return true; + return !target.every((f, i) => f === this.selectedGlyphFeatures[i]); + } + + /** Select the suggested features; returns the recorded undo entry id, or null. */ + applySuggestedFeatures(): number | null { + if (this.suggestedFeatures.length === 0) { + this.calculateSmartSuggestions(); + } + const suggested = this.suggestedFeatures.slice(0, SUGGESTED_FEATURE_COUNT); + // Top up to the minimum with other available features (never duplicates). + const extras = this.availableFeatures.filter(feature => !suggested.includes(feature)); + const next = [...suggested, ...extras].slice(0, Math.max(suggested.length, MIN_GLYPH_FEATURES)); + return this.commitSelection(next); + } + + /** + * A4: user-triggered "apply recommended features" (Smart Defaults). Applies the + * suggestions (which records a single history entry via saveGlyphFeatures) and + * shows a confirmation toast whose undo reverts exactly that entry. + */ + applySuggestedFeaturesByUser(): void { + const entryId = this.applySuggestedFeatures(); + if (entryId !== null) { + this.toastService.showUndo('Recommended features applied', () => this.preprocessingService.undoIfLatest(entryId)); + } + } + + addGlyphFeature(feature: string): void { + this.insertSelectedAt(feature, this.selectedGlyphFeatures.length); + } + + removeGlyphFeature(index: number): void { + // A glyph needs at least MIN_GLYPH_FEATURES rays. Removing below that is not a + // persistable state (setGlyphFeatures rejects it), so block it here instead of + // letting the local selection drift out of sync with the service and skipping + // the history entry — that mismatch is what made undo restore several features. + if (this.selectedGlyphFeatures.length <= MIN_GLYPH_FEATURES) { + this.toastService.warning(`At least ${MIN_GLYPH_FEATURES} glyph features are required.`); + return; + } + this.commitSelection(this.selectedGlyphFeatures.filter((_, i) => i !== index)); + } + + isFeatureSelected(feature: string): boolean { + return this.selectedGlyphFeatures.includes(feature); + } + + /** Data type of a feature, resolving one-hot columns (e.g. city_NYC → city). */ + getFeatureType(feature: string): DataType | null { + return this.findColumnStats(feature)?.dataType ?? null; + } + + /** Available features minus the already-selected ones, filtered by search + type. */ + get filteredAvailableFeatures(): string[] { + const term = this.featureSearch.trim().toLowerCase(); + return this.availableFeatures.filter(feature => { + if (this.isFeatureSelected(feature)) return false; + if (term && !feature.toLowerCase().includes(term)) return false; + return this.featureTypeFilter === 'all' || this.getFeatureType(feature) === this.featureTypeFilter; + }); + } + + clearFeatureFilters(): void { + this.featureSearch = ''; + this.featureTypeFilter = 'all'; + } + + getFeatureVariance(feature: string): number | null { + return this.featureVariances.get(feature) ?? null; + } + + getFeatureVariancePercent(feature: string): number { + const val = this.featureVariances.get(feature); + if (val === undefined) return 0; + const maxVar = Math.max(...this.featureVariances.values()); + return maxVar > 0 ? (val / maxVar) * 100 : 0; + } + + /** True while the glyph selection is in the valid 3–12 range. */ + get isGlyphSelectionValid(): boolean { + const count = this.selectedGlyphFeatures.length; + return count >= MIN_GLYPH_FEATURES && count <= MAX_GLYPH_FEATURES; + } + + /** + * Persist the local glyph selection. Returns the id of the recorded undo entry, + * or null when nothing was recorded (invalid count or still loading). + */ + saveGlyphFeatures(): number | null { + if (!this.isGlyphSelectionValid) { + return null; + } + // A4: every completed glyph edit (add/remove/reorder/apply-suggestions) + // funnels through here, so a single history push covers them all. Snapshot + // the state before the write so undo restores the previous feature set. + const entryId = this.historyReady ? this.preprocessingService.pushHistory('Glyph features changed') : null; + this.preprocessingService.setGlyphFeatures(this.selectedGlyphFeatures); + return entryId; + } + + /** Adopt a new selection, persist it (one undo entry) and refresh the preview. */ + private commitSelection(next: string[]): number | null { + this.selectedGlyphFeatures = next; + const entryId = this.saveGlyphFeatures(); + this.regeneratePreviewData(); + return entryId; + } + + private findColumnStats(feature: string): ColumnStatistics | undefined { + const columns = this.preprocessingService.currentState.dataProfile?.columns ?? []; + // Exact match first, then the source column of a one-hot feature (city_NYC → city). + return columns.find(c => c.name === feature) ?? columns.find(c => feature.startsWith(c.name + '_')); + } + + // ============================================================================ + // Drag & Drop + // ============================================================================ + + onDragStart(event: DragEvent, feature: string, fromList: FeatureList, index: number): void { + this.draggedFeature = feature; + this.draggedFromList = fromList; + this.draggedIndex = index; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', feature); + } + } + + onDragEnd(_event: DragEvent): void { + this.draggedFeature = null; + this.draggedFromList = 'available'; + this.draggedIndex = -1; + this.dragOverIndex = -1; + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + } + + /** Hovering a specific selected row while dragging — shows the insertion point. */ + onItemDragOver(event: DragEvent, index: number): void { + event.stopPropagation(); + this.onDragOver(event); + this.dragOverIndex = index; + } + + /** Insert a feature into the selected list at a specific position. */ + private insertSelectedAt(feature: string, index: number): void { + if (this.selectedGlyphFeatures.length >= MAX_GLYPH_FEATURES || this.isFeatureSelected(feature)) return; + const at = Math.max(0, Math.min(index, this.selectedGlyphFeatures.length)); + this.commitSelection([ + ...this.selectedGlyphFeatures.slice(0, at), + feature, + ...this.selectedGlyphFeatures.slice(at), + ]); + } + + /** Move a selected feature to a new position (reordering — order drives the glyph). */ + private moveSelected(from: number, to: number): void { + const current = this.selectedGlyphFeatures; + if (from < 0 || from >= current.length) return; + const target = Math.max(0, Math.min(to, current.length - 1)); + if (from === target) return; + const rest = current.filter((_, i) => i !== from); + this.commitSelection([...rest.slice(0, target), current[from], ...rest.slice(target)]); + } + + /** Drop onto a specific selected row: reorder (from selected) or insert (from available). */ + onDropOnSelectedItem(event: DragEvent, targetIndex: number): void { + event.preventDefault(); + event.stopPropagation(); + if (this.draggedFeature && this.draggedFromList === 'selected') { + this.moveSelected(this.draggedIndex, targetIndex); + } else if (this.draggedFeature) { + this.insertSelectedAt(this.draggedFeature, targetIndex); + } + this.onDragEnd(event); + } + + /** Drop on the selected list background: append (available) or move to end (selected). */ + onDropInSelected(event: DragEvent): void { + event.preventDefault(); + if (!this.draggedFeature) return; + + if (this.draggedFromList === 'available') { + this.addGlyphFeature(this.draggedFeature); + } else { + this.moveSelected(this.draggedIndex, this.selectedGlyphFeatures.length - 1); + } + this.onDragEnd(event); + } + + /** Drop a selected feature onto the available area to remove it from the glyph. */ + onDropInAvailable(event: DragEvent): void { + event.preventDefault(); + if (this.draggedFeature && this.draggedFromList === 'selected') { + const idx = this.selectedGlyphFeatures.indexOf(this.draggedFeature); + if (idx !== -1) { + this.removeGlyphFeature(idx); + } + } + this.onDragEnd(event); + } + + // ============================================================================ + // Keyboard / button alternatives to drag & drop (WCAG 2.1.1, 2.5.7) + // ============================================================================ + + moveButtonId(direction: MoveDirection, index: number): string { + return `step5-move-${direction}-${index}`; + } + + removeButtonId(index: number): string { + return `step5-remove-${index}`; + } + + availableButtonId(index: number): string { + return `step5-available-${index}`; + } + + /** + * Move a selected feature one position up or down — one undo entry, like a + * drag. Focus follows the feature to its new row; once it reaches an end, the + * button that still works there (the other direction) takes focus. + */ + moveGlyphFeature(index: number, direction: MoveDirection): void { + const feature = this.selectedGlyphFeatures[index]; + const target = direction === 'up' ? index - 1 : index + 1; + const total = this.selectedGlyphFeatures.length; + if (feature === undefined || target < 0 || target >= total) return; + + this.moveSelected(index, target); + this.announcer.announce(`${feature} moved to position ${target + 1} of ${total}.`); + + const atEnd = direction === 'up' ? target === 0 : target === total - 1; + const opposite: MoveDirection = direction === 'up' ? 'down' : 'up'; + this.focusElement(this.moveButtonId(atEnd ? opposite : direction, target)); + } + + /** Add a feature from the available list; focus stays in that list. */ + addAvailableFeature(feature: string, index: number): void { + if (this.selectedGlyphFeatures.length >= MAX_GLYPH_FEATURES) { + this.toastService.warning(`A glyph takes at most ${MAX_GLYPH_FEATURES} features. Remove one first.`); + return; + } + this.addGlyphFeature(feature); + const total = this.selectedGlyphFeatures.length; + this.announcer.announce(`${feature} added. ${total} glyph features selected.`); + + const remaining = this.filteredAvailableFeatures.length; + this.focusElement( + remaining > 0 ? this.availableButtonId(Math.min(index, remaining - 1)) : this.removeButtonId(total - 1) + ); + } + + /** Remove a selected feature; focus moves to the row that takes its place. */ + removeSelectedFeature(index: number): void { + const feature = this.selectedGlyphFeatures[index]; + const before = this.selectedGlyphFeatures.length; + this.removeGlyphFeature(index); + const total = this.selectedGlyphFeatures.length; + // Blocked at the minimum: the warning toast explains why, focus stays put. + if (feature === undefined || total === before) return; + + this.announcer.announce(`${feature} removed. ${total} glyph features selected.`); + this.focusElement(this.removeButtonId(Math.min(index, total - 1))); + } + + /** Render the new order first, then move focus (the pressed button may have moved or gone). */ + private focusElement(id: string): void { + this.cdr.detectChanges(); + document.getElementById(id)?.focus(); + } + + // ============================================================================ + // Glyph Preview (SVG) + // ============================================================================ + + /** Text alternative of the preview: glyph type and its axes in order. */ + get previewDescription(): string { + return `${this.selectedGlyphType} glyph preview with axes: ${this.selectedGlyphFeatures.join(', ')}`; + } + + private regeneratePreviewData(): void { + this.preview = buildGlyphPreview(this.selectedGlyphFeatures); + } + + // ============================================================================ + // Navigation + // ============================================================================ + + canProceed(): boolean { + return this.isGlyphSelectionValid; + } + + proceed(): void { + if (this.canProceed()) { + this.preprocessingService.nextStep(); + } + } +} + +/** Relative spread of a column: coefficient of variation, else the share of unique values. */ +function variationScore(stats: ColumnStatistics): number { + if (stats.stdDev !== undefined && stats.mean !== undefined && Math.abs(stats.mean) > 0) { + return stats.stdDev / Math.abs(stats.mean); + } + return stats.uniqueCount / stats.count; +} diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html deleted file mode 100644 index c51425a..0000000 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.html +++ /dev/null @@ -1,395 +0,0 @@ -
-
- - @if (showProcessing) { -
- save - Your work is saved -
- } - - - - - @if (!showProcessing) { -
- -
-
-
view_column
-
- Columns Selected - {{ enabledColumns }} / {{ totalColumns }} -
- -
- -
-
tune
-
- Projection Features - {{ projectionColumns }} -
- -
- -
-
palette
-
- Color Feature - {{ colorFeature || 'None' }} -
- -
- -
-
star_outline
-
- Glyph Features - {{ selectedGlyphFeatures.length }} -
- -
- -
-
settings
-
- Projection Methods - {{ enabledMethods.join(', ') }} -
- -
-
- - - @if (preflightIssues.length > 0) { -
- info -
- Heads up — {{ preflightIssues.length }} - {{ preflightIssues.length === 1 ? 'thing' : 'things' }} could cause processing to fail. You can start - anyway, or fix them first. -
-
-
- @for (issue of preflightIssues; track issue.code + issue.title) { - - } -
- } - - -
- - - - - - - - - - - - @for (config of columnConfigs; track config.name) { - @if (config.enabled) { - - - - - - - - } - } - -
ColumnTypeEncodingScalingIn Projection
- {{ config.name }} - @if (config.isColorFeature) { - - palette - - } - - - {{ getEncodingLabel(config.encodingMethod) }} - @if (config.targetType === DataType.Numeric || config.targetType === DataType.Date) { - {{ getScalingLabel(config.scalingMethod) }} - } @else { - N/A - } - - @if (config.includeInProjection) { - - check_circle - Yes - - } @else { - - cancel - No - - } -
-
- - -
- info -
- Processing Info: Processing time depends on your dataset size and enabled projection - methods. FastMap loads immediately, allowing you to explore your data right away. Additional projections - compute in the background. If anything fails, it appears here with a fix — never a blank screen. -
-
-
- } - - - - - @if (showProcessing) { - - @if (isProcessing) { -
-
- sync -
-

{{ processingStep || 'FastMap is computing your primary layout…' }}

-

- FastMap loads first so you can explore right away. Other projections keep computing in the background — - if a problem comes up, it shows here, not on a blank page. -

-
-
- -
-
-
-
{{ processingProgress }}%
- - -
- } - - - @if (isErrorScreen && blockingIssue) { -
-
- error_outline -
-

We couldn't finish your visualization — but this is fixable.

-

Your uploaded data and all settings are still there — nothing was lost.

-
-
- - - -
- -
-
- } - - - @if (processingComplete && !isProcessing) { -
-
- check_circle -
-

Your visualization is ready

-

- FastMap is loaded — open Glyphspace to start exploring. Additional projections finish in the background. -

-
-
- - @if (isPartialResult) { -
- warning -
- {{ partialIssues.length }} of {{ enabledMethods.length }} projections could not be computed. Your - visualization still works with the ones that succeeded. -
-
-
- @for (issue of partialIssues; track issue.title) { - - } -
- } @else { -
-
- check{{ enabledColumns }} columns processed -
-
- checkFastMap projection ready -
-
checkData cleaning applied
-
- } - - - - - @if (getBackgroundProjectionsArray().length > 0) { -
-

- schedule - Background Projections -

-
- @for (proj of getBackgroundProjectionsArray(); track proj.method) { -
-
- {{ proj.method.toUpperCase() }} - @if (proj.status === 'running') { - syncRunning - } - @if (proj.status === 'complete') { - check_circleComplete - } - @if (proj.status === 'error') { - errorFailed - } -
- @if (proj.status === 'running') { -
-
-
-

{{ proj.message }}

- } - @if (proj.status === 'complete') { -

Available in Glyphspace dropdown

- } -
- } -
-
- } -
- } - } -
- - - -
- @for (m of methodStatuses; track m.name) { -
-
- {{ m.name }} - {{ m.role }} -
- {{ m.statusLabel }} -
- } -
-
- - - @if (!showProcessing) { -
- -
- -
-
- } - - @if (isErrorScreen) { -
- -
- -
-
- } - - @if (processingComplete && !isProcessing) { -
- -
- - -
-
- } -
diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss b/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss deleted file mode 100644 index b21647a..0000000 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.scss +++ /dev/null @@ -1,660 +0,0 @@ -@use 'sass:color'; -@use '../../shared/wizard-shared' as w; -@use '../../../../styles/variables' as v; - -.step5-review-processing { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - - .step-content { - @include w.step-content; - } - - .step-header { - @include w.step-header; - } - - // Review Section - .review-section { - .summary-cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-bottom: 16px; - - .summary-card { - display: flex; - gap: 12px; - padding: 14px; - background: white; - border: 1px solid v.$gray-300; - border-radius: 4px; - - // A6: edit/jump affordance per summary card — uniform, borderless icon button - .card-edit-btn { - margin-left: auto; - align-self: flex-start; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - color: v.$gray-500; - cursor: pointer; - transition: - color 0.15s ease, - background 0.15s ease; - - .material-icons { - font-size: 18px; - } - - &:hover { - color: v.$status-info; - background: rgba(v.$active-color, 0.1); - } - - &:focus-visible { - outline: 2px solid v.$active-color; - outline-offset: 1px; - } - } - - .card-icon { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - background: rgba(v.$active-color, 0.1); - border-radius: 50%; - - .material-icons { - font-size: 22px; - color: v.$active-color; - } - } - - .card-content { - display: flex; - flex-direction: column; - gap: 2px; - - .card-label { - font-size: 11px; - font-weight: 600; - color: v.$gray-600; - text-transform: uppercase; - } - - .card-value { - font-size: 16px; - font-weight: 700; - color: v.$gray-800; - } - } - } - } - - .columns-table { - @include w.table-container; - margin-bottom: 16px; - - table { - @include w.table; - } - - .column-name-text { - @include w.column-name-text; - } - - .color-badge { - display: inline-flex; - align-items: center; - margin-left: 6px; - - .material-icons { - font-size: 14px; - color: v.$active-color; - } - } - - .data-type-badge { - @include w.badge; - } - - .text-muted { - font-size: 12px; - color: v.$gray-500; - font-style: italic; - } - - .status-badge { - @include w.status-badge; - } - } - - .info-box { - @include w.info-box; - } - } - - // Processing Section - .processing-view, - .success-view { - display: flex; - align-items: center; - justify-content: center; - min-height: 400px; - - .processing-card, - .success-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - padding: 40px; - background: white; - border: 1px solid v.$gray-300; - border-radius: 8px; - max-width: 600px; - text-align: center; - - .processing-icon, - .success-icon { - display: flex; - align-items: center; - justify-content: center; - width: 80px; - height: 80px; - background: rgba(v.$active-color, 0.1); - border-radius: 50%; - - .material-icons { - font-size: 48px; - - &.spinning { - color: v.$active-color; - animation: spin 1s linear infinite; - } - } - } - - .success-icon { - background: rgba(v.$status-success, 0.1); - - .material-icons { - color: v.$status-success; - } - } - - h3 { - font-size: 20px; - font-weight: 700; - color: v.$gray-800; - margin: 0; - } - - .processing-message, - .success-message { - font-size: 14px; - color: v.$gray-600; - } - - .progress-bar { - @include w.progress-bar; - width: 100%; - max-width: 400px; - height: 8px; - } - - .progress-fill { - @include w.progress-fill; - } - - .progress-text { - font-size: 16px; - font-weight: 700; - color: v.$active-color; - } - - .success-stats { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - - .stat-item { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - font-size: 14px; - color: v.$gray-700; - - .material-icons { - font-size: 18px; - color: v.$status-success; - } - } - } - - .background-status-section { - width: 100%; - padding-top: 16px; - border-top: 1px solid v.$gray-300; - - h4 { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - font-size: 14px; - font-weight: 600; - color: v.$gray-700; - margin-bottom: 12px; - - .material-icons { - font-size: 18px; - } - } - - .projection-status-list { - display: flex; - flex-direction: column; - gap: 10px; - - .projection-status-item { - padding: 10px; - background: v.$gray-100; - border-radius: 4px; - - .status-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; - - .method-name { - font-size: 13px; - font-weight: 600; - color: v.$gray-800; - } - - .status-badge { - @include w.status-badge; - } - } - - .progress-bar-small { - @include w.progress-bar; - height: 4px; - margin-bottom: 4px; - - .progress-fill-small { - @include w.progress-fill; - } - } - - .status-message { - font-size: 12px; - color: v.$gray-600; - margin: 0; - } - } - } - } - } - } - - // A3: persistence signifier pill - .persistence-pill { - display: inline-flex; - align-items: center; - gap: 6px; - align-self: flex-start; - margin-bottom: 14px; - padding: 4px 10px; - background: rgba(v.$active-color, 0.1); - border: 1px solid rgba(v.$active-color, 0.4); - border-radius: 12px; - font-size: 12px; - font-weight: 600; - color: v.$status-info; - - .material-icons { - font-size: 15px; - } - } - - // A3: pre-flight banner (amber) - .preflight-banner { - display: flex; - gap: 10px; - align-items: flex-start; - padding: 12px 14px; - margin-bottom: 12px; - background: #fffdf9; - border: 1px solid #f2dcb6; - border-radius: 6px; - font-size: 13px; - color: v.$gray-700; - line-height: 1.45; - - .material-icons { - color: #d08a1e; - font-size: 20px; - flex-shrink: 0; - } - } - - .issue-list { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 16px; - } - - // A3: persistent status/error/result panel - .status-panel { - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; - background: white; - border: 1px solid v.$gray-300; - border-radius: 8px; - - .status-hero { - display: flex; - align-items: center; - gap: 16px; - padding: 16px; - border-radius: 6px; - - .material-icons { - font-size: 40px; - flex-shrink: 0; - - &.spinning { - animation: spin 1s linear infinite; - } - } - - h3 { - font-size: 17px; - font-weight: 700; - margin: 0 0 4px; - color: v.$gray-800; - } - - p { - font-size: 13px; - color: v.$gray-600; - margin: 0; - line-height: 1.45; - } - - &.processing { - background: rgba(v.$active-color, 0.08); - .material-icons { - color: v.$active-color; - } - } - - &.error { - background: #fffafa; - border: 1px solid #f0d3cf; - .material-icons { - color: #d64541; - } - } - } - - .success-banner { - display: flex; - align-items: center; - gap: 16px; - padding: 16px; - background: #f1faf2; - border: 1px solid #c8ead0; - border-radius: 6px; - - .material-icons { - font-size: 40px; - color: #2a9d4a; - flex-shrink: 0; - } - - h3 { - font-size: 17px; - font-weight: 700; - margin: 0 0 4px; - color: v.$gray-800; - } - - p { - font-size: 13px; - color: v.$gray-600; - margin: 0; - line-height: 1.45; - } - } - - .partial-notice { - display: flex; - gap: 10px; - align-items: flex-start; - padding: 12px 14px; - background: #fffdf9; - border: 1px solid #f2dcb6; - border-radius: 6px; - font-size: 13px; - color: v.$gray-700; - line-height: 1.45; - - .material-icons { - color: #d08a1e; - font-size: 20px; - flex-shrink: 0; - } - } - - .progress-bar { - @include w.progress-bar; - width: 100%; - height: 8px; - } - - .progress-fill { - @include w.progress-fill; - } - - .progress-text { - font-size: 16px; - font-weight: 700; - color: v.$active-color; - text-align: center; - } - - .success-stats { - display: flex; - flex-direction: column; - gap: 8px; - - .stat-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - color: v.$gray-700; - - .material-icons { - font-size: 18px; - color: #2a9d4a; - } - } - } - - // Method status bar - .method-bar { - display: flex; - flex-wrap: wrap; - gap: 10px; - - .method-chip { - flex: 1 1 140px; - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: 1px solid v.$gray-200; - border-radius: 6px; - background: v.$gray-100; - - .chip-top { - display: flex; - align-items: baseline; - gap: 6px; - - .chip-name { - font-size: 13px; - font-weight: 700; - color: v.$gray-800; - } - - .chip-role { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.03em; - color: v.$gray-500; - } - } - - .chip-status { - font-size: 12px; - font-weight: 600; - color: v.$gray-600; - } - - &.chip-ready { - background: #f1faf2; - border-color: #c8ead0; - .chip-status { - color: #2a9d4a; - } - } - - &.chip-computing { - background: rgba(v.$active-color, 0.08); - border-color: rgba(v.$active-color, 0.35); - .chip-status { - color: v.$status-info; - } - } - - &.chip-failed { - background: #fffafa; - border-color: #f0d3cf; - .chip-status { - color: #d64541; - } - } - } - } - - .background-status-section { - padding-top: 8px; - border-top: 1px solid v.$gray-200; - - h4 { - display: flex; - align-items: center; - gap: 6px; - font-size: 14px; - font-weight: 600; - color: v.$gray-700; - margin: 0 0 12px; - - .material-icons { - font-size: 18px; - } - } - - .projection-status-list { - display: flex; - flex-direction: column; - gap: 10px; - - .projection-status-item { - padding: 10px; - background: v.$gray-100; - border-radius: 4px; - - .status-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; - - .method-name { - font-size: 13px; - font-weight: 600; - color: v.$gray-800; - } - - .status-badge { - @include w.status-badge; - } - } - - .progress-bar-small { - @include w.progress-bar; - height: 4px; - margin-bottom: 4px; - - .progress-fill-small { - @include w.progress-fill; - } - } - - .status-message { - font-size: 12px; - color: v.$gray-600; - margin: 0; - } - } - } - } - } - - .error-message { - @include w.message-error; - } - - // Action Buttons - .action-buttons { - @include w.action-buttons-footer; - - .btn-secondary { - @include w.btn-secondary; - } - - .buttons-right { - display: flex; - align-items: center; - gap: 12px; - margin-left: auto; - } - - .btn-primary { - @include w.btn-primary; - } - } -} diff --git a/src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.spec.ts b/src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.spec.ts new file mode 100644 index 0000000..1178124 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.spec.ts @@ -0,0 +1,78 @@ +import type { Mocked } from 'vitest'; +import { ProjectionService } from '../../../services/projection.service'; +import { ProjectionConfig } from '../../models/column-config'; +import { DEFAULT_PROJECTION_CONFIG } from '../../models/preprocessing-state'; +import { createSpyObj } from '../../../../testing/create-spy-obj'; +import { buildBackgroundProjectionRuns, enabledBackgroundMethodNames } from './background-projections'; + +const FEATURES = [ + [0.1, 0.2], + [0.3, 0.4], +]; +const IDS = [1, 2]; + +/** Every background method switched on, LLE included (as an older saved config may have it). */ +const EVERYTHING_ON: ProjectionConfig = { + ...DEFAULT_PROJECTION_CONFIG, + enablePCA: true, + enableIsoMap: true, + enableMDS: true, + enableLLE: true, + enableLTSA: true, + enableTSNE: true, + enableUMAP: true, + enableTriMap: true, + enableTopoMap: true, + enableSammon: true, +}; + +describe('background projections', () => { + let service: Mocked; + + beforeEach(() => { + service = createSpyObj('ProjectionService', [ + 'runPCABackground', + 'runIsoMap', + 'runMDS', + 'runLLE', + 'runLTSA', + 'runTSNE', + 'runUMAP', + 'runTriMap', + 'runTopoMap', + 'runSammon', + ]); + }); + + it('never names LLE, which is hidden, even when the config enables it', () => { + expect(enabledBackgroundMethodNames(EVERYTHING_ON)).toEqual([ + 'PCA', + 'IsoMap', + 'MDS', + 'LTSA', + 't-SNE', + 'UMAP', + 'TriMap', + 'TopoMap', + 'Sammon', + ]); + }); + + it('never starts LLE, even when the config enables it', async () => { + const runs = buildBackgroundProjectionRuns(service, EVERYTHING_ON, FEATURES, IDS); + await Promise.all(runs.map(r => r.run())); + + expect(runs.map(r => r.key)).not.toContain('lle'); + expect(service.runLLE).not.toHaveBeenCalled(); + expect(service.runLTSA).toHaveBeenCalledTimes(1); + }); + + it('runs TriMap without parameters (DruidJS defaults)', async () => { + const config: ProjectionConfig = { ...DEFAULT_PROJECTION_CONFIG, enablePCA: false, enableIsoMap: false }; + const runs = buildBackgroundProjectionRuns(service, { ...config, enableTriMap: true }, FEATURES, IDS); + await Promise.all(runs.map(r => r.run())); + + expect(runs.map(r => r.name)).toEqual(['TriMap']); + expect(service.runTriMap).toHaveBeenCalledWith(FEATURES, IDS); + }); +}); diff --git a/src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.ts b/src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.ts new file mode 100644 index 0000000..41129c1 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step6-review-processing/background-projections.ts @@ -0,0 +1,96 @@ +import { ProjectionConfig } from '../../models/column-config'; +import { ProjectionResult, ProjectionService } from '../../../services/projection.service'; +import { ProjectionMethodKey, isProjectionMethodActive } from '../step4-projection-settings/projection-methods'; + +type Ids = (string | number)[]; +type Runner = ( + service: ProjectionService, + features: number[][], + ids: Ids, + config: ProjectionConfig +) => Promise; + +interface BackgroundMethod { + /** ProjectionService method id, also the key of its background status. */ + key: string; + /** Display name. */ + name: string; + /** Projection-config switch; a hidden method never runs (see HIDDEN_PROJECTION_METHODS). */ + configKey: ProjectionMethodKey; + run: Runner; +} + +/** One optional projection to compute in the background after the primary FastMap run. */ +export interface BackgroundProjectionRun { + key: string; + name: string; + run: () => Promise; +} + +/** The optional projection methods, in display order. */ +const BACKGROUND_METHODS: readonly BackgroundMethod[] = [ + { key: 'pca', name: 'PCA', configKey: 'enablePCA', run: (s, f, ids) => s.runPCABackground(f, ids) }, + { + key: 'isomap', + name: 'IsoMap', + configKey: 'enableIsoMap', + run: (s, f, ids, c) => s.runIsoMap(f, ids, { neighbors: c.isomapNeighbors }), + }, + { key: 'mds', name: 'MDS', configKey: 'enableMDS', run: (s, f, ids) => s.runMDS(f, ids) }, + { + key: 'lle', + name: 'LLE', + configKey: 'enableLLE', + run: (s, f, ids, c) => s.runLLE(f, ids, { neighbors: c.lleNeighbors }), + }, + { + key: 'ltsa', + name: 'LTSA', + configKey: 'enableLTSA', + run: (s, f, ids, c) => s.runLTSA(f, ids, { neighbors: c.ltsaNeighbors }), + }, + { + key: 'tsne', + name: 't-SNE', + configKey: 'enableTSNE', + run: (s, f, ids, c) => s.runTSNE(f, ids, { perplexity: c.tsnePerplexity, iterations: c.tsneIterations }), + }, + { + key: 'umap', + name: 'UMAP', + configKey: 'enableUMAP', + run: (s, f, ids, c) => s.runUMAP(f, ids, { neighbors: c.umapNeighbors, minDist: c.umapMinDist }), + }, + { key: 'trimap', name: 'TriMap', configKey: 'enableTriMap', run: (s, f, ids) => s.runTriMap(f, ids) }, + { key: 'topomap', name: 'TopoMap', configKey: 'enableTopoMap', run: (s, f, ids) => s.runTopoMap(f, ids) }, + { key: 'sammon', name: 'Sammon', configKey: 'enableSammon', run: (s, f, ids) => s.runSammon(f, ids) }, +]; + +/** The background methods that run for `config`: switched on and not hidden, in display order. */ +function activeMethods(config: ProjectionConfig): BackgroundMethod[] { + return BACKGROUND_METHODS.filter(m => isProjectionMethodActive(config, m.configKey)); +} + +/** Display names of the background methods that run for `config`, in display order. */ +export function enabledBackgroundMethodNames(config: ProjectionConfig): string[] { + return activeMethods(config).map(m => m.name); +} + +/** Display name of a background status key (e.g. 'tsne' → 't-SNE'). */ +export function backgroundMethodName(key: string): string { + return BACKGROUND_METHODS.find(m => m.key === key)?.name ?? key.toUpperCase(); +} + +/** The runs for every background method that runs for `config`. */ +export function buildBackgroundProjectionRuns( + service: ProjectionService, + config: ProjectionConfig, + features: number[][], + ids: Ids +): BackgroundProjectionRun[] { + return activeMethods(config).map(m => ({ + key: m.key, + name: m.name, + run: () => m.run(service, features, ids, config), + })); +} diff --git a/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.html b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.html new file mode 100644 index 0000000..563e6b6 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.html @@ -0,0 +1,482 @@ +
+
+ + @if (showProcessing) { +
+ + Your work is saved +
+ } + + + + + @if (!showProcessing) { +
+ +
+
+
+
+

Columns Selected

+ {{ enabledColumns }} / {{ totalColumns }} +
+ +
+ +
+
+
+

+ Projection Step {{ projectionStepNumber }} +

+
+
+
Columns
+
+ {{ projectionColumns }} included + +
+
+
+
Methods
+
+ {{ enabledMethods.join(', ') }} + +
+
+
+
+
+ +
+
+
+

+ Glyphs & Color Step {{ appearanceStepNumber }} +

+
+
+
Color
+
+ {{ colorFeature || 'None' }} · {{ colorScaleName }} + +
+
+
+
Glyph
+
+ {{ selectedGlyphFeatures.length }} features + +
+
+
+

Initial dashboard view — adjustable later in the Encoding panel.

+
+
+
+ + + @if (preflightIssues.length > 0) { +
+ +
+ Heads up — {{ preflightIssues.length }} + {{ preflightIssues.length === 1 ? 'thing' : 'things' }} could cause processing to fail. You can start + anyway, or fix them first. +
+
+
+ @for (issue of preflightIssues; track issue.code + issue.title) { + + } +
+ } + + +
+ + + + + + + + + + + + @for (config of columnConfigs; track config.name) { + @if (config.enabled) { + + + + + + + + } + } + +
ColumnTypeEncodingScalingIn Projection
+ {{ config.name }} + @if (config.isColorFeature) { + + + (color feature) + + } + + + {{ getEncodingLabel(config.encodingMethod) }} + @if (config.targetType === DataType.Numeric || config.targetType === DataType.Date) { + {{ getScalingLabel(config.scalingMethod) }} + } @else { + N/A + } + + @if (config.includeInProjection) { + + + Yes + + } @else { + + + No + + } +
+
+ + +
+ +
+ Processing Info: Processing time depends on your dataset size and enabled projection + methods. FastMap loads immediately, allowing you to explore your data right away. Additional projections + compute in the background. If anything fails, it appears here with a fix — never a blank screen. +
+
+
+ } + + + + + @if (showProcessing) { + + @if (isProcessing) { +
+
+ +
+

{{ processingStep || 'FastMap is computing your primary layout…' }}

+

+ FastMap loads first so you can explore right away. Other projections keep computing in the background — + if a problem comes up, it shows here, not on a blank page. +

+
+
+ +
+
+
+ + + +
+ } + + + @if (isErrorScreen && blockingIssue) { +
+ + + + + +
+ +
+
+ } + + + @if (processingComplete && !isProcessing) { +
+
+ +
+

Your visualization is ready

+

+ FastMap is loaded — open Glyphspace to start exploring. Additional projections finish in the background. +

+
+
+ + @if (isPartialResult) { +
+ +
+ {{ partialIssues.length }} of {{ enabledMethods.length }} projections could not be computed. Your + visualization still works with the ones that succeeded. +
+
+
+ @for (issue of partialIssues; track issue.title) { + + } +
+ } @else { +
+
+ {{ enabledColumns }} columns processed +
+
+ FastMap projection ready +
+
+ Data cleaning applied +
+
+ } + + + + + @if (backgroundItems.length > 0) { +
+
+

+ + Background Projections +

+ @if (hasRunningBackground) { + + } +
+
+ @for (proj of backgroundItems; track proj.method) { +
+
+ {{ proj.name }} +
+ @switch (proj.status) { + @case ('running') { + Running + } + @case ('complete') { + Complete + } + @case ('error') { + Failed + } + @case ('cancelled') { + Cancelled + } + } + @if (proj.status === 'running') { + + } @else if (proj.canRetry) { + + } +
+
+ @if (proj.status === 'running') { +
+
+
+

{{ proj.message }}

+ } + @if (proj.status === 'complete') { +

Available in Glyphspace dropdown

+ } + @if (proj.status === 'cancelled') { +

Stopped before it finished. Your other projections are not affected.

+ } +
+ } +
+
+ } +
+ } + } +
+ + + +
+ @for (m of methodStatuses; track m.name) { +
+
+ {{ m.name }} + {{ m.role }} +
+ {{ m.statusLabel }} +
+ } +
+
+ + + @if (!showProcessing) { +
+ +
+ +
+
+ } + + @if (isErrorScreen) { +
+ +
+ +
+
+ } + + @if (processingComplete && !isProcessing) { +
+ +
+ + +
+
+ } +
diff --git a/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.scss b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.scss new file mode 100644 index 0000000..3c27e43 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.scss @@ -0,0 +1,555 @@ +@use '../../shared/wizard-shared' as w; +@use '../../../../styles/variables' as v; +@use '../../../../styles/mixins' as m; + +.step6-review-processing { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + + // Text for screen readers only (e.g. names of icon-only badges). + .visually-hidden { + @include m.visually-hidden; + } + + .step-content { + @include w.step-content; + } + + // Review Section + .review-section { + // Column tile + one wider card per settings step (Projection, Glyphs & Color). + // Flex (zero basis + min widths, grow 1:2:2) sizes the three like the former + // `minmax(160px, 0.8fr) repeat(2, minmax(240px, 1.6fr))` grid, but wraps + // instead of overflowing when the work area is narrower than the minimums. + // Grow factors stay >= 1 so a card alone on a wrapped line fills it. + .summary-cards { + display: flex; + flex-wrap: wrap; + gap: v.$space-3; + margin-bottom: v.$space-4; + + .summary-card { + flex: 2 1 0; + min-width: min(100%, 240px); + box-sizing: border-box; + display: flex; + gap: v.$space-3; + padding: 14px; + background: white; + border: 1px solid v.$gray-300; + border-radius: 4px; + + &:first-child { + flex-grow: 1; + min-width: min(100%, 160px); + } + + // A6: edit/jump affordance per summary card — uniform, borderless icon button + .card-edit-btn { + margin-left: auto; + align-self: flex-start; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: v.$text-muted; + cursor: pointer; + transition: + color 0.15s ease, + background-color 0.15s ease; + + .material-icons { + font-size: v.$icon-size-md; + } + + &:hover { + color: v.$color-accent; + background: rgba(v.$active-color, 0.1); + } + + &:focus-visible { + @include m.focus-ring(1px); + } + } + + .card-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + background: rgba(v.$active-color, 0.1); + border-radius: 50%; + + .material-icons { + font-size: v.$icon-size-xl; + color: v.$color-accent; + } + } + + .card-content { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + + .card-label { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: v.$font-size-xs; + font-weight: 600; + color: v.$text-muted; + text-transform: uppercase; + } + + // Which wizard step the card's settings live in. + .card-step { + @include w.severity-badge(info); + padding: 1px 6px; + border-radius: 8px; + font-size: v.$font-size-xs; + letter-spacing: 0.3px; + } + + .card-rows { + display: grid; + gap: 2px; + margin: 6px 0 0; + } + + .card-row { + display: grid; + grid-template-columns: 56px 1fr; + align-items: center; + gap: v.$space-2; + + dt { + font-size: v.$font-size-xs; + font-weight: 600; + color: v.$text-muted; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + dd { + display: flex; + align-items: center; + gap: v.$space-1; + min-width: 0; + margin: 0; + } + + .card-row-value { + flex: 1; + min-width: 0; + font-size: v.$font-size-md; + font-weight: 600; + color: v.$gray-800; + overflow-wrap: anywhere; + } + + .card-edit-btn { + align-self: center; + width: 24px; + height: 24px; + flex-shrink: 0; + } + } + + .card-note { + margin: 6px 0 0; + font-size: v.$font-size-xs; + color: v.$text-muted; + } + + .card-value { + font-size: v.$font-size-lg; + font-weight: 700; + color: v.$gray-800; + } + } + } + } + + .columns-table { + @include w.table-container; + margin-bottom: v.$space-4; + + table { + @include w.table; + } + + .column-name-text { + @include w.column-name-text; + } + + .color-badge { + display: inline-flex; + align-items: center; + margin-left: 6px; + + .material-icons { + font-size: v.$icon-size-xs; + color: v.$color-accent; + } + } + + .text-muted { + font-size: v.$font-size-sm; + color: v.$text-muted; + font-style: italic; + } + + .status-badge { + @include w.status-badge; + } + } + + .info-box { + @include w.info-box; + } + } + + // A3: persistence signifier pill + .persistence-pill { + @include w.severity-badge(info); + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + margin-bottom: 14px; + padding: v.$space-1 10px; + border: 1px solid v.$severity-info-border; + border-radius: 12px; + font-size: v.$font-size-sm; + font-weight: 600; + + .material-icons { + font-size: v.$icon-size-sm; + } + } + + // A3: pre-flight banner and partial-results notice (warning tone, neutral text) + .preflight-banner, + .status-panel .partial-notice { + @include w.severity-panel(warning); + display: flex; + gap: 10px; + align-items: flex-start; + padding: v.$space-3 14px; + border-radius: 6px; + font-size: v.$font-size-base; + color: v.$gray-700; + line-height: 1.45; + + .material-icons { + font-size: v.$icon-size-lg; + flex-shrink: 0; + } + } + + .preflight-banner { + margin-bottom: v.$space-3; + } + + .issue-list { + display: flex; + flex-direction: column; + gap: v.$space-3; + margin-bottom: v.$space-4; + } + + // A3: persistent status/error/result panel + .status-panel { + display: flex; + flex-direction: column; + gap: v.$space-4; + padding: 20px; + background: white; + border: 1px solid v.$gray-300; + border-radius: 8px; + + // Shared anatomy of the status hero and the success banner. + .status-hero, + .success-banner { + display: flex; + align-items: center; + gap: v.$space-4; + padding: v.$space-4; + border-radius: 6px; + + .material-icons { + font-size: v.$icon-size-2xl; + flex-shrink: 0; + } + + h3 { + font-size: v.$font-size-lg; + font-weight: 700; + margin: 0 0 v.$space-1; + color: v.$gray-800; + } + + p { + font-size: v.$font-size-base; + color: v.$text-muted; + margin: 0; + line-height: 1.45; + } + } + + .status-hero { + &.processing { + background: v.$severity-info-bg; + + .material-icons { + color: v.$severity-info-icon; + } + } + + &.error { + @include w.severity-panel(error); + } + } + + .success-banner { + @include w.severity-panel(success); + } + + .progress-bar { + @include w.progress-bar; + width: 100%; + height: 8px; + } + + .progress-fill { + @include w.progress-fill; + } + + .progress-text { + font-size: v.$font-size-lg; + font-weight: 700; + color: v.$color-accent; + text-align: center; + } + + .success-stats { + display: flex; + flex-direction: column; + gap: v.$space-2; + + .stat-item { + display: flex; + align-items: center; + gap: v.$space-2; + font-size: v.$font-size-md; + color: v.$gray-700; + + .material-icons { + font-size: v.$icon-size-md; + color: v.$severity-success-icon; + } + } + } + + // Method status bar + .method-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + + .method-chip { + flex: 1 1 140px; + display: flex; + flex-direction: column; + gap: v.$space-1; + padding: 10px v.$space-3; + border: 1px solid v.$gray-200; + border-radius: 6px; + background: v.$gray-100; + + .chip-top { + display: flex; + align-items: baseline; + gap: 6px; + + .chip-name { + font-size: v.$font-size-base; + font-weight: 700; + color: v.$gray-800; + } + + .chip-role { + font-size: v.$font-size-xs; + text-transform: uppercase; + letter-spacing: 0.03em; + color: v.$text-muted; + } + } + + .chip-status { + font-size: v.$font-size-sm; + font-weight: 600; + color: v.$text-muted; + } + + &.chip-ready { + @include w.severity-panel(success); + + .chip-status { + color: v.$severity-success-text; + } + } + + &.chip-computing { + @include w.severity-panel(info); + + .chip-status { + color: v.$severity-info-text; + } + } + + &.chip-failed { + @include w.severity-panel(error); + + .chip-status { + color: v.$severity-error-text; + } + } + } + } + + .background-status-section { + padding-top: v.$space-2; + border-top: 1px solid v.$gray-200; + + .background-status-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: v.$space-2; + margin-bottom: v.$space-3; + } + + h4 { + display: flex; + align-items: center; + gap: 6px; + font-size: v.$font-size-md; + font-weight: 600; + color: v.$gray-700; + margin: 0; + + .material-icons { + font-size: v.$icon-size-md; + } + + // Receives focus after "Stop all" removes the button that had it. + &:focus-visible { + @include m.focus-ring; + border-radius: 2px; + } + } + + .status-actions { + display: flex; + align-items: center; + gap: v.$space-2; + } + + // Cancel / Retry / Stop all + .btn-background-action { + @include w.btn-text; + font-size: v.$font-size-sm; + padding: 3px v.$space-2; + gap: v.$space-1; + + .material-icons { + font-size: v.$icon-size-xs; + } + } + + .status-badge.status-cancelled { + background: rgba(158, 158, 158, 0.1); + color: v.$text-muted; + } + + .projection-status-list { + display: flex; + flex-direction: column; + gap: 10px; + + .projection-status-item { + padding: 10px; + background: v.$gray-100; + border-radius: 4px; + + .status-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + + .method-name { + font-size: v.$font-size-base; + font-weight: 600; + color: v.$gray-800; + } + + .status-badge { + @include w.status-badge; + } + } + + .progress-bar-small { + @include w.progress-bar; + height: 4px; + margin-bottom: v.$space-1; + + .progress-fill-small { + @include w.progress-fill; + } + } + + .status-message { + font-size: v.$font-size-sm; + color: v.$text-muted; + margin: 0; + } + } + } + } + } + + // Action Buttons + .action-buttons { + @include w.action-buttons-footer; + + .btn-secondary { + @include w.btn-secondary; + } + + .buttons-right { + display: flex; + align-items: center; + gap: v.$space-3; + margin-left: auto; + } + + .btn-primary { + @include w.btn-primary; + } + } +} diff --git a/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.spec.ts b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.spec.ts new file mode 100644 index 0000000..72838b0 --- /dev/null +++ b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.spec.ts @@ -0,0 +1,542 @@ +import type { Mock, Mocked } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BehaviorSubject, Subject, of } from 'rxjs'; +import { Step6ReviewProcessingComponent } from './step6-review-processing.component'; +import { PreprocessingService } from '../../services/preprocessing.service'; +import { DataLoaderService } from '../../../services/data-loader.service'; +import { + BackgroundStatus, + ProjectionCancelledError, + ProjectionResult, + ProjectionService, +} from '../../../services/projection.service'; +import { ToastService } from '../../../services/toast.service'; +import { DEFAULT_PROJECTION_CONFIG, PreprocessingState, ProcessingProgress } from '../../models/preprocessing-state'; +import { ColumnConfig } from '../../models/column-config'; +import { DataProfile } from '../../models/column-statistics'; +import { DatasetCollection } from '../../../shared/interfaces/dataset-collection'; +import { WizardStepId } from '../../shared/constants/step-info'; +import { classifyProcessingError } from '../../shared/constants/wizard-error-classes'; +import { WizardAnnouncer } from '../../shared/wizard-announcer.service'; +import { + DataType, + EncodingMethod, + ScalingMethod, + MissingValueStrategy, + OutlierMethod, + OutlierStrategy, +} from '../../models/data-type.enum'; +import { createSpyObj } from '../../../../testing/create-spy-obj'; +import { createTestPreprocessingService, createToastServiceSpy } from '../../testing/preprocessing-test-helpers'; + +function makeConfig(name: string, isColorFeature = false): ColumnConfig { + return { + name, + originalType: DataType.Numeric, + targetType: DataType.Numeric, + encodingMethod: EncodingMethod.Normalize, + scalingMethod: ScalingMethod.MinMax, + includeInProjection: true, + isColorFeature, + missingValueStrategy: MissingValueStrategy.Keep, + outlierMethod: OutlierMethod.IQR_1_5, + outlierStrategy: OutlierStrategy.Keep, + enabled: true, + hasIssues: false, + }; +} + +function setState(service: PreprocessingService, updates: Partial): void { + (service as unknown as { updateState(u: Partial): void }).updateState(updates); +} + +/** A loaded three-column file on the review step. */ +function seedReview(service: PreprocessingService, updates: Partial = {}): void { + const names = ['x', 'y', 'z']; + const profile: DataProfile = { + totalRows: 100, + totalColumns: names.length, + fileSize: 1000, + fileName: 'test.csv', + columns: names.map(name => ({ + name, + dataType: DataType.Numeric, + count: 100, + missingCount: 0, + missingPercentage: 0, + uniqueCount: 50, + })), + qualityScore: 90, + duplicateCount: 0, + previewRows: [], + }; + setState(service, { + rawFileName: 'test.csv', + dataProfile: profile, + columnConfigs: new Map(names.map(name => [name, makeConfig(name, name === 'x')])), + glyphFeatures: [...names], + ...updates, + }); + service.goToStep('review'); +} + +/** Only FastMap: no background projection is started by a processing run. */ +const NO_BACKGROUND_METHODS = { ...DEFAULT_PROJECTION_CONFIG, enablePCA: false, enableIsoMap: false }; + +/** + * Review & Process summary: Projection and Glyphs & Color are separate cards, + * and every edit button deep-links to the step (by id) and anchor that owns the + * setting. The processing pipeline itself is not exercised here. + */ +describe('Step6ReviewProcessingComponent – summary and deep links', () => { + let service: PreprocessingService; + let toast: Mocked; + + function seed(): void { + seedReview(service); + } + + beforeEach(async () => { + localStorage.clear(); + service = createTestPreprocessingService(); + seed(); + toast = createToastServiceSpy(); + await TestBed.configureTestingModule({ + imports: [Step6ReviewProcessingComponent], + providers: [ + { provide: PreprocessingService, useValue: service }, + { provide: DataLoaderService, useValue: {} }, + { provide: ProjectionService, useValue: { backgroundStatusObservable: of(new Map()) } }, + { provide: ToastService, useValue: toast }, + ], + }).compileComponents(); + }); + + afterEach(() => localStorage.clear()); + + function render(): HTMLElement { + const fixture = TestBed.createComponent(Step6ReviewProcessingComponent); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + function cardTitles(el: HTMLElement): string[] { + return Array.from(el.querySelectorAll('.summary-card .card-label')).map(h => + (h.textContent ?? '').replace(/\s+/g, ' ').trim() + ); + } + + it('shows Projection and Glyphs & Color as separate cards with their step numbers', () => { + expect(cardTitles(render())).toEqual(['Columns Selected', 'Projection Step 4', 'Glyphs & Color Step 5']); + }); + + const editButtons: { label: string; step: WizardStepId; anchor: string }[] = [ + { label: 'Edit column selection', step: 'columns', anchor: 'wizard-anchor-columns' }, + { label: 'Edit projection columns', step: 'projection', anchor: 'wizard-anchor-projection-columns' }, + { label: 'Edit projection methods', step: 'projection', anchor: 'wizard-anchor-methods' }, + { label: 'Edit color attribute and scale', step: 'appearance', anchor: 'wizard-anchor-color' }, + { label: 'Edit glyph features', step: 'appearance', anchor: 'wizard-anchor-glyph' }, + ]; + + editButtons.forEach(({ label, step, anchor }) => { + it(`"${label}" opens the ${step} step at ${anchor}`, () => { + const button = render().querySelector(`button[aria-label="${label}"]`); + expect(button, label).not.toBeNull(); + + button?.click(); + + expect(service.currentStepId).toBe(step); + expect(service.consumeScrollTarget()).toBe(anchor); + }); + }); + + it('shows the color attribute, scale and glyph count on the Glyphs & Color card', () => { + const text = (render().textContent ?? '').replace(/\s+/g, ' '); + expect(text).toContain('x · Diverge'); + expect(text).toContain('3 features'); + }); + + it('does not list LLE, which is hidden, even when a saved config enables it', () => { + seedReview(service, { projectionConfig: { ...NO_BACKGROUND_METHODS, enableLLE: true, enableLTSA: true } }); + + const methodsRow = render().querySelector('button[aria-label="Edit projection methods"]')?.closest('dd'); + const methods = methodsRow?.querySelector('.card-row-value')?.textContent?.trim(); + + expect(methods).toBe('FastMap (Primary), LTSA'); + }); + + it('"Fix in step N" jumps to the issue step by id and names it in the toast', () => { + const fixture = TestBed.createComponent(Step6ReviewProcessingComponent); + const issue = classifyProcessingError('A glyph needs 3-12 glyph features'); + + fixture.componentInstance.fixInStep(issue); + + expect(service.currentStepId).toBe('appearance'); + expect(service.consumeScrollTarget()).toBe('wizard-anchor-glyph'); + expect(toast.info).toHaveBeenCalledWith('Opening Step 5 · Glyphs & Color', 2500); + }); +}); + +/** + * A full primary run: Python processing, then FastMap. The service's + * isProcessing flag (which the shell uses to block undo/redo) must stay up until + * FastMap has finished, and screen readers hear each phase once. + */ +describe('Step6ReviewProcessingComponent – processing run', () => { + let service: PreprocessingService; + let announcer: WizardAnnouncer; + let progress$: Subject; + let runFastMapSync: Mock; + let terminateAllWorkers: Mock; + let clearBackgroundStatuses: Mock; + let detachAll: Mock; + let runLLE: Mock; + + function dataProcessor(): object { + return { + processingProgress: progress$.asObservable(), + processWithConfig: () => { + progress$.next({ step: 'Building dataset', progress: 86, message: 'Row 1000' }); + progress$.next({ step: 'Building dataset', progress: 90, message: 'Row 2000' }); + progress$.next({ step: 'Complete', progress: 100, message: 'Processing successful' }); + return Promise.resolve({ datasets: { d: {} } }); + }, + getProcessedFeatures: () => Promise.resolve('id,f\n1,0.5\n2,0.7\n'), + pruneFiles: () => Promise.resolve(), + }; + } + + beforeEach(async () => { + localStorage.clear(); + progress$ = new Subject(); + service = createTestPreprocessingService(dataProcessor()); + seedReview(service, { projectionConfig: NO_BACKGROUND_METHODS }); + runFastMapSync = vi + .fn() + .mockResolvedValue({ method: 'fastmap', positions: [{ id: 1, x: 0, y: 0 }], computeTime: 3 }); + terminateAllWorkers = vi.fn().mockReturnValue([]); + clearBackgroundStatuses = vi.fn(); + detachAll = vi.fn(); + runLLE = vi.fn(); + const projection = { + backgroundStatusObservable: of(new Map()), + parseCSVFeatures: () => ({ features: [[0.5], [0.7]], ids: [1, 2] }), + runFastMapSync, + terminateAllWorkers, + clearBackgroundStatuses, + detachAll, + runLLE, + }; + await TestBed.configureTestingModule({ + imports: [Step6ReviewProcessingComponent], + providers: [ + { provide: PreprocessingService, useValue: service }, + { provide: DataLoaderService, useValue: {} }, + { provide: ProjectionService, useValue: projection }, + { provide: ToastService, useValue: createToastServiceSpy() }, + ], + }).compileComponents(); + announcer = TestBed.inject(WizardAnnouncer); + vi.spyOn(announcer, 'announce').mockImplementation(() => undefined); + }); + + afterEach(() => localStorage.clear()); + + function create(): ComponentFixture { + const fixture = TestBed.createComponent(Step6ReviewProcessingComponent); + fixture.detectChanges(); + // Skip the minimum loading-screen time; it only exists so sighted users notice a fast run. + (fixture.componentInstance as unknown as { MIN_LOADING_MS: number }).MIN_LOADING_MS = 0; + return fixture; + } + + function failFastMap(): void { + runFastMapSync.mockRejectedValue(new Error('FastMap failed: boom')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); // the component logs the failure + } + + it('keeps the service processing, so undo is blocked, until FastMap has finished', async () => { + service.pushHistory('Earlier change'); + let processingDuringFastMap = false; + let undoDuringFastMap: unknown = 'not called'; + runFastMapSync.mockImplementation(async () => { + processingDuringFastMap = service.currentState.isProcessing; + undoDuringFastMap = service.undo(); + return { method: 'fastmap', positions: [], computeTime: 1 }; + }); + + await create().componentInstance.startProcessing(); + + expect(processingDuringFastMap).toBe(true); + expect(undoDuringFastMap).toBeNull(); + expect(service.currentState.isProcessing).toBe(false); + expect(service.canUndo).toBe(true); + }); + + it('lets background projections of an earlier dataset finish, detached, on a fresh run', async () => { + await create().componentInstance.startProcessing(); + + expect(detachAll).toHaveBeenCalled(); + expect(terminateAllWorkers).not.toHaveBeenCalled(); + expect(clearBackgroundStatuses).not.toHaveBeenCalled(); + }); + + it('stops and forgets the outdated background projections when reprocessing', async () => { + setState(service, { processedDataset: { datasets: { d: {} } } as unknown as DatasetCollection }); + + await create().componentInstance.startProcessing(); + + expect(terminateAllWorkers).toHaveBeenCalled(); + expect(clearBackgroundStatuses).toHaveBeenCalledBefore(runFastMapSync); + expect(detachAll).not.toHaveBeenCalled(); + }); + + it('does not start LLE, which is hidden, even when a saved config enables it', async () => { + seedReview(service, { projectionConfig: { ...NO_BACKGROUND_METHODS, enableLLE: true } }); + + await create().componentInstance.startProcessing(); + + expect(runFastMapSync).toHaveBeenCalled(); + expect(runLLE).not.toHaveBeenCalled(); + }); + + it('releases the processing flag when FastMap fails', async () => { + failFastMap(); + const fixture = create(); + + await fixture.componentInstance.startProcessing(); + + expect(service.currentState.isProcessing).toBe(false); + expect(fixture.componentInstance.isErrorScreen).toBe(true); + }); + + it('announces the start, each phase once (not every tick) and the completion', async () => { + await create().componentInstance.startProcessing(); + + const messages = vi.mocked(announcer.announce).mock.calls.map(args => args[0]); + expect(messages).toEqual([ + 'Processing started.', + 'Building dataset.', + 'Loading features for projections.', + 'Computing FastMap projection.', + 'Loading dataset with FastMap.', + 'Processing complete. Your visualization is ready to open in Glyphspace.', + ]); + }); + + it('reports a failure once, through the blocking error alert with its cause', async () => { + failFastMap(); + const fixture = create(); + + await fixture.componentInstance.startProcessing(); + fixture.detectChanges(); + + const alert = (fixture.nativeElement as HTMLElement).querySelector('.status-hero.error[role="alert"]'); + const cause = fixture.componentInstance.blockingIssue?.title ?? 'missing'; + expect(alert?.textContent).toContain(`Cause: ${cause}.`); + // role="alert" is already an assertive announcement; the announcer must not repeat it. + const messages = vi.mocked(announcer.announce).mock.calls.map(args => args[0]); + expect(messages.some(m => /fail/i.test(m))).toBe(false); + }); + + it('exposes the processing progress as a labelled progressbar', () => { + const fixture = create(); + Object.assign(fixture.componentInstance, { + showProcessing: true, + isProcessing: true, + processingProgress: 45, + processingStep: 'Encoding features', + }); + fixture.detectChanges(); + + const bar = (fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]'); + expect(bar?.getAttribute('aria-label')).toBe('Processing progress'); + expect(bar?.getAttribute('aria-valuemin')).toBe('0'); + expect(bar?.getAttribute('aria-valuemax')).toBe('100'); + expect(bar?.getAttribute('aria-valuenow')).toBe('45'); + expect(bar?.getAttribute('aria-valuetext')).toBe('45% – Encoding features'); + }); +}); + +/** + * Background projections can be cancelled one by one or all at once, and a + * cancelled or failed one can be retried on its own, without resetting the wizard. + */ +describe('Step6ReviewProcessingComponent – cancel and retry background projections', () => { + let service: PreprocessingService; + let statuses: BehaviorSubject>; + let projection: Mocked; + let announcer: WizardAnnouncer; + let toast: Mocked; + let dataLoader: Mocked; + let retryable: Set; + + type Entry = [string, BackgroundStatus]; + const entry = (method: string, status: BackgroundStatus['status'], progress = 0): Entry => [ + method, + { method, status, progress, message: `${method} ${status}` }, + ]; + const initial: Entry[] = [ + entry('pca', 'complete', 100), + entry('umap', 'running', 40), + entry('tsne', 'error'), + entry('mds', 'cancelled'), + ]; + + /** Publish the initial statuses with the given entries replaced. */ + function publish(...changes: Entry[]): void { + statuses.next(new Map([...initial, ...changes])); + } + + beforeEach(async () => { + localStorage.clear(); + service = createTestPreprocessingService({ pruneFiles: () => Promise.resolve() }); + seedReview(service, { + projectionConfig: { + ...NO_BACKGROUND_METHODS, + enablePCA: true, + enableMDS: true, + enableTSNE: true, + enableUMAP: true, + }, + processedDataset: { datasets: { d: {} } } as unknown as DatasetCollection, + }); + statuses = new BehaviorSubject(new Map()); + retryable = new Set(['tsne', 'mds']); + projection = createSpyObj( + 'ProjectionService', + ['cancel', 'terminateAllWorkers', 'canRetry', 'retry', 'detachAll'], + { backgroundStatusObservable: statuses.asObservable() } + ); + projection.canRetry.mockImplementation(method => retryable.has(method)); + toast = createToastServiceSpy(); + dataLoader = createSpyObj('DataLoaderService', [ + 'addProcessedDatasetToCollection', + 'loadProcessedDataset', + 'saveDatasetToStorage', + ]); + await TestBed.configureTestingModule({ + imports: [Step6ReviewProcessingComponent], + providers: [ + { provide: PreprocessingService, useValue: service }, + { provide: DataLoaderService, useValue: dataLoader }, + { provide: ProjectionService, useValue: projection }, + { provide: ToastService, useValue: toast }, + ], + }).compileComponents(); + announcer = TestBed.inject(WizardAnnouncer); + vi.spyOn(announcer, 'announce').mockImplementation(() => undefined); + }); + + afterEach(() => localStorage.clear()); + + function render(): { fixture: ComponentFixture; el: HTMLElement } { + const fixture = TestBed.createComponent(Step6ReviewProcessingComponent); + fixture.detectChanges(); + publish(); + fixture.detectChanges(); + return { fixture, el: fixture.nativeElement as HTMLElement }; + } + + function button(el: HTMLElement, label: string): HTMLButtonElement | null { + return el.querySelector(`button[aria-label="${label}"]`); + } + + function buttonByText(el: HTMLElement, text: string): HTMLButtonElement | undefined { + return Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes(text)); + } + + const flush = (): Promise => new Promise(resolve => setTimeout(resolve)); + + it('offers Cancel while running and Retry once cancelled or failed', () => { + const { el } = render(); + + expect(button(el, 'Cancel UMAP')).not.toBeNull(); + expect(button(el, 'Retry t-SNE')).not.toBeNull(); + expect(button(el, 'Retry MDS')).not.toBeNull(); + expect(button(el, 'Cancel PCA')).toBeNull(); + expect(button(el, 'Retry PCA')).toBeNull(); + expect(buttonByText(el, 'Stop all background projections')).toBeDefined(); + }); + + it('shows the cancelled state in the list and in the method bar', () => { + const { el } = render(); + + expect(el.querySelector('.status-badge.status-cancelled')?.textContent).toContain('Cancelled'); + const mdsChip = Array.from(el.querySelectorAll('.method-chip')).find(c => c.textContent?.includes('MDS')); + expect(mdsChip?.querySelector('.chip-status')?.textContent?.trim()).toBe('Cancelled'); + }); + + it('labels the progressbar of each running projection', () => { + const bar = render().el.querySelector('.projection-status-item [role="progressbar"]'); + expect(bar?.getAttribute('aria-label')).toBe('UMAP progress'); + expect(bar?.getAttribute('aria-valuenow')).toBe('40'); + }); + + it('cancels just that method, announces it and moves focus to its Retry button', () => { + const { el } = render(); + projection.cancel.mockImplementation(method => { + retryable.add(method); + publish(entry(method, 'cancelled')); + return true; + }); + + button(el, 'Cancel UMAP')?.click(); + + expect(projection.cancel).toHaveBeenCalledExactlyOnceWith('umap'); + expect(announcer.announce).toHaveBeenCalledWith('UMAP cancelled. You can retry it.'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Retry UMAP'); + }); + + it('stops all running projections and announces how many were stopped', () => { + const { el } = render(); + projection.terminateAllWorkers.mockImplementation(() => { + publish(entry('umap', 'cancelled')); + return ['umap']; + }); + + buttonByText(el, 'Stop all background projections')?.click(); + + expect(projection.terminateAllWorkers).toHaveBeenCalled(); + expect(announcer.announce).toHaveBeenCalledWith('Stopped 1 background projection.'); + expect(buttonByText(el, 'Stop all background projections')).toBeUndefined(); + expect(document.activeElement?.id).toBe('step6-background-heading'); + }); + + it('retries only that method and adds its result to the dataset', async () => { + const { el } = render(); + const result: ProjectionResult = { method: 'tsne', positions: [{ id: 1, x: 1, y: 2 }], computeTime: 1500 }; + projection.retry.mockImplementation(method => { + retryable.delete(method); + publish(entry(method, 'running')); + return Promise.resolve(result); + }); + + button(el, 'Retry t-SNE')?.click(); + await flush(); + + expect(projection.retry).toHaveBeenCalledExactlyOnceWith('tsne'); + expect(announcer.announce).toHaveBeenCalledWith('Retrying t-SNE.'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Cancel t-SNE'); + expect(dataLoader.loadProcessedDataset).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('t-SNE projection ready! (1.5s)', 4000); + }); + + it('shows no error when a retried run is cancelled again', async () => { + const { el } = render(); + projection.retry.mockReturnValue(Promise.reject(new ProjectionCancelledError('tsne'))); + + button(el, 'Retry t-SNE')?.click(); + await flush(); + + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('detaches the running projections when the dataset is handed to the dashboard', () => { + const { fixture } = render(); + + fixture.componentInstance.goToDashboard(); + + expect(projection.detachAll).toHaveBeenCalled(); + }); +}); diff --git a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.ts similarity index 54% rename from src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts rename to src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.ts index 58f8849..7929ce9 100644 --- a/src/app/preprocessing-wizard/steps/step5-review-processing/step5-review-processing.component.ts +++ b/src/app/preprocessing-wizard/steps/step6-review-processing/step6-review-processing.component.ts @@ -3,37 +3,93 @@ import { NgTemplateOutlet } from '@angular/common'; import { Subscription } from 'rxjs'; import { PreprocessingService } from '../../services/preprocessing.service'; import { DataLoaderService } from '../../../services/data-loader.service'; -import { ProjectionService, ProjectionResult } from '../../../services/projection.service'; +import { + BackgroundStatus, + ProjectionCancelledError, + ProjectionResult, + ProjectionService, +} from '../../../services/projection.service'; import { ToastService } from '../../../services/toast.service'; import { ColumnConfig, ProjectionConfig } from '../../models/column-config'; +import { DEFAULT_PROJECTION_CONFIG } from '../../models/preprocessing-state'; import { DataType, getEncodingLabel as encLabelFn, getScalingLabel as scaleLabelFn } from '../../models/data-type.enum'; -import { STEP_INFO } from '../../shared/constants/step-info'; +import { WizardStepId, getStepInfo, stepNumber } from '../../shared/constants/step-info'; import { DataTypeBadgeComponent } from '../../shared/data-type-badge/data-type-badge.component'; import { WizardIssueCardComponent } from '../../shared/wizard-issue-card/wizard-issue-card.component'; +import { WizardAnnouncer } from '../../shared/wizard-announcer.service'; +import { getColorScaleById } from '../../../shared/interfaces/color-scale'; import { WizardIssue, - STEP_NAMES, classifyProcessingError, classifyBackgroundFailure, detectPreflightIssues, } from '../../shared/constants/wizard-error-classes'; +import { + backgroundMethodName, + buildBackgroundProjectionRuns, + enabledBackgroundMethodNames, +} from './background-projections'; + +type ChipStatus = 'ready' | 'computing' | 'queued' | 'failed' | 'cancelled'; /** One entry in the method status bar (FastMap / PCA / UMAP / …). */ interface MethodStatus { name: string; role: string; - status: 'ready' | 'computing' | 'queued' | 'failed'; + status: ChipStatus; statusLabel: string; } +/** One row of the background-projection status list. */ +export interface BackgroundItem { + method: string; // status key, e.g. 'tsne' + name: string; // display name, e.g. 't-SNE' + status: BackgroundStatus['status']; + progress: number; + message: string; + canRetry: boolean; +} + +/** Feature matrix handed from the primary run to the background projections. */ +interface PrimaryRunFeatures { + features: number[][]; + ids: (string | number)[]; +} + +const CHIP_LABELS: Record = { + ready: 'Ready', + computing: 'Computing…', + queued: 'Queued', + failed: 'Failed', + cancelled: 'Cancelled', +}; + +const BACKGROUND_CHIP_STATUS: Record = { + pending: 'queued', + running: 'computing', + complete: 'ready', + error: 'failed', + cancelled: 'cancelled', +}; + +/** Heading of the background list; takes focus after "Stop all" removes the button. */ +const BACKGROUND_HEADING_ID = 'step6-background-heading'; + +function methodRole(label: string): string { + if (label.startsWith('FastMap')) return 'Primary'; + if (label === 'PCA') return 'Instant'; + if (label === 't-SNE' || label === 'Sammon') return 'Slow'; + return 'Background'; +} + @Component({ - selector: 'app-step5-review-processing', + selector: 'app-step6-review-processing', standalone: true, imports: [NgTemplateOutlet, DataTypeBadgeComponent, WizardIssueCardComponent], - templateUrl: './step5-review-processing.component.html', - styleUrl: './step5-review-processing.component.scss', + templateUrl: './step6-review-processing.component.html', + styleUrl: './step6-review-processing.component.scss', }) -export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { +export class Step6ReviewProcessingComponent implements OnInit, OnDestroy { @Output() wizardFinish = new EventEmitter(); getEncodingLabel = encLabelFn; getScalingLabel = scaleLabelFn; @@ -45,8 +101,9 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { enabledMethods: string[] = []; columnConfigs: ColumnConfig[] = []; colorFeature: string | null = null; + colorScaleName = ''; selectedGlyphFeatures: string[] = []; - projectionConfig!: ProjectionConfig; + projectionConfig: ProjectionConfig = { ...DEFAULT_PROJECTION_CONFIG }; // Processing state isProcessing = false; @@ -56,17 +113,16 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { error: string | null = null; showProcessing = false; - // Background projection status - backgroundProjections = new Map(); + // Background projection status, by method key, and the list rows derived from it + backgroundProjections = new Map(); + backgroundItems: BackgroundItem[] = []; + readonly backgroundHeadingId = BACKGROUND_HEADING_ID; // A3: structured, persistent issues. preflightIssues: WizardIssue[] = []; // shown on the review screen before processing blockingIssue: WizardIssue | null = null; // primary (hard) failure — replaces the old bare "error" string partialIssues: WizardIssue[] = []; // degraded: some background projections failed (dismissable) private dismissedMethods = new Set(); - private expandedTechnical = new Set(); - - readonly STEP_NAMES = STEP_NAMES; // Capture dataset info for background projections (survives wizard reset) private backgroundDatasetName = ''; @@ -75,26 +131,38 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { // Expose enums DataType = DataType; - readonly stepInfo = STEP_INFO[4]; // Step 5 (index 4) + readonly stepInfo = getStepInfo('review'); + // Step numbers shown on the summary cards, e.g. "Step 4". + readonly projectionStepNumber = stepNumber('projection'); + readonly appearanceStepNumber = stepNumber('appearance'); private progressSubscription?: Subscription; private backgroundStatusSubscription?: Subscription; + // True while a startProcessing() run is in flight (re-entry guard). + private runActive = false; + // Set in ngOnDestroy: async continuations must not touch the destroyed view. + private destroyed = false; + // Last processing phase read out, so progress ticks within a phase stay silent. + private lastAnnouncedPhase = ''; + constructor( public preprocessingService: PreprocessingService, private dataLoader: DataLoaderService, private projectionService: ProjectionService, private toastService: ToastService, private cdr: ChangeDetectorRef, - private ngZone: NgZone + private ngZone: NgZone, + private announcer: WizardAnnouncer ) {} ngOnInit(): void { const state = this.preprocessingService.currentState; - // Load color feature + // Load color feature and scale const colorCol = Array.from(state.columnConfigs.values()).find(c => c.isColorFeature); this.colorFeature = colorCol?.name ?? null; + this.colorScaleName = getColorScaleById(state.colorScaleId).name; // Load glyph features this.selectedGlyphFeatures = [...state.glyphFeatures]; @@ -136,6 +204,7 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.destroyed = true; if (this.progressSubscription) { this.progressSubscription.unsubscribe(); } @@ -157,17 +226,7 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { this.projectionColumns = this.columnConfigs.filter(c => c.enabled && c.includeInProjection).length; // FastMap is always the primary projection - this.enabledMethods = ['FastMap (Primary)']; - if (this.projectionConfig.enablePCA) this.enabledMethods.push('PCA'); - if (this.projectionConfig.enableIsoMap) this.enabledMethods.push('IsoMap'); - if (this.projectionConfig.enableMDS) this.enabledMethods.push('MDS'); - if (this.projectionConfig.enableLLE) this.enabledMethods.push('LLE'); - if (this.projectionConfig.enableLTSA) this.enabledMethods.push('LTSA'); - if (this.projectionConfig.enableTSNE) this.enabledMethods.push('t-SNE'); - if (this.projectionConfig.enableUMAP) this.enabledMethods.push('UMAP'); - if (this.projectionConfig.enableTriMap) this.enabledMethods.push('TriMap'); - if (this.projectionConfig.enableTopoMap) this.enabledMethods.push('TopoMap'); - if (this.projectionConfig.enableSammon) this.enabledMethods.push('Sammon'); + this.enabledMethods = ['FastMap (Primary)', ...enabledBackgroundMethodNames(this.projectionConfig)]; } // ============================================================================ @@ -184,7 +243,7 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { this.blockingIssue = null; this.partialIssues = []; this.dismissedMethods.clear(); - this.expandedTechnical.clear(); + this.lastAnnouncedPhase = ''; // Clear any stale error left in the singleton state (P-S5-03). this.preprocessingService.clearError(); } @@ -193,10 +252,28 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { this.ngZone.run(() => { this.processingStep = step; this.processingProgress = progress; - this.cdr.detectChanges(); + this.announcePhase(step); + this.detectChangesIfAlive(); }); } + /** Read out a new processing phase once; repeated ticks of the same phase stay silent. */ + private announcePhase(phase: string): void { + const text = phase.replace(/(\.\.\.|…)$/, '').trim(); + if (!text || text === this.lastAnnouncedPhase) { + return; + } + this.lastAnnouncedPhase = text; + this.announcer.announce(`${text}.`); + } + + /** Render now, unless the step was destroyed while an async run continued. */ + private detectChangesIfAlive(): void { + if (!this.destroyed) { + this.cdr.detectChanges(); + } + } + /** Minimum time the loading hero stays on screen so a fast run -- or a fast * failure -- is actually perceivable instead of a one-frame flash. */ private readonly MIN_LOADING_MS = 600; @@ -216,18 +293,34 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { } async startProcessing(): Promise { + // Re-entry guard: a parallel run would race this one and its `finally` + // would tear down this run's progress subscription. + if (this.runActive) { + return; + } + this.runActive = true; + + this.releaseEarlierBackgroundRuns(); this.resetProcessingState(); this.showProcessing = true; this.isProcessing = true; this.processingStep = 'Initializing...'; + this.announcer.announce('Processing started.'); - this.progressSubscription = this.preprocessingService.processingProgress.subscribe({ + this.progressSubscription?.unsubscribe(); + const progressSubscription = this.preprocessingService.processingProgress.subscribe({ next: progress => { this.processingStep = progress.message || progress.step; this.processingProgress = Math.min(progress.progress, 70); - this.cdr.detectChanges(); + // Python's final 100 % step is not read out: FastMap still has to run, + // and completion is announced once the dataset is loaded. + if (progress.progress < 100) { + this.announcePhase(progress.step); + } + this.detectChangesIfAlive(); }, }); + this.progressSubscription = progressSubscription; // Keep the loading hero visible for a perceptible minimum, measured from // here, and let the browser paint it before the heavy work begins. @@ -235,55 +328,95 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { await this.paintYield(); try { - await this.preprocessingService.processData(); + // isProcessing stays up in the service for the whole primary run (not just + // processData), so the shell keeps undo/redo disabled during FastMap too. + const primary = await this.preprocessingService.whileProcessing(() => this.runPrimaryProcessing(startedAt)); + this.startBackgroundProjections(primary.features, primary.ids); + } catch (error: unknown) { + console.error('Processing failed:', error); + await this.ensureMinVisible(startedAt); + this.showProcessingError(error); + } finally { + // Only release what this run created. + progressSubscription.unsubscribe(); + if (this.progressSubscription === progressSubscription) { + this.progressSubscription = undefined; + } + this.runActive = false; + } + } - this.updateProcessingUI('Loading features for projections...', 70); + /** + * Before a new run: when reprocessing this wizard's dataset, its earlier + * background projections are outdated, so stop them and clear their statuses + * (Retry must not re-run old data). Otherwise they belong to a dataset that + * was already loaded into the dashboard: let them finish, but detached. + */ + private releaseEarlierBackgroundRuns(): void { + if (this.preprocessingService.currentState.processedDataset) { + this.projectionService.terminateAllWorkers(); + this.projectionService.clearBackgroundStatuses(); + } else { + this.projectionService.detachAll(); + } + } - const csvText = await this.preprocessingService.getProcessedFeaturesCSV(); - const { features, ids } = this.projectionService.parseCSVFeatures(csvText); + /** + * Python processing, then the primary FastMap projection. + * + * Deliberately not cancellable: the Python step runs in the shared Pyodide + * worker, and the only way to interrupt it is to terminate that worker, which + * drops the loaded Python runtime (a slow reload) and the uploaded file held + * in its virtual FS, so the user would have to upload again. FastMap runs + * synchronously on the main thread and cannot be interrupted either. Only the + * optional background projections (one worker each) offer Cancel. + */ + private async runPrimaryProcessing(startedAt: number): Promise { + await this.preprocessingService.processData(); - this.updateProcessingUI('Computing FastMap projection...', 75); + this.updateProcessingUI('Loading features for projections...', 70); - // Use FastMap as the primary projection - const fastmapResult = await this.projectionService.runFastMapSync(features, ids); + const csvText = await this.preprocessingService.getProcessedFeaturesCSV(); + const { features, ids } = this.projectionService.parseCSVFeatures(csvText); - this.updateProcessingUI('Loading dataset with FastMap...', 90); + this.updateProcessingUI('Computing FastMap projection...', 75); - await this.preprocessingService.addProjectionPositions('fastmap', fastmapResult.positions); + // Use FastMap as the primary projection + const fastmapResult = await this.projectionService.runFastMapSync(features, ids); - await this.ensureMinVisible(startedAt); - this.ngZone.run(() => { - this.processingProgress = 100; - this.processingStep = `Dataset loaded with FastMap (${fastmapResult.computeTime}ms)`; - this.processingComplete = true; - this.isProcessing = false; - this.cdr.detectChanges(); - }); + this.updateProcessingUI('Loading dataset with FastMap...', 90); - this.startBackgroundProjections(features, ids); - } catch (error: unknown) { - console.error('Processing failed:', error); - await this.ensureMinVisible(startedAt); - this.ngZone.run(() => { - const raw = error instanceof Error ? error.message : String(error); - this.error = raw; - // A3: classify into a persistent, actionable issue (cause + fix + target step). - this.blockingIssue = classifyProcessingError(raw); - this.isProcessing = false; - this.processingComplete = false; - this.cdr.detectChanges(); - }); - } finally { - if (this.progressSubscription) { - this.progressSubscription.unsubscribe(); - this.progressSubscription = undefined; - } - } + await this.preprocessingService.addProjectionPositions('fastmap', fastmapResult.positions); + + await this.ensureMinVisible(startedAt); + this.ngZone.run(() => { + this.processingProgress = 100; + this.processingStep = `Dataset loaded with FastMap (${fastmapResult.computeTime}ms)`; + this.processingComplete = true; + this.isProcessing = false; + this.announcer.announce('Processing complete. Your visualization is ready to open in Glyphspace.'); + this.detectChangesIfAlive(); + }); + + return { features, ids }; } - private async startBackgroundProjections(features: number[][], ids: (string | number)[]): Promise { - const config = this.projectionConfig; + private showProcessingError(error: unknown): void { + this.ngZone.run(() => { + const raw = error instanceof Error ? error.message : String(error); + this.error = raw; + // A3: classify into a persistent, actionable issue (cause + fix + target step). + // Screen readers hear it through the error panel's role="alert" (an + // assertive announcement including the cause); announcing it through the + // WizardAnnouncer as well would read the failure twice. + this.blockingIssue = classifyProcessingError(raw); + this.isProcessing = false; + this.processingComplete = false; + this.detectChangesIfAlive(); + }); + } + private startBackgroundProjections(features: number[][], ids: (string | number)[]): void { // Capture dataset info so background projections can add positions even after wizard reset const state = this.preprocessingService.currentState; this.backgroundDatasetName = state.datasetName; @@ -291,57 +424,9 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { // Note: the background-status subscription is created once in ngOnInit so it // also restores the method bar after the wizard is reopened. - - // Data-driven projection registry: each entry maps a config flag to its runner - const projections: { enabled: boolean; name: string; run: () => Promise }[] = [ - { enabled: config.enablePCA, name: 'PCA', run: () => this.projectionService.runPCABackground(features, ids) }, - { - enabled: config.enableIsoMap, - name: 'IsoMap', - run: () => this.projectionService.runIsoMap(features, ids, { neighbors: config.isomapNeighbors }), - }, - { enabled: config.enableMDS, name: 'MDS', run: () => this.projectionService.runMDS(features, ids) }, - { - enabled: config.enableLLE, - name: 'LLE', - run: () => this.projectionService.runLLE(features, ids, { neighbors: config.lleNeighbors }), - }, - { - enabled: config.enableLTSA, - name: 'LTSA', - run: () => this.projectionService.runLTSA(features, ids, { neighbors: config.ltsaNeighbors }), - }, - { - enabled: config.enableTSNE, - name: 't-SNE', - run: () => - this.projectionService.runTSNE(features, ids, { - perplexity: config.tsnePerplexity, - iterations: config.tsneIterations, - }), - }, - { - enabled: config.enableUMAP, - name: 'UMAP', - run: () => - this.projectionService.runUMAP(features, ids, { - neighbors: config.umapNeighbors, - minDist: config.umapMinDist, - }), - }, - { - enabled: config.enableTriMap, - name: 'TriMap', - run: () => this.projectionService.runTriMap(features, ids, { weightAdj: config.trimapWeightAdj }), - }, - { enabled: config.enableTopoMap, name: 'TopoMap', run: () => this.projectionService.runTopoMap(features, ids) }, - { enabled: config.enableSammon, name: 'Sammon', run: () => this.projectionService.runSammon(features, ids) }, - ]; - - for (const proj of projections) { - if (proj.enabled) { - this.runBackgroundProjection(proj.name, proj.run); - } + const runs = buildBackgroundProjectionRuns(this.projectionService, this.projectionConfig, features, ids); + for (const { name, run } of runs) { + void this.runBackgroundProjection(name, run); } } @@ -389,6 +474,10 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { this.toastService.success(`${name} projection ready! (${(result.computeTime / 1000).toFixed(1)}s)`, 4000); }); } catch (error: unknown) { + if (error instanceof ProjectionCancelledError) { + // Stopped on purpose (Cancel / Stop all / superseded); the status list shows it. + return; + } const raw = error instanceof Error ? error.message : String(error); console.error(`${name} projection failed:`, error); this.ngZone.run(() => { @@ -428,6 +517,10 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { } } + // Running background projections keep going for the dashboard, but can no + // longer be cancelled or retried from the (reset) wizard. + this.projectionService.detachAll(); + // Reset wizard state so it's ready for a new upload this.preprocessingService.resetState(); @@ -440,7 +533,7 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { // A6: jump from a review summary card back to the step that owns that setting // and scroll the relevant section into view. - editSetting(step: number, targetId: string): void { + editSetting(step: WizardStepId, targetId: string): void { this.preprocessingService.goToStepWithScroll(step, targetId); } @@ -451,48 +544,87 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { this.projectionService.clearBackgroundStatuses(); // Clear local state - this.backgroundProjections.clear(); + this.backgroundProjections = new Map(); + this.backgroundItems = []; this.resetProcessingState(); // Reset wizard state this.preprocessingService.resetState(); - this.preprocessingService.goToStep(0); + this.preprocessingService.goToStep('upload'); } } - getBackgroundProjectionsArray(): { method: string; status: string; progress: number; message: string }[] { - const result: { method: string; status: string; progress: number; message: string }[] = []; - this.backgroundProjections.forEach((value, key) => { - result.push({ method: key, ...value }); - }); - return result; + // ============================================================================ + // Background projections: cancel / stop all / retry + // ============================================================================ + + /** True while at least one background projection is computing. */ + get hasRunningBackground(): boolean { + return this.backgroundItems.some(item => item.status === 'running'); + } + + /** Id shared by an item's Cancel and Retry button (only one is shown at a time). */ + backgroundActionId(method: string): string { + return `step6-background-action-${method}`; + } + + /** Cancel one running background projection; focus moves to its Retry button. */ + cancelBackgroundProjection(method: string): void { + if (!this.projectionService.cancel(method)) { + return; + } + this.announcer.announce(`${backgroundMethodName(method)} cancelled. You can retry it.`); + this.focusElement(this.backgroundActionId(method)); + } + + /** Cancel every running background projection. */ + stopAllBackgroundProjections(): void { + const stopped = this.projectionService.terminateAllWorkers(); + if (stopped.length === 0) { + return; + } + const noun = stopped.length === 1 ? 'projection' : 'projections'; + this.announcer.announce(`Stopped ${stopped.length} background ${noun}.`); + this.focusElement(BACKGROUND_HEADING_ID); + } + + /** Re-run one cancelled or failed background projection with the same data and settings. */ + retryBackgroundProjection(method: string): void { + if (!this.projectionService.canRetry(method)) { + return; + } + const name = backgroundMethodName(method); + this.dismissedMethods.delete(method); + this.announcer.announce(`Retrying ${name}.`); + void this.runBackgroundProjection(name, () => this.projectionService.retry(method)); + this.focusElement(this.backgroundActionId(method)); + } + + /** Keep keyboard focus in the list after the pressed button was replaced or removed. */ + private focusElement(id: string): void { + this.detectChangesIfAlive(); + document.getElementById(id)?.focus(); } // ============================================================================ // A3: persistent status/error screen // ============================================================================ - /** Copy the latest background status into the local map and rebuild the - * degraded/partial issue list from any failed background projections. */ - private syncBackgroundStatus( - statusMap: Map - ): void { - this.backgroundProjections.clear(); - statusMap.forEach((status, method) => { - this.backgroundProjections.set(method, { - status: status.status, - progress: status.progress, - message: status.message, - error: status.error, - }); - }); - - this.partialIssues = []; - this.backgroundProjections.forEach((value, method) => { - if (value.status === 'error' && !this.dismissedMethods.has(method)) { - this.partialIssues.push(classifyBackgroundFailure(method, value.error || value.message)); - } - }); + /** Adopt the latest background statuses, rebuild the list rows and the + * degraded/partial issue list from any failed background projection. */ + private syncBackgroundStatus(statusMap: Map): void { + this.backgroundProjections = new Map(statusMap); + this.backgroundItems = Array.from(statusMap.entries()).map(([method, s]) => ({ + method, + name: backgroundMethodName(method), + status: s.status, + progress: s.progress, + message: s.message, + canRetry: this.projectionService.canRetry(method), + })); + this.partialIssues = Array.from(statusMap.entries()) + .filter(([method, s]) => s.status === 'error' && !this.dismissedMethods.has(method)) + .map(([method, s]) => classifyBackgroundFailure(method, s.error || s.message)); } /** True once processing succeeded but at least one background projection failed. */ @@ -507,62 +639,46 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { /** Method status bar (FastMap primary + enabled background methods). */ get methodStatuses(): MethodStatus[] { - const roleFor = (name: string): string => { - if (name.startsWith('FastMap')) return 'Primary'; - if (name === 'PCA') return 'Instant'; - if (name === 't-SNE' || name === 'Sammon') return 'Slow'; - return 'Background'; - }; - return this.enabledMethods.map(label => { - const isPrimary = label.startsWith('FastMap'); - // Background status keys are lowercase alphanumeric ids (e.g. "tsne"). - const key = label - .replace(' (Primary)', '') - .toLowerCase() - .replace(/[^a-z0-9]/g, ''); - let status: MethodStatus['status']; - - if (isPrimary) { - if (this.blockingIssue) status = 'failed'; - else if (this.processingComplete) status = 'ready'; - else if (this.isProcessing) status = 'computing'; - else status = 'queued'; - } else { - const bg = this.backgroundProjections.get(key); - if (bg) { - if (bg.status === 'complete') status = 'ready'; - else if (bg.status === 'running') status = 'computing'; - else if (bg.status === 'error') status = 'failed'; - else status = 'queued'; - } else { - // Not tracked via the worker status stream (e.g. PCA runs instantly): - // assume ready once the primary run has finished, otherwise queued. - status = this.processingComplete ? 'ready' : 'queued'; - } - } - - const labels: Record = { - ready: 'Ready', - computing: 'Computing…', - queued: 'Queued', - failed: 'Failed', + const status = label.startsWith('FastMap') ? this.primaryChipStatus() : this.backgroundChipStatus(label); + return { + name: label.replace(' (Primary)', ''), + role: methodRole(label), + status, + statusLabel: CHIP_LABELS[status], }; - - return { name: label.replace(' (Primary)', ''), role: roleFor(label), status, statusLabel: labels[status] }; }); } - /** "Fix in Schritt N" — reuse the A6 jump/scroll logic and confirm with a toast. */ + private primaryChipStatus(): ChipStatus { + if (this.blockingIssue) return 'failed'; + if (this.processingComplete) return 'ready'; + if (this.isProcessing) return 'computing'; + return 'queued'; + } + + private backgroundChipStatus(label: string): ChipStatus { + // Background status keys are lowercase alphanumeric ids (e.g. "tsne"). + const key = label.toLowerCase().replace(/[^a-z0-9]/g, ''); + const bg = this.backgroundProjections.get(key); + if (bg) { + return BACKGROUND_CHIP_STATUS[bg.status]; + } + // Not tracked via the worker status stream (e.g. PCA runs instantly): + // assume ready once the primary run has finished, otherwise queued. + return this.processingComplete ? 'ready' : 'queued'; + } + + /** "Fix in step N" — reuse the A6 jump/scroll logic and confirm with a toast. */ fixInStep(issue: WizardIssue): void { - const stepNumber = issue.step + 1; - const stepName = STEP_NAMES[issue.step] ?? ''; + const number = stepNumber(issue.step); + const stepName = getStepInfo(issue.step).title; if (issue.anchorId) { this.preprocessingService.goToStepWithScroll(issue.step, issue.anchorId); } else { this.preprocessingService.goToStep(issue.step); } - this.toastService.info(`Opening Step ${stepNumber} · ${stepName}`, 2500); + this.toastService.info(`Opening Step ${number} · ${stepName}`, 2500); } /** Dismiss a partial (optional) issue so the degraded notice can be cleared. */ @@ -578,15 +694,6 @@ export class Step5ReviewProcessingComponent implements OnInit, OnDestroy { return match ? match[1].toLowerCase() : null; } - toggleTechnical(id: string): void { - if (this.expandedTechnical.has(id)) this.expandedTechnical.delete(id); - else this.expandedTechnical.add(id); - } - - isTechnicalExpanded(id: string): boolean { - return this.expandedTechnical.has(id); - } - /** * Retry / reprocess, keeping all uploaded data and settings. Reachable from * both the blocking-error screen and the success screen. diff --git a/src/app/preprocessing-wizard/testing/preprocessing-test-helpers.ts b/src/app/preprocessing-wizard/testing/preprocessing-test-helpers.ts new file mode 100644 index 0000000..4fdffdd --- /dev/null +++ b/src/app/preprocessing-wizard/testing/preprocessing-test-helpers.ts @@ -0,0 +1,32 @@ +import type { Mocked } from 'vitest'; +import { createSpyObj } from '../../../testing/create-spy-obj'; +import { DataLoaderService } from '../../services/data-loader.service'; +import { DataProcessorService } from '../../services/data-processor'; +import { ToastService } from '../../services/toast.service'; +import { PreprocessingService } from '../services/preprocessing.service'; + +/** + * A PreprocessingService on stub collaborators, outside Angular DI. The data + * loader reports no existing dataset names; `dataProcessor` carries only the + * DataProcessorService members a spec relies on (none by default). + */ +export function createTestPreprocessingService(dataProcessor: object = {}): PreprocessingService { + const dataLoader = { getDataSetNames: () => [] as string[] } as unknown as DataLoaderService; + return new PreprocessingService(dataProcessor as DataProcessorService, dataLoader); +} + +/** A ToastService whose every public method is a mock. */ +export function createToastServiceSpy(): Mocked { + return createSpyObj('ToastService', [ + 'show', + 'showUndo', + 'success', + 'error', + 'info', + 'warning', + 'pause', + 'resume', + 'remove', + 'clear', + ]); +} diff --git a/src/app/services/data-processor.spec.ts b/src/app/services/data-processor.spec.ts new file mode 100644 index 0000000..81906a8 --- /dev/null +++ b/src/app/services/data-processor.spec.ts @@ -0,0 +1,138 @@ +import { DataProcessorService } from './data-processor'; +import { DatasetCollection } from '../shared/interfaces/dataset-collection'; + +interface PostedMessage { + type?: string; + requestId?: unknown; + [key: string]: unknown; +} + +/** Minimal in-memory stand-in for the Pyodide worker. */ +class FakeWorker { + static last: FakeWorker | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + readonly posted: PostedMessage[] = []; + + constructor() { + FakeWorker.last = this; + } + + postMessage(message: PostedMessage): void { + this.posted.push(message); + } + + terminate(): void { + // no-op + } + + /** Deliver a reply from the "worker" to the main thread. */ + reply(data: unknown): void { + this.onmessage?.({ data } as MessageEvent); + } +} + +/** + * Concurrent processWithConfig() runs all answer with type 'processed', so + * replies must be matched to their request by a per-request id rather than by + * type, or concurrent requests resolve/reject with each other's replies. + */ +describe('DataProcessorService – request/reply matching', () => { + const globals = globalThis as unknown as { Worker: unknown }; + let originalWorker: unknown; + let service: DataProcessorService; + let worker: FakeWorker; + + const datasetA = { tag: 'A' } as unknown as DatasetCollection; + const datasetB = { tag: 'B' } as unknown as DatasetCollection; + + beforeEach(() => { + originalWorker = globals.Worker; + globals.Worker = FakeWorker; + vi.spyOn(console, 'warn').mockImplementation(() => undefined); // the service logs worker errors + service = new DataProcessorService(); + if (!FakeWorker.last) { + throw new Error('FakeWorker was not constructed'); + } + worker = FakeWorker.last; + }); + + afterEach(() => { + globals.Worker = originalWorker; + FakeWorker.last = null; + }); + + function startTwoRequests(): [Promise, Promise, unknown, unknown] { + const a = service.processWithConfig('a.csv', {}); + const b = service.processWithConfig('b.csv', {}); + expect(worker.posted.length).toBe(2); + const [idA, idB] = worker.posted.map(m => m.requestId); + expect(idA).toBeDefined(); + expect(idA).not.toEqual(idB); + return [a, b, idA, idB]; + } + + it('resolves two concurrent requests with their own replies', async () => { + const [a, b, idA, idB] = startTwoRequests(); + + worker.reply({ type: 'processed', dataset: datasetB, requestId: idB }); + worker.reply({ type: 'processed', dataset: datasetA, requestId: idA }); + + expect(await a).toBe(datasetA); + expect(await b).toBe(datasetB); + }); + + it('rejects only the request an error reply belongs to', async () => { + const [a, b, idA, idB] = startTwoRequests(); + + worker.reply({ type: 'error', message: 'boom', requestId: idA }); + await expect(a).rejects.toThrowError('boom'); + + worker.reply({ type: 'processed', dataset: datasetB, requestId: idB }); + expect(await b).toBe(datasetB); + }); + + describe('pruneFiles', () => { + /** Resolves to true once `promise` has settled, false while it is still pending. */ + async function isSettled(promise: Promise): Promise { + let settled = false; + promise.then( + () => (settled = true), + () => (settled = true) + ); + await Promise.resolve(); + await Promise.resolve(); + return settled; + } + + it('sends the keep list to the worker and resolves on its filesPruned reply', async () => { + const pruned = service.pruneFiles(['current.csv', 'undo.csv']); + + expect(worker.posted.length).toBe(1); + const [request] = worker.posted; + expect(request.type).toBe('pruneFiles'); + expect(request['keep']).toEqual(['current.csv', 'undo.csv']); + + worker.reply({ type: 'filesPruned', removed: ['old.csv'], requestId: request.requestId }); + await expect(pruned).resolves.toEqual(undefined); + }); + + it('ignores a filesPruned reply that belongs to another request', async () => { + const pruned = service.pruneFiles([]); + const [request] = worker.posted; + + worker.reply({ type: 'filesPruned', removed: [], requestId: -1 }); + expect(await isSettled(pruned)).toBe(false); + + worker.reply({ type: 'filesPruned', removed: [], requestId: request.requestId }); + await expect(pruned).resolves.not.toThrow(); + }); + + it('rejects when the worker reports an error for the prune request', async () => { + const pruned = service.pruneFiles(['a.csv']); + const [request] = worker.posted; + + worker.reply({ type: 'error', message: 'unlink failed', requestId: request.requestId }); + await expect(pruned).rejects.toThrowError('unlink failed'); + }); + }); +}); diff --git a/src/app/services/data-processor.ts b/src/app/services/data-processor.ts index 49ee8a6..d671e4b 100644 --- a/src/app/services/data-processor.ts +++ b/src/app/services/data-processor.ts @@ -3,6 +3,12 @@ import { DatasetCollection } from '../shared/interfaces/dataset-collection'; import { BehaviorSubject, Observable, Subject, of } from 'rxjs'; import { WorkerReply } from '../shared/interfaces/pyodide-messages'; +/** + * A worker reply as received on the main thread. Replies to a request sent via + * sendRequestUntil() echo that request's `requestId` (see pyodide.worker.ts). + */ +type TaggedWorkerReply = WorkerReply & { requestId?: number }; + @Injectable({ providedIn: 'root' }) export class DataProcessorService { private worker = new Worker(new URL('../workers/pyodide.worker', import.meta.url), { type: 'module' }); @@ -10,7 +16,10 @@ export class DataProcessorService { private thumbCache = new Map(); private thumbSubjects = new Map>(); - private message$ = new Subject(); + private message$ = new Subject(); + // Monotonic id attached to every request so its reply can be matched even when + // several requests expecting the same reply type are in flight. + private nextRequestId = 0; private processingProgress$ = new Subject<{ step: string; progress: number; message: string }>(); constructor() { @@ -44,15 +53,6 @@ export class DataProcessorService { return result.folder; } - async process(file: File): Promise { - const result = await this.sendRequestUntil('processed', { - type: 'process', - fileName: file.name, - buffer: await file.arrayBuffer(), - }); - return result.dataset; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- returns dynamically typed JSON from the worker filesystem async fetchJson(file: string): Promise { const result = await this.sendRequestUntil( @@ -142,6 +142,18 @@ export class DataProcessorService { return result.data; } + /** + * Delete uploaded input files (and their derived result files) from the + * worker's virtual FS, except those listed in `keep`. The caller passes every + * file name still referenced by the current state and the undo/redo history. + */ + async pruneFiles(keep: string[]): Promise { + await this.sendRequestUntil('filesPruned', { + type: 'pruneFiles', + keep, + }); + } + /** * Observable for processing progress updates */ @@ -197,7 +209,11 @@ export class DataProcessorService { } /** - * Utility to wait for a specific message type (optionally filtered) + * Send a request and wait for its reply of the given type (optionally filtered). + * Replies are matched by a per-request id that the worker echoes back, so + * concurrent requests of the same reply type (e.g. two processWithConfig runs, + * which both answer with 'processed') never resolve or reject with each other's + * replies. */ private sendRequestUntil( type: T['type'], @@ -206,6 +222,7 @@ export class DataProcessorService { matchFn: (msg: T) => boolean = () => true, timeoutMs = 60000 ): Promise { + const requestId = ++this.nextRequestId; return new Promise((resolve, reject) => { const timer = setTimeout(() => { sub.unsubscribe(); @@ -213,6 +230,9 @@ export class DataProcessorService { }, timeoutMs); const sub = this.message$.subscribe(msg => { + if (msg.requestId !== requestId) { + return; + } if (msg.type === 'error') { clearTimeout(timer); sub.unsubscribe(); @@ -227,7 +247,7 @@ export class DataProcessorService { } }); - this.worker.postMessage(message); + this.worker.postMessage({ ...message, requestId }); }); } } diff --git a/src/app/services/dataset-collection.service.spec.ts b/src/app/services/dataset-collection.service.spec.ts index d9d41ab..49d56f5 100644 --- a/src/app/services/dataset-collection.service.spec.ts +++ b/src/app/services/dataset-collection.service.spec.ts @@ -93,16 +93,19 @@ describe('DatasetCollectionService', () => { expect(positions).toContain('tsne'); }); - it('should emit on observable when collection changes', (done: DoneFn) => { + it('should emit on observable when collection changes', async () => { let emitCount = 0; - service.dataSetCollectionSubject$.subscribe(() => { - emitCount++; - if (emitCount === 2) { - // Initial + one setDatasetCollection - done(); - } - }); + const secondEmission = new Promise(resolve => + service.dataSetCollectionSubject$.subscribe(() => { + emitCount++; + if (emitCount === 2) { + // Initial + one setDatasetCollection + resolve(); + } + }) + ); service.setDatasetCollection([makeEntry('new-ds', 't1')]); + await secondEmission; }); }); diff --git a/src/app/services/filter.service.spec.ts b/src/app/services/filter.service.spec.ts index 62ecb26..588d394 100644 --- a/src/app/services/filter.service.spec.ts +++ b/src/app/services/filter.service.spec.ts @@ -1,3 +1,4 @@ +import { firstValueFrom } from 'rxjs'; import { FilterService } from './filter.service'; import { GlyphObject } from '../glyph/glyph-object'; import { FilterMode } from '../shared/enum/filter-mode'; @@ -50,15 +51,14 @@ describe('FilterService', () => { expect(service.getFilters().length).toBe(0); }); - it('should emit filterChanged$ when refreshFilters is called', (done: DoneFn) => { + it('should emit filterChanged$ when refreshFilters is called', async () => { const glyphMap = createGlyphMap(['1', '2', '3']); service.setActiveGlyphData(glyphMap); - service.filterChanged$.subscribe(() => { - done(); - }); + const emitted = firstValueFrom(service.filterChanged$); service.refreshFilters(); + await emitted; }); it('should mark no items as passive when no filters exist', () => { @@ -67,7 +67,7 @@ describe('FilterService', () => { service.refreshFilters(); glyphMap.forEach(glyph => { - expect(glyph.passive).toBeFalse(); + expect(glyph.passive).toBe(false); }); expect(service.filteredItems).toBe(3); }); @@ -84,9 +84,9 @@ describe('FilterService', () => { service.ensureFilter(filter); service.refreshFilters(); - expect(glyphMap.get('1')?.passive).toBeFalse(); - expect(glyphMap.get('2')?.passive).toBeFalse(); - expect(glyphMap.get('3')?.passive).toBeTrue(); + expect(glyphMap.get('1')?.passive).toBe(false); + expect(glyphMap.get('2')?.passive).toBe(false); + expect(glyphMap.get('3')?.passive).toBe(true); expect(service.filteredItems).toBe(2); }); @@ -101,9 +101,9 @@ describe('FilterService', () => { service.ensureFilter(filter); service.refreshFilters(); - expect(glyphMap.get('1')?.passive).toBeFalse(); - expect(glyphMap.get('2')?.passive).toBeTrue(); - expect(glyphMap.get('3')?.passive).toBeTrue(); + expect(glyphMap.get('1')?.passive).toBe(false); + expect(glyphMap.get('2')?.passive).toBe(true); + expect(glyphMap.get('3')?.passive).toBe(true); expect(service.filteredItems).toBe(1); }); @@ -128,7 +128,7 @@ describe('FilterService', () => { // After clearing, all items should be active glyphMap.forEach(glyph => { - expect(glyph.passive).toBeFalse(); + expect(glyph.passive).toBe(false); }); }); @@ -142,7 +142,7 @@ describe('FilterService', () => { service.refreshFilters(); glyphMap.forEach(glyph => { - expect(glyph.passive).toBeFalse(); + expect(glyph.passive).toBe(false); }); expect(service.filteredItems).toBe(2); }); diff --git a/src/app/services/projection.service.spec.ts b/src/app/services/projection.service.spec.ts new file mode 100644 index 0000000..f7a520e --- /dev/null +++ b/src/app/services/projection.service.spec.ts @@ -0,0 +1,303 @@ +import { TestBed } from '@angular/core/testing'; +import { ProjectionCancelledError, ProjectionService } from './projection.service'; +import { ProjectionWorkerRequest, ProjectionWorkerResponse } from '../shared/types/projection.types'; + +/** Minimal in-memory stand-in for a projection worker. */ +class FakeWorker { + static instances: FakeWorker[] = []; + onmessage: ((ev: MessageEvent) => void) | null = null; + onerror: ((ev: ErrorEvent) => void) | null = null; + readonly posted: ProjectionWorkerRequest[] = []; + terminated = false; + + constructor() { + FakeWorker.instances.push(this); + } + + postMessage(message: ProjectionWorkerRequest): void { + this.posted.push(message); + } + + terminate(): void { + this.terminated = true; + } + + /** Deliver a message from the "worker" to the main thread. */ + emit(data: ProjectionWorkerResponse): void { + this.onmessage?.({ data } as MessageEvent); + } + + /** Raise an uncaught worker error. */ + crash(message: string): void { + this.onerror?.({ message } as ErrorEvent); + } +} + +describe('ProjectionService – background projections', () => { + const globals = globalThis as unknown as { Worker: unknown }; + let originalWorker: unknown; + let service: ProjectionService; + + const features = [ + [1, 2], + [3, 4], + ]; + const ids = ['a', 'b']; + const positions = [ + { id: 'a', x: 0, y: 1 }, + { id: 'b', x: 2, y: 3 }, + ]; + + beforeEach(() => { + originalWorker = globals.Worker; + globals.Worker = FakeWorker; + FakeWorker.instances = []; + TestBed.configureTestingModule({}); + service = TestBed.inject(ProjectionService); + }); + + afterEach(() => { + globals.Worker = originalWorker; + FakeWorker.instances = []; + }); + + function lastWorker(): FakeWorker { + const worker = FakeWorker.instances[FakeWorker.instances.length - 1]; + if (!worker) { + throw new Error('No worker was started'); + } + return worker; + } + + function statusOf(method: string): string | undefined { + return service.getBackgroundStatus(method)?.status; + } + + function startUmap(): Promise { + return service.runUMAP(features, ids, { neighbors: 15, minDist: 0.1 }); + } + + it('computes in a worker, reports progress and resolves with the result', async () => { + const run = service.runMDS(features, ids); + const worker = lastWorker(); + expect(worker.posted[0]).toEqual(expect.objectContaining({ type: 'compute', method: 'mds', features, ids })); + expect(statusOf('mds')).toBe('running'); + + worker.emit({ type: 'progress', progress: 40, message: 'Iterating' }); + expect(service.getBackgroundStatus('mds')?.progress).toBe(40); + + worker.emit({ type: 'result', positions, computeTime: 12 }); + expect(await run).toEqual({ method: 'mds', positions, computeTime: 12 }); + expect(statusOf('mds')).toBe('complete'); + expect(worker.terminated).toBe(true); + expect(service.isRunning('mds')).toBe(false); + expect(service.canRetry('mds')).toBe(false); + }); + + describe('cancel', () => { + it('terminates the worker, rejects the run and marks the method cancelled', async () => { + const run = startUmap(); + const worker = lastWorker(); + + expect(service.cancel('umap')).toBe(true); + + await expect(run).rejects.toEqual(expect.any(ProjectionCancelledError)); + expect(worker.terminated).toBe(true); + expect(statusOf('umap')).toBe('cancelled'); + expect(service.isRunning('umap')).toBe(false); + expect(service.canRetry('umap')).toBe(true); + }); + + it('returns false and changes nothing for a method that is not running', async () => { + const run = service.runMDS(features, ids); + lastWorker().emit({ type: 'result', positions, computeTime: 1 }); + await run; + + expect(service.cancel('mds')).toBe(false); + expect(service.cancel('tsne')).toBe(false); + expect(statusOf('mds')).toBe('complete'); + expect(service.getBackgroundStatus('tsne')).toBeUndefined(); + }); + + it('ignores late messages from a cancelled worker', async () => { + const run = startUmap(); + const worker = lastWorker(); + service.cancel('umap'); + await expect(run).rejects.toThrow(); + + worker.emit({ type: 'progress', progress: 90 }); + worker.emit({ type: 'result', positions, computeTime: 5 }); + + expect(statusOf('umap')).toBe('cancelled'); + expect(service.canRetry('umap')).toBe(true); + }); + + it('leaves the other running methods alone', () => { + startUmap().catch(() => undefined); + const tsne = service.runTSNE(features, ids, { perplexity: 30, iterations: 100 }); + + service.cancel('umap'); + + expect(service.isRunning('tsne')).toBe(true); + expect(statusOf('tsne')).toBe('running'); + tsne.catch(() => undefined); + }); + }); + + describe('terminateAllWorkers', () => { + it('cancels every running method and reports which ones were stopped', async () => { + const umap = startUmap(); + const tsne = service.runTSNE(features, ids, { perplexity: 30, iterations: 100 }); + const mds = service.runMDS(features, ids); + FakeWorker.instances[2].emit({ type: 'result', positions, computeTime: 1 }); + await mds; + + const stopped = service.terminateAllWorkers(); + + expect(stopped.sort()).toEqual(['tsne', 'umap']); + expect(FakeWorker.instances.every(w => w.terminated)).toBe(true); + expect(statusOf('umap')).toBe('cancelled'); + expect(statusOf('tsne')).toBe('cancelled'); + expect(statusOf('mds')).toBe('complete'); + await expect(umap).rejects.toEqual(expect.any(ProjectionCancelledError)); + await expect(tsne).rejects.toEqual(expect.any(ProjectionCancelledError)); + }); + + it('returns an empty list when nothing is running', () => { + expect(service.terminateAllWorkers()).toEqual([]); + }); + }); + + describe('retry', () => { + it('re-runs a cancelled method with the same data and settings in a new worker', async () => { + startUmap().catch(() => undefined); + const first = lastWorker(); + service.cancel('umap'); + + const retried = service.retry('umap'); + const second = lastWorker(); + + expect(second).not.toBe(first); + expect(second.posted[0]).toEqual(first.posted[0]); + expect(statusOf('umap')).toBe('running'); + expect(service.canRetry('umap')).toBe(false); + + second.emit({ type: 'result', positions, computeTime: 7 }); + expect(await retried).toEqual({ method: 'umap', positions, computeTime: 7 }); + expect(statusOf('umap')).toBe('complete'); + expect(service.canRetry('umap')).toBe(false); + }); + + it('re-runs a method whose worker reported an error', async () => { + const run = service.runLLE(features, ids, { neighbors: 5 }); + lastWorker().emit({ type: 'error', error: 'singular matrix' }); + await expect(run).rejects.toThrowError('lle failed: singular matrix'); + expect(statusOf('lle')).toBe('error'); + expect(service.canRetry('lle')).toBe(true); + + const retried = service.retry('lle'); + expect(lastWorker().posted[0].config).toEqual({ lleNeighbors: 5 }); + lastWorker().emit({ type: 'result', positions, computeTime: 3 }); + await expect(retried).resolves.not.toThrow(); + }); + + it('re-runs a method whose worker crashed', async () => { + const run = service.runSammon(features, ids); + lastWorker().crash('out of memory'); + await expect(run).rejects.toThrowError('sammon worker error: out of memory'); + expect(statusOf('sammon')).toBe('error'); + + expect(service.canRetry('sammon')).toBe(true); + service.retry('sammon').catch(() => undefined); + expect(statusOf('sammon')).toBe('running'); + }); + + it('rejects when there is no previous run of the method', async () => { + await expect(service.retry('tsne')).rejects.toThrowError('There is no previous tsne run to retry'); + }); + + it('forgets retryable runs when the statuses are cleared or the jobs are detached', () => { + startUmap().catch(() => undefined); + service.runMDS(features, ids).catch(() => undefined); + service.terminateAllWorkers(); + + service.detachAll(); + expect(service.canRetry('umap')).toBe(false); + + service.runTopoMap(features, ids).catch(() => undefined); + service.cancel('topomap'); + service.clearBackgroundStatuses(); + expect(service.canRetry('topomap')).toBe(false); + expect(service.getBackgroundStatus('topomap')).toBeUndefined(); + }); + }); + + describe('runs of an earlier dataset', () => { + it('keeps an earlier run of the same method computing; only the newer one is controllable', async () => { + const earlier = startUmap(); + const earlierWorker = lastWorker(); + const newer = startUmap(); + const newerWorker = lastWorker(); + expect(earlierWorker.terminated).toBe(false); + + expect(service.cancel('umap')).toBe(true); + await expect(newer).rejects.toEqual(expect.any(ProjectionCancelledError)); + expect(newerWorker.terminated).toBe(true); + + earlierWorker.emit({ type: 'result', positions, computeTime: 4 }); + expect(await earlier).toEqual({ method: 'umap', positions, computeTime: 4 }); + expect(earlierWorker.terminated).toBe(true); + // The newer run was cancelled, so it stays retryable. + expect(service.canRetry('umap')).toBe(true); + }); + + it('lets detached runs finish and report, but no longer cancels or retries them', async () => { + const run = startUmap(); + const worker = lastWorker(); + + service.detachAll(); + + expect(service.isRunning('umap')).toBe(false); + expect(service.cancel('umap')).toBe(false); + expect(service.terminateAllWorkers()).toEqual([]); + expect(worker.terminated).toBe(false); + + worker.emit({ type: 'progress', progress: 70 }); + expect(service.getBackgroundStatus('umap')?.progress).toBe(70); + + worker.emit({ type: 'result', positions, computeTime: 9 }); + expect(await run).toEqual({ method: 'umap', positions, computeTime: 9 }); + expect(statusOf('umap')).toBe('complete'); + expect(service.canRetry('umap')).toBe(false); + }); + }); +}); + +describe('ProjectionService – primary FastMap on the main thread', () => { + let service: ProjectionService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ProjectionService); + }); + + it('loads DruidJS on demand and projects every row to finite 2-D coordinates', async () => { + const features = Array.from({ length: 40 }, (_, i) => [Math.sin(i), Math.cos(i / 3), (i % 7) / 7, i / 40]); + const ids = features.map((_, i) => `row-${i}`); + const workerSpy = vi.spyOn(globalThis, 'Worker'); + + const result = await service.runFastMapSync(features, ids); + + expect(result.method).toBe('fastmap'); + expect(result.positions.map(p => p.id)).toEqual(ids); + expect(result.positions.every(p => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); + expect(Number.isInteger(result.computeTime)).toBe(true); + expect(workerSpy).not.toHaveBeenCalled(); + }); + + it('reports a DruidJS failure as a FastMap error', async () => { + const ragged = [[1, 2], [3]]; + + await expect(service.runFastMapSync(ragged, ['a', 'b'])).rejects.toThrowError(/^FastMap failed: /); + }); +}); diff --git a/src/app/services/projection.service.ts b/src/app/services/projection.service.ts index ce8ca7a..7d5d994 100644 --- a/src/app/services/projection.service.ts +++ b/src/app/services/projection.service.ts @@ -1,124 +1,102 @@ import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import * as druid from '@saehrimnir/druidjs'; +import { DruidModule, druidSpecFor, runDruidProjection } from '../workers/druid-runner'; import { + ProjectionComputeConfig, ProjectionMethod, ProjectionResult, ProjectionWorkerRequest, ProjectionWorkerResponse, } from '../shared/types/projection.types'; -// Re-export types for backward compatibility -export type { - ProjectionMethod, - ProjectionResult, - ProjectionComputeConfig, - ProjectionWorkerRequest, - ProjectionWorkerResponse, -} from '../shared/types/projection.types'; +// Re-exported for consumers that import it alongside ProjectionService +export type { ProjectionResult } from '../shared/types/projection.types'; export interface BackgroundStatus { method: string; - status: 'pending' | 'running' | 'complete' | 'error'; + status: 'pending' | 'running' | 'complete' | 'error' | 'cancelled'; progress: number; message: string; error?: string; } +/** Rejection reason of a background projection that was stopped on purpose (cancel / terminateAllWorkers). */ +export class ProjectionCancelledError extends Error { + constructor(readonly method: string) { + super(`${method} was cancelled`); + this.name = 'ProjectionCancelledError'; + } +} + +/** Everything needed to start, or re-start, one background projection. */ +interface ProjectionRequest { + method: ProjectionMethod; + features: number[][]; + ids: (string | number)[]; + config?: ProjectionComputeConfig; +} + +/** One background projection computing in its own worker. */ +interface ProjectionJob { + worker: Worker; + request: ProjectionRequest; + reject: (reason: Error) => void; + /** Set once the job finished or was cancelled; later worker messages are ignored. */ + ended: boolean; +} + +/** + * Loads DruidJS on first use. A dynamic import keeps the library (with its + * inlined WASM kernels) out of the initial bundle; only FastMap needs it on the + * main thread, every other method loads it inside its worker. + */ +function loadDruid(): Promise { + return import('@saehrimnir/druidjs'); +} + +/** + * Computes 2D projections. The primary FastMap projection runs synchronously on + * the main thread (runFastMapSync) and cannot be interrupted; every optional + * method runs in its own Web Worker, so it can be cancelled (cancel / + * terminateAllWorkers) and re-run with the same data and settings (retry). + */ @Injectable({ providedIn: 'root', }) export class ProjectionService { private backgroundStatus$ = new BehaviorSubject>(new Map()); - private workers = new Map(); - - constructor(private ngZone: NgZone) {} - /** - * Run PCA (fast, blocking) - runs synchronously on main thread + * The controllable job per method: the latest one started and not yet + * detached. An earlier job of the same method, or a job released by + * detachAll(), keeps computing and reporting on its own. + */ + private jobs = new Map(); + /** + * The latest request per method, kept for retry() until that method completes + * or detachAll() is called. All entries of one run share the same feature + * matrix, so this keeps at most one matrix alive. */ - async runPCA(features: number[][], ids: (string | number)[]): Promise { - const startTime = Date.now(); + private requests = new Map(); - try { - // DruidJS PCA: new druid.PCA(data, components).transform() - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DruidJS type definitions are incomplete - const pca = new (druid as any).PCA(features, 2); // 2 components for 2D visualization - const embedding = pca.transform(); - - const positions = embedding.map((coords: number[], i: number) => ({ - id: ids[i], - x: coords[0], - y: coords[1], - })); - - return { - method: 'pca', - positions, - computeTime: Date.now() - startTime, - }; - } catch (error: unknown) { - throw new Error(`PCA failed: ${error instanceof Error ? error.message : String(error)}`); - } - } + constructor(private ngZone: NgZone) {} /** * Run FastMap (blocking) - PRIMARY projection for dashboard * Fast distance-preserving projection with O(n) complexity * Ideal for large datasets (40K+ items) + * + * DruidJS is loaded on the first call (see loadDruid), then FastMap runs + * synchronously on the main thread. computeTime includes that load, in whole ms. */ async runFastMapSync(features: number[][], ids: (string | number)[]): Promise { - const startTime = Date.now(); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DruidJS type definitions are incomplete - const fastmap = new (druid as any).FASTMAP(features, 2); - const embedding = fastmap.transform(); - - const positions = embedding.map((coords: number[], i: number) => ({ - id: ids[i], - x: coords[0], - y: coords[1], - })); - - return { - method: 'fastmap', - positions, - computeTime: Date.now() - startTime, - }; + const { positions, computeTime } = await runDruidProjection(loadDruid, druidSpecFor('fastmap'), features, ids); + return { method: 'fastmap', positions, computeTime: Math.round(computeTime) }; } catch (error: unknown) { throw new Error(`FastMap failed: ${error instanceof Error ? error.message : String(error)}`); } } - /** - * Run IsoMap (blocking) - Non-linear manifold learning - * Preserves geodesic distances - slower than FastMap but better structure preservation - */ - async runIsoMapSync(features: number[][], ids: (string | number)[]): Promise { - const startTime = Date.now(); - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DruidJS type definitions are incomplete - const isomap = new (druid as any).ISOMAP(features, 2); - const embedding = isomap.transform(); - - const positions = embedding.map((coords: number[], i: number) => ({ - id: ids[i], - x: coords[0], - y: coords[1], - })); - - return { - method: 'isomap', - positions, - computeTime: Date.now() - startTime, - }; - } catch (error: unknown) { - throw new Error(`IsoMap failed: ${error instanceof Error ? error.message : String(error)}`); - } - } - /** * Run FastMap (background) - runs in Web Worker */ @@ -204,19 +182,10 @@ export class ProjectionService { } /** - * Run TriMap (background) - Good for large datasets + * Run TriMap (background) - Good for large datasets. Uses DruidJS's defaults. */ - async runTriMap( - features: number[][], - ids: (string | number)[], - config?: { weightAdj?: number } - ): Promise { - return this.runProjectionInWorker( - 'trimap', - features, - ids, - config ? { trimapWeightAdj: config.weightAdj } : undefined - ); + async runTriMap(features: number[][], ids: (string | number)[]): Promise { + return this.runProjectionInWorker('trimap', features, ids); } /** @@ -236,77 +205,139 @@ export class ProjectionService { /** * Run a projection in a Web Worker (non-blocking) */ - private async runProjectionInWorker( + private runProjectionInWorker( method: ProjectionMethod, features: number[][], ids: (string | number)[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- config varies per projection method - config?: any + config?: ProjectionComputeConfig ): Promise { + return this.startJob({ method, features, ids, config }); + } + + /** + * Start one background projection in a fresh worker; it becomes the method's + * controllable job. A still-running earlier job of the same method is not + * stopped (it may belong to a dataset already handed to the dashboard): it + * keeps computing, reports its status and settles its own promise. + */ + private startJob(request: ProjectionRequest): Promise { + const { method } = request; + this.requests.set(method, request); + return new Promise((resolve, reject) => { - // Create worker for this projection const worker = new Worker(new URL('../workers/projection.worker', import.meta.url), { type: 'module', }); - - this.workers.set(method, worker); + const job: ProjectionJob = { worker, request, reject, ended: false }; + this.jobs.set(method, job); this.updateBackgroundStatus(method, 'running', 0, `Starting ${method.toUpperCase()}...`); - // Handle messages from worker worker.onmessage = ({ data }: MessageEvent) => { - this.ngZone.run(() => { - if (data.type === 'progress') { - // Update progress status - this.updateBackgroundStatus( - method, - 'running', - data.progress || 0, - data.message || `Computing ${method}...` - ); - } else if (data.type === 'result') { - // Computation complete - this.updateBackgroundStatus(method, 'complete', 100, `${method} complete`); - worker.terminate(); - this.workers.delete(method); - resolve({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ProjectionMethod type narrowing not available here - method: method as any, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed present when data.type === 'result' - positions: data.positions!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed present when data.type === 'result' - computeTime: data.computeTime!, - }); - } else if (data.type === 'error') { - // Error occurred - this.updateBackgroundStatus(method, 'error', 0, `${method} failed`, data.error); - worker.terminate(); - this.workers.delete(method); - reject(new Error(`${method} failed: ${data.error}`)); - } - }); + this.ngZone.run(() => this.handleWorkerMessage(job, data, resolve)); }; - - worker.onerror = error => { - this.ngZone.run(() => { - this.updateBackgroundStatus(method, 'error', 0, `${method} worker error`, error.message); - worker.terminate(); - this.workers.delete(method); - reject(new Error(`${method} worker error: ${error.message}`)); - }); + worker.onerror = (event: ErrorEvent) => { + this.ngZone.run(() => this.failJob(job, `${method} worker error`, event.message)); }; - // Send computation request to worker - const request: ProjectionWorkerRequest = { + const message: ProjectionWorkerRequest = { type: 'compute', method, - features, - ids, - config, + features: request.features, + ids: request.ids, + config: request.config, }; - worker.postMessage(request); + worker.postMessage(message); }); } + private handleWorkerMessage( + job: ProjectionJob, + data: ProjectionWorkerResponse, + resolve: (result: ProjectionResult) => void + ): void { + const { method } = job.request; + if (job.ended) { + return; // a late message from a cancelled worker + } + if (data.type === 'progress') { + this.updateBackgroundStatus(method, 'running', data.progress || 0, data.message || `Computing ${method}...`); + } else if (data.type === 'result') { + this.endJob(job); + if (this.requests.get(method) === job.request) { + this.requests.delete(method); + } + this.updateBackgroundStatus(method, 'complete', 100, `${method} complete`); + resolve({ method, positions: data.positions ?? [], computeTime: data.computeTime ?? 0 }); + } else if (data.type === 'error') { + this.failJob(job, `${method} failed`, data.error ?? 'Unknown error'); + } + } + + /** The worker reported an error: end the job, mark the method failed (retryable) and reject. */ + private failJob(job: ProjectionJob, summary: string, detail: string): void { + if (job.ended) { + return; + } + this.endJob(job); + this.updateBackgroundStatus(job.request.method, 'error', 0, summary, detail); + job.reject(new Error(`${summary}: ${detail}`)); + } + + /** Terminate the job's worker and, if it is still the method's controllable job, forget it. */ + private endJob(job: ProjectionJob): void { + job.ended = true; + job.worker.terminate(); + if (this.jobs.get(job.request.method) === job) { + this.jobs.delete(job.request.method); + } + } + + /** + * Cancel the running background projection of `method`: terminate its worker, + * reject its pending promise with ProjectionCancelledError and mark its status + * 'cancelled'. The request is kept, so retry() can run it again. Returns false + * when the method has no controllable running job. + */ + cancel(method: string): boolean { + const job = this.jobs.get(method); + if (!job) { + return false; + } + this.endJob(job); + job.reject(new ProjectionCancelledError(method)); + this.updateBackgroundStatus(method, 'cancelled', 0, 'Cancelled'); + return true; + } + + /** True while a controllable background projection of `method` is computing. */ + isRunning(method: string): boolean { + return this.jobs.has(method); + } + + /** True when retry() can re-run `method`: its last run was cancelled or failed. */ + canRetry(method: string): boolean { + return !this.jobs.has(method) && this.requests.has(method); + } + + /** Re-run the last (cancelled or failed) request of `method` with the same data and settings. */ + retry(method: string): Promise { + const request = this.requests.get(method); + if (!request) { + return Promise.reject(new Error(`There is no previous ${method} run to retry`)); + } + return this.startJob(request); + } + + /** + * Hand every running projection over, e.g. once the wizard passes its dataset + * to the dashboard or starts on a new one: the jobs keep computing and + * reporting their status, but can no longer be cancelled or retried. + */ + detachAll(): void { + this.jobs.clear(); + this.requests.clear(); + } + /** * Parse CSV features exported from Python * Format: ID,feature1,feature2,... @@ -399,19 +430,18 @@ export class ProjectionService { } /** - * Clear all background statuses + * Clear all background statuses (and the requests kept for retry) */ clearBackgroundStatuses(): void { + this.requests.clear(); this.backgroundStatus$.next(new Map()); } /** - * Terminate all running workers + * Cancel every running background projection (see cancel()); each is marked + * 'cancelled'. Returns the methods that were stopped. */ - terminateAllWorkers(): void { - this.workers.forEach((worker, _method) => { - worker.terminate(); - }); - this.workers.clear(); + terminateAllWorkers(): string[] { + return Array.from(this.jobs.keys()).filter(method => this.cancel(method)); } } diff --git a/src/app/services/toast.service.spec.ts b/src/app/services/toast.service.spec.ts index 2825547..ecbcfb8 100644 --- a/src/app/services/toast.service.spec.ts +++ b/src/app/services/toast.service.spec.ts @@ -1,4 +1,5 @@ -import { ToastService, Toast } from './toast.service'; +import { firstValueFrom } from 'rxjs'; +import { ToastService, Toast, MIN_ACTION_TOAST_MS } from './toast.service'; describe('ToastService', () => { let service: ToastService; @@ -7,11 +8,9 @@ describe('ToastService', () => { service = new ToastService(); }); - it('should start with no toasts', (done: DoneFn) => { - service.toasts$.subscribe(toasts => { - expect(toasts.length).toBe(0); - done(); - }); + it('should start with no toasts', async () => { + const toasts = await firstValueFrom(service.toasts$); + expect(toasts.length).toBe(0); }); it('should add a toast via show()', () => { @@ -24,30 +23,18 @@ describe('ToastService', () => { expect(latest[0].type).toBe('info'); }); - it('should add success toast', () => { - service.success('Done', 0); - let latest: Toast[] = []; - service.toasts$.subscribe(t => (latest = t)); - - expect(latest.length).toBe(1); - expect(latest[0].type).toBe('success'); - }); - - it('should add error toast', () => { - service.error('Failed', 0); + it.each([ + ['success', 'Done'], + ['error', 'Failed'], + ['warning', 'Careful'], + ] as const)('should add %s toast', (type, message) => { + service[type](message, 0); let latest: Toast[] = []; service.toasts$.subscribe(t => (latest = t)); expect(latest.length).toBe(1); - expect(latest[0].type).toBe('error'); - }); - - it('should add warning toast', () => { - service.warning('Careful', 0); - let latest: Toast[] = []; - service.toasts$.subscribe(t => (latest = t)); - - expect(latest[0].type).toBe('warning'); + expect(latest[0].type).toBe(type); + expect(latest[0].message).toBe(message); }); it('should assign unique IDs', () => { @@ -82,16 +69,133 @@ describe('ToastService', () => { expect(latest.length).toBe(0); }); - it('should auto-remove toast after duration', (done: DoneFn) => { - service.show('Temp', 'info', 50); // 50ms duration - let latest: Toast[] = []; - service.toasts$.subscribe(t => (latest = t)); + describe('showUndo', () => { + function latestToasts(): Toast[] { + let latest: Toast[] = []; + service.toasts$.subscribe(t => (latest = t)).unsubscribe(); + return latest; + } + + it('adds an info toast with an English "Undo" action', () => { + service.showUndo('File replaced', () => true, 0); + const [toast] = latestToasts(); + expect(toast.message).toBe('File replaced'); + expect(toast.type).toBe('info'); + expect(toast.action?.label).toBe('Undo'); + }); - expect(latest.length).toBe(1); + it('runs the undo handler and adds nothing else when the undo succeeded', () => { + const handler = vi.fn().mockReturnValue(true); + service.showUndo('File replaced', handler, 0); + + latestToasts()[0].action?.handler(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(latestToasts().length).toBe(1); + }); + + it('explains why when the action can no longer be undone', () => { + service.showUndo('File replaced', () => false, 0); + + latestToasts()[0].action?.handler(); + + const toasts = latestToasts(); + expect(toasts.length).toBe(2); + expect(toasts[1].type).toBe('info'); + expect(toasts[1].message).toBe("Can't undo — other changes were made since."); + }); + }); + + it('should auto-remove toast after duration', () => { + vi.useFakeTimers(); + try { + service.show('Temp', 'info', 50); // 50ms duration + let latest: Toast[] = []; + service.toasts$.subscribe(t => (latest = t)); - setTimeout(() => { + expect(latest.length).toBe(1); + + vi.advanceTimersByTime(100); expect(latest.length).toBe(0); - done(); - }, 100); + } finally { + vi.useRealTimers(); + } + }); + + describe('action toasts and pause/resume', () => { + function latestToasts(): Toast[] { + let latest: Toast[] = []; + service.toasts$.subscribe(t => (latest = t)).unsubscribe(); + return latest; + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 1)); + }); + + afterEach(() => vi.useRealTimers()); + + it('keeps an undo toast for at least 10 s by default', () => { + service.showUndo('File replaced', () => true); + + expect(latestToasts()[0].duration).toBe(MIN_ACTION_TOAST_MS); + vi.advanceTimersByTime(MIN_ACTION_TOAST_MS - 1); + expect(latestToasts().length).toBe(1); + vi.advanceTimersByTime(1); + expect(latestToasts().length).toBe(0); + }); + + it('extends a too-short duration when the toast has an action', () => { + service.show('Saved', 'info', 2000, { label: 'Undo', handler: () => undefined }); + + expect(latestToasts()[0].duration).toBe(MIN_ACTION_TOAST_MS); + }); + + it('keeps a sticky (duration 0) action toast sticky', () => { + service.show('Saved', 'info', 0, { label: 'Undo', handler: () => undefined }); + + vi.advanceTimersByTime(60000); + expect(latestToasts().length).toBe(1); + }); + + it('does not auto-dismiss while paused and resumes with the remaining time', () => { + service.show('Temp', 'info', 1000); + const [toast] = latestToasts(); + + vi.advanceTimersByTime(400); + service.pause(toast.id); + vi.advanceTimersByTime(5000); + expect(latestToasts().length).toBe(1); + + service.resume(toast.id); + vi.advanceTimersByTime(599); + expect(latestToasts().length).toBe(1); + vi.advanceTimersByTime(1); + expect(latestToasts().length).toBe(0); + }); + + it('ignores pause/resume for unknown or already removed toasts', () => { + service.show('Temp', 'info', 1000); + const [toast] = latestToasts(); + service.remove(toast.id); + + expect(() => { + service.pause(toast.id); + service.resume(toast.id); + service.pause(999); + }).not.toThrow(); + vi.advanceTimersByTime(2000); + expect(latestToasts().length).toBe(0); + }); + + it('stops pending timers on clear()', () => { + service.show('A', 'info', 1000); + service.clear(); + service.show('B', 'info', 0); + + vi.advanceTimersByTime(2000); + expect(latestToasts().map(t => t.message)).toEqual(['B']); + }); }); }); diff --git a/src/app/services/toast.service.ts b/src/app/services/toast.service.ts index 66ef54e..bc1e553 100644 --- a/src/app/services/toast.service.ts +++ b/src/app/services/toast.service.ts @@ -11,7 +11,26 @@ export interface Toast { message: string; type: 'success' | 'error' | 'info' | 'warning'; duration?: number; - action?: ToastAction; // optional inline action button (e.g. "Rückgängig") + action?: ToastAction; // optional inline action button (e.g. "Undo") +} + +/** Shown when a toast's "Undo" can no longer revert the action it refers to. */ +export const UNDO_UNAVAILABLE_MESSAGE = "Can't undo — other changes were made since."; + +/** Default auto-dismiss delay of a plain toast. */ +export const DEFAULT_TOAST_MS = 5000; + +/** + * Minimum lifetime of a toast that offers an action (WCAG 2.2.1): the user + * needs time to notice, read and reach the button before it disappears. + */ +export const MIN_ACTION_TOAST_MS = 10000; + +/** Auto-dismiss bookkeeping of one toast; `handle` is null while paused. */ +interface DismissTimer { + handle: ReturnType | null; + remaining: number; + startedAt: number; } @Injectable({ @@ -21,33 +40,42 @@ export class ToastService { private toastsSubject = new BehaviorSubject([]); public toasts$ = this.toastsSubject.asObservable(); private nextId = 0; + private timers = new Map(); - show(message: string, type: Toast['type'] = 'info', duration = 5000, action?: ToastAction): void { + show(message: string, type: Toast['type'] = 'info', duration = DEFAULT_TOAST_MS, action?: ToastAction): void { + const lifetime = action && duration > 0 ? Math.max(duration, MIN_ACTION_TOAST_MS) : duration; const toast: Toast = { id: this.nextId++, message, type, - duration, + duration: lifetime, action, }; const currentToasts = this.toastsSubject.getValue(); this.toastsSubject.next([...currentToasts, toast]); - // Auto-remove after duration - if (duration > 0) { - setTimeout(() => { - this.remove(toast.id); - }, duration); + if (lifetime > 0) { + this.startTimer(toast.id, lifetime); } } /** - * Confirmation toast for a reversible action, with an inline "Rückgängig" button. - * Defaults to a 6s window before auto-dismiss. + * Confirmation toast for a reversible action, with an inline "Undo" button. + * `undoHandler` must revert exactly the action the toast refers to and return + * false when that is no longer possible (e.g. other changes were made since); + * the user then gets an explanatory info toast instead of a silent no-op. + * Stays at least MIN_ACTION_TOAST_MS before auto-dismiss. */ - showUndo(message: string, undoHandler: () => void, duration = 6000): void { - this.show(message, 'info', duration, { label: 'Rückgängig', handler: undoHandler }); + showUndo(message: string, undoHandler: () => boolean, duration = MIN_ACTION_TOAST_MS): void { + this.show(message, 'info', duration, { + label: 'Undo', + handler: () => { + if (!undoHandler()) { + this.info(UNDO_UNAVAILABLE_MESSAGE); + } + }, + }); } success(message: string, duration?: number): void { @@ -66,12 +94,43 @@ export class ToastService { this.show(message, 'warning', duration); } + /** Stops a toast's auto-dismiss countdown (pointer or focus is on it). */ + pause(id: number): void { + const timer = this.timers.get(id); + if (!timer || timer.handle === null) return; + clearTimeout(timer.handle); + const elapsed = Date.now() - timer.startedAt; + this.timers.set(id, { handle: null, remaining: Math.max(0, timer.remaining - elapsed), startedAt: 0 }); + } + + /** Continues a paused countdown with the time that was left. */ + resume(id: number): void { + const timer = this.timers.get(id); + if (!timer || timer.handle !== null) return; + this.startTimer(id, timer.remaining); + } + remove(id: number): void { + this.stopTimer(id); const currentToasts = this.toastsSubject.getValue(); this.toastsSubject.next(currentToasts.filter(toast => toast.id !== id)); } clear(): void { + [...this.timers.keys()].forEach(id => this.stopTimer(id)); this.toastsSubject.next([]); } + + private startTimer(id: number, delay: number): void { + const handle = setTimeout(() => this.remove(id), delay); + this.timers.set(id, { handle, remaining: delay, startedAt: Date.now() }); + } + + private stopTimer(id: number): void { + const handle = this.timers.get(id)?.handle; + if (handle !== undefined && handle !== null) { + clearTimeout(handle); + } + this.timers.delete(id); + } } diff --git a/src/app/shared/components/color-scale-selector/color-scale-selector.component.html b/src/app/shared/components/color-scale-selector/color-scale-selector.component.html index 7ab743f..83fb46b 100644 --- a/src/app/shared/components/color-scale-selector/color-scale-selector.component.html +++ b/src/app/shared/components/color-scale-selector/color-scale-selector.component.html @@ -1,42 +1,63 @@ -
-
+
+
+ @if (dropdownOpen) { -
+
@for (group of groupedColorScales; track group.group; let gi = $index) { -
{{ group.group }}
- @for (scale of group.scales; track scale.id) { -
- @if (scale.type === 'numeric') { -
- } @else { - @for (c of getCategoricalColors(scale); track c) { -
+
+ + @for (scale of group.scales; track scale.id) { +
+ {{ scale.name }} + @if (scale.type === 'numeric') { + + } @else { + @for (c of getCategoricalColors(scale); track c) { + + } } - } -
- } +
+ } +
@if (gi < groupedColorScales.length - 1) { -
+ } }
diff --git a/src/app/shared/components/color-scale-selector/color-scale-selector.component.scss b/src/app/shared/components/color-scale-selector/color-scale-selector.component.scss index 5e5e595..b021bdd 100644 --- a/src/app/shared/components/color-scale-selector/color-scale-selector.component.scss +++ b/src/app/shared/components/color-scale-selector/color-scale-selector.component.scss @@ -2,4 +2,26 @@ .color-scale-select { @include m.color-scale-select; + + // The trigger is a real button; keep the field look from the mixin. + .selected-scale { + width: 100%; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + @include m.focus-ring; + } + } + + .color-scale-option:focus-visible { + @include m.focus-ring(-2px); + } + + // Scale names for screen readers; sighted users see the color previews. + .visually-hidden { + @include m.visually-hidden; + } } diff --git a/src/app/shared/components/color-scale-selector/color-scale-selector.component.spec.ts b/src/app/shared/components/color-scale-selector/color-scale-selector.component.spec.ts new file mode 100644 index 0000000..91ad993 --- /dev/null +++ b/src/app/shared/components/color-scale-selector/color-scale-selector.component.spec.ts @@ -0,0 +1,142 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ColorScaleSelectorComponent } from './color-scale-selector.component'; +import { COLOR_SCALES, ColorScale, buildGroupedColorScales, getColorScaleById } from '../../interfaces/color-scale'; + +@Component({ + standalone: true, + imports: [ColorScaleSelectorComponent], + template: ` + Color scale + + + `, +}) +class HostComponent { + labelledby: string | null = 'host-scale-label'; + groups = buildGroupedColorScales(); + selected: ColorScale = getColorScaleById(0); + picked: number[] = []; + + pick(id: number): void { + this.picked = [...this.picked, id]; + this.selected = getColorScaleById(id); + } +} + +/** The picker must be nameable and fully keyboard-operable (WCAG 2.1.1, 4.1.2). */ +describe('ColorScaleSelectorComponent – accessibility', () => { + let fixture: ComponentFixture; + let host: HostComponent; + let el: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + el = fixture.nativeElement as HTMLElement; + fixture.detectChanges(); + }); + + const trigger = (): HTMLButtonElement => { + const button = el.querySelector('button.selected-scale'); + if (!button) throw new Error('trigger not rendered'); + return button; + }; + const listbox = (): HTMLElement | null => el.querySelector('[role="listbox"]'); + const options = (): HTMLElement[] => Array.from(el.querySelectorAll('[role="option"]')); + const focusedName = (): string => (document.activeElement?.textContent ?? '').trim(); + + function key(target: Element, name: string): void { + target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true })); + fixture.detectChanges(); + } + + it('is named by the given label plus the current scale', () => { + const ids = (trigger().getAttribute('aria-labelledby') ?? '').split(' '); + + expect(ids[0]).toBe('host-scale-label'); + expect(document.getElementById(ids[1])?.textContent?.trim()).toBe(COLOR_SCALES[0].name); + expect(trigger().hasAttribute('aria-label')).toBe(false); + }); + + it('falls back to "Color scale: " without a label element', () => { + host.labelledby = null; + fixture.detectChanges(); + + expect(trigger().getAttribute('aria-label')).toBe(`Color scale: ${COLOR_SCALES[0].name}`); + expect(trigger().hasAttribute('aria-labelledby')).toBe(false); + }); + + it('exposes a collapsed listbox popup that expands on click', () => { + expect(trigger().getAttribute('aria-haspopup')).toBe('listbox'); + expect(trigger().getAttribute('aria-expanded')).toBe('false'); + expect(listbox()).toBeNull(); + + trigger().click(); + fixture.detectChanges(); + + expect(trigger().getAttribute('aria-expanded')).toBe('true'); + expect(listbox()?.id).toBe(trigger().getAttribute('aria-controls') ?? 'missing'); + }); + + it('groups named options and marks the selected one', () => { + trigger().click(); + fixture.detectChanges(); + + expect(el.querySelectorAll('[role="listbox"] > [role="group"]').length).toBe(host.groups.length); + expect(options().map(o => o.textContent?.trim())).toEqual(host.groups.flatMap(g => g.scales.map(s => s.name))); + const selected = options().filter(o => o.getAttribute('aria-selected') === 'true'); + expect(selected.map(o => o.textContent?.trim())).toEqual([COLOR_SCALES[0].name]); + }); + + it('opens with ArrowDown on the selected scale, moves with the arrow keys and picks with Enter', () => { + key(trigger(), 'ArrowDown'); + expect(focusedName()).toBe(COLOR_SCALES[0].name); + + key(document.activeElement as Element, 'ArrowDown'); + key(document.activeElement as Element, 'ArrowDown'); + expect(focusedName()).toBe(options()[2].textContent?.trim()); + + key(document.activeElement as Element, 'Enter'); + + expect(host.picked).toEqual([host.groups.flatMap(g => g.scales)[2].id]); + expect(listbox()).toBeNull(); + expect(document.activeElement).toBe(trigger()); + }); + + it('supports Home, End and Space', () => { + key(trigger(), 'ArrowDown'); + key(document.activeElement as Element, 'End'); + expect(focusedName()).toBe(options()[options().length - 1].textContent?.trim()); + + key(document.activeElement as Element, 'Home'); + expect(focusedName()).toBe(options()[0].textContent?.trim()); + + key(document.activeElement as Element, ' '); + expect(host.picked).toEqual([host.groups.flatMap(g => g.scales)[0].id]); + }); + + it('closes on Escape without picking and returns focus to the button', () => { + key(trigger(), 'ArrowDown'); + key(document.activeElement as Element, 'Escape'); + + expect(listbox()).toBeNull(); + expect(host.picked).toEqual([]); + expect(document.activeElement).toBe(trigger()); + }); + + it('closes when focus leaves the picker', () => { + key(trigger(), 'ArrowDown'); + + el.querySelector('#host-outside')?.focus(); + fixture.detectChanges(); + + expect(listbox()).toBeNull(); + }); +}); diff --git a/src/app/shared/components/color-scale-selector/color-scale-selector.component.ts b/src/app/shared/components/color-scale-selector/color-scale-selector.component.ts index 9c6071b..02514ef 100644 --- a/src/app/shared/components/color-scale-selector/color-scale-selector.component.ts +++ b/src/app/shared/components/color-scale-selector/color-scale-selector.component.ts @@ -1,6 +1,17 @@ -import { Component, Input, Output, EventEmitter, HostListener } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core'; import { ColorScale, getContinuousGradient, getCategoricalColors } from '../../interfaces/color-scale'; +let nextSelectorId = 0; + +/** + * Color scale picker: a button that opens a listbox of scale previews + * (select-only combobox pattern). Keyboard: Enter, Space, ArrowDown or ArrowUp + * open the list on the selected scale; ArrowUp/ArrowDown/Home/End move between + * scales; Enter or Space picks one; Escape closes; Tab leaves. + * + * A `