diff --git a/.changeset/open-rocks-move.md b/.changeset/open-rocks-move.md new file mode 100644 index 000000000..2534cb185 --- /dev/null +++ b/.changeset/open-rocks-move.md @@ -0,0 +1,8 @@ +--- +"@devgateway/dvz-ui-react": patch +--- + +Fix chart tooltips extending off-screen near the edges of the viewport, on both desktop and mobile. Tooltips are now clamped to stay within the visible viewport bounds instead of relying solely on the chart library's own (container-relative) positioning. + +- `Tooltip.jsx` (used by Bar, Line, Pie and Bump charts) and `ChartTooltip.jsx` (used by GroupedBars and Sankey) now measure the rendered tooltip and nudge it back on-screen if it overflows the left, right, top or bottom edge of the viewport. +- The manual d3 tooltip in `LineLayer.jsx` (line-over-bar overlay) is clamped the same way instead of always positioning itself using unbounded `pageX`/`pageY` offsets. diff --git a/packages/dvz-ui/src/embeddable/chart/LineLayer.jsx b/packages/dvz-ui/src/embeddable/chart/LineLayer.jsx index e5e393dfc..06063c776 100644 --- a/packages/dvz-ui/src/embeddable/chart/LineLayer.jsx +++ b/packages/dvz-ui/src/embeddable/chart/LineLayer.jsx @@ -125,19 +125,24 @@ const Line = ( intl ) ); - tooltip - .style( - "top", - event.pageY - - tooltip.node().getBoundingClientRect().height + - "px" - ) - .style( - "left", - event.pageX - - tooltip.node().getBoundingClientRect().width + - "px" - ); + const rect = tooltip.node().getBoundingClientRect(); + const margin = 8; + const minLeft = window.scrollX + margin; + const maxLeft = + window.scrollX + window.innerWidth - rect.width - margin; + const minTop = window.scrollY + margin; + const maxTop = + window.scrollY + window.innerHeight - rect.height - margin; + const left = Math.min( + Math.max(event.pageX - rect.width, minLeft), + Math.max(minLeft, maxLeft) + ); + const top = Math.min( + Math.max(event.pageY - rect.height, minTop), + Math.max(minTop, maxTop) + ); + + tooltip.style("top", top + "px").style("left", left + "px") } }} onMouseOut={(event) => tooltip.style("visibility", "hidden")} diff --git a/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx b/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx index c5fff65b0..197bb7ec9 100644 --- a/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx +++ b/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useLayoutEffect, useRef } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; @@ -8,10 +8,112 @@ const percentExpresion = /(\+?\%)[\(]([A-z0-9,.,-]+)\)/gi; const numericExpresion = /(\+?\#)[\(]([A-z0-9,.,-]+)\)/gi; const compactExpresion = /(\+?\#C)[\(]([A-z0-9,.,-]+)\)/gi; +// Minimum gap (px) to keep between the tooltip and the viewport edge. +const VIEWPORT_EDGE_MARGIN = 8; + +// Shift needed to bring [start, end] inside [0, viewportSize]; 0 if it already fits. +const clampShift = (start, end, viewportSize, overshoot = 0) => { + if (start < VIEWPORT_EDGE_MARGIN) { + return VIEWPORT_EDGE_MARGIN - start; + } + if (end > viewportSize - VIEWPORT_EDGE_MARGIN) { + return viewportSize - VIEWPORT_EDGE_MARGIN - end - overshoot; + } + return 0; +}; + +// Nudges an already-positioned tooltip back inside the viewport via a +// corrective transform, without touching the chart library's own positioning +// (chart libraries like nivo can place tooltips past the screen edge). +export const clampTooltipToViewport = (el) => { + if (!el || typeof window === "undefined") { + return; + } + + // Reset so we measure relative to the library's original position. + el.style.transform = ""; + + const rect = el.getBoundingClientRect(); + const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window; + const mobileOvershoot = viewportWidth < 768 ? 12 : 40; // extra room on narrow viewports + + const shiftX = clampShift(rect.left, rect.right, viewportWidth, mobileOvershoot); + const shiftY = clampShift(rect.top, rect.bottom, viewportHeight); + + if (shiftX || shiftY) { + el.style.transform = `translate(${Math.round(shiftX)}px, ${Math.round(shiftY)}px)`; + } +}; + +// Frames with an unchanged correction before we consider the tooltip settled. +const SETTLE_FRAMES = 6; +// Safety cap on a burst's length (~1s at 60fps) in case position never settles. +const MAX_BURST_FRAMES = 60; + +/** + * Keeps a tooltip inside the viewport, re-clamping every frame for a short + * burst whenever it might have moved (mount, resize, scroll) and stopping + * once the correction settles. The burst is needed because animated wrappers + * (e.g. nivo + react-spring) can still be mid-animation when we first measure. + */ +export const useClampTooltipToViewport = (deps = []) => { + const ref = useRef(null); + + useLayoutEffect(() => { + const el = ref.current; + if (!el || typeof window === "undefined" || !window.requestAnimationFrame) { + clampTooltipToViewport(el); + return undefined; + } + + let frameId = null; + + const startBurst = () => { + if (frameId != null) { + window.cancelAnimationFrame(frameId); + } + + let lastTransform = null; + let unchangedFrames = 0; + + const tick = (frame) => { + clampTooltipToViewport(el); + + unchangedFrames = el.style.transform === lastTransform ? unchangedFrames + 1 : 0; + lastTransform = el.style.transform; + + const settled = unchangedFrames >= SETTLE_FRAMES || frame >= MAX_BURST_FRAMES; + frameId = settled ? null : window.requestAnimationFrame(() => tick(frame + 1)); + }; + + tick(0); + }; + + startBurst(); + + const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(startBurst) : null; + resizeObserver?.observe(el); + + // Capture phase catches scroll on any ancestor, not just the window. + window.addEventListener("scroll", startBurst, true); + window.addEventListener("resize", startBurst); + + return () => { + if (frameId != null) { + window.cancelAnimationFrame(frameId); + } + resizeObserver?.disconnect(); + window.removeEventListener("scroll", startBurst, true); + window.removeEventListener("resize", startBurst); + }; + }, deps); + + return ref; +}; + const applyFormat = (expresion, str, style, isPercent, intl, container) => { - // If intl is not available (e.g., during SSR), use a simple fallback formatter + // Fall back to the raw string when intl isn't available (e.g. SSR). if (!intl || !intl.formatNumber) { - // Return string as-is if intl is not available return str; } @@ -39,17 +141,15 @@ export const formatContent = ( intl, tooltipEnableMarkdown ) => { - // Guard against undefined/null values if (!tooltip || !variables) { return ""; } - // if variables have a property called "field" and another property with the value being _${field}, - // add _value to the variables object with the value of the _${field} property + // Map the "_${field}" variable to _value when present. if (variables.field && variables[`_${variables.field}`]) { variables._value = variables[`_${variables.field}`]; } - //if there is a category prop in the variables and field is not defined, set field to category + // Fall back to category as the field name. if (!variables.field && variables.category) { variables.field = variables.category; } @@ -69,65 +169,66 @@ export const formatContent = ( }; const Tooltip = ({ tooltip, d, intl, tooltipEnableMarkdown }) => { - // Guard against undefined/null values during SSR - if (!d || !tooltip) { - return
; - } + let str = ""; - const datum = d.datum || d.point || d; - const { color, data } = datum || {}; - const current = - d.value || - (d.datum ? d.datum.value : null) || - (d.point ? d.point.data.y : null); - - if (data) { - let vars; - if (data.variables) { - vars = typeof data.variables[d.id] === 'object' - ? data.variables[d.id] - : data.variables; - } else { - vars = data; - } + if (d && tooltip) { + const datum = d.datum || d.point || d; + const { data } = datum || {}; - const params = { - field: d.point ? d.point.serieId : (d.id || ''), - ...vars, - value: current, - }; + if (data) { + const current = + d.value || + (d.datum ? d.datum.value : null) || + (d.point ? d.point.data.y : null); - if (data.measureFieldName && data.variables) { - params.populationValue = - data.variables[data.measureFieldName + "Population"]; - } + let vars; + if (data.variables) { + vars = typeof data.variables[d.id] === 'object' + ? data.variables[d.id] + : data.variables; + } else { + vars = data; + } - const str = formatContent(tooltip, params, intl, tooltipEnableMarkdown); + const params = { + field: d.point ? d.point.serieId : (d.id || ''), + ...vars, + value: current, + }; - if (!str) { - return ; - } + if (data.measureFieldName && data.variables) { + params.populationValue = + data.variables[data.measureFieldName + "Population"]; + } - if (tooltipEnableMarkdown) { - return ( -