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 ( -
- -
- ); - } else { - return ( -
-
-
- ); + str = formatContent(tooltip, params, intl, tooltipEnableMarkdown); } - } else { + } + + // Must run before any early return below (rules of hooks). + const tooltipRef = useClampTooltipToViewport([str, tooltipEnableMarkdown]); + + if (!str) { return
; } + + if (tooltipEnableMarkdown) { + return ( +
+ +
+ ); + } else { + return ( +
+
+
+ ); + } }; export default Tooltip; diff --git a/packages/dvz-ui/src/embeddable/common/ChartTooltip.jsx b/packages/dvz-ui/src/embeddable/common/ChartTooltip.jsx index 60692d1bc..660f82e15 100644 --- a/packages/dvz-ui/src/embeddable/common/ChartTooltip.jsx +++ b/packages/dvz-ui/src/embeddable/common/ChartTooltip.jsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import template from 'string-template'; +import { useClampTooltipToViewport } from "../chart/Tooltip"; const percentExpresion = /(\+?\%)[\(]([A-z0-9,.,-]+)\)/gi const numericExpresion = /(\+?\#)[\(]([A-z0-9,.,-]+)\)/gi @@ -38,6 +39,7 @@ export const formatContent = (tooltip, variables, intl, tooltipEnableMarkdown) = const ChartTooltip = ({tooltip, d, intl, tooltipEnableMarkdown}) => { const {color, data} = d.datum || d.point || d const current = d.value || (d.datum ? d.datum.value : null) || (d.point ? d.point.data.y : null) + let str = "" if (data) { const vars = data.variables ? (data.variables[d.id] || data.variables) : data @@ -45,17 +47,23 @@ const ChartTooltip = ({tooltip, d, intl, tooltipEnableMarkdown}) => { if (data.measureFieldName) { params.populationValue = data.variables[data.measureFieldName + "Population"] } - const str = formatContent(tooltip, params, intl, tooltipEnableMarkdown) + str = formatContent(tooltip, params, intl, tooltipEnableMarkdown) + } + + // Must run on every render, before any conditional early return. + const tooltipRef = useClampTooltipToViewport([str, tooltipEnableMarkdown]) + + if (data) { if (tooltipEnableMarkdown) { return ( -
+
) } else { return ( -
+
) } diff --git a/packages/dvz-ui/src/embeddable/map/map.jsx b/packages/dvz-ui/src/embeddable/map/map.jsx index 2af8b1801..20393c21e 100644 --- a/packages/dvz-ui/src/embeddable/map/map.jsx +++ b/packages/dvz-ui/src/embeddable/map/map.jsx @@ -262,7 +262,8 @@ class Map extends React.Component { .select("body") .append("div") .style("position", "absolute") - .style("visibility", "hidden"); + .style("visibility", "hidden") + .style("pointer-events", "none"); console.log("Map props", this.metadataTypes); } @@ -1456,11 +1457,27 @@ class Map extends React.Component { } } + positionTooltip(event) { + const offset = 8; + const node = this.tooltip.node(); + const tooltipWidth = node ? node.offsetWidth : 0; + const viewportRight = + window.scrollX + document.documentElement.clientWidth; + + let left = event.pageX; + if (tooltipWidth && left + tooltipWidth > viewportRight) { + left = event.pageX - tooltipWidth; + if (left < window.scrollX) { + left = window.scrollX; + } + } + + this.tooltip.style("top", event.pageY + "px").style("left", left + "px").style("margin-left", offset + "px"); + } + mousemove(event, d) { // The event object contains the mouse coordinates - this.tooltip - .style("top", event.pageY + "px") - .style("left", event.pageX + 5 + "px"); + this.positionTooltip(event); } mouseout(event, d) { @@ -1474,10 +1491,8 @@ class Map extends React.Component { } onClick(event, d) { if (d.properties) { - this.tooltip - .style("visibility", "visible") - .style("top", event.pageY + "px") - .style("left", event.pageX + 5 + "px"); + this.tooltip.style("visibility", "visible"); + this.positionTooltip(event); } event.stopPropagation(); event.preventDefault(); @@ -1485,10 +1500,8 @@ class Map extends React.Component { onPointClick(event, d, i) { this.showTooltip(event, d); - this.tooltip - .style("visibility", "visible") - .style("top", event.pageY + "px") - .style("left", event.pageX + 5 + "px"); + this.tooltip.style("visibility", "visible"); + this.positionTooltip(event); const svg = d3.select(this.getMapId()).select("svg").select("g"); svg diff --git a/packages/dvz-ui/src/scss/common.scss b/packages/dvz-ui/src/scss/common.scss index 1e8795b6b..e678ab8b0 100644 --- a/packages/dvz-ui/src/scss/common.scss +++ b/packages/dvz-ui/src/scss/common.scss @@ -137,6 +137,11 @@ h1.type { border-radius: 5px; background-color: #f7f7f7 !important; min-width: 220px; + max-width: 320px; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + pointer-events: none; .tooltip-content { padding: 5px; @@ -193,6 +198,11 @@ h1.type { border-radius: 5px; background-color: #5c5c5c !important; min-width: 220px; + max-width: 320px; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + pointer-events: none; .tooltip-content { padding: 5px; diff --git a/packages/dvz-ui/src/scss/themes/default/_tabbed.scss b/packages/dvz-ui/src/scss/themes/default/_tabbed.scss index bc4876964..9365f0222 100644 --- a/packages/dvz-ui/src/scss/themes/default/_tabbed.scss +++ b/packages/dvz-ui/src/scss/themes/default/_tabbed.scss @@ -52,7 +52,7 @@ padding: 0; margin: 0; .sixteen.wide.column.content { - overflow: hidden; + overflow: visible; padding: 0; .entry-content { padding: 25px 33px 0 33px; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30da77006..f31122c52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2254,6 +2254,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react-swc@3.9.0': resolution: {integrity: sha512-jYFUSXhwMCYsh/aQTgSGLIN3Foz5wMbH9ahb0Zva//UzwZYbMiZd7oT3AU9jHT9DLswYDswsRwPU9jVF3yA48Q==} @@ -5127,6 +5128,7 @@ packages: tsconfck@3.1.5: resolution: {integrity: sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -5271,6 +5273,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.1.0: