@atomist/sdm-pack-aspect
Version:
an Atomist SDM Extension Pack for visualizing drift across an organization
338 lines (284 loc) • 12.1 kB
text/typescript
/*
* Copyright © 2019 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as d3 from "d3";
import {
PlantedTree,
SunburstLeaf,
SunburstTree,
} from "../tree/sunburst";
const NameOfThisLibrary = "SunburstYo";
/**
* Color palette for d3 to use
*/
const palette = [
"#56b06f",
"#3bbaa8",
"#35848c",
"#173d6d",
"#846473",
"#5F7186",
"#0d560d",
"#1f8045",
"#173d48",
];
type SunburstTreeNode = d3.HierarchyNode<SunburstTree | SunburstLeaf>;
// tslint:disable
export function sunburst(workspaceId,
data: any,
pWidth,
pHeight,
options: {
perLevelDataElementIds: string[],
fieldsToDisplay: FieldToDisplay[]
}) {
const { perLevelDataElementIds, fieldsToDisplay } = options;
const minDiameterInPixels = 100;
const width = Math.max(pWidth || window.innerWidth, minDiameterInPixels),
height = Math.max(pHeight || window.innerHeight, minDiameterInPixels),
maxRadius = (Math.min(width, height) / 2) - 5;
const viewBoxSide = maxRadius * 2 + 10;
const x = d3.scaleLinear()
.range([0, 2 * Math.PI])
.clamp(true);
const y = d3.scaleSqrt()
.range([maxRadius * .1, maxRadius]);
const chooseColorFromString = d3.scaleOrdinal(palette);
const arc = d3.arc()
.startAngle((d: any) => x(d.x0))
.endAngle((d: any) => x(d.x1))
.innerRadius((d: any) => Math.max(0, y(d.y0)))
.outerRadius((d: any) => Math.max(0, y(d.y1)));
const middleArcLine = d => {
const halfPi = Math.PI / 2;
const angles = [x(d.x0) - halfPi, x(d.x1) - halfPi];
const r = Math.max(0, (y(d.y0) + y(d.y1)) / 2);
const middleAngle = (angles[1] + angles[0]) / 2;
const invertDirection = middleAngle > 0 && middleAngle < Math.PI; // On lower quadrants write text ccw
if (invertDirection) {
angles.reverse();
}
const path = d3.path();
path.arc(0, 0, r, angles[0], angles[1], invertDirection);
return path.toString();
};
const textFits = d => {
const CHAR_SPACE = 6;
const deltaAngle = x(d.x1) - x(d.x0);
const r = Math.max(0, (y(d.y0) + y(d.y1)) / 2);
const perimeter = r * deltaAngle;
return d.data.name.length * CHAR_SPACE < perimeter;
};
const svg = d3.select("#putSvgHere").append("svg")
.style("width", viewBoxSide + "px")
.attr("viewBox", `${-viewBoxSide / 2} ${-viewBoxSide / 2} ${viewBoxSide} ${viewBoxSide}`)
.on("click", focusOn); // Reset zoom on canvas click
// now work with the data
const d: PlantedTree = data;
if (!d.tree || d.tree.children.length === 0) {
alert("No data");
return;
}
const root = d3.hierarchy<SunburstTree | SunburstLeaf>(d.tree);
root.sum(d => (d as SunburstLeaf).size || 0); // sets a "value" property on each node
const slice = svg.selectAll<d3.BaseType, SunburstTree | SunburstLeaf>("g.slice")
.data(d3.partition<SunburstTree | SunburstLeaf>()(root).descendants());
slice.exit().remove(); // does this remove any extraneous ones?
const perLevelDataElements = perLevelDataElementIds.map(id => d3.select("#" + id)).filter(a => !!a);
const additionalDataElement = d3.select("#additionalDataAboutWhatYouClicked");
const newSlice = slice.enter()
.append("g").attr("class", "slice")
.on("click", d => {
d3.event.stopPropagation();
setFrozenLevelData(workspaceId, perLevelDataElements, d);
focusOn(d);
})
.on("mouseover", (d: SunburstTreeNode) => {
populatePerLevelData(perLevelDataElements, d);
populateAdditionalData(additionalDataElement, fieldsToDisplay, d);
});
// This is the hover text
newSlice.append("title")
.text(d => d.data.name);
newSlice.append("path")
.attr("class", "main-arc")
// I think this says, the last ring should use the same color as its parent
.style("fill", (d: any) => d.data.color || chooseColorFromString((d.children ? d : d.parent).data.name))
.attr("d", arc as any);
newSlice.append("path")
.attr("class", "hidden-arc")
.attr("id", (_, i) => `hiddenArc${i}`)
.attr("d", middleArcLine);
const text = newSlice.append("text")
.attr("display", d => textFits(d) ? null : "none");
// Add white contour
text.append("textPath")
.attr("class", "textOutline")
.attr("startOffset", "50%")
.attr("xlink:href", (_, i) => `#hiddenArc${i}`)
.text(d => d.data.name);
text.append("textPath")
.attr("startOffset", "50%")
.attr("xlink:href", (_, i) => `#hiddenArc${i}`)
.text(d => d.data.name);
function focusOn(d = { x0: 0, x1: 1, y0: 0, y1: 1 }) {
// Reset to top-level if no data point specified
const transition = svg.transition()
.duration(750)
.tween("scale", () => {
const xd = d3.interpolate(x.domain(), [d.x0, d.x1]),
yd = d3.interpolate(y.domain(), [d.y0, 1]);
return t => {
x.domain(xd(t));
y.domain(yd(t));
};
});
transition.selectAll("path.main-arc")
.attrTween("d", d => () => arc(d as any));
transition.selectAll("path.hidden-arc")
.attrTween("d", d => () => middleArcLine(d));
transition.selectAll("text")
.attrTween("display", d => () => textFits(d) ? null : "none");
moveStackToFront(d);
function moveStackToFront(elD) {
svg.selectAll<d3.BaseType, SunburstTreeNode>(".slice").filter(d => d === elD)
.each(function (d) {
(this as any).parentNode.appendChild(this); // move all parents to the end of the line
if (d.parent) {
moveStackToFront(d.parent);
}
});
}
}
}
function hasTags(data: SunburstTree | SunburstLeaf): data is (SunburstTree | SunburstLeaf) & { tags: Array<{ name: string }> } {
return !!(data as any).tags
}
type FieldToDisplay = string;
function populateAdditionalData(
additionalDataElement: d3.Selection<any, any, any, any> | undefined,
usefulFields: FieldToDisplay[],
d: SunburstTreeNode) {
if (!additionalDataElement) {
return;
}
let content = "";
const data = d.data as any;
console.log("Properties on data: " + Object.getOwnPropertyNames(data).join(","))
if (hasTags(data)) {
content = "Tags: <ul>" + data.tags.map(t => `<li>${t.name}</li>`).join("") + "</ul>";
}
usefulFields.forEach(fieldname => {
if (data[fieldname] !== undefined) {
content += `<br />${fieldname}: ${data[fieldname]}`
}
});
additionalDataElement.html(content);
}
function populatePerLevelData(perLevelDataElements: d3.Selection<any, any, any, any>[], d: SunburstTreeNode) {
const namesUpTree = [d.data.name];
for (let place: any = d; place = place.parent; !!place) {
namesUpTree.push(place.data.name);
}
const namesDownTree = namesUpTree.reverse();
perLevelDataElements.forEach((e, i) => {
if (e.attr("class") === "frozenLevelData") {
return;
}
const value = namesDownTree[i] || "(various)";
e.html(value);
});
if (namesUpTree.length < perLevelDataElements.length) {
// if this isn't a leaf node, then
// the value property on the node is a count of the leaves under it.
// put that count in the last level data
perLevelDataElements[perLevelDataElements.length - 1].html("(" + d.value + " of them)");
}
}
function setFrozenLevelData(workspaceId, perLevelDataElements: d3.Selection<any, any, any, any>[], d: SunburstTreeNode) {
const htmlUpTree = [formatLevelData(d.data)];
for (let place: any = d; place = place.parent; !!place) {
htmlUpTree.push(formatLevelData(place.data))
}
const htmlDownTree = htmlUpTree.reverse();
const dataId = (d.data as any).id;
const levelCountAbove = htmlUpTree.length;
perLevelDataElements.forEach((e, i) => {
const className = i >= levelCountAbove ? "unfrozenLevelData" : "frozenLevelData";
e.attr("class", className);
if (!dataId || i !== (levelCountAbove - 1)) {
// no buttons
e.html(htmlDownTree[i]);
return;
}
e.html(htmlDownTree[i] + "<br/>" + htmlForSetIdeal(workspaceId, dataId) + htmlForNoteProblem(workspaceId, dataId));
});
}
function formatLevelData(data: { name: string, url?: string, viewUrl?: string, tags?: Array<{ name: string }> }): string {
console.log("tags: " + data.tags);
const viewLink = data.viewUrl ? `<a href="${data.viewUrl}"><img src="/hexagonal-fruit-of-power.png" class="linkToInsightsImage" ></img></a>` : "";
let tagList: string = "";
if (data.tags) {
tagList = "<ul>" + data.tags.map(t => `<li>${t.name}</li>`).join("") + "</ul>"
}
const nameDisplay = data.url ? `<a href="${data.url}">${data.name}</a>` : data.name;
return nameDisplay + viewLink + tagList;
}
function htmlForSetIdeal(workspaceId, dataId) {
return `<button id="setIdeal" onclick="${NameOfThisLibrary}.postSetIdeal('${workspaceId}','${dataId}')">
Set as ideal
</button><label for="setIdeal" id="setIdealLabel" class="nothingToSay"> </label>`;
}
function htmlForNoteProblem(workspaceId, dataId) {
return `<button id="noteProblem" onclick="${NameOfThisLibrary}.postNoteProblem('${workspaceId}','${dataId}')">
Note as problem
</button><label for="noteProblem" id="noteProblemLabel" class="nothingToSay"> </label>`;
}
export function postSetIdeal(workspaceId: string, fingerprintId: string) {
const postUrl = `../../api/v1/${workspaceId}/ideal/${encodeURIComponent(fingerprintId)}`;
const labelElement = document.getElementById("setIdealLabel");
fetch(postUrl, { method: "PUT" }).then(response => {
if (response.ok) {
labelElement.textContent = `Ideal set`;
labelElement.setAttribute("class", "success");
labelElement.setAttribute("display", "static");
} else {
labelElement.textContent = "Failed to set. consult the server logaments";
labelElement.setAttribute("class", "error");
}
},
e => {
labelElement.textContent = "Network error";
labelElement.setAttribute("class", "error");
});
}
export function postNoteProblem(workspaceId: string, fingerprintId: string) {
const postUrl = `../../api/v1/${workspaceId}/problem/${encodeURIComponent(fingerprintId)}`;
const labelElement = document.getElementById("noteProblemLabel");
fetch(postUrl, { method: "PUT" }).then(response => {
if (response.ok) {
labelElement.textContent = `Problem noted`;
labelElement.setAttribute("class", "success");
labelElement.setAttribute("display", "static");
} else {
labelElement.textContent = "Failed to set. consult the server logaments";
labelElement.setAttribute("class", "error");
}
},
e => {
labelElement.textContent = "Network error";
labelElement.setAttribute("class", "error");
});
}