diff --git a/.gitignore b/.gitignore index 823c11c..c6dc8fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ TODO* +docs/api.md node_modules/ dist/ diff --git a/README.md b/README.md index 891f3a6..6829e87 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Given this [table of sales](docs/sales.json ":ignore"): [![Sales dataset screenshot](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/sales-data.webp)](docs/sales.json ":ignore") -... we can render the following Sankey diagram: +... we can render the following clickable Sankey diagram: -[![Sales Sankey diagram](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/sales.webp)](docs/sales.html ":include") +[![Sales Sankey diagram](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/sales.webp)](docs/sales.html ":include height=320px") -[Here is the source code for the network above](docs/sales.html ":include :type=code") +[Here is the source code for the diagram above](docs/sales.html ":include :type=code") ## Installation @@ -56,6 +56,145 @@ Use via CDN as a script: ``` +## Use a DataFrame + +The `sankey()` function accepts an array of objects, like this: + +```json +[ + { "channel": "Online", "city": "Tokyo", "product": "Biscuit", "sales": 866.1, "prev": 1186.4 }, + { "channel": "Online", "city": "Tokyo", "product": "Cake", "sales": 26.4, "prev": 34.8 }, + { "channel": "Online", "city": "Tokyo", "product": "Cream", "sales": 38.3, "prev": 54.0 } + // ... +] +``` + +Pick any _categorical_ columns and use them as categories, like this: + +```js +const graph = sankey("#sankey", { data, categories: ["channel", "city", "product"], d3 }); +``` + +You can modify the labels with a `categories` object, as `{ "label": "key" }`: + +```js +const graph = sankey("#sankey", { data, categories: { Channel: "channel", City: "city", Product: "product" }, d3 }); +``` + +The `categories` object can also have values as functions that return each box's label: + +```js +const graph = sankey("#sankey", { + data, + categories: { Channel: (d) => d.channel, City: "city", Product: (d) => `${d.product} (${d.subProduct})` }, + d3, +}); +``` + +[![Example](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/simple.png)](docs/simple.html ":include height=320px") + +[See how to use sankey()](docs/simple.html ":include :type=code") + +## Style the graph + +Sankey accepts the following inputs options: + +- `labelWidth`: Width of the category labels on the left +- `gap`: Vertical gap between boxes as a % of the box height. 0.5 (default) means half the space is used by the gaps. +- `size`: Box width key or function. Defaults to `d => 1`, i.e. each row counts as 1. Use `"sales"` or `d => d.sales` to use a `sales` column as the box size. +- `text`: Box label key or function. Defaults to `"key"`. Use `d => d.key.toUpperCase()` to display the category key in uppercase. + +The returned `graph` object has these D3 joins: + +- `nodes`: The `` boxes across all rows. The joined data object has these keys: + - `cat`: The category name. From keys of `categories` + - `key`: The box label. The value in the category + - `group`: Data rows for this box + - `size`: Box size. Sum of the `size` accessor on `group` + - `range`: Box X position as [start, end] with values between 0 and 1 + - `width`: Box width in pixels when rendered +- `links`: The `` links connecting the boxes. The joined data object has these keys: + - `source`: Source node object. + - `target`: Target node object. + - `group`: Data rows for this link, for this source AND target combination + - `size`: Link size. Sum of the `size` accessor on `group` + - `sourceRange`: Link top X position as [start, end] with values between 0 and 1 + - `targetRange`: Link bottom Xposition as [start, end] with values between 0 and 1 +- `texts`: The `` labels on top of the boxes + - `text`: The text label +- `labels`: The `` category names on the left + - `label`: The category name +- `nodeData`: Array of node data (entries have the same values as the `nodes` join) +- `linkData`: Array of link data (entries have the same values as the `links` join) + +You can apply the D3 [`.attr`](https://github.com/d3/d3-selection#selection_attr), +[`.classed`](https://github.com/d3/d3-selection#selection_classed), +[`.style`](https://github.com/d3/d3-selection#selection_style), +[`.text`](https://github.com/d3/d3-selection#selection_text), +and any other [selection methods](https://github.com/d3/d3-selection) to style the elements. + +You can use any node/link keys in the styles. For example: + +```js +const graph = sankey("#sankey", data, { categories: ["channel", "city", "product"] }); +graph.nodes.attr("fill", d3.scaleOrdinal(d3.schemeCategory10)); +graph.links.attr("fill", "rgba(0,0,0,0.2)"); +``` + +The generated SVG has the following structure: + +```html + + + + + + + + + + + + + + + +``` + +[![Example](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/style.png)](docs/style.html ":include height=320px") + +[See how to style nodes and links](docs/style.html ":include :type=code") + +## Add tooltips + +You can use [Bootstrap tooltips](https://getbootstrap.com/docs/5.3/components/tooltips/). + +1. Add a `data-bs-toggle="tooltip" title="..."` attribute to each feature using `update` +2. Call `new bootstrap.Tooltip(element, {selector: '[data-bs-toggle="tooltip"]'})` to initialize tooltips + +[![Example](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/tooltip.png)](docs/tooltip.html ":include height=320px") + +[See how to add tooltips](docs/tooltip.html ":include :type=code") + +## Interactions + +- Clicking on a node highlights all links connected to it by adding the `.show` class to the links. +- If a node _already_ has all links highlighted, clicking on it hides them by removing the `.show` class from the links. + +You can style the `.show` class to make the links more or less visible. For example: + +```css +.link { + opacity: 0.05; +} +.link.show { + opacity: 1; +} +``` + +[![Sales Sankey diagram](https://raw.githubusercontent.com/gramener/gramex-sankey/main/docs/sales.webp)](docs/sales.html ":include height=320px") + +[Here is the source code for the diagram above](docs/sales.html ":include :type=code") ## API @@ -65,6 +204,8 @@ Use via CDN as a script: - 1.1.0: 28 Oct 2024. - Clicking a node first shows all nodes if any are missing. (Earlier it would HIDE if any were present.) + - Allow categories to be an array of keys or functions + - Refactor to remove margins - 1.0.0: 28 Oct 2024. Initial release ## Authors diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index e10c443..0000000 --- a/docs/api.md +++ /dev/null @@ -1,60 +0,0 @@ - - -## sankey - -Creates a network visualization. - -### Parameters - -- `el` **([string][1] | [HTMLElement][2])** The selector or HTML element for the SVG. -- `params` **[Object][3]** Parameters for the visualization. - - - `params.data` **[Array][4]** array of objects. - - `params.width` **[number][5]?** width of the SVG. - - `params.height` **[number][5]?** height of the SVG. - - `params.categories` **[Object][3]?** object of accessors for each category - - `params.size` **([string][1] | [Function][6])?** size accessor - - `params.text` **([string][1] | [Function][6])?** text accessor (defaults to "key") (optional, default `"key"`) - - `params.labelWidth` **[number][5]?** width of the label area - - `params.gap` **[number][5]?** total gap between categories as a percentage of the height - - `params.d3` **[Object][3]** D3 instance to use. (optional, default `window.d3`) - -Returns **[Graph][7]** Object containing D3.js selections for nodes and links. - -## Graph - -Define the returned graph - -Type: [Object][3] - -### Properties - -- `nodes` **[Object][3]** D3.js selection for nodes, allowing manipulation of node elements. -- `links` **[Object][3]** D3.js selection for links, enabling styling and interaction with link elements. -- `texts` **[Object][3]** D3.js selection for texts, used for updating or styling text labels on nodes. -- `labels` **[Object][3]** D3.js selection for labels, representing category names, useful for positioning and styling. -- `nodeData` **[Array][4]** Array of node data, each object contains: - - - `nodeData.cat` **[string][1]** Row name. From keys of `categories` - - `nodeData.key` **[string][1]** Box label. The value in the category - - `nodeData.group` **[Object][3]** Data rows for this box - - `nodeData.size` **[number][5]** Box size. Sum of the `size` accessor on `nodeData.group` - - `nodeData.range` **[Array][4]** Box X position as \[start, end] with values between 0 and 1 - - `nodeData.width` **[number][5]** Box width in pixels when rendered - -- `linkData` **[Array][4]** Array of link data, each object contains: - - - `linkData.source` **[Object][3]** Source node object. - - `linkData.target` **[Object][3]** Target node object. - - `linkData.group` **[Array][4]** Data rows for this link, for this source AND target combination - - `linkData.size` **[number][5]** Link size. Sum of the `size` accessor on `linkData.group` - - `linkData.sourceRange` **[Array][4]** Link top position as \[start, end] with values between 0 and 1 - - `linkData.targetRange` **[Array][4]** Link bottom position as \[start, end] with values between 0 and 1 - -[1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[2]: https://developer.mozilla.org/docs/Web/HTML/Element -[3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object -[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array -[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number -[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function -[7]: #graph diff --git a/docs/sales.html b/docs/sales.html index 10fced9..80e7c9d 100644 --- a/docs/sales.html +++ b/docs/sales.html @@ -1,4 +1,17 @@ - + + + diff --git a/docs/simple.webp b/docs/simple.webp new file mode 100644 index 0000000..9969190 Binary files /dev/null and b/docs/simple.webp differ diff --git a/docs/style.html b/docs/style.html new file mode 100644 index 0000000..5c4146e --- /dev/null +++ b/docs/style.html @@ -0,0 +1,15 @@ + + + diff --git a/docs/style.webp b/docs/style.webp new file mode 100644 index 0000000..e8ed858 Binary files /dev/null and b/docs/style.webp differ diff --git a/docs/tooltip.html b/docs/tooltip.html new file mode 100644 index 0000000..abe7f24 --- /dev/null +++ b/docs/tooltip.html @@ -0,0 +1,23 @@ + + + + + + diff --git a/docs/tooltip.webp b/docs/tooltip.webp new file mode 100644 index 0000000..40d608f Binary files /dev/null and b/docs/tooltip.webp differ diff --git a/index.html b/index.html index b93d281..a221d14 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ Sankey + diff --git a/package-lock.json b/package-lock.json index 33a27da..eb3264a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gramex/sankey", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gramex/sankey", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@gramex/chartbase": "^1.0.2" diff --git a/package.json b/package.json index 32ec7ae..baeb367 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gramex/sankey", - "version": "1.0.0", + "version": "1.1.0", "description": "A Sankey (or flow) diagram for graph visualization.", "module": "dist/sankey.js", "main": "dist/sankey.min.js", diff --git a/sankey.js b/sankey.js index b3e67f9..68bc05e 100644 --- a/sankey.js +++ b/sankey.js @@ -8,7 +8,7 @@ import { layer, getSVG } from "@gramex/chartbase"; * @param {Array} params.data - array of objects. * @param {number} [params.width] - width of the SVG. * @param {number} [params.height] - height of the SVG. - * @param {Object} [params.categories] - object of accessors for each category + * @param {Object|Array} [params.categories] - object of accessors for each category, or array of category names * @param {string|Function} [params.size] - size accessor * @param {string|Function} [params.text] - text accessor (defaults to "key") * @param {number} [params.labelWidth] - width of the label area @@ -24,20 +24,30 @@ export function sankey( ({ el, width, height } = getSVG(el._groups ? el.node() : el, width, height)); const svg = d3.select(el); - const margin = { top: 0, right: 0, bottom: 0, left: 0 }; - const innerWidth = width - labelWidth - margin.left - margin.right; - const innerHeight = height - margin.top - margin.bottom; + labelWidth = labelWidth ?? Math.min(width / 10, 100); + const innerWidth = width - labelWidth; const sizeAccessor = typeof size === "function" ? size : (d) => d[size]; + categories = Array.isArray(categories) ? Object.fromEntries(categories.map((cat) => [cat, cat])) : categories; const categoryList = Object.entries(categories); // Set categoryHeight based on gap - const categoryHeight = (innerHeight * (1 - (gap ?? 0.5))) / categoryList.length; + gap = gap ?? 0.5; // Create layers - const labelGroup = layer(svg, "g", "labels").attr("transform", `translate(${margin.left},${margin.top})`); + const labelGroup = layer(svg, "g", "labels"); const linkGroup = layer(svg, "g", "links"); - const nodeGroup = layer(svg, "g", "nodes").attr("transform", `translate(${margin.left + labelWidth},${margin.top})`); + const nodeGroup = layer(svg, "g", "nodes").attr("transform", `translate(${labelWidth},0)`); + + // Set up scales + const yScale = d3 + .scaleBand() + .domain(categoryList.map(([cat]) => cat)) + .range([0, height]) + .paddingInner(gap); + const yScaleBandwidth = yScale.bandwidth(); + + const xScale = d3.scaleLinear().range([0, innerWidth]); // Process nodes data const totalSize = d3.sum(data, sizeAccessor); @@ -45,11 +55,11 @@ export function sankey( .map(([categoryName, category]) => { const accessor = typeof category === "function" ? category : (d) => d[category]; const group = d3.group(data, accessor); - let cumulative = 0; + let sizeSum = 0; return Array.from(group, ([key, group]) => { const size = d3.sum(group, sizeAccessor); - const range = [cumulative / totalSize, (cumulative + size) / totalSize]; - cumulative += size; + const range = [sizeSum / totalSize, (sizeSum + size) / totalSize]; + sizeSum += size; return { cat: categoryName, key, size, range, group }; }); }) @@ -67,55 +77,33 @@ export function sankey( const getTarget = typeof targetCategory === "function" ? targetCategory : (d) => d[targetCategory]; // Group by source const sourceGroups = d3.group(data, getSource, getTarget); - const sourceCumulative = {}; - const targetCumulative = {}; + const sourceSizeSum = {}; + const targetSizeSum = {}; for (const [sourceKey, targets] of sourceGroups) { const source = nodeMap.get(`${sourceCategoryName}-${sourceKey}`); for (const [targetKey, group] of targets) { const size = d3.sum(group, sizeAccessor); const target = nodeMap.get(`${targetCategoryName}-${targetKey}`); - sourceCumulative[sourceKey] = sourceCumulative[sourceKey] ?? 0; - const sourceRange = [ - sourceCumulative[sourceKey] / source.size, - (sourceCumulative[sourceKey] + size) / source.size, - ]; - sourceCumulative[sourceKey] += size; - targetCumulative[targetKey] = targetCumulative[targetKey] ?? 0; - const targetRange = [ - targetCumulative[targetKey] / target.size, - (targetCumulative[targetKey] + size) / target.size, - ]; - targetCumulative[targetKey] += size; - linkData.push({ - source, - target, - size, - sourceRange, - targetRange, - group, - }); + sourceSizeSum[sourceKey] = sourceSizeSum[sourceKey] ?? 0; + const sourceRange = [sourceSizeSum[sourceKey] / source.size, (sourceSizeSum[sourceKey] + size) / source.size]; + sourceSizeSum[sourceKey] += size; + targetSizeSum[targetKey] = targetSizeSum[targetKey] ?? 0; + const targetRange = [targetSizeSum[targetKey] / target.size, (targetSizeSum[targetKey] + size) / target.size]; + targetSizeSum[targetKey] += size; + linkData.push({ source, target, size, sourceRange, targetRange, group }); } } } - // Set up scales - const yScale = d3 - .scalePoint() - .domain(categoryList.map(([cat]) => cat)) - .range([0, innerHeight]) - .padding(0.5); - - const xScale = d3.scaleLinear().range([0, innerWidth]); - // Add .width to nodeData. Helps determine whether to write text or not nodeData.forEach((d) => (d.width = xScale(d.range[1] - d.range[0]))); // Draw nodes const nodesLayer = layer(nodeGroup, "rect", "node", nodeData) .attr("x", (d) => xScale(d.range[0])) - .attr("y", (d) => yScale(d.cat) - categoryHeight / 2) + .attr("y", (d) => yScale(d.cat)) .attr("width", (d) => d.width) - .attr("height", categoryHeight) + .attr("height", yScaleBandwidth) .on("click.sankey", (_, d) => { const links = linkData.filter((link) => link.source === d || link.target === d); const show = !links.every((link) => linksLayer.filter((x) => x === link).classed("show")); @@ -126,7 +114,7 @@ export function sankey( const textAccessor = typeof text === "function" ? text : (d) => d[text]; const textLayer = layer(nodeGroup, "text", "text", nodeData) .attr("x", (d) => xScale((d.range[0] + d.range[1]) / 2)) - .attr("y", (d) => yScale(d.cat)) + .attr("y", (d) => yScale(d.cat) + yScaleBandwidth / 2) .attr("dy", "0.35em") .attr("text-anchor", "middle") .attr("pointer-events", "none") @@ -135,7 +123,7 @@ export function sankey( // Draw labels const labelsLayer = layer(labelGroup, "text", "label", categoryList) .attr("x", 0) - .attr("y", ([categoryName]) => yScale(categoryName)) + .attr("y", ([categoryName]) => yScale(categoryName) + yScaleBandwidth / 2) .attr("dy", "0.35em") .text(([categoryName]) => categoryName); @@ -148,8 +136,8 @@ export function sankey( const sourceX1 = xScale(d.source.range[0] + (d.source.range[1] - d.source.range[0]) * d.sourceRange[1]); const targetX0 = xScale(d.target.range[0] + (d.target.range[1] - d.target.range[0]) * d.targetRange[0]); const targetX1 = xScale(d.target.range[0] + (d.target.range[1] - d.target.range[0]) * d.targetRange[1]); - const sourceY = yScale(d.source.cat) + categoryHeight / 2; - const targetY = yScale(d.target.cat) - categoryHeight / 2; + const sourceY = yScale(d.source.cat) + yScaleBandwidth; + const targetY = yScale(d.target.cat); const gapY = (targetY - sourceY) * 0.5; return ` M ${sourceX0} ${sourceY} @@ -184,8 +172,8 @@ export function sankey( * @property {Object} linkData.target - Target node object. * @property {Array} linkData.group - Data rows for this link, for this source AND target combination * @property {number} linkData.size - Link size. Sum of the `size` accessor on `linkData.group` - * @property {Array} linkData.sourceRange - Link top position as [start, end] with values between 0 and 1 - * @property {Array} linkData.targetRange - Link bottom position as [start, end] with values between 0 and 1 + * @property {Array} linkData.sourceRange - Link top X position as [start, end] with values between 0 and 1 + * @property {Array} linkData.targetRange - Link bottom X position as [start, end] with values between 0 and 1 */ return { nodes: nodesLayer, links: linksLayer, texts: textLayer, labels: labelsLayer, nodeData, linkData }; }