singledivui
Version:
Simple JavaScript Chart Library built with a single <div> element alone
809 lines (673 loc) • 26.4 kB
JavaScript
/*!
* SingleDivUI v1.0.1 | https://singledivui.com | (c) 2023 Soundar | MIT License
*/
const math = Math;
function LinearScale(minPoint, maxPoint, maxTicks, stepSize) {
const result = [];
let lBound, uBound;
if (stepSize > 0) {
lBound = minPoint;
uBound = maxPoint;
}
else {
const range = niceNum(maxPoint - minPoint, false);
stepSize = niceNum(range / (maxTicks - 1), true);
lBound = math.floor(minPoint / stepSize) * stepSize;
uBound = math.ceil(maxPoint / stepSize) * stepSize;
}
var count = math.ceil((uBound - lBound) / stepSize);
for(let i=0; i<=count; i++) {
result.push(lBound + (i * stepSize));
}
result.reverse();
return {
min: lBound,
max: result[0],
scale: result,
step: stepSize
};
}
function niceNum(localRange, round) {
var exponent = math.floor(math.log10(localRange)),
fraction = localRange / math.pow(10, exponent),
niceFraction;
if (round) {
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3) niceFraction = 2;
else if (fraction < 7) niceFraction = 5;
else niceFraction = 10;
} else {
if (fraction <= 1) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * math.pow(10, exponent);
}
const math$1 = Math;
const defaultUnit = 'px';
const maxFraction = 2;
function deepExtend(sourceObj, targetObj) {
targetObj = targetObj || {};
for (var key in targetObj) {
var value = targetObj[key];
var valueType = Object.prototype.toString.call(value);
if (valueType === '[object Object]') {
sourceObj[key] = deepExtend(sourceObj[key] || {}, value);
}
else {
sourceObj[key] = value;
}
}
return sourceObj;
}
function unitValue(value, unit) {
if(typeof value === 'string' && value != +value) {
return value;
}
return +(+value).toFixed(maxFraction) + (unit || defaultUnit);
}
function calculateAngle(point1, point2, pointsDistance) {
var diff = point1 - point2;
var opposite = math$1.abs(diff);
var hypotenuse = math$1.sqrt(math$1.pow(pointsDistance, 2) + math$1.pow(opposite, 2));
var sinX = opposite / hypotenuse;
var x = math$1.asin(sinX);
var deg = radians_to_degrees(x);
if (diff < 0) {
deg = -deg;
}
return unitValue(deg, 'deg');
}
function convertRange(value, oldMin, oldMax, newMin, newMax) {
return (((value - oldMin) / (oldMax - oldMin)) * (newMax - newMin)) + newMin;
}
function convertObjToStyles(propsObj, prefix, excludeProps) {
var styles = {};
var addStyle = function (prop, val) {
var isValid = typeof val === 'string' || typeof val === 'number';
var canAdd = !excludeProps || !excludeProps.includes(prop);
if (isValid && canAdd) {
styles[(prefix || '') + camelToKebabCase(prop)] = unitValue(val);
}
};
for (var prop in propsObj) {
var val = propsObj[prop];
addStyle(prop, val);
}
return styles;
}
function camelToKebabCase(str) {
return str.split(/(?=[A-Z])/).join('-').toLowerCase();
}
function radians_to_degrees(radians) {
return radians * (180 / math$1.PI);
}
function throttle(func, interval, context) {
let shouldFire = true;
return function() {
if (shouldFire) {
shouldFire = false;
setTimeout(() => {
shouldFire = true;
func.call(context);
}, interval);
}
}
}
const DOCUMENT = document;
const querySelector = (selector) => DOCUMENT.querySelector(selector);
const addClass = (el, classNames) => updateClass(el, 'add', classNames);
const removeClass = (el, classNames) => updateClass(el, 'remove', classNames);
const setWidth = (el, val, forceSet) => setStyleProp(el, 'width', val, forceSet);
const setHeight = (el, val, forceSet) => setStyleProp(el, 'height', val, forceSet);
const isDOM = (obj) => obj && obj instanceof Element;
function injectStyles(stylesJson, targetEle, styleEle, selector) {
console.log('selector', selector);
var cssStyle = applyStyles(stylesJson, (targetEle === 'inline'));
if (cssStyle) {
if (typeof targetEle === 'string') {
targetEle = querySelector(targetEle);
}
if (!isDOM(targetEle)) {
targetEle = DOCUMENT.head; // fallback
}
var cssTextEle = DOCUMENT.createTextNode(cssStyle);
if (!styleEle) {
var styleSelector = '';
if (selector) {
styleSelector = '#sd-styles' + selector.replace('#', '-').replace('.', '_');
// check for previous style element and remove it
var oldStyleEle = DOCUMENT.querySelector(styleSelector);
if (oldStyleEle) oldStyleEle.remove();
}
styleEle = createElement('style' + styleSelector);
}
targetEle.appendChild(styleEle);
styleEle.appendChild(cssTextEle);
}
return styleEle;
}
function calculateTextWidth(text, fontStyle) {
var tempEle = DOCUMENT.createElement('span');
setStyleProp(tempEle, 'font', fontStyle);
tempEle.innerHTML = text;
DOCUMENT.body.appendChild(tempEle);
var { width } = tempEle.getBoundingClientRect();
tempEle.remove();
return width;
}
function setStyleProp(el, prop, val, forceSet) {
var isString = typeof val === 'string';
if (!forceSet && (!isString || (isString && val == +val))) {
val += 'px';
}
el.style[prop] = val;
}
function applyStyles(jsonObj, inline) {
var styleStr = "";
for (var selector in jsonObj) {
var rules = jsonObj[selector];
if (inline) {
var ele = querySelector(selector.split(':')[0]);
for (var prop in rules) {
ele.style.setProperty(prop, rules[prop]);
}
}
else {
styleStr += selector + " { ";
for (var prop in rules) {
styleStr += prop + ":" + rules[prop] + "; ";
}
styleStr += "} \n\n";
}
}
return styleStr;
}
function updateClass(el, mode, classNames) {
classNames && classNames.split(' ').forEach(name => el.classList[mode](name));
return classNames;
}
function createElement(tag) {
var t = tag.split('#');
var ele = DOCUMENT.createElement(t[0]);
if (t[1]) ele.id = t[1];
return ele;
}
const math$2 = Math;
const isNumber = (val) => !isNaN(parseFloat(val));
const WHITESPACE_CHAR = ' ';
function Graph(height, width, xData, yData, graphSettings, extraColumn) {
var { xAxis = {}, yAxis = {} } = graphSettings;
var xAxisSetting = Object.assign({}, graphSettings, xAxis);
var yAxisSetting = Object.assign({}, graphSettings, yAxis);
var paddingX = xAxisSetting.padding;
paddingX = (paddingX instanceof Array) ? paddingX : [0, 0];
var pLeft = paddingX[0] || 0, pRight = paddingX[1] || 0;
if (extraColumn) pLeft += 1;
// generate scale-y (based on the scale only we can calculate the row and rowSize)
var { min: yMin, max: yMax, scale: scaleY } = generateScaleY(yData, yAxisSetting);
var row = scaleY.length - 1, rowSize = height / row;
var col = (xData.length - 1) + (pLeft + pRight), columnSize = width / col;
// to round the nearby value of 0.5
// this is crucial part, since round-off the columnSize might lead the gap inbetween area
columnSize = Math.floor(columnSize / 0.5) * 0.5;
// format the X and Y labels, if the formatter is available
scaleY = formatData(scaleY, yAxisSetting.labelFormatter);
xData = formatData(xData, xAxisSetting.labelFormatter);
// generate scale-x
var scaleX = generateScaleX(xData, columnSize, xAxisSetting);
var startPosition = columnSize * pLeft;
var yLabelWidth = getMaxLabelWidth(scaleY, yAxisSetting);
// generate the styles related to X and Y axis
var xStyles = {}, yStyles = {};
yStyles['--_y-label-width'] = unitValue(yLabelWidth);
// add the additional required styles for Graph
xStyles['--_x-label'] = `"${scaleX}"`;
yStyles['--_y-label'] = `"${scaleY.join('\\a ')}"`;
// styles for x-axis vertical label
if (xAxisSetting.verticalLabel) {
xStyles['--_x-label-height'] = unitValue(columnSize);
xStyles['--_x-label-direction'] = 'vertical-lr';
}
// convert the graphSettings (xAxis & yAxis) props into styles
var commonStyles = convertPropsToStyles(xAxisSetting, yAxisSetting);
// below base syles will be commonly used for both Graph and Series
commonStyles['--_row-size'] = unitValue(rowSize);
commonStyles['--_column-size'] = unitValue(columnSize);
commonStyles['--_start-position'] = unitValue(startPosition);
return {
row, col,
rowSize, columnSize,
yMin, yMax,
chartMax: height,
startPosition,
styles: {
common: commonStyles,
x: xStyles,
y: yStyles
}
};
}
function formatData(data, formatter) {
if (typeof formatter === 'function') {
return data.map(formatter);
}
return data;
}
function getMaxLabelWidth(scaleData, { labelFontSize, labelFontFamily }) {
var fontStyle = unitValue(labelFontSize) + ' ' + labelFontFamily;
// most probably the last data will the biggest length one. eg., 1,2,3...1000
var label1 = scaleData.length > 0 ? scaleData[0] : '';
// in floating point case, the previous data also can big. eg., ... 39.5, 40
var label2 = scaleData.length > 1 ? scaleData[1] : '';
// in case of starting from negative value, first data can be big. eg., -100, ..., 0
var labelN = scaleData.length > 2 ? scaleData[scaleData.length - 1] : '';
return math$2.max(
calculateTextWidth(label1, fontStyle),
calculateTextWidth(label2, fontStyle),
calculateTextWidth(labelN, fontStyle)
);
}
function generateScaleX(xData, columnSize, { labelFontSize, labelFontFamily, verticalLabel }) {
if (verticalLabel) {
return xData.join('\\a ');
}
var labelFontStyle = unitValue(labelFontSize) + ' ' + labelFontFamily;
var xLabel = '';
var whitespaceWidth = calculateTextWidth(WHITESPACE_CHAR, labelFontStyle);
var extraSpace = 0;
// a tricky logic to generate the x-axis label with whitespaces
var appendWhitespace = function (sideSpace) {
var availableSpace = sideSpace + extraSpace;
var whitespacCount = math$2.round(availableSpace / whitespaceWidth);
if (whitespacCount >= 0) {
xLabel += ' '.repeat(whitespacCount);
}
var occupiedSpace = (whitespacCount * whitespaceWidth);
extraSpace = availableSpace - occupiedSpace;
};
xData.forEach((labelText) => {
var textWidth = calculateTextWidth(labelText, labelFontStyle);
var sideSpace = (columnSize - textWidth) / 2;
appendWhitespace(sideSpace); // For prefix
xLabel += labelText;
appendWhitespace(sideSpace); // For suffix
});
return xLabel;
}
function generateScaleY(data, { maxTicks, startFromZero, customScale }) {
var minValue, maxValue, step;
var { min, max, interval } = customScale;
if (isNumber(min)) minValue = parseFloat(min);
if (isNumber(max)) maxValue = parseFloat(max);
if (isNumber(interval)) step = parseFloat(interval);
// the below can be written as "math.min(...data)"
// the reason to avoid the spread operator is, during the babel
// transpiling it will create unwanted pollyfil dependencies.
// to make the bundle size minimal we can avoid these in the possible way.
var min = isNumber(minValue) ? minValue : (startFromZero ? 0 : math$2.min.apply(math$2, data));
var max = isNumber(maxValue) ? maxValue : math$2.max.apply(math$2, data);
return LinearScale(min, max, maxTicks, step);
}
function convertPropsToStyles(xAxisSetting, yAxisSetting) {
var commonSettings = {};
for (var prop in xAxisSetting) {
var value = xAxisSetting[prop];
if (value === yAxisSetting[prop]) {
commonSettings[prop] = value;
delete xAxisSetting[prop];
delete yAxisSetting[prop];
}
}
var commonStyles = convertObjToStyles(commonSettings, '--');
var xAxisStyles = convertObjToStyles(xAxisSetting, '--x-');
var yAxisStyles = convertObjToStyles(yAxisSetting, '--y-');
// merge the xAxis, yAxis and Common styles
return Object.assign({}, commonStyles, xAxisStyles, yAxisStyles);
}
const BACKGROUND = '--background-';
function Line({ points, pointRadius, pointStyle, lineSize, isArea }, { columnSize, yMin, yMax, chartMax, startPosition }
) {
var defaultPointRadius = isArea ? 0 : 6,
backgroundImage = [],
backgroundPosition = [],
styles = {},
halfColumnSize = columnSize / 2,
layerPaddingX = 0, layerPaddingY = 0,
layerStart = startPosition,
pointRadius = parseFloat(pointRadius),
pointRadius = pointRadius >= 0 ? pointRadius : defaultPointRadius,
showPoint = pointRadius > 0,
showLine = isArea || (!(parseFloat(lineSize) <= 0)),
prevPointY;
if (showPoint) {
layerPaddingX = pointRadius;
layerPaddingY = isArea ? 0 : pointRadius;
layerStart += layerPaddingX;
}
points.forEach((point, index) => {
var pointY = convertRange(point, yMin, yMax, 0, chartMax) + layerPaddingY;
if (showPoint) {
var doubleIndex = index * 2;
var pointX = layerStart + (halfColumnSize * (doubleIndex - 1));
backgroundImage.push('var(--point)');
backgroundPosition.push(unitValue(pointX) + ' ' + unitValue(-pointY));
}
if (showLine && index > 0) {
var point1 = prevPointY, point2 = pointY;
var angle = calculateAngle(point1, point2, columnSize);
var lineX = layerStart + (columnSize * (index-1));
var lineY = (point1 + point2) / 2;
backgroundImage.push(`linear-gradient(${angle}, var(--line))`);
backgroundPosition.push(unitValue(lineX) + ' ' + unitValue(-lineY));
}
prevPointY = pointY;
});
if (showPoint || showLine) {
styles[BACKGROUND + 'image'] = backgroundImage.join(', ');
styles[BACKGROUND + 'position'] = backgroundPosition.join(', ');
styles[BACKGROUND + 'size'] = unitValue(columnSize) + ' 200%';
}
if (showPoint) {
if (pointStyle === 'circle-dot') {
styles['--dot-ratio'] = 2.5;
}
else if (pointStyle === 'circle') {
styles['--dot-radius'] = '0px';
}
if (pointRadius !== defaultPointRadius) {
styles['--point-radius'] = unitValue(pointRadius);
}
styles['--_layer-padd-x'] = unitValue(layerPaddingX);
styles['--_layer-padd-y'] = unitValue(layerPaddingY);
}
return styles;
}
const BACKGROUND$1 = '--background-';
const defaultBarSize = '60%';
function Bar({ points, barSize, barColor }, { yMin, yMax, chartMax, columnSize, startPosition }) {
var backgroundImage = [],
backgroundSize = [],
backgroundPositionX = [],
styles = {};
var halfColumn = columnSize / 2;
var barWidth = getNumber(barSize, columnSize);
var halfBar = barWidth / 2;
var barStart = startPosition - halfColumn - halfBar;
var getBarImage = function(index) {
if (barColor instanceof Array) {
var colorsLen = barColor.length;
if(index >= colorsLen) {
index %= colorsLen;
}
return `linear-gradient(${barColor[index]} 100%, transparent)`;
}
return 'var(--bar)';
};
points.forEach((point, index) => {
var barHeight = convertRange(point, yMin, yMax, 0, chartMax);
if (!(barHeight >= 0)) barHeight = 0; // '>=' is to handle the NaN as well
var barPosition = (columnSize * index) + barStart;
backgroundImage.push(getBarImage(index));
backgroundSize.push(unitValue(barWidth) + ' ' + unitValue(barHeight));
backgroundPositionX.push(unitValue(barPosition));
});
styles[BACKGROUND$1 + 'image'] = backgroundImage.join(', ');
styles[BACKGROUND$1 + 'size'] = backgroundSize.join(', ');
styles[BACKGROUND$1 + 'position-x'] = backgroundPositionX.join(', ');
return styles;
}
function getNumber(value, parentValue) {
var intValue = parseFloat(value);
if (typeof value === 'string' && value.match(/%$/)) {
return (intValue / 100) * parentValue;
}
if (intValue > 0) {
return intValue;
}
return getNumber(defaultBarSize, parentValue);
}
const defaultPointRadius = 0;
function Area(areaObj, graphObj) {
if (areaObj.pointRadius === undefined) {
areaObj.pointRadius = defaultPointRadius;
}
areaObj.isArea = true;
return new Line(areaObj, graphObj);
}
const series = {
line: Line,
bar: Bar,
area: Area
};
// barSize - this value directly used in bar, so this can be ignored
// pointStyle - this value is no needed directly, so this will be handeled in Line chart
// pointRadius - this also needed when the point is shown, so this will be handeled in Line chart
const excludeProps = ['type', 'barSize', 'pointStyle', 'pointRadius'];
function Series(obj, graphObj) {
const seriesObj = Object.assign({}, obj);
const { type } = seriesObj;
// get the corresponding series class
var seriesClass = series[type];
if (seriesClass) {
// initialize the series, and generate the styles
var seriesStyles = new seriesClass(seriesObj, graphObj);
// convert the series custom properties into styles
var seriesCustomStyles = convertObjToStyles(seriesObj, '--', excludeProps);
// merge both the styles
this.styles = Object.assign({}, seriesCustomStyles, seriesStyles);
}
}
const PLUGIN_NAME = "SingleDivUI.Chart";
// class names
const CLASS_PREFIX = 'sd-';
const CLASS_CHART = CLASS_PREFIX + 'chart'; // sd-chart
const CLASS_GRAPH = CLASS_PREFIX + 'graph'; // sd-graph
Chart.prototype = {
PLUGIN_NAME,
version: "1.0.1",
// after the control initialization the updated default values
// are merged into the options
options: {},
// holds the current Chart element
control: null,
styleEle: null,
// default properties of the Chart plugin
defaults: {
// type should be 'line', 'bar' or 'area'
type: null,
data: {
labels: [],
series: {
points: [],
// ------ for line-chart related customizations ------
lineColor: null,
lineSize: null,
pointRadius: null,
pointColor: null,
pointBorderWidth: null,
pointBorderColor: null,
pointStyle: null,
pointInnerColor: null,
dotRadius: null,
// ------ for bar-chart related customizations ------
barSize: null,
barColor: null,
// ------ for area-chart related customizations ------
areaColor: null
},
},
graphSettings: {
labelFontSize: '11px',
labelFontFamily: 'Verdana, Arial, sans-serif',
gridLineColor: null,
gridLineSize: null,
axisLineColor: null,
axisLineSize: null,
labelColor: null,
labelDistance: null,
xAxis: {
verticalLabel: false,
padding: [0, 0],
labelFormatter: null
},
yAxis: {
maxTicks: 10,
startFromZero: false,
labelFormatter: null,
customScale: {
min: null,
max: null,
interval: null
}
}
},
height: 260,
width: '100%',
responsive: true,
stylesAppendTo: 'head'
},
_init: function () {
this._initialize();
this._render();
},
_initialize: function () {
var chart = this.control;
var { type, width, height, responsive } = this.options;
// add the related class names to the root element
var classNames = CLASS_CHART + ' ' + CLASS_GRAPH;
if (type) {
classNames += ' ' + CLASS_PREFIX + type;
}
this.rootClasses = addClass(chart, classNames);
// set the dimensions of the control, based on the
// height, width only all the callculations will happen
setWidth(chart, width);
setHeight(chart, height);
// for response support
if (responsive) {
if (!this._tResizeFn) {
this._tResizeFn = throttle(this._onResize, 300, this);
}
window.addEventListener('resize', this._tResizeFn);
}
},
_render: function () {
var chart = this.control;
var options = this.options;
var { type, data, graphSettings = {} } = options;
var seriesObj = data.series;
// TODO: as of now ignore the series type
seriesObj.type = type;
var chartHeight = chart.clientHeight;
var chartWidth = this.chartWidth = chart.clientWidth;
var xAxisData = data.labels;
var yAxisData = seriesObj.points;
// bar chart needs an additional column, since each bar renders in-between the column
var needExtraColumn = (type === 'bar');
// render the Graph
var graph = new Graph(chartHeight, chartWidth, xAxisData, yAxisData, graphSettings, needExtraColumn);
// render the Series
var series = new Series(seriesObj, graph);
// merge all the required styles with corresponding selectors
var styles = this._generateStyles(graph, series, type);
// inject the generated styles into DOM
this.styleEle = injectStyles(styles, options.stylesAppendTo, this.styleEle, this.selector);
},
_generateStyles: function (graph, series, type) {
var styles = {},
selector = this.selector + '.',
graphStyles = graph.styles,
seriesStyles = series.styles;
var graph_selector = selector + CLASS_GRAPH,
graphX_selector = graph_selector + ':after',
graphY_selector = graph_selector + ':before',
series_selector = selector + CLASS_PREFIX + type;
styles[graph_selector] = graphStyles.common;
styles[graphX_selector] = graphStyles.x;
styles[graphY_selector] = graphStyles.y;
if (seriesStyles) {
styles[series_selector] = seriesStyles;
}
return styles;
},
_onResize: function () {
var chart = this.control;
if (this.chartWidth !== chart.clientWidth) {
this.refresh();
}
},
// public methods
update: function (options) {
deepExtend(this.options, options);
this.refresh();
},
refresh: function () {
this._clearAll();
this._init();
},
destroy: function () {
this._clearAll(true);
},
// private methods
_clearAll: function (destroy) {
var chart = this.control;
// remove all the chart related classes that added initially
removeClass(chart, this.rootClasses);
// remove all the inline styles that added
if (this.options.stylesAppendTo === 'inline') {
chart.removeAttribute('style');
}
else {
setWidth(chart, '', true);
setHeight(chart, '', true);
}
// remove all the dynamic stylesheet that created
var styleEle = this.styleEle;
(styleEle || {}).innerHTML = '';
// unbind the responsive related events
if (this._tResizeFn) {
window.removeEventListener('resize', this._tResizeFn);
}
// in case of destroy, completely remove the elements and instances
if (destroy) {
styleEle && styleEle.remove();
this.styleEle = null;
// remove the plugin instance that saved on the element
delete chart[PLUGIN_NAME];
}
}
};
// The plugin constructor
function Chart(selector, options) {
var control = selector, strSelector = '';
if (typeof selector === 'string') {
control = querySelector(selector);
strSelector = selector;
}
if (!isDOM(control)) {
console.error(PLUGIN_NAME + `: Element(${selector}) is not available!`);
return;
}
// try to get the chart instance from the elment
// to confirm whether the chart already got initialized
var instance = control[PLUGIN_NAME];
if (instance) {
instance.update(options);
return instance;
}
if (!strSelector) {
var id = control.id;
strSelector = id ? '#' + id : '';
}
this.control = control;
this.selector = strSelector;
// save the instance on the element for the future reference
control[PLUGIN_NAME] = this;
// the options value holds the updated defaults value
this.options = deepExtend({}, this.defaults);
deepExtend(this.options, options);
this._init();
}
export { Chart };