From 74ad729428ebc8c8433e4221ae0a360f92f6f4b6 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Wed, 29 Oct 2025 12:44:57 -0700 Subject: [PATCH 1/6] [mind-the-scale-when-getting-large] Use SVG's coordinate system for adding new points so that we can solve scaling issues in Unlimited Graphs --- .../interactive-graphs/graphs/point.tsx | 22 +++++++++++++++---- .../interactive-graphs/graphs/polygon.tsx | 22 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index 9c38a7a2f50..cc9674791a5 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -133,11 +133,25 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) { return; } - const elementRect = - event.currentTarget.getBoundingClientRect(); + // Get the SVG element for coordinate transformation in order + // to handle potential viewport/text scaling issues on mobile + const svg = event.currentTarget.ownerSVGElement; + const ctm = event.currentTarget.getScreenCTM(); + // These should never be null as we're always within an SVG element + if (!svg || !ctm) { + return; + } + + // Create point using the SVG element's coordinate system + // and then transform it to the graph's coordinate system + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const svgPoint = pt.matrixTransform(ctm.inverse()); - const x = event.clientX - elementRect.x; - const y = event.clientY - elementRect.y; + // Calculate position relative to the rect's position + const x = svgPoint.x - left; + const y = svgPoint.y - top; const graphCoordinates = pixelsToVectors( [[x, y]], diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index 37177a9565d..c2064dc6d1b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -511,11 +511,25 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { return; } - const elementRect = - event.currentTarget.getBoundingClientRect(); + // Get the SVG element for coordinate transformation in order + // to handle potential viewport/text scaling issues on mobile + const svg = event.currentTarget.ownerSVGElement; + const ctm = event.currentTarget.getScreenCTM(); + // These should never be null as we're always within an SVG element + if (!svg || !ctm) { + return; + } + + // Create point using the SVG element's coordinate system + // and then transform it to the graph's coordinate system + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const svgPoint = pt.matrixTransform(ctm.inverse()); - const x = event.clientX - elementRect.x; - const y = event.clientY - elementRect.y; + // Calculate position relative to the rect's position + const x = svgPoint.x - left; + const y = svgPoint.y - top; const graphCoordinates = pixelsToVectors( [[x, y]], From 3ee1d826aa96aaf45c42311a4e1f029d37b19188 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Wed, 29 Oct 2025 12:48:29 -0700 Subject: [PATCH 2/6] [mind-the-scale-when-getting-large] docs(changeset): Update Unlimited Graphs to use SVG Coordinate system to solve "add point" issues that arise due to a11y scaling. --- .changeset/four-fans-hope.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/four-fans-hope.md diff --git a/.changeset/four-fans-hope.md b/.changeset/four-fans-hope.md new file mode 100644 index 00000000000..84b622f4b4b --- /dev/null +++ b/.changeset/four-fans-hope.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Update Unlimited Graphs to use SVG Coordinate system to solve "add point" issues that arise due to a11y scaling. From 0aef0143bc049de2867c1d3d489396cb8b6c4a46 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 30 Oct 2025 12:33:05 -0700 Subject: [PATCH 3/6] [mind-the-scale-when-getting-large] FINE. Let's just debug it all to see what the heck is going on. --- .../interactive-graphs/graphs/point.tsx | 58 +++++++++++++------ .../interactive-graphs/graphs/polygon.tsx | 58 +++++++++++++------ 2 files changed, 80 insertions(+), 36 deletions(-) diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index cc9674791a5..7f4f528aa32 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -108,6 +108,9 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) { 400, // Safari Webkit has up to a 350ms delay before a click event is fired ); + // Debug state to display diagnostic info on mobile + const [debugInfo, setDebugInfo] = React.useState(""); + const {graphDimensionsInPixels} = graphConfig; const widthPx = graphDimensionsInPixels[0]; @@ -133,33 +136,52 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) { return; } - // Get the SVG element for coordinate transformation in order - // to handle potential viewport/text scaling issues on mobile - const svg = event.currentTarget.ownerSVGElement; - const ctm = event.currentTarget.getScreenCTM(); - // These should never be null as we're always within an SVG element - if (!svg || !ctm) { - return; - } + const elementRect = + event.currentTarget.getBoundingClientRect(); + + // Calculate click position relative to the rect + const x = event.clientX - elementRect.left; + const y = event.clientY - elementRect.top; - // Create point using the SVG element's coordinate system - // and then transform it to the graph's coordinate system - const pt = svg.createSVGPoint(); - pt.x = event.clientX; - pt.y = event.clientY; - const svgPoint = pt.matrixTransform(ctm.inverse()); + // Capture diagnostic info for mobile debugging + const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} +Rect: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} +Config: ${graphConfig.width}x${graphConfig.height} +Relative: ${x.toFixed(1)}, ${y.toFixed(1)}`; + setDebugInfo(info); - // Calculate position relative to the rect's position - const x = svgPoint.x - left; - const y = svgPoint.y - top; + // Use actual rendered dimensions instead of graphConfig dimensions + // to account for any iOS text scaling + const actualDimensions = { + range: graphConfig.range, + width: elementRect.width, + height: elementRect.height, + }; const graphCoordinates = pixelsToVectors( [[x, y]], - graphConfig, + actualDimensions, ); dispatch(actions.pointGraph.addPoint(graphCoordinates[0])); }} /> + {/* Debug text for mobile - displays click diagnostic info */} + {debugInfo && ( + + {debugInfo.split("\n").map((line, i) => ( + + {line} + + ))} + + )} {coords.map((point, i) => ( { 400, // Safari Webkit has up to a 350ms delay before a click event is fired ); + // Debug state to display diagnostic info on mobile + const [debugInfo, setDebugInfo] = useState(""); + const id = React.useId(); const polygonPointsNumId = id + "-points-num"; const polygonPointsId = id + "-points"; @@ -511,33 +514,52 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { return; } - // Get the SVG element for coordinate transformation in order - // to handle potential viewport/text scaling issues on mobile - const svg = event.currentTarget.ownerSVGElement; - const ctm = event.currentTarget.getScreenCTM(); - // These should never be null as we're always within an SVG element - if (!svg || !ctm) { - return; - } + const elementRect = + event.currentTarget.getBoundingClientRect(); - // Create point using the SVG element's coordinate system - // and then transform it to the graph's coordinate system - const pt = svg.createSVGPoint(); - pt.x = event.clientX; - pt.y = event.clientY; - const svgPoint = pt.matrixTransform(ctm.inverse()); + // Calculate click position relative to the rect + const x = event.clientX - elementRect.left; + const y = event.clientY - elementRect.top; - // Calculate position relative to the rect's position - const x = svgPoint.x - left; - const y = svgPoint.y - top; + // Capture diagnostic info for mobile debugging + const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} +Rect: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} +Config: ${graphConfig.width}x${graphConfig.height} +Relative: ${x.toFixed(1)}, ${y.toFixed(1)}`; + setDebugInfo(info); + + // Use actual rendered dimensions instead of graphConfig dimensions + // to account for any iOS text scaling + const actualDimensions = { + range: graphConfig.range, + width: elementRect.width, + height: elementRect.height, + }; const graphCoordinates = pixelsToVectors( [[x, y]], - graphConfig, + actualDimensions, ); dispatch(actions.polygon.addPoint(graphCoordinates[0])); }} /> + {/* Debug text for mobile - displays click diagnostic info */} + {debugInfo && ( + + {debugInfo.split("\n").map((line, i) => ( + + {line} + + ))} + + )} {coords.map((point, i) => { const angleId = `${id}-angle-${i}`; let sideIds = ""; From 182f470cd002d7dc991793a2cf66db20eb86ffbd Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Fri, 31 Oct 2025 16:03:27 -0700 Subject: [PATCH 4/6] [mind-the-scale-when-getting-large] Back to SVG point with different dimensions and even more diagnostic info. SIGH. This. Is. So. Slow. --- .../interactive-graphs/graphs/point.tsx | 43 +++++++++++++------ .../interactive-graphs/graphs/polygon.tsx | 43 +++++++++++++------ 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index 7f4f528aa32..ee14a52ecaf 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -136,28 +136,45 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) { return; } - const elementRect = - event.currentTarget.getBoundingClientRect(); + const svg = event.currentTarget.ownerSVGElement; + const ctm = event.currentTarget.getScreenCTM(); + if (!svg || !ctm) { + return; + } - // Calculate click position relative to the rect - const x = event.clientX - elementRect.left; - const y = event.clientY - elementRect.top; + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const svgPoint = pt.matrixTransform(ctm.inverse()); - // Capture diagnostic info for mobile debugging - const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} -Rect: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} -Config: ${graphConfig.width}x${graphConfig.height} -Relative: ${x.toFixed(1)}, ${y.toFixed(1)}`; - setDebugInfo(info); + // Subtract left/top to get position relative to rect + const x = svgPoint.x - left; + const y = svgPoint.y - top; - // Use actual rendered dimensions instead of graphConfig dimensions - // to account for any iOS text scaling + // Use actual rendered dimensions + const elementRect = + event.currentTarget.getBoundingClientRect(); const actualDimensions = { range: graphConfig.range, width: elementRect.width, height: elementRect.height, }; + // Capture diagnostic info for mobile debugging + const graphCoords = pixelsToVectors( + [[x, y]], + actualDimensions, + ); + const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} +SVG: ${svgPoint.x.toFixed(1)}, ${svgPoint.y.toFixed(1)} +Left/Top: ${left.toFixed(1)}, ${top.toFixed(1)} +Relative: ${x.toFixed(1)}, ${y.toFixed(1)} +Expected: ${widthPx}x${heightPx} +Actual: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} +Range: [${graphConfig.range[0][0]},${graphConfig.range[0][1]}],[${graphConfig.range[1][0]},${graphConfig.range[1][1]}] +Result: [${graphCoords[0][0].toFixed(1)}, ${graphCoords[0][1].toFixed(1)}]`; + setDebugInfo(info); + const graphCoordinates = pixelsToVectors( [[x, y]], actualDimensions, diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index 028f45b41ce..4ed416e5a7f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -514,28 +514,45 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { return; } - const elementRect = - event.currentTarget.getBoundingClientRect(); + const svg = event.currentTarget.ownerSVGElement; + const ctm = event.currentTarget.getScreenCTM(); + if (!svg || !ctm) { + return; + } - // Calculate click position relative to the rect - const x = event.clientX - elementRect.left; - const y = event.clientY - elementRect.top; + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const svgPoint = pt.matrixTransform(ctm.inverse()); - // Capture diagnostic info for mobile debugging - const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} -Rect: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} -Config: ${graphConfig.width}x${graphConfig.height} -Relative: ${x.toFixed(1)}, ${y.toFixed(1)}`; - setDebugInfo(info); + // Subtract left/top to get position relative to rect + const x = svgPoint.x - left; + const y = svgPoint.y - top; - // Use actual rendered dimensions instead of graphConfig dimensions - // to account for any iOS text scaling + // Use actual rendered dimensions + const elementRect = + event.currentTarget.getBoundingClientRect(); const actualDimensions = { range: graphConfig.range, width: elementRect.width, height: elementRect.height, }; + // Capture diagnostic info for mobile debugging + const graphCoords = pixelsToVectors( + [[x, y]], + actualDimensions, + ); + const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} +SVG: ${svgPoint.x.toFixed(1)}, ${svgPoint.y.toFixed(1)} +Left/Top: ${left.toFixed(1)}, ${top.toFixed(1)} +Relative: ${x.toFixed(1)}, ${y.toFixed(1)} +Expected: ${widthPx}x${heightPx} +Actual: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} +Range: [${graphConfig.range[0][0]},${graphConfig.range[0][1]}],[${graphConfig.range[1][0]},${graphConfig.range[1][1]}] +Result: [${graphCoords[0][0].toFixed(1)}, ${graphCoords[0][1].toFixed(1)}]`; + setDebugInfo(info); + const graphCoordinates = pixelsToVectors( [[x, y]], actualDimensions, From e54f2776dac0cc3e5dc4940de3667506c2487303 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Mon, 3 Nov 2025 12:07:15 -0800 Subject: [PATCH 5/6] [mind-the-scale-when-getting-large] MORE. --- .../widgets/interactive-graphs/graphs/point.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index ee14a52ecaf..4cdc00ab0ec 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -165,12 +165,26 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) { [[x, y]], actualDimensions, ); + + // Get all possible diagnostic values + const scaleX = Math.sqrt(ctm.a * ctm.a + ctm.b * ctm.b); + const scaleY = Math.sqrt(ctm.c * ctm.c + ctm.d * ctm.d); + const visualViewportScale = + window.visualViewport?.scale ?? 1; + const devicePixelRatio = window.devicePixelRatio ?? 1; + const parentSvg = svg.getBoundingClientRect(); + const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} SVG: ${svgPoint.x.toFixed(1)}, ${svgPoint.y.toFixed(1)} +CTM: a=${ctm.a.toFixed(2)} d=${ctm.d.toFixed(2)} +CTM Scale: ${scaleX.toFixed(3)}, ${scaleY.toFixed(3)} +VVP Scale: ${visualViewportScale.toFixed(3)} +DPR: ${devicePixelRatio.toFixed(3)} Left/Top: ${left.toFixed(1)}, ${top.toFixed(1)} Relative: ${x.toFixed(1)}, ${y.toFixed(1)} Expected: ${widthPx}x${heightPx} Actual: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} +Parent SVG: ${parentSvg.width.toFixed(0)}x${parentSvg.height.toFixed(0)} Range: [${graphConfig.range[0][0]},${graphConfig.range[0][1]}],[${graphConfig.range[1][0]},${graphConfig.range[1][1]}] Result: [${graphCoords[0][0].toFixed(1)}, ${graphCoords[0][1].toFixed(1)}]`; setDebugInfo(info); From 19dba7553d27722eece4b76f9e9dbb41bd98cbbb Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Mon, 3 Nov 2025 12:07:26 -0800 Subject: [PATCH 6/6] [mind-the-scale-when-getting-large] MMOORREE --- .../widgets/interactive-graphs/graphs/polygon.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index 4ed416e5a7f..67e0232c4fd 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -543,12 +543,26 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { [[x, y]], actualDimensions, ); + + // Get all possible diagnostic values + const scaleX = Math.sqrt(ctm.a * ctm.a + ctm.b * ctm.b); + const scaleY = Math.sqrt(ctm.c * ctm.c + ctm.d * ctm.d); + const visualViewportScale = + window.visualViewport?.scale ?? 1; + const devicePixelRatio = window.devicePixelRatio ?? 1; + const parentSvg = svg.getBoundingClientRect(); + const info = `Click: ${event.clientX.toFixed(0)}, ${event.clientY.toFixed(0)} SVG: ${svgPoint.x.toFixed(1)}, ${svgPoint.y.toFixed(1)} +CTM: a=${ctm.a.toFixed(2)} d=${ctm.d.toFixed(2)} +CTM Scale: ${scaleX.toFixed(3)}, ${scaleY.toFixed(3)} +VVP Scale: ${visualViewportScale.toFixed(3)} +DPR: ${devicePixelRatio.toFixed(3)} Left/Top: ${left.toFixed(1)}, ${top.toFixed(1)} Relative: ${x.toFixed(1)}, ${y.toFixed(1)} Expected: ${widthPx}x${heightPx} Actual: ${elementRect.width.toFixed(0)}x${elementRect.height.toFixed(0)} +Parent SVG: ${parentSvg.width.toFixed(0)}x${parentSvg.height.toFixed(0)} Range: [${graphConfig.range[0][0]},${graphConfig.range[0][1]}],[${graphConfig.range[1][0]},${graphConfig.range[1][1]}] Result: [${graphCoords[0][0].toFixed(1)}, ${graphCoords[0][1].toFixed(1)}]`; setDebugInfo(info);