diff --git a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.html b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.html index c1aa46618..c15e6010c 100644 --- a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.html +++ b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.html @@ -23,29 +23,32 @@
- +
+ + + +
diff --git a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.scss b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.scss index ea77a241b..67c1648d2 100644 --- a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.scss +++ b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.scss @@ -72,10 +72,23 @@ display: flex; flex-direction: column; + .top { + display: flex; + height: 0; + flex-grow: 1; + } + cde-node-dist-visualization { border-color: #d5dbe3; border-width: 0 0 2px 0; border-style: solid; + height: 100%; + width: calc(100% - 488px); + } + + cde-violin { + height: 100%; + width: 488px; } } } diff --git a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts index 9366b0be3..8249df64b 100644 --- a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts +++ b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts @@ -12,13 +12,15 @@ import { signal, ViewContainerRef, } from '@angular/core'; -import { rgbToHex } from '@hra-ui/design-system/color-picker'; + import { CellTypesComponent } from '../components/cell-types/cell-types.component'; import { HistogramComponent } from '../components/histogram/histogram.component'; import { MetadataComponent } from '../components/metadata/metadata.component'; import { NodeDistVisualizationComponent } from '../components/node-dist-visualization/node-dist-visualization.component'; +import { ViolinComponent } from '../components/violin/violin.component'; import { VisualizationHeaderComponent } from '../components/visualization-header/visualization-header.component'; import { CellTypeEntry } from '../models/cell-type'; +import { Rgb, rgbToHex } from '@hra-ui/design-system/color-picker'; import { ColorMapColorKey, ColorMapEntry, @@ -27,7 +29,7 @@ import { DEFAULT_COLOR_MAP_KEY, DEFAULT_COLOR_MAP_VALUE_KEY, } from '../models/color-map'; -import { DEFAULT_MAX_EDGE_DISTANCE, EdgeEntry, EdgeIndex } from '../models/edge'; +import { DEFAULT_MAX_EDGE_DISTANCE, edgeDistance, EdgeEntry, EdgeIndex } from '../models/edge'; import { Metadata } from '../models/metadata'; import { DEFAULT_NODE_TARGET_KEY, NodeEntry, NodeTargetKey, selectNodeTargetValue } from '../models/node'; import { ColorMapFileLoaderService } from '../services/data/color-map-loader.service'; @@ -40,6 +42,14 @@ import { createColorGenerator } from '../shared/color-generator'; import { emptyArrayEquals } from '../shared/empty-array-equals'; import { mergeObjects } from '../shared/merge'; +/** Interface for representing the distance entry */ +export interface DistanceEntry { + /** Type of the entry */ + type: string; + /** Distance value of the entry */ + distance: number; +} + /** * CDE Visualization Root Component */ @@ -53,6 +63,7 @@ import { mergeObjects } from '../shared/merge'; CellTypesComponent, NodeDistVisualizationComponent, HistogramComponent, + ViolinComponent, ], templateUrl: './cde-visualization.component.html', styleUrl: './cde-visualization.component.scss', @@ -249,6 +260,26 @@ export class CdeVisualizationComponent { /** View container. Do NOT change the name. It is used by ngx-color-picker! */ readonly vcRef = inject(ViewContainerRef); + /** List of filtered cell types based on selection */ + protected readonly filteredCellTypes = computed( + () => { + const selection = new Set(this.cellTypesSelection()); + selection.delete(this.selectedNodeTargetValue()); + const filtered = this.cellTypes().filter(({ name }) => selection.has(name)); + return filtered.sort((a, b) => b.count - a.count); + }, + { equal: emptyArrayEquals }, + ); + + /** Computed distances between nodes */ + protected readonly distances = computed(() => this.computeDistances(), { equal: emptyArrayEquals }); + + /** Data for the histogram visualization */ + protected readonly filteredDistances = computed(() => this.computeFilteredDistances(), { equal: emptyArrayEquals }); + + /** Colors for the histogram visualization */ + protected readonly filteredColors = computed(() => this.computeFilteredColors(), { equal: emptyArrayEquals }); + /** Setup component */ constructor() { // Workaround for getting ngx-color-picker to attach to the root view @@ -289,4 +320,53 @@ export class CdeVisualizationComponent { this.fileSaver.saveCsv(data, 'color-map.csv'); } } + + /** Compute distances between nodes based on edges */ + private computeDistances(): DistanceEntry[] { + const nodes = this.loadedNodes(); + const edges = this.loadedEdges(); + if (nodes.length === 0 || edges.length === 0) { + return []; + } + + const nodeTypeKey = this.nodeTypeKey(); + const selectedCellType = this.selectedNodeTargetValue(); + const distances: DistanceEntry[] = []; + for (const edge of edges) { + const sourceNode = nodes[edge[EdgeIndex.SourceNode]]; + const type = sourceNode[nodeTypeKey]; + if (type !== selectedCellType) { + distances.push({ type, distance: edgeDistance(edge) }); + } + } + + return distances; + } + + /** Compute data for the violin visualization */ + private computeFilteredDistances(): DistanceEntry[] { + const selection = new Set(this.cellTypesSelection()); + if (selection.size === 0) { + return []; + } + + return this.distances().filter(({ type }) => selection.has(type)); + } + + /** Compute colors for the violin visualization */ + private computeFilteredColors(): string[] { + return this.filteredCellTypes() + .sort((a, b) => (a.name < b.name ? -1 : a.name === b.name ? 0 : 1)) + .map(({ color }) => rgbToHex(color)); + } + + /** Update the color of a specific cell type entry */ + updateColor(entry: CellTypeEntry, color: Rgb): void { + const entries = this.cellTypes(); + const index = entries.indexOf(entry); + const copy = [...entries]; + + copy[index] = { ...copy[index], color }; + this.cellTypes.set(copy); + } } diff --git a/libs/cde-visualization/src/lib/components/histogram/histogram.component.html b/libs/cde-visualization/src/lib/components/histogram/histogram.component.html index 7a203bd1b..220768bf5 100644 --- a/libs/cde-visualization/src/lib/components/histogram/histogram.component.html +++ b/libs/cde-visualization/src/lib/components/histogram/histogram.component.html @@ -58,13 +58,13 @@ (colorPickerOpen)="colorPicker.set($event)" > - @for (type of filteredCellTypes(); track type.name) { + @for (entry of filteredCellTypes(); track entry.name) { } diff --git a/libs/cde-visualization/src/lib/components/histogram/histogram.component.spec.ts b/libs/cde-visualization/src/lib/components/histogram/histogram.component.spec.ts index a5b8312a3..e17024cd7 100644 --- a/libs/cde-visualization/src/lib/components/histogram/histogram.component.spec.ts +++ b/libs/cde-visualization/src/lib/components/histogram/histogram.component.spec.ts @@ -1,13 +1,12 @@ import { WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Rgb } from '@hra-ui/design-system/color-picker'; import { provideScrolling } from '@hra-ui/design-system/scrolling'; -import { RenderComponentOptions, render, screen } from '@testing-library/angular'; +import { render, RenderComponentOptions, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; import { mockClear, mockDeep } from 'jest-mock-extended'; import embed, { Result } from 'vega-embed'; -import { CellTypeEntry } from '../../models/cell-type'; -import { Rgb } from '@hra-ui/design-system/color-picker'; -import { EdgeEntry } from '../../models/edge'; + import { DEFAULT_NODE_TARGET_KEY, NodeEntry } from '../../models/node'; import { FileSaverService } from '../../services/file-saver/file-saver.service'; import { HistogramComponent } from './histogram.component'; @@ -21,17 +20,16 @@ describe('HistogramComponent', () => { } const sampleNodes = [createNodeEntry('a', 0, 0), createNodeEntry('b', 0, 2), createNodeEntry('c', 0, 4)]; - const sampleEdges: EdgeEntry[] = [ - [0, 0, 0, 3, 4, 5, 6], - [1, 0, 2, 3, 4, 5, 6], - [2, 0, 4, 3, 4, 5, 6], - ]; - const sampleCellTypes: CellTypeEntry[] = [ - { name: 'a', count: 2, color: [0, 0, 0], outgoingEdgeCount: 0 }, - { name: 'b', count: 4, color: [0, 1, 2], outgoingEdgeCount: 0 }, - { name: 'c', count: 6, color: [0, 1, 3], outgoingEdgeCount: 0 }, + const sampleData = [ + { + type: 'A', + distance: 5, + }, + { + type: 'B', + distance: 7, + }, ]; - const sampleCellTypesSelection: string[] = [sampleCellTypes[0].name, sampleCellTypes[1].name]; const embedResult = mockDeep(); @@ -61,12 +59,10 @@ describe('HistogramComponent', () => { it('should render the histogram using vega', async () => { const { fixture } = await setup({ componentInputs: { - nodes: sampleNodes, - nodeTargetKey, - edges: sampleEdges, + data: sampleData, + colors: [], + filteredCellTypes: [], selectedCellType: sampleNodes[0][nodeTargetKey], - cellTypes: sampleCellTypes, - cellTypesSelection: sampleCellTypesSelection, }, }); await fixture.whenStable(); @@ -75,15 +71,13 @@ describe('HistogramComponent', () => { expect(embed).toHaveBeenCalledWith(container, expect.anything(), expect.anything()); }); - it('should set empty data when nodes or edges are empty', async () => { + it('should set empty data when input data is empty', async () => { const { fixture } = await setup({ componentInputs: { - nodes: [], - nodeTargetKey, - edges: [], + data: [], + colors: [], + filteredCellTypes: [], selectedCellType: sampleNodes[0][nodeTargetKey], - cellTypes: sampleCellTypes, - cellTypesSelection: sampleCellTypesSelection, }, }); await fixture.whenStable(); @@ -94,12 +88,10 @@ describe('HistogramComponent', () => { it('should download in the specified format', async () => { await setup({ componentInputs: { - nodes: [], - nodeTargetKey, - edges: [], + data: [], + colors: [], + filteredCellTypes: [], selectedCellType: '', - cellTypes: [], - cellTypesSelection: [], }, }); @@ -115,35 +107,15 @@ describe('HistogramComponent', () => { expect(fileSaveSpy).toHaveBeenCalledWith(imageUrl, 'cde-histogram.svg'); }); - it('should updateColor', async () => { - const { - fixture: { componentInstance: instance }, - } = await setup({ - componentInputs: { - nodes: [], - nodeTargetKey: 'key', - edges: [], - selectedCellType: 'type', - cellTypes: sampleCellTypes, - cellTypesSelection: sampleCellTypesSelection, - }, - }); - - instance.updateColor(sampleCellTypes[0], [255, 255, 255]); - expect(instance.cellTypes()[0].color).toEqual([255, 255, 255]); - }); - it('should reset all cell colors', async () => { const { fixture: { componentInstance: instance }, } = await setup({ componentInputs: { - nodes: [], - nodeTargetKey, - edges: [], + data: [], + colors: [], + filteredCellTypes: [], selectedCellType: '', - cellTypes: [], - cellTypesSelection: [], }, }); diff --git a/libs/cde-visualization/src/lib/components/histogram/histogram.component.ts b/libs/cde-visualization/src/lib/components/histogram/histogram.component.ts index 96b9e8349..587e6910e 100644 --- a/libs/cde-visualization/src/lib/components/histogram/histogram.component.ts +++ b/libs/cde-visualization/src/lib/components/histogram/histogram.component.ts @@ -3,13 +3,13 @@ import { CommonModule, DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, - ElementRef, - Renderer2, computed, effect, + ElementRef, inject, input, - model, + output, + Renderer2, signal, viewChild, } from '@angular/core'; @@ -20,28 +20,23 @@ import { MatExpansionPanelDefaultOptions, } from '@angular/material/expansion'; import { MatIconModule } from '@angular/material/icon'; +import { colorEquals, Rgb } from '@hra-ui/design-system/color-picker'; +import { ScrollingModule } from '@hra-ui/design-system/scrolling'; import { produce } from 'immer'; import { ColorPickerDirective, ColorPickerModule } from 'ngx-color-picker'; import { View } from 'vega'; import embed, { VisualizationSpec } from 'vega-embed'; + +import { DistanceEntry } from '../../cde-visualization/cde-visualization.component'; import { CellTypeEntry } from '../../models/cell-type'; -import { Rgb, colorEquals, rgbToHex } from '@hra-ui/design-system/color-picker'; -import { EdgeEntry, EdgeIndex, edgeDistance } from '../../models/edge'; -import { NodeEntry, NodeTargetKey } from '../../models/node'; import { FileSaverService } from '../../services/file-saver/file-saver.service'; -import { emptyArrayEquals } from '../../shared/empty-array-equals'; - -import { ScrollingModule } from '@hra-ui/design-system/scrolling'; import { TOOLTIP_POSITION_RIGHT_SIDE } from '../../shared/tooltip-position'; import { ColorPickerLabelComponent } from '../color-picker-label/color-picker-label.component'; import * as HISTOGRAM_SPEC from './histogram.vl.json'; -/** Interface for representing the distance entry */ -interface DistanceEntry { - /** Type of the entry */ - type: string; - /** Distance value of the entry */ - distance: number; +interface UpdateColorData { + entry: CellTypeEntry; + color: Rgb; } /** Interface for modifying the histogram specification */ @@ -142,24 +137,17 @@ const DYNAMIC_COLOR_RANGE = Array(DYNAMIC_COLOR_RANGE_LENGTH) changeDetection: ChangeDetectionStrategy.OnPush, }) export class HistogramComponent { - /** List of nodes used in the histogram */ - readonly nodes = input.required(); + /** Data for the violin visualization */ + readonly data = input.required(); - /** Key used to target specific node properties */ - readonly nodeTargetKey = input.required(); + /** Colors for the violin visualization */ + readonly colors = input.required(); - /** List of edges connecting nodes */ - readonly edges = input.required(); + readonly filteredCellTypes = input.required(); /** Currently selected cell type */ readonly selectedCellType = input.required(); - /** List of all cell types */ - readonly cellTypes = model.required(); - - /** List of selected cell types */ - readonly cellTypesSelection = input.required(); - /** Tooltip position configuration */ readonly tooltipPosition = TOOLTIP_POSITION_RIGHT_SIDE; @@ -171,32 +159,12 @@ export class HistogramComponent { /** State indicating whether overflow is visible */ protected readonly overflowVisible = computed(() => !!this.colorPicker()); - /** List of filtered cell types based on selection */ - protected readonly filteredCellTypes = computed( - () => { - const selection = new Set(this.cellTypesSelection()); - selection.delete(this.selectedCellType()); - const filtered = this.cellTypes().filter(({ name }) => selection.has(name)); - return filtered.sort((a, b) => b.count - a.count); - }, - { equal: emptyArrayEquals }, - ); - /** Label for the total cell type */ protected readonly totalCellTypeLabel = ALL_CELLS_TYPE; /** Color for the total cell type */ protected readonly totalCellTypeColor = signal([0, 0, 0], { equal: colorEquals }); - /** Computed distances between nodes */ - private readonly distances = computed(() => this.computeDistances(), { equal: emptyArrayEquals }); - - /** Data for the histogram visualization */ - private readonly data = computed(() => this.computeData(), { equal: emptyArrayEquals }); - - /** Colors for the histogram visualization */ - private readonly colors = computed(() => this.computeColors(), { equal: emptyArrayEquals }); - /** Reference to the document object */ private readonly document = inject(DOCUMENT); @@ -212,6 +180,8 @@ export class HistogramComponent { /** Vega view instance for the histogram */ private readonly view = signal(undefined); + readonly updateColor = output(); + /** Effect for updating view data */ protected readonly viewDataRef = effect(() => this.view()?.data('data', this.data()).run()); @@ -261,64 +231,14 @@ export class HistogramComponent { finalize(); } - /** Update the color of a specific cell type entry */ - updateColor(entry: CellTypeEntry, color: Rgb): void { - const entries = this.cellTypes(); - const index = entries.indexOf(entry); - const copy = [...entries]; - - copy[index] = { ...copy[index], color }; - this.cellTypes.set(copy); - } - - /** Reset the color of the total cell type */ - resetAllCellsColor(): void { - this.totalCellTypeColor.set([0, 0, 0]); - } - /** Ensure required fonts are loaded for the histogram */ private async ensureFontsLoaded(): Promise { const loadPromises = HISTOGRAM_FONTS.map((font) => this.document.fonts.load(font)); await Promise.all(loadPromises); } - /** Compute distances between nodes based on edges */ - private computeDistances(): DistanceEntry[] { - const nodes = this.nodes(); - const edges = this.edges(); - if (nodes.length === 0 || edges.length === 0) { - return []; - } - - const nodeTargetKey = this.nodeTargetKey(); - const selectedCellType = this.selectedCellType(); - const distances: DistanceEntry[] = []; - for (const edge of edges) { - const sourceNode = nodes[edge[EdgeIndex.SourceNode]]; - const type = sourceNode[nodeTargetKey]; - if (type !== selectedCellType) { - distances.push({ type, distance: edgeDistance(edge) }); - } - } - - return distances; - } - - /** Compute data for the histogram visualization */ - private computeData(): DistanceEntry[] { - const selection = new Set(this.cellTypesSelection()); - if (selection.size === 0) { - return []; - } - - return this.distances().filter(({ type }) => selection.has(type)); - } - - /** Compute colors for the histogram visualization */ - private computeColors(): string[] { - const totalCellType = { name: this.totalCellTypeLabel, color: this.totalCellTypeColor() }; - return [totalCellType, ...this.filteredCellTypes()] - .sort((a, b) => (a.name < b.name ? -1 : a.name === b.name ? 0 : 1)) - .map(({ color }) => rgbToHex(color)); + /** Reset the color of the total cell type */ + resetAllCellsColor(): void { + this.totalCellTypeColor.set([0, 0, 0]); } } diff --git a/libs/cde-visualization/src/lib/components/histogram/histogram.vl.json b/libs/cde-visualization/src/lib/components/histogram/histogram.vl.json index 1cfba9d02..f491e15ad 100644 --- a/libs/cde-visualization/src/lib/components/histogram/histogram.vl.json +++ b/libs/cde-visualization/src/lib/components/histogram/histogram.vl.json @@ -23,15 +23,6 @@ "ticks": false, "domain": false, "labelPadding": 8 - }, - "legend": { - "titleFontSize": 14, - "titleLineHeight": 21, - "titleColor": "#201E3D", - "titleFontWeight": 500, - "labelFontSize": 14, - "labelFontWeight": 500, - "labelColor": "#4B4B5E" } }, "data": { diff --git a/libs/cde-visualization/src/lib/components/violin/violin.component.html b/libs/cde-visualization/src/lib/components/violin/violin.component.html new file mode 100644 index 000000000..d9b13f4cb --- /dev/null +++ b/libs/cde-visualization/src/lib/components/violin/violin.component.html @@ -0,0 +1,46 @@ + + Violin Graph + info + +
Violin plot
+
+
+ +
+ + +
+ + +
+
diff --git a/libs/cde-visualization/src/lib/components/violin/violin.component.scss b/libs/cde-visualization/src/lib/components/violin/violin.component.scss new file mode 100644 index 000000000..dab5c4020 --- /dev/null +++ b/libs/cde-visualization/src/lib/components/violin/violin.component.scss @@ -0,0 +1,80 @@ +@use 'sass:map'; +@use '@angular/material' as mat; +@use '../../../../../../apps/cde-ui/src/styles/constants/palettes' as palettes; + +$blue: mat.m2-define-palette(map.get(palettes.$palettes, 'blue'), 600, 500, 700); +$blue-theme: mat.m2-define-light-theme( + ( + color: ( + primary: $blue, + accent: $blue, + ), + ) +); + +:host { + @include mat.button-color($blue-theme); + display: grid; + grid: + 'violin . download' + 'plot plot plot' 1fr + / auto 1fr auto; + grid-template-rows: 2rem calc(100% - 2rem); + align-items: start; + row-gap: 0.75rem; + overflow: hidden; + padding: 0.75rem 0.5rem; + border-width: 0px 0px 2px 2px; + border-style: solid; + border-color: map.get(map.get(palettes.$palettes, 'blue'), 600); + $button-text-color: map.get(map.get(palettes.$palettes, 'blue'), 900); + --mdc-filled-button-container-height: 2rem; + + span.violin-label { + grid-area: violin; + font-size: 1rem; + height: 2rem; + display: flex; + align-items: center; + gap: 0.25rem; + padding-left: 0.5rem; + } + + .download { + grid-area: download; + padding-right: 0.5rem; + } + + .violin { + grid-area: plot; + height: 100%; + overflow-y: scroll; + } + + button { + --mdc-filled-button-label-text-color: #201e3d; + padding: 0.375rem 0.5rem; + margin-left: 0.5rem; + + mat-icon { + margin-left: unset; + margin-right: 0.125rem; + font-size: 1.25rem; + height: 1.25rem; + width: 1.25rem; + } + } +} + +::ng-deep { + #vg-tooltip-element.vg-tooltip { + font-family: 'Metropolis'; + font-weight: 500; + font-size: 0.75rem; + line-height: 1.125rem; + color: #201e3d; + padding: 0.5rem; + max-width: 22.5rem; + box-shadow: 0px 5px 16px 0px #201e3d3d; + } +} diff --git a/libs/cde-visualization/src/lib/components/violin/violin.component.ts b/libs/cde-visualization/src/lib/components/violin/violin.component.ts new file mode 100644 index 000000000..e076480e6 --- /dev/null +++ b/libs/cde-visualization/src/lib/components/violin/violin.component.ts @@ -0,0 +1,213 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + effect, + ElementRef, + inject, + input, + Renderer2, + signal, + viewChild, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { ScrollingModule } from '@hra-ui/design-system/scrolling'; +import { produce } from 'immer'; +import { View } from 'vega'; +import embed, { VisualizationSpec } from 'vega-embed'; + +import { DistanceEntry } from '../../cde-visualization/cde-visualization.component'; +import { FileSaverService } from '../../services/file-saver/file-saver.service'; +import { TOOLTIP_POSITION_RIGHT_SIDE } from '../../shared/tooltip-position'; +import { ColorPickerLabelComponent } from '../color-picker-label/color-picker-label.component'; +import * as VIOLIN_SPEC from './violin.vl.json'; + +/** Interface for modifying the violin specification */ +interface ModifiableViolinSpec { + /** Configuration for the padding */ + config: { + padding: { + top: number; + right: number; + bottom: number; + left: number; + }; + }; + + /** Data values for the violin */ + data: { + values?: unknown[]; + }; + /** Encoding configuration for the violin */ + spec: { + /** Width of the violin */ + width: string | number; + /** Height of the violin */ + height: string | number; + layer: { + encoding: { + color: { + legend?: unknown; + scale: { + range: unknown[]; + }; + value?: string; + }; + }; + }[]; + }; +} + +/** Fonts used in the violin */ +const VIOLIN_FONTS = ['12px Metropolis', '14px Metropolis']; + +/** Width of the exported image */ +const EXPORT_IMAGE_WIDTH = 1000; + +/** Height of the exported image */ +const EXPORT_IMAGE_HEIGHT = 40; + +/** Padding for the exported image */ +const EXPORT_IMAGE_PADDING = 16; + +/** Configuration for the legend in the exported image */ +const EXPORT_IMAGE_LEGEND_CONFIG = { + title: null, + symbolType: 'circle', + symbolStrokeWidth: 10, + labelFontSize: 14, + titleFontSize: 14, + titleLineHeight: 21, + titleColor: '#201E3D', + titleFontWeight: 500, + labelFontWeight: 500, + labelColor: '#4B4B5E', + symbolStrokeColor: 'type', + symbolSize: 400, +}; + +/** Length of the dynamic color range */ +const DYNAMIC_COLOR_RANGE_LENGTH = 2000; + +/** Dynamic color range for the violin */ +const DYNAMIC_COLOR_RANGE = Array(DYNAMIC_COLOR_RANGE_LENGTH) + .fill(0) + .map((_value, index) => ({ expr: `colors[${index}] || '#000'` })); + +/** + * Violin Component + */ +@Component({ + selector: 'cde-violin', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatExpansionModule, + ColorPickerLabelComponent, + OverlayModule, + ScrollingModule, + ], + templateUrl: './violin.component.html', + styleUrl: './violin.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViolinComponent { + /** Tooltip position configuration */ + readonly tooltipPosition = TOOLTIP_POSITION_RIGHT_SIDE; + + /** State indicating whether the info panel is open */ + infoOpen = false; + + /** Data for the violin visualization */ + readonly data = input.required(); + + /** Colors for the violin visualization */ + readonly colors = input.required(); + + /** Reference to the document object */ + private readonly document = inject(DOCUMENT); + + /** Reference to the renderer for DOM manipulation */ + private readonly renderer = inject(Renderer2); + + /** Service for saving files */ + private readonly fileSaver = inject(FileSaverService); + + /** Element reference for the violin container */ + private readonly violinEl = viewChild.required('violin'); + + /** Vega view instance for the violin */ + private readonly view = signal(undefined); + + /** Effect for updating view data */ + protected readonly viewDataRef = effect(() => this.view()?.data('data', this.data()).run()); + + /** Effect for updating view colors */ + protected readonly viewColorsRef = effect(() => { + this.view()?.signal('colors', this.colors()).run(); + this.view()?.resize(); + }); + + /** Effect for creating the Vega view */ + protected readonly viewCreateRef = effect( + async (onCleanup) => { + const el = this.violinEl().nativeElement; + await this.ensureFontsLoaded(); + + const spec = produce(VIOLIN_SPEC, (draft) => { + for (const layer of draft.spec.layer) { + if (layer.encoding.color.legend === null) { + layer.encoding.color.scale = { range: DYNAMIC_COLOR_RANGE }; + } + } + }); + + const { finalize, view } = await embed(el, spec as VisualizationSpec, { + actions: false, + }); + + onCleanup(finalize); + this.view.set(view); + }, + { allowSignalWrites: true }, + ); + + /** Download the violin as an image in the specified format */ + async download(format: string): Promise { + const spec = produce(VIOLIN_SPEC as ModifiableViolinSpec, (draft) => { + draft.spec.width = EXPORT_IMAGE_WIDTH; + for (const layer of draft.spec.layer) { + if (layer.encoding.color.legend === null) { + layer.encoding.color.legend = EXPORT_IMAGE_LEGEND_CONFIG; + layer.encoding.color.scale = { range: this.colors() }; + } + } + draft.spec.height = EXPORT_IMAGE_HEIGHT; + draft.config.padding.bottom = EXPORT_IMAGE_PADDING; + draft.config.padding.top = EXPORT_IMAGE_PADDING; + draft.config.padding.right = EXPORT_IMAGE_PADDING; + draft.config.padding.left = EXPORT_IMAGE_PADDING; + draft.data.values = this.data(); + }); + + const el = this.renderer.createElement('div'); + const { view, finalize } = await embed(el, spec as VisualizationSpec, { + actions: false, + }); + + const url = await view.toImageURL(format); + this.fileSaver.save(url, `cde-violin.${format}`); + finalize(); + } + + /** Ensure required fonts are loaded for the violin */ + private async ensureFontsLoaded(): Promise { + const loadPromises = VIOLIN_FONTS.map((font) => this.document.fonts.load(font)); + await Promise.all(loadPromises); + } +} diff --git a/libs/cde-visualization/src/lib/components/violin/violin.vl.json b/libs/cde-visualization/src/lib/components/violin/violin.vl.json new file mode 100644 index 000000000..bb78da376 --- /dev/null +++ b/libs/cde-visualization/src/lib/components/violin/violin.vl.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "spacing": -2, + "config": { + "font": "Metropolis", + "padding": { + "top": 0, + "right": 0, + "bottom": 0, + "left": 0 + }, + "view": { + "stroke": "transparent" + }, + "axis": { + "labelFontSize": 12, + "titleFontSize": 14, + "titleFontWeight": 500, + "labelFontWeight": 500, + "titleLineHeight": 21, + "labelLineHeight": 18, + "titleColor": "#201E3D", + "labelColor": "#4B4B5E", + "labelAngle": -45, + "ticks": false, + "labelPadding": 8 + }, + "scale": { "continuousPadding": 20 } + }, + "data": { + "name": "data" + }, + "params": [ + { + "name": "colors", + "value": [] + } + ], + "facet": { + "row": { + "field": "type", + "title": "Cell Types", + "header": { + "labelAngle": 0, + "labelPadding": 4, + "labelAlign": "left", + "labelLimit": 100, + "labelBaseline": "middle", + "titlePadding": 8, + "labelFontSize": 12, + "titleFontSize": 14, + "titleFontWeight": 500, + "labelFontWeight": 500 + } + } + }, + "resolve": { + "scale": { + "y": "independent" + } + }, + "spec": { + "height": 40, + "width": 300, + "layer": [ + { + "mark": { + "type": "area", + "stroke": "black", + "strokeWidth": 0.5 + }, + "transform": [ + { + "density": "distance", + "bandwidth": 0, + "groupby": ["type"] + } + ], + "encoding": { + "x": { + "field": "value", + "type": "quantitative", + "title": "Distance (µm)", + "axis": { + "minExtent": 25, + "labelFlush": false, + "grid": true, + "labelAngle": 0, + "labelOverlap": true, + "labelSeparation": 4 + }, + "scale": { + "domainMin": 0 + } + }, + "y": { + "field": "density", + "type": "quantitative", + "stack": "center", + "impute": null, + "title": null, + "axis": { + "labels": false, + "grid": false + }, + "scale": { + "nice": false, + "padding": 4 + } + }, + "color": { + "field": "type", + "legend": null, + "scale": { + "range": [ + { + "expr": "'Replaced/repeated in javascript' || color[0] || '#000'" + } + ] + } + }, + "tooltip": { + "field": "type", + "type": "nominal" + } + } + }, + { + "mark": { + "type": "boxplot", + "extent": "min-max", + "size": 4 + }, + "encoding": { + "x": { + "field": "distance", + "type": "quantitative", + "title": "Distance (µm)", + "scale": { + "domainMin": 0 + } + }, + "color": { + "value": "black" + } + } + } + ] + } +}