Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Violin improvements #2875

Merged
merged 7 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion client/plots/singleCellPlot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
21 changes: 13 additions & 8 deletions client/plots/violin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
170 changes: 84 additions & 86 deletions client/plots/violin.renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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 () {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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
// <g> of one plot
// adding .5 to plotIdx allows to anchor each plot <g> 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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down