Skip to content

Commit

Permalink
feat: color by another column (#28)
Browse files Browse the repository at this point in the history
* feat: color one column by another #27
* feat: add id_label as synonym to label
* feat: option to disable bar guides
* fix: make column group headers fit their text
* docs: fix returns doc
  • Loading branch information
mxposed authored Jan 19, 2025
1 parent aa2cf4f commit f78e5bd
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 44 deletions.
123 changes: 103 additions & 20 deletions src/columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -52,7 +94,7 @@ export class Column {
}

if (this.geom === undefined) {
if (type === 'number') {
if (this.numeric) {
this.geom = 'funkyrect';
} else {
this.geom = 'text';
Expand All @@ -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() {
Expand All @@ -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]);
});
};

Expand Down Expand Up @@ -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;
Expand Down
45 changes: 26 additions & 19 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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];
}
Expand All @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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')
Expand Down Expand Up @@ -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
*
Expand Down
Loading

0 comments on commit f78e5bd

Please sign in to comment.