From e6cc287920b2b05289262355321b30bb6e29ac73 Mon Sep 17 00:00:00 2001 From: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Sun, 29 Dec 2024 20:57:12 -0300 Subject: [PATCH 001/106] Almost responsive --- package.json | 4 +- src/App.tsx | 6 +- src/lib/GaugeComponent/hooks/chart.ts | 144 +++++++++++++-------- src/lib/GaugeComponent/index.tsx | 35 +++-- src/lib/GaugeComponent/types/Dimensions.ts | 4 +- 5 files changed, 118 insertions(+), 75 deletions(-) diff --git a/package.json b/package.json index 7796678..3ee7198 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ "typescript-eslint": "^7.14.1" }, "peerDependencies": { - "react": "^16.8.2 || ^17.0 || ^18.x", - "react-dom": "^16.8.2 || ^17.0 || ^18.x" + "react": "^16.8.2 || ^17.0 || ^18.x || ^19.x", + "react-dom": "^16.8.2 || ^17.0 || ^18.x || ^19.x" }, "publishConfig": { "registry": "https://registry.npmjs.org/" diff --git a/src/App.tsx b/src/App.tsx index 33d3e1d..23c0a0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,9 +9,9 @@ import 'react-resizable/css/styles.css'; const App = () => { return( <> - {/* */} - - + + {/* */} + {/* */} ) }; diff --git a/src/lib/GaugeComponent/hooks/chart.ts b/src/lib/GaugeComponent/hooks/chart.ts index 6564cd6..ea69c01 100644 --- a/src/lib/GaugeComponent/hooks/chart.ts +++ b/src/lib/GaugeComponent/hooks/chart.ts @@ -47,19 +47,34 @@ export const calculateAngles = (gauge: Gauge) => { //Renders the chart, should be called every time the window is resized export const renderChart = (gauge: Gauge, resize: boolean = false) => { const { dimensions } = gauge; + let gaugeTypeHeightCorrection: Record = { + [GaugeType.Semicircle]: 50, + [GaugeType.Radial]: 55, + [GaugeType.Grafana]: 55 + } let arc = gauge.props.arc as Arc; let labels = gauge.props.labels as Labels; //if resize recalculate dimensions, clear chart and redraw //if not resize, treat each prop separately if (resize) { - updateDimensions(gauge); + // updateDimensions(gauge); + var outerRadius = dimensions.current.outerRadius; + var parentNode = gauge.container.current.node().parentNode; + gauge.svg.current + .attr("width", parentNode.getBoundingClientRect().width) + .attr("height", parentNode.getBoundingClientRect().height); + gauge.g.current + .data([ + { + x: (parentNode.getBoundingClientRect().width / 2) - outerRadius, + y: dimensions.current.margin.top + } + ]) + .attr("transform", (d: any) => `translate(${d.x}, ${d.y})`); //Set dimensions of svg element and translations - gauge.g.current.attr( - "transform", - "translate(" + dimensions.current.margin.left + ", " + 35 + ")" - ); //Set the radius to lesser of width or height and remove the margins //Calculate the new radius + // centerGraph(gauge) calculateRadius(gauge); gauge.doughnut.current.attr( "transform", @@ -77,17 +92,6 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { labelsHooks.setupLabels(gauge); if (!gauge.props?.pointer?.hide) pointerHooks.drawPointer(gauge, resize); - let gaugeTypeHeightCorrection: Record = { - [GaugeType.Semicircle]: 50, - [GaugeType.Radial]: 55, - [GaugeType.Grafana]: 55 - } - let boundHeight = gauge.doughnut.current.node().getBoundingClientRect().height; - let boundWidth = gauge.container.current.node().getBoundingClientRect().width; - let gaugeType = gauge.props.type as string; - gauge.svg.current - .attr("width", boundWidth) - .attr("height", boundHeight + gaugeTypeHeightCorrection[gaugeType]); } else { let arcsPropsChanged = (JSON.stringify(gauge.prevProps.current.arc) !== JSON.stringify(gauge.props.arc)); let pointerPropsChanged = (JSON.stringify(gauge.prevProps.current.pointer) !== JSON.stringify(gauge.props.pointer)); @@ -114,59 +118,89 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { } } }; -export const updateDimensions = (gauge: Gauge) => { - const { marginInPercent } = gauge.props; - const { dimensions } = gauge; - var divDimensions = gauge.container.current.node().getBoundingClientRect(), - divWidth = divDimensions.width, - divHeight = divDimensions.height; - if (dimensions.current.fixedHeight == 0) dimensions.current.fixedHeight = divHeight + 200; - //Set the new width and horizontal margins - let isMarginBox = typeof marginInPercent == 'number'; - let marginLeft: number = isMarginBox ? marginInPercent as number : (marginInPercent as GaugeInnerMarginInPercent).left; - let marginRight: number = isMarginBox ? marginInPercent as number : (marginInPercent as GaugeInnerMarginInPercent).right; - let marginTop: number = isMarginBox ? marginInPercent as number : (marginInPercent as GaugeInnerMarginInPercent).top; - let marginBottom: number = isMarginBox ? marginInPercent as number : (marginInPercent as GaugeInnerMarginInPercent).bottom; - dimensions.current.margin.left = divWidth * marginLeft; - dimensions.current.margin.right = divWidth * marginRight; - dimensions.current.width = divWidth - dimensions.current.margin.left - dimensions.current.margin.right; +// export const updateDimensions = (gauge: Gauge) => { +// const { marginInPercent } = gauge.props; +// const { dimensions } = gauge; +// var parentNode = gauge.container.current.node().parentNode; +// var divDimensions = gauge.container.current.node().getBoundingClientRect(), +// divWidth = parentNode.getBoundingClientRect().width, +// divHeight = parentNode.getBoundingClientRect().height; +// // if (dimensions.current.fixedHeight == 0) dimensions.current.fixedHeight = divHeight + 200; +// //Set the new width and horizontal margins +// let isMarginBox = typeof marginInPercent == 'number'; +// let marginLeft: number = isMarginBox ? marginInPercent as number : +// (marginInPercent as GaugeInnerMarginInPercent).left; +// let marginRight: number = isMarginBox ? marginInPercent as number : +// (marginInPercent as GaugeInnerMarginInPercent).right; +// let marginTop: number = isMarginBox ? marginInPercent as number : +// (marginInPercent as GaugeInnerMarginInPercent).top; +// let marginBottom: number = isMarginBox ? marginInPercent as number : +// (marginInPercent as GaugeInnerMarginInPercent).bottom; +// // dimensions.current.margin.left = gauge.dimensions.current.margin.left; +// // dimensions.current.margin.right = divWidth * marginRight; +// // dimensions.current.margin.top = divHeight - marginTop; +// // dimensions.current.margin.bottom = divHeight * marginBottom; +// console.log("divHeight", divHeight); +// console.log("divWidth", divWidth); +// // (dimensions.current.margin.left - dimensions.current.margin.right); - dimensions.current.margin.top = dimensions.current.fixedHeight * marginTop; - dimensions.current.margin.bottom = dimensions.current.fixedHeight * marginBottom; - dimensions.current.height = dimensions.current.width / 2 - dimensions.current.margin.top - dimensions.current.margin.bottom; - //gauge.height.current = divHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; -}; +// // dimensions.current.margin.top = gauge.dimensions.current.margin.top; +// // dimensions.current.margin.bottom = dimensions.current.fixedHeight * marginBottom; +// // dimensions.current.margin.left = gauge.dimensions.current.margin.left; +// // // dimensions.current.margin.right = divWidth * marginRight; +// // dimensions.current.height = parentNode.getBoundingClientRect().height; +// // dimensions.current.width = parentNode.getBoundingClientRect().width; +// // dimensions.current.width / 2 - dimensions.current.margin.top - dimensions.current.margin.bottom; +// //gauge.height.current = divHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; +// }; export const calculateRadius = (gauge: Gauge) => { const { dimensions } = gauge; - //The radius needs to be constrained by the containing div - //Since it is a half circle we are dealing with the height of the div - //Only needs to be half of the width, because the width needs to be 2 * radius - //For the whole arc to fit + const parentNode = gauge.container.current.node().parentNode as HTMLElement; + const parentWidth = parentNode.getBoundingClientRect().width; + const parentHeight = parentNode.getBoundingClientRect().height; + + const availableWidth = parentWidth - dimensions.current.margin.left - dimensions.current.margin.right; + const availableHeight = parentHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; - //First check if it is the width or the height that is the "limiting" dimension - if (dimensions.current.width < 2 * dimensions.current.height) { - //Then the width limits the size of the chart - //Set the radius to the width - the horizontal margins - dimensions.current.outerRadius = (dimensions.current.width - dimensions.current.margin.left - dimensions.current.margin.right) / 2; + // if (gauge.props.type === GaugeType.Semicircle) { + // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); + // } else { + // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight); + // } + + if (availableWidth < availableHeight) { + dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); } else { - dimensions.current.outerRadius = - dimensions.current.height - dimensions.current.margin.top - dimensions.current.margin.bottom + 35; + dimensions.current.outerRadius = availableHeight / 2; } centerGraph(gauge); }; //Calculates new margins to make the graph centered +// export const centerGraph = (gauge: Gauge) => { +// const { dimensions } = gauge; +// dimensions.current.margin.left = +// dimensions.current.width / 2 - dimensions.current.outerRadius + dimensions.current.margin.right; +// gauge.g.current.attr( +// "transform", +// "translate(" + dimensions.current.margin.left + ", " + (dimensions.current.margin.top) + ")" +// ); +// }; + export const centerGraph = (gauge: Gauge) => { const { dimensions } = gauge; - dimensions.current.margin.left = - dimensions.current.width / 2 - dimensions.current.outerRadius + dimensions.current.margin.right; - gauge.g.current.attr( - "transform", - "translate(" + dimensions.current.margin.left + ", " + (dimensions.current.margin.top) + ")" - ); + const xOffset = dimensions.current.width / 2; + const yOffset = + gauge.props.type === GaugeType.Semicircle + ? dimensions.current.height + : dimensions.current.height / 2; + var marginTop = dimensions.current.margin.top; + var marginBottom = dimensions.current.margin.bottom; + var marginLeft = dimensions.current.margin.left; + var marginRight = dimensions.current.margin.right; + // gauge.g.current.attr("transform", `translate(${marginLeft}, ${marginTop})`); }; - export const clearChart = (gauge: Gauge) => { //Remove the old stuff labelsHooks.clearTicks(gauge); diff --git a/src/lib/GaugeComponent/index.tsx b/src/lib/GaugeComponent/index.tsx index a80683c..b4bb581 100644 --- a/src/lib/GaugeComponent/index.tsx +++ b/src/lib/GaugeComponent/index.tsx @@ -26,11 +26,12 @@ const GaugeComponent = (props: Partial) => { const pointer = useRef({ ...defaultPointerRef }); const container = useRef({}); const arcData = useRef([]); + const parentNode = useRef(); const pieChart = useRef(pie()); const dimensions = useRef({ ...defaultDimensions }); const mergedProps = useRef(props as GaugeComponentProps); const prevProps = useRef({}); - let selectedRef = useRef(null); + let svgRef = useRef(null); var gauge: Gauge = { props: mergedProps.current, @@ -71,7 +72,7 @@ const GaugeComponent = (props: Partial) => { useLayoutEffect(() => { updateMergedProps(); isFirstRun.current = isEmptyObject(container.current) - if (isFirstRun.current) container.current = select(selectedRef.current); + if (isFirstRun.current) container.current = select(svgRef.current); if (shouldInitChart()) chartHooks.initChart(gauge); gauge.prevProps.current = mergedProps.current; }, [props]); @@ -79,14 +80,14 @@ const GaugeComponent = (props: Partial) => { useEffect(() => { const observer = new MutationObserver(function () { setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - if (!selectedRef.current?.offsetParent) return; - + if (!svgRef.current?.offsetParent) return; + chartHooks.renderChart(gauge, true); observer.disconnect() }); - observer.observe(selectedRef.current?.parentNode, {attributes: true, subtree: false}); + observer.observe(svgRef.current?.parentNode, { attributes: true, subtree: false }); return () => observer.disconnect(); - }, [selectedRef.current?.parentNode?.offsetWidth, selectedRef.current?.parentNode?.offsetHeight]); + }, [svgRef.current?.parentNode?.offsetWidth, svgRef.current?.parentNode?.offsetHeight]); useEffect(() => { const handleResize = () => chartHooks.renderChart(gauge, true); @@ -96,13 +97,13 @@ const GaugeComponent = (props: Partial) => { }, [props]); // useEffect(() => { - // console.log(selectedRef.current?.offsetWidth) + // console.log(svgRef.current?.offsetWidth) // // workaround to trigger recomputing of gauge size on first load (e.g. F5) // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - // }, [selectedRef.current?.parentNode]); + // }, [svgRef.current?.parentNode]); useEffect(() => { - const element = selectedRef.current; + const element = svgRef.current; if (!element) return; const handleResize = () => { @@ -128,14 +129,22 @@ const GaugeComponent = (props: Partial) => { } }; }, []); - + const { id, style, className, type } = props; + // add height: -webkit-fill-available; + // width: -webkit-fill-available; + // to the style prop to make the gauge responsive + var styled = { + ...style, + height: "-webkit-fill-available", + width: "-webkit-fill-available" + }; return (
(selectedRef.current = svg)} + className={`${gauge.props.type}-gauge${className ? ' ' + className : ''}`} + style={styled} + ref={(svg) => (svgRef.current = svg)} /> ); }; diff --git a/src/lib/GaugeComponent/types/Dimensions.ts b/src/lib/GaugeComponent/types/Dimensions.ts index 71c36a1..aa564e3 100644 --- a/src/lib/GaugeComponent/types/Dimensions.ts +++ b/src/lib/GaugeComponent/types/Dimensions.ts @@ -20,10 +20,10 @@ export interface Dimensions { fixedHeight: number; } export const defaultMargins: Margin = { - top: 0, + top: 10, right: 0, bottom: 0, - left: 0 + left: 10 } export const defaultAngles: Angles = { startAngle: 0, From bdb613a3f8d4bd5c49e2910bf3d4c658bad3bde1 Mon Sep 17 00:00:00 2001 From: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Wed, 1 Jan 2025 20:52:31 -0300 Subject: [PATCH 002/106] tst --- src/lib/GaugeComponent/hooks/arc.ts | 2 +- src/lib/GaugeComponent/hooks/chart.ts | 76 +++++++++++++++------- src/lib/GaugeComponent/types/Dimensions.ts | 4 +- src/lib/GaugeComponent/types/Gauge.ts | 2 +- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/lib/GaugeComponent/hooks/arc.ts b/src/lib/GaugeComponent/hooks/arc.ts index d68fa0a..acbdd21 100644 --- a/src/lib/GaugeComponent/hooks/arc.ts +++ b/src/lib/GaugeComponent/hooks/arc.ts @@ -192,7 +192,7 @@ export const drawArc = (gauge: Gauge, percent: number | undefined = undefined) = .padAngle(arcPadding); var arcPaths = gauge.doughnut.current .selectAll("anyString") - .data(gauge.pieChart.current(data)) + .data(gauge.pieChart.current(data as any)) .enter() .append("g") .attr("class", "subArc"); diff --git a/src/lib/GaugeComponent/hooks/chart.ts b/src/lib/GaugeComponent/hooks/chart.ts index ea69c01..b592a29 100644 --- a/src/lib/GaugeComponent/hooks/chart.ts +++ b/src/lib/GaugeComponent/hooks/chart.ts @@ -54,36 +54,57 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { } let arc = gauge.props.arc as Arc; let labels = gauge.props.labels as Labels; - //if resize recalculate dimensions, clear chart and redraw - //if not resize, treat each prop separately + if (resize) { - // updateDimensions(gauge); - var outerRadius = dimensions.current.outerRadius; - var parentNode = gauge.container.current.node().parentNode; + var parentNode = gauge.container.current.node().parentNode as HTMLElement; + var parentWidth = parentNode.getBoundingClientRect().width; + var parentHeight = parentNode.getBoundingClientRect().height; + gauge.svg.current - .attr("width", parentNode.getBoundingClientRect().width) - .attr("height", parentNode.getBoundingClientRect().height); + .attr("width", parentWidth) + .attr("height", parentHeight) + .attr('preserveAspectRatio', 'xMinYMin') + // .attr("viewBox", `0 0 ${parentWidth} ${parentHeight}`); + + var outerRadius = dimensions.current.outerRadius; + // Adjust outerRadius to fit within the parent node's height + if (outerRadius > parentHeight) { + // outerRadius = parentHeight + outerRadius = dimensions.current.outerRadius; + } + else { + outerRadius = dimensions.current.outerRadius; + } + + + // var xGauge = ((parentWidth / 2) - outerRadius) + // + (dimensions.current.margin.left) - dimensions.current.margin.right; + // var yGauge = ((parentHeight / 2) - outerRadius) + // + (dimensions.current.margin.top); + //Center the gauge horizontally + var xGauge = (parentWidth / 2) - outerRadius + dimensions.current.margin.left; + //Fix the position of the gauge vertically at the top of the frame + var yGauge = dimensions.current.margin.top; + gauge.g.current .data([ { - x: (parentNode.getBoundingClientRect().width / 2) - outerRadius, - y: dimensions.current.margin.top + x: xGauge, + y: yGauge } ]) .attr("transform", (d: any) => `translate(${d.x}, ${d.y})`); - //Set dimensions of svg element and translations - //Set the radius to lesser of width or height and remove the margins - //Calculate the new radius - // centerGraph(gauge) + calculateRadius(gauge); gauge.doughnut.current.attr( "transform", "translate(" + dimensions.current.outerRadius + ", " + dimensions.current.outerRadius + ")" ); - //Hide tooltip failsafe (sometimes subarcs events are not fired) + gauge.doughnut.current .on("mouseleave", () => arcHooks.hideTooltip(gauge)) - .on("mouseout", () => arcHooks.hideTooltip(gauge)) + .on("mouseout", () => arcHooks.hideTooltip(gauge)); + let arcWidth = arc.width as number; dimensions.current.innerRadius = dimensions.current.outerRadius * (1 - arcWidth); clearChart(gauge); @@ -97,15 +118,14 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { let pointerPropsChanged = (JSON.stringify(gauge.prevProps.current.pointer) !== JSON.stringify(gauge.props.pointer)); let valueChanged = (JSON.stringify(gauge.prevProps.current.value) !== JSON.stringify(gauge.props.value)); let ticksChanged = (JSON.stringify(gauge.prevProps.current.labels?.tickLabels) !== JSON.stringify(labels.tickLabels)); - let shouldRedrawArcs = arcsPropsChanged + let shouldRedrawArcs = arcsPropsChanged; if (shouldRedrawArcs) { arcHooks.clearArcs(gauge); arcHooks.setArcData(gauge); arcHooks.setupArcs(gauge, resize); } - //If pointer is hidden there's no need to redraw it when only value changes var shouldRedrawPointer = pointerPropsChanged || (valueChanged && !gauge.props?.pointer?.hide); - if ((shouldRedrawPointer)) { + if (shouldRedrawPointer) { pointerHooks.drawPointer(gauge); } if (arcsPropsChanged || ticksChanged) { @@ -156,8 +176,9 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { export const calculateRadius = (gauge: Gauge) => { const { dimensions } = gauge; const parentNode = gauge.container.current.node().parentNode as HTMLElement; + const parentNodeOfTheParentNode = parentNode.parentNode as HTMLElement; const parentWidth = parentNode.getBoundingClientRect().width; - const parentHeight = parentNode.getBoundingClientRect().height; + const parentHeight = parentNodeOfTheParentNode.getBoundingClientRect().height ?? 0; const availableWidth = parentWidth - dimensions.current.margin.left - dimensions.current.margin.right; const availableHeight = parentHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; @@ -167,12 +188,17 @@ export const calculateRadius = (gauge: Gauge) => { // } else { // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight); // } - - if (availableWidth < availableHeight) { - dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); - } else { - dimensions.current.outerRadius = availableHeight / 2; - } + // if(availableHeight < availableWidth) { + // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); + // } + // else { + dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); + // dimensions.current.outerRadius = availableHeight; + // } + console.log(dimensions.current.outerRadius > availableHeight) + // if (dimensions.current.outerRadius > parentHeight) + console.log("outerRadius", dimensions.current.outerRadius) + console.log("parentHeight", parentHeight) centerGraph(gauge); }; diff --git a/src/lib/GaugeComponent/types/Dimensions.ts b/src/lib/GaugeComponent/types/Dimensions.ts index aa564e3..71c36a1 100644 --- a/src/lib/GaugeComponent/types/Dimensions.ts +++ b/src/lib/GaugeComponent/types/Dimensions.ts @@ -20,10 +20,10 @@ export interface Dimensions { fixedHeight: number; } export const defaultMargins: Margin = { - top: 10, + top: 0, right: 0, bottom: 0, - left: 10 + left: 0 } export const defaultAngles: Angles = { startAngle: 0, diff --git a/src/lib/GaugeComponent/types/Gauge.ts b/src/lib/GaugeComponent/types/Gauge.ts index ec23723..a4a01ad 100644 --- a/src/lib/GaugeComponent/types/Gauge.ts +++ b/src/lib/GaugeComponent/types/Gauge.ts @@ -14,7 +14,7 @@ export interface Gauge { dimensions: React.MutableRefObject; //This holds the computed data for the arcs, computed only once and then reused without changing original props to avoid render problems arcData: React.MutableRefObject; - pieChart: React.MutableRefObject; + pieChart: React.MutableRefObject>; //This holds the only tooltip element rendered for any given gauge chart to use tooltip: React.MutableRefObject; } From 326190f820dd6fbcf519f1bb6ccb2633d5503610 Mon Sep 17 00:00:00 2001 From: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Sat, 4 Jan 2025 02:26:06 -0300 Subject: [PATCH 003/106] This is almost functional responsive to grid i suck at math --- package.json | 1 + src/lib/GaugeComponent/hooks/chart.ts | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 3ee7198..7d6bb8e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "scripts": { "start": "react-scripts start", + "dev": "react-scripts start", "prebuild": "rimraf dist", "build": "set NODE_ENV=production babel src/lib --out-dir dist --copy-files", "build:types": "tsc", diff --git a/src/lib/GaugeComponent/hooks/chart.ts b/src/lib/GaugeComponent/hooks/chart.ts index b592a29..7ee0cc9 100644 --- a/src/lib/GaugeComponent/hooks/chart.ts +++ b/src/lib/GaugeComponent/hooks/chart.ts @@ -56,6 +56,7 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { let labels = gauge.props.labels as Labels; if (resize) { + calculateRadius(gauge); var parentNode = gauge.container.current.node().parentNode as HTMLElement; var parentWidth = parentNode.getBoundingClientRect().width; var parentHeight = parentNode.getBoundingClientRect().height; @@ -63,8 +64,10 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { gauge.svg.current .attr("width", parentWidth) .attr("height", parentHeight) - .attr('preserveAspectRatio', 'xMinYMin') - // .attr("viewBox", `0 0 ${parentWidth} ${parentHeight}`); + .attr('preserveAspectRatio', 'xMaxYMax') + // .attr("viewBox", `0 0 100 100`); + + // gauge.g.current.attr('transform', `translate(${parentWidth}, ${parentHeight})`); var outerRadius = dimensions.current.outerRadius; // Adjust outerRadius to fit within the parent node's height @@ -82,9 +85,9 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { // var yGauge = ((parentHeight / 2) - outerRadius) // + (dimensions.current.margin.top); //Center the gauge horizontally - var xGauge = (parentWidth / 2) - outerRadius + dimensions.current.margin.left; + var xGauge = (parentWidth / 2) - outerRadius// - dimensions.current.margin.left; //Fix the position of the gauge vertically at the top of the frame - var yGauge = dimensions.current.margin.top; + var yGauge = dimensions.current.margin.top+10; gauge.g.current .data([ @@ -95,10 +98,9 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { ]) .attr("transform", (d: any) => `translate(${d.x}, ${d.y})`); - calculateRadius(gauge); gauge.doughnut.current.attr( "transform", - "translate(" + dimensions.current.outerRadius + ", " + dimensions.current.outerRadius + ")" + "translate(" + (dimensions.current.outerRadius) + ", " + (dimensions.current.outerRadius) + ")" ); gauge.doughnut.current @@ -178,8 +180,7 @@ export const calculateRadius = (gauge: Gauge) => { const parentNode = gauge.container.current.node().parentNode as HTMLElement; const parentNodeOfTheParentNode = parentNode.parentNode as HTMLElement; const parentWidth = parentNode.getBoundingClientRect().width; - const parentHeight = parentNodeOfTheParentNode.getBoundingClientRect().height ?? 0; - + const parentHeight = gauge.container.current.node().getBoundingClientRect().height ?? 0; const availableWidth = parentWidth - dimensions.current.margin.left - dimensions.current.margin.right; const availableHeight = parentHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; @@ -189,10 +190,10 @@ export const calculateRadius = (gauge: Gauge) => { // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight); // } // if(availableHeight < availableWidth) { - // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); + dimensions.current.outerRadius = Math.min(availableWidth - 100, availableHeight) / 2; // } // else { - dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); + // dimensions.current.outerRadius = Math.min(parentHeight, availableWidth); // dimensions.current.outerRadius = availableHeight; // } console.log(dimensions.current.outerRadius > availableHeight) From 16cc5f88ad78f3bcd102b894ec3a41fb49df6336 Mon Sep 17 00:00:00 2001 From: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Sun, 2 Feb 2025 01:46:19 -0300 Subject: [PATCH 004/106] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 81a0e2e877677171b82bbe60ebadbb82881d7713 Author: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Sun Feb 2 01:28:59 2025 -0300 tst commit dc6f5f42ff2f665fe33c34529a300d02963c0f0f Author: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Sat Feb 1 01:12:26 2025 -0300 fix height correction commit f098e46663e5220057cb22853cf52ed0e001e121 Merge: 0d65e12 b7905ca Author: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Fri Jan 31 23:03:57 2025 -0300 Merge branch 'main' into resize2 commit 0d65e12da267705bb5359b02c9b8dc1a369234d4 Author: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Fri Jan 31 22:59:48 2025 -0300 tst commit b7905caff59b4373a10809b3bbea682ae7f6bb7d Author: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Fri Jan 31 22:39:50 2025 -0300 Update package.json commit d512b31615f1e0ba4e3d11a48e81583ba1261a66 Author: Antônio Lago <45375617+antoniolago@users.noreply.github.com> Date: Fri Jan 31 22:05:17 2025 -0300 Fix flickering on resize (#73) --- package.json | 3 +- src/App.tsx | 2 +- src/TestComponent/GridLayout.tsx | 2 - src/lib/GaugeComponent/hooks/chart.ts | 27 +++-- src/lib/GaugeComponent/index.tsx | 146 ++++++++++++++------------ src/lib/GaugeComponent/render.tsx | 0 src/lib/GaugeComponent/types/Gauge.ts | 1 + 7 files changed, 97 insertions(+), 84 deletions(-) create mode 100644 src/lib/GaugeComponent/render.tsx diff --git a/package.json b/package.json index 7d6bb8e..9ec38d5 100644 --- a/package.json +++ b/package.json @@ -99,5 +99,6 @@ "bugs": { "url": "https://github.com/antoniolago/react-gauge-component/issues" }, - "author": "Antônio Lago" + "author": "Antônio Lago", + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/App.tsx b/src/App.tsx index 23c0a0d..af19bfb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ const App = () => { return( <> - {/* */} + {/* */} ) diff --git a/src/TestComponent/GridLayout.tsx b/src/TestComponent/GridLayout.tsx index ff4cb8b..6f4916f 100644 --- a/src/TestComponent/GridLayout.tsx +++ b/src/TestComponent/GridLayout.tsx @@ -1,8 +1,6 @@ import React from 'react'; import GridLayout from 'react-grid-layout'; import GaugeComponent from '../lib'; -import WidthProvider from 'react-grid-layout'; -import Responsive from 'react-grid-layout'; // const ResponsiveReactGridLayout = WidthProvider(Responsive); const layout = [ diff --git a/src/lib/GaugeComponent/hooks/chart.ts b/src/lib/GaugeComponent/hooks/chart.ts index 7ee0cc9..bb4a0a3 100644 --- a/src/lib/GaugeComponent/hooks/chart.ts +++ b/src/lib/GaugeComponent/hooks/chart.ts @@ -7,11 +7,13 @@ import * as arcHooks from "./arc"; import * as labelsHooks from "./labels"; import * as pointerHooks from "./pointer"; import * as utilHooks from "./utils"; -export const initChart = (gauge: Gauge) => { +export const initChart = (gauge: Gauge, isFirstRender: boolean) => { const { angles } = gauge.dimensions.current; + if (gauge.resizeObserver?.current?.disconnect) { + gauge.resizeObserver?.current?.disconnect(); + } let updatedValue = (JSON.stringify(gauge.prevProps.current.value) !== JSON.stringify(gauge.props.value)); - let isFirstTime = utilHooks.isEmptyObject(gauge.svg.current); - if (updatedValue && !isFirstTime) { + if (updatedValue && !isFirstRender) { renderChart(gauge, false); return; } @@ -57,15 +59,11 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { if (resize) { calculateRadius(gauge); - var parentNode = gauge.container.current.node().parentNode as HTMLElement; + var parentNode = gauge.container.current.node() as HTMLElement; var parentWidth = parentNode.getBoundingClientRect().width; var parentHeight = parentNode.getBoundingClientRect().height; - gauge.svg.current - .attr("width", parentWidth) - .attr("height", parentHeight) - .attr('preserveAspectRatio', 'xMaxYMax') - // .attr("viewBox", `0 0 100 100`); + // .attr("viewBox", `0 0 100 100`); // gauge.g.current.attr('transform', `translate(${parentWidth}, ${parentHeight})`); @@ -79,6 +77,15 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { outerRadius = dimensions.current.outerRadius; } + let gaugeTypeHeightCorrection: Record = { + [GaugeType.Semicircle]: 0.6, + [GaugeType.Radial]: 1, + [GaugeType.Grafana]: 0.89 + } + gauge.svg.current + .attr("width", parentWidth) + .attr("height", parentHeight*gaugeTypeHeightCorrection[gauge.props.type as string]) + .attr('preserveAspectRatio', 'xMaxYMax') // var xGauge = ((parentWidth / 2) - outerRadius) // + (dimensions.current.margin.left) - dimensions.current.margin.right; @@ -87,7 +94,7 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { //Center the gauge horizontally var xGauge = (parentWidth / 2) - outerRadius// - dimensions.current.margin.left; //Fix the position of the gauge vertically at the top of the frame - var yGauge = dimensions.current.margin.top+10; + var yGauge = dimensions.current.margin.top + 10; gauge.g.current .data([ diff --git a/src/lib/GaugeComponent/index.tsx b/src/lib/GaugeComponent/index.tsx index b4bb581..9e7a71e 100644 --- a/src/lib/GaugeComponent/index.tsx +++ b/src/lib/GaugeComponent/index.tsx @@ -8,6 +8,7 @@ import { isEmptyObject, mergeObjects } from "./hooks/utils"; import { Dimensions, defaultDimensions } from "./types/Dimensions"; import { PointerRef, defaultPointerRef } from "./types/Pointer"; import { Arc, getArcWidthByType } from "./types/Arc"; +import { debounce } from "lodash"; /* GaugeComponent creates a gauge chart using D3 The chart is responsive and will have the same width as the "container" @@ -32,9 +33,12 @@ const GaugeComponent = (props: Partial) => { const mergedProps = useRef(props as GaugeComponentProps); const prevProps = useRef({}); let svgRef = useRef(null); + const resizeObserver = useRef({}); + let selectedRef = useRef(null); var gauge: Gauge = { props: mergedProps.current, + resizeObserver, prevProps, svg, g, @@ -48,88 +52,90 @@ const GaugeComponent = (props: Partial) => { pieChart, tooltip }; - //Merged properties will get the default props and overwrite by the user's defined props - //To keep the original default props in the object - const updateMergedProps = () => { - let defaultValues = { ...defaultGaugeProps }; - gauge.props = mergedProps.current = mergeObjects(defaultValues, props); - if (gauge.props.arc?.width == defaultGaugeProps.arc?.width) { - let mergedArc = mergedProps.current.arc as Arc; - mergedArc.width = getArcWidthByType(gauge.props.type as GaugeType); + const setupRender = () => { + //Merged properties will get the default props and overwrite by the user's defined props + //To keep the original default props in the object + const updateMergedProps = () => { + let defaultValues = { ...defaultGaugeProps }; + gauge.props = mergedProps.current = mergeObjects(defaultValues, props); + if (gauge.props.arc?.width == defaultGaugeProps.arc?.width) { + let mergedArc = mergedProps.current.arc as Arc; + mergedArc.width = getArcWidthByType(gauge.props.type as GaugeType); + } + if (gauge.props.marginInPercent == defaultGaugeProps.marginInPercent) mergedProps.current.marginInPercent = getGaugeMarginByType(gauge.props.type as GaugeType); + arcHooks.validateArcs(gauge); } - if (gauge.props.marginInPercent == defaultGaugeProps.marginInPercent) mergedProps.current.marginInPercent = getGaugeMarginByType(gauge.props.type as GaugeType); - arcHooks.validateArcs(gauge); - } - const shouldInitChart = () => { - let arcsPropsChanged = (JSON.stringify(prevProps.current.arc) !== JSON.stringify(mergedProps.current.arc)); - let pointerPropsChanged = (JSON.stringify(prevProps.current.pointer) !== JSON.stringify(mergedProps.current.pointer)); - let valueChanged = (JSON.stringify(prevProps.current.value) !== JSON.stringify(mergedProps.current.value)); - let minValueChanged = (JSON.stringify(prevProps.current.minValue) !== JSON.stringify(mergedProps.current.minValue)); - let maxValueChanged = (JSON.stringify(prevProps.current.maxValue) !== JSON.stringify(mergedProps.current.maxValue)); - return arcsPropsChanged || pointerPropsChanged || valueChanged || minValueChanged || maxValueChanged; - } - useLayoutEffect(() => { - updateMergedProps(); - isFirstRun.current = isEmptyObject(container.current) - if (isFirstRun.current) container.current = select(svgRef.current); - if (shouldInitChart()) chartHooks.initChart(gauge); - gauge.prevProps.current = mergedProps.current; - }, [props]); + const shouldInitChart = () => { + let arcsPropsChanged = (JSON.stringify(prevProps.current.arc) !== JSON.stringify(mergedProps.current.arc)); + let pointerPropsChanged = (JSON.stringify(prevProps.current.pointer) !== JSON.stringify(mergedProps.current.pointer)); + let valueChanged = (JSON.stringify(prevProps.current.value) !== JSON.stringify(mergedProps.current.value)); + let minValueChanged = (JSON.stringify(prevProps.current.minValue) !== JSON.stringify(mergedProps.current.minValue)); + let maxValueChanged = (JSON.stringify(prevProps.current.maxValue) !== JSON.stringify(mergedProps.current.maxValue)); + return arcsPropsChanged || pointerPropsChanged || valueChanged || minValueChanged || maxValueChanged; + } + useLayoutEffect(() => { + updateMergedProps(); + isFirstRun.current = isEmptyObject(container.current) + if (isFirstRun.current) container.current = select(selectedRef.current); + if (shouldInitChart()) chartHooks.initChart(gauge, isFirstRun.current); + gauge.prevProps.current = mergedProps.current; + }, [props]); - useEffect(() => { - const observer = new MutationObserver(function () { - setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - if (!svgRef.current?.offsetParent) return; + // useEffect(() => { + // const observer = new MutationObserver(function () { + // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); + // if (!selectedRef.current?.offsetParent) return; - chartHooks.renderChart(gauge, true); - observer.disconnect() - }); - observer.observe(svgRef.current?.parentNode, { attributes: true, subtree: false }); - return () => observer.disconnect(); - }, [svgRef.current?.parentNode?.offsetWidth, svgRef.current?.parentNode?.offsetHeight]); + // chartHooks.renderChart(gauge, true); + // observer.disconnect() + // }); + // observer.observe(selectedRef.current?.parentNode, {attributes: true, subtree: false}); + // return () => observer.disconnect(); + // }, [selectedRef.current?.parentNode?.offsetWidth, selectedRef.current?.parentNode?.offsetHeight]); - useEffect(() => { - const handleResize = () => chartHooks.renderChart(gauge, true); - //Set up resize event listener to re-render the chart everytime the window is resized - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [props]); + // useEffect(() => { + // const handleResize = () => chartHooks.renderChart(gauge, true); + // //Set up resize event listener to re-render the chart everytime the window is resized + // window.addEventListener("resize", handleResize); + // return () => window.removeEventListener("resize", handleResize); + // }, [props]); - // useEffect(() => { - // console.log(svgRef.current?.offsetWidth) - // // workaround to trigger recomputing of gauge size on first load (e.g. F5) - // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - // }, [svgRef.current?.parentNode]); + // useEffect(() => { + // console.log(svgRef.current?.offsetWidth) + // // workaround to trigger recomputing of gauge size on first load (e.g. F5) + // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); + // }, [selectedRef.current?.parentNode]); - useEffect(() => { - const element = svgRef.current; - if (!element) return; + useEffect(() => { + const element = svgRef.current; + if (!element) return; - const handleResize = () => { - const parentNode = element.parentNode; - if (parentNode) { + // Create observer instance + const observer = new ResizeObserver(() => { + // chartHooks.initChart(gauge, isFirstRun.current); chartHooks.renderChart(gauge, true); - // console.log("Parent node width:", width); - } - }; + }); - // Create a ResizeObserver to watch the parent node - const observer = new ResizeObserver(handleResize); + // Store observer reference + gauge.resizeObserver.current = observer; - // Observe the parent node - if (element.parentNode) { - observer.observe(element.parentNode); - } - - // Cleanup observer when component unmounts - return () => { + // Observe parent node if (element.parentNode) { - observer.unobserve(element.parentNode); + observer.observe(element.parentNode); } - }; - }, []); + // Cleanup + return () => { + if (gauge.resizeObserver) { + gauge.resizeObserver.current?.disconnect(); + // delete gauge.resizeObserver.current; + } + }; + }, []); + } + + setupRender(); const { id, style, className, type } = props; // add height: -webkit-fill-available; // width: -webkit-fill-available; @@ -143,8 +149,8 @@ const GaugeComponent = (props: Partial) => {
(svgRef.current = svg)} + style={style} + ref={(svg) => (selectedRef.current = svg)} /> ); }; diff --git a/src/lib/GaugeComponent/render.tsx b/src/lib/GaugeComponent/render.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/GaugeComponent/types/Gauge.ts b/src/lib/GaugeComponent/types/Gauge.ts index a4a01ad..40b5282 100644 --- a/src/lib/GaugeComponent/types/Gauge.ts +++ b/src/lib/GaugeComponent/types/Gauge.ts @@ -7,6 +7,7 @@ export interface Gauge { svg: React.MutableRefObject; g: React.MutableRefObject; doughnut: React.MutableRefObject; + resizeObserver : React.MutableRefObject; pointer: React.MutableRefObject; container: React.MutableRefObject; isFirstRun: React.MutableRefObject; From 609716fd7f36a04290520b40215571a26960293b Mon Sep 17 00:00:00 2001 From: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Sun, 2 Feb 2025 02:08:52 -0300 Subject: [PATCH 005/106] Update index.tsx --- src/lib/GaugeComponent/index.tsx | 152 +++++++++++++++---------------- 1 file changed, 74 insertions(+), 78 deletions(-) diff --git a/src/lib/GaugeComponent/index.tsx b/src/lib/GaugeComponent/index.tsx index 9e7a71e..151e1d4 100644 --- a/src/lib/GaugeComponent/index.tsx +++ b/src/lib/GaugeComponent/index.tsx @@ -8,7 +8,6 @@ import { isEmptyObject, mergeObjects } from "./hooks/utils"; import { Dimensions, defaultDimensions } from "./types/Dimensions"; import { PointerRef, defaultPointerRef } from "./types/Pointer"; import { Arc, getArcWidthByType } from "./types/Arc"; -import { debounce } from "lodash"; /* GaugeComponent creates a gauge chart using D3 The chart is responsive and will have the same width as the "container" @@ -33,13 +32,11 @@ const GaugeComponent = (props: Partial) => { const mergedProps = useRef(props as GaugeComponentProps); const prevProps = useRef({}); let svgRef = useRef(null); - const resizeObserver = useRef({}); - let selectedRef = useRef(null); var gauge: Gauge = { props: mergedProps.current, - resizeObserver, prevProps, + resizeObserver: useRef(), svg, g, dimensions, @@ -52,90 +49,90 @@ const GaugeComponent = (props: Partial) => { pieChart, tooltip }; - const setupRender = () => { - //Merged properties will get the default props and overwrite by the user's defined props - //To keep the original default props in the object - const updateMergedProps = () => { - let defaultValues = { ...defaultGaugeProps }; - gauge.props = mergedProps.current = mergeObjects(defaultValues, props); - if (gauge.props.arc?.width == defaultGaugeProps.arc?.width) { - let mergedArc = mergedProps.current.arc as Arc; - mergedArc.width = getArcWidthByType(gauge.props.type as GaugeType); - } - if (gauge.props.marginInPercent == defaultGaugeProps.marginInPercent) mergedProps.current.marginInPercent = getGaugeMarginByType(gauge.props.type as GaugeType); - arcHooks.validateArcs(gauge); + //Merged properties will get the default props and overwrite by the user's defined props + //To keep the original default props in the object + const updateMergedProps = () => { + let defaultValues = { ...defaultGaugeProps }; + gauge.props = mergedProps.current = mergeObjects(defaultValues, props); + if (gauge.props.arc?.width == defaultGaugeProps.arc?.width) { + let mergedArc = mergedProps.current.arc as Arc; + mergedArc.width = getArcWidthByType(gauge.props.type as GaugeType); } + if (gauge.props.marginInPercent == defaultGaugeProps.marginInPercent) mergedProps.current.marginInPercent = getGaugeMarginByType(gauge.props.type as GaugeType); + arcHooks.validateArcs(gauge); + } - const shouldInitChart = () => { - let arcsPropsChanged = (JSON.stringify(prevProps.current.arc) !== JSON.stringify(mergedProps.current.arc)); - let pointerPropsChanged = (JSON.stringify(prevProps.current.pointer) !== JSON.stringify(mergedProps.current.pointer)); - let valueChanged = (JSON.stringify(prevProps.current.value) !== JSON.stringify(mergedProps.current.value)); - let minValueChanged = (JSON.stringify(prevProps.current.minValue) !== JSON.stringify(mergedProps.current.minValue)); - let maxValueChanged = (JSON.stringify(prevProps.current.maxValue) !== JSON.stringify(mergedProps.current.maxValue)); - return arcsPropsChanged || pointerPropsChanged || valueChanged || minValueChanged || maxValueChanged; - } - useLayoutEffect(() => { - updateMergedProps(); - isFirstRun.current = isEmptyObject(container.current) - if (isFirstRun.current) container.current = select(selectedRef.current); - if (shouldInitChart()) chartHooks.initChart(gauge, isFirstRun.current); - gauge.prevProps.current = mergedProps.current; - }, [props]); + const shouldInitChart = () => { + let arcsPropsChanged = (JSON.stringify(prevProps.current.arc) !== JSON.stringify(mergedProps.current.arc)); + let pointerPropsChanged = (JSON.stringify(prevProps.current.pointer) !== JSON.stringify(mergedProps.current.pointer)); + let valueChanged = (JSON.stringify(prevProps.current.value) !== JSON.stringify(mergedProps.current.value)); + let minValueChanged = (JSON.stringify(prevProps.current.minValue) !== JSON.stringify(mergedProps.current.minValue)); + let maxValueChanged = (JSON.stringify(prevProps.current.maxValue) !== JSON.stringify(mergedProps.current.maxValue)); + return arcsPropsChanged || pointerPropsChanged || valueChanged || minValueChanged || maxValueChanged; + } + useLayoutEffect(() => { + updateMergedProps(); + isFirstRun.current = isEmptyObject(container.current) + if (isFirstRun.current) container.current = select(svgRef.current); + if (shouldInitChart()) chartHooks.initChart(gauge, isFirstRun.current); + gauge.prevProps.current = mergedProps.current; + }, [props]); - // useEffect(() => { - // const observer = new MutationObserver(function () { - // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - // if (!selectedRef.current?.offsetParent) return; + useEffect(() => { + const observer = new MutationObserver(function () { + setTimeout(() => window.dispatchEvent(new Event('resize')), 10); + if (!svgRef.current?.offsetParent) return; - // chartHooks.renderChart(gauge, true); - // observer.disconnect() - // }); - // observer.observe(selectedRef.current?.parentNode, {attributes: true, subtree: false}); - // return () => observer.disconnect(); - // }, [selectedRef.current?.parentNode?.offsetWidth, selectedRef.current?.parentNode?.offsetHeight]); + chartHooks.renderChart(gauge, true); + observer.disconnect() + }); + observer.observe(svgRef.current?.parentNode, { attributes: true, subtree: false }); + return () => observer.disconnect(); + }, [svgRef.current?.parentNode?.offsetWidth, svgRef.current?.parentNode?.offsetHeight]); - // useEffect(() => { - // const handleResize = () => chartHooks.renderChart(gauge, true); - // //Set up resize event listener to re-render the chart everytime the window is resized - // window.addEventListener("resize", handleResize); - // return () => window.removeEventListener("resize", handleResize); - // }, [props]); + useEffect(() => { + const handleResize = () => chartHooks.renderChart(gauge, true); + //Set up resize event listener to re-render the chart everytime the window is resized + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [props]); - // useEffect(() => { - // console.log(svgRef.current?.offsetWidth) - // // workaround to trigger recomputing of gauge size on first load (e.g. F5) - // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - // }, [selectedRef.current?.parentNode]); + // useEffect(() => { + // console.log(svgRef.current?.offsetWidth) + // // workaround to trigger recomputing of gauge size on first load (e.g. F5) + // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); + // }, [svgRef.current?.parentNode]); - useEffect(() => { - const element = svgRef.current; - if (!element) return; + useEffect(() => { + const element = svgRef.current; + if (!element) return; - // Create observer instance - const observer = new ResizeObserver(() => { - // chartHooks.initChart(gauge, isFirstRun.current); - chartHooks.renderChart(gauge, true); - }); + const handleResize = () => { + const parentNode = element.parentNode; + if (parentNode) { + requestAnimationFrame(() => { + chartHooks.renderChart(gauge, true); + }); + // console.log("Parent node width:", width); + } + }; - // Store observer reference - gauge.resizeObserver.current = observer; + // Create a ResizeObserver to watch the parent node + const observer = new ResizeObserver(handleResize); - // Observe parent node + // Observe the parent node + if (element.parentNode) { + observer.observe(element.parentNode); + } + + // Cleanup observer when component unmounts + return () => { if (element.parentNode) { - observer.observe(element.parentNode); + observer.unobserve(element.parentNode); } + }; + }, []); - // Cleanup - return () => { - if (gauge.resizeObserver) { - gauge.resizeObserver.current?.disconnect(); - // delete gauge.resizeObserver.current; - } - }; - }, []); - } - - setupRender(); const { id, style, className, type } = props; // add height: -webkit-fill-available; // width: -webkit-fill-available; @@ -149,10 +146,9 @@ const GaugeComponent = (props: Partial) => {
(selectedRef.current = svg)} + style={styled} + ref={(svg) => (svgRef.current = svg)} /> ); }; -export { GaugeComponent }; export default GaugeComponent; \ No newline at end of file From dd4dbaf8375a170f449eaf4477d92f203482b770 Mon Sep 17 00:00:00 2001 From: antoniolago <45375617+antoniolago@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:10:28 -0300 Subject: [PATCH 006/106] tst --- src/App.tsx | 7 +- src/TestComponent/GridLayout.tsx | 8 +-- src/index.css | 3 + src/lib/GaugeComponent/constants.ts | 1 + src/lib/GaugeComponent/hooks/arc.ts | 2 +- src/lib/GaugeComponent/hooks/chart.ts | 57 ++++++++++++----- src/lib/GaugeComponent/index.tsx | 64 +++++++++++++------ src/lib/GaugeComponent/types/Gauge.ts | 2 + .../types/GaugeComponentProps.ts | 3 +- 9 files changed, 105 insertions(+), 42 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index af19bfb..bfd9388 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,15 @@ import InputTest from './TestComponent/InputTest'; import GridLayoutComponent from './TestComponent/GridLayout'; import 'react-grid-layout/css/styles.css' import 'react-resizable/css/styles.css'; +import GaugeComponent from './lib'; const App = () => { - return( + return ( <> - + + {/* */} + {/* */} ) diff --git a/src/TestComponent/GridLayout.tsx b/src/TestComponent/GridLayout.tsx index 6f4916f..c2cd68f 100644 --- a/src/TestComponent/GridLayout.tsx +++ b/src/TestComponent/GridLayout.tsx @@ -44,15 +44,15 @@ const GridLayoutComponent = () => ( rowHeight={70} width={1200} > -
+ {/*
-
+
*/}
-
+ {/*
-
+
*/} ); diff --git a/src/index.css b/src/index.css index d98f51e..8f67818 100644 --- a/src/index.css +++ b/src/index.css @@ -16,3 +16,6 @@ body { color: white; } + +.gauge svg { +} \ No newline at end of file diff --git a/src/lib/GaugeComponent/constants.ts b/src/lib/GaugeComponent/constants.ts index 2d741c7..a14d7b1 100644 --- a/src/lib/GaugeComponent/constants.ts +++ b/src/lib/GaugeComponent/constants.ts @@ -3,6 +3,7 @@ export const CONSTANTS: any = { tickLineClassname: "tick-line", tickValueClassname: "tick-value", valueLabelClassname: "value-text", + debugLogs: true, debugTicksRadius: false, debugSingleGauge: false, rangeBetweenCenteredTickValueLabel: [0.35, 0.65] diff --git a/src/lib/GaugeComponent/hooks/arc.ts b/src/lib/GaugeComponent/hooks/arc.ts index acbdd21..dc3dd02 100644 --- a/src/lib/GaugeComponent/hooks/arc.ts +++ b/src/lib/GaugeComponent/hooks/arc.ts @@ -172,7 +172,7 @@ const drawGrafanaOuterArc = (gauge: Gauge, resize: boolean = false) => { export const drawArc = (gauge: Gauge, percent: number | undefined = undefined) => { const { padding, cornerRadius } = gauge.props.arc as Arc; const { innerRadius, outerRadius } = gauge.dimensions.current; - // chartHooks.clearChart(gauge); + let data = {} //When gradient enabled, it'll have only 1 arc if (gauge.props?.arc?.gradient) { diff --git a/src/lib/GaugeComponent/hooks/chart.ts b/src/lib/GaugeComponent/hooks/chart.ts index bb4a0a3..0211aa9 100644 --- a/src/lib/GaugeComponent/hooks/chart.ts +++ b/src/lib/GaugeComponent/hooks/chart.ts @@ -9,9 +9,9 @@ import * as pointerHooks from "./pointer"; import * as utilHooks from "./utils"; export const initChart = (gauge: Gauge, isFirstRender: boolean) => { const { angles } = gauge.dimensions.current; - if (gauge.resizeObserver?.current?.disconnect) { - gauge.resizeObserver?.current?.disconnect(); - } + // if (gauge.resizeObserver?.current?.disconnect) { + // gauge.resizeObserver?.current?.disconnect(); + // } let updatedValue = (JSON.stringify(gauge.prevProps.current.value) !== JSON.stringify(gauge.props.value)); if (updatedValue && !isFirstRender) { renderChart(gauge, false); @@ -57,12 +57,13 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { let arc = gauge.props.arc as Arc; let labels = gauge.props.labels as Labels; + calculateRadius(gauge); if (resize) { - calculateRadius(gauge); var parentNode = gauge.container.current.node() as HTMLElement; + if (!parentNode) return; + var parentNodeEl = document.getElementById(gauge.props.id as string); var parentWidth = parentNode.getBoundingClientRect().width; var parentHeight = parentNode.getBoundingClientRect().height; - // .attr("viewBox", `0 0 100 100`); // gauge.g.current.attr('transform', `translate(${parentWidth}, ${parentHeight})`); @@ -78,24 +79,39 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { } let gaugeTypeHeightCorrection: Record = { - [GaugeType.Semicircle]: 0.6, - [GaugeType.Radial]: 1, - [GaugeType.Grafana]: 0.89 + [GaugeType.Semicircle]: 0, + [GaugeType.Radial]: 10, + [GaugeType.Grafana]: 25 } - gauge.svg.current - .attr("width", parentWidth) - .attr("height", parentHeight*gaugeTypeHeightCorrection[gauge.props.type as string]) - .attr('preserveAspectRatio', 'xMaxYMax') + let heightRatio = gaugeTypeHeightCorrection[gauge.props.type as GaugeType] || 1; + let calculatedHeight = (parentWidth * heightRatio) - gaugeTypeHeightCorrection[gauge.props.type as GaugeType]; + + // gauge.svg.current + // .attr("width", parentWidth) + //THIS IS WHERE THINGS GO HAYWIRE, HOW DO I DECIDE WHAT WILL BE THE + //HEIGHT OF THE SVG ELEMENT IF THE PARENT DIVs DOES NOT PROVIDE A HEIGHT + //AND THE HEIGHT OF THE GAUGE IS DYNAMICALLY CALCULATED + //WE NEED A MINHEIGHT FOR THE CONTAINER DIV + //BUT KEEP OTHER FUNCTIONALITIES WORKING LIKE RESIZING + // .attr("height", parentWidth) + // .attr("height", gHeight) // Set a minimum height of 200 + // .attr('preserveAspectRatio', 'xMaxYMax'); + // .attr('preserveAspectRatio', 'xMaxYMin') // var xGauge = ((parentWidth / 2) - outerRadius) // + (dimensions.current.margin.left) - dimensions.current.margin.right; // var yGauge = ((parentHeight / 2) - outerRadius) // + (dimensions.current.margin.top); //Center the gauge horizontally - var xGauge = (parentWidth / 2) - outerRadius// - dimensions.current.margin.left; + var xGauge = (parentWidth / 2) - outerRadius;// - dimensions.current.margin.left; //Fix the position of the gauge vertically at the top of the frame - var yGauge = dimensions.current.margin.top + 10; + var yGauge = 10 + gauge.svg.current + .attr("width", "100%") + .attr("viewBox", "0 0 100% 100%") // clipping [origin,size] + .attr("height","100%") // this was the secret sauce + .attr('preserveAspectRatio','xMinYMin') gauge.g.current .data([ { @@ -103,7 +119,8 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { y: yGauge } ]) - .attr("transform", (d: any) => `translate(${d.x}, ${d.y})`); + .attr("transform", (d: any) => `translate(${d.x}, ${d.y})`) + .attr("will-change", "transform"); gauge.doughnut.current.attr( "transform", @@ -114,6 +131,9 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { .on("mouseleave", () => arcHooks.hideTooltip(gauge)) .on("mouseout", () => arcHooks.hideTooltip(gauge)); + var gHeight = gauge.g.current.node().getBBox().height; + + // .attr("height", parentHeight) let arcWidth = arc.width as number; dimensions.current.innerRadius = dimensions.current.outerRadius * (1 - arcWidth); clearChart(gauge); @@ -146,6 +166,13 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { labelsHooks.setupValueLabel(gauge); } } + var gHeight = gauge.g.current.node().getBBox().height; + var gWidth = gauge.g.current.node().getBBox().width; + var h = Math.max(gHeight, 150) + // gauge.svg.current + // .attr("height", h) + // gauge.props.style = { ...gauge.props.style, height: gHeight }; + }; // export const updateDimensions = (gauge: Gauge) => { // const { marginInPercent } = gauge.props; diff --git a/src/lib/GaugeComponent/index.tsx b/src/lib/GaugeComponent/index.tsx index 151e1d4..093d265 100644 --- a/src/lib/GaugeComponent/index.tsx +++ b/src/lib/GaugeComponent/index.tsx @@ -8,6 +8,8 @@ import { isEmptyObject, mergeObjects } from "./hooks/utils"; import { Dimensions, defaultDimensions } from "./types/Dimensions"; import { PointerRef, defaultPointerRef } from "./types/Pointer"; import { Arc, getArcWidthByType } from "./types/Arc"; +import { random } from "lodash"; +import CONSTANTS from "./constants"; /* GaugeComponent creates a gauge chart using D3 The chart is responsive and will have the same width as the "container" @@ -22,6 +24,7 @@ const GaugeComponent = (props: Partial) => { const g = useRef({}); const doughnut = useRef({}); const isFirstRun = useRef(true); + const shouldAvoidNextRender = useRef(false); const currentProgress = useRef(0); const pointer = useRef({ ...defaultPointerRef }); const container = useRef({}); @@ -31,6 +34,8 @@ const GaugeComponent = (props: Partial) => { const dimensions = useRef({ ...defaultDimensions }); const mergedProps = useRef(props as GaugeComponentProps); const prevProps = useRef({}); + const prevGSize = useRef(null); + const maxGHeight = useRef(null); let svgRef = useRef(null); var gauge: Gauge = { @@ -47,7 +52,9 @@ const GaugeComponent = (props: Partial) => { container, arcData, pieChart, - tooltip + tooltip, + prevGSize, + maxGHeight }; //Merged properties will get the default props and overwrite by the user's defined props //To keep the original default props in the object @@ -68,34 +75,53 @@ const GaugeComponent = (props: Partial) => { let valueChanged = (JSON.stringify(prevProps.current.value) !== JSON.stringify(mergedProps.current.value)); let minValueChanged = (JSON.stringify(prevProps.current.minValue) !== JSON.stringify(mergedProps.current.minValue)); let maxValueChanged = (JSON.stringify(prevProps.current.maxValue) !== JSON.stringify(mergedProps.current.maxValue)); - return arcsPropsChanged || pointerPropsChanged || valueChanged || minValueChanged || maxValueChanged; + var shouldRender = arcsPropsChanged || pointerPropsChanged || valueChanged || minValueChanged || maxValueChanged; + console.log("shouldRender: ", shouldRender) + console.log("arcsPropsChanged: ", arcsPropsChanged) + console.log("pointerPropsChanged: ", pointerPropsChanged) + console.log("valueChanged: ", valueChanged) + console.log("minValueChanged: ", minValueChanged) + console.log("maxValueChanged: ", maxValueChanged) + + return shouldRender; + } + const isHeightProvidedByUser = () => { + return mergedProps.current.style?.height !== undefined; + } + const isHeightPresentInParentNode = () => { + // console.log("AAAAAAAAAA", gauge.container?.current?.node()) + return parentNode.current?.clientHeight !== 0; } useLayoutEffect(() => { updateMergedProps(); isFirstRun.current = isEmptyObject(container.current) + if (CONSTANTS.debugLogs) { + console.log("isHeightProvidedByUser: ", isHeightProvidedByUser()) + console.log("isHeightPresentInParentNode: ", isHeightPresentInParentNode()) + } if (isFirstRun.current) container.current = select(svgRef.current); if (shouldInitChart()) chartHooks.initChart(gauge, isFirstRun.current); gauge.prevProps.current = mergedProps.current; }, [props]); - useEffect(() => { - const observer = new MutationObserver(function () { - setTimeout(() => window.dispatchEvent(new Event('resize')), 10); - if (!svgRef.current?.offsetParent) return; + // useEffect(() => { + // const observer = new MutationObserver(function () { + // setTimeout(() => window.dispatchEvent(new Event('resize')), 10); + // // if (!svgRef.current?.offsetParent) return; - chartHooks.renderChart(gauge, true); - observer.disconnect() - }); - observer.observe(svgRef.current?.parentNode, { attributes: true, subtree: false }); - return () => observer.disconnect(); - }, [svgRef.current?.parentNode?.offsetWidth, svgRef.current?.parentNode?.offsetHeight]); + // // chartHooks.renderChart(gauge, true); + // observer.disconnect() + // }); + // observer.observe(svgRef.current?.parentNode, { attributes: true, subtree: false }); + // return () => observer.disconnect(); + // }, [svgRef.current?.parentNode?.offsetWidth, svgRef.current?.parentNode?.offsetHeight]); - useEffect(() => { - const handleResize = () => chartHooks.renderChart(gauge, true); - //Set up resize event listener to re-render the chart everytime the window is resized - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [props]); + // useEffect(() => { + // const handleResize = () => chartHooks.renderChart(gauge, true); + // //Set up resize event listener to re-render the chart everytime the window is resized + // window.addEventListener("resize", handleResize); + // return () => window.removeEventListener("resize", handleResize); + // }, [props]); // useEffect(() => { // console.log(svgRef.current?.offsetWidth) @@ -145,7 +171,7 @@ const GaugeComponent = (props: Partial) => { return (
(svgRef.current = svg)} /> diff --git a/src/lib/GaugeComponent/types/Gauge.ts b/src/lib/GaugeComponent/types/Gauge.ts index 40b5282..259e01b 100644 --- a/src/lib/GaugeComponent/types/Gauge.ts +++ b/src/lib/GaugeComponent/types/Gauge.ts @@ -18,4 +18,6 @@ export interface Gauge { pieChart: React.MutableRefObject>; //This holds the only tooltip element rendered for any given gauge chart to use tooltip: React.MutableRefObject; + prevGSize: React.MutableRefObject; + maxGHeight: React.MutableRefObject; } diff --git a/src/lib/GaugeComponent/types/GaugeComponentProps.ts b/src/lib/GaugeComponent/types/GaugeComponentProps.ts index 43bcdae..e60a9df 100644 --- a/src/lib/GaugeComponent/types/GaugeComponentProps.ts +++ b/src/lib/GaugeComponent/types/GaugeComponentProps.ts @@ -1,3 +1,4 @@ +import { random } from "lodash"; import { Arc, defaultArc } from "./Arc"; import { Labels, defaultLabels } from './Labels'; import { PointerProps, defaultPointer } from "./Pointer"; @@ -43,7 +44,7 @@ export interface GaugeComponentProps { } export const defaultGaugeProps: GaugeComponentProps = { - id: "", + id: random(0, 100000).toString(), className: "gauge-component-class", style: { width: "100%"}, marginInPercent: 0.07, From bebabaaddfe3ca7cc6c2b02a3e8aeae71024d0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Lago?= <45375617+antoniolago@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:41:27 -0300 Subject: [PATCH 007/106] chpnt --- package.json | 8 + src/App.tsx | 37 +- src/TestComponent/RefactoringDemo.tsx | 321 +++++++++++++++ src/__mocks__/styleMock.js | 2 + src/lib/GaugeComponent/hooks/arc.ts | 15 +- src/lib/GaugeComponent/hooks/chart.ts | 214 +++------- .../hooks/coordinateSystem.test.ts | 375 ++++++++++++++++++ .../GaugeComponent/hooks/coordinateSystem.ts | 219 ++++++++++ src/lib/GaugeComponent/hooks/debugHelpers.ts | 315 +++++++++++++++ src/lib/GaugeComponent/hooks/labels.ts | 19 +- src/lib/GaugeComponent/hooks/pointer.ts | 4 +- .../GaugeComponent/hooks/rendering.test.ts | 254 ++++++++++++ src/lib/GaugeComponent/hooks/svgSize.test.ts | 208 ++++++++++ src/lib/GaugeComponent/index.tsx | 7 +- src/setupTests.js | 17 + tsconfig.json | 3 +- 16 files changed, 1834 insertions(+), 184 deletions(-) create mode 100644 src/TestComponent/RefactoringDemo.tsx create mode 100644 src/__mocks__/styleMock.js create mode 100644 src/lib/GaugeComponent/hooks/coordinateSystem.test.ts create mode 100644 src/lib/GaugeComponent/hooks/coordinateSystem.ts create mode 100644 src/lib/GaugeComponent/hooks/debugHelpers.ts create mode 100644 src/lib/GaugeComponent/hooks/rendering.test.ts create mode 100644 src/lib/GaugeComponent/hooks/svgSize.test.ts create mode 100644 src/setupTests.js diff --git a/package.json b/package.json index 9ec38d5..4fce0a9 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,14 @@ "not ie <= 11", "not op_mini all" ], + "jest": { + "transformIgnorePatterns": [ + "node_modules/(?!(d3|d3-array|d3-axis|d3-brush|d3-chord|d3-color|d3-contour|d3-delaunay|d3-dispatch|d3-drag|d3-dsv|d3-ease|d3-fetch|d3-force|d3-format|d3-geo|d3-hierarchy|d3-interpolate|d3-path|d3-polygon|d3-quadtree|d3-random|d3-scale|d3-scale-chromatic|d3-selection|d3-shape|d3-time|d3-time-format|d3-timer|d3-transition|d3-zoom|internmap|delaunator|robust-predicates)/)" + ], + "moduleNameMapper": { + "\\.(css|less|scss|sass)$": "/src/__mocks__/styleMock.js" + } + }, "devDependencies": { "@babel/cli": "^7.12.8", "@babel/core": "^7.6.2", diff --git a/src/App.tsx b/src/App.tsx index bfd9388..8f378c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,47 @@ -import React from 'react'; +import React, { useState } from 'react'; import './App.css'; import MainPreviews from './TestComponent/MainPreviews'; import InputTest from './TestComponent/InputTest'; import GridLayoutComponent from './TestComponent/GridLayout'; +import RefactoringDemo from './TestComponent/RefactoringDemo'; import 'react-grid-layout/css/styles.css' import 'react-resizable/css/styles.css'; import GaugeComponent from './lib'; const App = () => { + const [showDemo, setShowDemo] = useState(false); + return ( <> - - - {/* */} +
+ +
- {/* */} + {showDemo ? ( + + ) : ( + <> + + + {/* */} + + {/* */} + + )} ) }; diff --git a/src/TestComponent/RefactoringDemo.tsx b/src/TestComponent/RefactoringDemo.tsx new file mode 100644 index 0000000..e3e2d44 --- /dev/null +++ b/src/TestComponent/RefactoringDemo.tsx @@ -0,0 +1,321 @@ +import React, { useState, useEffect, useRef } from 'react'; +import GaugeComponent from '../lib/GaugeComponent'; + +/** + * Demo component showing the improvements from the refactoring + * This demonstrates: + * - Proper space utilization + * - No infinite resize loops + * - Correct element positioning + * - All gauge types working correctly + */ +const RefactoringDemo: React.FC = () => { + const [value, setValue] = useState(33); + const [containerSize, setContainerSize] = useState({ width: 400, height: 300 }); + const [renderCount, setRenderCount] = useState(0); + const [debugMode, setDebugMode] = useState(true); + const renderTimestamp = useRef(Date.now()); + + // Animate value to show smooth updates + useEffect(() => { + const interval = setInterval(() => { + setValue((v) => (v + 5) % 100); + }, 2000); + return () => clearInterval(interval); + }, []); + + // Track renders to demonstrate no infinite loops + useEffect(() => { + const now = Date.now(); + const timeSinceLastRender = now - renderTimestamp.current; + renderTimestamp.current = now; + + setRenderCount((c) => c + 1); + + if (timeSinceLastRender < 100) { + console.warn('⚠️ Rapid re-render detected!', timeSinceLastRender + 'ms since last render'); + } + }); + + // Reset render count periodically + useEffect(() => { + const interval = setInterval(() => { + setRenderCount(0); + }, 3000); + return () => clearInterval(interval); + }, []); + + return ( +
+

🎯 Gauge Component Refactoring Demo

+ +
+

✅ Issues Fixed:

+
    +
  • ✅ Invalid SVG viewBox (was "0 0 100% 100%", now uses proper coordinates)
  • +
  • ✅ Magic numbers removed (all calculations centralized)
  • +
  • ✅ Optimal space utilization (no more arbitrary -100px)
  • +
  • ✅ Infinite resize loops prevented (stability checking)
  • +
  • ✅ Element positioning simplified (origin-based coordinates)
  • +
+
+ +
5 ? '#ffebee' : '#e8f5e9', + padding: '10px', + borderRadius: '4px', + marginBottom: '20px', + border: `2px solid ${renderCount > 5 ? '#f44336' : '#4caf50'}`, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' + }}> +
+ Render Count (last 3s): {renderCount} + {renderCount > 5 && ⚠️ INFINITE LOOP DETECTED!} + {renderCount <= 2 && ✅ Stable - No infinite loops!} +
+ +
+ +

Gauge Types Comparison

+
+
+

Semicircle

+
+ +
+
+ +
+

Radial

+
+ +
+
+ +
+

Grafana

+
+ +
+
+
+ +

Resize Test

+

Adjust the container size to test resize stability:

+
+ +
+ +
+ +
+

+ Resizable Gauge ({containerSize.width}x{containerSize.height}px) +

+
+ `${value}%` + }, + tickLabels: { + type: "inner", + ticks: [ + { value: 0 }, + { value: 50 }, + { value: 100 } + ] + } + }} + /> +
+
+ +
+

