diff --git a/apps/cde-ui/jest.config.ts b/apps/cde-ui/jest.config.ts index 3514aff6a..672ec7caa 100644 --- a/apps/cde-ui/jest.config.ts +++ b/apps/cde-ui/jest.config.ts @@ -2,7 +2,7 @@ export default { displayName: 'cde-ui', preset: '../../jest.preset.js', - setupFilesAfterEnv: ['/src/test-setup.ts'], + setupFilesAfterEnv: ['/src/test-setup.ts', '@testing-library/jest-dom'], coverageDirectory: '../../coverage/apps/cde-ui', transform: { '^.+\\.(ts|mjs|js|html)$': [ diff --git a/apps/cde-ui/project.json b/apps/cde-ui/project.json index 92762716d..1346d24ac 100644 --- a/apps/cde-ui/project.json +++ b/apps/cde-ui/project.json @@ -35,8 +35,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "2kb", - "maximumError": "4kb" + "maximumWarning": "4kb", + "maximumError": "8kb" } ] }, diff --git a/apps/cde-ui/src/_redirects b/apps/cde-ui/src/_redirects index c7ccf35d4..7797f7c6a 100644 --- a/apps/cde-ui/src/_redirects +++ b/apps/cde-ui/src/_redirects @@ -1 +1 @@ -/* /index.html 200 +/* /index.html 200 diff --git a/apps/cde-ui/src/app/app.component.html b/apps/cde-ui/src/app/app.component.html index 75c60f935..0680b43f9 100644 --- a/apps/cde-ui/src/app/app.component.html +++ b/apps/cde-ui/src/app/app.component.html @@ -1,3 +1 @@ - - diff --git a/apps/cde-ui/src/app/app.config.ts b/apps/cde-ui/src/app/app.config.ts index 8be24561f..29bbeb597 100644 --- a/apps/cde-ui/src/app/app.config.ts +++ b/apps/cde-ui/src/app/app.config.ts @@ -1,7 +1,9 @@ import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { Routes, provideRouter } from '@angular/router'; import { provideIcons } from '@hra-ui/cdk/icons'; +import { CreateVisualizationPageComponent } from './pages/create-visualization-page/create-visualization-page.component'; import { LandingPageComponent } from './pages/landing-page/landing-page.component'; /** @@ -12,6 +14,10 @@ const routes: Routes = [ path: 'home', loadComponent: () => LandingPageComponent, }, + { + path: 'create', + loadComponent: () => CreateVisualizationPageComponent, + }, { path: '**', redirectTo: 'home', @@ -24,6 +30,7 @@ const routes: Routes = [ export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), + provideAnimations(), provideHttpClient(), provideIcons({ fontIcons: { diff --git a/apps/cde-ui/src/app/components/empty-form-control/empty-form-control.directive.spec.ts b/apps/cde-ui/src/app/components/empty-form-control/empty-form-control.directive.spec.ts new file mode 100644 index 000000000..5a9ce80d1 --- /dev/null +++ b/apps/cde-ui/src/app/components/empty-form-control/empty-form-control.directive.spec.ts @@ -0,0 +1,33 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { MarkEmptyFormControlDirective } from './empty-form-control.directive'; + +describe('EmptyFormControlDirective', () => { + it('initial state should have class "empty" when the control is empty', async () => { + await render(``, { + imports: [MarkEmptyFormControlDirective, ReactiveFormsModule], + componentProperties: { + control: new FormControl(), + }, + }); + + const input = screen.getByTestId('input'); + expect(input).toHaveClass('empty'); + }); + + it('should remove "empty" class when the control is not empty', async () => { + const user = userEvent.setup(); + await render(``, { + imports: [MarkEmptyFormControlDirective, ReactiveFormsModule], + componentProperties: { + control: new FormControl(), + }, + }); + + const input = screen.getByTestId('input'); + await user.type(input, 'Hello'); + + expect(input).not.toHaveClass('empty'); + }); +}); diff --git a/apps/cde-ui/src/app/components/empty-form-control/empty-form-control.directive.ts b/apps/cde-ui/src/app/components/empty-form-control/empty-form-control.directive.ts new file mode 100644 index 000000000..50fee36ec --- /dev/null +++ b/apps/cde-ui/src/app/components/empty-form-control/empty-form-control.directive.ts @@ -0,0 +1,27 @@ +import { Directive, OnInit, inject, signal } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +/** + * Directive that adds an `empty` class to form control elements + * when the control has no value. + */ +@Directive({ + selector: '[cdeMarkEmptyFormControl]', + standalone: true, + // eslint-disable-next-line @angular-eslint/no-host-metadata-property + host: { + '[class.empty]': 'isEmpty()', + }, +}) +export class MarkEmptyFormControlDirective implements OnInit { + /** Whether the form is empty */ + readonly isEmpty = signal(true); + + /** Reference to the form control */ + private readonly control = inject(NgControl, { self: true }); + + /** Binds the form control state to the isEmpty signal */ + ngOnInit(): void { + this.control.valueChanges?.subscribe((value) => this.isEmpty.set(!value)); + } +} diff --git a/apps/cde-ui/src/app/components/file-upload/file-upload.component.html b/apps/cde-ui/src/app/components/file-upload/file-upload.component.html new file mode 100644 index 000000000..55c94ab41 --- /dev/null +++ b/apps/cde-ui/src/app/components/file-upload/file-upload.component.html @@ -0,0 +1,34 @@ +
+ @if (!file && fileTitle(); as title) { +

{{ fileTitle() }}

+ } + @if (file) { +
+
+ @if (fileTitle(); as title) { +

File Loaded:

+ } @else { + File loaded: + } + {{ file.name }} +
+ +
+ } @else { + + + } +
diff --git a/apps/cde-ui/src/app/components/file-upload/file-upload.component.scss b/apps/cde-ui/src/app/components/file-upload/file-upload.component.scss new file mode 100644 index 000000000..d11636365 --- /dev/null +++ b/apps/cde-ui/src/app/components/file-upload/file-upload.component.scss @@ -0,0 +1,26 @@ +:host { + display: block; + + .upload-success { + display: flex; + justify-content: space-between; + + .filename span { + display: block; + } + + .file-name { + font-weight: 300; + } + + .remove-file { + align-self: flex-end; + } + } + + .upload-colormap .loaded-label { + font-weight: 500; + font-size: 1.5rem; + line-height: 2.25rem; + } +} diff --git a/apps/cde-ui/src/app/components/file-upload/file-upload.component.spec.ts b/apps/cde-ui/src/app/components/file-upload/file-upload.component.spec.ts new file mode 100644 index 000000000..339f1b0a2 --- /dev/null +++ b/apps/cde-ui/src/app/components/file-upload/file-upload.component.spec.ts @@ -0,0 +1,28 @@ +import { FileLoaderOptions, FileLoaderResult, FileUploadComponent } from './file-upload.component'; +import { render, screen } from '@testing-library/angular'; +import '@testing-library/jest-dom'; +import { signal } from '@angular/core'; + +describe('FileUploadComponent', () => { + let loader: jest.Mock, [File, FileLoaderOptions]>; + + beforeEach(() => { + loader = jest.fn(); + loader.mockReturnValue({ + progress: signal(0), + result: Promise.resolve('abc'), + }); + }); + + test('should load', async () => { + await render(FileUploadComponent, { + componentInputs: { + fileTitle: 'testTitle', + accept: 'csv', + loader: loader, + }, + }); + + expect(screen.getByText(/testTitle/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts b/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts new file mode 100644 index 000000000..b9b6986d5 --- /dev/null +++ b/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts @@ -0,0 +1,138 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Injector, Signal, effect, inject, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +/** Loader options */ +export interface FileLoaderOptions { + /** Signal to stop loading */ + signal: AbortSignal; +} + +/** Result from a file loader callback */ +export interface FileLoaderResult { + /** Signal of current progress in range [0, 1] */ + progress: Signal; + /** Promise resolving to the loaded data */ + result: Promise; +} + +/** File loader function */ +export type FileLoader = (file: File, options: FileLoaderOptions) => FileLoaderResult; + +/** Cleanup function */ +type CleanupFn = () => void; + +/** Component for loading a file from disk */ +@Component({ + selector: 'cde-file-upload', + standalone: true, + imports: [CommonModule, MatIconModule, MatButtonModule], + templateUrl: './file-upload.component.html', + styleUrl: './file-upload.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileUploadComponent { + /** Title */ + readonly fileTitle = input(); + /** Accepted file types */ + readonly accept = input.required(); + /** File loader */ + readonly loader = input.required>(); + + /** Progress events */ + readonly progress = output(); + /** Loading start events */ + readonly loadStarted = output(); + /** Loading cancelled events */ + readonly loadCancelled = output(); + /** Loading error events */ + readonly loadErrored = output(); + /** Loading completed events */ + readonly loadCompleted = output(); + + /** Reference to injector */ + private readonly injector = inject(Injector); + + /** Loaded file */ + file?: File; + /** Cleanup functions */ + private cleanup: CleanupFn[] = []; + + /** + * Loads a file + * + * @param el Input element + */ + load(el: HTMLInputElement): void { + if (el.files === null) { + return; + } + + const { loader, injector, progress, loadStarted } = this; + const file = (this.file = el.files[0]); + const cleanup: CleanupFn[] = (this.cleanup = []); + const abortController = new AbortController(); + const options: FileLoaderOptions = { + signal: abortController.signal, + }; + const result = loader()(file, options); + let done = false; + + el.files = null; + loadStarted.emit(file); + const progressRef = effect(() => progress.emit(result.progress()), { injector, manualCleanup: true }); + this.handleResult(result.result, cleanup, () => done); + + cleanup.push( + () => (done = true), + () => progressRef.destroy(), + () => abortController.abort(), + ); + } + + /** + * Cancels the currently loading file + */ + cancelLoad(): void { + this.runCleanup(this.cleanup); + this.file = undefined; + this.loadCancelled.emit(); + } + + /** + * Runs a set of cleanup functions + * + * @param fns Cleanup functions to run + */ + private runCleanup(fns: CleanupFn[]): void { + for (const fn of fns) { + fn(); + } + } + + /** + * Processes the result of a loader + * + * @param result Promise resolving to the loaded data + * @param cleanup Cleanup function to run at the end + * @param isDone Query function returning whether the loading has cancelled + */ + private async handleResult(result: Promise, cleanup: CleanupFn[], isDone: () => boolean): Promise { + const { progress, loadErrored, loadCompleted } = this; + try { + const data = await result; + if (!isDone()) { + progress.emit(1); + loadCompleted.emit(data); + } + } catch (reason) { + if (!isDone()) { + this.file = undefined; + loadErrored.emit(reason); + } + } finally { + this.runCleanup(cleanup); + } + } +} diff --git a/apps/cde-ui/src/app/components/footer/footer.component.scss b/apps/cde-ui/src/app/components/footer/footer.component.scss index 41c47632a..04627ca76 100644 --- a/apps/cde-ui/src/app/components/footer/footer.component.scss +++ b/apps/cde-ui/src/app/components/footer/footer.component.scss @@ -111,7 +111,7 @@ } .disclaimer { - margin: 5rem 6.5rem 8.5rem; + margin: 5rem 6.5rem 8.5rem 6.5rem; color: hsla(240, 11%, 33%, 1); span { display: block; diff --git a/apps/cde-ui/src/app/models/create-visualization-page-types.ts b/apps/cde-ui/src/app/models/create-visualization-page-types.ts new file mode 100644 index 000000000..36312ab84 --- /dev/null +++ b/apps/cde-ui/src/app/models/create-visualization-page-types.ts @@ -0,0 +1,47 @@ +import { CellTypeTableData } from '../pages/create-visualization-page/create-visualization-page.component'; + +/** Color map row */ +export interface ColorMapItem { + /** Cell id */ + cell_id: number; + /** Cell type */ + cell_type: string; + /** Cell color */ + cell_color: [number, number, number]; +} + +/** Color map */ +export type ColorMap = ColorMapItem[]; + +/** Settings */ +export interface VisualizationSettings { + /** Node data */ + data: CellTypeTableData[]; + /** Cell type anchor */ + anchorCellType?: string; + /** User provided metadata */ + metadata: MetaData; + /** Optional color map */ + colorMap?: ColorMap; +} + +/** Metadata */ +export interface MetaData { + /** Visualization metadata */ + title?: string; + /** Sex */ + sex: string; + /** Thickness */ + thickness?: number; + /** Technology */ + technology?: string; + /** Age */ + age?: number; + /** Pixel size */ + pixelSize?: number; + /** Organ */ + organ?: string; +} + +/** Csv data source */ +export type CsvType = 'data' | 'colormap'; diff --git a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.html b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.html new file mode 100644 index 000000000..8edcc8b61 --- /dev/null +++ b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.html @@ -0,0 +1,164 @@ + +
+

Create a Cell Distance Visualization

+ +
+

+ 1 + Format and Upload Data + info +

+ +

Upload Cell Type Table

+

Required Columns

+

Optional Columns

+ X, Y, and Cell Type + Z and Cell Ontology ID + + + + + +

Cell Type Table Template

+
+ +
+
+ +
+

+ 2 + Optional: Select Anchor Cell Type + info +

+ + Anchor Cell Type + + Endothelial 1 + Endothelial 2 + @for (cell of anchorCellTypes; track cell) { + {{ cell.viewValue }} + } + + +
+ + + +
+

+ 4 + Optional: Configure Color Map + info +

+
+ + Use Default Colors + Upload Custom Color Map + + @if (visualizationForm.value.colorMapOption === 'custom') { + + + +
+

Color Map Template

+ +
+ } +
+
+ +
+

+ 5 + Visualize Cell Distance Data + info +

+ +
+
+ diff --git a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.scss b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.scss new file mode 100644 index 000000000..7d14d4c54 --- /dev/null +++ b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.scss @@ -0,0 +1,219 @@ +@use '@angular/material' as mat; + +$higher-density-theme: mat.define-light-theme( + ( + density: -1, + ) +); + +::ng-deep { + .cdk-overlay-container div.cdk-overlay-pane div.select-options-container { + border-radius: 1rem; + + mat-option { + --mat-option-selected-state-label-text-color: #201e3d; + --mat-minimal-pseudo-checkbox-selected-checkmark-color: #b20a2f; + } + } +} + +:host { + display: block; + background: url('../../../assets/backgrounds/create-visualization-page-background.png') no-repeat; + background-size: 100% 100%; + + .content { + display: flex; + flex-direction: column; + gap: 5rem; + max-width: 72rem; + margin: 0 auto; + width: 100%; + + > :last-child { + margin-bottom: 8rem; + } + + @include mat.form-field-density($higher-density-theme); + @include mat.select-density($higher-density-theme); + + .title { + margin-top: 5rem; + } + + .card { + background-color: white; + box-shadow: 0 5px 16px 0 rgba(32, 30, 61, 0.24); + border-radius: 1rem; + padding: 3.5rem 4rem; + --mdc-filled-text-field-active-indicator-height: 0; + --mdc-filled-text-field-focus-active-indicator-height: 0; + --mdc-filled-button-container-height: 2.5rem; + + ::ng-deep { + .mat-mdc-text-field-wrapper, + .mdc-text-field { + border-radius: 1rem; + box-shadow: 0 5px 16px 0 rgba(32, 30, 61, 0.24); + } + } + + .header { + display: flex; + align-items: center; + margin-bottom: 3rem; + + .step-number { + display: inline-block; + width: 3.5rem; + height: 3.5rem; + background: #464954; + text-align: center; + padding: 1rem; + line-height: 1.75rem; + margin-right: 1.5rem; + border-radius: 50%; + font-size: 1.5rem; + color: white; + } + + .info { + margin-left: 1.25rem; + } + } + } + + .select-form { + min-width: 26.75rem; + } + + .data-upload { + display: grid; + display: grid; + grid-template-columns: 1fr 1fr 0.5fr 1.5fr; + grid-template-rows: auto; + grid-template-areas: + 'header header header header' + 'subheader-1 subheader-1 divider subheader-2' + 'subheader-required-columns subheader-optional-columns divider use-template' + 'required-columns optional-columns divider .' + 'upload-csv upload-csv divider .'; + + .header { + grid-area: header; + } + + .subheader-1 { + grid-area: subheader-1; + } + + .subheader-2 { + grid-area: subheader-2; + } + + .subheader-required-columns { + grid-area: subheader-required-columns; + } + + .subheader-optional-columns { + grid-area: subheader-optional-columns; + } + + .divider { + grid-area: divider; + place-self: center; + height: 100%; + } + + .required-columns { + grid-area: required-columns; + } + + .optional-columns { + grid-area: optional-columns; + } + + .upload-csv { + grid-area: upload-csv; + margin-top: 2.5rem; + } + + .use-template { + grid-area: use-template; + + button { + gap: 0.75rem; + padding: 0; + } + } + } + + .metadata { + .metadata-options { + display: flex; + flex-wrap: wrap; + column-gap: 5rem; + row-gap: 3rem; + + .metadata-option { + gap: 0.5rem; + display: flex; + flex-direction: column; + } + } + + div.metadata-option:nth-child(-n + 2) { + width: calc(50% - 4rem); + } + + .metadata-option:nth-child(n + 3):nth-child(-n + 7) { + width: calc(33% - 4rem); + } + } + + .color-config { + .mat-button-toggle { + padding: 0 1.5rem; + } + + .toggle-actions { + display: grid; + grid-template-columns: 1.625fr 0.25fr 1.125fr; + grid-template-areas: + 'upload-toggle upload-toggle upload-toggle' + 'upload-colormap divider colormap-template'; + + mat-button-toggle-group { + width: max-content; + margin-bottom: 3.5rem; + grid-area: upload-toggle; + } + + .upload-colormap { + grid-area: upload-colormap; + } + + .divider { + height: 100%; + grid-area: divider; + place-self: center; + } + + .colormap-template { + grid-area: colormap-template; + + button { + padding: 0; + } + } + } + } + + .visualize { + .visualize-button { + color: white; + background-color: rgba(224, 13, 58, 1); + } + } + } +} diff --git a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.spec.ts b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.spec.ts new file mode 100644 index 000000000..05d14d9f6 --- /dev/null +++ b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.spec.ts @@ -0,0 +1,87 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { OutputEmitterRef } from '@angular/core'; +import { provideIcons } from '@hra-ui/cdk/icons'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { VisualizationSettings } from '../../models/create-visualization-page-types'; +import { CellTypeTableData, CreateVisualizationPageComponent } from './create-visualization-page.component'; + +describe('CreateVisualizationPageComponent', () => { + const globalProviders = [provideIcons(), provideHttpClient(), provideHttpClientTesting()]; + + it('should process and update data correctly in setData', async () => { + const { fixture } = await render(CreateVisualizationPageComponent, { + providers: globalProviders, + }); + + const component = fixture.componentInstance; + const testInputData = [ + { x: 1, y: 1, cellType: 'TypeA' }, + { x: 2, y: 2, cellType: 'TypeB' }, + { x: 3, y: 3, cellType: 'TypeA' }, + ]; + + component.setData(testInputData); + + expect(component.data).toEqual(testInputData); + expect(component.anchorCellTypes).toEqual([ + { value: 'TypeA', viewValue: 'TypeA' }, + { value: 'TypeB', viewValue: 'TypeB' }, + ]); + }); + + it('can toggle color map selection', async () => { + const { fixture } = await render(CreateVisualizationPageComponent, { + providers: globalProviders, + }); + fixture.componentInstance.toggleDefaultColorMap(); + expect(fixture.componentInstance.useDefaultColorMap).toBe(false); + }); + + describe('onSubmit()', () => { + it('should use provided colormap when default is false', async () => { + const submitFn = jest.fn(); + await render(CreateVisualizationPageComponent, { + componentOutputs: { + visualize: { emit: submitFn } as unknown as OutputEmitterRef, + }, + componentProperties: { + data: [ + { + x: 10, + y: 10, + cellType: 'ct', + }, + ] as CellTypeTableData[], + useDefaultColorMap: false, + }, + providers: globalProviders, + }); + await userEvent.click(screen.getByText('Visualize')); + expect(submitFn).toHaveBeenCalled(); + }); + + it('should use default colormap when default is true', async () => { + const submitFn = jest.fn(); + await render(CreateVisualizationPageComponent, { + componentOutputs: { + visualize: { emit: submitFn } as unknown as OutputEmitterRef, + }, + componentProperties: { + data: [ + { + x: 10, + y: 10, + cellType: 'ct', + }, + ] as CellTypeTableData[], + useDefaultColorMap: true, + }, + providers: globalProviders, + }); + await userEvent.click(screen.getByText('Visualize')); + expect(submitFn).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.theme.scss b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.theme.scss new file mode 100644 index 000000000..f069d5e35 --- /dev/null +++ b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.theme.scss @@ -0,0 +1,131 @@ +@use 'sass:map'; +@use '@angular/material' as mat; +@use '../../../styles/palettes' as palettes; + +$gray: mat.define-palette(palettes.$hra-gray1-palette, 500); +$red: mat.define-palette(palettes.$hra-red2-palette, 500); +$blue: mat.define-palette(palettes.$hra-blue4-palette, 500); + +$gray-theme: mat.define-light-theme( + ( + color: ( + primary: $gray, + accent: $gray, + ), + ) +); + +$red-theme: mat.define-light-theme( + ( + color: ( + primary: $red, + accent: $red, + ), + ) +); + +$blue-theme: mat.define-light-theme( + ( + color: ( + primary: $blue, + accent: $blue, + ), + ) +); + +@mixin color($theme) { + cde-create-visualization-page { + @include mat.form-field-color($gray-theme); + @include mat.select-color($gray-theme); + @include mat.option-color($gray-theme); + + $divider-color: map.get(palettes.$hra-blue4-palette, 500); + --mat-divider-color: #{$divider-color}; + + $button-toggle-selected-background: map.get(palettes.$hra-red2-palette, 500); + + cde-file-upload { + @include mat.button-color($blue-theme); + --mdc-filled-button-label-text-color: black; + } + + mat-button-toggle-group { + --mat-standard-button-toggle-selected-state-background-color: #{$button-toggle-selected-background}; + --mat-standard-button-toggle-selected-state-text-color: white; + --mat-standard-button-toggle-divider-color: #4c4c58; + } + + mat-form-field { + $label-color: map.get(palettes.$hra-gray1-palette, 500); + --mdc-filled-text-field-label-text-color: #{$label-color}; + --mdc-filled-text-field-label-text-weight: 500; + } + + .metadata .metadata-options mat-form-field, + .anchor-selection mat-form-field { + --mat-form-field-hover-state-layer-opacity: 0; + --mat-form-field-focus-state-layer-opacity: 0; + --mdc-filled-text-field-hover-label-text-color: var(--mdc-filled-text-field-label-text-color); + --mat-select-enabled-arrow-color: var(--mdc-filled-text-field-label-text-color); + --mdc-filled-text-field-container-color: #ffffff; + + $selected-option-color: map.get(palettes.$hra-blue1-palette, 500); + --mat-select-enabled-trigger-text-color: #{$selected-option-color}; + + $form-field-container-hover-color: map.get(palettes.$hra-blue5-palette, 500); + + &:hover { + --mdc-filled-text-field-container-color: #{$form-field-container-hover-color}; + --mdc-filled-text-field-label-text-color: #{$selected-option-color}; + --mat-select-enabled-arrow-color: #{$selected-option-color}; + } + + &:active { + --mdc-filled-text-field-container-color: #{$divider-color}; + --mdc-filled-text-field-label-text-color: #{$selected-option-color}; + --mat-select-enabled-arrow-color: #{$selected-option-color}; + } + + &:has(.empty) { + $select-label-color: map.get(palettes.$hra-red5-palette, 500); + .mdc-floating-label--float-above { + --mdc-filled-text-field-focus-label-text-color: #{$select-label-color}; + color: var(--mdc-filled-text-field-focus-label-text-color); + } + + .mdc-floating-label--float-above + mat-select { + --mat-select-focused-arrow-color: #{$select-label-color}; + --mat-select-enabled-arrow-color: #{$select-label-color}; + } + } + + &:has(mat-select:focus-visible, input:focus-visible) { + label { + --mdc-filled-text-field-focus-label-text-color: #{$selected-option-color}; + --mdc-filled-text-field-label-text-color: #{$selected-option-color}; + } + .mdc-text-field--filled { + outline: 4px solid #{$button-toggle-selected-background}; + } + } + } + } +} + +@mixin typography($theme) { + cde-create-visualization-page { + .metadata .metadata-options mat-form-field { + font: mat.get-theme-typography($theme, button); + } + } +} + +@mixin theme($theme) { + @if mat.theme-has($theme, color) { + @include color($theme); + } + + @if mat.theme-has($theme, typography) { + @include typography($theme); + } +} diff --git a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts new file mode 100644 index 000000000..51507e514 --- /dev/null +++ b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts @@ -0,0 +1,159 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MarkEmptyFormControlDirective } from '../../components/empty-form-control/empty-form-control.directive'; +import { FileUploadComponent } from '../../components/file-upload/file-upload.component'; +import { FooterComponent } from '../../components/footer/footer.component'; +import { HeaderComponent } from '../../components/header/header.component'; +import { ColorMap, ColorMapItem, VisualizationSettings } from '../../models/create-visualization-page-types'; +import { CsvLoaderService } from '../../services/csv-loader/csv-loader.service'; +import { validateInteger } from '../../shared/form-validators/is-integer'; + +/** Metadata select dropdown option */ +export interface MetadataSelectOption { + /** Value */ + value: string; + /** User text */ + viewValue: string; +} + +/** Node data row */ +export interface CellTypeTableData { + /** x position */ + x: number; + /** y position */ + y: number; + /** Cell type */ + cellType: string; + /** Optional z position */ + z?: number; + /** Ontology id */ + ontologyId?: string; +} + +/** Visualization customization page */ +@Component({ + selector: 'cde-create-visualization-page', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonToggleModule, + MatButtonModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + FormsModule, + ReactiveFormsModule, + MatIconModule, + FileUploadComponent, + MatDividerModule, + HeaderComponent, + FooterComponent, + MarkEmptyFormControlDirective, + ], + templateUrl: './create-visualization-page.component.html', + styleUrl: './create-visualization-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateVisualizationPageComponent { + /** Emits user data */ + readonly visualize = output(); + + /** Form builder */ + private readonly formBuilder = inject(FormBuilder); + + /** Component form controller */ + visualizationForm = this.formBuilder.nonNullable.group({ + anchorCellType: [''], + metadata: this.formBuilder.nonNullable.group({ + title: [''], + technology: [''], + organ: [''], + sex: [''], + age: [undefined, [Validators.min(0), Validators.max(120), validateInteger()]], + thickness: [undefined, Validators.min(0)], + pixelSize: [undefined, Validators.min(0)], + }), + colorMapOption: ['default'], + }); + + /** Node data */ + data?: CellTypeTableData[]; + /** Color map data */ + colorMap?: ColorMap; + /** ??? */ + selectedValue?: string; + /** Organ options */ + organs: MetadataSelectOption[] = []; + /** File loader factory service */ + service = inject(CsvLoaderService); + /** Node data file loader */ + loadCsv = this.service.createLoader({ + dynamicTyping: { + x: true, + y: true, + z: true, + }, + }); + /** Color map file loader */ + loadColorMap = this.service.createLoader({ + dynamicTyping: { + cell_id: true, + }, + transformItem: (item) => + ({ + ...item, + cell_color: JSON.parse(`${item['cell_color']}`), + }) as ColorMapItem, + }); + + /** Node cell type values */ + anchorCellTypes: MetadataSelectOption[] = []; + /** Whether to use the default color map */ + useDefaultColorMap = true; + + /** + * Sets the loaded node data + * + * @param data Nodes data + */ + setData(data: CellTypeTableData[]): void { + const cellTypes = data.map((item) => item.cellType); + const uniqueCellTypes = Array.from(new Set(cellTypes)); + + this.data = data; + this.anchorCellTypes = uniqueCellTypes.map((type) => ({ + value: type, + viewValue: type, + })); + } + + /** + * Toggle to/from using the default color map + */ + toggleDefaultColorMap(): void { + this.useDefaultColorMap = !this.useDefaultColorMap; + } + + /** + * Emits the user data + */ + onSubmit() { + if (this.data) { + this.visualize.emit({ + ...this.visualizationForm.getRawValue(), + data: this.data, + colorMap: this.useDefaultColorMap ? undefined : this.colorMap, + }); + } + } +} diff --git a/apps/cde-ui/src/app/pages/landing-page/landing-page.component.ts b/apps/cde-ui/src/app/pages/landing-page/landing-page.component.ts index 59a98ff6b..8b67ad343 100644 --- a/apps/cde-ui/src/app/pages/landing-page/landing-page.component.ts +++ b/apps/cde-ui/src/app/pages/landing-page/landing-page.component.ts @@ -1,8 +1,8 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { VisualCard, VisualCardComponent } from '../../components/visual-card/visual-card.component'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { YouTubePlayerModule } from '@angular/youtube-player'; +import { VisualCard, VisualCardComponent } from '../../components/visual-card/visual-card.component'; /** * Landing Page Component diff --git a/apps/cde-ui/src/app/services/csv-loader/csv-loader.service.spec.ts b/apps/cde-ui/src/app/services/csv-loader/csv-loader.service.spec.ts new file mode 100644 index 000000000..a61d01cdb --- /dev/null +++ b/apps/cde-ui/src/app/services/csv-loader/csv-loader.service.spec.ts @@ -0,0 +1,70 @@ +import { Shallow } from 'shallow-render'; +import { CsvLoaderOptions, CsvLoaderService, CsvObject } from './csv-loader.service'; +import { NgModule, WritableSignal, signal } from '@angular/core'; + +@NgModule({ + providers: [], +}) +class CsvLoaderServiceTestModule {} + +describe('CreateVisualizationPageComponent', () => { + let shallow: Shallow; + + beforeEach(async () => { + shallow = new Shallow(CsvLoaderService, CsvLoaderServiceTestModule); + }); + + it('should create', () => { + expect(() => shallow.createService()).not.toThrow(); + }); + + describe('createLoader()', () => { + it('should return a FileLoader function', () => { + const { instance } = shallow.createService(); + const loader = instance.createLoader(); + expect(typeof loader).toBe('function'); + }); + }); + describe('load()', () => { + const testFile: File = new File(['name,age,isStudent\nJohn,25,true\nJane,30,false'], 'test.csv', { + type: 'text/csv', + }); + + const testOptions: CsvLoaderOptions<{ name: string; age: number; isStudent: boolean }> = { + transformItem: (item: CsvObject) => ({ + name: item['name'] as string, + age: Number.parseInt(item['age'] as string), + isStudent: item['isStudent'] === 'true', + }), + }; + + it('should load and transform the CSV data', async () => { + const { instance } = shallow.createService(); + const progress = signal(0); + const result = await instance.load(testFile, progress, testOptions, { signal: new AbortController().signal }); + expect(result).toEqual([ + { name: 'John', age: 25, isStudent: true }, + { name: 'Jane', age: 30, isStudent: false }, + ]); + }); + + it('should update the progress signal', async () => { + const { instance } = shallow.createService(); + const progress: WritableSignal = signal(0); + await instance.load(testFile, progress, testOptions, { signal: new AbortController().signal }); + + expect(progress()).toBe(1); + }); + + it('should reject the promise on error', async () => { + const { instance } = shallow.createService(); + const progress: WritableSignal = signal(0); + const abortController = new AbortController(); + abortController.abort(); + + await expect(instance.load(testFile, progress, testOptions, { signal: abortController.signal })).rejects.toEqual( + [], + ); + }); + }); +}); diff --git a/apps/cde-ui/src/app/services/csv-loader/csv-loader.service.ts b/apps/cde-ui/src/app/services/csv-loader/csv-loader.service.ts new file mode 100644 index 000000000..eb109d640 --- /dev/null +++ b/apps/cde-ui/src/app/services/csv-loader/csv-loader.service.ts @@ -0,0 +1,89 @@ +import { Injectable, WritableSignal, signal } from '@angular/core'; +import { LocalChunkSize, ParseLocalConfig, parse } from 'papaparse'; +import { FileLoader, FileLoaderOptions } from '../../components/file-upload/file-upload.component'; + +/** Csv row object */ +export type CsvObject = Record; +/** Non-overridable options always provided by the service */ +type ReservedParseOptions = 'worker' | 'chunk' | 'complete' | 'error' | 'transform'; + +/** Factory options */ +export type CsvLoaderOptions = Omit & { + transformItem?: (item: CsvObject) => T; +}; + +/** Factory service for creating csv loaders */ +@Injectable({ + providedIn: 'root', +}) +export class CsvLoaderService { + /** + * Creates a new csv file loader + * + * @param options Loader options + * @returns A function for loading csv files + */ + createLoader(options?: CsvLoaderOptions): FileLoader { + return (file, opts) => { + const progress = signal(0); + return { + progress, + result: this.load(file, progress, options ?? {}, opts), + }; + }; + } + + /** + * Loads a csv file + * + * @param file File to load + * @param progress Progress events signal + * @param options Loader options + * @param opts File loader options + * @returns A promise that resolves to the loaded file data + */ + load( + file: File, + progress: WritableSignal, + options: CsvLoaderOptions, + opts: FileLoaderOptions, + ): Promise { + return new Promise((resolve, reject) => { + const chunkSize = options.chunkSize ?? LocalChunkSize; + const size = file.size; + const abortSignal = opts.signal; + const transformItem = options.transformItem; + const result: T[] = []; + let current = 0; + + delete options.transformItem; + + parse(file, { + header: true, + skipEmptyLines: 'greedy', + ...options, + worker: true, + chunk({ data, errors }, parser) { + if (errors.length > 0 || abortSignal.aborted) { + reject(errors); + parser.abort(); + return; + } + + for (const item of data) { + result.push(transformItem ? transformItem(item) : (item as T)); + } + + current += chunkSize; + progress.set(Math.min(current, size) / size); + }, + complete() { + resolve(result); + }, + error(error) { + reject(error); + }, + }); + }); + } +} diff --git a/apps/cde-ui/src/app/shared/form-validators/is-integer.spec.ts b/apps/cde-ui/src/app/shared/form-validators/is-integer.spec.ts new file mode 100644 index 000000000..063d5e403 --- /dev/null +++ b/apps/cde-ui/src/app/shared/form-validators/is-integer.spec.ts @@ -0,0 +1,35 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { validateInteger } from './is-integer'; + +describe('validateInteger', () => { + let validator: ValidatorFn; + + beforeEach(() => { + validator = validateInteger(); + }); + + it('should return null for empty values', () => { + const control = { value: null } as AbstractControl; + const errors = validator(control); + + expect(errors).toBeNull(); + }); + + it('should return null for valid integer values', () => { + const controls = [{ value: 0 }, { value: 10 }, { value: -5 }]; + + for (const control of controls) { + const errors = validator(control as AbstractControl); + expect(errors).toBeNull(); + } + }); + + it('should return error for non-integer values', () => { + const controls = [{ value: 3.14 }, { value: 'abc' }, { value: true }]; + + for (const control of controls) { + const errors = validator(control as AbstractControl); + expect(errors).toEqual({ notInteger: { value: control.value } }); + } + }); +}); diff --git a/apps/cde-ui/src/app/shared/form-validators/is-integer.ts b/apps/cde-ui/src/app/shared/form-validators/is-integer.ts new file mode 100644 index 000000000..90566b307 --- /dev/null +++ b/apps/cde-ui/src/app/shared/form-validators/is-integer.ts @@ -0,0 +1,17 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; + +/** + * Create a form validator that checks that the provided value is an integer + * + * @returns Validator function + */ +export function validateInteger(): ValidatorFn { + return (control: AbstractControl): { [key: string]: unknown } | null => { + const value = control.value; + if (value === null || value === undefined || value === '') { + return null; + } + const isInteger = Number.isInteger(value); + return isInteger ? null : { notInteger: { value: control.value } }; + }; +} diff --git a/apps/cde-ui/src/assets/backgrounds/create-visualization-page-background.png b/apps/cde-ui/src/assets/backgrounds/create-visualization-page-background.png new file mode 100644 index 000000000..7ae60975e Binary files /dev/null and b/apps/cde-ui/src/assets/backgrounds/create-visualization-page-background.png differ diff --git a/apps/cde-ui/src/assets/fonts/metropolis.medium.otf b/apps/cde-ui/src/assets/fonts/metropolis.medium.otf new file mode 100644 index 000000000..239d69dc2 Binary files /dev/null and b/apps/cde-ui/src/assets/fonts/metropolis.medium.otf differ diff --git a/apps/cde-ui/src/assets/fonts/metropolis.regular.otf b/apps/cde-ui/src/assets/fonts/metropolis.regular.otf new file mode 100644 index 000000000..737760b5d Binary files /dev/null and b/apps/cde-ui/src/assets/fonts/metropolis.regular.otf differ diff --git a/apps/cde-ui/src/assets/icons/upload.svg b/apps/cde-ui/src/assets/icons/upload.svg new file mode 100644 index 000000000..5a238de7f --- /dev/null +++ b/apps/cde-ui/src/assets/icons/upload.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/cde-ui/src/index.html b/apps/cde-ui/src/index.html index 863a74edd..58eadd0ce 100644 --- a/apps/cde-ui/src/index.html +++ b/apps/cde-ui/src/index.html @@ -5,6 +5,11 @@ Cell Distance Explorer + + diff --git a/apps/cde-ui/src/styles.scss b/apps/cde-ui/src/styles.scss index d03238f13..ef195a2bb 100644 --- a/apps/cde-ui/src/styles.scss +++ b/apps/cde-ui/src/styles.scss @@ -3,6 +3,17 @@ @use './styles/global/fonts'; @use './styles/material-theming'; +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ +} + +input[type='number'] { + -moz-appearance: textfield; /* Firefox */ +} + body { margin: 0; } diff --git a/apps/cde-ui/src/styles/_custom-component-theming.scss b/apps/cde-ui/src/styles/_custom-component-theming.scss new file mode 100644 index 000000000..067fd77d7 --- /dev/null +++ b/apps/cde-ui/src/styles/_custom-component-theming.scss @@ -0,0 +1,6 @@ +@use '../app/pages/create-visualization-page/create-visualization-page.component.theme.scss' as create-vis-page; + +@mixin theme($theme) { + /** Add theme mixins for custom components here! */ + @include create-vis-page.theme($theme); +} diff --git a/apps/cde-ui/tsconfig.spec.json b/apps/cde-ui/tsconfig.spec.json index 7870b7c01..2269b492d 100644 --- a/apps/cde-ui/tsconfig.spec.json +++ b/apps/cde-ui/tsconfig.spec.json @@ -4,7 +4,7 @@ "outDir": "../../dist/out-tsc", "module": "commonjs", "target": "es2016", - "types": ["jest", "node"] + "types": ["jest", "node", "@testing-library/jest-dom"] }, "files": ["src/test-setup.ts"], "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] diff --git a/package-lock.json b/package-lock.json index 1e18e65af..b56285d09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10539,8 +10539,7 @@ }, "node_modules/@testing-library/jest-dom": { "version": "6.4.5", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.5.tgz", - "integrity": "sha512-AguB9yvTXmCnySBP1lWjfNNUwpbElsaQ567lt2VdGqAdHtpieLgjmcVyv1q7PMIvLbgpDdkWV5Ydv3FEejyp2A==", + "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.3.2", "@babel/runtime": "^7.9.2", @@ -10583,8 +10582,7 @@ }, "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -10597,8 +10595,7 @@ }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10609,21 +10606,18 @@ }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" + "license": "MIT" }, "node_modules/@testing-library/jest-dom/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@testing-library/jest-dom/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15641,8 +15635,7 @@ }, "node_modules/css.escape": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "license": "MIT" }, "node_modules/cssdb": { "version": "8.0.0", @@ -22772,8 +22765,9 @@ }, "node_modules/jest-preset-angular": { "version": "14.0.4", + "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-14.0.4.tgz", + "integrity": "sha512-O4WhVRdfiN9TtJMbJbuVJxD3zn6fyOF2Pqvu12fvEVR6FxCN1S1POfR2nU1fRdP+rQZv7iiW+ttxsy+qkE8iCw==", "dev": true, - "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "esbuild-wasm": ">=0.15.13", @@ -23934,6 +23928,8 @@ }, "node_modules/jspreadsheet-ce": { "version": "4.13.4", + "resolved": "https://registry.npmjs.org/jspreadsheet-ce/-/jspreadsheet-ce-4.13.4.tgz", + "integrity": "sha512-Rv1xbR5AKme7Nd+vCRsHS05+3h0CtcDYcGseXPOEOWV9Mq7k3z57comq+kjLXJZyEf3CR9kCzIPQsd6tN7Yn6w==", "dependencies": { "@jspreadsheet/formula": "^2.0.2", "jsuites": "^5.0.25" @@ -26206,8 +26202,7 @@ }, "node_modules/min-indent": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "license": "MIT", "engines": { "node": ">=4" } @@ -30790,8 +30785,7 @@ }, "node_modules/redent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "license": "MIT", "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -32936,8 +32930,7 @@ }, "node_modules/strip-indent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "license": "MIT", "dependencies": { "min-indent": "^1.0.0" },