fix: Partial fix for #404 needs more work.

Better null value handling. Add option for filling zero/null values to
maintain backwards compat.
This commit is contained in:
Arjun Choudhary 2022-12-14 18:15:51 +05:30
parent 9dce5cf5a8
commit 256649fbcc
2 changed files with 367 additions and 362 deletions

View File

@ -1,26 +1,26 @@
import SvgTip from "../objects/SvgTip"; import SvgTip from "../objects/SvgTip";
import { import {
$, $,
isElementInViewport, isElementInViewport,
getElementContentWidth, getElementContentWidth,
isHidden, isHidden,
} from "../utils/dom"; } from "../utils/dom";
import { import {
makeSVGContainer, makeSVGContainer,
makeSVGDefs, makeSVGDefs,
makeSVGGroup, makeSVGGroup,
makeText, makeText,
} from "../utils/draw"; } from "../utils/draw";
import { LEGEND_ITEM_WIDTH } from "../utils/constants"; import { LEGEND_ITEM_WIDTH } from "../utils/constants";
import { import {
BASE_MEASURES, BASE_MEASURES,
getExtraHeight, getExtraHeight,
getExtraWidth, getExtraWidth,
getTopOffset, getTopOffset,
getLeftOffset, getLeftOffset,
INIT_CHART_UPDATE_TIMEOUT, INIT_CHART_UPDATE_TIMEOUT,
CHART_POST_ANIMATE_TIMEOUT, CHART_POST_ANIMATE_TIMEOUT,
DEFAULT_COLORS, DEFAULT_COLORS,
} from "../utils/constants"; } from "../utils/constants";
import { getColor, isValidColor } from "../utils/colors"; import { getColor, isValidColor } from "../utils/colors";
import { runSMILAnimation } from "../utils/animation"; import { runSMILAnimation } from "../utils/animation";
@ -28,348 +28,353 @@ import { downloadFile, prepareForExport } from "../utils/export";
import { deepClone } from "../utils/helpers"; import { deepClone } from "../utils/helpers";
export default class BaseChart { export default class BaseChart {
constructor(parent, options) { constructor(parent, options) {
// deepclone options to avoid making changes to orignal object // deepclone options to avoid making changes to orignal object
options = deepClone(options); options = deepClone(options);
this.parent = this.parent =
typeof parent === "string" ? document.querySelector(parent) : parent; typeof parent === "string"
? document.querySelector(parent)
: parent;
if (!(this.parent instanceof HTMLElement)) { if (!(this.parent instanceof HTMLElement)) {
throw new Error("No `parent` element to render on was provided."); throw new Error("No `parent` element to render on was provided.");
} }
this.rawChartArgs = options; this.rawChartArgs = options;
this.title = options.title || ""; this.title = options.title || "";
this.type = options.type || ""; this.type = options.type || "";
this.realData = this.prepareData(options.data); this.realData = this.prepareData(options.data, options.config);
this.data = this.prepareFirstData(this.realData); this.data = this.prepareFirstData(this.realData);
this.colors = this.validateColors(options.colors, this.type); this.colors = this.validateColors(options.colors, this.type);
this.config = { this.config = {
showTooltip: 1, // calculate showTooltip: 1, // calculate
showLegend: showLegend:
typeof options.showLegend !== "undefined" ? options.showLegend : 1, typeof options.showLegend !== "undefined"
isNavigable: options.isNavigable || 0, ? options.showLegend
animate: typeof options.animate !== "undefined" ? options.animate : 1, : 1,
truncateLegends: isNavigable: options.isNavigable || 0,
typeof options.truncateLegends !== "undefined" animate:
? options.truncateLegends typeof options.animate !== "undefined" ? options.animate : 0,
: 1, truncateLegends:
}; typeof options.truncateLegends !== "undefined"
? options.truncateLegends
: 1,
};
this.measures = JSON.parse(JSON.stringify(BASE_MEASURES)); this.measures = JSON.parse(JSON.stringify(BASE_MEASURES));
let m = this.measures; let m = this.measures;
this.setMeasures(options); this.setMeasures(options);
if (!this.title.length) { if (!this.title.length) {
m.titleHeight = 0; m.titleHeight = 0;
} }
if (!this.config.showLegend) m.legendHeight = 0; if (!this.config.showLegend) m.legendHeight = 0;
this.argHeight = options.height || m.baseHeight; this.argHeight = options.height || m.baseHeight;
this.state = {}; this.state = {};
this.options = {}; this.options = {};
this.initTimeout = INIT_CHART_UPDATE_TIMEOUT; this.initTimeout = INIT_CHART_UPDATE_TIMEOUT;
if (this.config.isNavigable) { if (this.config.isNavigable) {
this.overlays = []; this.overlays = [];
} }
this.configure(options); this.configure(options);
} }
prepareData(data) { prepareData(data) {
return data; return data;
} }
prepareFirstData(data) { prepareFirstData(data) {
return data; return data;
} }
validateColors(colors, type) { validateColors(colors, type) {
const validColors = []; const validColors = [];
colors = (colors || []).concat(DEFAULT_COLORS[type]); colors = (colors || []).concat(DEFAULT_COLORS[type]);
colors.forEach((string) => { colors.forEach((string) => {
const color = getColor(string); const color = getColor(string);
if (!isValidColor(color)) { if (!isValidColor(color)) {
console.warn('"' + string + '" is not a valid color.'); console.warn('"' + string + '" is not a valid color.');
} else { } else {
validColors.push(color); validColors.push(color);
} }
}); });
return validColors; return validColors;
} }
setMeasures() { setMeasures() {
// Override measures, including those for title and legend // Override measures, including those for title and legend
// set config for legend and title // set config for legend and title
} }
configure() { configure() {
let height = this.argHeight; let height = this.argHeight;
this.baseHeight = height; this.baseHeight = height;
this.height = height - getExtraHeight(this.measures); this.height = height - getExtraHeight(this.measures);
// Bind window events // Bind window events
this.boundDrawFn = () => this.draw(true); this.boundDrawFn = () => this.draw(true);
// Look into improving responsiveness // Look into improving responsiveness
//if (ResizeObserver) { //if (ResizeObserver) {
// this.resizeObserver = new ResizeObserver(this.boundDrawFn); // this.resizeObserver = new ResizeObserver(this.boundDrawFn);
// this.resizeObserver.observe(this.parent); // this.resizeObserver.observe(this.parent);
//} //}
window.addEventListener("resize", this.boundDrawFn); window.addEventListener("resize", this.boundDrawFn);
window.addEventListener("orientationchange", this.boundDrawFn); window.addEventListener("orientationchange", this.boundDrawFn);
} }
destroy() { destroy() {
//if (this.resizeObserver) this.resizeObserver.disconnect(); //if (this.resizeObserver) this.resizeObserver.disconnect();
window.removeEventListener("resize", this.boundDrawFn); window.removeEventListener("resize", this.boundDrawFn);
window.removeEventListener("orientationchange", this.boundDrawFn); window.removeEventListener("orientationchange", this.boundDrawFn);
} }
// Has to be called manually // Has to be called manually
setup() { setup() {
this.makeContainer(); this.makeContainer();
this.updateWidth(); this.updateWidth();
this.makeTooltip(); this.makeTooltip();
this.draw(false, true); this.draw(false, true);
} }
makeContainer() { makeContainer() {
// Chart needs a dedicated parent element // Chart needs a dedicated parent element
this.parent.innerHTML = ""; this.parent.innerHTML = "";
let args = { let args = {
inside: this.parent, inside: this.parent,
className: "chart-container", className: "chart-container",
}; };
if (this.independentWidth) { if (this.independentWidth) {
args.styles = { width: this.independentWidth + "px" }; args.styles = { width: this.independentWidth + "px" };
} }
this.container = $.create("div", args); this.container = $.create("div", args);
} }
makeTooltip() { makeTooltip() {
this.tip = new SvgTip({ this.tip = new SvgTip({
parent: this.container, parent: this.container,
colors: this.colors, colors: this.colors,
}); });
this.bindTooltip(); this.bindTooltip();
} }
bindTooltip() {} bindTooltip() {}
draw(onlyWidthChange = false, init = false) { draw(onlyWidthChange = false, init = false) {
if (onlyWidthChange && isHidden(this.parent)) { if (onlyWidthChange && isHidden(this.parent)) {
// Don't update anything if the chart is hidden // Don't update anything if the chart is hidden
return; return;
} }
this.updateWidth(); this.updateWidth();
this.calc(onlyWidthChange); this.calc(onlyWidthChange);
this.makeChartArea(); this.makeChartArea();
this.setupComponents(); this.setupComponents();
this.components.forEach((c) => c.setup(this.drawArea)); this.components.forEach((c) => c.setup(this.drawArea));
// this.components.forEach(c => c.make()); // this.components.forEach(c => c.make());
this.render(this.components, false); this.render(this.components, false);
if (init) { if (init) {
this.data = this.realData; this.data = this.realData;
setTimeout(() => { setTimeout(() => {
this.update(this.data, true); this.update(this.data, true);
}, this.initTimeout); }, this.initTimeout);
} }
if (this.config.showLegend) { if (this.config.showLegend) {
this.renderLegend(); this.renderLegend();
} }
this.setupNavigation(init); this.setupNavigation(init);
} }
calc() {} // builds state calc() {} // builds state
updateWidth() { updateWidth() {
this.baseWidth = getElementContentWidth(this.parent); this.baseWidth = getElementContentWidth(this.parent);
this.width = this.baseWidth - getExtraWidth(this.measures); this.width = this.baseWidth - getExtraWidth(this.measures);
} }
makeChartArea() { makeChartArea() {
if (this.svg) { if (this.svg) {
this.container.removeChild(this.svg); this.container.removeChild(this.svg);
} }
let m = this.measures; let m = this.measures;
this.svg = makeSVGContainer( this.svg = makeSVGContainer(
this.container, this.container,
"frappe-chart chart", "frappe-chart chart",
this.baseWidth, this.baseWidth,
this.baseHeight this.baseHeight
); );
this.svgDefs = makeSVGDefs(this.svg); this.svgDefs = makeSVGDefs(this.svg);
if (this.title.length) { if (this.title.length) {
this.titleEL = makeText( this.titleEL = makeText(
"title", "title",
m.margins.left, m.margins.left,
m.margins.top, m.margins.top,
this.title, this.title,
{ {
fontSize: m.titleFontSize, fontSize: m.titleFontSize,
fill: "#666666", fill: "#666666",
dy: m.titleFontSize, dy: m.titleFontSize,
} }
); );
} }
let top = getTopOffset(m); let top = getTopOffset(m);
this.drawArea = makeSVGGroup( this.drawArea = makeSVGGroup(
this.type + "-chart chart-draw-area", this.type + "-chart chart-draw-area",
`translate(${getLeftOffset(m)}, ${top})` `translate(${getLeftOffset(m)}, ${top})`
); );
if (this.config.showLegend) { if (this.config.showLegend) {
top += this.height + m.paddings.bottom; top += this.height + m.paddings.bottom;
this.legendArea = makeSVGGroup( this.legendArea = makeSVGGroup(
"chart-legend", "chart-legend",
`translate(${getLeftOffset(m)}, ${top})` `translate(${getLeftOffset(m)}, ${top})`
); );
} }
if (this.title.length) { if (this.title.length) {
this.svg.appendChild(this.titleEL); this.svg.appendChild(this.titleEL);
} }
this.svg.appendChild(this.drawArea); this.svg.appendChild(this.drawArea);
if (this.config.showLegend) { if (this.config.showLegend) {
this.svg.appendChild(this.legendArea); this.svg.appendChild(this.legendArea);
} }
this.updateTipOffset(getLeftOffset(m), getTopOffset(m)); this.updateTipOffset(getLeftOffset(m), getTopOffset(m));
} }
updateTipOffset(x, y) { updateTipOffset(x, y) {
this.tip.offset = { this.tip.offset = {
x: x, x: x,
y: y, y: y,
}; };
} }
setupComponents() { setupComponents() {
this.components = new Map(); this.components = new Map();
} }
update(data, drawing = false) { update(data, drawing = false) {
if (!data) console.error("No data to update."); if (!data) console.error("No data to update.");
if (!drawing) data = deepClone(data); if (!drawing) data = deepClone(data);
this.data = this.prepareData(data); this.data = this.prepareData(data);
this.calc(); // builds state this.calc(); // builds state
this.render(this.components, this.config.animate); this.render(this.components, this.config.animate);
} }
render(components = this.components, animate = true) { render(components = this.components, animate = true) {
if (this.config.isNavigable) { if (this.config.isNavigable) {
// Remove all existing overlays // Remove all existing overlays
this.overlays.map((o) => o.parentNode.removeChild(o)); this.overlays.map((o) => o.parentNode.removeChild(o));
// ref.parentNode.insertBefore(element, ref); // ref.parentNode.insertBefore(element, ref);
} }
let elementsToAnimate = []; let elementsToAnimate = [];
// Can decouple to this.refreshComponents() first to save animation timeout // Can decouple to this.refreshComponents() first to save animation timeout
components.forEach((c) => { components.forEach((c) => {
elementsToAnimate = elementsToAnimate.concat(c.update(animate)); elementsToAnimate = elementsToAnimate.concat(c.update(animate));
}); });
if (elementsToAnimate.length > 0) { if (elementsToAnimate.length > 0) {
runSMILAnimation(this.container, this.svg, elementsToAnimate); runSMILAnimation(this.container, this.svg, elementsToAnimate);
setTimeout(() => { setTimeout(() => {
components.forEach((c) => c.make()); components.forEach((c) => c.make());
this.updateNav(); this.updateNav();
}, CHART_POST_ANIMATE_TIMEOUT); }, CHART_POST_ANIMATE_TIMEOUT);
} else { } else {
components.forEach((c) => c.make()); components.forEach((c) => c.make());
this.updateNav(); this.updateNav();
} }
} }
updateNav() { updateNav() {
if (this.config.isNavigable) { if (this.config.isNavigable) {
this.makeOverlay(); this.makeOverlay();
this.bindUnits(); this.bindUnits();
} }
} }
renderLegend(dataset) { renderLegend(dataset) {
this.legendArea.textContent = ""; this.legendArea.textContent = "";
let count = 0; let count = 0;
let y = 0; let y = 0;
dataset.map((data, index) => { dataset.map((data, index) => {
let divisor = Math.floor(this.width / LEGEND_ITEM_WIDTH); let divisor = Math.floor(this.width / LEGEND_ITEM_WIDTH);
if (count > divisor) { if (count > divisor) {
count = 0; count = 0;
y += this.config.legendRowHeight; y += this.config.legendRowHeight;
} }
let x = LEGEND_ITEM_WIDTH * count; let x = LEGEND_ITEM_WIDTH * count;
let dot = this.makeLegend(data, index, x, y); let dot = this.makeLegend(data, index, x, y);
this.legendArea.appendChild(dot); this.legendArea.appendChild(dot);
count++; count++;
}); });
} }
makeLegend() {} makeLegend() {}
setupNavigation(init = false) { setupNavigation(init = false) {
if (!this.config.isNavigable) return; if (!this.config.isNavigable) return;
if (init) { if (init) {
this.bindOverlay(); this.bindOverlay();
this.keyActions = { this.keyActions = {
13: this.onEnterKey.bind(this), 13: this.onEnterKey.bind(this),
37: this.onLeftArrow.bind(this), 37: this.onLeftArrow.bind(this),
38: this.onUpArrow.bind(this), 38: this.onUpArrow.bind(this),
39: this.onRightArrow.bind(this), 39: this.onRightArrow.bind(this),
40: this.onDownArrow.bind(this), 40: this.onDownArrow.bind(this),
}; };
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (isElementInViewport(this.container)) { if (isElementInViewport(this.container)) {
e = e || window.event; e = e || window.event;
if (this.keyActions[e.keyCode]) { if (this.keyActions[e.keyCode]) {
this.keyActions[e.keyCode](); this.keyActions[e.keyCode]();
} }
} }
}); });
} }
} }
makeOverlay() {} makeOverlay() {}
updateOverlay() {} updateOverlay() {}
bindOverlay() {} bindOverlay() {}
bindUnits() {} bindUnits() {}
onLeftArrow() {} onLeftArrow() {}
onRightArrow() {} onRightArrow() {}
onUpArrow() {} onUpArrow() {}
onDownArrow() {} onDownArrow() {}
onEnterKey() {} onEnterKey() {}
addDataPoint() {} addDataPoint() {}
removeDataPoint() {} removeDataPoint() {}
getDataPoint() {} getDataPoint() {}
setCurrentDataPoint() {} setCurrentDataPoint() {}
updateDataset() {} updateDataset() {}
export() { export() {
let chartSvg = prepareForExport(this.svg); let chartSvg = prepareForExport(this.svg);
downloadFile(this.title || "Chart", [chartSvg]); downloadFile(this.title || "Chart", [chartSvg]);
} }
} }

