charts/src/js/utils/draw.js

950 lines
20 KiB
JavaScript

import {
getBarHeightAndYAttr,
truncateString,
shortenLargeNumber,
getSplineCurvePointsStr,
} from "./draw-utils";
import { getStringWidth, isValidNumber, round } from "./helpers";
import {
DOT_OVERLAY_SIZE_INCR,
PERCENTAGE_BAR_DEFAULT_DEPTH,
} from "./constants";
import { lightenDarkenColor } from "./colors";
export const AXIS_TICK_LENGTH = 6;
const LABEL_MARGIN = 4;
const LABEL_WIDTH = 25;
const TOTAL_PADDING = 120;
const LABEL_MAX_CHARS = 18;
export const FONT_SIZE = 10;
const BASE_LINE_COLOR = "#E2E6E9";
function $(expr, con) {
return typeof expr === "string"
? (con || document).querySelector(expr)
: expr || null;
}
export function createSVG(tag, o) {
var element = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (var i in o) {
var val = o[i];
if (i === "inside") {
$(val).appendChild(element);
} else if (i === "around") {
var ref = $(val);
ref.parentNode.insertBefore(element, ref);
element.appendChild(ref);
} else if (i === "styles") {
if (typeof val === "object") {
Object.keys(val).map((prop) => {
element.style[prop] = val[prop];
});
}
} else {
if (i === "className") {
i = "class";
}
if (i === "innerHTML") {
element["textContent"] = val;
} else {
element.setAttribute(i, val);
}
}
}
return element;
}
function renderVerticalGradient(svgDefElem, gradientId) {
return createSVG("linearGradient", {
inside: svgDefElem,
id: gradientId,
x1: 0,
x2: 0,
y1: 0,
y2: 1,
});
}
function setGradientStop(gradElem, offset, color, opacity) {
return createSVG("stop", {
inside: gradElem,
style: `stop-color: ${color}`,
offset: offset,
"stop-opacity": opacity,
});
}
export function makeSVGContainer(parent, className, width, height) {
return createSVG("svg", {
className: className,
inside: parent,
width: width,
height: height,
});
}
export function makeSVGDefs(svgContainer) {
return createSVG("defs", {
inside: svgContainer,
});
}
export function makeSVGGroup(className, transform = "", parent = undefined) {
let args = {
className: className,
transform: transform,
};
if (parent) args.inside = parent;
return createSVG("g", args);
}
export function wrapInSVGGroup(elements, className = "") {
let g = createSVG("g", {
className: className,
});
elements.forEach((e) => g.appendChild(e));
return g;
}
export function makePath(
pathStr,
className = "",
stroke = "none",
fill = "none",
strokeWidth = 2
) {
return createSVG("path", {
className: className,
d: pathStr,
styles: {
stroke: stroke,
fill: fill,
"stroke-width": strokeWidth,
},
});
}
export function makeArcPathStr(
startPosition,
endPosition,
center,
radius,
clockWise = 1,
largeArc = 0
) {
let [arcStartX, arcStartY] = [
center.x + startPosition.x,
center.y + startPosition.y,
];
let [arcEndX, arcEndY] = [
center.x + endPosition.x,
center.y + endPosition.y,
];
return `M${center.x} ${center.y}
L${arcStartX} ${arcStartY}
A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
${arcEndX} ${arcEndY} z`;
}
export function makeCircleStr(
startPosition,
endPosition,
center,
radius,
clockWise = 1,
largeArc = 0
) {
let [arcStartX, arcStartY] = [
center.x + startPosition.x,
center.y + startPosition.y,
];
let [arcEndX, midArc, arcEndY] = [
center.x + endPosition.x,
center.y * 2,
center.y + endPosition.y,
];
return `M${center.x} ${center.y}
L${arcStartX} ${arcStartY}
A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
${arcEndX} ${midArc} z
L${arcStartX} ${midArc}
A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
${arcEndX} ${arcEndY} z`;
}
export function makeArcStrokePathStr(
startPosition,
endPosition,
center,
radius,
clockWise = 1,
largeArc = 0
) {
let [arcStartX, arcStartY] = [
center.x + startPosition.x,
center.y + startPosition.y,
];
let [arcEndX, arcEndY] = [
center.x + endPosition.x,
center.y + endPosition.y,
];
return `M${arcStartX} ${arcStartY}
A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
${arcEndX} ${arcEndY}`;
}
export function makeStrokeCircleStr(
startPosition,
endPosition,
center,
radius,
clockWise = 1,
largeArc = 0
) {
let [arcStartX, arcStartY] = [
center.x + startPosition.x,
center.y + startPosition.y,
];
let [arcEndX, midArc, arcEndY] = [
center.x + endPosition.x,
radius * 2 + arcStartY,
center.y + startPosition.y,
];
return `M${arcStartX} ${arcStartY}
A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
${arcEndX} ${midArc}
M${arcStartX} ${midArc}
A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
${arcEndX} ${arcEndY}`;
}
export function makeGradient(svgDefElem, color, lighter = false) {
let gradientId =
"path-fill-gradient" +
"-" +
color +
"-" +
(lighter ? "lighter" : "default");
let gradientDef = renderVerticalGradient(svgDefElem, gradientId);
let opacities = [1, 0.6, 0.2];
if (lighter) {
opacities = [0.4, 0.2, 0];
}
setGradientStop(gradientDef, "0%", color, opacities[0]);
setGradientStop(gradientDef, "50%", color, opacities[1]);
setGradientStop(gradientDef, "100%", color, opacities[2]);
return gradientId;
}
export function rightRoundedBar(x, width, height) {
// https://medium.com/@dennismphil/one-side-rounded-rectangle-using-svg-fb31cf318d90
let radius = height / 2;
let xOffset = width - radius;
return `M${x},0 h${xOffset} q${radius},0 ${radius},${radius} q0,${radius} -${radius},${radius} h-${xOffset} v${height}z`;
}
export function leftRoundedBar(x, width, height) {
let radius = height / 2;
let xOffset = width - radius;
return `M${
x + radius
},0 h${xOffset} v${height} h-${xOffset} q-${radius}, 0 -${radius},-${radius} q0,-${radius} ${radius},-${radius}z`;
}
export function percentageBar(
x,
y,
width,
height,
isFirst,
isLast,
fill = "none"
) {
if (isLast) {
let pathStr = rightRoundedBar(x, width, height);
return makePath(pathStr, "percentage-bar", null, fill);
}
if (isFirst) {
let pathStr = leftRoundedBar(x, width, height);
return makePath(pathStr, "percentage-bar", null, fill);
}
let args = {
className: "percentage-bar",
x: x,
y: y,
width: width,
height: height,
fill: fill,
};
return createSVG("rect", args);
}
export function heatSquare(
className,
x,
y,
size,
radius,
fill = "none",
data = {}
) {
let args = {
className: className,
x: x,
y: y,
width: size,
height: size,
rx: radius,
fill: fill,
};
Object.keys(data).map((key) => {
args[key] = data[key];
});
return createSVG("rect", args);
}
export function legendDot(
x,
y,
size,
radius,
fill = "none",
label,
value,
font_size = null,
truncate = false
) {
label = truncate ? truncateString(label, LABEL_MAX_CHARS) : label;
if (!font_size) font_size = FONT_SIZE;
let args = {
className: "legend-dot",
x: 0,
y: 4 - size,
height: size,
width: size,
rx: radius,
fill: fill,
};
let textLabel = createSVG("text", {
className: "legend-dataset-label",
x: size,
y: 0,
dx: font_size + "px",
dy: font_size / 3 + "px",
"font-size": font_size * 1.6 + "px",
"text-anchor": "start",
innerHTML: label,
});
let textValue = null;
if (value) {
textValue = createSVG("text", {
className: "legend-dataset-value",
x: size,
y: FONT_SIZE + 10,
dx: FONT_SIZE + "px",
dy: FONT_SIZE / 3 + "px",
"font-size": FONT_SIZE * 1.2 + "px",
"text-anchor": "start",
innerHTML: value,
});
}
let group = createSVG("g", {
transform: `translate(${x}, ${y})`,
});
group.appendChild(createSVG("rect", args));
group.appendChild(textLabel);
if (value && textValue) {
group.appendChild(textValue);
}
return group;
}
export function makeText(className, x, y, content, options = {}) {
let fontSize = options.fontSize || FONT_SIZE;
let dy = options.dy !== undefined ? options.dy : fontSize / 2;
//let fill = options.fill || "var(--charts-label-color)";
let fill = options.fill || FONT_FILL;
let textAnchor = options.textAnchor || "start";
return createSVG("text", {
className: className,
x: x,
y: y,
dy: dy + "px",
"font-size": fontSize + "px",
fill: fill,
"text-anchor": textAnchor,
innerHTML: content,
});
}
function makeVertLine(x, label, y1, y2, options = {}) {
if (!options.stroke) options.stroke = BASE_LINE_COLOR;
let l = createSVG("line", {
className: "line-vertical " + options.className,
x1: 0,
x2: 0,
y1: y1,
y2: y2,
styles: {
stroke: options.stroke,
},
});
let text = createSVG("text", {
x: 0,
y: y1 > y2 ? y1 + LABEL_MARGIN : y1 - LABEL_MARGIN - FONT_SIZE,
dy: FONT_SIZE + "px",
"font-size": FONT_SIZE + "px",
"text-anchor": "middle",
innerHTML: label + "",
});
let line = createSVG("g", {
transform: `translate(${x}, 0)`,
});
line.appendChild(l);
line.appendChild(text);
return line;
}
function makeHoriLine(y, label, x1, x2, options = {}) {
if (!options.stroke) options.stroke = BASE_LINE_COLOR;
if (!options.lineType) options.lineType = "";
if (!options.alignment) options.alignment = "left";
if (options.shortenNumbers) label = shortenLargeNumber(label);
let className =
"line-horizontal " +
options.className +
(options.lineType === "dashed" ? "dashed" : "");
const textXPos =
options.alignment === "left"
? options.title
? x1 - LABEL_MARGIN + LABEL_WIDTH
: x1 - LABEL_MARGIN
: options.title
? x2 + LABEL_MARGIN * 4 - LABEL_WIDTH
: x2 + LABEL_MARGIN * 4;
const lineX1Post = options.title ? x1 + LABEL_WIDTH : x1;
const lineX2Post = options.title ? x2 - LABEL_WIDTH : x2;
let l = createSVG("line", {
className: className,
x1: lineX1Post,
x2: lineX2Post,
y1: 0,
y2: 0,
styles: {
stroke: options.stroke,
},
});
let text = createSVG("text", {
x: textXPos,
y: 0,
dy: FONT_SIZE / 2 - 2 + "px",
"font-size": FONT_SIZE + "px",
"text-anchor": x1 < x2 ? "end" : "start",
innerHTML: label + "",
});
let line = createSVG("g", {
transform: `translate(0, ${y})`,
"stroke-opacity": 1,
});
if (text === 0 || text === "0") {
line.style.stroke = "rgba(27, 31, 35, 0.6)";
}
line.appendChild(l);
line.appendChild(text);
return line;
}
export function generateAxisLabel(options) {
if (!options.title) return;
const y =
options.position === "left"
? (options.height - TOTAL_PADDING) / 2 +
getStringWidth(options.title, 5) / 2
: (options.height - TOTAL_PADDING) / 2 -
getStringWidth(options.title, 5) / 2;
const x = options.position === "left" ? 0 : options.width;
const y2 =
options.position === "left"
? FONT_SIZE - LABEL_WIDTH
: FONT_SIZE + LABEL_WIDTH * -1;
const rotation =
options.position === "right" ? `rotate(90)` : `rotate(270)`;
const labelSvg = createSVG("text", {
className: "chart-label",
x: 0, // getStringWidth(options.title, 5) / 2,
y: 0, // y,
dy: `${y2}px`,
"font-size": `${FONT_SIZE}px`,
"text-anchor": "start",
innerHTML: `${options.title} `,
});
let wrapper = createSVG("g", {
x: 0,
y: 0,
transformBox: "fill-box",
transform: `translate(${x}, ${y}) ${rotation}`,
className: `test-${options.position}`,
});
wrapper.appendChild(labelSvg);
return wrapper;
}
export function yLine(y, label, width, options = {}) {
if (!isValidNumber(y)) y = 0;
if (!options.pos) options.pos = "left";
if (!options.offset) options.offset = 0;
if (!options.mode) options.mode = "span";
if (!options.stroke) options.stroke = BASE_LINE_COLOR;
if (!options.className) options.className = "";
let x1 = -1 * AXIS_TICK_LENGTH;
let x2 = options.mode === "span" ? width + AXIS_TICK_LENGTH : 0;
if (options.mode === "tick" && options.pos === "right") {
x1 = width + AXIS_TICK_LENGTH;
x2 = width;
}
let offset = options.pos === "left" ? -1 * options.offset : options.offset;
// pr_366
//x1 += offset;
//x2 += offset;
x1 += options.offset;
x2 += options.offset;
if (typeof label === "number") label = round(label);
return makeHoriLine(y, label, x1, x2, {
stroke: options.stroke,
className: options.className,
lineType: options.lineType,
alignment: options.pos,
title: options.title,
shortenNumbers: options.shortenNumbers,
});
}
export function xLine(x, label, height, options = {}) {
if (!isValidNumber(x)) x = 0;
if (!options.pos) options.pos = "bottom";
if (!options.offset) options.offset = 0;
if (!options.mode) options.mode = "span";
if (!options.stroke) options.stroke = BASE_LINE_COLOR;
if (!options.className) options.className = "";
// Draw X axis line in span/tick mode with optional label
// y2(span)
// |
// |
// x line |
// |
// |
// ---------------------+-- y2(tick)
// |
// y1
let y1 = height + AXIS_TICK_LENGTH;
let y2 = options.mode === "span" ? -1 * AXIS_TICK_LENGTH : height;
if (options.mode === "tick" && options.pos === "top") {
// top axis ticks
y1 = -1 * AXIS_TICK_LENGTH;
y2 = 0;
}
return makeVertLine(x, label, y1, y2, {
stroke: options.stroke,
className: options.className,
lineType: options.lineType,
});
}
export function yMarker(y, label, width, options = {}) {
if (!isValidNumber(y)) y = 0;
if (!options.labelPos) options.labelPos = "right";
if (!options.lineType) options.lineType = "dashed";
let x =
options.labelPos === "left"
? LABEL_MARGIN
: width - getStringWidth(label, 5) - LABEL_MARGIN;
let labelSvg = createSVG("text", {
className: "chart-label",
x: x,
y: 0,
dy: FONT_SIZE / -2 + "px",
"font-size": FONT_SIZE + "px",
"text-anchor": "start",
innerHTML: label + "",
});
let line = makeHoriLine(y, "", 0, width, {
stroke: options.stroke || BASE_LINE_COLOR,
className: options.className || "",
lineType: options.lineType,
});
line.appendChild(labelSvg);
return line;
}
export function yRegion(y1, y2, width, label, options = {}) {
// return a group
let height = y1 - y2;
let rect = createSVG("rect", {
className: `bar mini`, // remove class
styles: {
fill: options.fill || `rgba(228, 234, 239, 0.49)`,
stroke: options.stroke || BASE_LINE_COLOR,
"stroke-dasharray": `${width}, ${height}`,
},
// 'data-point-index': index,
x: 0,
y: 0,
width: width,
height: height,
});
if (!options.labelPos) options.labelPos = "right";
let x =
options.labelPos === "left"
? LABEL_MARGIN
: width - getStringWidth(label + "", 4.5) - LABEL_MARGIN;
let labelSvg = createSVG("text", {
className: "chart-label",
x: x,
y: 0,
dy: FONT_SIZE / -2 + "px",
"font-size": FONT_SIZE + "px",
"text-anchor": "start",
innerHTML: label + "",
});
let region = createSVG("g", {
transform: `translate(0, ${y2})`,
});
region.appendChild(rect);
region.appendChild(labelSvg);
return region;
}
export function datasetBar(
x,
yTop,
width,
color,
label = "",
index = 0,
offset = 0,
meta = {}
) {
let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine);
y -= offset;
if (height === 0) {
height = meta.minHeight;
y -= meta.minHeight;
}
// Preprocess numbers to avoid svg building errors
if (!isValidNumber(x)) x = 0;
if (!isValidNumber(y)) y = 0;
if (!isValidNumber(height, true)) height = 0;
if (!isValidNumber(width, true)) width = 0;
// x y h w
// M{x},{y+r}
// q0,-{r} {r},-{r}
// q{r},0 {r},{r}
// v{h-r}
// h-{w}z
// let radius = width/2;
// let pathStr = `M${x},${y+radius} q0,-${radius} ${radius},-${radius} q${radius},0 ${radius},${radius} v${height-radius} h-${width}z`
// let rect = createSVG('path', {
// className: 'bar mini',
// d: pathStr,
// styles: { fill: color },
// x: x,
// y: y,
// 'data-point-index': index,
// });
let rect = createSVG("rect", {
className: `bar mini`,
style: `fill: ${color}`,
"data-point-index": index,
x: x,
y: y,
width: width,
height: height,
});
label += "";
if (!label && !label.length) {
return rect;
} else {
rect.setAttribute("y", 0);
rect.setAttribute("x", 0);
let text = createSVG("text", {
className: "data-point-value",
x: width / 2,
y: 0,
dy: (FONT_SIZE / 2) * -1 + "px",
"font-size": FONT_SIZE + "px",
"text-anchor": "middle",
innerHTML: label,
});
let group = createSVG("g", {
"data-point-index": index,
transform: `translate(${x}, ${y})`,
});
group.appendChild(rect);
group.appendChild(text);
return group;
}
}
export function datasetDot(x, y, radius, color, label = "", index = 0) {
let dot = createSVG("circle", {
style: `fill: ${color}`,
"data-point-index": index,
cx: x,
cy: y,
r: radius,
});
label += "";
if (!label && !label.length) {
return dot;
} else {
dot.setAttribute("cy", 0);
dot.setAttribute("cx", 0);
let text = createSVG("text", {
className: "data-point-value",
x: 0,
y: 0,
dy: (FONT_SIZE / 2) * -1 - radius + "px",
"font-size": FONT_SIZE + "px",
"text-anchor": "middle",
innerHTML: label,
});
let group = createSVG("g", {
"data-point-index": index,
transform: `translate(${x}, ${y})`,
});
group.appendChild(dot);
group.appendChild(text);
return group;
}
}
export function getPaths(xList, yList, color, options = {}, meta = {}) {
let pointsList = yList.map((y, i) => xList[i] + "," + y);
let pointsStr = pointsList.join("L");
// Spline
if (options.spline) pointsStr = getSplineCurvePointsStr(xList, yList);
let path = makePath("M" + pointsStr, "line-graph-path", color);
// HeatLine
if (options.heatline) {
let gradient_id = makeGradient(meta.svgDefs, color);
path.style.stroke = `url(#${gradient_id})`;
}
let paths = {
path: path,
};
// Region
if (options.regionFill) {
let gradient_id_region = makeGradient(meta.svgDefs, color, true);
let pathStr =
"M" +
`${xList[0]},${meta.zeroLine}L` +
pointsStr +
`L${xList.slice(-1)[0]},${meta.zeroLine}`;
paths.region = makePath(
pathStr,
`region-fill`,
"none",
`url(#${gradient_id_region})`
);
}
return paths;
}
export let makeOverlay = {
bar: (unit) => {
let transformValue;
if (unit.nodeName !== "rect") {
transformValue = unit.getAttribute("transform");
unit = unit.childNodes[0];
}
let overlay = unit.cloneNode();
overlay.style.fill = "#000000";
overlay.style.opacity = "0.4";
if (transformValue) {
overlay.setAttribute("transform", transformValue);
}
return overlay;
},
dot: (unit) => {
let transformValue;
if (unit.nodeName !== "circle") {
transformValue = unit.getAttribute("transform");
unit = unit.childNodes[0];
}
let overlay = unit.cloneNode();
let radius = unit.getAttribute("r");
let fill = unit.getAttribute("fill");
overlay.setAttribute("r", parseInt(radius) + DOT_OVERLAY_SIZE_INCR);
overlay.setAttribute("fill", fill);
overlay.style.opacity = "0.6";
if (transformValue) {
overlay.setAttribute("transform", transformValue);
}
return overlay;
},
heat_square: (unit) => {
let transformValue;
if (unit.nodeName !== "circle") {
transformValue = unit.getAttribute("transform");
unit = unit.childNodes[0];
}
let overlay = unit.cloneNode();
let radius = unit.getAttribute("r");
let fill = unit.getAttribute("fill");
overlay.setAttribute("r", parseInt(radius) + DOT_OVERLAY_SIZE_INCR);
overlay.setAttribute("fill", fill);
overlay.style.opacity = "0.6";
if (transformValue) {
overlay.setAttribute("transform", transformValue);
}
return overlay;
},
};
export let updateOverlay = {
bar: (unit, overlay) => {
let transformValue;
if (unit.nodeName !== "rect") {
transformValue = unit.getAttribute("transform");
unit = unit.childNodes[0];
}
let attributes = ["x", "y", "width", "height"];
Object.values(unit.attributes)
.filter((attr) => attributes.includes(attr.name) && attr.specified)
.map((attr) => {
overlay.setAttribute(attr.name, attr.nodeValue);
});
if (transformValue) {
overlay.setAttribute("transform", transformValue);
}
},
dot: (unit, overlay) => {
let transformValue;
if (unit.nodeName !== "circle") {
transformValue = unit.getAttribute("transform");
unit = unit.childNodes[0];
}
let attributes = ["cx", "cy"];
Object.values(unit.attributes)
.filter((attr) => attributes.includes(attr.name) && attr.specified)
.map((attr) => {
overlay.setAttribute(attr.name, attr.nodeValue);
});
if (transformValue) {
overlay.setAttribute("transform", transformValue);
}
},
heat_square: (unit, overlay) => {
let transformValue;
if (unit.nodeName !== "circle") {
transformValue = unit.getAttribute("transform");
unit = unit.childNodes[0];
}
let attributes = ["cx", "cy"];
Object.values(unit.attributes)
.filter((attr) => attributes.includes(attr.name) && attr.specified)
.map((attr) => {
overlay.setAttribute(attr.name, attr.nodeValue);
});
if (transformValue) {
overlay.setAttribute("transform", transformValue);
}
},
};