Public
Edited
Jun 27, 2023
2 forks
Insert cell
Insert cell
cloneData = await FileAttachment("y2.json").json()
Insert cell
Insert cell
Insert cell
tree = createBellPlotTree(cloneData.models.XXXX_pOme1_DNA[2])
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
treeToSvgDom(tree)
Insert cell
treeToSvgDom = function (tree) {
const barWidth = 20;
const spacing = 3;

const domNode = DOM.svg(bellWidth + spacing + barWidth, bellHeight);
const svg = SVG.SVG(domNode);

const bellGroup = svg.group().transform({
scaleX: bellWidth,
scaleY: bellHeight
});

// Make shaper functions for the nested logistic geometries of the subclones
const shapers = treeToShapers(tree);
// Render the subclones recursively
addTreeToSvgGroup(tree, shapers, bellGroup);

const barGroup = svg.group().transform({
scaleX: barWidth,
scaleY: bellHeight,
translateX: bellWidth + spacing
});

// Stacked bar chart. Displays the proportions. Also provides attachment points for tentacles.
addStackedBarToSvgGroup(tree, shapers, barGroup);

return domNode;
}
Insert cell
/**
* Adds the nested subclones into an SVG group. The dimensions of tree are 1x1 pixels.
*/
addTreeToSvgGroup = function (tree, shapers, g) {
/**
* Draw an 1x1 "rectangle" that is shaped using the shaper function.
*/
function drawNode(node) {
const shaper = shapers.get(node.id);

// Segment count. Higher number produces smoother curves.
const sc = 100;

// Find the first segment where the subclone starts to emerge
let firstSegment = 0;
for (let i = 0; i <= sc; i++) {
const x = i / sc;

if (shaper(x, 0) - shaper(x, 1) != 0) {
// The one where upper and lower edges collide
firstSegment = Math.max(0, i - 1);
break;
}
}

const p = d3.path();
// Start the path
p.moveTo(firstSegment / sc, shaper(firstSegment / sc, 1));

// Upper part of the bell path
for (let i = firstSegment + 1; i <= sc; i++) {
const x = i / sc;
p.lineTo(x, shaper(x, 1));
}
// Lower part of the bell path
for (let i = sc; i >= firstSegment; i--) {
const x = i / sc;
p.lineTo(x, shaper(x, 0));
}

g.path(p.toString())
.fill(node.color)
.stroke("black")
.attr("stroke-opacity", 0.3)
.attr("vector-effect", "non-scaling-stroke")
.addClass("clone")
.data("clone", node.data);

for (const child of node.children) {
drawNode(child);
}
}

drawNode(tree);

return g;
}
Insert cell
/**
* Creates shaper functions for each subclone
*/
treeToShapers = function (tree) {
const totalDepth = getDepth(tree);

/** @type {Map<string, function>} */
const shapers = new Map();

function process(node, shaper, depth = 0) {
if (!shaper) {
shaper = (x, y) => y; // Make an initial shaper. Just a rectangle, no bell shape
}

shapers.set(node.id, shaper);

// Children emerge as spread to better emphasize what their parent is
const spreadPositions = stackChildren(node, true);
// They end up as stacked to make the perception of the proportions easier
const stackedPositions = stackChildren(node, false);

const childDepth = depth + 1;
const fractionalChildDepth = childDepth / totalDepth;

// Make an interpolator that smoothly interpolates between the spread and stacked positions
const interpolatePositions = (childIdx, x) => {
let a = smoothstep(fractionalChildDepth, 1, x);
const s = 1 - spreadStrength;
a = a * (1 - s) + s;
return lerp(spreadPositions[childIdx], stackedPositions[childIdx], a);
};

for (let i = 0; i < node.children.length; i++) {
const childNode = node.children[i];
// Fractions indicate the proportion of the subclone in the whole sample.
// However, we need the fraction within its parent.
const childFraction = childNode.fraction / node.fraction;

// Create a new shaper for each children. Also apply parent's shaper.
const childShaper = (x, y) => {
// The fractionalChildDepth defines when the bell starts to appear
const v = fancystep(fractionalChildDepth, 1, x) * childFraction;
y = v * (y - 0.5) + 0.5 + interpolatePositions(i, x);
return shaper(x, y);
};

process(childNode, childShaper, childDepth);
}
}

// Make a pseudo root that contains the actual root.
// Rationale: the drawNode function provides shapers for node's children.
// We need an imaginary node so that we get a shaper for the root node.
const pseudoRoot = {
id: "pseudoRoot",
fraction: 1,
children: [tree]
};

process(
pseudoRoot, // root node
null, // no initial shaper. One is created for the true root node.
-1 // initial depth
);

return shapers;
}
Insert cell
stackChildren = function (node, spread = false) {
// Fractions wrt. the parent
const fractions = node.children.map((n) => n.fraction / node.fraction);

const remainingSpace = 1 - fractions.reduce((a, c) => a + c, 0);

// Stack or spread?
const spacing = spread ? remainingSpace / (fractions.length + 1) : 0;
let cumSum = spread ? spacing : remainingSpace;

const positions = [];
for (const x of fractions) {
positions.push(cumSum + (x - 1) / 2);
cumSum += x + spacing;
}
return positions;
}
Insert cell
/**
* Take the tree stucture and shapers. Render a stacked bar.
*/
addStackedBarToSvgGroup = function (tree, shapers, g) {
const stackedNodes = stackTree(tree, shapers);

function process(node) {
const [top, bottom] = stackedNodes.get(node.id);

console.log("foo", [top, bottom]);
g.rect(1, bottom - top)
.move(0, top)
.fill(node.color)
.stroke("black")
.attr("stroke-opacity", 0.3)
.attr("vector-effect", "non-scaling-stroke");

for (const child of node.children) {
process(child);
}
}

process(tree);

return g;
}
Insert cell
/**
* Takes the shapers and builds stacked extents of the subclone
* proportions visible at the right edge of the bells.
* Returns a Map that maps node id to an extent.
*
* This has two use cases:
* 1. Render a pretty stacked bar chart
* 2. Get attachment areas for the tentacles
*/
stackTree = function (tree, shapers) {
const stackedNodes = new Map();

function process(node) {
const nodeShaper = shapers.get(node.id);
const top = nodeShaper(1, 0);

let bottom = nodeShaper(1, 1);
for (const child of node.children) {
bottom = Math.min(bottom, process(child));
}
stackedNodes.set(node.id, [top, bottom]);

return top;
}

process(tree);
return stackedNodes;
}
Insert cell
Insert cell
Insert cell
Insert cell
lerp = (a, b, x) => (1 - x) * a + x * b
Insert cell
clamp = (lower, upper, x) => Math.max(lower, Math.min(upper, x))
Insert cell
Insert cell
SVG = import("@svgdotjs/svg.js")
Insert cell
d3scaleChromatic = require('d3-scale-chromatic')
Insert cell
import {slider} from '@jashkenas/inputs'
Insert cell
Insert cell
d3 = require("d3")
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more