diff --git a/client/plots/singleCellPlot.js b/client/plots/singleCellPlot.js index 615a03839c..2ef6611225 100644 --- a/client/plots/singleCellPlot.js +++ b/client/plots/singleCellPlot.js @@ -616,7 +616,6 @@ class singleCellPlot { plots: [ { chartType: 'violin', - settings: { violin: { plotThickness: 50 } }, term: { $id: await digestMessage(`${gene}-${this.state.config.sample}-${this.state.config.experimentID}`), term: { diff --git a/client/plots/violin.js b/client/plots/violin.js index 696e1da681..36498643fd 100644 --- a/client/plots/violin.js +++ b/client/plots/violin.js @@ -186,15 +186,25 @@ class ViolinPlot { }, { label: 'Plot thickness', - title: 'Thickness of plots, can be between 40 and 200', + title: 'If not specified, the plot thickness is calculated based on the number of categories', type: 'number', chartType: 'violin', settingsKey: 'plotThickness', step: 10, - max: 500, + max: 200, min: 40, debounceInterval: 1000 }, + { + label: 'Plot padding', + type: 'number', + chartType: 'violin', + settingsKey: 'rowSpace', + step: 1, + max: 20, + min: 0, + debounceInterval: 1000 + }, { label: 'Median length', title: 'Length of median', @@ -272,10 +282,6 @@ class ViolinPlot { const args = this.validateArgs() this.data = await this.app.vocabApi.getViolinPlotData(args) - if (this.settings.plotThickness == undefined) { - const thickness = this.data.plots.length == 1 ? 200 : 150 - this.settings.plotThickness = Math.min(1400 / this.data.plots.length, thickness) - } if (this.data.error) throw this.data.error /* .min @@ -382,8 +388,7 @@ export function getDefaultViolinSettings(app, overrides = {}) { rightMargin: 50, lines: [], unit: 'abs', // abs: absolute scale, log: log scale - rowSpace: 5, - plotThickness: undefined, + rowSpace: 10, medianLength: 7, medianThickness: 3, ticks: 20, diff --git a/client/plots/violin.renderer.js b/client/plots/violin.renderer.js index 8503b777c5..ab1a3d57d2 100644 --- a/client/plots/violin.renderer.js +++ b/client/plots/violin.renderer.js @@ -4,7 +4,7 @@ import { curveBasis, line } from 'd3-shape' import { getColors } from '#shared/common.js' import { brushX, brushY } from 'd3-brush' import { renderTable, Menu, getMaxLabelWidth, table2col } from '#dom' -import { rgb } from 'd3' +import { rgb, create } from 'd3' import { format as d3format } from 'd3-format' import { TermTypes } from '#shared/terms.js' @@ -57,11 +57,32 @@ export default function setViolinRenderer(self) { const svgData = renderSvg(t1, self, isH, settings) renderScale(t1, t2, settings, isH, svgData, self) - + let y = self.settings.rowSpace + const thickness = self.settings.plotThickness || self.getAutoThickness() for (const [plotIdx, plot] of self.data.plots.entries()) { - const violinG = createViolinG(svgData, plot, plotIdx, isH) + // The scale uses half of the plotThickness as the maximum value as the image is symmetrical + // Only one half of the image is computed and the other half is mirrored + const wScale = scaleLinear() + .domain([plot.density.densityMax, plot.density.densityMin]) + .range([thickness / 2, 0]) + let areaBuilder + //when doing this interpolation, the violin plot will be smoother and some padding may be added + //between the plot and the axis + if (isH) { + areaBuilder = line() + .curve(curveBasis) + .x(d => svgData.axisScale(d.x0)) + .y(d => wScale(d.density)) + } else { + areaBuilder = line() + .curve(curveBasis) + .x(d => wScale(d.density)) + .y(d => svgData.axisScale(d.x0)) + } + //if only one plot pass area builder to calculate the exact height of the plot + const { violinG, height } = renderViolinPlot(svgData, plot, isH, wScale, areaBuilder, y, imageOffset) + y += height if (self.opts.mode != 'minimal') renderLabels(t1, t2, violinG, plot, isH, settings, tip) - renderViolinPlot(plot, self, isH, svgData, plotIdx, violinG, imageOffset) if (self.config.term.term.type == TermTypes.SINGLECELL_GENE_EXPRESSION) { // is sc data, disable brushing for now because 1) no use 2) avoid bug of listing cells @@ -88,10 +109,15 @@ export default function setViolinRenderer(self) { td2.style('text-align', 'center').text(stat.value ?? 0) } } + self.getAutoThickness = function () { + if (self.data.plots.length === 1) return 150 + const count = self.data.plots.length + return Math.min(130, Math.max(60, 600 / count)) //clamp between 60 and 130 + } - self.getPlotThickness = function () { - //self.settings.plotThickness may be undefined if loading a state, because main is not executed - return (self.settings.plotThickness || 145) + self.settings.rowSpace + self.getPlotThicknessWithPadding = function () { + const plotThickness = self.settings.plotThickness || self.getAutoThickness() + return plotThickness + self.settings.rowSpace } self.renderPvalueTable = function () { @@ -138,7 +164,7 @@ export default function setViolinRenderer(self) { const rows = self.data.pvalues const isH = this.settings.orientation === 'horizontal' const maxHeight = isH - ? self.getPlotThickness() * this.data.plots.length + ? self.getPlotThicknessWithPadding() * this.data.plots.length + 10 //add axes height : this.settings.svgw + this.config.term.term.name.length renderTable({ rows, @@ -182,18 +208,15 @@ export default function setViolinRenderer(self) { ) const margin = createMargins(labelsize, settings, isH, self.opts.mode == 'minimal') - const plotThickness = self.getPlotThickness() + const plotThickness = self.getPlotThicknessWithPadding() + const width = + margin.left + margin.top + (isH ? settings.svgw : plotThickness * self.data.plots.length + t1.term.name.length) + const height = + margin.bottom + margin.top + (isH ? plotThickness * self.data.plots.length : settings.svgw + t1.term.name.length) + violinSvg - .attr( - 'width', - margin.left + margin.top + (isH ? settings.svgw : plotThickness * self.data.plots.length + t1.term.name.length) - ) - .attr( - 'height', - margin.bottom + - margin.top + - (isH ? plotThickness * self.data.plots.length : settings.svgw + t1.term.name.length) - ) + .attr('width', width) + .attr('height', height) .classed('sjpp-violin-plot', true) .attr('data-testid', 'sja_violin_plot') @@ -256,22 +279,51 @@ export default function setViolinRenderer(self) { } } - function createViolinG(svg, plot, plotIdx, isH) { + function renderViolinPlot(svgData, plot, isH, wScale, areaBuilder, y, imageOffset) { + const label = plot.label?.split(',')[0] + const catTerm = self.config.term.q.mode == 'discrete' ? self.config.term : self.config.term2 + const category = catTerm?.term.values ? Object.values(catTerm.term.values).find(o => o.label == label) : null + + const color = category?.color ? category.color : self.config.settings.violin.defaultColor + // : plot.divideTwBins + // ? plot.divideTwBins.color + // : self.config.term2 + // ? self.k2c(plotIdx) + // : self.config.settings.violin.defaultColor + if (!plot.color) plot.color = color + if (category && !category.color) category.color = color // of one plot // adding .5 to plotIdx allows to anchor each plot to the middle point + const svg = svgData.svgG + const violinG = svg.append('g').datum(plot).attr('class', 'sjpp-violinG') + renderArea(violinG, plot, areaBuilder) + //render symmetrical violin plot + renderArea(violinG, plot, isH ? areaBuilder.y(d => -wScale(d.density)) : areaBuilder.x(d => -wScale(d.density))) - const violinG = svg.svgG - .append('g') - .datum(plot) - .attr( - 'transform', - isH - ? 'translate(0,' + self.getPlotThickness() * (plotIdx + 0.5) + ')' - : 'translate(' + self.getPlotThickness() * (plotIdx + 0.5) + ',0)' - ) - .attr('class', 'sjpp-violinG') + renderSymbolImage(self, violinG, plot, isH, imageOffset) + if (self.opts.mode != 'minimal') renderMedian(violinG, isH, plot, svgData, self) + renderLines(violinG, isH, self.config.settings.violin.lines, svgData) + if (self.state.config.value) { + const value = svgData.axisScale(self.state.config.value) + const s = self.config.settings.violin + violinG + .append('line') + .style('stroke', 'black') + .style('stroke-width', s.medianThickness) + .attr('x1', 200) + .attr('x2', 200) + .attr('x1', isH ? value : -s.medianLength) + .attr('x2', isH ? value : s.medianLength) + .attr('y1', isH ? -s.medianLength : value) + .attr('y2', isH ? s.medianLength : value) + } + const rect = violinG.node().getBBox() + let height = isH ? rect.height : rect.width + height += self.settings.rowSpace + const translate = isH ? `translate(0, ${y + height / 2}) ` : `translate(${y + height / 2}, 0)` + violinG.attr('transform', translate) - return violinG + return { violinG, height } } function renderLabels(t1, t2, violinG, plot, isH, settings, tip) { @@ -305,60 +357,6 @@ export default function setViolinRenderer(self) { .attr('transform', isH ? null : 'rotate(-90)') } - function renderViolinPlot(plot, self, isH, svgData, plotIdx, violinG, imageOffset) { - const plotThickness = self.getPlotThickness() - // times 0.45 will leave out 10% as spacing between plots - const wScale = scaleLinear() - .domain([plot.density.densityMax, plot.density.densityMin]) - .range([plotThickness * 0.45, 0]) - let areaBuilder - if (isH) { - areaBuilder = line() - .curve(curveBasis) - .x(d => svgData.axisScale(d.x0)) - .y(d => wScale(d.density)) - } else { - areaBuilder = line() - .curve(curveBasis) - .x(d => wScale(d.density)) - .y(d => svgData.axisScale(d.x0)) - } - - const label = plot.label?.split(',')[0] - const catTerm = self.config.term.q.mode == 'discrete' ? self.config.term : self.config.term2 - const category = catTerm?.term.values ? Object.values(catTerm.term.values).find(o => o.label == label) : null - - const color = category?.color ? category.color : self.config.settings.violin.defaultColor - // : plot.divideTwBins - // ? plot.divideTwBins.color - // : self.config.term2 - // ? self.k2c(plotIdx) - // : self.config.settings.violin.defaultColor - if (!plot.color) plot.color = color - if (category && !category.color) category.color = color - - renderArea(violinG, plot, areaBuilder) - renderArea(violinG, plot, isH ? areaBuilder.y(d => -wScale(d.density)) : areaBuilder.x(d => -wScale(d.density))) - - renderSymbolImage(self, violinG, plot, isH, imageOffset) - if (self.opts.mode != 'minimal') renderMedian(violinG, isH, plot, svgData, self) - renderLines(violinG, isH, self.config.settings.violin.lines, svgData) - if (self.state.config.value) { - const value = svgData.axisScale(self.state.config.value) - const s = self.config.settings.violin - violinG - .append('line') - .style('stroke', 'black') - .style('stroke-width', s.medianThickness) - .attr('x1', 200) - .attr('x2', 200) - .attr('x1', isH ? value : -s.medianLength) - .attr('x2', isH ? value : s.medianLength) - .attr('y1', isH ? -s.medianLength : value) - .attr('y2', isH ? s.medianLength : value) - } - } - function renderArea(violinG, plot, areaBuilder) { if (plot.density.densityMax == 0) return violinG @@ -412,7 +410,7 @@ export default function setViolinRenderer(self) { function renderLines(violinG, isH, lines, svgData) { // render straight lines on plot - const plotThickness = self.settings.plotThickness || 150 //When loading plot from state plotThickness is not initialized + const plotThickness = self.settings.plotThickness violinG.selectAll('.sjpp-vp-line').remove() if (!lines?.length) return