View File

@ -5,7 +5,7 @@ import { ANGLE_RATIO } from "./constants";
* @param {Number} d Any number * @param {Number} d Any number
*/ */
export function floatTwo(d) { export function floatTwo(d) {
return parseFloat(d.toFixed(2)); return parseFloat(d.toFixed(2));
} }
/** /**
@ -14,12 +14,12 @@ export function floatTwo(d) {
* @param {Array} arr2 Second array * @param {Array} arr2 Second array
*/ */
export function arraysEqual(arr1, arr2) { export function arraysEqual(arr1, arr2) {
if (arr1.length !== arr2.length) return false; if (arr1.length !== arr2.length) return false;
let areEqual = true; let areEqual = true;
arr1.map((d, i) => { arr1.map((d, i) => {
if (arr2[i] !== d) areEqual = false; if (arr2[i] !== d) areEqual = false;
}); });
return areEqual; return areEqual;
} }
/** /**
@ -27,16 +27,16 @@ export function arraysEqual(arr1, arr2) {
* @param {Array} array An array containing the items. * @param {Array} array An array containing the items.
*/ */
export function shuffle(array) { export function shuffle(array) {
// Awesomeness: https://bost.ocks.org/mike/shuffle/ // Awesomeness: https://bost.ocks.org/mike/shuffle/
// https://stackoverflow.com/a/2450976/6495043 // https://stackoverflow.com/a/2450976/6495043
// https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array?noredirect=1&lq=1 // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array?noredirect=1&lq=1
for (let i = array.length - 1; i > 0; i--) { for (let i = array.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1)); let j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]]; [array[i], array[j]] = [array[j], array[i]];
} }
return array; return array;
} }
/** /**
@ -47,12 +47,12 @@ export function shuffle(array) {
* @param {Boolean} start fill at start? * @param {Boolean} start fill at start?
*/ */
export function fillArray(array, count, element, start = false) { export function fillArray(array, count, element, start = false) {
if (!element) { if (element == undefined) {
element = start ? array[0] : array[array.length - 1]; element = start ? array[0] : array[array.length - 1];
} }
let fillerArray = new Array(Math.abs(count)).fill(element); let fillerArray = new Array(Math.abs(count)).fill(element);
array = start ? fillerArray.concat(array) : array.concat(fillerArray); array = start ? fillerArray.concat(array) : array.concat(fillerArray);
return array; return array;
} }
/** /**
@ -61,36 +61,36 @@ export function fillArray(array, count, element, start = false) {
* @param {Number} charWidth Width of single char in pixels * @param {Number} charWidth Width of single char in pixels
*/ */
export function getStringWidth(string, charWidth) { export function getStringWidth(string, charWidth) {
return (string + "").length * charWidth; return (string + "").length * charWidth;
} }
export function bindChange(obj, getFn, setFn) { export function bindChange(obj, getFn, setFn) {
return new Proxy(obj, { return new Proxy(obj, {
set: function (target, prop, value) { set: function (target, prop, value) {
setFn(); setFn();
return Reflect.set(target, prop, value); return Reflect.set(target, prop, value);
}, },
get: function (target, prop) { get: function (target, prop) {
getFn(); getFn();
return Reflect.get(target, prop); return Reflect.get(target, prop);
}, },
}); });
} }
// https://stackoverflow.com/a/29325222 // https://stackoverflow.com/a/29325222
export function getRandomBias(min, max, bias, influence) { export function getRandomBias(min, max, bias, influence) {
const range = max - min; const range = max - min;
const biasValue = range * bias + min; const biasValue = range * bias + min;
var rnd = Math.random() * range + min, // random in range var rnd = Math.random() * range + min, // random in range
mix = Math.random() * influence; // random mixer mix = Math.random() * influence; // random mixer
return rnd * (1 - mix) + biasValue * mix; // mix full range and bias return rnd * (1 - mix) + biasValue * mix; // mix full range and bias
} }
export function getPositionByAngle(angle, radius) { export function getPositionByAngle(angle, radius) {
return { return {
x: Math.sin(angle * ANGLE_RATIO) * radius, x: Math.sin(angle * ANGLE_RATIO) * radius,
y: Math.cos(angle * ANGLE_RATIO) * radius, y: Math.cos(angle * ANGLE_RATIO) * radius,
}; };
} }
/** /**
@ -99,11 +99,11 @@ export function getPositionByAngle(angle, radius) {
* @param {Boolean} nonNegative flag to treat negative number as invalid * @param {Boolean} nonNegative flag to treat negative number as invalid
*/ */
export function isValidNumber(candidate, nonNegative = false) { export function isValidNumber(candidate, nonNegative = false) {
if (Number.isNaN(candidate)) return false; if (Number.isNaN(candidate)) return false;
else if (candidate === undefined) return false; else if (candidate === undefined) return false;
else if (!Number.isFinite(candidate)) return false; else if (!Number.isFinite(candidate)) return false;
else if (nonNegative && candidate < 0) return false; else if (nonNegative && candidate < 0) return false;
else return true; else return true;
} }
/** /**
@ -111,9 +111,9 @@ export function isValidNumber(candidate, nonNegative = false) {
* @param {Number} d Any Number * @param {Number} d Any Number
*/ */
export function round(d) { export function round(d) {
// https://floating-point-gui.de/ // https://floating-point-gui.de/
// https://www.jacklmoore.com/notes/rounding-in-javascript/ // https://www.jacklmoore.com/notes/rounding-in-javascript/
return Number(Math.round(d + "e4") + "e-4"); return Number(Math.round(d + "e4") + "e-4");
} }
/** /**
@ -121,23 +121,23 @@ export function round(d) {
* @param {Object} candidate Any Object * @param {Object} candidate Any Object
*/ */
export function deepClone(candidate) { export function deepClone(candidate) {
let cloned, value, key; let cloned, value, key;
if (candidate instanceof Date) { if (candidate instanceof Date) {
return new Date(candidate.getTime()); return new Date(candidate.getTime());
} }
if (typeof candidate !== "object" || candidate === null) { if (typeof candidate !== "object" || candidate === null) {
return candidate; return candidate;
} }
cloned = Array.isArray(candidate) ? [] : {}; cloned = Array.isArray(candidate) ? [] : {};
for (key in candidate) { for (key in candidate) {
value = candidate[key]; value = candidate[key];
cloned[key] = deepClone(value); cloned[key] = deepClone(value);
} }
return cloned; return cloned;
} }