@garvae/react-pie-donut-chart
Version:
Lightweight library allows you to create "pie" and "donut" charts easily
1,447 lines (1,399 loc) • 67.6 kB
JavaScript
/**
* *****************************************************************************
* *****************************************************************************
*
* MIT License
*
* Copyright (c) 2023 Garvae
*
* Author repo: https://github.com/garvae
* Author email: vgarvae@gmail.com
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* *****************************************************************************
* *****************************************************************************
*/
'use strict';
var React = require('react');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
/**
* Converts segment percentage to angle degrees
* @function convertPercentToDegrees
* @param { TConvertPercentToDegrees } props
* @returns { number } angle degrees
*/
const convertPercentToDegrees = (props) => {
const { percent } = props;
if (typeof percent !== 'number') {
return 0;
}
return percent / 100 * 360;
};
/**
* Original ENV
*/
process.env;
/**
* Defines the current environment type (client or server)
*/
const isClient = () => window && typeof window === 'object';
/**
* Defines the current environment type (development or production)
*/
const isProduction = () => process.env.NODE_ENV === 'production';
/**
* Defines if the current environment type is "test"
*/
const isTest = () => !isProduction() && process.env.NODE_ENV === 'test';
const alignConsoleTextLeftDefaultErr = `
Something went wrong while working with strings...
And seems like it's an INTERNAL LIBRARY error.
Error in: "lowerCaseFirstLetter" function
`;
const alignConsoleTextLeft = (text) => {
if (!text || typeof text !== 'string') {
if (!isProduction()) {
// eslint-disable-next-line no-console
console.error([alignConsoleTextLeftDefaultErr, `Received text: ${text}`].join('\n\n'));
}
return '';
}
return text.replaceAll(' ', '');
};
const defaultReportLinkMessage = `
If you see this error and are sure that all properties passed to the "PieDonutChart" component are valid,
please open an issue here: https://github.com/garvae/react-pie-donut-chart.
`;
const createErrorWithDescription = (props) => {
const { messageMain, report: reportProp, } = props;
const title = '😢 😢 😢 Sorry, but something went wrong while calculating the chart params.';
const hint = `
Please check:
1. All necessary and valid properties are passed to the "PieDonutChart" component
2. No other errors are displayed in the console
${defaultReportLinkMessage}
`;
const report = [title];
if (messageMain && typeof messageMain === 'string') {
report.push(messageMain);
}
report.push(hint);
if (reportProp && typeof reportProp === 'string') {
report.push(`
* * * * * * *
If want to open an issue please include the information below in the bug report.
${reportProp}
* * * * * * *
`);
}
return report.join('\n\n');
};
const defaultConsolePs = `
- - - - - - - - - - -
P.S. This message will not be shown in the 'production' mode.
`;
/**
* Manages errors showing in "development" and "production" mods
* @function { (err: string) => void } consoleError
* @param { string } err - error to show in console
*/
const consoleError = (err) => {
if (!isProduction()) {
// eslint-disable-next-line no-console
console.error(alignConsoleTextLeft(`
${err}
${defaultConsolePs}
`));
}
};
/**
* Manages warnings showing in "development" and "production" mods
* @function { (warn: string) => void } consoleWarn
* @param { string } warn - warning to show in console
*/
const consoleWarn = (warn) => {
if (!isProduction()) {
// eslint-disable-next-line no-console
console.warn(alignConsoleTextLeft(`
${warn}
${defaultConsolePs}
`));
}
};
/**
* Checks the validness of incoming params
* @function checkIncomingValues
* @param { TCreateChartSegmentPathDraw } props
*/
const checkIncomingValues = (props) => {
const { radiusInner, radiusOuter, size, valueSegment, valueSegmentsPreviousTotal, valueSegmentsTotal, } = props;
const isNotValid = isNaN(valueSegment)
|| isNaN(valueSegmentsPreviousTotal)
|| isNaN(size)
|| isNaN(valueSegmentsTotal)
|| isNaN(radiusOuter)
|| isNaN(radiusInner)
|| valueSegment > valueSegmentsTotal
|| valueSegmentsPreviousTotal > valueSegmentsTotal
|| radiusOuter > size
|| radiusInner > size
|| radiusInner > radiusOuter;
if (isNotValid) {
consoleError(createErrorWithDescription({
messageMain: 'In most cases, this error is caused by invalid props.',
report: `
One of the checks failed:
isNaN(valueSegment)
|| isNaN(valueSegmentsPreviousTotal)
|| isNaN(size)
|| isNaN(valueSegmentsTotal)
|| isNaN(radiusOuter)
|| isNaN(radiusInner)
|| valueSegment > valueSegmentsTotal
|| valueSegmentsPreviousTotal > valueSegmentsTotal
|| radiusOuter > size
|| radiusInner > size
|| radiusInner > radiusOuter
Error in: "checkIncomingValues" function
Received values:
${props}
`,
}));
return false;
}
return true;
};
/**
* Converts incoming params to the point coordinates
* @function { (props: TGetStartPointCoords): string } getPointCoords
* @param { TGetStartPointCoords } props
* @return { string } coords 'x y'
*/
const getPointCoords = (props) => {
const { angleDegrees, radius, size, } = props;
const halfSize = size / 2;
const x = radius * Math.cos((angleDegrees * Math.PI) / 180) + halfSize;
const y = -radius * Math.sin((angleDegrees * Math.PI) / 180) + halfSize;
return `${x} ${y}`;
};
/**
* Calculates chart segment path
* @function createSvgCommandsString
* @param { TCreateSvgCommandsString } props
* @return { string } path
*/
const createSvgCommandsString = (props) => {
const { angleDegrees, radiusInner, radiusOuter, size, } = props;
/**
* Here we determine which way the arc will be formed to the point - along a short or long one.
*/
const longPathFlag = angleDegrees > 180 ? 1 : 0;
const sizeHalf = size / 2;
const commandM1 = `M ${sizeHalf + radiusOuter} ${sizeHalf}`;
const coordsPointCommandA1 = getPointCoords({
angleDegrees,
radius: radiusOuter,
size,
});
const commandA1 = `A ${radiusOuter} ${radiusOuter} 0 ${longPathFlag} 0 ${coordsPointCommandA1}`;
const coordsPointCommandL1 = getPointCoords({
angleDegrees,
radius: radiusInner,
size,
});
const commandL1 = `L ${coordsPointCommandL1}`;
const commandA2 = `A ${radiusInner} ${radiusInner} 0 ${longPathFlag} 1 ${sizeHalf + radiusInner} ${sizeHalf}`;
const coordsPointCommandL2 = getPointCoords({
angleDegrees: 0,
radius: radiusOuter,
size,
});
const commandL2 = `L ${coordsPointCommandL2}`;
return [
commandM1,
commandA1,
commandL1,
commandA2,
commandL2,
].join(' ');
};
/**
* Prepares data for the chart segment path calculations
* @function createChartSegmentPathDraw
* @param { TCreateChartSegmentPathDraw } props
* @return { string } path
*/
const createChartSegmentPathDraw = (props) => {
const { radiusInner, radiusOuter, size, valueSegment, valueSegmentsPreviousTotal, valueSegmentsTotal, } = props;
if (!checkIncomingValues(props)) {
return '';
}
/**
* proportion of previous segments
*/
const ratioPrev = valueSegmentsPreviousTotal / (valueSegmentsTotal || 1);
/**
* proportion of the current segment to total chart
*/
const ratioCurrent = valueSegment / (valueSegmentsTotal || 1);
/**
* start angle of the current segment
*/
const angleStartDegrees = 360 * ratioPrev;
/**
* end angle of the current segment
*/
const angleEndDegrees = 360 * (ratioPrev + ratioCurrent);
/**
* angle of the current segment
*/
const angleDegrees = angleEndDegrees - angleStartDegrees;
return createSvgCommandsString({
angleDegrees,
radiusInner,
radiusOuter,
size,
});
};
/**
* Defines is current event is fired by the "enter" key press or not
* @function { (e: React.KeyboardEvent) => boolean } isKeyDownEnter
* @param { React.KeyboardEvent } e - KeyboardEvent
* @return { boolean } - is current event is fired by the "enter" key press
*/
const isKeyDownEnter = (e) => {
var _a, _b, _c, _d;
return ((_b = (_a = e.code) === null || _a === void 0 ? void 0 : _a.toLowerCase) === null || _b === void 0 ? void 0 : _b.call(_a)) === 'enter' ||
(e.code && String(e.code) === '13') ||
(e.key && String(e.key) === '13') ||
((_d = (_c = e.key) === null || _c === void 0 ? void 0 : _c.toLowerCase) === null || _d === void 0 ? void 0 : _d.call(_c)) === 'enter';
};
const DEFAULT_SANITISE_NUMBER_VALUE = 0;
/**
* Sanitizes number
* @function { (e: React.KeyboardEvent) => boolean } isKeyDownEnter
* @param { number } n - number to sanitize
* @param { number = 0 } defaultNumber - default number
* @return { number } - sanitized number
*/
const sanitizeNumber = (n, defaultNumber = DEFAULT_SANITISE_NUMBER_VALUE) => {
if (isNaN(n)) {
consoleError(createErrorWithDescription({
messageMain: 'In most cases, this error is caused by invalid props.',
report: `
Error in: "sanitizeNumber" function
Received value: ${n}
`,
}));
return defaultNumber;
}
return n;
};
/** correction ratio when passed only one data item */
const SINGLE_VALUE_CORRECTION_RATIO = 0.99999999;
/** default focused segment stroke width to size ratio */
const DEFAULT_FOCUSED_SEGMENT_STROKE_WIDTH_TO_SIZE_RATIO = 0.0066;
/** default outline color of focused segment */
const DEFAULT_COLOR_SEGMENT_FOCUSED_OUTLINE = '#287bc8';
/** default chart center color */
const DEFAULT_CHART_CENTER_COLOR = '#ffffff';
/** default chart text color */
const DEFAULT_CHART_TEXT_COLOR = '#333333';
/** default chart segment scale ratio */
const DEFAULT_CHART_SEGMENT_SCALE_RATIO = 1.05;
/** default resize re render debounce time */
const DEFAULT_RESIZE_RE_RENDER_DEBOUNCE_TIME = 0 /* ms */;
/** default animation speed */
const DEFAULT_ANIMATION_SPEED = 200 /* ms */;
/** segment offset correction angle */
const SEGMENT_OFFSET_CORRECTION_ANGLE = 90;
const TEST_DATA_ID_CHART_GROUP_SEGMENTS = 'TEST_DATA_ID_CHART_GROUP_SEGMENTS';
const TEST_DATA_ID_CHART_GROUP_SEGMENT = 'TEST_DATA_ID_CHART_GROUP_SEGMENT';
const TEST_DATA_ATTR_CHART_GROUP_SEGMENT_ID = 'data-test-id';
const TEST_DATA_ATTR_CHART_GROUP_SEGMENT_GAP = 'data-test-gap';
const TEST_DATA_ATTR_CHART_GROUP_SEGMENT_THICKNESS = 'data-test-thickness';
const TEST_DATA_ATTR_CHART_GROUP_SEGMENT_SELECTED = 'data-test-selected';
/**
* Main chart component
* @component Chart
* @param { TChartProps } props
* @returns { JSX.Element } returns svg group <g> of <path> (segments)
*/
const Chart = (props) => {
const { chartRef, classNameChartSegment, classNameSvgGroupSegments, colorSegmentFocusedOutline, data, donutThickness, focusedSegment, gap, handleClearSelects, hoverScaleRatio, hoveredSegment, isScaleOnHover, isSelectOnClick, isSelectOnKeyEnterDown, mouseDownSegment, onSegmentClick, onSegmentKeyEnterDown, radius, segmentsStyles, selected, setFocusedSegment, setHoveredSegment, setMouseDownSegment, setSelected, size, strokeWidth, stylesHoveredSegment, tabIndex, totalDataValue, } = props;
if (!data.length) {
return null;
}
return (React__default["default"].createElement("g", { className: classNameSvgGroupSegments, "data-testid": TEST_DATA_ID_CHART_GROUP_SEGMENTS, onBlurCapture: handleClearSelects, onMouseLeave: () => {
if (hoveredSegment) {
setHoveredSegment(null);
}
}, ref: chartRef }, data.map((item, i) => {
var _a;
/**
* if current segment is a gap
*/
const isGapSegment = !!gap && i % 2 !== 0;
const { color, id, value: valueParam, } = item;
let value = valueParam;
/**
* correction when only one item in data
*/
if (data.length === 1) {
value = value * SINGLE_VALUE_CORRECTION_RATIO;
}
/**
* the sum of previous segments values
*/
let prevTotal = 0;
if (i > 0) {
prevTotal = ((_a = data === null || data === void 0 ? void 0 : data.filter((_, index) => index < i)) === null || _a === void 0 ? void 0 : _a.reduce((c, n) => c + n.value, 0)) || 0;
}
/**
* the proportion of previous segments
*/
const prevTotalPercentage = sanitizeNumber(prevTotal) / sanitizeNumber(totalDataValue, 1) * 100;
/**
* the proportion of the current segment
*/
const currentPercentage = sanitizeNumber(value) / sanitizeNumber(totalDataValue, 1) * 100;
/**
* the offset of the current segment
* + corrections depends on index and svg rotation
*/
let segmentOffset = convertPercentToDegrees({ percent: currentPercentage }) - SEGMENT_OFFSET_CORRECTION_ANGLE;
if (i !== 0) {
segmentOffset = segmentOffset + convertPercentToDegrees({ percent: prevTotalPercentage });
}
/**
* is current segment selected
*/
const isSelected = selected === id;
/**
* is current segment hovered
*/
const isHovered = hoveredSegment === id;
/**
* is current segment focused
*/
const isFocused = focusedSegment === id;
/**
* is mouse down on current segment
*/
const isMouseDown = mouseDownSegment === id;
/**
* the 'transform' of the current segment
*/
let transform = `rotate(${segmentOffset}deg) scale(1)`;
if (!isGapSegment && (isSelected || isFocused || (isScaleOnHover && isHovered))) {
transform = `rotate(${segmentOffset}deg) scale(${hoverScaleRatio})`;
}
/**
* className
*/
const classNameSegmentDefault = 'PieDonutChart__segment';
const classNameSegment = classNameChartSegment
? `${classNameSegmentDefault} ${classNameChartSegment}`
: classNameSegmentDefault;
/**
* stroke on focus
*/
let stroke = undefined;
if (!isGapSegment && isFocused && !isMouseDown) {
stroke = colorSegmentFocusedOutline || DEFAULT_COLOR_SEGMENT_FOCUSED_OUTLINE;
}
/**
* create the segment path
*/
const segmentPath = createChartSegmentPathDraw({
radiusInner: donutThickness ? radius - donutThickness : 0,
radiusOuter: radius,
size,
valueSegment: value,
valueSegmentsPreviousTotal: prevTotal,
valueSegmentsTotal: totalDataValue,
});
/**
* Tests attributes (gap segments)
*/
const testsAttributesGap = { [TEST_DATA_ATTR_CHART_GROUP_SEGMENT_GAP]: gap };
/**
* Tests attributes
*/
const testsAttributes = Object.assign({ [TEST_DATA_ATTR_CHART_GROUP_SEGMENT_ID]: id, [TEST_DATA_ATTR_CHART_GROUP_SEGMENT_SELECTED]: isSelected, [TEST_DATA_ATTR_CHART_GROUP_SEGMENT_THICKNESS]: donutThickness }, (isGapSegment ? testsAttributesGap : {}));
return (React__default["default"].createElement("path", Object.assign({}, testsAttributes, { className: classNameSegment, d: segmentPath, "data-testid": TEST_DATA_ID_CHART_GROUP_SEGMENT, fill: color, key: `chart-segment-${id}`, onClick: () => {
if (isGapSegment) {
return;
}
if (isSelectOnClick && selected !== id) {
setSelected(id);
}
onSegmentClick === null || onSegmentClick === void 0 ? void 0 : onSegmentClick(id);
}, onFocus: () => {
if (isGapSegment) {
return;
}
if (focusedSegment !== id) {
setFocusedSegment(id);
}
}, onKeyDownCapture: e => {
if (isGapSegment) {
return;
}
/**
* fires only if key === 'enter'
*/
if (isKeyDownEnter(e)) {
if (isSelectOnKeyEnterDown && selected !== id) {
setSelected(id);
}
onSegmentKeyEnterDown === null || onSegmentKeyEnterDown === void 0 ? void 0 : onSegmentKeyEnterDown(id);
}
}, onMouseDown: () => {
if (isGapSegment) {
return;
}
if (mouseDownSegment !== id) {
setMouseDownSegment(id);
}
}, onMouseOverCapture: () => {
if (isGapSegment) {
return;
}
if (hoveredSegment !== id) {
setHoveredSegment(id);
}
}, onMouseUp: e => {
if (isGapSegment) {
return;
}
setMouseDownSegment(null);
e.currentTarget.blur();
}, stroke: stroke, strokeLinecap: 'round', strokeLinejoin: 'round', strokeWidth: isGapSegment ? 0 : strokeWidth, style: Object.assign(Object.assign(Object.assign({}, segmentsStyles), { cursor: isGapSegment ? 'initial' : 'pointer', outline: 'none', transform, transitionProperty: 'all' }), (!isGapSegment && isHovered && stylesHoveredSegment ? stylesHoveredSegment : {})), tabIndex: isGapSegment ? -1 : tabIndex })));
})));
};
const TEST_DATA_ID_CHART_BACKGROUND = 'TEST_DATA_ID_CHART_BACKGROUND';
/**
* Chart's background
* @component ChartBackground
* @param { TChartBackground } props
* @returns { JSX.Element } returns svg circle <circle>
*/
const ChartBackground = props => {
const { classNameChartBackground, colorChartBackground, radius, } = props;
if (!colorChartBackground) {
return null;
}
return (React__default["default"].createElement("circle", { className: classNameChartBackground, cx: radius, cy: radius, "data-testid": TEST_DATA_ID_CHART_BACKGROUND, fill: colorChartBackground, r: radius, style: { pointerEvents: 'none' } }));
};
const TEST_DATA_ID_CHART_CENTER = 'TEST_DATA_ID_CHART_CENTER';
/**
* Chart's center ("donut hole")
* @component ChartCenter
* @param { TChartCenter } props
* @returns { JSX.Element } returns svg circle <circle>
*/
const ChartCenter = props => {
const { centerSize, classNameChartCenter, colorChartCenter, radius, } = props;
if (!centerSize) {
return null;
}
return (React__default["default"].createElement("circle", { className: classNameChartCenter, cx: radius, cy: radius, "data-testid": TEST_DATA_ID_CHART_CENTER, fill: colorChartCenter, r: centerSize / 2, style: { pointerEvents: 'none' } }));
};
const TEST_DATA_ID_CHART_SEGMENTS_BACKGROUND = 'TEST_DATA_ID_CHART_SEGMENTS_BACKGROUND';
/**
* Chart segments background. Same shape as chart's segments but without gap.
* @component ChartSegmentsBackground
* @param { TChartSegmentsBackground } props
* @returns { JSX.Element } returns svg group <g> of <path>
*/
const ChartSegmentsBackground = props => {
const { classNameChartSegmentsBackground, classNameSvgGroupSegmentsBackground, colorSegmentsBackground, donutThickness, radius, } = props;
if (!colorSegmentsBackground) {
return null;
}
return (React__default["default"].createElement("g", { className: classNameSvgGroupSegmentsBackground },
React__default["default"].createElement("circle", { className: classNameChartSegmentsBackground, cx: radius, cy: radius, "data-testid": TEST_DATA_ID_CHART_SEGMENTS_BACKGROUND, fill: "none", r: donutThickness ? radius - donutThickness / 2 : 0, stroke: colorSegmentsBackground, strokeWidth: donutThickness, style: { pointerEvents: 'none' } })));
};
const TEST_DATA_ID_CHART_TEXT_FOREIGN_OBJECT = 'TEST_DATA_ID_CHART_TEXT_FOREIGN_OBJECT';
/**
* Chart's "text" or (and) "children"
* @component ChartText
* @param { TChartText } props
* @returns { JSX.Element } returns svg group <g> of <foreignObject> which contains passed "text" or (and) "children"
*/
const ChartText = props => {
const { children, classNameChildren, classNameSvgGroupText, classNameSvgObjectText, classNameText, colorText, fontSize, size, text, } = props;
if (!text && !children) {
return null;
}
return (React__default["default"].createElement("g", { className: classNameSvgGroupText, style: { pointerEvents: 'none' } },
React__default["default"].createElement("foreignObject", { className: classNameSvgObjectText, "data-testid": TEST_DATA_ID_CHART_TEXT_FOREIGN_OBJECT, height: size, style: { position: 'relative' }, width: size, x: "0", y: "0" },
text && (React__default["default"].createElement("div", { className: classNameText, style: {
alignItems: 'center',
color: colorText,
display: 'flex',
fontSize: `${fontSize}px`,
height: '100%',
justifyContent: 'center',
position: 'absolute',
transition: 'font-size .3s, color .3s',
width: '100%',
} }, text)),
children && (React__default["default"].createElement("div", { className: classNameChildren, style: {
display: 'flex',
height: '100%',
position: 'absolute',
width: '100%',
} }, children)))));
};
/**
* Resize handler
* Checks if new size is can be calculated, if it's valid when calculated
* and fires callback when new valid size calculated
*/
const resizeHandler = (props) => {
const { maxSize, minSize, parentRef, size, updateSize, } = props;
const { offsetHeight, offsetWidth, } = (parentRef === null || parentRef === void 0 ? void 0 : parentRef.current) || {};
const h = offsetHeight || size;
const w = offsetWidth || size;
if (typeof h !== 'number' || typeof w !== 'number') {
return;
}
let s = 0;
if (h === w && w > 0) {
s = h;
}
else if (h > 0 && w > 0) {
s = h > w ? w : h;
}
else if (h && h > 0) {
s = h;
}
else if (w && w > 0) {
s = w;
}
if (s) {
if (minSize && (minSize >= s)) {
updateSize(minSize);
}
else if (maxSize && (maxSize <= s)) {
updateSize(maxSize);
}
else {
updateSize(s);
}
}
else if (minSize) {
updateSize(minSize);
}
else {
updateSize(0);
}
};
const processResizeMutationDefaultErrText = 'Error while processing "processResizeMutation" function:';
const processResizeMutationNoMutationsErrText = 'No mutations received';
const processResizeMutationElNotFoundErrText = 'Node element is not found in the received mutation';
const processResizeMutationElInvalidErrText = `
Received mutation has invalid Node element.
Node element has invalid "clientWidth" or "clientHeight" param (or both)
`;
const processResizeMutationNoChangesErrText = 'Received mutation has no changes';
const CUSTOM_NODE_EVENT_NAME_RESIZE = 'resize';
/**
* Mutations calls subscription processing
* @param {MutationRecord[]} mutations
*/
const processResizeMutation = (mutations) => {
var _a;
if (!(mutations === null || mutations === void 0 ? void 0 : mutations.length) || (mutations && !Array.isArray(mutations))) {
if (isTest()) {
consoleError(`
${processResizeMutationDefaultErrText}
${processResizeMutationNoMutationsErrText}
`);
}
return;
}
const el = mutations[0].target;
if (!el) {
if (isTest()) {
consoleError(`
${processResizeMutationDefaultErrText}
${processResizeMutationElNotFoundErrText}
`);
}
return;
}
const w = el.clientWidth;
const h = el.clientHeight;
if (typeof w !== 'number' || typeof h !== 'number') {
if (isTest()) {
consoleError(`
${processResizeMutationDefaultErrText}
${processResizeMutationElInvalidErrText}
`);
}
return;
}
const isChange = mutations
.find(m => {
if (!m.oldValue) {
return true;
}
const oldValue = String(m.oldValue);
const isWidthChanged = !oldValue.includes(`width: ${w}px`);
const isHeightChanged = !oldValue.includes(`height: ${h}px`);
return isWidthChanged || isHeightChanged;
});
if (!isChange) {
if (isTest()) {
consoleError(`
${processResizeMutationDefaultErrText}
${processResizeMutationNoChangesErrText}
`);
}
return;
}
const event = new CustomEvent(CUSTOM_NODE_EVENT_NAME_RESIZE, { detail: {
height: h,
width: w,
} });
(_a = el.dispatchEvent) === null || _a === void 0 ? void 0 : _a.call(el, event);
};
/**
* Custom node event name
*/
/**
* Inspired by: https://stackoverflow.com/a/46555778/14140292
*
* @param {TResizeListener} props
* @function useResizeListener (hook)
*/
const startResizeListener = (props) => {
const { cb, node, } = props;
/**
* Custom mutation calls subscription
*/
const observer = new MutationObserver(processResizeMutation);
if (node === null || node === void 0 ? void 0 : node.nodeName) {
observer.observe(node, {
attributeFilter: ['style'],
attributeOldValue: true,
attributes: true,
});
node.addEventListener('resize', cb);
}
};
/**
* Hook allows you to determine if the component is in the DOM or the component is already unmounted
* @function useIsMounted (hook)
* @return { TUseIsMountedReturn } - returns function that returns a boolean value
*/
const useIsMounted = () => {
const ref = React.useRef(false);
React.useEffect(() => {
ref.current = true;
return () => {
ref.current = false;
};
}, []);
return React.useCallback(() => {
/**
* It's safer for tests. I think so =)
*/
if (isTest()) {
return true;
}
return ref.current;
}, [ref]);
};
/**
* Debounce passed callback
* @function { (cb: (...args: T) => void, wait: number): (...args: T) => void } debounce
*
* @typeParam T - Type of params passed to the callback arguments
* @param { string } cb - callback to debounce
* @param { number } wait - debounce time
*
* @return debounce passed callback;
*
* @example
* ```
* const debouncedFunction = debounce(() => {
* // some code to debounce
* }, 50);
* ```
*/
const debounce = (cb, wait) => {
let timer;
return (...args) => {
clearTimeout(timer);
return new Promise(resolve => {
timer = setTimeout(() => resolve(cb(...args)), wait);
});
};
};
/**
* Hook manages chart size param.
* This hook listens for window or chart's container "resize" events,
* and incoming chart's size props (maxSize, minSize, etc...) changes
* @function useHandleResize (hook)
* @param { TUseHandleResize } props
* @return { TUseHandleResizeReturn } - current chart's params (size)
*/
const useHandleResize = (props) => {
const { animationDuration, maxSize, minSize, parentRef, resizeReRenderDebounceTime, setAnimationDuration, size: sizeProp, } = props;
const isMounted = useIsMounted();
const [size, setSize] = React.useState(0);
const [parentRefCurrent, setParentRefCurrent] = React.useState(null);
const processUpdate = React.useCallback((newSize) => {
if (isMounted()) {
/**
* Freezes animation to prevent unnecessary animation bugs while resize.
* It will be restored automatically
*/
if (animationDuration !== 0) {
setAnimationDuration(0);
}
setSize(newSize);
}
}, [
animationDuration,
isMounted,
setAnimationDuration,
]);
/**
* debounced size updater
*/
const updateSizeDebounced = debounce((newSize) => {
if (newSize !== size && isMounted()) {
processUpdate(newSize);
}
}, resizeReRenderDebounceTime);
/**
* updates size
*/
const updateSize = React.useCallback((newSize) => {
const n = sanitizeNumber(newSize, size);
if (resizeReRenderDebounceTime === 0) {
processUpdate(newSize);
}
else {
updateSizeDebounced(n);
}
}, [
processUpdate,
resizeReRenderDebounceTime,
size,
updateSizeDebounced,
]);
/**
* resize handler
*/
const handleResize = React.useCallback(() => resizeHandler({
maxSize,
minSize,
parentRef,
size: sizeProp,
updateSize,
}), [
maxSize,
minSize,
parentRef,
sizeProp,
updateSize,
]);
/**
* useResizeListener callback
*/
/**
* listens for the 'resize' custom event on the parent container
*/
React.useLayoutEffect(() => {
startResizeListener({
cb: handleResize,
node: (parentRef === null || parentRef === void 0 ? void 0 : parentRef.current) || null,
});
}, [handleResize, parentRef]);
/**
* fires handleResize on window's 'resize' event
*/
React.useLayoutEffect(() => {
if (isClient() && !sizeProp) {
window.addEventListener('resize', handleResize) /* re-renders svg if parent container resized */;
return () => window.removeEventListener('resize', handleResize);
}
return undefined;
}, [handleResize, sizeProp]);
/**
* initializes size
*/
React.useLayoutEffect(() => {
const isReadyForSize = (parentRef === null || parentRef === void 0 ? void 0 : parentRef.current) || sizeProp;
if (isClient() && isReadyForSize && !size) {
handleResize();
}
return undefined;
}, [
handleResize,
parentRef,
size,
sizeProp,
]);
/**
* Listen for "parentRef" prop changes
*/
React.useEffect(() => {
if ((parentRef === null || parentRef === void 0 ? void 0 : parentRef.current) && parentRefCurrent !== parentRef.current) {
setParentRefCurrent(parentRef.current);
handleResize();
}
}, [
handleResize,
parentRef,
parentRefCurrent,
]);
/**
* Listen for "size" prop changes
*/
React.useEffect(() => {
if (sizeProp && size !== sizeProp) {
handleResize();
}
}, [
handleResize,
parentRef,
parentRefCurrent,
size,
sizeProp,
]);
return { size };
};
const ChanelRand = () => Math.floor(Math.random() * (256 + 1));
const rgbRand = () => [
ChanelRand(),
ChanelRand(),
ChanelRand(),
];
const rgbToHex = (rgb) => ((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1);
const colorRand = () => rgbToHex(rgbRand());
/**
* Generates random HEX color string
* @function { () => string } randomColorHEX
* @return { string } returns random HEX color string
*/
const randomColorHEX = () => '#' + colorRand();
/**
* Generates unique ID string
* @function { (complexity?: number) => string } generateUniqueID
* @param { number } complexity - complexity of the generated ID string
* @return { string } unique id
*
* @example
* ```
* const id = generateUniqueID() // 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX'
* const idLite = generateUniqueID(2) // 'XXXX-XXXX'
* ```
*/
const generateUniqueID = (complexity = 8) => {
const cmplxt = typeof complexity === 'number' && complexity > 0 ? complexity : 8;
const chr4 = () => Math.random()
.toString(16)
.slice(-4);
const newIdArr = Array(cmplxt)
.fill(null)
.map(chr4);
if (newIdArr.length === 1) {
return newIdArr[0];
}
return newIdArr.join('-');
};
const USE_CHART_DATA_REMAP_ERR_UNIQUE_ID_TEXT = 'It recommended to you to check provided "data" and make sure all id are unique';
const USE_CHART_DATA_REMAP_ERR_UNIQUE_ORDER_TEXT = 'Items with equal "order" params will be sorted one by another.';
/**
* 1. checks if some fields in data items are undefined
* 2. sorts data items
* 3. add gaps if it's needed
*
* @function useChartDataRemap (hook)
* @param { TUseChartDataRemap } props
* @return { TDataItemRequired[] } returns "re-mapped" sorted data (with all necessary params) and fake "gap" segments (if it's needed)
*/
const useChartDataRemap = (props) => {
const { data: dataProp, gap, } = props;
const incomingData = React.useMemo(() => {
const dataValid = dataProp.filter(segment => !!segment.value);
if (!dataValid.length) {
return [];
}
return dataValid
.map((item, i) => {
let id = item.id;
if (id && dataValid.filter(dataItem => dataItem.id === id).length > 1) {
const newId = generateUniqueID();
consoleWarn(`
Data item #${i} param "id" error: Must be unique.
Provided: "id" = ${id}
This was caught and now one of equals "id" replaced with: "id" = ${newId}
${USE_CHART_DATA_REMAP_ERR_UNIQUE_ID_TEXT}
`);
id = newId;
}
let order = item.order;
if (typeof order === 'number' && dataValid.filter(dataItem => dataItem.order === order).length > 1) {
consoleWarn(`
Data item #${i} param "order" error: Should be unique.
Provided: "order" = ${order}
${USE_CHART_DATA_REMAP_ERR_UNIQUE_ORDER_TEXT}
Just make sure the result on the chart is what you expected.
`);
}
order = 0;
if (dataValid.length > 1) {
order = item.order || dataValid.length + 1 + i;
}
return {
color: item.color || randomColorHEX(),
id: id || generateUniqueID(),
order,
value: item.value,
};
})
.sort((a, b) => {
if (a.order < b.order) {
return -1;
}
if (a.order > b.order) {
return 1;
}
return 0;
});
}, [dataProp]);
return React.useMemo(() => {
if (!gap) {
return incomingData;
}
const segments = [];
if (gap) {
const arrLength = incomingData.length > 1 ? incomingData.length * 2 : 1;
Array(arrLength)
.fill(null)
.forEach((_, i) => {
if (i === 0) {
segments.push(Object.assign(Object.assign({}, incomingData[0]), { order: 0 }));
return;
}
if (i % 2 !== 0) {
segments.push({
color: 'transparent',
id: generateUniqueID(),
order: i,
value: gap,
});
return;
}
segments.push(Object.assign(Object.assign({}, incomingData[i / 2]), { order: i }));
});
}
return segments;
}, [gap, incomingData]);
};
const SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_LIGHT = '#fff';
const SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_DARK = '#111';
/**
* Listens for some incoming params changes and calculates some of current chart params
* @function useChartParams (hook)
* @param { TUseChartParams } props
* @return { TUseChartParamsReturn } returns chart params (centerSize, colorText, etc...)
*/
const useChartParams = (props) => {
const { animationDuration, chartCenterSize, colorChartBackground, colorChartCenter, colorText: colorTextProp, data, donutThickness, gap, isSelectedValueShownInCenter, selected, size, text: textProp, } = props;
/**
* sum of data item's values
*/
const totalDataValue = React.useMemo(() => data.reduce((current, next) => current + next.value, 0) || 0, [data]);
/**
* the item with the biggest value in data
*/
const biggestValueItem = React.useMemo(() => {
let bvi = data[0];
if (data.length > 1) {
data.forEach(item => {
if (item.value > bvi.value) {
bvi = item;
}
});
}
return bvi;
}, [data]) || 0;
/**
* chart viewBox
*/
const viewBox = `0 0 ${size || 0} ${size || 0}`;
/**
* chart radius
*/
const radius = React.useMemo(() => sanitizeNumber(size / 2), [size]);
/**
* chart center circle radius
*/
const centerSize = React.useMemo(() => {
if (typeof chartCenterSize !== 'undefined' && typeof chartCenterSize === 'number') {
return chartCenterSize;
}
if (typeof donutThickness !== 'undefined' && donutThickness && typeof donutThickness === 'number') {
return (radius - donutThickness) * 2;
}
return 0;
}, [
chartCenterSize,
donutThickness,
radius,
]);
const segmentsStyles = React.useMemo(() => ({
transformOrigin: 'center',
transition: 'ease-in-out',
transitionDuration: `${animationDuration}ms`,
}), [animationDuration]);
const text = React.useMemo(() => {
if (textProp) {
return textProp;
}
if (centerSize || donutThickness) {
if (isSelectedValueShownInCenter && selected) {
return String(selected.value);
}
if (data.length > 1 && gap) {
return String(totalDataValue - gap * data.length / 2);
}
return String(totalDataValue);
}
return '';
}, [
centerSize,
data.length,
donutThickness,
gap,
isSelectedValueShownInCenter,
selected,
textProp,
totalDataValue,
]);
/**
* current text color
*/
const colorText = React.useMemo(() => {
if (colorTextProp) {
return colorTextProp;
}
if (data.length === 1) {
let textBackgroundColor = colorChartBackground || SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_LIGHT;
if (centerSize || donutThickness) {
if (colorChartCenter) {
textBackgroundColor = colorChartCenter;
}
}
else if (data[0].color) {
textBackgroundColor = data[0].color;
}
textBackgroundColor.toLocaleLowerCase();
const colorSegment = (data[0].color || (selected === null || selected === void 0 ? void 0 : selected.color) || (biggestValueItem === null || biggestValueItem === void 0 ? void 0 : biggestValueItem.color) || SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_LIGHT).toLocaleLowerCase();
let c = SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_LIGHT;
const isTextBackgroundColorWhite = (textBackgroundColor === 'white' || textBackgroundColor.slice(0, 3) === 'fff');
if ((textBackgroundColor.startsWith('#') && colorSegment.startsWith('#')) || (textBackgroundColor === colorSegment)) {
const textBackgroundColorClean = textBackgroundColor.replace('#', '');
const colorSegmentClean = colorSegment.replace('#', '');
if (textBackgroundColorClean === colorSegmentClean
&& (isTextBackgroundColorWhite || SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_DARK.toLowerCase().replace('#', '') !== colorSegmentClean)) {
c = SINGLE_SEGMENT_COLOR_TEXT_DEFAULT_DARK;
}
}
return c;
}
if (textProp) {
return biggestValueItem.color;
}
if (selected) {
return selected.color;
}
return DEFAULT_CHART_TEXT_COLOR;
}, [
biggestValueItem.color,
colorTextProp,
selected,
textProp,
centerSize,
colorChartBackground,
colorChartCenter,
data,
donutThickness,
]);
return {
centerSize,
colorText,
donutThickness,
radius,
segmentsStyles,
text,
totalDataValue,
viewBox,
};
};
/**
* Determines if there is a selected segment in chart
* @function useChartSelectedSegment (hook)
* @param { TUseChartSelectedSegment } props
* @return { TDataItemRequired | null } returns chart's selected segment
*/
const useChartSelectedSegment = (props) => {
const { data, focusedSegment, isSelectedValueShownInCenter, selected, } = props;
return React.useMemo(() => {
if (!data || !Array.isArray(data) || !data.length || !isSelectedValueShownInCenter || (!focusedSegment && !selected)) {
return null;
}
if (data.length === 1) {
return data[0];
}
const s = data.find(item => item.id === focusedSegment || item.id === selected);
if (!s) {
return null;
}
return s;
}, [
data,
focusedSegment,
isSelectedValueShownInCenter,
selected,
]);
};
const useClickOutsideDefaultErrText = 'Error while processing "useClickOutside" hook:';
const useClickOutsideNoCbErrText = 'No callback received';
/**
* Hook to listen for clicks outside the target component or the "escape" key press
* @function useClickOutside (hook)
* @param {TUseClickOutside} props
*
* @example
* ```
* const elementRef = useRef<HTMLElement>(null);
* const handleClickOutside = () => {
* // some actions
* };
*
* useClickOutside({
* callback: handleClickOutside,
* ref: elementRef,
* isWithKeyEsc: true,
* });
* ```
*/
const useClickOutside = (props) => {
const { callback, isWithKeyEsc, ref, } = props;
React.useEffect(() => {
if (!callback) {
if (isTest()) {
consoleError(`
${useClickOutsideDefaultErrText}
${useClickOutsideNoCbErrText}
`);
}
return undefined;
}
const handleClick = (e) => {
const element = ref.current;
const target = e.target;
let composedPath = e.composedPath;
let composedPathArr;
/**
* This is workaround.
* I didn't find a way to mock MouseEvent.composedPath.
* I tried running "fireEvent.click(button, {composedPath: undefined})" but that didn't work.
*
* If you are reading this, and you know how to solve this - please contact me =)
*/
try {
if (isTest()) {
composedPath = undefined;
}
composedPathArr = (composedPath === null || composedPath === void 0 ? void 0 : composedPath()) || [];
}
catch (_a) {
composedPathArr = [];
}
if (element &&
element !== target &&
(composedPath
? !composedPathArr.includes(element)
: !element.contains(target))) {
callback(e);
}
};
const handleKeyUp = (e) => {
if (e.key.toLowerCase() === 'escape') {
callback(e);
}
};
let active = true;
setTimeout(() => {
if (active) {
window.addEventListener('click', handleClick);
if (isWithKeyEsc) {
window.addEventListener('keyup', handleKeyUp);
}
}
}, 0);
return () => {
active = false;
window.removeEventListener('click', handleClick);
if (isWithKeyEsc) {
window.removeEventListener('keyup', handleKeyUp);
}
};
}, [
ref,
callback,
isWithKeyEsc,
]);
};
const SAFETY_ANIMATION_DURATION_UPDATE_TIME = 100;
/**
* Hook manages chart states
* @function { (props: TUseChartStates) => TUseChartStatesReturn } useChartStates (hook)
* @param { TUseChartStates } props
*/
const useChartStates = (props) => {
const { animationSpeed: animationSpeedProp } = props;
const chartRef = React.useRef(null);
const isMounted = useIsMounted();
const [selectedState, setSelected] = React.useState(null);
const [mouseDownSegment, setMouseDownSegment] = React.useState(null);
const [focusedSegment, setFocusedSegment] = React.useState(null);
const [hoveredSegment, setHoveredSegment] = React.useState(null);
const [animationDuration, setAnimationDuration] = React.useState(0);
const handleClearSelects = React.useCallback(() => {
if (focusedSegment) {
setFocusedSegment(null);
}
if (selectedState) {
setSelected(null);
}
}, [
focusedSegment,
selectedState,
setFocusedSegment,
setSelected,
]);
/**
* Safety states update
* @param {TUpdateStateValue} p
*/
const updateStateValue = React.useCallback((p) => {
const { setter, value, } = p;
if (isMounted()) {
setter(value);
}
}, [isMounted]);
useClickOutside({
callback: handleClearSelects,
isWithKeyEsc: true,
ref: chartRef,
});
/**
* Safely update "animationDuration" state avoiding animation bugs
*/
React.useLayoutEffect(() => {
if (typeof animationSpeedProp === 'number' && animationDuration !== animationSpeedProp) {
const timeout = setTimeout(() => {
updateStateValue({
setter: setAnimationDuration,
value: animationSpeedProp,
});
}, SAFETY_ANIMATION_DURATION_UPDATE_TIME);
return () => {
clearTimeout(timeout);
};
}
return undefined;
}, [
animationDuration,
animationSpeedProp,
updateStateValue,
]);
return {
animationDuration,
chartRef,
focusedSegment,
handleClearSelects,
hoveredSegment,
mouseDownSegment,
selectedState,
setAnimationDuration: value => updateStateValue({
setter: setAnimationDuration,
value,
}),
setFocusedSegment: value => updateStateValue({
setter: setFocusedSegment,
value,
}),
setHoveredSegment: value => updateStateValue({
setter: setHoveredSegment,
value,
}),
setMouseDownSegment: value => updateStateValue({
setter: setMouseDownSegment,
value,
}),
setSelected: value => updateStateValue({
setter: setSelected,
value,
}),
};
};
const lowerCaseFirstLetter = (str) => {
if (!str || typeof str !== 'string') {
consoleError(createErrorWithDescription({
messageMain: `
Something went wrong while working with strings...
And seems like it's an INTERNAL LIBRARY error.
`,
report: `
Error in: "lowerCaseFirstLetter" function
Received str: ${str}
`,
}));
return '';
}
return str.charAt(0).toUpperCase() + str.slice(1);
};
const INVALID_DATA_DEFAULT_ERROR = 'Must be type of Array<TDataItem>';
var ETyp