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"
+ }
+ }
+ }
+ ]
+ }
+}