Technical Improvements

+
+
+

🔧 Before Refactoring

+
    +
  • Invalid viewBox syntax
  • +
  • Magic numbers everywhere
  • +
  • Poor space utilization (-100px)
  • +
  • No resize loop prevention
  • +
  • Complex coordinate calculations
  • +
  • Hard to maintain and test
  • +
+
+
+

✨ After Refactoring

+
    +
  • Valid numeric viewBox
  • +
  • Centralized calculations
  • +
  • Optimal space usage
  • +
  • Stability checking
  • +
  • Simple origin-based coords
  • +
  • Well-tested and documented
  • +
+
+
+
+ +
+

🔍 How to Verify Improvements

+
    +
  1. + Check SVG ViewBox: Open browser DevTools, inspect the SVG element, + and verify viewBox contains only numbers (e.g., "0 0 250 150") not percentages. +
  2. +
  3. + Test Resize Stability: Use the sliders above. The render count + should stay low (≤2 per resize). Before the fix, it would spike to 10+. +
  4. +
  5. + Verify Space Usage: Notice how the gauge fills most of the + available space. Before, it was unnecessarily small. +
  6. +
  7. + Check Positioning: All elements (arcs, labels, pointer) should be + properly centered and positioned. +
  8. +
+
+ +
+

📚 Documentation

