diff --git a/src/columns.js b/src/columns.js index 51ea684..2b6486d 100644 --- a/src/columns.js +++ b/src/columns.js @@ -2,19 +2,53 @@ import * as d3 from 'd3'; +import { rowToColData } from './input_util'; + /** * @typedef {Object} ColumnInfo * @description Information about a dataframe column and how to display it. - * @property {string} id - column id in the dataset. Required. + * @property {string} id - column id in the dataset. Required + * @property {string} id_color - id of the column that will determine the color for display + * @property {boolean} colorByRank - whether to color by rank per column instead of by value + * @property {string} label - id of the column that has the values to display as labels over + * the geoms + * @property {string} id_label - synonym for `label` + * @property {string} geom - type of the geom to display. Default is `funkyrect` for numerical data, + * and `text` for categorical data + * @property {Object} options - additional options for the column + * @property {string} options.palette - name of the palette to use for coloring the column. + * Synonym for `palette` + * @property {boolean} options.drawGuide - whether to draw a guide at maximum for the bar geom + * column + * @property {boolean} options.draw_outline - synonym for `options.drawGuide` */ /** * @class + * @property {string} id - column id in the dataset + * @property {boolean} numeric - whether the column is numeric, computed from the data. + * See {@link module:columns~isNumeric} for details. + * @property {boolean} categorical - whether the column is categorical, computed from the data + * @property {string} id_color - id of the column that will determine the color for display + * @property {boolean} colorByRank - whether to color by rank per column instead of by value + * @property {boolean} scaleColumn - whether to scale the column data to `[0, 1]` + * @property {string} label - id of the column that has the values to display as labels over the + * geoms + * @property {string} geom - type of the geom to display */ export class Column { - constructor(info, value) { + /** + * Initialize a column with checks, defaults, and stats calculation. + * + * @param {module:columns~ColumnInfo} info - column configuration + * @param {Array} data - array of data for the column + */ + constructor(info, data) { ({ id: this.id, + id_color: this.id_color, + colorByRank: this.colorByRank, + scaleColumn: this.scaleColumn, name: this.name, geom: this.geom, group: this.group, @@ -24,16 +58,24 @@ export class Column { overlay: this.overlay, options: this.options } = info); + this.data = data; + + // defaults + this.colorByRank = this.colorByRank || false; + this.label = this.label || info.id_label; - let type = typeof value; - // geom text is always categorical - if (isNumeric(value) && this.geom !== 'text') { - type = 'number'; + const value = data[0]; + // geoms text and pie are always categorical + if (isNumeric(value) && this.geom !== 'text' && this.geom !== 'pie') { this.numeric = true; this.categorical = false; + this.data = this.data.map(d => +d); } else { this.numeric = false; this.categorical = true; + // disable numerical options for categorical data + this.colorByRank = false; + this.scaleColumn = false; } if (this.name === undefined) { @@ -52,7 +94,7 @@ export class Column { } if (this.geom === undefined) { - if (type === 'number') { + if (this.numeric) { this.geom = 'funkyrect'; } else { this.geom = 'text'; @@ -77,21 +119,46 @@ export class Column { if (this.geom === 'image' && this.width === undefined) { throw `Please, specify width for column with geom=image`; } + if (this.geom === 'bar' && this.options.draw_outline !== undefined) { + this.options.drawGuide = this.options.draw_outline; + } this.sortState = null; + if (this.numeric) { + this.maybeCalculateStats(); + } } - maybeCalculateStats(data, scaleColumn, colorByRank) { + maybeCalculateStats() { let extent = [0, 1]; - if (scaleColumn) { - extent = d3.extent(data, i => +i[this.id]); + if (this.scaleColumn) { + extent = d3.extent(this.data); } [this.min, this.max] = extent; this.range = this.max - this.min; this.scale = d3.scaleLinear().domain(extent); - if (colorByRank) { - this.colorScale = d3.scaleLinear().domain([0, data.length - 1]); + if (this.colorByRank) { + this.rankedData = d3.rank(this.data); + this.colorScale = d3.scaleLinear().domain([0, this.data.length - 1]); + } + } + + /** + * Get value for coloring the item. + * + * @param {Object} item - data item with our column + * @param {number} itemPos - data item position in the dataframe. Needed for getting the rank + * with ties. + * @returns {number} - value for coloring the item + */ + getColorValue(item, itemPos) { + if (this.id_color !== undefined) { + return item[this.id_color]; } + if (this.colorByRank) { + return this.rankedData[itemPos]; + } + return item[this.id]; } sort() { @@ -108,26 +175,36 @@ export class Column { * Assemble all column information needed for drawing * * @param {RowData} data - dataset - * @param {module:columns~ColumnInfo[]} columnInfo - properties of the columns for drawing, which we will modify - * @param {boolean} scaleColumn - whether to min-max scale data per column - * @param {boolean} colorByRank - whether to color by rank per column instead of by value + * @param {module:columns~ColumnInfo[]} columnInfo - properties of the columns for drawing, which + * will by modified in place + * @param {boolean} scaleColumn - whether to min-max scale data for column, default for all columns + * @param {boolean} colorByRank - whether to color by rank instead of by value, default for all + * columns */ export function buildColumnInfo(data, columnInfo, scaleColumn, colorByRank) { - const item = data[0]; + const colData = rowToColData(data); if (columnInfo === undefined || columnInfo.length === 0) { console.info("No column info specified, assuming all columns are to be displayed."); - columnInfo = Object.getOwnPropertyNames(item).map(id => { + columnInfo = Object.getOwnPropertyNames(colData).map(id => { return {id: id} }); } + if (colorByRank) { + columnInfo.forEach(info => { + info.colorByRank === undefined && (info.colorByRank = true); + }); + } + if (scaleColumn) { + columnInfo.forEach(info => { + info.scaleColumn === undefined && (info.scaleColumn = true); + }); + } return columnInfo.map(info => { let column = info.id; if (column === undefined) { throw "Column info must have id field corresponding to the column in the data"; } - column = new Column(info, item[column]); - column.maybeCalculateStats(data, scaleColumn, colorByRank); - return column; + return new Column(info, colData[column]); }); }; @@ -185,6 +262,12 @@ export function buildColumnGroups(columnGroups, columnInfo) { return columnGroups; }; +/** + * Test if a value is a number, including strings that can be coerced to a number. + * + * @param {*} str - value to test + * @returns {boolean} - if the value is a number + */ function isNumeric(str) { if (typeof str === 'number') return true; if (typeof str !== 'string') return false; diff --git a/src/main.js b/src/main.js index 239e1ad..1c2ada8 100644 --- a/src/main.js +++ b/src/main.js @@ -34,6 +34,8 @@ import { GEOMS } from './geoms'; /** * @typedef {Object} HeatmapOptions + * @property {boolean} [colorByRank=false] - whether to color elements by rank, default for all numeric + * columns. */ const DEFAULT_OPTIONS = { legendFontSize: 12, @@ -262,10 +264,6 @@ class FunkyHeatmap { if (prevColGroup && column.group && prevColGroup !== column.group) { offset += 2 * P.padding; } - let rankedData; - if (O.colorByRank && column.numeric) { - rankedData = d3.rank(this.data, item => +item[column.id]); - } let rowGroup, nGroups = 0; this.data.forEach((item, j) => { let width = 0; @@ -292,14 +290,11 @@ class FunkyHeatmap { if (value === undefined || value === null || (isNaN(value) && column.numeric)) { return; } - let colorValue = value; + let colorValue = column.getColorValue(item, j); let label; if (column.numeric) { value = +value; } - if (O.colorByRank && column.numeric) { - colorValue = rankedData[j]; - } if (column.label) { label = item[column.label]; } @@ -314,8 +309,14 @@ class FunkyHeatmap { const g = d3.create('svg:g') .classed('fh-geom', true); g.append(() => el.classed('fh-geom', false).classed('fh-orig-geom', true).node()); + // By default place label in the center of the geom + let labelX = P.rowHeight / 2; + if (column.geom === 'bar') { + // Bars are of different widths, place label on the left + labelX = P.padding + P.geomPaddingX * 3; + } g.append('text') - .attr('x', P.rowHeight / 2) + .attr('x', labelX) .attr('y', P.rowHeight / 2) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') @@ -367,7 +368,7 @@ class FunkyHeatmap { } } }); - if (column.geom === 'bar') { + if (column.geom === 'bar' && column.options.drawGuide !== false) { maxWidth = P.geomSize * column.width + P.geomPadding; this.body.append('line') .attr('x1', offset + maxWidth) @@ -408,14 +409,13 @@ class FunkyHeatmap { const column = new Column({ id: '_group', palette: groupInfo.palette - }, 1); - column.maybeCalculateStats(null, false); + }, [1]); assignPalettes([column], this.palettes); const lastCol = group[group.length - 1]; const groupStart = group[0].offset; const groupEnd = lastCol.offset + lastCol.widthPx + P.geomPadding; const fill = column.palette == 'none' && 'transparent' || column.palette(0.5); - groups.append('rect') + const rect = groups.append('rect') .attr('x', groupStart) .attr('y', 0) .attr('width', groupEnd - groupStart) @@ -432,6 +432,12 @@ class FunkyHeatmap { if (O.fontSize) { text.attr('font-size', O.fontSize); } + const { width } = text.node().getBBox(); + if (width + 2 * P.padding > groupEnd - groupStart) { + const diff = width + 2 * P.padding - (groupEnd - groupStart); + rect.attr('width', width + 2 * P.padding); + rect.attr('x', groupStart - diff / 2); + } if (O.labelGroupsAbc) { const letter = String.fromCharCode("a".charCodeAt(0) + abcCounter); const text = groups.append('text') @@ -892,19 +898,20 @@ class FunkyHeatmap { * The main entry point for the library. Takes data and various configuration options and returns * an SVG element with the heatmap. * - * @param {ColumnData|RowData} data - data to plot, usually d3-fetch output. + * @param {ColumnData|RowData} data - data to plot, usually d3-fetch output * @param {ColumnData|module:columns~ColumnInfo[]} columnInfo - information about how the columns * should be displayed. If not specified, all columns from `data` will be displayed. - * See {@link module:columns~ColumnInfo}, {@link module:columns.Column}. + * See {@link module:columns~ColumnInfo}, {@link module:columns.Column} * @param {ColumnData|RowData} rowInfo - information about how the rows should be displayed * @param {ColumnData|RowData} columnGroups - information about how to group columns * @param {ColumnData|RowData} rowGroups - information about how to group rows - * @param {Object} palettes - mapping of names to palette colors, see {@link module:palettes.assignPalettes} + * @param {Object} palettes - mapping of names to palette colors, see + * {@link module:palettes.assignPalettes} * @param {ColumnData|RowData} legends - a list of legends to add to the plot * @param {Object} positionArgs - positioning arguments, see {@link PositionArgs} - * @param {Object} options - options for the heatmap, see {@link HeatmapOptions} - * @param {boolean} scaleColumn - whether to apply min-max scaling to numerical - * columns. Defaults to true + * @param {HeatmapOptions} options - options for the heatmap, see {@link HeatmapOptions} + * @param {boolean} [scaleColumn=true] - whether to apply min-max scaling to numerical + * columns. Defaults to true * * @returns {SVGElement} - the SVG element containing the heatmap * diff --git a/tests/columns.test.js b/tests/columns.test.js index 3c90b21..0c04377 100644 --- a/tests/columns.test.js +++ b/tests/columns.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; -import { buildColumnInfo } from '../src/columns'; +import { buildColumnInfo, Column } from '../src/columns'; -describe('funkyheatmap', function() { +describe('buildColumnInfo', function() { it('should work with just data parameter', function() { const data = [{a: 1, b: 4}, {a: 2, b: 5}, {a: 3, b: 6}]; const result = buildColumnInfo(data); @@ -13,4 +13,73 @@ describe('funkyheatmap', function() { const data = [{a: 1, b: 4}, {a: 2, b: 5}, {a: 3, b: 6}]; assert.throws(() => buildColumnInfo(data, [{name: 'a'}])); }); + + it('should create columns with data', function() { + const data = [{a: 1, b: 4}, {a: 2, b: 5}, {a: 3, b: 6}]; + const result = buildColumnInfo(data); + assert.equal(result.length, 2); + assert.equal(result[0].numeric, true); + assert.equal(result[0].data.length, 3); + assert.equal(result[1].numeric, true); + assert.equal(result[1].data.length, 3); + assert.equal(result[0].data[0], 1); + assert.equal(result[1].data[0], 4); + }); + + it('should pass colorByRank to all columns, but with overrides', function() { + const data = [{a: 1, b: 4}, {a: 2, b: 5}, {a: 3, b: 6}]; + const result = buildColumnInfo(data, [{id: 'a'}, {id: 'b', colorByRank: false}], true, true); + assert.equal(result[0].colorByRank, true); + assert.equal(result[1].colorByRank, false); + }); + + it('should pass scaleColumn to all columns, but with overrides', function() { + const data = [{a: 1, b: 4}, {a: 2, b: 5}, {a: 3, b: 6}]; + const result = buildColumnInfo(data, [{id: 'a'}, {id: 'b', scaleColumn: false}], true, true); + assert.equal(result[0].scaleColumn, true); + assert.equal(result[1].scaleColumn, false); + }); +}); + +describe('column class', function() { + it('should be constructed with column info and data array', function() { + const info = {id: 'a'}; + const data = [1, 2, 3]; + const column = new Column(info, data); + assert.equal(column.id, 'a'); + assert.equal(column.numeric, true); + assert.equal(column.data.length, 3); + assert.equal(column.colorByRank, false); + }); + it('should return colorValue simple', function() { + const info = {id: 'a'}; + const data = [1, 2, 3]; + const column = new Column(info, data); + assert.equal(column.getColorValue({'a': 2}), 2); + }); + it('should return colorValue with colorByRank', function() { + const info = {id: 'a', colorByRank: true}; + const data = [5, 2, 1]; + const column = new Column(info, data); + column.maybeCalculateStats(true); + assert.equal(column.getColorValue({'a': 5}, 0), 2); + }); + it('should return colorValue with id_color option', function() { + const info = {id: 'a', id_color: 'b'}; + const data = [5, 2, 1]; + const column = new Column(info, data); + assert.equal(column.getColorValue({'a': 5, 'b': 10}, 0), 10); + }); + it('should not have numerical transformation options for categorical data', function() { + let info = {id: 'a', geom: 'text'}; + const data = [[5, 1], [2, 3], [1, 2]]; + let column = new Column(info, data); + assert.equal(column.colorByRank, false); + assert.equal(column.scaleColumn, false); + + info = {id: 'a', geom: 'pie'}; + column = new Column(info, data); + assert.equal(column.colorByRank, false); + assert.equal(column.scaleColumn, false); + }); }); diff --git a/vignettes/scIB.js b/vignettes/scIB.js index 34b6da8..7717469 100644 --- a/vignettes/scIB.js +++ b/vignettes/scIB.js @@ -49,7 +49,7 @@ function prepareData(data) { function labelTop3(vector) { // d3.rank behaves like R `rank` with `ties.method = "min"` - let ranks = d3.rank(vector); + let ranks = d3.rank(vector.map(i => +i)); return ranks.map(rank => { if (rank < 3) { return (rank + 1).toString(); @@ -60,8 +60,8 @@ function prepareData(data) { for (let [rank, label] of Object.entries(RANKS)) { data[label] = labelTop3(data[rank]); - const [min, max] = d3.extent(data[rank]); - data[rank] = data[rank].map(x => (-x + min) / (min - max)); + const [min, max] = d3.extent(data[rank].map(i => +i)); + data[rank] = data[rank].map(x => 1 - (x - min) / (max - min)); } return data;