clanviewer
Version:
A component to visualise the relationships between the Pfam families in a clan
468 lines (433 loc) • 13.7 kB
text/typescript
"use strict";
/*
* clanviewer
* https://github.com/ProteinsWebTeam/clanviewer
*
* Copyright (c) 2022 gustavo-salazar
* Licensed under the Apache-2.0 license.
*/
/**
@class clanviewer
A component to visualise the relationships between the Pfam families in a clan
*/
import { select, Selection } from "d3-selection";
import { ScaleLinear, scaleLinear } from "d3-scale";
import { zoom as d3Zoom, ZoomBehavior } from "d3-zoom";
import { drag as d3Drag, DragBehavior } from "d3-drag";
import {
forceSimulation,
forceCenter,
forceManyBody,
forceLink,
Simulation,
SimulationNodeDatum,
SimulationLinkDatum,
ForceLink,
} from "d3-force";
import "../css/clanviewer.css";
interface ClanSimulationNode extends SimulationNodeDatum {
accession: string;
}
type PfamMember = {
pfama_acc: string;
pfama_id: string;
num_full: number | string;
accession?: string;
link?: string;
};
type PfamRelationship = {
pfama_acc_1: string;
pfama_acc_2: string;
evalue: number;
};
type ClanNode = {
accession: string;
id?: string;
name?: string;
score: number;
x?: number;
y?: number;
link?: string;
short_name?: string;
type?: string;
};
type ClanLink = {
source: number | string | ClanSimulationNode;
target: number | string | ClanSimulationNode;
score: number;
link?: string;
radius?: number;
};
type ClanProcessedData = {
nodes: Array<ClanNode>;
links: Array<ClanLink>;
};
type PfamData = {
clan_acc: string;
clan_id: string;
total_occurrences?: number;
members?: Array<PfamMember>;
relationships?: Array<PfamRelationship>;
};
type ClanData = PfamData | ClanProcessedData;
type NodeLabel =
| "name"
| "short_name"
| "accession"
| ((n: ClanNode) => string);
export default class ClanViewer {
r: number;
_autoWidth: boolean;
_autoHeight: boolean;
width?: number;
height?: number;
element: string | Element;
multiple_relationships: boolean;
directional: boolean;
useCtrlToZoom: boolean;
size: ScaleLinear<number, number, never>;
tickness: ScaleLinear<number, number, never>;
force: Simulation<
ClanSimulationNode,
SimulationLinkDatum<ClanSimulationNode>
>;
zoom: ZoomBehavior<SVGSVGElement, unknown>;
drag: DragBehavior<SVGCircleElement, ClanNode, unknown>;
svg: Selection<SVGSVGElement, unknown, null, undefined>;
maingroup: Selection<SVGGElement, unknown, null, undefined>;
linksgroup: Selection<SVGGElement, unknown, null, undefined>;
nodeGroup: Selection<SVGGElement, unknown, null, undefined>;
_ro?: ResizeObserver;
tick?: () => void;
nodeLabel?: NodeLabel;
updatingLabels: boolean;
constructor({
element = "body",
directional = false,
width = undefined,
height = undefined,
r = 5,
multiple_relationships = false,
useCtrlToZoom = false,
nodeLabel,
}: {
element: string | HTMLElement;
directional?: boolean;
width?: number;
height?: number;
r?: number;
multiple_relationships?: boolean;
useCtrlToZoom?: boolean;
nodeLabel?: NodeLabel;
}) {
this.r = r;
this._autoWidth = !width;
this._autoHeight = !height;
this.width = width;
this.height = height;
this.element = element;
this.multiple_relationships = multiple_relationships;
this.directional = directional;
this.useCtrlToZoom = useCtrlToZoom;
this.nodeLabel = nodeLabel;
this.updatingLabels = false;
this.size = scaleLinear().range([1, 5 * this.r]);
this.tickness = scaleLinear().range([0.5, 1, 5]).domain([1, 1e-4, 0]);
this.svg = select(this.element as Element)
.append("svg")
.attr("width", this.width || 0)
.attr("height", this.height || 0)
.attr("class", "clanviewer")
.attr("pointer-events", "all");
this.maingroup = this.svg.append("g");
this.linksgroup = this.maingroup.append("g").attr("class", "links");
this.nodeGroup = this.maingroup.append("g").attr("class", "nodes");
this.force = forceSimulation<ClanSimulationNode>()
.force("charge", forceManyBody().strength(-100))
.force(
"link",
forceLink()
.id((d) => (d as ClanSimulationNode).accession)
.distance(200)
)
.force("center", forceCenter(width || 0 / 2, height || 0 / 2));
this.zoom = d3Zoom<SVGSVGElement, unknown>()
.filter((event) => {
if (!(event instanceof WheelEvent)) return true;
return !this.useCtrlToZoom || event.ctrlKey;
})
.scaleExtent([0.5, 10])
.on("zoom", (event) => {
this.maingroup.attr("transform", event.transform);
});
this.drag = d3Drag<SVGCircleElement, ClanNode>()
.on("start", (event, node) => {
const d = node as ClanSimulationNode;
event.sourceEvent.stopPropagation();
this.force.alphaTarget(0.1).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, node) => {
const d = node as ClanSimulationNode;
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event) => {
event.sourceEvent.stopPropagation();
if (!event.active) this.force.alphaTarget(0);
});
this.svg.call(this.zoom).on("dblclick.zoom", null);
if (this.directional)
this.maingroup
.append("defs")
.append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 11)
.attr("refY", 0)
.attr("markerWidth", 2)
.attr("markerHeight", 2)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5");
if (this._autoWidth) {
this.svg.style("display", "block");
this.svg.attr("width", "100%");
}
if (this._autoHeight) {
this.svg.style("display", "block");
this.svg.attr("height", "100%");
}
if (this._autoHeight || this._autoWidth) {
this._autoUpdateSize();
this._ro = new ResizeObserver(this._autoUpdateSize.bind(this));
if (this.svg.node()) this._ro.observe(this.svg.node() as Element);
}
this.paint.bind(this);
this.tick = undefined;
}
_autoUpdateSize() {
const parent = select(this.element as Element);
this.width = parseInt(parent.style("width"), 10) || 0;
this.height = parseInt(parent.style("height"), 10) || 0;
this.force.force("center", forceCenter(this.width / 2, this.height / 2));
this.svg.attr("viewBox", `0 0 ${this.width} ${this.height}`);
if (this.tick) {
this.force.restart();
}
}
private getLabel(node: ClanNode) {
if (this.nodeLabel) {
if (typeof this.nodeLabel === "string")
return node[this.nodeLabel] || node.accession;
if (typeof this.nodeLabel === "function") return this.nodeLabel(node);
}
return node.accession;
}
paint(data: ClanData) {
const processedData = this.processData(data);
this.force?.nodes(processedData.nodes || []);
(
this.force.force("link") as ForceLink<
SimulationNodeDatum,
SimulationLinkDatum<ClanSimulationNode>
>
).links(processedData.links);
const linkGData = this.linksgroup
.selectAll(".link")
.data(processedData.links);
const linkG = linkGData
.enter()
.append("g")
.attr(
"class",
(d) =>
`link ${(d.source as ClanNode).accession} ${
(d.target as ClanNode).accession
}`
);
linkGData.exit().remove();
const link = linkG
.append("path")
.style("stroke-width", (d) => this.tickness(d.score))
.attr("marker-end", this.directional ? "url(#arrowhead)" : null);
const label_link_link = linkG
.filter((d) => !!d.link)
.append("a")
.attr("xlink:href", (d) => (d["link"] ? d["link"] : null))
.attr("target", "_blank")
.attr("class", (d) => (d["link"] ? "href" : null))
.append("text")
.text((d) => d.score.toExponential());
const label_link_text = linkG
.filter((d) => !d.link)
.append("text")
.text((d) => d.score.toExponential());
const nodeGdata = this.nodeGroup
.selectAll(".node")
.data(processedData.nodes);
const nodeG = nodeGdata
.enter()
.append("g")
.attr("id", (d) => `node_${d.accession}`)
.attr("data-accession", (d) => d.accession)
.attr("class", "node");
nodeGdata.exit().remove();
const node = nodeG
.append("circle")
.attr("r", (d) => this.size(d.score))
.call(this.drag)
.on("dblclick", function (d) {
d.fx = null;
d.fy = null;
});
const label_node_link = nodeG
.filter((d) => !!d.link)
.append("a")
.attr("xlink:href", (d) => (d["link"] ? d["link"] : null))
.attr("target", "_blank")
.attr("class", (d) => (d["link"] ? "href" : null))
.append("text")
.text((d) => this.getLabel(d));
const label_node_text = nodeG
.filter((d) => !d.link)
.append("text")
.text((d) => this.getLabel(d));
this.tick = () => {
link.attr("d", ClanViewer.linkArc);
label_link_link.attr("transform", ClanViewer.labelArc);
label_link_text.attr("transform", ClanViewer.labelArc);
node.attr("cx", (d) => d.x || 0).attr("cy", (d) => d.y || 0);
label_node_link
.attr("x", (d) => (d.x || 0) + this.size(d.score) + 2)
.attr("y", (d) => (d.y || 0) + this.r);
label_node_text
.attr("x", (d) => (d.x || 0) + this.size(d.score) + 2)
.attr("y", (d) => (d.y || 0) + this.r);
if (this.updatingLabels) {
label_node_link.text((d) => this.getLabel(d));
label_node_text.text((d) => this.getLabel(d));
}
};
this.force.on("tick", this.tick);
this.force.alpha(1);
}
clear() {
this.paint({
nodes: [],
links: [],
});
}
static linkArc(d: ClanLink) {
const tx = (d.target as ClanSimulationNode)?.x || 0;
const ty = (d.target as ClanSimulationNode)?.y || 0;
const sx = (d.source as ClanSimulationNode)?.x || 0;
const sy = (d.source as ClanSimulationNode)?.y || 0;
const dx = tx - sx,
dy = ty - sy,
dr = Math.sqrt(dx * dx + dy * dy);
d.radius = dr;
return (
"M" + sx + "," + sy + "A" + dr + "," + dr + " 0 0,1 " + tx + "," + ty
);
}
static labelArc(d: ClanLink) {
const tx = (d.target as ClanSimulationNode)?.x || 0;
const ty = (d.target as ClanSimulationNode)?.y || 0;
const sx = (d.source as ClanSimulationNode)?.x || 0;
const sy = (d.source as ClanSimulationNode)?.y || 0;
const x = (tx + sx) / 2;
const y = (ty + sy) / 2; // (x,y) is the point in the middle of target and source
const h = d.radius || 0;
const A = h / 2;
const B = h - Math.sqrt(h * h - A * A);
const mB = (sx - x) / (y - sy); //TODO: check division by 0
const div = Math.sqrt(1 + mB * mB);
const px = ty > sy ? x + B * (1 / div) : x - B * (1 / div);
const py = ty > sy ? y + B * (mB / div) : y - B * (mB / div);
return "translate(" + px + "," + py + ")";
}
updateNodeLabel(nodeLabel?: NodeLabel) {
if (this.nodeLabel !== nodeLabel) {
this.updatingLabels = true;
this.nodeLabel = nodeLabel;
if (this.tick) this.tick();
this.updatingLabels = false;
}
}
processData(data: ClanData): ClanProcessedData {
const newData: ClanProcessedData = {
nodes: [],
links: [],
};
const members =
(data as PfamData).members || (data as ClanProcessedData).nodes || [];
const relationships =
(data as PfamData).relationships ||
(data as ClanProcessedData).links ||
[];
newData.nodes = members.map(
(e) =>
({
...e,
id: (e as ClanNode).accession || (e as PfamMember).pfama_acc,
accession: (e as ClanNode).accession || (e as PfamMember).pfama_acc,
name: (e as ClanNode).name || (e as PfamMember).pfama_id,
score: Number.parseFloat(
String((e as ClanNode).score ?? (e as PfamMember).num_full)
),
} || [])
);
let max = 0;
newData.nodes.forEach((e) => {
max = Math.max(max, e.score);
});
if (this.multiple_relationships) {
newData.links = relationships.map((e) => ({
source: (e as ClanLink).source || (e as PfamRelationship).pfama_acc_1,
target: (e as ClanLink).target || (e as PfamRelationship).pfama_acc_2,
score: Number.parseFloat(
String((e as ClanLink).score || (e as PfamRelationship).evalue)
),
}));
} else {
const tmp_relationships: {
[key: string]: ClanLink;
} = {};
relationships.forEach((e) => {
const acc1 =
(e as ClanLink).source || (e as PfamRelationship).pfama_acc_1;
const acc2 =
(e as ClanLink).target || (e as PfamRelationship).pfama_acc_2;
const score = Number.parseFloat(
String((e as ClanLink).score || (e as PfamRelationship).evalue)
);
let key = `${acc1}_${acc2}`;
if (key in tmp_relationships) {
tmp_relationships[key].score = Math.max(
tmp_relationships[key].score,
score
);
} else if (`${acc2}_${acc1}` in tmp_relationships) {
key = `${acc2}_${acc1}`;
tmp_relationships[key].score = Math.max(
tmp_relationships[key].score,
score
);
} else {
tmp_relationships[key] = {
source: acc1,
target: acc2,
score: score,
};
}
});
newData.links = Object.keys(tmp_relationships).map(
(key) => tmp_relationships[key]
);
}
this.size.domain([0, max]);
return newData;
}
}