From d5d848b65afb88fcca1f0b22e9106eb577e51f30 Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:04:17 +0200 Subject: [PATCH 1/8] refactor(algorithm-panel): move pure helpers to utils with specs Needs and readiness text, availability tooltip text, catalog filtering and group collapsing, tooltip placement, parameter schema enrichment, form building and value normalization, and run naming for cross-validation are plain functions now, each with a spec. The panel calls them; behavior is unchanged. One number-step rule (allowsDecimal) replaces the copy in the template and the copy in the integer validator. --- .../algorithm-panel.component.html | 9 +- .../algorithm-panel.component.ts | 685 ++---------------- .../algorithm-panel/algorithm-panel.models.ts | 78 ++ .../algorithm-availability-text.utils.spec.ts | 48 ++ .../algorithm-availability-text.utils.ts | 98 +++ .../utils/algorithm-catalog.utils.spec.ts | 55 ++ .../utils/algorithm-catalog.utils.ts | 93 +++ .../algorithm-parameter-form.utils.spec.ts | 130 ++++ .../utils/algorithm-parameter-form.utils.ts | 286 ++++++++ .../utils/algorithm-run.utils.spec.ts | 73 ++ .../utils/algorithm-run.utils.ts | 109 +++ .../utils/tooltip-position.utils.spec.ts | 14 + .../utils/tooltip-position.utils.ts | 39 + 13 files changed, 1102 insertions(+), 615 deletions(-) create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index 9ef1e43a..4103a7b2 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -339,14 +339,7 @@
No additional parameters for this algorithm
@if (field.type === 'number') { diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index aaf4f23a..93d90d47 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -2,22 +2,16 @@ import { SessionStorageService } from './../../../services/session-storage.servi import { ChangeDetectionStrategy, Component, inject, signal, computed, effect, untracked, output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; -import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { buildFormControl } from '../../shared/utils/form-control.factory'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { getOutputSchema, prettifyLabel as toHumanLabel } from '../../../core/algorithm-mappers'; -import { formatInputCountRange } from '../../../core/algorithm-input-counts'; -import { - omitEmptyOptionalParameters, - optionBindingValue, - serializeAlgorithmParameterValue, -} from '../../../core/algorithm-parameter.utils'; +import { optionBindingValue } from '../../../core/algorithm-parameter.utils'; import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../models/algorithm-definition.model'; import { experimentTables, PdfExportService } from '../../../services/pdf-export.service'; import { CsvExportService } from '../../../services/csv-export.service'; import { formatFilterExpression } from '../../../core/filter-display.utils'; import { ErrorService } from '../../../services/error.service'; import { AuthService } from '../../../services/auth.service'; -import { AlgorithmNames, VariableTypes } from '../../../core/constants/algorithm.constants'; +import { AlgorithmNames } from '../../../core/constants/algorithm.constants'; import { RuntimeEnvService } from '../../../services/runtime-env.service'; import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service'; import { @@ -35,6 +29,22 @@ import { } from '../../../core/outlier-rules'; import { AlgorithmRoleAssignmentComponent } from './algorithm-role-assignment/algorithm-role-assignment.component'; import { unavailableDatasetsFor, unavailableVariableTooltip } from '../../../core/variable-availability.utils'; +import { availabilityRequirementText, formatAvailabilityMessage } from './utils/algorithm-availability-text.utils'; +import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories, runnableCount } from './utils/algorithm-catalog.utils'; +import { + allowsDecimal, + enrichParameterSchema, + enumOptionsForVariableCode, + isAdvancedField, + buildParameterForm, + multiSelectValues, + nonBlankString, + normalizeParameterValues, + parameterFieldLabel, + parameterFormSignature, +} from './utils/algorithm-parameter-form.utils'; +import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from './utils/algorithm-run.utils'; +import { tooltipPosition } from './utils/tooltip-position.utils'; type AlgorithmStudioSubstep = 'setup' | 'parameters'; @@ -102,122 +112,30 @@ export class AlgorithmPanelComponent { readonly algorithmNeeds = computed(() => { const algorithm = this.selectedAlgorithm(); if (!algorithm) return []; - - const needs: AlgorithmNeedItem[] = []; - const details = this.selectedAvailabilityDetails(); - const detailByRole = new Map(details.map((detail) => [detail.role, detail])); - - for (const role of ['y', 'x'] as const) { - const detail = detailByRole.get(role); - if (detail) { - needs.push({ - key: `needs-${role}`, - met: detail.messages.length === 0, - title: this.needsRequirementText(detail), - messages: [...detail.messages], - }); - } else if (role === 'y' && !('y' in (algorithm.inputdata ?? {}))) { - needs.push({ - key: 'needs-y', - met: true, - title: 'No outcome needed', - messages: this.experimentStudioService.algorithmY().length ? ['Your outcome is ignored'] : [], - }); - } - } - - const unavailable = this.unavailableRunVariablesReason(); - if (unavailable) { - needs.push({ key: 'unavailable-variables', met: false, title: unavailable, messages: [] }); - } - - this.algorithmRunRequirements().forEach((requirement) => { - needs.push({ - key: `run-${requirement.id}`, - met: false, - title: requirement.message, - messages: [], - action: requirement, - }); + return buildAlgorithmNeeds({ + algorithm, + details: this.selectedAvailabilityDetails(), + hasOutcome: this.experimentStudioService.algorithmY().length > 0, + unavailableVariablesReason: this.unavailableRunVariablesReason(), + requirements: this.algorithmRunRequirements(), }); - - return needs; }); - needsRequirementText(detail: AlgorithmAvailabilityDetail): string { - const typeText = this.requirementTypeText(detail.types); + readonly readinessText = computed(() => readinessText({ + algorithm: this.selectedAlgorithm(), + canRun: this.canRun(), + needs: this.algorithmNeeds(), + y: this.experimentStudioService.algorithmY(), + x: this.experimentStudioService.algorithmX(), + })); - if (detail.role === 'y') { - if (detail.minCount <= 0 && !detail.required) { - return 'No outcome needed'; - } - const article = typeText - ? `${/^[aeiou]/i.test(typeText) ? 'an' : 'a'} ${typeText} ` - : ''; - const noun = detail.minCount > 1 ? 'outcomes' : 'outcome'; - return detail.minCount > 1 - ? `Needs at least ${detail.minCount} ${noun}` - : `Needs ${article}${noun}`; - } - - const noun = detail.minCount > 1 ? 'predictors' : 'predictor'; - if (detail.minCount > 0) { - return `At least ${detail.minCount}${typeText ? ` ${typeText}` : ''} ${noun}`; - } - if (detail.maxCount !== null) { - return `Up to ${detail.maxCount} ${noun}`; - } - return 'Predictors optional'; - } - - private requirementTypeText(types: string[] | undefined | null): string { - if (!Array.isArray(types) || types.length === 0) return ''; - const labels = new Set(); - types.forEach((type) => { - const normalized = String(type ?? '').trim().toLowerCase(); - if (!normalized) return; - if (['real', 'int', 'integer', 'numeric', 'numerical'].includes(normalized)) { - labels.add('numerical'); - } else if ( - ['text', 'nominal', 'categorical', 'polynominal', 'ordinal', 'string'].includes(normalized) - ) { - labels.add('categorical'); - } else { - labels.add(normalized); - } - }); - return Array.from(labels).join(' or '); - } - - readonly readinessText = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return 'Pick a method to continue'; - - if (!this.canRun()) { - const reasons = this.algorithmNeeds() - .filter((need) => !need.met) - .flatMap((need) => (need.action ? [need.title] : need.messages)) - .filter((message) => !!message); - if (!reasons.length) return 'Not ready to run yet'; - return `${reasons.length} ${reasons.length === 1 ? 'thing' : 'things'} left — ${reasons.slice(0, 2).join(' · ')}${reasons.length > 2 ? ` +${reasons.length - 2}` : ''}`; - } - - const y = this.experimentStudioService.algorithmY(); - const x = this.experimentStudioService.algorithmX(); - const outcome = y[0] ? ` · y ${y[0].label ?? y[0].name ?? y[0].code}` : ''; - const predictors = ` · ${x.length} ${x.length === 1 ? 'predictor' : 'predictors'}`; - return `Ready · ${algorithm.label}${outcome}${predictors}`; - }); - - categoryRunnableCount(category: { algorithms: AlgorithmConfig[] }): number { - return category.algorithms.filter((algorithm) => !algorithm.isDisabled).length; - } + readonly categoryRunnableCount = runnableCount; readonly selectedAlgorithmAvailabilitySummary = computed(() => { const algorithm = this.selectedAlgorithm(); if (!algorithm || !this.selectedAlgorithmUnavailable()) return ''; const summary = this.experimentStudioService.getAlgorithmAvailability(algorithm.name).summary ?? algorithm.availability?.summary ?? ''; - return this.formatAvailabilityMessage(summary); + return formatAvailabilityMessage(summary); }); readonly algorithmRunRequirements = computed((): AlgorithmRunRequirement[] => { const algorithm = this.selectedAlgorithm(); @@ -338,78 +256,14 @@ export class AlgorithmPanelComponent { }); prettyFieldLabel(field: any): string { - let base = field.label ?? field.key; - if ((field.key === 'groupA' || field.key === 'groupB')) { - const x = this.experimentStudioService.algorithmX(); - const cov = x[0]; - const covName = cov?.label || cov?.name || cov?.code; - if (covName) base = `${base} (${covName})`; - } - return base; - } - - private variableOptionsForRoles(roles: string[]): Array<{ code: string; label: string }> { - const roleList = roles.length ? roles : ['x']; - return roleList.flatMap((role) => { - const selected = - role === 'y' - ? this.experimentStudioService.algorithmY() - : role === 'x' - ? this.experimentStudioService.algorithmX() - : []; - - return selected - .map((item: any) => { - const code = item?.code; - if (code === null || code === undefined || code === '') return null; - return { - code: String(code), - label: String(item?.label ?? item?.name ?? code), - }; - }) - .filter((item): item is { code: string; label: string } => !!item); - }); - } - private enumOptionsForRole(role: string | null | undefined): any[] { - const selected = - role === 'x' - ? this.experimentStudioService.algorithmX()[0] - : this.experimentStudioService.algorithmY()[0]; - return Array.isArray(selected?.enumerations) ? [...selected.enumerations] : []; + return parameterFieldLabel(field, this.experimentStudioService.algorithmX()[0]); } private resolveSelectedEventVarCode(algorithmName: string | undefined): string | null { if (!algorithmName) return null; - const live = this.eventVarSelection(); - if (live !== null && live !== undefined && String(live).trim() !== '') { - return String(live); - } - - const stored = untracked(() => this.experimentStudioService.algorithmConfigurations())[ - algorithmName - ]?.['event_var']; - if (stored !== undefined && stored !== null && String(stored).trim() !== '') { - return String(stored); - } - - return null; - } - - private buildConfigSchemaSignature( - algorithmName: string, - schema: Array<{ key?: string; type?: string; required?: boolean; options?: unknown[] }> - ): string { - return JSON.stringify({ - algorithmName, - crossValidation: this.crossValidationEnabled(), - fields: schema.map((field) => ({ - key: field.key, - type: field.type, - required: field.required, - options: (field.options ?? []).map((opt) => this.optionValue(opt)), - })), - }); + const stored = untracked(() => this.experimentStudioService.algorithmConfigurations())[algorithmName]?.['event_var']; + return nonBlankString(this.eventVarSelection()) ?? nonBlankString(stored); } private syncEventVarSelectionFromValues( @@ -417,54 +271,21 @@ export class AlgorithmPanelComponent { values: Record ): void { if (!Object.prototype.hasOwnProperty.call(values, 'event_var')) return; - const raw = values['event_var']; - const next = - raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : null; + const next = nonBlankString(values['event_var']); if (this.eventVarSelection() === next) return; this.eventVarSelection.set(next); this.syncPositiveClassWithEventVar(algorithmName); } private syncEventVarSelectionFromStored(stored: Record): void { - const raw = stored['event_var']; - const next = - raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : null; - this.eventVarSelection.set(next); + this.eventVarSelection.set(nonBlankString(stored['event_var'])); } - private enumOptionsForVariableCode( - varCode: string | null | undefined - ): Array<{ code: string; label: string }> { - if (!varCode) return []; - - const variables = [ + private enumOptionsForVariableCode(varCode: string | null | undefined) { + return enumOptionsForVariableCode(varCode, [ ...this.experimentStudioService.algorithmY(), ...this.experimentStudioService.algorithmX(), - ]; - const variable = variables.find((item) => String(item?.code) === String(varCode)); - if (!variable) return []; - - const enumerations = Array.isArray(variable.enumerations) ? variable.enumerations : []; - const seen = new Set(); - - return enumerations - .map((entry: any) => { - const code = entry?.code ?? entry?.value ?? entry?.name ?? entry?.label; - if (code === null || code === undefined || String(code).trim() === '') { - return null; - } - const normalizedCode = String(code); - if (seen.has(normalizedCode)) return null; - seen.add(normalizedCode); - return { - code: normalizedCode, - label: String(entry?.label ?? entry?.name ?? normalizedCode), - }; - }) - .filter( - (item: { code: string; label: string } | null): item is { code: string; label: string } => - !!item - ); + ]); } /** Live event_var from the config form; avoids reading FormGroup inside other computeds. */ @@ -479,30 +300,6 @@ export class AlgorithmPanelComponent { this.enumOptionsForVariableCode(this.selectedEventVarCode()) ); - private isCoxRegressionAlgorithm(algorithmName: string | null | undefined): boolean { - return algorithmName === 'cox_regression_classical' || algorithmName === 'cox_regression_stacked'; - } - - private optionValue(opt: any): any { - if (opt && typeof opt === 'object') { - return opt.code ?? opt.value ?? opt.name ?? opt.label ?? String(opt); - } - return opt; - } - - // sensible defaults - private readonly uiDefaults: Record = { - alpha: 0.05, - alt_hypothesis: 'two-sided', - mu: 0.0, - n_splits: 5, - k: 4, - tol: 0.01, - sstype: 2, - conf_level: 0.95, - iterations: 1000, - }; - constructor() { effect(() => { const algorithm = this.selectedAlgorithm(); @@ -561,7 +358,7 @@ export class AlgorithmPanelComponent { return; } - const schemaSignature = this.buildConfigSchemaSignature(algorithm.name, schema); + const schemaSignature = parameterFormSignature(algorithm.name, this.crossValidationEnabled(), schema); if (schemaSignature === this.lastBuiltFormSchemaSignature) { return; } @@ -571,80 +368,10 @@ export class AlgorithmPanelComponent { const stored = allConfigs?.[algorithm.name] || {}; this.syncEventVarSelectionFromStored(stored); - const group: { [key: string]: FormControl } = {}; - - schema.forEach((field) => { - // backend default - let backendDefault = - field.default !== undefined ? field.default : null; - const storedValue = stored?.[field.key]; - - // special treatment for alpha - if ((backendDefault === null || backendDefault === undefined) && field.key === 'alpha') { - backendDefault = 0.05; - } - - // fallback value logic - const fallback = - storedValue !== undefined - ? storedValue - : field.default !== undefined && field.default !== null - ? field.default - : backendDefault !== undefined && backendDefault !== null - ? backendDefault - : this.uiDefaults[field.key] !== undefined - ? this.uiDefaults[field.key] - : field.type === 'checkbox' - ? false - : ''; - - // label if missing - const label = - field.label && field.label.trim() !== '' - ? field.label - : toHumanLabel(field.key); - - const prettyField = { - ...field, - label, - desc: field.desc ?? field.description ?? '', - }; - - const control = buildFormControl(prettyField, fallback); - - // extra validation: integer-only for numeric fields - const isNumeric = prettyField.type === 'number'; - const allowsDecimal = - prettyField.types?.includes('real') || - prettyField.key === 'alpha' || - prettyField.key === 'tol' || - prettyField.key === 'mu'; - - if (isNumeric && !allowsDecimal) { - const integerPattern = Validators.pattern(/^-?\d+$/); - const existingValidator = control.validator; - - control.setValidators( - existingValidator ? [existingValidator, integerPattern] : [integerPattern] - ); - - // stop events while building form - control.updateValueAndValidity({ emitEvent: false }); - } - - group[field.key] = control; - }); - - const form = new FormGroup(group, { updateOn: 'change' }); + const form = buildParameterForm(schema, stored); this.configForm.set(form); this.formInvalid.set(form.invalid); - Object.values(this.configForm().controls).forEach(control => { - if (control.valid) { - control.markAsTouched({ onlySelf: true }); - } - }); - const statusSub = form.statusChanges.subscribe(() => this.formInvalid.set(form.invalid)); const subscription = form.valueChanges.subscribe((values) => { this.syncEventVarSelectionFromValues(algorithm.name, values); @@ -687,90 +414,12 @@ export class AlgorithmPanelComponent { enrichedConfigSchema = computed(() => { const algorithm = this.selectedAlgorithm(); if (!algorithm) return []; - - // Basic algorithm schema (shallow copy) - const schema = (algorithm.configSchema ?? []).map(f => ({ ...f })); - - const yVars = this.experimentStudioService.algorithmY(); - const xVars = this.experimentStudioService.algorithmX(); - const yVar = yVars[0]; - const xVar = xVars[0]; - const eventVarCode = this.selectedEventVarCode(); - const positiveClassOptions = this.enumOptionsForVariableCode(eventVarCode); - const isCox = this.isCoxRegressionAlgorithm(algorithm.name); - const enriched = schema.map(field => { - let options = field.options ?? []; - const enumSource = Array.isArray(field.enumSource) ? field.enumSource : []; - - if (field.enumType === 'input_var_names') { - options = this.variableOptionsForRoles(enumSource); - } else if (field.enumType === 'input_var_CDE_enums') { - options = this.enumOptionsForRole(enumSource[0]); - } else if (field.key === 'category_order' && yVar?.enumerations?.length) { - options = [...yVar.enumerations]; - } else if (field.key === 'positive_class' && positiveClassOptions.length) { - options = positiveClassOptions; - } - - // Placeholder substitution for enums - if (Array.isArray(options) && options.length === 1) { - const placeholder = options[0]; - - if (placeholder === 'y' && yVar?.enumerations?.length) { - options = [...yVar.enumerations]; - } else if (placeholder === 'x' && xVar?.enumerations?.length) { - options = [...xVar.enumerations]; - } - } - - // Fallback for empty select fields (not input_var_names — those list y/x variables) - if ( - field.type === 'select' && - field.enumType !== 'input_var_names' && - (!options || options.length === 0) && - yVar?.enumerations?.length - ) { - options = [...yVar.enumerations]; - } - - const defaultValue = field.key === 'category_order' && Array.isArray(options) && options.length > 0 - ? options.map((opt: any) => this.optionValue(opt)) - : field.default; - - // Normalize label and desc - const label = field.label?.trim() || toHumanLabel(field.key); - const desc = - field.key === 'positive_class' && isCox - ? 'Event level mapped to 1; other observed levels are mapped to 0.' - : field.desc ?? field.description ?? ''; - const type = - field.key === 'positive_class' && positiveClassOptions.length - ? 'select' - : field.type; - const required = - (field.key === 'positive_class' && isCox) || - field.required === true || - String(field.required ?? '').trim().toLowerCase() === 'true'; - - // Return enriched field - return { ...field, label, desc, options, default: defaultValue, type, required }; + return enrichParameterSchema(algorithm, { + y: this.experimentStudioService.algorithmY(), + x: this.experimentStudioService.algorithmX(), + eventVarCode: this.selectedEventVarCode(), + withCrossValidationFolds: this.crossValidationEnabled() && this.canToggleCrossValidation(), }); - - if (this.crossValidationEnabled() && this.canToggleCrossValidation()) { - const hasSplits = enriched.some((field) => field.key === 'n_splits'); - if (!hasSplits) { - enriched.push({ - key: 'n_splits', - label: 'Folds', - desc: 'Number of parts the data is split into; each part is held out once.', - type: 'number', - min: 2, - default: 5, - }); - } - } - - return enriched; }); readonly visibleConfigSchema = computed(() => { @@ -779,10 +428,8 @@ export class AlgorithmPanelComponent { return schema.filter((field) => field.type !== 'dict' && !['strategies', 'tails', 'folds'].includes(String(field.key))); }); - /** Backend metadata has no advanced flag yet; the heuristic mirrors the review handoff. */ - isAdvancedField(field: any): boolean { - return field?.advanced ?? (field?.default !== undefined && !field?.required); - } + readonly isAdvancedField = isAdvancedField; + readonly allowsDecimal = allowsDecimal; /** n_splits belongs to the cross-validation row, so it renders under that switch, not in the main form. */ isCrossValidationField(field: any): boolean { @@ -819,47 +466,13 @@ export class AlgorithmPanelComponent { readonly searchActive = computed(() => this.algoSearchQuery().trim().length > 0); - private matchesAlgorithmSearch(algorithm: AlgorithmConfig, query: string): boolean { - if (!query) return true; - const haystack = [ - algorithm.label, - algorithm.name, - algorithm.description, - algorithm.documentation ?? '', - algorithm.category ?? '', - (algorithm.flags ?? []).join(' '), - ].join(' ').toLowerCase(); - return query.split(/\s+/).every((term) => haystack.includes(term)); - } - - readonly filteredAlgorithmCategories = computed(() => { - const grouped = this.experimentStudioService.availableGroupedAlgorithms(); - const onlyActive = this.showOnlyActive(); - const query = this.algoSearchQuery().trim().toLowerCase(); - - const entries = Object.entries(grouped ?? {}).map(([name, algorithms]) => { - let filtered = (algorithms ?? []).filter(a => this.matchesAlgorithmSearch(a, query)); - if (onlyActive) { - filtered = filtered.filter(a => !a.isDisabled); - } - - // Runnable-first: when showing all methods, surface runnable ones before disabled ones. - const ordered = onlyActive - ? filtered - : [...filtered].sort((a, b) => Number(a.isDisabled) - Number(b.isDisabled)); - - return { name, algorithms: ordered }; - }); - - return entries.filter(c => c.algorithms.length > 0); - }); + readonly filteredAlgorithmCategories = computed(() => filterAlgorithmCategories( + this.experimentStudioService.availableGroupedAlgorithms(), + this.showOnlyActive(), + this.algoSearchQuery(), + )); - readonly algoCounts = computed(() => { - const grouped = this.experimentStudioService.availableGroupedAlgorithms(); - const all = Object.values(grouped ?? {}).flat(); - const active = all.filter(a => !a.isDisabled); - return { all: all.length, active: active.length }; - }); + readonly algoCounts = computed(() => countAlgorithms(this.experimentStudioService.availableGroupedAlgorithms())); toggleActiveOnly() { this.showOnlyActive.update(v => !v); @@ -910,44 +523,25 @@ export class AlgorithmPanelComponent { const selectionChanged = this.lastMatchSelectionName !== selectedName; this.lastMatchSelectionName = selectedName; - const selectedCategory = selectedName - ? categories.find((category) => - category.algorithms.some((algorithm) => algorithm.name === selectedName))?.name - : null; - - const next = new Set(this.collapsedMatchCategories()); - let changed = false; - - categories.forEach((category) => { - if (this.matchCategoryUserToggles.has(category.name)) return; - const shouldOpen = - category.name === selectedCategory || - category.algorithms.some((algorithm) => !algorithm.isDisabled); - if (shouldOpen === !next.has(category.name)) return; - if (shouldOpen) { - next.delete(category.name); - } else { - next.add(category.name); - } - changed = true; - }); - - if (selectedCategory && selectionChanged && next.delete(selectedCategory)) { - changed = true; - } - - if (changed) this.collapsedMatchCategories.set(next); + const next = nextCollapsedCategories( + categories, + selectedName, + selectionChanged, + this.collapsedMatchCategories(), + this.matchCategoryUserToggles, + ); + if (next !== this.collapsedMatchCategories()) this.collapsedMatchCategories.set(next); } algorithmUnavailableReason(algorithm: AlgorithmConfig): string { const availability = this.experimentStudioService.getAlgorithmAvailability(algorithm.name); const summary = availability?.summary || algorithm.availability?.summary || ''; - if (summary) return this.formatAvailabilityMessage(summary); + if (summary) return formatAvailabilityMessage(summary); const details = availability?.details?.length ? availability.details : (algorithm.availability?.details ?? []); const first = details.find((detail) => detail.messages.length > 0); - return first ? this.formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; + return first ? formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; } @@ -1035,28 +629,7 @@ export class AlgorithmPanelComponent { } private normalizeFormValues(values: Record, schema: any[]): Record { - const normalized = { ...values }; - schema - .filter((field) => field?.type === 'number') - .forEach((field) => { - const key = String(field.key); - const value = normalized[key]; - if (typeof value !== 'string') return; - const trimmed = value.trim(); - if (!trimmed) return; - const parsed = Number(trimmed); - if (Number.isFinite(parsed)) normalized[key] = parsed; - }); - - schema.forEach((field) => { - const key = String(field.key); - if (!(key in normalized)) return; - if (field?.type === 'select' || field?.type === 'multi-select' || key === 'positive_class') { - normalized[key] = serializeAlgorithmParameterValue(normalized[key], field); - } - }); - - return omitEmptyOptionalParameters(normalized, schema) as Record; + return normalizeParameterValues(values, schema); } private mergeOutlierReportConfig(configValues: Record): Record { @@ -1311,21 +884,12 @@ export class AlgorithmPanelComponent { return; } - const isCvOnly = this.experimentStudioService.isCrossValidationOnly(algo.name); - const baseAlgorithmName = isCvOnly - ? algo.name - : this.experimentStudioService.getCrossValidationBase(algo.name) ?? algo.name; - const cvVariant = - this.experimentStudioService.getCrossValidationVariant(baseAlgorithmName); - const shouldIncludeSplits = - this.crossValidationEnabled() || - this.experimentStudioService.isCrossValidationOnly(algo.name); - const useCrossValidation = shouldIncludeSplits && !!cvVariant; - const effectiveAlgorithmName = useCrossValidation - ? cvVariant - : baseAlgorithmName; - - const finalAlgorithmName = isCvOnly ? algo.name : effectiveAlgorithmName; + const { + baseName: baseAlgorithmName, + effectiveName: effectiveAlgorithmName, + finalName: finalAlgorithmName, + includeSplits: shouldIncludeSplits, + } = resolveRunAlgorithmNames(algo.name, this.crossValidationEnabled(), this.experimentStudioService); this.experimentStudioService.lastUsedAlgorithm.set(finalAlgorithmName); this.errorMsg.set(null); @@ -1431,12 +995,7 @@ export class AlgorithmPanelComponent { } private multiSelectValues(key: string): string[] { - const value = this.configForm().get(key)?.value; - if (Array.isArray(value)) return value.map((entry) => String(entry)); - if (typeof value === 'string' && value.trim() !== '') { - return value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''); - } - return []; + return multiSelectValues(this.configForm().get(key)?.value); } isMultiSelectOptionSelected(key: string, option: unknown): boolean { @@ -1468,31 +1027,7 @@ export class AlgorithmPanelComponent { return this.experimentStudioService.getAlgorithmAvailability(target.name).details ?? []; } - availabilityRequirementText(detail: AlgorithmAvailabilityDetail): string { - const count = this.formatAvailabilityCount(detail); - const parts = [detail.label + ': ' + count + ', selected ' + detail.selectedCount]; - const types = this.formatRequirementTypes(detail.types); - if (types) { - parts.push('type: ' + types.join(', ')); - } - return parts.join(' • '); - } - - private formatAvailabilityCount(detail: AlgorithmAvailabilityDetail): string { - return formatInputCountRange(detail.minCount, detail.maxCount); - } - - private formatAvailabilityMessage(message: string): string { - return message.replace( - /((?:Outcome|Predictor) type must be one of )([^.]*)\./g, - (_match, prefix: string, types: string) => { - const displayTypes = this.formatRequirementTypes( - types.split(',').map((type) => type.trim()) - ); - return prefix + (displayTypes ?? []).join(', ') + '.'; - } - ); - } + readonly availabilityRequirementText = availabilityRequirementText; resolveRunRequirement(requirement: AlgorithmRunRequirement): void { if (requirement.id === 'preprocessing') { @@ -1537,7 +1072,7 @@ export class AlgorithmPanelComponent { this.tooltipVisible.set(true); this.tooltipData.set(algorithm); - const initialPosition = this.getTooltipPosition(rect, containerRect, 320, 200, viewportWidth, viewportHeight, padding, offset); + const initialPosition = tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset }); // Immediate initial placement this.tooltipPosition.set(initialPosition); @@ -1551,7 +1086,7 @@ export class AlgorithmPanelComponent { const h = el.offsetHeight || 200; this.tooltipPosition.set( - this.getTooltipPosition(rect, containerRect, w, h, viewportWidth, viewportHeight, padding, offset) + tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset }) ); }, 0); } @@ -1560,70 +1095,6 @@ export class AlgorithmPanelComponent { this.tooltipVisible.set(false); } - private getTooltipPosition( - rect: DOMRect, - containerRect: DOMRect, - tooltipWidth: number, - tooltipHeight: number, - viewportWidth: number, - viewportHeight: number, - padding: number, - offset: number, - ): { x: number; y: number } { - const rightAlignedX = rect.right + offset; - const leftAlignedX = rect.left - tooltipWidth - offset; - const centeredY = rect.top + (rect.height - tooltipHeight) / 2; - - let x = rightAlignedX; - if (rightAlignedX + tooltipWidth > viewportWidth - padding) { - x = leftAlignedX >= padding - ? leftAlignedX - : viewportWidth - tooltipWidth - padding; - } - - let y = centeredY; - if (y + tooltipHeight > viewportHeight - padding) { - y = viewportHeight - tooltipHeight - padding; - } - if (y < padding) { - y = padding; - } - - return { - x: Math.max(padding, x - containerRect.left), - y: Math.max(padding, y - containerRect.top), - }; - } - - private normalizeRequirementDisplayType(type: unknown): string | null { - const normalized = String(type ?? '').trim().toLowerCase(); - if (!normalized) return null; - - switch (normalized) { - case VariableTypes.TEXT: - case 'polynominal': - case 'ordinal': - return VariableTypes.NOMINAL; - case VariableTypes.INTEGER: - return VariableTypes.INT; - default: - return normalized; - } - } - - private formatRequirementTypes(types: unknown): string[] | null { - if (!Array.isArray(types) || types.length === 0) { - return null; - } - - const normalized = types - .map((type) => this.normalizeRequirementDisplayType(type)) - .filter((type): type is string => !!type); - - const unique = Array.from(new Set(normalized)); - return unique.length ? unique : null; - } - onExportResult(section: HTMLElement) { const payload = this.resultExportPayload(); if (!section || !payload) { diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts new file mode 100644 index 00000000..901a0044 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts @@ -0,0 +1,78 @@ +import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; + +/** Studio sub-steps of the Algorithm view: setup (roles + methods), then parameters (configure + run). */ +export type AlgorithmStudioSubstep = 'setup' | 'parameters'; + +type AlgorithmRunRequirementKind = 'availability' | 'preprocessing'; + +/** Something that blocks the run, with the action that fixes it. */ +export interface AlgorithmRunRequirement { + id: AlgorithmRunRequirementKind; + message: string; + actionLabel: string; +} + +/** One line of the "Requirements before running" list. */ +export interface AlgorithmNeedItem { + key: string; + met: boolean; + title: string; + messages: string[]; + action?: AlgorithmRunRequirement; +} + +/** A catalog group as the panel lists it. */ +export interface AlgorithmCategory { + name: string; + algorithms: AlgorithmConfig[]; +} + +/** An enumeration entry or a select option, as the backend and the data model send them. */ +export type ParameterOption = + | string + | number + | boolean + | { code?: unknown; value?: unknown; name?: unknown; label?: unknown }; + +/** + * A studio variable as the panel reads it: entries of `algorithmY`, `algorithmX`, + * `algorithmAssignableVariables` and `selectedFilters`. + */ +export interface StudioVariable { + code?: string; + label?: string; + name?: string; + type?: string; + enumerations?: ParameterOption[]; +} + +/** One algorithm parameter of `AlgorithmConfig.configSchema` (see `buildConfigSchema` in core/algorithm-mappers). */ +export interface AlgorithmParameterField { + key: string; + label?: string; + desc?: string; + description?: string; + type?: string; + types?: string[]; + required?: boolean | string; + default?: unknown; + options?: ParameterOption[]; + enumType?: string; + enumSource?: string[]; + min?: number; + max?: number; + advanced?: boolean; +} + +/** A parameter after enrichment: options resolved from the selected variables, label and desc filled in. */ +export interface EnrichedParameterField extends AlgorithmParameterField { + label: string; + desc: string; + required?: boolean; +} + +/** A `{ code, label }` choice offered by a select built from variables or enumerations. */ +export interface CodeLabelOption { + code: string; + label: string; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts new file mode 100644 index 00000000..15d4d012 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts @@ -0,0 +1,48 @@ +import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model'; +import { + availabilityRequirementText, + formatAvailabilityMessage, + needsRequirementText, +} from './algorithm-availability-text.utils'; + +const detail = (overrides: Partial): AlgorithmAvailabilityDetail => ({ + role: 'y', + label: 'Outcome', + selectedCount: 0, + minCount: 1, + maxCount: 1, + required: true, + types: [], + stattypes: [], + messages: [], + satisfied: true, + ...overrides, +}); + +describe('algorithm availability text', () => { + it('words outcome needs with an article and the type family', () => { + expect(needsRequirementText(detail({ types: ['real', 'int'] }))).toBe('Needs a numerical outcome'); + expect(needsRequirementText(detail({ types: ['ordinal'] }))).toBe('Needs a categorical outcome'); + expect(needsRequirementText(detail({ types: ['other'] }))).toBe('Needs an other outcome'); + expect(needsRequirementText(detail({ minCount: 2 }))).toBe('Needs at least 2 outcomes'); + expect(needsRequirementText(detail({ minCount: 0, required: false }))).toBe('No outcome needed'); + }); + + it('words predictor needs by count', () => { + expect(needsRequirementText(detail({ role: 'x', minCount: 2, types: ['nominal'] }))).toBe('At least 2 categorical predictors'); + expect(needsRequirementText(detail({ role: 'x', minCount: 0, maxCount: 3 }))).toBe('Up to 3 predictor'); + expect(needsRequirementText(detail({ role: 'x', minCount: 0, maxCount: null }))).toBe('Predictors optional'); + }); + + it('lists count, selection and display types in tooltip requirements', () => { + expect(availabilityRequirementText(detail({ minCount: 1, maxCount: 1, selectedCount: 0, types: ['text', 'integer', 'text'] }))) + .toBe('Outcome: exactly 1, selected 0 • type: nominal, int'); + expect(availabilityRequirementText(detail({ types: [] }))).toBe('Outcome: exactly 1, selected 0'); + }); + + it('rewrites backend type lists in availability messages', () => { + expect(formatAvailabilityMessage('Outcome type must be one of text, polynominal. Other text.')) + .toBe('Outcome type must be one of nominal. Other text.'); + expect(formatAvailabilityMessage('Outcome needs at least 2, selected 1.')).toBe('Outcome needs at least 2, selected 1.'); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts new file mode 100644 index 00000000..bceebb5c --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts @@ -0,0 +1,98 @@ +import { formatInputCountRange } from '../../../../core/algorithm-input-counts'; +import { VariableTypes } from '../../../../core/constants/algorithm.constants'; +import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model'; + +/** "numerical" / "categorical" wording for the needs list; unknown types pass through lowercased. */ +function needsTypeText(types: readonly string[] | undefined | null): string { + if (!Array.isArray(types) || types.length === 0) return ''; + const labels = new Set(); + types.forEach((type) => { + const normalized = String(type ?? '').trim().toLowerCase(); + if (!normalized) return; + if (['real', 'int', 'integer', 'numeric', 'numerical'].includes(normalized)) { + labels.add('numerical'); + } else if (['text', 'nominal', 'categorical', 'polynominal', 'ordinal', 'string'].includes(normalized)) { + labels.add('categorical'); + } else { + labels.add(normalized); + } + }); + return Array.from(labels).join(' or '); +} + +/** Title of one role line in the needs list, e.g. "Needs a numerical outcome" or "At least 2 predictors". */ +export function needsRequirementText(detail: AlgorithmAvailabilityDetail): string { + const typeText = needsTypeText(detail.types); + + if (detail.role === 'y') { + if (detail.minCount <= 0 && !detail.required) { + return 'No outcome needed'; + } + const article = typeText + ? `${/^[aeiou]/i.test(typeText) ? 'an' : 'a'} ${typeText} ` + : ''; + const noun = detail.minCount > 1 ? 'outcomes' : 'outcome'; + return detail.minCount > 1 + ? `Needs at least ${detail.minCount} ${noun}` + : `Needs ${article}${noun}`; + } + + const noun = detail.minCount > 1 ? 'predictors' : 'predictor'; + if (detail.minCount > 0) { + return `At least ${detail.minCount}${typeText ? ` ${typeText}` : ''} ${noun}`; + } + if (detail.maxCount !== null) { + return `Up to ${detail.maxCount} ${noun}`; + } + return 'Predictors optional'; +} + +/** Backend type names as the availability tips show them: text/polynominal/ordinal read "nominal", integer reads "int". */ +function requirementDisplayType(type: unknown): string | null { + const normalized = String(type ?? '').trim().toLowerCase(); + if (!normalized) return null; + + switch (normalized) { + case VariableTypes.TEXT: + case 'polynominal': + case 'ordinal': + return VariableTypes.NOMINAL; + case VariableTypes.INTEGER: + return VariableTypes.INT; + default: + return normalized; + } +} + +function requirementDisplayTypes(types: unknown): string[] | null { + if (!Array.isArray(types) || types.length === 0) { + return null; + } + const normalized = types + .map((type) => requirementDisplayType(type)) + .filter((type): type is string => !!type); + const unique = Array.from(new Set(normalized)); + return unique.length ? unique : null; +} + +/** One requirement line of the availability tooltip, e.g. "Outcome: 1, selected 0 • type: real". */ +export function availabilityRequirementText(detail: AlgorithmAvailabilityDetail): string { + const count = formatInputCountRange(detail.minCount, detail.maxCount); + const parts = [detail.label + ': ' + count + ', selected ' + detail.selectedCount]; + const types = requirementDisplayTypes(detail.types); + if (types) { + parts.push('type: ' + types.join(', ')); + } + return parts.join(' • '); +} + +/** Rewrites the type list of "Outcome/Predictor type must be one of …." with display type names. */ +export function formatAvailabilityMessage(message: string): string { + return message.replace( + /((?:Outcome|Predictor) type must be one of )([^.]*)\./g, + (_match, prefix: string, types: string) => { + const displayTypes = requirementDisplayTypes(types.split(',').map((type) => type.trim())); + return prefix + (displayTypes ?? []).join(', ') + '.'; + } + ); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts new file mode 100644 index 00000000..a7de214b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts @@ -0,0 +1,55 @@ +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories, runnableCount } from './algorithm-catalog.utils'; + +const algo = (name: string, isDisabled = false, extra: Partial = {}): AlgorithmConfig => ({ + name, + label: name.replace(/_/g, ' '), + description: '', + requiredVariable: [], + covariate: [], + category: 'Test', + configSchema: [], + isDisabled, + ...extra, +}); + +describe('algorithm catalog utils', () => { + const grouped = { + Regression: [algo('logistic_regression', true), algo('linear_regression')], + Tests: [algo('ttest', true, { flags: ['paired'] })], + }; + + it('keeps runnable methods only, or all with runnable first', () => { + expect(filterAlgorithmCategories(grouped, true, '')).toEqual([{ name: 'Regression', algorithms: [grouped.Regression[1]] }]); + const all = filterAlgorithmCategories(grouped, false, ''); + expect(all.map((c) => c.algorithms.map((a) => a.name))).toEqual([['linear_regression', 'logistic_regression'], ['ttest']]); + }); + + it('matches every search term across label, name and flags', () => { + expect(filterAlgorithmCategories(grouped, false, ' PAIRED ').map((c) => c.name)).toEqual(['Tests']); + expect(filterAlgorithmCategories(grouped, false, 'linear regression').flatMap((c) => c.algorithms.map((a) => a.name))) + .toEqual(['linear_regression']); + expect(filterAlgorithmCategories(grouped, false, 'nothing')).toEqual([]); + }); + + it('counts all and runnable methods', () => { + expect(countAlgorithms(grouped)).toEqual({ all: 3, active: 1 }); + expect(countAlgorithms(null)).toEqual({ all: 0, active: 0 }); + expect(runnableCount({ name: 'Regression', algorithms: grouped.Regression })).toBe(1); + }); + + describe('nextCollapsedCategories', () => { + const categories = filterAlgorithmCategories(grouped, false, ''); + + it('collapses groups with nothing runnable and opens the rest', () => { + const next = nextCollapsedCategories(categories, null, true, new Set(), new Set()); + expect([...next]).toEqual(['Tests']); + }); + + it('reveals the group of a new selection but leaves user-toggled groups alone', () => { + expect([...nextCollapsedCategories(categories, 'ttest', true, new Set(['Tests']), new Set())]).toEqual([]); + const collapsed = new Set(['Regression']); + expect(nextCollapsedCategories(categories, null, false, collapsed, new Set(['Regression', 'Tests']))).toBe(collapsed); + }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts new file mode 100644 index 00000000..7b3ff64e --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts @@ -0,0 +1,93 @@ +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmCategory } from '../algorithm-panel.models'; + +type GroupedAlgorithms = Record | null | undefined; + +/** Every whitespace-separated term must occur in label, name, description, documentation, category or flags. */ +function matchesAlgorithmSearch(algorithm: AlgorithmConfig, query: string): boolean { + if (!query) return true; + const haystack = [ + algorithm.label, + algorithm.name, + algorithm.description, + algorithm.documentation ?? '', + algorithm.category ?? '', + (algorithm.flags ?? []).join(' '), + ].join(' ').toLowerCase(); + return query.split(/\s+/).every((term) => haystack.includes(term)); +} + +/** + * The catalog groups to list: search-filtered, runnable only when `onlyActive`, and with + * runnable methods first when all are shown. Empty groups are dropped. + */ +export function filterAlgorithmCategories( + grouped: GroupedAlgorithms, + onlyActive: boolean, + rawQuery: string, +): AlgorithmCategory[] { + const query = rawQuery.trim().toLowerCase(); + const entries = Object.entries(grouped ?? {}).map(([name, algorithms]) => { + let filtered = (algorithms ?? []).filter((algorithm) => matchesAlgorithmSearch(algorithm, query)); + if (onlyActive) { + filtered = filtered.filter((algorithm) => !algorithm.isDisabled); + } + const ordered = onlyActive + ? filtered + : [...filtered].sort((a, b) => Number(a.isDisabled) - Number(b.isDisabled)); + return { name, algorithms: ordered }; + }); + return entries.filter((category) => category.algorithms.length > 0); +} + +/** All and runnable method counts across every group. */ +export function countAlgorithms(grouped: GroupedAlgorithms): { all: number; active: number } { + const all = Object.values(grouped ?? {}).flat(); + return { all: all.length, active: all.filter((algorithm) => !algorithm.isDisabled).length }; +} + +export function runnableCount(category: AlgorithmCategory): number { + return category.algorithms.filter((algorithm) => !algorithm.isDisabled).length; +} + +/** + * Next set of collapsed groups. By default a group is open only when it holds the selected + * method or at least one runnable method; on a new selection the selected method's group + * is revealed. Groups the user toggled are never changed. Returns `collapsed` itself when + * nothing changes. + */ +export function nextCollapsedCategories( + categories: readonly AlgorithmCategory[], + selectedName: string | null, + selectionChanged: boolean, + collapsed: ReadonlySet, + userToggled: ReadonlySet, +): ReadonlySet { + const selectedCategory = selectedName + ? categories.find((category) => + category.algorithms.some((algorithm) => algorithm.name === selectedName))?.name + : null; + + const next = new Set(collapsed); + let changed = false; + + categories.forEach((category) => { + if (userToggled.has(category.name)) return; + const shouldOpen = + category.name === selectedCategory || + category.algorithms.some((algorithm) => !algorithm.isDisabled); + if (shouldOpen === !next.has(category.name)) return; + if (shouldOpen) { + next.delete(category.name); + } else { + next.add(category.name); + } + changed = true; + }); + + if (selectedCategory && selectionChanged && next.delete(selectedCategory)) { + changed = true; + } + + return changed ? next : collapsed; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts new file mode 100644 index 00000000..2568491f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts @@ -0,0 +1,130 @@ +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { EnrichedParameterField } from '../algorithm-panel.models'; +import { + allowsDecimal, + buildParameterForm, + enrichParameterSchema, + enumOptionsForVariableCode, + isAdvancedField, + multiSelectValues, + nonBlankString, + normalizeParameterValues, + optionLabel, + parameterFieldLabel, + parameterFormSignature, +} from './algorithm-parameter-form.utils'; + +const algorithm = (name: string, configSchema: AlgorithmConfig['configSchema']): AlgorithmConfig => ({ + name, + label: name, + description: '', + requiredVariable: [], + covariate: [], + category: 'Test', + configSchema, + isDisabled: false, +}); + +const field = (overrides: Partial): EnrichedParameterField => ({ + key: 'k', + label: 'K', + desc: '', + ...overrides, +}); + +describe('algorithm parameter form utils', () => { + const sex = { code: 'sex', label: 'Sex', enumerations: [{ code: 'M', label: 'Male' }, { code: 'F', label: 'Female' }, { code: 'M', label: 'Dup' }] }; + const age = { code: 'age', label: 'Age' }; + + it('reads blank values as null and options by label, name, then the option itself', () => { + expect(nonBlankString(' ')).toBeNull(); + expect(nonBlankString(0)).toBe('0'); + expect(optionLabel({ code: 'M', label: 'Male' })).toBe('Male'); + expect(optionLabel({ code: 'M', name: 'Masc' })).toBe('Masc'); + expect(optionLabel('efron')).toBe('efron'); + expect(optionLabel(3)).toBe('3'); + }); + + it('treats real, alpha, tol and mu as decimal fields and defaulted optionals as advanced', () => { + expect(allowsDecimal({ key: 'alpha' })).toBe(true); + expect(allowsDecimal({ key: 'k', types: ['real'] })).toBe(true); + expect(allowsDecimal({ key: 'k', types: ['int'] })).toBe(false); + expect(isAdvancedField({ key: 'alpha', default: 0.05 })).toBe(true); + expect(isAdvancedField({ key: 'alpha', default: 0.05, required: true })).toBe(false); + expect(isAdvancedField({ key: 'k', advanced: false, default: 1 })).toBe(false); + }); + + it('names the covariate in group field labels', () => { + expect(parameterFieldLabel(field({ key: 'groupA', label: 'Group A' }), sex)).toBe('Group A (Sex)'); + expect(parameterFieldLabel(field({ key: 'alpha', label: 'Alpha' }), sex)).toBe('Alpha'); + }); + + it('lists distinct enumeration codes of a selected variable', () => { + expect(enumOptionsForVariableCode('sex', [age, sex])).toEqual([{ code: 'M', label: 'Male' }, { code: 'F', label: 'Female' }]); + expect(enumOptionsForVariableCode('missing', [age, sex])).toEqual([]); + }); + + it('resolves options from the selected variables and fills labels', () => { + const schema = enrichParameterSchema( + algorithm('cox_regression_classical', [ + { key: 'event_var', type: 'select', enumType: 'input_var_names', enumSource: ['x'], required: 'true' }, + { key: 'positive_class', type: 'text' }, + { key: 'levels', type: 'select', options: ['y'] }, + { key: 'max_iter', label: ' ', type: 'number', default: 100 }, + ]), + { y: [sex], x: [age, sex], eventVarCode: 'sex', withCrossValidationFolds: true }, + ); + const byKey = Object.fromEntries(schema.map((f) => [f.key, f])); + expect(byKey['event_var'].options).toEqual([{ code: 'age', label: 'Age' }, { code: 'sex', label: 'Sex' }]); + expect(byKey['event_var'].required).toBe(true); + expect(byKey['positive_class']).toEqual(expect.objectContaining({ + type: 'select', + required: true, + desc: 'Event level mapped to 1; other observed levels are mapped to 0.', + })); + expect(byKey['levels'].options).toEqual(sex.enumerations); + expect(byKey['max_iter'].label).toBe('Max Iter'); + expect(byKey['n_splits']).toEqual(expect.objectContaining({ label: 'Folds', type: 'number', min: 2, default: 5 })); + }); + + it('defaults category_order to every outcome level', () => { + const [order] = enrichParameterSchema(algorithm('ordinal', [{ key: 'category_order', type: 'multi-select' }]), { + y: [sex], x: [], eventVarCode: null, withCrossValidationFolds: false, + }); + expect(order.default).toEqual(['M', 'F', 'M']); + }); + + it('changes the form signature with cross-validation and options only', () => { + const schema = [field({ key: 'a', type: 'select', options: [{ code: 'x' }] })]; + expect(parameterFormSignature('m', false, schema)).toBe(parameterFormSignature('m', false, [{ ...schema[0], label: 'Other' }])); + expect(parameterFormSignature('m', true, schema)).not.toBe(parameterFormSignature('m', false, schema)); + }); + + it('starts controls from stored, backend, then UI defaults and validates integers', () => { + const form = buildParameterForm([ + field({ key: 'alpha', type: 'number' }), + field({ key: 'k', type: 'number', types: ['int'] }), + field({ key: 'flag', type: 'checkbox' }), + field({ key: 'name', type: 'text', default: 'x' }), + ], { k: '2.5' }); + expect(form.getRawValue()).toEqual({ alpha: 0.05, k: 2.5, flag: false, name: 'x' }); + expect(form.get('k')?.hasError('pattern')).toBe(true); + expect(form.get('alpha')?.touched).toBe(true); + expect(form.get('k')?.touched).toBe(false); + }); + + it('parses numbers, serializes enum codes and drops empty optionals', () => { + const schema = [ + field({ key: 'n', type: 'number' }), + field({ key: 'level', type: 'select', options: [{ code: 'M', label: 'Male' }] }), + field({ key: 'note', type: 'text' }), + ]; + expect(normalizeParameterValues({ n: ' 3 ', level: 'Male', note: '' }, schema)).toEqual({ n: 3, level: 'M' }); + }); + + it('reads multi-select values from arrays and comma lists', () => { + expect(multiSelectValues(['a', 1])).toEqual(['a', '1']); + expect(multiSelectValues('a, b,,')).toEqual(['a', 'b']); + expect(multiSelectValues(null)).toEqual([]); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts new file mode 100644 index 00000000..1b92847c --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts @@ -0,0 +1,286 @@ +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { prettifyLabel as toHumanLabel } from '../../../../core/algorithm-mappers'; +import { + omitEmptyOptionalParameters, + serializeAlgorithmParameterValue, +} from '../../../../core/algorithm-parameter.utils'; +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { buildFormControl } from '../../../shared/utils/form-control.factory'; +import { + AlgorithmParameterField, + CodeLabelOption, + EnrichedParameterField, + ParameterOption, + StudioVariable, +} from '../algorithm-panel.models'; + +/** Parameter values the form starts from when neither the stored config nor the backend has one. */ +const UI_DEFAULTS: Readonly> = { + alpha: 0.05, + alt_hypothesis: 'two-sided', + mu: 0.0, + n_splits: 5, + k: 4, + tol: 0.01, + sstype: 2, + conf_level: 0.95, + iterations: 1000, +}; + +/** Number fields that take decimals although the backend does not type them `real`. */ +const DECIMAL_KEYS = new Set(['alpha', 'tol', 'mu']); + +/** `String(raw)` when `raw` holds a non-blank value, else null. */ +export function nonBlankString(raw: unknown): string | null { + return raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : null; +} + +/** The primitive an option stands for: code, value, name or label of an object option. */ +function optionValue(option: ParameterOption | null | undefined): unknown { + if (option && typeof option === 'object') { + return option.code ?? option.value ?? option.name ?? option.label ?? String(option); + } + return option; +} + +/** What an option shows: label, then name, then the option itself. */ +export function optionLabel(option: ParameterOption | null | undefined): string { + const shown = option && typeof option === 'object' ? option.label ?? option.name ?? option : option; + return shown === null || shown === undefined ? '' : String(shown); +} + +/** Whether a number field accepts decimals (else it is integer-only and steps by 1). */ +export function allowsDecimal(field: Pick): boolean { + return !!field.types?.includes('real') || DECIMAL_KEYS.has(field.key); +} + +/** Backend metadata has no advanced flag yet; the heuristic mirrors the review handoff. */ +export function isAdvancedField(field: AlgorithmParameterField): boolean { + return field.advanced ?? (field.default !== undefined && !field.required); +} + +export function isCoxRegressionAlgorithm(algorithmName: string | null | undefined): boolean { + return algorithmName === 'cox_regression_classical' || algorithmName === 'cox_regression_stacked'; +} + +/** Label of a group field (`groupA` / `groupB`) names the covariate the groups come from. */ +export function parameterFieldLabel(field: EnrichedParameterField, firstCovariate: StudioVariable | undefined): string { + let base = field.label ?? field.key; + if (field.key === 'groupA' || field.key === 'groupB') { + const covName = firstCovariate?.label || firstCovariate?.name || firstCovariate?.code; + if (covName) base = `${base} (${covName})`; + } + return base; +} + +/** The selected y / x variables as options, for `input_var_names` parameters (x when no role is named). */ +function variableOptionsForRoles(roles: readonly string[], y: readonly StudioVariable[], x: readonly StudioVariable[]): CodeLabelOption[] { + const roleList = roles.length ? roles : ['x']; + return roleList.flatMap((role) => { + const selected = role === 'y' ? y : role === 'x' ? x : []; + return selected + .map((item) => { + const code = item?.code; + if (code === null || code === undefined || code === '') return null; + return { code: String(code), label: String(item?.label ?? item?.name ?? code) }; + }) + .filter((item): item is CodeLabelOption => !!item); + }); +} + +/** Enumerations of the first variable in `role` (x for "x", y otherwise). */ +function enumOptionsForRole(role: string | null | undefined, y: readonly StudioVariable[], x: readonly StudioVariable[]): ParameterOption[] { + const selected = role === 'x' ? x[0] : y[0]; + return Array.isArray(selected?.enumerations) ? [...selected.enumerations] : []; +} + +/** Distinct enumeration codes of the variable `varCode` among the selected variables. */ +export function enumOptionsForVariableCode(varCode: string | null | undefined, variables: readonly StudioVariable[]): CodeLabelOption[] { + if (!varCode) return []; + const variable = variables.find((item) => String(item?.code) === String(varCode)); + if (!variable) return []; + + const enumerations = Array.isArray(variable.enumerations) ? variable.enumerations : []; + const seen = new Set(); + return enumerations + .map((entry): CodeLabelOption | null => { + const record = entry && typeof entry === 'object' ? entry : null; + const code = nonBlankString(record?.code ?? record?.value ?? record?.name ?? record?.label); + if (code === null || seen.has(code)) return null; + seen.add(code); + return { code, label: String(record?.label ?? record?.name ?? code) }; + }) + .filter((item): item is CodeLabelOption => !!item); +} + +/** Selection context the parameter schema is enriched with. */ +export interface ParameterSchemaContext { + y: readonly StudioVariable[]; + x: readonly StudioVariable[]; + /** Live or stored `event_var` (Cox), which decides the `positive_class` options. */ + eventVarCode: string | null; + /** Cross-validation is on and the method can toggle it: adds a Folds field when the schema has none. */ + withCrossValidationFolds: boolean; +} + +/** + * The algorithm's parameters as the form shows them: options from the selected variables + * (`input_var_names`, `input_var_CDE_enums`, `category_order`, `positive_class`, y/x + * placeholders, the y enumerations as a fallback for empty selects), a human label, a + * description, and the Cox `positive_class` rules. + */ +export function enrichParameterSchema(algorithm: AlgorithmConfig, context: ParameterSchemaContext): EnrichedParameterField[] { + const schema: AlgorithmParameterField[] = (algorithm.configSchema ?? []).map((field) => ({ ...field })); + const { y, x } = context; + const yVar = y[0]; + const xVar = x[0]; + const positiveClassOptions = enumOptionsForVariableCode(context.eventVarCode, [...y, ...x]); + const isCox = isCoxRegressionAlgorithm(algorithm.name); + + const enriched = schema.map((field): EnrichedParameterField => { + let options: ParameterOption[] = field.options ?? []; + const enumSource = Array.isArray(field.enumSource) ? field.enumSource : []; + + if (field.enumType === 'input_var_names') { + options = variableOptionsForRoles(enumSource, y, x); + } else if (field.enumType === 'input_var_CDE_enums') { + options = enumOptionsForRole(enumSource[0], y, x); + } else if (field.key === 'category_order' && yVar?.enumerations?.length) { + options = [...yVar.enumerations]; + } else if (field.key === 'positive_class' && positiveClassOptions.length) { + options = positiveClassOptions; + } + + // Placeholder substitution for enums + if (Array.isArray(options) && options.length === 1) { + const placeholder = options[0]; + if (placeholder === 'y' && yVar?.enumerations?.length) { + options = [...yVar.enumerations]; + } else if (placeholder === 'x' && xVar?.enumerations?.length) { + options = [...xVar.enumerations]; + } + } + + // Fallback for empty select fields (not input_var_names — those list y/x variables) + if ( + field.type === 'select' && + field.enumType !== 'input_var_names' && + (!options || options.length === 0) && + yVar?.enumerations?.length + ) { + options = [...yVar.enumerations]; + } + + const defaultValue = field.key === 'category_order' && Array.isArray(options) && options.length > 0 + ? options.map((option) => optionValue(option)) + : field.default; + const label = field.label?.trim() || toHumanLabel(field.key); + const desc = + field.key === 'positive_class' && isCox + ? 'Event level mapped to 1; other observed levels are mapped to 0.' + : field.desc ?? field.description ?? ''; + const type = field.key === 'positive_class' && positiveClassOptions.length ? 'select' : field.type; + const required = + (field.key === 'positive_class' && isCox) || + field.required === true || + String(field.required ?? '').trim().toLowerCase() === 'true'; + + return { ...field, label, desc, options, default: defaultValue, type, required }; + }); + + if (context.withCrossValidationFolds && !enriched.some((field) => field.key === 'n_splits')) { + enriched.push({ + key: 'n_splits', + label: 'Folds', + desc: 'Number of parts the data is split into; each part is held out once.', + type: 'number', + min: 2, + default: 5, + }); + } + + return enriched; +} + +/** Identity of a built form: the form is rebuilt only when this changes. */ +export function parameterFormSignature(algorithmName: string, crossValidation: boolean, schema: readonly AlgorithmParameterField[]): string { + return JSON.stringify({ + algorithmName, + crossValidation, + fields: schema.map((field) => ({ + key: field.key, + type: field.type, + required: field.required, + options: (field.options ?? []).map((option) => optionValue(option)), + })), + }); +} + +/** + * The parameter form: one control per field, starting from the stored value, then the + * backend default, then {@link UI_DEFAULTS}. Integer-only number fields get a pattern + * validator. Controls that start valid are marked touched. + */ +export function buildParameterForm(schema: readonly EnrichedParameterField[], stored: Record): FormGroup { + const group: Record = {}; + + schema.forEach((field) => { + const storedValue = stored?.[field.key]; + const initialValue = storedValue !== undefined + ? storedValue + : field.default ?? UI_DEFAULTS[field.key] ?? (field.type === 'checkbox' ? false : ''); + const control = buildFormControl(field, initialValue); + + if (field.type === 'number' && !allowsDecimal(field)) { + const integerPattern = Validators.pattern(/^-?\d+$/); + const existingValidator = control.validator; + control.setValidators(existingValidator ? [existingValidator, integerPattern] : [integerPattern]); + control.updateValueAndValidity({ emitEvent: false }); + } + + group[field.key] = control; + }); + + const form = new FormGroup(group, { updateOn: 'change' }); + Object.values(form.controls).forEach((control) => { + if (control.valid) { + control.markAsTouched({ onlySelf: true }); + } + }); + return form; +} + +/** Form values as the request sends them: numbers parsed, enum values as codes, empty optionals dropped. */ +export function normalizeParameterValues(values: Record, schema: readonly EnrichedParameterField[]): Record { + const normalized = { ...values }; + schema + .filter((field) => field?.type === 'number') + .forEach((field) => { + const key = String(field.key); + const value = normalized[key]; + if (typeof value !== 'string') return; + const trimmed = value.trim(); + if (!trimmed) return; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) normalized[key] = parsed; + }); + + schema.forEach((field) => { + const key = String(field.key); + if (!(key in normalized)) return; + if (field?.type === 'select' || field?.type === 'multi-select' || key === 'positive_class') { + normalized[key] = serializeAlgorithmParameterValue(normalized[key], field); + } + }); + + return omitEmptyOptionalParameters(normalized, [...schema]); +} + +/** A multi-select control value as a list of strings (arrays as is, comma-separated strings split). */ +export function multiSelectValues(value: unknown): string[] { + if (Array.isArray(value)) return value.map((entry) => String(entry)); + if (typeof value === 'string' && value.trim() !== '') { + return value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''); + } + return []; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts new file mode 100644 index 00000000..73fa041f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts @@ -0,0 +1,73 @@ +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from './algorithm-run.utils'; + +const algorithm = (extra: Partial = {}): AlgorithmConfig => ({ + name: 'linear_regression', + label: 'Linear regression', + description: '', + requiredVariable: [], + covariate: [], + category: 'Regression', + configSchema: [], + isDisabled: false, + ...extra, +}); + +const outcomeDetail: AlgorithmAvailabilityDetail = { + role: 'y', label: 'Outcome', selectedCount: 0, minCount: 1, maxCount: 1, required: true, + types: ['real'], stattypes: [], messages: ['Outcome needs exactly 1, selected 0.'], satisfied: false, +}; + +describe('algorithm run utils', () => { + it('lists role needs, unavailable variables and run blockers in order', () => { + const needs = buildAlgorithmNeeds({ + algorithm: algorithm(), + details: [outcomeDetail], + hasOutcome: false, + unavailableVariablesReason: 'Remove variables not available in: ADNI', + requirements: [{ id: 'preprocessing', message: 'Apply missing value preprocessing before running algorithms.', actionLabel: 'Go to preprocessing' }], + }); + expect(needs.map((need) => [need.key, need.met, need.title])).toEqual([ + ['needs-y', false, 'Needs a numerical outcome'], + ['unavailable-variables', false, 'Remove variables not available in: ADNI'], + ['run-preprocessing', false, 'Apply missing value preprocessing before running algorithms.'], + ]); + }); + + it('says an outcome is ignored by methods without one', () => { + const [need] = buildAlgorithmNeeds({ + algorithm: algorithm({ inputdata: {} as AlgorithmConfig['inputdata'] }), + details: [], hasOutcome: true, unavailableVariablesReason: '', requirements: [], + }); + expect(need).toEqual({ key: 'needs-y', met: true, title: 'No outcome needed', messages: ['Your outcome is ignored'] }); + }); + + it('summarizes readiness', () => { + expect(readinessText({ algorithm: null, canRun: false, needs: [], y: [], x: [] })).toBe('Pick a method to continue'); + expect(readinessText({ algorithm: algorithm(), canRun: false, needs: [], y: [], x: [] })).toBe('Not ready to run yet'); + const needs = [ + { key: 'a', met: false, title: 'A', messages: ['one', 'two'] }, + { key: 'b', met: false, title: 'Fix B', messages: [], action: { id: 'availability' as const, message: 'Fix B', actionLabel: 'Go' } }, + ]; + expect(readinessText({ algorithm: algorithm(), canRun: false, needs, y: [], x: [] })).toBe('3 things left — one · two +1'); + expect(readinessText({ algorithm: algorithm(), canRun: true, needs: [], y: [{ code: 'age', label: 'Age' }], x: [{ code: 'sex' }] })) + .toBe('Ready · Linear regression · y Age · 1 predictor'); + }); + + it('resolves base, variant and sent names for cross-validation', () => { + const catalog = { + isCrossValidationOnly: (name: string) => name === 'svm_cv', + getCrossValidationBase: (name: string) => (name.endsWith('_cv') ? name.slice(0, -3) : null), + getCrossValidationVariant: (base: string) => (base === 'linear_regression' ? 'linear_regression_cv' : null), + }; + expect(resolveRunAlgorithmNames('linear_regression', false, catalog)).toEqual({ + baseName: 'linear_regression', effectiveName: 'linear_regression', finalName: 'linear_regression', includeSplits: false, + }); + expect(resolveRunAlgorithmNames('linear_regression', true, catalog)).toEqual({ + baseName: 'linear_regression', effectiveName: 'linear_regression_cv', finalName: 'linear_regression_cv', includeSplits: true, + }); + expect(resolveRunAlgorithmNames('svm_cv', false, catalog)).toEqual({ + baseName: 'svm_cv', effectiveName: 'svm_cv', finalName: 'svm_cv', includeSplits: true, + }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts new file mode 100644 index 00000000..7f4ca29c --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts @@ -0,0 +1,109 @@ +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmNeedItem, AlgorithmRunRequirement, StudioVariable } from '../algorithm-panel.models'; +import { needsRequirementText } from './algorithm-availability-text.utils'; + +/** What the needs list is built from. */ +interface AlgorithmNeedsInput { + algorithm: AlgorithmConfig; + details: readonly AlgorithmAvailabilityDetail[]; + /** An outcome is assigned (it is ignored by methods without one). */ + hasOutcome: boolean; + /** "Remove variables not available in: …", or ''. */ + unavailableVariablesReason: string; + requirements: readonly AlgorithmRunRequirement[]; +} + +/** The needs list: one line per role (outcome, then predictors), unavailable variables, then run blockers with actions. */ +export function buildAlgorithmNeeds(input: AlgorithmNeedsInput): AlgorithmNeedItem[] { + const needs: AlgorithmNeedItem[] = []; + const detailByRole = new Map(input.details.map((detail) => [detail.role, detail])); + + for (const role of ['y', 'x'] as const) { + const detail = detailByRole.get(role); + if (detail) { + needs.push({ + key: `needs-${role}`, + met: detail.messages.length === 0, + title: needsRequirementText(detail), + messages: [...detail.messages], + }); + } else if (role === 'y' && !('y' in (input.algorithm.inputdata ?? {}))) { + needs.push({ + key: 'needs-y', + met: true, + title: 'No outcome needed', + messages: input.hasOutcome ? ['Your outcome is ignored'] : [], + }); + } + } + + if (input.unavailableVariablesReason) { + needs.push({ key: 'unavailable-variables', met: false, title: input.unavailableVariablesReason, messages: [] }); + } + + input.requirements.forEach((requirement) => { + needs.push({ key: `run-${requirement.id}`, met: false, title: requirement.message, messages: [], action: requirement }); + }); + + return needs; +} + +/** What the run bar's status line is built from. */ +interface ReadinessInput { + algorithm: AlgorithmConfig | null; + canRun: boolean; + needs: readonly AlgorithmNeedItem[]; + y: readonly StudioVariable[]; + x: readonly StudioVariable[]; +} + +/** "Pick a method to continue", "2 things left — …", or "Ready · Method · y Outcome · 2 predictors". */ +export function readinessText({ algorithm, canRun, needs, y, x }: ReadinessInput): string { + if (!algorithm) return 'Pick a method to continue'; + + if (!canRun) { + const reasons = needs + .filter((need) => !need.met) + .flatMap((need) => (need.action ? [need.title] : need.messages)) + .filter((message) => !!message); + if (!reasons.length) return 'Not ready to run yet'; + return `${reasons.length} ${reasons.length === 1 ? 'thing' : 'things'} left — ${reasons.slice(0, 2).join(' · ')}${reasons.length > 2 ? ` +${reasons.length - 2}` : ''}`; + } + + const outcome = y[0] ? ` · y ${y[0].label ?? y[0].name ?? y[0].code}` : ''; + const predictors = ` · ${x.length} ${x.length === 1 ? 'predictor' : 'predictors'}`; + return `Ready · ${algorithm.label}${outcome}${predictors}`; +} + +/** Cross-validation lookups of ExperimentStudioService the run naming needs. */ +export interface CrossValidationCatalog { + isCrossValidationOnly(name: string): boolean; + getCrossValidationBase(name: string): string | null; + getCrossValidationVariant(baseName: string): string | null; +} + +/** Which algorithm names a run uses. */ +export interface RunAlgorithmNames { + /** The base method; its config is stored under this name. */ + baseName: string; + /** The cross-validation variant when it runs, else the base. */ + effectiveName: string; + /** The name sent and recorded as the run's algorithm. */ + finalName: string; + /** Whether `n_splits` stays in the parameters. */ + includeSplits: boolean; +} + +/** Resolves base, variant and sent names for running `algorithmName` with cross-validation on or off. */ +export function resolveRunAlgorithmNames( + algorithmName: string, + crossValidationEnabled: boolean, + catalog: CrossValidationCatalog, +): RunAlgorithmNames { + const isCvOnly = catalog.isCrossValidationOnly(algorithmName); + const baseName = isCvOnly ? algorithmName : catalog.getCrossValidationBase(algorithmName) ?? algorithmName; + const variant = catalog.getCrossValidationVariant(baseName); + const includeSplits = crossValidationEnabled || isCvOnly; + const effectiveName = includeSplits && variant ? variant : baseName; + return { baseName, effectiveName, finalName: isCvOnly ? algorithmName : effectiveName, includeSplits }; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts new file mode 100644 index 00000000..90e02e4e --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts @@ -0,0 +1,14 @@ +import { tooltipPosition } from './tooltip-position.utils'; + +describe('tooltipPosition', () => { + const container = new DOMRect(100, 50, 1000, 800); + const placement = { width: 320, height: 200, viewportWidth: 1280, viewportHeight: 800, padding: 16, offset: 12 }; + + it('places the tooltip right of the item, centred, relative to the container', () => { + expect(tooltipPosition(new DOMRect(200, 300, 100, 40), container, placement)).toEqual({ x: 212, y: 170 }); + }); + + it('flips left when there is no room on the right and clamps to the viewport', () => { + expect(tooltipPosition(new DOMRect(1000, 700, 100, 40), container, placement)).toEqual({ x: 568, y: 534 }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts new file mode 100644 index 00000000..147c689f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts @@ -0,0 +1,39 @@ +/** Tooltip box and spacing for {@link tooltipPosition}. */ +export interface TooltipPlacement { + width: number; + height: number; + viewportWidth: number; + viewportHeight: number; + padding: number; + offset: number; +} + +/** + * Where to put a tooltip for an item, in coordinates relative to `containerRect`: right of + * the item, else left of it, else pinned to the right viewport edge; vertically centred on + * the item and kept inside the viewport, never closer than `padding` to the container edge. + */ +export function tooltipPosition(rect: DOMRect, containerRect: DOMRect, placement: TooltipPlacement): { x: number; y: number } { + const { width, height, viewportWidth, viewportHeight, padding, offset } = placement; + const rightAlignedX = rect.right + offset; + const leftAlignedX = rect.left - width - offset; + const centeredY = rect.top + (rect.height - height) / 2; + + let x = rightAlignedX; + if (rightAlignedX + width > viewportWidth - padding) { + x = leftAlignedX >= padding ? leftAlignedX : viewportWidth - width - padding; + } + + let y = centeredY; + if (y + height > viewportHeight - padding) { + y = viewportHeight - height - padding; + } + if (y < padding) { + y = padding; + } + + return { + x: Math.max(padding, x - containerRect.left), + y: Math.max(padding, y - containerRect.top), + }; +} From f7eecbda76b8ee0dc10a3e8a4bfecea89bae200d Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:07:10 +0200 Subject: [PATCH 2/8] refactor(algorithm-panel): replace any with typed models The panel's 28 any are gone: parameters are AlgorithmParameterField / EnrichedParameterField, studio variables StudioVariable, outlier rows OutlierReportVariable, the tooltip holds an AlgorithmConfig and the export run info is ExperimentRunInfo. Option labels go through optionLabel instead of an untyped template expression, and the outlier enable checkbox reads a template reference instead of $any($event.target). experimentInfo no longer builds a preprocessingSteps list nothing read. --- .../algorithm-panel.component.html | 8 +- .../algorithm-panel.component.ts | 108 +++++++++--------- .../algorithm-panel/algorithm-panel.models.ts | 14 +++ 3 files changed, 69 insertions(+), 61 deletions(-) diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index 4103a7b2..40a1a1b8 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -188,8 +188,8 @@
Per-variable Outlier Rules
@@ -351,7 +351,7 @@
No additional parameters for this algorithm
@for (opt of field.options; track opt) { } @@ -361,7 +361,7 @@
No additional parameters for this algorithm
} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index 93d90d47..3b41c3b9 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -40,29 +40,23 @@ import { multiSelectValues, nonBlankString, normalizeParameterValues, + optionLabel, parameterFieldLabel, parameterFormSignature, } from './utils/algorithm-parameter-form.utils'; import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from './utils/algorithm-run.utils'; import { tooltipPosition } from './utils/tooltip-position.utils'; - -type AlgorithmStudioSubstep = 'setup' | 'parameters'; - -type AlgorithmRunRequirementKind = 'availability' | 'preprocessing'; - -interface AlgorithmRunRequirement { - id: AlgorithmRunRequirementKind; - message: string; - actionLabel: string; -} - -interface AlgorithmNeedItem { - key: string; - met: boolean; - title: string; - messages: string[]; - action?: AlgorithmRunRequirement; -} +import { + AlgorithmNeedItem, + AlgorithmParameterField, + AlgorithmRunRequirement, + AlgorithmStudioSubstep, + EnrichedParameterField, + ExperimentRunInfo, + OutlierReportVariable, + ParameterOption, + StudioVariable, +} from './algorithm-panel.models'; @Component({ selector: 'app-algorithm-panel', @@ -255,7 +249,7 @@ export class AlgorithmPanelComponent { return this.filteredAlgorithmCategories().some(c => c.algorithms?.length > 0); }); - prettyFieldLabel(field: any): string { + prettyFieldLabel(field: EnrichedParameterField): string { return parameterFieldLabel(field, this.experimentStudioService.algorithmX()[0]); } @@ -429,11 +423,12 @@ export class AlgorithmPanelComponent { }); readonly isAdvancedField = isAdvancedField; + readonly optionLabel = optionLabel; readonly allowsDecimal = allowsDecimal; /** n_splits belongs to the cross-validation row, so it renders under that switch, not in the main form. */ - isCrossValidationField(field: any): boolean { - return field?.key === 'n_splits' && (this.canToggleCrossValidation() || this.isCrossValidationOnly()); + isCrossValidationField(field: AlgorithmParameterField): boolean { + return field.key === 'n_splits' && (this.canToggleCrossValidation() || this.isCrossValidationOnly()); } readonly primaryConfigSchema = computed(() => @@ -453,9 +448,9 @@ export class AlgorithmPanelComponent { ); readonly outlierReportVariables = computed(() => { - const unique = new Map(); - this.experimentStudioService.algorithmAssignableVariables().forEach((variable: any) => { - if (variable?.code) unique.set(String(variable.code), variable); + const unique = new Map(); + this.experimentStudioService.algorithmAssignableVariables().forEach((variable: StudioVariable) => { + if (hasCode(variable)) unique.set(String(variable.code), variable); }); return Array.from(unique.values()).filter((variable) => isOutlierEligibleVariable(variable)); }); @@ -555,7 +550,8 @@ export class AlgorithmPanelComponent { })); }); - readonly experimentInfo = computed(() => { + /** Name, roles, filters and parameters of the run the exports describe. */ + readonly experimentInfo = computed((): ExperimentRunInfo => { const selected = this.experimentStudioService.selectedAlgorithm(); const lastRunName = this.experimentStudioService.lastUsedAlgorithm() || this.lastUsedAlgorithm; @@ -566,25 +562,14 @@ export class AlgorithmPanelComponent { const algoMeta = algoName ? algoCatalog[algoName] : null; const displayLabel = selected?.label || algoMeta?.label || algoName || 'N/A'; - const defaultName = `Experiment for ${displayLabel}`; - const allConfigs = this.experimentStudioService.algorithmConfigurations(); - const configs = - (algoName && allConfigs[algoName]) || {}; - - const preprocessingSteps = this.experimentStudioService.formatPreprocessingEntries( - this.experimentStudioService.getAppliedDescriptivePreprocessing(), - this.labelMap(), - this.experimentStudioService.getCategoricalEnumMaps(), - ); return { - experimentName: defaultName, + experimentName: `Experiment for ${displayLabel}`, variables: this.experimentStudioService.algorithmY(), covariates: this.experimentStudioService.algorithmX(), filters: this.experimentStudioService.selectedFilters(), - algorithmConfigs: configs, - preprocessingSteps, + algorithmConfigs: (algoName && allConfigs[algoName]) || {}, }; }); @@ -607,7 +592,7 @@ export class AlgorithmPanelComponent { } } - private persistCurrentFormConfig(algorithmName: string, schema = this.visibleConfigSchema()): Record { + private persistCurrentFormConfig(algorithmName: string, schema = this.visibleConfigSchema()): Record { const values = this.normalizeFormValues(this.configForm().getRawValue(), schema); const nextValues = algorithmName === AlgorithmNames.OUTLIER_REPORT ? this.mergeOutlierReportConfig(values) @@ -624,15 +609,15 @@ export class AlgorithmPanelComponent { return nextValues; } - bindOptionValue(option: unknown): string { + bindOptionValue(option: ParameterOption): string { return optionBindingValue(option); } - private normalizeFormValues(values: Record, schema: any[]): Record { + private normalizeFormValues(values: Record, schema: readonly EnrichedParameterField[]): Record { return normalizeParameterValues(values, schema); } - private mergeOutlierReportConfig(configValues: Record): Record { + private mergeOutlierReportConfig(configValues: Record): Record { if (!this.isOutlierReportSelected()) return configValues; delete configValues['strategies']; delete configValues['tails']; @@ -655,7 +640,7 @@ export class AlgorithmPanelComponent { }); } - private reconcileOutlierReportRules(variables: any[], stored: Record): void { + private reconcileOutlierReportRules(variables: OutlierReportVariable[], stored: Record): void { const allowedCodes = new Set(variables.map((variable) => variable.code)); const hydrated = hydrateOutlierRules(stored, allowedCodes); const hasStoredRules = !!stored && typeof stored === 'object' && Object.keys(stored?.['strategies'] ?? {}).length > 0; @@ -677,7 +662,7 @@ export class AlgorithmPanelComponent { ); } - outlierReportRuleFor(variable: any): OutlierRule { + outlierReportRuleFor(variable: OutlierReportVariable): OutlierRule { if (!this.outlierReportRules[variable.code]) { this.outlierReportRules = { ...this.outlierReportRules, @@ -687,11 +672,11 @@ export class AlgorithmPanelComponent { return this.outlierReportRules[variable.code]; } - outlierVariableLabel(variable: any): string { + outlierVariableLabel(variable: OutlierReportVariable): string { return variable?.label ?? variable?.name ?? variable?.code ?? ''; } - onOutlierReportEnabledChange(variable: any, enabled: boolean): void { + onOutlierReportEnabledChange(variable: OutlierReportVariable, enabled: boolean): void { const existing = this.outlierReportRuleFor(variable); this.outlierReportRules = { ...this.outlierReportRules, @@ -704,7 +689,7 @@ export class AlgorithmPanelComponent { this.persistOutlierReportConfig(); } - onOutlierReportStrategyChange(variable: any, strategy: OutlierStrategy): void { + onOutlierReportStrategyChange(variable: OutlierReportVariable, strategy: OutlierStrategy): void { const existing = this.outlierReportRuleFor(variable); this.outlierReportRules = { ...this.outlierReportRules, @@ -722,7 +707,7 @@ export class AlgorithmPanelComponent { this.persistOutlierReportConfig(); } - onOutlierReportTailChange(variable: any, tail: OutlierTail): void { + onOutlierReportTailChange(variable: OutlierReportVariable, tail: OutlierTail): void { const existing = this.outlierReportRuleFor(variable); this.outlierReportRules = { ...this.outlierReportRules, @@ -731,7 +716,7 @@ export class AlgorithmPanelComponent { this.persistOutlierReportConfig(); } - onOutlierReportFoldChange(variable: any, rawValue: string | number): void { + onOutlierReportFoldChange(variable: OutlierReportVariable, rawValue: string | number): void { const existing = this.outlierReportRuleFor(variable); const value = String(rawValue ?? '').trim(); const fold = value === '' ? null : Number(value); @@ -751,7 +736,7 @@ export class AlgorithmPanelComponent { this.persistOutlierReportConfig(); } - outlierReportRuleError(variable: any): string { + outlierReportRuleError(variable: OutlierReportVariable): string { const rule = this.outlierReportRuleFor(variable); return this.outlierReportValidationErrors[variable.code] || validateOutlierRule(rule) || ''; } @@ -805,7 +790,7 @@ export class AlgorithmPanelComponent { readonly tooltipVisible = signal(false); readonly tooltipPosition = signal({ x: 0, y: 0 }); - readonly tooltipData = signal(null); + readonly tooltipData = signal(null); selectAlgorithm(algorithm: AlgorithmConfig) { this.lastBuiltFormSchemaSignature = ''; @@ -998,12 +983,12 @@ export class AlgorithmPanelComponent { return multiSelectValues(this.configForm().get(key)?.value); } - isMultiSelectOptionSelected(key: string, option: unknown): boolean { + isMultiSelectOptionSelected(key: string, option: ParameterOption): boolean { const value = String(this.bindOptionValue(option)); return this.multiSelectValues(key).includes(value); } - toggleMultiSelectOption(key: string, option: unknown): void { + toggleMultiSelectOption(key: string, option: ParameterOption): void { const control = this.configForm().get(key); if (!control) return; const value = String(this.bindOptionValue(option)); @@ -1057,7 +1042,7 @@ export class AlgorithmPanelComponent { return 'Assign outcome'; } - showTooltip(algorithm: any, event: MouseEvent) { + showTooltip(algorithm: AlgorithmConfig, event: MouseEvent) { event.stopPropagation(); const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement); const rect = item.getBoundingClientRect(); @@ -1159,11 +1144,11 @@ export class AlgorithmPanelComponent { .join('\n'), domain: model ? [model.label || model.code, model.version].filter(Boolean).join(' ') : null, datasets: this.datasetsWithLabels().map((d) => d.label), - variables: (info.variables ?? []).map((v: any) => v.label || v.name || v.code), - covariates: (info.covariates ?? []).map((c: any) => c.label || c.name || c.code), + variables: (info.variables ?? []).map(variableDisplayName), + covariates: (info.covariates ?? []).map(variableDisplayName), filters: filterExpression ? [filterExpression] - : (info.filters ?? []).map((f: any) => f.label || f.name || f.code), + : (info.filters ?? []).map(variableDisplayName), transformations: null, mipVersion: this.mipVersion, }, @@ -1172,3 +1157,12 @@ export class AlgorithmPanelComponent { }; } } + +/** How exports name a variable: label, then name, then code. */ +function variableDisplayName(variable: StudioVariable): string { + return variable.label || variable.name || variable.code || ''; +} + +function hasCode(variable: StudioVariable | null | undefined): variable is OutlierReportVariable { + return !!variable?.code; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts index 901a0044..81289348 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts @@ -76,3 +76,17 @@ export interface CodeLabelOption { code: string; label: string; } + +/** A numerical variable the outlier report editor lists (always has a code). */ +export interface OutlierReportVariable extends StudioVariable { + code: string; +} + +/** The run an export describes. */ +export interface ExperimentRunInfo { + experimentName: string; + variables: StudioVariable[]; + covariates: StudioVariable[]; + filters: StudioVariable[]; + algorithmConfigs: Record; +} From e3361bcba364932a32607e87f59cd9fee53900e3 Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:10:50 +0200 Subject: [PATCH 3/8] refactor(algorithm-panel): signals for mutable fields Outlier rules and errors, the built-form signature and the last run algorithm become signals; category collapse becomes a linkedSignal; outlierReportRuleFor is a pure read; unused formKey removed. --- .../algorithm-panel.component.ts | 219 +++++++----------- 1 file changed, 90 insertions(+), 129 deletions(-) diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index 3b41c3b9..8efaa7e2 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -1,5 +1,5 @@ import { SessionStorageService } from './../../../services/session-storage.service'; -import { ChangeDetectionStrategy, Component, inject, signal, computed, effect, untracked, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal, computed, effect, untracked, output, linkedSignal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -47,6 +47,7 @@ import { import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from './utils/algorithm-run.utils'; import { tooltipPosition } from './utils/tooltip-position.utils'; import { + AlgorithmCategory, AlgorithmNeedItem, AlgorithmParameterField, AlgorithmRunRequirement, @@ -80,7 +81,8 @@ export class AlgorithmPanelComponent { private studioNavigation = inject(ExperimentStudioNavigationService); experimentStudioService = inject(ExperimentStudioService); sessionStorage = inject(SessionStorageService); - lastUsedAlgorithm = ''; + /** The algorithm of the last successful run; the exports name it. */ + private readonly lastRunAlgorithm = signal(''); errorMsg = signal(null); readonly isRunning = this.experimentStudioService.isRunning; @@ -161,7 +163,8 @@ export class AlgorithmPanelComponent { readonly yVar = computed(() => this.experimentStudioService.algorithmY()[0]?.code ?? null); readonly xVar = computed(() => this.experimentStudioService.algorithmX()[0]?.code ?? null); readonly crossValidationEnabled = signal(false); - private readonly crossValidationSelections: Record = {}; + /** Cross-validation on/off the user chose per base method; a memory, not reactive state. */ + private readonly crossValidationChoices = new Map(); readonly isCrossValidationOnly = computed(() => { const algorithm = this.selectedAlgorithm(); if (!algorithm) return false; @@ -169,8 +172,10 @@ export class AlgorithmPanelComponent { }); readonly outlierStrategies = OUTLIER_STRATEGIES; readonly outlierTails = OUTLIER_TAILS; - outlierReportRules: Record = {}; - outlierReportValidationErrors: Record = {}; + /** Per-variable outlier_report rules, keyed by variable code. */ + readonly outlierReportRules = signal>({}); + /** Fold errors shown under each outlier_report rule, keyed by variable code. */ + readonly outlierReportValidationErrors = signal>({}); readonly canToggleCrossValidation = computed(() => { const algorithm = this.selectedAlgorithm(); if (!algorithm) return false; @@ -311,7 +316,7 @@ export class AlgorithmPanelComponent { this.crossValidationEnabled.set(false); return; } - const stored = this.crossValidationSelections[baseName]; + const stored = this.crossValidationChoices.get(baseName); const defaultValue = stored !== undefined ? stored : this.experimentStudioService.isCrossValidationAlgorithm(algorithm.name); @@ -333,7 +338,7 @@ export class AlgorithmPanelComponent { const algorithm = this.selectedAlgorithm(); const variables = this.outlierReportVariables(); if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) { - this.outlierReportValidationErrors = {}; + this.outlierReportValidationErrors.set({}); return; } const stored = untracked(() => this.experimentStudioService.algorithmConfigurations()[algorithm.name] ?? {}); @@ -345,7 +350,7 @@ export class AlgorithmPanelComponent { const schema = this.visibleConfigSchema(); if (!algorithm) { - this.lastBuiltFormSchemaSignature = ''; + this.builtFormSignature.set(''); this.eventVarSelection.set(null); this.configForm.set(new FormGroup({})); this.formInvalid.set(false); @@ -353,10 +358,10 @@ export class AlgorithmPanelComponent { } const schemaSignature = parameterFormSignature(algorithm.name, this.crossValidationEnabled(), schema); - if (schemaSignature === this.lastBuiltFormSchemaSignature) { + if (schemaSignature === untracked(this.builtFormSignature)) { return; } - this.lastBuiltFormSchemaSignature = schemaSignature; + this.builtFormSignature.set(schemaSignature); const allConfigs = untracked(() => this.experimentStudioService.algorithmConfigurations()); const stored = allConfigs?.[algorithm.name] || {}; @@ -378,8 +383,6 @@ export class AlgorithmPanelComponent { subscription.unsubscribe(); statusSub.unsubscribe(); }); - - this.formKey++; }); effect(() => { const res = this.experimentStudioService.runResult(); @@ -393,17 +396,12 @@ export class AlgorithmPanelComponent { } }); - effect(() => { - const categories = this.filteredAlgorithmCategories(); - const selectedName = this.selectedAlgorithm()?.name ?? null; - untracked(() => this.syncMatchCategoryOpen(categories, selectedName)); - }); } configForm = signal(new FormGroup({})); - formKey = 0; - private lastBuiltFormSchemaSignature = ''; + /** Signature of the built form; cleared so that picking a method always rebuilds it. */ + private readonly builtFormSignature = signal(''); enrichedConfigSchema = computed(() => { const algorithm = this.selectedAlgorithm(); @@ -481,53 +479,42 @@ export class AlgorithmPanelComponent { this.algoSearchQuery.set(''); } + /** Groups the user opened or closed themselves; these are never changed automatically. */ + private readonly userToggledCategories = signal>(new Set()); + /** - * Collapsed Matching-method categories. A category missing from this set is open, so - * categories coming back after a filter switch keep the state the user left them in. + * Collapsed catalog groups. A group missing from this set is open, so groups coming back + * after a filter switch keep the state the user left them in. Recomputed from the listed + * groups and the selection (see nextCollapsedCategories); a toggle sets it directly. */ - private readonly collapsedMatchCategories = signal>(new Set()); - /** Categories the user opened/closed themselves; these are never changed automatically. */ - private readonly matchCategoryUserToggles = new Set(); - private lastMatchSelectionName: string | null | undefined; + private readonly collapsedMatchCategories = linkedSignal< + { categories: AlgorithmCategory[]; selectedName: string | null }, + ReadonlySet + >({ + source: () => ({ + categories: this.filteredAlgorithmCategories(), + selectedName: this.selectedAlgorithm()?.name ?? null, + }), + computation: (source, previous) => nextCollapsedCategories( + source.categories, + source.selectedName, + previous?.source.selectedName !== source.selectedName, + previous?.value ?? new Set(), + untracked(this.userToggledCategories), + ), + }); isMatchCategoryOpen(categoryName: string): boolean { return !this.collapsedMatchCategories().has(categoryName); } toggleMatchCategory(categoryName: string): void { - this.matchCategoryUserToggles.add(categoryName); + this.userToggledCategories.update((toggled) => new Set(toggled).add(categoryName)); const next = new Set(this.collapsedMatchCategories()); - if (next.has(categoryName)) { - next.delete(categoryName); - } else { - next.add(categoryName); - } + if (!next.delete(categoryName)) next.add(categoryName); this.collapsedMatchCategories.set(next); } - /** - * Default state: a group is open only when it holds the selected method or at least one - * runnable method — with nothing runnable in a group it stays collapsed so the catalog - * opens straight to what can actually be executed. Beyond that default, the group a new - * selection lands in is revealed; groups the user toggled are never changed automatically. - */ - private syncMatchCategoryOpen( - categories: Array<{ name: string; algorithms: AlgorithmConfig[] }>, - selectedName: string | null, - ): void { - const selectionChanged = this.lastMatchSelectionName !== selectedName; - this.lastMatchSelectionName = selectedName; - - const next = nextCollapsedCategories( - categories, - selectedName, - selectionChanged, - this.collapsedMatchCategories(), - this.matchCategoryUserToggles, - ); - if (next !== this.collapsedMatchCategories()) this.collapsedMatchCategories.set(next); - } - algorithmUnavailableReason(algorithm: AlgorithmConfig): string { const availability = this.experimentStudioService.getAlgorithmAvailability(algorithm.name); const summary = availability?.summary || algorithm.availability?.summary || ''; @@ -554,7 +541,7 @@ export class AlgorithmPanelComponent { readonly experimentInfo = computed((): ExperimentRunInfo => { const selected = this.experimentStudioService.selectedAlgorithm(); const lastRunName = - this.experimentStudioService.lastUsedAlgorithm() || this.lastUsedAlgorithm; + this.experimentStudioService.lastUsedAlgorithm() || this.lastRunAlgorithm(); const algoName = selected?.name || lastRunName || null; @@ -623,7 +610,7 @@ export class AlgorithmPanelComponent { delete configValues['tails']; delete configValues['folds']; const payload = serializeOutlierRules( - this.outlierReportRules, + this.outlierReportRules(), new Set(this.outlierReportVariables().map((variable) => variable.code)) ); return payload ? { ...configValues, ...payload } : configValues; @@ -640,36 +627,28 @@ export class AlgorithmPanelComponent { }); } + /** One rule per listed variable: kept, else restored from the stored config, else a default. */ private reconcileOutlierReportRules(variables: OutlierReportVariable[], stored: Record): void { const allowedCodes = new Set(variables.map((variable) => variable.code)); const hydrated = hydrateOutlierRules(stored, allowedCodes); const hasStoredRules = !!stored && typeof stored === 'object' && Object.keys(stored?.['strategies'] ?? {}).length > 0; - const hasExistingRules = Object.keys(this.outlierReportRules).length > 0; + const current = untracked(this.outlierReportRules); + const enableDefaults = Object.keys(current).length > 0 || !hasStoredRules; const next: Record = {}; - variables.forEach((variable) => { - const code = variable.code; - next[code] = this.outlierReportRules[code] - ? { ...this.outlierReportRules[code] } - : hydrated[code] - ? { ...hydrated[code] } - : createDefaultOutlierRule(code, hasExistingRules || !hasStoredRules); + variables.forEach(({ code }) => { + const kept = current[code] ?? hydrated[code]; + next[code] = kept ? { ...kept } : createDefaultOutlierRule(code, enableDefaults); }); - this.outlierReportRules = next; - this.outlierReportValidationErrors = Object.fromEntries( - Object.entries(this.outlierReportValidationErrors).filter(([code]) => allowedCodes.has(code)) - ); + this.outlierReportRules.set(next); + this.outlierReportValidationErrors.update((errors) => + Object.fromEntries(Object.entries(errors).filter(([code]) => allowedCodes.has(code)))); } + /** The variable's rule; a variable without one reads as an enabled default rule. */ outlierReportRuleFor(variable: OutlierReportVariable): OutlierRule { - if (!this.outlierReportRules[variable.code]) { - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: createDefaultOutlierRule(variable.code, true), - }; - } - return this.outlierReportRules[variable.code]; + return this.outlierReportRules()[variable.code] ?? createDefaultOutlierRule(variable.code, true); } outlierVariableLabel(variable: OutlierReportVariable): string { @@ -677,68 +656,44 @@ export class AlgorithmPanelComponent { } onOutlierReportEnabledChange(variable: OutlierReportVariable, enabled: boolean): void { - const existing = this.outlierReportRuleFor(variable); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { ...existing, enabled }, - }; - this.outlierReportValidationErrors = { - ...this.outlierReportValidationErrors, - [variable.code]: '', - }; - this.persistOutlierReportConfig(); + this.patchOutlierRule(variable, { enabled }, () => ''); } onOutlierReportStrategyChange(variable: OutlierReportVariable, strategy: OutlierStrategy): void { - const existing = this.outlierReportRuleFor(variable); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { - ...existing, - enabled: true, - strategy, - fold: defaultFoldForStrategy(strategy), - }, - }; - this.outlierReportValidationErrors = { - ...this.outlierReportValidationErrors, - [variable.code]: '', - }; - this.persistOutlierReportConfig(); + this.patchOutlierRule(variable, { enabled: true, strategy, fold: defaultFoldForStrategy(strategy) }, () => ''); } onOutlierReportTailChange(variable: OutlierReportVariable, tail: OutlierTail): void { - const existing = this.outlierReportRuleFor(variable); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { ...existing, enabled: true, tail }, - }; - this.persistOutlierReportConfig(); + this.patchOutlierRule(variable, { enabled: true, tail }); } onOutlierReportFoldChange(variable: OutlierReportVariable, rawValue: string | number): void { - const existing = this.outlierReportRuleFor(variable); const value = String(rawValue ?? '').trim(); const fold = value === '' ? null : Number(value); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { - ...existing, - enabled: true, - fold: typeof fold === 'number' && Number.isFinite(fold) ? fold : null, - }, - }; - const error = validateOutlierRule(this.outlierReportRules[variable.code]); - this.outlierReportValidationErrors = { - ...this.outlierReportValidationErrors, - [variable.code]: error ?? '', - }; + this.patchOutlierRule( + variable, + { enabled: true, fold: typeof fold === 'number' && Number.isFinite(fold) ? fold : null }, + (rule) => validateOutlierRule(rule) ?? '', + ); + } + + /** Applies an edit to one rule, optionally replaces its shown error, and persists the config. */ + private patchOutlierRule( + variable: OutlierReportVariable, + patch: Partial, + errorFor?: (rule: OutlierRule) => string, + ): void { + const rule = { ...this.outlierReportRuleFor(variable), ...patch }; + this.outlierReportRules.update((rules) => ({ ...rules, [variable.code]: rule })); + if (errorFor) { + this.outlierReportValidationErrors.update((errors) => ({ ...errors, [variable.code]: errorFor(rule) })); + } this.persistOutlierReportConfig(); } outlierReportRuleError(variable: OutlierReportVariable): string { const rule = this.outlierReportRuleFor(variable); - return this.outlierReportValidationErrors[variable.code] || validateOutlierRule(rule) || ''; + return this.outlierReportValidationErrors()[variable.code] || validateOutlierRule(rule) || ''; } private validateOutlierReportConfig(): boolean { @@ -750,21 +705,27 @@ export class AlgorithmPanelComponent { return false; } + // Every listed variable runs with a rule, so the defaults it reads as become real rules. + this.outlierReportRules.update((rules) => { + const complete = { ...rules }; + variables.forEach((variable) => { complete[variable.code] ??= createDefaultOutlierRule(variable.code, true); }); + return complete; + }); + const errors: Record = {}; variables.forEach((variable) => { - const rule = this.outlierReportRuleFor(variable); - const error = validateOutlierRule(rule); + const error = validateOutlierRule(this.outlierReportRuleFor(variable)); if (error) errors[variable.code] = error; }); - this.outlierReportValidationErrors = errors; + this.outlierReportValidationErrors.set(errors); if (Object.keys(errors).length > 0) { this.errorMsg.set('Fix the outlier report configuration before running.'); return false; } const serialized = serializeOutlierRules( - this.outlierReportRules, + this.outlierReportRules(), new Set(variables.map((variable) => variable.code)) ); if (!serialized) { @@ -793,7 +754,7 @@ export class AlgorithmPanelComponent { readonly tooltipData = signal(null); selectAlgorithm(algorithm: AlgorithmConfig) { - this.lastBuiltFormSchemaSignature = ''; + this.builtFormSignature.set(''); this.eventVarSelection.set(null); // Use service method so it enriches configSchema and persists to sessionStorage this.experimentStudioService.setAlgorithm(algorithm); @@ -925,7 +886,7 @@ export class AlgorithmPanelComponent { }); // Save persists this kept run, so the saved experiment is the result shown here. this.experimentStudioService.runId.set(res?.uuid ?? null); - this.lastUsedAlgorithm = finalAlgorithmName; + this.lastRunAlgorithm.set(finalAlgorithmName); this.experimentStudioService.lastRunSchema.set(schema); }, error: () => { @@ -952,7 +913,7 @@ export class AlgorithmPanelComponent { const currentFormValues = this.configForm()?.getRawValue?.() ?? {}; const preservedConfig = { ...(configs[baseName] ?? {}), ...currentFormValues }; - this.crossValidationSelections[baseName] = enabled; + this.crossValidationChoices.set(baseName, enabled); if (!enabled) { if (preservedConfig['n_splits'] !== undefined) delete preservedConfig['n_splits']; @@ -1111,7 +1072,7 @@ export class AlgorithmPanelComponent { const info = this.experimentInfo(); const algoKey = - this.lastUsedAlgorithm || + this.lastRunAlgorithm() || this.experimentStudioService.lastUsedAlgorithm() || this.experimentStudioService.selectedAlgorithm()?.name || 'experiment'; From eb90adb1ee3ad309279f8ad66d79fc6800d83c8a Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:22:37 +0200 Subject: [PATCH 4/8] refactor(algorithm-panel): catalog, config-form and run services AlgorithmCatalogService owns search, filter, group collapse and availability; AlgorithmConfigFormService the parameter form, cross-validation and outlier rules; AlgorithmRunService run requirements, the run and the exports; AlgorithmStudioSubstepService the sub-step behind studioSubstepChange. The panel delegates its public API to them. Unused yVar, xVar, selectedVariables, isAlgorithmAvailable and SessionStorage removed. Service-level spec cases move to service specs. --- .../algorithm-panel.component.html | 164 +-- .../algorithm-panel.component.spec.ts | 229 +--- .../algorithm-panel.component.ts | 1116 ++--------------- .../algorithm-catalog.service.spec.ts | 119 ++ .../algorithm-catalog.service.ts | 108 ++ .../algorithm-config-form.service.spec.ts | 102 ++ .../algorithm-config-form.service.ts | 466 +++++++ .../algorithm-studio-substep.service.ts | 21 + .../algorithm-run.service.spec.ts | 216 ++++ .../algorithm-run.service.ts | 405 ++++++ .../algorithm-availability-text.utils.spec.ts | 7 + 11 files changed, 1639 insertions(+), 1314 deletions(-) create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index 40a1a1b8..1bd13ecf 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -1,12 +1,12 @@
-@if (assignablePool.length > 0) { +@if (assignablePool().length > 0) {
Algorithm
- {{ algoCounts().active }} of {{ algoCounts().all }} runnable + {{ catalog.algoCounts().active }} of {{ catalog.algoCounts().all }} runnable
@@ -27,45 +27,45 @@

Algorithm catalog

type="search" placeholder="Search methods…" autocomplete="off" - [value]="algoSearchQuery()" + [value]="catalog.algoSearchQuery()" (input)="onSearchInput($event)" /> - @if (searchActive()) { + @if (catalog.searchActive()) { }
- @if (hasAnyVisibleAlgorithms()) { + @if (catalog.hasAnyVisibleAlgorithms()) {
- @for (category of filteredAlgorithmCategories(); track category.name) { + @for (category of catalog.filteredAlgorithmCategories(); track category.name) {
- @if (isMatchCategoryOpen(category.name) || searchActive()) { + @if (catalog.isCategoryOpen(category.name) || catalog.searchActive()) {
@for (algorithm of category.algorithms; track algorithm.name) { } @@ -91,9 +91,9 @@

Algorithm catalog

}
- } @else if (searchActive()) { -

No methods match “{{ algoSearchQuery() }}”.

- } @else if (showOnlyActive()) { + } @else if (catalog.searchActive()) { +

No methods match “{{ catalog.algoSearchQuery() }}”.

+ } @else if (catalog.showOnlyActive()) {

No runnable methods for this assignment. Switch to All to see why.

} @else {

No methods in the catalog.

@@ -130,10 +130,10 @@

{{ algorithm.label }}

@if (selectedAlgorithm(); as algorithm) {
- @if (algorithmNeeds().length) { + @if (run.algorithmNeeds().length) {
  • Complete before running
  • - @for (need of algorithmNeeds(); track need.key) { + @for (need of run.algorithmNeeds(); track need.key) {
  • {{ need.title }} @@ -152,22 +152,22 @@

    {{ algorithm.label }}

    ✓ All inputs match

    } -
    +
    - @if (hasAlgorithmParameterControls()) { + @if (form.hasAlgorithmParameterControls()) {

    Parameters

    } - @if (isOutlierReportSelected()) { + @if (form.isOutlierReportSelected()) {
    Per-variable Outlier Rules
    - {{ outlierReportVariables().length }} numerical + {{ form.outlierReportVariables().length }} numerical
    - @if (outlierReportVariables().length > 0) { + @if (form.outlierReportVariables().length > 0) {
    @@ -180,7 +180,7 @@
    Per-variable Outlier Rules
    - @for (variable of outlierReportVariables(); track variable.code) { + @for (variable of form.outlierReportVariables(); track variable.code) { @@ -234,20 +234,20 @@
    No numerical variables selected
    } - @if (primaryConfigSchema().length || advancedConfigSchema().length) { - - @for (field of primaryConfigSchema(); track field.key) { + @if (form.primaryConfigSchema().length || form.advancedConfigSchema().length) { + + @for (field of form.primaryConfigSchema(); track field.key) { } - @if (advancedConfigSchema().length) { + @if (form.advancedConfigSchema().length) {
    - Advanced ({{ advancedConfigSchema().length }}) + Advanced ({{ form.advancedConfigSchema().length }})
    - @for (field of advancedConfigSchema(); track field.key) { + @for (field of form.advancedConfigSchema(); track field.key) { }
    @@ -256,32 +256,32 @@
    No numerical variables selected
    } - @if (canToggleCrossValidation() || isCrossValidationOnly()) { -
    + @if (form.canToggleCrossValidation() || form.isCrossValidationOnly()) { +
    Cross-validation - {{ isCrossValidationOnly() ? 'Always on for this method' : 'Runs the cross-validation variant and reports fold metrics' }} + {{ form.isCrossValidationOnly() ? 'Always on for this method' : 'Runs the cross-validation variant and reports fold metrics' }}
    -
    - @if (crossValidationConfigSchema().length) { + @if (form.crossValidationConfigSchema().length) {
    - @for (field of crossValidationConfigSchema(); track field.key) { + @for (field of form.crossValidationConfigSchema(); track field.key) { }
    } } - @if (!hasAlgorithmParameterControls()) { + @if (!form.hasAlgorithmParameterControls()) {
    No additional parameters for this algorithm
    @@ -296,11 +296,11 @@
    No additional parameters for this algorithm
    - @if (configForm().get(field.key)) { -
    + @if (form.configForm().get(field.key)) { +
    @@ -321,17 +321,17 @@
    No additional parameters for this algorithm

    } - @if (field.key === 'positive_class' && !selectedEventVarCode()) { + @if (field.key === 'positive_class' && !form.selectedEventVarCode()) {

    Select an event indicator variable first.

    } - @if (field.key === 'positive_class' && selectedEventVarCode() && positiveClassOptions().length) { + @if (field.key === 'positive_class' && form.selectedEventVarCode() && form.positiveClassOptions().length) {

    Select the event level mapped to 1; all other levels are mapped to 0.

    } - @if (field.key === 'positive_class' && selectedEventVarCode() && !positiveClassOptions().length) { + @if (field.key === 'positive_class' && form.selectedEventVarCode() && !form.positiveClassOptions().length) {

    This event variable has no catalog categories. Enter the value treated as event=1 (for example 0 or 1).

    @@ -350,17 +350,17 @@
    No additional parameters for this algorithm
    @for (opt of field.options; track opt) { - } } @else if (field.type === 'multi-select') { -
    +
    @for (opt of field.options; track opt) { } @@ -374,18 +374,18 @@
    No additional parameters for this algorithm
    } - @if (configForm().get(field.key)?.invalid && - configForm().get(field.key)?.touched) { + @if (form.configForm().get(field.key)?.invalid && + form.configForm().get(field.key)?.touched) {
    - @if (configForm().get(field.key)?.hasError('required')) { + @if (form.configForm().get(field.key)?.hasError('required')) { This field is required. - } @if (configForm().get(field.key)?.hasError('min')) { + } @if (form.configForm().get(field.key)?.hasError('min')) { Value too small. - } @if (configForm().get(field.key)?.hasError('max')) { + } @if (form.configForm().get(field.key)?.hasError('max')) { Value too large. - } @if (configForm().get(field.key)?.hasError('pattern')) { + } @if (form.configForm().get(field.key)?.hasError('pattern')) { Invalid value. - } @if (configForm().get(field.key)?.hasError('invalidOption')) { + } @if (form.configForm().get(field.key)?.hasError('invalidOption')) { Select a value from the list. }
    @@ -408,29 +408,29 @@

    No algorithm selected

    -
    +
    - {{ readinessText() }} + {{ run.readinessText() }} - @if (!canRun()) { + @if (!run.canRun()) { Finish the needs above to enable the run. } - @if (errorMsg()) { - Backend error: {{ errorMsg() }} + @if (run.errorMsg()) { + Backend error: {{ run.errorMsg() }} }
    -@if (tooltipVisible()) { -
    -
    {{ tooltipData()?.label }}
    -
    +@if (catalog.tooltipVisible()) { +
    +
    {{ catalog.tooltipData()?.label }}
    +
    - @if (availabilityDetails().length) { + @if (catalog.availabilityDetails(catalog.tooltipData()).length) {
    Availability
    - @for (detail of availabilityDetails(); track detail.role) { + @for (detail of catalog.availabilityDetails(catalog.tooltipData()); track detail.role) {
    {{ availabilityRequirementText(detail) }} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts index b5adeaf6..9ce73c71 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts @@ -8,11 +8,12 @@ import { PdfExportService } from '../../../services/pdf-export.service'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; import { RuntimeEnvService } from '../../../services/runtime-env.service'; import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service'; -import { SessionStorageService } from '../../../services/session-storage.service'; import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; import { AlgorithmPanelComponent } from './algorithm-panel.component'; -import { of, throwError } from 'rxjs'; import { createSpyObj } from '../../../testing/spy-obj.testing'; +import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; +import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; +import { normalizeParameterValues } from './utils/algorithm-parameter-form.utils'; describe('AlgorithmPanelComponent', () => { let fixture: ComponentFixture; @@ -146,7 +147,6 @@ describe('AlgorithmPanelComponent', () => { { provide: AuthService, useValue: { currentUser: null } }, { provide: PdfExportService, useValue: { exportExperimentPdf: vi.fn() } }, { provide: RuntimeEnvService, useValue: { mipVersion: 'test' } }, - { provide: SessionStorageService, useValue: {} }, { provide: ExperimentStudioNavigationService, useValue: studioNavigation }, ], }).compileComponents(); @@ -155,6 +155,10 @@ describe('AlgorithmPanelComponent', () => { fixture.detectChanges(); }); + /** The panel's feature services, as its template sees them. */ + const catalog = () => fixture.debugElement.injector.get(AlgorithmCatalogService); + const configForm = () => fixture.debugElement.injector.get(AlgorithmConfigFormService); + it('groups default optional parameter fields under a closed Advanced disclosure', async () => { fixture.componentInstance.setStudioSubstep('parameters'); await fixture.whenStable(); @@ -171,61 +175,14 @@ describe('AlgorithmPanelComponent', () => { expect(fixture.componentInstance.canRun()).toBe(true); }); - describe('variables missing from a selected dataset', () => { - const unavailable = new Map([ - ['age', ['Dataset B']], - ['sex', ['Dataset B', 'Dataset C']], - ]); - - it('blocks the run while an outcome, predictor or filter is unavailable', () => { - const component = fixture.componentInstance; - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); - experimentStudioService.algorithmX.set([{ code: 'mmse', label: 'MMSE' }]); - experimentStudioService.selectedFilters.set([{ code: 'sex', label: 'Sex' }]); - fixture.detectChanges(); - - expect(component.canRun()).toBe(false); - expect(component.runDisabledReason()).toBe('Remove variables not available in: Dataset B, Dataset C'); - expect(component.algorithmNeeds()).toContainEqual(expect.objectContaining({ - key: 'unavailable-variables', - met: false, - title: 'Remove variables not available in: Dataset B, Dataset C', - })); - expect((fixture.nativeElement as HTMLElement).textContent) - .toContain('Remove variables not available in: Dataset B, Dataset C'); - - component.onClickRunExp(); - expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); - expect(experimentStudioService.setRunning).not.toHaveBeenCalled(); - }); - - it('checks predictors on their own', () => { - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.algorithmX.set([{ code: 'age', label: 'Age' }]); - - expect(fixture.componentInstance.canRun()).toBe(false); - expect(fixture.componentInstance.runDisabledReason()).toBe('Remove variables not available in: Dataset B'); - }); - - it('runs again once the unavailable variables are removed', () => { - const component = fixture.componentInstance; - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); - expect(component.canRun()).toBe(false); - - experimentStudioService.algorithmY.set([{ code: 'mmse', label: 'MMSE' }]); - expect(component.canRun()).toBe(true); - expect(component.runDisabledReason()).toBe('Execute experiment'); - expect(component.algorithmNeeds().some((need) => need.key === 'unavailable-variables')).toBe(false); - }); - - it('ignores unavailable variables the run does not use', () => { - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.selectedVariables.set([{ code: 'age', label: 'Age' }]); + it('lists variables missing from a selected dataset as a run need', () => { + experimentStudioService.unavailableVariables.set(new Map([['age', ['Dataset B']]])); + experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); + fixture.detectChanges(); - expect(fixture.componentInstance.canRun()).toBe(true); - }); + expect(fixture.componentInstance.canRun()).toBe(false); + expect((fixture.nativeElement as HTMLElement).textContent) + .toContain('Remove variables not available in: Dataset B'); }); it('keeps required parameters in the primary list and knows the friendly needs copy', async () => { @@ -323,7 +280,7 @@ describe('AlgorithmPanelComponent', () => { }, }; experimentStudioService.availableGroupedAlgorithms.set({ Test: [disabledAlgorithm] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); await fixture.whenStable(); @@ -331,9 +288,8 @@ describe('AlgorithmPanelComponent', () => { ((fixture.nativeElement as HTMLElement).querySelector('.algorithm-match-cat') as HTMLButtonElement).click(); fixture.detectChanges(); - const component = fixture.componentInstance; - component.tooltipVisible.set(true); - component.tooltipData.set(disabledAlgorithm); + catalog().tooltipVisible.set(true); + catalog().tooltipData.set(disabledAlgorithm); fixture.detectChanges(); const nativeElement = fixture.nativeElement as HTMLElement; @@ -349,24 +305,6 @@ describe('AlgorithmPanelComponent', () => { expect(nativeElement.textContent).not.toContain('stattypes: nominal'); }); - it('displays text availability types as nominal in requirement tips', () => { - const text = fixture.componentInstance.availabilityRequirementText({ - role: 'x', - label: 'Predictor', - selectedCount: 1, - minCount: 1, - maxCount: null, - required: true, - types: ['real', 'int', 'text'], - stattypes: [], - messages: [], - satisfied: true, - }); - - expect(text).toContain('type: real, int, nominal'); - expect(text).not.toContain('text'); - }); - it('shows type availability reasons on algorithm cards', async () => { const disabledAlgorithm: AlgorithmConfig = { ...algorithm, @@ -393,7 +331,7 @@ describe('AlgorithmPanelComponent', () => { }, }; experimentStudioService.availableGroupedAlgorithms.set({ Test: [disabledAlgorithm] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); await fixture.whenStable(); @@ -429,7 +367,7 @@ describe('AlgorithmPanelComponent', () => { ? unavailableAlgorithm.availability : { available: true, summary: '', details: [] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); await fixture.whenStable(); @@ -471,15 +409,6 @@ describe('AlgorithmPanelComponent', () => { expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); }); - it('keeps fields after the old advanced cutoff in the persisted form config', async () => { - await fixture.whenStable(); - - const component = fixture.componentInstance; - component.configForm().get('fourth')?.setValue('42'); - - expect(experimentStudioService.algorithmConfigurations()['flat_config_algorithm']['fourth']).toBe(42); - }); - it('shows event_var description and covariate options for Cox regression', async () => { const coxAlgorithm: AlgorithmConfig = { name: 'cox_regression_classical', @@ -583,13 +512,12 @@ describe('AlgorithmPanelComponent', () => { expect(positiveSelect.textContent).toContain('-- Select --'); expect(root.textContent).toContain('Select the event level mapped to 1'); - const component = fixture.componentInstance; - expect(component.visibleConfigSchema().find((field) => field.key === 'positive_class')?.required).toBe(true); - const values = (component as any).normalizeFormValues( + expect(configForm().visibleConfigSchema().find((field) => field.key === 'positive_class')?.required).toBe(true); + const values = normalizeParameterValues( { event_var: 'procedure', positive_class: 'yes' }, - component.visibleConfigSchema() + configForm().visibleConfigSchema() ); - expect(values.positive_class).toBe('yes'); + expect(values['positive_class']).toBe('yes'); }); it('blocks Cox runs when positive_class is unset', async () => { @@ -643,7 +571,7 @@ describe('AlgorithmPanelComponent', () => { component.onClickRunExp(); expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); - expect(component.configForm().get('positive_class')?.invalid).toBe(true); + expect(configForm().configForm().get('positive_class')?.invalid).toBe(true); }); it('shows covariate hint for empty input_var_names selects', async () => { @@ -682,7 +610,7 @@ describe('AlgorithmPanelComponent', () => { expect(select?.options.length).toBe(1); }); - it('serializes outlier_report rules into dictionary parameters', async () => { + it('lists the numerical pool variables in the outlier_report editor', async () => { const outlierAlgorithm: AlgorithmConfig = { name: 'outlier_report', label: 'Outlier Report', @@ -698,92 +626,17 @@ describe('AlgorithmPanelComponent', () => { isDisabled: false, }; experimentStudioService.selectedAlgorithm.set(outlierAlgorithm); - experimentStudioService.backendAlgorithms.set({ outlier_report: outlierAlgorithm }); - experimentStudioService.availableGroupedAlgorithms.set({ 'Descriptive Statistics': [outlierAlgorithm] }); - experimentStudioService.selectedVariables.set([ - { code: 'age', label: 'Age', type: 'real' }, - { code: 'sex', label: 'Sex', type: 'nominal' }, - ]); - experimentStudioService.algorithmX.set([{ code: 'bmi', label: 'BMI', type: 'real' }]); experimentStudioService.algorithmAssignableVariables.set([ { code: 'age', label: 'Age', type: 'real' }, { code: 'sex', label: 'Sex', type: 'nominal' }, { code: 'bmi', label: 'BMI', type: 'real' }, ]); - experimentStudioService.hasAppliedDescriptivePreprocessing.mockReturnValue(false); - experimentStudioService.runId.set('stale-run'); - experimentStudioService.runSelectedAlgorithm.mockReturnValue(of({ - uuid: 'kept-run', - status: 'success', - result: { featurewise: [] }, - })); - - fixture.detectChanges(); - await fixture.whenStable(); - const component = fixture.componentInstance; - component.onOutlierReportEnabledChange({ code: 'bmi', label: 'BMI', type: 'real' }, false); - component.onOutlierReportStrategyChange({ code: 'age', label: 'Age', type: 'real' }, 'quantile'); - component.onOutlierReportFoldChange({ code: 'age', label: 'Age', type: 'real' }, 0.05); - component.onClickRunExp(); - - expect(experimentStudioService.algorithmConfigurations()['outlier_report']).toEqual({ - strategies: { age: 'quantile' }, - tails: { age: 'both' }, - folds: { age: 0.05 }, - }); - expect(experimentStudioService.captureRunSetup).toHaveBeenCalledWith('outlier_report'); - expect(experimentStudioService.runSelectedAlgorithm).toHaveBeenCalledWith('outlier_report', 'outlier_report'); - // The kept run is what Save persists, so it must be the run behind the shown result. - expect(experimentStudioService.runId()).toBe('kept-run'); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('Age'); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('BMI'); - }); - - it('shows the run error and stops running when the run request fails', async () => { - experimentStudioService.selectedAlgorithm.set(algorithm); - experimentStudioService.runSelectedAlgorithm.mockReturnValue(throwError(() => new Error('500'))); - fixture.detectChanges(); - await fixture.whenStable(); - - fixture.componentInstance.onClickRunExp(); - - expect(experimentStudioService.runError()).toBe('Unable to run experiment. Please try again.'); - expect(experimentStudioService.runId()).toBeNull(); - expect(experimentStudioService.setRunning).toHaveBeenCalledWith(true); - expect(experimentStudioService.setRunning).toHaveBeenLastCalledWith(false); - }); - - it('blocks invalid outlier_report folds', async () => { - const outlierAlgorithm: AlgorithmConfig = { - name: 'outlier_report', - label: 'Outlier Report', - description: '', - category: 'Descriptive Statistics', - requiredVariable: ['real'], - covariate: ['real'], - configSchema: [ - { key: 'strategies', label: 'Strategies', type: 'dict' }, - { key: 'tails', label: 'Tails', type: 'dict' }, - { key: 'folds', label: 'Folds', type: 'dict' }, - ], - isDisabled: false, - }; - experimentStudioService.selectedAlgorithm.set(outlierAlgorithm); - experimentStudioService.selectedVariables.set([{ code: 'age', label: 'Age', type: 'real' }]); - experimentStudioService.algorithmAssignableVariables.set([{ code: 'age', label: 'Age', type: 'real' }]); - experimentStudioService.hasAppliedDescriptivePreprocessing.mockReturnValue(false); fixture.detectChanges(); await fixture.whenStable(); - const component = fixture.componentInstance; - component.onOutlierReportStrategyChange({ code: 'age', label: 'Age', type: 'real' }, 'quantile'); - component.onOutlierReportFoldChange({ code: 'age', label: 'Age', type: 'real' }, 0.5); - experimentStudioService.runSelectedAlgorithm.mockClear(); - - component.onClickRunExp(); - - expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); - expect(component.errorMsg()).toBe('Fix the outlier report configuration before running.'); + const labels = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.outlier-variable-label')) + .map((label) => label.textContent?.trim()); + expect(labels).toEqual(['Age', 'BMI']); }); it('lists availability and preprocessing requirements together', async () => { @@ -911,9 +764,9 @@ describe('AlgorithmPanelComponent', () => { summary: 'Needs an outcome', details: [], }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); - expect(fixture.componentInstance.showOnlyActive()).toBe(false); + expect(catalog().showOnlyActive()).toBe(false); // The group holds the selected method, so it is open even with nothing runnable. const tile = (fixture.nativeElement as HTMLElement).querySelector('.algo-tile'); @@ -1056,7 +909,7 @@ describe('AlgorithmPanelComponent', () => { const disabledAlgorithm = { ...algorithm, isDisabled: true, label: 'Locked Algorithm' }; experimentStudioService.availableGroupedAlgorithms.set({ Test: [disabledAlgorithm] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); fixture.detectChanges(); @@ -1071,7 +924,7 @@ describe('AlgorithmPanelComponent', () => { modeTabs()[0].click(); fixture.detectChanges(); - expect(fixture.componentInstance.showOnlyActive()).toBe(true); + expect(catalog().showOnlyActive()).toBe(true); expect(modeTabs()[0].getAttribute('aria-pressed')).toBe('true'); expect(modeTabs()[0].textContent).toContain('Runnable'); expect(root.querySelector('.algo-tile')).toBeNull(); @@ -1086,13 +939,13 @@ describe('AlgorithmPanelComponent', () => { const root = fixture.nativeElement as HTMLElement; expect(root.querySelectorAll('.algo-tile').length).toBe(2); - fixture.componentInstance.algoSearchQuery.set('kruskal'); + catalog().algoSearchQuery.set('kruskal'); fixture.detectChanges(); const tiles = root.querySelectorAll('.algo-tile'); expect(tiles.length).toBe(1); expect(tiles[0].textContent).toContain('Kruskal-Wallis'); - fixture.componentInstance.algoSearchQuery.set('no-such-method'); + catalog().algoSearchQuery.set('no-such-method'); fixture.detectChanges(); expect(root.querySelector('.algo-tile')).toBeNull(); expect(root.textContent).toContain('No methods match'); @@ -1118,7 +971,7 @@ describe('AlgorithmPanelComponent', () => { Association: [association], }); // These specs study category expansion over the full catalog, so opt into All mode. - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); return { groupComparison: [anova, kruskal], association }; @@ -1183,16 +1036,6 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('app-algorithm-role-assignment')).toBeTruthy(); }); - it('exposes y/x role selections to the availability and config helpers', () => { - experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); - experimentStudioService.algorithmX.set([{ code: 'sex', label: 'Sex' }]); - fixture.detectChanges(); - - const component = fixture.componentInstance; - expect(component.yVar()).toBe('age'); - expect(component.xVar()).toBe('sex'); - }); - it('keeps parameters off Setup until a method is chosen', () => { experimentStudioService.selectedAlgorithm.set(null); fixture.detectChanges(); @@ -1235,6 +1078,6 @@ describe('AlgorithmPanelComponent', () => { (label) => label.textContent?.trim(), ); expect(labels).toEqual(expect.arrayContaining(['First optional', 'Second optional', 'Third optional', 'Fourth optional'])); - expect(component.configForm().get('first')?.value).toBe(1); + expect(configForm().configForm().get('first')?.value).toBe(1); }); }); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index 8efaa7e2..05415f11 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -1,63 +1,21 @@ -import { SessionStorageService } from './../../../services/session-storage.service'; -import { ChangeDetectionStrategy, Component, inject, signal, computed, effect, untracked, output, linkedSignal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { getOutputSchema, prettifyLabel as toHumanLabel } from '../../../core/algorithm-mappers'; -import { optionBindingValue } from '../../../core/algorithm-parameter.utils'; -import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../models/algorithm-definition.model'; -import { experimentTables, PdfExportService } from '../../../services/pdf-export.service'; -import { CsvExportService } from '../../../services/csv-export.service'; -import { formatFilterExpression } from '../../../core/filter-display.utils'; -import { ErrorService } from '../../../services/error.service'; -import { AuthService } from '../../../services/auth.service'; -import { AlgorithmNames } from '../../../core/constants/algorithm.constants'; -import { RuntimeEnvService } from '../../../services/runtime-env.service'; import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service'; -import { - createDefaultOutlierRule, - defaultFoldForStrategy, - hydrateOutlierRules, - isOutlierEligibleVariable, - OUTLIER_STRATEGIES, - OUTLIER_TAILS, - OutlierRule, - OutlierStrategy, - OutlierTail, - serializeOutlierRules, - validateOutlierRule, -} from '../../../core/outlier-rules'; +import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; +import { OUTLIER_STRATEGIES, OUTLIER_TAILS } from '../../../core/outlier-rules'; import { AlgorithmRoleAssignmentComponent } from './algorithm-role-assignment/algorithm-role-assignment.component'; -import { unavailableDatasetsFor, unavailableVariableTooltip } from '../../../core/variable-availability.utils'; -import { availabilityRequirementText, formatAvailabilityMessage } from './utils/algorithm-availability-text.utils'; -import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories, runnableCount } from './utils/algorithm-catalog.utils'; -import { - allowsDecimal, - enrichParameterSchema, - enumOptionsForVariableCode, - isAdvancedField, - buildParameterForm, - multiSelectValues, - nonBlankString, - normalizeParameterValues, - optionLabel, - parameterFieldLabel, - parameterFormSignature, -} from './utils/algorithm-parameter-form.utils'; -import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from './utils/algorithm-run.utils'; +import { availabilityRequirementText } from './utils/algorithm-availability-text.utils'; +import { runnableCount } from './utils/algorithm-catalog.utils'; +import { allowsDecimal, isAdvancedField, optionLabel } from './utils/algorithm-parameter-form.utils'; import { tooltipPosition } from './utils/tooltip-position.utils'; -import { - AlgorithmCategory, - AlgorithmNeedItem, - AlgorithmParameterField, - AlgorithmRunRequirement, - AlgorithmStudioSubstep, - EnrichedParameterField, - ExperimentRunInfo, - OutlierReportVariable, - ParameterOption, - StudioVariable, -} from './algorithm-panel.models'; +import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; +import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; +import { AlgorithmRunService } from './experiment-result/algorithm-run.service'; +import { AlgorithmStudioSubstepService } from './algorithm-studio-substep.service'; +import { AlgorithmRunRequirement, AlgorithmStudioSubstep, OutlierReportVariable } from './algorithm-panel.models'; @Component({ selector: 'app-algorithm-panel', @@ -67,914 +25,92 @@ import { ReactiveFormsModule, AlgorithmRoleAssignmentComponent, ], + providers: [ + AlgorithmStudioSubstepService, + AlgorithmCatalogService, + AlgorithmConfigFormService, + AlgorithmRunService, + ], templateUrl: './algorithm-panel.component.html', styleUrl: './algorithm-panel.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) - export class AlgorithmPanelComponent { - pdfExport = inject(PdfExportService); - private readonly csvExport = inject(CsvExportService); - private errorService = inject(ErrorService); - private authService = inject(AuthService); - private runtimeEnvService = inject(RuntimeEnvService); - private studioNavigation = inject(ExperimentStudioNavigationService); - experimentStudioService = inject(ExperimentStudioService); - sessionStorage = inject(SessionStorageService); - /** The algorithm of the last successful run; the exports name it. */ - private readonly lastRunAlgorithm = signal(''); - errorMsg = signal(null); - readonly isRunning = this.experimentStudioService.isRunning; + private readonly studio = inject(ExperimentStudioService); + private readonly studioNavigation = inject(ExperimentStudioNavigationService); + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly run = inject(AlgorithmRunService); /** Studio: setup (roles + methods), then parameters (configure + run). Results live in the Execution step. */ - readonly studioSubstep = signal('setup'); - readonly studioSubstepChange = output(); - - setStudioSubstep(step: AlgorithmStudioSubstep): void { - this.studioSubstep.set(step); - this.studioSubstepChange.emit(step); - } - - readonly mipVersion = this.runtimeEnvService.mipVersion; - - readonly selectedAlgorithm = this.experimentStudioService.selectedAlgorithm; - readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? ''); - readonly selectedAlgorithmUnavailable = computed(() => { - const algorithm = this.selectedAlgorithm(); - return !!algorithm && !this.experimentStudioService.isAlgorithmAvailable(algorithm.name); - }); - readonly selectedAvailabilityDetails = computed(() => this.availabilityDetails(this.selectedAlgorithm())); - - readonly algorithmNeeds = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return []; - return buildAlgorithmNeeds({ - algorithm, - details: this.selectedAvailabilityDetails(), - hasOutcome: this.experimentStudioService.algorithmY().length > 0, - unavailableVariablesReason: this.unavailableRunVariablesReason(), - requirements: this.algorithmRunRequirements(), - }); - }); - - readonly readinessText = computed(() => readinessText({ - algorithm: this.selectedAlgorithm(), - canRun: this.canRun(), - needs: this.algorithmNeeds(), - y: this.experimentStudioService.algorithmY(), - x: this.experimentStudioService.algorithmX(), - })); - - readonly categoryRunnableCount = runnableCount; - - readonly selectedAlgorithmAvailabilitySummary = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm || !this.selectedAlgorithmUnavailable()) return ''; - const summary = this.experimentStudioService.getAlgorithmAvailability(algorithm.name).summary ?? algorithm.availability?.summary ?? ''; - return formatAvailabilityMessage(summary); - }); - readonly algorithmRunRequirements = computed((): AlgorithmRunRequirement[] => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return []; - - const requirements: AlgorithmRunRequirement[] = []; - - if (this.selectedAlgorithmUnavailable()) { - const summary = this.selectedAlgorithmAvailabilitySummary(); - requirements.push({ - id: 'availability', - message: summary || 'Algorithm requirements are not satisfied.', - actionLabel: this.availabilityRequirementActionLabel(algorithm.name), - }); - } - - const preprocessingMessage = this.preprocessingRunBlockMessage(); - if (preprocessingMessage) { - requirements.push({ - id: 'preprocessing', - message: preprocessingMessage, - actionLabel: 'Go to preprocessing', - }); - } - - return requirements; - }); - readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? ''); - readonly yVar = computed(() => this.experimentStudioService.algorithmY()[0]?.code ?? null); - readonly xVar = computed(() => this.experimentStudioService.algorithmX()[0]?.code ?? null); - readonly crossValidationEnabled = signal(false); - /** Cross-validation on/off the user chose per base method; a memory, not reactive state. */ - private readonly crossValidationChoices = new Map(); - readonly isCrossValidationOnly = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return false; - return this.experimentStudioService.isCrossValidationOnly(algorithm.name); - }); - readonly outlierStrategies = OUTLIER_STRATEGIES; - readonly outlierTails = OUTLIER_TAILS; - /** Per-variable outlier_report rules, keyed by variable code. */ - readonly outlierReportRules = signal>({}); - /** Fold errors shown under each outlier_report rule, keyed by variable code. */ - readonly outlierReportValidationErrors = signal>({}); - readonly canToggleCrossValidation = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return false; - if (this.experimentStudioService.isCrossValidationOnly(algorithm.name)) return false; - const baseName = this.experimentStudioService.getCrossValidationBase(algorithm.name) ?? algorithm.name; - return ( - !!this.experimentStudioService.getCrossValidationVariant(baseName) && - !!this.experimentStudioService.backendAlgorithms()[baseName] - ); - }); - readonly isOutlierReportSelected = computed( - () => this.selectedAlgorithm()?.name === AlgorithmNames.OUTLIER_REPORT - ); - readonly hasAlgorithmParameterControls = computed(() => ( - this.visibleConfigSchema().length > 0 || - this.isOutlierReportSelected() || - this.canToggleCrossValidation() || - this.isCrossValidationOnly() - )); - readonly preprocessingRunBlockMessage = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return null; - if (algorithm.name === AlgorithmNames.OUTLIER_REPORT) return null; - if ( - this.experimentStudioService.hasAppliedDescriptivePreprocessing() || - this.experimentStudioService.hasRequestPreprocessingForRun(algorithm.name) - ) return null; - return 'Apply missing value preprocessing before running algorithms.'; - }); - private readonly formInvalid = signal(false); - /** - * "Remove variables not available in: …" when a variable the run uses (outcome, predictor - * or cohort filter) is missing from a selected dataset; '' otherwise. Such variables stay - * in the pool grayed out, so the run is blocked until the user removes them. - */ - readonly unavailableRunVariablesReason = computed(() => { - const map = this.experimentStudioService.unavailableVariables(); - if (!map.size) return ''; - const datasets = new Set(); - for (const node of [ - ...this.experimentStudioService.algorithmY(), - ...this.experimentStudioService.algorithmX(), - ...this.experimentStudioService.selectedFilters(), - ]) { - unavailableDatasetsFor(map, { code: node?.code }).forEach((label) => datasets.add(label)); - } - return unavailableVariableTooltip([...datasets], 'Remove variables not available in'); - }); - readonly canRun = computed(() => ( - !!this.selectedAlgorithm() && - !this.isRunning() && - !this.selectedAlgorithmUnavailable() && - !this.unavailableRunVariablesReason() && - !this.preprocessingRunBlockMessage() && - !this.formInvalid() - )); - readonly runDisabledReason = computed(() => { - if (this.isRunning()) return 'Experiment is running'; - if (!this.selectedAlgorithm()) return 'Select an algorithm to run'; - if (this.selectedAlgorithmUnavailable()) return 'Algorithm unavailable for the current selection'; - const unavailable = this.unavailableRunVariablesReason(); - if (unavailable) return unavailable; - const preprocessing = this.preprocessingRunBlockMessage(); - if (preprocessing) return preprocessing; - if (this.formInvalid()) return 'Complete variable and algorithm selection to proceed'; - return 'Execute experiment'; - }); - readonly labelMap = this.experimentStudioService.variableLabelMap; - - // UI toggle: the catalog defaults to runnable methods only — each group lists just the - // available algorithms. Switching to All re-lists unavailable methods with their reason. - readonly showOnlyActive = signal(true); - /** Instant text filter over label, name, description, documentation, category, and flags. */ - readonly algoSearchQuery = signal(''); - readonly hasAnyVisibleAlgorithms = computed(() => { - return this.filteredAlgorithmCategories().some(c => c.algorithms?.length > 0); - }); - - prettyFieldLabel(field: EnrichedParameterField): string { - return parameterFieldLabel(field, this.experimentStudioService.algorithmX()[0]); - } - - private resolveSelectedEventVarCode(algorithmName: string | undefined): string | null { - if (!algorithmName) return null; - - const stored = untracked(() => this.experimentStudioService.algorithmConfigurations())[algorithmName]?.['event_var']; - return nonBlankString(this.eventVarSelection()) ?? nonBlankString(stored); - } - - private syncEventVarSelectionFromValues( - algorithmName: string, - values: Record - ): void { - if (!Object.prototype.hasOwnProperty.call(values, 'event_var')) return; - const next = nonBlankString(values['event_var']); - if (this.eventVarSelection() === next) return; - this.eventVarSelection.set(next); - this.syncPositiveClassWithEventVar(algorithmName); - } - - private syncEventVarSelectionFromStored(stored: Record): void { - this.eventVarSelection.set(nonBlankString(stored['event_var'])); - } - - private enumOptionsForVariableCode(varCode: string | null | undefined) { - return enumOptionsForVariableCode(varCode, [ - ...this.experimentStudioService.algorithmY(), - ...this.experimentStudioService.algorithmX(), - ]); - } - - /** Live event_var from the config form; avoids reading FormGroup inside other computeds. */ - private readonly eventVarSelection = signal(null); - - readonly selectedEventVarCode = computed(() => { - const algorithm = this.selectedAlgorithm(); - return algorithm ? this.resolveSelectedEventVarCode(algorithm.name) : null; - }); - - readonly positiveClassOptions = computed(() => - this.enumOptionsForVariableCode(this.selectedEventVarCode()) - ); - - constructor() { - effect(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) { - this.crossValidationEnabled.set(false); - return; - } - if (this.experimentStudioService.isCrossValidationOnly(algorithm.name)) { - this.crossValidationEnabled.set(true); - return; - } - const baseName = this.experimentStudioService.getCrossValidationBase(algorithm.name) ?? algorithm.name; - const hasVariant = !!this.experimentStudioService.getCrossValidationVariant(baseName); - if (!hasVariant) { - this.crossValidationEnabled.set(false); - return; - } - const stored = this.crossValidationChoices.get(baseName); - const defaultValue = stored !== undefined - ? stored - : this.experimentStudioService.isCrossValidationAlgorithm(algorithm.name); - this.crossValidationEnabled.set(defaultValue); - }); - - effect(() => { - // Establish dependencies - this.selectedAlgorithm(); - this.experimentStudioService.algorithmY(); - this.experimentStudioService.algorithmX(); - - // A new selection invalidates whatever the last run produced - this.experimentStudioService.runResult.set(null); - this.experimentStudioService.runError.set(null); - }); - - effect(() => { - const algorithm = this.selectedAlgorithm(); - const variables = this.outlierReportVariables(); - if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) { - this.outlierReportValidationErrors.set({}); - return; - } - const stored = untracked(() => this.experimentStudioService.algorithmConfigurations()[algorithm.name] ?? {}); - this.reconcileOutlierReportRules(variables, stored); - }); - - effect((onCleanup) => { - const algorithm = this.selectedAlgorithm(); - const schema = this.visibleConfigSchema(); - - if (!algorithm) { - this.builtFormSignature.set(''); - this.eventVarSelection.set(null); - this.configForm.set(new FormGroup({})); - this.formInvalid.set(false); - return; - } - - const schemaSignature = parameterFormSignature(algorithm.name, this.crossValidationEnabled(), schema); - if (schemaSignature === untracked(this.builtFormSignature)) { - return; - } - this.builtFormSignature.set(schemaSignature); - - const allConfigs = untracked(() => this.experimentStudioService.algorithmConfigurations()); - const stored = allConfigs?.[algorithm.name] || {}; - this.syncEventVarSelectionFromStored(stored); - - const form = buildParameterForm(schema, stored); - this.configForm.set(form); - this.formInvalid.set(form.invalid); - - const statusSub = form.statusChanges.subscribe(() => this.formInvalid.set(form.invalid)); - const subscription = form.valueChanges.subscribe((values) => { - this.syncEventVarSelectionFromValues(algorithm.name, values); - this.persistCurrentFormConfig( - algorithm.name, - untracked(() => this.visibleConfigSchema()) - ); - }); - onCleanup(() => { - subscription.unsubscribe(); - statusSub.unsubscribe(); - }); - }); - effect(() => { - const res = this.experimentStudioService.runResult(); - const schema = this.outputSchema(); - - if (!res || !schema.length) return; - - const missing = schema.filter(field => !(field.key in res)); - if (missing.length > 0) { - console.warn('[Validation] Missing fields from result:', missing.map(f => f.key)); - } - }); - - } - - - configForm = signal(new FormGroup({})); - /** Signature of the built form; cleared so that picking a method always rebuilds it. */ - private readonly builtFormSignature = signal(''); - - enrichedConfigSchema = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return []; - return enrichParameterSchema(algorithm, { - y: this.experimentStudioService.algorithmY(), - x: this.experimentStudioService.algorithmX(), - eventVarCode: this.selectedEventVarCode(), - withCrossValidationFolds: this.crossValidationEnabled() && this.canToggleCrossValidation(), - }); - }); - - readonly visibleConfigSchema = computed(() => { - const schema = this.enrichedConfigSchema(); - if (!this.isOutlierReportSelected()) return schema; - return schema.filter((field) => field.type !== 'dict' && !['strategies', 'tails', 'folds'].includes(String(field.key))); - }); - - readonly isAdvancedField = isAdvancedField; - readonly optionLabel = optionLabel; - readonly allowsDecimal = allowsDecimal; - - /** n_splits belongs to the cross-validation row, so it renders under that switch, not in the main form. */ - isCrossValidationField(field: AlgorithmParameterField): boolean { - return field.key === 'n_splits' && (this.canToggleCrossValidation() || this.isCrossValidationOnly()); - } - - readonly primaryConfigSchema = computed(() => - this.visibleConfigSchema().filter((field) => - !this.isCrossValidationField(field) && !this.isAdvancedField(field) && this.configForm().get(field.key)) - ); - - readonly advancedConfigSchema = computed(() => - this.visibleConfigSchema().filter((field) => - !this.isCrossValidationField(field) && this.isAdvancedField(field) && this.configForm().get(field.key)) - ); - - readonly crossValidationConfigSchema = computed(() => - this.crossValidationEnabled() - ? this.visibleConfigSchema().filter((field) => this.isCrossValidationField(field) && this.configForm().get(field.key)) - : [] - ); - - readonly outlierReportVariables = computed(() => { - const unique = new Map(); - this.experimentStudioService.algorithmAssignableVariables().forEach((variable: StudioVariable) => { - if (hasCode(variable)) unique.set(String(variable.code), variable); - }); - return Array.from(unique.values()).filter((variable) => isOutlierEligibleVariable(variable)); - }); - - readonly outputSchema = computed(() => - getOutputSchema(this.selectedAlgorithm()?.name ?? '') ?? [] - ); - - readonly searchActive = computed(() => this.algoSearchQuery().trim().length > 0); - - readonly filteredAlgorithmCategories = computed(() => filterAlgorithmCategories( - this.experimentStudioService.availableGroupedAlgorithms(), - this.showOnlyActive(), - this.algoSearchQuery(), - )); - - readonly algoCounts = computed(() => countAlgorithms(this.experimentStudioService.availableGroupedAlgorithms())); - - toggleActiveOnly() { - this.showOnlyActive.update(v => !v); - } - - onSearchInput(event: Event): void { - this.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? ''); - } - - clearSearch(): void { - this.algoSearchQuery.set(''); - } - - /** Groups the user opened or closed themselves; these are never changed automatically. */ - private readonly userToggledCategories = signal>(new Set()); - - /** - * Collapsed catalog groups. A group missing from this set is open, so groups coming back - * after a filter switch keep the state the user left them in. Recomputed from the listed - * groups and the selection (see nextCollapsedCategories); a toggle sets it directly. - */ - private readonly collapsedMatchCategories = linkedSignal< - { categories: AlgorithmCategory[]; selectedName: string | null }, - ReadonlySet - >({ - source: () => ({ - categories: this.filteredAlgorithmCategories(), - selectedName: this.selectedAlgorithm()?.name ?? null, - }), - computation: (source, previous) => nextCollapsedCategories( - source.categories, - source.selectedName, - previous?.source.selectedName !== source.selectedName, - previous?.value ?? new Set(), - untracked(this.userToggledCategories), - ), - }); - - isMatchCategoryOpen(categoryName: string): boolean { - return !this.collapsedMatchCategories().has(categoryName); - } - - toggleMatchCategory(categoryName: string): void { - this.userToggledCategories.update((toggled) => new Set(toggled).add(categoryName)); - const next = new Set(this.collapsedMatchCategories()); - if (!next.delete(categoryName)) next.add(categoryName); - this.collapsedMatchCategories.set(next); - } - - algorithmUnavailableReason(algorithm: AlgorithmConfig): string { - const availability = this.experimentStudioService.getAlgorithmAvailability(algorithm.name); - const summary = availability?.summary || algorithm.availability?.summary || ''; - if (summary) return formatAvailabilityMessage(summary); - const details = availability?.details?.length - ? availability.details - : (algorithm.availability?.details ?? []); - const first = details.find((detail) => detail.messages.length > 0); - return first ? formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; - } - - - readonly datasetsWithLabels = computed(() => { - const codes = this.experimentStudioService.selectedDatasets(); - const labelByCode = this.experimentStudioService.getDatasetLabelMap(); - const fallbackMap = this.labelMap(); - return codes.map((code) => ({ - code, - label: labelByCode[code] ?? fallbackMap[code] ?? toHumanLabel(code), - })); - }); - - /** Name, roles, filters and parameters of the run the exports describe. */ - readonly experimentInfo = computed((): ExperimentRunInfo => { - const selected = this.experimentStudioService.selectedAlgorithm(); - const lastRunName = - this.experimentStudioService.lastUsedAlgorithm() || this.lastRunAlgorithm(); - - const algoName = selected?.name || lastRunName || null; - - const algoCatalog = this.experimentStudioService.backendAlgorithms(); - const algoMeta = algoName ? algoCatalog[algoName] : null; - - const displayLabel = selected?.label || algoMeta?.label || algoName || 'N/A'; - const allConfigs = this.experimentStudioService.algorithmConfigurations(); - - return { - experimentName: `Experiment for ${displayLabel}`, - variables: this.experimentStudioService.algorithmY(), - covariates: this.experimentStudioService.algorithmX(), - filters: this.experimentStudioService.selectedFilters(), - algorithmConfigs: (algoName && allConfigs[algoName]) || {}, - }; - }); - - private syncPositiveClassWithEventVar(algorithmName: string): void { - const positiveControl = this.configForm().get('positive_class'); - if (!positiveControl) return; - - const options = this.enumOptionsForVariableCode(this.resolveSelectedEventVarCode(algorithmName)); - if (!options.length) return; - - const allowed = new Set(options.map((option) => option.code)); - const current = positiveControl.value; - if ( - current !== null && - current !== undefined && - String(current).trim() !== '' && - !allowed.has(String(current)) - ) { - positiveControl.setValue(null, { emitEvent: false }); - } - } - - private persistCurrentFormConfig(algorithmName: string, schema = this.visibleConfigSchema()): Record { - const values = this.normalizeFormValues(this.configForm().getRawValue(), schema); - const nextValues = algorithmName === AlgorithmNames.OUTLIER_REPORT - ? this.mergeOutlierReportConfig(values) - : values; - const allConfigs = this.experimentStudioService.algorithmConfigurations(); - const previous = allConfigs[algorithmName]; - if (previous && JSON.stringify(previous) === JSON.stringify(nextValues)) { - return nextValues; - } - this.experimentStudioService.algorithmConfigurations.set({ - ...allConfigs, - [algorithmName]: nextValues, - }); - return nextValues; - } - - bindOptionValue(option: ParameterOption): string { - return optionBindingValue(option); - } - - private normalizeFormValues(values: Record, schema: readonly EnrichedParameterField[]): Record { - return normalizeParameterValues(values, schema); - } - - private mergeOutlierReportConfig(configValues: Record): Record { - if (!this.isOutlierReportSelected()) return configValues; - delete configValues['strategies']; - delete configValues['tails']; - delete configValues['folds']; - const payload = serializeOutlierRules( - this.outlierReportRules(), - new Set(this.outlierReportVariables().map((variable) => variable.code)) - ); - return payload ? { ...configValues, ...payload } : configValues; - } - - private persistOutlierReportConfig(): void { - const algorithm = this.selectedAlgorithm(); - if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) return; - const visibleValues = this.normalizeFormValues(this.configForm().getRawValue(), this.visibleConfigSchema()); - const nextConfig = this.mergeOutlierReportConfig(visibleValues); - this.experimentStudioService.algorithmConfigurations.set({ - ...this.experimentStudioService.algorithmConfigurations(), - [algorithm.name]: nextConfig, - }); - } - - /** One rule per listed variable: kept, else restored from the stored config, else a default. */ - private reconcileOutlierReportRules(variables: OutlierReportVariable[], stored: Record): void { - const allowedCodes = new Set(variables.map((variable) => variable.code)); - const hydrated = hydrateOutlierRules(stored, allowedCodes); - const hasStoredRules = !!stored && typeof stored === 'object' && Object.keys(stored?.['strategies'] ?? {}).length > 0; - const current = untracked(this.outlierReportRules); - const enableDefaults = Object.keys(current).length > 0 || !hasStoredRules; - const next: Record = {}; - - variables.forEach(({ code }) => { - const kept = current[code] ?? hydrated[code]; - next[code] = kept ? { ...kept } : createDefaultOutlierRule(code, enableDefaults); - }); - - this.outlierReportRules.set(next); - this.outlierReportValidationErrors.update((errors) => - Object.fromEntries(Object.entries(errors).filter(([code]) => allowedCodes.has(code)))); - } - - /** The variable's rule; a variable without one reads as an enabled default rule. */ - outlierReportRuleFor(variable: OutlierReportVariable): OutlierRule { - return this.outlierReportRules()[variable.code] ?? createDefaultOutlierRule(variable.code, true); - } - - outlierVariableLabel(variable: OutlierReportVariable): string { - return variable?.label ?? variable?.name ?? variable?.code ?? ''; - } - - onOutlierReportEnabledChange(variable: OutlierReportVariable, enabled: boolean): void { - this.patchOutlierRule(variable, { enabled }, () => ''); - } + readonly studioSubstep = this.substep.current; + readonly studioSubstepChange = outputFromObservable(this.substep.changed$); - onOutlierReportStrategyChange(variable: OutlierReportVariable, strategy: OutlierStrategy): void { - this.patchOutlierRule(variable, { enabled: true, strategy, fold: defaultFoldForStrategy(strategy) }, () => ''); - } + /** Read by the studio's run button. */ + readonly canRun = this.run.canRun; + readonly runDisabledReason = this.run.runDisabledReason; - onOutlierReportTailChange(variable: OutlierReportVariable, tail: OutlierTail): void { - this.patchOutlierRule(variable, { enabled: true, tail }); - } + protected readonly selectedAlgorithm = this.studio.selectedAlgorithm; + protected readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? ''); + protected readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? ''); + /** Assignable pool drives the role-assignment UI (shown without a selected algorithm). */ + protected readonly assignablePool = this.studio.algorithmAssignableVariables; - onOutlierReportFoldChange(variable: OutlierReportVariable, rawValue: string | number): void { - const value = String(rawValue ?? '').trim(); - const fold = value === '' ? null : Number(value); - this.patchOutlierRule( - variable, - { enabled: true, fold: typeof fold === 'number' && Number.isFinite(fold) ? fold : null }, - (rule) => validateOutlierRule(rule) ?? '', - ); - } + protected readonly outlierStrategies = OUTLIER_STRATEGIES; + protected readonly outlierTails = OUTLIER_TAILS; + protected readonly categoryRunnableCount = runnableCount; + protected readonly isAdvancedField = isAdvancedField; + protected readonly optionLabel = optionLabel; + protected readonly allowsDecimal = allowsDecimal; + protected readonly availabilityRequirementText = availabilityRequirementText; - /** Applies an edit to one rule, optionally replaces its shown error, and persists the config. */ - private patchOutlierRule( - variable: OutlierReportVariable, - patch: Partial, - errorFor?: (rule: OutlierRule) => string, - ): void { - const rule = { ...this.outlierReportRuleFor(variable), ...patch }; - this.outlierReportRules.update((rules) => ({ ...rules, [variable.code]: rule })); - if (errorFor) { - this.outlierReportValidationErrors.update((errors) => ({ ...errors, [variable.code]: errorFor(rule) })); - } - this.persistOutlierReportConfig(); + setStudioSubstep(step: AlgorithmStudioSubstep): void { + this.substep.set(step); } - outlierReportRuleError(variable: OutlierReportVariable): string { - const rule = this.outlierReportRuleFor(variable); - return this.outlierReportValidationErrors()[variable.code] || validateOutlierRule(rule) || ''; + /** The studio's run button. */ + onClickRunExp(): void { + this.run.runExperiment(); } - private validateOutlierReportConfig(): boolean { - if (!this.isOutlierReportSelected()) return true; - - const variables = this.outlierReportVariables(); - if (!variables.length) { - this.errorMsg.set('Select at least one numerical variable or covariate for the outlier report.'); - return false; - } - - // Every listed variable runs with a rule, so the defaults it reads as become real rules. - this.outlierReportRules.update((rules) => { - const complete = { ...rules }; - variables.forEach((variable) => { complete[variable.code] ??= createDefaultOutlierRule(variable.code, true); }); - return complete; - }); - - const errors: Record = {}; - variables.forEach((variable) => { - const error = validateOutlierRule(this.outlierReportRuleFor(variable)); - if (error) errors[variable.code] = error; - }); - - this.outlierReportValidationErrors.set(errors); - if (Object.keys(errors).length > 0) { - this.errorMsg.set('Fix the outlier report configuration before running.'); - return false; - } - - const serialized = serializeOutlierRules( - this.outlierReportRules(), - new Set(variables.map((variable) => variable.code)) - ); - if (!serialized) { - this.errorMsg.set('Enable at least one numerical variable for the outlier report.'); - return false; - } - - this.persistOutlierReportConfig(); - return true; + /** The Execution step's PDF export, with the result figures in `section`. */ + onExportResult(section: HTMLElement): void { + this.run.exportPdf(section); } - - /** Transformation grid operates on the assigned outcome variables (y). */ - get selectedVariables() { - return this.experimentStudioService.algorithmY(); - } - - /** Assignable pool drives the role-assignment UI (shown without a selected algorithm). */ - get assignablePool() { - return this.experimentStudioService.algorithmAssignableVariables(); + /** The Execution step's ZIP export. */ + onExportResultZip(): void { + this.run.exportZip(); } - - readonly tooltipVisible = signal(false); - readonly tooltipPosition = signal({ x: 0, y: 0 }); - readonly tooltipData = signal(null); - - selectAlgorithm(algorithm: AlgorithmConfig) { - this.builtFormSignature.set(''); - this.eventVarSelection.set(null); + selectAlgorithm(algorithm: AlgorithmConfig): void { + this.form.resetForNewAlgorithm(); // Use service method so it enriches configSchema and persists to sessionStorage - this.experimentStudioService.setAlgorithm(algorithm); - this.experimentStudioService.runResult.set(null); - this.experimentStudioService.runError.set(null); - this.errorMsg.set(null); + this.studio.setAlgorithm(algorithm); + this.run.clearRunState(); } - onAlgorithmClick(algorithm: AlgorithmConfig) { + onAlgorithmClick(algorithm: AlgorithmConfig): void { this.selectAlgorithm(algorithm); // Parent bookkeeping only: the details and parameters bands render from // selectedAlgorithm() below the row, so no substep navigation or page scroll. this.setStudioSubstep('parameters'); - this.hideTooltip(); + this.catalog.hideTooltip(); } - /** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ - private scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { - if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - if (focus) target.focus({ preventScroll: true }); - } - - onClickRunExp() { - this.errorMsg.set(null); - this.errorService.clearError(); - - if (this.selectedAlgorithmUnavailable()) { - this.errorMsg.set('This algorithm is unavailable for the current selection.'); - return; - } - - const unavailableVariables = this.unavailableRunVariablesReason(); - if (unavailableVariables) { - this.errorMsg.set(unavailableVariables); - return; - } - - const preprocessingMessage = this.preprocessingRunBlockMessage(); - if (preprocessingMessage) { - this.errorMsg.set(preprocessingMessage); - return; - } - - // algorithm does not run if form is invalid - if (this.configForm() && this.configForm().invalid) { - this.configForm().markAllAsTouched(); - console.warn('[AlgorithmPanel] Run blocked – configForm invalid'); - return; - } - - const algo = this.experimentStudioService.selectedAlgorithm(); - this.errorMsg.set(null); - this.errorService.clearError(); - this.experimentStudioService.runStatusText.set('Processing experiment...'); - this.experimentStudioService.hasRunStarted.set(true); - this.experimentStudioService.runError.set(null); - this.experimentStudioService.runResult.set(null); - this.experimentStudioService.runId.set(null); - this.experimentStudioService.setRunning(true); - - if (!algo) { - console.error('No algorithm selected in service.'); - const msg = 'Please choose an algorithm before running.'; - this.errorMsg.set(msg); - this.experimentStudioService.setRunning(false); - this.experimentStudioService.hasRunStarted.set(false); - return; - } - - if (!this.validateOutlierReportConfig()) { - this.experimentStudioService.setRunning(false); - return; - } - - const { - baseName: baseAlgorithmName, - effectiveName: effectiveAlgorithmName, - finalName: finalAlgorithmName, - includeSplits: shouldIncludeSplits, - } = resolveRunAlgorithmNames(algo.name, this.crossValidationEnabled(), this.experimentStudioService); - - this.experimentStudioService.lastUsedAlgorithm.set(finalAlgorithmName); - this.errorMsg.set(null); - - let configValues = this.normalizeFormValues(this.configForm().getRawValue(), this.visibleConfigSchema()); - configValues = this.mergeOutlierReportConfig(configValues); - if (!shouldIncludeSplits && configValues['n_splits'] !== undefined) { - delete configValues['n_splits']; - } - this.experimentStudioService.algorithmConfigurations.set({ - ...this.experimentStudioService.algorithmConfigurations(), - [baseAlgorithmName]: configValues, - ...(effectiveAlgorithmName !== baseAlgorithmName ? { [effectiveAlgorithmName]: configValues } : {}) - }); - - // Freeze the setup before the request leaves: the Execution step describes the run from - // this snapshot, and parameter edits after the run must not rewrite it. - this.experimentStudioService.captureRunSetup(finalAlgorithmName); - - const result$ = this.experimentStudioService.runSelectedAlgorithm( - baseAlgorithmName, - finalAlgorithmName - ); - if (!result$) { - const msg = 'Unable to start the run. Check your selections.'; - this.errorMsg.set(msg); - this.experimentStudioService.setRunning(false); - this.experimentStudioService.hasRunStarted.set(false); - return; - } - - result$.subscribe({ - next: (res) => { - const status = res?.status; - const payload = res?.result ?? {}; - if (status === 'error') { - const msg = - payload?.data || - payload?.message || - 'The server returned an error for this run.'; - this.experimentStudioService.runError.set(msg); - return; - } - - const schema = getOutputSchema(finalAlgorithmName ?? '') ?? []; - this.experimentStudioService.runResult.set({ - ...res?.result ?? { message: "No result returned" }, - }); - // Save persists this kept run, so the saved experiment is the result shown here. - this.experimentStudioService.runId.set(res?.uuid ?? null); - this.lastRunAlgorithm.set(finalAlgorithmName); - this.experimentStudioService.lastRunSchema.set(schema); - }, - error: () => { - this.experimentStudioService.runError.set('Unable to run experiment. Please try again.'); - // An errored stream never completes, so the run state is released here too. - this.experimentStudioService.setRunning(false); - }, - complete: () => { - this.experimentStudioService.setRunning(false); - } - }); - - } - - toggleCrossValidation(event: Event) { - const target = event.target as HTMLInputElement | null; - const enabled = !!target?.checked; - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return; - const baseName = this.experimentStudioService.getCrossValidationBase(algorithm.name) ?? algorithm.name; - const variant = this.experimentStudioService.getCrossValidationVariant(baseName); - if (!variant) return; - const configs = this.experimentStudioService.algorithmConfigurations(); - const currentFormValues = this.configForm()?.getRawValue?.() ?? {}; - const preservedConfig = { ...(configs[baseName] ?? {}), ...currentFormValues }; - - this.crossValidationChoices.set(baseName, enabled); - - if (!enabled) { - if (preservedConfig['n_splits'] !== undefined) delete preservedConfig['n_splits']; - - if (this.configForm()?.contains('n_splits')) { - this.configForm().removeControl('n_splits'); - } - - const variantConfig = { ...(configs[variant] ?? {}) }; - if (variantConfig['n_splits'] !== undefined) delete variantConfig['n_splits']; - this.experimentStudioService.algorithmConfigurations.set({ - ...configs, - [baseName]: preservedConfig, - [variant]: variantConfig, - }); - } else { - this.experimentStudioService.algorithmConfigurations.set({ - ...configs, - [baseName]: preservedConfig, - [variant]: { ...(configs[variant] ?? {}), ...preservedConfig }, - }); - } - - this.crossValidationEnabled.set(enabled); - } - - private multiSelectValues(key: string): string[] { - return multiSelectValues(this.configForm().get(key)?.value); - } - - isMultiSelectOptionSelected(key: string, option: ParameterOption): boolean { - const value = String(this.bindOptionValue(option)); - return this.multiSelectValues(key).includes(value); - } - - toggleMultiSelectOption(key: string, option: ParameterOption): void { - const control = this.configForm().get(key); - if (!control) return; - const value = String(this.bindOptionValue(option)); - const current = this.multiSelectValues(key); - const next = current.includes(value) - ? current.filter((entry) => entry !== value) - : [...current, value]; - control.setValue(next); - control.markAsTouched(); - control.updateValueAndValidity(); + onSearchInput(event: Event): void { + this.catalog.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? ''); } - isAlgorithmAvailable(algorithm: string): boolean { - return this.experimentStudioService.isAlgorithmAvailable(algorithm); + toggleCrossValidation(event: Event): void { + this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked); } - availabilityDetails(algorithm: AlgorithmConfig | null = null): AlgorithmAvailabilityDetail[] { - const target = algorithm ?? this.tooltipData(); - if (!target) return []; - if (target.availability?.details?.length) return target.availability.details; - return this.experimentStudioService.getAlgorithmAvailability(target.name).details ?? []; + outlierVariableLabel(variable: OutlierReportVariable): string { + return variable?.label ?? variable?.name ?? variable?.code ?? ''; } - readonly availabilityRequirementText = availabilityRequirementText; - resolveRunRequirement(requirement: AlgorithmRunRequirement): void { if (requirement.id === 'preprocessing') { this.studioNavigation.goToPreprocessing(); @@ -990,20 +126,14 @@ export class AlgorithmPanelComponent { ); } - private availabilityRequirementActionLabel(algorithmName: string): string { - const failingDetail = this.experimentStudioService - .getAlgorithmAvailability(algorithmName) - .details - .find((detail) => !detail.satisfied && detail.messages.length > 0); - - if (failingDetail?.role === 'x') { - return 'Assign predictor'; - } - - return 'Assign outcome'; + /** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ + private scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (focus) target.focus({ preventScroll: true }); } - showTooltip(algorithm: AlgorithmConfig, event: MouseEvent) { + showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void { event.stopPropagation(); const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement); const rect = item.getBoundingClientRect(); @@ -1015,115 +145,23 @@ export class AlgorithmPanelComponent { const padding = 16; const offset = 12; - this.tooltipVisible.set(true); - this.tooltipData.set(algorithm); - - const initialPosition = tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset }); + this.catalog.tooltipVisible.set(true); + this.catalog.tooltipData.set(algorithm); // Immediate initial placement - this.tooltipPosition.set(initialPosition); + this.catalog.tooltipPosition.set( + tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset }) + ); // Refinement after render setTimeout(() => { const el = document.querySelector('.tooltip') as HTMLElement | null; if (!el) return; - const w = el.offsetWidth || 320; const h = el.offsetHeight || 200; - - this.tooltipPosition.set( + this.catalog.tooltipPosition.set( tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset }) ); }, 0); } - - hideTooltip() { - this.tooltipVisible.set(false); - } - - onExportResult(section: HTMLElement) { - const payload = this.resultExportPayload(); - if (!section || !payload) { - console.warn('No result or element to export'); - return; - } - this.pdfExport.exportExperimentPdf({ ...payload, chartContainer: section }); - } - - onExportResultZip() { - const payload = this.resultExportPayload(); - if (!payload) { - console.warn('No result to export'); - return; - } - this.csvExport.exportExperimentZip({ - ...payload, - tables: experimentTables(payload.algorithmKey, payload.result), - }).catch((error) => { - console.error('Result ZIP export failed:', error); - this.errorMsg.set('Failed to export the result tables.'); - }); - } - - /** The run's setup and result, shared by the PDF report and the ZIP export. */ - private resultExportPayload() { - const result = this.experimentStudioService.runResult(); - if (!result) return null; - - const info = this.experimentInfo(); - const algoKey = - this.lastRunAlgorithm() || - this.experimentStudioService.lastUsedAlgorithm() || - this.experimentStudioService.selectedAlgorithm()?.name || - 'experiment'; - - const algoConfig = this.experimentStudioService.backendAlgorithms()[algoKey]; - const algoLabel = algoConfig?.label || algoKey; - - const currentUser = this.authService.currentUser; - const createdBy = - currentUser?.fullname || currentUser?.username || currentUser?.email || null; - - const filterLogic = this.experimentStudioService.filterLogic(); - const enumMaps = this.experimentStudioService.getCategoricalEnumMaps(); - const filterExpression = filterLogic - ? formatFilterExpression(filterLogic, { labelMap: this.labelMap(), enumMaps }) - : ''; - const model = this.experimentStudioService.selectedDataModel(); - - return { - filename: info.experimentName, - details: { - experimentName: info.experimentName, - createdBy, - createdAt: new Date(), - algorithm: algoLabel, - params: info.algorithmConfigs, - preprocessing: this.experimentStudioService - .getEffectivePreprocessingEntries(algoKey, this.labelMap(), enumMaps) - .map((entry) => `${entry.label}: ${entry.value}`) - .join('\n'), - domain: model ? [model.label || model.code, model.version].filter(Boolean).join(' ') : null, - datasets: this.datasetsWithLabels().map((d) => d.label), - variables: (info.variables ?? []).map(variableDisplayName), - covariates: (info.covariates ?? []).map(variableDisplayName), - filters: filterExpression - ? [filterExpression] - : (info.filters ?? []).map(variableDisplayName), - transformations: null, - mipVersion: this.mipVersion, - }, - algorithmKey: algoKey, - result, - }; - } -} - -/** How exports name a variable: label, then name, then code. */ -function variableDisplayName(variable: StudioVariable): string { - return variable.label || variable.name || variable.code || ''; -} - -function hasCode(variable: StudioVariable | null | undefined): variable is OutlierReportVariable { - return !!variable?.code; } diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts new file mode 100644 index 00000000..fc4cbe35 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts @@ -0,0 +1,119 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmCatalogService } from './algorithm-catalog.service'; + +const base: AlgorithmConfig = { + name: 'anova_oneway', + label: 'One-way ANOVA', + description: '', + category: 'Group comparison', + requiredVariable: [], + covariate: [], + configSchema: [], + isDisabled: false, +}; + +const detail = (messages: string[]): AlgorithmAvailabilityDetail => ({ + role: 'y', + label: 'Outcome', + selectedCount: 0, + minCount: 1, + maxCount: 1, + required: true, + types: [], + stattypes: [], + messages, + satisfied: messages.length === 0, +}); + +describe('AlgorithmCatalogService', () => { + let studio: { + selectedAlgorithm: ReturnType>; + availableGroupedAlgorithms: ReturnType>>; + isAlgorithmAvailable: ReturnType; + getAlgorithmAvailability: ReturnType; + }; + let service: AlgorithmCatalogService; + + beforeEach(() => { + studio = { + selectedAlgorithm: signal(null), + availableGroupedAlgorithms: signal>({ + 'Group comparison': [base, { ...base, name: 'kruskal', label: 'Kruskal-Wallis' }], + Association: [{ ...base, name: 'correlation', label: 'Correlation', isDisabled: true }], + }), + isAlgorithmAvailable: vi.fn().mockReturnValue(true), + getAlgorithmAvailability: vi.fn().mockReturnValue({ available: true, summary: '', details: [] }), + }; + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: ExperimentStudioService, useValue: studio }, + AlgorithmCatalogService, + ], + }); + service = TestBed.inject(AlgorithmCatalogService); + }); + + it('counts and filters methods by the Runnable/All switch and search text', () => { + expect(service.algoCounts()).toEqual({ all: 3, active: 2 }); + expect(service.filteredAlgorithmCategories().map((category) => category.name)).toEqual(['Group comparison']); + + service.toggleActiveOnly(); + expect(service.filteredAlgorithmCategories().map((category) => category.name)).toEqual(['Group comparison', 'Association']); + + service.algoSearchQuery.set('kruskal'); + expect(service.searchActive()).toBe(true); + expect(service.filteredAlgorithmCategories().flatMap((category) => category.algorithms.map((a) => a.name))).toEqual(['kruskal']); + + service.clearSearch(); + expect(service.searchActive()).toBe(false); + }); + + it('keeps a group the user toggled as they left it when the selection moves', () => { + service.toggleActiveOnly(); + expect(service.isCategoryOpen('Group comparison')).toBe(true); + expect(service.isCategoryOpen('Association')).toBe(false); + + service.toggleCategory('Association'); + service.toggleCategory('Group comparison'); + expect(service.isCategoryOpen('Association')).toBe(true); + expect(service.isCategoryOpen('Group comparison')).toBe(false); + + // Selecting a method opens its group, without closing the one the user opened. + studio.selectedAlgorithm.set(base); + expect(service.isCategoryOpen('Group comparison')).toBe(true); + expect(service.isCategoryOpen('Association')).toBe(true); + }); + + it('words why a method is unavailable from its summary, else its first failed check', () => { + studio.getAlgorithmAvailability.mockReturnValue({ available: false, summary: 'Outcome type must be one of text.', details: [] }); + expect(service.algorithmUnavailableReason(base)).toBe('Outcome type must be one of nominal.'); + + studio.getAlgorithmAvailability.mockReturnValue({ available: false, summary: '', details: [] }); + const withDetails = { ...base, availability: { available: false, summary: '', details: [detail([]), detail(['Outcome needs at least 1, selected 0.'])] } }; + expect(service.algorithmUnavailableReason(withDetails)).toBe('Outcome needs at least 1, selected 0.'); + + expect(service.algorithmUnavailableReason(base)).toBe('Unavailable'); + }); + + it('reads availability checks from the method, else from the studio', () => { + const own = [detail(['own'])]; + const studioDetails = [detail(['studio'])]; + studio.getAlgorithmAvailability.mockReturnValue({ available: false, summary: '', details: studioDetails }); + + expect(service.availabilityDetails(null)).toEqual([]); + expect(service.availabilityDetails({ ...base, availability: { available: false, summary: '', details: own } })).toBe(own); + expect(service.availabilityDetails(base)).toBe(studioDetails); + }); + + it('marks the selected method unavailable from the studio availability', () => { + expect(service.selectedAlgorithmUnavailable()).toBe(false); + studio.selectedAlgorithm.set(base); + studio.isAlgorithmAvailable.mockReturnValue(false); + studio.selectedAlgorithm.set({ ...base }); + expect(service.selectedAlgorithmUnavailable()).toBe(true); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts new file mode 100644 index 00000000..d23ac8a8 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts @@ -0,0 +1,108 @@ +import { Injectable, computed, inject, linkedSignal, signal, untracked } from '@angular/core'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { formatAvailabilityMessage } from '../utils/algorithm-availability-text.utils'; +import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories } from '../utils/algorithm-catalog.utils'; +import { AlgorithmCategory } from '../algorithm-panel.models'; + +/** + * The method catalog: search text, the Runnable/All filter, which groups are open, the + * availability tooltip, and why a method is unavailable. Provided by the algorithm panel. + */ +@Injectable() +export class AlgorithmCatalogService { + private readonly studio = inject(ExperimentStudioService); + + // The catalog defaults to runnable methods only: each group lists just the available + // algorithms. Switching to All re-lists unavailable methods with their reason. + readonly showOnlyActive = signal(true); + /** Instant text filter over label, name, description, documentation, category, and flags. */ + readonly algoSearchQuery = signal(''); + readonly searchActive = computed(() => this.algoSearchQuery().trim().length > 0); + + readonly filteredAlgorithmCategories = computed(() => filterAlgorithmCategories( + this.studio.availableGroupedAlgorithms(), + this.showOnlyActive(), + this.algoSearchQuery(), + )); + readonly hasAnyVisibleAlgorithms = computed(() => + this.filteredAlgorithmCategories().some((category) => category.algorithms?.length > 0)); + readonly algoCounts = computed(() => countAlgorithms(this.studio.availableGroupedAlgorithms())); + + /** Groups the user opened or closed themselves; these are never changed automatically. */ + private readonly userToggledCategories = signal>(new Set()); + + /** + * Collapsed catalog groups. A group missing from this set is open, so groups coming back + * after a filter switch keep the state the user left them in. Recomputed from the listed + * groups and the selection (see nextCollapsedCategories); a toggle sets it directly. + */ + private readonly collapsedCategories = linkedSignal< + { categories: AlgorithmCategory[]; selectedName: string | null }, + ReadonlySet + >({ + source: () => ({ + categories: this.filteredAlgorithmCategories(), + selectedName: this.studio.selectedAlgorithm()?.name ?? null, + }), + computation: (source, previous) => nextCollapsedCategories( + source.categories, + source.selectedName, + previous?.source.selectedName !== source.selectedName, + previous?.value ?? new Set(), + untracked(this.userToggledCategories), + ), + }); + + readonly tooltipVisible = signal(false); + readonly tooltipPosition = signal({ x: 0, y: 0 }); + readonly tooltipData = signal(null); + + /** The selected method cannot run with the current roles and datasets. */ + readonly selectedAlgorithmUnavailable = computed(() => { + const algorithm = this.studio.selectedAlgorithm(); + return !!algorithm && !this.studio.isAlgorithmAvailable(algorithm.name); + }); + + toggleActiveOnly(): void { + this.showOnlyActive.update((value) => !value); + } + + clearSearch(): void { + this.algoSearchQuery.set(''); + } + + isCategoryOpen(categoryName: string): boolean { + return !this.collapsedCategories().has(categoryName); + } + + toggleCategory(categoryName: string): void { + this.userToggledCategories.update((toggled) => new Set(toggled).add(categoryName)); + const next = new Set(this.collapsedCategories()); + if (!next.delete(categoryName)) next.add(categoryName); + this.collapsedCategories.set(next); + } + + /** The role checks behind a method's availability: its own, else the studio's. */ + availabilityDetails(algorithm: AlgorithmConfig | null): AlgorithmAvailabilityDetail[] { + if (!algorithm) return []; + if (algorithm.availability?.details?.length) return algorithm.availability.details; + return this.studio.getAlgorithmAvailability(algorithm.name).details ?? []; + } + + /** The one-line reason on an unavailable method's tile. */ + algorithmUnavailableReason(algorithm: AlgorithmConfig): string { + const availability = this.studio.getAlgorithmAvailability(algorithm.name); + const summary = availability?.summary || algorithm.availability?.summary || ''; + if (summary) return formatAvailabilityMessage(summary); + const details = availability?.details?.length + ? availability.details + : (algorithm.availability?.details ?? []); + const first = details.find((detail) => detail.messages.length > 0); + return first ? formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; + } + + hideTooltip(): void { + this.tooltipVisible.set(false); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts new file mode 100644 index 00000000..1ebcf531 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts @@ -0,0 +1,102 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { StudioVariable } from '../algorithm-panel.models'; +import { AlgorithmConfigFormService } from './algorithm-config-form.service'; + +const algorithm: AlgorithmConfig = { + name: 'flat_config_algorithm', + label: 'Flat Config Algorithm', + description: '', + category: 'Test', + requiredVariable: [], + covariate: [], + configSchema: [ + { key: 'first', label: 'First', type: 'number', default: 1 }, + { key: 'second', label: 'Second', type: 'number', default: 2 }, + { key: 'third', label: 'Third', type: 'number', default: 3 }, + { key: 'fourth', label: 'Fourth', type: 'number', default: 4 }, + ], + isDisabled: false, +}; + +describe('AlgorithmConfigFormService', () => { + let studio: ReturnType; + let service: AlgorithmConfigFormService; + + function createStudio() { + return { + selectedAlgorithm: signal(algorithm), + algorithmY: signal([]), + algorithmX: signal([]), + algorithmAssignableVariables: signal([]), + algorithmConfigurations: signal>>({}), + backendAlgorithms: signal>({}), + isCrossValidationOnly: vi.fn().mockReturnValue(false), + getCrossValidationBase: vi.fn().mockReturnValue(null), + getCrossValidationVariant: vi.fn().mockReturnValue(null), + isCrossValidationAlgorithm: vi.fn().mockReturnValue(false), + }; + } + + beforeEach(() => { + studio = createStudio(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: ExperimentStudioService, useValue: studio }, + AlgorithmConfigFormService, + ], + }); + service = TestBed.inject(AlgorithmConfigFormService); + TestBed.tick(); + }); + + it('keeps fields after the old advanced cutoff in the persisted form config', () => { + service.configForm().get('fourth')?.setValue('42'); + + expect(studio.algorithmConfigurations()['flat_config_algorithm']['fourth']).toBe(42); + }); + + it('builds the form from the stored config of the selected method', () => { + studio.algorithmConfigurations.set({ flat_config_algorithm: { first: 7 } }); + service.resetForNewAlgorithm(); + studio.selectedAlgorithm.set({ ...algorithm }); + TestBed.tick(); + + expect(service.configForm().get('first')?.value).toBe(7); + expect(service.configForm().get('second')?.value).toBe(2); + }); + + it('carries the parameters over to the cross-validation variant and back', () => { + studio.getCrossValidationVariant.mockImplementation((name: string) => name === 'flat_config_algorithm' ? 'flat_config_algorithm_cv' : null); + studio.backendAlgorithms.set({ flat_config_algorithm: algorithm }); + studio.algorithmConfigurations.set({ flat_config_algorithm_cv: { first: 1, n_splits: 5 } }); + TestBed.tick(); + + service.setCrossValidation(true); + expect(service.crossValidationEnabled()).toBe(true); + expect(studio.algorithmConfigurations()['flat_config_algorithm_cv']).toEqual( + expect.objectContaining({ first: 1, fourth: 4, n_splits: 5 })); + + service.setCrossValidation(false); + expect(service.crossValidationEnabled()).toBe(false); + expect(studio.algorithmConfigurations()['flat_config_algorithm_cv']).not.toHaveProperty('n_splits'); + }); + + it('reads an outlier_report variable without a rule as an enabled default', () => { + const rule = service.outlierReportRuleFor({ code: 'age', label: 'Age', type: 'real' }); + + expect(rule.enabled).toBe(true); + expect(service.outlierReportRuleError({ code: 'age' })).toBe(''); + }); + + it('clears the form when nothing is selected', () => { + studio.selectedAlgorithm.set(null); + TestBed.tick(); + + expect(Object.keys(service.configForm().controls)).toEqual([]); + expect(service.invalid()).toBe(false); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts new file mode 100644 index 00000000..35000dc2 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts @@ -0,0 +1,466 @@ +import { Injectable, computed, effect, inject, signal, untracked } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmNames } from '../../../../core/constants/algorithm.constants'; +import { optionBindingValue } from '../../../../core/algorithm-parameter.utils'; +import { + createDefaultOutlierRule, + defaultFoldForStrategy, + hydrateOutlierRules, + isOutlierEligibleVariable, + OutlierRule, + OutlierStrategy, + OutlierTail, + serializeOutlierRules, + validateOutlierRule, +} from '../../../../core/outlier-rules'; +import { + buildParameterForm, + enrichParameterSchema, + enumOptionsForVariableCode, + isAdvancedField, + multiSelectValues, + nonBlankString, + normalizeParameterValues, + parameterFieldLabel, + parameterFormSignature, +} from '../utils/algorithm-parameter-form.utils'; +import { + AlgorithmParameterField, + EnrichedParameterField, + OutlierReportVariable, + ParameterOption, + StudioVariable, +} from '../algorithm-panel.models'; + +/** outlier_report keys its rules by variable; the editor, not the form, edits them. */ +const OUTLIER_RULE_KEYS = ['strategies', 'tails', 'folds']; + +/** + * The selected method's parameter form: the schema enriched with the assigned roles, the + * reactive form built from it and persisted on every edit, cross-validation, the + * positive_class options for the chosen event variable, and the outlier_report rules. + * Provided by the algorithm panel. + */ +@Injectable() +export class AlgorithmConfigFormService { + private readonly studio = inject(ExperimentStudioService); + private readonly selectedAlgorithm = this.studio.selectedAlgorithm; + + private readonly form = signal(new FormGroup({})); + /** The selected method's parameter form; replaced when the schema it was built from changes. */ + readonly configForm = this.form.asReadonly(); + private readonly formInvalid = signal(false); + readonly invalid = this.formInvalid.asReadonly(); + /** Signature of the built form; cleared so that picking a method always rebuilds it. */ + private readonly builtFormSignature = signal(''); + + private readonly crossValidation = signal(false); + readonly crossValidationEnabled = this.crossValidation.asReadonly(); + /** Cross-validation on/off the user chose per base method; a memory, not reactive state. */ + private readonly crossValidationChoices = new Map(); + + readonly isCrossValidationOnly = computed(() => { + const algorithm = this.selectedAlgorithm(); + return !!algorithm && this.studio.isCrossValidationOnly(algorithm.name); + }); + + readonly canToggleCrossValidation = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return false; + if (this.studio.isCrossValidationOnly(algorithm.name)) return false; + const baseName = this.studio.getCrossValidationBase(algorithm.name) ?? algorithm.name; + return !!this.studio.getCrossValidationVariant(baseName) && !!this.studio.backendAlgorithms()[baseName]; + }); + + readonly isOutlierReportSelected = computed( + () => this.selectedAlgorithm()?.name === AlgorithmNames.OUTLIER_REPORT + ); + + /** Live event_var from the config form; avoids reading FormGroup inside other computeds. */ + private readonly eventVarSelection = signal(null); + + readonly selectedEventVarCode = computed(() => { + const algorithm = this.selectedAlgorithm(); + return algorithm ? this.resolveSelectedEventVarCode(algorithm.name) : null; + }); + + readonly positiveClassOptions = computed(() => this.enumOptionsForVariableCode(this.selectedEventVarCode())); + + readonly enrichedConfigSchema = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return []; + return enrichParameterSchema(algorithm, { + y: this.studio.algorithmY(), + x: this.studio.algorithmX(), + eventVarCode: this.selectedEventVarCode(), + withCrossValidationFolds: this.crossValidation() && this.canToggleCrossValidation(), + }); + }); + + /** The fields the form shows; outlier_report's rule dictionaries belong to its editor. */ + readonly visibleConfigSchema = computed(() => { + const schema = this.enrichedConfigSchema(); + if (!this.isOutlierReportSelected()) return schema; + return schema.filter((field) => field.type !== 'dict' && !OUTLIER_RULE_KEYS.includes(String(field.key))); + }); + + readonly primaryConfigSchema = computed(() => + this.visibleConfigSchema().filter((field) => + !this.isCrossValidationField(field) && !isAdvancedField(field) && this.form().get(field.key)) + ); + + readonly advancedConfigSchema = computed(() => + this.visibleConfigSchema().filter((field) => + !this.isCrossValidationField(field) && isAdvancedField(field) && this.form().get(field.key)) + ); + + readonly crossValidationConfigSchema = computed(() => + this.crossValidation() + ? this.visibleConfigSchema().filter((field) => this.isCrossValidationField(field) && this.form().get(field.key)) + : [] + ); + + readonly hasAlgorithmParameterControls = computed(() => ( + this.visibleConfigSchema().length > 0 || + this.isOutlierReportSelected() || + this.canToggleCrossValidation() || + this.isCrossValidationOnly() + )); + + /** Numerical variables of the assignable pool, once each: the outlier_report rows. */ + readonly outlierReportVariables = computed(() => { + const unique = new Map(); + this.studio.algorithmAssignableVariables().forEach((variable: StudioVariable) => { + if (hasCode(variable)) unique.set(String(variable.code), variable); + }); + return Array.from(unique.values()).filter((variable) => isOutlierEligibleVariable(variable)); + }); + + /** Per-variable outlier_report rules, keyed by variable code. */ + private readonly outlierReportRules = signal>({}); + /** Fold errors shown under each outlier_report rule, keyed by variable code. */ + private readonly outlierReportValidationErrors = signal>({}); + + constructor() { + effect(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) { + this.crossValidation.set(false); + return; + } + if (this.studio.isCrossValidationOnly(algorithm.name)) { + this.crossValidation.set(true); + return; + } + const baseName = this.studio.getCrossValidationBase(algorithm.name) ?? algorithm.name; + if (!this.studio.getCrossValidationVariant(baseName)) { + this.crossValidation.set(false); + return; + } + const stored = this.crossValidationChoices.get(baseName); + this.crossValidation.set(stored ?? this.studio.isCrossValidationAlgorithm(algorithm.name)); + }); + + effect(() => { + const algorithm = this.selectedAlgorithm(); + const variables = this.outlierReportVariables(); + if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) { + this.outlierReportValidationErrors.set({}); + return; + } + const stored = untracked(() => this.studio.algorithmConfigurations()[algorithm.name] ?? {}); + this.reconcileOutlierReportRules(variables, stored); + }); + + effect((onCleanup) => { + const algorithm = this.selectedAlgorithm(); + const schema = this.visibleConfigSchema(); + + if (!algorithm) { + this.builtFormSignature.set(''); + this.eventVarSelection.set(null); + this.form.set(new FormGroup({})); + this.formInvalid.set(false); + return; + } + + const schemaSignature = parameterFormSignature(algorithm.name, this.crossValidation(), schema); + if (schemaSignature === untracked(this.builtFormSignature)) { + return; + } + this.builtFormSignature.set(schemaSignature); + + const stored = untracked(() => this.studio.algorithmConfigurations())?.[algorithm.name] || {}; + this.eventVarSelection.set(nonBlankString(stored['event_var'])); + + const form = buildParameterForm(schema, stored); + this.form.set(form); + this.formInvalid.set(form.invalid); + + const statusSub = form.statusChanges.subscribe(() => this.formInvalid.set(form.invalid)); + const subscription = form.valueChanges.subscribe((values) => { + this.syncEventVarSelectionFromValues(algorithm.name, values); + this.persistCurrentFormConfig(algorithm.name, untracked(() => this.visibleConfigSchema())); + }); + onCleanup(() => { + subscription.unsubscribe(); + statusSub.unsubscribe(); + }); + }); + } + + /** Forgets the built form and event variable so the next method starts from its stored config. */ + resetForNewAlgorithm(): void { + this.builtFormSignature.set(''); + this.eventVarSelection.set(null); + } + + /** n_splits belongs to the cross-validation row, so it renders under that switch, not in the main form. */ + isCrossValidationField(field: AlgorithmParameterField): boolean { + return field.key === 'n_splits' && (this.canToggleCrossValidation() || this.isCrossValidationOnly()); + } + + fieldLabel(field: EnrichedParameterField): string { + return parameterFieldLabel(field, this.studio.algorithmX()[0]); + } + + bindOptionValue(option: ParameterOption): string { + return optionBindingValue(option); + } + + isMultiSelectOptionSelected(key: string, option: ParameterOption): boolean { + return multiSelectValues(this.form().get(key)?.value).includes(String(this.bindOptionValue(option))); + } + + toggleMultiSelectOption(key: string, option: ParameterOption): void { + const control = this.form().get(key); + if (!control) return; + const value = String(this.bindOptionValue(option)); + const current = multiSelectValues(control.value); + const next = current.includes(value) + ? current.filter((entry) => entry !== value) + : [...current, value]; + control.setValue(next); + control.markAsTouched(); + control.updateValueAndValidity(); + } + + /** Switches between the base method and its cross-validation variant, carrying the parameters over. */ + setCrossValidation(enabled: boolean): void { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return; + const baseName = this.studio.getCrossValidationBase(algorithm.name) ?? algorithm.name; + const variant = this.studio.getCrossValidationVariant(baseName); + if (!variant) return; + const configs = this.studio.algorithmConfigurations(); + const preservedConfig = { ...(configs[baseName] ?? {}), ...this.form().getRawValue() }; + + this.crossValidationChoices.set(baseName, enabled); + + if (!enabled) { + if (preservedConfig['n_splits'] !== undefined) delete preservedConfig['n_splits']; + if (this.form().contains('n_splits')) this.form().removeControl('n_splits'); + + const variantConfig = { ...(configs[variant] ?? {}) }; + if (variantConfig['n_splits'] !== undefined) delete variantConfig['n_splits']; + this.studio.algorithmConfigurations.set({ + ...configs, + [baseName]: preservedConfig, + [variant]: variantConfig, + }); + } else { + this.studio.algorithmConfigurations.set({ + ...configs, + [baseName]: preservedConfig, + [variant]: { ...(configs[variant] ?? {}), ...preservedConfig }, + }); + } + + this.crossValidation.set(enabled); + } + + /** The parameters a run sends: the visible form values, plus the outlier_report rules. */ + runParameterValues(): Record { + return this.mergeOutlierReportConfig(normalizeParameterValues(this.form().getRawValue(), this.visibleConfigSchema())); + } + + // --- outlier_report rules --- + + /** The variable's rule; a variable without one reads as an enabled default rule. */ + outlierReportRuleFor(variable: OutlierReportVariable): OutlierRule { + return this.outlierReportRules()[variable.code] ?? createDefaultOutlierRule(variable.code, true); + } + + outlierReportRuleError(variable: OutlierReportVariable): string { + return this.outlierReportValidationErrors()[variable.code] || validateOutlierRule(this.outlierReportRuleFor(variable)) || ''; + } + + onOutlierReportEnabledChange(variable: OutlierReportVariable, enabled: boolean): void { + this.patchOutlierRule(variable, { enabled }, () => ''); + } + + onOutlierReportStrategyChange(variable: OutlierReportVariable, strategy: OutlierStrategy): void { + this.patchOutlierRule(variable, { enabled: true, strategy, fold: defaultFoldForStrategy(strategy) }, () => ''); + } + + onOutlierReportTailChange(variable: OutlierReportVariable, tail: OutlierTail): void { + this.patchOutlierRule(variable, { enabled: true, tail }); + } + + onOutlierReportFoldChange(variable: OutlierReportVariable, rawValue: string | number): void { + const value = String(rawValue ?? '').trim(); + const fold = value === '' ? null : Number(value); + this.patchOutlierRule( + variable, + { enabled: true, fold: typeof fold === 'number' && Number.isFinite(fold) ? fold : null }, + (rule) => validateOutlierRule(rule) ?? '', + ); + } + + /** + * Checks the outlier_report rules before a run and persists them when they pass. + * Returns the message that blocks the run, or null (always null for other methods). + */ + validateOutlierReportForRun(): string | null { + if (!this.isOutlierReportSelected()) return null; + + const variables = this.outlierReportVariables(); + if (!variables.length) { + return 'Select at least one numerical variable or covariate for the outlier report.'; + } + + // Every listed variable runs with a rule, so the defaults it reads as become real rules. + this.outlierReportRules.update((rules) => { + const complete = { ...rules }; + variables.forEach((variable) => { complete[variable.code] ??= createDefaultOutlierRule(variable.code, true); }); + return complete; + }); + + const errors: Record = {}; + variables.forEach((variable) => { + const error = validateOutlierRule(this.outlierReportRuleFor(variable)); + if (error) errors[variable.code] = error; + }); + + this.outlierReportValidationErrors.set(errors); + if (Object.keys(errors).length > 0) { + return 'Fix the outlier report configuration before running.'; + } + + const serialized = serializeOutlierRules( + this.outlierReportRules(), + new Set(variables.map((variable) => variable.code)) + ); + if (!serialized) { + return 'Enable at least one numerical variable for the outlier report.'; + } + + this.persistOutlierReportConfig(); + return null; + } + + private resolveSelectedEventVarCode(algorithmName: string): string | null { + const stored = untracked(() => this.studio.algorithmConfigurations())[algorithmName]?.['event_var']; + return nonBlankString(this.eventVarSelection()) ?? nonBlankString(stored); + } + + private syncEventVarSelectionFromValues(algorithmName: string, values: Record): void { + if (!Object.prototype.hasOwnProperty.call(values, 'event_var')) return; + const next = nonBlankString(values['event_var']); + if (this.eventVarSelection() === next) return; + this.eventVarSelection.set(next); + this.syncPositiveClassWithEventVar(algorithmName); + } + + private enumOptionsForVariableCode(varCode: string | null | undefined) { + return enumOptionsForVariableCode(varCode, [...this.studio.algorithmY(), ...this.studio.algorithmX()]); + } + + /** Clears a positive_class that is not a level of the newly chosen event variable. */ + private syncPositiveClassWithEventVar(algorithmName: string): void { + const positiveControl = this.form().get('positive_class'); + if (!positiveControl) return; + + const options = this.enumOptionsForVariableCode(this.resolveSelectedEventVarCode(algorithmName)); + if (!options.length) return; + + const allowed = new Set(options.map((option) => option.code)); + const current = positiveControl.value; + if ( + current !== null && + current !== undefined && + String(current).trim() !== '' && + !allowed.has(String(current)) + ) { + positiveControl.setValue(null, { emitEvent: false }); + } + } + + private persistCurrentFormConfig(algorithmName: string, schema: readonly EnrichedParameterField[]): void { + const values = normalizeParameterValues(this.form().getRawValue(), schema); + const nextValues = algorithmName === AlgorithmNames.OUTLIER_REPORT + ? this.mergeOutlierReportConfig(values) + : values; + const allConfigs = this.studio.algorithmConfigurations(); + const previous = allConfigs[algorithmName]; + if (previous && JSON.stringify(previous) === JSON.stringify(nextValues)) return; + this.studio.algorithmConfigurations.set({ ...allConfigs, [algorithmName]: nextValues }); + } + + private mergeOutlierReportConfig(configValues: Record): Record { + if (!this.isOutlierReportSelected()) return configValues; + OUTLIER_RULE_KEYS.forEach((key) => delete configValues[key]); + const payload = serializeOutlierRules( + this.outlierReportRules(), + new Set(this.outlierReportVariables().map((variable) => variable.code)) + ); + return payload ? { ...configValues, ...payload } : configValues; + } + + private persistOutlierReportConfig(): void { + const algorithm = this.selectedAlgorithm(); + if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) return; + this.studio.algorithmConfigurations.set({ + ...this.studio.algorithmConfigurations(), + [algorithm.name]: this.runParameterValues(), + }); + } + + /** One rule per listed variable: kept, else restored from the stored config, else a default. */ + private reconcileOutlierReportRules(variables: OutlierReportVariable[], stored: Record): void { + const allowedCodes = new Set(variables.map((variable) => variable.code)); + const hydrated = hydrateOutlierRules(stored, allowedCodes); + const hasStoredRules = !!stored && typeof stored === 'object' && Object.keys(stored?.['strategies'] ?? {}).length > 0; + const current = untracked(this.outlierReportRules); + const enableDefaults = Object.keys(current).length > 0 || !hasStoredRules; + const next: Record = {}; + + variables.forEach(({ code }) => { + const kept = current[code] ?? hydrated[code]; + next[code] = kept ? { ...kept } : createDefaultOutlierRule(code, enableDefaults); + }); + + this.outlierReportRules.set(next); + this.outlierReportValidationErrors.update((errors) => + Object.fromEntries(Object.entries(errors).filter(([code]) => allowedCodes.has(code)))); + } + + /** Applies an edit to one rule, optionally replaces its shown error, and persists the config. */ + private patchOutlierRule( + variable: OutlierReportVariable, + patch: Partial, + errorFor?: (rule: OutlierRule) => string, + ): void { + const rule = { ...this.outlierReportRuleFor(variable), ...patch }; + this.outlierReportRules.update((rules) => ({ ...rules, [variable.code]: rule })); + if (errorFor) { + this.outlierReportValidationErrors.update((errors) => ({ ...errors, [variable.code]: errorFor(rule) })); + } + this.persistOutlierReportConfig(); + } +} + +function hasCode(variable: StudioVariable | null | undefined): variable is OutlierReportVariable { + return !!variable?.code; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts new file mode 100644 index 00000000..297832e3 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts @@ -0,0 +1,21 @@ +import { Injectable, signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import { AlgorithmStudioSubstep } from './algorithm-panel.models'; + +/** + * The algorithm studio's sub-step: setup (roles and method), then parameters. Provided by the + * algorithm panel, which re-emits every change as its `studioSubstepChange` output. + */ +@Injectable() +export class AlgorithmStudioSubstepService { + private readonly step = signal('setup'); + private readonly changes = new Subject(); + + readonly current = this.step.asReadonly(); + readonly changed$ = this.changes.asObservable(); + + set(step: AlgorithmStudioSubstep): void { + this.step.set(step); + this.changes.next(step); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts new file mode 100644 index 00000000..0145498b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts @@ -0,0 +1,216 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AuthService } from '../../../../services/auth.service'; +import { CsvExportService } from '../../../../services/csv-export.service'; +import { ErrorService } from '../../../../services/error.service'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { PdfExportService } from '../../../../services/pdf-export.service'; +import { RuntimeEnvService } from '../../../../services/runtime-env.service'; +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmCatalogService } from '../algorithm-selection/algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-selection/algorithm-config-form.service'; +import { StudioVariable } from '../algorithm-panel.models'; +import { AlgorithmRunService } from './algorithm-run.service'; + +const algorithm: AlgorithmConfig = { + name: 'flat_config_algorithm', + label: 'Flat Config Algorithm', + description: '', + category: 'Test', + requiredVariable: [], + covariate: [], + configSchema: [{ key: 'first', label: 'First', type: 'number', default: 1 }], + isDisabled: false, +}; + +const outlierAlgorithm: AlgorithmConfig = { + name: 'outlier_report', + label: 'Outlier Report', + description: '', + category: 'Descriptive Statistics', + requiredVariable: ['real'], + covariate: ['real'], + configSchema: [ + { key: 'strategies', label: 'Strategies', type: 'dict' }, + { key: 'tails', label: 'Tails', type: 'dict' }, + { key: 'folds', label: 'Folds', type: 'dict' }, + ], + isDisabled: false, +}; + +describe('AlgorithmRunService', () => { + let studio: ReturnType; + let run: AlgorithmRunService; + let configForm: AlgorithmConfigFormService; + + function createStudio() { + return { + selectedAlgorithm: signal(algorithm), + algorithmY: signal([]), + algorithmX: signal([]), + algorithmAssignableVariables: signal([]), + unavailableVariables: signal>(new Map()), + selectedFilters: signal([]), + selectedDatasets: signal([]), + selectedDataModel: signal(null), + algorithmConfigurations: signal>>({}), + availableGroupedAlgorithms: signal>({ Test: [algorithm] }), + backendAlgorithms: signal>({ flat_config_algorithm: algorithm }), + isRunning: signal(false), + lastUsedAlgorithm: signal(''), + runResult: signal | null>(null), + runId: signal(null), + runError: signal(null), + lastRunSchema: signal([]), + hasRunStarted: signal(false), + runStatusText: signal(''), + variableLabelMap: signal>({}), + filterLogic: signal(null), + getCategoricalEnumMaps: vi.fn().mockReturnValue({}), + isCrossValidationOnly: vi.fn().mockReturnValue(false), + getCrossValidationBase: vi.fn().mockReturnValue(null), + getCrossValidationVariant: vi.fn().mockReturnValue(null), + isCrossValidationAlgorithm: vi.fn().mockReturnValue(false), + hasAppliedDescriptivePreprocessing: vi.fn().mockReturnValue(true), + hasRequestPreprocessingForRun: vi.fn().mockReturnValue(false), + isAlgorithmAvailable: vi.fn().mockReturnValue(true), + getAlgorithmAvailability: vi.fn().mockReturnValue({ available: true, summary: '', details: [] }), + setRunning: vi.fn(), + runSelectedAlgorithm: vi.fn(), + captureRunSetup: vi.fn(), + getDatasetLabelMap: vi.fn().mockReturnValue({}), + getEffectivePreprocessingEntries: vi.fn().mockReturnValue([]), + }; + } + + beforeEach(() => { + studio = createStudio(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: ExperimentStudioService, useValue: studio }, + { provide: ErrorService, useValue: { clearError: vi.fn() } }, + { provide: AuthService, useValue: { currentUser: null } }, + { provide: PdfExportService, useValue: { exportExperimentPdf: vi.fn() } }, + { provide: CsvExportService, useValue: { exportExperimentZip: vi.fn() } }, + { provide: RuntimeEnvService, useValue: { mipVersion: 'test' } }, + AlgorithmCatalogService, + AlgorithmConfigFormService, + AlgorithmRunService, + ], + }); + configForm = TestBed.inject(AlgorithmConfigFormService); + run = TestBed.inject(AlgorithmRunService); + TestBed.tick(); + }); + + describe('variables missing from a selected dataset', () => { + const unavailable = new Map([ + ['age', ['Dataset B']], + ['sex', ['Dataset B', 'Dataset C']], + ]); + + it('blocks the run while an outcome, predictor or filter is unavailable', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmY.set([{ code: 'age', label: 'Age' }]); + studio.algorithmX.set([{ code: 'mmse', label: 'MMSE' }]); + studio.selectedFilters.set([{ code: 'sex', label: 'Sex' }]); + + expect(run.canRun()).toBe(false); + expect(run.runDisabledReason()).toBe('Remove variables not available in: Dataset B, Dataset C'); + expect(run.algorithmNeeds()).toContainEqual(expect.objectContaining({ + key: 'unavailable-variables', + met: false, + title: 'Remove variables not available in: Dataset B, Dataset C', + })); + + run.runExperiment(); + expect(studio.runSelectedAlgorithm).not.toHaveBeenCalled(); + expect(studio.setRunning).not.toHaveBeenCalled(); + }); + + it('checks predictors on their own', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmX.set([{ code: 'age', label: 'Age' }]); + + expect(run.canRun()).toBe(false); + expect(run.runDisabledReason()).toBe('Remove variables not available in: Dataset B'); + }); + + it('runs again once the unavailable variables are removed', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmY.set([{ code: 'age', label: 'Age' }]); + expect(run.canRun()).toBe(false); + + studio.algorithmY.set([{ code: 'mmse', label: 'MMSE' }]); + expect(run.canRun()).toBe(true); + expect(run.runDisabledReason()).toBe('Execute experiment'); + expect(run.algorithmNeeds().some((need) => need.key === 'unavailable-variables')).toBe(false); + }); + + it('ignores unavailable variables the run does not use', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmAssignableVariables.set([{ code: 'age', label: 'Age' }]); + + expect(run.canRun()).toBe(true); + }); + }); + + it('shows the run error and stops running when the run request fails', () => { + studio.runSelectedAlgorithm.mockReturnValue(throwError(() => new Error('500'))); + + run.runExperiment(); + + expect(studio.runError()).toBe('Unable to run experiment. Please try again.'); + expect(studio.runId()).toBeNull(); + expect(studio.setRunning).toHaveBeenCalledWith(true); + expect(studio.setRunning).toHaveBeenLastCalledWith(false); + }); + + describe('outlier_report', () => { + const age = { code: 'age', label: 'Age', type: 'real' }; + const bmi = { code: 'bmi', label: 'BMI', type: 'real' }; + + beforeEach(() => { + studio.selectedAlgorithm.set(outlierAlgorithm); + studio.backendAlgorithms.set({ outlier_report: outlierAlgorithm }); + studio.hasAppliedDescriptivePreprocessing.mockReturnValue(false); + }); + + it('sends the rules as dictionary parameters and keeps the run', () => { + studio.algorithmAssignableVariables.set([age, { code: 'sex', label: 'Sex', type: 'nominal' }, bmi]); + studio.runId.set('stale-run'); + studio.runSelectedAlgorithm.mockReturnValue(of({ uuid: 'kept-run', status: 'success', result: { featurewise: [] } })); + TestBed.tick(); + + configForm.onOutlierReportEnabledChange(bmi, false); + configForm.onOutlierReportStrategyChange(age, 'quantile'); + configForm.onOutlierReportFoldChange(age, 0.05); + run.runExperiment(); + + expect(studio.algorithmConfigurations()['outlier_report']).toEqual({ + strategies: { age: 'quantile' }, + tails: { age: 'both' }, + folds: { age: 0.05 }, + }); + expect(studio.captureRunSetup).toHaveBeenCalledWith('outlier_report'); + expect(studio.runSelectedAlgorithm).toHaveBeenCalledWith('outlier_report', 'outlier_report'); + // The kept run is what Save persists, so it must be the run behind the shown result. + expect(studio.runId()).toBe('kept-run'); + }); + + it('blocks invalid folds', () => { + studio.algorithmAssignableVariables.set([age]); + TestBed.tick(); + + configForm.onOutlierReportStrategyChange(age, 'quantile'); + configForm.onOutlierReportFoldChange(age, 0.5); + run.runExperiment(); + + expect(studio.runSelectedAlgorithm).not.toHaveBeenCalled(); + expect(run.errorMsg()).toBe('Fix the outlier report configuration before running.'); + expect(studio.setRunning).toHaveBeenLastCalledWith(false); + }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts new file mode 100644 index 00000000..38a0eba1 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts @@ -0,0 +1,405 @@ +import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { ErrorService } from '../../../../services/error.service'; +import { AuthService } from '../../../../services/auth.service'; +import { RuntimeEnvService } from '../../../../services/runtime-env.service'; +import { experimentTables, PdfExportService } from '../../../../services/pdf-export.service'; +import { CsvExportService } from '../../../../services/csv-export.service'; +import { getOutputSchema, prettifyLabel as toHumanLabel } from '../../../../core/algorithm-mappers'; +import { formatFilterExpression } from '../../../../core/filter-display.utils'; +import { AlgorithmNames } from '../../../../core/constants/algorithm.constants'; +import { unavailableDatasetsFor, unavailableVariableTooltip } from '../../../../core/variable-availability.utils'; +import { formatAvailabilityMessage } from '../utils/algorithm-availability-text.utils'; +import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from '../utils/algorithm-run.utils'; +import { AlgorithmCatalogService } from '../algorithm-selection/algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-selection/algorithm-config-form.service'; +import { + AlgorithmNeedItem, + AlgorithmRunRequirement, + ExperimentRunInfo, + StudioVariable, +} from '../algorithm-panel.models'; + +/** + * Running the selected method and exporting its result: what still blocks the run, the + * run request itself, its error, and the PDF and ZIP exports. Provided by the algorithm panel. + */ +@Injectable() +export class AlgorithmRunService { + private readonly studio = inject(ExperimentStudioService); + private readonly catalog = inject(AlgorithmCatalogService); + private readonly configForm = inject(AlgorithmConfigFormService); + private readonly errorService = inject(ErrorService); + private readonly authService = inject(AuthService); + private readonly pdfExport = inject(PdfExportService); + private readonly csvExport = inject(CsvExportService); + private readonly mipVersion = inject(RuntimeEnvService).mipVersion; + + /** Why the last run attempt or export failed, shown in the run bar. */ + readonly errorMsg = signal(null); + /** The algorithm of the last successful run; the exports name it. */ + private readonly lastRunAlgorithm = signal(''); + private readonly selectedAlgorithm = this.studio.selectedAlgorithm; + private readonly labelMap = this.studio.variableLabelMap; + + private readonly selectedAlgorithmAvailabilitySummary = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm || !this.catalog.selectedAlgorithmUnavailable()) return ''; + const summary = this.studio.getAlgorithmAvailability(algorithm.name).summary ?? algorithm.availability?.summary ?? ''; + return formatAvailabilityMessage(summary); + }); + + readonly preprocessingRunBlockMessage = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return null; + if (algorithm.name === AlgorithmNames.OUTLIER_REPORT) return null; + if ( + this.studio.hasAppliedDescriptivePreprocessing() || + this.studio.hasRequestPreprocessingForRun(algorithm.name) + ) return null; + return 'Apply missing value preprocessing before running algorithms.'; + }); + + /** Problems the user fixes elsewhere in the studio, each with the action that gets there. */ + readonly algorithmRunRequirements = computed((): AlgorithmRunRequirement[] => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return []; + + const requirements: AlgorithmRunRequirement[] = []; + if (this.catalog.selectedAlgorithmUnavailable()) { + requirements.push({ + id: 'availability', + message: this.selectedAlgorithmAvailabilitySummary() || 'Algorithm requirements are not satisfied.', + actionLabel: this.availabilityRequirementActionLabel(algorithm.name), + }); + } + const preprocessingMessage = this.preprocessingRunBlockMessage(); + if (preprocessingMessage) { + requirements.push({ id: 'preprocessing', message: preprocessingMessage, actionLabel: 'Go to preprocessing' }); + } + return requirements; + }); + + /** + * "Remove variables not available in: …" when a variable the run uses (outcome, predictor + * or cohort filter) is missing from a selected dataset; '' otherwise. Such variables stay + * in the pool grayed out, so the run is blocked until the user removes them. + */ + readonly unavailableRunVariablesReason = computed(() => { + const map = this.studio.unavailableVariables(); + if (!map.size) return ''; + const datasets = new Set(); + for (const node of [ + ...this.studio.algorithmY(), + ...this.studio.algorithmX(), + ...this.studio.selectedFilters(), + ]) { + unavailableDatasetsFor(map, { code: node?.code }).forEach((label) => datasets.add(label)); + } + return unavailableVariableTooltip([...datasets], 'Remove variables not available in'); + }); + + readonly algorithmNeeds = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return []; + return buildAlgorithmNeeds({ + algorithm, + details: this.catalog.availabilityDetails(algorithm), + hasOutcome: this.studio.algorithmY().length > 0, + unavailableVariablesReason: this.unavailableRunVariablesReason(), + requirements: this.algorithmRunRequirements(), + }); + }); + + readonly canRun = computed(() => ( + !!this.selectedAlgorithm() && + !this.studio.isRunning() && + !this.catalog.selectedAlgorithmUnavailable() && + !this.unavailableRunVariablesReason() && + !this.preprocessingRunBlockMessage() && + !this.configForm.invalid() + )); + + readonly runDisabledReason = computed(() => { + if (this.studio.isRunning()) return 'Experiment is running'; + if (!this.selectedAlgorithm()) return 'Select an algorithm to run'; + if (this.catalog.selectedAlgorithmUnavailable()) return 'Algorithm unavailable for the current selection'; + const unavailable = this.unavailableRunVariablesReason(); + if (unavailable) return unavailable; + const preprocessing = this.preprocessingRunBlockMessage(); + if (preprocessing) return preprocessing; + if (this.configForm.invalid()) return 'Complete variable and algorithm selection to proceed'; + return 'Execute experiment'; + }); + + readonly readinessText = computed(() => readinessText({ + algorithm: this.selectedAlgorithm(), + canRun: this.canRun(), + needs: this.algorithmNeeds(), + y: this.studio.algorithmY(), + x: this.studio.algorithmX(), + })); + + private readonly outputSchema = computed(() => getOutputSchema(this.selectedAlgorithm()?.name ?? '') ?? []); + + private readonly datasetsWithLabels = computed(() => { + const labelByCode = this.studio.getDatasetLabelMap(); + const fallbackMap = this.labelMap(); + return this.studio.selectedDatasets().map((code) => ({ + code, + label: labelByCode[code] ?? fallbackMap[code] ?? toHumanLabel(code), + })); + }); + + /** Name, roles, filters and parameters of the run the exports describe. */ + private readonly experimentInfo = computed((): ExperimentRunInfo => { + const selected = this.studio.selectedAlgorithm(); + const lastRunName = this.studio.lastUsedAlgorithm() || this.lastRunAlgorithm(); + const algoName = selected?.name || lastRunName || null; + const algoMeta = algoName ? this.studio.backendAlgorithms()[algoName] : null; + const displayLabel = selected?.label || algoMeta?.label || algoName || 'N/A'; + const allConfigs = this.studio.algorithmConfigurations(); + + return { + experimentName: `Experiment for ${displayLabel}`, + variables: this.studio.algorithmY(), + covariates: this.studio.algorithmX(), + filters: this.studio.selectedFilters(), + algorithmConfigs: (algoName && allConfigs[algoName]) || {}, + }; + }); + + constructor() { + effect(() => { + // Establish dependencies + this.selectedAlgorithm(); + this.studio.algorithmY(); + this.studio.algorithmX(); + + // A new selection invalidates whatever the last run produced + this.studio.runResult.set(null); + this.studio.runError.set(null); + }); + + effect(() => { + const res = this.studio.runResult(); + const schema = this.outputSchema(); + if (!res || !schema.length) return; + + const missing = schema.filter((field) => !(field.key in res)); + if (missing.length > 0) { + console.warn('[Validation] Missing fields from result:', missing.map((field) => field.key)); + } + }); + } + + /** Drops the previous run's result and errors, as picking another method does. */ + clearRunState(): void { + this.studio.runResult.set(null); + this.studio.runError.set(null); + this.errorMsg.set(null); + } + + /** Sends the selected method with its parameters, unless something still blocks the run. */ + runExperiment(): void { + this.errorMsg.set(null); + this.errorService.clearError(); + + if (this.catalog.selectedAlgorithmUnavailable()) { + this.errorMsg.set('This algorithm is unavailable for the current selection.'); + return; + } + + const unavailableVariables = this.unavailableRunVariablesReason(); + if (unavailableVariables) { + this.errorMsg.set(unavailableVariables); + return; + } + + const preprocessingMessage = this.preprocessingRunBlockMessage(); + if (preprocessingMessage) { + this.errorMsg.set(preprocessingMessage); + return; + } + + // algorithm does not run if form is invalid + const form = this.configForm.configForm(); + if (form.invalid) { + form.markAllAsTouched(); + console.warn('[AlgorithmPanel] Run blocked – configForm invalid'); + return; + } + + const algo = this.studio.selectedAlgorithm(); + this.errorMsg.set(null); + this.errorService.clearError(); + this.studio.runStatusText.set('Processing experiment...'); + this.studio.hasRunStarted.set(true); + this.studio.runError.set(null); + this.studio.runResult.set(null); + this.studio.runId.set(null); + this.studio.setRunning(true); + + if (!algo) { + console.error('No algorithm selected in service.'); + this.errorMsg.set('Please choose an algorithm before running.'); + this.studio.setRunning(false); + this.studio.hasRunStarted.set(false); + return; + } + + const outlierReportError = this.configForm.validateOutlierReportForRun(); + if (outlierReportError) { + this.errorMsg.set(outlierReportError); + this.studio.setRunning(false); + return; + } + + const { + baseName: baseAlgorithmName, + effectiveName: effectiveAlgorithmName, + finalName: finalAlgorithmName, + includeSplits: shouldIncludeSplits, + } = resolveRunAlgorithmNames(algo.name, this.configForm.crossValidationEnabled(), this.studio); + + this.studio.lastUsedAlgorithm.set(finalAlgorithmName); + this.errorMsg.set(null); + + const configValues = this.configForm.runParameterValues(); + if (!shouldIncludeSplits && configValues['n_splits'] !== undefined) { + delete configValues['n_splits']; + } + this.studio.algorithmConfigurations.set({ + ...this.studio.algorithmConfigurations(), + [baseAlgorithmName]: configValues, + ...(effectiveAlgorithmName !== baseAlgorithmName ? { [effectiveAlgorithmName]: configValues } : {}) + }); + + // Freeze the setup before the request leaves: the Execution step describes the run from + // this snapshot, and parameter edits after the run must not rewrite it. + this.studio.captureRunSetup(finalAlgorithmName); + + const result$ = this.studio.runSelectedAlgorithm(baseAlgorithmName, finalAlgorithmName); + if (!result$) { + this.errorMsg.set('Unable to start the run. Check your selections.'); + this.studio.setRunning(false); + this.studio.hasRunStarted.set(false); + return; + } + + result$.subscribe({ + next: (res) => { + const payload = res?.result ?? {}; + if (res?.status === 'error') { + this.studio.runError.set(payload?.data || payload?.message || 'The server returned an error for this run.'); + return; + } + + const schema = getOutputSchema(finalAlgorithmName ?? '') ?? []; + this.studio.runResult.set({ ...res?.result ?? { message: "No result returned" } }); + // Save persists this kept run, so the saved experiment is the result shown here. + this.studio.runId.set(res?.uuid ?? null); + this.lastRunAlgorithm.set(finalAlgorithmName); + this.studio.lastRunSchema.set(schema); + }, + error: () => { + this.studio.runError.set('Unable to run experiment. Please try again.'); + // An errored stream never completes, so the run state is released here too. + this.studio.setRunning(false); + }, + complete: () => { + this.studio.setRunning(false); + } + }); + } + + /** Exports the shown result and its setup as a PDF report, with the figures in `section`. */ + exportPdf(section: HTMLElement): void { + const payload = this.resultExportPayload(); + if (!section || !payload) { + console.warn('No result or element to export'); + return; + } + this.pdfExport.exportExperimentPdf({ ...payload, chartContainer: section }); + } + + /** Exports the shown result's tables and setup as a ZIP of CSV files. */ + exportZip(): void { + const payload = this.resultExportPayload(); + if (!payload) { + console.warn('No result to export'); + return; + } + this.csvExport.exportExperimentZip({ + ...payload, + tables: experimentTables(payload.algorithmKey, payload.result), + }).catch((error) => { + console.error('Result ZIP export failed:', error); + this.errorMsg.set('Failed to export the result tables.'); + }); + } + + private availabilityRequirementActionLabel(algorithmName: string): string { + const failingDetail = this.studio + .getAlgorithmAvailability(algorithmName) + .details + .find((detail) => !detail.satisfied && detail.messages.length > 0); + return failingDetail?.role === 'x' ? 'Assign predictor' : 'Assign outcome'; + } + + /** The run's setup and result, shared by the PDF report and the ZIP export. */ + private resultExportPayload() { + const result = this.studio.runResult(); + if (!result) return null; + + const info = this.experimentInfo(); + const algoKey = + this.lastRunAlgorithm() || + this.studio.lastUsedAlgorithm() || + this.studio.selectedAlgorithm()?.name || + 'experiment'; + + const algoLabel = this.studio.backendAlgorithms()[algoKey]?.label || algoKey; + + const currentUser = this.authService.currentUser; + const createdBy = currentUser?.fullname || currentUser?.username || currentUser?.email || null; + + const filterLogic = this.studio.filterLogic(); + const enumMaps = this.studio.getCategoricalEnumMaps(); + const filterExpression = filterLogic + ? formatFilterExpression(filterLogic, { labelMap: this.labelMap(), enumMaps }) + : ''; + const model = this.studio.selectedDataModel(); + + return { + filename: info.experimentName, + details: { + experimentName: info.experimentName, + createdBy, + createdAt: new Date(), + algorithm: algoLabel, + params: info.algorithmConfigs, + preprocessing: this.studio + .getEffectivePreprocessingEntries(algoKey, this.labelMap(), enumMaps) + .map((entry) => `${entry.label}: ${entry.value}`) + .join('\n'), + domain: model ? [model.label || model.code, model.version].filter(Boolean).join(' ') : null, + datasets: this.datasetsWithLabels().map((dataset) => dataset.label), + variables: (info.variables ?? []).map(variableDisplayName), + covariates: (info.covariates ?? []).map(variableDisplayName), + filters: filterExpression + ? [filterExpression] + : (info.filters ?? []).map(variableDisplayName), + transformations: null, + mipVersion: this.mipVersion, + }, + algorithmKey: algoKey, + result, + }; + } +} + +/** How exports name a variable: label, then name, then code. */ +function variableDisplayName(variable: StudioVariable): string { + return variable.label || variable.name || variable.code || ''; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts index 15d4d012..644daf13 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts @@ -40,6 +40,13 @@ describe('algorithm availability text', () => { expect(availabilityRequirementText(detail({ types: [] }))).toBe('Outcome: exactly 1, selected 0'); }); + it('displays text availability types as nominal in requirement tips', () => { + const text = availabilityRequirementText(detail({ role: 'x', minCount: 1, maxCount: null, selectedCount: 1, types: ['real', 'int', 'text'] })); + + expect(text).toContain('type: real, int, nominal'); + expect(text).not.toContain('text'); + }); + it('rewrites backend type lists in availability messages', () => { expect(formatAvailabilityMessage('Outcome type must be one of text, polynominal. Other text.')) .toBe('Outcome type must be one of nominal. Other text.'); From 333d9bdd1e644e668fc4a98949db028697f9447b Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:32:56 +0200 Subject: [PATCH 5/8] refactor(algorithm-panel): extract algorithm-selection components section[app-algorithm-selection] holds the card; aside[app-algorithm-catalog], section[app-algorithm-parameters], section[app-outlier-report-editor], div[app-algorithm-config-field] (typed field input, replaces the untyped ng-template) and div[app-algorithm-availability-tooltip] attach to the elements they render into, so the DOM, anchors and selectors are unchanged. Each rule moves to the sheet of the template its element lives in; rules several sheets share move to styles/*.shared.css partials. The panel sheet drops from 22.1 kB (over budget) to 3.7 kB. --- .../algorithm-panel.component.css | 1510 +---------------- .../algorithm-panel.component.html | 428 +---- .../algorithm-panel.component.spec.ts | 17 +- .../algorithm-panel.component.ts | 128 +- ...gorithm-availability-tooltip.component.css | 54 + ...orithm-availability-tooltip.component.html | 18 + ...lgorithm-availability-tooltip.component.ts | 18 + .../algorithm-catalog.component.css | 352 ++++ .../algorithm-catalog.component.html | 83 + .../algorithm-catalog.component.ts | 73 + .../algorithm-config-field.component.css | 124 ++ .../algorithm-config-field.component.html | 93 + .../algorithm-config-field.component.ts | 25 + .../algorithm-parameters.component.css | 296 ++++ .../algorithm-parameters.component.html | 107 ++ .../algorithm-parameters.component.ts | 55 + .../algorithm-selection.component.css | 331 ++++ .../algorithm-selection.component.html | 46 + .../algorithm-selection.component.ts | 25 + .../outlier-report-editor.component.css | 102 ++ .../outlier-report-editor.component.html | 70 + .../outlier-report-editor.component.ts | 23 + .../styles/config-placeholder.shared.css | 41 + .../styles/form-controls.shared.css | 58 + .../algorithm-panel/styles/sr-only.shared.css | 7 + .../styles/step-kicker.shared.css | 21 + 26 files changed, 2114 insertions(+), 1991 deletions(-) create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css index 8332c345..f32eceeb 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css @@ -3,1441 +3,96 @@ master–detail row below it — Algorithm catalog on the left, Algorithm configuration (documentation + parameters) scrolling in the column on the right. Tokens per DESIGN.md — typography, borders, subtle tints; no icon fonts. */ - -/* ROOT LAYOUT - CONTAINER */ - -.experiment-container { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 16px; - padding: 12px 0 0; - font-family: inherit; - position: relative; - align-items: flex-start; - box-sizing: border-box; - width: 100%; -} - -/* The unified studio card is the only child; the host paints no frame itself. */ -.algorithm-studio-card { - min-width: 0; -} - -.algorithm-roles-board { - display: flex; - flex-direction: column; - width: 100%; - min-width: 0; - min-height: 0; - height: auto; - box-sizing: border-box; - border: 1px solid var(--studio-card-border, rgba(148, 163, 184, 0.3)); - border-radius: var(--radius-lg, 12px); - background: var(--studio-card-bg); - box-shadow: var(--studio-card-shadow, 0 8px 32px rgba(15, 23, 42, 0.08)); - color: var(--text-main); -} - -.algorithm-roles-board__header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px 16px; - padding: 12px 14px; - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); - background: var(--studio-card-header-bg, #f8fbff); -} - -.algorithm-roles-board__title { - margin: 0 0 2px; - font-weight: 700; - line-height: 1.25; - letter-spacing: 0.01em; - color: var(--text-main); -} - -/* Live runnable/total counter — tabular so filtering never reflows the header. */ -.algorithm-count-badge { - gap: 6px; - flex: 0 0 auto; -} - -.algorithm-count-badge strong { - color: var(--primary-color); - font-size: 1rem; - font-weight: 800; - font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; -} - -.algorithm-count-badge span { - font-variant-numeric: tabular-nums; -} - -/* BOTTOM ROW: Algorithm catalog (left) · Algorithm configuration (right). The row - grows with its content (24rem floor) instead of being pinned to a short fixed - height, so the parameter selection expands and every parameter field is readable - in the normal flow — the window is the scroller rather than a nested inner scroll. - The assignment rails sit above the row in flow, so switching methods never moves - them out from under the pointer; only space below the row changes. */ - -.algorithm-workspace { - display: grid; - grid-template-columns: minmax(300px, 20%) minmax(0, 80%); /* 20% catalog · 80% configuration */ - min-width: 0; - min-height: 24rem; - height: auto; - overflow: hidden; -} - -/* TOP BAND: Experiment pool across the full card width — the variable list on the left, - the stacked outcome and predictor rails beside it on the right — so the band only has - to be tall enough for the scrolling list. It is one shrinking grid track rather than a - stretched flex cross-size: that is what lets the band stop at its max-height and hand - the overflow to the list's own scroll, instead of clipping the hint out of the card. */ -.algorithm-pool { - display: grid; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr); - flex: 0 0 auto; - min-width: 0; - min-height: 0; - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); -} - -.algorithm-pool > app-algorithm-role-assignment { - min-width: 0; - min-height: 0; -} - -/* LEFT OF THE BOTTOM ROW: ALGORITHM CATALOG */ - -/* The catalog title mirrors the pool's pane head so the two panes read as peers. */ -.catalog-title { - margin: 0; - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--primary-color); -} - -.algorithm-catalog { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 14px 14px; - min-width: 0; - min-height: 0; - overflow: hidden; - color: var(--text-main); - box-sizing: border-box; - border-right: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); -} - -.algorithm-match-empty { - margin: 0; - padding: 14px 12px; - font-size: 0.78rem; - color: var(--text-muted); - line-height: 1.45; - border: 1px dashed rgba(148, 163, 184, 0.4); - border-radius: var(--radius-md, 8px); -} - -/* Search */ - -.catalog-search { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -/* The clear button overlays the field, so the field — not the row — is its containing - block; the filter chip sits beside the field instead of under it. */ -.catalog-search-field { - position: relative; - display: block; - flex: 1 1 auto; - /* Below this the chip drops to its own line rather than crushing the field: the - row wraps on this minimum plus the chip, so the field never shrinks under a - readable width when the catalog is narrow. */ - min-width: 8rem; -} - -.catalog-search-input { - display: block; - width: 100%; - min-width: 0; - box-sizing: border-box; - padding: 8px 34px 8px 12px; - border-radius: var(--radius-md, 8px); - border: 1px solid rgba(148, 163, 184, 0.34); - background: var(--studio-card-bg); - color: var(--text-main); - font: inherit; - font-size: 0.85rem; - line-height: 1.4; -} - -.catalog-search-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(127, 156, 232, 0.16); -} - -.catalog-search-input::placeholder { - color: #94a3b8; -} - -.catalog-search-clear { - position: absolute; - right: 6px; - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - border: 0; - border-radius: var(--radius-sm, 4px); - background: transparent; - color: var(--text-muted); - font: inherit; - font-size: 1rem; - line-height: 1; - cursor: pointer; -} - -.catalog-search-clear:hover, -.catalog-search-clear:focus-visible { - background: color-mix(in srgb, var(--primary-color) 8%, white); - color: var(--text-main); -} - -/* One status chip (All / Runnable) sits on the search row — see .pill-toggle below. */ - -/* Method list. Category kicker: chevron first, then the group name (per the mock). */ - -.algorithm-match-rows { - display: flex; - flex-direction: column; - gap: 10px; - flex: 1 1 auto; - min-height: 0; - overflow: auto; - padding-right: 2px; -} - -.algorithm-match-row { - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Category kicker doubles as the disclosure control for its tiles. */ -.algorithm-match-cat { - appearance: none; - display: flex; - align-items: center; - gap: 8px; - width: 100%; - margin: 0; - padding: 2px 0; - border: 0; - background: none; - font-family: inherit; - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 0.01em; - color: var(--primary-color); - line-height: 1.3; - text-align: left; - cursor: pointer; -} - -.algorithm-match-cat-name { - min-width: 0; -} - -.algorithm-match-cat-meta { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - order: -1; /* chevron sits before the label */ - color: var(--primary-color); -} - -/* Collapsed points right (›), expanded points down (⌄). */ -.algorithm-match-cat-meta::after { - content: ""; - width: 0.4rem; - height: 0.4rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; -} - -.algorithm-match-cat[aria-expanded="true"] .algorithm-match-cat-meta::after { - transform: rotate(45deg); -} - -.algorithm-match-cat:hover .algorithm-match-cat-meta, -.algorithm-match-cat:focus-visible .algorithm-match-cat-meta { - color: var(--primary-color); -} - -.algorithm-match-tiles { - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Algorithm row: plain label with trailing role chips; no selected highlight — - the chosen method is named in the details and parameters bands below. */ - -.algo-tile { - appearance: none; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 2px; - width: 100%; - max-width: 100%; - border-radius: var(--radius-md, 8px); - border: 1px solid transparent; - background: #ffffff; - color: var(--text-main); - font: inherit; - font-size: 0.8rem; - font-weight: 500; - line-height: 1.45; - text-align: left; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; -} - -.algo-tile:hover:not(.disabled-algo), -.algo-tile:focus-visible:not(.disabled-algo) { - border-color: rgba(43, 51, 233, 0.22); - background: color-mix(in srgb, var(--primary-light) 10%, white); -} - -/* Selected method: accent bar, primary border, and a subtle tint so the tile the - configuration column on the right refers to is unmistakable. */ -.algo-tile.selected:not(.disabled-algo) { - border-color: var(--primary-color); - background: color-mix(in srgb, var(--primary-color) 8%, white); - box-shadow: inset 3px 0 0 var(--primary-color); -} - -/* The chevron points from the chosen tile into the configuration column, so the eye - lands on the details without scanning. Hidden until the tile is the selection. */ -.algo-tile-cue { - display: none; - flex: 0 0 auto; - width: 6px; - height: 6px; - margin-left: auto; - border-top: 2px solid var(--primary-color); - border-right: 2px solid var(--primary-color); - transform: rotate(45deg); -} - -.algo-tile.selected:not(.disabled-algo) .algo-tile-cue { - display: block; -} - -.algo-tile.selected:not(.disabled-algo) .algo-label { - color: var(--primary-color); - font-weight: 700; -} - -.algo-tile-top { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; -} - -.algo-tile .algo-label { - min-width: 0; - color: var(--text-main); - font-weight: 500; - overflow-wrap: anywhere; -} - -/* Required-input chips: amber y (outcome), lavender x (predictor) */ - -.algo-why { - display: block; - font-weight: 500; - line-height: 1.4; -} - -.algo-tile.disabled-algo { - cursor: pointer; - background: color-mix(in srgb, var(--bg-neutral, #f8fafc) 55%, white); - border-color: transparent; - box-shadow: none; - color: var(--text-muted); -} - -.algo-tile.disabled-algo .algo-label { - color: var(--text-muted); -} - -.algo-tile.disabled-algo:hover, -.algo-tile.disabled-algo:focus-visible { - background: color-mix(in srgb, var(--text-muted) 8%, white); -} - -/* A read-only preview selection of an unavailable method: same accent bar, muted. */ -.algo-tile.disabled-algo.selected { - border-color: rgba(100, 116, 139, 0.55); - background: color-mix(in srgb, var(--text-muted) 8%, white); - box-shadow: inset 3px 0 0 rgba(100, 116, 139, 0.55); -} - -/* RIGHT OF THE BOTTOM ROW: ALGORITHM CONFIGURATION — documentation and parameter - fields share one column that grows with its content, so a parameter-rich method - reveals its whole form instead of clipping it under a fixed-height inner scroll. - The board paints the frame: this column adds no border, tint, or shadow of its - own. */ - -.algorithm-config-column { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - overflow: visible; -} - -/* PARAMETERS — continue the configuration flow directly under the documentation; - the unified card paints the frame. */ - -.algorithm-params { - display: flex; - flex-direction: column; - flex: 1 1 auto; - gap: 10px; - padding: 0 16px 16px; - min-width: 0; - box-sizing: border-box; - color: var(--text-main); -} - -.inspector-tag { - display: inline-flex; - align-items: center; - padding: 1px 8px; - border-radius: 999px; - border: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.14)); - background: var(--bg-neutral); - color: var(--text-muted); - font-size: 0.64rem; - font-weight: 700; -} - -.algorithm-params__body { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.algorithm-params__empty { - display: flex; - flex-direction: column; - flex: 1 1 auto; - border-radius: var(--radius-md, 8px); -} - -.algorithm-params__empty-title { - margin: 0; - font-size: 0.92rem; - font-weight: 700; - color: var(--text-main); - line-height: 1.35; -} - -.algorithm-params__empty-text { - margin: 0; - max-width: 30rem; - font-size: 0.8rem; - line-height: 1.6; - color: var(--text-muted); -} - -/* TOP OF THE CONFIGURATION COLUMN — one continuous Algorithm configuration section: - header, description, and documentation. It no longer pins: the column expands to - its content and the window scrolls, so a sticky head would hang over the scrolling - page and hide the parameter fields below it instead of helping them. */ - -.algorithm-details { - display: flex; - flex-direction: column; - gap: 8px; - position: static; - background: var(--studio-card-bg); - color: var(--text-main); -} - -.algorithm-details__header { - flex-direction: column; -} - -.algorithm-details__heading { - flex-wrap: wrap; -} - -/* Documentation */ - -.documentation-panel { - margin: 0; -} - -/* The native disclosure triangle is hidden, so the summary states its own affordance: - a chevron that turns with the state, driven by [open] on the
    itself. */ -.documentation-summary { - gap: 8px; - cursor: pointer; - list-style: none; -} - -.documentation-summary::-webkit-details-marker { - display: none; -} - -/* Same chevron geometry as the catalog groups: right when collapsed, down when open. */ -.documentation-chevron { - flex: 0 0 auto; - width: 0.45rem; - height: 0.45rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; -} - -.documentation-panel[open] > .documentation-summary .documentation-chevron { - transform: rotate(45deg); -} - -.documentation-content { - font-size: 0.82rem; - line-height: 1.45; - color: rgba(20, 22, 31, 0.72); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.config-description { - margin: 0; - font-size: 0.86rem; - line-height: 1.55; - color: var(--text-muted); -} - -/* Run requirements */ - -.config-form { - display: flex; - flex-direction: column; - gap: 10px; -} - -.config-field { - flex-direction: column; -} - -/* Fields are divided by a hairline rather than framed: the parameters read as one - form, and the card already paints the only container. */ -.config-field + .config-field { - border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); - padding-top: 10px; -} - -/* Label + "Required" */ - -.config-field>label { - font-weight: 700; - font-size: 0.72rem; - color: var(--text-muted); - margin-bottom: 5px; - display: flex; - align-items: center; - gap: 8px; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.config-field-desc { - margin: 0 0 8px; - font-size: 0.8rem; - line-height: 1.45; - color: #64748b; -} - -.config-field-hint { - margin: 0 0 8px; - font-size: 0.78rem; - line-height: 1.4; - color: var(--text-muted); -} - -/* Inputs / selects */ - -.config-input, -.config-select { - display: block; - width: 100%; - min-width: 0; - box-sizing: border-box; - padding: 9px 12px; - background: rgba(255, 255, 255, 0.98); - border-radius: var(--radius-md, 8px); - border: 1px solid rgba(148, 163, 184, 0.34); - font-size: 0.85rem; - line-height: 1.4; - transition: border-color 0.18s ease, box-shadow 0.18s ease, - background-color 0.18s ease; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset; -} - -.config-select { - padding-right: 30px; -} - -.config-select[multiple] { - min-height: 7rem; - padding-right: 12px; -} - -.config-input:focus, -.config-select:focus { - outline: none; - border-color: var(--primary-color); - background-color: var(--studio-card-bg); - box-shadow: - 0 0 0 3px rgba(127, 156, 232, 0.16), - 0 4px 12px rgba(43, 51, 233, 0.08); -} - -/* Checkbox wrapper */ - -.checkbox-wrapper { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 0.85rem; - color: var(--text-main); -} - -.checkbox-wrapper input[type="checkbox"] { - width: 18px; - height: 18px; - border-radius: 5px; -} - -/* Validation states */ - -.config-error { - color: #b42318; - font-size: 0.74rem; - margin-top: 5px; -} - -/* Validation states: valid stays neutral; only the invalid edge and message signal a problem. */ - -/* Placeholders */ - -.config-placeholder { - width: 100%; - display: flex; - align-items: center; - gap: 16px; - padding: 14px 16px; - border-radius: var(--radius-md, 8px); - border: 1px solid rgba(226, 232, 240, 0.95); - background: rgba(248, 250, 252, 0.65); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85); -} - -.config-placeholder--minimal { - width: auto; - max-width: 100%; - min-height: 0; -} - -.config-placeholder-content { - display: flex; - flex-direction: column; - gap: 6px; - max-width: 560px; -} - -.config-placeholder-content h5, -.config-placeholder-content h6 { - margin: 0; - color: var(--text-main); - font-size: 0.92rem; - font-weight: 700; -} - -.config-placeholder-content p { - margin: 0; - color: var(--text-muted); - font-size: 0.82rem; - line-height: 1.6; -} - -/* Outlier report table */ - -.outlier-report-editor { - display: flex; - flex-direction: column; - gap: 8px; - padding: 4px 0; -} - -.outlier-editor-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.outlier-editor-header h6 { - margin: 0; - font-size: 0.9rem; - font-weight: 700; - color: var(--text-main); -} - -.outlier-count { - font-size: 0.72rem; - font-weight: 600; - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} - -.outlier-table-wrapper { - overflow-x: auto; -} - -.outlier-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8rem; -} - -.outlier-table th { - padding: 6px 8px; - text-align: left; - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--text-muted); - border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -.outlier-table td { - padding: 6px 8px; - vertical-align: top; - border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -.outlier-table tr:last-child td { - border-bottom: 0; -} - -.outlier-variable-label { - display: block; - font-weight: 600; - color: var(--text-main); -} - -.outlier-table small { - color: var(--text-muted); - font-size: 0.68rem; -} - -.outlier-table .compact-select, -.outlier-table .compact-input { - min-width: 6.5rem; - padding: 5px 8px; - font-size: 0.78rem; -} - -.outlier-enable { - min-height: 24px; -} - -/* Panel-level empty state (no variables selected yet) */ - -.algorithm-panel-empty { - margin: 14px 0 18px; - min-height: min(280px, calc(100vh - 320px)); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 36px 28px; - text-align: center; - border-radius: var(--radius-lg, 12px); - background: rgba(255, 255, 255, 0.4); - border: 1px solid rgba(255, 255, 255, 0.5); - box-shadow: inset 0 2px 10px rgba(43, 51, 233, 0.02); -} - -.algorithm-panel-empty__title { - margin: 4px 0 0; - font-size: 1rem; - font-weight: 700; - color: var(--text-main); - line-height: 1.3; -} - -.algorithm-panel-empty__text { - margin: 0; - max-width: 420px; - font-size: 0.84rem; - line-height: 1.55; - color: #64748b; -} - -/* Tooltip (hover affordance for catalog cards) */ - -.tooltip { - position: absolute !important; - background: var(--studio-card-bg); - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22); - padding: 16px; - border-radius: var(--radius-lg, 12px); - width: 320px; - max-width: calc(100vw - 40px); - max-height: 70vh; - overflow-y: auto; - z-index: 99999; - pointer-events: none; - border: 1px solid rgba(226, 232, 240, 0.8); - opacity: 1; - transition: opacity 0.15s ease-out; -} - -.tooltip-title { - margin: 0 0 8px; - font-size: 1rem; - font-weight: 700; - color: var(--primary-color); - line-height: 1.3; -} - -.tooltip-description { - font-size: 0.82rem; - line-height: 1.5; - color: var(--text-muted); - margin-bottom: 0; -} - -.tooltip-requirements { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(226, 232, 240, 0.6); -} - -.tooltip-requirements-header { - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #94a3b8; - margin-bottom: 8px; -} - -.req-item { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 8px; -} - -.req-copy { - display: flex; - flex-direction: column; - gap: 2px; -} - -.req-text { - font-size: 0.78rem; - color: var(--chart-label-color); - line-height: 1.4; -} - -.req-error { - font-size: 0.72rem; - color: #b45309; - line-height: 1.35; -} - -/* Shared fieldset host: the unified card paints the frame, not the fieldset. */ - -.algorithm-readonly-fieldset { - border: 0; - padding: 0; - margin: 0; - min-width: 0; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); -} - -/* RESPONSIVE LAYOUT */ - -@media (max-width: 960px) { - .algorithm-workspace { - grid-template-columns: minmax(0, 1fr); - height: auto; - min-height: 0; - } - - /* Stacked: head, rails, list and hint sit in one column, so the band hugs that - taller content and the window scrolls rather than the band scrolling inside it. */ - .algorithm-pool { - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); - } - - .algorithm-catalog { - border-right: 0; - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); - max-height: 24rem; - overflow: auto; - } - -} - -@media (max-width: 640px) { - .algo-why { - max-width: 100%; - } -} - -@media (prefers-reduced-motion: reduce) { - .algo-tile, - .algorithm-match-cat-meta::after, - .documentation-chevron { - transition: none; - } -} - -/* ========================================================================= - Algorithm panel review pass: step kickers, compact role band, catalog, - needs checklist, parameter layout, advanced grouping, option switches, - and the readiness bar. - ========================================================================= */ - -.algorithm-step-kicker { - padding: 10px 14px 0; -} - -.step-kicker { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: var(--letter-spacing-config); - text-transform: uppercase; - color: var(--text-muted); -} - -.step-kicker-n { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px var(--studio-card-border); - font-size: 0.68rem; -} - -.algorithm-roles-board__title { - font-size: 0.96rem; -} - -.algorithm-count-badge { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border: 0; - border-radius: 999px; - background: color-mix(in srgb, var(--primary-color) 8%, white); - color: var(--primary-color); - font-size: 0.72rem; - font-weight: 600; - white-space: nowrap; -} - -/* The role band owns its own height now: the expanded roster caps itself and - scrolls; the pool no longer forces the whole assignment band into 22rem. */ -.algorithm-roles-board { - /* visible keeps the run bar sticking to the page scroller; the bar rounds its - own bottom corners so the card frame stays intact. */ - overflow: visible; -} - -.algorithm-pool { - height: auto; - max-height: none; - overflow: visible; -} - -.algorithm-pool > app-algorithm-role-assignment { - overflow: visible; -} - -.catalog-mode-tabs { - flex: 0 0 auto; -} - -.catalog-mode-tabs button { - gap: 4px; - min-height: 26px; - padding: 0 10px; - font-size: 0.74rem; -} - -.catalog-title.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); -} - -.algorithm-match-cat-count { - margin-left: auto; - padding-left: 10px; - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 600; -} - -.algo-tile { - min-height: 36px; - padding: 8px 10px; -} - -.algo-tile-top::before { - content: ''; - flex: 0 0 auto; - width: 10px; - height: 10px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.45); -} - -.algo-tile.selected .algo-tile-top::before { - box-shadow: inset 0 0 0 3.5px var(--primary-color); -} - -.algo-tile.disabled-algo .algo-tile-top::before { - box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.25); -} - -.algo-why { - font-size: 0.74rem; - color: #78350f; -} - -/* Configuration head: number ring, real method label, category tag. */ -.algorithm-details { - padding: 14px 16px 8px; -} - -.algorithm-details__header { - display: flex; +/* ROOT LAYOUT - CONTAINER */ +.experiment-container { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + padding: 12px 0 0; + font-family: inherit; + position: relative; align-items: flex-start; - justify-content: space-between; - gap: 12px; + box-sizing: border-box; + width: 100%; } -.algorithm-details__heading { - display: flex; - align-items: center; - gap: 10px; +/* The unified studio card is the only child; the host paints no frame itself. */ +.algorithm-studio-card { min-width: 0; } -.algorithm-details__title { - margin: 0; +.algorithm-roles-board { + display: flex; + flex-direction: column; + width: 100%; min-width: 0; - font-size: 1.1rem; - font-weight: 700; - line-height: 1.3; + min-height: 0; + height: auto; + box-sizing: border-box; + border: 1px solid var(--studio-card-border, rgba(148, 163, 184, 0.3)); + border-radius: var(--radius-lg, 12px); + background: var(--studio-card-bg); + box-shadow: var(--studio-card-shadow, 0 8px 32px rgba(15, 23, 42, 0.08)); color: var(--text-main); - overflow-wrap: anywhere; -} - -.inspector-tag { - flex: 0 0 auto; - text-transform: none; - letter-spacing: 0; -} - -.config-description { - max-width: 60ch; - margin-top: 6px; -} - -/* Documentation is a quiet text link by default. */ -.documentation-panel { - margin-top: 2px; -} - -.documentation-summary { - display: inline-flex; - align-items: center; - padding: 2px 0; - margin: 0; - border-radius: 0; - background: none; - font-size: 0.8rem; - font-weight: 600; - color: var(--primary-color); -} - -.documentation-summary:hover { - background: none; - text-decoration: underline; -} - -.documentation-chevron { - display: none; -} - -.documentation-panel[open] > .documentation-summary { - border-bottom: 0; - border-radius: 0; -} - -.documentation-content { - margin-top: 6px; - padding-left: 0; - border-left: 0; -} - -/* Needs checklist */ -.algo-needs { - display: grid; - gap: 4px; - margin: 0 0 14px; - padding: 10px 12px; - list-style: none; - border-radius: var(--radius-md); - background: var(--bg-neutral); - font-size: 0.8rem; } -.algo-needs li { +/* Panel-level empty state (no variables selected yet) */ +.algorithm-panel-empty { + margin: 14px 0 18px; + min-height: min(280px, calc(100vh - 320px)); display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 8px; -} - -.algo-needs-mark { - width: 12px; - height: 12px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px #a06b10; - transform: translateY(2px); -} - -.algo-needs li.is-met .algo-needs-mark { - background: var(--success); - box-shadow: none; -} - -.algo-needs small { - flex-basis: 100%; - padding-left: 20px; - color: #78350f; -} - -.algo-needs-all-met { - margin: 0 0 14px; - padding: 8px 12px; - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--success) 10%, white); - color: #065f46; - font-size: 0.8rem; - font-weight: 600; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 36px 28px; + text-align: center; + border-radius: var(--radius-lg, 12px); + background: rgba(255, 255, 255, 0.4); + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: inset 0 2px 10px rgba(43, 51, 233, 0.02); } -/* Parameters */ -.algorithm-params__title { - margin: 0 0 2px; - font-size: 0.72rem; +.algorithm-panel-empty__title { + margin: 4px 0 0; + font-size: 1rem; font-weight: 700; - letter-spacing: var(--letter-spacing-config); - text-transform: uppercase; - color: var(--text-muted); -} - -/* Parameter rows: label beside control, sentence case, optional tag. */ -.config-field { - display: grid; - grid-template-columns: 220px minmax(0, 360px); - gap: 2px 16px; - align-items: start; - padding: 10px 0; -} - -.config-field > label { - grid-column: 1; - display: flex; - align-items: center; - gap: 8px; - margin: 0; - padding-top: 8px; - font-size: 0.84rem; - font-weight: 600; color: var(--text-main); - text-transform: none; - letter-spacing: 0; + line-height: 1.3; } -.config-field-desc, -.config-field-hint { - grid-column: 1; +.algorithm-panel-empty__text { margin: 0; - font-size: 0.76rem; -} - -.config-field > .config-input, -.config-field > .config-select, -.config-field > .checkbox-wrapper, -.config-field > .chip-picker, -.config-field > .validation-message { - grid-column: 2; - grid-row: 1 / span 3; -} - -.optional-tag { - margin-left: 4px; - font-size: 0.66rem; - font-weight: 600; - color: var(--text-muted); -} - -.chip-picker { - display: flex; - flex-wrap: wrap; - gap: 6px; - min-width: 0; -} - -.chip-picker-option { - min-height: 28px; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.34); - border-radius: 999px; - background: #fff; - color: var(--text-main); - font: inherit; - font-size: 0.76rem; - cursor: pointer; -} - -.chip-picker-option.active { - border-color: var(--primary-color); - background: color-mix(in srgb, var(--primary-color) 8%, white); - color: var(--primary-color); - font-weight: 600; -} - -/* Advanced fields stay behind a disclosure, closed by default. */ -.config-advanced { - border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); - margin-top: 4px; -} - -.config-advanced__summary { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 0; - list-style: none; - cursor: pointer; - font-size: 0.84rem; - font-weight: 600; - color: var(--text-main); -} - -.config-advanced__summary::-webkit-details-marker { - display: none; -} - -.config-advanced__chevron { - width: 0.4rem; - height: 0.4rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; -} - -.config-advanced[open] > .config-advanced__summary .config-advanced__chevron { - transform: rotate(45deg); -} - -.config-advanced__body { - padding-bottom: 6px; -} - -/* Option rows: cross-validation uses the studio switch. */ -.config-option-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 0; - border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -/* Settings that belong to the switch above sit under it, inset to read as its children. */ -.config-option-detail { - margin: -4px 0 8px; - padding-left: 14px; - border-left: 2px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -.config-option-detail .config-field { - grid-template-columns: 206px minmax(0, 360px); - padding: 4px 0; -} - -.config-option-row.is-locked { - opacity: 0.86; -} - -.config-option-copy { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.config-option-label { + max-width: 420px; font-size: 0.84rem; - font-weight: 600; - color: var(--text-main); -} - -.config-option-hint { - font-size: 0.76rem; - line-height: 1.4; - color: var(--text-muted); -} - -.toggle-switch { - position: relative; - display: inline-block; - flex: 0 0 auto; - width: 44px; - height: 24px; -} - -.toggle-switch.is-locked { - cursor: not-allowed; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-switch input:focus-visible + .toggle-slider { - outline: 2px solid var(--primary-color); - outline-offset: 2px; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - inset: 0; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--text-muted) 75%, transparent); - background: color-mix(in srgb, var(--text-muted) 22%, white); - transition: background 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease; -} - -.toggle-slider::before { - content: ''; - position: absolute; - height: 18px; - width: 18px; - left: 3px; - top: 2.5px; - border-radius: 50%; - background: var(--card-bg); - box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18), inset 0 0 0 1px rgba(15, 23, 42, 0.12); - transition: transform 0.22s var(--ease-standard, ease), box-shadow 0.22s ease; -} - -.toggle-switch input:checked + .toggle-slider { - background: var(--primary-color); - border-color: rgba(43, 51, 233, 0.24); - box-shadow: 0 5px 14px color-mix(in srgb, var(--primary-color) 22%, transparent); -} - -.toggle-switch input:checked + .toggle-slider::before { - transform: translateX(20px); -} - -.toggle-switch input:disabled + .toggle-slider { - cursor: not-allowed; -} - -/* Muted validation: invalid keeps a danger edge; valid is neutral. */ -.config-input.ng-valid.ng-touched, -.config-select.ng-valid.ng-touched { - border: 1px solid rgba(148, 163, 184, 0.34); - background: #fff; - background-image: none; - box-shadow: none; - padding-right: 12px; + line-height: 1.55; + color: #64748b; } -.config-input.ng-invalid.ng-touched, -.config-select.ng-invalid.ng-touched { - border: 1px solid var(--danger); - background-color: #fff; - background-image: none; - box-shadow: none; - padding-right: 12px; +/* Tooltip (hover affordance for catalog cards) */ +.tooltip { + position: absolute !important; + background: var(--studio-card-bg); + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22); + padding: 16px; + border-radius: var(--radius-lg, 12px); + width: 320px; + max-width: calc(100vw - 40px); + max-height: 70vh; + overflow-y: auto; + z-index: 99999; + pointer-events: none; + border: 1px solid rgba(226, 232, 240, 0.8); + opacity: 1; + transition: opacity 0.15s ease-out; } -.config-input.ng-invalid.ng-touched:focus, -.config-select.ng-invalid.ng-touched:focus { - border-color: var(--danger); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); +/* The role band owns its own height now: the expanded roster caps itself and + scrolls; the pool no longer forces the whole assignment band into 22rem. */ +.algorithm-roles-board { + /* visible keeps the run bar sticking to the page scroller; the bar rounds its + own bottom corners so the card frame stays intact. */ + overflow: visible; } /* Readiness bar: Run lives in the step bar, so this closes the panel with state only. */ @@ -1490,34 +145,3 @@ flex-basis: 100%; color: #8a1f1f; } - -/* Empty state under the ③ kicker, without the dashed frame. */ -.algorithm-params__empty { - align-items: flex-start; - justify-content: flex-start; - gap: 6px; - margin: 0; - padding: 16px; - border: 0; - background: transparent; -} - -@media (max-width: 900px) { - .algorithm-details__header { - flex-wrap: wrap; - } - - .config-field, - .config-option-detail .config-field { - grid-template-columns: 1fr; - } - - .config-field > * { - grid-column: 1 !important; - grid-row: auto !important; - } - - .algorithm-workspace { - grid-template-columns: 1fr; - } -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index 1bd13ecf..917887a6 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -1,413 +1,7 @@
    @if (assignablePool().length > 0) { -
    -
    -
    -
    Algorithm
    -
    - {{ catalog.algoCounts().active }} of {{ catalog.algoCounts().all }} runnable -
    - -
    - -
    - -
    - - -
    - @if (selectedAlgorithm(); as algorithm) { -
    -
    -
    -
    3
    -

    {{ algorithm.label }}

    -
    - @if (selectedAlgorithmCategory()) { - {{ selectedAlgorithmCategory() }} - } -
    - @if (algorithm.description) { -

    {{ algorithm.description }}

    - } - @if (selectedAlgorithmDocumentation()) { -
    - - - How this method works - -
    {{ selectedAlgorithmDocumentation() }}
    -
    - } -
    - } - -
    - @if (selectedAlgorithm(); as algorithm) { -
    - @if (run.algorithmNeeds().length) { -
      -
    • Complete before running
    • - @for (need of run.algorithmNeeds(); track need.key) { -
    • - - {{ need.title }} - @for (message of need.messages; track message) { - {{ message }} - } - @if (need.action) { - - } -
    • - } -
    - } @else if (selectedAlgorithm()) { -

    ✓ All inputs match

    - } - -
    - - @if (form.hasAlgorithmParameterControls()) { -

    Parameters

    - } - - @if (form.isOutlierReportSelected()) { -
    -
    -
    -
    Per-variable Outlier Rules
    -
    - {{ form.outlierReportVariables().length }} numerical -
    - - @if (form.outlierReportVariables().length > 0) { -
    -
    {{ outlierVariableLabel(variable) }} @@ -188,22 +188,22 @@
    Per-variable Outlier Rules
    - @for (strategy of outlierStrategies; track strategy.value) { } - @for (tail of outlierTails; track tail.value) { } @@ -211,11 +211,11 @@
    Per-variable Outlier Rules
    - @if (outlierReportRuleError(variable)) { -
    {{ outlierReportRuleError(variable) }}
    + [ngModel]="form.outlierReportRuleFor(variable).fold" + (ngModelChange)="form.onOutlierReportFoldChange(variable, $event)" + [class.input-error]="form.outlierReportRuleError(variable)" /> + @if (form.outlierReportRuleError(variable)) { +
    {{ form.outlierReportRuleError(variable) }}
    }
    - - - - - - - - - - - @for (variable of form.outlierReportVariables(); track variable.code) { - - - - - - - - } - -
    VariableEnabledStrategyTailFold
    - {{ outlierVariableLabel(variable) }} - {{ variable.code }} - - - - - - - - - @if (form.outlierReportRuleError(variable)) { -
    {{ form.outlierReportRuleError(variable) }}
    - } -
    -
    - } @else { -
    -
    -
    No numerical variables selected
    -
    -
    - } -
    - } - - - @if (form.primaryConfigSchema().length || form.advancedConfigSchema().length) { -
    - @for (field of form.primaryConfigSchema(); track field.key) { - - } - - @if (form.advancedConfigSchema().length) { -
    - - - Advanced ({{ form.advancedConfigSchema().length }}) - -
    - @for (field of form.advancedConfigSchema(); track field.key) { - - } -
    -
    - } -
    - } - - @if (form.canToggleCrossValidation() || form.isCrossValidationOnly()) { -
    -
    - Cross-validation - - {{ form.isCrossValidationOnly() ? 'Always on for this method' : 'Runs the cross-validation variant and reports fold metrics' }} - -
    - -
    - - @if (form.crossValidationConfigSchema().length) { -
    - @for (field of form.crossValidationConfigSchema(); track field.key) { - - } -
    - } - } - - @if (!form.hasAlgorithmParameterControls()) { -
    -
    -
    No additional parameters for this algorithm
    -

    - This algorithm does not expose extra configuration fields. Review the - documentation and run the experiment when you are ready. -

    -
    -
    - } - -
    - - - @if (form.configForm().get(field.key)) { -
    - - - @if (field.desc) { -

    {{ field.desc }}

    - } - - @if (field.enumType === 'input_var_names' && field.type === 'select' && (!field.options || !field.options.length)) { -

    - @if (field.enumSource?.includes('x')) { - Select covariates first. This parameter must be one of the variables in x. - } @else if (field.enumSource?.includes('y')) { - Select a variable first. This parameter must be one of the variables in y. - } @else { - Select the required variables before choosing this parameter. - } -

    - } - - @if (field.key === 'positive_class' && !form.selectedEventVarCode()) { -

    Select an event indicator variable first.

    - } - - @if (field.key === 'positive_class' && form.selectedEventVarCode() && form.positiveClassOptions().length) { -

    - Select the event level mapped to 1; all other levels are mapped to 0. -

    - } - - @if (field.key === 'positive_class' && form.selectedEventVarCode() && !form.positiveClassOptions().length) { -

    - This event variable has no catalog categories. Enter the value treated as event=1 (for example 0 or 1). -

    - } - - @if (field.type === 'number') { - - - } @else if (field.type === 'select') { - - } @else if (field.type === 'multi-select') { -
    - @for (opt of field.options; track opt) { - - } -
    - } @else if (field.type === 'checkbox') { - - } @else { - - } - - @if (form.configForm().get(field.key)?.invalid && - form.configForm().get(field.key)?.touched) { -
    - @if (form.configForm().get(field.key)?.hasError('required')) { - This field is required. - } @if (form.configForm().get(field.key)?.hasError('min')) { - Value too small. - } @if (form.configForm().get(field.key)?.hasError('max')) { - Value too large. - } @if (form.configForm().get(field.key)?.hasError('pattern')) { - Invalid value. - } @if (form.configForm().get(field.key)?.hasError('invalidOption')) { - Select a value from the list. - } -
    - } -
    - } -
    -
- } @else { -
-
3
-

No algorithm selected

-

- Select a runnable algorithm from the catalog to configure parameters and run the - experiment. -

-
- } -
-
- - +
@@ -423,25 +17,7 @@

No algorithm selected

@if (catalog.tooltipVisible()) { -
-
{{ catalog.tooltipData()?.label }}
-
- - @if (catalog.availabilityDetails(catalog.tooltipData()).length) { -
-
Availability
- @for (detail of catalog.availabilityDetails(catalog.tooltipData()); track detail.role) { -
-
- {{ availabilityRequirementText(detail) }} - @for (message of detail.messages; track message) { - {{ message }} - } -
-
- } -
- } +
} } @else { diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts index 9ce73c71..22328e2e 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts @@ -1,6 +1,7 @@ import type { MockedObject, Mock } from 'vitest'; import { provideZonelessChangeDetection, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; import { AuthService } from '../../../services/auth.service'; import { ErrorService } from '../../../services/error.service'; @@ -12,6 +13,7 @@ import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; import { AlgorithmPanelComponent } from './algorithm-panel.component'; import { createSpyObj } from '../../../testing/spy-obj.testing'; import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; +import { AlgorithmCatalogComponent } from './algorithm-selection/algorithm-catalog/algorithm-catalog.component'; import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; import { normalizeParameterValues } from './utils/algorithm-parameter-form.utils'; @@ -158,6 +160,9 @@ describe('AlgorithmPanelComponent', () => { /** The panel's feature services, as its template sees them. */ const catalog = () => fixture.debugElement.injector.get(AlgorithmCatalogService); const configForm = () => fixture.debugElement.injector.get(AlgorithmConfigFormService); + /** Picks a method as a click on its catalog tile does, even when its group is collapsed. */ + const chooseAlgorithm = (next: AlgorithmConfig) => + fixture.debugElement.query(By.directive(AlgorithmCatalogComponent)).componentInstance.onAlgorithmClick(next); it('groups default optional parameter fields under a closed Advanced disclosure', async () => { fixture.componentInstance.setStudioSubstep('parameters'); @@ -378,7 +383,7 @@ describe('AlgorithmPanelComponent', () => { const unavailableListItem = (fixture.nativeElement as HTMLElement).querySelector('.algo-tile.disabled-algo') as HTMLElement; expect(getComputedStyle(unavailableListItem).cursor).toBe('pointer'); - fixture.componentInstance.onAlgorithmClick(unavailableAlgorithm); + chooseAlgorithm(unavailableAlgorithm); fixture.detectChanges(); await fixture.whenStable(); @@ -829,7 +834,7 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('.algo-tile')).toBeTruthy(); expect(root.querySelector('.documentation-panel')).toBeNull(); - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); await fixture.whenStable(); @@ -845,7 +850,7 @@ describe('AlgorithmPanelComponent', () => { }); it('renders setup and configuration in one unified card', () => { - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; @@ -878,7 +883,7 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('.algorithm-details')).toBeNull(); expect(column?.contains(params ?? null)).toBe(true); - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); await fixture.whenStable(); @@ -1022,7 +1027,7 @@ describe('AlgorithmPanelComponent', () => { fixture.detectChanges(); expect(rows[0].querySelector('.algo-tile')).toBeNull(); - fixture.componentInstance.onAlgorithmClick(groupComparison[1]); + chooseAlgorithm(groupComparison[1]); fixture.detectChanges(); await fixture.whenStable(); @@ -1045,7 +1050,7 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('.algorithm-readonly-fieldset')).toBeNull(); expect(root.querySelector('.documentation-panel')).toBeNull(); - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); expect(root.querySelector('.documentation-panel')).toBeTruthy(); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index 05415f11..9d967245 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -1,30 +1,22 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { outputFromObservable } from '@angular/core/rxjs-interop'; -import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; -import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service'; -import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; -import { OUTLIER_STRATEGIES, OUTLIER_TAILS } from '../../../core/outlier-rules'; -import { AlgorithmRoleAssignmentComponent } from './algorithm-role-assignment/algorithm-role-assignment.component'; -import { availabilityRequirementText } from './utils/algorithm-availability-text.utils'; -import { runnableCount } from './utils/algorithm-catalog.utils'; -import { allowsDecimal, isAdvancedField, optionLabel } from './utils/algorithm-parameter-form.utils'; -import { tooltipPosition } from './utils/tooltip-position.utils'; +import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component'; +import { AlgorithmAvailabilityTooltipComponent } from './algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component'; import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; import { AlgorithmRunService } from './experiment-result/algorithm-run.service'; import { AlgorithmStudioSubstepService } from './algorithm-studio-substep.service'; -import { AlgorithmRunRequirement, AlgorithmStudioSubstep, OutlierReportVariable } from './algorithm-panel.models'; +import { AlgorithmStudioSubstep } from './algorithm-panel.models'; +/** + * The studio's Algorithm step. A shell: it provides the feature services its sections share, + * lays out the algorithm card and the availability tooltip, and keeps the API the studio calls + * (run state for the run button, the run itself, the exports and the sub-step). + */ @Component({ selector: 'app-algorithm-panel', - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - AlgorithmRoleAssignmentComponent, - ], + imports: [AlgorithmSelectionComponent, AlgorithmAvailabilityTooltipComponent], providers: [ AlgorithmStudioSubstepService, AlgorithmCatalogService, @@ -36,12 +28,11 @@ import { AlgorithmRunRequirement, AlgorithmStudioSubstep, OutlierReportVariable changeDetection: ChangeDetectionStrategy.OnPush, }) export class AlgorithmPanelComponent { - private readonly studio = inject(ExperimentStudioService); - private readonly studioNavigation = inject(ExperimentStudioNavigationService); private readonly substep = inject(AlgorithmStudioSubstepService); protected readonly catalog = inject(AlgorithmCatalogService); - protected readonly form = inject(AlgorithmConfigFormService); protected readonly run = inject(AlgorithmRunService); + /** The card shows once the studio has variables to assign. */ + protected readonly assignablePool = inject(ExperimentStudioService).algorithmAssignableVariables; /** Studio: setup (roles + methods), then parameters (configure + run). Results live in the Execution step. */ readonly studioSubstep = this.substep.current; @@ -51,20 +42,6 @@ export class AlgorithmPanelComponent { readonly canRun = this.run.canRun; readonly runDisabledReason = this.run.runDisabledReason; - protected readonly selectedAlgorithm = this.studio.selectedAlgorithm; - protected readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? ''); - protected readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? ''); - /** Assignable pool drives the role-assignment UI (shown without a selected algorithm). */ - protected readonly assignablePool = this.studio.algorithmAssignableVariables; - - protected readonly outlierStrategies = OUTLIER_STRATEGIES; - protected readonly outlierTails = OUTLIER_TAILS; - protected readonly categoryRunnableCount = runnableCount; - protected readonly isAdvancedField = isAdvancedField; - protected readonly optionLabel = optionLabel; - protected readonly allowsDecimal = allowsDecimal; - protected readonly availabilityRequirementText = availabilityRequirementText; - setStudioSubstep(step: AlgorithmStudioSubstep): void { this.substep.set(step); } @@ -83,85 +60,4 @@ export class AlgorithmPanelComponent { onExportResultZip(): void { this.run.exportZip(); } - - selectAlgorithm(algorithm: AlgorithmConfig): void { - this.form.resetForNewAlgorithm(); - // Use service method so it enriches configSchema and persists to sessionStorage - this.studio.setAlgorithm(algorithm); - this.run.clearRunState(); - } - - onAlgorithmClick(algorithm: AlgorithmConfig): void { - this.selectAlgorithm(algorithm); - // Parent bookkeeping only: the details and parameters bands render from - // selectedAlgorithm() below the row, so no substep navigation or page scroll. - this.setStudioSubstep('parameters'); - this.catalog.hideTooltip(); - } - - onSearchInput(event: Event): void { - this.catalog.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? ''); - } - - toggleCrossValidation(event: Event): void { - this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked); - } - - outlierVariableLabel(variable: OutlierReportVariable): string { - return variable?.label ?? variable?.name ?? variable?.code ?? ''; - } - - resolveRunRequirement(requirement: AlgorithmRunRequirement): void { - if (requirement.id === 'preprocessing') { - this.studioNavigation.goToPreprocessing(); - return; - } - // Availability issues are fixed on the setup board, which stays on screen above. - this.setStudioSubstep('setup'); - requestAnimationFrame(() => - this.scrollStudioTargetIntoView( - document.querySelector('[data-guide="guide-role-assignment"]'), - true - ) - ); - } - - /** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ - private scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { - if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - if (focus) target.focus({ preventScroll: true }); - } - - showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void { - event.stopPropagation(); - const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement); - const rect = item.getBoundingClientRect(); - const container = item.closest('.experiment-container') as HTMLElement | null; - const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight); - - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const padding = 16; - const offset = 12; - - this.catalog.tooltipVisible.set(true); - this.catalog.tooltipData.set(algorithm); - - // Immediate initial placement - this.catalog.tooltipPosition.set( - tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset }) - ); - - // Refinement after render - setTimeout(() => { - const el = document.querySelector('.tooltip') as HTMLElement | null; - if (!el) return; - const w = el.offsetWidth || 320; - const h = el.offsetHeight || 200; - this.catalog.tooltipPosition.set( - tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset }) - ); - }, 0); - } } diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css new file mode 100644 index 00000000..abcd3188 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css @@ -0,0 +1,54 @@ +.tooltip-title { + margin: 0 0 8px; + font-size: 1rem; + font-weight: 700; + color: var(--primary-color); + line-height: 1.3; +} + +.tooltip-description { + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-muted); + margin-bottom: 0; +} + +.tooltip-requirements { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(226, 232, 240, 0.6); +} + +.tooltip-requirements-header { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #94a3b8; + margin-bottom: 8px; +} + +.req-item { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 8px; +} + +.req-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.req-text { + font-size: 0.78rem; + color: var(--chart-label-color); + line-height: 1.4; +} + +.req-error { + font-size: 0.72rem; + color: #b45309; + line-height: 1.35; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html new file mode 100644 index 00000000..6048a213 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html @@ -0,0 +1,18 @@ +
{{ algorithm()?.label }}
+
+ +@if (details().length) { +
+
Availability
+ @for (detail of details(); track detail.role) { +
+
+ {{ availabilityRequirementText(detail) }} + @for (message of detail.messages; track message) { + {{ message }} + } +
+
+ } +
+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts new file mode 100644 index 00000000..580479d6 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model'; +import { availabilityRequirementText } from '../../utils/algorithm-availability-text.utils'; +import { AlgorithmCatalogService } from '../algorithm-catalog.service'; + +/** A method's description and the role checks behind its availability. */ +@Component({ + selector: 'div[app-algorithm-availability-tooltip]', + templateUrl: './algorithm-availability-tooltip.component.html', + styleUrl: './algorithm-availability-tooltip.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmAvailabilityTooltipComponent { + readonly algorithm = input(null); + private readonly catalog = inject(AlgorithmCatalogService); + protected readonly details = computed(() => this.catalog.availabilityDetails(this.algorithm())); + protected readonly availabilityRequirementText = availabilityRequirementText; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css new file mode 100644 index 00000000..76ea82c8 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css @@ -0,0 +1,352 @@ +@import '../../styles/sr-only.shared.css'; +@import '../../styles/step-kicker.shared.css'; + +/* LEFT OF THE BOTTOM ROW: ALGORITHM CATALOG */ +/* The catalog title mirrors the pool's pane head so the two panes read as peers. */ +.catalog-title { + margin: 0; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary-color); +} + +.algorithm-match-empty { + margin: 0; + padding: 14px 12px; + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.45; + border: 1px dashed rgba(148, 163, 184, 0.4); + border-radius: var(--radius-md, 8px); +} + +/* Search */ +.catalog-search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +/* The clear button overlays the field, so the field — not the row — is its containing + block; the filter chip sits beside the field instead of under it. */ +.catalog-search-field { + position: relative; + display: block; + flex: 1 1 auto; + /* Below this the chip drops to its own line rather than crushing the field: the + row wraps on this minimum plus the chip, so the field never shrinks under a + readable width when the catalog is narrow. */ + min-width: 8rem; +} + +.catalog-search-input { + display: block; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 8px 34px 8px 12px; + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(148, 163, 184, 0.34); + background: var(--studio-card-bg); + color: var(--text-main); + font: inherit; + font-size: 0.85rem; + line-height: 1.4; +} + +.catalog-search-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(127, 156, 232, 0.16); +} + +.catalog-search-input::placeholder { + color: #94a3b8; +} + +.catalog-search-clear { + position: absolute; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: var(--radius-sm, 4px); + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.catalog-search-clear:hover, +.catalog-search-clear:focus-visible { + background: color-mix(in srgb, var(--primary-color) 8%, white); + color: var(--text-main); +} + +/* One status chip (All / Runnable) sits on the search row — see .pill-toggle below. */ +/* Method list. Category kicker: chevron first, then the group name (per the mock). */ +.algorithm-match-rows { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.algorithm-match-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Category kicker doubles as the disclosure control for its tiles. */ +.algorithm-match-cat { + appearance: none; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin: 0; + padding: 2px 0; + border: 0; + background: none; + font-family: inherit; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--primary-color); + line-height: 1.3; + text-align: left; + cursor: pointer; +} + +.algorithm-match-cat-name { + min-width: 0; +} + +.algorithm-match-cat-meta { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + order: -1; /* chevron sits before the label */ + color: var(--primary-color); +} + +/* Collapsed points right (›), expanded points down (⌄). */ +.algorithm-match-cat-meta::after { + content: ""; + width: 0.4rem; + height: 0.4rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; +} + +.algorithm-match-cat[aria-expanded="true"] .algorithm-match-cat-meta::after { + transform: rotate(45deg); +} + +.algorithm-match-cat:hover .algorithm-match-cat-meta, +.algorithm-match-cat:focus-visible .algorithm-match-cat-meta { + color: var(--primary-color); +} + +.algorithm-match-tiles { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Algorithm row: plain label with trailing role chips; no selected highlight — + the chosen method is named in the details and parameters bands below. */ +.algo-tile { + appearance: none; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 2px; + width: 100%; + max-width: 100%; + border-radius: var(--radius-md, 8px); + border: 1px solid transparent; + background: #ffffff; + color: var(--text-main); + font: inherit; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.45; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.algo-tile:hover:not(.disabled-algo), +.algo-tile:focus-visible:not(.disabled-algo) { + border-color: rgba(43, 51, 233, 0.22); + background: color-mix(in srgb, var(--primary-light) 10%, white); +} + +/* Selected method: accent bar, primary border, and a subtle tint so the tile the + configuration column on the right refers to is unmistakable. */ +.algo-tile.selected:not(.disabled-algo) { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 8%, white); + box-shadow: inset 3px 0 0 var(--primary-color); +} + +/* The chevron points from the chosen tile into the configuration column, so the eye + lands on the details without scanning. Hidden until the tile is the selection. */ +.algo-tile-cue { + display: none; + flex: 0 0 auto; + width: 6px; + height: 6px; + margin-left: auto; + border-top: 2px solid var(--primary-color); + border-right: 2px solid var(--primary-color); + transform: rotate(45deg); +} + +.algo-tile.selected:not(.disabled-algo) .algo-tile-cue { + display: block; +} + +.algo-tile.selected:not(.disabled-algo) .algo-label { + color: var(--primary-color); + font-weight: 700; +} + +.algo-tile-top { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +.algo-tile .algo-label { + min-width: 0; + color: var(--text-main); + font-weight: 500; + overflow-wrap: anywhere; +} + +/* Required-input chips: amber y (outcome), lavender x (predictor) */ +.algo-why { + display: block; + font-weight: 500; + line-height: 1.4; +} + +.algo-tile.disabled-algo { + cursor: pointer; + background: color-mix(in srgb, var(--bg-neutral, #f8fafc) 55%, white); + border-color: transparent; + box-shadow: none; + color: var(--text-muted); +} + +.algo-tile.disabled-algo .algo-label { + color: var(--text-muted); +} + +.algo-tile.disabled-algo:hover, +.algo-tile.disabled-algo:focus-visible { + background: color-mix(in srgb, var(--text-muted) 8%, white); +} + +/* A read-only preview selection of an unavailable method: same accent bar, muted. */ +.algo-tile.disabled-algo.selected { + border-color: rgba(100, 116, 139, 0.55); + background: color-mix(in srgb, var(--text-muted) 8%, white); + box-shadow: inset 3px 0 0 rgba(100, 116, 139, 0.55); +} + +@media (max-width: 640px) { + .algo-why { + max-width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .algo-tile, + .algorithm-match-cat-meta::after { + transition: none; + } +} + +/* ========================================================================= + Algorithm panel review pass: step kickers, compact role band, catalog, + needs checklist, parameter layout, advanced grouping, option switches, + and the readiness bar. + ========================================================================= */ +.algorithm-step-kicker { + padding: 10px 14px 0; +} + +.catalog-mode-tabs { + flex: 0 0 auto; +} + +.catalog-mode-tabs button { + gap: 4px; + min-height: 26px; + padding: 0 10px; + font-size: 0.74rem; +} + +.catalog-title.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +.algorithm-match-cat-count { + margin-left: auto; + padding-left: 10px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; +} + +.algo-tile { + min-height: 36px; + padding: 8px 10px; +} + +.algo-tile-top::before { + content: ''; + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.45); +} + +.algo-tile.selected .algo-tile-top::before { + box-shadow: inset 0 0 0 3.5px var(--primary-color); +} + +.algo-tile.disabled-algo .algo-tile-top::before { + box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.25); +} + +.algo-why { + font-size: 0.74rem; + color: #78350f; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html new file mode 100644 index 00000000..8332508b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html @@ -0,0 +1,83 @@ +
2 Method
+

Algorithm catalog

+ + + +@if (catalog.hasAnyVisibleAlgorithms()) { +
+ @for (category of catalog.filteredAlgorithmCategories(); track category.name) { +
+ + @if (catalog.isCategoryOpen(category.name) || catalog.searchActive()) { +
+ @for (algorithm of category.algorithms; track algorithm.name) { + + } +
+ } +
+ } +
+} @else if (catalog.searchActive()) { +

No methods match “{{ catalog.algoSearchQuery() }}”.

+} @else if (catalog.showOnlyActive()) { +

No runnable methods for this assignment. Switch to All to see why.

+} @else { +

No methods in the catalog.

+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts new file mode 100644 index 00000000..18aba2b2 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ExperimentStudioService } from '../../../../../services/experiment-studio.service'; +import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model'; +import { runnableCount } from '../../utils/algorithm-catalog.utils'; +import { tooltipPosition } from '../../utils/tooltip-position.utils'; +import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service'; +import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; +import { AlgorithmCatalogService } from '../algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; + +/** The method catalog: search, the Runnable/All switch, and the method groups to pick from. */ +@Component({ + selector: 'aside[app-algorithm-catalog]', + templateUrl: './algorithm-catalog.component.html', + styleUrl: './algorithm-catalog.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmCatalogComponent { + private readonly studio = inject(ExperimentStudioService); + private readonly configForm = inject(AlgorithmConfigFormService); + private readonly run = inject(AlgorithmRunService); + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly selectedAlgorithm = this.studio.selectedAlgorithm; + protected readonly categoryRunnableCount = runnableCount; + + onSearchInput(event: Event): void { + this.catalog.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? ''); + } + + /** Selects the method; its details and parameters render beside the catalog, so nothing scrolls. */ + onAlgorithmClick(algorithm: AlgorithmConfig): void { + this.configForm.resetForNewAlgorithm(); + // Use service method so it enriches configSchema and persists to sessionStorage + this.studio.setAlgorithm(algorithm); + this.run.clearRunState(); + this.substep.set('parameters'); + this.catalog.hideTooltip(); + } + + /** Opens the availability tooltip beside a method. Nothing in the catalog calls it at the moment. */ + showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void { + event.stopPropagation(); + const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement); + const rect = item.getBoundingClientRect(); + const container = item.closest('.experiment-container') as HTMLElement | null; + const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight); + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const padding = 16; + const offset = 12; + + this.catalog.tooltipVisible.set(true); + this.catalog.tooltipData.set(algorithm); + + // Immediate initial placement + this.catalog.tooltipPosition.set( + tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset }) + ); + + // Refinement after render + setTimeout(() => { + const el = document.querySelector('.tooltip') as HTMLElement | null; + if (!el) return; + const w = el.offsetWidth || 320; + const h = el.offsetHeight || 200; + this.catalog.tooltipPosition.set( + tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset }) + ); + }, 0); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css new file mode 100644 index 00000000..59398faa --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css @@ -0,0 +1,124 @@ +@import '../../styles/form-controls.shared.css'; + +/* Label + "Required" */ +:host(.config-field) > label { + font-weight: 700; + font-size: 0.72rem; + color: var(--text-muted); + margin-bottom: 5px; + display: flex; + align-items: center; + gap: 8px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.config-field-desc { + margin: 0 0 8px; + font-size: 0.8rem; + line-height: 1.45; + color: #64748b; +} + +.config-field-hint { + margin: 0 0 8px; + font-size: 0.78rem; + line-height: 1.4; + color: var(--text-muted); +} + +:host(.config-field) > label { + grid-column: 1; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding-top: 8px; + font-size: 0.84rem; + font-weight: 600; + color: var(--text-main); + text-transform: none; + letter-spacing: 0; +} + +.config-field-desc, +.config-field-hint { + grid-column: 1; + margin: 0; + font-size: 0.76rem; +} + +:host(.config-field) > .config-input, +:host(.config-field) > .config-select, +:host(.config-field) > .checkbox-wrapper, +:host(.config-field) > .chip-picker, +:host(.config-field) > .validation-message { + grid-column: 2; + grid-row: 1 / span 3; +} + +.optional-tag { + margin-left: 4px; + font-size: 0.66rem; + font-weight: 600; + color: var(--text-muted); +} + +.chip-picker { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.chip-picker-option { + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 999px; + background: #fff; + color: var(--text-main); + font: inherit; + font-size: 0.76rem; + cursor: pointer; +} + +.chip-picker-option.active { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 8%, white); + color: var(--primary-color); + font-weight: 600; +} + +/* Muted validation: invalid keeps a danger edge; valid is neutral. Kept in this sheet rather + than in the form-controls partial: it must come after the rules above to win their ties. */ +.config-input.ng-valid.ng-touched, +.config-select.ng-valid.ng-touched { + border: 1px solid rgba(148, 163, 184, 0.34); + background: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched, +.config-select.ng-invalid.ng-touched { + border: 1px solid var(--danger); + background-color: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched:focus, +.config-select.ng-invalid.ng-touched:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); +} + +@media (max-width: 900px) { + :host(.config-field) > * { + grid-column: 1 !important; + grid-row: auto !important; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html new file mode 100644 index 00000000..807991a0 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html @@ -0,0 +1,93 @@ +@let field = this.field(); +@let control = form.configForm().get(field.key); + + +@if (field.desc) { +

{{ field.desc }}

+} + +@if (field.enumType === 'input_var_names' && field.type === 'select' && (!field.options || !field.options.length)) { +

+ @if (field.enumSource?.includes('x')) { + Select covariates first. This parameter must be one of the variables in x. + } @else if (field.enumSource?.includes('y')) { + Select a variable first. This parameter must be one of the variables in y. + } @else { + Select the required variables before choosing this parameter. + } +

+} + +@if (field.key === 'positive_class' && !form.selectedEventVarCode()) { +

Select an event indicator variable first.

+} + +@if (field.key === 'positive_class' && form.selectedEventVarCode() && form.positiveClassOptions().length) { +

+ Select the event level mapped to 1; all other levels are mapped to 0. +

+} + +@if (field.key === 'positive_class' && form.selectedEventVarCode() && !form.positiveClassOptions().length) { +

+ This event variable has no catalog categories. Enter the value treated as event=1 (for example 0 or 1). +

+} + +@if (field.type === 'number') { + + +} @else if (field.type === 'select') { + +} @else if (field.type === 'multi-select') { +
+ @for (opt of field.options; track opt) { + + } +
+} @else if (field.type === 'checkbox') { + +} @else { + +} + +@if (control?.invalid && control?.touched) { +
+ @if (control?.hasError('required')) { + This field is required. + } @if (control?.hasError('min')) { + Value too small. + } @if (control?.hasError('max')) { + Value too large. + } @if (control?.hasError('pattern')) { + Invalid value. + } @if (control?.hasError('invalidOption')) { + Select a value from the list. + } +
+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts new file mode 100644 index 00000000..d46ce541 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angular/forms'; +import { allowsDecimal, optionLabel } from '../../utils/algorithm-parameter-form.utils'; +import { EnrichedParameterField } from '../../algorithm-panel.models'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; + +/** + * One parameter of the selected method: its label, hints, the control for its type, and its + * validation messages. Attached to the `.config-field` element, which carries the form group. + */ +@Component({ + selector: 'div[app-algorithm-config-field]', + imports: [ReactiveFormsModule], + // The form group sits on the host element; this lets formControlName in the view find it. + viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }], + templateUrl: './algorithm-config-field.component.html', + styleUrl: './algorithm-config-field.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmConfigFieldComponent { + readonly field = input.required(); + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly allowsDecimal = allowsDecimal; + protected readonly optionLabel = optionLabel; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css new file mode 100644 index 00000000..eae9f363 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css @@ -0,0 +1,296 @@ +@import '../../styles/config-placeholder.shared.css'; +@import '../../styles/sr-only.shared.css'; +@import '../../styles/step-kicker.shared.css'; + +.algorithm-params__body { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.algorithm-params__empty { + display: flex; + flex-direction: column; + flex: 1 1 auto; + border-radius: var(--radius-md, 8px); +} + +.algorithm-params__empty-title { + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: var(--text-main); + line-height: 1.35; +} + +.algorithm-params__empty-text { + margin: 0; + max-width: 30rem; + font-size: 0.8rem; + line-height: 1.6; + color: var(--text-muted); +} + +/* Run requirements */ +.config-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.config-field { + flex-direction: column; +} + +/* Fields are divided by a hairline rather than framed: the parameters read as one + form, and the card already paints the only container. */ +.config-field + .config-field { + border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); + padding-top: 10px; +} + +/* Outlier report table */ +.outlier-report-editor { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0; +} + +/* Shared fieldset host: the unified card paints the frame, not the fieldset. */ +.algorithm-readonly-fieldset { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} + +/* Needs checklist */ +.algo-needs { + display: grid; + gap: 4px; + margin: 0 0 14px; + padding: 10px 12px; + list-style: none; + border-radius: var(--radius-md); + background: var(--bg-neutral); + font-size: 0.8rem; +} + +.algo-needs li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; +} + +.algo-needs-mark { + width: 12px; + height: 12px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px #a06b10; + transform: translateY(2px); +} + +.algo-needs li.is-met .algo-needs-mark { + background: var(--success); + box-shadow: none; +} + +.algo-needs small { + flex-basis: 100%; + padding-left: 20px; + color: #78350f; +} + +.algo-needs-all-met { + margin: 0 0 14px; + padding: 8px 12px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--success) 10%, white); + color: #065f46; + font-size: 0.8rem; + font-weight: 600; +} + +/* Parameters */ +.algorithm-params__title { + margin: 0 0 2px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: var(--letter-spacing-config); + text-transform: uppercase; + color: var(--text-muted); +} + +/* Parameter rows: label beside control, sentence case, optional tag. */ +.config-field { + display: grid; + grid-template-columns: 220px minmax(0, 360px); + gap: 2px 16px; + align-items: start; + padding: 10px 0; +} + +/* Advanced fields stay behind a disclosure, closed by default. */ +.config-advanced { + border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); + margin-top: 4px; +} + +.config-advanced__summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 0; + list-style: none; + cursor: pointer; + font-size: 0.84rem; + font-weight: 600; + color: var(--text-main); +} + +.config-advanced__summary::-webkit-details-marker { + display: none; +} + +.config-advanced__chevron { + width: 0.4rem; + height: 0.4rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; +} + +.config-advanced[open] > .config-advanced__summary .config-advanced__chevron { + transform: rotate(45deg); +} + +.config-advanced__body { + padding-bottom: 6px; +} + +/* Option rows: cross-validation uses the studio switch. */ +.config-option-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +/* Settings that belong to the switch above sit under it, inset to read as its children. */ +.config-option-detail { + margin: -4px 0 8px; + padding-left: 14px; + border-left: 2px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +.config-option-detail .config-field { + grid-template-columns: 206px minmax(0, 360px); + padding: 4px 0; +} + +.config-option-row.is-locked { + opacity: 0.86; +} + +.config-option-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.config-option-label { + font-size: 0.84rem; + font-weight: 600; + color: var(--text-main); +} + +.config-option-hint { + font-size: 0.76rem; + line-height: 1.4; + color: var(--text-muted); +} + +.toggle-switch { + position: relative; + display: inline-block; + flex: 0 0 auto; + width: 44px; + height: 24px; +} + +.toggle-switch.is-locked { + cursor: not-allowed; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-switch input:focus-visible + .toggle-slider { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--text-muted) 75%, transparent); + background: color-mix(in srgb, var(--text-muted) 22%, white); + transition: background 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease; +} + +.toggle-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + top: 2.5px; + border-radius: 50%; + background: var(--card-bg); + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18), inset 0 0 0 1px rgba(15, 23, 42, 0.12); + transition: transform 0.22s var(--ease-standard, ease), box-shadow 0.22s ease; +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--primary-color); + border-color: rgba(43, 51, 233, 0.24); + box-shadow: 0 5px 14px color-mix(in srgb, var(--primary-color) 22%, transparent); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +.toggle-switch input:disabled + .toggle-slider { + cursor: not-allowed; +} + +/* Empty state under the ③ kicker, without the dashed frame. */ +.algorithm-params__empty { + align-items: flex-start; + justify-content: flex-start; + gap: 6px; + margin: 0; + padding: 16px; + border: 0; + background: transparent; +} + +@media (max-width: 900px) { + .config-field, + .config-option-detail .config-field { + grid-template-columns: 1fr; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html new file mode 100644 index 00000000..be4b3dc0 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html @@ -0,0 +1,107 @@ +@if (selectedAlgorithm()) { +
+ @if (run.algorithmNeeds().length) { +
    +
  • Complete before running
  • + @for (need of run.algorithmNeeds(); track need.key) { +
  • + + {{ need.title }} + @for (message of need.messages; track message) { + {{ message }} + } + @if (need.action) { + + } +
  • + } +
+ } @else if (selectedAlgorithm()) { +

✓ All inputs match

+ } + +
+ + @if (form.hasAlgorithmParameterControls()) { +

Parameters

+ } + + @if (form.isOutlierReportSelected()) { +
+ } + + + @if (form.primaryConfigSchema().length || form.advancedConfigSchema().length) { +
+ @for (field of form.primaryConfigSchema(); track field.key) { +
+ } + + @if (form.advancedConfigSchema().length) { +
+ + + Advanced ({{ form.advancedConfigSchema().length }}) + +
+ @for (field of form.advancedConfigSchema(); track field.key) { +
+ } +
+
+ } +
+ } + + @if (form.canToggleCrossValidation() || form.isCrossValidationOnly()) { +
+
+ Cross-validation + + {{ form.isCrossValidationOnly() ? 'Always on for this method' : 'Runs the cross-validation variant and reports fold metrics' }} + +
+ +
+ + @if (form.crossValidationConfigSchema().length) { +
+ @for (field of form.crossValidationConfigSchema(); track field.key) { +
+ } +
+ } + } + + @if (!form.hasAlgorithmParameterControls()) { +
+
+
No additional parameters for this algorithm
+

+ This algorithm does not expose extra configuration fields. Review the + documentation and run the experiment when you are ready. +

+
+
+ } + +
+ +
+} @else { +
+
3
+

No algorithm selected

+

+ Select a runnable algorithm from the catalog to configure parameters and run the + experiment. +

+
+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts new file mode 100644 index 00000000..d2d25220 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts @@ -0,0 +1,55 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ExperimentStudioService } from '../../../../../services/experiment-studio.service'; +import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service'; +import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service'; +import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; +import { AlgorithmRunRequirement } from '../../algorithm-panel.models'; +import { AlgorithmCatalogService } from '../algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; +import { AlgorithmConfigFieldComponent } from '../algorithm-config-field/algorithm-config-field.component'; +import { OutlierReportEditorComponent } from '../outlier-report-editor/outlier-report-editor.component'; + +/** + * The selected method's parameters: what still blocks the run, the parameter form with its + * Advanced fields, cross-validation, and the outlier_report editor. Read-only while the method + * is unavailable. + */ +@Component({ + selector: 'section[app-algorithm-parameters]', + imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent], + templateUrl: './algorithm-parameters.component.html', + styleUrl: './algorithm-parameters.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmParametersComponent { + private readonly studioNavigation = inject(ExperimentStudioNavigationService); + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly run = inject(AlgorithmRunService); + protected readonly selectedAlgorithm = inject(ExperimentStudioService).selectedAlgorithm; + + toggleCrossValidation(event: Event): void { + this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked); + } + + resolveRunRequirement(requirement: AlgorithmRunRequirement): void { + if (requirement.id === 'preprocessing') { + this.studioNavigation.goToPreprocessing(); + return; + } + // Availability issues are fixed on the setup board, which stays on screen above. + this.substep.set('setup'); + requestAnimationFrame(() => + scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true) + ); + } +} + +/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ +function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (focus) target.focus({ preventScroll: true }); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css new file mode 100644 index 00000000..d03da9d9 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css @@ -0,0 +1,331 @@ +@import '../styles/step-kicker.shared.css'; + +.algorithm-roles-board__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 16px; + padding: 12px 14px; + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); + background: var(--studio-card-header-bg, #f8fbff); +} + +.algorithm-roles-board__title { + margin: 0 0 2px; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.01em; + color: var(--text-main); +} + +/* Live runnable/total counter — tabular so filtering never reflows the header. */ +.algorithm-count-badge { + gap: 6px; + flex: 0 0 auto; +} + +.algorithm-count-badge strong { + color: var(--primary-color); + font-size: 1rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.algorithm-count-badge span { + font-variant-numeric: tabular-nums; +} + +/* BOTTOM ROW: Algorithm catalog (left) · Algorithm configuration (right). The row + grows with its content (24rem floor) instead of being pinned to a short fixed + height, so the parameter selection expands and every parameter field is readable + in the normal flow — the window is the scroller rather than a nested inner scroll. + The assignment rails sit above the row in flow, so switching methods never moves + them out from under the pointer; only space below the row changes. */ +.algorithm-workspace { + display: grid; + grid-template-columns: minmax(300px, 20%) minmax(0, 80%); /* 20% catalog · 80% configuration */ + min-width: 0; + min-height: 24rem; + height: auto; + overflow: hidden; +} + +/* TOP BAND: Experiment pool across the full card width — the variable list on the left, + the stacked outcome and predictor rails beside it on the right — so the band only has + to be tall enough for the scrolling list. It is one shrinking grid track rather than a + stretched flex cross-size: that is what lets the band stop at its max-height and hand + the overflow to the list's own scroll, instead of clipping the hint out of the card. */ +.algorithm-pool { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + flex: 0 0 auto; + min-width: 0; + min-height: 0; + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); +} + +.algorithm-pool > app-algorithm-role-assignment { + min-width: 0; + min-height: 0; +} + +.algorithm-catalog { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px 14px; + min-width: 0; + min-height: 0; + overflow: hidden; + color: var(--text-main); + box-sizing: border-box; + border-right: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); +} + +/* RIGHT OF THE BOTTOM ROW: ALGORITHM CONFIGURATION — documentation and parameter + fields share one column that grows with its content, so a parameter-rich method + reveals its whole form instead of clipping it under a fixed-height inner scroll. + The board paints the frame: this column adds no border, tint, or shadow of its + own. */ +.algorithm-config-column { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: visible; +} + +/* PARAMETERS — continue the configuration flow directly under the documentation; + the unified card paints the frame. */ +.algorithm-params { + display: flex; + flex-direction: column; + flex: 1 1 auto; + gap: 10px; + padding: 0 16px 16px; + min-width: 0; + box-sizing: border-box; + color: var(--text-main); +} + +.inspector-tag { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.14)); + background: var(--bg-neutral); + color: var(--text-muted); + font-size: 0.64rem; + font-weight: 700; +} + +/* TOP OF THE CONFIGURATION COLUMN — one continuous Algorithm configuration section: + header, description, and documentation. It no longer pins: the column expands to + its content and the window scrolls, so a sticky head would hang over the scrolling + page and hide the parameter fields below it instead of helping them. */ +.algorithm-details { + display: flex; + flex-direction: column; + gap: 8px; + position: static; + background: var(--studio-card-bg); + color: var(--text-main); +} + +.algorithm-details__header { + flex-direction: column; +} + +.algorithm-details__heading { + flex-wrap: wrap; +} + +/* Documentation */ +.documentation-panel { + margin: 0; +} + +/* The native disclosure triangle is hidden, so the summary states its own affordance: + a chevron that turns with the state, driven by [open] on the
itself. */ +.documentation-summary { + gap: 8px; + cursor: pointer; + list-style: none; +} + +.documentation-summary::-webkit-details-marker { + display: none; +} + +/* Same chevron geometry as the catalog groups: right when collapsed, down when open. */ +.documentation-chevron { + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; +} + +.documentation-panel[open] > .documentation-summary .documentation-chevron { + transform: rotate(45deg); +} + +.documentation-content { + font-size: 0.82rem; + line-height: 1.45; + color: rgba(20, 22, 31, 0.72); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.config-description { + margin: 0; + font-size: 0.86rem; + line-height: 1.55; + color: var(--text-muted); +} + +/* RESPONSIVE LAYOUT */ +@media (max-width: 960px) { + .algorithm-workspace { + grid-template-columns: minmax(0, 1fr); + height: auto; + min-height: 0; + } + /* Stacked: head, rails, list and hint sit in one column, so the band hugs that + taller content and the window scrolls rather than the band scrolling inside it. */ + .algorithm-pool { + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); + } + .algorithm-catalog { + border-right: 0; + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); + max-height: 24rem; + overflow: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .documentation-chevron { + transition: none; + } +} + +.algorithm-roles-board__title { + font-size: 0.96rem; +} + +.algorithm-count-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--primary-color) 8%, white); + color: var(--primary-color); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; +} + +.algorithm-pool { + height: auto; + max-height: none; + overflow: visible; +} + +.algorithm-pool > app-algorithm-role-assignment { + overflow: visible; +} + +/* Configuration head: number ring, real method label, category tag. */ +.algorithm-details { + padding: 14px 16px 8px; +} + +.algorithm-details__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.algorithm-details__heading { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.algorithm-details__title { + margin: 0; + min-width: 0; + font-size: 1.1rem; + font-weight: 700; + line-height: 1.3; + color: var(--text-main); + overflow-wrap: anywhere; +} + +.inspector-tag { + flex: 0 0 auto; + text-transform: none; + letter-spacing: 0; +} + +.config-description { + max-width: 60ch; + margin-top: 6px; +} + +/* Documentation is a quiet text link by default. */ +.documentation-panel { + margin-top: 2px; +} + +.documentation-summary { + display: inline-flex; + align-items: center; + padding: 2px 0; + margin: 0; + border-radius: 0; + background: none; + font-size: 0.8rem; + font-weight: 600; + color: var(--primary-color); +} + +.documentation-summary:hover { + background: none; + text-decoration: underline; +} + +.documentation-chevron { + display: none; +} + +.documentation-panel[open] > .documentation-summary { + border-bottom: 0; + border-radius: 0; +} + +.documentation-content { + margin-top: 6px; + padding-left: 0; + border-left: 0; +} + +@media (max-width: 900px) { + .algorithm-details__header { + flex-wrap: wrap; + } + .algorithm-workspace { + grid-template-columns: 1fr; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html new file mode 100644 index 00000000..736870a4 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html @@ -0,0 +1,46 @@ +
+
+
Algorithm
+
+ {{ catalog.algoCounts().active }} of {{ catalog.algoCounts().all }} runnable +
+ +
+ +
+ +
+ + +
+ @if (selectedAlgorithm(); as algorithm) { +
+
+
+
3
+

{{ algorithm.label }}

+
+ @if (selectedAlgorithmCategory()) { + {{ selectedAlgorithmCategory() }} + } +
+ @if (algorithm.description) { +

{{ algorithm.description }}

+ } + @if (selectedAlgorithmDocumentation()) { +
+ + + How this method works + +
{{ selectedAlgorithmDocumentation() }}
+
+ } +
+ } + +
+
+
+ + diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts new file mode 100644 index 00000000..68006f42 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmRoleAssignmentComponent } from '../algorithm-role-assignment/algorithm-role-assignment.component'; +import { AlgorithmCatalogComponent } from './algorithm-catalog/algorithm-catalog.component'; +import { AlgorithmParametersComponent } from './algorithm-parameters/algorithm-parameters.component'; +import { AlgorithmCatalogService } from './algorithm-catalog.service'; + +/** + * The algorithm card: role assignment, the method catalog, and the selected method's details + * and parameters. Attached to the card element the panel renders, so the DOM and its anchors + * are unchanged; the panel projects the run bar at the bottom of the card. + */ +@Component({ + selector: 'section[app-algorithm-selection]', + imports: [AlgorithmRoleAssignmentComponent, AlgorithmCatalogComponent, AlgorithmParametersComponent], + templateUrl: './algorithm-selection.component.html', + styleUrl: './algorithm-selection.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmSelectionComponent { + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly selectedAlgorithm = inject(ExperimentStudioService).selectedAlgorithm; + protected readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? ''); + protected readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? ''); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css new file mode 100644 index 00000000..80480011 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css @@ -0,0 +1,102 @@ +@import '../../styles/config-placeholder.shared.css'; +@import '../../styles/form-controls.shared.css'; + +.outlier-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.outlier-editor-header h6 { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: var(--text-main); +} + +.outlier-count { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.outlier-table-wrapper { + overflow-x: auto; +} + +.outlier-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.outlier-table th { + padding: 6px 8px; + text-align: left; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +.outlier-table td { + padding: 6px 8px; + vertical-align: top; + border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +.outlier-table tr:last-child td { + border-bottom: 0; +} + +.outlier-variable-label { + display: block; + font-weight: 600; + color: var(--text-main); +} + +.outlier-table small { + color: var(--text-muted); + font-size: 0.68rem; +} + +.outlier-table .compact-select, +.outlier-table .compact-input { + min-width: 6.5rem; + padding: 5px 8px; + font-size: 0.78rem; +} + +.outlier-enable { + min-height: 24px; +} + +/* Muted validation: invalid keeps a danger edge; valid is neutral. Kept in this sheet rather + than in the form-controls partial: it must come after the rules above to win their ties. */ +.config-input.ng-valid.ng-touched, +.config-select.ng-valid.ng-touched { + border: 1px solid rgba(148, 163, 184, 0.34); + background: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched, +.config-select.ng-invalid.ng-touched { + border: 1px solid var(--danger); + background-color: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched:focus, +.config-select.ng-invalid.ng-touched:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html new file mode 100644 index 00000000..aafc4ff6 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html @@ -0,0 +1,70 @@ +
+
+
Per-variable Outlier Rules
+
+ {{ form.outlierReportVariables().length }} numerical +
+ +@if (form.outlierReportVariables().length > 0) { +
+ + + + + + + + + + + + @for (variable of form.outlierReportVariables(); track variable.code) { + + + + + + + + } + +
VariableEnabledStrategyTailFold
+ {{ variableLabel(variable) }} + {{ variable.code }} + + + + + + + + + @if (form.outlierReportRuleError(variable)) { +
{{ form.outlierReportRuleError(variable) }}
+ } +
+
+} @else { +
+
+
No numerical variables selected
+
+
+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts new file mode 100644 index 00000000..d975cf64 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { OUTLIER_STRATEGIES, OUTLIER_TAILS } from '../../../../../core/outlier-rules'; +import { OutlierReportVariable } from '../../algorithm-panel.models'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; + +/** outlier_report's per-variable rules: enabled, strategy, tail and fold for each numerical variable. */ +@Component({ + selector: 'section[app-outlier-report-editor]', + imports: [FormsModule], + templateUrl: './outlier-report-editor.component.html', + styleUrl: './outlier-report-editor.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OutlierReportEditorComponent { + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly outlierStrategies = OUTLIER_STRATEGIES; + protected readonly outlierTails = OUTLIER_TAILS; + + variableLabel(variable: OutlierReportVariable): string { + return variable?.label ?? variable?.name ?? variable?.code ?? ''; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css new file mode 100644 index 00000000..ecf5c1ec --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css @@ -0,0 +1,41 @@ +/* Validation states: valid stays neutral; only the invalid edge and message signal a problem. */ +/* Placeholders */ +.config-placeholder { + width: 100%; + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(226, 232, 240, 0.95); + background: rgba(248, 250, 252, 0.65); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85); +} + +.config-placeholder--minimal { + width: auto; + max-width: 100%; + min-height: 0; +} + +.config-placeholder-content { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 560px; +} + +.config-placeholder-content h5, +.config-placeholder-content h6 { + margin: 0; + color: var(--text-main); + font-size: 0.92rem; + font-weight: 700; +} + +.config-placeholder-content p { + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; + line-height: 1.6; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css new file mode 100644 index 00000000..19ef365f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css @@ -0,0 +1,58 @@ +/* Inputs / selects */ +.config-input, +.config-select { + display: block; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 9px 12px; + background: rgba(255, 255, 255, 0.98); + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(148, 163, 184, 0.34); + font-size: 0.85rem; + line-height: 1.4; + transition: border-color 0.18s ease, box-shadow 0.18s ease, + background-color 0.18s ease; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset; +} + +.config-select { + padding-right: 30px; +} + +.config-select[multiple] { + min-height: 7rem; + padding-right: 12px; +} + +.config-input:focus, +.config-select:focus { + outline: none; + border-color: var(--primary-color); + background-color: var(--studio-card-bg); + box-shadow: + 0 0 0 3px rgba(127, 156, 232, 0.16), + 0 4px 12px rgba(43, 51, 233, 0.08); +} + +/* Checkbox wrapper */ +.checkbox-wrapper { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--text-main); +} + +.checkbox-wrapper input[type="checkbox"] { + width: 18px; + height: 18px; + border-radius: 5px; +} + +/* Validation states */ +.config-error { + color: #b42318; + font-size: 0.74rem; + margin-top: 5px; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css new file mode 100644 index 00000000..5c260cfe --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css @@ -0,0 +1,7 @@ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css new file mode 100644 index 00000000..17c8525a --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css @@ -0,0 +1,21 @@ +.step-kicker { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: var(--letter-spacing-config); + text-transform: uppercase; + color: var(--text-muted); +} + +.step-kicker-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px var(--studio-card-border); + font-size: 0.68rem; +} From 5e32ef226a5b36a3ccb865d7eb40db02bfd0ba78 Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:35:35 +0200 Subject: [PATCH 6/8] refactor(algorithm-panel): extract experiment-result components div[app-experiment-result] renders the readiness bar the panel projects into the card (is-ready as a host binding); ul[app-algorithm-run-requirements] renders the run needs and resolves their actions. Both read AlgorithmRunService, which owns the run, its error and the exports. --- .../algorithm-panel.component.css | 28 ---------- .../algorithm-panel.component.html | 13 +---- .../algorithm-panel.component.ts | 5 +- .../algorithm-parameters.component.css | 26 ---------- .../algorithm-parameters.component.html | 18 +------ .../algorithm-parameters.component.ts | 27 +--------- .../algorithm-run-requirements.component.css | 27 ++++++++++ .../algorithm-run-requirements.component.html | 15 ++++++ .../algorithm-run-requirements.component.ts | 37 +++++++++++++ .../experiment-result.component.css | 27 ++++++++++ .../experiment-result.component.html | 10 ++++ .../experiment-result.component.spec.ts | 52 +++++++++++++++++++ .../experiment-result.component.ts | 18 +++++++ 13 files changed, 193 insertions(+), 110 deletions(-) create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css index f32eceeb..b1a9f394 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css @@ -113,35 +113,7 @@ color: var(--text-muted); } -.algorithm-run-status { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.algorithm-run-dot { - flex: 0 0 auto; - width: 8px; - height: 8px; - border-radius: 50%; - background: #b45309; -} - -.algorithm-run-bar.is-ready .algorithm-run-dot { - background: var(--success); -} - -.algorithm-run-text { - min-width: 0; -} - .algorithm-run-bar.is-ready { color: #065f46; font-weight: 600; } - -.algorithm-run-error { - flex-basis: 100%; - color: #8a1f1f; -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index 917887a6..bb036102 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -2,18 +2,7 @@ @if (assignablePool().length > 0) {
-
- - - {{ run.readinessText() }} - - @if (!run.canRun()) { - Finish the needs above to enable the run. - } - @if (run.errorMsg()) { - Backend error: {{ run.errorMsg() }} - } -
+
@if (catalog.tooltipVisible()) { diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index 9d967245..26828a28 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -4,6 +4,7 @@ import { ExperimentStudioService } from '../../../services/experiment-studio.ser import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component'; import { AlgorithmAvailabilityTooltipComponent } from './algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component'; import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; +import { ExperimentResultComponent } from './experiment-result/experiment-result.component'; import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; import { AlgorithmRunService } from './experiment-result/algorithm-run.service'; import { AlgorithmStudioSubstepService } from './algorithm-studio-substep.service'; @@ -11,12 +12,12 @@ import { AlgorithmStudioSubstep } from './algorithm-panel.models'; /** * The studio's Algorithm step. A shell: it provides the feature services its sections share, - * lays out the algorithm card and the availability tooltip, and keeps the API the studio calls + * lays out the algorithm card, its readiness bar and the availability tooltip, and keeps the API the studio calls * (run state for the run button, the run itself, the exports and the sub-step). */ @Component({ selector: 'app-algorithm-panel', - imports: [AlgorithmSelectionComponent, AlgorithmAvailabilityTooltipComponent], + imports: [AlgorithmSelectionComponent, ExperimentResultComponent, AlgorithmAvailabilityTooltipComponent], providers: [ AlgorithmStudioSubstepService, AlgorithmCatalogService, diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css index eae9f363..6fa9e956 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css @@ -78,32 +78,6 @@ font-size: 0.8rem; } -.algo-needs li { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 8px; -} - -.algo-needs-mark { - width: 12px; - height: 12px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px #a06b10; - transform: translateY(2px); -} - -.algo-needs li.is-met .algo-needs-mark { - background: var(--success); - box-shadow: none; -} - -.algo-needs small { - flex-basis: 100%; - padding-left: 20px; - color: #78350f; -} - .algo-needs-all-met { margin: 0 0 14px; padding: 8px 12px; diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html index be4b3dc0..b9c3c2b1 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html @@ -1,23 +1,7 @@ @if (selectedAlgorithm()) {
@if (run.algorithmNeeds().length) { -
    -
  • Complete before running
  • - @for (need of run.algorithmNeeds(); track need.key) { -
  • - - {{ need.title }} - @for (message of need.messages; track message) { - {{ message }} - } - @if (need.action) { - - } -
  • - } -
+
    } @else if (selectedAlgorithm()) {

    ✓ All inputs match

    } diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts index d2d25220..f748b479 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts @@ -1,10 +1,8 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { ExperimentStudioService } from '../../../../../services/experiment-studio.service'; -import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service'; import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service'; -import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; -import { AlgorithmRunRequirement } from '../../algorithm-panel.models'; +import { AlgorithmRunRequirementsComponent } from '../../experiment-result/algorithm-run-requirements/algorithm-run-requirements.component'; import { AlgorithmCatalogService } from '../algorithm-catalog.service'; import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; import { AlgorithmConfigFieldComponent } from '../algorithm-config-field/algorithm-config-field.component'; @@ -17,14 +15,12 @@ import { OutlierReportEditorComponent } from '../outlier-report-editor/outlier-r */ @Component({ selector: 'section[app-algorithm-parameters]', - imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent], + imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent, AlgorithmRunRequirementsComponent], templateUrl: './algorithm-parameters.component.html', styleUrl: './algorithm-parameters.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AlgorithmParametersComponent { - private readonly studioNavigation = inject(ExperimentStudioNavigationService); - private readonly substep = inject(AlgorithmStudioSubstepService); protected readonly catalog = inject(AlgorithmCatalogService); protected readonly form = inject(AlgorithmConfigFormService); protected readonly run = inject(AlgorithmRunService); @@ -33,23 +29,4 @@ export class AlgorithmParametersComponent { toggleCrossValidation(event: Event): void { this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked); } - - resolveRunRequirement(requirement: AlgorithmRunRequirement): void { - if (requirement.id === 'preprocessing') { - this.studioNavigation.goToPreprocessing(); - return; - } - // Availability issues are fixed on the setup board, which stays on screen above. - this.substep.set('setup'); - requestAnimationFrame(() => - scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true) - ); - } -} - -/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ -function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { - if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - if (focus) target.focus({ preventScroll: true }); } diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css new file mode 100644 index 00000000..7d42dca0 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css @@ -0,0 +1,27 @@ +@import '../../styles/sr-only.shared.css'; + +:host(.algo-needs) li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; +} + +.algo-needs-mark { + width: 12px; + height: 12px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px #a06b10; + transform: translateY(2px); +} + +:host(.algo-needs) li.is-met .algo-needs-mark { + background: var(--success); + box-shadow: none; +} + +:host(.algo-needs) small { + flex-basis: 100%; + padding-left: 20px; + color: #78350f; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html new file mode 100644 index 00000000..d85d8227 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html @@ -0,0 +1,15 @@ +
  • Complete before running
  • +@for (need of run.algorithmNeeds(); track need.key) { +
  • + + {{ need.title }} + @for (message of need.messages; track message) { + {{ message }} + } + @if (need.action) { + + } +
  • +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts new file mode 100644 index 00000000..8d42c110 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service'; +import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; +import { AlgorithmRunRequirement } from '../../algorithm-panel.models'; +import { AlgorithmRunService } from '../algorithm-run.service'; + +/** What the selected method still needs before it can run, each with the action that fixes it. */ +@Component({ + selector: 'ul[app-algorithm-run-requirements]', + templateUrl: './algorithm-run-requirements.component.html', + styleUrl: './algorithm-run-requirements.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmRunRequirementsComponent { + private readonly studioNavigation = inject(ExperimentStudioNavigationService); + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly run = inject(AlgorithmRunService); + + resolveRunRequirement(requirement: AlgorithmRunRequirement): void { + if (requirement.id === 'preprocessing') { + this.studioNavigation.goToPreprocessing(); + return; + } + // Availability issues are fixed on the setup board, which stays on screen above. + this.substep.set('setup'); + requestAnimationFrame(() => + scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true) + ); + } +} + +/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ +function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (focus) target.focus({ preventScroll: true }); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css new file mode 100644 index 00000000..997d419b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css @@ -0,0 +1,27 @@ +.algorithm-run-status { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.algorithm-run-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: #b45309; +} + +:host(.algorithm-run-bar.is-ready) .algorithm-run-dot { + background: var(--success); +} + +.algorithm-run-text { + min-width: 0; +} + +.algorithm-run-error { + flex-basis: 100%; + color: #8a1f1f; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html new file mode 100644 index 00000000..55a65a84 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html @@ -0,0 +1,10 @@ + + + {{ run.readinessText() }} + +@if (!run.canRun()) { +Finish the needs above to enable the run. +} +@if (run.errorMsg()) { +Backend error: {{ run.errorMsg() }} +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts new file mode 100644 index 00000000..506698d3 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts @@ -0,0 +1,52 @@ +import { Component, provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { AlgorithmRunService } from './algorithm-run.service'; +import { ExperimentResultComponent } from './experiment-result.component'; + +@Component({ + imports: [ExperimentResultComponent], + template: '
    ', +}) +class HostComponent {} + +describe('ExperimentResultComponent', () => { + const run = { + canRun: signal(false), + readinessText: signal('Select a method'), + errorMsg: signal(null), + }; + + beforeEach(() => { + run.canRun.set(false); + run.errorMsg.set(null); + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [provideZonelessChangeDetection(), { provide: AlgorithmRunService, useValue: run }], + }); + }); + + function render(): HTMLElement { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + return (fixture.nativeElement as HTMLElement).querySelector('.algorithm-run-bar') as HTMLElement; + } + + it('renders into the run bar and asks to finish the needs while the run is blocked', () => { + const bar = render(); + + expect(bar.getAttribute('role')).toBe('status'); + expect(bar.classList.contains('is-ready')).toBe(false); + expect(bar.querySelector('.algorithm-run-text')?.textContent).toContain('Select a method'); + expect(bar.querySelector('.algorithm-run-hint')).toBeTruthy(); + }); + + it('marks the bar ready and shows the last error', () => { + run.canRun.set(true); + run.errorMsg.set('Failed to export the result tables.'); + const bar = render(); + + expect(bar.classList.contains('is-ready')).toBe(true); + expect(bar.querySelector('.algorithm-run-hint')).toBeNull(); + expect(bar.querySelector('.algorithm-run-error')?.textContent).toContain('Backend error: Failed to export the result tables.'); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts new file mode 100644 index 00000000..4a0ba1b9 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { AlgorithmRunService } from './algorithm-run.service'; + +/** + * The readiness bar at the bottom of the algorithm card: whether the run can start and why + * the last attempt or export failed. The run button itself is the studio's; the result shows + * in the Execution step. Attached to the bar element the panel projects into the card. + */ +@Component({ + selector: 'div[app-experiment-result]', + templateUrl: './experiment-result.component.html', + styleUrl: './experiment-result.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { '[class.is-ready]': 'run.canRun()' }, +}) +export class ExperimentResultComponent { + protected readonly run = inject(AlgorithmRunService); +} From 8b9ea8bb8a4540d3bce8a2fc9f766bec8d0c58fa Mon Sep 17 00:00:00 2001 From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:36:08 +0200 Subject: [PATCH 7/8] docs: algorithm panel split - sections, feature services and CSS partials --- AGENTS.md | 2 +- docs/context/conventions.md | 1 + docs/context/decision-log.md | 14 ++++++++++++++ docs/context/module-index.md | 2 +- docs/llm-wiki/03-feature-index.md | 4 +++- 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e969c33..a7955397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This repository contains `fl-platform`, the Angular 22 standalone frontend for t - `src/app/services/`: auth/session, experiment orchestration, dashboard data access, algorithm rules, labeling, runtime env, theme, errors, and PDF/CSV exports. - `src/app/models/`: frontend and backend DTOs/interfaces for users, algorithms, experiments, filters, and data models. - `src/app/core/`: algorithm mapping, result enum mapping, constants, and result utility logic. -- `src/app/pages/experiment-studio/`: step views switched via a sticky horizontal stepper — `experiment-studio.component` shows one step view at a time (Datasets & Variables · Data review & preprocessing · Algorithm); all views stay mounted (hidden via CSS) so state is preserved. On the Data review step the stepper expands a sub-step row (Filtering, Raw Summary, Preprocessing, Processed Summary, Transformation) that drives the `statistic-analysis-panel` sections (`goToSection`). In-panel section targets use `scroll-margin-top` so `scrollIntoView` clears sticky chrome. Also contains variable/filter selection, QueryBuilder filter UI, algorithm configuration with variable role assignment (outcome y / covariates x on the algorithm panel), run/edit flows, statistics, visualizations, and result rendering. +- `src/app/pages/experiment-studio/`: step views switched via a sticky horizontal stepper — `experiment-studio.component` shows one step view at a time (Datasets & Variables · Data review & preprocessing · Algorithm); all views stay mounted (hidden via CSS) so state is preserved. On the Data review step the stepper expands a sub-step row (Filtering, Raw Summary, Preprocessing, Processed Summary, Transformation) that drives the `statistic-analysis-panel` sections (`goToSection`). In-panel section targets use `scroll-margin-top` so `scrollIntoView` clears sticky chrome. Also contains variable/filter selection, QueryBuilder filter UI, algorithm configuration with variable role assignment (outcome y / covariates x on the algorithm panel; `algorithm-panel` is a shell over `algorithm-selection/` and `experiment-result/` sections that share panel-provided services), run/edit flows, statistics, visualizations, and result rendering. - `src/app/pages/experiments-dashboard/`: experiment list/search/pagination, detail view, compare mode, sharing, delete/edit/name updates, and result export. - `src/app/pages/terms-page/`: NDA/TOS display and acceptance flow. - `src/app/pages/account-page/`: account/profile view and logout entry. diff --git a/docs/context/conventions.md b/docs/context/conventions.md index ee621fc5..c5cf94eb 100644 --- a/docs/context/conventions.md +++ b/docs/context/conventions.md @@ -31,6 +31,7 @@ - Use component CSS for feature-specific styles. - Use `src/styles.css` for app-wide styles and QueryBuilder theming. - A component may list several stylesheets in `styleUrls`; Angular concatenates them **in array order**, so that order is the cascade. Splitting a file must preserve the original order and must not move a rule across another rule that shares its selector — `statistic-analysis-panel` is split this way (`.component.css` shell + pipeline canvas, then `.preprocessing.css`, then `.results.css`). +- A component split into section components keeps its DOM by attaching each section to the element it renders into (attribute selectors such as `section[app-algorithm-parameters]`). Each rule moves to the stylesheet of the template its element is declared in; a rule that reaches into a section from its host element is rewritten as `:host(.class)` (same specificity as `.class` under emulated encapsulation). Rules several sections share go into `styles/*.shared.css` partials, `@import`ed at the top of each stylesheet, so a rule only moves into a partial when it does not cross an equal-specificity rule for the same element (`algorithm-panel/` keeps its `.ng-touched` validation rules inline for that reason). - Consult `DESIGN.md` before visual changes. - Existing branding assets live under `src/assets`. diff --git a/docs/context/decision-log.md b/docs/context/decision-log.md index 1e7e1f92..1d55f217 100644 --- a/docs/context/decision-log.md +++ b/docs/context/decision-log.md @@ -215,3 +215,17 @@ Consequences: Breaking: this UI needs a backend with PR #34 and does not work ag Files affected: `src/app/services/experiment-studio.service.ts`, `src/app/services/experiment-studio.service.spec.ts`, `e2e/experiment-studio-api.ts`, `e2e/experiment-studio.flows.spec.ts`, `README.md`, `docs/context/*.md`, `docs/llm-wiki/indexes/backend-endpoints.md`. Date: 2026-10-15. + +### Decision: Algorithm panel split into selection and result sections + +Status: Accepted + +Context: `algorithm-panel.component.ts` had grown to 1,703 lines with 28 `any`, loose mutable fields (`lastUsedAlgorithm`, the outlier_report rule maps, `formKey`, the built-form signature, the category-collapse bookkeeping) and one 22.1 kB stylesheet over the 20 kB component budget. Data Handling had been split the same way (PR 56). + +Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text, tooltip state), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`, and `div[app-algorithm-availability-tooltip]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`. + +Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged. + +Files affected: `src/app/pages/experiment-studio/algorithm-panel/**`, `docs/context/conventions.md`, `docs/context/module-index.md`, `docs/llm-wiki/03-feature-index.md`, `AGENTS.md`. + +Date: 2026-10-29. diff --git a/docs/context/module-index.md b/docs/context/module-index.md index bdc4f92d..f9c1509c 100644 --- a/docs/context/module-index.md +++ b/docs/context/module-index.md @@ -42,7 +42,7 @@ Notes: Stored historical experiment payloads may depend on legacy algorithm keys ## `src/app/pages/experiment-studio` Purpose: Experiment composition workflow. -Key files: `experiment-studio.component.*`, `variables-panel/*`, `statistic-analysis-panel/*` (including `pca-column-source` / `kmeans-cluster-source`), `algorithm-panel/*`, `execution-panel/*` (result view + docked setup summary), `stepper/*`, `shared/*`, `guide/*`. +Key files: `experiment-studio.component.*`, `variables-panel/*`, `statistic-analysis-panel/*` (including `pca-column-source` / `kmeans-cluster-source`), `algorithm-panel/*` (thin shell; `algorithm-selection/` catalog, details, parameter form and outlier_report editor; `experiment-result/` run needs, readiness bar, run and exports; feature services `AlgorithmCatalogService`, `AlgorithmConfigFormService`, `AlgorithmRunService`, `AlgorithmStudioSubstepService` provided by the panel; `algorithm-result/` is shared and stays put), `execution-panel/*` (result view + docked setup summary), `stepper/*`, `shared/*`, `guide/*`. Used by: `/experiment-studio` route and dashboard edit flows. Rules: Keep page-specific UI here; use `ExperimentStudioService` for shared selection/run state. Transformation cards append to `appliedTransformations` in creation order; each creator step names outputs with `new_column_names`. Do not reintroduce `pca_with_transformation`. Tests: Component specs live near components; e2e covers PCA then K-means then logistic regression payload order in `e2e/experiment-studio.flows.spec.ts`. diff --git a/docs/llm-wiki/03-feature-index.md b/docs/llm-wiki/03-feature-index.md index b3fdbab0..95c2da81 100644 --- a/docs/llm-wiki/03-feature-index.md +++ b/docs/llm-wiki/03-feature-index.md @@ -19,7 +19,9 @@ Start with: - `ExperimentStudioComponent` (step shell) and `StudioStepperComponent` (step rail) - `ExperimentStudioService` (selection, algorithm roles, run state) - `execution-panel/` for run status/results and - `algorithm-panel/algorithm-role-assignment/` for the y/x slots + `algorithm-panel/algorithm-role-assignment/` for the y/x slots; the rest of + `algorithm-panel/` is a shell over `algorithm-selection/` (catalog, parameters) + and `experiment-result/` (run needs, readiness bar, run and exports) - `shared/station-card`, `shared/station-action-bar`, `shared/station-list-row` for the cards and footers every Data review step reuses - relevant model under `src/app/models/` From 3cc56bfdff683640f7d04ac6a1e0243ce808ad92 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 30 Oct 2026 12:06:37 +0200 Subject: [PATCH 8/8] refactor(algorithm-panel): remove the unreachable availability tooltip and tidy the run flow - showTooltip had no caller, so the tooltip component, its position helper, catalog tooltip state, .tooltip CSS, availabilityRequirementText and formatInputCountRange are removed with their specs - runExperiment checks the algorithm before touching run state and no longer clears the error three times - the requirements action scrolls inline instead of through a one-use helper --- docs/context/decision-log.md | 4 +- src/app/core/algorithm-input-counts.spec.ts | 4 -- src/app/core/algorithm-input-counts.ts | 10 ---- .../algorithm-panel.component.css | 18 ------- .../algorithm-panel.component.html | 5 -- .../algorithm-panel.component.spec.ts | 13 +---- .../algorithm-panel.component.ts | 8 ++- ...gorithm-availability-tooltip.component.css | 54 ------------------- ...orithm-availability-tooltip.component.html | 18 ------- ...lgorithm-availability-tooltip.component.ts | 18 ------- .../algorithm-catalog.service.ts | 12 +---- .../algorithm-catalog.component.ts | 35 ------------ .../algorithm-run-requirements.component.ts | 16 +++--- .../algorithm-run.service.ts | 18 +++---- .../algorithm-availability-text.utils.spec.ts | 14 ----- .../algorithm-availability-text.utils.ts | 12 ----- .../utils/tooltip-position.utils.spec.ts | 14 ----- .../utils/tooltip-position.utils.ts | 39 -------------- 18 files changed, 20 insertions(+), 292 deletions(-) delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts diff --git a/docs/context/decision-log.md b/docs/context/decision-log.md index 1d55f217..d0c1403c 100644 --- a/docs/context/decision-log.md +++ b/docs/context/decision-log.md @@ -222,9 +222,9 @@ Status: Accepted Context: `algorithm-panel.component.ts` had grown to 1,703 lines with 28 `any`, loose mutable fields (`lastUsedAlgorithm`, the outlier_report rule maps, `formKey`, the built-form signature, the category-collapse bookkeeping) and one 22.1 kB stylesheet over the 20 kB component budget. Data Handling had been split the same way (PR 56). -Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text, tooltip state), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`, and `div[app-algorithm-availability-tooltip]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`. +Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`. -Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged. +Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. The catalog's availability tooltip (`showTooltip` had no caller) was removed with its position helper and `formatInputCountRange`. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged. Files affected: `src/app/pages/experiment-studio/algorithm-panel/**`, `docs/context/conventions.md`, `docs/context/module-index.md`, `docs/llm-wiki/03-feature-index.md`, `AGENTS.md`. diff --git a/src/app/core/algorithm-input-counts.spec.ts b/src/app/core/algorithm-input-counts.spec.ts index dcf037c8..ba7088d2 100644 --- a/src/app/core/algorithm-input-counts.spec.ts +++ b/src/app/core/algorithm-input-counts.spec.ts @@ -1,6 +1,5 @@ import { applyFallbackInputCounts, - formatInputCountRange, readInputCount, resolveInputMaxCount, resolveInputMinCount, @@ -29,8 +28,5 @@ describe('algorithm input counts', () => { expect(resolveInputMaxCount(y, 'y', 'glmm_binary')).toBe(1); expect(resolveInputMinCount(x, 'x', 'glmm_binary')).toBe(2); - expect(formatInputCountRange(1, 1)).toBe('exactly 1'); - expect(formatInputCountRange(2, null)).toBe('2+'); - expect(formatInputCountRange(2, 2)).toBe('exactly 2'); }); }); diff --git a/src/app/core/algorithm-input-counts.ts b/src/app/core/algorithm-input-counts.ts index eb983634..009bb75e 100644 --- a/src/app/core/algorithm-input-counts.ts +++ b/src/app/core/algorithm-input-counts.ts @@ -65,16 +65,6 @@ export function resolveInputMaxCount( } -export function formatInputCountRange(minCount: number, maxCount: number | null): string { - if (minCount === 1 && maxCount === 1) return 'exactly 1'; - if (minCount === 0 && maxCount === 1) return '0-1'; - if (minCount > 0 && maxCount !== null && minCount === maxCount) return `exactly ${minCount}`; - if (minCount > 0 && maxCount !== null) return `${minCount}-${maxCount}`; - if (minCount > 0) return `${minCount}+`; - if (maxCount !== null) return `0-${maxCount}`; - return 'optional'; -} - export function applyFallbackInputCounts(inputdata: unknown, algorithmName: string): Record { if (!inputdata || typeof inputdata !== 'object') return {}; const next: Record = { ...(inputdata as Record) }; diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css index b1a9f394..73f2bf61 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css @@ -69,24 +69,6 @@ color: #64748b; } -/* Tooltip (hover affordance for catalog cards) */ -.tooltip { - position: absolute !important; - background: var(--studio-card-bg); - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22); - padding: 16px; - border-radius: var(--radius-lg, 12px); - width: 320px; - max-width: calc(100vw - 40px); - max-height: 70vh; - overflow-y: auto; - z-index: 99999; - pointer-events: none; - border: 1px solid rgba(226, 232, 240, 0.8); - opacity: 1; - transition: opacity 0.15s ease-out; -} - /* The role band owns its own height now: the expanded roster caps itself and scrolls; the pool no longer forces the whole assignment band into 22rem. */ .algorithm-roles-board { diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index bb036102..134a95e9 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -4,11 +4,6 @@
    - -@if (catalog.tooltipVisible()) { -
    -
    -} } @else {
    Select variables first
    diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts index 22328e2e..49bea61a 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts @@ -256,7 +256,7 @@ describe('AlgorithmPanelComponent', () => { expect(details?.querySelector('.documentation-content')?.textContent).toContain('Line one.'); }); - it('shows disabled algorithm availability reasons on the card and in the tooltip', async () => { + it('shows disabled algorithm availability reasons on the card', async () => { const disabledAlgorithm: AlgorithmConfig = { ...algorithm, name: 'needs_two_variables', @@ -293,21 +293,10 @@ describe('AlgorithmPanelComponent', () => { ((fixture.nativeElement as HTMLElement).querySelector('.algorithm-match-cat') as HTMLButtonElement).click(); fixture.detectChanges(); - catalog().tooltipVisible.set(true); - catalog().tooltipData.set(disabledAlgorithm); - fixture.detectChanges(); - const nativeElement = fixture.nativeElement as HTMLElement; const algorithmItem = nativeElement.querySelector('.algo-tile') as HTMLElement; expect(algorithmItem.textContent).toContain('Needs Two Variables'); expect(algorithmItem.querySelector('.algo-why')?.textContent).toContain('Outcome needs at least 2, selected 1.'); - expect(nativeElement.querySelector('.tooltip')?.textContent).toContain('Outcome needs at least 2, selected 1.'); - expect(nativeElement.textContent).toContain('Availability'); - expect(nativeElement.textContent).toContain('Outcome: 2-3, selected 1'); - expect(nativeElement.textContent).toContain('type: real'); - expect(nativeElement.querySelector('.tooltip')?.textContent).toContain('Outcome type must be one of real.'); - expect(nativeElement.textContent).not.toContain('type: nominal'); - expect(nativeElement.textContent).not.toContain('stattypes: nominal'); }); it('shows type availability reasons on algorithm cards', async () => { diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index 26828a28..2a3b4289 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { outputFromObservable } from '@angular/core/rxjs-interop'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component'; -import { AlgorithmAvailabilityTooltipComponent } from './algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component'; import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; import { ExperimentResultComponent } from './experiment-result/experiment-result.component'; import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; @@ -12,12 +11,12 @@ import { AlgorithmStudioSubstep } from './algorithm-panel.models'; /** * The studio's Algorithm step. A shell: it provides the feature services its sections share, - * lays out the algorithm card, its readiness bar and the availability tooltip, and keeps the API the studio calls + * lays out the algorithm card and its readiness bar, and keeps the API the studio calls * (run state for the run button, the run itself, the exports and the sub-step). */ @Component({ selector: 'app-algorithm-panel', - imports: [AlgorithmSelectionComponent, ExperimentResultComponent, AlgorithmAvailabilityTooltipComponent], + imports: [AlgorithmSelectionComponent, ExperimentResultComponent], providers: [ AlgorithmStudioSubstepService, AlgorithmCatalogService, @@ -30,8 +29,7 @@ import { AlgorithmStudioSubstep } from './algorithm-panel.models'; }) export class AlgorithmPanelComponent { private readonly substep = inject(AlgorithmStudioSubstepService); - protected readonly catalog = inject(AlgorithmCatalogService); - protected readonly run = inject(AlgorithmRunService); + protected readonly run = inject(AlgorithmRunService); /** The card shows once the studio has variables to assign. */ protected readonly assignablePool = inject(ExperimentStudioService).algorithmAssignableVariables; diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css deleted file mode 100644 index abcd3188..00000000 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css +++ /dev/null @@ -1,54 +0,0 @@ -.tooltip-title { - margin: 0 0 8px; - font-size: 1rem; - font-weight: 700; - color: var(--primary-color); - line-height: 1.3; -} - -.tooltip-description { - font-size: 0.82rem; - line-height: 1.5; - color: var(--text-muted); - margin-bottom: 0; -} - -.tooltip-requirements { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(226, 232, 240, 0.6); -} - -.tooltip-requirements-header { - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #94a3b8; - margin-bottom: 8px; -} - -.req-item { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 8px; -} - -.req-copy { - display: flex; - flex-direction: column; - gap: 2px; -} - -.req-text { - font-size: 0.78rem; - color: var(--chart-label-color); - line-height: 1.4; -} - -.req-error { - font-size: 0.72rem; - color: #b45309; - line-height: 1.35; -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html deleted file mode 100644 index 6048a213..00000000 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html +++ /dev/null @@ -1,18 +0,0 @@ -
    {{ algorithm()?.label }}
    -
    - -@if (details().length) { -
    -
    Availability
    - @for (detail of details(); track detail.role) { -
    -
    - {{ availabilityRequirementText(detail) }} - @for (message of detail.messages; track message) { - {{ message }} - } -
    -
    - } -
    -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts deleted file mode 100644 index 580479d6..00000000 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; -import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model'; -import { availabilityRequirementText } from '../../utils/algorithm-availability-text.utils'; -import { AlgorithmCatalogService } from '../algorithm-catalog.service'; - -/** A method's description and the role checks behind its availability. */ -@Component({ - selector: 'div[app-algorithm-availability-tooltip]', - templateUrl: './algorithm-availability-tooltip.component.html', - styleUrl: './algorithm-availability-tooltip.component.css', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AlgorithmAvailabilityTooltipComponent { - readonly algorithm = input(null); - private readonly catalog = inject(AlgorithmCatalogService); - protected readonly details = computed(() => this.catalog.availabilityDetails(this.algorithm())); - protected readonly availabilityRequirementText = availabilityRequirementText; -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts index d23ac8a8..a7c6ca71 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts @@ -6,8 +6,8 @@ import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories } f import { AlgorithmCategory } from '../algorithm-panel.models'; /** - * The method catalog: search text, the Runnable/All filter, which groups are open, the - * availability tooltip, and why a method is unavailable. Provided by the algorithm panel. + * The method catalog: search text, the Runnable/All filter, which groups are open, and why + * a method is unavailable. Provided by the algorithm panel. */ @Injectable() export class AlgorithmCatalogService { @@ -54,10 +54,6 @@ export class AlgorithmCatalogService { ), }); - readonly tooltipVisible = signal(false); - readonly tooltipPosition = signal({ x: 0, y: 0 }); - readonly tooltipData = signal(null); - /** The selected method cannot run with the current roles and datasets. */ readonly selectedAlgorithmUnavailable = computed(() => { const algorithm = this.studio.selectedAlgorithm(); @@ -101,8 +97,4 @@ export class AlgorithmCatalogService { const first = details.find((detail) => detail.messages.length > 0); return first ? formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; } - - hideTooltip(): void { - this.tooltipVisible.set(false); - } } diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts index 18aba2b2..0a171d8c 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ExperimentStudioService } from '../../../../../services/experiment-studio.service'; import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model'; import { runnableCount } from '../../utils/algorithm-catalog.utils'; -import { tooltipPosition } from '../../utils/tooltip-position.utils'; import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service'; import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; import { AlgorithmCatalogService } from '../algorithm-catalog.service'; @@ -35,39 +34,5 @@ export class AlgorithmCatalogComponent { this.studio.setAlgorithm(algorithm); this.run.clearRunState(); this.substep.set('parameters'); - this.catalog.hideTooltip(); - } - - /** Opens the availability tooltip beside a method. Nothing in the catalog calls it at the moment. */ - showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void { - event.stopPropagation(); - const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement); - const rect = item.getBoundingClientRect(); - const container = item.closest('.experiment-container') as HTMLElement | null; - const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight); - - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const padding = 16; - const offset = 12; - - this.catalog.tooltipVisible.set(true); - this.catalog.tooltipData.set(algorithm); - - // Immediate initial placement - this.catalog.tooltipPosition.set( - tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset }) - ); - - // Refinement after render - setTimeout(() => { - const el = document.querySelector('.tooltip') as HTMLElement | null; - if (!el) return; - const w = el.offsetWidth || 320; - const h = el.offsetHeight || 200; - this.catalog.tooltipPosition.set( - tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset }) - ); - }, 0); } } diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts index 8d42c110..c3385af6 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts @@ -23,15 +23,11 @@ export class AlgorithmRunRequirementsComponent { } // Availability issues are fixed on the setup board, which stays on screen above. this.substep.set('setup'); - requestAnimationFrame(() => - scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true) - ); + requestAnimationFrame(() => { + // `scroll-margin-top` on the target clears the sticky chrome. + const target = document.querySelector('[data-guide="guide-role-assignment"]'); + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + target?.focus({ preventScroll: true }); + }); } } - -/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ -function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { - if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - if (focus) target.focus({ preventScroll: true }); -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts index 38a0eba1..4da9ec05 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts @@ -230,9 +230,12 @@ export class AlgorithmRunService { return; } - const algo = this.studio.selectedAlgorithm(); - this.errorMsg.set(null); - this.errorService.clearError(); + const algo = this.selectedAlgorithm(); + if (!algo) { + this.errorMsg.set('Please choose an algorithm before running.'); + return; + } + this.studio.runStatusText.set('Processing experiment...'); this.studio.hasRunStarted.set(true); this.studio.runError.set(null); @@ -240,14 +243,6 @@ export class AlgorithmRunService { this.studio.runId.set(null); this.studio.setRunning(true); - if (!algo) { - console.error('No algorithm selected in service.'); - this.errorMsg.set('Please choose an algorithm before running.'); - this.studio.setRunning(false); - this.studio.hasRunStarted.set(false); - return; - } - const outlierReportError = this.configForm.validateOutlierReportForRun(); if (outlierReportError) { this.errorMsg.set(outlierReportError); @@ -263,7 +258,6 @@ export class AlgorithmRunService { } = resolveRunAlgorithmNames(algo.name, this.configForm.crossValidationEnabled(), this.studio); this.studio.lastUsedAlgorithm.set(finalAlgorithmName); - this.errorMsg.set(null); const configValues = this.configForm.runParameterValues(); if (!shouldIncludeSplits && configValues['n_splits'] !== undefined) { diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts index 644daf13..6b957f21 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts @@ -1,6 +1,5 @@ import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model'; import { - availabilityRequirementText, formatAvailabilityMessage, needsRequirementText, } from './algorithm-availability-text.utils'; @@ -34,19 +33,6 @@ describe('algorithm availability text', () => { expect(needsRequirementText(detail({ role: 'x', minCount: 0, maxCount: null }))).toBe('Predictors optional'); }); - it('lists count, selection and display types in tooltip requirements', () => { - expect(availabilityRequirementText(detail({ minCount: 1, maxCount: 1, selectedCount: 0, types: ['text', 'integer', 'text'] }))) - .toBe('Outcome: exactly 1, selected 0 • type: nominal, int'); - expect(availabilityRequirementText(detail({ types: [] }))).toBe('Outcome: exactly 1, selected 0'); - }); - - it('displays text availability types as nominal in requirement tips', () => { - const text = availabilityRequirementText(detail({ role: 'x', minCount: 1, maxCount: null, selectedCount: 1, types: ['real', 'int', 'text'] })); - - expect(text).toContain('type: real, int, nominal'); - expect(text).not.toContain('text'); - }); - it('rewrites backend type lists in availability messages', () => { expect(formatAvailabilityMessage('Outcome type must be one of text, polynominal. Other text.')) .toBe('Outcome type must be one of nominal. Other text.'); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts index bceebb5c..e811b42f 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts @@ -1,4 +1,3 @@ -import { formatInputCountRange } from '../../../../core/algorithm-input-counts'; import { VariableTypes } from '../../../../core/constants/algorithm.constants'; import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model'; @@ -75,17 +74,6 @@ function requirementDisplayTypes(types: unknown): string[] | null { return unique.length ? unique : null; } -/** One requirement line of the availability tooltip, e.g. "Outcome: 1, selected 0 • type: real". */ -export function availabilityRequirementText(detail: AlgorithmAvailabilityDetail): string { - const count = formatInputCountRange(detail.minCount, detail.maxCount); - const parts = [detail.label + ': ' + count + ', selected ' + detail.selectedCount]; - const types = requirementDisplayTypes(detail.types); - if (types) { - parts.push('type: ' + types.join(', ')); - } - return parts.join(' • '); -} - /** Rewrites the type list of "Outcome/Predictor type must be one of …." with display type names. */ export function formatAvailabilityMessage(message: string): string { return message.replace( diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts deleted file mode 100644 index 90e02e4e..00000000 --- a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { tooltipPosition } from './tooltip-position.utils'; - -describe('tooltipPosition', () => { - const container = new DOMRect(100, 50, 1000, 800); - const placement = { width: 320, height: 200, viewportWidth: 1280, viewportHeight: 800, padding: 16, offset: 12 }; - - it('places the tooltip right of the item, centred, relative to the container', () => { - expect(tooltipPosition(new DOMRect(200, 300, 100, 40), container, placement)).toEqual({ x: 212, y: 170 }); - }); - - it('flips left when there is no room on the right and clamps to the viewport', () => { - expect(tooltipPosition(new DOMRect(1000, 700, 100, 40), container, placement)).toEqual({ x: 568, y: 534 }); - }); -}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts deleted file mode 100644 index 147c689f..00000000 --- a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** Tooltip box and spacing for {@link tooltipPosition}. */ -export interface TooltipPlacement { - width: number; - height: number; - viewportWidth: number; - viewportHeight: number; - padding: number; - offset: number; -} - -/** - * Where to put a tooltip for an item, in coordinates relative to `containerRect`: right of - * the item, else left of it, else pinned to the right viewport edge; vertically centred on - * the item and kept inside the viewport, never closer than `padding` to the container edge. - */ -export function tooltipPosition(rect: DOMRect, containerRect: DOMRect, placement: TooltipPlacement): { x: number; y: number } { - const { width, height, viewportWidth, viewportHeight, padding, offset } = placement; - const rightAlignedX = rect.right + offset; - const leftAlignedX = rect.left - width - offset; - const centeredY = rect.top + (rect.height - height) / 2; - - let x = rightAlignedX; - if (rightAlignedX + width > viewportWidth - padding) { - x = leftAlignedX >= padding ? leftAlignedX : viewportWidth - width - padding; - } - - let y = centeredY; - if (y + height > viewportHeight - padding) { - y = viewportHeight - height - padding; - } - if (y < padding) { - y = padding; - } - - return { - x: Math.max(padding, x - containerRect.left), - y: Math.max(padding, y - containerRect.top), - }; -}