+

For detailed information about the refactoring, see:

+
    +
  • REFACTORING_GUIDE.md - Complete technical documentation
  • +
  • TESTING_CHECKLIST.md - Comprehensive testing guide
  • +
  • hooks/coordinateSystem.ts - New coordinate system implementation
  • +
  • hooks/debugHelpers.ts - Debug utilities for visualization
  • +
+
+
+ ); +}; + +export default RefactoringDemo; diff --git a/src/__mocks__/styleMock.js b/src/__mocks__/styleMock.js new file mode 100644 index 0000000..727d8df --- /dev/null +++ b/src/__mocks__/styleMock.js @@ -0,0 +1,2 @@ +// Mock for CSS imports in Jest tests +module.exports = {}; diff --git a/src/lib/GaugeComponent/hooks/arc.ts b/src/lib/GaugeComponent/hooks/arc.ts index dc3dd02..4093ba4 100644 --- a/src/lib/GaugeComponent/hooks/arc.ts +++ b/src/lib/GaugeComponent/hooks/arc.ts @@ -301,7 +301,7 @@ export const getCoordByValue = (value: number, gauge: Gauge, position = "inner", "inner": () => gauge.dimensions.current.innerRadius * radiusFactor - centerToArcLengthSubtract + 9, "between": () => { let lengthBetweenOuterAndInner = (gauge.dimensions.current.outerRadius - gauge.dimensions.current.innerRadius); - let middlePosition = gauge.dimensions.current.innerRadius + lengthBetweenOuterAndInner - 5; + let middlePosition = gauge.dimensions.current.innerRadius + lengthBetweenOuterAndInner / 2; return middlePosition; } }; @@ -331,15 +331,10 @@ export const getCoordByValue = (value: number, gauge: Gauge, position = "inner", let { startAngle, endAngle } = gaugeTypesAngles[gauge.props.type as GaugeType]; const angle = startAngle + (percent) * (endAngle - startAngle); - let coordsRadius = 1 * (gauge.dimensions.current.width / 500); - let coord = [0, -coordsRadius / 2]; - let coordMinusCenter = [ - coord[0] - centerToArcLength * Math.cos(angle), - coord[1] - centerToArcLength * Math.sin(angle), - ]; - let centerCoords = [gauge.dimensions.current.outerRadius, gauge.dimensions.current.outerRadius]; - let x = (centerCoords[0] + coordMinusCenter[0]); - let y = (centerCoords[1] + coordMinusCenter[1]); + // Calculate position relative to center (0, 0) + // Since g is now centered at gaugeCenter, coordinates are relative to origin + let x = -centerToArcLength * Math.cos(angle); + let y = -centerToArcLength * Math.sin(angle); return { x, y } } export const redrawArcs = (gauge: Gauge) => { diff --git a/src/lib/GaugeComponent/hooks/chart.ts b/src/lib/GaugeComponent/hooks/chart.ts index 0211aa9..aae1f65 100644 --- a/src/lib/GaugeComponent/hooks/chart.ts +++ b/src/lib/GaugeComponent/hooks/chart.ts @@ -7,6 +7,8 @@ import * as arcHooks from "./arc"; import * as labelsHooks from "./labels"; import * as pointerHooks from "./pointer"; import * as utilHooks from "./utils"; +import * as coordinateSystem from "./coordinateSystem"; +import { GaugeLayout } from "./coordinateSystem"; export const initChart = (gauge: Gauge, isFirstRender: boolean) => { const { angles } = gauge.dimensions.current; // if (gauge.resizeObserver?.current?.disconnect) { @@ -49,93 +51,70 @@ export const calculateAngles = (gauge: Gauge) => { //Renders the chart, should be called every time the window is resized export const renderChart = (gauge: Gauge, resize: boolean = false) => { const { dimensions } = gauge; - let gaugeTypeHeightCorrection: Record = { - [GaugeType.Semicircle]: 50, - [GaugeType.Radial]: 55, - [GaugeType.Grafana]: 55 - } let arc = gauge.props.arc as Arc; let labels = gauge.props.labels as Labels; - calculateRadius(gauge); if (resize) { var parentNode = gauge.container.current.node() as HTMLElement; if (!parentNode) return; - var parentNodeEl = document.getElementById(gauge.props.id as string); + var parentWidth = parentNode.getBoundingClientRect().width; var parentHeight = parentNode.getBoundingClientRect().height; - // .attr("viewBox", `0 0 100 100`); - - // gauge.g.current.attr('transform', `translate(${parentWidth}, ${parentHeight})`); - - var outerRadius = dimensions.current.outerRadius; - // Adjust outerRadius to fit within the parent node's height - if (outerRadius > parentHeight) { - // outerRadius = parentHeight - outerRadius = dimensions.current.outerRadius; - } - else { - outerRadius = dimensions.current.outerRadius; - } - - let gaugeTypeHeightCorrection: Record = { - [GaugeType.Semicircle]: 0, - [GaugeType.Radial]: 10, - [GaugeType.Grafana]: 25 + + // Use the new coordinate system to calculate layout + const layout = coordinateSystem.calculateGaugeLayout( + parentWidth, + parentHeight, + gauge.props.type as GaugeType, + arc.width as number, + typeof gauge.props.marginInPercent === 'number' + ? gauge.props.marginInPercent + : 0 + ); + + // Check for layout stability to prevent infinite resize loops + if (gauge.prevGSize.current) { + const stable = coordinateSystem.isLayoutStable( + gauge.prevGSize.current, + layout, + 0.005 // 0.5% tolerance + ); + if (stable) { + // Layout hasn't changed significantly, skip re-render + return; + } } - let heightRatio = gaugeTypeHeightCorrection[gauge.props.type as GaugeType] || 1; - let calculatedHeight = (parentWidth * heightRatio) - gaugeTypeHeightCorrection[gauge.props.type as GaugeType]; - - // gauge.svg.current - // .attr("width", parentWidth) - //THIS IS WHERE THINGS GO HAYWIRE, HOW DO I DECIDE WHAT WILL BE THE - //HEIGHT OF THE SVG ELEMENT IF THE PARENT DIVs DOES NOT PROVIDE A HEIGHT - //AND THE HEIGHT OF THE GAUGE IS DYNAMICALLY CALCULATED - //WE NEED A MINHEIGHT FOR THE CONTAINER DIV - //BUT KEEP OTHER FUNCTIONALITIES WORKING LIKE RESIZING - // .attr("height", parentWidth) - // .attr("height", gHeight) // Set a minimum height of 200 - // .attr('preserveAspectRatio', 'xMaxYMax'); - // .attr('preserveAspectRatio', 'xMaxYMin') - - // var xGauge = ((parentWidth / 2) - outerRadius) - // + (dimensions.current.margin.left) - dimensions.current.margin.right; - // var yGauge = ((parentHeight / 2) - outerRadius) - // + (dimensions.current.margin.top); - //Center the gauge horizontally - var xGauge = (parentWidth / 2) - outerRadius;// - dimensions.current.margin.left; - //Fix the position of the gauge vertically at the top of the frame - var yGauge = 10 - + gauge.prevGSize.current = layout; + + // Update dimensions from the new layout + coordinateSystem.updateDimensionsFromLayout(dimensions.current, layout); + + // Configure SVG with proper viewBox and dimensions + // Calculate aspect ratio from viewBox to set proper height + const aspectRatio = layout.viewBox.height / layout.viewBox.width; + gauge.svg.current .attr("width", "100%") - .attr("viewBox", "0 0 100% 100%") // clipping [origin,size] - .attr("height","100%") // this was the secret sauce - .attr('preserveAspectRatio','xMinYMin') + .attr("height", "auto") + .style("aspect-ratio", `${layout.viewBox.width} / ${layout.viewBox.height}`) + .attr("viewBox", layout.viewBox.toString()) + .attr('preserveAspectRatio', 'xMidYMid meet'); + + // Position the main gauge group at the calculated center gauge.g.current - .data([ - { - x: xGauge, - y: yGauge - } - ]) - .attr("transform", (d: any) => `translate(${d.x}, ${d.y})`) - .attr("will-change", "transform"); + .attr("transform", `translate(${layout.gaugeCenter.x}, ${layout.gaugeCenter.y})`); + // Position the doughnut (arcs) at the origin relative to g + // Since g is already centered, doughnut just needs to be at origin gauge.doughnut.current.attr( "transform", - "translate(" + (dimensions.current.outerRadius) + ", " + (dimensions.current.outerRadius) + ")" + "translate(0, 0)" ); gauge.doughnut.current .on("mouseleave", () => arcHooks.hideTooltip(gauge)) .on("mouseout", () => arcHooks.hideTooltip(gauge)); - - var gHeight = gauge.g.current.node().getBBox().height; - // .attr("height", parentHeight) - let arcWidth = arc.width as number; - dimensions.current.innerRadius = dimensions.current.outerRadius * (1 - arcWidth); clearChart(gauge); arcHooks.setArcData(gauge); arcHooks.setupArcs(gauge, resize); @@ -143,6 +122,7 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { if (!gauge.props?.pointer?.hide) pointerHooks.drawPointer(gauge, resize); } else { + // Non-resize updates (only data/props changed) let arcsPropsChanged = (JSON.stringify(gauge.prevProps.current.arc) !== JSON.stringify(gauge.props.arc)); let pointerPropsChanged = (JSON.stringify(gauge.prevProps.current.pointer) !== JSON.stringify(gauge.props.pointer)); let valueChanged = (JSON.stringify(gauge.prevProps.current.value) !== JSON.stringify(gauge.props.value)); @@ -166,100 +146,24 @@ export const renderChart = (gauge: Gauge, resize: boolean = false) => { labelsHooks.setupValueLabel(gauge); } } - var gHeight = gauge.g.current.node().getBBox().height; - var gWidth = gauge.g.current.node().getBBox().width; - var h = Math.max(gHeight, 150) - // gauge.svg.current - // .attr("height", h) - // gauge.props.style = { ...gauge.props.style, height: gHeight }; - }; -// export const updateDimensions = (gauge: Gauge) => { -// const { marginInPercent } = gauge.props; -// const { dimensions } = gauge; -// var parentNode = gauge.container.current.node().parentNode; -// var divDimensions = gauge.container.current.node().getBoundingClientRect(), -// divWidth = parentNode.getBoundingClientRect().width, -// divHeight = parentNode.getBoundingClientRect().height; -// // if (dimensions.current.fixedHeight == 0) dimensions.current.fixedHeight = divHeight + 200; -// //Set the new width and horizontal margins -// let isMarginBox = typeof marginInPercent == 'number'; -// let marginLeft: number = isMarginBox ? marginInPercent as number : -// (marginInPercent as GaugeInnerMarginInPercent).left; -// let marginRight: number = isMarginBox ? marginInPercent as number : -// (marginInPercent as GaugeInnerMarginInPercent).right; -// let marginTop: number = isMarginBox ? marginInPercent as number : -// (marginInPercent as GaugeInnerMarginInPercent).top; -// let marginBottom: number = isMarginBox ? marginInPercent as number : -// (marginInPercent as GaugeInnerMarginInPercent).bottom; -// // dimensions.current.margin.left = gauge.dimensions.current.margin.left; -// // dimensions.current.margin.right = divWidth * marginRight; -// // dimensions.current.margin.top = divHeight - marginTop; -// // dimensions.current.margin.bottom = divHeight * marginBottom; -// console.log("divHeight", divHeight); -// console.log("divWidth", divWidth); -// // (dimensions.current.margin.left - dimensions.current.margin.right); - -// // dimensions.current.margin.top = gauge.dimensions.current.margin.top; -// // dimensions.current.margin.bottom = dimensions.current.fixedHeight * marginBottom; -// // dimensions.current.margin.left = gauge.dimensions.current.margin.left; -// // // dimensions.current.margin.right = divWidth * marginRight; -// // dimensions.current.height = parentNode.getBoundingClientRect().height; -// // dimensions.current.width = parentNode.getBoundingClientRect().width; -// // dimensions.current.width / 2 - dimensions.current.margin.top - dimensions.current.margin.bottom; -// //gauge.height.current = divHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; -// }; +/** + * Legacy function kept for backward compatibility during transition + * This should eventually be removed as all code migrates to the new coordinate system + * @deprecated Use coordinateSystem.calculateGaugeLayout instead + */ export const calculateRadius = (gauge: Gauge) => { - const { dimensions } = gauge; - const parentNode = gauge.container.current.node().parentNode as HTMLElement; - const parentNodeOfTheParentNode = parentNode.parentNode as HTMLElement; - const parentWidth = parentNode.getBoundingClientRect().width; - const parentHeight = gauge.container.current.node().getBoundingClientRect().height ?? 0; - const availableWidth = parentWidth - dimensions.current.margin.left - dimensions.current.margin.right; - const availableHeight = parentHeight - dimensions.current.margin.top - dimensions.current.margin.bottom; - - // if (gauge.props.type === GaugeType.Semicircle) { - // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight / 2); - // } else { - // dimensions.current.outerRadius = Math.min(availableWidth / 2, availableHeight); - // } - // if(availableHeight < availableWidth) { - dimensions.current.outerRadius = Math.min(availableWidth - 100, availableHeight) / 2; - // } - // else { - // dimensions.current.outerRadius = Math.min(parentHeight, availableWidth); - // dimensions.current.outerRadius = availableHeight; - // } - console.log(dimensions.current.outerRadius > availableHeight) - // if (dimensions.current.outerRadius > parentHeight) - console.log("outerRadius", dimensions.current.outerRadius) - console.log("parentHeight", parentHeight) - centerGraph(gauge); + // This function is now handled by the coordinate system module + // Kept for backward compatibility only }; -//Calculates new margins to make the graph centered -// export const centerGraph = (gauge: Gauge) => { -// const { dimensions } = gauge; -// dimensions.current.margin.left = -// dimensions.current.width / 2 - dimensions.current.outerRadius + dimensions.current.margin.right; -// gauge.g.current.attr( -// "transform", -// "translate(" + dimensions.current.margin.left + ", " + (dimensions.current.margin.top) + ")" -// ); -// }; - +/** + * Legacy function kept for backward compatibility during transition + * @deprecated Centering is now handled by coordinateSystem.calculateGaugeCenter + */ export const centerGraph = (gauge: Gauge) => { - const { dimensions } = gauge; - const xOffset = dimensions.current.width / 2; - const yOffset = - gauge.props.type === GaugeType.Semicircle - ? dimensions.current.height - : dimensions.current.height / 2; - var marginTop = dimensions.current.margin.top; - var marginBottom = dimensions.current.margin.bottom; - var marginLeft = dimensions.current.margin.left; - var marginRight = dimensions.current.margin.right; - // gauge.g.current.attr("transform", `translate(${marginLeft}, ${marginTop})`); + // This function is now handled by the coordinate system module + // Kept for backward compatibility only }; export const clearChart = (gauge: Gauge) => { diff --git a/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts b/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts new file mode 100644 index 0000000..45e968a --- /dev/null +++ b/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts @@ -0,0 +1,375 @@ +import { + calculateOptimalRadius, + calculateViewBox, + calculateGaugeCenter, + calculateGaugeLayout, + isLayoutStable, +} from './coordinateSystem'; +import { GaugeType } from '../types/GaugeComponentProps'; + +describe('Coordinate System', () => { + describe('calculateOptimalRadius', () => { + it('should calculate radius that fits within available space', () => { + const radius = calculateOptimalRadius(400, 300, GaugeType.Semicircle); + + // Radius should be less than half the width to account for padding + expect(radius).toBeLessThan(200); + expect(radius).toBeGreaterThan(0); + }); + + it('should respect height constraints for semicircle', () => { + const radius = calculateOptimalRadius(1000, 200, GaugeType.Semicircle); + + // With limited height, radius should be constrained by height, not width + expect(radius).toBeLessThan(500); + }); + + it('should respect width constraints', () => { + const radius = calculateOptimalRadius(200, 1000, GaugeType.Semicircle); + + // With limited width, radius should be constrained by width + expect(radius).toBeLessThan(100); + }); + + it('should handle margin percentage correctly', () => { + const radiusNoMargin = calculateOptimalRadius(400, 300, GaugeType.Semicircle, 0); + const radiusWithMargin = calculateOptimalRadius(400, 300, GaugeType.Semicircle, 0.1); + + expect(radiusWithMargin).toBeLessThan(radiusNoMargin); + }); + + it('should calculate different radii for gauge types with different padding', () => { + // Grafana has different paddingPercent (0.12) vs Semicircle/Radial (0.15) + const semicircleRadius = calculateOptimalRadius(400, 400, GaugeType.Semicircle); + const grafanaRadius = calculateOptimalRadius(400, 400, GaugeType.Grafana); + + // Different padding percentages should produce different radii + expect(semicircleRadius).not.toBe(grafanaRadius); + // Verify they're both reasonable + expect(semicircleRadius).toBeGreaterThan(150); + expect(grafanaRadius).toBeGreaterThan(150); + }); + }); + + describe('calculateViewBox', () => { + it('should create valid viewBox for semicircle', () => { + const viewBox = calculateViewBox(100, GaugeType.Semicircle); + + expect(viewBox.x).toBe(0); + expect(viewBox.y).toBe(0); + expect(viewBox.width).toBeGreaterThan(0); + expect(viewBox.height).toBeGreaterThan(0); + expect(viewBox.width).toBeGreaterThan(200); // Diameter + padding + }); + + it('should create viewBox with height less than width for semicircle', () => { + const viewBox = calculateViewBox(100, GaugeType.Semicircle); + + // Semicircle should be wider than tall + expect(viewBox.height).toBeLessThan(viewBox.width); + }); + + it('should create appropriate viewBox for radial gauge', () => { + const viewBox = calculateViewBox(100, GaugeType.Radial); + + expect(viewBox.height).toBeGreaterThan(100); // Needs more vertical space + }); + + it('should return valid viewBox string', () => { + const viewBox = calculateViewBox(100, GaugeType.Semicircle); + const viewBoxString = viewBox.toString(); + + // Should be in format "x y width height" + const parts = viewBoxString.split(' '); + expect(parts).toHaveLength(4); + expect(parseFloat(parts[2])).toBeGreaterThan(0); + expect(parseFloat(parts[3])).toBeGreaterThan(0); + }); + }); + + describe('calculateGaugeCenter', () => { + it('should center gauge horizontally', () => { + const viewBox = calculateViewBox(100, GaugeType.Semicircle); + const center = calculateGaugeCenter(viewBox, 100, GaugeType.Semicircle); + + // Should be horizontally centered + expect(center.x).toBe(viewBox.width / 2); + }); + + it('should position semicircle appropriately', () => { + const viewBox = calculateViewBox(100, GaugeType.Semicircle); + const center = calculateGaugeCenter(viewBox, 100, GaugeType.Semicircle); + + // Y position should account for semicircle shape + expect(center.y).toBeGreaterThan(0); + expect(center.y).toBeLessThan(viewBox.height); + }); + + it('should calculate consistent centers for same inputs', () => { + const viewBox = calculateViewBox(150, GaugeType.Radial); + const center1 = calculateGaugeCenter(viewBox, 150, GaugeType.Radial); + const center2 = calculateGaugeCenter(viewBox, 150, GaugeType.Radial); + + expect(center1.x).toBe(center2.x); + expect(center1.y).toBe(center2.y); + }); + }); + + describe('calculateGaugeLayout', () => { + it('should return complete layout information', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + expect(layout.viewBox).toBeDefined(); + expect(layout.outerRadius).toBeGreaterThan(0); + expect(layout.innerRadius).toBeGreaterThan(0); + expect(layout.gaugeCenter).toBeDefined(); + expect(layout.doughnutTransform).toBeDefined(); + }); + + it('should respect arc width in inner radius calculation', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.4); + + // Wider arc should result in smaller inner radius + expect(layout2.innerRadius).toBeLessThan(layout1.innerRadius); + + // Outer radius should be the same + expect(layout1.outerRadius).toBe(layout2.outerRadius); + }); + + it('should maintain consistent inner to outer radius ratio', () => { + const arcWidth = 0.3; + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, arcWidth); + + const expectedInnerRadius = layout.outerRadius * (1 - arcWidth); + expect(layout.innerRadius).toBeCloseTo(expectedInnerRadius, 5); + }); + + it('should scale proportionally with parent dimensions', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(800, 600, GaugeType.Semicircle, 0.2); + + // Doubling dimensions should roughly double the radius + const ratio = layout2.outerRadius / layout1.outerRadius; + expect(ratio).toBeGreaterThan(1.8); + expect(ratio).toBeLessThan(2.2); + }); + + it('should create valid layout for all gauge types', () => { + const types = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + types.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + expect(layout.outerRadius).toBeGreaterThan(0); + expect(layout.innerRadius).toBeGreaterThan(0); + expect(layout.innerRadius).toBeLessThan(layout.outerRadius); + expect(layout.viewBox.width).toBeGreaterThan(0); + expect(layout.viewBox.height).toBeGreaterThan(0); + }); + }); + + it('should not waste excessive space', () => { + const parentWidth = 400; + const parentHeight = 300; + const layout = calculateGaugeLayout(parentWidth, parentHeight, GaugeType.Semicircle, 0.2); + + // The viewBox should utilize most of the available space + // With padding, it should still be a significant portion + const utilizationWidth = (layout.outerRadius * 2) / parentWidth; + const utilizationHeight = (layout.outerRadius * 2) / parentHeight; + + // Should use at least 60% of available space (accounting for padding) + expect(utilizationWidth).toBeGreaterThan(0.6); + }); + }); + + describe('isLayoutStable', () => { + it('should return true for first layout', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + expect(isLayoutStable(null, layout)).toBe(true); + }); + + it('should return true for identical layouts', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + expect(isLayoutStable(layout1, layout2)).toBe(true); + }); + + it('should return false for significantly different layouts', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(800, 600, GaugeType.Semicircle, 0.2); + + expect(isLayoutStable(layout1, layout2)).toBe(false); + }); + + it('should return true for minor variations within tolerance', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(401, 300, GaugeType.Semicircle, 0.2); + + expect(isLayoutStable(layout1, layout2, 0.01)).toBe(true); + }); + + it('should detect infinite resize loops', () => { + // Simulate a resize loop scenario + const layouts = []; + let prevLayout = null; + + for (let i = 0; i < 10; i++) { + // Gradually changing dimensions (simulating instability) + const currentLayout = calculateGaugeLayout( + 400 + i * 0.5, + 300, + GaugeType.Semicircle, + 0.2 + ); + + if (prevLayout) { + const stable = isLayoutStable(prevLayout, currentLayout, 0.001); + layouts.push(stable); + } + + prevLayout = currentLayout; + } + + // Should detect changes + expect(layouts.some(stable => !stable)).toBe(true); + }); + }); + + describe('Edge Cases', () => { + it('should handle very small dimensions', () => { + const layout = calculateGaugeLayout(50, 50, GaugeType.Semicircle, 0.2); + + expect(layout.outerRadius).toBeGreaterThan(0); + expect(layout.innerRadius).toBeGreaterThan(0); + }); + + it('should handle very large dimensions', () => { + const layout = calculateGaugeLayout(4000, 3000, GaugeType.Semicircle, 0.2); + + expect(layout.outerRadius).toBeGreaterThan(0); + expect(layout.viewBox.width).toBeGreaterThan(0); + }); + + it('should handle extreme aspect ratios', () => { + const wideLayout = calculateGaugeLayout(1000, 100, GaugeType.Semicircle, 0.2); + const tallLayout = calculateGaugeLayout(100, 1000, GaugeType.Semicircle, 0.2); + + expect(wideLayout.outerRadius).toBeGreaterThan(0); + expect(tallLayout.outerRadius).toBeGreaterThan(0); + }); + + it('should handle zero arc width', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0); + + // Inner radius should equal outer radius + expect(layout.innerRadius).toBe(layout.outerRadius); + }); + + it('should handle full arc width', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 1); + + // Inner radius should be zero + expect(layout.innerRadius).toBe(0); + }); + }); + + describe('G Element Containment (Critical for preventing cutoff)', () => { + it('should ensure g element fully contained in SVG for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // Calculate g element bounds (center ± radius) + const gLeft = layout.gaugeCenter.x - layout.outerRadius; + const gRight = layout.gaugeCenter.x + layout.outerRadius; + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + + // G element must be fully within viewBox + expect(gLeft).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(gRight).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(gTop).toBeGreaterThanOrEqual(layout.viewBox.y); + // Note: bottom can extend beyond for semicircle by design + }); + + it('should ensure g element fully contained in SVG for Radial', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Radial, 0.2); + + const gLeft = layout.gaugeCenter.x - layout.outerRadius; + const gRight = layout.gaugeCenter.x + layout.outerRadius; + const gTop = layout.gaugeCenter.y - layout.outerRadius; + + expect(gLeft).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(gRight).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(gTop).toBeGreaterThanOrEqual(layout.viewBox.y); + }); + + it('should ensure g element fully contained in SVG for Grafana', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Grafana, 0.2); + + const gLeft = layout.gaugeCenter.x - layout.outerRadius; + const gRight = layout.gaugeCenter.x + layout.outerRadius; + const gTop = layout.gaugeCenter.y - layout.outerRadius; + + expect(gLeft).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(gRight).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(gTop).toBeGreaterThanOrEqual(layout.viewBox.y); + }); + + it('should prevent top cutoff with adequate padding', () => { + const types = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + types.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const topPadding = gTop - layout.viewBox.y; + + // Should have at least some padding (not touching edge) + expect(topPadding).toBeGreaterThan(0); + + // Should have reasonable padding (at least 5% of radius) + expect(topPadding).toBeGreaterThanOrEqual(layout.outerRadius * 0.05); + }); + }); + + it('should maintain containment across various sizes', () => { + const sizes = [ + [200, 150], + [400, 300], + [800, 600], + [1200, 900] + ]; + + sizes.forEach(([width, height]) => { + const layout = calculateGaugeLayout(width, height, GaugeType.Semicircle, 0.2); + + const gLeft = layout.gaugeCenter.x - layout.outerRadius; + const gRight = layout.gaugeCenter.x + layout.outerRadius; + const gTop = layout.gaugeCenter.y - layout.outerRadius; + + expect(gLeft).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(gRight).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(gTop).toBeGreaterThanOrEqual(layout.viewBox.y); + }); + }); + + it('should handle containment with different arc widths', () => { + const arcWidths = [0.1, 0.2, 0.3, 0.5, 0.8]; + + arcWidths.forEach(arcWidth => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, arcWidth); + + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + + // Top should always be contained + expect(gTop).toBeGreaterThanOrEqual(layout.viewBox.y); + + // Outer radius shouldn't change with arc width + expect(layout.outerRadius).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/src/lib/GaugeComponent/hooks/coordinateSystem.ts b/src/lib/GaugeComponent/hooks/coordinateSystem.ts new file mode 100644 index 0000000..be9b2aa --- /dev/null +++ b/src/lib/GaugeComponent/hooks/coordinateSystem.ts @@ -0,0 +1,219 @@ +import { GaugeType } from "../types/GaugeComponentProps"; +import { Dimensions } from "../types/Dimensions"; + +/** + * Coordinate System Manager for Gauge Component + * + * This module centralizes all coordinate, dimension, and viewBox calculations + * to ensure consistent positioning and optimal space utilization. + */ + +export interface ViewBox { + x: number; + y: number; + width: number; + height: number; + toString(): string; +} + +export interface GaugeLayout { + viewBox: ViewBox; + outerRadius: number; + innerRadius: number; + gaugeCenter: { x: number; y: number }; + doughnutTransform: { x: number; y: number }; +} + +/** + * Configuration for gauge type specific dimensions + */ +const GAUGE_TYPE_CONFIG = { + [GaugeType.Semicircle]: { + // Semicircle occupies approximately 50% of the circle height + some padding + heightRatio: 0.55, + // Padding around the gauge for labels and ticks + paddingPercent: 0.15, + }, + [GaugeType.Radial]: { + // Radial needs more height (about 75% of full circle) + heightRatio: 0.78, + paddingPercent: 0.15, + }, + [GaugeType.Grafana]: { + // Grafana style is similar to radial + heightRatio: 0.75, + paddingPercent: 0.12, + }, +}; + +/** + * Calculates optimal outer radius given available space and gauge type + * This is the core function that determines how much space the gauge will use + */ +export const calculateOptimalRadius = ( + parentWidth: number, + parentHeight: number, + gaugeType: GaugeType, + marginPercent: number = 0 +): number => { + const config = GAUGE_TYPE_CONFIG[gaugeType]; + + // Apply margin (as percentage of dimensions) + const availableWidth = parentWidth * (1 - marginPercent); + const availableHeight = parentHeight * (1 - marginPercent); + + // Apply padding for labels and ticks + const paddedWidth = availableWidth * (1 - config.paddingPercent); + const paddedHeight = availableHeight * (1 - config.paddingPercent); + + // For semicircle and radial, height constraint is different + // The gauge diameter should fit in available width, or available height / heightRatio + const radiusFromWidth = paddedWidth / 2; + const radiusFromHeight = paddedHeight / config.heightRatio / 2; + + // Use the smaller radius to ensure gauge fits in both dimensions + return Math.min(radiusFromWidth, radiusFromHeight); +}; + +/** + * Calculates the viewBox dimensions based on gauge type and radius + * ViewBox defines the coordinate system for the SVG + */ +export const calculateViewBox = ( + outerRadius: number, + gaugeType: GaugeType +): ViewBox => { + const config = GAUGE_TYPE_CONFIG[gaugeType]; + const diameter = outerRadius * 2; + + // Minimal padding for labels and ticks (small fixed value) + const minPadding = 10; // Small fixed padding in viewBox units + + let width = diameter + minPadding * 2; + let height: number; + let y = 0; + + // Adjust height based on gauge type - be tight to avoid wasted space + if (gaugeType === GaugeType.Semicircle) { + // Semicircle: minimal padding + outerRadius (center to top) + space below for arc & label + // Top padding + radius above center + radius below center * 0.7 + bottom padding + height = minPadding + outerRadius + outerRadius * 0.7 + minPadding; + } else { + // Radial and Grafana: just diameter + minimal padding + height = diameter + minPadding * 2; + } + + return { + x: 0, + y: y, + width: width, + height: height, + toString() { + return `${this.x} ${this.y} ${this.width} ${this.height}`; + }, + }; +}; + +/** + * Calculates the center point of the gauge within the viewBox + * This is where the doughnut element should be positioned + */ +export const calculateGaugeCenter = ( + viewBox: ViewBox, + outerRadius: number, + gaugeType: GaugeType +): { x: number; y: number } => { + const minPadding = 10; // Must match the padding used in calculateViewBox + + // Horizontal center is always in the middle + const x = viewBox.width / 2; + + let y: number; + if (gaugeType === GaugeType.Semicircle) { + // For semicircle, position center so top of arc has minPadding from top + // y = minPadding (top) + outerRadius (distance from top to center) + y = minPadding + outerRadius; + } else { + // For radial and grafana, center vertically + y = viewBox.height / 2; + } + + return { x, y }; +}; + +/** + * Calculates complete layout information for the gauge + * This is the main function used by the rendering code + */ +export const calculateGaugeLayout = ( + parentWidth: number, + parentHeight: number, + gaugeType: GaugeType, + arcWidth: number, + marginPercent: number = 0 +): GaugeLayout => { + // Calculate optimal outer radius + const outerRadius = calculateOptimalRadius( + parentWidth, + parentHeight, + gaugeType, + marginPercent + ); + + // Calculate inner radius based on arc width + const innerRadius = outerRadius * (1 - arcWidth); + + // Calculate viewBox + const viewBox = calculateViewBox(outerRadius, gaugeType); + + // Calculate gauge center position + const gaugeCenter = calculateGaugeCenter(viewBox, outerRadius, gaugeType); + + // The doughnut transform is the same as gauge center + // because doughnut is positioned relative to its parent + const doughnutTransform = { x: outerRadius, y: outerRadius }; + + return { + viewBox, + outerRadius, + innerRadius, + gaugeCenter, + doughnutTransform, + }; +}; + +/** + * Updates the dimensions object with new layout calculations + * This maintains backward compatibility with existing code + */ +export const updateDimensionsFromLayout = ( + dimensions: Dimensions, + layout: GaugeLayout +): void => { + dimensions.outerRadius = layout.outerRadius; + dimensions.innerRadius = layout.innerRadius; + dimensions.width = layout.viewBox.width; + dimensions.height = layout.viewBox.height; + + // Keep margin values as they might be used elsewhere + // But they should be derived from the layout in the future +}; + +/** + * Validates that a gauge won't cause infinite resizing + * Returns true if the layout is stable + */ +export const isLayoutStable = ( + previousLayout: GaugeLayout | null, + currentLayout: GaugeLayout, + tolerance: number = 0.01 +): boolean => { + if (!previousLayout) return true; + + const radiusDiff = Math.abs( + currentLayout.outerRadius - previousLayout.outerRadius + ); + const radiusChange = radiusDiff / previousLayout.outerRadius; + + return radiusChange < tolerance; +}; diff --git a/src/lib/GaugeComponent/hooks/debugHelpers.ts b/src/lib/GaugeComponent/hooks/debugHelpers.ts new file mode 100644 index 0000000..dcb7fc6 --- /dev/null +++ b/src/lib/GaugeComponent/hooks/debugHelpers.ts @@ -0,0 +1,315 @@ +/** + * Debug Helpers for Gauge Component + * + * These utilities help visualize and debug the coordinate system, + * making it easier to understand positioning and catch issues. + */ + +import { Gauge } from '../types/Gauge'; +import { GaugeLayout } from './coordinateSystem'; + +/** + * Draws a debug overlay showing the coordinate system + * Call this after renderChart to visualize layout + */ +export const drawDebugOverlay = (gauge: Gauge, layout: GaugeLayout) => { + const debugGroup = gauge.g.current.append('g').attr('class', 'debug-overlay'); + + // Draw origin point (0, 0) + debugGroup.append('circle') + .attr('cx', 0) + .attr('cy', 0) + .attr('r', 3) + .attr('fill', 'red') + .attr('stroke', 'white') + .attr('stroke-width', 1); + + // Draw origin crosshair + debugGroup.append('line') + .attr('x1', -10) + .attr('y1', 0) + .attr('x2', 10) + .attr('y2', 0) + .attr('stroke', 'red') + .attr('stroke-width', 1) + .attr('stroke-dasharray', '2,2'); + + debugGroup.append('line') + .attr('x1', 0) + .attr('y1', -10) + .attr('x2', 0) + .attr('y2', 10) + .attr('stroke', 'red') + .attr('stroke-width', 1) + .attr('stroke-dasharray', '2,2'); + + // Draw outer radius circle + debugGroup.append('circle') + .attr('cx', 0) + .attr('cy', 0) + .attr('r', layout.outerRadius) + .attr('fill', 'none') + .attr('stroke', 'blue') + .attr('stroke-width', 1) + .attr('stroke-dasharray', '5,5') + .attr('opacity', 0.5); + + // Draw inner radius circle + debugGroup.append('circle') + .attr('cx', 0) + .attr('cy', 0) + .attr('r', layout.innerRadius) + .attr('fill', 'none') + .attr('stroke', 'green') + .attr('stroke-width', 1) + .attr('stroke-dasharray', '5,5') + .attr('opacity', 0.5); + + // Add labels + debugGroup.append('text') + .attr('x', 5) + .attr('y', -5) + .attr('fill', 'red') + .attr('font-size', '10px') + .text('(0,0)'); + + debugGroup.append('text') + .attr('x', 5) + .attr('y', -layout.outerRadius + 15) + .attr('fill', 'blue') + .attr('font-size', '10px') + .text(`outer: ${layout.outerRadius.toFixed(1)}`); + + debugGroup.append('text') + .attr('x', 5) + .attr('y', -layout.innerRadius + 15) + .attr('fill', 'green') + .attr('font-size', '10px') + .text(`inner: ${layout.innerRadius.toFixed(1)}`); +}; + +/** + * Removes the debug overlay + */ +export const clearDebugOverlay = (gauge: Gauge) => { + gauge.g.current.selectAll('.debug-overlay').remove(); +}; + +/** + * Draws the viewBox bounds in the SVG + * Useful to see if elements are outside the viewBox + */ +export const drawViewBoxBounds = (gauge: Gauge, layout: GaugeLayout) => { + const { viewBox } = layout; + + // Add to SVG (not g), since viewBox is in SVG coordinate space + gauge.svg.current.append('rect') + .attr('x', viewBox.x) + .attr('y', viewBox.y) + .attr('width', viewBox.width) + .attr('height', viewBox.height) + .attr('fill', 'none') + .attr('stroke', 'purple') + .attr('stroke-width', 2) + .attr('stroke-dasharray', '10,5') + .attr('opacity', 0.7) + .attr('class', 'debug-viewbox'); +}; + +/** + * Removes viewBox bounds visualization + */ +export const clearViewBoxBounds = (gauge: Gauge) => { + gauge.svg.current.selectAll('.debug-viewbox').remove(); +}; + +/** + * Logs detailed layout information to console + */ +export const logLayoutInfo = (gauge: Gauge, layout: GaugeLayout, parentWidth: number, parentHeight: number) => { + console.group('🎯 Gauge Layout Information'); + + console.log('Parent Container:', { + width: parentWidth, + height: parentHeight, + aspectRatio: (parentWidth / parentHeight).toFixed(2), + }); + + console.log('ViewBox:', { + x: layout.viewBox.x, + y: layout.viewBox.y, + width: layout.viewBox.width, + height: layout.viewBox.height, + string: layout.viewBox.toString(), + }); + + console.log('Radii:', { + outer: layout.outerRadius, + inner: layout.innerRadius, + arcWidth: layout.outerRadius - layout.innerRadius, + arcWidthPercent: ((layout.outerRadius - layout.innerRadius) / layout.outerRadius * 100).toFixed(1) + '%', + }); + + console.log('Center Point:', { + x: layout.gaugeCenter.x, + y: layout.gaugeCenter.y, + }); + + console.log('Space Utilization:', { + widthUsage: ((layout.outerRadius * 2 / parentWidth) * 100).toFixed(1) + '%', + heightUsage: ((layout.outerRadius * 2 / parentHeight) * 100).toFixed(1) + '%', + }); + + console.log('Gauge Type:', gauge.props.type); + + console.groupEnd(); +}; + +/** + * Tracks render count to detect infinite loops + * Returns a function to call on each render + */ +export const createRenderCounter = (threshold: number = 10) => { + let renderCount = 0; + let lastResetTime = Date.now(); + + return () => { + renderCount++; + const now = Date.now(); + const elapsed = now - lastResetTime; + + // Reset counter every second + if (elapsed > 1000) { + if (renderCount > threshold) { + console.warn(`⚠️ High render count: ${renderCount} renders in ${elapsed}ms`); + console.warn('This may indicate an infinite loop or excessive re-rendering'); + } + renderCount = 0; + lastResetTime = now; + } + + return renderCount; + }; +}; + +/** + * Validates that a layout is valid + * Throws descriptive errors if something is wrong + */ +export const validateLayout = (layout: GaugeLayout, parentWidth: number, parentHeight: number) => { + const errors: string[] = []; + + // Check for NaN values + if (isNaN(layout.outerRadius)) errors.push('outerRadius is NaN'); + if (isNaN(layout.innerRadius)) errors.push('innerRadius is NaN'); + if (isNaN(layout.viewBox.width)) errors.push('viewBox.width is NaN'); + if (isNaN(layout.viewBox.height)) errors.push('viewBox.height is NaN'); + + // Check for invalid values + if (layout.outerRadius <= 0) errors.push('outerRadius must be positive'); + if (layout.innerRadius < 0) errors.push('innerRadius must be non-negative'); + if (layout.innerRadius > layout.outerRadius) errors.push('innerRadius cannot exceed outerRadius'); + if (layout.viewBox.width <= 0) errors.push('viewBox.width must be positive'); + if (layout.viewBox.height <= 0) errors.push('viewBox.height must be positive'); + + // Check for unreasonable values + if (layout.outerRadius > parentWidth * 2) { + errors.push(`outerRadius (${layout.outerRadius}) is unreasonably large for parent width (${parentWidth})`); + } + if (layout.outerRadius > parentHeight * 2) { + errors.push(`outerRadius (${layout.outerRadius}) is unreasonably large for parent height (${parentHeight})`); + } + + if (errors.length > 0) { + console.error('❌ Invalid Layout Detected:'); + errors.forEach(err => console.error(' -', err)); + console.error('Layout:', layout); + throw new Error('Invalid gauge layout: ' + errors.join(', ')); + } + + return true; +}; + +/** + * Creates a visual test pattern to verify positioning + * Draws markers at key angles and radii + */ +export const drawTestPattern = (gauge: Gauge) => { + const testGroup = gauge.g.current.append('g').attr('class', 'test-pattern'); + const { outerRadius, innerRadius } = gauge.dimensions.current; + + // Draw angle markers every 45 degrees + const angles = [0, 45, 90, 135, 180, 225, 270, 315]; + + angles.forEach(degrees => { + const radians = (degrees - 90) * Math.PI / 180; // -90 to start from top + const x = outerRadius * Math.cos(radians); + const y = outerRadius * Math.sin(radians); + + // Marker at outer radius + testGroup.append('circle') + .attr('cx', x) + .attr('cy', y) + .attr('r', 3) + .attr('fill', 'orange'); + + // Label + testGroup.append('text') + .attr('x', x * 1.15) + .attr('y', y * 1.15) + .attr('text-anchor', 'middle') + .attr('font-size', '8px') + .attr('fill', 'orange') + .text(degrees + '°'); + }); + + // Draw radius markers + const radii = [innerRadius, (innerRadius + outerRadius) / 2, outerRadius]; + const radiusLabels = ['inner', 'mid', 'outer']; + + radii.forEach((radius, i) => { + testGroup.append('circle') + .attr('cx', 0) + .attr('cy', 0) + .attr('r', radius) + .attr('fill', 'none') + .attr('stroke', 'orange') + .attr('stroke-width', 0.5) + .attr('stroke-dasharray', '2,2') + .attr('opacity', 0.5); + + testGroup.append('text') + .attr('x', 5) + .attr('y', -radius + 3) + .attr('font-size', '8px') + .attr('fill', 'orange') + .text(radiusLabels[i]); + }); +}; + +/** + * Removes test pattern + */ +export const clearTestPattern = (gauge: Gauge) => { + gauge.g.current.selectAll('.test-pattern').remove(); +}; + +/** + * Comprehensive debug mode - enables all visualizations + */ +export const enableDebugMode = (gauge: Gauge, layout: GaugeLayout, parentWidth: number, parentHeight: number) => { + drawDebugOverlay(gauge, layout); + drawViewBoxBounds(gauge, layout); + drawTestPattern(gauge); + logLayoutInfo(gauge, layout, parentWidth, parentHeight); + validateLayout(layout, parentWidth, parentHeight); +}; + +/** + * Disables debug mode - removes all visualizations + */ +export const disableDebugMode = (gauge: Gauge) => { + clearDebugOverlay(gauge); + clearViewBoxBounds(gauge); + clearTestPattern(gauge); +}; diff --git a/src/lib/GaugeComponent/hooks/labels.ts b/src/lib/GaugeComponent/hooks/labels.ts index 4a6363b..f1dd255 100644 --- a/src/lib/GaugeComponent/hooks/labels.ts +++ b/src/lib/GaugeComponent/hooks/labels.ts @@ -231,19 +231,24 @@ export const addValueText = (gauge: Gauge) => { let fontRatio = textLength > maxLengthBeforeComputation ? maxLengthBeforeComputation / textLength * 1.5 : 1; // Compute the font size ratio let valueFontSize = valueLabel?.style?.fontSize as string; let valueTextStyle = { ...valueLabel.style }; - let x = gauge.dimensions.current.outerRadius; + // Since g is centered at gauge center (0, 0), position label relative to that + let x = 0; let y = 0; valueTextStyle.textAnchor = "middle"; + + // Position label in the center/visible area of the gauge if (gauge.props.type == GaugeType.Semicircle) { - y = gauge.dimensions.current.outerRadius / 1.5 + textPadding; + // For semicircle, place label slightly below center + // Since center is at (0,0), positive y moves down + y = gauge.dimensions.current.innerRadius * 0.3 + textPadding; } else if (gauge.props.type == GaugeType.Radial) { - y = gauge.dimensions.current.outerRadius * 1.45 + textPadding; + // For radial, place label more towards bottom + y = gauge.dimensions.current.innerRadius * 0.5 + textPadding; } else if (gauge.props.type == GaugeType.Grafana) { - y = gauge.dimensions.current.outerRadius * 1.0 + textPadding; + // For Grafana, center of gauge + y = textPadding; } - //if(gauge.props.pointer.type == PointerType.Arrow){ - // y = gauge.dimensions.current.outerRadius * 0.79 + textPadding; - //} + let widthFactor = gauge.props.type == GaugeType.Radial ? 0.003 : 0.003; fontRatio = gauge.dimensions.current.width * widthFactor * fontRatio; let fontSizeNumber = parseInt(valueFontSize, 10) * fontRatio; diff --git a/src/lib/GaugeComponent/hooks/pointer.ts b/src/lib/GaugeComponent/hooks/pointer.ts index 89fa3ec..8f4d039 100644 --- a/src/lib/GaugeComponent/hooks/pointer.ts +++ b/src/lib/GaugeComponent/hooks/pointer.ts @@ -118,8 +118,8 @@ const setPointerPosition = (pointerRadius: number, progress: number, gauge: Gaug let value = utils.getCurrentGaugeValueByPercentage(progress, gauge); let pointers: { [key: string]: () => void } = { [PointerType.Needle]: () => { - // Set needle position to center - translatePointer(dimensions.current.outerRadius,dimensions.current.outerRadius, gauge); + // Set needle position to center (origin, since g is already centered) + translatePointer(0, 0, gauge); }, [PointerType.Arrow]: () => { let { x, y } = getCoordByValue(value, gauge, "inner", 0, 0.70); diff --git a/src/lib/GaugeComponent/hooks/rendering.test.ts b/src/lib/GaugeComponent/hooks/rendering.test.ts new file mode 100644 index 0000000..88a6d24 --- /dev/null +++ b/src/lib/GaugeComponent/hooks/rendering.test.ts @@ -0,0 +1,254 @@ +/** + * Tests for rendering behaviors: + * - Infinite loop detection + * - Element positioning within viewBox + * - G element boundaries + */ + +import { calculateGaugeLayout, isLayoutStable } from './coordinateSystem'; +import { GaugeType } from '../types/GaugeComponentProps'; + +describe('Rendering Behavior Tests', () => { + describe('Infinite Loop Prevention', () => { + it('should detect stable layout and prevent re-render', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + const stable = isLayoutStable(layout1, layout2, 0.005); + expect(stable).toBe(true); + }); + + it('should detect unstable layout when size changes significantly', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(450, 300, GaugeType.Semicircle, 0.2); + + const stable = isLayoutStable(layout1, layout2, 0.005); + expect(stable).toBe(false); + }); + + it('should tolerate minor variations within threshold', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + // Simulate tiny change (< 0.5%) + const layout2 = calculateGaugeLayout(401, 300, GaugeType.Semicircle, 0.2); + + const stable = isLayoutStable(layout1, layout2, 0.005); + expect(stable).toBe(true); + }); + + it('should handle rapid successive renders', () => { + const layouts = []; + const sizes = [400, 401, 400, 401, 400]; // Oscillating + + for (let i = 0; i < sizes.length; i++) { + layouts.push(calculateGaugeLayout(sizes[i], 300, GaugeType.Semicircle, 0.2)); + } + + // Check that alternating layouts are considered stable + const stable1 = isLayoutStable(layouts[0], layouts[2], 0.005); + const stable2 = isLayoutStable(layouts[1], layouts[3], 0.005); + + expect(stable1).toBe(true); + expect(stable2).toBe(true); + }); + + it('should prevent infinite loop scenario with rounding errors', () => { + // Simulate scenario where calculations might have floating point errors + const layout1 = calculateGaugeLayout(400.1, 300.1, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(400.2, 300.2, GaugeType.Semicircle, 0.2); + + const stable = isLayoutStable(layout1, layout2, 0.005); + expect(stable).toBe(true); + }); + }); + + describe('G Element Boundary Tests', () => { + it('should ensure gauge center allows full radius within viewBox', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // The gauge center should be positioned such that: + // center.x - outerRadius >= viewBox.x + // center.x + outerRadius <= viewBox.x + viewBox.width + const leftEdge = layout.gaugeCenter.x - layout.outerRadius; + const rightEdge = layout.gaugeCenter.x + layout.outerRadius; + const topEdge = layout.gaugeCenter.y - layout.outerRadius; + const bottomEdge = layout.gaugeCenter.y + layout.outerRadius; + + expect(leftEdge).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(rightEdge).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(topEdge).toBeGreaterThanOrEqual(layout.viewBox.y); + // Bottom can extend beyond for semicircle, but top must be within + }); + + it('should ensure all gauge types fit within viewBox horizontally', () => { + const gaugeTypes = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + gaugeTypes.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + const leftEdge = layout.gaugeCenter.x - layout.outerRadius; + const rightEdge = layout.gaugeCenter.x + layout.outerRadius; + + expect(leftEdge).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(rightEdge).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + }); + }); + + it('should prevent top cutoff by ensuring adequate padding', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // Top of the gauge should not be at or above viewBox top + const topEdge = layout.gaugeCenter.y - layout.outerRadius; + + // Should have some padding (at least 1px, ideally more) + expect(topEdge).toBeGreaterThan(layout.viewBox.y); + + // Should have reasonable padding (at least 5% of radius) + const padding = topEdge - layout.viewBox.y; + expect(padding).toBeGreaterThanOrEqual(layout.outerRadius * 0.05); + }); + + it('should maintain consistent positioning across resizes', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(800, 600, GaugeType.Semicircle, 0.2); + + // The ratio of center to viewBox should be consistent + const ratio1X = layout1.gaugeCenter.x / layout1.viewBox.width; + const ratio2X = layout2.gaugeCenter.x / layout2.viewBox.width; + + expect(Math.abs(ratio1X - ratio2X)).toBeLessThan(0.01); + }); + + it('should handle very small containers without cutoff', () => { + const layout = calculateGaugeLayout(100, 100, GaugeType.Semicircle, 0.2); + + const leftEdge = layout.gaugeCenter.x - layout.outerRadius; + const rightEdge = layout.gaugeCenter.x + layout.outerRadius; + const topEdge = layout.gaugeCenter.y - layout.outerRadius; + + expect(leftEdge).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(rightEdge).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(topEdge).toBeGreaterThanOrEqual(layout.viewBox.y); + }); + + it('should handle very large containers without cutoff', () => { + const layout = calculateGaugeLayout(2000, 1500, GaugeType.Semicircle, 0.2); + + const leftEdge = layout.gaugeCenter.x - layout.outerRadius; + const rightEdge = layout.gaugeCenter.x + layout.outerRadius; + const topEdge = layout.gaugeCenter.y - layout.outerRadius; + + expect(leftEdge).toBeGreaterThanOrEqual(layout.viewBox.x); + expect(rightEdge).toBeLessThanOrEqual(layout.viewBox.x + layout.viewBox.width); + expect(topEdge).toBeGreaterThanOrEqual(layout.viewBox.y); + }); + }); + + describe('ViewBox Containment Tests', () => { + it('should ensure viewBox contains all possible gauge elements', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // ViewBox should be large enough to contain gauge + padding for labels + const minRequiredWidth = layout.outerRadius * 2; + const minRequiredHeight = layout.outerRadius * 2; + + expect(layout.viewBox.width).toBeGreaterThanOrEqual(minRequiredWidth); + expect(layout.viewBox.height).toBeGreaterThanOrEqual(minRequiredHeight * 0.5); // Semicircle uses less height + }); + + it('should provide adequate space for labels outside the arc', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // With minimal padding approach, expect exactly 10px + const minPadding = 10; + + const leftPadding = layout.gaugeCenter.x - layout.outerRadius - layout.viewBox.x; + const topPadding = layout.gaugeCenter.y - layout.outerRadius - layout.viewBox.y; + + expect(leftPadding).toBeGreaterThanOrEqual(minPadding); + expect(topPadding).toBeGreaterThanOrEqual(minPadding); + }); + + it('should scale viewBox proportionally with parent size', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(800, 600, GaugeType.Semicircle, 0.2); + + const ratio = layout2.viewBox.width / layout1.viewBox.width; + + // Should scale approximately 2x (allowing small variance for padding) + expect(ratio).toBeGreaterThan(1.8); + expect(ratio).toBeLessThan(2.2); + }); + }); + + describe('Stability Edge Cases', () => { + it('should handle null previous layout', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + const stable = isLayoutStable(null, layout); + expect(stable).toBe(true); // First render is always stable + }); + + it('should detect continuous oscillation', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(410, 300, GaugeType.Semicircle, 0.2); + const layout3 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // 400 -> 410 should be unstable + expect(isLayoutStable(layout1, layout2, 0.005)).toBe(false); + + // 410 -> 400 should be unstable + expect(isLayoutStable(layout2, layout3, 0.005)).toBe(false); + + // 400 -> 400 (back to same) should be stable + expect(isLayoutStable(layout1, layout3, 0.005)).toBe(true); + }); + + it('should handle extreme tolerance values', () => { + const layout1 = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const layout2 = calculateGaugeLayout(401, 300, GaugeType.Semicircle, 0.2); + + // Very strict tolerance + expect(isLayoutStable(layout1, layout2, 0.0001)).toBe(false); + + // Very loose tolerance + expect(isLayoutStable(layout1, layout2, 0.1)).toBe(true); + }); + }); + + describe('Render Count Monitoring', () => { + it('should detect rapid re-renders within 100ms window', () => { + const timestamps: number[] = []; + const now = Date.now(); + + // Simulate rapid renders + for (let i = 0; i < 10; i++) { + timestamps.push(now + i * 50); // Every 50ms + } + + // Check intervals + for (let i = 1; i < timestamps.length; i++) { + const interval = timestamps[i] - timestamps[i - 1]; + if (interval < 100) { + expect(interval).toBeLessThan(100); + // This would trigger a warning + } + } + }); + + it('should not flag normal render intervals as problematic', () => { + const timestamps: number[] = []; + const now = Date.now(); + + // Simulate normal renders (every 500ms) + for (let i = 0; i < 5; i++) { + timestamps.push(now + i * 500); + } + + // Check intervals - all should be >= 100ms + for (let i = 1; i < timestamps.length; i++) { + const interval = timestamps[i] - timestamps[i - 1]; + expect(interval).toBeGreaterThanOrEqual(100); + } + }); + }); +}); diff --git a/src/lib/GaugeComponent/hooks/svgSize.test.ts b/src/lib/GaugeComponent/hooks/svgSize.test.ts new file mode 100644 index 0000000..ec58724 --- /dev/null +++ b/src/lib/GaugeComponent/hooks/svgSize.test.ts @@ -0,0 +1,208 @@ +/** + * Tests for SVG size matching content + * Ensures SVG doesn't waste space and matches g element bounds + */ + +import { calculateGaugeLayout } from './coordinateSystem'; +import { GaugeType } from '../types/GaugeComponentProps'; + +describe('SVG Size Tests', () => { + describe('SVG to ViewBox Size Matching', () => { + it('should have viewBox height close to content height for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // Calculate actual content bounds within viewBox + const contentTop = layout.gaugeCenter.y - layout.outerRadius; + const contentBottom = layout.gaugeCenter.y + layout.outerRadius * 0.7; // Bottom with label space + const contentHeight = contentBottom - contentTop; + + // ViewBox height should be close to content height (within 20px tolerance) + const wastedSpace = layout.viewBox.height - contentHeight; + expect(wastedSpace).toBeLessThanOrEqual(20); + }); + + it('should have viewBox height close to content height for Radial', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Radial, 0.2); + + const contentTop = layout.gaugeCenter.y - layout.outerRadius; + const contentBottom = layout.gaugeCenter.y + layout.outerRadius; + const contentHeight = contentBottom - contentTop; + + const wastedSpace = layout.viewBox.height - contentHeight; + expect(wastedSpace).toBeLessThanOrEqual(20); + }); + + it('should have viewBox height close to content height for Grafana', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Grafana, 0.2); + + const contentTop = layout.gaugeCenter.y - layout.outerRadius; + const contentBottom = layout.gaugeCenter.y + layout.outerRadius; + const contentHeight = contentBottom - contentTop; + + const wastedSpace = layout.viewBox.height - contentHeight; + expect(wastedSpace).toBeLessThanOrEqual(20); + }); + }); + + describe('G Element Bounds within ViewBox', () => { + it('should ensure g element bounds are tight for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // G element bounds + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + const gHeight = gBottom - gTop; + + // ViewBox should not be much larger than g bounds + const heightRatio = layout.viewBox.height / gHeight; + + // ViewBox should be at most 1.2x the g height (20% tolerance) + expect(heightRatio).toBeLessThan(1.2); + }); + + it('should ensure g element bounds are tight for all gauge types', () => { + const types = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + types.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + const gHeight = layout.outerRadius * 2; // Full diameter + const heightRatio = layout.viewBox.height / gHeight; + + // ViewBox should be at most 1.3x the gauge diameter (30% tolerance for padding) + expect(heightRatio).toBeLessThan(1.3); + }); + }); + }); + + describe('Space Efficiency', () => { + it('should have minimal top padding (< 10% of radius)', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + const topPadding = layout.gaugeCenter.y - layout.outerRadius - layout.viewBox.y; + const paddingRatio = topPadding / layout.outerRadius; + + expect(paddingRatio).toBeLessThan(0.1); // Less than 10% + expect(topPadding).toBeGreaterThan(0); // But still some padding + }); + + it('should have minimal bottom padding for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // Bottom of content (gauge center + space for label) + const contentBottom = layout.gaugeCenter.y + layout.outerRadius * 0.7; + const bottomPadding = (layout.viewBox.y + layout.viewBox.height) - contentBottom; + + expect(bottomPadding).toBeLessThan(20); + expect(bottomPadding).toBeGreaterThan(0); + }); + + it('should use less vertical space for Semicircle than Radial', () => { + const semicircleLayout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + const radialLayout = calculateGaugeLayout(400, 300, GaugeType.Radial, 0.2); + + // Semicircle should have smaller viewBox height + expect(semicircleLayout.viewBox.height).toBeLessThan(radialLayout.viewBox.height); + + // Ratio should be significant (at least 10% smaller) + const ratio = semicircleLayout.viewBox.height / radialLayout.viewBox.height; + expect(ratio).toBeLessThan(0.92); // Adjusted tolerance + }); + }); + + describe('SVG Rendered Size (Conceptual)', () => { + it('should calculate correct aspect ratio for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + const aspectRatio = layout.viewBox.height / layout.viewBox.width; + + // Semicircle should be wider than tall + expect(aspectRatio).toBeLessThan(1.0); + + // Should be around 0.8-0.9 for semicircle (compact but with label space) + expect(aspectRatio).toBeGreaterThan(0.7); + expect(aspectRatio).toBeLessThan(0.95); + }); + + it('should calculate correct aspect ratio for Radial', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Radial, 0.2); + + const aspectRatio = layout.viewBox.height / layout.viewBox.width; + + // Radial should be roughly square or slightly wider + expect(aspectRatio).toBeGreaterThan(0.8); + expect(aspectRatio).toBeLessThan(1.1); + }); + + it('should calculate correct aspect ratio for Grafana', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Grafana, 0.2); + + const aspectRatio = layout.viewBox.height / layout.viewBox.width; + + // Grafana should be roughly square + expect(aspectRatio).toBeGreaterThan(0.85); + expect(aspectRatio).toBeLessThan(1.15); + }); + }); + + describe('ViewBox Height Calculation Accuracy', () => { + it('should match expected formula for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // For semicircle: minPadding + outerRadius + (outerRadius * 0.7) + minPadding + const minPadding = 10; // Fixed padding value + + const expectedHeight = minPadding + layout.outerRadius + layout.outerRadius * 0.7 + minPadding; + + // Should be very close (within 1px) + expect(Math.abs(layout.viewBox.height - expectedHeight)).toBeLessThan(1); + }); + + it('should not have excessive viewBox height for any gauge type', () => { + const types = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + types.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + // ViewBox height should not exceed 1.5x the width + expect(layout.viewBox.height).toBeLessThan(layout.viewBox.width * 1.5); + }); + }); + }); + + describe('Content Bounds Validation', () => { + it('should ensure all content fits within viewBox with tolerance', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // Calculate the actual bounds needed for content + const minY = layout.gaugeCenter.y - layout.outerRadius; + const maxY = layout.gaugeCenter.y + layout.outerRadius * 0.7; // Include label space + + // Check content fits within viewBox + expect(minY).toBeGreaterThanOrEqual(layout.viewBox.y); + expect(maxY).toBeLessThanOrEqual(layout.viewBox.y + layout.viewBox.height); + + // Check wasted space at top and bottom combined is <= 20px + const topWaste = minY - layout.viewBox.y; + const bottomWaste = (layout.viewBox.y + layout.viewBox.height) - maxY; + const totalWaste = topWaste + bottomWaste; + + expect(totalWaste).toBeLessThanOrEqual(20); + }); + + it('should have balanced padding for all gauge types', () => { + const types = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + types.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + const topEdge = layout.gaugeCenter.y - layout.outerRadius; + const topPadding = topEdge - layout.viewBox.y; + + // Should have some padding but not excessive + expect(topPadding).toBeGreaterThan(0); + expect(topPadding).toBeLessThan(layout.outerRadius * 0.15); // < 15% of radius + }); + }); + }); +}); diff --git a/src/lib/GaugeComponent/index.tsx b/src/lib/GaugeComponent/index.tsx index 093d265..30d85fe 100644 --- a/src/lib/GaugeComponent/index.tsx +++ b/src/lib/GaugeComponent/index.tsx @@ -160,13 +160,12 @@ const GaugeComponent = (props: Partial) => { }, []); const { id, style, className, type } = props; - // add height: -webkit-fill-available; - // width: -webkit-fill-available; - // to the style prop to make the gauge responsive + // Make the gauge responsive - width fills available space, + // but height is auto to match SVG aspect ratio (no more wasted space) var styled = { ...style, - height: "-webkit-fill-available", width: "-webkit-fill-available" + // Height removed - let it match the SVG's aspect-ratio naturally }; return (
Date: Thu, 16 Oct 2025 15:57:37 -0300 Subject: [PATCH 008/106] tst --- package.json | 8 - .../hooks/coordinateSystem.test.ts | 10 +- .../GaugeComponent/hooks/coordinateSystem.ts | 36 ++-- .../GaugeComponent/hooks/rendering.test.ts | 170 +++++++++++++++++- src/lib/GaugeComponent/index.tsx | 7 +- 5 files changed, 193 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 4fce0a9..9ec38d5 100644 --- a/package.json +++ b/package.json @@ -50,14 +50,6 @@ "not ie <= 11", "not op_mini all" ], - "jest": { - "transformIgnorePatterns": [ - "node_modules/(?!(d3|d3-array|d3-axis|d3-brush|d3-chord|d3-color|d3-contour|d3-delaunay|d3-dispatch|d3-drag|d3-dsv|d3-ease|d3-fetch|d3-force|d3-format|d3-geo|d3-hierarchy|d3-interpolate|d3-path|d3-polygon|d3-quadtree|d3-random|d3-scale|d3-scale-chromatic|d3-selection|d3-shape|d3-time|d3-time-format|d3-timer|d3-transition|d3-zoom|internmap|delaunator|robust-predicates)/)" - ], - "moduleNameMapper": { - "\\.(css|less|scss|sass)$": "/src/__mocks__/styleMock.js" - } - }, "devDependencies": { "@babel/cli": "^7.12.8", "@babel/core": "^7.6.2", diff --git a/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts b/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts index 45e968a..1cd18d1 100644 --- a/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts +++ b/src/lib/GaugeComponent/hooks/coordinateSystem.test.ts @@ -38,16 +38,14 @@ describe('Coordinate System', () => { expect(radiusWithMargin).toBeLessThan(radiusNoMargin); }); - it('should calculate different radii for gauge types with different padding', () => { - // Grafana has different paddingPercent (0.12) vs Semicircle/Radial (0.15) + it('should calculate different radii for different gauge types', () => { const semicircleRadius = calculateOptimalRadius(400, 400, GaugeType.Semicircle); + const radialRadius = calculateOptimalRadius(400, 400, GaugeType.Radial); const grafanaRadius = calculateOptimalRadius(400, 400, GaugeType.Grafana); - // Different padding percentages should produce different radii + // Different types should produce different results due to different height ratios + expect(semicircleRadius).not.toBe(radialRadius); expect(semicircleRadius).not.toBe(grafanaRadius); - // Verify they're both reasonable - expect(semicircleRadius).toBeGreaterThan(150); - expect(grafanaRadius).toBeGreaterThan(150); }); }); diff --git a/src/lib/GaugeComponent/hooks/coordinateSystem.ts b/src/lib/GaugeComponent/hooks/coordinateSystem.ts index be9b2aa..53fbc69 100644 --- a/src/lib/GaugeComponent/hooks/coordinateSystem.ts +++ b/src/lib/GaugeComponent/hooks/coordinateSystem.ts @@ -31,18 +31,18 @@ const GAUGE_TYPE_CONFIG = { [GaugeType.Semicircle]: { // Semicircle occupies approximately 50% of the circle height + some padding heightRatio: 0.55, - // Padding around the gauge for labels and ticks - paddingPercent: 0.15, + // Minimal padding around the gauge for labels and ticks (10px per 100px radius = 10%) + paddingPercent: 0.05, }, [GaugeType.Radial]: { // Radial needs more height (about 75% of full circle) heightRatio: 0.78, - paddingPercent: 0.15, + paddingPercent: 0.05, }, [GaugeType.Grafana]: { // Grafana style is similar to radial heightRatio: 0.75, - paddingPercent: 0.12, + paddingPercent: 0.05, }, }; @@ -86,21 +86,22 @@ export const calculateViewBox = ( const config = GAUGE_TYPE_CONFIG[gaugeType]; const diameter = outerRadius * 2; - // Minimal padding for labels and ticks (small fixed value) - const minPadding = 10; // Small fixed padding in viewBox units + // Add minimal padding for labels, ticks, and other elements + // Using single padding value (not *2) for tighter bounds + const padding = outerRadius * config.paddingPercent; - let width = diameter + minPadding * 2; + let width = diameter + padding * 2; let height: number; let y = 0; - // Adjust height based on gauge type - be tight to avoid wasted space + // Adjust height based on gauge type with tight bounds if (gaugeType === GaugeType.Semicircle) { - // Semicircle: minimal padding + outerRadius (center to top) + space below for arc & label - // Top padding + radius above center + radius below center * 0.7 + bottom padding - height = minPadding + outerRadius + outerRadius * 0.7 + minPadding; + // Semicircle: tight calculation + // padding (top) + outerRadius (to center) + outerRadius * 0.6 (bottom arc + label space) + padding (bottom) + height = padding + outerRadius + outerRadius * 0.6 + padding; } else { - // Radial and Grafana: just diameter + minimal padding - height = diameter + minPadding * 2; + // Radial and Grafana need full circular space with minimal padding + height = diameter + padding * 2; } return { @@ -123,16 +124,17 @@ export const calculateGaugeCenter = ( outerRadius: number, gaugeType: GaugeType ): { x: number; y: number } => { - const minPadding = 10; // Must match the padding used in calculateViewBox + const config = GAUGE_TYPE_CONFIG[gaugeType]; + const padding = outerRadius * config.paddingPercent; // Horizontal center is always in the middle const x = viewBox.width / 2; let y: number; if (gaugeType === GaugeType.Semicircle) { - // For semicircle, position center so top of arc has minPadding from top - // y = minPadding (top) + outerRadius (distance from top to center) - y = minPadding + outerRadius; + // For semicircle, position the center so top has padding + // padding + outerRadius = center position from top + y = padding + outerRadius; } else { // For radial and grafana, center vertically y = viewBox.height / 2; diff --git a/src/lib/GaugeComponent/hooks/rendering.test.ts b/src/lib/GaugeComponent/hooks/rendering.test.ts index 88a6d24..4322143 100644 --- a/src/lib/GaugeComponent/hooks/rendering.test.ts +++ b/src/lib/GaugeComponent/hooks/rendering.test.ts @@ -158,14 +158,14 @@ describe('Rendering Behavior Tests', () => { it('should provide adequate space for labels outside the arc', () => { const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); - // With minimal padding approach, expect exactly 10px - const minPadding = 10; + // Padding should be at least 10% of radius for labels + const expectedPadding = layout.outerRadius * 0.1; const leftPadding = layout.gaugeCenter.x - layout.outerRadius - layout.viewBox.x; const topPadding = layout.gaugeCenter.y - layout.outerRadius - layout.viewBox.y; - expect(leftPadding).toBeGreaterThanOrEqual(minPadding); - expect(topPadding).toBeGreaterThanOrEqual(minPadding); + expect(leftPadding).toBeGreaterThanOrEqual(expectedPadding); + expect(topPadding).toBeGreaterThanOrEqual(expectedPadding); }); it('should scale viewBox proportionally with parent size', () => { @@ -251,4 +251,166 @@ describe('Rendering Behavior Tests', () => { } }); }); + + describe('SVG to G Element Size Matching (20px tolerance)', () => { + it('should ensure SVG height closely matches g element bounds for Semicircle', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // G element bounds (from center ± radius) + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + const gHeight = gBottom - gTop; + + // ViewBox defines SVG coordinate space height + const svgHeight = layout.viewBox.height; + + // Actual used space by g element + const usedSpace = gHeight; + const tolerance = 20; + + // SVG should not be significantly taller than g element + const wastedSpace = svgHeight - usedSpace; + expect(wastedSpace).toBeLessThanOrEqual(tolerance * 2); // Top and bottom padding + }); + + it('should ensure SVG height closely matches g element bounds for Radial', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Radial, 0.2); + + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + const gHeight = gBottom - gTop; + + const svgHeight = layout.viewBox.height; + const wastedSpace = svgHeight - gHeight; + const tolerance = 20; + + expect(wastedSpace).toBeLessThanOrEqual(tolerance * 2); + }); + + it('should ensure SVG height closely matches g element bounds for Grafana', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Grafana, 0.2); + + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + const gHeight = gBottom - gTop; + + const svgHeight = layout.viewBox.height; + const wastedSpace = svgHeight - gHeight; + const tolerance = 20; + + expect(wastedSpace).toBeLessThanOrEqual(tolerance * 2); + }); + + it('should maintain tight bounds across different container sizes', () => { + const sizes = [ + [200, 150], + [400, 300], + [800, 600], + [1200, 900] + ]; + + const tolerance = 20; + + sizes.forEach(([width, height]) => { + const layout = calculateGaugeLayout(width, height, GaugeType.Semicircle, 0.2); + + const gHeight = layout.outerRadius * 2; // Diameter + const svgHeight = layout.viewBox.height; + const wastedSpace = svgHeight - gHeight; + + // Wasted space should be minimal (just padding) + expect(wastedSpace).toBeLessThanOrEqual(tolerance * 2); + }); + }); + + it('should ensure viewBox height is not excessively larger than gauge diameter', () => { + const gaugeTypes = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + gaugeTypes.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + const gaugeDiameter = layout.outerRadius * 2; + const viewBoxHeight = layout.viewBox.height; + + // ViewBox should not be more than 1.5x the gauge diameter + // (accounting for padding and label space) + const ratio = viewBoxHeight / gaugeDiameter; + expect(ratio).toBeLessThanOrEqual(1.5); + }); + }); + + it('should calculate optimal viewBox height for Semicircle specifically', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + // For semicircle, viewBox height should be approximately: + // outerRadius (top half) + space for center + space for bottom labels + // Should be around 1.2-1.4x the outerRadius + const ratio = layout.viewBox.height / layout.outerRadius; + + expect(ratio).toBeGreaterThan(1.0); // More than just radius + expect(ratio).toBeLessThan(2.0); // Less than full diameter + }); + + it('should ensure g element top is not cut off (has padding)', () => { + const gaugeTypes = [GaugeType.Semicircle, GaugeType.Radial, GaugeType.Grafana]; + + gaugeTypes.forEach(type => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + const gTop = layout.gaugeCenter.y - layout.outerRadius; + const viewBoxTop = layout.viewBox.y; + + // G element top should have some padding from viewBox top + const topPadding = gTop - viewBoxTop; + expect(topPadding).toBeGreaterThan(0); + expect(topPadding).toBeLessThanOrEqual(20); // Within tolerance + }); + }); + + it('should ensure g element bottom fits within viewBox with minimal waste', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + const gBottom = layout.gaugeCenter.y + layout.outerRadius; + const viewBoxBottom = layout.viewBox.y + layout.viewBox.height; + + // G element should fit within viewBox + expect(gBottom).toBeLessThanOrEqual(viewBoxBottom); + + // Bottom padding should be reasonable (not excessive) + const bottomPadding = viewBoxBottom - gBottom; + expect(bottomPadding).toBeLessThanOrEqual(50); // Allow for value label + }); + + it('should verify aspect ratio calculation matches viewBox proportions', () => { + const layout = calculateGaugeLayout(400, 300, GaugeType.Semicircle, 0.2); + + const aspectRatio = layout.viewBox.height / layout.viewBox.width; + + // For semicircle, aspect ratio should be less than 1 (wider than tall) + expect(aspectRatio).toBeLessThan(1.0); + + // Should be reasonable (not too flat) + expect(aspectRatio).toBeGreaterThan(0.4); + }); + + it('should ensure SVG-to-g size efficiency for all gauge types', () => { + const gaugeTypes = [ + { type: GaugeType.Semicircle, expectedEfficiency: 0.7 }, // ~70% of viewBox used + { type: GaugeType.Radial, expectedEfficiency: 0.75 }, // ~75% of viewBox used + { type: GaugeType.Grafana, expectedEfficiency: 0.8 } // ~80% of viewBox used + ]; + + gaugeTypes.forEach(({ type, expectedEfficiency }) => { + const layout = calculateGaugeLayout(400, 300, type, 0.2); + + const gHeight = layout.outerRadius * 2; + const svgHeight = layout.viewBox.height; + const efficiency = gHeight / svgHeight; + + // Efficiency should be close to expected (within 20%) + expect(efficiency).toBeGreaterThan(expectedEfficiency - 0.2); + expect(efficiency).toBeLessThan(expectedEfficiency + 0.2); + }); + }); + }); }); diff --git a/src/lib/GaugeComponent/index.tsx b/src/lib/GaugeComponent/index.tsx index 30d85fe..093d265 100644 --- a/src/lib/GaugeComponent/index.tsx +++ b/src/lib/GaugeComponent/index.tsx @@ -160,12 +160,13 @@ const GaugeComponent = (props: Partial) => { }, []); const { id, style, className, type } = props; - // Make the gauge responsive - width fills available space, - // but height is auto to match SVG aspect ratio (no more wasted space) + // add height: -webkit-fill-available; + // width: -webkit-fill-available; + // to the style prop to make the gauge responsive var styled = { ...style, + height: "-webkit-fill-available", width: "-webkit-fill-available" - // Height removed - let it match the SVG's aspect-ratio naturally }; return (
Date: Mon, 1 Dec 2025 21:48:00 -0300 Subject: [PATCH 009/106] fitting semicircles on parent --- package.json | 8 +- src/App.css | 68 ++ src/App.tsx | 50 +- src/TestComponent/GaugeGallery.tsx | 820 ++++++++++++++++++ src/__mocks__/d3.js | 255 ++++++ src/lib/GaugeComponent/constants.ts | 2 +- src/lib/GaugeComponent/hooks/chart.ts | 35 +- .../GaugeComponent/hooks/containment.test.ts | 180 ++++ .../hooks/coordinateSystem.test.ts | 61 +- .../GaugeComponent/hooks/coordinateSystem.ts | 85 +- src/lib/GaugeComponent/hooks/labels.ts | 20 +- src/lib/GaugeComponent/hooks/pointer.ts | 9 +- .../hooks/pointerBehavior.test.ts | 180 ++++ .../GaugeComponent/hooks/rendering.test.ts | 92 +- src/lib/GaugeComponent/hooks/svgSize.test.ts | 88 +- src/lib/GaugeComponent/index.tsx | 189 ++-- yarn.lock | 165 ++++ 17 files changed, 2007 insertions(+), 300 deletions(-) create mode 100644 src/TestComponent/GaugeGallery.tsx create mode 100644 src/__mocks__/d3.js create mode 100644 src/lib/GaugeComponent/hooks/containment.test.ts create mode 100644 src/lib/GaugeComponent/hooks/pointerBehavior.test.ts diff --git a/package.json b/package.json index 9ec38d5..1a0f327 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,11 @@ "not ie <= 11", "not op_mini all" ], + "jest": { + "moduleNameMapper": { + "^d3$": "/src/__mocks__/d3.js" + } + }, "devDependencies": { "@babel/cli": "^7.12.8", "@babel/core": "^7.6.2", @@ -86,7 +91,8 @@ "react-scripts": "^5.0.1", "rimraf": "^2.7.1", "typescript": "^5.0.4", - "typescript-eslint": "^7.14.1" + "typescript-eslint": "^7.14.1", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11" }, "peerDependencies": { "react": "^16.8.2 || ^17.0 || ^18.x || ^19.x", diff --git a/src/App.css b/src/App.css index e69de29..ff552ac 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1,68 @@ +/* Reset and base styles */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + padding: 0; + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #1a1a2e; +} + +/* Gauge component tooltip styles */ +.gauge-component-arc-tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 8px 12px; + border-radius: 6px; + font-size: 14px; + pointer-events: none; + z-index: 1000; + backdrop-filter: blur(4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .gallery { + grid-template-columns: 1fr !important; + } +} + +/* Selection color */ +::selection { + background: rgba(0, 217, 255, 0.3); + color: white; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + diff --git a/src/App.tsx b/src/App.tsx index 8f378c7..81231be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,49 +1,9 @@ -import React, { useState } from 'react'; +import React from 'react'; import './App.css'; -import MainPreviews from './TestComponent/MainPreviews'; -import InputTest from './TestComponent/InputTest'; -import GridLayoutComponent from './TestComponent/GridLayout'; -import RefactoringDemo from './TestComponent/RefactoringDemo'; -import 'react-grid-layout/css/styles.css' -import 'react-resizable/css/styles.css'; -import GaugeComponent from './lib'; +import GaugeGallery from './TestComponent/GaugeGallery'; -const App = () => { - const [showDemo, setShowDemo] = useState(false); - - return ( - <> -
- -
- - {showDemo ? ( - - ) : ( - <> - - - {/* */} - - {/* */} - - )} - - ) +const App: React.FC = () => { + return ; }; -export default App +export default App; diff --git a/src/TestComponent/GaugeGallery.tsx b/src/TestComponent/GaugeGallery.tsx new file mode 100644 index 0000000..4e58a50 --- /dev/null +++ b/src/TestComponent/GaugeGallery.tsx @@ -0,0 +1,820 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import GaugeComponent from '../lib'; + +// Gauge presets with different styles +const GAUGE_PRESETS = [ + { + name: 'Speedometer', + description: 'Classic speedometer with gradient arc', + config: { + type: 'semicircle' as const, + arc: { + gradient: true, + width: 0.15, + subArcs: [ + { limit: 30, color: '#5BE12C' }, + { limit: 70, color: '#F5CD19' }, + { color: '#EA4228' }, + ], + }, + pointer: { type: 'needle' as const, color: '#1a1a2e', length: 0.8, width: 15 }, + labels: { + valueLabel: { formatTextValue: (v: number) => `${v} km/h`, style: { fontSize: '28px', fill: '#1a1a2e' } }, + tickLabels: { + type: 'outer' as const, + ticks: [{ value: 0 }, { value: 20 }, { value: 40 }, { value: 60 }, { value: 80 }, { value: 100 }], + }, + }, + }, + }, + { + name: 'Temperature', + description: 'Temperature gauge with color zones', + config: { + type: 'semicircle' as const, + minValue: -20, + maxValue: 50, + arc: { + width: 0.2, + padding: 0.005, + cornerRadius: 1, + subArcs: [ + { limit: 0, color: '#00bcd4', showTick: true }, + { limit: 15, color: '#4caf50', showTick: true }, + { limit: 25, color: '#8bc34a', showTick: true }, + { limit: 35, color: '#ff9800', showTick: true }, + { color: '#f44336' }, + ], + }, + pointer: { type: 'blob' as const, color: '#333', animationDelay: 0 }, + labels: { + valueLabel: { formatTextValue: (v: number) => `${v}°C`, style: { fontSize: '32px' } }, + tickLabels: { + type: 'outer' as const, + defaultTickValueConfig: { formatTextValue: (v: number) => `${v}°` }, + }, + }, + }, + }, + { + name: 'Battery', + description: 'Simple battery level indicator', + config: { + type: 'grafana' as const, + arc: { + width: 0.25, + padding: 0.02, + subArcs: [ + { limit: 20, color: '#EA4228', showTick: true }, + { limit: 40, color: '#F58B19', showTick: true }, + { limit: 60, color: '#F5CD19', showTick: true }, + { limit: 100, color: '#5BE12C', showTick: true }, + ], + }, + pointer: { type: 'arrow' as const, color: '#1a1a2e' }, + labels: { + valueLabel: { formatTextValue: (v: number) => `${v}%`, matchColorWithArc: true }, + }, + }, + }, + { + name: 'CPU Usage', + description: 'Radial gauge with smooth gradient', + config: { + type: 'radial' as const, + arc: { + width: 0.2, + nbSubArcs: 30, + colorArray: ['#00c853', '#ffeb3b', '#ff5722'], + padding: 0.01, + }, + pointer: { type: 'needle' as const, color: '#263238', elastic: true, animationDelay: 0 }, + labels: { + valueLabel: { formatTextValue: (v: number) => `${v}%`, style: { fontSize: '24px' } }, + tickLabels: { + type: 'inner' as const, + ticks: [{ value: 0 }, { value: 25 }, { value: 50 }, { value: 75 }, { value: 100 }], + }, + }, + }, + }, + { + name: 'Performance', + description: 'Stylish performance meter', + config: { + type: 'semicircle' as const, + arc: { + width: 0.12, + padding: 0.02, + subArcs: [ + { limit: 15, color: '#e91e63' }, + { limit: 35, color: '#9c27b0' }, + { limit: 55, color: '#673ab7' }, + { limit: 75, color: '#3f51b5' }, + { color: '#2196f3' }, + ], + }, + pointer: { type: 'blob' as const, color: '#1a237e', elastic: true, strokeWidth: 5 }, + labels: { + valueLabel: { style: { fontSize: '30px', fill: '#1a237e' } }, + }, + }, + }, + { + name: 'Minimalist', + description: 'Clean and minimal design', + config: { + type: 'semicircle' as const, + arc: { + width: 0.08, + padding: 0, + subArcs: [{ color: '#37474f' }], + }, + pointer: { type: 'needle' as const, color: '#37474f', length: 0.7, width: 10 }, + labels: { + valueLabel: { style: { fontSize: '36px', fill: '#37474f' } }, + tickLabels: { hideMinMax: true }, + }, + }, + }, + { + name: 'Fuel Gauge', + description: 'Car-style fuel indicator', + config: { + type: 'semicircle' as const, + arc: { + width: 0.18, + subArcs: [ + { limit: 25, color: '#EA4228', showTick: true }, + { color: '#5BE12C' }, + ], + }, + pointer: { type: 'arrow' as const, color: '#333', width: 20 }, + labels: { + valueLabel: { hide: true }, + tickLabels: { + type: 'outer' as const, + ticks: [{ value: 0 }, { value: 50 }, { value: 100 }], + defaultTickValueConfig: { formatTextValue: (v: number) => v === 0 ? 'E' : v === 100 ? 'F' : '' }, + }, + }, + }, + }, + { + name: 'Progress Ring', + description: 'Modern progress indicator', + config: { + type: 'radial' as const, + arc: { + width: 0.35, + nbSubArcs: 1, + colorArray: ['#00e676'], + padding: 0, + cornerRadius: 0, + }, + pointer: { hide: true }, + labels: { + valueLabel: { + formatTextValue: (v: number) => `${v}%`, + style: { fontSize: '42px', fill: '#00e676', fontWeight: 'bold' }, + }, + tickLabels: { hideMinMax: true }, + }, + }, + }, +]; + +// Generate random gauge config +const generateRandomConfig = () => { + const types = ['semicircle', 'radial', 'grafana'] as const; + const pointerTypes = ['needle', 'blob', 'arrow'] as const; + + const colors = [ + ['#5BE12C', '#F5CD19', '#EA4228'], + ['#00bcd4', '#4caf50', '#ff5722'], + ['#e91e63', '#9c27b0', '#2196f3'], + ['#ff6f00', '#ff8f00', '#ffc107'], + ['#00c853', '#69f0ae', '#b9f6ca'], + ['#d500f9', '#651fff', '#3d5afe'], + ['#1de9b6', '#00e5ff', '#00b0ff'], + ['#ff1744', '#ff5252', '#ff8a80'], + ]; + + const randomType = types[Math.floor(Math.random() * types.length)]; + const randomPointer = pointerTypes[Math.floor(Math.random() * pointerTypes.length)]; + const randomColors = colors[Math.floor(Math.random() * colors.length)]; + const useGradient = Math.random() > 0.5; + const arcWidth = 0.1 + Math.random() * 0.25; + + return { + type: randomType, + arc: { + width: arcWidth, + ...(useGradient ? { + gradient: true, + subArcs: randomColors.map((color, i) => ({ + limit: ((i + 1) / randomColors.length) * 100, + color + })), + } : { + nbSubArcs: 15 + Math.floor(Math.random() * 40), + colorArray: randomColors, + padding: 0.01 + Math.random() * 0.02, + }), + }, + pointer: { + type: randomPointer, + elastic: Math.random() > 0.5, + animationDelay: Math.random() > 0.5 ? 0 : 200, + }, + labels: { + valueLabel: { + formatTextValue: (v: number) => `${v}`, + matchColorWithArc: Math.random() > 0.5, + }, + }, + }; +}; + +// Helper to stringify config for copy +const stringifyConfig = (config: any, value: number): string => { + const replacer = (key: string, val: any) => { + if (typeof val === 'function') { + const fnStr = val.toString(); + return fnStr.includes('=>') ? fnStr : `function ${fnStr}`; + } + return val; + }; + + try { + const cleanConfig = JSON.parse(JSON.stringify(config, replacer)); + return ``; + } catch { + return ``; + } +}; + +const GaugeGallery: React.FC = () => { + const [values, setValues] = useState(GAUGE_PRESETS.map(() => 50)); + const [randomConfig, setRandomConfig] = useState(() => generateRandomConfig()); + const [randomValue, setRandomValue] = useState(50); + const [autoAnimate, setAutoAnimate] = useState(true); + const [copiedIndex, setCopiedIndex] = useState(null); + const [randomKey, setRandomKey] = useState(0); // Key to force re-render + const [showEditor, setShowEditor] = useState(false); + const [editorValue, setEditorValue] = useState(''); + + // Auto-animate values + useEffect(() => { + if (!autoAnimate) return; + + const interval = setInterval(() => { + setValues(prev => prev.map(() => Math.floor(Math.random() * 100))); + setRandomValue(Math.floor(Math.random() * 100)); + }, 3000); + + return () => clearInterval(interval); + }, [autoAnimate]); + + const handleRandomize = useCallback(() => { + try { + const newConfig = generateRandomConfig(); + setRandomConfig(newConfig); + setRandomValue(Math.floor(Math.random() * 100)); + setRandomKey(prev => prev + 1); // Force complete re-render + setEditorValue(JSON.stringify(newConfig, null, 2)); + } catch (error) { + console.error('Error generating config:', error); + } + }, []); + + // Initialize editor value + useEffect(() => { + setEditorValue(JSON.stringify(randomConfig, null, 2)); + }, []); + + // Handle config changes from editor + const handleEditorChange = useCallback((e: React.ChangeEvent) => { + setEditorValue(e.target.value); + }, []); + + const applyEditorConfig = useCallback(() => { + try { + const parsed = JSON.parse(editorValue); + setRandomConfig(parsed); + setRandomKey(prev => prev + 1); // Force re-render with new config + } catch (error) { + console.error('Invalid JSON:', error); + alert('Invalid JSON configuration'); + } + }, [editorValue]); + + const copyToClipboard = useCallback(async (config: any, value: number, index: number | 'random') => { + try { + const code = stringifyConfig(config, value); + + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(code); + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = code; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }, []); + + const handleCardClick = useCallback((e: React.MouseEvent, config: any, value: number, index: number | 'random') => { + e.stopPropagation(); + copyToClipboard(config, value, index); + }, [copyToClipboard]); + + return ( +
+ {/* Header */} +
+
+

+ 📊 + React Gauge Component +

+

+ Beautiful, customizable gauge charts for React applications +

+ +
+
+ + {/* Controls */} +
+ + Click any gauge to copy its code! +
+ + {/* Randomizer Section */} +
+
+

🎲 Playground

+
+ + +
+
+
+ {/* Gauge Display */} +
handleCardClick(e, randomConfig, randomValue, 'random')} + > +
+ +
+
+ {copiedIndex === 'random' ? ( + ✓ Copied! + ) : ( + Click to copy code + )} +
+
+ + {/* Config Editor */} + {showEditor && ( +
+
+ Configuration (JSON) + +
+
+ + setRandomValue(Number(e.target.value))} + style={styles.valueInput} + /> +
+